rspace-online/lib/folk-rapp.ts

1037 lines
27 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";
import { rspaceNavUrl } from "../shared/url-helpers";
/**
* <folk-rapp> — 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<string, { badge: string; color: string; name: string; icon: string }> = {
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: "🔮" },
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 {
display: flex;
flex-direction: column;
background: #1e293b;
border-radius: 10px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
min-width: 200px;
min-height: 120px;
overflow: hidden;
}
.rapp-wrapper {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
}
.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;
flex-shrink: 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 {
flex: 1;
position: relative;
overflow: hidden;
}
/* Transparent overlay intercepts wheel/pointer events so canvas zoom + selection work */
.rapp-overlay {
position: absolute;
inset: 0;
z-index: 10;
background: transparent;
cursor: move;
}
/* In editing mode, hide overlay so iframe becomes interactive */
:host(:state(editing)) .rapp-overlay {
display: none;
}
.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;
}
/* Widget mode styles */
.rapp-widget {
display: flex;
flex-direction: column;
height: 100%;
padding: 10px 12px;
color: #e2e8f0;
font-size: 13px;
cursor: pointer;
overflow: hidden;
}
.rapp-widget:hover {
background: rgba(255, 255, 255, 0.03);
}
.rapp-widget-stat {
font-size: 20px;
font-weight: 700;
color: #f8fafc;
margin-bottom: 6px;
}
.rapp-widget-divider {
border: none;
border-top: 1px solid rgba(255, 255, 255, 0.08);
margin: 6px 0;
}
.rapp-widget-list {
display: flex;
flex-direction: column;
gap: 3px;
flex: 1;
overflow: hidden;
}
.rapp-widget-row {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: #cbd5e1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.rapp-widget-row .bullet {
color: #64748b;
flex-shrink: 0;
}
.rapp-widget-row .label {
overflow: hidden;
text-overflow: ellipsis;
}
.rapp-widget-row .value {
margin-left: auto;
color: #94a3b8;
flex-shrink: 0;
}
.rapp-widget-empty {
color: #64748b;
font-size: 12px;
font-style: italic;
}
.rapp-widget-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px;
flex: 1;
overflow: hidden;
}
.rapp-widget-thumb {
width: 100%;
aspect-ratio: 1;
object-fit: cover;
border-radius: 4px;
background: #334155;
}
/* Mode toggle button active state */
.rapp-actions button.active {
opacity: 1;
background: rgba(0, 0, 0, 0.15);
}
`;
declare global {
interface HTMLElementTagNameMap {
"folk-rapp": FolkRApp;
}
}
// API endpoint config per module for widget mode
const WIDGET_API: Record<string, { path: string; transform: (data: any) => 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";
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 = "";
#mode: "widget" | "iframe" = "widget";
#iframe: HTMLIFrameElement | null = null;
#contentEl: HTMLElement | null = null;
#messageHandler: ((e: MessageEvent) => void) | null = null;
#statusEl: HTMLElement | null = null;
#refreshTimer: ReturnType<typeof setInterval> | null = null;
#modeToggleBtn: HTMLButtonElement | 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.#renderContent();
}
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.#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() {
const root = super.createRenderRoot();
// 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) {
const pathParts = window.location.pathname.split("/").filter(Boolean);
if (pathParts.length >= 1) this.#spaceSlug = pathParts[0];
}
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.className = "rapp-wrapper";
wrapper.innerHTML = html`
<div class="header rapp-header" data-drag style="--rapp-color: ${headerColor}">
<div class="rapp-header-left">
<span class="rapp-badge">${headerBadge}</span>
<span class="rapp-name">${headerName}</span>
<span class="rapp-icon">${headerIcon}</span>
<span class="rapp-status" title="Not connected"></span>
<div class="rapp-switcher"></div>
</div>
<div class="rapp-actions">
<button class="mode-toggle-btn" title="Toggle widget/iframe mode">${this.#mode === "widget" ? "□" : "▪"}</button>
<button class="switch-btn" title="Switch module">⇄</button>
<button class="open-tab-btn" title="Open in tab">↗</button>
<button class="close-btn" title="Close">×</button>
</div>
</div>
<div class="rapp-content"></div>
`;
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;
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) => {
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.#renderContent();
} else {
this.#showPicker();
}
return root;
}
disconnectedCallback() {
super.disconnectedCallback?.();
if (this.#messageHandler) {
window.removeEventListener("message", this.#messageHandler);
this.#messageHandler = null;
}
if (this.#refreshTimer) {
clearInterval(this.#refreshTimer);
this.#refreshTimer = null;
}
}
#buildSwitcher(switcherEl: HTMLElement) {
const items = Object.entries(MODULE_META)
.map(([id, meta]) => `
<button class="rapp-switcher-item ${id === this.#moduleId ? "active" : ""}" data-module="${id}">
<span class="rapp-switcher-badge" style="background: ${meta.color}">${meta.badge}</span>
<span>${meta.name} ${meta.icon}</span>
</button>
`)
.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
}
}
#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) {
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;
}
}
/** 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) {
this.#statusEl.classList.remove("connected");
this.#statusEl.title = "Loading...";
}
const meta = MODULE_META[this.#moduleId];
// Show loading state
this.#contentEl.innerHTML = `
<div class="rapp-overlay"></div>
<div class="rapp-loading">
<div class="spinner"></div>
<span>Loading ${meta?.name || this.#moduleId}...</span>
</div>
`;
const iframeUrl = this.#getModulePath();
const iframe = document.createElement("iframe");
iframe.className = "rapp-iframe";
iframe.src = iframeUrl;
iframe.loading = "lazy";
iframe.allow = "clipboard-write";
iframe.addEventListener("load", () => {
const loading = this.#contentEl?.querySelector(".rapp-loading");
if (loading) loading.remove();
this.#sendContext();
});
iframe.addEventListener("error", () => {
if (this.#contentEl) {
this.#contentEl.innerHTML = `
<div class="rapp-overlay"></div>
<div class="rapp-error">
<span>Failed to load ${meta?.name || this.#moduleId}</span>
<button class="rapp-picker-item" style="justify-content: center; color: #94a3b8;">
Retry
</button>
</div>
`;
this.#contentEl.querySelector("button")?.addEventListener("click", () => this.#loadIframe());
}
});
this.#contentEl.appendChild(iframe);
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 = `
<div class="rapp-loading">
<div class="spinner"></div>
<span>Loading ${meta?.name || this.#moduleId}...</span>
</div>
`;
// 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) => `<img class="rapp-widget-thumb" src="${basePath}/api/assets/${id}/thumbnail" alt="" loading="lazy" />`)
.join("");
listHtml = `<div class="rapp-widget-grid">${thumbItems}</div>`;
} else if (data.rows && data.rows.length > 0) {
const rowItems = data.rows
.map((r) => `
<div class="rapp-widget-row">
<span class="bullet">•</span>
<span class="label">${this.#escapeHtml(r.label)}</span>
${r.value ? `<span class="value">${this.#escapeHtml(r.value)}</span>` : ""}
</div>
`)
.join("");
listHtml = `
<hr class="rapp-widget-divider" />
<div class="rapp-widget-list">${rowItems}</div>
`;
} else {
listHtml = `<span class="rapp-widget-empty">No data yet</span>`;
}
this.#contentEl.innerHTML = `
<div class="rapp-widget" title="Click to open ${meta?.name || this.#moduleId}">
<div class="rapp-widget-stat">${this.#escapeHtml(data.stat)}</div>
${listHtml}
</div>
`;
// 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 = `
<div class="rapp-widget" title="Click to open ${meta?.name || this.#moduleId}">
<div class="rapp-widget-stat">${meta?.icon || "📱"} ${meta?.name || this.#moduleId}</div>
<span class="rapp-widget-empty">Could not load data</span>
</div>
`;
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;
const items = Object.entries(MODULE_META)
.map(([id, meta]) => `
<button class="rapp-picker-item" data-module="${id}">
<span class="rapp-picker-badge" style="background: ${meta.color}">${meta.badge}</span>
<span>${meta.name}</span>
<span>${meta.icon}</span>
</button>
`)
.join("");
this.#contentEl.innerHTML = `
<div class="rapp-picker">
<span class="rapp-picker-title">Choose an rApp to embed</span>
${items}
</div>
`;
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,
mode: this.#mode,
};
}
}