From 763567baea8d6ce900e95718b71e9da5b3212645 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Tue, 3 Mar 2026 16:26:33 -0800 Subject: [PATCH] feat: fix rApp canvas bugs + add widget data card mode Fix zoom over iframes (transparent overlay captures wheel events), eliminate black boxes (flex layout replaces calc height), and enable resize handles (overlay lets pointerdown bubble to shape). Add widget mode that fetches compact data from each module's API with 60s auto-refresh, plus mode toggle button in header. Co-Authored-By: Claude Opus 4.6 --- lib/folk-rapp.ts | 462 +++++++++++++++++++++++++++++++++++++++++--- website/canvas.html | 1 + 2 files changed, 438 insertions(+), 25 deletions(-) diff --git a/lib/folk-rapp.ts b/lib/folk-rapp.ts index 6c3c35d..77fd71a 100644 --- a/lib/folk-rapp.ts +++ b/lib/folk-rapp.ts @@ -42,14 +42,23 @@ const MODULE_META: Record WidgetData }> = { + rinbox: { + path: "/api/mailboxes", + transform: (data) => { + const mailboxes = data?.mailboxes || []; + const totalThreads = mailboxes.reduce((sum: number, m: any) => sum + (m.threadCount || 0), 0); + return { + stat: `${totalThreads} thread${totalThreads !== 1 ? "s" : ""}`, + rows: mailboxes.slice(0, 3).map((m: any) => ({ + label: m.name || m.slug, + value: `${m.threadCount || 0}`, + })), + }; + }, + }, + rphotos: { + path: "/api/assets", + transform: (data) => { + const assets = data?.assets || []; + return { + stat: `${assets.length} photo${assets.length !== 1 ? "s" : ""}`, + thumbs: assets.slice(0, 4).map((a: any) => a.id), + }; + }, + }, + rcal: { + path: "/api/events?upcoming=true&limit=3", + transform: (data) => { + const events = data?.results || []; + return { + stat: `${data?.count ?? events.length} event${(data?.count ?? events.length) !== 1 ? "s" : ""}`, + rows: events.slice(0, 3).map((ev: any) => ({ + label: ev.title || ev.summary || "Untitled", + value: ev.start ? new Date(ev.start).toLocaleDateString(undefined, { month: "short", day: "numeric" }) : "", + })), + }; + }, + }, + rwork: { + path: "/api/spaces", + transform: (data) => { + const spaces = Array.isArray(data) ? data : []; + const totalTasks = spaces.reduce((sum: number, s: any) => sum + (s.taskCount || 0), 0); + return { + stat: `${totalTasks} task${totalTasks !== 1 ? "s" : ""}`, + rows: spaces.slice(0, 3).map((s: any) => ({ + label: s.name || s.slug, + value: `${s.taskCount || 0}`, + })), + }; + }, + }, + rnotes: { + path: "/api/notebooks", + transform: (data) => { + const notebooks = data?.notebooks || []; + return { + stat: `${notebooks.length} notebook${notebooks.length !== 1 ? "s" : ""}`, + rows: notebooks.slice(0, 3).map((n: any) => ({ + label: n.title || n.name || "Untitled", + })), + }; + }, + }, + rfunds: { + path: "/api/flows", + transform: (data) => { + const flows = Array.isArray(data) ? data : []; + const active = flows.filter((f: any) => f.status === "active").length; + return { + stat: `${active} active flow${active !== 1 ? "s" : ""}`, + rows: flows.slice(0, 3).map((f: any) => ({ + label: f.name || f.id, + value: f.status || "", + })), + }; + }, + }, + rschedule: { + path: "/api/jobs", + transform: (data) => { + const jobs = data?.results || []; + return { + stat: `${data?.count ?? jobs.length} job${(data?.count ?? jobs.length) !== 1 ? "s" : ""}`, + rows: jobs.slice(0, 3).map((j: any) => ({ + label: j.name || j.id, + value: j.enabled === false ? "paused" : "active", + })), + }; + }, + }, + rnetwork: { + path: "/api/graph", + transform: (data) => { + const nodes = data?.nodes?.length ?? 0; + const edges = data?.edges?.length ?? 0; + return { + stat: `${nodes} node${nodes !== 1 ? "s" : ""}`, + rows: [ + { label: "Nodes", value: `${nodes}` }, + { label: "Edges", value: `${edges}` }, + ], + }; + }, + }, +}; + +interface WidgetData { + stat: string; + rows?: { label: string; value?: string }[]; + thumbs?: string[]; // rphotos asset IDs for thumbnail grid +} + export class FolkRApp extends FolkShape { static override tagName = "folk-rapp"; @@ -314,10 +543,13 @@ export class FolkRApp extends FolkShape { #moduleId: string = ""; #spaceSlug: string = ""; + #mode: "widget" | "iframe" = "widget"; #iframe: HTMLIFrameElement | null = null; #contentEl: HTMLElement | null = null; #messageHandler: ((e: MessageEvent) => void) | null = null; #statusEl: HTMLElement | null = null; + #refreshTimer: ReturnType | null = null; + #modeToggleBtn: HTMLButtonElement | null = null; get moduleId() { return this.#moduleId; } set moduleId(value: string) { @@ -325,7 +557,7 @@ export class FolkRApp extends FolkShape { this.#moduleId = value; this.requestUpdate("moduleId"); this.dispatchEvent(new CustomEvent("content-change")); - this.#loadModule(); + this.#renderContent(); } get spaceSlug() { return this.#spaceSlug; } @@ -334,7 +566,16 @@ export class FolkRApp extends FolkShape { this.#spaceSlug = value; this.requestUpdate("spaceSlug"); this.dispatchEvent(new CustomEvent("content-change")); - this.#loadModule(); + this.#renderContent(); + } + + get mode() { return this.#mode; } + set mode(value: "widget" | "iframe") { + if (this.#mode === value) return; + this.#mode = value; + this.dispatchEvent(new CustomEvent("content-change")); + this.#updateModeToggle(); + this.#renderContent(); } override createRenderRoot() { @@ -343,6 +584,8 @@ export class FolkRApp extends FolkShape { // Prefer JS-set properties (from newShape props); fall back to HTML attributes if (!this.#moduleId) this.#moduleId = this.getAttribute("module-id") || ""; if (!this.#spaceSlug) this.#spaceSlug = this.getAttribute("space-slug") || ""; + const attrMode = this.getAttribute("mode"); + if (attrMode === "iframe" || attrMode === "widget") this.#mode = attrMode; // Auto-derive spaceSlug from current URL if not explicitly provided (/{space}/canvas → space) if (!this.#spaceSlug) { @@ -357,6 +600,7 @@ export class FolkRApp extends FolkShape { const headerIcon = meta?.icon || "📱"; const wrapper = document.createElement("div"); + wrapper.className = "rapp-wrapper"; wrapper.innerHTML = html`
@@ -367,6 +611,7 @@ export class FolkRApp extends FolkShape {
+ @@ -383,11 +628,18 @@ export class FolkRApp extends FolkShape { this.#contentEl = wrapper.querySelector(".rapp-content") as HTMLElement; this.#statusEl = wrapper.querySelector(".rapp-status") as HTMLElement; + this.#modeToggleBtn = wrapper.querySelector(".mode-toggle-btn") as HTMLButtonElement; 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; + // Mode toggle: widget ↔ iframe + this.#modeToggleBtn.addEventListener("click", (e) => { + e.stopPropagation(); + this.mode = this.#mode === "widget" ? "iframe" : "widget"; + }); + // Module switcher dropdown this.#buildSwitcher(switcherEl); switchBtn.addEventListener("click", (e) => { @@ -419,7 +671,7 @@ export class FolkRApp extends FolkShape { // Load content if (this.#moduleId) { - this.#loadModule(); + this.#renderContent(); } else { this.#showPicker(); } @@ -433,6 +685,10 @@ export class FolkRApp extends FolkShape { window.removeEventListener("message", this.#messageHandler); this.#messageHandler = null; } + if (this.#refreshTimer) { + clearInterval(this.#refreshTimer); + this.#refreshTimer = null; + } } #buildSwitcher(switcherEl: HTMLElement) { @@ -507,10 +763,46 @@ export class FolkRApp extends FolkShape { } } - #loadModule() { + #updateModeToggle() { + if (this.#modeToggleBtn) { + this.#modeToggleBtn.textContent = this.#mode === "widget" ? "□" : "▪"; + this.#modeToggleBtn.title = this.#mode === "widget" ? "Expand to iframe" : "Collapse to widget"; + } + } + + /** Derive the base module URL path, accounting for subdomain routing */ + #getModulePath(): string { + if (!this.#spaceSlug) { + const pathParts = window.location.pathname.split("/").filter(Boolean); + if (pathParts.length >= 1) this.#spaceSlug = pathParts[0]; + } + const space = this.#spaceSlug || "demo"; + const hostname = window.location.hostname; + const onSubdomain = hostname.split(".").length >= 3 && hostname.startsWith(space + "."); + return onSubdomain ? `/${this.#moduleId}` : `/${space}/${this.#moduleId}`; + } + + /** Route to the right render method based on current mode */ + #renderContent() { if (!this.#contentEl || !this.#moduleId) return; + // Clear refresh timer + if (this.#refreshTimer) { + clearInterval(this.#refreshTimer); + this.#refreshTimer = null; + } + // Update header + this.#updateHeader(); + + if (this.#mode === "widget") { + this.#loadWidget(); + } else { + this.#loadIframe(); + } + } + + #updateHeader() { const meta = MODULE_META[this.#moduleId]; const header = this.shadowRoot?.querySelector(".rapp-header") as HTMLElement; if (header && meta) { @@ -522,6 +814,11 @@ export class FolkRApp extends FolkShape { if (name) name.textContent = meta.name; if (icon) icon.textContent = meta.icon; } + } + + /** Iframe mode: load the full rApp in an iframe with overlay for event capture */ + #loadIframe() { + if (!this.#contentEl) return; // Reset connection status if (this.#statusEl) { @@ -529,26 +826,18 @@ export class FolkRApp extends FolkShape { this.#statusEl.title = "Loading..."; } + const meta = MODULE_META[this.#moduleId]; + // Show loading state this.#contentEl.innerHTML = ` +
Loading ${meta?.name || this.#moduleId}...
`; - // Auto-derive space from URL if still missing - if (!this.#spaceSlug) { - const pathParts = window.location.pathname.split("/").filter(Boolean); - if (pathParts.length >= 1) this.#spaceSlug = pathParts[0]; - } - const space = this.#spaceSlug || "demo"; - // On subdomain URLs (jeff.rspace.online), the server prepends /{space}/ automatically, - // so the iframe should load /{moduleId} directly. Using /{space}/{moduleId} would - // double-prefix to /{space}/{space}/{moduleId} → 404. - const hostname = window.location.hostname; - const onSubdomain = hostname.split(".").length >= 3 && hostname.startsWith(space + "."); - const iframeUrl = onSubdomain ? `/${this.#moduleId}` : `/${space}/${this.#moduleId}`; + const iframeUrl = this.#getModulePath(); const iframe = document.createElement("iframe"); iframe.className = "rapp-iframe"; @@ -557,17 +846,15 @@ export class FolkRApp extends FolkShape { 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.querySelector("button")?.addEventListener("click", () => this.#loadIframe()); } }); @@ -583,6 +870,130 @@ export class FolkRApp extends FolkShape { this.#iframe = iframe; } + /** Widget mode: fetch data from module API and render a compact card */ + #loadWidget() { + if (!this.#contentEl) return; + + this.#iframe = null; + + // Reset status — widget mode doesn't use postMessage connection + if (this.#statusEl) { + this.#statusEl.classList.remove("connected"); + this.#statusEl.title = "Widget mode"; + } + + const meta = MODULE_META[this.#moduleId]; + + // Show loading + this.#contentEl.innerHTML = ` +
+
+ Loading ${meta?.name || this.#moduleId}... +
+ `; + + // Fetch and render + this.#fetchAndRenderWidget(); + + // Auto-refresh every 60 seconds + this.#refreshTimer = setInterval(() => this.#fetchAndRenderWidget(), 60_000); + } + + async #fetchAndRenderWidget() { + if (!this.#contentEl || this.#mode !== "widget") return; + + const apiConfig = WIDGET_API[this.#moduleId]; + if (!apiConfig) { + this.#renderWidgetFallback(); + return; + } + + const basePath = this.#getModulePath(); + const url = `${basePath}${apiConfig.path}`; + + try { + const res = await fetch(url); + if (!res.ok) throw new Error(`${res.status}`); + const data = await res.json(); + const widgetData = apiConfig.transform(data); + this.#renderWidgetCard(widgetData); + } catch { + this.#renderWidgetFallback(); + } + } + + #renderWidgetCard(data: WidgetData) { + if (!this.#contentEl) return; + + const meta = MODULE_META[this.#moduleId]; + const basePath = this.#getModulePath(); + + let listHtml = ""; + + if (data.thumbs && data.thumbs.length > 0) { + // Photo grid mode + const thumbItems = data.thumbs + .map((id) => ``) + .join(""); + listHtml = `
${thumbItems}
`; + } else if (data.rows && data.rows.length > 0) { + const rowItems = data.rows + .map((r) => ` +
+ + ${this.#escapeHtml(r.label)} + ${r.value ? `${this.#escapeHtml(r.value)}` : ""} +
+ `) + .join(""); + listHtml = ` +
+
${rowItems}
+ `; + } else { + listHtml = `No data yet`; + } + + this.#contentEl.innerHTML = ` +
+
${this.#escapeHtml(data.stat)}
+ ${listHtml} +
+ `; + + // Click widget → navigate to full module + this.#contentEl.querySelector(".rapp-widget")?.addEventListener("click", () => { + if (this.#moduleId && this.#spaceSlug) { + window.location.href = rspaceNavUrl(this.#spaceSlug, this.#moduleId); + } + }); + } + + /** Fallback widget when no API config exists or fetch fails */ + #renderWidgetFallback() { + if (!this.#contentEl) return; + + const meta = MODULE_META[this.#moduleId]; + this.#contentEl.innerHTML = ` +
+
${meta?.icon || "📱"} ${meta?.name || this.#moduleId}
+ Could not load data +
+ `; + + this.#contentEl.querySelector(".rapp-widget")?.addEventListener("click", () => { + if (this.#moduleId && this.#spaceSlug) { + window.location.href = rspaceNavUrl(this.#spaceSlug, this.#moduleId); + } + }); + } + + #escapeHtml(str: string): string { + const div = document.createElement("div"); + div.textContent = str; + return div.innerHTML; + } + #showPicker() { if (!this.#contentEl) return; @@ -619,6 +1030,7 @@ export class FolkRApp extends FolkShape { type: "folk-rapp", moduleId: this.#moduleId, spaceSlug: this.#spaceSlug, + mode: this.#mode, }; } } diff --git a/website/canvas.html b/website/canvas.html index b0e8c20..646860a 100644 --- a/website/canvas.html +++ b/website/canvas.html @@ -3084,6 +3084,7 @@ shape = document.createElement("folk-rapp"); if (data.moduleId) shape.moduleId = data.moduleId; shape.spaceSlug = data.spaceSlug || communitySlug; + if (data.mode) shape.mode = data.mode; break; case "folk-feed": shape = document.createElement("folk-feed");