Merge branch 'dev'
# Conflicts: # modules/rcal/mod.ts # modules/rfiles/mod.ts # modules/rforum/mod.ts # modules/rmaps/mod.ts # modules/rnetwork/mod.ts # modules/rswag/mod.ts # modules/rwork/mod.ts # shared/module.ts
This commit is contained in:
commit
75b148e772
|
|
@ -6,7 +6,7 @@ dist/
|
|||
|
||||
# Data storage
|
||||
data/
|
||||
!modules/data/
|
||||
!modules/rdata/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
|
|
|
|||
|
|
@ -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`)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
`,
|
||||
});
|
||||
|
|
@ -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 {};
|
||||
|
|
@ -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>`;
|
||||
}
|
||||
|
|
@ -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?v=3"></script>`,
|
||||
styles: `<link rel="stylesheet" href="/modules/cal/cal.css?v=2">`,
|
||||
scripts: `<script type="module" src="/modules/rcal/folk-calendar-view.js?v=3"></script>`,
|
||||
styles: `<link rel="stylesheet" href="/modules/rcal/cal.css?v=2">`,
|
||||
}));
|
||||
});
|
||||
|
||||
|
|
@ -396,6 +409,7 @@ export const calModule: RSpaceModule = {
|
|||
routes,
|
||||
standaloneDomain: "rcal.online",
|
||||
landingPage: renderLanding,
|
||||
demoPage: renderDemo,
|
||||
feeds: [
|
||||
{
|
||||
id: "events",
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
// rCart demo — static display, no client interactivity needed
|
||||
export {};
|
||||
|
|
@ -0,0 +1,234 @@
|
|||
/**
|
||||
* rCart demo page — static community garden shopping cart.
|
||||
*
|
||||
* Renders a fully server-side demo with 8 cart items, funding progress bars,
|
||||
* member activity, and summary stats. No WebSocket needed (all static data).
|
||||
*/
|
||||
|
||||
/* ─── Mock Data ─────────────────────────────────────────────── */
|
||||
|
||||
const members = [
|
||||
{ name: "Alice", color: "#10b981" },
|
||||
{ name: "Bob", color: "#0ea5e9" },
|
||||
{ name: "Carol", color: "#f59e0b" },
|
||||
{ name: "Dave", color: "#8b5cf6" },
|
||||
];
|
||||
|
||||
interface CartItem {
|
||||
name: string;
|
||||
price: number;
|
||||
requestedBy: string;
|
||||
funded: number;
|
||||
status: "Funded" | "In Cart" | "Needs Funding";
|
||||
}
|
||||
|
||||
const cartItems: CartItem[] = [
|
||||
{ name: "Raised Garden Bed Kit (4x8 ft)", price: 89.99, requestedBy: "Alice", funded: 89.99, status: "Funded" },
|
||||
{ name: "Organic Seed Variety Pack (30 types)", price: 34.5, requestedBy: "Carol", funded: 34.5, status: "Funded" },
|
||||
{ name: "Premium Potting Soil (40 qt, 3-pack)", price: 47.99, requestedBy: "Bob", funded: 32.0, status: "In Cart" },
|
||||
{ name: "Stainless Steel Garden Tool Set", price: 62.0, requestedBy: "Dave", funded: 62.0, status: "Funded" },
|
||||
{ name: "Drip Irrigation Kit (100 ft)", price: 54.95, requestedBy: "Alice", funded: 20.0, status: "Needs Funding" },
|
||||
{ name: "Compost Tumbler (45 gal)", price: 109.0, requestedBy: "Bob", funded: 109.0, status: "Funded" },
|
||||
{ name: "Garden Kneeling Pad & Gloves Set", price: 28.5, requestedBy: "Carol", funded: 12.0, status: "Needs Funding" },
|
||||
{ name: "Solar-Powered Pest Repeller (4-pack)", price: 39.99, requestedBy: "Dave", funded: 39.99, status: "In Cart" },
|
||||
];
|
||||
|
||||
/* ─── Helpers ──────────────────────────────────────────────── */
|
||||
|
||||
function getMemberColor(name: string): string {
|
||||
return members.find((m) => m.name === name)?.color || "#64748b";
|
||||
}
|
||||
|
||||
function statusBadgeClass(status: CartItem["status"]): string {
|
||||
switch (status) {
|
||||
case "Funded":
|
||||
return "rd-badge--emerald";
|
||||
case "In Cart":
|
||||
return "rd-badge--sky";
|
||||
case "Needs Funding":
|
||||
return "rd-badge--amber";
|
||||
}
|
||||
}
|
||||
|
||||
function progressFillClass(pct: number): string {
|
||||
if (pct >= 100) return "rd-progress__fill--emerald";
|
||||
if (pct >= 50) return "rd-progress__fill--sky";
|
||||
return "rd-progress__fill--amber";
|
||||
}
|
||||
|
||||
/* ─── Render ─────────────────────────────────────────────── */
|
||||
|
||||
export function renderDemo(): string {
|
||||
const totalCost = cartItems.reduce((sum, item) => sum + item.price, 0);
|
||||
const totalFunded = cartItems.reduce((sum, item) => sum + item.funded, 0);
|
||||
const perPerson = totalCost / members.length;
|
||||
const fundedCount = cartItems.filter((i) => i.status === "Funded").length;
|
||||
const overallPct = Math.round((totalFunded / totalCost) * 100);
|
||||
const uniqueRequesters = new Set(cartItems.map((i) => i.requestedBy)).size;
|
||||
|
||||
return `
|
||||
<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">🛒</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;">·</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>`;
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
@ -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">`,
|
||||
}));
|
||||
});
|
||||
|
||||
|
|
@ -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">`,
|
||||
}));
|
||||
});
|
||||
|
||||
|
|
@ -389,8 +389,8 @@ routes.get("/", (c) => {
|
|||
theme: "dark",
|
||||
body: `<div class="rapp-nav" style="padding:0 1rem;margin-top:8px"><span class="rapp-nav__title"></span><a href="?view=app" class="rapp-nav__btn--app-toggle">Open Full App</a></div>
|
||||
<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">`,
|
||||
}));
|
||||
});
|
||||
|
||||
|
|
@ -180,8 +180,8 @@ routes.get("/", (c) => {
|
|||
theme: "dark",
|
||||
body: `<div class="rapp-nav" style="padding:0 1rem;margin-top:8px"><span class="rapp-nav__title"></span><a href="?view=app" class="rapp-nav__btn--app-toggle">Open Full App</a></div>
|
||||
<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">`,
|
||||
}));
|
||||
});
|
||||
|
||||
|
|
@ -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() {
|
||||
|
|
@ -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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
}
|
||||
|
||||
// ── Balance computation ──
|
||||
|
||||
interface BalanceEntry {
|
||||
name: string;
|
||||
paid: number;
|
||||
owes: number;
|
||||
balance: number;
|
||||
}
|
||||
|
||||
function computeBalances(expenses: ExpenseShape[]): BalanceEntry[] {
|
||||
const total = expenses.reduce((s, e) => s + e.amount, 0);
|
||||
const perPerson = total / MEMBERS.length;
|
||||
const paid: Record<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();
|
||||
|
|
@ -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 — 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>`;
|
||||
}
|
||||
|
|
@ -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: [
|
||||
{
|
||||
|
|
@ -589,8 +589,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">`,
|
||||
}));
|
||||
});
|
||||
|
||||
|
|
@ -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?v=3"></script>`,
|
||||
styles: `<link rel="stylesheet" href="/modules/maps/maps.css">`,
|
||||
scripts: `<script type="module" src="/modules/rmaps/folk-map-viewer.js?v=3"></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?v=3"></script>`,
|
||||
scripts: `<script type="module" src="/modules/rmaps/folk-map-viewer.js?v=3"></script>`,
|
||||
}));
|
||||
});
|
||||
|
||||
|
|
@ -239,8 +239,8 @@ routes.get("/", (c) => {
|
|||
theme: "dark",
|
||||
body: `<div class="rapp-nav" style="padding:0 1rem;margin-top:8px"><span class="rapp-nav__title"></span><a href="?view=app" class="rapp-nav__btn--app-toggle">Open Full App</a></div>
|
||||
<folk-graph-viewer space="${space}"></folk-graph-viewer>`,
|
||||
scripts: `<script type="module" src="/modules/network/folk-graph-viewer.js?v=3"></script>`,
|
||||
styles: `<link rel="stylesheet" href="/modules/network/network.css">`,
|
||||
scripts: `<script type="module" src="/modules/rnetwork/folk-graph-viewer.js?v=3"></script>`,
|
||||
styles: `<link rel="stylesheet" href="/modules/rnetwork/network.css">`,
|
||||
}));
|
||||
});
|
||||
|
||||
|
|
@ -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, "<").replace(/>/g, ">");
|
||||
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
}
|
||||
|
||||
function formatRelative(iso: string): string {
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
const now = Date.now();
|
||||
const diff = now - d.getTime();
|
||||
if (diff < 60_000) return "just now";
|
||||
if (diff < 3600_000) return `${Math.floor(diff / 60_000)}m ago`;
|
||||
if (diff < 86400_000) return `${Math.floor(diff / 3600_000)}h ago`;
|
||||
return d.toLocaleDateString("en", { month: "short", day: "numeric" });
|
||||
} catch { return ""; }
|
||||
}
|
||||
|
||||
// ── Packing list rendering ──
|
||||
|
||||
function renderPackingList(packingList: DemoShape): string {
|
||||
const items = (packingList.items as Array<{ name: string; packed: boolean; category: string }>) || [];
|
||||
if (items.length === 0) return "";
|
||||
|
||||
// Group by category
|
||||
const groups: Record<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();
|
||||
|
|
@ -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 & 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 — 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;">📓</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>📓</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 — 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>`;
|
||||
}
|
||||
|
|
@ -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: [
|
||||
{
|
||||
|
|
@ -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">`,
|
||||
}));
|
||||
});
|
||||
|
||||
|
|
@ -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
Loading…
Reference in New Issue