384 lines
8.7 KiB
TypeScript
384 lines
8.7 KiB
TypeScript
import { FolkShape } from "./folk-shape";
|
|
import { css, html } from "./tags";
|
|
|
|
const styles = css`
|
|
:host {
|
|
background: white;
|
|
border-radius: 8px;
|
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
|
min-width: 300px;
|
|
min-height: 200px;
|
|
}
|
|
|
|
.header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 8px 12px;
|
|
background: #eab308;
|
|
color: white;
|
|
border-radius: 8px 8px 0 0;
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
cursor: move;
|
|
}
|
|
|
|
.header-title {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
}
|
|
|
|
.favicon {
|
|
width: 16px;
|
|
height: 16px;
|
|
border-radius: 2px;
|
|
}
|
|
|
|
.header-actions {
|
|
display: flex;
|
|
gap: 4px;
|
|
}
|
|
|
|
.header-actions button {
|
|
background: transparent;
|
|
border: none;
|
|
color: white;
|
|
cursor: pointer;
|
|
padding: 2px 6px;
|
|
border-radius: 4px;
|
|
font-size: 14px;
|
|
}
|
|
|
|
.header-actions button:hover {
|
|
background: rgba(255, 255, 255, 0.2);
|
|
}
|
|
|
|
.content {
|
|
width: 100%;
|
|
height: calc(100% - 36px);
|
|
position: relative;
|
|
}
|
|
|
|
.url-input-container {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
height: 100%;
|
|
padding: 24px;
|
|
gap: 12px;
|
|
}
|
|
|
|
.url-input {
|
|
width: 100%;
|
|
max-width: 400px;
|
|
padding: 12px 16px;
|
|
border: 2px solid #e2e8f0;
|
|
border-radius: 8px;
|
|
font-size: 14px;
|
|
outline: none;
|
|
}
|
|
|
|
.url-input:focus {
|
|
border-color: #eab308;
|
|
}
|
|
|
|
.url-error {
|
|
color: #ef4444;
|
|
font-size: 12px;
|
|
}
|
|
|
|
.embed-iframe {
|
|
width: 100%;
|
|
height: 100%;
|
|
border: none;
|
|
border-radius: 0 0 8px 8px;
|
|
}
|
|
|
|
.unsupported {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
height: 100%;
|
|
padding: 24px;
|
|
gap: 12px;
|
|
text-align: center;
|
|
color: #64748b;
|
|
}
|
|
|
|
.open-link {
|
|
background: #eab308;
|
|
color: white;
|
|
border: none;
|
|
border-radius: 6px;
|
|
padding: 8px 16px;
|
|
cursor: pointer;
|
|
font-size: 14px;
|
|
}
|
|
|
|
.open-link:hover {
|
|
background: #ca8a04;
|
|
}
|
|
`;
|
|
|
|
// URL transformation patterns
|
|
function transformUrl(url: string): string | null {
|
|
try {
|
|
const parsed = new URL(url);
|
|
const hostname = parsed.hostname.replace("www.", "");
|
|
|
|
// YouTube
|
|
const youtubeMatch = url.match(
|
|
/(?:youtube\.com\/(?:[^\/]+\/.+\/|(?:v|e(?:mbed)?)\/|.*[?&]v=)|youtu\.be\/)([^"&?\/\s]{11})/
|
|
);
|
|
if (youtubeMatch) {
|
|
return `https://www.youtube.com/embed/${youtubeMatch[1]}`;
|
|
}
|
|
|
|
// Twitter/X
|
|
const twitterMatch = url.match(
|
|
/(?:twitter\.com|x\.com)\/([^\/\s?]+)(?:\/(?:status|tweets)\/(\d+)|$)/
|
|
);
|
|
if (twitterMatch) {
|
|
if (twitterMatch[2]) {
|
|
// Tweet embed
|
|
return `https://platform.x.com/embed/Tweet.html?id=${twitterMatch[2]}`;
|
|
}
|
|
// Profile - not embeddable
|
|
return null;
|
|
}
|
|
|
|
// Google Maps
|
|
if (hostname.includes("google") && parsed.pathname.includes("/maps")) {
|
|
// Already an embed URL
|
|
if (parsed.pathname.includes("/embed")) return url;
|
|
// Convert place/directions URLs would need API key
|
|
return url;
|
|
}
|
|
|
|
// Gather.town
|
|
if (hostname === "app.gather.town") {
|
|
return url.replace("app.gather.town", "gather.town/embed");
|
|
}
|
|
|
|
// Medium - not embeddable
|
|
if (hostname.includes("medium.com")) {
|
|
return null;
|
|
}
|
|
|
|
// Pass through other URLs
|
|
return url;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function getFaviconUrl(url: string): string {
|
|
try {
|
|
const hostname = new URL(url).hostname;
|
|
return `https://www.google.com/s2/favicons?domain=${hostname}&sz=32`;
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
function getDisplayTitle(url: string): string {
|
|
try {
|
|
const hostname = new URL(url).hostname.replace("www.", "");
|
|
if (hostname.includes("youtube")) return "YouTube";
|
|
if (hostname.includes("twitter") || hostname.includes("x.com")) return "Twitter/X";
|
|
if (hostname.includes("google") && url.includes("/maps")) return "Google Maps";
|
|
return hostname;
|
|
} catch {
|
|
return "Embed";
|
|
}
|
|
}
|
|
|
|
declare global {
|
|
interface HTMLElementTagNameMap {
|
|
"folk-embed": FolkEmbed;
|
|
}
|
|
}
|
|
|
|
export class FolkEmbed extends FolkShape {
|
|
static override tagName = "folk-embed";
|
|
|
|
static {
|
|
const sheet = new CSSStyleSheet();
|
|
const parentRules = Array.from(FolkShape.styles.cssRules)
|
|
.map((r) => r.cssText)
|
|
.join("\n");
|
|
const childRules = Array.from(styles.cssRules)
|
|
.map((r) => r.cssText)
|
|
.join("\n");
|
|
sheet.replaceSync(`${parentRules}\n${childRules}`);
|
|
this.styles = sheet;
|
|
}
|
|
|
|
#url: string | null = null;
|
|
#error: string | null = null;
|
|
|
|
get url() {
|
|
return this.#url;
|
|
}
|
|
|
|
set url(value: string | null) {
|
|
this.#url = value;
|
|
this.requestUpdate("url");
|
|
this.dispatchEvent(new CustomEvent("url-change", { detail: { url: value } }));
|
|
}
|
|
|
|
override createRenderRoot() {
|
|
const root = super.createRenderRoot();
|
|
|
|
this.#url = this.getAttribute("url") || null;
|
|
|
|
const wrapper = document.createElement("div");
|
|
wrapper.innerHTML = html`
|
|
<div class="header">
|
|
<span class="header-title">
|
|
<span>\u{1F517}</span>
|
|
<span class="title-text">Embed</span>
|
|
</span>
|
|
<div class="header-actions">
|
|
<button class="close-btn" title="Close">\u00D7</button>
|
|
</div>
|
|
</div>
|
|
<div class="content">
|
|
<div class="url-input-container">
|
|
<input
|
|
type="text"
|
|
class="url-input"
|
|
placeholder="Enter URL to embed (YouTube, Twitter, etc.)..."
|
|
/>
|
|
<span class="url-error" style="display: none;"></span>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
const slot = root.querySelector("slot");
|
|
if (slot?.parentElement) {
|
|
const parent = slot.parentElement;
|
|
const existingDiv = parent.querySelector("div");
|
|
if (existingDiv) {
|
|
parent.replaceChild(wrapper, existingDiv);
|
|
}
|
|
}
|
|
|
|
const content = wrapper.querySelector(".content") as HTMLElement;
|
|
const urlInputContainer = wrapper.querySelector(".url-input-container") as HTMLElement;
|
|
const urlInput = wrapper.querySelector(".url-input") as HTMLInputElement;
|
|
const urlError = wrapper.querySelector(".url-error") as HTMLElement;
|
|
const titleText = wrapper.querySelector(".title-text") as HTMLElement;
|
|
const headerTitle = wrapper.querySelector(".header-title") as HTMLElement;
|
|
const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement;
|
|
|
|
// Handle URL input
|
|
const handleUrlSubmit = () => {
|
|
let inputUrl = urlInput.value.trim();
|
|
if (!inputUrl) return;
|
|
|
|
// Auto-complete https://
|
|
if (!inputUrl.startsWith("http://") && !inputUrl.startsWith("https://")) {
|
|
inputUrl = `https://${inputUrl}`;
|
|
}
|
|
|
|
// Validate URL
|
|
const isValid = inputUrl.match(/(^\w+:|^)\/\//);
|
|
if (!isValid) {
|
|
this.#error = "Please enter a valid URL";
|
|
urlError.textContent = this.#error;
|
|
urlError.style.display = "block";
|
|
return;
|
|
}
|
|
|
|
// Transform and set URL
|
|
const embedUrl = transformUrl(inputUrl);
|
|
this.url = inputUrl;
|
|
this.#renderEmbed(content, urlInputContainer, titleText, headerTitle, inputUrl, embedUrl);
|
|
};
|
|
|
|
urlInput.addEventListener("keydown", (e) => {
|
|
if (e.key === "Enter") {
|
|
e.preventDefault();
|
|
handleUrlSubmit();
|
|
}
|
|
});
|
|
|
|
urlInput.addEventListener("blur", () => {
|
|
if (urlInput.value.trim()) {
|
|
handleUrlSubmit();
|
|
}
|
|
});
|
|
|
|
// Close button
|
|
closeBtn.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
this.dispatchEvent(new CustomEvent("close"));
|
|
});
|
|
|
|
// If URL is already set, render embed
|
|
if (this.#url) {
|
|
const embedUrl = transformUrl(this.#url);
|
|
this.#renderEmbed(content, urlInputContainer, titleText, headerTitle, this.#url, embedUrl);
|
|
}
|
|
|
|
return root;
|
|
}
|
|
|
|
#renderEmbed(
|
|
content: HTMLElement,
|
|
urlInputContainer: HTMLElement,
|
|
titleText: HTMLElement,
|
|
headerTitle: HTMLElement,
|
|
originalUrl: string,
|
|
embedUrl: string | null
|
|
) {
|
|
// Update header
|
|
titleText.textContent = getDisplayTitle(originalUrl);
|
|
const favicon = document.createElement("img");
|
|
favicon.className = "favicon";
|
|
favicon.src = getFaviconUrl(originalUrl);
|
|
favicon.onerror = () => (favicon.style.display = "none");
|
|
headerTitle.insertBefore(favicon, titleText);
|
|
|
|
if (!embedUrl) {
|
|
// Unsupported content
|
|
urlInputContainer.innerHTML = `
|
|
<div class="unsupported">
|
|
<p>This content cannot be embedded in an iframe.</p>
|
|
<button class="open-link">Open in new tab \u2192</button>
|
|
</div>
|
|
`;
|
|
const openBtn = urlInputContainer.querySelector(".open-link");
|
|
openBtn?.addEventListener("click", () => {
|
|
window.open(originalUrl, "_blank", "noopener,noreferrer");
|
|
});
|
|
} else {
|
|
// Create iframe
|
|
urlInputContainer.style.display = "none";
|
|
|
|
const iframe = document.createElement("iframe");
|
|
iframe.className = "embed-iframe";
|
|
iframe.src = embedUrl;
|
|
iframe.loading = "lazy";
|
|
iframe.allow =
|
|
"accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture";
|
|
iframe.allowFullscreen = true;
|
|
iframe.referrerPolicy = "no-referrer";
|
|
|
|
content.appendChild(iframe);
|
|
}
|
|
}
|
|
|
|
override toJSON() {
|
|
return {
|
|
...super.toJSON(),
|
|
type: "folk-embed",
|
|
url: this.url,
|
|
};
|
|
}
|
|
}
|