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); });