From 20c26cd3d7aa40db1ee3e36c687fadb6cbde0882 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Mon, 9 Mar 2026 06:33:13 +0000 Subject: [PATCH] feat: scope system, cross-space navigation, and spaces-as-layers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 — Fix scope system: new scope-resolver.ts resolves global vs space-scoped docId prefixes. Server middleware sets effectiveSpace on Hono context. All 18 modules updated to use dataSpace for Automerge doc access while keeping space for display. Client runtime gets setModuleScopes() and resolveDocSpace() for local-first scope resolution. Phase 2 — Seamless cross-space navigation: TabCache now tracks panes per space:module key. OfflineRuntime maintains lazy WebSocket connections per space. Space-switcher dispatches space-switch event handled client-side with history.pushState instead of full reload. Phase 3 — Spaces as layers: Layer type extended with spaceSlug and spaceRole. Tab bar gains "Add Space Layer" picker. Canvas renders cross-space shapes with visual indicators. Space layers persisted as SpaceRefs via nesting API. Runtime provides getAllActiveSpaces() and subscribeModuleAcrossSpaces() for module-level data aggregation. Co-Authored-By: Claude Opus 4.6 --- lib/layer-types.ts | 4 + modules/rbooks/mod.ts | 24 +- modules/rcal/mod.ts | 45 ++-- modules/rdata/mod.ts | 1 + modules/rdesign/mod.ts | 1 + modules/rdocs/mod.ts | 1 + modules/rmaps/mod.ts | 2 + modules/rnetwork/mod.ts | 32 ++- modules/rnotes/mod.ts | 98 +++++--- modules/rphotos/mod.ts | 2 + modules/rpubs/mod.ts | 1 + modules/rschedule/mod.ts | 72 +++--- modules/rsocials/mod.ts | 42 ++-- modules/rsplat/mod.ts | 34 +-- modules/rswag/mod.ts | 1 + modules/rtrips/mod.ts | 45 ++-- modules/rtube/mod.ts | 1 + modules/rwallet/mod.ts | 1 + modules/rwork/mod.ts | 1 + server/index.ts | 8 +- server/shell.ts | 109 ++++++++- shared/components/rstack-app-switcher.ts | 7 +- shared/components/rstack-space-switcher.ts | 45 ++++ shared/components/rstack-tab-bar.ts | 260 ++++++++++++++++++++- shared/local-first/runtime.ts | 168 ++++++++++++- shared/scope-resolver.ts | 28 +++ shared/tab-cache.ts | 142 ++++++++--- website/canvas.html | 62 +++++ website/shell.ts | 15 ++ 29 files changed, 1066 insertions(+), 186 deletions(-) create mode 100644 shared/scope-resolver.ts diff --git a/lib/layer-types.ts b/lib/layer-types.ts index be6a203..6228d1c 100644 --- a/lib/layer-types.ts +++ b/lib/layer-types.ts @@ -79,6 +79,10 @@ export interface Layer { visible: boolean; /** Created timestamp */ createdAt: number; + /** If set, this layer shows data from another space (cross-space layer). */ + spaceSlug?: string; + /** Resolved permission level in the source space (for cross-space layers). */ + spaceRole?: 'viewer' | 'member' | 'moderator' | 'admin'; } // ── Inter-layer flow ── diff --git a/modules/rbooks/mod.ts b/modules/rbooks/mod.ts index 0477800..aa4d348 100644 --- a/modules/rbooks/mod.ts +++ b/modules/rbooks/mod.ts @@ -104,12 +104,13 @@ const routes = new Hono(); // ── API: List books ── routes.get("/api/books", async (c) => { const space = c.req.param("space") || "global"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; const search = c.req.query("search")?.toLowerCase(); const tag = c.req.query("tag"); const limit = Math.min(parseInt(c.req.query("limit") || "50"), 100); const offset = parseInt(c.req.query("offset") || "0"); - const doc = ensureDoc(space); + const doc = ensureDoc(dataSpace); let books = Object.values(doc.items).filter((b) => b.status === "published"); if (search) { @@ -156,6 +157,7 @@ routes.get("/api/books", async (c) => { // ── API: Upload book ── routes.post("/api/books", async (c) => { const space = c.req.param("space") || "global"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; const token = extractToken(c.req.raw.headers); if (!token) return c.json({ error: "Authentication required" }, 401); @@ -187,7 +189,7 @@ routes.post("/api/books", async (c) => { let slug = slugify(title); // Check slug collision - const doc = ensureDoc(space); + const doc = ensureDoc(dataSpace); const slugExists = Object.values(doc.items).some((b) => b.slug === slug); if (slugExists) { slug = `${slug}-${shortId}`; @@ -203,7 +205,7 @@ routes.post("/api/books", async (c) => { const now = Date.now(); // Insert into Automerge doc - const docId = booksCatalogDocId(space); + const docId = booksCatalogDocId(dataSpace); _syncServer!.changeDoc(docId, `add book: ${slug}`, (d) => { d.items[id] = { id, @@ -242,9 +244,10 @@ routes.post("/api/books", async (c) => { // ── API: Get book details ── routes.get("/api/books/:id", async (c) => { const space = c.req.param("space") || "global"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; const id = c.req.param("id"); - const doc = ensureDoc(space); + const doc = ensureDoc(dataSpace); const book = findBook(doc, id); if (!book || book.status !== "published") { @@ -252,7 +255,7 @@ routes.get("/api/books/:id", async (c) => { } // Increment view count - const docId = booksCatalogDocId(space); + const docId = booksCatalogDocId(dataSpace); _syncServer!.changeDoc(docId, `view: ${book.slug}`, (d) => { if (d.items[book.id]) { d.items[book.id].viewCount += 1; @@ -266,9 +269,10 @@ routes.get("/api/books/:id", async (c) => { // ── API: Serve PDF ── routes.get("/api/books/:id/pdf", async (c) => { const space = c.req.param("space") || "global"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; const id = c.req.param("id"); - const doc = ensureDoc(space); + const doc = ensureDoc(dataSpace); const book = findBook(doc, id); if (!book || book.status !== "published") { @@ -283,7 +287,7 @@ routes.get("/api/books/:id/pdf", async (c) => { } // Increment download count - const docId = booksCatalogDocId(space); + const docId = booksCatalogDocId(dataSpace); _syncServer!.changeDoc(docId, `download: ${book.slug}`, (d) => { if (d.items[book.id]) { d.items[book.id].downloadCount += 1; @@ -303,6 +307,7 @@ routes.get("/api/books/:id/pdf", async (c) => { // ── Page: Library ── routes.get("/", (c) => { const spaceSlug = c.req.param("space") || "personal"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || spaceSlug; return c.html(renderShell({ title: `${spaceSlug} — Library | rSpace`, moduleId: "rbooks", @@ -318,9 +323,10 @@ routes.get("/", (c) => { // ── Page: Book reader ── routes.get("/read/:id", async (c) => { const spaceSlug = c.req.param("space") || "personal"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || spaceSlug; const id = c.req.param("id"); - const doc = ensureDoc(spaceSlug); + const doc = ensureDoc(dataSpace); const book = findBook(doc, id); if (!book || book.status !== "published") { @@ -335,7 +341,7 @@ routes.get("/read/:id", async (c) => { } // Increment view count - const docId = booksCatalogDocId(spaceSlug); + const docId = booksCatalogDocId(dataSpace); _syncServer!.changeDoc(docId, `view: ${book.slug}`, (d) => { if (d.items[book.id]) { d.items[book.id].viewCount += 1; diff --git a/modules/rcal/mod.ts b/modules/rcal/mod.ts index 803ac43..9adeaf5 100644 --- a/modules/rcal/mod.ts +++ b/modules/rcal/mod.ts @@ -259,9 +259,10 @@ function seedDemoIfEmpty(space: string) { // GET /api/events — query events with filters routes.get("/api/events", async (c) => { const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; const { start, end, source, search, rTool, rEntityId, upcoming } = c.req.query(); - const doc = ensureDoc(space); + const doc = ensureDoc(dataSpace); let events = Object.values(doc.events); // Apply filters @@ -311,14 +312,15 @@ routes.post("/api/events", async (c) => { try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; const body = await c.req.json(); const { title, description, start_time, end_time, all_day, timezone, source_id, location_id, location_name, is_virtual, virtual_url, virtual_platform, r_tool_source, r_tool_entity_id, is_scheduled_item, provenance, item_preview } = body; if (!title?.trim() || !start_time) return c.json({ error: "Title and start_time required" }, 400); - const docId = calendarDocId(space); - ensureDoc(space); + const docId = calendarDocId(dataSpace); + ensureDoc(dataSpace); const eventId = crypto.randomUUID(); const now = Date.now(); @@ -394,9 +396,10 @@ routes.post("/api/events", async (c) => { // GET /api/events/scheduled — query only scheduled knowledge items routes.get("/api/events/scheduled", async (c) => { const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; const { date, upcoming, pending_only } = c.req.query(); - const doc = ensureDoc(space); + const doc = ensureDoc(dataSpace); let events = Object.values(doc.events).filter((e) => { const meta = e.metadata as ScheduledItemMetadata | null; return meta?.isScheduledItem === true; @@ -429,8 +432,9 @@ routes.get("/api/events/scheduled", async (c) => { // GET /api/events/:id routes.get("/api/events/:id", async (c) => { const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; const id = c.req.param("id"); - const doc = ensureDoc(space); + const doc = ensureDoc(dataSpace); const ev = doc.events[id]; if (!ev) return c.json({ error: "Event not found" }, 404); @@ -445,11 +449,12 @@ routes.patch("/api/events/:id", async (c) => { try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; const id = c.req.param("id"); const body = await c.req.json(); - const docId = calendarDocId(space); - const doc = ensureDoc(space); + const docId = calendarDocId(dataSpace); + const doc = ensureDoc(dataSpace); if (!doc.events[id]) return c.json({ error: "Not found" }, 404); // Map of allowed body keys to CalendarEvent fields @@ -497,10 +502,11 @@ routes.patch("/api/events/:id", async (c) => { // DELETE /api/events/:id routes.delete("/api/events/:id", async (c) => { const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; const id = c.req.param("id"); - const docId = calendarDocId(space); - const doc = ensureDoc(space); + const docId = calendarDocId(dataSpace); + const doc = ensureDoc(dataSpace); if (!doc.events[id]) return c.json({ error: "Not found" }, 404); _syncServer!.changeDoc(docId, `delete event ${id}`, (d) => { @@ -513,8 +519,9 @@ routes.delete("/api/events/:id", async (c) => { routes.get("/api/sources", async (c) => { const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; const { is_active, is_visible, source_type } = c.req.query(); - const doc = ensureDoc(space); + const doc = ensureDoc(dataSpace); let sources = Object.values(doc.sources); @@ -541,9 +548,10 @@ routes.post("/api/sources", async (c) => { try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; const body = await c.req.json(); - const docId = calendarDocId(space); - ensureDoc(space); + const docId = calendarDocId(dataSpace); + ensureDoc(dataSpace); const sourceId = crypto.randomUUID(); const now = Date.now(); @@ -601,8 +609,9 @@ function deriveLocations(doc: CalendarDoc): DerivedLocation[] { routes.get("/api/locations", async (c) => { const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; const { granularity, parent, search, root } = c.req.query(); - const doc = ensureDoc(space); + const doc = ensureDoc(dataSpace); let locations = deriveLocations(doc); @@ -627,7 +636,8 @@ routes.get("/api/locations", async (c) => { routes.get("/api/locations/tree", async (c) => { const space = c.req.param("space") || "demo"; - const doc = ensureDoc(space); + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; + const doc = ensureDoc(dataSpace); // Flat list with depth=0 since hierarchical parent_id data is not stored in Automerge const locations = deriveLocations(doc).map((l) => ({ ...l, depth: 0 })); @@ -678,7 +688,8 @@ routes.get("/api/lunar", async (c) => { routes.get("/api/stats", async (c) => { const space = c.req.param("space") || "demo"; - const doc = ensureDoc(space); + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; + const doc = ensureDoc(dataSpace); const events = Object.values(doc.events).length; const sources = Object.values(doc.sources).filter((s) => s.isActive).length; @@ -691,11 +702,12 @@ routes.get("/api/stats", async (c) => { routes.get("/api/context/:tool", async (c) => { const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; const tool = c.req.param("tool"); const entityId = c.req.query("entityId"); if (!entityId) return c.json({ error: "entityId required" }, 400); - const doc = ensureDoc(space); + const doc = ensureDoc(dataSpace); const matching = Object.values(doc.events) .filter((e) => e.rToolSource === tool && e.rToolEntityId === entityId) .sort((a, b) => a.startTime - b.startTime); @@ -707,6 +719,7 @@ routes.get("/api/context/:tool", async (c) => { // ── Page route ── routes.get("/", (c) => { const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; return c.html(renderShell({ title: `${space} — Calendar | rSpace`, moduleId: "rcal", diff --git a/modules/rdata/mod.ts b/modules/rdata/mod.ts index da12662..41aade6 100644 --- a/modules/rdata/mod.ts +++ b/modules/rdata/mod.ts @@ -121,6 +121,7 @@ routes.post("/api/collect", async (c) => { // ── Page route ── routes.get("/", (c) => { const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; return c.html(renderShell({ title: `${space} — Data | rSpace`, moduleId: "rdata", diff --git a/modules/rdesign/mod.ts b/modules/rdesign/mod.ts index d385218..f1c30fc 100644 --- a/modules/rdesign/mod.ts +++ b/modules/rdesign/mod.ts @@ -19,6 +19,7 @@ routes.get("/api/health", (c) => { routes.get("/", (c) => { const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; const view = c.req.query("view"); if (view === "demo") { diff --git a/modules/rdocs/mod.ts b/modules/rdocs/mod.ts index e7d0605..2fc0575 100644 --- a/modules/rdocs/mod.ts +++ b/modules/rdocs/mod.ts @@ -19,6 +19,7 @@ routes.get("/api/health", (c) => { routes.get("/", (c) => { const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; const view = c.req.query("view"); if (view === "demo") { diff --git a/modules/rmaps/mod.ts b/modules/rmaps/mod.ts index 06ab2f1..8b672fe 100644 --- a/modules/rmaps/mod.ts +++ b/modules/rmaps/mod.ts @@ -134,6 +134,7 @@ routes.get("/api/c3nav/:event", async (c) => { // ── Page route ── routes.get("/", (c) => { const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; return c.html(renderShell({ title: `${space} — Maps | rSpace`, moduleId: "rmaps", @@ -149,6 +150,7 @@ routes.get("/", (c) => { // Room-specific page routes.get("/:room", (c) => { const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; const room = c.req.param("room"); return c.html(renderShell({ title: `${room} — Maps | rSpace`, diff --git a/modules/rnetwork/mod.ts b/modules/rnetwork/mod.ts index a0830c0..888f409 100644 --- a/modules/rnetwork/mod.ts +++ b/modules/rnetwork/mod.ts @@ -55,14 +55,16 @@ const CACHE_TTL = 60_000; // ── API: Health ── routes.get("/api/health", (c) => { const space = c.req.param("space") || "demo"; - const token = getTokenForSpace(space); + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; + const token = getTokenForSpace(dataSpace); return c.json({ ok: true, module: "network", space, twentyConfigured: !!token }); }); // ── API: Info ── routes.get("/api/info", (c) => { const space = c.req.param("space") || "demo"; - const token = getTokenForSpace(space); + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; + const token = getTokenForSpace(dataSpace); return c.json({ module: "network", description: "Community relationship graph visualization", @@ -76,7 +78,8 @@ routes.get("/api/info", (c) => { // ── API: People ── routes.get("/api/people", async (c) => { const space = c.req.param("space") || "demo"; - const token = getTokenForSpace(space); + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; + const token = getTokenForSpace(dataSpace); const data = await twentyQuery(`{ people(first: 200) { edges { @@ -91,7 +94,7 @@ routes.get("/api/people", async (c) => { } } } - }`, undefined, space); + }`, undefined, dataSpace); if (!data) return c.json({ people: [], error: token ? "Twenty API error" : "Twenty not configured" }); const people = ((data as any).people?.edges || []).map((e: any) => e.node); c.header("Cache-Control", "public, max-age=60"); @@ -101,7 +104,8 @@ routes.get("/api/people", async (c) => { // ── API: Companies ── routes.get("/api/companies", async (c) => { const space = c.req.param("space") || "demo"; - const token = getTokenForSpace(space); + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; + const token = getTokenForSpace(dataSpace); const data = await twentyQuery(`{ companies(first: 200) { edges { @@ -115,7 +119,7 @@ routes.get("/api/companies", async (c) => { } } } - }`, undefined, space); + }`, undefined, dataSpace); if (!data) return c.json({ companies: [], error: token ? "Twenty API error" : "Twenty not configured" }); const companies = ((data as any).companies?.edges || []).map((e: any) => e.node); c.header("Cache-Control", "public, max-age=60"); @@ -125,10 +129,11 @@ routes.get("/api/companies", async (c) => { // ── API: Graph — transform entities to node/edge format ── routes.get("/api/graph", async (c) => { const space = c.req.param("space") || "demo"; - const token = getTokenForSpace(space); + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; + const token = getTokenForSpace(dataSpace); // Check per-space cache - const cached = graphCaches.get(space); + const cached = graphCaches.get(dataSpace); if (cached && Date.now() - cached.ts < CACHE_TTL) { c.header("Cache-Control", "public, max-age=60"); return c.json(cached.data); @@ -186,7 +191,7 @@ routes.get("/api/graph", async (c) => { } } } - }`, undefined, space); + }`, undefined, dataSpace); if (!data) return c.json({ nodes: [], edges: [], error: "Twenty API error" }); @@ -227,7 +232,7 @@ routes.get("/api/graph", async (c) => { } const result = { nodes, edges, demo: false }; - graphCaches.set(space, { data: result, ts: Date.now() }); + graphCaches.set(dataSpace, { data: result, ts: Date.now() }); c.header("Cache-Control", "public, max-age=60"); return c.json(result); } catch (e) { @@ -246,7 +251,8 @@ routes.get("/api/workspaces", (c) => { // ── API: Opportunities ── routes.get("/api/opportunities", async (c) => { const space = c.req.param("space") || "demo"; - const token = getTokenForSpace(space); + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; + const token = getTokenForSpace(dataSpace); const data = await twentyQuery(`{ opportunities(first: 200) { edges { @@ -262,7 +268,7 @@ routes.get("/api/opportunities", async (c) => { } } } - }`, undefined, space); + }`, undefined, dataSpace); if (!data) return c.json({ opportunities: [], error: token ? "Twenty API error" : "Twenty not configured" }); const opportunities = ((data as any).opportunities?.edges || []).map((e: any) => e.node); c.header("Cache-Control", "public, max-age=60"); @@ -272,6 +278,7 @@ routes.get("/api/opportunities", async (c) => { // ── CRM sub-route — embed Twenty CRM via iframe ── routes.get("/crm", (c) => { const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; return c.html(renderExternalAppShell({ title: `${space} — CRM | rSpace`, moduleId: "rnetwork", @@ -285,6 +292,7 @@ routes.get("/crm", (c) => { // ── Page route ── routes.get("/", (c) => { const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; const view = c.req.query("view"); if (view === "app") { diff --git a/modules/rnotes/mod.ts b/modules/rnotes/mod.ts index 584a074..aa08a40 100644 --- a/modules/rnotes/mod.ts +++ b/modules/rnotes/mod.ts @@ -245,8 +245,9 @@ function extractPlainText(content: string, format?: string): string { // GET /api/notebooks — list notebooks routes.get("/api/notebooks", async (c) => { const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; - const notebooks = listNotebooks(space).map(({ doc }) => notebookToRest(doc)); + const notebooks = listNotebooks(dataSpace).map(({ doc }) => notebookToRest(doc)); notebooks.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()); return c.json({ notebooks, source: "automerge" }); }); @@ -254,6 +255,7 @@ routes.get("/api/notebooks", async (c) => { // POST /api/notebooks — create notebook routes.post("/api/notebooks", async (c) => { const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; const token = extractToken(c.req.raw.headers); if (!token) return c.json({ error: "Authentication required" }, 401); let claims; @@ -266,8 +268,8 @@ routes.post("/api/notebooks", async (c) => { const notebookId = newId(); const now = Date.now(); - const doc = ensureDoc(space, notebookId); - _syncServer!.changeDoc(notebookDocId(space, notebookId), "Create notebook", (d) => { + const doc = ensureDoc(dataSpace, notebookId); + _syncServer!.changeDoc(notebookDocId(dataSpace, notebookId), "Create notebook", (d) => { d.notebook.id = notebookId; d.notebook.title = nbTitle; d.notebook.slug = slugify(nbTitle); @@ -278,16 +280,17 @@ routes.post("/api/notebooks", async (c) => { d.notebook.updatedAt = now; }); - const updatedDoc = _syncServer!.getDoc(notebookDocId(space, notebookId))!; + const updatedDoc = _syncServer!.getDoc(notebookDocId(dataSpace, notebookId))!; return c.json(notebookToRest(updatedDoc), 201); }); // GET /api/notebooks/:id — notebook detail with notes routes.get("/api/notebooks/:id", async (c) => { const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; const id = c.req.param("id"); - const docId = notebookDocId(space, id); + const docId = notebookDocId(dataSpace, id); const doc = _syncServer?.getDoc(docId); if (!doc || !doc.notebook || !doc.notebook.title) { return c.json({ error: "Notebook not found" }, 404); @@ -306,6 +309,7 @@ routes.get("/api/notebooks/:id", async (c) => { // PUT /api/notebooks/:id — update notebook routes.put("/api/notebooks/:id", async (c) => { const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; const token = extractToken(c.req.raw.headers); if (!token) return c.json({ error: "Authentication required" }, 401); let claims; @@ -319,7 +323,7 @@ routes.put("/api/notebooks/:id", async (c) => { return c.json({ error: "No fields to update" }, 400); } - const docId = notebookDocId(space, id); + const docId = notebookDocId(dataSpace, id); const doc = _syncServer?.getDoc(docId); if (!doc || !doc.notebook || !doc.notebook.title) { return c.json({ error: "Notebook not found" }, 404); @@ -340,9 +344,10 @@ routes.put("/api/notebooks/:id", async (c) => { // DELETE /api/notebooks/:id routes.delete("/api/notebooks/:id", async (c) => { const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; const id = c.req.param("id"); - const docId = notebookDocId(space, id); + const docId = notebookDocId(dataSpace, id); const doc = _syncServer?.getDoc(docId); if (!doc || !doc.notebook || !doc.notebook.title) { return c.json({ error: "Notebook not found" }, 404); @@ -366,15 +371,16 @@ routes.delete("/api/notebooks/:id", async (c) => { // GET /api/notes — list all notes routes.get("/api/notes", async (c) => { const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; const { notebook_id, type, q, limit = "50", offset = "0" } = c.req.query(); let allNotes: ReturnType[] = []; const notebooks = notebook_id ? (() => { - const doc = _syncServer?.getDoc(notebookDocId(space, notebook_id)); + const doc = _syncServer?.getDoc(notebookDocId(dataSpace, notebook_id)); return doc ? [{ doc }] : []; })() - : listNotebooks(space); + : listNotebooks(dataSpace); for (const { doc } of notebooks) { for (const item of Object.values(doc.items)) { @@ -401,6 +407,7 @@ routes.get("/api/notes", async (c) => { // POST /api/notes — create note routes.post("/api/notes", async (c) => { const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; const token = extractToken(c.req.raw.headers); if (!token) return c.json({ error: "Authentication required" }, 401); let claims; @@ -439,8 +446,8 @@ routes.post("/api/notes", async (c) => { }); // Ensure the notebook doc exists, then add the note - ensureDoc(space, notebook_id); - const docId = notebookDocId(space, notebook_id); + ensureDoc(dataSpace, notebook_id); + const docId = notebookDocId(dataSpace, notebook_id); _syncServer!.changeDoc(docId, `Create note: ${title.trim()}`, (d) => { d.items[noteId] = item; d.notebook.updatedAt = Date.now(); @@ -452,9 +459,10 @@ routes.post("/api/notes", async (c) => { // GET /api/notes/:id — note detail routes.get("/api/notes/:id", async (c) => { const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; const id = c.req.param("id"); - const found = findNote(space, id); + const found = findNote(dataSpace, id); if (!found) return c.json({ error: "Note not found" }, 404); return c.json({ ...noteToRest(found.item), source: "automerge" }); @@ -463,6 +471,7 @@ routes.get("/api/notes/:id", async (c) => { // PUT /api/notes/:id — update note routes.put("/api/notes/:id", async (c) => { const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; const id = c.req.param("id"); const body = await c.req.json(); const { title, content, content_format, type, url, language, is_pinned, sort_order } = body; @@ -472,7 +481,7 @@ routes.put("/api/notes/:id", async (c) => { return c.json({ error: "No fields to update" }, 400); } - const found = findNote(space, id); + const found = findNote(dataSpace, id); if (!found) return c.json({ error: "Note not found" }, 404); const contentPlain = content !== undefined @@ -503,9 +512,10 @@ routes.put("/api/notes/:id", async (c) => { // DELETE /api/notes/:id routes.delete("/api/notes/:id", async (c) => { const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; const id = c.req.param("id"); - const found = findNote(space, id); + const found = findNote(dataSpace, id); if (!found) return c.json({ error: "Note not found" }, 404); _syncServer!.changeDoc(found.docId, `Delete note ${id}`, (d) => { @@ -586,6 +596,7 @@ function getConnectionDoc(space: string): ConnectionsDoc | null { // POST /api/import/upload — ZIP upload for Logseq/Obsidian routes.post("/api/import/upload", async (c) => { const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; const token = extractToken(c.req.raw.headers); if (!token) return c.json({ error: "Authentication required" }, 401); try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } @@ -612,8 +623,8 @@ routes.post("/api/import/upload", async (c) => { // Create a new notebook with the import title targetNotebookId = newId(); const now = Date.now(); - ensureDoc(space, targetNotebookId); - _syncServer!.changeDoc(notebookDocId(space, targetNotebookId), "Create import notebook", (d) => { + ensureDoc(dataSpace, targetNotebookId); + _syncServer!.changeDoc(notebookDocId(dataSpace, targetNotebookId), "Create import notebook", (d) => { d.notebook.id = targetNotebookId!; d.notebook.title = result.notebookTitle; d.notebook.slug = slugify(result.notebookTitle); @@ -622,7 +633,7 @@ routes.post("/api/import/upload", async (c) => { }); } - const { imported, updated } = importNotesIntoNotebook(space, targetNotebookId, result.notes); + const { imported, updated } = importNotesIntoNotebook(dataSpace, targetNotebookId, result.notes); return c.json({ ok: true, @@ -637,6 +648,7 @@ routes.post("/api/import/upload", async (c) => { // POST /api/import/notion — Import selected Notion pages routes.post("/api/import/notion", async (c) => { const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; const token = extractToken(c.req.raw.headers); if (!token) return c.json({ error: "Authentication required" }, 401); try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } @@ -648,7 +660,7 @@ routes.post("/api/import/notion", async (c) => { return c.json({ error: "pageIds array is required" }, 400); } - const conn = getConnectionDoc(space); + const conn = getConnectionDoc(dataSpace); if (!conn?.notion?.accessToken) { return c.json({ error: "Notion not connected. Connect your Notion account first." }, 400); } @@ -664,8 +676,8 @@ routes.post("/api/import/notion", async (c) => { if (!targetNotebookId) { targetNotebookId = newId(); const now = Date.now(); - ensureDoc(space, targetNotebookId); - _syncServer!.changeDoc(notebookDocId(space, targetNotebookId), "Create Notion import notebook", (d) => { + ensureDoc(dataSpace, targetNotebookId); + _syncServer!.changeDoc(notebookDocId(dataSpace, targetNotebookId), "Create Notion import notebook", (d) => { d.notebook.id = targetNotebookId!; d.notebook.title = result.notebookTitle; d.notebook.slug = slugify(result.notebookTitle); @@ -674,7 +686,7 @@ routes.post("/api/import/notion", async (c) => { }); } - const { imported, updated } = importNotesIntoNotebook(space, targetNotebookId, result.notes); + const { imported, updated } = importNotesIntoNotebook(dataSpace, targetNotebookId, result.notes); return c.json({ ok: true, notebookId: targetNotebookId, imported, updated, warnings: result.warnings }); }); @@ -682,6 +694,7 @@ routes.post("/api/import/notion", async (c) => { // POST /api/import/google-docs — Import selected Google Docs routes.post("/api/import/google-docs", async (c) => { const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; const token = extractToken(c.req.raw.headers); if (!token) return c.json({ error: "Authentication required" }, 401); try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } @@ -693,7 +706,7 @@ routes.post("/api/import/google-docs", async (c) => { return c.json({ error: "docIds array is required" }, 400); } - const conn = getConnectionDoc(space); + const conn = getConnectionDoc(dataSpace); if (!conn?.google?.accessToken) { return c.json({ error: "Google not connected. Connect your Google account first." }, 400); } @@ -708,8 +721,8 @@ routes.post("/api/import/google-docs", async (c) => { if (!targetNotebookId) { targetNotebookId = newId(); const now = Date.now(); - ensureDoc(space, targetNotebookId); - _syncServer!.changeDoc(notebookDocId(space, targetNotebookId), "Create Google Docs import notebook", (d) => { + ensureDoc(dataSpace, targetNotebookId); + _syncServer!.changeDoc(notebookDocId(dataSpace, targetNotebookId), "Create Google Docs import notebook", (d) => { d.notebook.id = targetNotebookId!; d.notebook.title = result.notebookTitle; d.notebook.slug = slugify(result.notebookTitle); @@ -718,7 +731,7 @@ routes.post("/api/import/google-docs", async (c) => { }); } - const { imported, updated } = importNotesIntoNotebook(space, targetNotebookId, result.notes); + const { imported, updated } = importNotesIntoNotebook(dataSpace, targetNotebookId, result.notes); return c.json({ ok: true, notebookId: targetNotebookId, imported, updated, warnings: result.warnings }); }); @@ -726,7 +739,8 @@ routes.post("/api/import/google-docs", async (c) => { // GET /api/import/notion/pages — Browse Notion pages for selection routes.get("/api/import/notion/pages", async (c) => { const space = c.req.param("space") || "demo"; - const conn = getConnectionDoc(space); + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; + const conn = getConnectionDoc(dataSpace); if (!conn?.notion?.accessToken) { return c.json({ error: "Notion not connected" }, 400); } @@ -768,7 +782,8 @@ routes.get("/api/import/notion/pages", async (c) => { // GET /api/import/google-docs/list — Browse Google Docs for selection routes.get("/api/import/google-docs/list", async (c) => { const space = c.req.param("space") || "demo"; - const conn = getConnectionDoc(space); + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; + const conn = getConnectionDoc(dataSpace); if (!conn?.google?.accessToken) { return c.json({ error: "Google not connected" }, 400); } @@ -798,10 +813,11 @@ routes.get("/api/import/google-docs/list", async (c) => { // GET /api/export/obsidian — Download Obsidian-format ZIP routes.get("/api/export/obsidian", async (c) => { const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; const notebookId = c.req.query("notebookId"); if (!notebookId) return c.json({ error: "notebookId is required" }, 400); - const docId = notebookDocId(space, notebookId); + const docId = notebookDocId(dataSpace, notebookId); const doc = _syncServer?.getDoc(docId); if (!doc || !doc.notebook?.title) return c.json({ error: "Notebook not found" }, 404); @@ -820,10 +836,11 @@ routes.get("/api/export/obsidian", async (c) => { // GET /api/export/logseq — Download Logseq-format ZIP routes.get("/api/export/logseq", async (c) => { const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; const notebookId = c.req.query("notebookId"); if (!notebookId) return c.json({ error: "notebookId is required" }, 400); - const docId = notebookDocId(space, notebookId); + const docId = notebookDocId(dataSpace, notebookId); const doc = _syncServer?.getDoc(docId); if (!doc || !doc.notebook?.title) return c.json({ error: "Notebook not found" }, 404); @@ -842,6 +859,7 @@ routes.get("/api/export/logseq", async (c) => { // GET /api/export/markdown — Download universal Markdown ZIP routes.get("/api/export/markdown", async (c) => { const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; const notebookId = c.req.query("notebookId"); const noteIds = c.req.query("noteIds"); @@ -849,7 +867,7 @@ routes.get("/api/export/markdown", async (c) => { let title = "rNotes Export"; if (notebookId) { - const docId = notebookDocId(space, notebookId); + const docId = notebookDocId(dataSpace, notebookId); const doc = _syncServer?.getDoc(docId); if (!doc || !doc.notebook?.title) return c.json({ error: "Notebook not found" }, 404); notes = Object.values(doc.items); @@ -857,7 +875,7 @@ routes.get("/api/export/markdown", async (c) => { } else if (noteIds) { const ids = noteIds.split(",").map(id => id.trim()); for (const id of ids) { - const found = findNote(space, id); + const found = findNote(dataSpace, id); if (found) notes.push(found.item); } } else { @@ -881,6 +899,7 @@ routes.get("/api/export/markdown", async (c) => { // POST /api/export/notion — Push notes to Notion routes.post("/api/export/notion", async (c) => { const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; const token = extractToken(c.req.raw.headers); if (!token) return c.json({ error: "Authentication required" }, 401); try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } @@ -888,19 +907,19 @@ routes.post("/api/export/notion", async (c) => { const body = await c.req.json(); const { notebookId, noteIds, parentId } = body; - const conn = getConnectionDoc(space); + const conn = getConnectionDoc(dataSpace); if (!conn?.notion?.accessToken) { return c.json({ error: "Notion not connected" }, 400); } let notes: NoteItem[] = []; if (notebookId) { - const docId = notebookDocId(space, notebookId); + const docId = notebookDocId(dataSpace, notebookId); const doc = _syncServer?.getDoc(docId); if (doc) notes = Object.values(doc.items); } else if (noteIds && Array.isArray(noteIds)) { for (const id of noteIds) { - const found = findNote(space, id); + const found = findNote(dataSpace, id); if (found) notes.push(found.item); } } @@ -920,6 +939,7 @@ routes.post("/api/export/notion", async (c) => { // POST /api/export/google-docs — Push notes to Google Docs routes.post("/api/export/google-docs", async (c) => { const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; const token = extractToken(c.req.raw.headers); if (!token) return c.json({ error: "Authentication required" }, 401); try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } @@ -927,19 +947,19 @@ routes.post("/api/export/google-docs", async (c) => { const body = await c.req.json(); const { notebookId, noteIds, parentId } = body; - const conn = getConnectionDoc(space); + const conn = getConnectionDoc(dataSpace); if (!conn?.google?.accessToken) { return c.json({ error: "Google not connected" }, 400); } let notes: NoteItem[] = []; if (notebookId) { - const docId = notebookDocId(space, notebookId); + const docId = notebookDocId(dataSpace, notebookId); const doc = _syncServer?.getDoc(docId); if (doc) notes = Object.values(doc.items); } else if (noteIds && Array.isArray(noteIds)) { for (const id of noteIds) { - const found = findNote(space, id); + const found = findNote(dataSpace, id); if (found) notes.push(found.item); } } @@ -959,7 +979,8 @@ routes.post("/api/export/google-docs", async (c) => { // GET /api/connections — Status of all integrations routes.get("/api/connections", async (c) => { const space = c.req.param("space") || "demo"; - const conn = getConnectionDoc(space); + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; + const conn = getConnectionDoc(dataSpace); return c.json({ notion: conn?.notion ? { @@ -980,6 +1001,7 @@ routes.get("/api/connections", async (c) => { // ── Page route ── routes.get("/", (c) => { const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; return c.html(renderShell({ title: `${space} — Notes | rSpace`, moduleId: "rnotes", diff --git a/modules/rphotos/mod.ts b/modules/rphotos/mod.ts index 8a59542..f013d3f 100644 --- a/modules/rphotos/mod.ts +++ b/modules/rphotos/mod.ts @@ -110,6 +110,7 @@ routes.get("/api/assets/:id/original", async (c) => { // ── Embedded Immich UI ── routes.get("/album", (c) => { const spaceSlug = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || spaceSlug; return c.html(renderExternalAppShell({ title: `${spaceSlug} — Immich | rSpace`, moduleId: "rphotos", @@ -124,6 +125,7 @@ routes.get("/album", (c) => { // ── Page route ── routes.get("/", (c) => { const spaceSlug = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || spaceSlug; return c.html(renderShell({ title: `${spaceSlug} — Photos | rSpace`, moduleId: "rphotos", diff --git a/modules/rpubs/mod.ts b/modules/rpubs/mod.ts index b03c2dd..1a49236 100644 --- a/modules/rpubs/mod.ts +++ b/modules/rpubs/mod.ts @@ -322,6 +322,7 @@ routes.get("/api/artifact/:id/pdf", async (c) => { // ── Page: Editor ── routes.get("/", (c) => { const spaceSlug = c.req.param("space") || "personal"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || spaceSlug; return c.html(renderShell({ title: `${spaceSlug} — rPubs Editor | rSpace`, moduleId: "rpubs", diff --git a/modules/rschedule/mod.ts b/modules/rschedule/mod.ts index 82a74b4..6c37a09 100644 --- a/modules/rschedule/mod.ts +++ b/modules/rschedule/mod.ts @@ -756,6 +756,7 @@ function seedDefaultJobs(space: string) { // GET / — serve schedule UI routes.get("/", (c) => { const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; return c.html( renderShell({ title: `${space} — Schedule | rSpace`, @@ -773,7 +774,8 @@ routes.get("/", (c) => { // GET /api/jobs — list all jobs routes.get("/api/jobs", (c) => { const space = c.req.param("space") || "demo"; - const doc = ensureDoc(space); + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; + const doc = ensureDoc(dataSpace); const jobs = Object.values(doc.jobs).map((j) => ({ ...j, cronHuman: cronToHuman(j.cronExpression), @@ -785,6 +787,7 @@ routes.get("/api/jobs", (c) => { // POST /api/jobs — create a new job routes.post("/api/jobs", async (c) => { const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; const body = await c.req.json(); const { name, description, cronExpression, timezone, actionType, actionConfig, enabled } = body; @@ -798,8 +801,8 @@ routes.post("/api/jobs", async (c) => { return c.json({ error: "Invalid cron expression" }, 400); } - const docId = scheduleDocId(space); - ensureDoc(space); + const docId = scheduleDocId(dataSpace); + ensureDoc(dataSpace); const jobId = crypto.randomUUID(); const now = Date.now(); const tz = timezone || "UTC"; @@ -832,8 +835,9 @@ routes.post("/api/jobs", async (c) => { // GET /api/jobs/:id routes.get("/api/jobs/:id", (c) => { const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; const id = c.req.param("id"); - const doc = ensureDoc(space); + const doc = ensureDoc(dataSpace); const job = doc.jobs[id]; if (!job) return c.json({ error: "Job not found" }, 404); @@ -843,11 +847,12 @@ routes.get("/api/jobs/:id", (c) => { // PUT /api/jobs/:id — update a job routes.put("/api/jobs/:id", async (c) => { const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; const id = c.req.param("id"); const body = await c.req.json(); - const docId = scheduleDocId(space); - const doc = ensureDoc(space); + const docId = scheduleDocId(dataSpace); + const doc = ensureDoc(dataSpace); if (!doc.jobs[id]) return c.json({ error: "Job not found" }, 404); // Validate cron if provided @@ -885,10 +890,11 @@ routes.put("/api/jobs/:id", async (c) => { // DELETE /api/jobs/:id routes.delete("/api/jobs/:id", (c) => { const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; const id = c.req.param("id"); - const docId = scheduleDocId(space); - const doc = ensureDoc(space); + const docId = scheduleDocId(dataSpace); + const doc = ensureDoc(dataSpace); if (!doc.jobs[id]) return c.json({ error: "Job not found" }, 404); _syncServer!.changeDoc(docId, `delete job ${id}`, (d) => { @@ -901,10 +907,11 @@ routes.delete("/api/jobs/:id", (c) => { // POST /api/jobs/:id/run — manually trigger a job routes.post("/api/jobs/:id/run", async (c) => { const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; const id = c.req.param("id"); - const docId = scheduleDocId(space); - const doc = ensureDoc(space); + const docId = scheduleDocId(dataSpace); + const doc = ensureDoc(dataSpace); const job = doc.jobs[id]; if (!job) return c.json({ error: "Job not found" }, 404); @@ -912,7 +919,7 @@ routes.post("/api/jobs/:id/run", async (c) => { let result: { success: boolean; message: string }; try { - result = await executeJob(job, space); + result = await executeJob(job, dataSpace); } catch (e: any) { result = { success: false, message: e.message || String(e) }; } @@ -947,7 +954,8 @@ routes.post("/api/jobs/:id/run", async (c) => { // GET /api/log — execution log routes.get("/api/log", (c) => { const space = c.req.param("space") || "demo"; - const doc = ensureDoc(space); + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; + const doc = ensureDoc(dataSpace); const log = [...doc.log].reverse(); // newest first return c.json({ count: log.length, results: log }); }); @@ -955,8 +963,9 @@ routes.get("/api/log", (c) => { // GET /api/log/:jobId — execution log filtered by job routes.get("/api/log/:jobId", (c) => { const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; const jobId = c.req.param("jobId"); - const doc = ensureDoc(space); + const doc = ensureDoc(dataSpace); const log = doc.log.filter((e) => e.jobId === jobId).reverse(); return c.json({ count: log.length, results: log }); }); @@ -1103,7 +1112,8 @@ async function executeReminderEmail( // GET /api/reminders — list reminders routes.get("/api/reminders", (c) => { const space = c.req.param("space") || "demo"; - const doc = ensureDoc(space); + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; + const doc = ensureDoc(dataSpace); let reminders = Object.values(doc.reminders); @@ -1131,14 +1141,15 @@ routes.get("/api/reminders", (c) => { // POST /api/reminders — create a reminder routes.post("/api/reminders", async (c) => { const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; const body = await c.req.json(); const { title, description, remindAt, allDay, timezone, notifyEmail, syncToCalendar, cronExpression } = body; if (!title?.trim() || !remindAt) return c.json({ error: "title and remindAt required" }, 400); - const docId = scheduleDocId(space); - const doc = ensureDoc(space); + const docId = scheduleDocId(dataSpace); + const doc = ensureDoc(dataSpace); if (Object.keys(doc.reminders).length >= MAX_REMINDERS) return c.json({ error: `Maximum ${MAX_REMINDERS} reminders reached` }, 400); @@ -1169,7 +1180,7 @@ routes.post("/api/reminders", async (c) => { // Sync to calendar if requested if (syncToCalendar) { - const eventId = syncReminderToCalendar(reminder, space); + const eventId = syncReminderToCalendar(reminder, dataSpace); if (eventId) reminder.calendarEventId = eventId; } @@ -1184,8 +1195,9 @@ routes.post("/api/reminders", async (c) => { // GET /api/reminders/:id — get single reminder routes.get("/api/reminders/:id", (c) => { const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; const id = c.req.param("id"); - const doc = ensureDoc(space); + const doc = ensureDoc(dataSpace); const reminder = doc.reminders[id]; if (!reminder) return c.json({ error: "Reminder not found" }, 404); @@ -1195,11 +1207,12 @@ routes.get("/api/reminders/:id", (c) => { // PUT /api/reminders/:id — update a reminder routes.put("/api/reminders/:id", async (c) => { const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; const id = c.req.param("id"); const body = await c.req.json(); - const docId = scheduleDocId(space); - const doc = ensureDoc(space); + const docId = scheduleDocId(dataSpace); + const doc = ensureDoc(dataSpace); if (!doc.reminders[id]) return c.json({ error: "Reminder not found" }, 404); _syncServer!.changeDoc(docId, `update reminder ${id}`, (d) => { @@ -1222,16 +1235,17 @@ routes.put("/api/reminders/:id", async (c) => { // DELETE /api/reminders/:id — delete (cascades to calendar) routes.delete("/api/reminders/:id", (c) => { const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; const id = c.req.param("id"); - const docId = scheduleDocId(space); - const doc = ensureDoc(space); + const docId = scheduleDocId(dataSpace); + const doc = ensureDoc(dataSpace); const reminder = doc.reminders[id]; if (!reminder) return c.json({ error: "Reminder not found" }, 404); // Cascade: delete linked calendar event if (reminder.calendarEventId) { - deleteCalendarEvent(space, reminder.calendarEventId); + deleteCalendarEvent(dataSpace, reminder.calendarEventId); } _syncServer!.changeDoc(docId, `delete reminder ${id}`, (d) => { @@ -1244,10 +1258,11 @@ routes.delete("/api/reminders/:id", (c) => { // POST /api/reminders/:id/complete — mark completed routes.post("/api/reminders/:id/complete", (c) => { const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; const id = c.req.param("id"); - const docId = scheduleDocId(space); - const doc = ensureDoc(space); + const docId = scheduleDocId(dataSpace); + const doc = ensureDoc(dataSpace); if (!doc.reminders[id]) return c.json({ error: "Reminder not found" }, 404); _syncServer!.changeDoc(docId, `complete reminder ${id}`, (d) => { @@ -1264,11 +1279,12 @@ routes.post("/api/reminders/:id/complete", (c) => { // POST /api/reminders/:id/snooze — reschedule to a new date routes.post("/api/reminders/:id/snooze", async (c) => { const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; const id = c.req.param("id"); const body = await c.req.json(); - const docId = scheduleDocId(space); - const doc = ensureDoc(space); + const docId = scheduleDocId(dataSpace); + const doc = ensureDoc(dataSpace); if (!doc.reminders[id]) return c.json({ error: "Reminder not found" }, 404); const newRemindAt = body.remindAt @@ -1287,7 +1303,7 @@ routes.post("/api/reminders/:id/snooze", async (c) => { const updated = _syncServer!.getDoc(docId)!; const reminder = updated.reminders[id]; if (reminder?.calendarEventId) { - const calDocId = calendarDocId(space); + const calDocId = calendarDocId(dataSpace); const duration = reminder.allDay ? 86400000 : 3600000; _syncServer!.changeDoc(calDocId, `update reminder event time`, (d) => { const ev = d.events[reminder.calendarEventId!]; diff --git a/modules/rsocials/mod.ts b/modules/rsocials/mod.ts index a99ef4f..25a30b3 100644 --- a/modules/rsocials/mod.ts +++ b/modules/rsocials/mod.ts @@ -179,10 +179,11 @@ routes.get("/api/feed", (c) => routes.post("/api/threads/:id/image", async (c) => { const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; const id = c.req.param("id"); if (!id || id.includes("..") || id.includes("/")) return c.json({ error: "Invalid ID" }, 400); - const thread = getThreadFromDoc(space, id); + const thread = getThreadFromDoc(dataSpace, id); if (!thread) return c.json({ error: "Thread not found" }, 404); if (!process.env.FAL_KEY) return c.json({ error: "FAL_KEY not configured" }, 503); @@ -210,7 +211,7 @@ routes.post("/api/threads/:id/image", async (c) => { if (!imageUrl) return c.json({ error: "Failed to download image" }, 502); // Update Automerge doc with image URL - const docId = socialsDocId(space); + const docId = socialsDocId(dataSpace); _syncServer!.changeDoc(docId, "set thread image", (d) => { if (d.threads?.[id]) { d.threads[id].imageUrl = imageUrl; @@ -223,10 +224,11 @@ routes.post("/api/threads/:id/image", async (c) => { routes.post("/api/threads/:id/upload-image", async (c) => { const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; const id = c.req.param("id"); if (!id || id.includes("..") || id.includes("/")) return c.json({ error: "Invalid ID" }, 400); - const thread = getThreadFromDoc(space, id); + const thread = getThreadFromDoc(dataSpace, id); if (!thread) return c.json({ error: "Thread not found" }, 404); let formData: FormData; @@ -246,7 +248,7 @@ routes.post("/api/threads/:id/upload-image", async (c) => { const buffer = Buffer.from(await file.arrayBuffer()); const imageUrl = await saveUploadedFile(buffer, filename); - const docId = socialsDocId(space); + const docId = socialsDocId(dataSpace); _syncServer!.changeDoc(docId, "upload thread image", (d) => { if (d.threads?.[id]) { d.threads[id].imageUrl = imageUrl; @@ -259,12 +261,13 @@ routes.post("/api/threads/:id/upload-image", async (c) => { routes.post("/api/threads/:id/tweet/:index/upload-image", async (c) => { const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; const id = c.req.param("id"); const index = c.req.param("index"); if (!id || id.includes("..") || id.includes("/")) return c.json({ error: "Invalid ID" }, 400); if (!/^\d+$/.test(index)) return c.json({ error: "Invalid index" }, 400); - const thread = getThreadFromDoc(space, id); + const thread = getThreadFromDoc(dataSpace, id); if (!thread) return c.json({ error: "Thread not found" }, 404); let formData: FormData; @@ -285,7 +288,7 @@ routes.post("/api/threads/:id/tweet/:index/upload-image", async (c) => { const buffer = Buffer.from(await file.arrayBuffer()); const imageUrl = await saveUploadedFile(buffer, filename); - const docId = socialsDocId(space); + const docId = socialsDocId(dataSpace); _syncServer!.changeDoc(docId, "upload tweet image", (d) => { if (d.threads?.[id]) { if (!d.threads[id].tweetImages) d.threads[id].tweetImages = {} as any; @@ -299,12 +302,13 @@ routes.post("/api/threads/:id/tweet/:index/upload-image", async (c) => { routes.post("/api/threads/:id/tweet/:index/image", async (c) => { const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; const id = c.req.param("id"); const index = c.req.param("index"); if (!id || id.includes("..") || id.includes("/")) return c.json({ error: "Invalid ID" }, 400); if (!/^\d+$/.test(index)) return c.json({ error: "Invalid index" }, 400); - const thread = getThreadFromDoc(space, id); + const thread = getThreadFromDoc(dataSpace, id); if (!thread) return c.json({ error: "Thread not found" }, 404); const tweetIndex = parseInt(index, 10); @@ -340,7 +344,7 @@ routes.post("/api/threads/:id/tweet/:index/image", async (c) => { } if (!imageUrl) return c.json({ error: "Failed to download image" }, 502); - const docId = socialsDocId(space); + const docId = socialsDocId(dataSpace); _syncServer!.changeDoc(docId, "generate tweet image", (d) => { if (d.threads?.[id]) { if (!d.threads[id].tweetImages) d.threads[id].tweetImages = {} as any; @@ -354,19 +358,20 @@ routes.post("/api/threads/:id/tweet/:index/image", async (c) => { routes.delete("/api/threads/:id/tweet/:index/image", async (c) => { const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; const id = c.req.param("id"); const index = c.req.param("index"); if (!id || id.includes("..") || id.includes("/")) return c.json({ error: "Invalid ID" }, 400); if (!/^\d+$/.test(index)) return c.json({ error: "Invalid index" }, 400); - const thread = getThreadFromDoc(space, id); + const thread = getThreadFromDoc(dataSpace, id); if (!thread) return c.json({ error: "Thread not found" }, 404); if (!thread.tweetImages?.[index]) return c.json({ ok: true }); await deleteImageFile(thread.tweetImages[index]); - const docId = socialsDocId(space); + const docId = socialsDocId(dataSpace); _syncServer!.changeDoc(docId, "remove tweet image", (d) => { if (d.threads?.[id]?.tweetImages?.[index]) { delete d.threads[id].tweetImages![index]; @@ -382,10 +387,11 @@ routes.delete("/api/threads/:id/tweet/:index/image", async (c) => { routes.delete("/api/threads/:id/images", async (c) => { const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; const id = c.req.param("id"); if (!id || id.includes("..") || id.includes("/")) return c.json({ error: "Invalid ID" }, 400); - const thread = getThreadFromDoc(space, id); + const thread = getThreadFromDoc(dataSpace, id); if (!thread) return c.json({ ok: true }); // Thread already gone // Clean up header image @@ -405,6 +411,7 @@ routes.delete("/api/threads/:id/images", async (c) => { routes.get("/campaign", (c) => { const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; return c.html(renderShell({ title: `Campaign — rSocials | rSpace`, moduleId: "rsocials", @@ -419,10 +426,11 @@ routes.get("/campaign", (c) => { routes.get("/thread/:id", async (c) => { const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; const id = c.req.param("id"); if (!id || id.includes("..") || id.includes("/")) return c.text("Not found", 404); - const thread = getThreadFromDoc(space, id); + const thread = getThreadFromDoc(dataSpace, id); if (!thread) return c.text("Thread not found", 404); // OG tags for social crawlers (SSR) @@ -461,10 +469,11 @@ routes.get("/thread/:id", async (c) => { routes.get("/thread/:id/edit", async (c) => { const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; const id = c.req.param("id"); if (!id || id.includes("..") || id.includes("/")) return c.text("Not found", 404); - const thread = getThreadFromDoc(space, id); + const thread = getThreadFromDoc(dataSpace, id); if (!thread) return c.text("Thread not found", 404); const dataScript = ``; @@ -483,6 +492,7 @@ routes.get("/thread/:id/edit", async (c) => { routes.get("/thread", (c) => { const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; return c.html(renderShell({ title: `Thread Builder — rSocials | rSpace`, moduleId: "rsocials", @@ -497,6 +507,7 @@ routes.get("/thread", (c) => { routes.get("/threads", (c) => { const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; return c.html(renderShell({ title: `Threads — rSocials | rSpace`, moduleId: "rsocials", @@ -511,6 +522,7 @@ routes.get("/threads", (c) => { routes.get("/campaigns", (c) => { const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; return c.redirect(`/${space}/rsocials/campaign`); }); @@ -558,6 +570,7 @@ const POSTIZ_URL = process.env.POSTIZ_URL || "https://demo.rsocials.online"; routes.get("/scheduler", (c) => { const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; return c.html(renderExternalAppShell({ title: `Post Scheduler — rSocials | rSpace`, moduleId: "rsocials", @@ -571,6 +584,7 @@ routes.get("/scheduler", (c) => { routes.get("/feed", (c) => { const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; const isDemo = space === "demo"; const body = isDemo ? renderDemoFeedHTML() : renderLanding(); const styles = isDemo @@ -589,6 +603,7 @@ routes.get("/feed", (c) => { routes.get("/landing", (c) => { const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; return c.html(renderShell({ title: `${space} — rSocials | rSpace`, moduleId: "rsocials", @@ -604,6 +619,7 @@ routes.get("/landing", (c) => { routes.get("/", (c) => { const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; return c.html(renderShell({ title: `${space} — rSocials | rSpace`, moduleId: "rsocials", diff --git a/modules/rsplat/mod.ts b/modules/rsplat/mod.ts index e4284af..305e2ea 100644 --- a/modules/rsplat/mod.ts +++ b/modules/rsplat/mod.ts @@ -205,11 +205,12 @@ const routes = new Hono(); // ── API: List splats ── routes.get("/api/splats", async (c) => { const spaceSlug = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || spaceSlug; const tag = c.req.query("tag"); const limit = Math.min(parseInt(c.req.query("limit") || "50"), 100); const offset = parseInt(c.req.query("offset") || "0"); - const doc = ensureDoc(spaceSlug); + const doc = ensureDoc(dataSpace); let items = Object.values(doc.items) .filter((item) => item.status === 'published'); @@ -230,9 +231,10 @@ routes.get("/api/splats", async (c) => { // ── API: Get splat details ── routes.get("/api/splats/:id", async (c) => { const spaceSlug = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || spaceSlug; const id = c.req.param("id"); - const doc = ensureDoc(spaceSlug); + const doc = ensureDoc(dataSpace); const found = findItem(doc, id); if (!found || found[1].status !== 'published') { @@ -242,7 +244,7 @@ routes.get("/api/splats/:id", async (c) => { const [itemKey, item] = found; // Increment view count - const docId = splatScenesDocId(spaceSlug); + const docId = splatScenesDocId(dataSpace); _syncServer!.changeDoc(docId, 'increment view count', (d) => { d.items[itemKey].viewCount += 1; }); @@ -254,9 +256,10 @@ routes.get("/api/splats/:id", async (c) => { // Matches both /api/splats/:id/file and /api/splats/:id/:filename (e.g. rainbow-sphere.splat) routes.get("/api/splats/:id/:filename", async (c) => { const spaceSlug = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || spaceSlug; const id = c.req.param("id"); - const doc = ensureDoc(spaceSlug); + const doc = ensureDoc(dataSpace); const found = findItem(doc, id); if (!found || found[1].status !== 'published') { @@ -307,6 +310,7 @@ routes.post("/api/splats", async (c) => { } const spaceSlug = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || spaceSlug; const formData = await c.req.formData(); const file = formData.get("file") as File | null; const title = (formData.get("title") as string || "").trim(); @@ -336,7 +340,7 @@ routes.post("/api/splats", async (c) => { let slug = slugify(title); // Check slug collision in Automerge doc - const doc = ensureDoc(spaceSlug); + const doc = ensureDoc(dataSpace); const slugExists = Object.values(doc.items).some((item) => item.slug === slug); if (slugExists) { slug = `${slug}-${shortId}`; @@ -354,7 +358,7 @@ routes.post("/api/splats", async (c) => { const now = Date.now(); const paymentTx = (c as any).get("x402Payment") || null; - const docId = splatScenesDocId(spaceSlug); + const docId = splatScenesDocId(dataSpace); _syncServer!.changeDoc(docId, 'add splat', (d) => { d.items[splatId] = { id: splatId, @@ -418,6 +422,7 @@ routes.post("/api/splats/from-media", async (c) => { } const spaceSlug = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || spaceSlug; const formData = await c.req.formData(); const title = (formData.get("title") as string || "").trim(); const description = (formData.get("description") as string || "").trim() || null; @@ -463,7 +468,7 @@ routes.post("/api/splats/from-media", async (c) => { let slug = slugify(title); // Check slug collision in Automerge doc - const doc = ensureDoc(spaceSlug); + const doc = ensureDoc(dataSpace); const slugExists = Object.values(doc.items).some((item) => item.slug === slug); if (slugExists) { slug = `${slug}-${shortId}`; @@ -496,7 +501,7 @@ routes.post("/api/splats/from-media", async (c) => { // Insert splat record (pending processing) into Automerge doc const paymentTx = (c as any).get("x402Payment") || null; - const docId = splatScenesDocId(spaceSlug); + const docId = splatScenesDocId(dataSpace); _syncServer!.changeDoc(docId, 'add splat from media', (d) => { d.items[splatId] = { id: splatId, @@ -549,9 +554,10 @@ routes.delete("/api/splats/:id", async (c) => { } const spaceSlug = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || spaceSlug; const id = c.req.param("id"); - const doc = ensureDoc(spaceSlug); + const doc = ensureDoc(dataSpace); const found = findItem(doc, id); if (!found || found[1].status !== 'published') { @@ -563,7 +569,7 @@ routes.delete("/api/splats/:id", async (c) => { return c.json({ error: "Not authorized" }, 403); } - const docId = splatScenesDocId(spaceSlug); + const docId = splatScenesDocId(dataSpace); _syncServer!.changeDoc(docId, 'remove splat', (d) => { d.items[itemKey].status = 'removed'; }); @@ -574,8 +580,9 @@ routes.delete("/api/splats/:id", async (c) => { // ── Page: Gallery ── routes.get("/", async (c) => { const spaceSlug = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || spaceSlug; - const doc = ensureDoc(spaceSlug); + const doc = ensureDoc(dataSpace); const items = Object.values(doc.items) .filter((item) => item.status === 'published') @@ -612,9 +619,10 @@ routes.get("/", async (c) => { // ── Page: Viewer ── routes.get("/view/:id", async (c) => { const spaceSlug = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || spaceSlug; const id = c.req.param("id"); - const doc = ensureDoc(spaceSlug); + const doc = ensureDoc(dataSpace); const found = findItem(doc, id); if (!found || found[1].status !== 'published') { @@ -632,7 +640,7 @@ routes.get("/view/:id", async (c) => { const [itemKey, splat] = found; // Increment view count - const docId = splatScenesDocId(spaceSlug); + const docId = splatScenesDocId(dataSpace); _syncServer!.changeDoc(docId, 'increment view count', (d) => { d.items[itemKey].viewCount += 1; }); diff --git a/modules/rswag/mod.ts b/modules/rswag/mod.ts index 1674251..aa9d6d5 100644 --- a/modules/rswag/mod.ts +++ b/modules/rswag/mod.ts @@ -230,6 +230,7 @@ routes.get("/api/artifact/:id", async (c) => { // ── Page route: swag designer ── routes.get("/", (c) => { const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; return c.html(renderShell({ title: `Swag Designer | rSpace`, moduleId: "rswag", diff --git a/modules/rtrips/mod.ts b/modules/rtrips/mod.ts index 6403757..599cf44 100644 --- a/modules/rtrips/mod.ts +++ b/modules/rtrips/mod.ts @@ -68,7 +68,8 @@ const routes = new Hono(); // GET /api/trips — list trips routes.get("/api/trips", async (c) => { const space = c.req.param("space") || "demo"; - const docIds = listTripDocIds(space); + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; + const docIds = listTripDocIds(dataSpace); const rows = docIds.map((docId) => { const doc = _syncServer!.getDoc(docId); @@ -102,11 +103,12 @@ routes.post("/api/trips", async (c) => { if (!title?.trim()) return c.json({ error: "Title required" }, 400); const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; const tripId = newId(); const slug = title.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""); const now = Date.now(); - const docId = tripDocId(space, tripId); + const docId = tripDocId(dataSpace, tripId); let doc = Automerge.change(Automerge.init(), 'create trip', (d) => { const init = tripSchema.init(); d.meta = init.meta; @@ -140,8 +142,9 @@ routes.post("/api/trips", async (c) => { // GET /api/trips/:id — trip detail with all sub-resources routes.get("/api/trips/:id", async (c) => { const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; const tripId = c.req.param("id"); - const docId = tripDocId(space, tripId); + const docId = tripDocId(dataSpace, tripId); const doc = _syncServer!.getDoc(docId); if (!doc) return c.json({ error: "Trip not found" }, 404); @@ -163,8 +166,9 @@ routes.get("/api/trips/:id", async (c) => { // PUT /api/trips/:id — update trip routes.put("/api/trips/:id", async (c) => { const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; const tripId = c.req.param("id"); - const docId = tripDocId(space, tripId); + const docId = tripDocId(dataSpace, tripId); const doc = _syncServer!.getDoc(docId); if (!doc) return c.json({ error: "Not found" }, 404); @@ -198,9 +202,10 @@ routes.post("/api/trips/:id/destinations", async (c) => { try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; const tripId = c.req.param("id"); - ensureDoc(space, tripId); - const docId = tripDocId(space, tripId); + ensureDoc(dataSpace, tripId); + const docId = tripDocId(dataSpace, tripId); const body = await c.req.json(); const destId = newId(); @@ -234,9 +239,10 @@ routes.post("/api/trips/:id/itinerary", async (c) => { try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; const tripId = c.req.param("id"); - ensureDoc(space, tripId); - const docId = tripDocId(space, tripId); + ensureDoc(dataSpace, tripId); + const docId = tripDocId(dataSpace, tripId); const body = await c.req.json(); const itemId = newId(); @@ -270,9 +276,10 @@ routes.post("/api/trips/:id/bookings", async (c) => { try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; const tripId = c.req.param("id"); - ensureDoc(space, tripId); - const docId = tripDocId(space, tripId); + ensureDoc(dataSpace, tripId); + const docId = tripDocId(dataSpace, tripId); const body = await c.req.json(); const bookingId = newId(); @@ -307,9 +314,10 @@ routes.post("/api/trips/:id/expenses", async (c) => { try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; const tripId = c.req.param("id"); - ensureDoc(space, tripId); - const docId = tripDocId(space, tripId); + ensureDoc(dataSpace, tripId); + const docId = tripDocId(dataSpace, tripId); const body = await c.req.json(); const expenseId = newId(); @@ -338,8 +346,9 @@ routes.post("/api/trips/:id/expenses", async (c) => { routes.get("/api/trips/:id/packing", async (c) => { const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; const tripId = c.req.param("id"); - const docId = tripDocId(space, tripId); + const docId = tripDocId(dataSpace, tripId); const doc = _syncServer!.getDoc(docId); if (!doc) return c.json([]); @@ -356,9 +365,10 @@ routes.post("/api/trips/:id/packing", async (c) => { try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; const tripId = c.req.param("id"); - ensureDoc(space, tripId); - const docId = tripDocId(space, tripId); + ensureDoc(dataSpace, tripId); + const docId = tripDocId(dataSpace, tripId); const body = await c.req.json(); const itemId = newId(); @@ -384,10 +394,11 @@ routes.post("/api/trips/:id/packing", async (c) => { routes.patch("/api/packing/:id", async (c) => { const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; const packingId = c.req.param("id"); // Find the trip doc containing this packing item - const docIds = listTripDocIds(space); + const docIds = listTripDocIds(dataSpace); for (const docId of docIds) { const doc = _syncServer!.getDoc(docId); if (!doc || !doc.packingItems[packingId]) continue; @@ -423,6 +434,7 @@ routes.post("/api/route", async (c) => { // ── Route planner page ── routes.get("/routes", (c) => { const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; return c.html(renderShell({ title: `${space} — Route Planner | rTrips`, moduleId: "rtrips", @@ -438,6 +450,7 @@ routes.get("/routes", (c) => { // ── Page route ── routes.get("/", (c) => { const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; return c.html(renderShell({ title: `${space} — Trips | rSpace`, moduleId: "rtrips", diff --git a/modules/rtube/mod.ts b/modules/rtube/mod.ts index 7900cfd..c700869 100644 --- a/modules/rtube/mod.ts +++ b/modules/rtube/mod.ts @@ -192,6 +192,7 @@ routes.get("/api/health", (c) => c.json({ ok: true })); // ── Page route ── routes.get("/", (c) => { const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; return c.html(renderShell({ title: `${space} — Tube | rSpace`, moduleId: "rtube", diff --git a/modules/rwallet/mod.ts b/modules/rwallet/mod.ts index b27bbbd..5a724a5 100644 --- a/modules/rwallet/mod.ts +++ b/modules/rwallet/mod.ts @@ -383,6 +383,7 @@ interface BalanceItem { // ── Page route ── routes.get("/", (c) => { const spaceSlug = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || spaceSlug; return c.html(renderShell({ title: `${spaceSlug} — Wallet | rSpace`, moduleId: "rwallet", diff --git a/modules/rwork/mod.ts b/modules/rwork/mod.ts index 50b4e02..db8b05e 100644 --- a/modules/rwork/mod.ts +++ b/modules/rwork/mod.ts @@ -393,6 +393,7 @@ routes.get("/api/spaces/:slug/activity", async (c) => { // ── Page route ── routes.get("/", (c) => { const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; return c.html(renderShell({ title: `${space} — Work | rSpace`, moduleId: "rwork", diff --git a/server/index.ts b/server/index.ts index 66b442e..4c44345 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1660,6 +1660,7 @@ app.get("/:space/:moduleId/template", async (c) => { // ── Empty-state detection for onboarding ── import type { RSpaceModule } from "../shared/module"; +import { resolveDataSpace } from "../shared/scope-resolver"; function moduleHasData(space: string, mod: RSpaceModule): boolean { if (space === "demo") return true; // demo always has data @@ -1681,13 +1682,18 @@ for (const mod of getAllModules()) { if (!space || space === "api" || space.includes(".")) return next(); // Check enabled modules (skip for core rspace module) + const doc = getDocumentData(space); if (mod.id !== "rspace") { - const doc = getDocumentData(space); if (doc?.meta?.enabledModules && !doc.meta.enabledModules.includes(mod.id)) { return c.json({ error: "Module not enabled for this space" }, 404); } } + // Resolve effective data space (global vs space-scoped) + const overrides = doc?.meta?.moduleScopeOverrides ?? null; + const effectiveSpace = resolveDataSpace(mod.id, space, overrides); + c.set("effectiveSpace" as any, effectiveSpace); + // Resolve caller's role for write-method blocking const method = c.req.method; if (!mod.publicWrite && (method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE")) { diff --git a/server/shell.ts b/server/shell.ts index 73e3a4a..916eb00 100644 --- a/server/shell.ts +++ b/server/shell.ts @@ -87,6 +87,10 @@ export function renderShell(opts: ShellOptions): string { const enabledModules = opts.enabledModules ?? spaceMeta?.enabledModules ?? null; const spaceEncrypted = opts.spaceEncrypted ?? spaceMeta?.spaceEncrypted ?? false; + // Build scope config for client-side runtime + const spaceData = getDocumentData(spaceSlug); + const scopeOverrides = spaceData?.meta?.moduleScopeOverrides ?? {}; + // Filter modules by enabledModules (null = show all) const visibleModules = enabledModules ? modules.filter(m => m.id === "rspace" || enabledModules.includes(m.id)) @@ -119,7 +123,7 @@ export function renderShell(opts: ShellOptions): string { - +
@@ -186,8 +190,9 @@ export function renderShell(opts: ShellOptions): string { // Restore saved theme preference across header / tab-row (function(){try{var t=localStorage.getItem('canvas-theme');if(t)document.querySelectorAll('[data-theme]').forEach(function(el){el.setAttribute('data-theme',t)})}catch(e){}})(); - // Provide module list to app switcher - document.querySelector('rstack-app-switcher')?.setModules(${moduleListJSON}); + // Provide module list to app switcher and offline runtime + window.__rspaceModuleList = ${moduleListJSON}; + document.querySelector('rstack-app-switcher')?.setModules(window.__rspaceModuleList); // ── "Try Demo" button visibility ── // Hidden when logged in. When logged out, shown everywhere except demo.rspace.online @@ -391,10 +396,23 @@ export function renderShell(opts: ShellOptions): string { tabBar.addEventListener('layer-close', (e) => { const { layerId } = e.detail; + const closedLayer = layers.find(l => l.id === layerId); const closedModuleId = layerId.replace('layer-', ''); tabBar.removeLayer(layerId); layers = layers.filter(l => l.id !== layerId); saveTabs(); + + // If this was a space layer with a persisted SpaceRef, clean it up + if (closedLayer?.spaceSlug && closedLayer._spaceRefId) { + const token = document.cookie.match(/encryptid_token=([^;]+)/)?.[1]; + if (token) { + fetch('/api/spaces/' + encodeURIComponent(spaceSlug) + '/nest/' + encodeURIComponent(closedLayer._spaceRefId), { + method: 'DELETE', + headers: { 'Authorization': 'Bearer ' + token }, + }).catch(() => { /* best-effort cleanup */ }); + } + } + // Remove cached pane from DOM if (tabCache) tabCache.removePane(closedModuleId); // If we closed the active tab, switch to the first remaining @@ -415,6 +433,53 @@ export function renderShell(opts: ShellOptions): string { document.dispatchEvent(new CustomEvent('layer-view-mode', { detail: { mode } })); }); + // ── Space-switch: client-side space navigation ── + // When user clicks a space in the space-switcher, handle it client-side + // if tabCache is available. Updates URL, tabs, and shell chrome. + const spaceSwitcher = document.querySelector('rstack-space-switcher'); + if (spaceSwitcher) { + spaceSwitcher.addEventListener('space-switch', (e) => { + const { space: newSpace, moduleId: targetModule, href } = e.detail; + if (!tabCache || !newSpace) return; + + e.preventDefault(); // signal to space-switcher that we're handling it + + const targetModuleId = targetModule || currentModuleId; + + tabCache.switchSpace(newSpace, targetModuleId).then(ok => { + if (!ok) { + // Client-side switch failed — full navigation + window.location.href = href || window.__rspaceNavUrl(newSpace, targetModuleId); + return; + } + + // Update runtime to switch space + const runtime = window.__rspaceOfflineRuntime; + if (runtime && runtime.switchSpace) runtime.switchSpace(newSpace); + + // Update tab state for the new space + const newTabsKey = 'rspace_tabs_' + newSpace; + let newLayers; + try { + const saved = localStorage.getItem(newTabsKey); + newLayers = saved ? JSON.parse(saved) : []; + if (!Array.isArray(newLayers)) newLayers = []; + } catch(e) { newLayers = []; } + + if (!newLayers.find(l => l.moduleId === targetModuleId)) { + newLayers.push(makeLayer(targetModuleId, newLayers.length)); + } + localStorage.setItem(newTabsKey, JSON.stringify(newLayers)); + + // Update layers reference and tab bar + layers = newLayers; + tabBar.setLayers(layers); + tabBar.setAttribute('active', 'layer-' + targetModuleId); + tabBar.setAttribute('space', newSpace); + }); + }); + } + // ── App-switcher → tab system integration ── // When user picks a module from the app-switcher, route through tabs // instead of doing a full page navigation. @@ -445,6 +510,44 @@ export function renderShell(opts: ShellOptions): string { }); } + // ── Space Layer: cross-space data overlay ── + tabBar.addEventListener('space-layer-add', (e) => { + const { layer, spaceSlug: layerSpace, role } = e.detail; + if (!layers.find(l => l.id === layer.id)) { + layers.push(layer); + } + saveTabs(); + + // Persist as a SpaceRef via the nesting API (best-effort) + const token = document.cookie.match(/encryptid_token=([^;]+)/)?.[1]; + if (token) { + fetch('/api/spaces/' + encodeURIComponent(spaceSlug) + '/nest', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token }, + body: JSON.stringify({ + sourceSlug: layerSpace, + label: layer.label || layerSpace, + permissions: { read: true, write: role !== 'viewer' }, + }), + }).then(res => res.json()).then(data => { + if (data.ref) layer._spaceRefId = data.ref.id; + }).catch(() => { /* best-effort */ }); + } + + // Connect offline runtime to the source space for cross-space data + const runtime = window.__rspaceOfflineRuntime; + if (runtime && runtime.connectToSpace) { + runtime.connectToSpace(layerSpace).then(() => { + // Dispatch event for canvas and modules to pick up the new space layer + document.dispatchEvent(new CustomEvent('space-layer-added', { + detail: { spaceSlug: layerSpace, role, layerId: layer.id }, + })); + }).catch(() => { + console.warn('[shell] Failed to connect to space layer:', layerSpace); + }); + } + }); + // Expose tabBar for CommunitySync integration window.__rspaceTabBar = tabBar; diff --git a/shared/components/rstack-app-switcher.ts b/shared/components/rstack-app-switcher.ts index 09da706..270b71f 100644 --- a/shared/components/rstack-app-switcher.ts +++ b/shared/components/rstack-app-switcher.ts @@ -301,11 +301,16 @@ export class RStackAppSwitcher extends HTMLElement { document.addEventListener("click", this.#outsideClickHandler); // Intercept same-origin module links → dispatch event for tab system + // Only intercept when the shell tab system is active (window.__rspaceTabBar). + // On landing pages (rspace.online/, rspace.online/{moduleId}), let links + // navigate normally since there's no module-select listener. this.#shadow.querySelectorAll("a.item").forEach((el) => { el.addEventListener("click", (e) => { const moduleId = (el as HTMLElement).dataset.id; if (!moduleId) return; - // Only intercept same-origin links (skip bare-domain landing pages) + // Skip interception if tab system isn't active (landing pages) + if (!(window as any).__rspaceTabBar) return; + // Only intercept same-origin links const href = (el as HTMLAnchorElement).href; try { const url = new URL(href, window.location.href); diff --git a/shared/components/rstack-space-switcher.ts b/shared/components/rstack-space-switcher.ts index 9b89c08..dc4657d 100644 --- a/shared/components/rstack-space-switcher.ts +++ b/shared/components/rstack-space-switcher.ts @@ -250,6 +250,51 @@ export class RStackSpaceSwitcher extends HTMLElement { }); }); + // Intercept space link clicks — dispatch space-switch event for client-side switching + menu.querySelectorAll("a.item[href]").forEach((link) => { + link.addEventListener("click", (e) => { + const href = link.getAttribute("href") || ""; + // Extract space slug from the link + const linkEl = link as HTMLElement; + const row = linkEl.closest(".item-row"); + // Find the space slug from the href pattern + const match = href.match(/^(?:https?:\/\/([^.]+)\.rspace\.online)?\/([^/?]+)/); + let spaceSlug = ""; + let moduleTarget = moduleId; + if (match) { + if (match[1]) { + spaceSlug = match[1]; // subdomain pattern + } else { + // path pattern: /{space}/{moduleId} or /{moduleId} + spaceSlug = match[2]; + } + } + // Also try data attribute on parent + if (!spaceSlug) return; // let default navigation handle it + + // If same space, ignore + if (spaceSlug === current) { + menu.classList.remove("open"); + return; + } + + // Dispatch space-switch event for client-side handling + const event = new CustomEvent("space-switch", { + bubbles: true, + composed: true, + detail: { space: spaceSlug, moduleId: moduleTarget, href }, + }); + const handled = this.dispatchEvent(event); + + // If a listener called preventDefault(), we handled it client-side + if (event.defaultPrevented) { + e.preventDefault(); + menu.classList.remove("open"); + } + // Otherwise, let the default navigation happen + }); + }); + // Attach Edit Space gear button listeners menu.querySelectorAll(".item-gear").forEach((btn) => { btn.addEventListener("click", (e) => { diff --git a/shared/components/rstack-tab-bar.ts b/shared/components/rstack-tab-bar.ts index 43b7ff8..9ba1259 100644 --- a/shared/components/rstack-tab-bar.ts +++ b/shared/components/rstack-tab-bar.ts @@ -234,6 +234,114 @@ export class RStackTabBar extends HTMLElement { } } + // ── Space Layer Picker ── + + /** Show a popup to pick a space to add as a composable layer. */ + async #showSpaceLayerPicker() { + // Remove any existing picker + this.#shadow.querySelector(".space-layer-picker")?.remove(); + + // Fetch spaces from the API + let spaces: Array<{ slug: string; name: string; icon?: string; role?: string }> = []; + try { + const token = localStorage.getItem("encryptid_session"); + const headers: Record = {}; + if (token) { + try { + const session = JSON.parse(token); + if (session?.accessToken) headers["Authorization"] = `Bearer ${session.accessToken}`; + } catch { /* ignore */ } + } + const res = await fetch("/api/spaces", { headers }); + if (res.ok) { + const data = await res.json(); + spaces = (data.spaces || []).filter( + (s: any) => s.slug !== this.space && s.role // only spaces user has access to + ); + } + } catch { /* offline */ } + + // Filter out spaces already added as layers + const existingSpaceSlugs = new Set( + this.#layers.filter(l => l.spaceSlug).map(l => l.spaceSlug) + ); + spaces = spaces.filter(s => !existingSpaceSlugs.has(s.slug)); + + // Build picker HTML + let pickerHtml = `
+
+ Add Space Layer + +
`; + + if (spaces.length === 0) { + pickerHtml += `
No other spaces available
`; + } else { + for (const s of spaces) { + const roleLabel = s.role === "viewer" ? "view only" : s.role || ""; + pickerHtml += ` + `; + } + } + + pickerHtml += `
`; + + // Inject into shadow DOM + const container = document.createElement("div"); + container.innerHTML = pickerHtml; + const picker = container.firstElementChild!; + this.#shadow.appendChild(picker); + + // Close button + this.#shadow.getElementById("slp-close")?.addEventListener("click", () => { + picker.remove(); + }); + + // Space item clicks + picker.querySelectorAll(".space-layer-picker__item").forEach(item => { + item.addEventListener("click", () => { + const slug = item.dataset.slug!; + const name = item.dataset.name || slug; + const role = (item.dataset.role || "viewer") as Layer["spaceRole"]; + + const layer: Layer = { + id: `layer-space-${slug}`, + moduleId: "rspace", + label: `${name}`, + order: this.#layers.length, + color: "", + visible: true, + createdAt: Date.now(), + spaceSlug: slug, + spaceRole: role, + }; + + this.addLayer(layer); + this.dispatchEvent(new CustomEvent("space-layer-add", { + detail: { layer, spaceSlug: slug, role }, + bubbles: true, + })); + + picker.remove(); + }); + }); + + // Close on outside click + setTimeout(() => { + const handler = (e: Event) => { + if (!picker.contains(e.target as Node)) { + picker.remove(); + document.removeEventListener("click", handler); + } + }; + document.addEventListener("click", handler); + }, 0); + } + // ── Feed compatibility helpers ── /** Get the set of FlowKinds a module can output (from its feeds) */ @@ -318,14 +426,25 @@ export class RStackTabBar extends HTMLElement { const isActive = layer.id === activeId; const badgeColor = layer.color || badge?.color || "#94a3b8"; + const isSpaceLayer = !!layer.spaceSlug; + const spaceLayerClass = isSpaceLayer ? " tab--space-layer" : ""; + const spaceTag = isSpaceLayer + ? `${layer.spaceSlug}` + : ""; + const readOnlyTag = isSpaceLayer && layer.spaceRole === "viewer" + ? `👁` + : ""; + return ` -
${badge?.badge || layer.moduleId.slice(0, 2)} ${layer.label} + ${spaceTag}${readOnlyTag} ${this.#layers.length > 1 ? `` : ""}
`; @@ -385,6 +504,16 @@ export class RStackTabBar extends HTMLElement { html += uncategorized.map(m => this.#renderAddMenuItem(m, existingModuleIds.has(m.id))).join(""); } + // ── Add Space Layer section ── + html += `
`; + html += ``; + return `
${html}
`; } @@ -898,6 +1027,16 @@ export class RStackTabBar extends HTMLElement { item.addEventListener("touchend", handleSelect); }); + // Add Space Layer button handler + const spaceLayerBtn = this.#shadow.getElementById("add-space-layer-btn"); + if (spaceLayerBtn) { + spaceLayerBtn.addEventListener("click", (e) => { + e.stopPropagation(); + this.#addMenuOpen = false; + this.#showSpaceLayerPicker(); + }); + } + // Close add menu on outside click/touch if (this.#addMenuOpen) { const handler = () => { @@ -1310,6 +1449,33 @@ const STYLES = ` .tab:hover .tab-close { opacity: 0.6; } .tab-close:hover { opacity: 1 !important; background: rgba(239,68,68,0.2); color: #ef4444; } +/* Space layer tab styling */ +.tab--space-layer { + border: 1px dashed rgba(99,102,241,0.4); + background: rgba(99,102,241,0.05); +} +.tab--space-layer.active { + border-color: rgba(99,102,241,0.6); + background: rgba(99,102,241,0.1); +} +.tab-space-tag { + font-size: 0.6rem; + padding: 1px 4px; + border-radius: 3px; + background: rgba(99,102,241,0.15); + color: #a5b4fc; + max-width: 50px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex-shrink: 0; +} +.tab-readonly-tag { + font-size: 0.65rem; + flex-shrink: 0; + opacity: 0.7; +} + /* ── Drag states ── */ .tab.dragging { opacity: 0.4; } @@ -1469,6 +1635,98 @@ const STYLES = ` opacity: 0.5; } +.add-menu-divider { + height: 1px; + margin: 4px 8px; + background: var(--rs-border-subtle); +} + +.add-menu-item--space-layer { + border-top: none; +} +.add-menu-item--space-layer .add-menu-icon { + font-size: 1rem; +} + +/* ── Space Layer Picker ── */ + +.space-layer-picker { + position: absolute; + top: 100%; + right: 0; + min-width: 240px; + max-height: 320px; + overflow-y: auto; + background: var(--rs-bg-surface); + border: 1px solid var(--rs-border); + border-radius: 12px; + box-shadow: var(--rs-shadow-lg); + z-index: 10002; + margin-top: 4px; +} +.space-layer-picker__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 14px 6px; + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--rs-text-secondary); +} +.space-layer-picker__close { + background: none; + border: none; + color: var(--rs-text-muted); + font-size: 1.1rem; + cursor: pointer; + padding: 2px 6px; + border-radius: 4px; + line-height: 1; +} +.space-layer-picker__close:hover { + color: var(--rs-text-primary); + background: var(--rs-bg-hover); +} +.space-layer-picker__item { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 8px 14px; + border: none; + background: none; + cursor: pointer; + transition: background 0.12s; + text-align: left; + font-family: inherit; + color: var(--rs-text-primary); +} +.space-layer-picker__item:hover { + background: var(--rs-bg-hover); +} +.space-layer-picker__icon { + font-size: 1.1rem; + flex-shrink: 0; +} +.space-layer-picker__name { + font-size: 0.85rem; + font-weight: 500; + flex: 1; +} +.space-layer-picker__role { + font-size: 0.7rem; + color: var(--rs-text-muted); + flex-shrink: 0; +} +.space-layer-picker__empty { + padding: 16px; + text-align: center; + font-size: 0.8rem; + color: var(--rs-text-muted); +} + /* ── View toggle ── */ .view-toggle { diff --git a/shared/local-first/runtime.ts b/shared/local-first/runtime.ts index 67da95b..fdf34e0 100644 --- a/shared/local-first/runtime.ts +++ b/shared/local-first/runtime.ts @@ -46,13 +46,17 @@ export class RSpaceOfflineRuntime { #store: EncryptedDocStore; #sync: DocSyncManager; #crypto: DocCrypto; - #space: string; + #activeSpace: string; #status: RuntimeStatus = 'idle'; #statusListeners = new Set(); #initialized = false; + /** Module scope config: moduleId → 'global' | 'space'. Set from page's module list. */ + #moduleScopes = new Map(); + /** Lazy WebSocket connections per space slug (for cross-space subscriptions). */ + #spaceConnections = new Map(); constructor(space: string) { - this.#space = space; + this.#activeSpace = space; this.#crypto = new DocCrypto(); this.#documents = new DocumentManager(); this.#store = new EncryptedDocStore(space); @@ -64,7 +68,7 @@ export class RSpaceOfflineRuntime { // ── Getters ── - get space(): string { return this.#space; } + get space(): string { return this.#activeSpace; } get isInitialized(): boolean { return this.#initialized; } get isOnline(): boolean { return this.#sync.isConnected; } get status(): RuntimeStatus { return this.#status; } @@ -85,13 +89,13 @@ export class RSpaceOfflineRuntime { // 2. Connect WebSocket (non-blocking — works offline) const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; - const wsUrl = `${proto}//${location.host}/ws/${this.#space}`; + const wsUrl = `${proto}//${location.host}/ws/${this.#activeSpace}`; this.#sync.onConnect(() => this.#setStatus('online')); this.#sync.onDisconnect(() => this.#setStatus('offline')); try { - await this.#sync.connect(wsUrl, this.#space); + await this.#sync.connect(wsUrl, this.#activeSpace); this.#setStatus('online'); } catch { // WebSocket failed — still usable offline @@ -175,10 +179,32 @@ export class RSpaceOfflineRuntime { } /** - * Build a DocumentId for a module using the current space. + * Configure module scope information from the page's module list. + * Call once after init with the modules config for this space. + */ + setModuleScopes(scopes: Array<{ id: string; scope: 'global' | 'space' }>): void { + this.#moduleScopes.clear(); + for (const { id, scope } of scopes) { + this.#moduleScopes.set(id, scope); + } + } + + /** + * Resolve the data-space prefix for a module. + * Returns 'global' for global-scoped modules, the current space otherwise. + */ + resolveDocSpace(moduleId: string): string { + const scope = this.#moduleScopes.get(moduleId); + return scope === 'global' ? 'global' : this.#activeSpace; + } + + /** + * Build a DocumentId for a module, respecting scope resolution. + * Global-scoped modules get `global:module:collection` prefix. */ makeDocId(module: string, collection: string, itemId?: string): DocumentId { - return makeDocumentId(this.#space, module, collection, itemId); + const dataSpace = this.resolveDocSpace(module); + return makeDocumentId(dataSpace, module, collection, itemId); } /** @@ -200,7 +226,8 @@ export class RSpaceOfflineRuntime { ): Promise>> { this.#documents.registerSchema(schema); - const prefix = `${this.#space}:${module}:${collection}`; + const dataSpace = this.resolveDocSpace(module); + const prefix = `${dataSpace}:${module}:${collection}`; const docIds = await this.#sync.requestDocList(prefix); const results = new Map>(); @@ -231,10 +258,133 @@ export class RSpaceOfflineRuntime { } /** - * Tear down: flush, disconnect, clear listeners. + * Switch the active space context without destroying existing connections. + * Previous space's WebSocket stays alive for cross-space subscriptions. + */ + switchSpace(newSpace: string): void { + if (newSpace === this.#activeSpace) return; + + // Store the current connection for cross-space access + if (!this.#spaceConnections.has(this.#activeSpace)) { + this.#spaceConnections.set(this.#activeSpace, this.#sync); + } + + this.#activeSpace = newSpace; + + // Re-use existing connection if we've visited this space before + const existing = this.#spaceConnections.get(newSpace); + if (existing) { + this.#sync = existing; + return; + } + + // Create a new sync manager for the new space + const newSync = new DocSyncManager({ + documents: this.#documents, + store: this.#store, + }); + this.#sync = newSync; + this.#spaceConnections.set(newSpace, newSync); + + // Connect lazily — will connect when first doc is subscribed + const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; + const wsUrl = `${proto}//${location.host}/ws/${newSpace}`; + newSync.onConnect(() => this.#setStatus('online')); + newSync.onDisconnect(() => this.#setStatus('offline')); + newSync.connect(wsUrl, newSpace).catch(() => { + this.#setStatus('offline'); + }); + } + + /** + * Get all spaces that have active connections (current + cross-space layers). + * Modules use this to aggregate data from all active space layers. + * Returns array of { space, sync } pairs for iteration. + */ + getAllActiveSpaces(): Array<{ space: string; sync: DocSyncManager }> { + const result: Array<{ space: string; sync: DocSyncManager }> = [ + { space: this.#activeSpace, sync: this.#sync }, + ]; + for (const [space, sync] of this.#spaceConnections) { + if (space !== this.#activeSpace) { + result.push({ space, sync }); + } + } + return result; + } + + /** + * Subscribe to a module's docs across all active spaces. + * Returns merged results from the current space + all space layers. + */ + async subscribeModuleAcrossSpaces>( + module: string, + collection: string, + schema: DocSchema, + ): Promise; space: string }>> { + this.#documents.registerSchema(schema); + const results = new Map; space: string }>(); + + for (const { space, sync } of this.getAllActiveSpaces()) { + const dataSpace = this.#moduleScopes.get(module) === 'global' ? 'global' : space; + const prefix = `${dataSpace}:${module}:${collection}`; + try { + const docIds = await sync.requestDocList(prefix); + for (const id of docIds) { + const docId = id as DocumentId; + if (results.has(docId)) continue; // global docs already seen + const doc = await this.subscribe(docId, schema); + results.set(docId, { doc, space }); + } + } catch { + // Cross-space fetch failed — skip silently + } + } + return results; + } + + /** + * Get a sync manager for a specific space (lazy connect). + * Used for cross-space subscriptions (space layers). + */ + async connectToSpace(spaceSlug: string): Promise { + // Already connected? + const existing = this.#spaceConnections.get(spaceSlug); + if (existing) return existing; + + // Current space + if (spaceSlug === this.#activeSpace) return this.#sync; + + // Create new connection + const sync = new DocSyncManager({ + documents: this.#documents, + store: this.#store, + }); + this.#spaceConnections.set(spaceSlug, sync); + + const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; + const wsUrl = `${proto}//${location.host}/ws/${spaceSlug}`; + sync.onConnect(() => { /* cross-space connected */ }); + sync.onDisconnect(() => { /* cross-space disconnected */ }); + + try { + await sync.connect(wsUrl, spaceSlug); + } catch { + // Cross-space connection failed — operate offline for this space + } + + return sync; + } + + /** + * Tear down: flush, disconnect all connections, clear listeners. */ destroy(): void { this.#sync.disconnect(); + for (const [, sync] of this.#spaceConnections) { + sync.disconnect(); + } + this.#spaceConnections.clear(); this.#statusListeners.clear(); this.#initialized = false; this.#setStatus('idle'); diff --git a/shared/scope-resolver.ts b/shared/scope-resolver.ts new file mode 100644 index 0000000..baa6ea7 --- /dev/null +++ b/shared/scope-resolver.ts @@ -0,0 +1,28 @@ +/** + * Scope resolver — determines whether a module's data lives per-space or globally. + * + * When a module declares `defaultScope: 'global'`, its Automerge documents + * use the `global:` prefix instead of `{space}:`, so data is shared across + * all spaces. Space owners can override this per-module via `moduleScopeOverrides`. + */ + +import { getModule } from './module'; +import type { ModuleScope } from './module'; + +/** + * Resolve the effective data-space prefix for a module. + * + * Returns 'global' if the module should use global-scoped data, + * otherwise returns the current space slug for space-scoped data. + */ +export function resolveDataSpace( + moduleId: string, + spaceSlug: string, + scopeOverrides?: Record | null, +): string { + const mod = getModule(moduleId); + if (!mod) return spaceSlug; + + const effectiveScope = scopeOverrides?.[moduleId] ?? mod.scoping.defaultScope; + return effectiveScope === 'global' ? 'global' : spaceSlug; +} diff --git a/shared/tab-cache.ts b/shared/tab-cache.ts index 06d34cb..4a856dc 100644 --- a/shared/tab-cache.ts +++ b/shared/tab-cache.ts @@ -1,10 +1,14 @@ /** - * Client-side tab cache for instant tab switching. + * Client-side tab cache for instant tab and space switching. * * Instead of full page navigations, previously loaded tabs are kept in the DOM * and shown/hidden via CSS. New tabs are fetched via fetch() + DOMParser on * first visit, then stay in the DOM forever. * + * Pane keys are `${spaceSlug}:${moduleId}` so panes from different spaces + * coexist in the DOM. On space switch, current space's panes are hidden and + * the target space's panes are shown (or fetched on first visit). + * * Hiding preserves ALL component state: Shadow DOM, event listeners, * WebSocket connections, timers. */ @@ -19,22 +23,40 @@ export class TabCache { this.currentModuleId = moduleId; } + /** Current space slug */ + get currentSpace(): string { + return this.spaceSlug; + } + + /** Current module ID */ + get currentModule(): string { + return this.currentModuleId; + } + + /** Build the composite pane key */ + private paneKey(space: string, moduleId: string): string { + return `${space}:${moduleId}`; + } + /** Wrap the initial #app content in a pane div and set up popstate */ init(): boolean { const app = document.getElementById("app"); if (!app) return false; + const key = this.paneKey(this.spaceSlug, this.currentModuleId); + // Wrap existing content in a pane const pane = document.createElement("div"); pane.className = "rspace-tab-pane rspace-tab-pane--active"; pane.dataset.moduleId = this.currentModuleId; + pane.dataset.spaceSlug = this.spaceSlug; pane.dataset.pageTitle = document.title; while (app.firstChild) { pane.appendChild(app.firstChild); } app.appendChild(pane); - this.panes.set(this.currentModuleId, pane); + this.panes.set(key, pane); // Push initial state into history history.replaceState( @@ -46,8 +68,18 @@ export class TabCache { // Handle browser back/forward window.addEventListener("popstate", (e) => { const state = e.state; - if (state?.moduleId && this.panes.has(state.moduleId)) { - this.showPane(state.moduleId); + if (!state?.moduleId) { + window.location.reload(); + return; + } + const stateSpace = state.spaceSlug || this.spaceSlug; + const key = this.paneKey(stateSpace, state.moduleId); + if (this.panes.has(key)) { + if (stateSpace !== this.spaceSlug) { + this.hideAllPanes(); + this.spaceSlug = stateSpace; + } + this.showPane(stateSpace, state.moduleId); } else { window.location.reload(); } @@ -56,43 +88,84 @@ export class TabCache { return true; } - /** Switch to a module tab. Returns true if handled client-side. */ + /** Switch to a module tab within the current space. Returns true if handled client-side. */ async switchTo(moduleId: string): Promise { - if (moduleId === this.currentModuleId) return true; + const key = this.paneKey(this.spaceSlug, moduleId); + if (moduleId === this.currentModuleId && this.panes.has(key)) return true; - if (this.panes.has(moduleId)) { - this.showPane(moduleId); - this.updateUrl(moduleId); + if (this.panes.has(key)) { + this.showPane(this.spaceSlug, moduleId); + this.updateUrl(this.spaceSlug, moduleId); return true; } - return this.fetchAndInject(moduleId); + return this.fetchAndInject(this.spaceSlug, moduleId); + } + + /** + * Switch to a different space, loading a module within it. + * Hides all panes from the current space, shows/fetches the target space's pane. + * Returns true if handled client-side. + */ + async switchSpace(newSpace: string, moduleId: string): Promise { + if (newSpace === this.spaceSlug && moduleId === this.currentModuleId) return true; + + const key = this.paneKey(newSpace, moduleId); + + // Hide all current panes + this.hideAllPanes(); + + // Update active space + const oldSpace = this.spaceSlug; + this.spaceSlug = newSpace; + + if (this.panes.has(key)) { + this.showPane(newSpace, moduleId); + this.updateUrl(newSpace, moduleId); + this.updateSpaceChrome(newSpace); + return true; + } + + const ok = await this.fetchAndInject(newSpace, moduleId); + if (ok) { + this.updateSpaceChrome(newSpace); + return true; + } + + // Rollback on failure + this.spaceSlug = oldSpace; + return false; } /** Check if a pane exists in the DOM cache */ has(moduleId: string): boolean { - return this.panes.has(moduleId); + return this.panes.has(this.paneKey(this.spaceSlug, moduleId)); } /** Remove a cached pane from the DOM */ removePane(moduleId: string): void { - const pane = this.panes.get(moduleId); + const key = this.paneKey(this.spaceSlug, moduleId); + const pane = this.panes.get(key); if (pane) { pane.remove(); - this.panes.delete(moduleId); + this.panes.delete(key); } } /** Fetch a module page, extract content, and inject into a new pane */ - private async fetchAndInject(moduleId: string): Promise { + private async fetchAndInject(space: string, moduleId: string): Promise { const navUrl = (window as any).__rspaceNavUrl; if (!navUrl) return false; - const url: string = navUrl(this.spaceSlug, moduleId); + const url: string = navUrl(space, moduleId); - // Cross-origin URLs → fall back to full navigation + // For cross-space fetches, use the path-based API to stay same-origin + let fetchUrl = url; try { const resolved = new URL(url, window.location.href); - if (resolved.origin !== window.location.origin) return false; + if (resolved.origin !== window.location.origin) { + // Rewrite subdomain URL to path format: /{space}/{moduleId} + fetchUrl = `/${space}/${moduleId}`; + } } catch { return false; } @@ -109,7 +182,7 @@ export class TabCache { app.appendChild(loadingPane); try { - const resp = await fetch(url); + const resp = await fetch(fetchUrl); if (!resp.ok) { loadingPane.remove(); return false; @@ -125,23 +198,26 @@ export class TabCache { // Remove loading spinner loadingPane.remove(); + const key = this.paneKey(space, moduleId); + // Create the real pane const pane = document.createElement("div"); pane.className = "rspace-tab-pane rspace-tab-pane--active"; pane.dataset.moduleId = moduleId; + pane.dataset.spaceSlug = space; pane.dataset.pageTitle = content.title; pane.innerHTML = content.body; app.appendChild(pane); - this.panes.set(moduleId, pane); + this.panes.set(key, pane); // Load module-specific assets - this.loadAssets(content.scripts, content.styles, content.inlineStyles, moduleId); + this.loadAssets(content.scripts, content.styles, content.inlineStyles, `${space}-${moduleId}`); // Update shell state this.currentModuleId = moduleId; document.title = content.title; this.updateShellState(moduleId); - this.updateUrl(moduleId); + this.updateUrl(space, moduleId); this.updateCanvasLayout(moduleId); return true; @@ -240,9 +316,10 @@ export class TabCache { } /** Show a specific pane and update shell state */ - private showPane(moduleId: string): void { + private showPane(space: string, moduleId: string): void { this.hideAllPanes(); - const pane = this.panes.get(moduleId); + const key = this.paneKey(space, moduleId); + const pane = this.panes.get(key); if (pane) { pane.classList.add("rspace-tab-pane--active"); const storedTitle = pane.dataset.pageTitle; @@ -282,13 +359,26 @@ export class TabCache { } } + /** Update space-specific shell chrome (space switcher, body attrs) */ + private updateSpaceChrome(newSpace: string): void { + // Update space-switcher attribute + const spaceSwitcher = document.querySelector("rstack-space-switcher"); + if (spaceSwitcher) { + spaceSwitcher.setAttribute("current", newSpace); + spaceSwitcher.setAttribute("name", newSpace); + } + + // Update body data attribute + document.body?.setAttribute("data-space-slug", newSpace); + } + /** Push new URL to browser history */ - private updateUrl(moduleId: string): void { + private updateUrl(space: string, moduleId: string): void { const navUrl = (window as any).__rspaceNavUrl; if (!navUrl) return; - const url: string = navUrl(this.spaceSlug, moduleId); + const url: string = navUrl(space, moduleId); history.pushState( - { moduleId, spaceSlug: this.spaceSlug }, + { moduleId, spaceSlug: space }, "", url, ); diff --git a/website/canvas.html b/website/canvas.html index a8a6cf9..0e9090d 100644 --- a/website/canvas.html +++ b/website/canvas.html @@ -1339,6 +1339,25 @@ display: none !important; } + /* Cross-space shape styling — colored border + source badge */ + .rspace-cross-space-shape { + outline: 2px dashed rgba(99, 102, 241, 0.5) !important; + outline-offset: 2px; + } + .rspace-cross-space-shape::after { + content: attr(data-source-space); + position: absolute; + top: -18px; + right: 0; + font-size: 0.55rem; + padding: 1px 5px; + border-radius: 3px; + background: rgba(99, 102, 241, 0.8); + color: white; + pointer-events: none; + white-space: nowrap; + } + #select-rect { position: fixed; border: 1.5px solid #3b82f6; @@ -2438,6 +2457,49 @@ }); } + // ── Space Layer: overlay shapes from other spaces ── + document.addEventListener('space-layer-added', (e) => { + const { spaceSlug: layerSpace, role, layerId } = e.detail; + if (!sync || !layerSpace) return; + + // Fetch shapes from the other space's canvas + fetch(`/${layerSpace}/rspace/api/shapes`) + .then(res => res.ok ? res.json() : []) + .then(shapes => { + if (!Array.isArray(shapes)) return; + const canvasEl = document.getElementById('canvas-content'); + if (!canvasEl) return; + + for (const data of shapes) { + const el = newShapeElement(data); + if (!el) continue; + + // Mark as cross-space shape + el.dataset.sourceSpace = layerSpace; + el.dataset.sourceLayerId = layerId; + el.classList.add('rspace-cross-space-shape'); + + // View-only: make non-interactive + if (role === 'viewer') { + el.style.pointerEvents = 'none'; + el.style.opacity = '0.8'; + } + + canvasEl.appendChild(el); + } + }) + .catch(() => { + console.warn('[Canvas] Failed to load shapes from space layer:', layerSpace); + }); + }); + + // Clean up shapes when a space layer is removed + document.addEventListener('layer-close', (e) => { + const layerId = e.detail?.layerId; + if (!layerId) return; + document.querySelectorAll(`[data-source-layer-id="${layerId}"]`).forEach(el => el.remove()); + }); + // ── "Try Demo" button visibility ── // Hide on demo space, show on bare domain (function() { diff --git a/website/shell.ts b/website/shell.ts index a122744..57d4943 100644 --- a/website/shell.ts +++ b/website/shell.ts @@ -42,6 +42,21 @@ const spaceSlug = document.body?.getAttribute("data-space-slug"); if (spaceSlug && spaceSlug !== "demo") { const runtime = new RSpaceOfflineRuntime(spaceSlug); (window as any).__rspaceOfflineRuntime = runtime; + + // Configure module scope resolution from server-rendered data + try { + const scopeJson = document.body?.getAttribute("data-scope-overrides"); + const overrides: Record = scopeJson ? JSON.parse(scopeJson) : {}; + // Build scope config: merge module defaults with space overrides + const moduleList: Array<{ id: string; scoping?: { defaultScope: string } }> = + (window as any).__rspaceModuleList || []; + const scopes: Array<{ id: string; scope: 'global' | 'space' }> = moduleList.map(m => ({ + id: m.id, + scope: (overrides[m.id] || m.scoping?.defaultScope || 'space') as 'global' | 'space', + })); + runtime.setModuleScopes(scopes); + } catch { /* scope config unavailable — defaults to space-scoped */ } + runtime.init().catch((e: unknown) => { console.warn("[shell] Offline runtime init failed — REST fallback only:", e); });