refactor: rename module directories to match r-prefixed module IDs

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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-28 19:49:26 -08:00
parent 29d49c7b26
commit 5613370817
145 changed files with 4528 additions and 249 deletions

2
.gitignore vendored
View File

@ -6,7 +6,7 @@ dist/
# Data storage
data/
!modules/data/
!modules/rdata/
# IDE
.vscode/

View File

@ -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`)

210
lib/demo-sync-vanilla.ts Normal file
View File

@ -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<string, DemoShape> }
* - "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<typeof setTimeout> | null = null;
private pingTimer: ReturnType<typeof setInterval> | null = null;
private destroyed = false;
shapes: Record<string, DemoShape> = {};
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<DemoShape>): 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<void> {
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<string, DemoShape>): Record<string, DemoShape> {
if (!this.filter || this.filter.length === 0) return allShapes;
const filtered: Record<string, DemoShape> = {};
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);
}
}

View File

@ -212,8 +212,8 @@ routes.get("/", (c) => {
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-book-shelf space-slug="${spaceSlug}"></folk-book-shelf>`,
scripts: `<script type="module" src="/modules/books/folk-book-shelf.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/books/books.css">`,
scripts: `<script type="module" src="/modules/rbooks/folk-book-shelf.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/rbooks/books.css">`,
}));
});
@ -264,10 +264,10 @@ routes.get("/read/:id", async (c) => {
`,
modules: getModuleInfoList(),
theme: "dark",
head: `<link rel="stylesheet" href="/modules/books/books.css">`,
head: `<link rel="stylesheet" href="/modules/rbooks/books.css">`,
scripts: `
<script type="module">
import { FolkBookReader } from '/modules/books/folk-book-reader.js';
import { FolkBookReader } from '/modules/rbooks/folk-book-reader.js';
</script>
`,
});

View File

@ -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<HTMLElement>("[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 {};

344
modules/rcal/demo.ts Normal file
View File

@ -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(`<div class="rcal-cell rcal-cell--empty"></div>`);
}
// 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 = `<div class="rcal-pill" style="background:${ev.bg};color:${ev.color};border:1px solid ${ev.color}22;">
<span class="rcal-pill__emoji">${ev.emoji}</span>
<span class="rcal-pill__label">${ev.label}</span>
</div>`;
}
calendarCells.push(`<div class="rcal-cell${tripClass}${todayClass}">
<span class="rcal-cell__num${trip ? " rcal-cell__num--trip" : ""}">${d}</span>
${pill}
</div>`);
}
return `
<div class="rd-root" style="--rd-accent-from:#6366f1; --rd-accent-to:#a78bfa;">
<!-- Hero -->
<section class="rd-hero">
<div style="display:inline-block;padding:0.375rem 1rem;background:rgba(99,102,241,0.1);border:1px solid rgba(99,102,241,0.2);border-radius:9999px;font-size:0.875rem;color:#a5b4fc;font-weight:500;margin-bottom:1.5rem;">
Multi-Dimensional Calendar
</div>
<h1>rCal Demo</h1>
<p class="rd-subtitle">Multi-dimensional calendar with temporal zoom</p>
<div class="rd-meta">
<span>\u{1F50D} Temporal Zoom</span>
<span style="color:#475569">|</span>
<span>\u{1F30D} Spatial Context</span>
<span style="color:#475569">|</span>
<span>\u{1F319} Lunar Cycles</span>
<span style="color:#475569">|</span>
<span>\u{1F4C5} Multi-Calendar</span>
</div>
</section>
<!-- Calendar Section -->
<section class="rd-section rd-section--narrow">
<!-- Header bar -->
<div class="rd-card" style="margin-bottom:0;">
<div style="display:flex;align-items:center;justify-content:space-between;padding:1rem 1.25rem;border-bottom:1px solid rgba(51,65,85,0.3);flex-wrap:wrap;gap:0.75rem;">
<h2 style="font-size:1.25rem;font-weight:700;color:#f1f5f9;margin:0;display:flex;align-items:center;gap:0.5rem;">
\u{1F4C5} July 2026
</h2>
<div style="display:flex;gap:0.25rem;" id="rcal-tabs">
${TABS.map(
(tab, i) => `<button data-cal-tab="${tab.toLowerCase()}" style="
padding:0.375rem 0.875rem;
border-radius:0.5rem;
font-size:0.8rem;
font-weight:500;
border:none;
cursor:pointer;
transition:all 0.15s;
${i === 0 ? "background:rgba(99,102,241,0.15);color:#818cf8;" : "background:transparent;color:#94a3b8;"}
">${tab}</button>`,
).join("\n ")}
</div>
</div>
<!-- Day header row -->
<div class="rcal-grid rcal-grid--header">
${dayNames.map((d) => `<div class="rcal-day-header">${d}</div>`).join("\n ")}
</div>
<!-- Calendar grid -->
<div class="rcal-grid">
${calendarCells.join("\n ")}
</div>
<!-- Legend -->
<div style="display:flex;align-items:center;gap:1rem;padding:0.75rem 1.25rem;border-top:1px solid rgba(51,65,85,0.3);flex-wrap:wrap;">
<span style="font-size:0.75rem;color:#64748b;font-weight:500;">Legend:</span>
${LEGEND.map(
(l) => `<span style="display:flex;align-items:center;gap:0.375rem;font-size:0.75rem;color:#94a3b8;">
<span style="width:0.5rem;height:0.5rem;border-radius:9999px;background:${l.color};display:inline-block;"></span>
${l.label}
</span>`,
).join("\n ")}
</div>
</div>
</section>
<!-- Zoom Panel -->
<section class="rd-section rd-section--narrow">
<div class="rd-card" style="padding:1.5rem;">
<h2 style="font-size:1.125rem;font-weight:600;color:#f1f5f9;margin:0 0 1rem;display:flex;align-items:center;gap:0.5rem;">
\u{1F50D} Temporal Zoom
</h2>
<p style="font-size:0.875rem;color:#94a3b8;margin:0 0 1rem;">
Navigate across temporal granularities. The calendar grid adapts at each zoom level.
</p>
<div style="display:flex;flex-wrap:wrap;gap:0.5rem;">
${ZOOM_LEVELS.map(
(level) => {
const isActive = level === "Month";
return `<div style="
padding:0.5rem 1rem;
border-radius:0.5rem;
font-size:0.8rem;
font-weight:500;
border:1px solid ${isActive ? "rgba(99,102,241,0.4)" : "rgba(51,65,85,0.4)"};
background:${isActive ? "rgba(99,102,241,0.15)" : "rgba(30,41,59,0.5)"};
color:${isActive ? "#818cf8" : "#64748b"};
${isActive ? "box-shadow:0 0 12px rgba(99,102,241,0.2);" : ""}
">${level}${isActive ? " \u25C0" : ""}</div>`;
},
).join("\n ")}
</div>
</div>
</section>
<!-- Features -->
<section class="rd-section">
<div class="rd-grid rd-grid--2">
${FEATURES.map(
(f) => `
<div class="rd-card" style="padding:1.5rem;">
<div style="font-size:1.75rem;margin-bottom:0.75rem;">${f.icon}</div>
<h3 style="font-size:1rem;font-weight:600;color:#e2e8f0;margin:0 0 0.5rem;">${f.title}</h3>
<p style="font-size:0.875rem;color:#94a3b8;margin:0;line-height:1.5;">${f.desc}</p>
</div>`,
).join("")}
</div>
</section>
<!-- CTA -->
<section class="rd-section rd-section--narrow">
<div class="rd-cta">
<h2>Coordinate in Time & Space</h2>
<p>
rCal layers temporal zoom, spatial context, and lunar cycles into a single calendar.
Plan events that respect natural rhythms and local conditions.
</p>
<a href="/create-space" style="background:linear-gradient(135deg,#6366f1,#a78bfa);box-shadow:0 8px 24px rgba(99,102,241,0.25);">
Create Your Space
</a>
</div>
</section>
</div>
<style>
/* ── rCal demo grid ── */
.rcal-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 1px;
background: rgba(51,65,85,0.2);
padding: 0 1px 1px;
}
.rcal-grid--header {
gap: 0;
padding: 0;
background: transparent;
border-bottom: 1px solid rgba(51,65,85,0.3);
}
.rcal-day-header {
padding: 0.5rem 0;
text-align: center;
font-size: 0.75rem;
font-weight: 600;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.rcal-cell {
min-height: 3.5rem;
padding: 0.375rem;
background: rgba(15,23,42,0.6);
display: flex;
flex-direction: column;
gap: 0.25rem;
transition: background 0.15s;
}
.rcal-cell:hover {
background: rgba(30,41,59,0.8);
}
.rcal-cell--empty {
background: rgba(15,23,42,0.3);
}
.rcal-cell--trip {
background: rgba(99,102,241,0.04);
}
.rcal-cell--today {
outline: 2px solid rgba(99,102,241,0.5);
outline-offset: -2px;
background: rgba(99,102,241,0.08);
}
.rcal-cell__num {
font-size: 0.75rem;
font-weight: 500;
color: #64748b;
line-height: 1;
}
.rcal-cell__num--trip {
color: #a5b4fc;
font-weight: 600;
}
.rcal-pill {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
font-size: 0.625rem;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
}
.rcal-pill__emoji {
flex-shrink: 0;
font-size: 0.7rem;
}
.rcal-pill__label {
overflow: hidden;
text-overflow: ellipsis;
font-weight: 500;
}
/* Responsive: stack pill text on small screens */
@media (max-width: 640px) {
.rcal-cell {
min-height: 2.75rem;
padding: 0.25rem;
}
.rcal-pill__label {
display: none;
}
.rcal-pill {
justify-content: center;
padding: 0.125rem;
}
}
</style>`;
}

View File

@ -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: `<script type="module" src="/modules/rcal/cal-demo.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/rcal/cal.css">`,
}));
}
return c.html(renderShell({
title: `${space} — Calendar | rSpace`,
moduleId: "rcal",
@ -383,8 +396,8 @@ routes.get("/", (c) => {
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-calendar-view space="${space}"></folk-calendar-view>`,
scripts: `<script type="module" src="/modules/cal/folk-calendar-view.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/cal/cal.css">`,
scripts: `<script type="module" src="/modules/rcal/folk-calendar-view.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/rcal/cal.css">`,
}));
});
@ -396,6 +409,7 @@ export const calModule: RSpaceModule = {
routes,
standaloneDomain: "rcal.online",
landingPage: renderLanding,
demoPage: renderDemo,
feeds: [
{
id: "events",

View File

@ -0,0 +1,2 @@
// rCart demo — static display, no client interactivity needed
export {};

234
modules/rcart/demo.ts Normal file
View File

@ -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 &amp; 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 `
<div class="rd-root" style="--rd-accent-from: #10b981; --rd-accent-to: #2dd4bf;">
<!-- Hero -->
<section class="rd-hero">
<div style="display:inline-block;padding:0.375rem 1rem;background:rgba(16,185,129,0.1);border:1px solid rgba(16,185,129,0.2);border-radius:9999px;font-size:0.875rem;color:#6ee7b7;font-weight:500;margin-bottom:1.5rem;">
Group Shopping, Together
</div>
<h1>See how rCart works</h1>
<p class="rd-subtitle">
A community garden project where neighbors pool resources to buy everything they need together.
</p>
<div class="rd-meta">
<span>8 items</span>
<span style="color:#475569">|</span>
<span>$${totalCost.toFixed(2)} total</span>
<span style="color:#475569">|</span>
<span>${fundedCount}/${cartItems.length} funded</span>
</div>
<div class="rd-avatars">
${members
.map(
(m) =>
`<div class="rd-avatar" style="background:${m.color}" title="${m.name}">${m.name[0]}</div>`,
)
.join("\n ")}
<span class="rd-count">${members.length} members</span>
</div>
</section>
<!-- Overall Funding Progress -->
<section class="rd-section rd-section--narrow">
<div class="rd-card" style="padding:1.5rem;margin-bottom:1.5rem;">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:0.75rem;">
<div>
<h2 style="font-size:1.125rem;font-weight:600;color:#f1f5f9;margin:0 0 0.25rem;">Community Garden Project</h2>
<p class="rd-text-xs rd-text-muted" style="margin:0;">Shared cart for our neighborhood garden setup</p>
</div>
<div style="text-align:right">
<p style="font-size:1.5rem;font-weight:700;color:white;margin:0;">$${totalFunded.toFixed(2)}</p>
<p class="rd-text-xs rd-text-muted" style="margin:0;">of $${totalCost.toFixed(2)} funded</p>
</div>
</div>
<div class="rd-progress" style="margin-bottom:0.5rem;">
<div class="rd-progress__fill" style="width:${overallPct}%"></div>
</div>
<div style="display:flex;align-items:center;justify-content:space-between;">
<span class="rd-text-xs rd-text-muted">${overallPct}% funded</span>
<span class="rd-text-xs rd-text-muted">$${(totalCost - totalFunded).toFixed(2)} remaining</span>
</div>
</div>
<!-- Cart Items -->
<div class="rd-card" style="margin-bottom:1.5rem;">
<div class="rd-card-header">
<div class="rd-card-title"><span class="rd-icon">&#128722;</span> Cart Items</div>
<div style="display:flex;align-items:center;gap:0.75rem;font-size:0.75rem;color:#94a3b8;">
<span style="display:flex;align-items:center;gap:0.25rem;">
<span style="width:0.5rem;height:0.5rem;border-radius:9999px;background:#10b981;display:inline-block;"></span> Funded
</span>
<span style="display:flex;align-items:center;gap:0.25rem;">
<span style="width:0.5rem;height:0.5rem;border-radius:9999px;background:#0ea5e9;display:inline-block;"></span> In Cart
</span>
<span style="display:flex;align-items:center;gap:0.25rem;">
<span style="width:0.5rem;height:0.5rem;border-radius:9999px;background:#f59e0b;display:inline-block;"></span> Needs Funding
</span>
</div>
</div>
${cartItems
.map((item) => {
const pct = Math.round((item.funded / item.price) * 100);
const memberColor = getMemberColor(item.requestedBy);
return `
<div style="padding:1rem 1.25rem;${cartItems.indexOf(item) > 0 ? "border-top:1px solid rgba(51,65,85,0.3);" : ""}">
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:1rem;margin-bottom:0.5rem;">
<div style="min-width:0;flex:1;">
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.25rem;">
<span class="rd-text-sm rd-font-medium" style="color:#e2e8f0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${item.name}</span>
<span class="rd-badge ${statusBadgeClass(item.status)}">${item.status}</span>
</div>
<div style="display:flex;align-items:center;gap:0.5rem;font-size:0.75rem;color:#94a3b8;">
<span style="display:flex;align-items:center;gap:0.375rem;">
<span style="width:1rem;height:1rem;background:${memberColor};border-radius:9999px;display:inline-flex;align-items:center;justify-content:center;font-size:0.625rem;font-weight:700;color:white;">${item.requestedBy[0]}</span>
${item.requestedBy}
</span>
<span style="color:#475569;">requested this</span>
</div>
</div>
<div style="text-align:right;flex-shrink:0;">
<p class="rd-text-sm rd-font-semibold" style="color:#e2e8f0;margin:0;">$${item.price.toFixed(2)}</p>
${item.status !== "Funded" ? `<p class="rd-text-xs" style="color:#64748b;margin:0;">$${item.funded.toFixed(2)} funded</p>` : ""}
</div>
</div>
<div class="rd-progress rd-progress--sm">
<div class="rd-progress__fill ${progressFillClass(pct)}" style="width:${pct}%"></div>
</div>
</div>`;
})
.join("")}
</div>
<!-- Summary Grid -->
<div class="rd-grid rd-grid--3" style="margin-bottom:1.5rem;">
<div class="rd-stat">
<p class="rd-text-xs rd-font-semibold rd-text-muted" style="text-transform:uppercase;letter-spacing:0.05em;margin:0 0 0.5rem;">Total Cost</p>
<p class="rd-stat__value">$${totalCost.toFixed(2)}</p>
<p class="rd-stat__sub">${cartItems.length} items across ${uniqueRequesters} requesters</p>
</div>
<div class="rd-stat">
<p class="rd-text-xs rd-font-semibold rd-text-muted" style="text-transform:uppercase;letter-spacing:0.05em;margin:0 0 0.5rem;">Amount Funded</p>
<p class="rd-stat__value" style="color:#34d399;">$${totalFunded.toFixed(2)}</p>
<p class="rd-stat__sub">${fundedCount} of ${cartItems.length} items fully funded</p>
</div>
<div class="rd-stat">
<p class="rd-text-xs rd-font-semibold rd-text-muted" style="text-transform:uppercase;letter-spacing:0.05em;margin:0 0 0.5rem;">Per-Person Split</p>
<p class="rd-stat__value" style="color:#2dd4bf;">$${perPerson.toFixed(2)}</p>
<p class="rd-stat__sub">split equally among ${members.length} members</p>
</div>
</div>
<!-- Member Activity -->
<div class="rd-card" style="padding:1.5rem;">
<h3 class="rd-text-sm rd-font-semibold" style="color:#cbd5e1;margin:0 0 1rem;">Member Activity</h3>
<div class="rd-grid rd-grid--2">
${members
.map((member) => {
const requested = cartItems.filter((i) => i.requestedBy === member.name);
const requestedTotal = requested.reduce((sum, i) => sum + i.price, 0);
return `
<div style="display:flex;align-items:center;gap:0.75rem;background:rgba(51,65,85,0.2);border-radius:0.75rem;padding:0.75rem;">
<div class="rd-avatar" style="background:${member.color};flex-shrink:0;width:2.5rem;height:2.5rem;font-size:0.875rem;">${member.name[0]}</div>
<div style="min-width:0;flex:1;">
<p class="rd-text-sm rd-font-medium" style="color:#e2e8f0;margin:0;">${member.name}</p>
<p class="rd-text-xs rd-text-muted" style="margin:0;">
${requested.length} item${requested.length !== 1 ? "s" : ""} requested
<span style="color:#475569;margin:0 0.375rem;">&middot;</span>
$${requestedTotal.toFixed(2)} total
</p>
</div>
<div style="text-align:right;flex-shrink:0;">
<p class="rd-text-sm rd-font-medium" style="color:#cbd5e1;margin:0;">$${perPerson.toFixed(2)}</p>
<p class="rd-text-xs" style="color:#64748b;margin:0;">share</p>
</div>
</div>`;
})
.join("")}
</div>
</div>
</section>
<!-- CTA -->
<section class="rd-section rd-section--narrow">
<div class="rd-cta">
<h2>Ready to shop together?</h2>
<p>
Create a shared cart for your group, community, or team. Add items from any store,
split costs fairly, and check out together.
</p>
<a href="/create-space" style="background:linear-gradient(135deg, #10b981, #059669);box-shadow:0 8px 24px rgba(16,185,129,0.25);">
Create Your First Cart
</a>
</div>
</section>
</div>`;
}

View File

@ -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: `<script type="module" src="/modules/rcart/cart-demo.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/rcart/cart.css">`,
}));
}
return c.html(renderShell({
title: `Shop | rSpace`,
moduleId: "rcart",
@ -449,8 +462,8 @@ routes.get("/", (c) => {
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-cart-shop space="${space}"></folk-cart-shop>`,
scripts: `<script type="module" src="/modules/cart/folk-cart-shop.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/cart/cart.css">`,
scripts: `<script type="module" src="/modules/rcart/folk-cart-shop.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/rcart/cart.css">`,
}));
});
@ -462,6 +475,7 @@ export const cartModule: RSpaceModule = {
routes,
standaloneDomain: "rcart.online",
landingPage: renderLanding,
demoPage: renderDemo,
feeds: [
{
id: "orders",

View File

@ -56,8 +56,8 @@ routes.get("/", (c) => {
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-choices-dashboard space="${spaceSlug}"></folk-choices-dashboard>`,
scripts: `<script type="module" src="/modules/choices/folk-choices-dashboard.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/choices/choices.css">`,
scripts: `<script type="module" src="/modules/rchoices/folk-choices-dashboard.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/rchoices/choices.css">`,
}));
});

View File

@ -128,8 +128,8 @@ routes.get("/", (c) => {
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-analytics-view space="${space}"></folk-analytics-view>`,
scripts: `<script type="module" src="/modules/data/folk-analytics-view.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/data/data.css">`,
scripts: `<script type="module" src="/modules/rdata/folk-analytics-view.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/rdata/data.css">`,
}));
});

View File

@ -374,8 +374,8 @@ routes.get("/", (c) => {
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-file-browser space="${spaceSlug}"></folk-file-browser>`,
scripts: `<script type="module" src="/modules/files/folk-file-browser.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/files/files.css">`,
scripts: `<script type="module" src="/modules/rfiles/folk-file-browser.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/rfiles/files.css">`,
}));
});

View File

@ -165,8 +165,8 @@ routes.get("/", (c) => {
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-forum-dashboard space="${spaceSlug}"></folk-forum-dashboard>`,
scripts: `<script type="module" src="/modules/forum/folk-forum-dashboard.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/forum/forum.css">`,
scripts: `<script type="module" src="/modules/rforum/folk-forum-dashboard.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/rforum/forum.css">`,
}));
});

View File

@ -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() {

View File

@ -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<string, string> = {
Maya: "#10b981",
Liam: "#06b6d4",
Priya: "#8b5cf6",
Omar: "#f59e0b",
};
const MEMBER_BG: Record<string, string> = {
Maya: "rd-bg-emerald",
Liam: "rd-bg-cyan",
Priya: "rd-bg-violet",
Omar: "rd-bg-amber",
};
const CATEGORY_ICONS: Record<string, string> = {
transport: "\u{1F682}",
accommodation: "\u{1F3E8}",
activity: "\u26F7",
food: "\u{1F372}",
};
const CATEGORY_LABELS: Record<string, string> = {
transport: "Transport",
accommodation: "Accommodation",
activity: "Activities",
food: "Food & Drink",
};
const CATEGORY_TEXT_CLASS: Record<string, string> = {
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
// ── 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<string, number> = {};
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<string, DemoShape> = 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<string, number> = {};
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<HTMLElement>("[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 `<div class="rd-item" data-expense-id="${esc(ex.id)}">
<div style="width:2.5rem; height:2.5rem; border-radius:0.75rem; display:flex; align-items:center; justify-content:center; font-size:1.125rem; background:rgba(51,65,85,0.4); flex-shrink:0;">
${catIcon}
</div>
<div style="flex:1; min-width:0;">
<p style="font-size:0.875rem; color:#e2e8f0; font-weight:500; margin:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">${esc(ex.description)}</p>
<div style="display:flex; align-items:center; gap:0.375rem; font-size:0.75rem; color:#64748b; margin-top:0.125rem; flex-wrap:wrap;">
<span class="${catTextClass}">${catLabel}</span>
<span>\u00B7</span>
<span>Paid by ${esc(ex.paidBy)}</span>
<span>\u00B7</span>
<span>${ex.split === "equal" ? `Split ${MEMBERS.length} ways` : "Custom split"}</span>
<span>\u00B7</span>
<span>${formatDate(ex.date)}</span>
</div>
</div>
<button class="rd-expense-amount" data-edit-expense="${esc(ex.id)}" data-amount="${ex.amount}" style="background:none; border:none; cursor:pointer; text-align:right; padding:0.25rem 0.5rem; border-radius:0.5rem; transition:background 0.15s; flex-shrink:0;" title="Click to edit amount">
<span style="font-size:0.875rem; font-weight:600; color:#e2e8f0; display:block;">${fmt(ex.amount)}</span>
<span style="font-size:0.75rem; color:#64748b; display:block;">${perPerson}/person</span>
</button>
</div>`;
})
.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 `<div style="display:flex; align-items:center; gap:0.75rem; margin-bottom:0.75rem;">
<div class="rd-avatar ${bgClass}" style="width:2rem; height:2rem; font-size:0.75rem;">${initial}</div>
<div style="flex:1; min-width:0;">
<p style="font-size:0.875rem; color:#e2e8f0; margin:0;">${esc(b.name)}</p>
<p style="font-size:0.75rem; color:#64748b; margin:0;">Paid ${fmt(b.paid)}</p>
</div>
<span style="font-size:0.875rem; font-weight:600;" class="${balanceColor}">${balanceStr}</span>
</div>`;
})
.join("");
// ── Update Settlements ──
if (settlements.length > 0) {
settlementsBody.innerHTML =
settlements
.map(
(s) => `<div style="display:flex; align-items:center; gap:0.5rem; background:rgba(51,65,85,0.3); border-radius:0.75rem; padding:0.75rem; margin-bottom:0.5rem;">
<span style="font-size:0.875rem; font-weight:500; color:#fb7185;">${esc(s.from)}</span>
<span style="flex:1; text-align:center;">
<span style="font-size:0.75rem; color:#64748b;">\u2192</span>
<span style="display:block; font-size:0.875rem; font-weight:600; color:white;">${fmt(s.amount)}</span>
</span>
<span style="font-size:0.875rem; font-weight:500; color:#34d399;">${esc(s.to)}</span>
</div>`,
)
.join("") +
`<p style="font-size:0.75rem; color:#64748b; text-align:center; margin:0.5rem 0 0;">
${settlements.length} payment${settlements.length !== 1 ? "s" : ""} to settle all debts
</p>`;
} else {
settlementsBody.innerHTML = `<p style="font-size:0.875rem; color:#64748b; text-align:center;">All settled up!</p>`;
}
// ── 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<HTMLElement>(`[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<HTMLElement>(`[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<HTMLElement>("[data-edit-expense]");
if (amountBtn && !editingExpenseId) {
const expenseId = amountBtn.dataset.editExpense!;
const currentAmount = parseFloat(amountBtn.dataset.amount!);
editingExpenseId = expenseId;
amountBtn.innerHTML = `
<div style="display:flex; align-items:center; gap:0.25rem;">
<span style="font-size:0.875rem; color:#94a3b8;">\u20AC</span>
<input type="number" value="${currentAmount}" step="0.01" min="0"
style="width:5rem; background:#334155; border:1px solid rgba(16,185,129,0.5); border-radius:0.5rem; padding:0.25rem 0.5rem; font-size:0.875rem; color:white; text-align:right; outline:none;"
id="rd-edit-input" autofocus>
<button id="rd-edit-save" style="background:rgba(16,185,129,0.1); color:#34d399; border:none; cursor:pointer; font-size:0.75rem; padding:0.125rem 0.375rem; border-radius:0.25rem;">\u2713</button>
<button id="rd-edit-cancel" style="background:rgba(51,65,85,0.5); color:#94a3b8; border:none; cursor:pointer; font-size:0.75rem; padding:0.125rem 0.375rem; border-radius:0.25rem;">\u2717</button>
</div>`;
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();

256
modules/rfunds/demo.ts Normal file
View File

@ -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 `
<div class="rd-root" style="--rd-accent-from: #f59e0b; --rd-accent-to: #10b981;">
<!-- Hero -->
<section class="rd-hero">
<h1>Alpine Explorer 2026</h1>
<p class="rd-subtitle">Group Expenses</p>
<div class="rd-meta">
<span>\u{1F4C5} Jul 6-20, 2026</span>
<span style="color:#475569">|</span>
<span>\u{1F465} ${MEMBERS.length} travelers</span>
<span style="color:#475569">|</span>
<span>\u{1F3D4} Chamonix \u2192 Zermatt \u2192 Dolomites</span>
</div>
<div class="rd-avatars">
${MEMBERS.map(
(m) =>
`<div class="rd-avatar ${m.bgClass}" title="${m.name}">${m.initial}</div>`,
).join("\n ")}
<span class="rd-count">${MEMBERS.length} members</span>
</div>
</section>
<!-- Status bar -->
<div class="rd-section rd-section--narrow">
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:1.5rem; flex-wrap:wrap; gap:0.75rem">
<div style="display:flex; align-items:center; gap:0.75rem; flex-wrap:wrap">
<span id="rd-conn-badge" class="rd-status rd-status--disconnected">Disconnected</span>
<span class="rd-badge rd-badge--amber" style="font-size:0.7rem">Live &mdash; synced across all r* demos</span>
</div>
<button id="rd-reset-btn" class="rd-btn rd-btn--ghost" disabled>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg>
Reset Demo
</button>
</div>
</div>
<!-- Loading state -->
<div class="rd-section rd-section--narrow">
<div id="rd-loading" class="rd-card" style="border:2px dashed rgba(100,116,139,0.4); display:none">
<div class="rd-card-body" style="padding:3rem; text-align:center">
<div style="width:2rem; height:2rem; margin:0 auto 0.75rem; border:3px solid rgba(245,158,11,0.2); border-top-color:#f59e0b; border-radius:50%; animation:rd-spin 0.8s linear infinite"></div>
<p class="rd-text-muted">Connecting to rSpace...</p>
</div>
</div>
<style>@keyframes rd-spin { to { transform: rotate(360deg); } }</style>
<!-- Empty state -->
<div id="rd-empty" class="rd-card" style="border:2px dashed rgba(100,116,139,0.4); display:none">
<div class="rd-card-body" style="padding:3rem; text-align:center">
<div style="font-size:2rem; margin-bottom:0.75rem">\u{1F4B0}</div>
<p class="rd-text-muted">No expense data found. Try resetting the demo.</p>
</div>
</div>
</div>
<!-- Budget Overview -->
<section id="rd-budget-section" class="rd-section rd-section--narrow" style="display:none">
<div class="rd-card" style="padding:1.5rem; margin-bottom:1.5rem;">
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:1rem;">
<h2 style="font-size:1.125rem; font-weight:600; color:#f1f5f9; margin:0; display:flex; align-items:center; gap:0.5rem;">
\u{1F4CA} Trip Budget
</h2>
<span class="rd-live">live</span>
</div>
<!-- Budget totals: 3-column stat grid -->
<div class="rd-grid rd-grid--3" style="margin-bottom:1rem;">
<div class="rd-stat">
<p class="rd-stat__value" id="rd-budget-total">\u20AC4,000</p>
<p class="rd-stat__label">Total Budget</p>
</div>
<div class="rd-stat">
<p class="rd-stat__value rd-emerald" id="rd-budget-spent">\u20AC0</p>
<p class="rd-stat__label">Spent</p>
</div>
<div class="rd-stat">
<p class="rd-stat__value rd-cyan" id="rd-budget-remaining">\u20AC4,000</p>
<p class="rd-stat__label" id="rd-budget-remaining-label">Remaining</p>
</div>
</div>
<!-- Progress bar -->
<div style="margin-bottom:1rem;">
<div style="display:flex; align-items:center; justify-content:space-between; font-size:0.75rem; color:#94a3b8; margin-bottom:0.375rem;">
<span id="rd-budget-pct-label">0% used</span>
<span id="rd-budget-left-label">\u20AC4,000 left</span>
</div>
<div class="rd-progress">
<div class="rd-progress__fill rd-progress__fill--emerald" id="rd-budget-bar" style="width:0%"></div>
</div>
</div>
<!-- Category breakdown -->
<div>
<h3 style="font-size:0.875rem; font-weight:600; color:#cbd5e1; margin:0 0 0.75rem;">Budget by Category</h3>
<div class="rd-grid rd-grid--2" id="rd-category-breakdown">
${CATEGORIES.map(
(cat) => `
<div class="rd-stat" style="padding:0.75rem;" data-category="${cat.key}">
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:0.5rem;">
<span style="display:flex; align-items:center; gap:0.5rem; font-size:0.875rem; color:#e2e8f0;">
${cat.icon} ${cat.label}
</span>
<span class="rd-text-xs rd-text-muted" data-cat-amounts>\u20AC0 / \u20AC1,000</span>
</div>
<div class="rd-progress rd-progress--sm">
<div class="${cat.colorClass}" style="height:100%; border-radius:9999px; width:0%; transition:width 0.3s" data-cat-bar></div>
</div>
<p class="rd-text-xs rd-text-dim" style="margin:0.25rem 0 0;" data-cat-pct>0% used</p>
</div>`,
).join("")}
</div>
</div>
</div>
</section>
<!-- Expenses + Balances grid -->
<section id="rd-expenses-section" class="rd-section" style="display:none">
<div style="display:grid; grid-template-columns:1fr; gap:1rem;">
<!-- Expense list (left 2/3 on desktop) -->
<div class="rd-card" id="rd-expense-card" style="grid-column:1;">
<div class="rd-card-header">
<div class="rd-card-title"><span class="rd-icon">\u{1F4DD}</span> <span id="rd-expense-count">Expenses (0)</span></div>
<span class="rd-text-xs rd-text-muted">Click amount to edit</span>
</div>
<div id="rd-expense-list">
<!-- Populated by funds-demo.ts -->
</div>
<!-- Total row -->
<div style="display:flex; align-items:center; justify-content:space-between; padding:0.75rem 1.25rem; border-top:1px solid rgba(51,65,85,0.5); background:rgba(51,65,85,0.2);">
<span style="font-size:0.875rem; font-weight:600; color:#cbd5e1;">Total</span>
<span style="font-size:1.125rem; font-weight:700; color:white;" id="rd-expense-total">\u20AC0</span>
</div>
</div>
<!-- Balances (right 1/3 on desktop) -->
<div style="display:flex; flex-direction:column; gap:1rem;">
<div class="rd-card" id="rd-balances">
<div class="rd-card-header">
<div class="rd-card-title"><span class="rd-icon">\u2696\uFE0F</span> Balances</div>
</div>
<div class="rd-card-body" id="rd-balances-body">
<!-- Populated by funds-demo.ts -->
</div>
</div>
<div class="rd-card" id="rd-settlements">
<div class="rd-card-header">
<div class="rd-card-title"><span class="rd-icon">\u{1F4B8}</span> Settle Up</div>
</div>
<div class="rd-card-body" id="rd-settlements-body">
<!-- Populated by funds-demo.ts -->
</div>
</div>
</div>
</div>
</section>
<!-- Spending by Category -->
<section id="rd-spending-section" class="rd-section" style="display:none">
<div class="rd-card" style="padding:1.5rem;">
<h2 style="font-size:1.125rem; font-weight:600; margin:0 0 1rem; display:flex; align-items:center; gap:0.5rem;">
\u{1F4CA} Spending by Category
</h2>
<div class="rd-grid rd-grid--4" id="rd-spending-grid">
${CATEGORIES.map(
(cat) => `
<div class="rd-stat" data-spending-cat="${cat.key}">
<div style="font-size:1.5rem; margin-bottom:0.5rem;">${cat.icon}</div>
<p style="font-size:0.875rem; color:#cbd5e1; font-weight:500; margin:0;">${cat.label}</p>
<p style="font-size:1.125rem; font-weight:700; color:white; margin:0.25rem 0;" data-spending-amount>\u20AC0</p>
<div class="rd-progress rd-progress--xs" style="margin-bottom:0.25rem;">
<div class="${cat.colorClass}" style="height:100%; border-radius:9999px; width:0%; transition:width 0.3s" data-spending-bar></div>
</div>
<p class="rd-text-xs rd-text-dim" style="margin:0;" data-spending-pct>0% of total</p>
</div>`,
).join("")}
</div>
</div>
</section>
<!-- Per Person -->
<section id="rd-person-section" class="rd-section" style="display:none">
<div class="rd-card" style="padding:1.5rem;">
<h2 style="font-size:1.125rem; font-weight:600; margin:0 0 1rem; display:flex; align-items:center; gap:0.5rem;">
\u{1F464} Per Person
</h2>
<div class="rd-grid rd-grid--4" id="rd-person-grid">
${MEMBERS.map(
(m) => `
<div class="rd-stat" data-person="${m.name}">
<div class="rd-avatar ${m.bgClass}" style="width:3rem; height:3rem; font-size:1.125rem; margin:0 auto 0.5rem; box-shadow:0 0 0 2px #1e293b;">${m.initial}</div>
<p style="font-size:0.875rem; color:#cbd5e1; font-weight:500; margin:0;">${m.name}</p>
<p style="font-size:1.125rem; font-weight:700; color:white; margin:0.25rem 0;" data-person-paid>\u20AC0</p>
<p class="rd-text-xs rd-text-dim" style="margin:0;" data-person-pct>paid (0%)</p>
<p style="font-size:0.875rem; font-weight:600; margin:0.25rem 0 0;" data-person-balance>\u20AC0</p>
</div>`,
).join("")}
</div>
</div>
</section>
<!-- CTA -->
<section class="rd-section rd-section--narrow">
<div class="rd-cta">
<h2>Track Your Group Expenses</h2>
<p>
rFunds makes it easy to manage shared costs, track budgets, and settle up.
Design custom funding flows with threshold-based mechanisms.
</p>
<a href="/create-space" style="background:linear-gradient(135deg, #f59e0b, #10b981); box-shadow:0 8px 24px rgba(245,158,11,0.25);">
Create Your Space
</a>
</div>
</section>
</div>
<style>
/* Responsive grid for expenses + balances on desktop */
@media (min-width: 768px) {
#rd-expenses-section > div {
grid-template-columns: 2fr 1fr;
}
}
</style>`;
}

View File

@ -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 = `
<script type="module" src="/modules/funds/folk-funds-app.js"></script>
<script type="module" src="/modules/funds/folk-budget-river.js"></script>`;
<script type="module" src="/modules/rfunds/folk-funds-app.js"></script>
<script type="module" src="/modules/rfunds/folk-budget-river.js"></script>`;
const fundsStyles = `<link rel="stylesheet" href="/modules/funds/funds.css">`;
const fundsStyles = `<link rel="stylesheet" href="/modules/rfunds/funds.css">`;
// 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: `<script type="module" src="/lib/demo-sync.js"></script>
<script type="module" src="/modules/rfunds/funds-demo.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/rfunds/funds.css">`,
}));
}
return c.html(renderShell({
title: `rFunds — TBFF Flow Funding | rSpace`,
moduleId: "rfunds",
@ -204,8 +218,8 @@ routes.get("/", (c) => {
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-funds-app space="${spaceSlug}"></folk-funds-app>`,
scripts: `<script type="module" src="/modules/funds/folk-funds-app.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/funds/funds.css">`,
scripts: `<script type="module" src="/modules/rfunds/folk-funds-app.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/rfunds/funds.css">`,
}));
});
@ -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: [
{

View File

@ -538,8 +538,8 @@ routes.get("/", (c) => {
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-inbox-client space="${space}"></folk-inbox-client>`,
scripts: `<script type="module" src="/modules/inbox/folk-inbox-client.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/inbox/inbox.css">`,
scripts: `<script type="module" src="/modules/rinbox/folk-inbox-client.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/rinbox/inbox.css">`,
}));
});

View File

@ -141,8 +141,8 @@ routes.get("/", (c) => {
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-map-viewer space="${space}"></folk-map-viewer>`,
scripts: `<script type="module" src="/modules/maps/folk-map-viewer.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/maps/maps.css">`,
scripts: `<script type="module" src="/modules/rmaps/folk-map-viewer.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/rmaps/maps.css">`,
}));
});
@ -156,9 +156,9 @@ routes.get("/:room", (c) => {
spaceSlug: space,
modules: getModuleInfoList(),
theme: "dark",
styles: `<link rel="stylesheet" href="/modules/maps/maps.css">`,
styles: `<link rel="stylesheet" href="/modules/rmaps/maps.css">`,
body: `<folk-map-viewer space="${space}" room="${room}"></folk-map-viewer>`,
scripts: `<script type="module" src="/modules/maps/folk-map-viewer.js"></script>`,
scripts: `<script type="module" src="/modules/rmaps/folk-map-viewer.js"></script>`,
}));
});

View File

@ -224,8 +224,8 @@ routes.get("/", (c) => {
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-graph-viewer space="${space}"></folk-graph-viewer>`,
scripts: `<script type="module" src="/modules/network/folk-graph-viewer.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/network/network.css">`,
scripts: `<script type="module" src="/modules/rnetwork/folk-graph-viewer.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/rnetwork/network.css">`,
}));
});

View File

@ -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<string, DemoShape>, type: string): DemoShape[] {
return Object.values(shapes).filter((s) => s.type === type);
}
function shapeByType(shapes: Record<string, DemoShape>, 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" ? "</ul>" : "</ol>"); inList = null; }
}
function flushCode() {
if (inCodeBlock) {
const escaped = codeLines.join("\n").replace(/</g, "&lt;").replace(/>/g, "&gt;");
out.push(`<div class="rd-md-codeblock">${codeLang ? `<div class="rd-md-codeblock-lang"><span>${codeLang}</span></div>` : ""}<pre>${escaped}</pre></div>`);
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(`<h3>${inlineFormat(line.slice(4))}</h3>`); continue; }
if (line.startsWith("## ")) { flushList(); out.push(`<h3>${inlineFormat(line.slice(3))}</h3>`); continue; }
if (line.startsWith("# ")) { flushList(); out.push(`<h3>${inlineFormat(line.slice(2))}</h3>`); continue; }
if (line.startsWith("#### ")) { flushList(); out.push(`<h4>${inlineFormat(line.slice(5))}</h4>`); continue; }
if (line.startsWith("##### ")) { flushList(); out.push(`<h5>${inlineFormat(line.slice(6))}</h5>`); continue; }
// Blockquote
if (line.startsWith("> ")) { flushList(); out.push(`<div class="rd-md-quote"><p>${inlineFormat(line.slice(2))}</p></div>`); continue; }
// Unordered list
const ulMatch = line.match(/^[-*]\s+(.+)/);
if (ulMatch) {
if (inList !== "ul") { flushList(); out.push("<ul>"); inList = "ul"; }
out.push(`<li>${inlineFormat(ulMatch[1])}</li>`);
continue;
}
// Ordered list
const olMatch = line.match(/^(\d+)\.\s+(.+)/);
if (olMatch) {
if (inList !== "ol") { flushList(); out.push("<ol>"); inList = "ol"; }
out.push(`<li><span class="rd-md-num">${olMatch[1]}.</span>${inlineFormat(olMatch[2])}</li>`);
continue;
}
// Paragraph
flushList();
out.push(`<p>${inlineFormat(line)}</p>`);
}
flushCode();
flushList();
return out.join("\n");
}
function inlineFormat(text: string): string {
return text
.replace(/`([^`]+)`/g, "<code>$1</code>")
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
.replace(/\*([^*]+)\*/g, "<em>$1</em>");
}
// ── Note card rendering ──
const TAG_COLORS: Record<string, string> = {
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 `
<div class="rd-card rd-note-card ${expanded ? "rd-note-card--expanded" : ""}" data-note-id="${note.id}" style="cursor:pointer;">
<div style="padding:1rem 1.25rem;">
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:1rem;margin-bottom:0.5rem;">
<h3 style="font-size:0.9375rem;font-weight:600;color:white;margin:0;">${escHtml(title)}</h3>
${synced ? `<span class="rd-synced-badge"><span style="width:6px;height:6px;border-radius:50%;background:#2dd4bf;"></span>synced</span>` : ""}
</div>
${expanded
? `<div class="rd-md" style="margin-top:0.75rem;">${renderMarkdown(content)}</div>`
: `<p style="font-size:0.8125rem;color:#94a3b8;margin:0 0 0.75rem;line-height:1.5;">${escHtml(previewText)}${content.length > 120 ? "..." : ""}</p>`
}
<div style="display:flex;align-items:center;justify-content:space-between;margin-top:0.75rem;">
<div style="display:flex;flex-wrap:wrap;gap:0.375rem;">
${tags.map((t) => `<span class="rd-note-tag" style="background:${TAG_COLORS[t] || "rgba(51,65,85,0.5)"}">${escHtml(t)}</span>`).join("")}
</div>
${lastEdited ? `<span style="font-size:0.6875rem;color:#64748b;">${formatRelative(lastEdited)}</span>` : ""}
</div>
</div>
</div>`;
}
function escHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
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<string, typeof items> = {};
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 = `
<div class="rd-card" style="overflow:hidden;">
<div style="padding:0.75rem 1rem;border-bottom:1px solid rgba(51,65,85,0.5);display:flex;align-items:center;justify-content:space-between;">
<span style="font-size:0.8125rem;font-weight:600;color:white;">Packing Checklist</span>
<span style="font-size:0.75rem;color:#94a3b8;">${checked}/${items.length} packed (${pct}%)</span>
</div>
<div style="padding:0.75rem 1rem 0.25rem;">
<div style="height:0.375rem;background:rgba(51,65,85,0.5);border-radius:9999px;overflow:hidden;margin-bottom:0.75rem;">
<div style="height:100%;width:${pct}%;background:linear-gradient(90deg,#f59e0b,#fb923c);border-radius:9999px;transition:width 0.3s;"></div>
</div>
</div>`;
for (const [cat, catItems] of Object.entries(groups)) {
html += `<div style="padding:0 0.75rem 0.75rem;">
<h4 style="font-size:0.6875rem;font-weight:600;color:#64748b;text-transform:uppercase;letter-spacing:0.05em;margin:0.5rem 0 0.25rem;">${escHtml(cat)}</h4>`;
for (let i = 0; i < catItems.length; i++) {
const item = catItems[i];
const globalIdx = items.indexOf(item);
html += `
<div class="rd-pack-item" data-pack-idx="${globalIdx}">
<div class="rd-pack-check ${item.packed ? "rd-pack-check--checked" : ""}">
${item.packed ? `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M5 13l4 4L19 7"/></svg>` : ""}
</div>
<span style="font-size:0.8125rem;${item.packed ? "color:#64748b;text-decoration:line-through;" : "color:#e2e8f0;"}">${escHtml(item.name)}</span>
</div>`;
}
html += `</div>`;
}
html += `</div>`;
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) =>
`<div class="rd-avatar" style="background:${AVATAR_COLORS[i % AVATAR_COLORS.length]}" title="${escHtml(name)}">${name[0]}</div>`
).join("") + `<span style="font-size:0.75rem;color:#94a3b8;margin-left:0.25rem;">${members.length} collaborators</span>`;
}
// ── Main ──
const expandedNotes = new Set<string>();
const sync = new DemoSync({ filter: ["folk-notebook", "folk-note", "folk-packing-list"] });
function render(shapes: Record<string, DemoShape>) {
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<HTMLElement>("[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<HTMLElement>("[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();

360
modules/rnotes/demo.ts Normal file
View File

@ -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 `
<div class="rd-root" style="--rd-accent-from:#f59e0b; --rd-accent-to:#fb923c">
<!-- Hero -->
<section class="rd-hero">
<div style="display:inline-flex;align-items:center;gap:0.5rem;padding:0.375rem 1rem;background:rgba(245,158,11,0.1);border:1px solid rgba(245,158,11,0.2);border-radius:9999px;font-size:0.875rem;color:#fbbf24;font-weight:500;margin-bottom:1.5rem;">
<span id="rd-hero-dot" style="width:0.5rem;height:0.5rem;border-radius:9999px;background:#f59e0b;animation:rd-pulse 2s ease-in-out infinite;"></span>
<span id="rd-hero-label">Interactive Demo</span>
</div>
<h1>See how rNotes works</h1>
<p class="rd-subtitle">A collaborative knowledge base for your team</p>
<div class="rd-meta">
<span>Live transcription</span>
<span>Audio &amp; video</span>
<span>Organized notebooks</span>
<span>Canvas sync</span>
<span>Real-time collaboration</span>
</div>
<div class="rd-avatars" id="rd-avatars">
<div class="rd-avatar" style="background:#14b8a6" title="...">...</div>
</div>
</section>
<!-- Context bar + Reset -->
<section class="rd-section rd-section--narrow">
<div style="display:flex;flex-wrap:wrap;align-items:center;justify-content:center;gap:1rem;margin-bottom:1.5rem;">
<p style="text-align:center;font-size:0.875rem;color:#94a3b8;max-width:40rem;margin:0;">
This demo shows a <span style="color:#e2e8f0;font-weight:500">Trip Planning Notebook</span> scenario
with notes, a packing list, tags, and canvas sync &mdash; all powered by the
<span style="color:#e2e8f0;font-weight:500">r* ecosystem</span> with live data from
<span style="color:#e2e8f0;font-weight:500">rSpace</span>.
</p>
<button id="rd-reset-btn" class="rd-btn rd-btn--ghost" disabled>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg>
Reset Demo
</button>
</div>
</section>
<!-- Notebook header card -->
<section class="rd-section rd-section--narrow">
<div class="rd-card" id="rd-notebook-header" style="margin-bottom:1.5rem;">
<div class="rd-card-header">
<div class="rd-card-title">
<span class="rd-icon" style="font-size:1.25rem;">&#128211;</span>
<span id="rd-nb-title">Loading...</span>
<span id="rd-nb-count" class="rd-text-xs rd-text-muted" style="margin-left:0.5rem;"></span>
</div>
<a href="https://rnotes.online" target="_blank" rel="noopener noreferrer" class="rd-card-header rd-open-link">Open in rNotes</a>
</div>
<div class="rd-card-body" style="padding:0.75rem 1.25rem;">
<p id="rd-nb-desc" class="rd-text-sm rd-text-muted" style="margin:0;">Loading notebook data...</p>
</div>
</div>
<!-- Main layout: sidebar + content -->
<div style="display:grid;grid-template-columns:1fr;gap:1.5rem;" class="rd-notes-layout">
<!-- Sidebar -->
<div id="rd-sidebar">
<div class="rd-card" style="overflow:hidden;">
<!-- Sidebar header -->
<div style="padding:0.75rem 1rem;border-bottom:1px solid rgba(51,65,85,0.5);display:flex;align-items:center;justify-content:space-between;">
<span style="font-size:0.75rem;font-weight:600;color:#94a3b8;text-transform:uppercase;letter-spacing:0.05em;">Notebook</span>
<span id="rd-sb-note-count" style="font-size:0.75rem;color:#64748b;">0 notes</span>
</div>
<!-- Active notebook tree -->
<div style="padding:0.5rem;">
<div style="margin-bottom:0.25rem;">
<div style="display:flex;align-items:center;gap:0.5rem;padding:0.5rem 0.75rem;border-radius:0.5rem;font-size:0.875rem;background:rgba(245,158,11,0.1);color:#fcd34d;">
<span>&#128211;</span>
<span id="rd-sb-nb-title" style="font-weight:500;">Loading...</span>
</div>
<div style="margin-left:1rem;margin-top:0.125rem;">
<div style="display:flex;align-items:center;justify-content:space-between;padding:0.375rem 0.75rem;border-radius:0.375rem;font-size:0.75rem;background:rgba(51,65,85,0.4);color:white;">
<span>Notes</span>
<span id="rd-sb-notes-num" style="color:#475569">0</span>
</div>
<div style="display:flex;align-items:center;justify-content:space-between;padding:0.375rem 0.75rem;border-radius:0.375rem;font-size:0.75rem;color:#64748b;margin-top:0.125rem;cursor:pointer;" onmouseover="this.style.background='rgba(51,65,85,0.2)';this.style.color='#cbd5e1'" onmouseout="this.style.background='';this.style.color='#64748b'">
<span>Packing List</span>
<span style="color:#475569">1</span>
</div>
</div>
</div>
</div>
<!-- Quick info links -->
<div style="padding:0.75rem 1rem;border-top:1px solid rgba(51,65,85,0.5);display:flex;flex-direction:column;gap:0.5rem;">
<div style="display:flex;align-items:center;gap:0.5rem;font-size:0.75rem;color:#64748b;">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
<span>Search notes...</span>
</div>
<div style="display:flex;align-items:center;gap:0.5rem;font-size:0.75rem;color:#64748b;">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/></svg>
<span>Browse tags</span>
</div>
<div style="display:flex;align-items:center;gap:0.5rem;font-size:0.75rem;color:#64748b;">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
<span>Recent edits</span>
</div>
</div>
</div>
</div>
<!-- Notes + Packing list -->
<div>
<!-- Notes section -->
<div id="rd-notes-section">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1rem;">
<div style="display:flex;align-items:center;gap:0.5rem;">
<h2 style="font-size:0.875rem;font-weight:600;color:#cbd5e1;margin:0;">Notes</h2>
<span id="rd-notes-count" class="rd-text-xs rd-text-muted">0 notes</span>
</div>
<span class="rd-text-xs rd-text-muted">Sort: Recently edited</span>
</div>
<!-- Loading skeleton -->
<div id="rd-loading" style="display:flex;flex-direction:column;gap:1rem;">
${[1, 2, 3]
.map(
() => `
<div class="rd-card" style="padding:1rem;">
<div style="height:1rem;background:rgba(51,65,85,0.5);border-radius:0.25rem;width:66%;margin-bottom:0.75rem;animation:rd-skeleton-pulse 1.5s ease-in-out infinite;"></div>
<div style="height:0.75rem;background:rgba(51,65,85,0.3);border-radius:0.25rem;width:100%;margin-bottom:0.5rem;animation:rd-skeleton-pulse 1.5s ease-in-out infinite;"></div>
<div style="height:0.75rem;background:rgba(51,65,85,0.3);border-radius:0.25rem;width:80%;margin-bottom:0.75rem;animation:rd-skeleton-pulse 1.5s ease-in-out infinite;"></div>
<div style="display:flex;gap:0.5rem;">
<div style="height:1.25rem;background:rgba(51,65,85,0.3);border-radius:0.25rem;width:4rem;animation:rd-skeleton-pulse 1.5s ease-in-out infinite;"></div>
<div style="height:1.25rem;background:rgba(51,65,85,0.3);border-radius:0.25rem;width:5rem;animation:rd-skeleton-pulse 1.5s ease-in-out infinite;"></div>
</div>
</div>`,
)
.join("")}
</div>
<style>
@keyframes rd-skeleton-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
</style>
<!-- Note cards container (populated by notes-demo.ts) -->
<div id="rd-notes-container" style="display:flex;flex-direction:column;gap:1rem;"></div>
<!-- Empty state -->
<div id="rd-notes-empty" class="rd-card" style="display:none;padding:2rem;text-align:center;">
<p class="rd-text-muted rd-text-sm">No notes found. Try resetting the demo.</p>
</div>
</div>
<!-- Packing list section -->
<div id="rd-packing-section" style="margin-top:1.5rem;display:none;">
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:1rem;">
<h2 style="font-size:0.875rem;font-weight:600;color:#cbd5e1;margin:0;">Packing List</h2>
</div>
<div id="rd-packing-container"></div>
</div>
</div>
</div>
</section>
<!-- Features showcase -->
<section class="rd-section" style="margin-top:2rem;">
<h2 style="font-size:1.5rem;font-weight:700;color:white;text-align:center;margin-bottom:2rem;">Everything you need to capture knowledge</h2>
<div style="display:grid;grid-template-columns:repeat(2,1fr);gap:1rem;" class="rd-features-grid">
${[
{
icon: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#f59e0b" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>`,
title: "Live Transcription",
desc: "Record and transcribe in real time. Stream audio via WebSocket or transcribe offline with Parakeet.js.",
},
{
icon: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#f59e0b" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>`,
title: "Rich Editing",
desc: "Headings, lists, code blocks, highlights, images, and file attachments in every note.",
},
{
icon: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#f59e0b" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></svg>`,
title: "Notebooks",
desc: "Organize notes into notebooks with sections. Nest as deep as you need.",
},
{
icon: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#f59e0b" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/></svg>`,
title: "Flexible Tags",
desc: "Cross-cutting tags let you find notes across all notebooks instantly.",
},
{
icon: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#f59e0b" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>`,
title: "Canvas Sync",
desc: "Pin any note to your rSpace canvas for visual collaboration with your team.",
},
]
.map(
(f) => `
<div class="rd-card" style="padding:1.25rem;">
<div style="width:2.5rem;height:2.5rem;background:rgba(245,158,11,0.1);border-radius:0.5rem;display:flex;align-items:center;justify-content:center;margin-bottom:0.75rem;">
${f.icon}
</div>
<h3 style="font-size:0.875rem;font-weight:600;color:white;margin:0 0 0.25rem;">${f.title}</h3>
<p style="font-size:0.75rem;color:#94a3b8;margin:0;line-height:1.5;">${f.desc}</p>
</div>`,
)
.join("")}
</div>
</section>
<!-- CTA -->
<section class="rd-section rd-section--narrow">
<div class="rd-cta">
<h2>Ready to capture everything?</h2>
<p>
rNotes gives your team a shared knowledge base with rich editing, flexible organization,
and deep integration with the r* ecosystem &mdash; all on a collaborative canvas.
</p>
<a href="/create-space" style="background:linear-gradient(135deg,#f59e0b,#f97316);box-shadow:0 8px 24px rgba(245,158,11,0.25);">
Start Taking Notes
</a>
</div>
</section>
</div>
<style>
/* ── Notes-specific layout ── */
@media (min-width: 1024px) {
.rd-notes-layout {
grid-template-columns: 16rem 1fr !important;
}
}
@media (min-width: 640px) {
.rd-features-grid {
grid-template-columns: repeat(3, 1fr) !important;
}
}
@media (min-width: 1024px) {
.rd-features-grid {
grid-template-columns: repeat(5, 1fr) !important;
}
}
/* Note card styles */
.rd-note-card {
cursor: pointer;
transition: border-color 0.15s;
}
.rd-note-card:hover {
border-color: rgba(100,116,139,0.6);
}
.rd-note-card--expanded {
cursor: default;
border-color: rgba(245,158,11,0.3) !important;
box-shadow: 0 0 0 1px rgba(245,158,11,0.15);
}
/* Synced badge */
.rd-synced-badge {
display: inline-flex;
align-items: center;
gap: 0.375rem;
font-size: 0.75rem;
padding: 0.25rem 0.625rem;
background: rgba(20,184,166,0.1);
border: 1px solid rgba(20,184,166,0.2);
color: #2dd4bf;
border-radius: 9999px;
white-space: nowrap;
flex-shrink: 0;
}
/* Markdown rendered content */
.rd-md h3 { font-size: 1.125rem; font-weight: 700; color: white; margin: 1rem 0 0.5rem; }
.rd-md h4 { font-size: 1rem; font-weight: 600; color: #e2e8f0; margin: 1rem 0 0.5rem; }
.rd-md h5 { font-size: 0.875rem; font-weight: 600; color: #cbd5e1; margin: 0.75rem 0 0.25rem; }
.rd-md p { font-size: 0.875rem; color: #cbd5e1; margin: 0.25rem 0; line-height: 1.6; }
.rd-md strong { color: white; font-weight: 500; }
.rd-md em { color: #cbd5e1; font-style: italic; }
.rd-md code {
color: #fcd34d;
background: rgba(30,41,59,1);
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
font-size: 0.75rem;
font-family: monospace;
}
.rd-md .rd-md-quote {
background: rgba(245,158,11,0.1);
border-left: 2px solid #f59e0b;
padding: 0.5rem 1rem;
border-radius: 0 0.5rem 0.5rem 0;
margin: 0.5rem 0;
}
.rd-md .rd-md-quote p { color: #fcd34d; }
.rd-md ul, .rd-md ol { margin: 0.5rem 0; padding: 0; list-style: none; }
.rd-md ul li, .rd-md ol li {
display: flex; align-items: flex-start; gap: 0.5rem;
font-size: 0.875rem; color: #cbd5e1; padding: 0.125rem 0;
}
.rd-md ul li::before { content: "\\2022"; color: #f59e0b; margin-top: 0.1rem; flex-shrink: 0; }
.rd-md ol li .rd-md-num { color: #f59e0b; font-weight: 500; min-width: 1.2em; text-align: right; flex-shrink: 0; }
.rd-md .rd-md-codeblock {
background: rgba(2,6,23,1);
border-radius: 0.5rem;
border: 1px solid rgba(51,65,85,0.5);
overflow: hidden;
margin: 0.5rem 0;
}
.rd-md .rd-md-codeblock-lang {
display: flex; align-items: center; justify-content: space-between;
padding: 0.5rem 1rem;
background: rgba(30,41,59,0.5);
border-bottom: 1px solid rgba(51,65,85,0.5);
font-size: 0.75rem; color: #94a3b8; font-family: monospace;
}
.rd-md .rd-md-codeblock pre {
padding: 0.75rem 1rem;
font-size: 0.75rem; color: #cbd5e1;
font-family: monospace;
overflow-x: auto;
line-height: 1.5;
margin: 0;
}
/* Tag pill */
.rd-note-tag {
display: inline-block;
font-size: 0.75rem;
padding: 0.125rem 0.5rem;
background: rgba(51,65,85,0.5);
color: #94a3b8;
border-radius: 0.375rem;
border: 1px solid rgba(51,65,85,0.3);
}
/* Packing list checkbox */
.rd-pack-check {
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-pack-check--checked {
background: #f59e0b; border-color: #f59e0b;
}
.rd-pack-check:hover { border-color: #64748b; }
/* Packing item label */
.rd-pack-item {
display: flex; align-items: center; gap: 0.75rem;
padding: 0.5rem 0.75rem;
border-radius: 0.5rem;
cursor: pointer;
transition: background 0.1s;
}
.rd-pack-item:hover { background: rgba(51,65,85,0.3); }
</style>`;
}

View File

@ -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: `<script type="module" src="/lib/demo-sync.js"></script>
<script type="module" src="/modules/rnotes/notes-demo.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/rnotes/notes.css">`,
}));
}
return c.html(renderShell({
title: `${space} — Notes | rSpace`,
moduleId: "rnotes",
@ -369,8 +383,8 @@ routes.get("/", (c) => {
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-notes-app space="${space}"></folk-notes-app>`,
scripts: `<script type="module" src="/modules/notes/folk-notes-app.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/notes/notes.css">`,
scripts: `<script type="module" src="/modules/rnotes/folk-notes-app.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/rnotes/notes.css">`,
}));
});
@ -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: [
{

View File

@ -116,8 +116,8 @@ routes.get("/", (c) => {
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-photo-gallery space="${spaceSlug}"></folk-photo-gallery>`,
scripts: `<script type="module" src="/modules/photos/folk-photo-gallery.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/photos/photos.css">`,
scripts: `<script type="module" src="/modules/rphotos/folk-photo-gallery.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/rphotos/photos.css">`,
}));
});

View File

@ -329,8 +329,8 @@ routes.get("/", (c) => {
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-pubs-editor space="${spaceSlug}"></folk-pubs-editor>`,
scripts: `<script type="module" src="/modules/pubs/folk-pubs-editor.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/pubs/pubs.css">`,
scripts: `<script type="module" src="/modules/rpubs/folk-pubs-editor.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/rpubs/pubs.css">`,
}));
});

Some files were not shown because too many files have changed in this diff Show More