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`
\u{1F517} Embed
`; // Replace the container div (slot's parent) with our wrapper const slot = root.querySelector("slot"); const containerDiv = slot?.parentElement as HTMLElement; if (containerDiv) { containerDiv.replaceWith(wrapper); } 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 = `

This content cannot be embedded in an iframe.

`; 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, }; } }