From 5613370817fbcb1bab1a11187c2ab667e20afea4 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Sat, 28 Feb 2026 19:49:26 -0800 Subject: [PATCH] refactor: rename module directories to match r-prefixed module IDs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All 22 module directories under modules/ now match their module IDs (e.g. modules/cart → modules/rcart, modules/canvas → modules/rspace). Updated all import paths, vite build config, HTML template asset refs, docker-compose standalone commands, and .gitignore accordingly. Co-Authored-By: Claude Opus 4.6 --- .gitignore | 2 +- docker-compose.standalone.yml | 38 +- lib/demo-sync-vanilla.ts | 210 +++++++ .../{books => rbooks}/components/books.css | 0 .../components/folk-book-reader.ts | 0 .../components/folk-book-shelf.ts | 0 modules/{books => rbooks}/db/schema.sql | 0 modules/{books => rbooks}/landing.ts | 0 modules/{books => rbooks}/mod.ts | 8 +- modules/rcal/components/cal-demo.ts | 21 + modules/{cal => rcal}/components/cal.css | 0 .../components/folk-calendar-view.ts | 0 modules/{cal => rcal}/db/schema.sql | 0 modules/rcal/demo.ts | 344 ++++++++++++ modules/{cal => rcal}/landing.ts | 0 modules/{cal => rcal}/mod.ts | 20 +- modules/rcart/components/cart-demo.ts | 2 + modules/{cart => rcart}/components/cart.css | 0 .../components/folk-cart-shop.ts | 0 modules/{cart => rcart}/db/schema.sql | 0 modules/rcart/demo.ts | 234 ++++++++ modules/{cart => rcart}/flow.ts | 0 modules/{cart => rcart}/landing.ts | 0 modules/{cart => rcart}/mod.ts | 20 +- .../components/choices.css | 0 .../components/folk-choices-dashboard.ts | 0 modules/{choices => rchoices}/landing.ts | 0 modules/{choices => rchoices}/mod.ts | 4 +- modules/{data => rdata}/components/data.css | 0 .../components/folk-analytics-view.ts | 0 modules/{data => rdata}/landing.ts | 0 modules/{data => rdata}/mod.ts | 4 +- .../{files => rfiles}/components/files.css | 0 .../components/folk-file-browser.ts | 0 modules/{files => rfiles}/db/schema.sql | 0 modules/{files => rfiles}/landing.ts | 0 modules/{files => rfiles}/mod.ts | 4 +- .../components/folk-forum-dashboard.ts | 0 .../{forum => rforum}/components/forum.css | 0 modules/{forum => rforum}/db/schema.sql | 0 modules/{forum => rforum}/landing.ts | 0 modules/{forum => rforum}/lib/cloud-init.ts | 0 modules/{forum => rforum}/lib/dns.ts | 0 modules/{forum => rforum}/lib/hetzner.ts | 0 modules/{forum => rforum}/lib/provisioner.ts | 0 modules/{forum => rforum}/mod.ts | 4 +- .../components/folk-budget-river.ts | 0 .../components/folk-funds-app.ts | 6 +- modules/rfunds/components/funds-demo.ts | 526 ++++++++++++++++++ .../{funds => rfunds}/components/funds.css | 0 modules/{funds => rfunds}/db/schema.sql | 0 modules/rfunds/demo.ts | 256 +++++++++ modules/{funds => rfunds}/landing.ts | 0 modules/{funds => rfunds}/lib/map-flow.ts | 0 modules/{funds => rfunds}/lib/presets.ts | 0 modules/{funds => rfunds}/lib/simulation.ts | 0 modules/{funds => rfunds}/lib/types.ts | 0 modules/{funds => rfunds}/mod.ts | 27 +- .../components/folk-inbox-client.ts | 0 .../{inbox => rinbox}/components/inbox.css | 0 modules/{inbox => rinbox}/db/schema.sql | 0 modules/{inbox => rinbox}/landing.ts | 0 modules/{inbox => rinbox}/mod.ts | 4 +- .../components/folk-map-viewer.ts | 0 modules/{maps => rmaps}/components/maps.css | 0 modules/{maps => rmaps}/landing.ts | 0 modules/{maps => rmaps}/mod.ts | 8 +- .../components/folk-graph-viewer.ts | 0 .../components/network.css | 0 modules/{network => rnetwork}/landing.ts | 0 modules/{network => rnetwork}/mod.ts | 4 +- .../components/folk-notes-app.ts | 0 modules/rnotes/components/notes-demo.ts | 353 ++++++++++++ .../{notes => rnotes}/components/notes.css | 0 modules/{notes => rnotes}/db/schema.sql | 0 modules/rnotes/demo.ts | 360 ++++++++++++ modules/{notes => rnotes}/landing.ts | 0 modules/{notes => rnotes}/mod.ts | 21 +- .../components/folk-photo-gallery.ts | 0 .../{photos => rphotos}/components/photos.css | 0 modules/{photos => rphotos}/landing.ts | 0 modules/{photos => rphotos}/mod.ts | 4 +- .../components/folk-pubs-editor.ts | 0 modules/{pubs => rpubs}/components/pubs.css | 0 modules/{pubs => rpubs}/formats.ts | 0 modules/{pubs => rpubs}/landing.ts | 0 modules/{pubs => rpubs}/mod.ts | 4 +- modules/{pubs => rpubs}/parse-document.ts | 0 modules/{pubs => rpubs}/typst-compile.ts | 0 modules/{pubs => rpubs}/typst/formats/a6.typ | 0 modules/{pubs => rpubs}/typst/formats/a7.typ | 0 .../{pubs => rpubs}/typst/formats/digest.typ | 0 .../typst/formats/quarter-letter.typ | 0 .../typst/lib/series-style.typ | 0 .../typst/templates/pocket-book.typ | 0 modules/{canvas => rspace}/mod.ts | 0 .../components/folk-splat-viewer.ts | 0 .../{splat => rsplat}/components/splat.css | 0 modules/{splat => rsplat}/db/schema.sql | 0 modules/{splat => rsplat}/landing.ts | 0 modules/{splat => rsplat}/mod.ts | 8 +- .../components/folk-swag-designer.ts | 0 modules/{swag => rswag}/components/swag.css | 0 modules/{swag => rswag}/landing.ts | 0 modules/{swag => rswag}/mod.ts | 4 +- modules/{swag => rswag}/process-image.ts | 0 modules/{swag => rswag}/products.ts | 0 .../components/folk-route-planner.ts | 0 .../components/folk-trips-planner.ts | 0 .../components/route-planner.css | 0 modules/rtrips/components/trips-demo.ts | 459 +++++++++++++++ .../{trips => rtrips}/components/trips.css | 0 modules/{trips => rtrips}/db/schema.sql | 0 modules/rtrips/demo.ts | 414 ++++++++++++++ modules/{trips => rtrips}/landing.ts | 0 modules/{trips => rtrips}/lib/conic-math.ts | 0 modules/{trips => rtrips}/lib/projection.ts | 0 modules/{trips => rtrips}/lib/types.ts | 0 modules/{trips => rtrips}/mod.ts | 24 +- .../components/folk-video-player.ts | 0 modules/rtube/components/tube-demo.ts | 156 ++++++ modules/{tube => rtube}/components/tube.css | 0 modules/rtube/demo.ts | 301 ++++++++++ modules/{tube => rtube}/landing.ts | 0 modules/{tube => rtube}/mod.ts | 20 +- .../components/folk-vote-dashboard.ts | 0 modules/rvote/components/vote-demo.ts | 179 ++++++ modules/{vote => rvote}/components/vote.css | 0 modules/{vote => rvote}/db/schema.sql | 0 modules/rvote/demo.ts | 68 +++ modules/{vote => rvote}/landing.ts | 0 modules/{vote => rvote}/mod.ts | 21 +- .../components/folk-wallet-viewer.ts | 0 .../{wallet => rwallet}/components/wallet.css | 0 modules/{wallet => rwallet}/landing.ts | 0 modules/{wallet => rwallet}/mod.ts | 4 +- .../components/folk-work-board.ts | 0 modules/{work => rwork}/components/work.css | 0 modules/{work => rwork}/db/schema.sql | 0 modules/{work => rwork}/landing.ts | 0 modules/{work => rwork}/mod.ts | 4 +- server/index.ts | 44 +- server/shell.ts | 235 ++++++++ shared/module.ts | 2 + vite.config.ts | 346 +++++++----- 145 files changed, 4528 insertions(+), 249 deletions(-) create mode 100644 lib/demo-sync-vanilla.ts rename modules/{books => rbooks}/components/books.css (100%) rename modules/{books => rbooks}/components/folk-book-reader.ts (100%) rename modules/{books => rbooks}/components/folk-book-shelf.ts (100%) rename modules/{books => rbooks}/db/schema.sql (100%) rename modules/{books => rbooks}/landing.ts (100%) rename modules/{books => rbooks}/mod.ts (96%) create mode 100644 modules/rcal/components/cal-demo.ts rename modules/{cal => rcal}/components/cal.css (100%) rename modules/{cal => rcal}/components/folk-calendar-view.ts (100%) rename modules/{cal => rcal}/db/schema.sql (100%) create mode 100644 modules/rcal/demo.ts rename modules/{cal => rcal}/landing.ts (100%) rename modules/{cal => rcal}/mod.ts (96%) create mode 100644 modules/rcart/components/cart-demo.ts rename modules/{cart => rcart}/components/cart.css (100%) rename modules/{cart => rcart}/components/folk-cart-shop.ts (100%) rename modules/{cart => rcart}/db/schema.sql (100%) create mode 100644 modules/rcart/demo.ts rename modules/{cart => rcart}/flow.ts (100%) rename modules/{cart => rcart}/landing.ts (100%) rename modules/{cart => rcart}/mod.ts (96%) rename modules/{choices => rchoices}/components/choices.css (100%) rename modules/{choices => rchoices}/components/folk-choices-dashboard.ts (100%) rename modules/{choices => rchoices}/landing.ts (100%) rename modules/{choices => rchoices}/mod.ts (93%) rename modules/{data => rdata}/components/data.css (100%) rename modules/{data => rdata}/components/folk-analytics-view.ts (100%) rename modules/{data => rdata}/landing.ts (100%) rename modules/{data => rdata}/mod.ts (96%) rename modules/{files => rfiles}/components/files.css (100%) rename modules/{files => rfiles}/components/folk-file-browser.ts (100%) rename modules/{files => rfiles}/db/schema.sql (100%) rename modules/{files => rfiles}/landing.ts (100%) rename modules/{files => rfiles}/mod.ts (99%) rename modules/{forum => rforum}/components/folk-forum-dashboard.ts (100%) rename modules/{forum => rforum}/components/forum.css (100%) rename modules/{forum => rforum}/db/schema.sql (100%) rename modules/{forum => rforum}/landing.ts (100%) rename modules/{forum => rforum}/lib/cloud-init.ts (100%) rename modules/{forum => rforum}/lib/dns.ts (100%) rename modules/{forum => rforum}/lib/hetzner.ts (100%) rename modules/{forum => rforum}/lib/provisioner.ts (100%) rename modules/{forum => rforum}/mod.ts (97%) rename modules/{funds => rfunds}/components/folk-budget-river.ts (100%) rename modules/{funds => rfunds}/components/folk-funds-app.ts (99%) create mode 100644 modules/rfunds/components/funds-demo.ts rename modules/{funds => rfunds}/components/funds.css (100%) rename modules/{funds => rfunds}/db/schema.sql (100%) create mode 100644 modules/rfunds/demo.ts rename modules/{funds => rfunds}/landing.ts (100%) rename modules/{funds => rfunds}/lib/map-flow.ts (100%) rename modules/{funds => rfunds}/lib/presets.ts (100%) rename modules/{funds => rfunds}/lib/simulation.ts (100%) rename modules/{funds => rfunds}/lib/types.ts (100%) rename modules/{funds => rfunds}/mod.ts (89%) rename modules/{inbox => rinbox}/components/folk-inbox-client.ts (100%) rename modules/{inbox => rinbox}/components/inbox.css (100%) rename modules/{inbox => rinbox}/db/schema.sql (100%) rename modules/{inbox => rinbox}/landing.ts (100%) rename modules/{inbox => rinbox}/mod.ts (99%) rename modules/{maps => rmaps}/components/folk-map-viewer.ts (100%) rename modules/{maps => rmaps}/components/maps.css (100%) rename modules/{maps => rmaps}/landing.ts (100%) rename modules/{maps => rmaps}/mod.ts (95%) rename modules/{network => rnetwork}/components/folk-graph-viewer.ts (100%) rename modules/{network => rnetwork}/components/network.css (100%) rename modules/{network => rnetwork}/landing.ts (100%) rename modules/{network => rnetwork}/mod.ts (97%) rename modules/{notes => rnotes}/components/folk-notes-app.ts (100%) create mode 100644 modules/rnotes/components/notes-demo.ts rename modules/{notes => rnotes}/components/notes.css (100%) rename modules/{notes => rnotes}/db/schema.sql (100%) create mode 100644 modules/rnotes/demo.ts rename modules/{notes => rnotes}/landing.ts (100%) rename modules/{notes => rnotes}/mod.ts (96%) rename modules/{photos => rphotos}/components/folk-photo-gallery.ts (100%) rename modules/{photos => rphotos}/components/photos.css (100%) rename modules/{photos => rphotos}/landing.ts (100%) rename modules/{photos => rphotos}/mod.ts (96%) rename modules/{pubs => rpubs}/components/folk-pubs-editor.ts (100%) rename modules/{pubs => rpubs}/components/pubs.css (100%) rename modules/{pubs => rpubs}/formats.ts (100%) rename modules/{pubs => rpubs}/landing.ts (100%) rename modules/{pubs => rpubs}/mod.ts (98%) rename modules/{pubs => rpubs}/parse-document.ts (100%) rename modules/{pubs => rpubs}/typst-compile.ts (100%) rename modules/{pubs => rpubs}/typst/formats/a6.typ (100%) rename modules/{pubs => rpubs}/typst/formats/a7.typ (100%) rename modules/{pubs => rpubs}/typst/formats/digest.typ (100%) rename modules/{pubs => rpubs}/typst/formats/quarter-letter.typ (100%) rename modules/{pubs => rpubs}/typst/lib/series-style.typ (100%) rename modules/{pubs => rpubs}/typst/templates/pocket-book.typ (100%) rename modules/{canvas => rspace}/mod.ts (100%) rename modules/{splat => rsplat}/components/folk-splat-viewer.ts (100%) rename modules/{splat => rsplat}/components/splat.css (100%) rename modules/{splat => rsplat}/db/schema.sql (100%) rename modules/{splat => rsplat}/landing.ts (100%) rename modules/{splat => rsplat}/mod.ts (98%) rename modules/{swag => rswag}/components/folk-swag-designer.ts (100%) rename modules/{swag => rswag}/components/swag.css (100%) rename modules/{swag => rswag}/landing.ts (100%) rename modules/{swag => rswag}/mod.ts (98%) rename modules/{swag => rswag}/process-image.ts (100%) rename modules/{swag => rswag}/products.ts (100%) rename modules/{trips => rtrips}/components/folk-route-planner.ts (100%) rename modules/{trips => rtrips}/components/folk-trips-planner.ts (100%) rename modules/{trips => rtrips}/components/route-planner.css (100%) create mode 100644 modules/rtrips/components/trips-demo.ts rename modules/{trips => rtrips}/components/trips.css (100%) rename modules/{trips => rtrips}/db/schema.sql (100%) create mode 100644 modules/rtrips/demo.ts rename modules/{trips => rtrips}/landing.ts (100%) rename modules/{trips => rtrips}/lib/conic-math.ts (100%) rename modules/{trips => rtrips}/lib/projection.ts (100%) rename modules/{trips => rtrips}/lib/types.ts (100%) rename modules/{trips => rtrips}/mod.ts (93%) rename modules/{tube => rtube}/components/folk-video-player.ts (100%) create mode 100644 modules/rtube/components/tube-demo.ts rename modules/{tube => rtube}/components/tube.css (100%) create mode 100644 modules/rtube/demo.ts rename modules/{tube => rtube}/landing.ts (100%) rename modules/{tube => rtube}/mod.ts (91%) rename modules/{vote => rvote}/components/folk-vote-dashboard.ts (100%) create mode 100644 modules/rvote/components/vote-demo.ts rename modules/{vote => rvote}/components/vote.css (100%) rename modules/{vote => rvote}/db/schema.sql (100%) create mode 100644 modules/rvote/demo.ts rename modules/{vote => rvote}/landing.ts (100%) rename modules/{vote => rvote}/mod.ts (94%) rename modules/{wallet => rwallet}/components/folk-wallet-viewer.ts (100%) rename modules/{wallet => rwallet}/components/wallet.css (100%) rename modules/{wallet => rwallet}/landing.ts (100%) rename modules/{wallet => rwallet}/mod.ts (96%) rename modules/{work => rwork}/components/folk-work-board.ts (100%) rename modules/{work => rwork}/components/work.css (100%) rename modules/{work => rwork}/db/schema.sql (100%) rename modules/{work => rwork}/landing.ts (100%) rename modules/{work => rwork}/mod.ts (98%) diff --git a/.gitignore b/.gitignore index 2048993..2d0a196 100644 --- a/.gitignore +++ b/.gitignore @@ -6,7 +6,7 @@ dist/ # Data storage data/ -!modules/data/ +!modules/rdata/ # IDE .vscode/ diff --git a/docker-compose.standalone.yml b/docker-compose.standalone.yml index 06e2cd0..20e59b8 100644 --- a/docker-compose.standalone.yml +++ b/docker-compose.standalone.yml @@ -35,7 +35,7 @@ services: rbooks-standalone: <<: *standalone-base container_name: rbooks-standalone - command: ["bun", "run", "modules/books/standalone.ts"] + command: ["bun", "run", "modules/rbooks/standalone.ts"] volumes: - rspace-books:/data/books environment: @@ -51,7 +51,7 @@ services: rpubs-standalone: <<: *standalone-base container_name: rpubs-standalone - command: ["bun", "run", "modules/pubs/standalone.ts"] + command: ["bun", "run", "modules/rpubs/standalone.ts"] labels: <<: *traefik-enabled traefik.http.routers.rpubs-sa.rule: Host(`rpubs.online`) @@ -62,7 +62,7 @@ services: rcart-standalone: <<: *standalone-base container_name: rcart-standalone - command: ["bun", "run", "modules/cart/standalone.ts"] + command: ["bun", "run", "modules/rcart/standalone.ts"] environment: <<: *base-env FLOW_SERVICE_URL: http://payment-flow:3010 @@ -86,7 +86,7 @@ services: rswag-standalone: <<: *standalone-base container_name: rswag-standalone - command: ["bun", "run", "modules/swag/standalone.ts"] + command: ["bun", "run", "modules/rswag/standalone.ts"] volumes: - rspace-swag:/data/swag-artifacts environment: @@ -102,7 +102,7 @@ services: rchoices-standalone: <<: *standalone-base container_name: rchoices-standalone - command: ["bun", "run", "modules/choices/standalone.ts"] + command: ["bun", "run", "modules/rchoices/standalone.ts"] labels: <<: *traefik-enabled traefik.http.routers.rchoices-sa.rule: Host(`rchoices.online`) @@ -113,7 +113,7 @@ services: rfunds-standalone: <<: *standalone-base container_name: rfunds-standalone - command: ["bun", "run", "modules/funds/standalone.ts"] + command: ["bun", "run", "modules/rfunds/standalone.ts"] environment: <<: *base-env FLOW_SERVICE_URL: http://payment-flow:3010 @@ -133,7 +133,7 @@ services: rfiles-standalone: <<: *standalone-base container_name: rfiles-standalone - command: ["bun", "run", "modules/files/standalone.ts"] + command: ["bun", "run", "modules/rfiles/standalone.ts"] volumes: - rspace-files:/data/files environment: @@ -149,7 +149,7 @@ services: rforum-standalone: <<: *standalone-base container_name: rforum-standalone - command: ["bun", "run", "modules/forum/standalone.ts"] + command: ["bun", "run", "modules/rforum/standalone.ts"] environment: <<: *base-env HETZNER_API_TOKEN: ${HETZNER_API_TOKEN} @@ -165,7 +165,7 @@ services: rvote-standalone: <<: *standalone-base container_name: rvote-standalone - command: ["bun", "run", "modules/vote/standalone.ts"] + command: ["bun", "run", "modules/rvote/standalone.ts"] labels: <<: *traefik-enabled traefik.http.routers.rvote-sa.rule: Host(`rvote.online`) @@ -176,7 +176,7 @@ services: rnotes-standalone: <<: *standalone-base container_name: rnotes-standalone - command: ["bun", "run", "modules/notes/standalone.ts"] + command: ["bun", "run", "modules/rnotes/standalone.ts"] labels: <<: *traefik-enabled traefik.http.routers.rnotes-sa.rule: Host(`rnotes.online`) @@ -187,7 +187,7 @@ services: rmaps-standalone: <<: *standalone-base container_name: rmaps-standalone - command: ["bun", "run", "modules/maps/standalone.ts"] + command: ["bun", "run", "modules/rmaps/standalone.ts"] environment: <<: *base-env MAPS_SYNC_URL: wss://sync.rmaps.online @@ -201,7 +201,7 @@ services: rwork-standalone: <<: *standalone-base container_name: rwork-standalone - command: ["bun", "run", "modules/work/standalone.ts"] + command: ["bun", "run", "modules/rwork/standalone.ts"] labels: <<: *traefik-enabled traefik.http.routers.rwork-sa.rule: Host(`rwork.online`) @@ -212,7 +212,7 @@ services: rtrips-standalone: <<: *standalone-base container_name: rtrips-standalone - command: ["bun", "run", "modules/trips/standalone.ts"] + command: ["bun", "run", "modules/rtrips/standalone.ts"] environment: <<: *base-env OSRM_URL: http://osrm-backend:5000 @@ -226,7 +226,7 @@ services: rcal-standalone: <<: *standalone-base container_name: rcal-standalone - command: ["bun", "run", "modules/cal/standalone.ts"] + command: ["bun", "run", "modules/rcal/standalone.ts"] labels: <<: *traefik-enabled traefik.http.routers.rcal-sa.rule: Host(`rcal.online`) @@ -237,7 +237,7 @@ services: rnetwork-standalone: <<: *standalone-base container_name: rnetwork-standalone - command: ["bun", "run", "modules/network/standalone.ts"] + command: ["bun", "run", "modules/rnetwork/standalone.ts"] environment: <<: *base-env TWENTY_API_URL: https://rnetwork.online @@ -252,7 +252,7 @@ services: rtube-standalone: <<: *standalone-base container_name: rtube-standalone - command: ["bun", "run", "modules/tube/standalone.ts"] + command: ["bun", "run", "modules/rtube/standalone.ts"] environment: <<: *base-env R2_ENDPOINT: ${R2_ENDPOINT} @@ -270,7 +270,7 @@ services: rinbox-standalone: <<: *standalone-base container_name: rinbox-standalone - command: ["bun", "run", "modules/inbox/standalone.ts"] + command: ["bun", "run", "modules/rinbox/standalone.ts"] environment: <<: *base-env IMAP_HOST: mail.rmail.online @@ -290,7 +290,7 @@ services: rdata-standalone: <<: *standalone-base container_name: rdata-standalone - command: ["bun", "run", "modules/data/standalone.ts"] + command: ["bun", "run", "modules/rdata/standalone.ts"] environment: <<: *base-env UMAMI_URL: https://analytics.rspace.online @@ -305,7 +305,7 @@ services: rwallet-standalone: <<: *standalone-base container_name: rwallet-standalone - command: ["bun", "run", "modules/wallet/standalone.ts"] + command: ["bun", "run", "modules/rwallet/standalone.ts"] labels: <<: *traefik-enabled traefik.http.routers.rwallet-sa.rule: Host(`rwallet.online`) diff --git a/lib/demo-sync-vanilla.ts b/lib/demo-sync-vanilla.ts new file mode 100644 index 0000000..9820c1e --- /dev/null +++ b/lib/demo-sync-vanilla.ts @@ -0,0 +1,210 @@ +/** + * DemoSync — vanilla JS class for real-time demo data via rSpace WebSocket. + * + * Mirrors the React useDemoSync hook but uses EventTarget for framework-free + * operation. Designed for module demo pages served as server-rendered HTML. + * + * Events: + * - "snapshot" → detail: { shapes: Record } + * - "connected" → WebSocket opened + * - "disconnected" → WebSocket closed + * + * Usage: + * const sync = new DemoSync({ filter: ['demo-poll'] }); + * sync.addEventListener('snapshot', (e) => renderUI(e.detail.shapes)); + * sync.connect(); + */ + +export interface DemoShape { + type: string; + id: string; + x: number; + y: number; + width: number; + height: number; + rotation: number; + [key: string]: unknown; +} + +export interface DemoSyncOptions { + /** Community slug (default: 'demo') */ + slug?: string; + /** Only subscribe to these shape types */ + filter?: string[]; + /** rSpace server URL (default: auto-detect) */ + serverUrl?: string; +} + +const DEFAULT_SLUG = "demo"; +const RECONNECT_BASE_MS = 1000; +const RECONNECT_MAX_MS = 30000; +const PING_INTERVAL_MS = 30000; + +function getDefaultServerUrl(): string { + if (typeof window === "undefined") return "https://rspace.online"; + const host = window.location.hostname; + if (host === "localhost" || host === "127.0.0.1") { + return `http://${host}:3000`; + } + return "https://rspace.online"; +} + +export class DemoSync extends EventTarget { + private slug: string; + private filter: string[] | undefined; + private serverUrl: string; + private ws: WebSocket | null = null; + private reconnectAttempt = 0; + private reconnectTimer: ReturnType | null = null; + private pingTimer: ReturnType | null = null; + private destroyed = false; + + shapes: Record = {}; + connected = false; + + constructor(options?: DemoSyncOptions) { + super(); + this.slug = options?.slug ?? DEFAULT_SLUG; + this.filter = options?.filter; + this.serverUrl = options?.serverUrl ?? getDefaultServerUrl(); + } + + connect(): void { + if (this.destroyed) return; + + const wsProtocol = this.serverUrl.startsWith("https") ? "wss" : "ws"; + const host = this.serverUrl.replace(/^https?:\/\//, ""); + const wsUrl = `${wsProtocol}://${host}/ws/${this.slug}?mode=json`; + + const ws = new WebSocket(wsUrl); + this.ws = ws; + + ws.onopen = () => { + if (this.destroyed) return; + this.connected = true; + this.reconnectAttempt = 0; + this.dispatchEvent(new Event("connected")); + + this.pingTimer = setInterval(() => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: "ping", timestamp: Date.now() })); + } + }, PING_INTERVAL_MS); + }; + + ws.onmessage = (event) => { + if (this.destroyed) return; + try { + const msg = JSON.parse(event.data); + if (msg.type === "snapshot" && msg.shapes) { + this.shapes = this.applyFilter(msg.shapes); + this.dispatchEvent( + new CustomEvent("snapshot", { + detail: { shapes: this.shapes }, + }), + ); + } + } catch { + // ignore parse errors + } + }; + + ws.onclose = () => { + if (this.destroyed) return; + this.connected = false; + this.cleanup(); + this.dispatchEvent(new Event("disconnected")); + this.scheduleReconnect(); + }; + + ws.onerror = () => { + // onclose fires after onerror + }; + } + + updateShape(id: string, data: Partial): void { + const ws = this.ws; + if (!ws || ws.readyState !== WebSocket.OPEN) return; + + // Optimistic local update + const existing = this.shapes[id]; + if (existing) { + const updated = { ...existing, ...data, id }; + if (this.filter && this.filter.length > 0 && !this.filter.includes(updated.type)) return; + this.shapes = { ...this.shapes, [id]: updated as DemoShape }; + this.dispatchEvent( + new CustomEvent("snapshot", { + detail: { shapes: this.shapes }, + }), + ); + } + + ws.send(JSON.stringify({ type: "update", id, data: { ...data, id } })); + } + + deleteShape(id: string): void { + const ws = this.ws; + if (!ws || ws.readyState !== WebSocket.OPEN) return; + + const { [id]: _, ...rest } = this.shapes; + this.shapes = rest; + this.dispatchEvent( + new CustomEvent("snapshot", { detail: { shapes: this.shapes } }), + ); + ws.send(JSON.stringify({ type: "delete", id })); + } + + async resetDemo(): Promise { + const res = await fetch(`${this.serverUrl}/api/communities/demo/reset`, { + method: "POST", + }); + if (!res.ok) { + const body = await res.text(); + throw new Error(`Reset failed: ${res.status} ${body}`); + } + } + + destroy(): void { + this.destroyed = true; + this.cleanup(); + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + if (this.ws) { + this.ws.onclose = null; + this.ws.close(); + this.ws = null; + } + } + + private applyFilter(allShapes: Record): Record { + if (!this.filter || this.filter.length === 0) return allShapes; + const filtered: Record = {}; + for (const [id, shape] of Object.entries(allShapes)) { + if (this.filter.includes(shape.type)) { + filtered[id] = shape; + } + } + return filtered; + } + + private cleanup(): void { + if (this.pingTimer) { + clearInterval(this.pingTimer); + this.pingTimer = null; + } + } + + private scheduleReconnect(): void { + if (this.destroyed) return; + const delay = Math.min( + RECONNECT_BASE_MS * Math.pow(2, this.reconnectAttempt), + RECONNECT_MAX_MS, + ); + this.reconnectAttempt++; + this.reconnectTimer = setTimeout(() => { + if (!this.destroyed) this.connect(); + }, delay); + } +} diff --git a/modules/books/components/books.css b/modules/rbooks/components/books.css similarity index 100% rename from modules/books/components/books.css rename to modules/rbooks/components/books.css diff --git a/modules/books/components/folk-book-reader.ts b/modules/rbooks/components/folk-book-reader.ts similarity index 100% rename from modules/books/components/folk-book-reader.ts rename to modules/rbooks/components/folk-book-reader.ts diff --git a/modules/books/components/folk-book-shelf.ts b/modules/rbooks/components/folk-book-shelf.ts similarity index 100% rename from modules/books/components/folk-book-shelf.ts rename to modules/rbooks/components/folk-book-shelf.ts diff --git a/modules/books/db/schema.sql b/modules/rbooks/db/schema.sql similarity index 100% rename from modules/books/db/schema.sql rename to modules/rbooks/db/schema.sql diff --git a/modules/books/landing.ts b/modules/rbooks/landing.ts similarity index 100% rename from modules/books/landing.ts rename to modules/rbooks/landing.ts diff --git a/modules/books/mod.ts b/modules/rbooks/mod.ts similarity index 96% rename from modules/books/mod.ts rename to modules/rbooks/mod.ts index 24f9de8..614a841 100644 --- a/modules/books/mod.ts +++ b/modules/rbooks/mod.ts @@ -212,8 +212,8 @@ routes.get("/", (c) => { modules: getModuleInfoList(), theme: "dark", body: ``, - scripts: ``, - styles: ``, + scripts: ``, + styles: ``, })); }); @@ -264,10 +264,10 @@ routes.get("/read/:id", async (c) => { `, modules: getModuleInfoList(), theme: "dark", - head: ``, + head: ``, scripts: ` `, }); diff --git a/modules/rcal/components/cal-demo.ts b/modules/rcal/components/cal-demo.ts new file mode 100644 index 0000000..b019461 --- /dev/null +++ b/modules/rcal/components/cal-demo.ts @@ -0,0 +1,21 @@ +/** + * rCal demo — tab switching and zoom controls (local state only, no WebSocket). + * + * Highlights the active tab when clicked. All tabs show the same + * calendar grid for now; this is purely visual feedback. + */ + +const tabs = document.querySelectorAll("[data-cal-tab]"); + +tabs.forEach((tab) => { + tab.addEventListener("click", () => { + tabs.forEach((t) => { + t.style.background = "transparent"; + t.style.color = "#94a3b8"; + }); + tab.style.background = "rgba(99,102,241,0.15)"; + tab.style.color = "#818cf8"; + }); +}); + +export {}; diff --git a/modules/cal/components/cal.css b/modules/rcal/components/cal.css similarity index 100% rename from modules/cal/components/cal.css rename to modules/rcal/components/cal.css diff --git a/modules/cal/components/folk-calendar-view.ts b/modules/rcal/components/folk-calendar-view.ts similarity index 100% rename from modules/cal/components/folk-calendar-view.ts rename to modules/rcal/components/folk-calendar-view.ts diff --git a/modules/cal/db/schema.sql b/modules/rcal/db/schema.sql similarity index 100% rename from modules/cal/db/schema.sql rename to modules/rcal/db/schema.sql diff --git a/modules/rcal/demo.ts b/modules/rcal/demo.ts new file mode 100644 index 0000000..67f3736 --- /dev/null +++ b/modules/rcal/demo.ts @@ -0,0 +1,344 @@ +/** + * rCal demo page — server-rendered HTML body. + * + * Static July 2026 calendar grid with Alpine Explorer trip events, + * tab switching (Temporal/Spatial/Lunar/Context), zoom panel, + * and feature cards. Entirely local state, no WebSocket. + */ + +/* ─── Event Data ──────────────────────────────────────────── */ + +interface CalEvent { + day: number; + emoji: string; + label: string; + color: string; + bg: string; +} + +const TRIP_EVENTS: CalEvent[] = [ + { day: 6, emoji: "\u2708\uFE0F", label: "Arrive Chamonix", color: "#2dd4bf", bg: "rgba(20,184,166,0.15)" }, + { day: 7, emoji: "\u{1F97E}", label: "Lac Blanc Hike", color: "#34d399", bg: "rgba(16,185,129,0.15)" }, + { day: 8, emoji: "\u{1F9D7}", label: "Aiguille du Midi", color: "#fbbf24", bg: "rgba(245,158,11,0.15)" }, + { day: 9, emoji: "\u{1F97E}", label: "Mer de Glace", color: "#34d399", bg: "rgba(16,185,129,0.15)" }, + { day: 10, emoji: "\u{1F682}", label: "Train to Zermatt", color: "#22d3ee", bg: "rgba(6,182,212,0.15)" }, + { day: 11, emoji: "\u{1F97E}", label: "Five Lakes Walk", color: "#34d399", bg: "rgba(16,185,129,0.15)" }, + { day: 12, emoji: "\u26F7", label: "Glacier Paradise", color: "#fbbf24", bg: "rgba(245,158,11,0.15)" }, + { day: 13, emoji: "\u{1F3DB}", label: "Alpine Museum", color: "#a78bfa", bg: "rgba(139,92,246,0.15)" }, + { day: 14, emoji: "\u{1F68C}", label: "Bus to Dolomites", color: "#22d3ee", bg: "rgba(6,182,212,0.15)" }, + { day: 15, emoji: "\u{1F97E}", label: "Tre Cime Circuit", color: "#34d399", bg: "rgba(16,185,129,0.15)" }, + { day: 16, emoji: "\u{1FA82}", label: "Paragliding", color: "#fbbf24", bg: "rgba(245,158,11,0.15)" }, + { day: 17, emoji: "\u{1F6F6}", label: "Lago di Braies", color: "#fbbf24", bg: "rgba(245,158,11,0.15)" }, + { day: 18, emoji: "\u{1F97E}", label: "Seceda Ridge", color: "#34d399", bg: "rgba(16,185,129,0.15)" }, + { day: 19, emoji: "\u{1F4F8}", label: "Rest & Photos", color: "#94a3b8", bg: "rgba(100,116,139,0.15)" }, + { day: 20, emoji: "\u2708\uFE0F", label: "Depart", color: "#2dd4bf", bg: "rgba(20,184,166,0.15)" }, +]; + +const TABS = ["Temporal", "Spatial", "Lunar", "Context"]; + +const ZOOM_LEVELS = [ + "Era", "Century", "Decade", "Year", "Quarter", + "Month", "Week", "Day", "Hour", "Minute", +]; + +const FEATURES = [ + { + icon: "\u{1F50D}", + title: "Temporal Zoom", + desc: "Navigate seamlessly from geological eras down to individual minutes. The calendar adapts its grid density and label fidelity at every level.", + }, + { + icon: "\u{1F30D}", + title: "Spatial Context", + desc: "Events are location-aware. Zoom the map and the calendar filters to show only events within the visible region.", + }, + { + icon: "\u{1F319}", + title: "Lunar Cycles", + desc: "Overlay moon phases, tidal patterns, and seasonal markers. Useful for agriculture, ceremony, and natural rhythm tracking.", + }, + { + icon: "\u{1F4C5}", + title: "Multi-Calendar", + desc: "Layer Gregorian, Islamic, Hebrew, Chinese, and custom community calendars. Cross-reference events across time systems.", + }, +]; + +const LEGEND = [ + { color: "#2dd4bf", label: "Travel" }, + { color: "#34d399", label: "Hike" }, + { color: "#fbbf24", label: "Adventure" }, + { color: "#22d3ee", label: "Transit" }, + { color: "#a78bfa", label: "Culture" }, + { color: "#94a3b8", label: "Rest" }, +]; + +/* ─── Helpers ─────────────────────────────────────────────── */ + +function eventForDay(day: number): CalEvent | undefined { + return TRIP_EVENTS.find((e) => e.day === day); +} + +function isTripDay(day: number): boolean { + return day >= 6 && day <= 20; +} + +/* ─── Render ──────────────────────────────────────────────── */ + +export function renderDemo(): string { + // July 2026: starts on Wednesday (offset 2 for Mon-based grid), 31 days + const firstDayOffset = 2; // Mon=0, Tue=1, Wed=2 + const totalDays = 31; + const dayNames = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; + + // Build calendar cells + const calendarCells: string[] = []; + + // Empty offset cells + for (let i = 0; i < firstDayOffset; i++) { + calendarCells.push(`
`); + } + + // Day cells + for (let d = 1; d <= totalDays; d++) { + const ev = eventForDay(d); + const trip = isTripDay(d); + const todayClass = d === 15 ? " rcal-cell--today" : ""; + const tripClass = trip ? " rcal-cell--trip" : ""; + + let pill = ""; + if (ev) { + pill = `
+ ${ev.emoji} + ${ev.label} +
`; + } + + calendarCells.push(`
+ ${d} + ${pill} +
`); + } + + return ` +
+ + +
+
+ Multi-Dimensional Calendar +
+

rCal Demo

+

Multi-dimensional calendar with temporal zoom

+
+ \u{1F50D} Temporal Zoom + | + \u{1F30D} Spatial Context + | + \u{1F319} Lunar Cycles + | + \u{1F4C5} Multi-Calendar +
+
+ + +
+ + +
+
+

+ \u{1F4C5} July 2026 +

+
+ ${TABS.map( + (tab, i) => ``, + ).join("\n ")} +
+
+ + +
+ ${dayNames.map((d) => `
${d}
`).join("\n ")} +
+ + +
+ ${calendarCells.join("\n ")} +
+ + +
+ Legend: + ${LEGEND.map( + (l) => ` + + ${l.label} + `, + ).join("\n ")} +
+
+
+ + +
+
+

+ \u{1F50D} Temporal Zoom +

+

+ Navigate across temporal granularities. The calendar grid adapts at each zoom level. +

+
+ ${ZOOM_LEVELS.map( + (level) => { + const isActive = level === "Month"; + return `
${level}${isActive ? " \u25C0" : ""}
`; + }, + ).join("\n ")} +
+
+
+ + +
+
+ ${FEATURES.map( + (f) => ` +
+
${f.icon}
+

${f.title}

+

${f.desc}

+
`, + ).join("")} +
+
+ + +
+
+

Coordinate in Time & Space

+

+ rCal layers temporal zoom, spatial context, and lunar cycles into a single calendar. + Plan events that respect natural rhythms and local conditions. +

+ + Create Your Space + +
+
+ +
+ +`; +} diff --git a/modules/cal/landing.ts b/modules/rcal/landing.ts similarity index 100% rename from modules/cal/landing.ts rename to modules/rcal/landing.ts diff --git a/modules/cal/mod.ts b/modules/rcal/mod.ts similarity index 96% rename from modules/cal/mod.ts rename to modules/rcal/mod.ts index 1c2324e..0adb122 100644 --- a/modules/cal/mod.ts +++ b/modules/rcal/mod.ts @@ -9,11 +9,12 @@ import { Hono } from "hono"; import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { sql } from "../../shared/db/pool"; -import { renderShell } from "../../server/shell"; +import { renderShell, renderDemoShell } from "../../server/shell"; import { getModuleInfoList } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module"; import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server"; import { renderLanding } from "./landing"; +import { renderDemo } from "./demo"; const routes = new Hono(); @@ -376,6 +377,18 @@ routes.get("/api/context/:tool", async (c) => { // ── Page route ── routes.get("/", (c) => { const space = c.req.param("space") || "demo"; + if (space === "demo") { + return c.html(renderDemoShell({ + title: "rCal Demo — rSpace", + moduleId: "rcal", + spaceSlug: space, + modules: getModuleInfoList(), + theme: "dark", + body: renderDemo(), + scripts: ``, + styles: ``, + })); + } return c.html(renderShell({ title: `${space} — Calendar | rSpace`, moduleId: "rcal", @@ -383,8 +396,8 @@ routes.get("/", (c) => { modules: getModuleInfoList(), theme: "dark", body: ``, - scripts: ``, - styles: ``, + scripts: ``, + styles: ``, })); }); @@ -396,6 +409,7 @@ export const calModule: RSpaceModule = { routes, standaloneDomain: "rcal.online", landingPage: renderLanding, + demoPage: renderDemo, feeds: [ { id: "events", diff --git a/modules/rcart/components/cart-demo.ts b/modules/rcart/components/cart-demo.ts new file mode 100644 index 0000000..56bea93 --- /dev/null +++ b/modules/rcart/components/cart-demo.ts @@ -0,0 +1,2 @@ +// rCart demo — static display, no client interactivity needed +export {}; diff --git a/modules/cart/components/cart.css b/modules/rcart/components/cart.css similarity index 100% rename from modules/cart/components/cart.css rename to modules/rcart/components/cart.css diff --git a/modules/cart/components/folk-cart-shop.ts b/modules/rcart/components/folk-cart-shop.ts similarity index 100% rename from modules/cart/components/folk-cart-shop.ts rename to modules/rcart/components/folk-cart-shop.ts diff --git a/modules/cart/db/schema.sql b/modules/rcart/db/schema.sql similarity index 100% rename from modules/cart/db/schema.sql rename to modules/rcart/db/schema.sql diff --git a/modules/rcart/demo.ts b/modules/rcart/demo.ts new file mode 100644 index 0000000..14084c4 --- /dev/null +++ b/modules/rcart/demo.ts @@ -0,0 +1,234 @@ +/** + * rCart demo page — static community garden shopping cart. + * + * Renders a fully server-side demo with 8 cart items, funding progress bars, + * member activity, and summary stats. No WebSocket needed (all static data). + */ + +/* ─── Mock Data ─────────────────────────────────────────────── */ + +const members = [ + { name: "Alice", color: "#10b981" }, + { name: "Bob", color: "#0ea5e9" }, + { name: "Carol", color: "#f59e0b" }, + { name: "Dave", color: "#8b5cf6" }, +]; + +interface CartItem { + name: string; + price: number; + requestedBy: string; + funded: number; + status: "Funded" | "In Cart" | "Needs Funding"; +} + +const cartItems: CartItem[] = [ + { name: "Raised Garden Bed Kit (4x8 ft)", price: 89.99, requestedBy: "Alice", funded: 89.99, status: "Funded" }, + { name: "Organic Seed Variety Pack (30 types)", price: 34.5, requestedBy: "Carol", funded: 34.5, status: "Funded" }, + { name: "Premium Potting Soil (40 qt, 3-pack)", price: 47.99, requestedBy: "Bob", funded: 32.0, status: "In Cart" }, + { name: "Stainless Steel Garden Tool Set", price: 62.0, requestedBy: "Dave", funded: 62.0, status: "Funded" }, + { name: "Drip Irrigation Kit (100 ft)", price: 54.95, requestedBy: "Alice", funded: 20.0, status: "Needs Funding" }, + { name: "Compost Tumbler (45 gal)", price: 109.0, requestedBy: "Bob", funded: 109.0, status: "Funded" }, + { name: "Garden Kneeling Pad & Gloves Set", price: 28.5, requestedBy: "Carol", funded: 12.0, status: "Needs Funding" }, + { name: "Solar-Powered Pest Repeller (4-pack)", price: 39.99, requestedBy: "Dave", funded: 39.99, status: "In Cart" }, +]; + +/* ─── Helpers ──────────────────────────────────────────────── */ + +function getMemberColor(name: string): string { + return members.find((m) => m.name === name)?.color || "#64748b"; +} + +function statusBadgeClass(status: CartItem["status"]): string { + switch (status) { + case "Funded": + return "rd-badge--emerald"; + case "In Cart": + return "rd-badge--sky"; + case "Needs Funding": + return "rd-badge--amber"; + } +} + +function progressFillClass(pct: number): string { + if (pct >= 100) return "rd-progress__fill--emerald"; + if (pct >= 50) return "rd-progress__fill--sky"; + return "rd-progress__fill--amber"; +} + +/* ─── Render ─────────────────────────────────────────────── */ + +export function renderDemo(): string { + const totalCost = cartItems.reduce((sum, item) => sum + item.price, 0); + const totalFunded = cartItems.reduce((sum, item) => sum + item.funded, 0); + const perPerson = totalCost / members.length; + const fundedCount = cartItems.filter((i) => i.status === "Funded").length; + const overallPct = Math.round((totalFunded / totalCost) * 100); + const uniqueRequesters = new Set(cartItems.map((i) => i.requestedBy)).size; + + return ` +
+ + +
+
+ Group Shopping, Together +
+

See how rCart works

+

+ A community garden project where neighbors pool resources to buy everything they need together. +

+
+ 8 items + | + $${totalCost.toFixed(2)} total + | + ${fundedCount}/${cartItems.length} funded +
+
+ ${members + .map( + (m) => + `
${m.name[0]}
`, + ) + .join("\n ")} + ${members.length} members +
+
+ + +
+
+
+
+

Community Garden Project

+

Shared cart for our neighborhood garden setup

+
+
+

$${totalFunded.toFixed(2)}

+

of $${totalCost.toFixed(2)} funded

+
+
+
+
+
+
+ ${overallPct}% funded + $${(totalCost - totalFunded).toFixed(2)} remaining +
+
+ + +
+
+
🛒 Cart Items
+
+ + Funded + + + In Cart + + + Needs Funding + +
+
+ ${cartItems + .map((item) => { + const pct = Math.round((item.funded / item.price) * 100); + const memberColor = getMemberColor(item.requestedBy); + return ` +
+
+
+
+ ${item.name} + ${item.status} +
+
+ + ${item.requestedBy[0]} + ${item.requestedBy} + + requested this +
+
+
+

$${item.price.toFixed(2)}

+ ${item.status !== "Funded" ? `

$${item.funded.toFixed(2)} funded

` : ""} +
+
+
+
+
+
`; + }) + .join("")} +
+ + +
+
+

Total Cost

+

$${totalCost.toFixed(2)}

+

${cartItems.length} items across ${uniqueRequesters} requesters

+
+
+

Amount Funded

+

$${totalFunded.toFixed(2)}

+

${fundedCount} of ${cartItems.length} items fully funded

+
+
+

Per-Person Split

+

$${perPerson.toFixed(2)}

+

split equally among ${members.length} members

+
+
+ + +
+

Member Activity

+
+ ${members + .map((member) => { + const requested = cartItems.filter((i) => i.requestedBy === member.name); + const requestedTotal = requested.reduce((sum, i) => sum + i.price, 0); + return ` +
+
${member.name[0]}
+
+

${member.name}

+

+ ${requested.length} item${requested.length !== 1 ? "s" : ""} requested + · + $${requestedTotal.toFixed(2)} total +

+
+
+

$${perPerson.toFixed(2)}

+

share

+
+
`; + }) + .join("")} +
+
+
+ + +
+
+

Ready to shop together?

+

+ Create a shared cart for your group, community, or team. Add items from any store, + split costs fairly, and check out together. +

+ + Create Your First Cart + +
+
+ +
`; +} diff --git a/modules/cart/flow.ts b/modules/rcart/flow.ts similarity index 100% rename from modules/cart/flow.ts rename to modules/rcart/flow.ts diff --git a/modules/cart/landing.ts b/modules/rcart/landing.ts similarity index 100% rename from modules/cart/landing.ts rename to modules/rcart/landing.ts diff --git a/modules/cart/mod.ts b/modules/rcart/mod.ts similarity index 96% rename from modules/cart/mod.ts rename to modules/rcart/mod.ts index 24dc275..3ef01d7 100644 --- a/modules/cart/mod.ts +++ b/modules/rcart/mod.ts @@ -10,12 +10,13 @@ import { Hono } from "hono"; import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { sql } from "../../shared/db/pool"; -import { renderShell } from "../../server/shell"; +import { renderShell, renderDemoShell } from "../../server/shell"; import { getModuleInfoList } from "../../shared/module"; import { depositOrderRevenue } from "./flow"; import type { RSpaceModule } from "../../shared/module"; import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server"; import { renderLanding } from "./landing"; +import { renderDemo } from "./demo"; const routes = new Hono(); @@ -442,6 +443,18 @@ routes.post("/api/fulfill/resolve", async (c) => { // ── Page route: shop ── routes.get("/", (c) => { const space = c.req.param("space") || "demo"; + if (space === "demo") { + return c.html(renderDemoShell({ + title: "rCart Demo — rSpace", + moduleId: "rcart", + spaceSlug: space, + modules: getModuleInfoList(), + theme: "dark", + body: renderDemo(), + scripts: ``, + styles: ``, + })); + } return c.html(renderShell({ title: `Shop | rSpace`, moduleId: "rcart", @@ -449,8 +462,8 @@ routes.get("/", (c) => { modules: getModuleInfoList(), theme: "dark", body: ``, - scripts: ``, - styles: ``, + scripts: ``, + styles: ``, })); }); @@ -462,6 +475,7 @@ export const cartModule: RSpaceModule = { routes, standaloneDomain: "rcart.online", landingPage: renderLanding, + demoPage: renderDemo, feeds: [ { id: "orders", diff --git a/modules/choices/components/choices.css b/modules/rchoices/components/choices.css similarity index 100% rename from modules/choices/components/choices.css rename to modules/rchoices/components/choices.css diff --git a/modules/choices/components/folk-choices-dashboard.ts b/modules/rchoices/components/folk-choices-dashboard.ts similarity index 100% rename from modules/choices/components/folk-choices-dashboard.ts rename to modules/rchoices/components/folk-choices-dashboard.ts diff --git a/modules/choices/landing.ts b/modules/rchoices/landing.ts similarity index 100% rename from modules/choices/landing.ts rename to modules/rchoices/landing.ts diff --git a/modules/choices/mod.ts b/modules/rchoices/mod.ts similarity index 93% rename from modules/choices/mod.ts rename to modules/rchoices/mod.ts index 9a233a9..dcd8d70 100644 --- a/modules/choices/mod.ts +++ b/modules/rchoices/mod.ts @@ -56,8 +56,8 @@ routes.get("/", (c) => { modules: getModuleInfoList(), theme: "dark", body: ``, - scripts: ``, - styles: ``, + scripts: ``, + styles: ``, })); }); diff --git a/modules/data/components/data.css b/modules/rdata/components/data.css similarity index 100% rename from modules/data/components/data.css rename to modules/rdata/components/data.css diff --git a/modules/data/components/folk-analytics-view.ts b/modules/rdata/components/folk-analytics-view.ts similarity index 100% rename from modules/data/components/folk-analytics-view.ts rename to modules/rdata/components/folk-analytics-view.ts diff --git a/modules/data/landing.ts b/modules/rdata/landing.ts similarity index 100% rename from modules/data/landing.ts rename to modules/rdata/landing.ts diff --git a/modules/data/mod.ts b/modules/rdata/mod.ts similarity index 96% rename from modules/data/mod.ts rename to modules/rdata/mod.ts index cf8780d..d24c404 100644 --- a/modules/data/mod.ts +++ b/modules/rdata/mod.ts @@ -128,8 +128,8 @@ routes.get("/", (c) => { modules: getModuleInfoList(), theme: "dark", body: ``, - scripts: ``, - styles: ``, + scripts: ``, + styles: ``, })); }); diff --git a/modules/files/components/files.css b/modules/rfiles/components/files.css similarity index 100% rename from modules/files/components/files.css rename to modules/rfiles/components/files.css diff --git a/modules/files/components/folk-file-browser.ts b/modules/rfiles/components/folk-file-browser.ts similarity index 100% rename from modules/files/components/folk-file-browser.ts rename to modules/rfiles/components/folk-file-browser.ts diff --git a/modules/files/db/schema.sql b/modules/rfiles/db/schema.sql similarity index 100% rename from modules/files/db/schema.sql rename to modules/rfiles/db/schema.sql diff --git a/modules/files/landing.ts b/modules/rfiles/landing.ts similarity index 100% rename from modules/files/landing.ts rename to modules/rfiles/landing.ts diff --git a/modules/files/mod.ts b/modules/rfiles/mod.ts similarity index 99% rename from modules/files/mod.ts rename to modules/rfiles/mod.ts index c1f52fd..c8a6302 100644 --- a/modules/files/mod.ts +++ b/modules/rfiles/mod.ts @@ -374,8 +374,8 @@ routes.get("/", (c) => { modules: getModuleInfoList(), theme: "dark", body: ``, - scripts: ``, - styles: ``, + scripts: ``, + styles: ``, })); }); diff --git a/modules/forum/components/folk-forum-dashboard.ts b/modules/rforum/components/folk-forum-dashboard.ts similarity index 100% rename from modules/forum/components/folk-forum-dashboard.ts rename to modules/rforum/components/folk-forum-dashboard.ts diff --git a/modules/forum/components/forum.css b/modules/rforum/components/forum.css similarity index 100% rename from modules/forum/components/forum.css rename to modules/rforum/components/forum.css diff --git a/modules/forum/db/schema.sql b/modules/rforum/db/schema.sql similarity index 100% rename from modules/forum/db/schema.sql rename to modules/rforum/db/schema.sql diff --git a/modules/forum/landing.ts b/modules/rforum/landing.ts similarity index 100% rename from modules/forum/landing.ts rename to modules/rforum/landing.ts diff --git a/modules/forum/lib/cloud-init.ts b/modules/rforum/lib/cloud-init.ts similarity index 100% rename from modules/forum/lib/cloud-init.ts rename to modules/rforum/lib/cloud-init.ts diff --git a/modules/forum/lib/dns.ts b/modules/rforum/lib/dns.ts similarity index 100% rename from modules/forum/lib/dns.ts rename to modules/rforum/lib/dns.ts diff --git a/modules/forum/lib/hetzner.ts b/modules/rforum/lib/hetzner.ts similarity index 100% rename from modules/forum/lib/hetzner.ts rename to modules/rforum/lib/hetzner.ts diff --git a/modules/forum/lib/provisioner.ts b/modules/rforum/lib/provisioner.ts similarity index 100% rename from modules/forum/lib/provisioner.ts rename to modules/rforum/lib/provisioner.ts diff --git a/modules/forum/mod.ts b/modules/rforum/mod.ts similarity index 97% rename from modules/forum/mod.ts rename to modules/rforum/mod.ts index 9c8f0e2..181a3e0 100644 --- a/modules/forum/mod.ts +++ b/modules/rforum/mod.ts @@ -165,8 +165,8 @@ routes.get("/", (c) => { modules: getModuleInfoList(), theme: "dark", body: ``, - scripts: ``, - styles: ``, + scripts: ``, + styles: ``, })); }); diff --git a/modules/funds/components/folk-budget-river.ts b/modules/rfunds/components/folk-budget-river.ts similarity index 100% rename from modules/funds/components/folk-budget-river.ts rename to modules/rfunds/components/folk-budget-river.ts diff --git a/modules/funds/components/folk-funds-app.ts b/modules/rfunds/components/folk-funds-app.ts similarity index 99% rename from modules/funds/components/folk-funds-app.ts rename to modules/rfunds/components/folk-funds-app.ts index a581669..1bbad1b 100644 --- a/modules/funds/components/folk-funds-app.ts +++ b/modules/rfunds/components/folk-funds-app.ts @@ -160,9 +160,9 @@ class FolkFundsApp extends HTMLElement { } private getCssPath(): string { - // In rSpace: /modules/funds/funds.css | Standalone: /modules/funds/funds.css - // The shell always serves from /modules/funds/ in both modes - return "/modules/funds/funds.css"; + // In rSpace: /modules/rfunds/funds.css | Standalone: /modules/rfunds/funds.css + // The shell always serves from /modules/rfunds/ in both modes + return "/modules/rfunds/funds.css"; } private render() { diff --git a/modules/rfunds/components/funds-demo.ts b/modules/rfunds/components/funds-demo.ts new file mode 100644 index 0000000..466c25a --- /dev/null +++ b/modules/rfunds/components/funds-demo.ts @@ -0,0 +1,526 @@ +/** + * rFunds demo — client-side WebSocket controller. + * + * Connects via DemoSync, extracts expenses and budget from shapes, + * renders/updates budget overview, expense list, balances, settlements, + * category breakdown, and per-person stats. Supports inline expense editing. + */ + +import { DemoSync } from "@lib/demo-sync-vanilla"; +import type { DemoShape } from "@lib/demo-sync-vanilla"; + +// ── Types ── + +interface ExpenseShape extends DemoShape { + type: "demo-expense"; + description: string; + amount: number; + currency: string; + paidBy: string; + split: "equal" | "custom"; + category: "transport" | "accommodation" | "activity" | "food"; + date: string; +} + +interface BudgetShape extends DemoShape { + type: "folk-budget"; + budgetTitle: string; + currency: string; + budgetTotal: number; + spent: number; + categories: { name: string; budget: number; spent: number }[]; +} + +function isExpense(shape: DemoShape): shape is ExpenseShape { + return shape.type === "demo-expense" && typeof (shape as ExpenseShape).amount === "number"; +} + +function isBudget(shape: DemoShape): shape is BudgetShape { + return shape.type === "folk-budget"; +} + +// ── Constants ── + +const MEMBERS = ["Maya", "Liam", "Priya", "Omar"]; + +const MEMBER_COLORS: Record = { + Maya: "#10b981", + Liam: "#06b6d4", + Priya: "#8b5cf6", + Omar: "#f59e0b", +}; + +const MEMBER_BG: Record = { + Maya: "rd-bg-emerald", + Liam: "rd-bg-cyan", + Priya: "rd-bg-violet", + Omar: "rd-bg-amber", +}; + +const CATEGORY_ICONS: Record = { + transport: "\u{1F682}", + accommodation: "\u{1F3E8}", + activity: "\u26F7", + food: "\u{1F372}", +}; + +const CATEGORY_LABELS: Record = { + transport: "Transport", + accommodation: "Accommodation", + activity: "Activities", + food: "Food & Drink", +}; + +const CATEGORY_TEXT_CLASS: Record = { + transport: "rd-cyan", + accommodation: "rd-violet", + activity: "rd-amber", + food: "rd-rose", +}; + +// ── Helpers ── + +function fmt(amount: number): string { + return `\u20AC${amount.toLocaleString("en-US", { minimumFractionDigits: 0, maximumFractionDigits: 2 })}`; +} + +function formatDate(dateStr: string): string { + try { + const d = new Date(dateStr); + return d.toLocaleDateString("en-GB", { day: "numeric", month: "short" }); + } catch { + return dateStr; + } +} + +function esc(s: string): string { + return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +} + +// ── Balance computation ── + +interface BalanceEntry { + name: string; + paid: number; + owes: number; + balance: number; +} + +function computeBalances(expenses: ExpenseShape[]): BalanceEntry[] { + const total = expenses.reduce((s, e) => s + e.amount, 0); + const perPerson = total / MEMBERS.length; + const paid: Record = {}; + MEMBERS.forEach((m) => (paid[m] = 0)); + expenses.forEach((e) => (paid[e.paidBy] = (paid[e.paidBy] || 0) + e.amount)); + return MEMBERS.map((name) => ({ + name, + paid: paid[name] || 0, + owes: perPerson, + balance: (paid[name] || 0) - perPerson, + })); +} + +// ── Settlement computation (greedy) ── + +interface Settlement { + from: string; + to: string; + amount: number; +} + +function computeSettlements(balances: BalanceEntry[]): Settlement[] { + const debtors = balances + .filter((b) => b.balance < -0.01) + .map((b) => ({ ...b, balance: -b.balance })); + const creditors = balances + .filter((b) => b.balance > 0.01) + .map((b) => ({ ...b })); + + debtors.sort((a, b) => b.balance - a.balance); + creditors.sort((a, b) => b.balance - a.balance); + + const settlements: Settlement[] = []; + let di = 0, + ci = 0; + + while (di < debtors.length && ci < creditors.length) { + const amount = Math.min(debtors[di].balance, creditors[ci].balance); + if (amount > 0.01) { + settlements.push({ + from: debtors[di].name, + to: creditors[ci].name, + amount: Math.round(amount * 100) / 100, + }); + } + debtors[di].balance -= amount; + creditors[ci].balance -= amount; + if (debtors[di].balance < 0.01) di++; + if (creditors[ci].balance < 0.01) ci++; + } + + return settlements; +} + +// ── DOM refs ── + +const connBadge = document.getElementById("rd-conn-badge") as HTMLElement; +const resetBtn = document.getElementById("rd-reset-btn") as HTMLButtonElement; +const loadingEl = document.getElementById("rd-loading") as HTMLElement; +const emptyEl = document.getElementById("rd-empty") as HTMLElement; + +const budgetSection = document.getElementById("rd-budget-section") as HTMLElement; +const expensesSection = document.getElementById("rd-expenses-section") as HTMLElement; +const spendingSection = document.getElementById("rd-spending-section") as HTMLElement; +const personSection = document.getElementById("rd-person-section") as HTMLElement; + +const budgetTotalEl = document.getElementById("rd-budget-total") as HTMLElement; +const budgetSpentEl = document.getElementById("rd-budget-spent") as HTMLElement; +const budgetRemainingEl = document.getElementById("rd-budget-remaining") as HTMLElement; +const budgetRemainingLabel = document.getElementById("rd-budget-remaining-label") as HTMLElement; +const budgetPctLabel = document.getElementById("rd-budget-pct-label") as HTMLElement; +const budgetLeftLabel = document.getElementById("rd-budget-left-label") as HTMLElement; +const budgetBar = document.getElementById("rd-budget-bar") as HTMLElement; + +const expenseList = document.getElementById("rd-expense-list") as HTMLElement; +const expenseCount = document.getElementById("rd-expense-count") as HTMLElement; +const expenseTotal = document.getElementById("rd-expense-total") as HTMLElement; +const balancesBody = document.getElementById("rd-balances-body") as HTMLElement; +const settlementsBody = document.getElementById("rd-settlements-body") as HTMLElement; + +// ── DemoSync ── + +const sync = new DemoSync({ filter: ["demo-expense", "folk-budget"] }); + +// Editing state +let editingExpenseId: string | null = null; + +// Show loading spinner immediately +loadingEl.style.display = ""; + +// ── Connection events ── + +sync.addEventListener("connected", () => { + connBadge.className = "rd-status rd-status--connected"; + connBadge.textContent = "Connected"; + resetBtn.disabled = false; +}); + +sync.addEventListener("disconnected", () => { + connBadge.className = "rd-status rd-status--disconnected"; + connBadge.textContent = "Disconnected"; + resetBtn.disabled = true; +}); + +// ── Snapshot -> render ── + +sync.addEventListener("snapshot", ((e: CustomEvent) => { + const shapes: Record = e.detail.shapes; + + const expenses = Object.values(shapes) + .filter(isExpense) + .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); + + const budget = Object.values(shapes).find(isBudget) ?? null; + + // Hide loading + loadingEl.style.display = "none"; + + const hasData = expenses.length > 0 || budget !== null; + + if (!hasData) { + emptyEl.style.display = ""; + budgetSection.style.display = "none"; + expensesSection.style.display = "none"; + spendingSection.style.display = "none"; + personSection.style.display = "none"; + return; + } + emptyEl.style.display = "none"; + + // ── Computed values ── + const totalSpent = expenses.reduce((s, ex) => s + ex.amount, 0); + const budgetTotal = budget?.budgetTotal ?? 4000; + const budgetSpent = budget?.spent ?? totalSpent; + const budgetRemaining = budgetTotal - budgetSpent; + const budgetPct = budgetTotal > 0 ? Math.min(100, Math.round((budgetSpent / budgetTotal) * 100)) : 0; + + const balances = computeBalances(expenses); + const settlements = computeSettlements(balances); + + // Category breakdown from budget shape or computed from expenses + let categoryBreakdown: { name: string; budget: number; spent: number }[]; + if (budget?.categories && budget.categories.length > 0) { + categoryBreakdown = budget.categories; + } else { + const cats: Record = {}; + expenses.forEach((ex) => (cats[ex.category] = (cats[ex.category] || 0) + ex.amount)); + categoryBreakdown = Object.entries(cats).map(([name, spent]) => ({ + name, + budget: Math.round(budgetTotal / 4), + spent, + })); + } + + // ── Update Budget Overview ── + budgetSection.style.display = ""; + budgetTotalEl.textContent = fmt(budgetTotal); + budgetSpentEl.textContent = fmt(budgetSpent); + budgetRemainingEl.textContent = fmt(Math.abs(budgetRemaining)); + budgetRemainingEl.className = `rd-stat__value ${budgetRemaining >= 0 ? "rd-cyan" : "rd-rose"}`; + budgetRemainingLabel.textContent = budgetRemaining >= 0 ? "Remaining" : "Over Budget"; + budgetPctLabel.textContent = `${budgetPct}% used`; + budgetLeftLabel.textContent = `${fmt(Math.abs(budgetRemaining))} ${budgetRemaining >= 0 ? "left" : "over"}`; + budgetBar.style.width = `${budgetPct}%`; + budgetBar.className = `rd-progress__fill ${budgetPct >= 90 ? "rd-progress__fill--rose" : budgetPct >= 70 ? "rd-progress__fill--amber" : "rd-progress__fill--emerald"}`; + + // Category breakdown + const catSection = document.getElementById("rd-category-breakdown")!; + const catCards = catSection.querySelectorAll("[data-category]"); + catCards.forEach((card) => { + const key = card.dataset.category!; + const catData = categoryBreakdown.find( + (c) => c.name.toLowerCase() === key, + ); + const spent = catData?.spent ?? 0; + const catBudget = catData?.budget ?? Math.round(budgetTotal / 4); + const catPct = catBudget > 0 ? Math.min(100, Math.round((spent / catBudget) * 100)) : 0; + + const amountsEl = card.querySelector("[data-cat-amounts]") as HTMLElement; + const barEl = card.querySelector("[data-cat-bar]") as HTMLElement; + const pctEl = card.querySelector("[data-cat-pct]") as HTMLElement; + + if (amountsEl) amountsEl.textContent = `${fmt(spent)} / ${fmt(catBudget)}`; + if (barEl) barEl.style.width = `${catPct}%`; + if (pctEl) pctEl.textContent = `${catPct}% used`; + }); + + // ── Update Expenses ── + if (expenses.length > 0) { + expensesSection.style.display = ""; + expenseCount.textContent = `Expenses (${expenses.length})`; + expenseTotal.textContent = fmt(totalSpent); + + expenseList.innerHTML = expenses + .map((ex) => { + const catIcon = CATEGORY_ICONS[ex.category] || "\u{1F4B0}"; + const catLabel = CATEGORY_LABELS[ex.category] || ex.category; + const catTextClass = CATEGORY_TEXT_CLASS[ex.category] || "rd-text-muted"; + const perPerson = fmt(Math.round((ex.amount / MEMBERS.length) * 100) / 100); + + return `
+
+ ${catIcon} +
+
+

${esc(ex.description)}

+
+ ${catLabel} + \u00B7 + Paid by ${esc(ex.paidBy)} + \u00B7 + ${ex.split === "equal" ? `Split ${MEMBERS.length} ways` : "Custom split"} + \u00B7 + ${formatDate(ex.date)} +
+
+ +
`; + }) + .join(""); + } else { + expensesSection.style.display = "none"; + } + + // ── Update Balances ── + balancesBody.innerHTML = balances + .map((b) => { + const bgClass = MEMBER_BG[b.name] || "rd-bg-slate"; + const initial = b.name[0]; + const balanceColor = b.balance >= 0 ? "rd-emerald" : "rd-rose"; + const balanceStr = `${b.balance >= 0 ? "+" : ""}${fmt(Math.round(b.balance * 100) / 100)}`; + + return `
+
${initial}
+
+

${esc(b.name)}

+

Paid ${fmt(b.paid)}

+
+ ${balanceStr} +
`; + }) + .join(""); + + // ── Update Settlements ── + if (settlements.length > 0) { + settlementsBody.innerHTML = + settlements + .map( + (s) => `
+ ${esc(s.from)} + + \u2192 + ${fmt(s.amount)} + + ${esc(s.to)} +
`, + ) + .join("") + + `

+ ${settlements.length} payment${settlements.length !== 1 ? "s" : ""} to settle all debts +

`; + } else { + settlementsBody.innerHTML = `

All settled up!

`; + } + + // ── Update Spending by Category ── + if (expenses.length > 0) { + spendingSection.style.display = ""; + const catKeys = ["transport", "accommodation", "activity", "food"] as const; + catKeys.forEach((cat) => { + const catExpenses = expenses.filter((e) => e.category === cat); + const catTotal = catExpenses.reduce((s, e) => s + e.amount, 0); + const catPct = totalSpent > 0 ? Math.round((catTotal / totalSpent) * 100) : 0; + + const card = document.querySelector(`[data-spending-cat="${cat}"]`); + if (!card) return; + const amountEl = card.querySelector("[data-spending-amount]") as HTMLElement; + const barEl = card.querySelector("[data-spending-bar]") as HTMLElement; + const pctEl = card.querySelector("[data-spending-pct]") as HTMLElement; + + if (amountEl) amountEl.textContent = fmt(catTotal); + if (barEl) barEl.style.width = `${catPct}%`; + if (pctEl) pctEl.textContent = `${catPct}% of total`; + }); + } else { + spendingSection.style.display = "none"; + } + + // ── Update Per Person ── + if (expenses.length > 0) { + personSection.style.display = ""; + balances.forEach((b) => { + const card = document.querySelector(`[data-person="${b.name}"]`); + if (!card) return; + const paidPct = totalSpent > 0 ? Math.round((b.paid / totalSpent) * 100) : 0; + const paidEl = card.querySelector("[data-person-paid]") as HTMLElement; + const pctEl = card.querySelector("[data-person-pct]") as HTMLElement; + const balanceEl = card.querySelector("[data-person-balance]") as HTMLElement; + + if (paidEl) paidEl.textContent = fmt(b.paid); + if (pctEl) pctEl.textContent = `paid (${paidPct}%)`; + if (balanceEl) { + const roundedBalance = Math.round(b.balance * 100) / 100; + const label = b.balance >= 0 ? "Gets back " : "Owes "; + balanceEl.textContent = `${label}${fmt(Math.abs(roundedBalance))}`; + balanceEl.className = b.balance >= 0 ? "rd-emerald" : "rd-rose"; + balanceEl.style.fontSize = "0.875rem"; + balanceEl.style.fontWeight = "600"; + balanceEl.style.margin = "0.25rem 0 0"; + } + }); + } else { + personSection.style.display = "none"; + } +}) as EventListener); + +// ── Inline expense editing via event delegation ── + +document.addEventListener("click", (e) => { + const target = e.target as HTMLElement; + + // Click on amount button to start editing + const amountBtn = target.closest("[data-edit-expense]"); + if (amountBtn && !editingExpenseId) { + const expenseId = amountBtn.dataset.editExpense!; + const currentAmount = parseFloat(amountBtn.dataset.amount!); + editingExpenseId = expenseId; + + amountBtn.innerHTML = ` +
+ \u20AC + + + +
`; + + const input = document.getElementById("rd-edit-input") as HTMLInputElement; + if (input) { + input.focus(); + input.select(); + } + return; + } + + // Save edit + if (target.id === "rd-edit-save" || target.closest("#rd-edit-save")) { + saveEdit(); + return; + } + + // Cancel edit + if (target.id === "rd-edit-cancel" || target.closest("#rd-edit-cancel")) { + cancelEdit(); + return; + } +}); + +// Handle keyboard events on edit input +document.addEventListener("keydown", (e) => { + if (!editingExpenseId) return; + const input = document.getElementById("rd-edit-input") as HTMLInputElement; + if (!input || e.target !== input) return; + + if (e.key === "Enter") { + e.preventDefault(); + saveEdit(); + } else if (e.key === "Escape") { + e.preventDefault(); + cancelEdit(); + } +}); + +function saveEdit(): void { + if (!editingExpenseId) return; + const input = document.getElementById("rd-edit-input") as HTMLInputElement; + if (!input) return; + + const newAmount = parseFloat(input.value); + if (!isNaN(newAmount) && newAmount >= 0) { + sync.updateShape(editingExpenseId, { + amount: Math.round(newAmount * 100) / 100, + }); + } + editingExpenseId = null; +} + +function cancelEdit(): void { + editingExpenseId = null; + // Re-render will happen on next snapshot; force it by dispatching current state + sync.dispatchEvent( + new CustomEvent("snapshot", { + detail: { shapes: sync.shapes }, + }), + ); +} + +// ── Reset button ── + +resetBtn.addEventListener("click", async () => { + resetBtn.disabled = true; + try { + await sync.resetDemo(); + } catch (err) { + console.error("Reset failed:", err); + } finally { + if (sync.connected) resetBtn.disabled = false; + } +}); + +// ── Connect ── + +sync.connect(); diff --git a/modules/funds/components/funds.css b/modules/rfunds/components/funds.css similarity index 100% rename from modules/funds/components/funds.css rename to modules/rfunds/components/funds.css diff --git a/modules/funds/db/schema.sql b/modules/rfunds/db/schema.sql similarity index 100% rename from modules/funds/db/schema.sql rename to modules/rfunds/db/schema.sql diff --git a/modules/rfunds/demo.ts b/modules/rfunds/demo.ts new file mode 100644 index 0000000..9ab2e75 --- /dev/null +++ b/modules/rfunds/demo.ts @@ -0,0 +1,256 @@ +/** + * rFunds demo page — group expense tracking for "Alpine Explorer 2026". + * + * Renders server-side HTML skeleton with budget overview, expense list, + * balances, settlements, category breakdown, and per-person stats. + * The client-side funds-demo.ts hydrates via WebSocket (DemoSync). + */ + +/* ─── Constants ─────────────────────────────────────────────── */ + +const MEMBERS = [ + { name: "Maya", initial: "M", color: "#10b981", bgClass: "rd-bg-emerald" }, + { name: "Liam", initial: "L", color: "#06b6d4", bgClass: "rd-bg-cyan" }, + { name: "Priya", initial: "P", color: "#8b5cf6", bgClass: "rd-bg-violet" }, + { name: "Omar", initial: "O", color: "#f59e0b", bgClass: "rd-bg-amber" }, +]; + +const CATEGORIES = [ + { key: "transport", icon: "\u{1F682}", label: "Transport", colorClass: "rd-progress__fill--cyan", badgeClass: "rd-badge--sky", textClass: "rd-cyan" }, + { key: "accommodation", icon: "\u{1F3E8}", label: "Accommodation", colorClass: "rd-progress__fill--violet", badgeClass: "rd-badge--teal", textClass: "rd-violet" }, + { key: "activity", icon: "\u26F7", label: "Activities", colorClass: "rd-progress__fill--amber", badgeClass: "rd-badge--amber", textClass: "rd-amber" }, + { key: "food", icon: "\u{1F372}", label: "Food & Drink", colorClass: "rd-progress__fill--rose", badgeClass: "rd-badge--rose", textClass: "rd-rose" }, +]; + +/* ─── Render ─────────────────────────────────────────────── */ + +export function renderDemo(): string { + return ` +
+ + +
+

Alpine Explorer 2026

+

Group Expenses

+
+ \u{1F4C5} Jul 6-20, 2026 + | + \u{1F465} ${MEMBERS.length} travelers + | + \u{1F3D4} Chamonix \u2192 Zermatt \u2192 Dolomites +
+
+ ${MEMBERS.map( + (m) => + `
${m.initial}
`, + ).join("\n ")} + ${MEMBERS.length} members +
+
+ + +
+
+
+ Disconnected + Live — synced across all r* demos +
+ +
+
+ + +
+ + + + + +
+ + + + + + + + + + + + + + +
+
+

Track Your Group Expenses

+

+ rFunds makes it easy to manage shared costs, track budgets, and settle up. + Design custom funding flows with threshold-based mechanisms. +

+ + Create Your Space + +
+
+ +
+ +`; +} diff --git a/modules/funds/landing.ts b/modules/rfunds/landing.ts similarity index 100% rename from modules/funds/landing.ts rename to modules/rfunds/landing.ts diff --git a/modules/funds/lib/map-flow.ts b/modules/rfunds/lib/map-flow.ts similarity index 100% rename from modules/funds/lib/map-flow.ts rename to modules/rfunds/lib/map-flow.ts diff --git a/modules/funds/lib/presets.ts b/modules/rfunds/lib/presets.ts similarity index 100% rename from modules/funds/lib/presets.ts rename to modules/rfunds/lib/presets.ts diff --git a/modules/funds/lib/simulation.ts b/modules/rfunds/lib/simulation.ts similarity index 100% rename from modules/funds/lib/simulation.ts rename to modules/rfunds/lib/simulation.ts diff --git a/modules/funds/lib/types.ts b/modules/rfunds/lib/types.ts similarity index 100% rename from modules/funds/lib/types.ts rename to modules/rfunds/lib/types.ts diff --git a/modules/funds/mod.ts b/modules/rfunds/mod.ts similarity index 89% rename from modules/funds/mod.ts rename to modules/rfunds/mod.ts index b1e31a4..4ae283a 100644 --- a/modules/funds/mod.ts +++ b/modules/rfunds/mod.ts @@ -8,11 +8,12 @@ import { Hono } from "hono"; import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { sql } from "../../shared/db/pool"; -import { renderShell } from "../../server/shell"; +import { renderShell, renderDemoShell } from "../../server/shell"; import type { RSpaceModule } from "../../shared/module"; import { getModuleInfoList } from "../../shared/module"; import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server"; import { renderLanding } from "./landing"; +import { renderDemo } from "./demo"; const FLOW_SERVICE_URL = process.env.FLOW_SERVICE_URL || "http://payment-flow:3010"; @@ -189,14 +190,27 @@ routes.delete("/api/space-flows/:flowId", async (c) => { // ─── Page routes ──────────────────────────────────────── const fundsScripts = ` - - `; + + `; -const fundsStyles = ``; +const fundsStyles = ``; // Landing page routes.get("/", (c) => { const spaceSlug = c.req.param("space") || "demo"; + if (spaceSlug === "demo") { + return c.html(renderDemoShell({ + title: "rFunds Demo — rSpace", + moduleId: "rfunds", + spaceSlug, + modules: getModuleInfoList(), + theme: "dark", + body: renderDemo(), + demoScripts: ` + `, + styles: ``, + })); + } return c.html(renderShell({ title: `rFunds — TBFF Flow Funding | rSpace`, moduleId: "rfunds", @@ -204,8 +218,8 @@ routes.get("/", (c) => { modules: getModuleInfoList(), theme: "dark", body: ``, - scripts: ``, - styles: ``, + scripts: ``, + styles: ``, })); }); @@ -247,6 +261,7 @@ export const fundsModule: RSpaceModule = { description: "Budget flows, river visualization, and treasury management", routes, landingPage: renderLanding, + demoPage: renderDemo, standaloneDomain: "rfunds.online", feeds: [ { diff --git a/modules/inbox/components/folk-inbox-client.ts b/modules/rinbox/components/folk-inbox-client.ts similarity index 100% rename from modules/inbox/components/folk-inbox-client.ts rename to modules/rinbox/components/folk-inbox-client.ts diff --git a/modules/inbox/components/inbox.css b/modules/rinbox/components/inbox.css similarity index 100% rename from modules/inbox/components/inbox.css rename to modules/rinbox/components/inbox.css diff --git a/modules/inbox/db/schema.sql b/modules/rinbox/db/schema.sql similarity index 100% rename from modules/inbox/db/schema.sql rename to modules/rinbox/db/schema.sql diff --git a/modules/inbox/landing.ts b/modules/rinbox/landing.ts similarity index 100% rename from modules/inbox/landing.ts rename to modules/rinbox/landing.ts diff --git a/modules/inbox/mod.ts b/modules/rinbox/mod.ts similarity index 99% rename from modules/inbox/mod.ts rename to modules/rinbox/mod.ts index 2e7e568..bb0f97b 100644 --- a/modules/inbox/mod.ts +++ b/modules/rinbox/mod.ts @@ -538,8 +538,8 @@ routes.get("/", (c) => { modules: getModuleInfoList(), theme: "dark", body: ``, - scripts: ``, - styles: ``, + scripts: ``, + styles: ``, })); }); diff --git a/modules/maps/components/folk-map-viewer.ts b/modules/rmaps/components/folk-map-viewer.ts similarity index 100% rename from modules/maps/components/folk-map-viewer.ts rename to modules/rmaps/components/folk-map-viewer.ts diff --git a/modules/maps/components/maps.css b/modules/rmaps/components/maps.css similarity index 100% rename from modules/maps/components/maps.css rename to modules/rmaps/components/maps.css diff --git a/modules/maps/landing.ts b/modules/rmaps/landing.ts similarity index 100% rename from modules/maps/landing.ts rename to modules/rmaps/landing.ts diff --git a/modules/maps/mod.ts b/modules/rmaps/mod.ts similarity index 95% rename from modules/maps/mod.ts rename to modules/rmaps/mod.ts index 757f820..94d74c4 100644 --- a/modules/maps/mod.ts +++ b/modules/rmaps/mod.ts @@ -141,8 +141,8 @@ routes.get("/", (c) => { modules: getModuleInfoList(), theme: "dark", body: ``, - scripts: ``, - styles: ``, + scripts: ``, + styles: ``, })); }); @@ -156,9 +156,9 @@ routes.get("/:room", (c) => { spaceSlug: space, modules: getModuleInfoList(), theme: "dark", - styles: ``, + styles: ``, body: ``, - scripts: ``, + scripts: ``, })); }); diff --git a/modules/network/components/folk-graph-viewer.ts b/modules/rnetwork/components/folk-graph-viewer.ts similarity index 100% rename from modules/network/components/folk-graph-viewer.ts rename to modules/rnetwork/components/folk-graph-viewer.ts diff --git a/modules/network/components/network.css b/modules/rnetwork/components/network.css similarity index 100% rename from modules/network/components/network.css rename to modules/rnetwork/components/network.css diff --git a/modules/network/landing.ts b/modules/rnetwork/landing.ts similarity index 100% rename from modules/network/landing.ts rename to modules/rnetwork/landing.ts diff --git a/modules/network/mod.ts b/modules/rnetwork/mod.ts similarity index 97% rename from modules/network/mod.ts rename to modules/rnetwork/mod.ts index 5c528d1..66843f3 100644 --- a/modules/network/mod.ts +++ b/modules/rnetwork/mod.ts @@ -224,8 +224,8 @@ routes.get("/", (c) => { modules: getModuleInfoList(), theme: "dark", body: ``, - scripts: ``, - styles: ``, + scripts: ``, + styles: ``, })); }); diff --git a/modules/notes/components/folk-notes-app.ts b/modules/rnotes/components/folk-notes-app.ts similarity index 100% rename from modules/notes/components/folk-notes-app.ts rename to modules/rnotes/components/folk-notes-app.ts diff --git a/modules/rnotes/components/notes-demo.ts b/modules/rnotes/components/notes-demo.ts new file mode 100644 index 0000000..6d08512 --- /dev/null +++ b/modules/rnotes/components/notes-demo.ts @@ -0,0 +1,353 @@ +/** + * rNotes demo — client-side WebSocket controller. + * + * Connects to rSpace via DemoSync, populates note cards, + * packing list checkboxes, sidebar, and notebook header. + */ + +import { DemoSync, type DemoShape } from "../../../lib/demo-sync-vanilla"; + +// ── Helpers ── + +function shapesByType(shapes: Record, type: string): DemoShape[] { + return Object.values(shapes).filter((s) => s.type === type); +} + +function shapeByType(shapes: Record, type: string): DemoShape | undefined { + return Object.values(shapes).find((s) => s.type === type); +} + +function $(id: string): HTMLElement | null { + return document.getElementById(id); +} + +// ── Simple markdown renderer ── + +function renderMarkdown(text: string): string { + if (!text) return ""; + const lines = text.split("\n"); + const out: string[] = []; + let inCodeBlock = false; + let codeLang = ""; + let codeLines: string[] = []; + let inList: "ul" | "ol" | null = null; + + function flushList() { + if (inList) { out.push(inList === "ul" ? "" : ""); inList = null; } + } + + function flushCode() { + if (inCodeBlock) { + const escaped = codeLines.join("\n").replace(//g, ">"); + out.push(`
${codeLang ? `
${codeLang}
` : ""}
${escaped}
`); + inCodeBlock = false; + codeLines = []; + codeLang = ""; + } + } + + for (const raw of lines) { + const line = raw; + + // Code fence + if (line.startsWith("```")) { + if (inCodeBlock) { flushCode(); } else { flushList(); inCodeBlock = true; codeLang = line.slice(3).trim(); } + continue; + } + if (inCodeBlock) { codeLines.push(line); continue; } + + // Blank line + if (!line.trim()) { flushList(); continue; } + + // Headings + if (line.startsWith("### ")) { flushList(); out.push(`

${inlineFormat(line.slice(4))}

`); continue; } + if (line.startsWith("## ")) { flushList(); out.push(`

${inlineFormat(line.slice(3))}

`); continue; } + if (line.startsWith("# ")) { flushList(); out.push(`

${inlineFormat(line.slice(2))}

`); continue; } + if (line.startsWith("#### ")) { flushList(); out.push(`

${inlineFormat(line.slice(5))}

`); continue; } + if (line.startsWith("##### ")) { flushList(); out.push(`
${inlineFormat(line.slice(6))}
`); continue; } + + // Blockquote + if (line.startsWith("> ")) { flushList(); out.push(`

${inlineFormat(line.slice(2))}

`); continue; } + + // Unordered list + const ulMatch = line.match(/^[-*]\s+(.+)/); + if (ulMatch) { + if (inList !== "ul") { flushList(); out.push("
    "); inList = "ul"; } + out.push(`
  • ${inlineFormat(ulMatch[1])}
  • `); + continue; + } + + // Ordered list + const olMatch = line.match(/^(\d+)\.\s+(.+)/); + if (olMatch) { + if (inList !== "ol") { flushList(); out.push("
      "); inList = "ol"; } + out.push(`
    1. ${olMatch[1]}.${inlineFormat(olMatch[2])}
    2. `); + continue; + } + + // Paragraph + flushList(); + out.push(`

      ${inlineFormat(line)}

      `); + } + + flushCode(); + flushList(); + return out.join("\n"); +} + +function inlineFormat(text: string): string { + return text + .replace(/`([^`]+)`/g, "$1") + .replace(/\*\*([^*]+)\*\*/g, "$1") + .replace(/\*([^*]+)\*/g, "$1"); +} + +// ── Note card rendering ── + +const TAG_COLORS: Record = { + planning: "rgba(245,158,11,0.15)", + travel: "rgba(20,184,166,0.15)", + food: "rgba(251,146,60,0.15)", + gear: "rgba(168,85,247,0.15)", + safety: "rgba(239,68,68,0.15)", + accommodation: "rgba(59,130,246,0.15)", +}; + +function renderNoteCard(note: DemoShape, expanded: boolean): string { + const title = (note.title as string) || "Untitled"; + const content = (note.content as string) || ""; + const tags = (note.tags as string[]) || []; + const lastEdited = note.lastEdited as string; + const synced = note.synced !== false; + + const preview = content.split("\n").slice(0, 3).join(" ").slice(0, 120); + const previewText = preview.replace(/[#*>`\-]/g, "").trim(); + + return ` +
      +
      +
      +

      ${escHtml(title)}

      + ${synced ? `synced` : ""} +
      + ${expanded + ? `
      ${renderMarkdown(content)}
      ` + : `

      ${escHtml(previewText)}${content.length > 120 ? "..." : ""}

      ` + } +
      +
      + ${tags.map((t) => `${escHtml(t)}`).join("")} +
      + ${lastEdited ? `${formatRelative(lastEdited)}` : ""} +
      +
      +
      `; +} + +function escHtml(s: string): string { + return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +} + +function formatRelative(iso: string): string { + try { + const d = new Date(iso); + const now = Date.now(); + const diff = now - d.getTime(); + if (diff < 60_000) return "just now"; + if (diff < 3600_000) return `${Math.floor(diff / 60_000)}m ago`; + if (diff < 86400_000) return `${Math.floor(diff / 3600_000)}h ago`; + return d.toLocaleDateString("en", { month: "short", day: "numeric" }); + } catch { return ""; } +} + +// ── Packing list rendering ── + +function renderPackingList(packingList: DemoShape): string { + const items = (packingList.items as Array<{ name: string; packed: boolean; category: string }>) || []; + if (items.length === 0) return ""; + + // Group by category + const groups: Record = {}; + for (const item of items) { + const cat = item.category || "General"; + if (!groups[cat]) groups[cat] = []; + groups[cat].push(item); + } + + const checked = items.filter((i) => i.packed).length; + const pct = Math.round((checked / items.length) * 100); + + let html = ` +
      +
      + Packing Checklist + ${checked}/${items.length} packed (${pct}%) +
      +
      +
      +
      +
      +
      `; + + for (const [cat, catItems] of Object.entries(groups)) { + html += `
      +

      ${escHtml(cat)}

      `; + for (let i = 0; i < catItems.length; i++) { + const item = catItems[i]; + const globalIdx = items.indexOf(item); + html += ` +
      +
      + ${item.packed ? `` : ""} +
      + ${escHtml(item.name)} +
      `; + } + html += `
      `; + } + + html += `
      `; + return html; +} + +// ── Avatars ── + +const AVATAR_COLORS = ["#14b8a6", "#06b6d4", "#8b5cf6", "#f59e0b", "#f43f5e"]; + +function renderAvatars(members: string[]): string { + if (!members.length) return ""; + return members.map((name, i) => + `
      ${name[0]}
      ` + ).join("") + `${members.length} collaborators`; +} + +// ── Main ── + +const expandedNotes = new Set(); + +const sync = new DemoSync({ filter: ["folk-notebook", "folk-note", "folk-packing-list"] }); + +function render(shapes: Record) { + const notebook = shapeByType(shapes, "folk-notebook"); + const notes = shapesByType(shapes, "folk-note").sort((a, b) => { + const aTime = a.lastEdited ? new Date(a.lastEdited as string).getTime() : 0; + const bTime = b.lastEdited ? new Date(b.lastEdited as string).getTime() : 0; + return bTime - aTime; + }); + const packingList = shapeByType(shapes, "folk-packing-list"); + + // Hide loading skeleton + const loading = $("rd-loading"); + if (loading) loading.style.display = "none"; + + // Notebook header + if (notebook) { + const nbTitle = $("rd-nb-title"); + const nbCount = $("rd-nb-count"); + const nbDesc = $("rd-nb-desc"); + const sbTitle = $("rd-sb-nb-title"); + const sbCount = $("rd-sb-note-count"); + const sbNum = $("rd-sb-notes-num"); + + if (nbTitle) nbTitle.textContent = (notebook.name as string) || "Trip Notebook"; + if (nbCount) nbCount.textContent = `${notes.length} notes`; + if (nbDesc) nbDesc.textContent = (notebook.description as string) || ""; + if (sbTitle) sbTitle.textContent = (notebook.name as string) || "Trip Notebook"; + if (sbCount) sbCount.textContent = `${notes.length} note${notes.length !== 1 ? "s" : ""}`; + if (sbNum) sbNum.textContent = String(notes.length); + } + + // Notes count + const notesCount = $("rd-notes-count"); + if (notesCount) notesCount.textContent = `${notes.length} note${notes.length !== 1 ? "s" : ""}`; + + // Notes container + const container = $("rd-notes-container"); + const empty = $("rd-notes-empty"); + if (container) { + if (notes.length === 0) { + container.innerHTML = ""; + if (empty) empty.style.display = "block"; + } else { + if (empty) empty.style.display = "none"; + container.innerHTML = notes.map((n) => renderNoteCard(n, expandedNotes.has(n.id))).join(""); + } + } + + // Packing list + const packSection = $("rd-packing-section"); + const packContainer = $("rd-packing-container"); + if (packingList && packSection && packContainer) { + packSection.style.display = "block"; + packContainer.innerHTML = renderPackingList(packingList); + } + + // Avatars — extract from notebook members or note authors + const members = (notebook?.members as string[]) || []; + const avatarsEl = $("rd-avatars"); + if (avatarsEl && members.length > 0) { + avatarsEl.innerHTML = renderAvatars(members); + } +} + +// ── Event listeners ── + +sync.addEventListener("snapshot", ((e: CustomEvent) => { + render(e.detail.shapes); +}) as EventListener); + +sync.addEventListener("connected", () => { + const dot = $("rd-hero-dot"); + const label = $("rd-hero-label"); + if (dot) dot.style.background = "#10b981"; + if (label) label.textContent = "Live — Connected to rSpace"; + const resetBtn = $("rd-reset-btn") as HTMLButtonElement | null; + if (resetBtn) resetBtn.disabled = false; +}); + +sync.addEventListener("disconnected", () => { + const dot = $("rd-hero-dot"); + const label = $("rd-hero-label"); + if (dot) dot.style.background = "#64748b"; + if (label) label.textContent = "Reconnecting..."; + const resetBtn = $("rd-reset-btn") as HTMLButtonElement | null; + if (resetBtn) resetBtn.disabled = true; +}); + +// ── Event delegation ── + +document.addEventListener("click", (e) => { + const target = e.target as HTMLElement; + + // Note card expand/collapse + const noteCard = target.closest("[data-note-id]"); + if (noteCard) { + const id = noteCard.dataset.noteId!; + if (expandedNotes.has(id)) expandedNotes.delete(id); + else expandedNotes.add(id); + render(sync.shapes); + return; + } + + // Packing checkbox toggle + const packItem = target.closest("[data-pack-idx]"); + if (packItem) { + const idx = parseInt(packItem.dataset.packIdx!, 10); + const packingList = shapeByType(sync.shapes, "folk-packing-list"); + if (packingList) { + const items = [...(packingList.items as Array<{ name: string; packed: boolean; category: string }>)]; + items[idx] = { ...items[idx], packed: !items[idx].packed }; + sync.updateShape(packingList.id, { items }); + } + return; + } + + // Reset button + if (target.closest("#rd-reset-btn")) { + sync.resetDemo().catch((err) => console.error("[Notes] Reset failed:", err)); + } +}); + +// ── Start ── + +sync.connect(); diff --git a/modules/notes/components/notes.css b/modules/rnotes/components/notes.css similarity index 100% rename from modules/notes/components/notes.css rename to modules/rnotes/components/notes.css diff --git a/modules/notes/db/schema.sql b/modules/rnotes/db/schema.sql similarity index 100% rename from modules/notes/db/schema.sql rename to modules/rnotes/db/schema.sql diff --git a/modules/rnotes/demo.ts b/modules/rnotes/demo.ts new file mode 100644 index 0000000..b7e3d21 --- /dev/null +++ b/modules/rnotes/demo.ts @@ -0,0 +1,360 @@ +/** + * rNotes demo page — server-rendered HTML body. + * + * Returns the static HTML skeleton for the interactive notes demo. + * The client-side notes-demo.ts populates note cards, packing list, + * sidebar, and notebook header via WebSocket snapshots. + */ + +export function renderDemo(): string { + return ` +
      + + +
      +
      + + Interactive Demo +
      +

      See how rNotes works

      +

      A collaborative knowledge base for your team

      +
      + Live transcription + Audio & video + Organized notebooks + Canvas sync + Real-time collaboration +
      +
      +
      ...
      +
      +
      + + +
      +
      +

      + This demo shows a Trip Planning Notebook scenario + with notes, a packing list, tags, and canvas sync — all powered by the + r* ecosystem with live data from + rSpace. +

      + +
      +
      + + +
      +
      +
      +
      + 📓 + Loading... + +
      + Open in rNotes +
      +
      +

      Loading notebook data...

      +
      +
      + + +
      + + +
      +
      + +
      + Notebook + 0 notes +
      + +
      +
      +
      + 📓 + Loading... +
      +
      +
      + Notes + 0 +
      +
      + Packing List + 1 +
      +
      +
      +
      + +
      +
      + + Search notes... +
      +
      + + Browse tags +
      +
      + + Recent edits +
      +
      +
      +
      + + +
      + +
      +
      +
      +

      Notes

      + 0 notes +
      + Sort: Recently edited +
      + + +
      + ${[1, 2, 3] + .map( + () => ` +
      +
      +
      +
      +
      +
      +
      +
      +
      `, + ) + .join("")} +
      + + + +
      + + + +
      + + + +
      +
      +
      + + +
      +

      Everything you need to capture knowledge

      +
      + ${[ + { + icon: ``, + title: "Live Transcription", + desc: "Record and transcribe in real time. Stream audio via WebSocket or transcribe offline with Parakeet.js.", + }, + { + icon: ``, + title: "Rich Editing", + desc: "Headings, lists, code blocks, highlights, images, and file attachments in every note.", + }, + { + icon: ``, + title: "Notebooks", + desc: "Organize notes into notebooks with sections. Nest as deep as you need.", + }, + { + icon: ``, + title: "Flexible Tags", + desc: "Cross-cutting tags let you find notes across all notebooks instantly.", + }, + { + icon: ``, + title: "Canvas Sync", + desc: "Pin any note to your rSpace canvas for visual collaboration with your team.", + }, + ] + .map( + (f) => ` +
      +
      + ${f.icon} +
      +

      ${f.title}

      +

      ${f.desc}

      +
      `, + ) + .join("")} +
      +
      + + +
      +
      +

      Ready to capture everything?

      +

      + rNotes gives your team a shared knowledge base with rich editing, flexible organization, + and deep integration with the r* ecosystem — all on a collaborative canvas. +

      + + Start Taking Notes + +
      +
      + +
      + +`; +} diff --git a/modules/notes/landing.ts b/modules/rnotes/landing.ts similarity index 100% rename from modules/notes/landing.ts rename to modules/rnotes/landing.ts diff --git a/modules/notes/mod.ts b/modules/rnotes/mod.ts similarity index 96% rename from modules/notes/mod.ts rename to modules/rnotes/mod.ts index 104e7cf..70a9d7c 100644 --- a/modules/notes/mod.ts +++ b/modules/rnotes/mod.ts @@ -9,11 +9,12 @@ import { Hono } from "hono"; import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { sql } from "../../shared/db/pool"; -import { renderShell } from "../../server/shell"; +import { renderShell, renderDemoShell } from "../../server/shell"; import { getModuleInfoList } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module"; import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server"; import { renderLanding } from "./landing"; +import { renderDemo } from "./demo"; const routes = new Hono(); @@ -362,6 +363,19 @@ routes.delete("/api/notes/:id", async (c) => { // ── Page route ── routes.get("/", (c) => { const space = c.req.param("space") || "demo"; + if (space === "demo") { + return c.html(renderDemoShell({ + title: "rNotes Demo — rSpace", + moduleId: "rnotes", + spaceSlug: space, + modules: getModuleInfoList(), + theme: "dark", + body: renderDemo(), + demoScripts: ` + `, + styles: ``, + })); + } return c.html(renderShell({ title: `${space} — Notes | rSpace`, moduleId: "rnotes", @@ -369,8 +383,8 @@ routes.get("/", (c) => { modules: getModuleInfoList(), theme: "dark", body: ``, - scripts: ``, - styles: ``, + scripts: ``, + styles: ``, })); }); @@ -381,6 +395,7 @@ export const notesModule: RSpaceModule = { description: "Notebooks with rich-text notes, voice transcription, and collaboration", routes, landingPage: renderLanding, + demoPage: renderDemo, standaloneDomain: "rnotes.online", feeds: [ { diff --git a/modules/photos/components/folk-photo-gallery.ts b/modules/rphotos/components/folk-photo-gallery.ts similarity index 100% rename from modules/photos/components/folk-photo-gallery.ts rename to modules/rphotos/components/folk-photo-gallery.ts diff --git a/modules/photos/components/photos.css b/modules/rphotos/components/photos.css similarity index 100% rename from modules/photos/components/photos.css rename to modules/rphotos/components/photos.css diff --git a/modules/photos/landing.ts b/modules/rphotos/landing.ts similarity index 100% rename from modules/photos/landing.ts rename to modules/rphotos/landing.ts diff --git a/modules/photos/mod.ts b/modules/rphotos/mod.ts similarity index 96% rename from modules/photos/mod.ts rename to modules/rphotos/mod.ts index 58cc623..c0ef75f 100644 --- a/modules/photos/mod.ts +++ b/modules/rphotos/mod.ts @@ -116,8 +116,8 @@ routes.get("/", (c) => { modules: getModuleInfoList(), theme: "dark", body: ``, - scripts: ``, - styles: ``, + scripts: ``, + styles: ``, })); }); diff --git a/modules/pubs/components/folk-pubs-editor.ts b/modules/rpubs/components/folk-pubs-editor.ts similarity index 100% rename from modules/pubs/components/folk-pubs-editor.ts rename to modules/rpubs/components/folk-pubs-editor.ts diff --git a/modules/pubs/components/pubs.css b/modules/rpubs/components/pubs.css similarity index 100% rename from modules/pubs/components/pubs.css rename to modules/rpubs/components/pubs.css diff --git a/modules/pubs/formats.ts b/modules/rpubs/formats.ts similarity index 100% rename from modules/pubs/formats.ts rename to modules/rpubs/formats.ts diff --git a/modules/pubs/landing.ts b/modules/rpubs/landing.ts similarity index 100% rename from modules/pubs/landing.ts rename to modules/rpubs/landing.ts diff --git a/modules/pubs/mod.ts b/modules/rpubs/mod.ts similarity index 98% rename from modules/pubs/mod.ts rename to modules/rpubs/mod.ts index 82bdc2a..1205bfb 100644 --- a/modules/pubs/mod.ts +++ b/modules/rpubs/mod.ts @@ -329,8 +329,8 @@ routes.get("/", (c) => { modules: getModuleInfoList(), theme: "dark", body: ``, - scripts: ``, - styles: ``, + scripts: ``, + styles: ``, })); }); diff --git a/modules/pubs/parse-document.ts b/modules/rpubs/parse-document.ts similarity index 100% rename from modules/pubs/parse-document.ts rename to modules/rpubs/parse-document.ts diff --git a/modules/pubs/typst-compile.ts b/modules/rpubs/typst-compile.ts similarity index 100% rename from modules/pubs/typst-compile.ts rename to modules/rpubs/typst-compile.ts diff --git a/modules/pubs/typst/formats/a6.typ b/modules/rpubs/typst/formats/a6.typ similarity index 100% rename from modules/pubs/typst/formats/a6.typ rename to modules/rpubs/typst/formats/a6.typ diff --git a/modules/pubs/typst/formats/a7.typ b/modules/rpubs/typst/formats/a7.typ similarity index 100% rename from modules/pubs/typst/formats/a7.typ rename to modules/rpubs/typst/formats/a7.typ diff --git a/modules/pubs/typst/formats/digest.typ b/modules/rpubs/typst/formats/digest.typ similarity index 100% rename from modules/pubs/typst/formats/digest.typ rename to modules/rpubs/typst/formats/digest.typ diff --git a/modules/pubs/typst/formats/quarter-letter.typ b/modules/rpubs/typst/formats/quarter-letter.typ similarity index 100% rename from modules/pubs/typst/formats/quarter-letter.typ rename to modules/rpubs/typst/formats/quarter-letter.typ diff --git a/modules/pubs/typst/lib/series-style.typ b/modules/rpubs/typst/lib/series-style.typ similarity index 100% rename from modules/pubs/typst/lib/series-style.typ rename to modules/rpubs/typst/lib/series-style.typ diff --git a/modules/pubs/typst/templates/pocket-book.typ b/modules/rpubs/typst/templates/pocket-book.typ similarity index 100% rename from modules/pubs/typst/templates/pocket-book.typ rename to modules/rpubs/typst/templates/pocket-book.typ diff --git a/modules/canvas/mod.ts b/modules/rspace/mod.ts similarity index 100% rename from modules/canvas/mod.ts rename to modules/rspace/mod.ts diff --git a/modules/splat/components/folk-splat-viewer.ts b/modules/rsplat/components/folk-splat-viewer.ts similarity index 100% rename from modules/splat/components/folk-splat-viewer.ts rename to modules/rsplat/components/folk-splat-viewer.ts diff --git a/modules/splat/components/splat.css b/modules/rsplat/components/splat.css similarity index 100% rename from modules/splat/components/splat.css rename to modules/rsplat/components/splat.css diff --git a/modules/splat/db/schema.sql b/modules/rsplat/db/schema.sql similarity index 100% rename from modules/splat/db/schema.sql rename to modules/rsplat/db/schema.sql diff --git a/modules/splat/landing.ts b/modules/rsplat/landing.ts similarity index 100% rename from modules/splat/landing.ts rename to modules/rsplat/landing.ts diff --git a/modules/splat/mod.ts b/modules/rsplat/mod.ts similarity index 98% rename from modules/splat/mod.ts rename to modules/rsplat/mod.ts index fcb9190..1ce66b1 100644 --- a/modules/splat/mod.ts +++ b/modules/rsplat/mod.ts @@ -436,12 +436,12 @@ routes.get("/", async (c) => { modules: getModuleInfoList(), theme: "dark", head: ` - + ${IMPORTMAP} `, scripts: ` `, }); diff --git a/modules/swag/components/folk-swag-designer.ts b/modules/rswag/components/folk-swag-designer.ts similarity index 100% rename from modules/swag/components/folk-swag-designer.ts rename to modules/rswag/components/folk-swag-designer.ts diff --git a/modules/swag/components/swag.css b/modules/rswag/components/swag.css similarity index 100% rename from modules/swag/components/swag.css rename to modules/rswag/components/swag.css diff --git a/modules/swag/landing.ts b/modules/rswag/landing.ts similarity index 100% rename from modules/swag/landing.ts rename to modules/rswag/landing.ts diff --git a/modules/swag/mod.ts b/modules/rswag/mod.ts similarity index 98% rename from modules/swag/mod.ts rename to modules/rswag/mod.ts index d353c96..82e615a 100644 --- a/modules/swag/mod.ts +++ b/modules/rswag/mod.ts @@ -236,8 +236,8 @@ routes.get("/", (c) => { modules: getModuleInfoList(), theme: "dark", body: ``, - scripts: ``, - styles: ``, + scripts: ``, + styles: ``, })); }); diff --git a/modules/swag/process-image.ts b/modules/rswag/process-image.ts similarity index 100% rename from modules/swag/process-image.ts rename to modules/rswag/process-image.ts diff --git a/modules/swag/products.ts b/modules/rswag/products.ts similarity index 100% rename from modules/swag/products.ts rename to modules/rswag/products.ts diff --git a/modules/trips/components/folk-route-planner.ts b/modules/rtrips/components/folk-route-planner.ts similarity index 100% rename from modules/trips/components/folk-route-planner.ts rename to modules/rtrips/components/folk-route-planner.ts diff --git a/modules/trips/components/folk-trips-planner.ts b/modules/rtrips/components/folk-trips-planner.ts similarity index 100% rename from modules/trips/components/folk-trips-planner.ts rename to modules/rtrips/components/folk-trips-planner.ts diff --git a/modules/trips/components/route-planner.css b/modules/rtrips/components/route-planner.css similarity index 100% rename from modules/trips/components/route-planner.css rename to modules/rtrips/components/route-planner.css diff --git a/modules/rtrips/components/trips-demo.ts b/modules/rtrips/components/trips-demo.ts new file mode 100644 index 0000000..7b27467 --- /dev/null +++ b/modules/rtrips/components/trips-demo.ts @@ -0,0 +1,459 @@ +/** + * rTrips demo — client-side WebSocket controller. + * + * Connects to rSpace (no filter — needs all shape types) and + * populates the 6-card trip dashboard: maps, notes/packing, + * calendar, polls, funds, cart. + */ + +import { DemoSync, type DemoShape } from "../../../lib/demo-sync-vanilla"; + +// ── Helpers ── + +function shapesByType(shapes: Record, type: string): DemoShape[] { + return Object.values(shapes).filter((s) => s.type === type); +} + +function shapeByType(shapes: Record, type: string): DemoShape | undefined { + return Object.values(shapes).find((s) => s.type === type); +} + +function $(id: string): HTMLElement | null { + return document.getElementById(id); +} + +function escHtml(s: string): string { + return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +} + +// ── Constants ── + +const MEMBER_COLORS = ["#14b8a6", "#06b6d4", "#3b82f6", "#8b5cf6", "#f59e0b", "#f43f5e"]; +const POLL_COLORS = ["#f59e0b", "#3b82f6", "#10b981", "#f43f5e"]; +const CATEGORY_COLORS: Record = { + travel: "#14b8a6", + hike: "#10b981", + adventure: "#f59e0b", + rest: "#64748b", + culture: "#8b5cf6", + FLIGHT: "#14b8a6", + TRANSPORT: "#06b6d4", + ACCOMMODATION: "#14b8a6", + ACTIVITY: "#10b981", + MEAL: "#f59e0b", + FREE_TIME: "#64748b", + OTHER: "#64748b", +}; + +// ── DemoSync (no filter — needs all shape types) ── + +const sync = new DemoSync(); + +// ── Render functions ── + +function render(shapes: Record) { + const itinerary = shapeByType(shapes, "folk-itinerary"); + const destinations = shapesByType(shapes, "folk-destination"); + const packingList = shapeByType(shapes, "folk-packing-list"); + const pollShapes = shapesByType(shapes, "demo-poll"); + const expenseShapes = shapesByType(shapes, "demo-expense"); + const cartShapes = shapesByType(shapes, "demo-cart-item"); + const budgetShape = shapeByType(shapes, "folk-budget"); + + const hasShapes = Object.keys(shapes).length > 0; + + // Members from itinerary + const travelers = (itinerary?.travelers ?? []) as string[]; + const members = travelers.map((name, i) => ({ name, color: MEMBER_COLORS[i % MEMBER_COLORS.length] })); + + // Trip header + renderHeader(itinerary, destinations, budgetShape, members, hasShapes); + + // Show live badges + for (const card of ["maps", "notes", "cal", "polls", "funds", "cart"]) { + const el = $(`rd-${card}-live`); + if (el) el.style.display = hasShapes ? "inline-flex" : "none"; + } + + // Cards + renderMap(destinations); + renderNotes(packingList); + renderCalendar(itinerary); + renderPolls(pollShapes); + renderFunds(expenseShapes, members); + renderCart(cartShapes); +} + +// ── Header ── + +function renderHeader( + itinerary: DemoShape | undefined, + destinations: DemoShape[], + budgetShape: DemoShape | undefined, + members: { name: string; color: string }[], + hasShapes: boolean, +) { + const title = $("rd-trip-title"); + const route = $("rd-trip-route"); + const meta = $("rd-trip-meta"); + const avatars = $("rd-avatars"); + + if (title && itinerary?.tripTitle) { + title.textContent = itinerary.tripTitle as string; + } + + if (route && destinations.length > 0) { + route.textContent = destinations.map((d) => d.destName as string).join(" → "); + } + + if (meta && itinerary) { + const start = itinerary.startDate as string; + const end = itinerary.endDate as string; + const budgetTotal = (budgetShape?.budgetTotal as number) || 4000; + const dateRange = start && end + ? `${new Date(start).toLocaleDateString("en", { month: "short", day: "numeric" })}–${new Date(end).toLocaleDateString("en", { month: "short", day: "numeric" })}, 2026` + : "Jul 6–20, 2026"; + const countries = destinations.length > 0 + ? new Set(destinations.map((d) => d.country as string)).size + : 3; + meta.innerHTML = ` + 📅 ${dateRange} + 💶 ~€${budgetTotal.toLocaleString()} budget + 🏔️ ${countries} countr${countries !== 1 ? "ies" : "y"} + ${hasShapes ? ` Live data` : ""} + `; + } + + if (avatars && members.length > 0) { + avatars.innerHTML = members.map((m) => + `
      ${m.name[0]}
      ` + ).join("") + `${members.length} explorers`; + } +} + +// ── Map card ── + +function renderMap(destinations: DemoShape[]) { + if (destinations.length === 0) return; + + const pins = destinations.map((d, i) => ({ + name: d.destName as string, + cx: 160 + i * 245, + cy: 180 - i * 20, + color: ["#14b8a6", "#06b6d4", "#8b5cf6"][i] || "#94a3b8", + stroke: ["#0d9488", "#0891b2", "#7c3aed"][i] || "#64748b", + dates: d.arrivalDate && d.departureDate + ? `${new Date(d.arrivalDate as string).toLocaleDateString("en", { month: "short", day: "numeric" })}–${new Date(d.departureDate as string).getUTCDate()}` + : "", + })); + + const pinsEl = $("rd-map-pins"); + if (pinsEl) { + pinsEl.innerHTML = pins.map((p) => ` + + + ${escHtml(p.name)} + ${p.dates} + + `).join(""); + } + + if (pins.length >= 3) { + const routeEl = $("rd-route-path"); + if (routeEl) { + routeEl.setAttribute("d", + `M${pins[0].cx} ${pins[0].cy} C${pins[0].cx + 90} ${pins[0].cy - 20}, ${pins[1].cx - 80} ${pins[1].cy + 50}, ${pins[1].cx} ${pins[1].cy} C${pins[1].cx + 80} ${pins[1].cy - 50}, ${pins[2].cx - 90} ${pins[2].cy + 20}, ${pins[2].cx} ${pins[2].cy}` + ); + } + } +} + +// ── Notes card (packing checklist) ── + +function renderNotes(packingList: DemoShape | undefined) { + const container = $("rd-packing-list"); + if (!container || !packingList) return; + + const items = (packingList.items as Array<{ name: string; packed: boolean; category: string }>) || []; + if (items.length === 0) return; + + container.innerHTML = `
        + ${items.map((item, idx) => ` +
      • +
        + ${item.packed ? `` : ""} +
        + ${escHtml(item.name)} +
      • + `).join("")} +
      `; +} + +// ── Calendar card ── + +function renderCalendar(itinerary: DemoShape | undefined) { + const grid = $("rd-cal-grid"); + if (!grid) return; + + const items = (itinerary?.items ?? []) as Array<{ date: string; activity: string; category: string }>; + + // Build calendar events map + const calEvents: Record = {}; + for (const item of items) { + if (!item.date) continue; + const match = item.date.match(/(\d+)/); + if (!match) continue; + const day = parseInt(match[1], 10); + if (!calEvents[day]) calEvents[day] = []; + calEvents[day].push({ + label: item.activity, + color: CATEGORY_COLORS[item.category] || "#64748b", + }); + } + + const eventDays = Object.keys(calEvents).map(Number); + const tripStart = eventDays.length > 0 ? Math.min(...eventDays) : 6; + const tripEnd = eventDays.length > 0 ? Math.max(...eventDays) : 20; + + const daysLabel = $("rd-cal-days"); + if (daysLabel) daysLabel.textContent = `${tripEnd - tripStart + 1} days`; + + const dayNames = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; + const offset = 2; // July 1 2026 = Wednesday (0-indexed Mon grid) + const daysInJuly = 31; + + let html = `
      `; + + // Day name headers + for (const d of dayNames) { + html += `
      ${d}
      `; + } + + // Empty cells for offset + for (let i = 0; i < offset; i++) { + html += `
      `; + } + + // Day cells + for (let day = 1; day <= daysInJuly; day++) { + const isTrip = day >= tripStart && day <= tripEnd; + const events = calEvents[day]; + html += `
      + ${day}`; + if (events) { + for (const ev of events) { + html += `${escHtml(ev.label)}`; + } + } + html += `
      `; + } + + html += `
      `; + grid.innerHTML = html; +} + +// ── Polls card ── + +function renderPolls(pollShapes: DemoShape[]) { + const body = $("rd-polls-body"); + if (!body) return; + + if (pollShapes.length === 0) return; + + let html = ""; + for (const shape of pollShapes) { + const question = shape.question as string; + const options = (shape.options ?? []) as Array<{ label: string; votes: number }>; + const totalVotes = options.reduce((s, o) => s + o.votes, 0); + + html += `
      +

      ${escHtml(question)}

      `; + + for (let i = 0; i < options.length; i++) { + const opt = options[i]; + const pct = totalVotes > 0 ? Math.round((opt.votes / totalVotes) * 100) : 0; + const color = POLL_COLORS[i % POLL_COLORS.length]; + html += `
      +
      + ${escHtml(opt.label)} + ${opt.votes} vote${opt.votes !== 1 ? "s" : ""} (${pct}%) +
      +
      +
      +
      +
      `; + } + + html += `

      ${totalVotes} votes cast

      `; + } + + body.innerHTML = html; +} + +// ── Funds card ── + +function renderFunds(expenseShapes: DemoShape[], members: { name: string; color: string }[]) { + const totalEl = $("rd-funds-total"); + const skeleton = $("rd-funds-skeleton"); + const expensesEl = $("rd-funds-expenses"); + const balancesEl = $("rd-funds-balances"); + + if (!totalEl || !expensesEl || !balancesEl) return; + if (expenseShapes.length === 0) return; + + const expenses = expenseShapes.map((s) => ({ + desc: s.description as string, + who: s.paidBy as string, + amount: s.amount as number, + split: members.length || 4, + })); + + const totalSpent = expenses.reduce((s, e) => s + e.amount, 0); + totalEl.textContent = `€${totalSpent.toLocaleString()}`; + + if (skeleton) skeleton.style.display = "none"; + expensesEl.style.display = "block"; + balancesEl.style.display = "block"; + + // Recent expenses + let expHtml = `

      Recent

      `; + for (const e of expenses.slice(0, 4)) { + expHtml += `
      +
      +

      ${escHtml(e.desc)}

      +

      ${escHtml(e.who)} · split ${e.split} ways

      +
      + €${e.amount} +
      `; + } + expensesEl.innerHTML = expHtml; + + // Balances + const balances: Record = {}; + for (const m of members) balances[m.name] = 0; + for (const e of expenses) { + const share = e.amount / e.split; + balances[e.who] = (balances[e.who] || 0) + e.amount; + for (const m of members.slice(0, e.split)) { + balances[m.name] = (balances[m.name] || 0) - share; + } + } + + let balHtml = `

      Balances

      +
      `; + for (const m of members) { + const bal = balances[m.name] || 0; + balHtml += `
      + ${escHtml(m.name)} + ${bal >= 0 ? "+" : ""}€${Math.round(bal)} +
      `; + } + balHtml += `
      `; + balancesEl.innerHTML = balHtml; +} + +// ── Cart card ── + +function renderCart(cartShapes: DemoShape[]) { + const skeleton = $("rd-cart-skeleton"); + const content = $("rd-cart-content"); + if (!content) return; + + if (cartShapes.length === 0) return; + + if (skeleton) skeleton.style.display = "none"; + content.style.display = "block"; + + const items = cartShapes.map((s) => ({ + name: s.name as string, + target: s.price as number, + funded: s.funded as number, + status: ((s.funded as number) >= (s.price as number)) ? "Purchased" : "Funding", + })); + + const totalFunded = items.reduce((s, i) => s + i.funded, 0); + const totalTarget = items.reduce((s, i) => s + i.target, 0); + const purchased = items.filter((i) => i.status === "Purchased").length; + const overallPct = totalTarget > 0 ? Math.round((totalFunded / totalTarget) * 100) : 0; + + let html = ` +
      + €${totalFunded} / €${totalTarget} funded + ${purchased}/${items.length} purchased +
      +
      +
      +
      +
      `; + + for (const item of items) { + const pct = item.target > 0 ? Math.round((item.funded / item.target) * 100) : 0; + html += `
      +
      + ${escHtml(item.name)} + ${item.status === "Purchased" + ? `✓ Bought` + : `€${item.funded}/€${item.target}` + } +
      +
      +
      +
      +
      `; + } + + html += `
      `; + content.innerHTML = html; +} + +// ── Event listeners ── + +sync.addEventListener("snapshot", ((e: CustomEvent) => { + render(e.detail.shapes); +}) as EventListener); + +sync.addEventListener("connected", () => { + const dot = $("rd-hero-dot"); + const label = $("rd-hero-label"); + if (dot) dot.style.background = "#10b981"; + if (label) label.textContent = "Live — Connected to rSpace"; + const resetBtn = $("rd-reset-btn") as HTMLButtonElement | null; + if (resetBtn) resetBtn.disabled = false; +}); + +sync.addEventListener("disconnected", () => { + const dot = $("rd-hero-dot"); + const label = $("rd-hero-label"); + if (dot) dot.style.background = "#64748b"; + if (label) label.textContent = "Reconnecting..."; + const resetBtn = $("rd-reset-btn") as HTMLButtonElement | null; + if (resetBtn) resetBtn.disabled = true; +}); + +// ── Event delegation ── + +document.addEventListener("click", (e) => { + const target = e.target as HTMLElement; + + // Packing checkbox toggle + const packItem = target.closest("[data-pack-idx]"); + if (packItem) { + const idx = parseInt(packItem.dataset.packIdx!, 10); + const packingList = shapeByType(sync.shapes, "folk-packing-list"); + if (packingList) { + const items = [...(packingList.items as Array<{ name: string; packed: boolean; category: string }>)]; + items[idx] = { ...items[idx], packed: !items[idx].packed }; + sync.updateShape(packingList.id, { items }); + } + return; + } + + // Reset button + if (target.closest("#rd-reset-btn")) { + sync.resetDemo().catch((err) => console.error("[Trips] Reset failed:", err)); + } +}); + +// ── Start ── + +sync.connect(); diff --git a/modules/trips/components/trips.css b/modules/rtrips/components/trips.css similarity index 100% rename from modules/trips/components/trips.css rename to modules/rtrips/components/trips.css diff --git a/modules/trips/db/schema.sql b/modules/rtrips/db/schema.sql similarity index 100% rename from modules/trips/db/schema.sql rename to modules/rtrips/db/schema.sql diff --git a/modules/rtrips/demo.ts b/modules/rtrips/demo.ts new file mode 100644 index 0000000..47c87f9 --- /dev/null +++ b/modules/rtrips/demo.ts @@ -0,0 +1,414 @@ +/** + * rTrips demo page — server-rendered HTML body. + * + * "Alpine Explorer 2026" dashboard with 6 cards powered by the rStack: + * Maps (SVG), Notes (packing checklist), Calendar (grid), + * Polls (bars), Funds (expenses), Cart (gear progress). + * + * Client-side trips-demo.ts populates all cards via WebSocket snapshots. + */ + +export function renderDemo(): string { + return ` +
      + + +
      +
      + + Interactive Demo +
      +

      Alpine Explorer 2026

      +

      Chamonix → Zermatt → Dolomites

      +
      + 📅 Jul 6–20, 2026 + 💶 ~€4,000 budget + 🏔️ 3 countries +
      +
      +
      +
      +
      + + +
      +
      +

      + Every trip is powered by the rStack — + a suite of collaborative tools that handle routes, notes, schedules, voting, expenses, + and shared purchases. Each card below shows live data with a link to the full tool. +

      + +
      +
      + + +
      +
      + + +
      +
      +
      + 🗺️ + Route Map + +
      + Open in rMaps ↗ +
      +
      +
      + + + + + + + + + + + + ChamonixJul 6–10 + ZermattJul 10–14 + DolomitesJul 14–20 + + + 🥾 + 🧗 + 🚵 + 🪂 + 🛶 + +
      + France + Switzerland + Italy +
      +
      +
      +
      + + +
      +
      +
      + 📝 + Trip Notes + +
      + Open in rNotes ↗ +
      +
      +
      +

      Packing Checklist

      +
      +
      +
      +
      +
      +
      +
      +
      +

      Trip Rules

      +
        +
      1. Majority vote on daily activities
      2. +
      3. Shared expenses split equally
      4. +
      5. Quiet hours after 10pm in huts
      6. +
      7. Everyone carries their own pack
      8. +
      +
      +
      +
      + + +
      +
      +
      + 📅 + Group Calendar + +
      + Open in rCal ↗ +
      +
      +
      +

      July 2026

      + 15 days +
      +
      +
      + Travel + Hiking + Adventure + Culture + Rest + Transit +
      +
      +
      + + +
      +
      +
      + 🗳️ + Group Polls + +
      + Open in rVote ↗ +
      +
      +
      +
      +
      +
      +
      +
      +
      +
      + + +
      +
      +
      + 💰 + Group Expenses + +
      + Open in rFunds ↗ +
      +
      +
      +

      ...

      +

      Total group spending

      +
      +
      +
      +
      +
      + + +
      +
      + + +
      +
      +
      + 🛒 + Shared Gear + +
      + Open in rCart ↗ +
      +
      +
      +
      +
      +
      +
      +
      +
      +
      +
      + +
      +
      + +
      +
      + + +
      +
      +

      Plan Your Own Group Adventure

      +

      + The rStack gives your group everything you need — routes, schedules, polls, + shared expenses, and gear lists — all connected in one trip canvas. +

      + + Start Planning + +
      +
      + +
      + +`; +} diff --git a/modules/trips/landing.ts b/modules/rtrips/landing.ts similarity index 100% rename from modules/trips/landing.ts rename to modules/rtrips/landing.ts diff --git a/modules/trips/lib/conic-math.ts b/modules/rtrips/lib/conic-math.ts similarity index 100% rename from modules/trips/lib/conic-math.ts rename to modules/rtrips/lib/conic-math.ts diff --git a/modules/trips/lib/projection.ts b/modules/rtrips/lib/projection.ts similarity index 100% rename from modules/trips/lib/projection.ts rename to modules/rtrips/lib/projection.ts diff --git a/modules/trips/lib/types.ts b/modules/rtrips/lib/types.ts similarity index 100% rename from modules/trips/lib/types.ts rename to modules/rtrips/lib/types.ts diff --git a/modules/trips/mod.ts b/modules/rtrips/mod.ts similarity index 93% rename from modules/trips/mod.ts rename to modules/rtrips/mod.ts index c061ecc..15eae34 100644 --- a/modules/trips/mod.ts +++ b/modules/rtrips/mod.ts @@ -9,11 +9,12 @@ import { Hono } from "hono"; import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { sql } from "../../shared/db/pool"; -import { renderShell } from "../../server/shell"; +import { renderShell, renderDemoShell } from "../../server/shell"; import { getModuleInfoList } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module"; import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server"; import { renderLanding } from "./landing"; +import { renderDemo } from "./demo"; const OSRM_URL = process.env.OSRM_URL || "http://osrm-backend:5000"; @@ -245,15 +246,27 @@ routes.get("/routes", (c) => { spaceSlug: space, modules: getModuleInfoList(), theme: "dark", - styles: ``, + styles: ``, body: ``, - scripts: ``, + scripts: ``, })); }); // ── Page route ── routes.get("/", (c) => { const space = c.req.param("space") || "demo"; + if (space === "demo") { + return c.html(renderDemoShell({ + title: "rTrips Demo — rSpace", + moduleId: "rtrips", + spaceSlug: space, + modules: getModuleInfoList(), + theme: "dark", + body: renderDemo(), + scripts: ``, + styles: ``, + })); + } return c.html(renderShell({ title: `${space} — Trips | rSpace`, moduleId: "rtrips", @@ -261,8 +274,8 @@ routes.get("/", (c) => { modules: getModuleInfoList(), theme: "dark", body: ``, - scripts: ``, - styles: ``, + scripts: ``, + styles: ``, })); }); @@ -273,6 +286,7 @@ export const tripsModule: RSpaceModule = { description: "Collaborative trip planner with itinerary, bookings, and expense splitting", routes, landingPage: renderLanding, + demoPage: renderDemo, standaloneDomain: "rtrips.online", feeds: [ { diff --git a/modules/tube/components/folk-video-player.ts b/modules/rtube/components/folk-video-player.ts similarity index 100% rename from modules/tube/components/folk-video-player.ts rename to modules/rtube/components/folk-video-player.ts diff --git a/modules/rtube/components/tube-demo.ts b/modules/rtube/components/tube-demo.ts new file mode 100644 index 0000000..40d947b --- /dev/null +++ b/modules/rtube/components/tube-demo.ts @@ -0,0 +1,156 @@ +/** + * rTube demo — client-side video library controller. + * + * Handles video selection from the sidebar, search filtering, + * playback (or warning for unplayable formats), and copy-link. + * No WebSocket — all local state with seed data baked into the HTML. + */ + +/* ─── Helpers ──────────────────────────────────────────────── */ + +function isPlayable(filename: string): boolean { + const ext = filename.split(".").pop()?.toLowerCase() || ""; + return ["mp4", "webm", "mov", "ogg", "m4v"].includes(ext); +} + +function getExt(filename: string): string { + return filename.split(".").pop()?.toLowerCase() || ""; +} + +/* ─── DOM refs ─────────────────────────────────────────────── */ + +const sidebar = document.getElementById("rd-video-sidebar") as HTMLElement; +const videoList = document.getElementById("rd-video-list") as HTMLElement; +const searchInput = document.getElementById("rd-video-search") as HTMLInputElement; +const emptyMsg = document.getElementById("rd-video-empty") as HTMLElement; +const playerContainer = document.getElementById("rd-player-container") as HTMLElement; +const placeholder = document.getElementById("rd-player-placeholder") as HTMLElement; +const infoBar = document.getElementById("rd-video-info") as HTMLElement; +const videoNameEl = document.getElementById("rd-video-name") as HTMLElement; +const downloadBtn = document.getElementById("rd-download-btn") as HTMLAnchorElement; +const copyLinkBtn = document.getElementById("rd-copy-link-btn") as HTMLButtonElement; + +/* ─── State ─────────────────────────────────────────────────── */ + +let currentVideo: string | null = null; + +/* ─── Video selection ──────────────────────────────────────── */ + +function selectVideo(filename: string): void { + currentVideo = filename; + + // Update active state in sidebar + const items = videoList.querySelectorAll(".rd-tube-item"); + for (const item of items) { + if (item.dataset.video === filename) { + item.classList.add("rd-tube-item--active"); + } else { + item.classList.remove("rd-tube-item--active"); + } + } + + const ext = getExt(filename); + const playable = isPlayable(filename); + + // Clear player container (keep only the placeholder, hidden) + playerContainer.innerHTML = ""; + + if (playable) { + // Create video element + const video = document.createElement("video"); + video.controls = true; + video.autoplay = true; + video.preload = "auto"; + video.style.width = "100%"; + video.style.height = "100%"; + + const source = document.createElement("source"); + // Demo: point to a placeholder URL (no real video files) + source.src = `#demo-video/${encodeURIComponent(filename)}`; + source.type = ext === "webm" ? "video/webm" : "video/mp4"; + video.appendChild(source); + + // Suppress error UI for missing demo files + video.addEventListener("error", () => { + playerContainer.innerHTML = ` +
      +
      \u{1F3AC}
      +

      ${filename}

      +

      Demo mode — no actual video files loaded.
      In a real space, this would stream from Cloudflare R2.

      +
      `; + }); + + playerContainer.appendChild(video); + } else { + // Show unplayable warning + playerContainer.innerHTML = ` +
      +
      \u26A0\uFE0F
      +

      ${ext.toUpperCase()} files cannot play in browsers

      +

      Download to play locally, or re-record in MP4 format

      +
      `; + } + + // Show info bar + infoBar.style.display = ""; + videoNameEl.textContent = filename; + downloadBtn.href = "#"; +} + +/* ─── Event delegation: sidebar clicks ─────────────────────── */ + +sidebar.addEventListener("click", (e) => { + const item = (e.target as HTMLElement).closest("[data-video]"); + if (!item) return; + const filename = item.dataset.video; + if (filename) selectVideo(filename); +}); + +// Keyboard support (Enter/Space on focused items) +sidebar.addEventListener("keydown", (e) => { + if (e.key !== "Enter" && e.key !== " ") return; + const item = (e.target as HTMLElement).closest("[data-video]"); + if (!item) return; + e.preventDefault(); + const filename = item.dataset.video; + if (filename) selectVideo(filename); +}); + +/* ─── Search filtering ─────────────────────────────────────── */ + +searchInput.addEventListener("input", () => { + const query = searchInput.value.toLowerCase().trim(); + const items = videoList.querySelectorAll(".rd-tube-item"); + let visibleCount = 0; + + for (const item of items) { + const name = (item.dataset.video || "").toLowerCase(); + const matches = !query || name.includes(query); + item.style.display = matches ? "" : "none"; + if (matches) visibleCount++; + } + + emptyMsg.style.display = visibleCount === 0 ? "" : "none"; +}); + +/* ─── Copy link ────────────────────────────────────────────── */ + +copyLinkBtn.addEventListener("click", () => { + if (!currentVideo) return; + const url = `${window.location.origin}/api/v/${encodeURIComponent(currentVideo)}`; + + navigator.clipboard.writeText(url).then( + () => { + const original = copyLinkBtn.textContent; + copyLinkBtn.textContent = "Copied!"; + setTimeout(() => { + copyLinkBtn.innerHTML = ` + + Copy Link`; + }, 1500); + }, + (err) => { + console.error("[Tube] Copy failed:", err); + }, + ); +}); diff --git a/modules/tube/components/tube.css b/modules/rtube/components/tube.css similarity index 100% rename from modules/tube/components/tube.css rename to modules/rtube/components/tube.css diff --git a/modules/rtube/demo.ts b/modules/rtube/demo.ts new file mode 100644 index 0000000..81da86a --- /dev/null +++ b/modules/rtube/demo.ts @@ -0,0 +1,301 @@ +/** + * rTube demo page — server-rendered HTML body. + * + * Video library with sidebar, search, player area, download/copy-link buttons. + * Client-side tube-demo.ts handles selection, filtering, and playback. + */ + +/* ─── Seed Data ─────────────────────────────────────────────── */ + +interface DemoVideo { + name: string; + size: number; +} + +const DEMO_VIDEOS: DemoVideo[] = [ + { name: "lac-blanc-test-footage.mp4", size: 245_000_000 }, + { name: "chamonix-arrival-timelapse.mp4", size: 128_000_000 }, + { name: "matterhorn-sunset-4k.mp4", size: 512_000_000 }, + { name: "paragliding-zermatt.webm", size: 89_000_000 }, + { name: "glacier-paradise-walk.mp4", size: 340_000_000 }, + { name: "tre-cime-circuit-gopro.mp4", size: 420_000_000 }, + { name: "dolomites-drone-footage.mkv", size: 780_000_000 }, + { name: "group-dinner-recap.mp4", size: 67_000_000 }, +]; + +/* ─── Helpers ──────────────────────────────────────────────── */ + +function formatSize(bytes: number): string { + if (!bytes) return ""; + const units = ["B", "KB", "MB", "GB"]; + let i = 0; + let b = bytes; + while (b >= 1024 && i < units.length - 1) { + b /= 1024; + i++; + } + return `${b.toFixed(1)} ${units[i]}`; +} + +function getIcon(filename: string): string { + const ext = filename.split(".").pop()?.toLowerCase() || ""; + if (["mp4", "webm", "mov"].includes(ext)) return "\u{1F3AC}"; + if (["mkv", "avi"].includes(ext)) return "\u26A0\uFE0F"; + return "\u{1F4C4}"; +} + +function isPlayable(filename: string): boolean { + const ext = filename.split(".").pop()?.toLowerCase() || ""; + return ["mp4", "webm", "mov", "ogg", "m4v"].includes(ext); +} + +/* ─── Render ─────────────────────────────────────────────── */ + +export function renderDemo(): string { + const videoListHTML = DEMO_VIDEOS.map( + (v) => ` +
    3. + ${getIcon(v.name)} + ${v.name} + ${formatSize(v.size)} +
    4. `, + ).join("\n"); + + return ` +
      + + +
      +

      Video Library

      +

      Browse, preview, and download videos from the Alpine Explorer expedition

      +
      + \u{1F3AC} ${DEMO_VIDEOS.length} videos + | + \u{1F4BE} ${formatSize(DEMO_VIDEOS.reduce((sum, v) => sum + v.size, 0))} total + | + \u{1F3D4} Alpine Explorer 2026 +
      +
      + + +
      +
      + + + + + +
      +
      +

      Select a video to play

      +
      + + + +
      + +
      +
      + + +
      +
      +

      Host Your Video Library

      +

      + rTube gives your community a private video hosting solution with streaming, + uploads, and live broadcasting — all powered by Cloudflare R2. +

      + + Create Your Space + +
      +
      + +
      + +`; +} diff --git a/modules/tube/landing.ts b/modules/rtube/landing.ts similarity index 100% rename from modules/tube/landing.ts rename to modules/rtube/landing.ts diff --git a/modules/tube/mod.ts b/modules/rtube/mod.ts similarity index 91% rename from modules/tube/mod.ts rename to modules/rtube/mod.ts index b1ea30e..95ce15d 100644 --- a/modules/tube/mod.ts +++ b/modules/rtube/mod.ts @@ -6,11 +6,12 @@ */ import { Hono } from "hono"; -import { renderShell } from "../../server/shell"; +import { renderShell, renderDemoShell } from "../../server/shell"; import { getModuleInfoList } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module"; import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server"; import { renderLanding } from "./landing"; +import { renderDemo } from "./demo"; import { S3Client, ListObjectsV2Command, GetObjectCommand, HeadObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3"; const routes = new Hono(); @@ -192,6 +193,18 @@ routes.get("/api/health", (c) => c.json({ ok: true })); // ── Page route ── routes.get("/", (c) => { const space = c.req.param("space") || "demo"; + if (space === "demo") { + return c.html(renderDemoShell({ + title: "rTube Demo — rSpace", + moduleId: "rtube", + spaceSlug: space, + modules: getModuleInfoList(), + theme: "dark", + body: renderDemo(), + scripts: ``, + styles: ``, + })); + } return c.html(renderShell({ title: `${space} — Tube | rSpace`, moduleId: "rtube", @@ -199,8 +212,8 @@ routes.get("/", (c) => { modules: getModuleInfoList(), theme: "dark", body: ``, - scripts: ``, - styles: ``, + scripts: ``, + styles: ``, })); }); @@ -211,6 +224,7 @@ export const tubeModule: RSpaceModule = { description: "Community video hosting & live streaming", routes, landingPage: renderLanding, + demoPage: renderDemo, standaloneDomain: "rtube.online", feeds: [ { diff --git a/modules/vote/components/folk-vote-dashboard.ts b/modules/rvote/components/folk-vote-dashboard.ts similarity index 100% rename from modules/vote/components/folk-vote-dashboard.ts rename to modules/rvote/components/folk-vote-dashboard.ts diff --git a/modules/rvote/components/vote-demo.ts b/modules/rvote/components/vote-demo.ts new file mode 100644 index 0000000..fdfb804 --- /dev/null +++ b/modules/rvote/components/vote-demo.ts @@ -0,0 +1,179 @@ +/** + * rVote demo — client-side WebSocket controller. + * + * Connects via DemoSync, renders poll cards into #rd-polls-container, + * and handles vote +/- button clicks via event delegation. + */ + +import { DemoSync } from "@lib/demo-sync-vanilla"; +import type { DemoShape } from "@lib/demo-sync-vanilla"; + +// ── Types ── + +interface PollOption { + label: string; + votes: number; +} + +interface DemoPoll extends DemoShape { + question: string; + options: PollOption[]; + totalVoters: number; + status: "active" | "closed"; + endsAt: string; +} + +function isPoll(shape: DemoShape): shape is DemoPoll { + return shape.type === "demo-poll" && Array.isArray((shape as DemoPoll).options); +} + +// ── Helpers ── + +function formatDeadline(dateStr: string): string { + const diff = new Date(dateStr).getTime() - Date.now(); + if (diff <= 0) return "Voting closed"; + const days = Math.ceil(diff / (1000 * 60 * 60 * 24)); + return `${days} day${days !== 1 ? "s" : ""} left`; +} + +function esc(s: string): string { + return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +} + +// ── DOM refs ── + +const connBadge = document.getElementById("rd-conn-badge") as HTMLElement; +const resetBtn = document.getElementById("rd-reset-btn") as HTMLButtonElement; +const loadingEl = document.getElementById("rd-loading") as HTMLElement; +const emptyEl = document.getElementById("rd-empty") as HTMLElement; +const container = document.getElementById("rd-polls-container") as HTMLElement; + +// ── DemoSync ── + +const sync = new DemoSync({ filter: ["demo-poll"] }); + +// Show loading spinner immediately +loadingEl.style.display = ""; + +// ── Connection events ── + +sync.addEventListener("connected", () => { + connBadge.className = "rd-status rd-status--connected"; + connBadge.textContent = "Connected"; + resetBtn.disabled = false; +}); + +sync.addEventListener("disconnected", () => { + connBadge.className = "rd-status rd-status--disconnected"; + connBadge.textContent = "Disconnected"; + resetBtn.disabled = true; +}); + +// ── Snapshot → render ── + +sync.addEventListener("snapshot", ((e: CustomEvent) => { + const shapes: Record = e.detail.shapes; + const polls = Object.values(shapes).filter(isPoll); + + // Hide loading + loadingEl.style.display = "none"; + + // Show/hide empty state + if (polls.length === 0) { + emptyEl.style.display = ""; + container.innerHTML = ""; + return; + } + emptyEl.style.display = "none"; + + // Render poll cards + container.innerHTML = polls.map((poll) => renderPollCard(poll)).join(""); +}) as EventListener); + +// ── Render a single poll card ── + +function renderPollCard(poll: DemoPoll): string { + const total = poll.options.reduce((sum, opt) => sum + opt.votes, 0); + const maxVotes = Math.max(...poll.options.map((o) => o.votes), 1); + const statusBadge = + poll.status === "active" + ? `Active` + : `Closed`; + + const optionsHTML = poll.options + .map((opt, idx) => { + const pct = total > 0 ? (opt.votes / total) * 100 : 0; + const barPct = maxVotes > 0 ? (opt.votes / maxVotes) * 100 : 0; + + return `
      +
      + + ${opt.votes} + +
      +
      +
      + ${esc(opt.label)} + ${Math.round(pct)}% +
      +
      +
      +
      +
      +
      `; + }) + .join(""); + + return `
      +
      +
      🗳 ${esc(poll.question)}
      + ${statusBadge} +
      +
      + 👥 ${poll.totalVoters} voter${poll.totalVoters !== 1 ? "s" : ""} + ⏱ ${formatDeadline(poll.endsAt)} + ${total} total vote${total !== 1 ? "s" : ""} +
      +
      + ${optionsHTML} +
      +
      `; +} + +// ── Event delegation for vote buttons ── + +container.addEventListener("click", (e) => { + const btn = (e.target as HTMLElement).closest("button[data-vote]"); + if (!btn || btn.disabled) return; + + const pollId = btn.dataset.poll!; + const optIdx = parseInt(btn.dataset.opt!, 10); + const delta = parseInt(btn.dataset.vote!, 10); + + const shape = sync.shapes[pollId]; + if (!shape || !isPoll(shape)) return; + + const updatedOptions = shape.options.map((opt, i) => { + if (i !== optIdx) return opt; + return { ...opt, votes: Math.max(0, opt.votes + delta) }; + }); + + sync.updateShape(pollId, { options: updatedOptions }); +}); + +// ── Reset button ── + +resetBtn.addEventListener("click", async () => { + resetBtn.disabled = true; + try { + await sync.resetDemo(); + } catch (err) { + console.error("Reset failed:", err); + } finally { + if (sync.connected) resetBtn.disabled = false; + } +}); + +// ── Connect ── + +sync.connect(); diff --git a/modules/vote/components/vote.css b/modules/rvote/components/vote.css similarity index 100% rename from modules/vote/components/vote.css rename to modules/rvote/components/vote.css diff --git a/modules/vote/db/schema.sql b/modules/rvote/db/schema.sql similarity index 100% rename from modules/vote/db/schema.sql rename to modules/rvote/db/schema.sql diff --git a/modules/rvote/demo.ts b/modules/rvote/demo.ts new file mode 100644 index 0000000..8561188 --- /dev/null +++ b/modules/rvote/demo.ts @@ -0,0 +1,68 @@ +/** + * rVote demo page — server-rendered HTML body. + * + * Returns the static HTML skeleton for the interactive poll demo. + * The client-side vote-demo.ts populates #rd-polls-container via WebSocket. + */ + +export function renderDemo(): string { + return ` +
      + + +
      +

      rVote Demo

      +

      Interactive polls synced in real-time across all r* demos

      +
      + Vote on options with +/- buttons + Changes sync instantly via WebSocket +
      +
      + + +
      +
      +
      + Disconnected + Live — synced across all r* demos +
      + +
      + + + + + + + + + +
      +
      + + +
      +
      +

      Build with rVote

      +

      Run conviction-weighted polls and governance decisions in your own community space.

      + + Create Your Space + +
      +
      + +
      `; +} diff --git a/modules/vote/landing.ts b/modules/rvote/landing.ts similarity index 100% rename from modules/vote/landing.ts rename to modules/rvote/landing.ts diff --git a/modules/vote/mod.ts b/modules/rvote/mod.ts similarity index 94% rename from modules/vote/mod.ts rename to modules/rvote/mod.ts index bd5458a..ab036a8 100644 --- a/modules/vote/mod.ts +++ b/modules/rvote/mod.ts @@ -9,8 +9,9 @@ import { Hono } from "hono"; import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { sql } from "../../shared/db/pool"; -import { renderShell } from "../../server/shell"; +import { renderShell, renderDemoShell } from "../../server/shell"; import { getModuleInfoList } from "../../shared/module"; +import { renderDemo } from "./demo"; import type { RSpaceModule } from "../../shared/module"; import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server"; import { renderLanding } from "./landing"; @@ -328,6 +329,19 @@ routes.post("/api/proposals/:id/final-vote", async (c) => { // ── Page route ── routes.get("/", (c) => { const space = c.req.param("space") || "demo"; + if (space === "demo") { + return c.html(renderDemoShell({ + title: "rVote Demo — rSpace", + moduleId: "rvote", + spaceSlug: space, + modules: getModuleInfoList(), + theme: "dark", + body: renderDemo(), + demoScripts: ` + `, + styles: ``, + })); + } return c.html(renderShell({ title: `${space} — Vote | rSpace`, moduleId: "rvote", @@ -335,8 +349,8 @@ routes.get("/", (c) => { modules: getModuleInfoList(), theme: "dark", body: ``, - scripts: ``, - styles: ``, + scripts: ``, + styles: ``, })); }); @@ -348,6 +362,7 @@ export const voteModule: RSpaceModule = { routes, standaloneDomain: "rvote.online", landingPage: renderLanding, + demoPage: renderDemo, feeds: [ { id: "proposals", diff --git a/modules/wallet/components/folk-wallet-viewer.ts b/modules/rwallet/components/folk-wallet-viewer.ts similarity index 100% rename from modules/wallet/components/folk-wallet-viewer.ts rename to modules/rwallet/components/folk-wallet-viewer.ts diff --git a/modules/wallet/components/wallet.css b/modules/rwallet/components/wallet.css similarity index 100% rename from modules/wallet/components/wallet.css rename to modules/rwallet/components/wallet.css diff --git a/modules/wallet/landing.ts b/modules/rwallet/landing.ts similarity index 100% rename from modules/wallet/landing.ts rename to modules/rwallet/landing.ts diff --git a/modules/wallet/mod.ts b/modules/rwallet/mod.ts similarity index 96% rename from modules/wallet/mod.ts rename to modules/rwallet/mod.ts index 753eff0..8e36db8 100644 --- a/modules/wallet/mod.ts +++ b/modules/rwallet/mod.ts @@ -102,8 +102,8 @@ routes.get("/", (c) => { modules: getModuleInfoList(), theme: "dark", body: ``, - scripts: ``, - styles: ``, + scripts: ``, + styles: ``, })); }); diff --git a/modules/work/components/folk-work-board.ts b/modules/rwork/components/folk-work-board.ts similarity index 100% rename from modules/work/components/folk-work-board.ts rename to modules/rwork/components/folk-work-board.ts diff --git a/modules/work/components/work.css b/modules/rwork/components/work.css similarity index 100% rename from modules/work/components/work.css rename to modules/rwork/components/work.css diff --git a/modules/work/db/schema.sql b/modules/rwork/db/schema.sql similarity index 100% rename from modules/work/db/schema.sql rename to modules/rwork/db/schema.sql diff --git a/modules/work/landing.ts b/modules/rwork/landing.ts similarity index 100% rename from modules/work/landing.ts rename to modules/rwork/landing.ts diff --git a/modules/work/mod.ts b/modules/rwork/mod.ts similarity index 98% rename from modules/work/mod.ts rename to modules/rwork/mod.ts index c8ecefe..d92669d 100644 --- a/modules/work/mod.ts +++ b/modules/rwork/mod.ts @@ -225,8 +225,8 @@ routes.get("/", (c) => { modules: getModuleInfoList(), theme: "dark", body: ``, - scripts: ``, - styles: ``, + scripts: ``, + styles: ``, })); }); diff --git a/server/index.ts b/server/index.ts index 60bee06..29ebf37 100644 --- a/server/index.ts +++ b/server/index.ts @@ -40,28 +40,28 @@ import type { EncryptIDClaims, SpaceAuthConfig } from "@encryptid/sdk/server"; // ── Module system ── import { registerModule, getAllModules, getModuleInfoList } from "../shared/module"; -import { canvasModule } from "../modules/canvas/mod"; -import { booksModule } from "../modules/books/mod"; -import { pubsModule } from "../modules/pubs/mod"; -import { cartModule } from "../modules/cart/mod"; -import { swagModule } from "../modules/swag/mod"; -import { choicesModule } from "../modules/choices/mod"; -import { fundsModule } from "../modules/funds/mod"; -import { filesModule } from "../modules/files/mod"; -import { forumModule } from "../modules/forum/mod"; -import { walletModule } from "../modules/wallet/mod"; -import { voteModule } from "../modules/vote/mod"; -import { notesModule } from "../modules/notes/mod"; -import { mapsModule } from "../modules/maps/mod"; -import { workModule } from "../modules/work/mod"; -import { tripsModule } from "../modules/trips/mod"; -import { calModule } from "../modules/cal/mod"; -import { networkModule } from "../modules/network/mod"; -import { tubeModule } from "../modules/tube/mod"; -import { inboxModule } from "../modules/inbox/mod"; -import { dataModule } from "../modules/data/mod"; -import { splatModule } from "../modules/splat/mod"; -import { photosModule } from "../modules/photos/mod"; +import { canvasModule } from "../modules/rspace/mod"; +import { booksModule } from "../modules/rbooks/mod"; +import { pubsModule } from "../modules/rpubs/mod"; +import { cartModule } from "../modules/rcart/mod"; +import { swagModule } from "../modules/rswag/mod"; +import { choicesModule } from "../modules/rchoices/mod"; +import { fundsModule } from "../modules/rfunds/mod"; +import { filesModule } from "../modules/rfiles/mod"; +import { forumModule } from "../modules/rforum/mod"; +import { walletModule } from "../modules/rwallet/mod"; +import { voteModule } from "../modules/rvote/mod"; +import { notesModule } from "../modules/rnotes/mod"; +import { mapsModule } from "../modules/rmaps/mod"; +import { workModule } from "../modules/rwork/mod"; +import { tripsModule } from "../modules/rtrips/mod"; +import { calModule } from "../modules/rcal/mod"; +import { networkModule } from "../modules/rnetwork/mod"; +import { tubeModule } from "../modules/rtube/mod"; +import { inboxModule } from "../modules/rinbox/mod"; +import { dataModule } from "../modules/rdata/mod"; +import { splatModule } from "../modules/rsplat/mod"; +import { photosModule } from "../modules/rphotos/mod"; import { socialsModule } from "../modules/rsocials/mod"; import { spaces } from "./spaces"; import { renderShell, renderModuleLanding } from "./shell"; diff --git a/server/shell.ts b/server/shell.ts index 8dee908..7f1ea8e 100644 --- a/server/shell.ts +++ b/server/shell.ts @@ -415,6 +415,18 @@ const WELCOME_CSS = ` `; +/** + * Render a demo page shell: standard shell + DEMO_PAGE_CSS + demo scripts. + * Used by modules that have a demoPage() renderer. + */ +export function renderDemoShell(opts: ShellOptions & { demoScripts?: string }): string { + return renderShell({ + ...opts, + styles: `${opts.styles || ""}\n`, + scripts: `${opts.scripts || ""}\n${opts.demoScripts || ""}`, + }); +} + // ── Module landing page (bare-domain rspace.online/{moduleId}) ── export interface ModuleLandingOptions { @@ -727,6 +739,229 @@ const RICH_LANDING_CSS = ` } `; +// ── Demo page CSS utilities (rd-* prefix, parallel to rl-* landing pages) ── + +export const DEMO_PAGE_CSS = ` +/* ── Demo page base ── */ +.rd-root { + min-height: 100vh; + background: linear-gradient(135deg, #0f172a 0%, #1e293b 50%, #0f172a 100%); + color: white; padding-bottom: 2rem; +} +.rd-hero { + max-width: 48rem; margin: 0 auto; text-align: center; + padding: 3rem 1.5rem 2rem; +} +.rd-hero h1 { + font-size: 2.25rem; font-weight: 700; margin-bottom: 1rem; + background: linear-gradient(135deg, var(--rd-accent-from, #14b8a6), var(--rd-accent-to, #22d3ee)); + -webkit-background-clip: text; -webkit-text-fill-color: transparent; + background-clip: text; +} +@media (min-width: 640px) { .rd-hero h1 { font-size: 3rem; } } +.rd-hero .rd-subtitle { font-size: 1.1rem; color: #cbd5e1; margin-bottom: 0.5rem; } +.rd-hero .rd-meta { display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 1rem; font-size: 0.875rem; color: #94a3b8; margin-bottom: 1.5rem; } +.rd-hero .rd-meta span:not(:last-child)::after { content: ""; } + +/* Avatars */ +.rd-avatars { display: flex; align-items: center; justify-content: center; gap: 0.5rem; } +.rd-avatar { + width: 2.5rem; height: 2.5rem; border-radius: 9999px; + display: flex; align-items: center; justify-content: center; + font-size: 0.875rem; font-weight: 700; color: white; + box-shadow: 0 0 0 2px #1e293b; +} +.rd-avatars .rd-count { font-size: 0.875rem; color: #94a3b8; margin-left: 0.5rem; } + +/* Cards */ +.rd-card { + background: rgba(30,41,59,0.5); border-radius: 1rem; + border: 1px solid rgba(51,65,85,0.5); overflow: hidden; +} +.rd-card-header { + display: flex; align-items: center; justify-content: space-between; + padding: 0.75rem 1.25rem; border-bottom: 1px solid rgba(51,65,85,0.5); +} +.rd-card-header .rd-card-title { display: flex; align-items: center; gap: 0.5rem; font-weight: 600; font-size: 0.875rem; } +.rd-card-header .rd-card-title .rd-icon { font-size: 1.25rem; } +.rd-card-header .rd-open-link { + font-size: 0.75rem; padding: 0.375rem 0.75rem; + background: rgba(51,65,85,0.6); border-radius: 0.5rem; + color: #cbd5e1; text-decoration: none; transition: all 0.15s; +} +.rd-card-header .rd-open-link:hover { background: rgba(71,85,105,0.6); color: white; } +.rd-card-body { padding: 1.25rem; } + +/* Live badge */ +.rd-live { + display: inline-flex; align-items: center; gap: 0.375rem; + font-size: 0.75rem; color: #34d399; +} +.rd-live::before { + content: ""; width: 0.375rem; height: 0.375rem; border-radius: 9999px; + background: #34d399; animation: rd-pulse 2s ease-in-out infinite; +} +@keyframes rd-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } } + +/* Status badge */ +.rd-status { + display: inline-flex; align-items: center; gap: 0.375rem; + font-size: 0.75rem; padding: 0.25rem 0.625rem; border-radius: 9999px; +} +.rd-status--connected { color: #34d399; background: rgba(52,211,153,0.1); border: 1px solid rgba(52,211,153,0.2); } +.rd-status--disconnected { color: #f87171; background: rgba(248,113,113,0.1); border: 1px solid rgba(248,113,113,0.2); } +.rd-status::before { + content: ""; width: 0.5rem; height: 0.5rem; border-radius: 9999px; +} +.rd-status--connected::before { background: #34d399; } +.rd-status--disconnected::before { background: #f87171; } + +/* Progress bar */ +.rd-progress { height: 0.75rem; border-radius: 9999px; background: rgba(51,65,85,1); overflow: hidden; } +.rd-progress--sm { height: 0.375rem; } +.rd-progress--xs { height: 0.25rem; } +.rd-progress__fill { + height: 100%; border-radius: 9999px; transition: width 0.3s ease-out; + background: linear-gradient(90deg, var(--rd-accent-from, #14b8a6), var(--rd-accent-to, #2dd4bf)); +} +.rd-progress__fill--emerald { background: #10b981; } +.rd-progress__fill--sky { background: #0ea5e9; } +.rd-progress__fill--amber { background: #f59e0b; } +.rd-progress__fill--rose { background: #f43f5e; } +.rd-progress__fill--orange { background: linear-gradient(90deg, #fb923c, #f97316); } +.rd-progress__fill--teal { background: linear-gradient(90deg, #14b8a6, #2dd4bf); } +.rd-progress__fill--cyan { background: #06b6d4; } +.rd-progress__fill--violet { background: #8b5cf6; } + +/* Grid */ +.rd-grid { display: grid; gap: 1rem; } +.rd-grid--2 { grid-template-columns: 1fr; } +.rd-grid--3 { grid-template-columns: 1fr; } +.rd-grid--4 { grid-template-columns: 1fr 1fr; } +@media (min-width: 640px) { + .rd-grid--2 { grid-template-columns: repeat(2, 1fr); } + .rd-grid--3 { grid-template-columns: repeat(3, 1fr); } +} +@media (min-width: 768px) { + .rd-grid--3 { grid-template-columns: repeat(3, 1fr); } + .rd-grid--4 { grid-template-columns: repeat(4, 1fr); } +} + +/* Section */ +.rd-section { max-width: 72rem; margin: 0 auto; padding: 0 1.5rem 1.5rem; } +.rd-section--narrow { max-width: 64rem; } + +/* Badge */ +.rd-badge { + display: inline-block; font-size: 0.75rem; font-weight: 500; + padding: 0.125rem 0.625rem; border-radius: 9999px; +} +.rd-badge--emerald { background: rgba(16,185,129,0.2); color: #6ee7b7; } +.rd-badge--sky { background: rgba(14,165,233,0.2); color: #7dd3fc; } +.rd-badge--amber { background: rgba(245,158,11,0.2); color: #fcd34d; } +.rd-badge--rose { background: rgba(244,63,94,0.2); color: #fda4af; } +.rd-badge--orange { background: rgba(249,115,22,0.2); color: #fdba74; } +.rd-badge--teal { background: rgba(20,184,166,0.2); color: #5eead4; } +.rd-badge--slate { background: rgba(100,116,139,0.2); color: #94a3b8; } + +/* Stat box */ +.rd-stat { background: rgba(51,65,85,0.3); border-radius: 0.75rem; padding: 1rem; text-align: center; } +.rd-stat__value { font-size: 1.5rem; font-weight: 700; color: white; } +.rd-stat__label { font-size: 0.75rem; color: #94a3b8; margin-top: 0.25rem; } +.rd-stat__sub { font-size: 0.75rem; color: #64748b; margin-top: 0.125rem; } + +/* Button */ +.rd-btn { + display: inline-flex; align-items: center; gap: 0.5rem; + padding: 0.5rem 1rem; border-radius: 0.5rem; font-size: 0.875rem; + font-weight: 500; cursor: pointer; border: none; transition: all 0.15s; +} +.rd-btn--ghost { background: rgba(51,65,85,0.6); color: #cbd5e1; } +.rd-btn--ghost:hover { background: rgba(71,85,105,0.6); color: white; } +.rd-btn--ghost:disabled { opacity: 0.5; cursor: not-allowed; } +.rd-btn--primary { color: white; } + +/* Divider row */ +.rd-row { + display: flex; align-items: center; justify-content: space-between; + padding: 0.75rem 1.25rem; border-top: 1px solid rgba(51,65,85,0.5); +} + +/* Item row (expense/cart list item) */ +.rd-item { + display: flex; align-items: center; gap: 1rem; + padding: 0.75rem 1.25rem; transition: background 0.1s; +} +.rd-item:hover { background: rgba(51,65,85,0.2); } +.rd-item + .rd-item { border-top: 1px solid rgba(51,65,85,0.3); } + +/* Checkbox */ +.rd-checkbox { + width: 1.25rem; height: 1.25rem; border-radius: 0.25rem; flex-shrink: 0; + border: 2px solid #475569; display: flex; align-items: center; justify-content: center; + cursor: pointer; transition: all 0.15s; +} +.rd-checkbox--checked { background: var(--rd-accent-from, #14b8a6); border-color: var(--rd-accent-from, #14b8a6); } +.rd-checkbox svg { width: 0.75rem; height: 0.75rem; color: white; } +.rd-checkbox:hover { border-color: #64748b; } + +/* CTA section */ +.rd-cta { + background: rgba(30,41,59,0.5); border-radius: 1rem; + border: 1px solid rgba(51,65,85,0.5); padding: 2.5rem; + text-align: center; margin-top: 1rem; +} +.rd-cta h2 { font-size: 1.875rem; font-weight: 700; margin-bottom: 0.75rem; } +.rd-cta p { color: #94a3b8; max-width: 32rem; margin: 0 auto 1.5rem; font-size: 0.875rem; } +.rd-cta a { + display: inline-block; padding: 0.875rem 2rem; border-radius: 0.75rem; + font-size: 1.1rem; font-weight: 500; color: white; text-decoration: none; + transition: transform 0.2s, box-shadow 0.2s; +} +.rd-cta a:hover { transform: translateY(-2px); } + +/* Text helpers */ +.rd-text-muted { color: #94a3b8; } +.rd-text-dim { color: #64748b; } +.rd-text-sm { font-size: 0.875rem; } +.rd-text-xs { font-size: 0.75rem; } +.rd-text-center { text-align: center; } +.rd-font-bold { font-weight: 700; } +.rd-font-semibold { font-weight: 600; } +.rd-font-medium { font-weight: 500; } +.rd-truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.rd-line-through { text-decoration: line-through; } +.rd-hidden { display: none !important; } + +/* Color helpers */ +.rd-emerald { color: #34d399; } +.rd-rose { color: #fb7185; } +.rd-amber { color: #fbbf24; } +.rd-cyan { color: #22d3ee; } +.rd-teal { color: #2dd4bf; } +.rd-orange { color: #fb923c; } +.rd-sky { color: #38bdf8; } +.rd-violet { color: #a78bfa; } + +/* BG helpers */ +.rd-bg-emerald { background: #10b981; } +.rd-bg-cyan { background: #06b6d4; } +.rd-bg-violet { background: #8b5cf6; } +.rd-bg-amber { background: #f59e0b; } +.rd-bg-rose { background: #f43f5e; } +.rd-bg-teal { background: #14b8a6; } +.rd-bg-sky { background: #0ea5e9; } +.rd-bg-orange { background: #f97316; } +.rd-bg-slate { background: #64748b; } + +/* Responsive */ +@media (max-width: 640px) { + .rd-hero { padding: 2rem 1rem 1.5rem; } + .rd-hero h1 { font-size: 2rem; } + .rd-section { padding: 0 1rem 1rem; } +} +`; + export function escapeHtml(s: string): string { return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); } diff --git a/shared/module.ts b/shared/module.ts index 83d4fbc..a84b285 100644 --- a/shared/module.ts +++ b/shared/module.ts @@ -35,6 +35,8 @@ export interface RSpaceModule { hidden?: boolean; /** Optional: render rich landing page body HTML */ landingPage?: () => string; + /** Optional: render rich demo page body HTML (served when space === "demo") */ + demoPage?: () => string; } /** Registry of all loaded modules */ diff --git a/vite.config.ts b/vite.config.ts index 8ce5dc4..25dc2c6 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -62,12 +62,12 @@ export default defineConfig({ // Build books module components await build({ configFile: false, - root: resolve(__dirname, "modules/books/components"), + root: resolve(__dirname, "modules/rbooks/components"), build: { emptyOutDir: false, - outDir: resolve(__dirname, "dist/modules/books"), + outDir: resolve(__dirname, "dist/modules/rbooks"), lib: { - entry: resolve(__dirname, "modules/books/components/folk-book-shelf.ts"), + entry: resolve(__dirname, "modules/rbooks/components/folk-book-shelf.ts"), formats: ["es"], fileName: () => "folk-book-shelf.js", }, @@ -81,12 +81,12 @@ export default defineConfig({ await build({ configFile: false, - root: resolve(__dirname, "modules/books/components"), + root: resolve(__dirname, "modules/rbooks/components"), build: { emptyOutDir: false, - outDir: resolve(__dirname, "dist/modules/books"), + outDir: resolve(__dirname, "dist/modules/rbooks"), lib: { - entry: resolve(__dirname, "modules/books/components/folk-book-reader.ts"), + entry: resolve(__dirname, "modules/rbooks/components/folk-book-reader.ts"), formats: ["es"], fileName: () => "folk-book-reader.js", }, @@ -100,21 +100,21 @@ export default defineConfig({ // Copy books CSS const { copyFileSync, mkdirSync } = await import("node:fs"); - mkdirSync(resolve(__dirname, "dist/modules/books"), { recursive: true }); + mkdirSync(resolve(__dirname, "dist/modules/rbooks"), { recursive: true }); copyFileSync( - resolve(__dirname, "modules/books/components/books.css"), - resolve(__dirname, "dist/modules/books/books.css"), + resolve(__dirname, "modules/rbooks/components/books.css"), + resolve(__dirname, "dist/modules/rbooks/books.css"), ); // Build pubs module component await build({ configFile: false, - root: resolve(__dirname, "modules/pubs/components"), + root: resolve(__dirname, "modules/rpubs/components"), build: { emptyOutDir: false, - outDir: resolve(__dirname, "dist/modules/pubs"), + outDir: resolve(__dirname, "dist/modules/rpubs"), lib: { - entry: resolve(__dirname, "modules/pubs/components/folk-pubs-editor.ts"), + entry: resolve(__dirname, "modules/rpubs/components/folk-pubs-editor.ts"), formats: ["es"], fileName: () => "folk-pubs-editor.js", }, @@ -127,21 +127,21 @@ export default defineConfig({ }); // Copy pubs CSS - mkdirSync(resolve(__dirname, "dist/modules/pubs"), { recursive: true }); + mkdirSync(resolve(__dirname, "dist/modules/rpubs"), { recursive: true }); copyFileSync( - resolve(__dirname, "modules/pubs/components/pubs.css"), - resolve(__dirname, "dist/modules/pubs/pubs.css"), + resolve(__dirname, "modules/rpubs/components/pubs.css"), + resolve(__dirname, "dist/modules/rpubs/pubs.css"), ); // Build cart module component await build({ configFile: false, - root: resolve(__dirname, "modules/cart/components"), + root: resolve(__dirname, "modules/rcart/components"), build: { emptyOutDir: false, - outDir: resolve(__dirname, "dist/modules/cart"), + outDir: resolve(__dirname, "dist/modules/rcart"), lib: { - entry: resolve(__dirname, "modules/cart/components/folk-cart-shop.ts"), + entry: resolve(__dirname, "modules/rcart/components/folk-cart-shop.ts"), formats: ["es"], fileName: () => "folk-cart-shop.js", }, @@ -154,21 +154,21 @@ export default defineConfig({ }); // Copy cart CSS - mkdirSync(resolve(__dirname, "dist/modules/cart"), { recursive: true }); + mkdirSync(resolve(__dirname, "dist/modules/rcart"), { recursive: true }); copyFileSync( - resolve(__dirname, "modules/cart/components/cart.css"), - resolve(__dirname, "dist/modules/cart/cart.css"), + resolve(__dirname, "modules/rcart/components/cart.css"), + resolve(__dirname, "dist/modules/rcart/cart.css"), ); // Build swag module component await build({ configFile: false, - root: resolve(__dirname, "modules/swag/components"), + root: resolve(__dirname, "modules/rswag/components"), build: { emptyOutDir: false, - outDir: resolve(__dirname, "dist/modules/swag"), + outDir: resolve(__dirname, "dist/modules/rswag"), lib: { - entry: resolve(__dirname, "modules/swag/components/folk-swag-designer.ts"), + entry: resolve(__dirname, "modules/rswag/components/folk-swag-designer.ts"), formats: ["es"], fileName: () => "folk-swag-designer.js", }, @@ -181,21 +181,21 @@ export default defineConfig({ }); // Copy swag CSS - mkdirSync(resolve(__dirname, "dist/modules/swag"), { recursive: true }); + mkdirSync(resolve(__dirname, "dist/modules/rswag"), { recursive: true }); copyFileSync( - resolve(__dirname, "modules/swag/components/swag.css"), - resolve(__dirname, "dist/modules/swag/swag.css"), + resolve(__dirname, "modules/rswag/components/swag.css"), + resolve(__dirname, "dist/modules/rswag/swag.css"), ); // Build choices module component await build({ configFile: false, - root: resolve(__dirname, "modules/choices/components"), + root: resolve(__dirname, "modules/rchoices/components"), build: { emptyOutDir: false, - outDir: resolve(__dirname, "dist/modules/choices"), + outDir: resolve(__dirname, "dist/modules/rchoices"), lib: { - entry: resolve(__dirname, "modules/choices/components/folk-choices-dashboard.ts"), + entry: resolve(__dirname, "modules/rchoices/components/folk-choices-dashboard.ts"), formats: ["es"], fileName: () => "folk-choices-dashboard.js", }, @@ -208,29 +208,29 @@ export default defineConfig({ }); // Copy choices CSS - mkdirSync(resolve(__dirname, "dist/modules/choices"), { recursive: true }); + mkdirSync(resolve(__dirname, "dist/modules/rchoices"), { recursive: true }); copyFileSync( - resolve(__dirname, "modules/choices/components/choices.css"), - resolve(__dirname, "dist/modules/choices/choices.css"), + resolve(__dirname, "modules/rchoices/components/choices.css"), + resolve(__dirname, "dist/modules/rchoices/choices.css"), ); // Build funds module components const fundsAlias = { - "../lib/types": resolve(__dirname, "modules/funds/lib/types.ts"), - "../lib/simulation": resolve(__dirname, "modules/funds/lib/simulation.ts"), - "../lib/presets": resolve(__dirname, "modules/funds/lib/presets.ts"), - "../lib/map-flow": resolve(__dirname, "modules/funds/lib/map-flow.ts"), + "../lib/types": resolve(__dirname, "modules/rfunds/lib/types.ts"), + "../lib/simulation": resolve(__dirname, "modules/rfunds/lib/simulation.ts"), + "../lib/presets": resolve(__dirname, "modules/rfunds/lib/presets.ts"), + "../lib/map-flow": resolve(__dirname, "modules/rfunds/lib/map-flow.ts"), }; await build({ configFile: false, - root: resolve(__dirname, "modules/funds/components"), + root: resolve(__dirname, "modules/rfunds/components"), resolve: { alias: fundsAlias }, build: { emptyOutDir: false, - outDir: resolve(__dirname, "dist/modules/funds"), + outDir: resolve(__dirname, "dist/modules/rfunds"), lib: { - entry: resolve(__dirname, "modules/funds/components/folk-budget-river.ts"), + entry: resolve(__dirname, "modules/rfunds/components/folk-budget-river.ts"), formats: ["es"], fileName: () => "folk-budget-river.js", }, @@ -240,13 +240,13 @@ export default defineConfig({ await build({ configFile: false, - root: resolve(__dirname, "modules/funds/components"), + root: resolve(__dirname, "modules/rfunds/components"), resolve: { alias: fundsAlias }, build: { emptyOutDir: false, - outDir: resolve(__dirname, "dist/modules/funds"), + outDir: resolve(__dirname, "dist/modules/rfunds"), lib: { - entry: resolve(__dirname, "modules/funds/components/folk-funds-app.ts"), + entry: resolve(__dirname, "modules/rfunds/components/folk-funds-app.ts"), formats: ["es"], fileName: () => "folk-funds-app.js", }, @@ -255,21 +255,21 @@ export default defineConfig({ }); // Copy funds CSS - mkdirSync(resolve(__dirname, "dist/modules/funds"), { recursive: true }); + mkdirSync(resolve(__dirname, "dist/modules/rfunds"), { recursive: true }); copyFileSync( - resolve(__dirname, "modules/funds/components/funds.css"), - resolve(__dirname, "dist/modules/funds/funds.css"), + resolve(__dirname, "modules/rfunds/components/funds.css"), + resolve(__dirname, "dist/modules/rfunds/funds.css"), ); // Build files module component await build({ configFile: false, - root: resolve(__dirname, "modules/files/components"), + root: resolve(__dirname, "modules/rfiles/components"), build: { emptyOutDir: false, - outDir: resolve(__dirname, "dist/modules/files"), + outDir: resolve(__dirname, "dist/modules/rfiles"), lib: { - entry: resolve(__dirname, "modules/files/components/folk-file-browser.ts"), + entry: resolve(__dirname, "modules/rfiles/components/folk-file-browser.ts"), formats: ["es"], fileName: () => "folk-file-browser.js", }, @@ -282,21 +282,21 @@ export default defineConfig({ }); // Copy files CSS - mkdirSync(resolve(__dirname, "dist/modules/files"), { recursive: true }); + mkdirSync(resolve(__dirname, "dist/modules/rfiles"), { recursive: true }); copyFileSync( - resolve(__dirname, "modules/files/components/files.css"), - resolve(__dirname, "dist/modules/files/files.css"), + resolve(__dirname, "modules/rfiles/components/files.css"), + resolve(__dirname, "dist/modules/rfiles/files.css"), ); // Build forum module component await build({ configFile: false, - root: resolve(__dirname, "modules/forum/components"), + root: resolve(__dirname, "modules/rforum/components"), build: { emptyOutDir: false, - outDir: resolve(__dirname, "dist/modules/forum"), + outDir: resolve(__dirname, "dist/modules/rforum"), lib: { - entry: resolve(__dirname, "modules/forum/components/folk-forum-dashboard.ts"), + entry: resolve(__dirname, "modules/rforum/components/folk-forum-dashboard.ts"), formats: ["es"], fileName: () => "folk-forum-dashboard.js", }, @@ -309,21 +309,21 @@ export default defineConfig({ }); // Copy forum CSS - mkdirSync(resolve(__dirname, "dist/modules/forum"), { recursive: true }); + mkdirSync(resolve(__dirname, "dist/modules/rforum"), { recursive: true }); copyFileSync( - resolve(__dirname, "modules/forum/components/forum.css"), - resolve(__dirname, "dist/modules/forum/forum.css"), + resolve(__dirname, "modules/rforum/components/forum.css"), + resolve(__dirname, "dist/modules/rforum/forum.css"), ); // Build wallet module component await build({ configFile: false, - root: resolve(__dirname, "modules/wallet/components"), + root: resolve(__dirname, "modules/rwallet/components"), build: { emptyOutDir: false, - outDir: resolve(__dirname, "dist/modules/wallet"), + outDir: resolve(__dirname, "dist/modules/rwallet"), lib: { - entry: resolve(__dirname, "modules/wallet/components/folk-wallet-viewer.ts"), + entry: resolve(__dirname, "modules/rwallet/components/folk-wallet-viewer.ts"), formats: ["es"], fileName: () => "folk-wallet-viewer.js", }, @@ -336,21 +336,21 @@ export default defineConfig({ }); // Copy wallet CSS - mkdirSync(resolve(__dirname, "dist/modules/wallet"), { recursive: true }); + mkdirSync(resolve(__dirname, "dist/modules/rwallet"), { recursive: true }); copyFileSync( - resolve(__dirname, "modules/wallet/components/wallet.css"), - resolve(__dirname, "dist/modules/wallet/wallet.css"), + resolve(__dirname, "modules/rwallet/components/wallet.css"), + resolve(__dirname, "dist/modules/rwallet/wallet.css"), ); // Build vote module component await build({ configFile: false, - root: resolve(__dirname, "modules/vote/components"), + root: resolve(__dirname, "modules/rvote/components"), build: { emptyOutDir: false, - outDir: resolve(__dirname, "dist/modules/vote"), + outDir: resolve(__dirname, "dist/modules/rvote"), lib: { - entry: resolve(__dirname, "modules/vote/components/folk-vote-dashboard.ts"), + entry: resolve(__dirname, "modules/rvote/components/folk-vote-dashboard.ts"), formats: ["es"], fileName: () => "folk-vote-dashboard.js", }, @@ -363,16 +363,16 @@ export default defineConfig({ }); // Copy vote CSS - mkdirSync(resolve(__dirname, "dist/modules/vote"), { recursive: true }); + mkdirSync(resolve(__dirname, "dist/modules/rvote"), { recursive: true }); copyFileSync( - resolve(__dirname, "modules/vote/components/vote.css"), - resolve(__dirname, "dist/modules/vote/vote.css"), + resolve(__dirname, "modules/rvote/components/vote.css"), + resolve(__dirname, "dist/modules/rvote/vote.css"), ); // Build notes module component (with Automerge WASM support) await build({ configFile: false, - root: resolve(__dirname, "modules/notes/components"), + root: resolve(__dirname, "modules/rnotes/components"), plugins: [wasm()], resolve: { alias: { @@ -382,9 +382,9 @@ export default defineConfig({ build: { target: "esnext", emptyOutDir: false, - outDir: resolve(__dirname, "dist/modules/notes"), + outDir: resolve(__dirname, "dist/modules/rnotes"), lib: { - entry: resolve(__dirname, "modules/notes/components/folk-notes-app.ts"), + entry: resolve(__dirname, "modules/rnotes/components/folk-notes-app.ts"), formats: ["es"], fileName: () => "folk-notes-app.js", }, @@ -397,21 +397,21 @@ export default defineConfig({ }); // Copy notes CSS - mkdirSync(resolve(__dirname, "dist/modules/notes"), { recursive: true }); + mkdirSync(resolve(__dirname, "dist/modules/rnotes"), { recursive: true }); copyFileSync( - resolve(__dirname, "modules/notes/components/notes.css"), - resolve(__dirname, "dist/modules/notes/notes.css"), + resolve(__dirname, "modules/rnotes/components/notes.css"), + resolve(__dirname, "dist/modules/rnotes/notes.css"), ); // Build maps module component await build({ configFile: false, - root: resolve(__dirname, "modules/maps/components"), + root: resolve(__dirname, "modules/rmaps/components"), build: { emptyOutDir: false, - outDir: resolve(__dirname, "dist/modules/maps"), + outDir: resolve(__dirname, "dist/modules/rmaps"), lib: { - entry: resolve(__dirname, "modules/maps/components/folk-map-viewer.ts"), + entry: resolve(__dirname, "modules/rmaps/components/folk-map-viewer.ts"), formats: ["es"], fileName: () => "folk-map-viewer.js", }, @@ -424,21 +424,21 @@ export default defineConfig({ }); // Copy maps CSS - mkdirSync(resolve(__dirname, "dist/modules/maps"), { recursive: true }); + mkdirSync(resolve(__dirname, "dist/modules/rmaps"), { recursive: true }); copyFileSync( - resolve(__dirname, "modules/maps/components/maps.css"), - resolve(__dirname, "dist/modules/maps/maps.css"), + resolve(__dirname, "modules/rmaps/components/maps.css"), + resolve(__dirname, "dist/modules/rmaps/maps.css"), ); // Build work module component await build({ configFile: false, - root: resolve(__dirname, "modules/work/components"), + root: resolve(__dirname, "modules/rwork/components"), build: { emptyOutDir: false, - outDir: resolve(__dirname, "dist/modules/work"), + outDir: resolve(__dirname, "dist/modules/rwork"), lib: { - entry: resolve(__dirname, "modules/work/components/folk-work-board.ts"), + entry: resolve(__dirname, "modules/rwork/components/folk-work-board.ts"), formats: ["es"], fileName: () => "folk-work-board.js", }, @@ -451,21 +451,21 @@ export default defineConfig({ }); // Copy work CSS - mkdirSync(resolve(__dirname, "dist/modules/work"), { recursive: true }); + mkdirSync(resolve(__dirname, "dist/modules/rwork"), { recursive: true }); copyFileSync( - resolve(__dirname, "modules/work/components/work.css"), - resolve(__dirname, "dist/modules/work/work.css"), + resolve(__dirname, "modules/rwork/components/work.css"), + resolve(__dirname, "dist/modules/rwork/work.css"), ); // Build trips module component await build({ configFile: false, - root: resolve(__dirname, "modules/trips/components"), + root: resolve(__dirname, "modules/rtrips/components"), build: { emptyOutDir: false, - outDir: resolve(__dirname, "dist/modules/trips"), + outDir: resolve(__dirname, "dist/modules/rtrips"), lib: { - entry: resolve(__dirname, "modules/trips/components/folk-trips-planner.ts"), + entry: resolve(__dirname, "modules/rtrips/components/folk-trips-planner.ts"), formats: ["es"], fileName: () => "folk-trips-planner.js", }, @@ -478,21 +478,21 @@ export default defineConfig({ }); // Copy trips CSS - mkdirSync(resolve(__dirname, "dist/modules/trips"), { recursive: true }); + mkdirSync(resolve(__dirname, "dist/modules/rtrips"), { recursive: true }); copyFileSync( - resolve(__dirname, "modules/trips/components/trips.css"), - resolve(__dirname, "dist/modules/trips/trips.css"), + resolve(__dirname, "modules/rtrips/components/trips.css"), + resolve(__dirname, "dist/modules/rtrips/trips.css"), ); // Build cal module component await build({ configFile: false, - root: resolve(__dirname, "modules/cal/components"), + root: resolve(__dirname, "modules/rcal/components"), build: { emptyOutDir: false, - outDir: resolve(__dirname, "dist/modules/cal"), + outDir: resolve(__dirname, "dist/modules/rcal"), lib: { - entry: resolve(__dirname, "modules/cal/components/folk-calendar-view.ts"), + entry: resolve(__dirname, "modules/rcal/components/folk-calendar-view.ts"), formats: ["es"], fileName: () => "folk-calendar-view.js", }, @@ -505,21 +505,21 @@ export default defineConfig({ }); // Copy cal CSS - mkdirSync(resolve(__dirname, "dist/modules/cal"), { recursive: true }); + mkdirSync(resolve(__dirname, "dist/modules/rcal"), { recursive: true }); copyFileSync( - resolve(__dirname, "modules/cal/components/cal.css"), - resolve(__dirname, "dist/modules/cal/cal.css"), + resolve(__dirname, "modules/rcal/components/cal.css"), + resolve(__dirname, "dist/modules/rcal/cal.css"), ); // Build network module component await build({ configFile: false, - root: resolve(__dirname, "modules/network/components"), + root: resolve(__dirname, "modules/rnetwork/components"), build: { emptyOutDir: false, - outDir: resolve(__dirname, "dist/modules/network"), + outDir: resolve(__dirname, "dist/modules/rnetwork"), lib: { - entry: resolve(__dirname, "modules/network/components/folk-graph-viewer.ts"), + entry: resolve(__dirname, "modules/rnetwork/components/folk-graph-viewer.ts"), formats: ["es"], fileName: () => "folk-graph-viewer.js", }, @@ -532,21 +532,21 @@ export default defineConfig({ }); // Copy network CSS - mkdirSync(resolve(__dirname, "dist/modules/network"), { recursive: true }); + mkdirSync(resolve(__dirname, "dist/modules/rnetwork"), { recursive: true }); copyFileSync( - resolve(__dirname, "modules/network/components/network.css"), - resolve(__dirname, "dist/modules/network/network.css"), + resolve(__dirname, "modules/rnetwork/components/network.css"), + resolve(__dirname, "dist/modules/rnetwork/network.css"), ); // Build tube module component await build({ configFile: false, - root: resolve(__dirname, "modules/tube/components"), + root: resolve(__dirname, "modules/rtube/components"), build: { emptyOutDir: false, - outDir: resolve(__dirname, "dist/modules/tube"), + outDir: resolve(__dirname, "dist/modules/rtube"), lib: { - entry: resolve(__dirname, "modules/tube/components/folk-video-player.ts"), + entry: resolve(__dirname, "modules/rtube/components/folk-video-player.ts"), formats: ["es"], fileName: () => "folk-video-player.js", }, @@ -559,21 +559,21 @@ export default defineConfig({ }); // Copy tube CSS - mkdirSync(resolve(__dirname, "dist/modules/tube"), { recursive: true }); + mkdirSync(resolve(__dirname, "dist/modules/rtube"), { recursive: true }); copyFileSync( - resolve(__dirname, "modules/tube/components/tube.css"), - resolve(__dirname, "dist/modules/tube/tube.css"), + resolve(__dirname, "modules/rtube/components/tube.css"), + resolve(__dirname, "dist/modules/rtube/tube.css"), ); // Build inbox module component await build({ configFile: false, - root: resolve(__dirname, "modules/inbox/components"), + root: resolve(__dirname, "modules/rinbox/components"), build: { emptyOutDir: false, - outDir: resolve(__dirname, "dist/modules/inbox"), + outDir: resolve(__dirname, "dist/modules/rinbox"), lib: { - entry: resolve(__dirname, "modules/inbox/components/folk-inbox-client.ts"), + entry: resolve(__dirname, "modules/rinbox/components/folk-inbox-client.ts"), formats: ["es"], fileName: () => "folk-inbox-client.js", }, @@ -586,21 +586,21 @@ export default defineConfig({ }); // Copy inbox CSS - mkdirSync(resolve(__dirname, "dist/modules/inbox"), { recursive: true }); + mkdirSync(resolve(__dirname, "dist/modules/rinbox"), { recursive: true }); copyFileSync( - resolve(__dirname, "modules/inbox/components/inbox.css"), - resolve(__dirname, "dist/modules/inbox/inbox.css"), + resolve(__dirname, "modules/rinbox/components/inbox.css"), + resolve(__dirname, "dist/modules/rinbox/inbox.css"), ); // Build data module component await build({ configFile: false, - root: resolve(__dirname, "modules/data/components"), + root: resolve(__dirname, "modules/rdata/components"), build: { emptyOutDir: false, - outDir: resolve(__dirname, "dist/modules/data"), + outDir: resolve(__dirname, "dist/modules/rdata"), lib: { - entry: resolve(__dirname, "modules/data/components/folk-analytics-view.ts"), + entry: resolve(__dirname, "modules/rdata/components/folk-analytics-view.ts"), formats: ["es"], fileName: () => "folk-analytics-view.js", }, @@ -613,28 +613,28 @@ export default defineConfig({ }); // Copy data CSS - mkdirSync(resolve(__dirname, "dist/modules/data"), { recursive: true }); + mkdirSync(resolve(__dirname, "dist/modules/rdata"), { recursive: true }); copyFileSync( - resolve(__dirname, "modules/data/components/data.css"), - resolve(__dirname, "dist/modules/data/data.css"), + resolve(__dirname, "modules/rdata/components/data.css"), + resolve(__dirname, "dist/modules/rdata/data.css"), ); // Build route planner component (part of trips module) await build({ configFile: false, - root: resolve(__dirname, "modules/trips/components"), + root: resolve(__dirname, "modules/rtrips/components"), resolve: { alias: { - "../lib/types": resolve(__dirname, "modules/trips/lib/types.ts"), - "../lib/conic-math": resolve(__dirname, "modules/trips/lib/conic-math.ts"), - "../lib/projection": resolve(__dirname, "modules/trips/lib/projection.ts"), + "../lib/types": resolve(__dirname, "modules/rtrips/lib/types.ts"), + "../lib/conic-math": resolve(__dirname, "modules/rtrips/lib/conic-math.ts"), + "../lib/projection": resolve(__dirname, "modules/rtrips/lib/projection.ts"), }, }, build: { emptyOutDir: false, - outDir: resolve(__dirname, "dist/modules/trips"), + outDir: resolve(__dirname, "dist/modules/rtrips"), lib: { - entry: resolve(__dirname, "modules/trips/components/folk-route-planner.ts"), + entry: resolve(__dirname, "modules/rtrips/components/folk-route-planner.ts"), formats: ["es"], fileName: () => "folk-route-planner.js", }, @@ -648,19 +648,19 @@ export default defineConfig({ // Copy route planner CSS copyFileSync( - resolve(__dirname, "modules/trips/components/route-planner.css"), - resolve(__dirname, "dist/modules/trips/route-planner.css"), + resolve(__dirname, "modules/rtrips/components/route-planner.css"), + resolve(__dirname, "dist/modules/rtrips/route-planner.css"), ); // Build splat module component await build({ configFile: false, - root: resolve(__dirname, "modules/splat/components"), + root: resolve(__dirname, "modules/rsplat/components"), build: { emptyOutDir: false, - outDir: resolve(__dirname, "dist/modules/splat"), + outDir: resolve(__dirname, "dist/modules/rsplat"), lib: { - entry: resolve(__dirname, "modules/splat/components/folk-splat-viewer.ts"), + entry: resolve(__dirname, "modules/rsplat/components/folk-splat-viewer.ts"), formats: ["es"], fileName: () => "folk-splat-viewer.js", }, @@ -674,21 +674,21 @@ export default defineConfig({ }); // Copy splat CSS - mkdirSync(resolve(__dirname, "dist/modules/splat"), { recursive: true }); + mkdirSync(resolve(__dirname, "dist/modules/rsplat"), { recursive: true }); copyFileSync( - resolve(__dirname, "modules/splat/components/splat.css"), - resolve(__dirname, "dist/modules/splat/splat.css"), + resolve(__dirname, "modules/rsplat/components/splat.css"), + resolve(__dirname, "dist/modules/rsplat/splat.css"), ); // Build photos module component await build({ configFile: false, - root: resolve(__dirname, "modules/photos/components"), + root: resolve(__dirname, "modules/rphotos/components"), build: { emptyOutDir: false, - outDir: resolve(__dirname, "dist/modules/photos"), + outDir: resolve(__dirname, "dist/modules/rphotos"), lib: { - entry: resolve(__dirname, "modules/photos/components/folk-photo-gallery.ts"), + entry: resolve(__dirname, "modules/rphotos/components/folk-photo-gallery.ts"), formats: ["es"], fileName: () => "folk-photo-gallery.js", }, @@ -701,11 +701,69 @@ export default defineConfig({ }); // Copy photos CSS - mkdirSync(resolve(__dirname, "dist/modules/photos"), { recursive: true }); + mkdirSync(resolve(__dirname, "dist/modules/rphotos"), { recursive: true }); copyFileSync( - resolve(__dirname, "modules/photos/components/photos.css"), - resolve(__dirname, "dist/modules/photos/photos.css"), + resolve(__dirname, "modules/rphotos/components/photos.css"), + resolve(__dirname, "dist/modules/rphotos/photos.css"), ); + + // ── Demo infrastructure ── + + // Build demo-sync-vanilla library + await build({ + configFile: false, + root: resolve(__dirname, "lib"), + build: { + emptyOutDir: false, + outDir: resolve(__dirname, "dist/lib"), + lib: { + entry: resolve(__dirname, "lib/demo-sync-vanilla.ts"), + formats: ["es"], + fileName: () => "demo-sync.js", + }, + rollupOptions: { + output: { + entryFileNames: "demo-sync.js", + }, + }, + }, + }); + + // Build demo scripts for each module that has one + const demoModules = ["cart", "vote", "funds", "notes", "cal", "tube", "trips"]; + for (const mod of demoModules) { + const dir = `r${mod}`; + const demoEntry = resolve(__dirname, `modules/${dir}/components/${mod}-demo.ts`); + try { + const { statSync } = await import("node:fs"); + statSync(demoEntry); + await build({ + configFile: false, + root: resolve(__dirname, `modules/${dir}/components`), + resolve: { + alias: { + "@lib": resolve(__dirname, "./lib"), + }, + }, + build: { + emptyOutDir: false, + outDir: resolve(__dirname, `dist/modules/${dir}`), + lib: { + entry: demoEntry, + formats: ["es"], + fileName: () => `${mod}-demo.js`, + }, + rollupOptions: { + output: { + entryFileNames: `${mod}-demo.js`, + }, + }, + }, + }); + } catch { + // Demo script not yet created — skip + } + } }, }, },