1037 lines
27 KiB
TypeScript
1037 lines
27 KiB
TypeScript
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: "🎬" },
|
||
rflows: { badge: "rFl", color: "#bef264", name: "rFlows", 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",
|
||
})),
|
||
};
|
||
},
|
||
},
|
||
rflows: {
|
||
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,
|
||
};
|
||
}
|
||
}
|