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");