diff --git a/lib/folk-rapp.ts b/lib/folk-rapp.ts index e3b1aff..dbacf11 100644 --- a/lib/folk-rapp.ts +++ b/lib/folk-rapp.ts @@ -8,6 +8,11 @@ import { rspaceNavUrl } from "../shared/url-helpers"; * 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) @@ -64,6 +69,7 @@ const styles = css` display: flex; align-items: center; gap: 7px; + position: relative; } .rapp-badge { @@ -211,6 +217,79 @@ const styles = css` 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 { @@ -238,9 +317,12 @@ export class FolkRApp extends FolkShape { #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")); @@ -249,6 +331,7 @@ export class FolkRApp extends FolkShape { 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")); @@ -274,10 +357,13 @@ export class FolkRApp extends FolkShape { ${headerBadge} ${headerName} ${headerIcon} + +
+ - +
@@ -290,14 +376,28 @@ export class FolkRApp extends FolkShape { } 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; - // Open in tab navigates to the module's page + // 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.open(rspaceNavUrl(this.#spaceSlug, this.#moduleId), "_blank"); + window.location.href = rspaceNavUrl(this.#spaceSlug, this.#moduleId); } }); @@ -307,6 +407,10 @@ export class FolkRApp extends FolkShape { 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(); @@ -317,6 +421,86 @@ export class FolkRApp extends FolkShape { 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; @@ -333,6 +517,12 @@ export class FolkRApp extends FolkShape { 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 = `
@@ -355,6 +545,9 @@ export class FolkRApp extends FolkShape { // 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", () => {