rspace-online/lib/folk-embed.ts

382 lines
8.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>🔗</span>
<span class="title-text">Embed</span>
</span>
<div class="header-actions">
<button class="close-btn" title="Close">×</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 →</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,
};
}
}