From 0f7d4eb7bbd4fafffbdae20b8b71db481427b7f4 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Wed, 25 Feb 2026 18:03:29 -0800 Subject: [PATCH] feat: canvas tab bar + rApps toolbar + iframe shell for all modules Add the missing tab bar to the canvas page so users can switch between rApp layers (with full CommunitySync persistence). Add an "rApps" toolbar group that embeds any of the 18 remaining modules as interactive iframes directly on the canvas. Switch all module page routes to renderIframeShell, loading standalone domains inside the unified shell. Co-Authored-By: Claude Opus 4.6 --- modules/books/mod.ts | 36 ++----- modules/cal/mod.ts | 8 +- modules/cart/mod.ts | 8 +- modules/choices/mod.ts | 8 +- modules/data/mod.ts | 8 +- modules/files/mod.ts | 8 +- modules/forum/mod.ts | 8 +- modules/funds/mod.ts | 10 +- modules/inbox/mod.ts | 8 +- modules/maps/mod.ts | 8 +- modules/network/mod.ts | 8 +- modules/notes/mod.ts | 8 +- modules/photos/mod.ts | 8 +- modules/providers/mod.ts | 8 +- modules/pubs/mod.ts | 26 +---- modules/trips/mod.ts | 8 +- modules/tube/mod.ts | 8 +- modules/vote/mod.ts | 8 +- modules/wallet/mod.ts | 8 +- modules/work/mod.ts | 8 +- server/shell.ts | 75 +++++++++++++++ website/canvas.html | 202 +++++++++++++++++++++++++++++++++++++++ 22 files changed, 343 insertions(+), 142 deletions(-) diff --git a/modules/books/mod.ts b/modules/books/mod.ts index da357ca..c331011 100644 --- a/modules/books/mod.ts +++ b/modules/books/mod.ts @@ -10,7 +10,7 @@ import { resolve } from "node:path"; import { mkdir, readFile } from "node:fs/promises"; import { randomUUID } from "node:crypto"; import { sql } from "../../shared/db/pool"; -import { renderShell } from "../../server/shell"; +import { renderShell, renderIframeShell } from "../../server/shell"; import { getModuleInfoList } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module"; import { @@ -201,41 +201,17 @@ routes.get("/api/books/:id/pdf", async (c) => { }); }); -// ── Page: Library (book grid) ── -routes.get("/", async (c) => { +// ── Page: Library ── +routes.get("/", (c) => { const spaceSlug = c.req.param("space") || "personal"; - - // Fetch books for the library page - const rows = await sql.unsafe( - `SELECT id, slug, title, author, description, pdf_size_bytes, page_count, tags, - cover_color, contributor_name, featured, view_count, created_at - FROM rbooks.books WHERE status = 'published' - ORDER BY featured DESC, created_at DESC LIMIT 50` - ); - - const booksJSON = JSON.stringify(rows); - - const html = renderShell({ + return c.html(renderIframeShell({ title: `${spaceSlug} — Library | rSpace`, moduleId: "books", spaceSlug, - body: ` - - `, modules: getModuleInfoList(), theme: "dark", - head: ``, - scripts: ` - - `, - }); - - return c.html(html); + standaloneDomain: "rbooks.online", + })); }); // ── Page: Book reader ── diff --git a/modules/cal/mod.ts b/modules/cal/mod.ts index 358ca28..eeec1d7 100644 --- a/modules/cal/mod.ts +++ b/modules/cal/mod.ts @@ -9,7 +9,7 @@ import { Hono } from "hono"; import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { sql } from "../../shared/db/pool"; -import { renderShell } from "../../server/shell"; +import { renderShell, renderIframeShell } from "../../server/shell"; import { getModuleInfoList } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module"; import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server"; @@ -375,15 +375,13 @@ routes.get("/api/context/:tool", async (c) => { // ── Page route ── routes.get("/", (c) => { const space = c.req.param("space") || "demo"; - return c.html(renderShell({ + return c.html(renderIframeShell({ title: `${space} — Calendar | rSpace`, moduleId: "cal", spaceSlug: space, modules: getModuleInfoList(), theme: "dark", - styles: ``, - body: ``, - scripts: ``, + standaloneDomain: "rcal.online", })); }); diff --git a/modules/cart/mod.ts b/modules/cart/mod.ts index 113925c..3d07441 100644 --- a/modules/cart/mod.ts +++ b/modules/cart/mod.ts @@ -10,7 +10,7 @@ import { Hono } from "hono"; import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { sql } from "../../shared/db/pool"; -import { renderShell } from "../../server/shell"; +import { renderShell, renderIframeShell } from "../../server/shell"; import { getModuleInfoList } from "../../shared/module"; import { depositOrderRevenue } from "./flow"; import type { RSpaceModule } from "../../shared/module"; @@ -441,15 +441,13 @@ routes.post("/api/fulfill/resolve", async (c) => { // ── Page route: shop ── routes.get("/", (c) => { const space = c.req.param("space") || "demo"; - return c.html(renderShell({ + return c.html(renderIframeShell({ title: `Shop | rSpace`, moduleId: "cart", spaceSlug: space, modules: getModuleInfoList(), theme: "dark", - styles: ``, - body: ``, - scripts: ``, + standaloneDomain: "rcart.online", })); }); diff --git a/modules/choices/mod.ts b/modules/choices/mod.ts index 13d7558..ff26119 100644 --- a/modules/choices/mod.ts +++ b/modules/choices/mod.ts @@ -9,7 +9,7 @@ */ import { Hono } from "hono"; -import { renderShell } from "../../server/shell"; +import { renderShell, renderIframeShell } from "../../server/shell"; import type { RSpaceModule } from "../../shared/module"; import { getModuleInfoList } from "../../shared/module"; import { getDocumentData } from "../../server/community-store"; @@ -48,15 +48,13 @@ routes.get("/api/choices", async (c) => { // GET / — choices page routes.get("/", (c) => { const spaceSlug = c.req.param("space") || "demo"; - return c.html(renderShell({ + return c.html(renderIframeShell({ title: `${spaceSlug} — Choices | rSpace`, moduleId: "choices", spaceSlug, modules: getModuleInfoList(), theme: "dark", - styles: ``, - body: ``, - scripts: ``, + standaloneDomain: "rchoices.online", })); }); diff --git a/modules/data/mod.ts b/modules/data/mod.ts index 3c1dc4d..e8c0aec 100644 --- a/modules/data/mod.ts +++ b/modules/data/mod.ts @@ -6,7 +6,7 @@ */ import { Hono } from "hono"; -import { renderShell } from "../../server/shell"; +import { renderShell, renderIframeShell } from "../../server/shell"; import { getModuleInfoList } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module"; @@ -120,15 +120,13 @@ routes.post("/api/collect", async (c) => { // ── Page route ── routes.get("/", (c) => { const space = c.req.param("space") || "demo"; - return c.html(renderShell({ + return c.html(renderIframeShell({ title: `${space} — Data | rSpace`, moduleId: "data", spaceSlug: space, modules: getModuleInfoList(), theme: "dark", - styles: ``, - body: ``, - scripts: ``, + standaloneDomain: "rdata.online", })); }); diff --git a/modules/files/mod.ts b/modules/files/mod.ts index 4965f18..90a279a 100644 --- a/modules/files/mod.ts +++ b/modules/files/mod.ts @@ -9,7 +9,7 @@ import { resolve } from "node:path"; import { mkdir, writeFile, unlink } from "node:fs/promises"; import { createHash, randomBytes } from "node:crypto"; import { sql } from "../../shared/db/pool"; -import { renderShell } from "../../server/shell"; +import { renderShell, renderIframeShell } from "../../server/shell"; import { getModuleInfoList } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module"; import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server"; @@ -366,15 +366,13 @@ routes.delete("/api/cards/:id", async (c) => { // ── Page route ── routes.get("/", (c) => { const spaceSlug = c.req.param("space") || "demo"; - return c.html(renderShell({ + return c.html(renderIframeShell({ title: `${spaceSlug} — Files | rSpace`, moduleId: "files", spaceSlug, modules: getModuleInfoList(), theme: "dark", - styles: ``, - body: ``, - scripts: ``, + standaloneDomain: "rfiles.online", })); }); diff --git a/modules/forum/mod.ts b/modules/forum/mod.ts index c2f6cc0..08c3314 100644 --- a/modules/forum/mod.ts +++ b/modules/forum/mod.ts @@ -7,7 +7,7 @@ import { Hono } from "hono"; import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { sql } from "../../shared/db/pool"; -import { renderShell } from "../../server/shell"; +import { renderShell, renderIframeShell } from "../../server/shell"; import { getModuleInfoList } from "../../shared/module"; import { provisionInstance, destroyInstance } from "./lib/provisioner"; import type { RSpaceModule } from "../../shared/module"; @@ -157,15 +157,13 @@ routes.get("/api/health", (c) => { // ── Page route ── routes.get("/", (c) => { const spaceSlug = c.req.param("space") || "demo"; - return c.html(renderShell({ + return c.html(renderIframeShell({ title: `${spaceSlug} — Forum | rSpace`, moduleId: "forum", spaceSlug, modules: getModuleInfoList(), theme: "dark", - styles: ``, - body: ``, - scripts: ``, + standaloneDomain: "rforum.online", })); }); diff --git a/modules/funds/mod.ts b/modules/funds/mod.ts index 9c11aa5..509dd95 100644 --- a/modules/funds/mod.ts +++ b/modules/funds/mod.ts @@ -8,7 +8,7 @@ import { Hono } from "hono"; import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { sql } from "../../shared/db/pool"; -import { renderShell } from "../../server/shell"; +import { renderShell, renderIframeShell } from "../../server/shell"; import type { RSpaceModule } from "../../shared/module"; import { getModuleInfoList } from "../../shared/module"; import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server"; @@ -193,18 +193,16 @@ const fundsScripts = ` const fundsStyles = ``; -// Landing page — TBFF info + flow list +// Landing page — iframes the standalone rfunds.online app routes.get("/", (c) => { const spaceSlug = c.req.param("space") || "demo"; - return c.html(renderShell({ + return c.html(renderIframeShell({ title: `rFunds — TBFF Flow Funding | rSpace`, moduleId: "funds", spaceSlug, modules: getModuleInfoList(), theme: "dark", - styles: fundsStyles, - body: ``, - scripts: fundsScripts, + standaloneDomain: "rfunds.online", })); }); diff --git a/modules/inbox/mod.ts b/modules/inbox/mod.ts index 15d7c8f..4f97d33 100644 --- a/modules/inbox/mod.ts +++ b/modules/inbox/mod.ts @@ -9,7 +9,7 @@ import { Hono } from "hono"; import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { sql } from "../../shared/db/pool"; -import { renderShell } from "../../server/shell"; +import { renderShell, renderIframeShell } from "../../server/shell"; import { getModuleInfoList } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module"; import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server"; @@ -530,15 +530,13 @@ runSyncLoop(); // ── Page route ── routes.get("/", (c) => { const space = c.req.param("space") || "demo"; - return c.html(renderShell({ + return c.html(renderIframeShell({ title: `${space} — Inbox | rSpace`, moduleId: "inbox", spaceSlug: space, modules: getModuleInfoList(), theme: "dark", - styles: ``, - body: ``, - scripts: ``, + standaloneDomain: "rinbox.online", })); }); diff --git a/modules/maps/mod.ts b/modules/maps/mod.ts index c230664..955c497 100644 --- a/modules/maps/mod.ts +++ b/modules/maps/mod.ts @@ -7,7 +7,7 @@ */ import { Hono } from "hono"; -import { renderShell } from "../../server/shell"; +import { renderShell, renderIframeShell } from "../../server/shell"; import { getModuleInfoList } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module"; @@ -133,15 +133,13 @@ routes.get("/api/c3nav/:event", async (c) => { // ── Page route ── routes.get("/", (c) => { const space = c.req.param("space") || "demo"; - return c.html(renderShell({ + return c.html(renderIframeShell({ title: `${space} — Maps | rSpace`, moduleId: "maps", spaceSlug: space, modules: getModuleInfoList(), theme: "dark", - styles: ``, - body: ``, - scripts: ``, + standaloneDomain: "rmaps.online", })); }); diff --git a/modules/network/mod.ts b/modules/network/mod.ts index 0bd075d..890793c 100644 --- a/modules/network/mod.ts +++ b/modules/network/mod.ts @@ -7,7 +7,7 @@ */ import { Hono } from "hono"; -import { renderShell } from "../../server/shell"; +import { renderShell, renderIframeShell } from "../../server/shell"; import { getModuleInfoList } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module"; @@ -216,15 +216,13 @@ routes.get("/api/workspaces", (c) => { // ── Page route ── routes.get("/", (c) => { const space = c.req.param("space") || "demo"; - return c.html(renderShell({ + return c.html(renderIframeShell({ title: `${space} — Network | rSpace`, moduleId: "network", spaceSlug: space, modules: getModuleInfoList(), theme: "dark", - styles: ``, - body: ``, - scripts: ``, + standaloneDomain: "rnetwork.online", })); }); diff --git a/modules/notes/mod.ts b/modules/notes/mod.ts index dd3860c..06b5e1c 100644 --- a/modules/notes/mod.ts +++ b/modules/notes/mod.ts @@ -9,7 +9,7 @@ import { Hono } from "hono"; import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { sql } from "../../shared/db/pool"; -import { renderShell } from "../../server/shell"; +import { renderShell, renderIframeShell } from "../../server/shell"; import { getModuleInfoList } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module"; import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server"; @@ -361,15 +361,13 @@ routes.delete("/api/notes/:id", async (c) => { // ── Page route ── routes.get("/", (c) => { const space = c.req.param("space") || "demo"; - return c.html(renderShell({ + return c.html(renderIframeShell({ title: `${space} — Notes | rSpace`, moduleId: "notes", spaceSlug: space, modules: getModuleInfoList(), theme: "dark", - styles: ``, - body: ``, - scripts: ``, + standaloneDomain: "rnotes.online", })); }); diff --git a/modules/photos/mod.ts b/modules/photos/mod.ts index 4fd5f3b..5073588 100644 --- a/modules/photos/mod.ts +++ b/modules/photos/mod.ts @@ -7,7 +7,7 @@ */ import { Hono } from "hono"; -import { renderShell } from "../../server/shell"; +import { renderShell, renderIframeShell } from "../../server/shell"; import { getModuleInfoList } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module"; @@ -108,15 +108,13 @@ routes.get("/api/assets/:id/original", async (c) => { // ── Page route ── routes.get("/", (c) => { const spaceSlug = c.req.param("space") || "demo"; - return c.html(renderShell({ + return c.html(renderIframeShell({ title: `${spaceSlug} — Photos | rSpace`, moduleId: "photos", spaceSlug, modules: getModuleInfoList(), theme: "dark", - styles: ``, - body: ``, - scripts: ``, + standaloneDomain: "rphotos.online", })); }); diff --git a/modules/providers/mod.ts b/modules/providers/mod.ts index 79cf1a8..7a87734 100644 --- a/modules/providers/mod.ts +++ b/modules/providers/mod.ts @@ -9,7 +9,7 @@ import { Hono } from "hono"; import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { sql } from "../../shared/db/pool"; -import { renderShell } from "../../server/shell"; +import { renderShell, renderIframeShell } from "../../server/shell"; import { getModuleInfoList } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module"; import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server"; @@ -348,15 +348,13 @@ routes.delete("/api/providers/:id", async (c) => { // ── Page route: browse providers ── routes.get("/", (c) => { const space = c.req.param("space") || "demo"; - return c.html(renderShell({ + return c.html(renderIframeShell({ title: `Providers | rSpace`, moduleId: "providers", spaceSlug: space, modules: getModuleInfoList(), theme: "dark", - styles: ``, - body: ``, - scripts: ``, + standaloneDomain: "providers.mycofi.earth", })); }); diff --git a/modules/pubs/mod.ts b/modules/pubs/mod.ts index 4f7bccb..f9f7593 100644 --- a/modules/pubs/mod.ts +++ b/modules/pubs/mod.ts @@ -13,7 +13,7 @@ import { parseMarkdown } from "./parse-document"; import { compileDocument } from "./typst-compile"; import { getFormat, FORMATS, listFormats } from "./formats"; import type { BookFormat } from "./formats"; -import { renderShell } from "../../server/shell"; +import { renderShell, renderIframeShell } from "../../server/shell"; import { getModuleInfoList } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module"; @@ -319,32 +319,16 @@ routes.get("/api/artifact/:id/pdf", async (c) => { }); // ── Page: Editor ── -routes.get("/", async (c) => { +routes.get("/", (c) => { const spaceSlug = c.req.param("space") || "personal"; - - const formatsJSON = JSON.stringify(listFormats()); - - const html = renderShell({ + return c.html(renderIframeShell({ title: `${spaceSlug} — rPubs Editor | rSpace`, moduleId: "pubs", spaceSlug, - body: ` - - `, modules: getModuleInfoList(), theme: "dark", - head: ``, - scripts: ` - - `, - }); - - return c.html(html); + standaloneDomain: "rpubs.online", + })); }); // ── Module export ── diff --git a/modules/trips/mod.ts b/modules/trips/mod.ts index 7fcee8e..6df7ad8 100644 --- a/modules/trips/mod.ts +++ b/modules/trips/mod.ts @@ -9,7 +9,7 @@ import { Hono } from "hono"; import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { sql } from "../../shared/db/pool"; -import { renderShell } from "../../server/shell"; +import { renderShell, renderIframeShell } from "../../server/shell"; import { getModuleInfoList } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module"; import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server"; @@ -253,15 +253,13 @@ routes.get("/routes", (c) => { // ── Page route ── routes.get("/", (c) => { const space = c.req.param("space") || "demo"; - return c.html(renderShell({ + return c.html(renderIframeShell({ title: `${space} — Trips | rSpace`, moduleId: "trips", spaceSlug: space, modules: getModuleInfoList(), theme: "dark", - styles: ``, - body: ``, - scripts: ``, + standaloneDomain: "rtrips.online", })); }); diff --git a/modules/tube/mod.ts b/modules/tube/mod.ts index ca0de2b..94d8333 100644 --- a/modules/tube/mod.ts +++ b/modules/tube/mod.ts @@ -6,7 +6,7 @@ */ import { Hono } from "hono"; -import { renderShell } from "../../server/shell"; +import { renderShell, renderIframeShell } from "../../server/shell"; import { getModuleInfoList } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module"; import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server"; @@ -191,15 +191,13 @@ routes.get("/api/health", (c) => c.json({ ok: true })); // ── Page route ── routes.get("/", (c) => { const space = c.req.param("space") || "demo"; - return c.html(renderShell({ + return c.html(renderIframeShell({ title: `${space} — Tube | rSpace`, moduleId: "tube", spaceSlug: space, modules: getModuleInfoList(), theme: "dark", - styles: ``, - body: ``, - scripts: ``, + standaloneDomain: "rtube.online", })); }); diff --git a/modules/vote/mod.ts b/modules/vote/mod.ts index bff5ce9..35d705d 100644 --- a/modules/vote/mod.ts +++ b/modules/vote/mod.ts @@ -9,7 +9,7 @@ import { Hono } from "hono"; import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { sql } from "../../shared/db/pool"; -import { renderShell } from "../../server/shell"; +import { renderShell, renderIframeShell } from "../../server/shell"; import { getModuleInfoList } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module"; import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server"; @@ -327,15 +327,13 @@ routes.post("/api/proposals/:id/final-vote", async (c) => { // ── Page route ── routes.get("/", (c) => { const space = c.req.param("space") || "demo"; - return c.html(renderShell({ + return c.html(renderIframeShell({ title: `${space} — Vote | rSpace`, moduleId: "vote", spaceSlug: space, modules: getModuleInfoList(), theme: "dark", - styles: ``, - body: ``, - scripts: ``, + standaloneDomain: "rvote.online", })); }); diff --git a/modules/wallet/mod.ts b/modules/wallet/mod.ts index 3aff1c9..1b9af12 100644 --- a/modules/wallet/mod.ts +++ b/modules/wallet/mod.ts @@ -6,7 +6,7 @@ */ import { Hono } from "hono"; -import { renderShell } from "../../server/shell"; +import { renderShell, renderIframeShell } from "../../server/shell"; import { getModuleInfoList } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module"; @@ -94,15 +94,13 @@ function getSafePrefix(chainId: string): string | null { // ── Page route ── routes.get("/", (c) => { const spaceSlug = c.req.param("space") || "demo"; - return c.html(renderShell({ + return c.html(renderIframeShell({ title: `${spaceSlug} — Wallet | rSpace`, moduleId: "wallet", spaceSlug, modules: getModuleInfoList(), theme: "dark", - styles: ``, - body: ``, - scripts: ``, + standaloneDomain: "rwallet.online", })); }); diff --git a/modules/work/mod.ts b/modules/work/mod.ts index 0c055a3..1cbade7 100644 --- a/modules/work/mod.ts +++ b/modules/work/mod.ts @@ -9,7 +9,7 @@ import { Hono } from "hono"; import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { sql } from "../../shared/db/pool"; -import { renderShell } from "../../server/shell"; +import { renderShell, renderIframeShell } from "../../server/shell"; import { getModuleInfoList } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module"; import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server"; @@ -217,15 +217,13 @@ routes.get("/api/spaces/:slug/activity", async (c) => { // ── Page route ── routes.get("/", (c) => { const space = c.req.param("space") || "demo"; - return c.html(renderShell({ + return c.html(renderIframeShell({ title: `${space} — Work | rSpace`, moduleId: "work", spaceSlug: space, modules: getModuleInfoList(), theme: "dark", - styles: ``, - body: ``, - scripts: ``, + standaloneDomain: "rwork.online", })); }); diff --git a/server/shell.ts b/server/shell.ts index 79be254..9d6e2d9 100644 --- a/server/shell.ts +++ b/server/shell.ts @@ -411,6 +411,81 @@ const WELCOME_CSS = ` } `; +/** + * Shell that embeds a standalone app via iframe. + * + * Wraps the independent app's domain in the shared rSpace header/nav + * so users get the latest code from the standalone repo while preserving + * the unified space/identity experience. + */ +export interface IframeShellOptions extends Omit { + /** The standalone app domain, e.g. "rvote.online" */ + standaloneDomain: string; + /** Extra path to append after the domain root (default: "") */ + path?: string; +} + +export function renderIframeShell(opts: IframeShellOptions): string { + const { standaloneDomain, path = "", ...shellOpts } = opts; + const iframeSrc = `https://${standaloneDomain}${path}`; + + return renderShell({ + ...shellOpts, + body: ``, + styles: ``, + scripts: ``, + }); +} + function escapeHtml(s: string): string { return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); } diff --git a/website/canvas.html b/website/canvas.html index 132118f..a439d52 100644 --- a/website/canvas.html +++ b/website/canvas.html @@ -577,6 +577,9 @@ +
+ +

Loading...

@@ -654,6 +657,30 @@
+
+ +
+ + + + + + + + + + + + + + + + + + +
+
+ @@ -724,6 +751,7 @@ import { RStackAppSwitcher } from "@shared/components/rstack-app-switcher"; import { RStackSpaceSwitcher } from "@shared/components/rstack-space-switcher"; import { RStackTabBar } from "@shared/components/rstack-tab-bar"; + import { rspaceNavUrl } from "@shared/url-helpers"; // Register shell header components RStackIdentity.define(); @@ -736,6 +764,55 @@ document.querySelector("rstack-app-switcher")?.setModules(data.modules || []); }).catch(() => {}); + // ── Tab bar / Layer system initialization ── + const tabBar = document.querySelector("rstack-tab-bar"); + if (tabBar) { + const canvasDefaultLayer = { + id: "layer-canvas", + moduleId: "canvas", + label: "rSpace", + order: 0, + color: "", + visible: true, + createdAt: Date.now(), + }; + + tabBar.setLayers([canvasDefaultLayer]); + tabBar.setAttribute("active", canvasDefaultLayer.id); + + // Tab switching: navigate to the selected module's page + tabBar.addEventListener("layer-switch", (e) => { + const { moduleId } = e.detail; + if (moduleId === "canvas") return; // already on canvas + window.location.href = rspaceNavUrl( + document.querySelector("rstack-space-switcher")?.getAttribute("current") || "demo", + moduleId + ); + }); + + // Adding a new tab: navigate to that module + tabBar.addEventListener("layer-add", (e) => { + const { moduleId } = e.detail; + window.location.href = rspaceNavUrl( + document.querySelector("rstack-space-switcher")?.getAttribute("current") || "demo", + moduleId + ); + }); + + // Closing a tab + tabBar.addEventListener("layer-close", (e) => { + tabBar.removeLayer(e.detail.layerId); + }); + + // View mode toggle + tabBar.addEventListener("view-toggle", (e) => { + document.dispatchEvent(new CustomEvent("layer-view-mode", { detail: { mode: e.detail.mode } })); + }); + + // Expose for CommunitySync wiring + window.__rspaceTabBar = tabBar; + } + // Register service worker for offline support if ("serviceWorker" in navigator && window.location.hostname !== "localhost") { navigator.serviceWorker.register("/sw.js").then((reg) => { @@ -807,6 +884,11 @@ spaceSwitcher.setAttribute("name", communitySlug); } + // Update tab bar with resolved space slug + if (tabBar) { + tabBar.setAttribute("space", communitySlug); + } + const canvas = document.getElementById("canvas"); const canvasContent = document.getElementById("canvas-content"); const status = document.getElementById("status"); @@ -844,6 +926,94 @@ detail: { sync, communitySlug } })); + // Wire tab bar to CommunitySync for layer persistence + if (tabBar && sync) { + const canvasDefaultLayer = { + id: "layer-canvas", + moduleId: "canvas", + label: "rSpace", + order: 0, + color: "", + visible: true, + createdAt: Date.now(), + }; + + // Load persisted layers from Automerge + const layers = sync.getLayers?.() || []; + if (layers.length > 0) { + tabBar.setLayers(layers); + const activeId = sync.doc?.activeLayerId; + if (activeId) tabBar.setAttribute("active", activeId); + if (sync.getFlows) tabBar.setFlows(sync.getFlows()); + } else { + // First visit: persist the canvas layer + sync.addLayer?.(canvasDefaultLayer); + sync.setActiveLayer?.(canvasDefaultLayer.id); + } + + // Persist layer switch + tabBar.addEventListener("layer-switch", (e) => { + sync.setActiveLayer?.(e.detail.layerId); + }); + + // Persist new layer + tabBar.addEventListener("layer-add", (e) => { + const { moduleId } = e.detail; + sync.addLayer?.({ + id: "layer-" + moduleId, + moduleId, + label: moduleId, + order: (sync.getLayers?.() || []).length, + color: "", + visible: true, + createdAt: Date.now(), + }); + }); + + // Persist layer close + tabBar.addEventListener("layer-close", (e) => { + sync.removeLayer?.(e.detail.layerId); + }); + + // Persist layer reorder + tabBar.addEventListener("layer-reorder", (e) => { + const { layerId, newIndex } = e.detail; + sync.updateLayer?.(layerId, { order: newIndex }); + const allLayers = sync.getLayers?.() || []; + allLayers.forEach((l, i) => { + if (l.order !== i) sync.updateLayer?.(l.id, { order: i }); + }); + }); + + // Flow creation from stack view + tabBar.addEventListener("flow-create", (e) => { + sync.addFlow?.(e.detail.flow); + }); + + // Flow removal from stack view + tabBar.addEventListener("flow-remove", (e) => { + sync.removeFlow?.(e.detail.flowId); + }); + + // View mode persistence + tabBar.addEventListener("view-toggle", (e) => { + sync.setLayerViewMode?.(e.detail.mode); + }); + + // Sync remote layer/flow changes back to tab bar + sync.addEventListener("change", () => { + const updatedLayers = sync.getLayers?.() || []; + if (updatedLayers.length > 0) { + tabBar.setLayers(updatedLayers); + if (sync.getFlows) tabBar.setFlows(sync.getFlows()); + const activeId = sync.doc?.activeLayerId; + if (activeId) tabBar.setAttribute("active", activeId); + const viewMode = sync.doc?.layerViewMode; + if (viewMode) tabBar.setAttribute("view-mode", viewMode); + } + }); + } + // Initialize Presence for real-time cursors const peerId = generatePeerId(); const storedUsername = localStorage.getItem("rspace-username") || `User ${peerId.slice(0, 4)}`; @@ -1468,6 +1638,38 @@ }); }); + // rApp embed buttons — embed any module as an interactive iframe on the canvas + const rAppModules = [ + { btnId: "embed-notes", moduleId: "notes", icon: "📝", name: "rNotes" }, + { btnId: "embed-photos", moduleId: "photos", icon: "📸", name: "rPhotos" }, + { btnId: "embed-books", moduleId: "books", icon: "📚", name: "rBooks" }, + { btnId: "embed-pubs", moduleId: "pubs", icon: "📖", name: "rPubs" }, + { btnId: "embed-files", moduleId: "files", icon: "📁", name: "rFiles" }, + { btnId: "embed-work", moduleId: "work", icon: "📋", name: "rWork" }, + { btnId: "embed-forum", moduleId: "forum", icon: "💬", name: "rForum" }, + { btnId: "embed-inbox", moduleId: "inbox", icon: "📧", name: "rInbox" }, + { btnId: "embed-tube", moduleId: "tube", icon: "🎬", name: "rTube" }, + { btnId: "embed-funds", moduleId: "funds", icon: "🌊", name: "rFunds" }, + { btnId: "embed-wallet", moduleId: "wallet", icon: "💰", name: "rWallet" }, + { btnId: "embed-vote", moduleId: "vote", icon: "🗳️", name: "rVote" }, + { btnId: "embed-cart", moduleId: "cart", icon: "🛒", name: "rCart" }, + { btnId: "embed-data", moduleId: "data", icon: "📊", name: "rData" }, + { btnId: "embed-network", moduleId: "network", icon: "🌍", name: "rNetwork" }, + { btnId: "embed-splat", moduleId: "splat", icon: "🔮", name: "rSplat" }, + { btnId: "embed-providers", moduleId: "providers", icon: "🏭", name: "rProviders" }, + { btnId: "embed-swag", moduleId: "swag", icon: "🎨", name: "rSwag" }, + ]; + + for (const app of rAppModules) { + const btn = document.getElementById(app.btnId); + if (btn) { + btn.addEventListener("click", () => { + const moduleUrl = rspaceNavUrl(communitySlug, app.moduleId); + newShape("folk-embed", { url: moduleUrl }); + }); + } + } + // Feed shape — pull live data from another layer/module document.getElementById("new-feed").addEventListener("click", () => { // Prompt for source module (simple for now — will get a proper UI)