feat: scope system, cross-space navigation, and spaces-as-layers
Phase 1 — Fix scope system: new scope-resolver.ts resolves global vs space-scoped docId prefixes. Server middleware sets effectiveSpace on Hono context. All 18 modules updated to use dataSpace for Automerge doc access while keeping space for display. Client runtime gets setModuleScopes() and resolveDocSpace() for local-first scope resolution. Phase 2 — Seamless cross-space navigation: TabCache now tracks panes per space:module key. OfflineRuntime maintains lazy WebSocket connections per space. Space-switcher dispatches space-switch event handled client-side with history.pushState instead of full reload. Phase 3 — Spaces as layers: Layer type extended with spaceSlug and spaceRole. Tab bar gains "Add Space Layer" picker. Canvas renders cross-space shapes with visual indicators. Space layers persisted as SpaceRefs via nesting API. Runtime provides getAllActiveSpaces() and subscribeModuleAcrossSpaces() for module-level data aggregation. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
bfb3a588ed
commit
20c26cd3d7
|
|
@ -79,6 +79,10 @@ export interface Layer {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
/** Created timestamp */
|
/** Created timestamp */
|
||||||
createdAt: number;
|
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 ──
|
// ── Inter-layer flow ──
|
||||||
|
|
|
||||||
|
|
@ -104,12 +104,13 @@ const routes = new Hono();
|
||||||
// ── API: List books ──
|
// ── API: List books ──
|
||||||
routes.get("/api/books", async (c) => {
|
routes.get("/api/books", async (c) => {
|
||||||
const space = c.req.param("space") || "global";
|
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 search = c.req.query("search")?.toLowerCase();
|
||||||
const tag = c.req.query("tag");
|
const tag = c.req.query("tag");
|
||||||
const limit = Math.min(parseInt(c.req.query("limit") || "50"), 100);
|
const limit = Math.min(parseInt(c.req.query("limit") || "50"), 100);
|
||||||
const offset = parseInt(c.req.query("offset") || "0");
|
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");
|
let books = Object.values(doc.items).filter((b) => b.status === "published");
|
||||||
|
|
||||||
if (search) {
|
if (search) {
|
||||||
|
|
@ -156,6 +157,7 @@ routes.get("/api/books", async (c) => {
|
||||||
// ── API: Upload book ──
|
// ── API: Upload book ──
|
||||||
routes.post("/api/books", async (c) => {
|
routes.post("/api/books", async (c) => {
|
||||||
const space = c.req.param("space") || "global";
|
const space = c.req.param("space") || "global";
|
||||||
|
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
|
||||||
const token = extractToken(c.req.raw.headers);
|
const token = extractToken(c.req.raw.headers);
|
||||||
if (!token) return c.json({ error: "Authentication required" }, 401);
|
if (!token) return c.json({ error: "Authentication required" }, 401);
|
||||||
|
|
||||||
|
|
@ -187,7 +189,7 @@ routes.post("/api/books", async (c) => {
|
||||||
let slug = slugify(title);
|
let slug = slugify(title);
|
||||||
|
|
||||||
// Check slug collision
|
// Check slug collision
|
||||||
const doc = ensureDoc(space);
|
const doc = ensureDoc(dataSpace);
|
||||||
const slugExists = Object.values(doc.items).some((b) => b.slug === slug);
|
const slugExists = Object.values(doc.items).some((b) => b.slug === slug);
|
||||||
if (slugExists) {
|
if (slugExists) {
|
||||||
slug = `${slug}-${shortId}`;
|
slug = `${slug}-${shortId}`;
|
||||||
|
|
@ -203,7 +205,7 @@ routes.post("/api/books", async (c) => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
// Insert into Automerge doc
|
// Insert into Automerge doc
|
||||||
const docId = booksCatalogDocId(space);
|
const docId = booksCatalogDocId(dataSpace);
|
||||||
_syncServer!.changeDoc<BooksCatalogDoc>(docId, `add book: ${slug}`, (d) => {
|
_syncServer!.changeDoc<BooksCatalogDoc>(docId, `add book: ${slug}`, (d) => {
|
||||||
d.items[id] = {
|
d.items[id] = {
|
||||||
id,
|
id,
|
||||||
|
|
@ -242,9 +244,10 @@ routes.post("/api/books", async (c) => {
|
||||||
// ── API: Get book details ──
|
// ── API: Get book details ──
|
||||||
routes.get("/api/books/:id", async (c) => {
|
routes.get("/api/books/:id", async (c) => {
|
||||||
const space = c.req.param("space") || "global";
|
const space = c.req.param("space") || "global";
|
||||||
|
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
|
||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
|
|
||||||
const doc = ensureDoc(space);
|
const doc = ensureDoc(dataSpace);
|
||||||
const book = findBook(doc, id);
|
const book = findBook(doc, id);
|
||||||
|
|
||||||
if (!book || book.status !== "published") {
|
if (!book || book.status !== "published") {
|
||||||
|
|
@ -252,7 +255,7 @@ routes.get("/api/books/:id", async (c) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Increment view count
|
// Increment view count
|
||||||
const docId = booksCatalogDocId(space);
|
const docId = booksCatalogDocId(dataSpace);
|
||||||
_syncServer!.changeDoc<BooksCatalogDoc>(docId, `view: ${book.slug}`, (d) => {
|
_syncServer!.changeDoc<BooksCatalogDoc>(docId, `view: ${book.slug}`, (d) => {
|
||||||
if (d.items[book.id]) {
|
if (d.items[book.id]) {
|
||||||
d.items[book.id].viewCount += 1;
|
d.items[book.id].viewCount += 1;
|
||||||
|
|
@ -266,9 +269,10 @@ routes.get("/api/books/:id", async (c) => {
|
||||||
// ── API: Serve PDF ──
|
// ── API: Serve PDF ──
|
||||||
routes.get("/api/books/:id/pdf", async (c) => {
|
routes.get("/api/books/:id/pdf", async (c) => {
|
||||||
const space = c.req.param("space") || "global";
|
const space = c.req.param("space") || "global";
|
||||||
|
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
|
||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
|
|
||||||
const doc = ensureDoc(space);
|
const doc = ensureDoc(dataSpace);
|
||||||
const book = findBook(doc, id);
|
const book = findBook(doc, id);
|
||||||
|
|
||||||
if (!book || book.status !== "published") {
|
if (!book || book.status !== "published") {
|
||||||
|
|
@ -283,7 +287,7 @@ routes.get("/api/books/:id/pdf", async (c) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Increment download count
|
// Increment download count
|
||||||
const docId = booksCatalogDocId(space);
|
const docId = booksCatalogDocId(dataSpace);
|
||||||
_syncServer!.changeDoc<BooksCatalogDoc>(docId, `download: ${book.slug}`, (d) => {
|
_syncServer!.changeDoc<BooksCatalogDoc>(docId, `download: ${book.slug}`, (d) => {
|
||||||
if (d.items[book.id]) {
|
if (d.items[book.id]) {
|
||||||
d.items[book.id].downloadCount += 1;
|
d.items[book.id].downloadCount += 1;
|
||||||
|
|
@ -303,6 +307,7 @@ routes.get("/api/books/:id/pdf", async (c) => {
|
||||||
// ── Page: Library ──
|
// ── Page: Library ──
|
||||||
routes.get("/", (c) => {
|
routes.get("/", (c) => {
|
||||||
const spaceSlug = c.req.param("space") || "personal";
|
const spaceSlug = c.req.param("space") || "personal";
|
||||||
|
const dataSpace = (c.get("effectiveSpace" as any) as string) || spaceSlug;
|
||||||
return c.html(renderShell({
|
return c.html(renderShell({
|
||||||
title: `${spaceSlug} — Library | rSpace`,
|
title: `${spaceSlug} — Library | rSpace`,
|
||||||
moduleId: "rbooks",
|
moduleId: "rbooks",
|
||||||
|
|
@ -318,9 +323,10 @@ routes.get("/", (c) => {
|
||||||
// ── Page: Book reader ──
|
// ── Page: Book reader ──
|
||||||
routes.get("/read/:id", async (c) => {
|
routes.get("/read/:id", async (c) => {
|
||||||
const spaceSlug = c.req.param("space") || "personal";
|
const spaceSlug = c.req.param("space") || "personal";
|
||||||
|
const dataSpace = (c.get("effectiveSpace" as any) as string) || spaceSlug;
|
||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
|
|
||||||
const doc = ensureDoc(spaceSlug);
|
const doc = ensureDoc(dataSpace);
|
||||||
const book = findBook(doc, id);
|
const book = findBook(doc, id);
|
||||||
|
|
||||||
if (!book || book.status !== "published") {
|
if (!book || book.status !== "published") {
|
||||||
|
|
@ -335,7 +341,7 @@ routes.get("/read/:id", async (c) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Increment view count
|
// Increment view count
|
||||||
const docId = booksCatalogDocId(spaceSlug);
|
const docId = booksCatalogDocId(dataSpace);
|
||||||
_syncServer!.changeDoc<BooksCatalogDoc>(docId, `view: ${book.slug}`, (d) => {
|
_syncServer!.changeDoc<BooksCatalogDoc>(docId, `view: ${book.slug}`, (d) => {
|
||||||
if (d.items[book.id]) {
|
if (d.items[book.id]) {
|
||||||
d.items[book.id].viewCount += 1;
|
d.items[book.id].viewCount += 1;
|
||||||
|
|
|
||||||
|
|
@ -259,9 +259,10 @@ function seedDemoIfEmpty(space: string) {
|
||||||
// GET /api/events — query events with filters
|
// GET /api/events — query events with filters
|
||||||
routes.get("/api/events", async (c) => {
|
routes.get("/api/events", async (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
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 { start, end, source, search, rTool, rEntityId, upcoming } = c.req.query();
|
||||||
|
|
||||||
const doc = ensureDoc(space);
|
const doc = ensureDoc(dataSpace);
|
||||||
let events = Object.values(doc.events);
|
let events = Object.values(doc.events);
|
||||||
|
|
||||||
// Apply filters
|
// 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); }
|
try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
|
||||||
|
|
||||||
const space = c.req.param("space") || "demo";
|
const space = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
|
||||||
const body = await c.req.json();
|
const body = await c.req.json();
|
||||||
const { title, description, start_time, end_time, all_day, timezone, source_id, location_id, location_name,
|
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_virtual, virtual_url, virtual_platform, r_tool_source, r_tool_entity_id,
|
||||||
is_scheduled_item, provenance, item_preview } = body;
|
is_scheduled_item, provenance, item_preview } = body;
|
||||||
if (!title?.trim() || !start_time) return c.json({ error: "Title and start_time required" }, 400);
|
if (!title?.trim() || !start_time) return c.json({ error: "Title and start_time required" }, 400);
|
||||||
|
|
||||||
const docId = calendarDocId(space);
|
const docId = calendarDocId(dataSpace);
|
||||||
ensureDoc(space);
|
ensureDoc(dataSpace);
|
||||||
const eventId = crypto.randomUUID();
|
const eventId = crypto.randomUUID();
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
|
|
@ -394,9 +396,10 @@ routes.post("/api/events", async (c) => {
|
||||||
// GET /api/events/scheduled — query only scheduled knowledge items
|
// GET /api/events/scheduled — query only scheduled knowledge items
|
||||||
routes.get("/api/events/scheduled", async (c) => {
|
routes.get("/api/events/scheduled", async (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
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 { date, upcoming, pending_only } = c.req.query();
|
||||||
|
|
||||||
const doc = ensureDoc(space);
|
const doc = ensureDoc(dataSpace);
|
||||||
let events = Object.values(doc.events).filter((e) => {
|
let events = Object.values(doc.events).filter((e) => {
|
||||||
const meta = e.metadata as ScheduledItemMetadata | null;
|
const meta = e.metadata as ScheduledItemMetadata | null;
|
||||||
return meta?.isScheduledItem === true;
|
return meta?.isScheduledItem === true;
|
||||||
|
|
@ -429,8 +432,9 @@ routes.get("/api/events/scheduled", async (c) => {
|
||||||
// GET /api/events/:id
|
// GET /api/events/:id
|
||||||
routes.get("/api/events/:id", async (c) => {
|
routes.get("/api/events/:id", async (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
const space = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
|
||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
const doc = ensureDoc(space);
|
const doc = ensureDoc(dataSpace);
|
||||||
|
|
||||||
const ev = doc.events[id];
|
const ev = doc.events[id];
|
||||||
if (!ev) return c.json({ error: "Event not found" }, 404);
|
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); }
|
try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
|
||||||
|
|
||||||
const space = c.req.param("space") || "demo";
|
const space = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
|
||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
const body = await c.req.json();
|
const body = await c.req.json();
|
||||||
|
|
||||||
const docId = calendarDocId(space);
|
const docId = calendarDocId(dataSpace);
|
||||||
const doc = ensureDoc(space);
|
const doc = ensureDoc(dataSpace);
|
||||||
if (!doc.events[id]) return c.json({ error: "Not found" }, 404);
|
if (!doc.events[id]) return c.json({ error: "Not found" }, 404);
|
||||||
|
|
||||||
// Map of allowed body keys to CalendarEvent fields
|
// Map of allowed body keys to CalendarEvent fields
|
||||||
|
|
@ -497,10 +502,11 @@ routes.patch("/api/events/:id", async (c) => {
|
||||||
// DELETE /api/events/:id
|
// DELETE /api/events/:id
|
||||||
routes.delete("/api/events/:id", async (c) => {
|
routes.delete("/api/events/:id", async (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
const space = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
|
||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
|
|
||||||
const docId = calendarDocId(space);
|
const docId = calendarDocId(dataSpace);
|
||||||
const doc = ensureDoc(space);
|
const doc = ensureDoc(dataSpace);
|
||||||
if (!doc.events[id]) return c.json({ error: "Not found" }, 404);
|
if (!doc.events[id]) return c.json({ error: "Not found" }, 404);
|
||||||
|
|
||||||
_syncServer!.changeDoc<CalendarDoc>(docId, `delete event ${id}`, (d) => {
|
_syncServer!.changeDoc<CalendarDoc>(docId, `delete event ${id}`, (d) => {
|
||||||
|
|
@ -513,8 +519,9 @@ routes.delete("/api/events/:id", async (c) => {
|
||||||
|
|
||||||
routes.get("/api/sources", async (c) => {
|
routes.get("/api/sources", async (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
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 { is_active, is_visible, source_type } = c.req.query();
|
||||||
const doc = ensureDoc(space);
|
const doc = ensureDoc(dataSpace);
|
||||||
|
|
||||||
let sources = Object.values(doc.sources);
|
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); }
|
try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
|
||||||
|
|
||||||
const space = c.req.param("space") || "demo";
|
const space = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
|
||||||
const body = await c.req.json();
|
const body = await c.req.json();
|
||||||
const docId = calendarDocId(space);
|
const docId = calendarDocId(dataSpace);
|
||||||
ensureDoc(space);
|
ensureDoc(dataSpace);
|
||||||
|
|
||||||
const sourceId = crypto.randomUUID();
|
const sourceId = crypto.randomUUID();
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
@ -601,8 +609,9 @@ function deriveLocations(doc: CalendarDoc): DerivedLocation[] {
|
||||||
|
|
||||||
routes.get("/api/locations", async (c) => {
|
routes.get("/api/locations", async (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
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 { granularity, parent, search, root } = c.req.query();
|
||||||
const doc = ensureDoc(space);
|
const doc = ensureDoc(dataSpace);
|
||||||
|
|
||||||
let locations = deriveLocations(doc);
|
let locations = deriveLocations(doc);
|
||||||
|
|
||||||
|
|
@ -627,7 +636,8 @@ routes.get("/api/locations", async (c) => {
|
||||||
|
|
||||||
routes.get("/api/locations/tree", async (c) => {
|
routes.get("/api/locations/tree", async (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
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
|
// Flat list with depth=0 since hierarchical parent_id data is not stored in Automerge
|
||||||
const locations = deriveLocations(doc).map((l) => ({ ...l, depth: 0 }));
|
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) => {
|
routes.get("/api/stats", async (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
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 events = Object.values(doc.events).length;
|
||||||
const sources = Object.values(doc.sources).filter((s) => s.isActive).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) => {
|
routes.get("/api/context/:tool", async (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
const space = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
|
||||||
const tool = c.req.param("tool");
|
const tool = c.req.param("tool");
|
||||||
const entityId = c.req.query("entityId");
|
const entityId = c.req.query("entityId");
|
||||||
if (!entityId) return c.json({ error: "entityId required" }, 400);
|
if (!entityId) return c.json({ error: "entityId required" }, 400);
|
||||||
|
|
||||||
const doc = ensureDoc(space);
|
const doc = ensureDoc(dataSpace);
|
||||||
const matching = Object.values(doc.events)
|
const matching = Object.values(doc.events)
|
||||||
.filter((e) => e.rToolSource === tool && e.rToolEntityId === entityId)
|
.filter((e) => e.rToolSource === tool && e.rToolEntityId === entityId)
|
||||||
.sort((a, b) => a.startTime - b.startTime);
|
.sort((a, b) => a.startTime - b.startTime);
|
||||||
|
|
@ -707,6 +719,7 @@ routes.get("/api/context/:tool", async (c) => {
|
||||||
// ── Page route ──
|
// ── Page route ──
|
||||||
routes.get("/", (c) => {
|
routes.get("/", (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
const space = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
|
||||||
return c.html(renderShell({
|
return c.html(renderShell({
|
||||||
title: `${space} — Calendar | rSpace`,
|
title: `${space} — Calendar | rSpace`,
|
||||||
moduleId: "rcal",
|
moduleId: "rcal",
|
||||||
|
|
|
||||||
|
|
@ -121,6 +121,7 @@ routes.post("/api/collect", async (c) => {
|
||||||
// ── Page route ──
|
// ── Page route ──
|
||||||
routes.get("/", (c) => {
|
routes.get("/", (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
const space = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
|
||||||
return c.html(renderShell({
|
return c.html(renderShell({
|
||||||
title: `${space} — Data | rSpace`,
|
title: `${space} — Data | rSpace`,
|
||||||
moduleId: "rdata",
|
moduleId: "rdata",
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ routes.get("/api/health", (c) => {
|
||||||
|
|
||||||
routes.get("/", (c) => {
|
routes.get("/", (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
const space = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
|
||||||
const view = c.req.query("view");
|
const view = c.req.query("view");
|
||||||
|
|
||||||
if (view === "demo") {
|
if (view === "demo") {
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ routes.get("/api/health", (c) => {
|
||||||
|
|
||||||
routes.get("/", (c) => {
|
routes.get("/", (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
const space = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
|
||||||
const view = c.req.query("view");
|
const view = c.req.query("view");
|
||||||
|
|
||||||
if (view === "demo") {
|
if (view === "demo") {
|
||||||
|
|
|
||||||
|
|
@ -134,6 +134,7 @@ routes.get("/api/c3nav/:event", async (c) => {
|
||||||
// ── Page route ──
|
// ── Page route ──
|
||||||
routes.get("/", (c) => {
|
routes.get("/", (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
const space = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
|
||||||
return c.html(renderShell({
|
return c.html(renderShell({
|
||||||
title: `${space} — Maps | rSpace`,
|
title: `${space} — Maps | rSpace`,
|
||||||
moduleId: "rmaps",
|
moduleId: "rmaps",
|
||||||
|
|
@ -149,6 +150,7 @@ routes.get("/", (c) => {
|
||||||
// Room-specific page
|
// Room-specific page
|
||||||
routes.get("/:room", (c) => {
|
routes.get("/:room", (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
const space = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
|
||||||
const room = c.req.param("room");
|
const room = c.req.param("room");
|
||||||
return c.html(renderShell({
|
return c.html(renderShell({
|
||||||
title: `${room} — Maps | rSpace`,
|
title: `${room} — Maps | rSpace`,
|
||||||
|
|
|
||||||
|
|
@ -55,14 +55,16 @@ const CACHE_TTL = 60_000;
|
||||||
// ── API: Health ──
|
// ── API: Health ──
|
||||||
routes.get("/api/health", (c) => {
|
routes.get("/api/health", (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
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 });
|
return c.json({ ok: true, module: "network", space, twentyConfigured: !!token });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── API: Info ──
|
// ── API: Info ──
|
||||||
routes.get("/api/info", (c) => {
|
routes.get("/api/info", (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
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({
|
return c.json({
|
||||||
module: "network",
|
module: "network",
|
||||||
description: "Community relationship graph visualization",
|
description: "Community relationship graph visualization",
|
||||||
|
|
@ -76,7 +78,8 @@ routes.get("/api/info", (c) => {
|
||||||
// ── API: People ──
|
// ── API: People ──
|
||||||
routes.get("/api/people", async (c) => {
|
routes.get("/api/people", async (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
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(`{
|
const data = await twentyQuery(`{
|
||||||
people(first: 200) {
|
people(first: 200) {
|
||||||
edges {
|
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" });
|
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);
|
const people = ((data as any).people?.edges || []).map((e: any) => e.node);
|
||||||
c.header("Cache-Control", "public, max-age=60");
|
c.header("Cache-Control", "public, max-age=60");
|
||||||
|
|
@ -101,7 +104,8 @@ routes.get("/api/people", async (c) => {
|
||||||
// ── API: Companies ──
|
// ── API: Companies ──
|
||||||
routes.get("/api/companies", async (c) => {
|
routes.get("/api/companies", async (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
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(`{
|
const data = await twentyQuery(`{
|
||||||
companies(first: 200) {
|
companies(first: 200) {
|
||||||
edges {
|
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" });
|
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);
|
const companies = ((data as any).companies?.edges || []).map((e: any) => e.node);
|
||||||
c.header("Cache-Control", "public, max-age=60");
|
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 ──
|
// ── API: Graph — transform entities to node/edge format ──
|
||||||
routes.get("/api/graph", async (c) => {
|
routes.get("/api/graph", async (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
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
|
// Check per-space cache
|
||||||
const cached = graphCaches.get(space);
|
const cached = graphCaches.get(dataSpace);
|
||||||
if (cached && Date.now() - cached.ts < CACHE_TTL) {
|
if (cached && Date.now() - cached.ts < CACHE_TTL) {
|
||||||
c.header("Cache-Control", "public, max-age=60");
|
c.header("Cache-Control", "public, max-age=60");
|
||||||
return c.json(cached.data);
|
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" });
|
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 };
|
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");
|
c.header("Cache-Control", "public, max-age=60");
|
||||||
return c.json(result);
|
return c.json(result);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -246,7 +251,8 @@ routes.get("/api/workspaces", (c) => {
|
||||||
// ── API: Opportunities ──
|
// ── API: Opportunities ──
|
||||||
routes.get("/api/opportunities", async (c) => {
|
routes.get("/api/opportunities", async (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
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(`{
|
const data = await twentyQuery(`{
|
||||||
opportunities(first: 200) {
|
opportunities(first: 200) {
|
||||||
edges {
|
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" });
|
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);
|
const opportunities = ((data as any).opportunities?.edges || []).map((e: any) => e.node);
|
||||||
c.header("Cache-Control", "public, max-age=60");
|
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 ──
|
// ── CRM sub-route — embed Twenty CRM via iframe ──
|
||||||
routes.get("/crm", (c) => {
|
routes.get("/crm", (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
const space = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
|
||||||
return c.html(renderExternalAppShell({
|
return c.html(renderExternalAppShell({
|
||||||
title: `${space} — CRM | rSpace`,
|
title: `${space} — CRM | rSpace`,
|
||||||
moduleId: "rnetwork",
|
moduleId: "rnetwork",
|
||||||
|
|
@ -285,6 +292,7 @@ routes.get("/crm", (c) => {
|
||||||
// ── Page route ──
|
// ── Page route ──
|
||||||
routes.get("/", (c) => {
|
routes.get("/", (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
const space = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
|
||||||
const view = c.req.query("view");
|
const view = c.req.query("view");
|
||||||
|
|
||||||
if (view === "app") {
|
if (view === "app") {
|
||||||
|
|
|
||||||
|
|
@ -245,8 +245,9 @@ function extractPlainText(content: string, format?: string): string {
|
||||||
// GET /api/notebooks — list notebooks
|
// GET /api/notebooks — list notebooks
|
||||||
routes.get("/api/notebooks", async (c) => {
|
routes.get("/api/notebooks", async (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
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());
|
notebooks.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime());
|
||||||
return c.json({ notebooks, source: "automerge" });
|
return c.json({ notebooks, source: "automerge" });
|
||||||
});
|
});
|
||||||
|
|
@ -254,6 +255,7 @@ routes.get("/api/notebooks", async (c) => {
|
||||||
// POST /api/notebooks — create notebook
|
// POST /api/notebooks — create notebook
|
||||||
routes.post("/api/notebooks", async (c) => {
|
routes.post("/api/notebooks", async (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
const space = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
|
||||||
const token = extractToken(c.req.raw.headers);
|
const token = extractToken(c.req.raw.headers);
|
||||||
if (!token) return c.json({ error: "Authentication required" }, 401);
|
if (!token) return c.json({ error: "Authentication required" }, 401);
|
||||||
let claims;
|
let claims;
|
||||||
|
|
@ -266,8 +268,8 @@ routes.post("/api/notebooks", async (c) => {
|
||||||
const notebookId = newId();
|
const notebookId = newId();
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
const doc = ensureDoc(space, notebookId);
|
const doc = ensureDoc(dataSpace, notebookId);
|
||||||
_syncServer!.changeDoc<NotebookDoc>(notebookDocId(space, notebookId), "Create notebook", (d) => {
|
_syncServer!.changeDoc<NotebookDoc>(notebookDocId(dataSpace, notebookId), "Create notebook", (d) => {
|
||||||
d.notebook.id = notebookId;
|
d.notebook.id = notebookId;
|
||||||
d.notebook.title = nbTitle;
|
d.notebook.title = nbTitle;
|
||||||
d.notebook.slug = slugify(nbTitle);
|
d.notebook.slug = slugify(nbTitle);
|
||||||
|
|
@ -278,16 +280,17 @@ routes.post("/api/notebooks", async (c) => {
|
||||||
d.notebook.updatedAt = now;
|
d.notebook.updatedAt = now;
|
||||||
});
|
});
|
||||||
|
|
||||||
const updatedDoc = _syncServer!.getDoc<NotebookDoc>(notebookDocId(space, notebookId))!;
|
const updatedDoc = _syncServer!.getDoc<NotebookDoc>(notebookDocId(dataSpace, notebookId))!;
|
||||||
return c.json(notebookToRest(updatedDoc), 201);
|
return c.json(notebookToRest(updatedDoc), 201);
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /api/notebooks/:id — notebook detail with notes
|
// GET /api/notebooks/:id — notebook detail with notes
|
||||||
routes.get("/api/notebooks/:id", async (c) => {
|
routes.get("/api/notebooks/:id", async (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
const space = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
|
||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
|
|
||||||
const docId = notebookDocId(space, id);
|
const docId = notebookDocId(dataSpace, id);
|
||||||
const doc = _syncServer?.getDoc<NotebookDoc>(docId);
|
const doc = _syncServer?.getDoc<NotebookDoc>(docId);
|
||||||
if (!doc || !doc.notebook || !doc.notebook.title) {
|
if (!doc || !doc.notebook || !doc.notebook.title) {
|
||||||
return c.json({ error: "Notebook not found" }, 404);
|
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
|
// PUT /api/notebooks/:id — update notebook
|
||||||
routes.put("/api/notebooks/:id", async (c) => {
|
routes.put("/api/notebooks/:id", async (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
const space = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
|
||||||
const token = extractToken(c.req.raw.headers);
|
const token = extractToken(c.req.raw.headers);
|
||||||
if (!token) return c.json({ error: "Authentication required" }, 401);
|
if (!token) return c.json({ error: "Authentication required" }, 401);
|
||||||
let claims;
|
let claims;
|
||||||
|
|
@ -319,7 +323,7 @@ routes.put("/api/notebooks/:id", async (c) => {
|
||||||
return c.json({ error: "No fields to update" }, 400);
|
return c.json({ error: "No fields to update" }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const docId = notebookDocId(space, id);
|
const docId = notebookDocId(dataSpace, id);
|
||||||
const doc = _syncServer?.getDoc<NotebookDoc>(docId);
|
const doc = _syncServer?.getDoc<NotebookDoc>(docId);
|
||||||
if (!doc || !doc.notebook || !doc.notebook.title) {
|
if (!doc || !doc.notebook || !doc.notebook.title) {
|
||||||
return c.json({ error: "Notebook not found" }, 404);
|
return c.json({ error: "Notebook not found" }, 404);
|
||||||
|
|
@ -340,9 +344,10 @@ routes.put("/api/notebooks/:id", async (c) => {
|
||||||
// DELETE /api/notebooks/:id
|
// DELETE /api/notebooks/:id
|
||||||
routes.delete("/api/notebooks/:id", async (c) => {
|
routes.delete("/api/notebooks/:id", async (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
const space = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
|
||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
|
|
||||||
const docId = notebookDocId(space, id);
|
const docId = notebookDocId(dataSpace, id);
|
||||||
const doc = _syncServer?.getDoc<NotebookDoc>(docId);
|
const doc = _syncServer?.getDoc<NotebookDoc>(docId);
|
||||||
if (!doc || !doc.notebook || !doc.notebook.title) {
|
if (!doc || !doc.notebook || !doc.notebook.title) {
|
||||||
return c.json({ error: "Notebook not found" }, 404);
|
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
|
// GET /api/notes — list all notes
|
||||||
routes.get("/api/notes", async (c) => {
|
routes.get("/api/notes", async (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
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();
|
const { notebook_id, type, q, limit = "50", offset = "0" } = c.req.query();
|
||||||
|
|
||||||
let allNotes: ReturnType<typeof noteToRest>[] = [];
|
let allNotes: ReturnType<typeof noteToRest>[] = [];
|
||||||
const notebooks = notebook_id
|
const notebooks = notebook_id
|
||||||
? (() => {
|
? (() => {
|
||||||
const doc = _syncServer?.getDoc<NotebookDoc>(notebookDocId(space, notebook_id));
|
const doc = _syncServer?.getDoc<NotebookDoc>(notebookDocId(dataSpace, notebook_id));
|
||||||
return doc ? [{ doc }] : [];
|
return doc ? [{ doc }] : [];
|
||||||
})()
|
})()
|
||||||
: listNotebooks(space);
|
: listNotebooks(dataSpace);
|
||||||
|
|
||||||
for (const { doc } of notebooks) {
|
for (const { doc } of notebooks) {
|
||||||
for (const item of Object.values(doc.items)) {
|
for (const item of Object.values(doc.items)) {
|
||||||
|
|
@ -401,6 +407,7 @@ routes.get("/api/notes", async (c) => {
|
||||||
// POST /api/notes — create note
|
// POST /api/notes — create note
|
||||||
routes.post("/api/notes", async (c) => {
|
routes.post("/api/notes", async (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
const space = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
|
||||||
const token = extractToken(c.req.raw.headers);
|
const token = extractToken(c.req.raw.headers);
|
||||||
if (!token) return c.json({ error: "Authentication required" }, 401);
|
if (!token) return c.json({ error: "Authentication required" }, 401);
|
||||||
let claims;
|
let claims;
|
||||||
|
|
@ -439,8 +446,8 @@ routes.post("/api/notes", async (c) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Ensure the notebook doc exists, then add the note
|
// Ensure the notebook doc exists, then add the note
|
||||||
ensureDoc(space, notebook_id);
|
ensureDoc(dataSpace, notebook_id);
|
||||||
const docId = notebookDocId(space, notebook_id);
|
const docId = notebookDocId(dataSpace, notebook_id);
|
||||||
_syncServer!.changeDoc<NotebookDoc>(docId, `Create note: ${title.trim()}`, (d) => {
|
_syncServer!.changeDoc<NotebookDoc>(docId, `Create note: ${title.trim()}`, (d) => {
|
||||||
d.items[noteId] = item;
|
d.items[noteId] = item;
|
||||||
d.notebook.updatedAt = Date.now();
|
d.notebook.updatedAt = Date.now();
|
||||||
|
|
@ -452,9 +459,10 @@ routes.post("/api/notes", async (c) => {
|
||||||
// GET /api/notes/:id — note detail
|
// GET /api/notes/:id — note detail
|
||||||
routes.get("/api/notes/:id", async (c) => {
|
routes.get("/api/notes/:id", async (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
const space = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
|
||||||
const id = c.req.param("id");
|
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);
|
if (!found) return c.json({ error: "Note not found" }, 404);
|
||||||
|
|
||||||
return c.json({ ...noteToRest(found.item), source: "automerge" });
|
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
|
// PUT /api/notes/:id — update note
|
||||||
routes.put("/api/notes/:id", async (c) => {
|
routes.put("/api/notes/:id", async (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
const space = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
|
||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
const body = await c.req.json();
|
const body = await c.req.json();
|
||||||
const { title, content, content_format, type, url, language, is_pinned, sort_order } = body;
|
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);
|
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);
|
if (!found) return c.json({ error: "Note not found" }, 404);
|
||||||
|
|
||||||
const contentPlain = content !== undefined
|
const contentPlain = content !== undefined
|
||||||
|
|
@ -503,9 +512,10 @@ routes.put("/api/notes/:id", async (c) => {
|
||||||
// DELETE /api/notes/:id
|
// DELETE /api/notes/:id
|
||||||
routes.delete("/api/notes/:id", async (c) => {
|
routes.delete("/api/notes/:id", async (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
const space = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
|
||||||
const id = c.req.param("id");
|
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);
|
if (!found) return c.json({ error: "Note not found" }, 404);
|
||||||
|
|
||||||
_syncServer!.changeDoc<NotebookDoc>(found.docId, `Delete note ${id}`, (d) => {
|
_syncServer!.changeDoc<NotebookDoc>(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
|
// POST /api/import/upload — ZIP upload for Logseq/Obsidian
|
||||||
routes.post("/api/import/upload", async (c) => {
|
routes.post("/api/import/upload", async (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
const space = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
|
||||||
const token = extractToken(c.req.raw.headers);
|
const token = extractToken(c.req.raw.headers);
|
||||||
if (!token) return c.json({ error: "Authentication required" }, 401);
|
if (!token) return c.json({ error: "Authentication required" }, 401);
|
||||||
try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 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
|
// Create a new notebook with the import title
|
||||||
targetNotebookId = newId();
|
targetNotebookId = newId();
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
ensureDoc(space, targetNotebookId);
|
ensureDoc(dataSpace, targetNotebookId);
|
||||||
_syncServer!.changeDoc<NotebookDoc>(notebookDocId(space, targetNotebookId), "Create import notebook", (d) => {
|
_syncServer!.changeDoc<NotebookDoc>(notebookDocId(dataSpace, targetNotebookId), "Create import notebook", (d) => {
|
||||||
d.notebook.id = targetNotebookId!;
|
d.notebook.id = targetNotebookId!;
|
||||||
d.notebook.title = result.notebookTitle;
|
d.notebook.title = result.notebookTitle;
|
||||||
d.notebook.slug = slugify(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({
|
return c.json({
|
||||||
ok: true,
|
ok: true,
|
||||||
|
|
@ -637,6 +648,7 @@ routes.post("/api/import/upload", async (c) => {
|
||||||
// POST /api/import/notion — Import selected Notion pages
|
// POST /api/import/notion — Import selected Notion pages
|
||||||
routes.post("/api/import/notion", async (c) => {
|
routes.post("/api/import/notion", async (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
const space = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
|
||||||
const token = extractToken(c.req.raw.headers);
|
const token = extractToken(c.req.raw.headers);
|
||||||
if (!token) return c.json({ error: "Authentication required" }, 401);
|
if (!token) return c.json({ error: "Authentication required" }, 401);
|
||||||
try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 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);
|
return c.json({ error: "pageIds array is required" }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const conn = getConnectionDoc(space);
|
const conn = getConnectionDoc(dataSpace);
|
||||||
if (!conn?.notion?.accessToken) {
|
if (!conn?.notion?.accessToken) {
|
||||||
return c.json({ error: "Notion not connected. Connect your Notion account first." }, 400);
|
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) {
|
if (!targetNotebookId) {
|
||||||
targetNotebookId = newId();
|
targetNotebookId = newId();
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
ensureDoc(space, targetNotebookId);
|
ensureDoc(dataSpace, targetNotebookId);
|
||||||
_syncServer!.changeDoc<NotebookDoc>(notebookDocId(space, targetNotebookId), "Create Notion import notebook", (d) => {
|
_syncServer!.changeDoc<NotebookDoc>(notebookDocId(dataSpace, targetNotebookId), "Create Notion import notebook", (d) => {
|
||||||
d.notebook.id = targetNotebookId!;
|
d.notebook.id = targetNotebookId!;
|
||||||
d.notebook.title = result.notebookTitle;
|
d.notebook.title = result.notebookTitle;
|
||||||
d.notebook.slug = slugify(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 });
|
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
|
// POST /api/import/google-docs — Import selected Google Docs
|
||||||
routes.post("/api/import/google-docs", async (c) => {
|
routes.post("/api/import/google-docs", async (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
const space = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
|
||||||
const token = extractToken(c.req.raw.headers);
|
const token = extractToken(c.req.raw.headers);
|
||||||
if (!token) return c.json({ error: "Authentication required" }, 401);
|
if (!token) return c.json({ error: "Authentication required" }, 401);
|
||||||
try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 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);
|
return c.json({ error: "docIds array is required" }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const conn = getConnectionDoc(space);
|
const conn = getConnectionDoc(dataSpace);
|
||||||
if (!conn?.google?.accessToken) {
|
if (!conn?.google?.accessToken) {
|
||||||
return c.json({ error: "Google not connected. Connect your Google account first." }, 400);
|
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) {
|
if (!targetNotebookId) {
|
||||||
targetNotebookId = newId();
|
targetNotebookId = newId();
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
ensureDoc(space, targetNotebookId);
|
ensureDoc(dataSpace, targetNotebookId);
|
||||||
_syncServer!.changeDoc<NotebookDoc>(notebookDocId(space, targetNotebookId), "Create Google Docs import notebook", (d) => {
|
_syncServer!.changeDoc<NotebookDoc>(notebookDocId(dataSpace, targetNotebookId), "Create Google Docs import notebook", (d) => {
|
||||||
d.notebook.id = targetNotebookId!;
|
d.notebook.id = targetNotebookId!;
|
||||||
d.notebook.title = result.notebookTitle;
|
d.notebook.title = result.notebookTitle;
|
||||||
d.notebook.slug = slugify(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 });
|
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
|
// GET /api/import/notion/pages — Browse Notion pages for selection
|
||||||
routes.get("/api/import/notion/pages", async (c) => {
|
routes.get("/api/import/notion/pages", async (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
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) {
|
if (!conn?.notion?.accessToken) {
|
||||||
return c.json({ error: "Notion not connected" }, 400);
|
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
|
// GET /api/import/google-docs/list — Browse Google Docs for selection
|
||||||
routes.get("/api/import/google-docs/list", async (c) => {
|
routes.get("/api/import/google-docs/list", async (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
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) {
|
if (!conn?.google?.accessToken) {
|
||||||
return c.json({ error: "Google not connected" }, 400);
|
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
|
// GET /api/export/obsidian — Download Obsidian-format ZIP
|
||||||
routes.get("/api/export/obsidian", async (c) => {
|
routes.get("/api/export/obsidian", async (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
const space = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
|
||||||
const notebookId = c.req.query("notebookId");
|
const notebookId = c.req.query("notebookId");
|
||||||
if (!notebookId) return c.json({ error: "notebookId is required" }, 400);
|
if (!notebookId) return c.json({ error: "notebookId is required" }, 400);
|
||||||
|
|
||||||
const docId = notebookDocId(space, notebookId);
|
const docId = notebookDocId(dataSpace, notebookId);
|
||||||
const doc = _syncServer?.getDoc<NotebookDoc>(docId);
|
const doc = _syncServer?.getDoc<NotebookDoc>(docId);
|
||||||
if (!doc || !doc.notebook?.title) return c.json({ error: "Notebook not found" }, 404);
|
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
|
// GET /api/export/logseq — Download Logseq-format ZIP
|
||||||
routes.get("/api/export/logseq", async (c) => {
|
routes.get("/api/export/logseq", async (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
const space = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
|
||||||
const notebookId = c.req.query("notebookId");
|
const notebookId = c.req.query("notebookId");
|
||||||
if (!notebookId) return c.json({ error: "notebookId is required" }, 400);
|
if (!notebookId) return c.json({ error: "notebookId is required" }, 400);
|
||||||
|
|
||||||
const docId = notebookDocId(space, notebookId);
|
const docId = notebookDocId(dataSpace, notebookId);
|
||||||
const doc = _syncServer?.getDoc<NotebookDoc>(docId);
|
const doc = _syncServer?.getDoc<NotebookDoc>(docId);
|
||||||
if (!doc || !doc.notebook?.title) return c.json({ error: "Notebook not found" }, 404);
|
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
|
// GET /api/export/markdown — Download universal Markdown ZIP
|
||||||
routes.get("/api/export/markdown", async (c) => {
|
routes.get("/api/export/markdown", async (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
const space = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
|
||||||
const notebookId = c.req.query("notebookId");
|
const notebookId = c.req.query("notebookId");
|
||||||
const noteIds = c.req.query("noteIds");
|
const noteIds = c.req.query("noteIds");
|
||||||
|
|
||||||
|
|
@ -849,7 +867,7 @@ routes.get("/api/export/markdown", async (c) => {
|
||||||
let title = "rNotes Export";
|
let title = "rNotes Export";
|
||||||
|
|
||||||
if (notebookId) {
|
if (notebookId) {
|
||||||
const docId = notebookDocId(space, notebookId);
|
const docId = notebookDocId(dataSpace, notebookId);
|
||||||
const doc = _syncServer?.getDoc<NotebookDoc>(docId);
|
const doc = _syncServer?.getDoc<NotebookDoc>(docId);
|
||||||
if (!doc || !doc.notebook?.title) return c.json({ error: "Notebook not found" }, 404);
|
if (!doc || !doc.notebook?.title) return c.json({ error: "Notebook not found" }, 404);
|
||||||
notes = Object.values(doc.items);
|
notes = Object.values(doc.items);
|
||||||
|
|
@ -857,7 +875,7 @@ routes.get("/api/export/markdown", async (c) => {
|
||||||
} else if (noteIds) {
|
} else if (noteIds) {
|
||||||
const ids = noteIds.split(",").map(id => id.trim());
|
const ids = noteIds.split(",").map(id => id.trim());
|
||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
const found = findNote(space, id);
|
const found = findNote(dataSpace, id);
|
||||||
if (found) notes.push(found.item);
|
if (found) notes.push(found.item);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -881,6 +899,7 @@ routes.get("/api/export/markdown", async (c) => {
|
||||||
// POST /api/export/notion — Push notes to Notion
|
// POST /api/export/notion — Push notes to Notion
|
||||||
routes.post("/api/export/notion", async (c) => {
|
routes.post("/api/export/notion", async (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
const space = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
|
||||||
const token = extractToken(c.req.raw.headers);
|
const token = extractToken(c.req.raw.headers);
|
||||||
if (!token) return c.json({ error: "Authentication required" }, 401);
|
if (!token) return c.json({ error: "Authentication required" }, 401);
|
||||||
try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 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 body = await c.req.json();
|
||||||
const { notebookId, noteIds, parentId } = body;
|
const { notebookId, noteIds, parentId } = body;
|
||||||
|
|
||||||
const conn = getConnectionDoc(space);
|
const conn = getConnectionDoc(dataSpace);
|
||||||
if (!conn?.notion?.accessToken) {
|
if (!conn?.notion?.accessToken) {
|
||||||
return c.json({ error: "Notion not connected" }, 400);
|
return c.json({ error: "Notion not connected" }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
let notes: NoteItem[] = [];
|
let notes: NoteItem[] = [];
|
||||||
if (notebookId) {
|
if (notebookId) {
|
||||||
const docId = notebookDocId(space, notebookId);
|
const docId = notebookDocId(dataSpace, notebookId);
|
||||||
const doc = _syncServer?.getDoc<NotebookDoc>(docId);
|
const doc = _syncServer?.getDoc<NotebookDoc>(docId);
|
||||||
if (doc) notes = Object.values(doc.items);
|
if (doc) notes = Object.values(doc.items);
|
||||||
} else if (noteIds && Array.isArray(noteIds)) {
|
} else if (noteIds && Array.isArray(noteIds)) {
|
||||||
for (const id of noteIds) {
|
for (const id of noteIds) {
|
||||||
const found = findNote(space, id);
|
const found = findNote(dataSpace, id);
|
||||||
if (found) notes.push(found.item);
|
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
|
// POST /api/export/google-docs — Push notes to Google Docs
|
||||||
routes.post("/api/export/google-docs", async (c) => {
|
routes.post("/api/export/google-docs", async (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
const space = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
|
||||||
const token = extractToken(c.req.raw.headers);
|
const token = extractToken(c.req.raw.headers);
|
||||||
if (!token) return c.json({ error: "Authentication required" }, 401);
|
if (!token) return c.json({ error: "Authentication required" }, 401);
|
||||||
try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 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 body = await c.req.json();
|
||||||
const { notebookId, noteIds, parentId } = body;
|
const { notebookId, noteIds, parentId } = body;
|
||||||
|
|
||||||
const conn = getConnectionDoc(space);
|
const conn = getConnectionDoc(dataSpace);
|
||||||
if (!conn?.google?.accessToken) {
|
if (!conn?.google?.accessToken) {
|
||||||
return c.json({ error: "Google not connected" }, 400);
|
return c.json({ error: "Google not connected" }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
let notes: NoteItem[] = [];
|
let notes: NoteItem[] = [];
|
||||||
if (notebookId) {
|
if (notebookId) {
|
||||||
const docId = notebookDocId(space, notebookId);
|
const docId = notebookDocId(dataSpace, notebookId);
|
||||||
const doc = _syncServer?.getDoc<NotebookDoc>(docId);
|
const doc = _syncServer?.getDoc<NotebookDoc>(docId);
|
||||||
if (doc) notes = Object.values(doc.items);
|
if (doc) notes = Object.values(doc.items);
|
||||||
} else if (noteIds && Array.isArray(noteIds)) {
|
} else if (noteIds && Array.isArray(noteIds)) {
|
||||||
for (const id of noteIds) {
|
for (const id of noteIds) {
|
||||||
const found = findNote(space, id);
|
const found = findNote(dataSpace, id);
|
||||||
if (found) notes.push(found.item);
|
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
|
// GET /api/connections — Status of all integrations
|
||||||
routes.get("/api/connections", async (c) => {
|
routes.get("/api/connections", async (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
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({
|
return c.json({
|
||||||
notion: conn?.notion ? {
|
notion: conn?.notion ? {
|
||||||
|
|
@ -980,6 +1001,7 @@ routes.get("/api/connections", async (c) => {
|
||||||
// ── Page route ──
|
// ── Page route ──
|
||||||
routes.get("/", (c) => {
|
routes.get("/", (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
const space = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
|
||||||
return c.html(renderShell({
|
return c.html(renderShell({
|
||||||
title: `${space} — Notes | rSpace`,
|
title: `${space} — Notes | rSpace`,
|
||||||
moduleId: "rnotes",
|
moduleId: "rnotes",
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,7 @@ routes.get("/api/assets/:id/original", async (c) => {
|
||||||
// ── Embedded Immich UI ──
|
// ── Embedded Immich UI ──
|
||||||
routes.get("/album", (c) => {
|
routes.get("/album", (c) => {
|
||||||
const spaceSlug = c.req.param("space") || "demo";
|
const spaceSlug = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = (c.get("effectiveSpace" as any) as string) || spaceSlug;
|
||||||
return c.html(renderExternalAppShell({
|
return c.html(renderExternalAppShell({
|
||||||
title: `${spaceSlug} — Immich | rSpace`,
|
title: `${spaceSlug} — Immich | rSpace`,
|
||||||
moduleId: "rphotos",
|
moduleId: "rphotos",
|
||||||
|
|
@ -124,6 +125,7 @@ routes.get("/album", (c) => {
|
||||||
// ── Page route ──
|
// ── Page route ──
|
||||||
routes.get("/", (c) => {
|
routes.get("/", (c) => {
|
||||||
const spaceSlug = c.req.param("space") || "demo";
|
const spaceSlug = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = (c.get("effectiveSpace" as any) as string) || spaceSlug;
|
||||||
return c.html(renderShell({
|
return c.html(renderShell({
|
||||||
title: `${spaceSlug} — Photos | rSpace`,
|
title: `${spaceSlug} — Photos | rSpace`,
|
||||||
moduleId: "rphotos",
|
moduleId: "rphotos",
|
||||||
|
|
|
||||||
|
|
@ -322,6 +322,7 @@ routes.get("/api/artifact/:id/pdf", async (c) => {
|
||||||
// ── Page: Editor ──
|
// ── Page: Editor ──
|
||||||
routes.get("/", (c) => {
|
routes.get("/", (c) => {
|
||||||
const spaceSlug = c.req.param("space") || "personal";
|
const spaceSlug = c.req.param("space") || "personal";
|
||||||
|
const dataSpace = (c.get("effectiveSpace" as any) as string) || spaceSlug;
|
||||||
return c.html(renderShell({
|
return c.html(renderShell({
|
||||||
title: `${spaceSlug} — rPubs Editor | rSpace`,
|
title: `${spaceSlug} — rPubs Editor | rSpace`,
|
||||||
moduleId: "rpubs",
|
moduleId: "rpubs",
|
||||||
|
|
|
||||||
|
|
@ -756,6 +756,7 @@ function seedDefaultJobs(space: string) {
|
||||||
// GET / — serve schedule UI
|
// GET / — serve schedule UI
|
||||||
routes.get("/", (c) => {
|
routes.get("/", (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
const space = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
|
||||||
return c.html(
|
return c.html(
|
||||||
renderShell({
|
renderShell({
|
||||||
title: `${space} — Schedule | rSpace`,
|
title: `${space} — Schedule | rSpace`,
|
||||||
|
|
@ -773,7 +774,8 @@ routes.get("/", (c) => {
|
||||||
// GET /api/jobs — list all jobs
|
// GET /api/jobs — list all jobs
|
||||||
routes.get("/api/jobs", (c) => {
|
routes.get("/api/jobs", (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
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) => ({
|
const jobs = Object.values(doc.jobs).map((j) => ({
|
||||||
...j,
|
...j,
|
||||||
cronHuman: cronToHuman(j.cronExpression),
|
cronHuman: cronToHuman(j.cronExpression),
|
||||||
|
|
@ -785,6 +787,7 @@ routes.get("/api/jobs", (c) => {
|
||||||
// POST /api/jobs — create a new job
|
// POST /api/jobs — create a new job
|
||||||
routes.post("/api/jobs", async (c) => {
|
routes.post("/api/jobs", async (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
const space = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
|
||||||
const body = await c.req.json();
|
const body = await c.req.json();
|
||||||
|
|
||||||
const { name, description, cronExpression, timezone, actionType, actionConfig, enabled } = body;
|
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);
|
return c.json({ error: "Invalid cron expression" }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const docId = scheduleDocId(space);
|
const docId = scheduleDocId(dataSpace);
|
||||||
ensureDoc(space);
|
ensureDoc(dataSpace);
|
||||||
const jobId = crypto.randomUUID();
|
const jobId = crypto.randomUUID();
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const tz = timezone || "UTC";
|
const tz = timezone || "UTC";
|
||||||
|
|
@ -832,8 +835,9 @@ routes.post("/api/jobs", async (c) => {
|
||||||
// GET /api/jobs/:id
|
// GET /api/jobs/:id
|
||||||
routes.get("/api/jobs/:id", (c) => {
|
routes.get("/api/jobs/:id", (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
const space = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
|
||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
const doc = ensureDoc(space);
|
const doc = ensureDoc(dataSpace);
|
||||||
|
|
||||||
const job = doc.jobs[id];
|
const job = doc.jobs[id];
|
||||||
if (!job) return c.json({ error: "Job not found" }, 404);
|
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
|
// PUT /api/jobs/:id — update a job
|
||||||
routes.put("/api/jobs/:id", async (c) => {
|
routes.put("/api/jobs/:id", async (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
const space = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
|
||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
const body = await c.req.json();
|
const body = await c.req.json();
|
||||||
|
|
||||||
const docId = scheduleDocId(space);
|
const docId = scheduleDocId(dataSpace);
|
||||||
const doc = ensureDoc(space);
|
const doc = ensureDoc(dataSpace);
|
||||||
if (!doc.jobs[id]) return c.json({ error: "Job not found" }, 404);
|
if (!doc.jobs[id]) return c.json({ error: "Job not found" }, 404);
|
||||||
|
|
||||||
// Validate cron if provided
|
// Validate cron if provided
|
||||||
|
|
@ -885,10 +890,11 @@ routes.put("/api/jobs/:id", async (c) => {
|
||||||
// DELETE /api/jobs/:id
|
// DELETE /api/jobs/:id
|
||||||
routes.delete("/api/jobs/:id", (c) => {
|
routes.delete("/api/jobs/:id", (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
const space = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
|
||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
|
|
||||||
const docId = scheduleDocId(space);
|
const docId = scheduleDocId(dataSpace);
|
||||||
const doc = ensureDoc(space);
|
const doc = ensureDoc(dataSpace);
|
||||||
if (!doc.jobs[id]) return c.json({ error: "Job not found" }, 404);
|
if (!doc.jobs[id]) return c.json({ error: "Job not found" }, 404);
|
||||||
|
|
||||||
_syncServer!.changeDoc<ScheduleDoc>(docId, `delete job ${id}`, (d) => {
|
_syncServer!.changeDoc<ScheduleDoc>(docId, `delete job ${id}`, (d) => {
|
||||||
|
|
@ -901,10 +907,11 @@ routes.delete("/api/jobs/:id", (c) => {
|
||||||
// POST /api/jobs/:id/run — manually trigger a job
|
// POST /api/jobs/:id/run — manually trigger a job
|
||||||
routes.post("/api/jobs/:id/run", async (c) => {
|
routes.post("/api/jobs/:id/run", async (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
const space = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
|
||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
|
|
||||||
const docId = scheduleDocId(space);
|
const docId = scheduleDocId(dataSpace);
|
||||||
const doc = ensureDoc(space);
|
const doc = ensureDoc(dataSpace);
|
||||||
const job = doc.jobs[id];
|
const job = doc.jobs[id];
|
||||||
if (!job) return c.json({ error: "Job not found" }, 404);
|
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 };
|
let result: { success: boolean; message: string };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
result = await executeJob(job, space);
|
result = await executeJob(job, dataSpace);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
result = { success: false, message: e.message || String(e) };
|
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
|
// GET /api/log — execution log
|
||||||
routes.get("/api/log", (c) => {
|
routes.get("/api/log", (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
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
|
const log = [...doc.log].reverse(); // newest first
|
||||||
return c.json({ count: log.length, results: log });
|
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
|
// GET /api/log/:jobId — execution log filtered by job
|
||||||
routes.get("/api/log/:jobId", (c) => {
|
routes.get("/api/log/:jobId", (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
const space = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
|
||||||
const jobId = c.req.param("jobId");
|
const jobId = c.req.param("jobId");
|
||||||
const doc = ensureDoc(space);
|
const doc = ensureDoc(dataSpace);
|
||||||
const log = doc.log.filter((e) => e.jobId === jobId).reverse();
|
const log = doc.log.filter((e) => e.jobId === jobId).reverse();
|
||||||
return c.json({ count: log.length, results: log });
|
return c.json({ count: log.length, results: log });
|
||||||
});
|
});
|
||||||
|
|
@ -1103,7 +1112,8 @@ async function executeReminderEmail(
|
||||||
// GET /api/reminders — list reminders
|
// GET /api/reminders — list reminders
|
||||||
routes.get("/api/reminders", (c) => {
|
routes.get("/api/reminders", (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
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);
|
let reminders = Object.values(doc.reminders);
|
||||||
|
|
||||||
|
|
@ -1131,14 +1141,15 @@ routes.get("/api/reminders", (c) => {
|
||||||
// POST /api/reminders — create a reminder
|
// POST /api/reminders — create a reminder
|
||||||
routes.post("/api/reminders", async (c) => {
|
routes.post("/api/reminders", async (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
const space = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
|
||||||
const body = await c.req.json();
|
const body = await c.req.json();
|
||||||
|
|
||||||
const { title, description, remindAt, allDay, timezone, notifyEmail, syncToCalendar, cronExpression } = body;
|
const { title, description, remindAt, allDay, timezone, notifyEmail, syncToCalendar, cronExpression } = body;
|
||||||
if (!title?.trim() || !remindAt)
|
if (!title?.trim() || !remindAt)
|
||||||
return c.json({ error: "title and remindAt required" }, 400);
|
return c.json({ error: "title and remindAt required" }, 400);
|
||||||
|
|
||||||
const docId = scheduleDocId(space);
|
const docId = scheduleDocId(dataSpace);
|
||||||
const doc = ensureDoc(space);
|
const doc = ensureDoc(dataSpace);
|
||||||
|
|
||||||
if (Object.keys(doc.reminders).length >= MAX_REMINDERS)
|
if (Object.keys(doc.reminders).length >= MAX_REMINDERS)
|
||||||
return c.json({ error: `Maximum ${MAX_REMINDERS} reminders reached` }, 400);
|
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
|
// Sync to calendar if requested
|
||||||
if (syncToCalendar) {
|
if (syncToCalendar) {
|
||||||
const eventId = syncReminderToCalendar(reminder, space);
|
const eventId = syncReminderToCalendar(reminder, dataSpace);
|
||||||
if (eventId) reminder.calendarEventId = eventId;
|
if (eventId) reminder.calendarEventId = eventId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1184,8 +1195,9 @@ routes.post("/api/reminders", async (c) => {
|
||||||
// GET /api/reminders/:id — get single reminder
|
// GET /api/reminders/:id — get single reminder
|
||||||
routes.get("/api/reminders/:id", (c) => {
|
routes.get("/api/reminders/:id", (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
const space = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
|
||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
const doc = ensureDoc(space);
|
const doc = ensureDoc(dataSpace);
|
||||||
|
|
||||||
const reminder = doc.reminders[id];
|
const reminder = doc.reminders[id];
|
||||||
if (!reminder) return c.json({ error: "Reminder not found" }, 404);
|
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
|
// PUT /api/reminders/:id — update a reminder
|
||||||
routes.put("/api/reminders/:id", async (c) => {
|
routes.put("/api/reminders/:id", async (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
const space = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
|
||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
const body = await c.req.json();
|
const body = await c.req.json();
|
||||||
|
|
||||||
const docId = scheduleDocId(space);
|
const docId = scheduleDocId(dataSpace);
|
||||||
const doc = ensureDoc(space);
|
const doc = ensureDoc(dataSpace);
|
||||||
if (!doc.reminders[id]) return c.json({ error: "Reminder not found" }, 404);
|
if (!doc.reminders[id]) return c.json({ error: "Reminder not found" }, 404);
|
||||||
|
|
||||||
_syncServer!.changeDoc<ScheduleDoc>(docId, `update reminder ${id}`, (d) => {
|
_syncServer!.changeDoc<ScheduleDoc>(docId, `update reminder ${id}`, (d) => {
|
||||||
|
|
@ -1222,16 +1235,17 @@ routes.put("/api/reminders/:id", async (c) => {
|
||||||
// DELETE /api/reminders/:id — delete (cascades to calendar)
|
// DELETE /api/reminders/:id — delete (cascades to calendar)
|
||||||
routes.delete("/api/reminders/:id", (c) => {
|
routes.delete("/api/reminders/:id", (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
const space = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
|
||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
|
|
||||||
const docId = scheduleDocId(space);
|
const docId = scheduleDocId(dataSpace);
|
||||||
const doc = ensureDoc(space);
|
const doc = ensureDoc(dataSpace);
|
||||||
const reminder = doc.reminders[id];
|
const reminder = doc.reminders[id];
|
||||||
if (!reminder) return c.json({ error: "Reminder not found" }, 404);
|
if (!reminder) return c.json({ error: "Reminder not found" }, 404);
|
||||||
|
|
||||||
// Cascade: delete linked calendar event
|
// Cascade: delete linked calendar event
|
||||||
if (reminder.calendarEventId) {
|
if (reminder.calendarEventId) {
|
||||||
deleteCalendarEvent(space, reminder.calendarEventId);
|
deleteCalendarEvent(dataSpace, reminder.calendarEventId);
|
||||||
}
|
}
|
||||||
|
|
||||||
_syncServer!.changeDoc<ScheduleDoc>(docId, `delete reminder ${id}`, (d) => {
|
_syncServer!.changeDoc<ScheduleDoc>(docId, `delete reminder ${id}`, (d) => {
|
||||||
|
|
@ -1244,10 +1258,11 @@ routes.delete("/api/reminders/:id", (c) => {
|
||||||
// POST /api/reminders/:id/complete — mark completed
|
// POST /api/reminders/:id/complete — mark completed
|
||||||
routes.post("/api/reminders/:id/complete", (c) => {
|
routes.post("/api/reminders/:id/complete", (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
const space = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
|
||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
|
|
||||||
const docId = scheduleDocId(space);
|
const docId = scheduleDocId(dataSpace);
|
||||||
const doc = ensureDoc(space);
|
const doc = ensureDoc(dataSpace);
|
||||||
if (!doc.reminders[id]) return c.json({ error: "Reminder not found" }, 404);
|
if (!doc.reminders[id]) return c.json({ error: "Reminder not found" }, 404);
|
||||||
|
|
||||||
_syncServer!.changeDoc<ScheduleDoc>(docId, `complete reminder ${id}`, (d) => {
|
_syncServer!.changeDoc<ScheduleDoc>(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
|
// POST /api/reminders/:id/snooze — reschedule to a new date
|
||||||
routes.post("/api/reminders/:id/snooze", async (c) => {
|
routes.post("/api/reminders/:id/snooze", async (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
const space = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
|
||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
const body = await c.req.json();
|
const body = await c.req.json();
|
||||||
|
|
||||||
const docId = scheduleDocId(space);
|
const docId = scheduleDocId(dataSpace);
|
||||||
const doc = ensureDoc(space);
|
const doc = ensureDoc(dataSpace);
|
||||||
if (!doc.reminders[id]) return c.json({ error: "Reminder not found" }, 404);
|
if (!doc.reminders[id]) return c.json({ error: "Reminder not found" }, 404);
|
||||||
|
|
||||||
const newRemindAt = body.remindAt
|
const newRemindAt = body.remindAt
|
||||||
|
|
@ -1287,7 +1303,7 @@ routes.post("/api/reminders/:id/snooze", async (c) => {
|
||||||
const updated = _syncServer!.getDoc<ScheduleDoc>(docId)!;
|
const updated = _syncServer!.getDoc<ScheduleDoc>(docId)!;
|
||||||
const reminder = updated.reminders[id];
|
const reminder = updated.reminders[id];
|
||||||
if (reminder?.calendarEventId) {
|
if (reminder?.calendarEventId) {
|
||||||
const calDocId = calendarDocId(space);
|
const calDocId = calendarDocId(dataSpace);
|
||||||
const duration = reminder.allDay ? 86400000 : 3600000;
|
const duration = reminder.allDay ? 86400000 : 3600000;
|
||||||
_syncServer!.changeDoc<CalendarDoc>(calDocId, `update reminder event time`, (d) => {
|
_syncServer!.changeDoc<CalendarDoc>(calDocId, `update reminder event time`, (d) => {
|
||||||
const ev = d.events[reminder.calendarEventId!];
|
const ev = d.events[reminder.calendarEventId!];
|
||||||
|
|
|
||||||
|
|
@ -179,10 +179,11 @@ routes.get("/api/feed", (c) =>
|
||||||
|
|
||||||
routes.post("/api/threads/:id/image", async (c) => {
|
routes.post("/api/threads/:id/image", async (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
const space = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
|
||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
if (!id || id.includes("..") || id.includes("/")) return c.json({ error: "Invalid ID" }, 400);
|
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 (!thread) return c.json({ error: "Thread not found" }, 404);
|
||||||
|
|
||||||
if (!process.env.FAL_KEY) return c.json({ error: "FAL_KEY not configured" }, 503);
|
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);
|
if (!imageUrl) return c.json({ error: "Failed to download image" }, 502);
|
||||||
|
|
||||||
// Update Automerge doc with image URL
|
// Update Automerge doc with image URL
|
||||||
const docId = socialsDocId(space);
|
const docId = socialsDocId(dataSpace);
|
||||||
_syncServer!.changeDoc<SocialsDoc>(docId, "set thread image", (d) => {
|
_syncServer!.changeDoc<SocialsDoc>(docId, "set thread image", (d) => {
|
||||||
if (d.threads?.[id]) {
|
if (d.threads?.[id]) {
|
||||||
d.threads[id].imageUrl = imageUrl;
|
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) => {
|
routes.post("/api/threads/:id/upload-image", async (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
const space = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
|
||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
if (!id || id.includes("..") || id.includes("/")) return c.json({ error: "Invalid ID" }, 400);
|
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 (!thread) return c.json({ error: "Thread not found" }, 404);
|
||||||
|
|
||||||
let formData: FormData;
|
let formData: FormData;
|
||||||
|
|
@ -246,7 +248,7 @@ routes.post("/api/threads/:id/upload-image", async (c) => {
|
||||||
const buffer = Buffer.from(await file.arrayBuffer());
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
const imageUrl = await saveUploadedFile(buffer, filename);
|
const imageUrl = await saveUploadedFile(buffer, filename);
|
||||||
|
|
||||||
const docId = socialsDocId(space);
|
const docId = socialsDocId(dataSpace);
|
||||||
_syncServer!.changeDoc<SocialsDoc>(docId, "upload thread image", (d) => {
|
_syncServer!.changeDoc<SocialsDoc>(docId, "upload thread image", (d) => {
|
||||||
if (d.threads?.[id]) {
|
if (d.threads?.[id]) {
|
||||||
d.threads[id].imageUrl = imageUrl;
|
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) => {
|
routes.post("/api/threads/:id/tweet/:index/upload-image", async (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
const space = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
|
||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
const index = c.req.param("index");
|
const index = c.req.param("index");
|
||||||
if (!id || id.includes("..") || id.includes("/")) return c.json({ error: "Invalid ID" }, 400);
|
if (!id || id.includes("..") || id.includes("/")) return c.json({ error: "Invalid ID" }, 400);
|
||||||
if (!/^\d+$/.test(index)) return c.json({ error: "Invalid index" }, 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) return c.json({ error: "Thread not found" }, 404);
|
||||||
|
|
||||||
let formData: FormData;
|
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 buffer = Buffer.from(await file.arrayBuffer());
|
||||||
const imageUrl = await saveUploadedFile(buffer, filename);
|
const imageUrl = await saveUploadedFile(buffer, filename);
|
||||||
|
|
||||||
const docId = socialsDocId(space);
|
const docId = socialsDocId(dataSpace);
|
||||||
_syncServer!.changeDoc<SocialsDoc>(docId, "upload tweet image", (d) => {
|
_syncServer!.changeDoc<SocialsDoc>(docId, "upload tweet image", (d) => {
|
||||||
if (d.threads?.[id]) {
|
if (d.threads?.[id]) {
|
||||||
if (!d.threads[id].tweetImages) d.threads[id].tweetImages = {} as any;
|
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) => {
|
routes.post("/api/threads/:id/tweet/:index/image", async (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
const space = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
|
||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
const index = c.req.param("index");
|
const index = c.req.param("index");
|
||||||
if (!id || id.includes("..") || id.includes("/")) return c.json({ error: "Invalid ID" }, 400);
|
if (!id || id.includes("..") || id.includes("/")) return c.json({ error: "Invalid ID" }, 400);
|
||||||
if (!/^\d+$/.test(index)) return c.json({ error: "Invalid index" }, 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) return c.json({ error: "Thread not found" }, 404);
|
||||||
|
|
||||||
const tweetIndex = parseInt(index, 10);
|
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);
|
if (!imageUrl) return c.json({ error: "Failed to download image" }, 502);
|
||||||
|
|
||||||
const docId = socialsDocId(space);
|
const docId = socialsDocId(dataSpace);
|
||||||
_syncServer!.changeDoc<SocialsDoc>(docId, "generate tweet image", (d) => {
|
_syncServer!.changeDoc<SocialsDoc>(docId, "generate tweet image", (d) => {
|
||||||
if (d.threads?.[id]) {
|
if (d.threads?.[id]) {
|
||||||
if (!d.threads[id].tweetImages) d.threads[id].tweetImages = {} as any;
|
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) => {
|
routes.delete("/api/threads/:id/tweet/:index/image", async (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
const space = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
|
||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
const index = c.req.param("index");
|
const index = c.req.param("index");
|
||||||
if (!id || id.includes("..") || id.includes("/")) return c.json({ error: "Invalid ID" }, 400);
|
if (!id || id.includes("..") || id.includes("/")) return c.json({ error: "Invalid ID" }, 400);
|
||||||
if (!/^\d+$/.test(index)) return c.json({ error: "Invalid index" }, 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) return c.json({ error: "Thread not found" }, 404);
|
||||||
|
|
||||||
if (!thread.tweetImages?.[index]) return c.json({ ok: true });
|
if (!thread.tweetImages?.[index]) return c.json({ ok: true });
|
||||||
|
|
||||||
await deleteImageFile(thread.tweetImages[index]);
|
await deleteImageFile(thread.tweetImages[index]);
|
||||||
|
|
||||||
const docId = socialsDocId(space);
|
const docId = socialsDocId(dataSpace);
|
||||||
_syncServer!.changeDoc<SocialsDoc>(docId, "remove tweet image", (d) => {
|
_syncServer!.changeDoc<SocialsDoc>(docId, "remove tweet image", (d) => {
|
||||||
if (d.threads?.[id]?.tweetImages?.[index]) {
|
if (d.threads?.[id]?.tweetImages?.[index]) {
|
||||||
delete 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) => {
|
routes.delete("/api/threads/:id/images", async (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
const space = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
|
||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
if (!id || id.includes("..") || id.includes("/")) return c.json({ error: "Invalid ID" }, 400);
|
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
|
if (!thread) return c.json({ ok: true }); // Thread already gone
|
||||||
|
|
||||||
// Clean up header image
|
// Clean up header image
|
||||||
|
|
@ -405,6 +411,7 @@ routes.delete("/api/threads/:id/images", async (c) => {
|
||||||
|
|
||||||
routes.get("/campaign", (c) => {
|
routes.get("/campaign", (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
const space = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
|
||||||
return c.html(renderShell({
|
return c.html(renderShell({
|
||||||
title: `Campaign — rSocials | rSpace`,
|
title: `Campaign — rSocials | rSpace`,
|
||||||
moduleId: "rsocials",
|
moduleId: "rsocials",
|
||||||
|
|
@ -419,10 +426,11 @@ routes.get("/campaign", (c) => {
|
||||||
|
|
||||||
routes.get("/thread/:id", async (c) => {
|
routes.get("/thread/:id", async (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
const space = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
|
||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
if (!id || id.includes("..") || id.includes("/")) return c.text("Not found", 404);
|
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);
|
if (!thread) return c.text("Thread not found", 404);
|
||||||
|
|
||||||
// OG tags for social crawlers (SSR)
|
// OG tags for social crawlers (SSR)
|
||||||
|
|
@ -461,10 +469,11 @@ routes.get("/thread/:id", async (c) => {
|
||||||
|
|
||||||
routes.get("/thread/:id/edit", async (c) => {
|
routes.get("/thread/:id/edit", async (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
const space = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
|
||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
if (!id || id.includes("..") || id.includes("/")) return c.text("Not found", 404);
|
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);
|
if (!thread) return c.text("Thread not found", 404);
|
||||||
|
|
||||||
const dataScript = `<script>window.__THREAD_DATA__ = ${JSON.stringify(thread).replace(/</g, "\\u003c")};</script>`;
|
const dataScript = `<script>window.__THREAD_DATA__ = ${JSON.stringify(thread).replace(/</g, "\\u003c")};</script>`;
|
||||||
|
|
@ -483,6 +492,7 @@ routes.get("/thread/:id/edit", async (c) => {
|
||||||
|
|
||||||
routes.get("/thread", (c) => {
|
routes.get("/thread", (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
const space = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
|
||||||
return c.html(renderShell({
|
return c.html(renderShell({
|
||||||
title: `Thread Builder — rSocials | rSpace`,
|
title: `Thread Builder — rSocials | rSpace`,
|
||||||
moduleId: "rsocials",
|
moduleId: "rsocials",
|
||||||
|
|
@ -497,6 +507,7 @@ routes.get("/thread", (c) => {
|
||||||
|
|
||||||
routes.get("/threads", (c) => {
|
routes.get("/threads", (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
const space = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
|
||||||
return c.html(renderShell({
|
return c.html(renderShell({
|
||||||
title: `Threads — rSocials | rSpace`,
|
title: `Threads — rSocials | rSpace`,
|
||||||
moduleId: "rsocials",
|
moduleId: "rsocials",
|
||||||
|
|
@ -511,6 +522,7 @@ routes.get("/threads", (c) => {
|
||||||
|
|
||||||
routes.get("/campaigns", (c) => {
|
routes.get("/campaigns", (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
const space = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
|
||||||
return c.redirect(`/${space}/rsocials/campaign`);
|
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) => {
|
routes.get("/scheduler", (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
const space = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
|
||||||
return c.html(renderExternalAppShell({
|
return c.html(renderExternalAppShell({
|
||||||
title: `Post Scheduler — rSocials | rSpace`,
|
title: `Post Scheduler — rSocials | rSpace`,
|
||||||
moduleId: "rsocials",
|
moduleId: "rsocials",
|
||||||
|
|
@ -571,6 +584,7 @@ routes.get("/scheduler", (c) => {
|
||||||
|
|
||||||
routes.get("/feed", (c) => {
|
routes.get("/feed", (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
const space = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
|
||||||
const isDemo = space === "demo";
|
const isDemo = space === "demo";
|
||||||
const body = isDemo ? renderDemoFeedHTML() : renderLanding();
|
const body = isDemo ? renderDemoFeedHTML() : renderLanding();
|
||||||
const styles = isDemo
|
const styles = isDemo
|
||||||
|
|
@ -589,6 +603,7 @@ routes.get("/feed", (c) => {
|
||||||
|
|
||||||
routes.get("/landing", (c) => {
|
routes.get("/landing", (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
const space = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
|
||||||
return c.html(renderShell({
|
return c.html(renderShell({
|
||||||
title: `${space} — rSocials | rSpace`,
|
title: `${space} — rSocials | rSpace`,
|
||||||
moduleId: "rsocials",
|
moduleId: "rsocials",
|
||||||
|
|
@ -604,6 +619,7 @@ routes.get("/landing", (c) => {
|
||||||
|
|
||||||
routes.get("/", (c) => {
|
routes.get("/", (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
const space = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
|
||||||
return c.html(renderShell({
|
return c.html(renderShell({
|
||||||
title: `${space} — rSocials | rSpace`,
|
title: `${space} — rSocials | rSpace`,
|
||||||
moduleId: "rsocials",
|
moduleId: "rsocials",
|
||||||
|
|
|
||||||
|
|
@ -205,11 +205,12 @@ const routes = new Hono();
|
||||||
// ── API: List splats ──
|
// ── API: List splats ──
|
||||||
routes.get("/api/splats", async (c) => {
|
routes.get("/api/splats", async (c) => {
|
||||||
const spaceSlug = c.req.param("space") || "demo";
|
const spaceSlug = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = (c.get("effectiveSpace" as any) as string) || spaceSlug;
|
||||||
const tag = c.req.query("tag");
|
const tag = c.req.query("tag");
|
||||||
const limit = Math.min(parseInt(c.req.query("limit") || "50"), 100);
|
const limit = Math.min(parseInt(c.req.query("limit") || "50"), 100);
|
||||||
const offset = parseInt(c.req.query("offset") || "0");
|
const offset = parseInt(c.req.query("offset") || "0");
|
||||||
|
|
||||||
const doc = ensureDoc(spaceSlug);
|
const doc = ensureDoc(dataSpace);
|
||||||
|
|
||||||
let items = Object.values(doc.items)
|
let items = Object.values(doc.items)
|
||||||
.filter((item) => item.status === 'published');
|
.filter((item) => item.status === 'published');
|
||||||
|
|
@ -230,9 +231,10 @@ routes.get("/api/splats", async (c) => {
|
||||||
// ── API: Get splat details ──
|
// ── API: Get splat details ──
|
||||||
routes.get("/api/splats/:id", async (c) => {
|
routes.get("/api/splats/:id", async (c) => {
|
||||||
const spaceSlug = c.req.param("space") || "demo";
|
const spaceSlug = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = (c.get("effectiveSpace" as any) as string) || spaceSlug;
|
||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
|
|
||||||
const doc = ensureDoc(spaceSlug);
|
const doc = ensureDoc(dataSpace);
|
||||||
const found = findItem(doc, id);
|
const found = findItem(doc, id);
|
||||||
|
|
||||||
if (!found || found[1].status !== 'published') {
|
if (!found || found[1].status !== 'published') {
|
||||||
|
|
@ -242,7 +244,7 @@ routes.get("/api/splats/:id", async (c) => {
|
||||||
const [itemKey, item] = found;
|
const [itemKey, item] = found;
|
||||||
|
|
||||||
// Increment view count
|
// Increment view count
|
||||||
const docId = splatScenesDocId(spaceSlug);
|
const docId = splatScenesDocId(dataSpace);
|
||||||
_syncServer!.changeDoc<SplatScenesDoc>(docId, 'increment view count', (d) => {
|
_syncServer!.changeDoc<SplatScenesDoc>(docId, 'increment view count', (d) => {
|
||||||
d.items[itemKey].viewCount += 1;
|
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)
|
// Matches both /api/splats/:id/file and /api/splats/:id/:filename (e.g. rainbow-sphere.splat)
|
||||||
routes.get("/api/splats/:id/:filename", async (c) => {
|
routes.get("/api/splats/:id/:filename", async (c) => {
|
||||||
const spaceSlug = c.req.param("space") || "demo";
|
const spaceSlug = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = (c.get("effectiveSpace" as any) as string) || spaceSlug;
|
||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
|
|
||||||
const doc = ensureDoc(spaceSlug);
|
const doc = ensureDoc(dataSpace);
|
||||||
const found = findItem(doc, id);
|
const found = findItem(doc, id);
|
||||||
|
|
||||||
if (!found || found[1].status !== 'published') {
|
if (!found || found[1].status !== 'published') {
|
||||||
|
|
@ -307,6 +310,7 @@ routes.post("/api/splats", async (c) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const spaceSlug = c.req.param("space") || "demo";
|
const spaceSlug = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = (c.get("effectiveSpace" as any) as string) || spaceSlug;
|
||||||
const formData = await c.req.formData();
|
const formData = await c.req.formData();
|
||||||
const file = formData.get("file") as File | null;
|
const file = formData.get("file") as File | null;
|
||||||
const title = (formData.get("title") as string || "").trim();
|
const title = (formData.get("title") as string || "").trim();
|
||||||
|
|
@ -336,7 +340,7 @@ routes.post("/api/splats", async (c) => {
|
||||||
let slug = slugify(title);
|
let slug = slugify(title);
|
||||||
|
|
||||||
// Check slug collision in Automerge doc
|
// 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);
|
const slugExists = Object.values(doc.items).some((item) => item.slug === slug);
|
||||||
if (slugExists) {
|
if (slugExists) {
|
||||||
slug = `${slug}-${shortId}`;
|
slug = `${slug}-${shortId}`;
|
||||||
|
|
@ -354,7 +358,7 @@ routes.post("/api/splats", async (c) => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const paymentTx = (c as any).get("x402Payment") || null;
|
const paymentTx = (c as any).get("x402Payment") || null;
|
||||||
|
|
||||||
const docId = splatScenesDocId(spaceSlug);
|
const docId = splatScenesDocId(dataSpace);
|
||||||
_syncServer!.changeDoc<SplatScenesDoc>(docId, 'add splat', (d) => {
|
_syncServer!.changeDoc<SplatScenesDoc>(docId, 'add splat', (d) => {
|
||||||
d.items[splatId] = {
|
d.items[splatId] = {
|
||||||
id: splatId,
|
id: splatId,
|
||||||
|
|
@ -418,6 +422,7 @@ routes.post("/api/splats/from-media", async (c) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const spaceSlug = c.req.param("space") || "demo";
|
const spaceSlug = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = (c.get("effectiveSpace" as any) as string) || spaceSlug;
|
||||||
const formData = await c.req.formData();
|
const formData = await c.req.formData();
|
||||||
const title = (formData.get("title") as string || "").trim();
|
const title = (formData.get("title") as string || "").trim();
|
||||||
const description = (formData.get("description") as string || "").trim() || null;
|
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);
|
let slug = slugify(title);
|
||||||
|
|
||||||
// Check slug collision in Automerge doc
|
// 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);
|
const slugExists = Object.values(doc.items).some((item) => item.slug === slug);
|
||||||
if (slugExists) {
|
if (slugExists) {
|
||||||
slug = `${slug}-${shortId}`;
|
slug = `${slug}-${shortId}`;
|
||||||
|
|
@ -496,7 +501,7 @@ routes.post("/api/splats/from-media", async (c) => {
|
||||||
|
|
||||||
// Insert splat record (pending processing) into Automerge doc
|
// Insert splat record (pending processing) into Automerge doc
|
||||||
const paymentTx = (c as any).get("x402Payment") || null;
|
const paymentTx = (c as any).get("x402Payment") || null;
|
||||||
const docId = splatScenesDocId(spaceSlug);
|
const docId = splatScenesDocId(dataSpace);
|
||||||
_syncServer!.changeDoc<SplatScenesDoc>(docId, 'add splat from media', (d) => {
|
_syncServer!.changeDoc<SplatScenesDoc>(docId, 'add splat from media', (d) => {
|
||||||
d.items[splatId] = {
|
d.items[splatId] = {
|
||||||
id: splatId,
|
id: splatId,
|
||||||
|
|
@ -549,9 +554,10 @@ routes.delete("/api/splats/:id", async (c) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const spaceSlug = c.req.param("space") || "demo";
|
const spaceSlug = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = (c.get("effectiveSpace" as any) as string) || spaceSlug;
|
||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
|
|
||||||
const doc = ensureDoc(spaceSlug);
|
const doc = ensureDoc(dataSpace);
|
||||||
const found = findItem(doc, id);
|
const found = findItem(doc, id);
|
||||||
|
|
||||||
if (!found || found[1].status !== 'published') {
|
if (!found || found[1].status !== 'published') {
|
||||||
|
|
@ -563,7 +569,7 @@ routes.delete("/api/splats/:id", async (c) => {
|
||||||
return c.json({ error: "Not authorized" }, 403);
|
return c.json({ error: "Not authorized" }, 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
const docId = splatScenesDocId(spaceSlug);
|
const docId = splatScenesDocId(dataSpace);
|
||||||
_syncServer!.changeDoc<SplatScenesDoc>(docId, 'remove splat', (d) => {
|
_syncServer!.changeDoc<SplatScenesDoc>(docId, 'remove splat', (d) => {
|
||||||
d.items[itemKey].status = 'removed';
|
d.items[itemKey].status = 'removed';
|
||||||
});
|
});
|
||||||
|
|
@ -574,8 +580,9 @@ routes.delete("/api/splats/:id", async (c) => {
|
||||||
// ── Page: Gallery ──
|
// ── Page: Gallery ──
|
||||||
routes.get("/", async (c) => {
|
routes.get("/", async (c) => {
|
||||||
const spaceSlug = c.req.param("space") || "demo";
|
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)
|
const items = Object.values(doc.items)
|
||||||
.filter((item) => item.status === 'published')
|
.filter((item) => item.status === 'published')
|
||||||
|
|
@ -612,9 +619,10 @@ routes.get("/", async (c) => {
|
||||||
// ── Page: Viewer ──
|
// ── Page: Viewer ──
|
||||||
routes.get("/view/:id", async (c) => {
|
routes.get("/view/:id", async (c) => {
|
||||||
const spaceSlug = c.req.param("space") || "demo";
|
const spaceSlug = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = (c.get("effectiveSpace" as any) as string) || spaceSlug;
|
||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
|
|
||||||
const doc = ensureDoc(spaceSlug);
|
const doc = ensureDoc(dataSpace);
|
||||||
const found = findItem(doc, id);
|
const found = findItem(doc, id);
|
||||||
|
|
||||||
if (!found || found[1].status !== 'published') {
|
if (!found || found[1].status !== 'published') {
|
||||||
|
|
@ -632,7 +640,7 @@ routes.get("/view/:id", async (c) => {
|
||||||
const [itemKey, splat] = found;
|
const [itemKey, splat] = found;
|
||||||
|
|
||||||
// Increment view count
|
// Increment view count
|
||||||
const docId = splatScenesDocId(spaceSlug);
|
const docId = splatScenesDocId(dataSpace);
|
||||||
_syncServer!.changeDoc<SplatScenesDoc>(docId, 'increment view count', (d) => {
|
_syncServer!.changeDoc<SplatScenesDoc>(docId, 'increment view count', (d) => {
|
||||||
d.items[itemKey].viewCount += 1;
|
d.items[itemKey].viewCount += 1;
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -230,6 +230,7 @@ routes.get("/api/artifact/:id", async (c) => {
|
||||||
// ── Page route: swag designer ──
|
// ── Page route: swag designer ──
|
||||||
routes.get("/", (c) => {
|
routes.get("/", (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
const space = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
|
||||||
return c.html(renderShell({
|
return c.html(renderShell({
|
||||||
title: `Swag Designer | rSpace`,
|
title: `Swag Designer | rSpace`,
|
||||||
moduleId: "rswag",
|
moduleId: "rswag",
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,8 @@ const routes = new Hono();
|
||||||
// GET /api/trips — list trips
|
// GET /api/trips — list trips
|
||||||
routes.get("/api/trips", async (c) => {
|
routes.get("/api/trips", async (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
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 rows = docIds.map((docId) => {
|
||||||
const doc = _syncServer!.getDoc<TripDoc>(docId);
|
const doc = _syncServer!.getDoc<TripDoc>(docId);
|
||||||
|
|
@ -102,11 +103,12 @@ routes.post("/api/trips", async (c) => {
|
||||||
if (!title?.trim()) return c.json({ error: "Title required" }, 400);
|
if (!title?.trim()) return c.json({ error: "Title required" }, 400);
|
||||||
|
|
||||||
const space = c.req.param("space") || "demo";
|
const space = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
|
||||||
const tripId = newId();
|
const tripId = newId();
|
||||||
const slug = title.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
const slug = title.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
const docId = tripDocId(space, tripId);
|
const docId = tripDocId(dataSpace, tripId);
|
||||||
let doc = Automerge.change(Automerge.init<TripDoc>(), 'create trip', (d) => {
|
let doc = Automerge.change(Automerge.init<TripDoc>(), 'create trip', (d) => {
|
||||||
const init = tripSchema.init();
|
const init = tripSchema.init();
|
||||||
d.meta = init.meta;
|
d.meta = init.meta;
|
||||||
|
|
@ -140,8 +142,9 @@ routes.post("/api/trips", async (c) => {
|
||||||
// GET /api/trips/:id — trip detail with all sub-resources
|
// GET /api/trips/:id — trip detail with all sub-resources
|
||||||
routes.get("/api/trips/:id", async (c) => {
|
routes.get("/api/trips/:id", async (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
const space = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
|
||||||
const tripId = c.req.param("id");
|
const tripId = c.req.param("id");
|
||||||
const docId = tripDocId(space, tripId);
|
const docId = tripDocId(dataSpace, tripId);
|
||||||
const doc = _syncServer!.getDoc<TripDoc>(docId);
|
const doc = _syncServer!.getDoc<TripDoc>(docId);
|
||||||
if (!doc) return c.json({ error: "Trip not found" }, 404);
|
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
|
// PUT /api/trips/:id — update trip
|
||||||
routes.put("/api/trips/:id", async (c) => {
|
routes.put("/api/trips/:id", async (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
const space = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
|
||||||
const tripId = c.req.param("id");
|
const tripId = c.req.param("id");
|
||||||
const docId = tripDocId(space, tripId);
|
const docId = tripDocId(dataSpace, tripId);
|
||||||
const doc = _syncServer!.getDoc<TripDoc>(docId);
|
const doc = _syncServer!.getDoc<TripDoc>(docId);
|
||||||
if (!doc) return c.json({ error: "Not found" }, 404);
|
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); }
|
try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
|
||||||
|
|
||||||
const space = c.req.param("space") || "demo";
|
const space = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
|
||||||
const tripId = c.req.param("id");
|
const tripId = c.req.param("id");
|
||||||
ensureDoc(space, tripId);
|
ensureDoc(dataSpace, tripId);
|
||||||
const docId = tripDocId(space, tripId);
|
const docId = tripDocId(dataSpace, tripId);
|
||||||
|
|
||||||
const body = await c.req.json();
|
const body = await c.req.json();
|
||||||
const destId = newId();
|
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); }
|
try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
|
||||||
|
|
||||||
const space = c.req.param("space") || "demo";
|
const space = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
|
||||||
const tripId = c.req.param("id");
|
const tripId = c.req.param("id");
|
||||||
ensureDoc(space, tripId);
|
ensureDoc(dataSpace, tripId);
|
||||||
const docId = tripDocId(space, tripId);
|
const docId = tripDocId(dataSpace, tripId);
|
||||||
|
|
||||||
const body = await c.req.json();
|
const body = await c.req.json();
|
||||||
const itemId = newId();
|
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); }
|
try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
|
||||||
|
|
||||||
const space = c.req.param("space") || "demo";
|
const space = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
|
||||||
const tripId = c.req.param("id");
|
const tripId = c.req.param("id");
|
||||||
ensureDoc(space, tripId);
|
ensureDoc(dataSpace, tripId);
|
||||||
const docId = tripDocId(space, tripId);
|
const docId = tripDocId(dataSpace, tripId);
|
||||||
|
|
||||||
const body = await c.req.json();
|
const body = await c.req.json();
|
||||||
const bookingId = newId();
|
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); }
|
try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
|
||||||
|
|
||||||
const space = c.req.param("space") || "demo";
|
const space = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
|
||||||
const tripId = c.req.param("id");
|
const tripId = c.req.param("id");
|
||||||
ensureDoc(space, tripId);
|
ensureDoc(dataSpace, tripId);
|
||||||
const docId = tripDocId(space, tripId);
|
const docId = tripDocId(dataSpace, tripId);
|
||||||
|
|
||||||
const body = await c.req.json();
|
const body = await c.req.json();
|
||||||
const expenseId = newId();
|
const expenseId = newId();
|
||||||
|
|
@ -338,8 +346,9 @@ routes.post("/api/trips/:id/expenses", async (c) => {
|
||||||
|
|
||||||
routes.get("/api/trips/:id/packing", async (c) => {
|
routes.get("/api/trips/:id/packing", async (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
const space = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
|
||||||
const tripId = c.req.param("id");
|
const tripId = c.req.param("id");
|
||||||
const docId = tripDocId(space, tripId);
|
const docId = tripDocId(dataSpace, tripId);
|
||||||
const doc = _syncServer!.getDoc<TripDoc>(docId);
|
const doc = _syncServer!.getDoc<TripDoc>(docId);
|
||||||
if (!doc) return c.json([]);
|
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); }
|
try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
|
||||||
|
|
||||||
const space = c.req.param("space") || "demo";
|
const space = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
|
||||||
const tripId = c.req.param("id");
|
const tripId = c.req.param("id");
|
||||||
ensureDoc(space, tripId);
|
ensureDoc(dataSpace, tripId);
|
||||||
const docId = tripDocId(space, tripId);
|
const docId = tripDocId(dataSpace, tripId);
|
||||||
|
|
||||||
const body = await c.req.json();
|
const body = await c.req.json();
|
||||||
const itemId = newId();
|
const itemId = newId();
|
||||||
|
|
@ -384,10 +394,11 @@ routes.post("/api/trips/:id/packing", async (c) => {
|
||||||
|
|
||||||
routes.patch("/api/packing/:id", async (c) => {
|
routes.patch("/api/packing/:id", async (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
const space = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
|
||||||
const packingId = c.req.param("id");
|
const packingId = c.req.param("id");
|
||||||
|
|
||||||
// Find the trip doc containing this packing item
|
// Find the trip doc containing this packing item
|
||||||
const docIds = listTripDocIds(space);
|
const docIds = listTripDocIds(dataSpace);
|
||||||
for (const docId of docIds) {
|
for (const docId of docIds) {
|
||||||
const doc = _syncServer!.getDoc<TripDoc>(docId);
|
const doc = _syncServer!.getDoc<TripDoc>(docId);
|
||||||
if (!doc || !doc.packingItems[packingId]) continue;
|
if (!doc || !doc.packingItems[packingId]) continue;
|
||||||
|
|
@ -423,6 +434,7 @@ routes.post("/api/route", async (c) => {
|
||||||
// ── Route planner page ──
|
// ── Route planner page ──
|
||||||
routes.get("/routes", (c) => {
|
routes.get("/routes", (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
const space = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
|
||||||
return c.html(renderShell({
|
return c.html(renderShell({
|
||||||
title: `${space} — Route Planner | rTrips`,
|
title: `${space} — Route Planner | rTrips`,
|
||||||
moduleId: "rtrips",
|
moduleId: "rtrips",
|
||||||
|
|
@ -438,6 +450,7 @@ routes.get("/routes", (c) => {
|
||||||
// ── Page route ──
|
// ── Page route ──
|
||||||
routes.get("/", (c) => {
|
routes.get("/", (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
const space = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
|
||||||
return c.html(renderShell({
|
return c.html(renderShell({
|
||||||
title: `${space} — Trips | rSpace`,
|
title: `${space} — Trips | rSpace`,
|
||||||
moduleId: "rtrips",
|
moduleId: "rtrips",
|
||||||
|
|
|
||||||
|
|
@ -192,6 +192,7 @@ routes.get("/api/health", (c) => c.json({ ok: true }));
|
||||||
// ── Page route ──
|
// ── Page route ──
|
||||||
routes.get("/", (c) => {
|
routes.get("/", (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
const space = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
|
||||||
return c.html(renderShell({
|
return c.html(renderShell({
|
||||||
title: `${space} — Tube | rSpace`,
|
title: `${space} — Tube | rSpace`,
|
||||||
moduleId: "rtube",
|
moduleId: "rtube",
|
||||||
|
|
|
||||||
|
|
@ -383,6 +383,7 @@ interface BalanceItem {
|
||||||
// ── Page route ──
|
// ── Page route ──
|
||||||
routes.get("/", (c) => {
|
routes.get("/", (c) => {
|
||||||
const spaceSlug = c.req.param("space") || "demo";
|
const spaceSlug = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = (c.get("effectiveSpace" as any) as string) || spaceSlug;
|
||||||
return c.html(renderShell({
|
return c.html(renderShell({
|
||||||
title: `${spaceSlug} — Wallet | rSpace`,
|
title: `${spaceSlug} — Wallet | rSpace`,
|
||||||
moduleId: "rwallet",
|
moduleId: "rwallet",
|
||||||
|
|
|
||||||
|
|
@ -393,6 +393,7 @@ routes.get("/api/spaces/:slug/activity", async (c) => {
|
||||||
// ── Page route ──
|
// ── Page route ──
|
||||||
routes.get("/", (c) => {
|
routes.get("/", (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
const space = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
|
||||||
return c.html(renderShell({
|
return c.html(renderShell({
|
||||||
title: `${space} — Work | rSpace`,
|
title: `${space} — Work | rSpace`,
|
||||||
moduleId: "rwork",
|
moduleId: "rwork",
|
||||||
|
|
|
||||||
|
|
@ -1660,6 +1660,7 @@ app.get("/:space/:moduleId/template", async (c) => {
|
||||||
// ── Empty-state detection for onboarding ──
|
// ── Empty-state detection for onboarding ──
|
||||||
|
|
||||||
import type { RSpaceModule } from "../shared/module";
|
import type { RSpaceModule } from "../shared/module";
|
||||||
|
import { resolveDataSpace } from "../shared/scope-resolver";
|
||||||
|
|
||||||
function moduleHasData(space: string, mod: RSpaceModule): boolean {
|
function moduleHasData(space: string, mod: RSpaceModule): boolean {
|
||||||
if (space === "demo") return true; // demo always has data
|
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();
|
if (!space || space === "api" || space.includes(".")) return next();
|
||||||
|
|
||||||
// Check enabled modules (skip for core rspace module)
|
// Check enabled modules (skip for core rspace module)
|
||||||
|
const doc = getDocumentData(space);
|
||||||
if (mod.id !== "rspace") {
|
if (mod.id !== "rspace") {
|
||||||
const doc = getDocumentData(space);
|
|
||||||
if (doc?.meta?.enabledModules && !doc.meta.enabledModules.includes(mod.id)) {
|
if (doc?.meta?.enabledModules && !doc.meta.enabledModules.includes(mod.id)) {
|
||||||
return c.json({ error: "Module not enabled for this space" }, 404);
|
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
|
// Resolve caller's role for write-method blocking
|
||||||
const method = c.req.method;
|
const method = c.req.method;
|
||||||
if (!mod.publicWrite && (method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE")) {
|
if (!mod.publicWrite && (method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE")) {
|
||||||
|
|
|
||||||
109
server/shell.ts
109
server/shell.ts
|
|
@ -87,6 +87,10 @@ export function renderShell(opts: ShellOptions): string {
|
||||||
const enabledModules = opts.enabledModules ?? spaceMeta?.enabledModules ?? null;
|
const enabledModules = opts.enabledModules ?? spaceMeta?.enabledModules ?? null;
|
||||||
const spaceEncrypted = opts.spaceEncrypted ?? spaceMeta?.spaceEncrypted ?? false;
|
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)
|
// Filter modules by enabledModules (null = show all)
|
||||||
const visibleModules = enabledModules
|
const visibleModules = enabledModules
|
||||||
? modules.filter(m => m.id === "rspace" || enabledModules.includes(m.id))
|
? modules.filter(m => m.id === "rspace" || enabledModules.includes(m.id))
|
||||||
|
|
@ -119,7 +123,7 @@ export function renderShell(opts: ShellOptions): string {
|
||||||
<style>${WELCOME_CSS}</style>
|
<style>${WELCOME_CSS}</style>
|
||||||
<style>${ACCESS_GATE_CSS}</style>
|
<style>${ACCESS_GATE_CSS}</style>
|
||||||
</head>
|
</head>
|
||||||
<body data-space-visibility="${escapeAttr(spaceVisibility)}" data-space-slug="${escapeAttr(spaceSlug)}">
|
<body data-space-visibility="${escapeAttr(spaceVisibility)}" data-space-slug="${escapeAttr(spaceSlug)}" data-scope-overrides="${escapeAttr(JSON.stringify(scopeOverrides))}">
|
||||||
<header class="rstack-header">
|
<header class="rstack-header">
|
||||||
<div class="rstack-header__left">
|
<div class="rstack-header__left">
|
||||||
<a href="/" style="display:flex;align-items:center;margin-right:4px"><img src="/favicon.png" alt="rSpace" class="rstack-header__logo"></a>
|
<a href="/" style="display:flex;align-items:center;margin-right:4px"><img src="/favicon.png" alt="rSpace" class="rstack-header__logo"></a>
|
||||||
|
|
@ -186,8 +190,9 @@ export function renderShell(opts: ShellOptions): string {
|
||||||
|
|
||||||
// Restore saved theme preference across header / tab-row
|
// 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){}})();
|
(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
|
// Provide module list to app switcher and offline runtime
|
||||||
document.querySelector('rstack-app-switcher')?.setModules(${moduleListJSON});
|
window.__rspaceModuleList = ${moduleListJSON};
|
||||||
|
document.querySelector('rstack-app-switcher')?.setModules(window.__rspaceModuleList);
|
||||||
|
|
||||||
// ── "Try Demo" button visibility ──
|
// ── "Try Demo" button visibility ──
|
||||||
// Hidden when logged in. When logged out, shown everywhere except demo.rspace.online
|
// 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) => {
|
tabBar.addEventListener('layer-close', (e) => {
|
||||||
const { layerId } = e.detail;
|
const { layerId } = e.detail;
|
||||||
|
const closedLayer = layers.find(l => l.id === layerId);
|
||||||
const closedModuleId = layerId.replace('layer-', '');
|
const closedModuleId = layerId.replace('layer-', '');
|
||||||
tabBar.removeLayer(layerId);
|
tabBar.removeLayer(layerId);
|
||||||
layers = layers.filter(l => l.id !== layerId);
|
layers = layers.filter(l => l.id !== layerId);
|
||||||
saveTabs();
|
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
|
// Remove cached pane from DOM
|
||||||
if (tabCache) tabCache.removePane(closedModuleId);
|
if (tabCache) tabCache.removePane(closedModuleId);
|
||||||
// If we closed the active tab, switch to the first remaining
|
// 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 } }));
|
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 ──
|
// ── App-switcher → tab system integration ──
|
||||||
// When user picks a module from the app-switcher, route through tabs
|
// When user picks a module from the app-switcher, route through tabs
|
||||||
// instead of doing a full page navigation.
|
// 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
|
// Expose tabBar for CommunitySync integration
|
||||||
window.__rspaceTabBar = tabBar;
|
window.__rspaceTabBar = tabBar;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -301,11 +301,16 @@ export class RStackAppSwitcher extends HTMLElement {
|
||||||
document.addEventListener("click", this.#outsideClickHandler);
|
document.addEventListener("click", this.#outsideClickHandler);
|
||||||
|
|
||||||
// Intercept same-origin module links → dispatch event for tab system
|
// 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) => {
|
this.#shadow.querySelectorAll("a.item").forEach((el) => {
|
||||||
el.addEventListener("click", (e) => {
|
el.addEventListener("click", (e) => {
|
||||||
const moduleId = (el as HTMLElement).dataset.id;
|
const moduleId = (el as HTMLElement).dataset.id;
|
||||||
if (!moduleId) return;
|
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;
|
const href = (el as HTMLAnchorElement).href;
|
||||||
try {
|
try {
|
||||||
const url = new URL(href, window.location.href);
|
const url = new URL(href, window.location.href);
|
||||||
|
|
|
||||||
|
|
@ -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
|
// Attach Edit Space gear button listeners
|
||||||
menu.querySelectorAll(".item-gear").forEach((btn) => {
|
menu.querySelectorAll(".item-gear").forEach((btn) => {
|
||||||
btn.addEventListener("click", (e) => {
|
btn.addEventListener("click", (e) => {
|
||||||
|
|
|
||||||
|
|
@ -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<string, string> = {};
|
||||||
|
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 = `<div class="space-layer-picker">
|
||||||
|
<div class="space-layer-picker__header">
|
||||||
|
<span>Add Space Layer</span>
|
||||||
|
<button class="space-layer-picker__close" id="slp-close">×</button>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
if (spaces.length === 0) {
|
||||||
|
pickerHtml += `<div class="space-layer-picker__empty">No other spaces available</div>`;
|
||||||
|
} else {
|
||||||
|
for (const s of spaces) {
|
||||||
|
const roleLabel = s.role === "viewer" ? "view only" : s.role || "";
|
||||||
|
pickerHtml += `
|
||||||
|
<button class="space-layer-picker__item" data-slug="${s.slug}" data-name="${(s.name || s.slug).replace(/"/g, '"')}" data-role="${s.role || 'viewer'}">
|
||||||
|
<span class="space-layer-picker__icon">${s.icon || "🌐"}</span>
|
||||||
|
<span class="space-layer-picker__name">${s.name || s.slug}</span>
|
||||||
|
<span class="space-layer-picker__role">${roleLabel}</span>
|
||||||
|
</button>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pickerHtml += `</div>`;
|
||||||
|
|
||||||
|
// 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<HTMLElement>(".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 ──
|
// ── Feed compatibility helpers ──
|
||||||
|
|
||||||
/** Get the set of FlowKinds a module can output (from its feeds) */
|
/** 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 isActive = layer.id === activeId;
|
||||||
const badgeColor = layer.color || badge?.color || "#94a3b8";
|
const badgeColor = layer.color || badge?.color || "#94a3b8";
|
||||||
|
|
||||||
|
const isSpaceLayer = !!layer.spaceSlug;
|
||||||
|
const spaceLayerClass = isSpaceLayer ? " tab--space-layer" : "";
|
||||||
|
const spaceTag = isSpaceLayer
|
||||||
|
? `<span class="tab-space-tag" title="From ${layer.spaceSlug}">${layer.spaceSlug}</span>`
|
||||||
|
: "";
|
||||||
|
const readOnlyTag = isSpaceLayer && layer.spaceRole === "viewer"
|
||||||
|
? `<span class="tab-readonly-tag" title="View only">👁</span>`
|
||||||
|
: "";
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="tab ${isActive ? "active" : ""}"
|
<div class="tab ${isActive ? "active" : ""}${spaceLayerClass}"
|
||||||
data-layer-id="${layer.id}"
|
data-layer-id="${layer.id}"
|
||||||
data-module-id="${layer.moduleId}"
|
data-module-id="${layer.moduleId}"
|
||||||
|
${isSpaceLayer ? `data-space-slug="${layer.spaceSlug}"` : ""}
|
||||||
draggable="true">
|
draggable="true">
|
||||||
<span class="tab-indicator" style="background:${badgeColor}"></span>
|
<span class="tab-indicator" style="background:${badgeColor}"></span>
|
||||||
<span class="tab-badge" style="background:${badgeColor}">${badge?.badge || layer.moduleId.slice(0, 2)}</span>
|
<span class="tab-badge" style="background:${badgeColor}">${badge?.badge || layer.moduleId.slice(0, 2)}</span>
|
||||||
<span class="tab-label">${layer.label}</span>
|
<span class="tab-label">${layer.label}</span>
|
||||||
|
${spaceTag}${readOnlyTag}
|
||||||
${this.#layers.length > 1 ? `<button class="tab-close" data-close="${layer.id}">×</button>` : ""}
|
${this.#layers.length > 1 ? `<button class="tab-close" data-close="${layer.id}">×</button>` : ""}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
@ -385,6 +504,16 @@ export class RStackTabBar extends HTMLElement {
|
||||||
html += uncategorized.map(m => this.#renderAddMenuItem(m, existingModuleIds.has(m.id))).join("");
|
html += uncategorized.map(m => this.#renderAddMenuItem(m, existingModuleIds.has(m.id))).join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Add Space Layer section ──
|
||||||
|
html += `<div class="add-menu-divider"></div>`;
|
||||||
|
html += `<button class="add-menu-item add-menu-item--space-layer" id="add-space-layer-btn">
|
||||||
|
<span class="add-menu-icon">🌐</span>
|
||||||
|
<div class="add-menu-text">
|
||||||
|
<span class="add-menu-name">Add Space Layer</span>
|
||||||
|
<span class="add-menu-desc">Overlay data from another space</span>
|
||||||
|
</div>
|
||||||
|
</button>`;
|
||||||
|
|
||||||
return `<div class="add-menu" id="add-menu">${html}</div>`;
|
return `<div class="add-menu" id="add-menu">${html}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -898,6 +1027,16 @@ export class RStackTabBar extends HTMLElement {
|
||||||
item.addEventListener("touchend", handleSelect);
|
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
|
// Close add menu on outside click/touch
|
||||||
if (this.#addMenuOpen) {
|
if (this.#addMenuOpen) {
|
||||||
const handler = () => {
|
const handler = () => {
|
||||||
|
|
@ -1310,6 +1449,33 @@ const STYLES = `
|
||||||
.tab:hover .tab-close { opacity: 0.6; }
|
.tab:hover .tab-close { opacity: 0.6; }
|
||||||
.tab-close:hover { opacity: 1 !important; background: rgba(239,68,68,0.2); color: #ef4444; }
|
.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 ── */
|
/* ── Drag states ── */
|
||||||
|
|
||||||
.tab.dragging { opacity: 0.4; }
|
.tab.dragging { opacity: 0.4; }
|
||||||
|
|
@ -1469,6 +1635,98 @@ const STYLES = `
|
||||||
opacity: 0.5;
|
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 ── */
|
||||||
|
|
||||||
.view-toggle {
|
.view-toggle {
|
||||||
|
|
|
||||||
|
|
@ -46,13 +46,17 @@ export class RSpaceOfflineRuntime {
|
||||||
#store: EncryptedDocStore;
|
#store: EncryptedDocStore;
|
||||||
#sync: DocSyncManager;
|
#sync: DocSyncManager;
|
||||||
#crypto: DocCrypto;
|
#crypto: DocCrypto;
|
||||||
#space: string;
|
#activeSpace: string;
|
||||||
#status: RuntimeStatus = 'idle';
|
#status: RuntimeStatus = 'idle';
|
||||||
#statusListeners = new Set<StatusCallback>();
|
#statusListeners = new Set<StatusCallback>();
|
||||||
#initialized = false;
|
#initialized = false;
|
||||||
|
/** Module scope config: moduleId → 'global' | 'space'. Set from page's module list. */
|
||||||
|
#moduleScopes = new Map<string, 'global' | 'space'>();
|
||||||
|
/** Lazy WebSocket connections per space slug (for cross-space subscriptions). */
|
||||||
|
#spaceConnections = new Map<string, DocSyncManager>();
|
||||||
|
|
||||||
constructor(space: string) {
|
constructor(space: string) {
|
||||||
this.#space = space;
|
this.#activeSpace = space;
|
||||||
this.#crypto = new DocCrypto();
|
this.#crypto = new DocCrypto();
|
||||||
this.#documents = new DocumentManager();
|
this.#documents = new DocumentManager();
|
||||||
this.#store = new EncryptedDocStore(space);
|
this.#store = new EncryptedDocStore(space);
|
||||||
|
|
@ -64,7 +68,7 @@ export class RSpaceOfflineRuntime {
|
||||||
|
|
||||||
// ── Getters ──
|
// ── Getters ──
|
||||||
|
|
||||||
get space(): string { return this.#space; }
|
get space(): string { return this.#activeSpace; }
|
||||||
get isInitialized(): boolean { return this.#initialized; }
|
get isInitialized(): boolean { return this.#initialized; }
|
||||||
get isOnline(): boolean { return this.#sync.isConnected; }
|
get isOnline(): boolean { return this.#sync.isConnected; }
|
||||||
get status(): RuntimeStatus { return this.#status; }
|
get status(): RuntimeStatus { return this.#status; }
|
||||||
|
|
@ -85,13 +89,13 @@ export class RSpaceOfflineRuntime {
|
||||||
|
|
||||||
// 2. Connect WebSocket (non-blocking — works offline)
|
// 2. Connect WebSocket (non-blocking — works offline)
|
||||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
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.onConnect(() => this.#setStatus('online'));
|
||||||
this.#sync.onDisconnect(() => this.#setStatus('offline'));
|
this.#sync.onDisconnect(() => this.#setStatus('offline'));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.#sync.connect(wsUrl, this.#space);
|
await this.#sync.connect(wsUrl, this.#activeSpace);
|
||||||
this.#setStatus('online');
|
this.#setStatus('online');
|
||||||
} catch {
|
} catch {
|
||||||
// WebSocket failed — still usable offline
|
// 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 {
|
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<Map<DocumentId, Automerge.Doc<T>>> {
|
): Promise<Map<DocumentId, Automerge.Doc<T>>> {
|
||||||
this.#documents.registerSchema(schema);
|
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 docIds = await this.#sync.requestDocList(prefix);
|
||||||
|
|
||||||
const results = new Map<DocumentId, Automerge.Doc<T>>();
|
const results = new Map<DocumentId, Automerge.Doc<T>>();
|
||||||
|
|
@ -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<T extends Record<string, any>>(
|
||||||
|
module: string,
|
||||||
|
collection: string,
|
||||||
|
schema: DocSchema<T>,
|
||||||
|
): Promise<Map<DocumentId, { doc: Automerge.Doc<T>; space: string }>> {
|
||||||
|
this.#documents.registerSchema(schema);
|
||||||
|
const results = new Map<DocumentId, { doc: Automerge.Doc<T>; 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<T>(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<DocSyncManager> {
|
||||||
|
// 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 {
|
destroy(): void {
|
||||||
this.#sync.disconnect();
|
this.#sync.disconnect();
|
||||||
|
for (const [, sync] of this.#spaceConnections) {
|
||||||
|
sync.disconnect();
|
||||||
|
}
|
||||||
|
this.#spaceConnections.clear();
|
||||||
this.#statusListeners.clear();
|
this.#statusListeners.clear();
|
||||||
this.#initialized = false;
|
this.#initialized = false;
|
||||||
this.#setStatus('idle');
|
this.#setStatus('idle');
|
||||||
|
|
|
||||||
|
|
@ -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<string, ModuleScope> | null,
|
||||||
|
): string {
|
||||||
|
const mod = getModule(moduleId);
|
||||||
|
if (!mod) return spaceSlug;
|
||||||
|
|
||||||
|
const effectiveScope = scopeOverrides?.[moduleId] ?? mod.scoping.defaultScope;
|
||||||
|
return effectiveScope === 'global' ? 'global' : spaceSlug;
|
||||||
|
}
|
||||||
|
|
@ -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
|
* 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
|
* and shown/hidden via CSS. New tabs are fetched via fetch() + DOMParser on
|
||||||
* first visit, then stay in the DOM forever.
|
* 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,
|
* Hiding preserves ALL component state: Shadow DOM, event listeners,
|
||||||
* WebSocket connections, timers.
|
* WebSocket connections, timers.
|
||||||
*/
|
*/
|
||||||
|
|
@ -19,22 +23,40 @@ export class TabCache {
|
||||||
this.currentModuleId = moduleId;
|
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 */
|
/** Wrap the initial #app content in a pane div and set up popstate */
|
||||||
init(): boolean {
|
init(): boolean {
|
||||||
const app = document.getElementById("app");
|
const app = document.getElementById("app");
|
||||||
if (!app) return false;
|
if (!app) return false;
|
||||||
|
|
||||||
|
const key = this.paneKey(this.spaceSlug, this.currentModuleId);
|
||||||
|
|
||||||
// Wrap existing content in a pane
|
// Wrap existing content in a pane
|
||||||
const pane = document.createElement("div");
|
const pane = document.createElement("div");
|
||||||
pane.className = "rspace-tab-pane rspace-tab-pane--active";
|
pane.className = "rspace-tab-pane rspace-tab-pane--active";
|
||||||
pane.dataset.moduleId = this.currentModuleId;
|
pane.dataset.moduleId = this.currentModuleId;
|
||||||
|
pane.dataset.spaceSlug = this.spaceSlug;
|
||||||
pane.dataset.pageTitle = document.title;
|
pane.dataset.pageTitle = document.title;
|
||||||
|
|
||||||
while (app.firstChild) {
|
while (app.firstChild) {
|
||||||
pane.appendChild(app.firstChild);
|
pane.appendChild(app.firstChild);
|
||||||
}
|
}
|
||||||
app.appendChild(pane);
|
app.appendChild(pane);
|
||||||
this.panes.set(this.currentModuleId, pane);
|
this.panes.set(key, pane);
|
||||||
|
|
||||||
// Push initial state into history
|
// Push initial state into history
|
||||||
history.replaceState(
|
history.replaceState(
|
||||||
|
|
@ -46,8 +68,18 @@ export class TabCache {
|
||||||
// Handle browser back/forward
|
// Handle browser back/forward
|
||||||
window.addEventListener("popstate", (e) => {
|
window.addEventListener("popstate", (e) => {
|
||||||
const state = e.state;
|
const state = e.state;
|
||||||
if (state?.moduleId && this.panes.has(state.moduleId)) {
|
if (!state?.moduleId) {
|
||||||
this.showPane(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 {
|
} else {
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
}
|
}
|
||||||
|
|
@ -56,43 +88,84 @@ export class TabCache {
|
||||||
return true;
|
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<boolean> {
|
async switchTo(moduleId: string): Promise<boolean> {
|
||||||
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)) {
|
if (this.panes.has(key)) {
|
||||||
this.showPane(moduleId);
|
this.showPane(this.spaceSlug, moduleId);
|
||||||
this.updateUrl(moduleId);
|
this.updateUrl(this.spaceSlug, moduleId);
|
||||||
return true;
|
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<boolean> {
|
||||||
|
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 */
|
/** Check if a pane exists in the DOM cache */
|
||||||
has(moduleId: string): boolean {
|
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 */
|
/** Remove a cached pane from the DOM */
|
||||||
removePane(moduleId: string): void {
|
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) {
|
if (pane) {
|
||||||
pane.remove();
|
pane.remove();
|
||||||
this.panes.delete(moduleId);
|
this.panes.delete(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fetch a module page, extract content, and inject into a new pane */
|
/** Fetch a module page, extract content, and inject into a new pane */
|
||||||
private async fetchAndInject(moduleId: string): Promise<boolean> {
|
private async fetchAndInject(space: string, moduleId: string): Promise<boolean> {
|
||||||
const navUrl = (window as any).__rspaceNavUrl;
|
const navUrl = (window as any).__rspaceNavUrl;
|
||||||
if (!navUrl) return false;
|
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 {
|
try {
|
||||||
const resolved = new URL(url, window.location.href);
|
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 {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -109,7 +182,7 @@ export class TabCache {
|
||||||
app.appendChild(loadingPane);
|
app.appendChild(loadingPane);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(url);
|
const resp = await fetch(fetchUrl);
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
loadingPane.remove();
|
loadingPane.remove();
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -125,23 +198,26 @@ export class TabCache {
|
||||||
// Remove loading spinner
|
// Remove loading spinner
|
||||||
loadingPane.remove();
|
loadingPane.remove();
|
||||||
|
|
||||||
|
const key = this.paneKey(space, moduleId);
|
||||||
|
|
||||||
// Create the real pane
|
// Create the real pane
|
||||||
const pane = document.createElement("div");
|
const pane = document.createElement("div");
|
||||||
pane.className = "rspace-tab-pane rspace-tab-pane--active";
|
pane.className = "rspace-tab-pane rspace-tab-pane--active";
|
||||||
pane.dataset.moduleId = moduleId;
|
pane.dataset.moduleId = moduleId;
|
||||||
|
pane.dataset.spaceSlug = space;
|
||||||
pane.dataset.pageTitle = content.title;
|
pane.dataset.pageTitle = content.title;
|
||||||
pane.innerHTML = content.body;
|
pane.innerHTML = content.body;
|
||||||
app.appendChild(pane);
|
app.appendChild(pane);
|
||||||
this.panes.set(moduleId, pane);
|
this.panes.set(key, pane);
|
||||||
|
|
||||||
// Load module-specific assets
|
// 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
|
// Update shell state
|
||||||
this.currentModuleId = moduleId;
|
this.currentModuleId = moduleId;
|
||||||
document.title = content.title;
|
document.title = content.title;
|
||||||
this.updateShellState(moduleId);
|
this.updateShellState(moduleId);
|
||||||
this.updateUrl(moduleId);
|
this.updateUrl(space, moduleId);
|
||||||
this.updateCanvasLayout(moduleId);
|
this.updateCanvasLayout(moduleId);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -240,9 +316,10 @@ export class TabCache {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Show a specific pane and update shell state */
|
/** Show a specific pane and update shell state */
|
||||||
private showPane(moduleId: string): void {
|
private showPane(space: string, moduleId: string): void {
|
||||||
this.hideAllPanes();
|
this.hideAllPanes();
|
||||||
const pane = this.panes.get(moduleId);
|
const key = this.paneKey(space, moduleId);
|
||||||
|
const pane = this.panes.get(key);
|
||||||
if (pane) {
|
if (pane) {
|
||||||
pane.classList.add("rspace-tab-pane--active");
|
pane.classList.add("rspace-tab-pane--active");
|
||||||
const storedTitle = pane.dataset.pageTitle;
|
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 */
|
/** Push new URL to browser history */
|
||||||
private updateUrl(moduleId: string): void {
|
private updateUrl(space: string, moduleId: string): void {
|
||||||
const navUrl = (window as any).__rspaceNavUrl;
|
const navUrl = (window as any).__rspaceNavUrl;
|
||||||
if (!navUrl) return;
|
if (!navUrl) return;
|
||||||
const url: string = navUrl(this.spaceSlug, moduleId);
|
const url: string = navUrl(space, moduleId);
|
||||||
history.pushState(
|
history.pushState(
|
||||||
{ moduleId, spaceSlug: this.spaceSlug },
|
{ moduleId, spaceSlug: space },
|
||||||
"",
|
"",
|
||||||
url,
|
url,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1339,6 +1339,25 @@
|
||||||
display: none !important;
|
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 {
|
#select-rect {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
border: 1.5px solid #3b82f6;
|
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 ──
|
// ── "Try Demo" button visibility ──
|
||||||
// Hide on demo space, show on bare domain
|
// Hide on demo space, show on bare domain
|
||||||
(function() {
|
(function() {
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,21 @@ const spaceSlug = document.body?.getAttribute("data-space-slug");
|
||||||
if (spaceSlug && spaceSlug !== "demo") {
|
if (spaceSlug && spaceSlug !== "demo") {
|
||||||
const runtime = new RSpaceOfflineRuntime(spaceSlug);
|
const runtime = new RSpaceOfflineRuntime(spaceSlug);
|
||||||
(window as any).__rspaceOfflineRuntime = runtime;
|
(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<string, string> = 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) => {
|
runtime.init().catch((e: unknown) => {
|
||||||
console.warn("[shell] Offline runtime init failed — REST fallback only:", e);
|
console.warn("[shell] Offline runtime init failed — REST fallback only:", e);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue