Merge branch 'dev'

This commit is contained in:
Jeff Emmett 2026-03-03 16:28:13 -08:00
commit 4f49af905a
2 changed files with 438 additions and 25 deletions

View File

@ -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,
};
}
}

View File

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