rspace-online/lib/folk-embed.ts

382 lines
8.6 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>
`;
// 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 = `
<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,
};
}
}