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 <noreply@anthropic.com>
This commit is contained in:
parent
b6eeb41daf
commit
763567baea
462
lib/folk-rapp.ts
462
lib/folk-rapp.ts
|
|
@ -42,14 +42,23 @@ const MODULE_META: Record<string, { badge: string; color: string; name: string;
|
|||
|
||||
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: 320px;
|
||||
min-height: 240px;
|
||||
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;
|
||||
|
|
@ -62,6 +71,7 @@ const styles = css`
|
|||
cursor: move;
|
||||
user-select: none;
|
||||
border-radius: 10px 10px 0 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.rapp-header-left {
|
||||
|
|
@ -117,10 +127,23 @@ const styles = css`
|
|||
}
|
||||
|
||||
.rapp-content {
|
||||
width: 100%;
|
||||
height: calc(100% - 34px);
|
||||
flex: 1;
|
||||
position: relative;
|
||||
background: #0f172a;
|
||||
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 {
|
||||
|
|
@ -289,6 +312,98 @@ const styles = css`
|
|||
.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 {
|
||||
|
|
@ -297,6 +412,120 @@ declare global {
|
|||
}
|
||||
}
|
||||
|
||||
// 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";
|
||||
|
||||
|
|
@ -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<typeof setInterval> | 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`
|
||||
<div class="header rapp-header" data-drag style="--rapp-color: ${headerColor}">
|
||||
<div class="rapp-header-left">
|
||||
|
|
@ -367,6 +611,7 @@ export class FolkRApp extends FolkShape {
|
|||
<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>
|
||||
|
|
@ -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 = `
|
||||
<div class="rapp-overlay"></div>
|
||||
<div class="rapp-loading">
|
||||
<div class="spinner"></div>
|
||||
<span>Loading ${meta?.name || this.#moduleId}...</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 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 = `
|
||||
<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;">
|
||||
|
|
@ -575,7 +862,7 @@ export class FolkRApp extends FolkShape {
|
|||
</button>
|
||||
</div>
|
||||
`;
|
||||
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 = `
|
||||
<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;
|
||||
|
||||
|
|
@ -619,6 +1030,7 @@ export class FolkRApp extends FolkShape {
|
|||
type: "folk-rapp",
|
||||
moduleId: this.#moduleId,
|
||||
spaceSlug: this.#spaceSlug,
|
||||
mode: this.#mode,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Reference in New Issue