import { FolkShape } from "./folk-shape"; import { css, html } from "./tags"; import { rspaceNavUrl } from "../shared/url-helpers"; /** * — Embeds a live rApp module as a shape on the canvas. * * Unlike folk-embed (generic URL iframe), folk-rapp understands the module * system: it stores moduleId + spaceSlug, derives the iframe URL, shows * the module's icon/badge in the header, and can switch modules in-place. * * PostMessage protocol: * Parent → iframe: { source: "rspace-parent", type: "context", shapeId, space, moduleId } * iframe → parent: { source: "rspace-canvas", type: "shape-updated", ... } (from CommunitySync) * iframe → parent: { source: "rspace-rapp", type: "navigate", moduleId } */ // Module metadata for header display (subset of rstack-app-switcher badges) const MODULE_META: Record = { rnotes: { badge: "rN", color: "#fcd34d", name: "rNotes", icon: "📝" }, rphotos: { badge: "rPh", color: "#f9a8d4", name: "rPhotos", icon: "📸" }, rbooks: { badge: "rB", color: "#fda4af", name: "rBooks", icon: "📚" }, rpubs: { badge: "rP", color: "#fda4af", name: "rPubs", icon: "📖" }, rfiles: { badge: "rFi", color: "#67e8f9", name: "rFiles", icon: "📁" }, rwork: { badge: "rWo", color: "#cbd5e1", name: "rWork", icon: "📋" }, rforum: { badge: "rFo", color: "#fcd34d", name: "rForum", icon: "💬" }, rinbox: { badge: "rI", color: "#a5b4fc", name: "rInbox", icon: "📧" }, rtube: { badge: "rTu", color: "#f9a8d4", name: "rTube", icon: "🎬" }, rfunds: { badge: "rF", color: "#bef264", name: "rFunds", icon: "🌊" }, rwallet: { badge: "rW", color: "#fde047", name: "rWallet", icon: "💰" }, rvote: { badge: "rV", color: "#c4b5fd", name: "rVote", icon: "🗳️" }, rcart: { badge: "rCt", color: "#fdba74", name: "rCart", icon: "🛒" }, rdata: { badge: "rD", color: "#d8b4fe", name: "rData", icon: "📊" }, rnetwork: { badge: "rNe", color: "#93c5fd", name: "rNetwork", icon: "🌍" }, rsplat: { badge: "r3", color: "#d8b4fe", name: "rSplat", icon: "🔮" }, rproviders: { badge: "rPr", color: "#fdba74", name: "rProviders", icon: "🏭" }, rswag: { badge: "rSw", color: "#fda4af", name: "rSwag", icon: "🎨" }, rchoices: { badge: "rCo", color: "#f0abfc", name: "rChoices", icon: "🤔" }, rcal: { badge: "rC", color: "#7dd3fc", name: "rCal", icon: "📅" }, rtrips: { badge: "rT", color: "#6ee7b7", name: "rTrips", icon: "✈️" }, rmaps: { badge: "rM", color: "#86efac", name: "rMaps", icon: "🗺️" }, }; const styles = css` :host { background: #1e293b; border-radius: 10px; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25); min-width: 320px; min-height: 240px; overflow: hidden; } .rapp-header { display: flex; align-items: center; justify-content: space-between; padding: 6px 10px; background: var(--rapp-color, #334155); color: #0f172a; font-size: 12px; font-weight: 700; cursor: move; user-select: none; border-radius: 10px 10px 0 0; } .rapp-header-left { display: flex; align-items: center; gap: 7px; position: relative; } .rapp-badge { display: flex; align-items: center; justify-content: center; width: 22px; height: 22px; border-radius: 5px; background: rgba(0, 0, 0, 0.15); font-size: 0.55rem; font-weight: 900; line-height: 1; flex-shrink: 0; } .rapp-name { font-size: 12px; font-weight: 700; } .rapp-icon { font-size: 13px; } .rapp-actions { display: flex; gap: 2px; } .rapp-actions button { background: transparent; border: none; color: #0f172a; cursor: pointer; padding: 2px 5px; border-radius: 4px; font-size: 12px; opacity: 0.7; transition: opacity 0.15s, background 0.15s; } .rapp-actions button:hover { opacity: 1; background: rgba(0, 0, 0, 0.1); } .rapp-content { width: 100%; height: calc(100% - 34px); position: relative; background: #0f172a; } .rapp-iframe { width: 100%; height: 100%; border: none; border-radius: 0 0 10px 10px; } .rapp-loading { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; gap: 12px; color: #64748b; font-size: 13px; } .rapp-loading .spinner { width: 24px; height: 24px; border: 2px solid rgba(100, 116, 139, 0.3); border-top-color: #64748b; border-radius: 50%; animation: spin 0.8s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } } .rapp-error { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; gap: 8px; color: #ef4444; font-size: 13px; padding: 16px; text-align: center; } /* Module picker (shown when no moduleId set) */ .rapp-picker { display: flex; flex-direction: column; gap: 4px; padding: 12px; height: 100%; overflow-y: auto; } .rapp-picker-title { font-size: 12px; font-weight: 600; color: #94a3b8; margin-bottom: 4px; } .rapp-picker-item { display: flex; align-items: center; gap: 8px; padding: 6px 10px; border-radius: 6px; cursor: pointer; transition: background 0.12s; color: #e2e8f0; font-size: 13px; border: none; background: transparent; text-align: left; width: 100%; } .rapp-picker-item:hover { background: rgba(255, 255, 255, 0.08); } .rapp-picker-badge { display: flex; align-items: center; justify-content: center; width: 24px; height: 24px; border-radius: 5px; font-size: 0.5rem; font-weight: 900; color: #0f172a; flex-shrink: 0; } /* Module switcher dropdown */ .rapp-switcher { position: absolute; top: 100%; left: 0; margin-top: 4px; min-width: 180px; max-height: 300px; overflow-y: auto; background: #1e293b; border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 8px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3); padding: 4px; z-index: 100; display: none; } .rapp-switcher.open { display: block; } .rapp-switcher-item { display: flex; align-items: center; gap: 6px; padding: 5px 8px; border-radius: 5px; cursor: pointer; color: #e2e8f0; font-size: 12px; border: none; background: transparent; width: 100%; text-align: left; transition: background 0.12s; } .rapp-switcher-item:hover { background: rgba(255, 255, 255, 0.08); } .rapp-switcher-item.active { background: rgba(6, 182, 212, 0.15); } .rapp-switcher-badge { display: flex; align-items: center; justify-content: center; width: 18px; height: 18px; border-radius: 4px; font-size: 0.45rem; font-weight: 900; color: #0f172a; flex-shrink: 0; } /* Status indicator for postMessage connection */ .rapp-status { width: 6px; height: 6px; border-radius: 50%; background: #475569; flex-shrink: 0; transition: background 0.3s; } .rapp-status.connected { background: #22c55e; } `; declare global { interface HTMLElementTagNameMap { "folk-rapp": FolkRApp; } } export class FolkRApp extends FolkShape { static override tagName = "folk-rapp"; 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; } #moduleId: string = ""; #spaceSlug: string = ""; #iframe: HTMLIFrameElement | null = null; #contentEl: HTMLElement | null = null; #messageHandler: ((e: MessageEvent) => void) | null = null; #statusEl: HTMLElement | null = null; get moduleId() { return this.#moduleId; } set moduleId(value: string) { if (this.#moduleId === value) return; this.#moduleId = value; this.requestUpdate("moduleId"); this.dispatchEvent(new CustomEvent("content-change")); this.#loadModule(); } get spaceSlug() { return this.#spaceSlug; } set spaceSlug(value: string) { if (this.#spaceSlug === value) return; this.#spaceSlug = value; this.requestUpdate("spaceSlug"); this.dispatchEvent(new CustomEvent("content-change")); this.#loadModule(); } override createRenderRoot() { const root = super.createRenderRoot(); this.#moduleId = this.getAttribute("module-id") || ""; this.#spaceSlug = this.getAttribute("space-slug") || ""; const meta = MODULE_META[this.#moduleId]; const headerColor = meta?.color || "#475569"; const headerName = meta?.name || this.#moduleId || "rApp"; const headerBadge = meta?.badge || "r?"; const headerIcon = meta?.icon || "📱"; const wrapper = document.createElement("div"); wrapper.innerHTML = html`
${headerBadge} ${headerName} ${headerIcon}
`; const slot = root.querySelector("slot"); const containerDiv = slot?.parentElement as HTMLElement; if (containerDiv) { containerDiv.replaceWith(wrapper); } this.#contentEl = wrapper.querySelector(".rapp-content") as HTMLElement; this.#statusEl = wrapper.querySelector(".rapp-status") as HTMLElement; const switchBtn = wrapper.querySelector(".switch-btn") as HTMLButtonElement; const openTabBtn = wrapper.querySelector(".open-tab-btn") as HTMLButtonElement; const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement; const switcherEl = wrapper.querySelector(".rapp-switcher") as HTMLElement; // Module switcher dropdown this.#buildSwitcher(switcherEl); switchBtn.addEventListener("click", (e) => { e.stopPropagation(); switcherEl.classList.toggle("open"); }); // Close switcher when clicking elsewhere const closeSwitcher = () => switcherEl.classList.remove("open"); root.addEventListener("click", closeSwitcher); // Open in tab — navigate to the module's page via tab bar openTabBtn.addEventListener("click", (e) => { e.stopPropagation(); if (this.#moduleId && this.#spaceSlug) { window.location.href = rspaceNavUrl(this.#spaceSlug, this.#moduleId); } }); // Close button closeBtn.addEventListener("click", (e) => { e.stopPropagation(); this.dispatchEvent(new CustomEvent("close")); }); // Set up postMessage listener this.#messageHandler = (e: MessageEvent) => this.#handleMessage(e); window.addEventListener("message", this.#messageHandler); // Load content if (this.#moduleId) { this.#loadModule(); } else { this.#showPicker(); } return root; } disconnectedCallback() { super.disconnectedCallback?.(); if (this.#messageHandler) { window.removeEventListener("message", this.#messageHandler); this.#messageHandler = null; } } #buildSwitcher(switcherEl: HTMLElement) { const items = Object.entries(MODULE_META) .map(([id, meta]) => ` `) .join(""); switcherEl.innerHTML = items; switcherEl.querySelectorAll(".rapp-switcher-item").forEach((btn) => { btn.addEventListener("click", (e) => { e.stopPropagation(); const modId = (btn as HTMLElement).dataset.module; if (modId && modId !== this.#moduleId) { this.moduleId = modId; this.#buildSwitcher(switcherEl); } switcherEl.classList.remove("open"); }); }); } /** Handle postMessage from embedded iframe */ #handleMessage(e: MessageEvent) { if (!this.#iframe) return; // Only accept messages from our iframe if (e.source !== this.#iframe.contentWindow) return; const msg = e.data; if (!msg || typeof msg !== "object") return; // CommunitySync shape updates from the embedded module if (msg.source === "rspace-canvas" && msg.type === "shape-updated") { this.dispatchEvent(new CustomEvent("rapp-data", { detail: { moduleId: this.#moduleId, shapeId: msg.shapeId, data: msg.data }, bubbles: true, })); // Mark as connected if (this.#statusEl) { this.#statusEl.classList.add("connected"); this.#statusEl.title = "Connected — receiving data"; } } // Navigation request from embedded module if (msg.source === "rspace-rapp" && msg.type === "navigate" && msg.moduleId) { this.moduleId = msg.moduleId; } } /** Send context to the iframe after it loads */ #sendContext() { if (!this.#iframe?.contentWindow) return; try { this.#iframe.contentWindow.postMessage({ source: "rspace-parent", type: "context", shapeId: this.id, space: this.#spaceSlug, moduleId: this.#moduleId, embedded: true, }, "*"); } catch { // cross-origin or iframe not ready } } #loadModule() { if (!this.#contentEl || !this.#moduleId) return; // Update header const meta = MODULE_META[this.#moduleId]; const header = this.shadowRoot?.querySelector(".rapp-header") as HTMLElement; if (header && meta) { header.style.setProperty("--rapp-color", meta.color); const badge = header.querySelector(".rapp-badge"); const name = header.querySelector(".rapp-name"); const icon = header.querySelector(".rapp-icon"); if (badge) badge.textContent = meta.badge; if (name) name.textContent = meta.name; if (icon) icon.textContent = meta.icon; } // Reset connection status if (this.#statusEl) { this.#statusEl.classList.remove("connected"); this.#statusEl.title = "Loading..."; } // Show loading state this.#contentEl.innerHTML = `
Loading ${meta?.name || this.#moduleId}...
`; // Create iframe const space = this.#spaceSlug || "demo"; const iframeUrl = `/${space}/${this.#moduleId}`; const iframe = document.createElement("iframe"); iframe.className = "rapp-iframe"; iframe.src = iframeUrl; iframe.loading = "lazy"; iframe.allow = "clipboard-write"; iframe.addEventListener("load", () => { // Remove loading indicator const loading = this.#contentEl?.querySelector(".rapp-loading"); if (loading) loading.remove(); // Send context to the newly loaded iframe this.#sendContext(); }); iframe.addEventListener("error", () => { if (this.#contentEl) { this.#contentEl.innerHTML = `
Failed to load ${meta?.name || this.#moduleId}
`; this.#contentEl.querySelector("button")?.addEventListener("click", () => this.#loadModule()); } }); this.#contentEl.appendChild(iframe); this.#iframe = iframe; } #showPicker() { if (!this.#contentEl) return; const items = Object.entries(MODULE_META) .map(([id, meta]) => ` `) .join(""); this.#contentEl.innerHTML = `
Choose an rApp to embed ${items}
`; this.#contentEl.querySelectorAll(".rapp-picker-item").forEach((btn) => { btn.addEventListener("click", () => { const modId = (btn as HTMLElement).dataset.module; if (modId) { this.moduleId = modId; } }); }); } override toJSON() { return { ...super.toJSON(), type: "folk-rapp", moduleId: this.#moduleId, spaceSlug: this.#spaceSlug, }; } }