import { FolkShape } from "./folk-shape"; import { css, html } from "./tags"; import { rspaceNavUrl } from "../shared/url-helpers"; /** * — 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 = { 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 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 | 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`
${headerBadge} ${headerName} ${headerIcon}
`; 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]) => ` `) .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 = `
Loading ${meta?.name || this.#moduleId}...
`; 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 = `
Failed to load ${meta?.name || this.#moduleId}
`; 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 = `
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; const items = Object.entries(MODULE_META) .map(([id, meta]) => ` `) .join(""); this.#contentEl.innerHTML = `
Choose an rApp to embed ${items}
`; 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, }; } }