feat: add JSON WebSocket mode, demo seed data, and useDemoSync hook
Add lightweight JSON WebSocket protocol (?mode=json) that bridges Automerge to JSON for demo pages, avoiding the ~500KB Automerge bundle. Includes GET /api/communities/:slug/shapes endpoint, POST demo reset with rate limiting, Alpine Explorer 2026 seed data (~40 shapes), and the useDemoSync React hook for real-time demo page connectivity. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
89fba95e40
commit
0a32944243
|
|
@ -412,3 +412,17 @@ export function deleteShape(slug: string, shapeId: string): void {
|
|||
saveCommunity(slug);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all shapes from a community (for demo reset)
|
||||
*/
|
||||
export function clearShapes(slug: string): void {
|
||||
const doc = communities.get(slug);
|
||||
if (doc) {
|
||||
const newDoc = Automerge.change(doc, "Clear all shapes", (d) => {
|
||||
d.shapes = {};
|
||||
});
|
||||
communities.set(slug, newDoc);
|
||||
saveCommunity(slug);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
192
server/index.ts
192
server/index.ts
|
|
@ -2,6 +2,7 @@ import { resolve } from "node:path";
|
|||
import type { ServerWebSocket } from "bun";
|
||||
import {
|
||||
addShapes,
|
||||
clearShapes,
|
||||
communityExists,
|
||||
createCommunity,
|
||||
deleteShape,
|
||||
|
|
@ -12,6 +13,7 @@ import {
|
|||
removePeerSyncState,
|
||||
updateShape,
|
||||
} from "./community-store";
|
||||
import { ensureDemoCommunity } from "./seed-demo";
|
||||
import type { SpaceVisibility } from "./community-store";
|
||||
import {
|
||||
verifyEncryptIDToken,
|
||||
|
|
@ -47,6 +49,7 @@ interface WSData {
|
|||
peerId: string;
|
||||
claims: EncryptIDClaims | null;
|
||||
readOnly: boolean;
|
||||
mode: "automerge" | "json";
|
||||
}
|
||||
|
||||
// Track connected clients per community (for broadcasting)
|
||||
|
|
@ -62,6 +65,38 @@ function getClient(slug: string, peerId: string): ServerWebSocket<WSData> | unde
|
|||
return communityClients.get(slug)?.get(peerId);
|
||||
}
|
||||
|
||||
// Broadcast a JSON snapshot of all shapes to json-mode clients in a community
|
||||
function broadcastJsonSnapshot(slug: string, excludePeerId?: string): void {
|
||||
const clients = communityClients.get(slug);
|
||||
if (!clients) return;
|
||||
const docData = getDocumentData(slug);
|
||||
if (!docData) return;
|
||||
const snapshotMsg = JSON.stringify({ type: "snapshot", shapes: docData.shapes || {} });
|
||||
for (const [clientPeerId, client] of clients) {
|
||||
if (client.data.mode === "json" && clientPeerId !== excludePeerId && client.readyState === WebSocket.OPEN) {
|
||||
client.send(snapshotMsg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcast Automerge sync messages to automerge-mode clients in a community
|
||||
function broadcastAutomergeSync(slug: string, excludePeerId?: string): void {
|
||||
const clients = communityClients.get(slug);
|
||||
if (!clients) return;
|
||||
for (const [clientPeerId, client] of clients) {
|
||||
if (client.data.mode === "automerge" && clientPeerId !== excludePeerId && client.readyState === WebSocket.OPEN) {
|
||||
const syncMsg = generateSyncMessageForPeer(slug, clientPeerId);
|
||||
if (syncMsg) {
|
||||
client.send(JSON.stringify({ type: "sync", data: Array.from(syncMsg) }));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Demo reset rate limiter
|
||||
let lastDemoReset = 0;
|
||||
const DEMO_RESET_COOLDOWN = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
// Special subdomains that should show the creation form instead of canvas
|
||||
const RESERVED_SUBDOMAINS = ["www", "rspace", "create", "new", "start"];
|
||||
|
||||
|
|
@ -146,8 +181,9 @@ const server = Bun.serve<WSData>({
|
|||
}
|
||||
|
||||
const peerId = generatePeerId();
|
||||
const mode = url.searchParams.get("mode") === "json" ? "json" : "automerge";
|
||||
const upgraded = server.upgrade(req, {
|
||||
data: { communitySlug, peerId, claims, readOnly },
|
||||
data: { communitySlug, peerId, claims, readOnly, mode } as WSData,
|
||||
});
|
||||
if (upgraded) return undefined;
|
||||
}
|
||||
|
|
@ -200,7 +236,7 @@ const server = Bun.serve<WSData>({
|
|||
|
||||
websocket: {
|
||||
open(ws: ServerWebSocket<WSData>) {
|
||||
const { communitySlug, peerId } = ws.data;
|
||||
const { communitySlug, peerId, mode } = ws.data;
|
||||
|
||||
// Add to clients map
|
||||
if (!communityClients.has(communitySlug)) {
|
||||
|
|
@ -208,11 +244,23 @@ const server = Bun.serve<WSData>({
|
|||
}
|
||||
communityClients.get(communitySlug)!.set(peerId, ws);
|
||||
|
||||
console.log(`[WS] Client ${peerId} connected to ${communitySlug}`);
|
||||
console.log(`[WS] Client ${peerId} connected to ${communitySlug} (mode: ${mode})`);
|
||||
|
||||
// Load community and send initial sync message
|
||||
// Load community and send initial data
|
||||
loadCommunity(communitySlug).then((doc) => {
|
||||
if (doc) {
|
||||
if (!doc) return;
|
||||
|
||||
if (mode === "json") {
|
||||
// JSON mode: send full shapes snapshot
|
||||
const docData = getDocumentData(communitySlug);
|
||||
if (docData) {
|
||||
ws.send(JSON.stringify({
|
||||
type: "snapshot",
|
||||
shapes: docData.shapes || {},
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
// Automerge mode: send sync message
|
||||
const syncMessage = generateSyncMessageForPeer(communitySlug, peerId);
|
||||
if (syncMessage) {
|
||||
ws.send(
|
||||
|
|
@ -255,10 +303,10 @@ const server = Bun.serve<WSData>({
|
|||
);
|
||||
}
|
||||
|
||||
// Broadcast to other peers
|
||||
// Broadcast to other Automerge peers
|
||||
for (const [targetPeerId, targetMessage] of result.broadcastToPeers) {
|
||||
const targetClient = getClient(communitySlug, targetPeerId);
|
||||
if (targetClient && targetClient.readyState === WebSocket.OPEN) {
|
||||
if (targetClient && targetClient.data.mode === "automerge" && targetClient.readyState === WebSocket.OPEN) {
|
||||
targetClient.send(
|
||||
JSON.stringify({
|
||||
type: "sync",
|
||||
|
|
@ -267,6 +315,11 @@ const server = Bun.serve<WSData>({
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Also broadcast JSON snapshot to json-mode clients
|
||||
if (result.broadcastToPeers.size > 0) {
|
||||
broadcastJsonSnapshot(communitySlug, peerId);
|
||||
}
|
||||
} else if (msg.type === "ping") {
|
||||
// Handle keep-alive ping
|
||||
ws.send(JSON.stringify({ type: "pong", timestamp: msg.timestamp }));
|
||||
|
|
@ -286,39 +339,27 @@ const server = Bun.serve<WSData>({
|
|||
}
|
||||
}
|
||||
}
|
||||
// Legacy message handling for backward compatibility
|
||||
// Legacy/JSON-mode message handling
|
||||
else if (msg.type === "update" && msg.id && msg.data) {
|
||||
if (ws.data.readOnly) {
|
||||
ws.send(JSON.stringify({ type: "error", message: "Authentication required to edit" }));
|
||||
return;
|
||||
}
|
||||
updateShape(communitySlug, msg.id, msg.data);
|
||||
// Broadcast to other clients
|
||||
const clients = communityClients.get(communitySlug);
|
||||
if (clients) {
|
||||
const updateMsg = JSON.stringify(msg);
|
||||
for (const [clientPeerId, client] of clients) {
|
||||
if (clientPeerId !== peerId && client.readyState === WebSocket.OPEN) {
|
||||
client.send(updateMsg);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Broadcast JSON update to other json-mode clients
|
||||
broadcastJsonSnapshot(communitySlug, peerId);
|
||||
// Broadcast Automerge sync to automerge-mode clients
|
||||
broadcastAutomergeSync(communitySlug, peerId);
|
||||
} else if (msg.type === "delete" && msg.id) {
|
||||
if (ws.data.readOnly) {
|
||||
ws.send(JSON.stringify({ type: "error", message: "Authentication required to delete" }));
|
||||
return;
|
||||
}
|
||||
deleteShape(communitySlug, msg.id);
|
||||
// Broadcast to other clients
|
||||
const clients = communityClients.get(communitySlug);
|
||||
if (clients) {
|
||||
const deleteMsg = JSON.stringify(msg);
|
||||
for (const [clientPeerId, client] of clients) {
|
||||
if (clientPeerId !== peerId && client.readyState === WebSocket.OPEN) {
|
||||
client.send(deleteMsg);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Broadcast JSON snapshot to other json-mode clients
|
||||
broadcastJsonSnapshot(communitySlug, peerId);
|
||||
// Broadcast Automerge sync to automerge-mode clients
|
||||
broadcastAutomergeSync(communitySlug, peerId);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[WS] Failed to parse message:", e);
|
||||
|
|
@ -441,6 +482,74 @@ async function handleAPI(req: Request, url: URL): Promise<Response> {
|
|||
}
|
||||
}
|
||||
|
||||
// GET /api/communities/:slug/shapes - Get all shapes as JSON
|
||||
if (
|
||||
url.pathname.match(/^\/api\/communities\/[^/]+\/shapes$/) &&
|
||||
req.method === "GET"
|
||||
) {
|
||||
const slug = url.pathname.split("/")[3];
|
||||
|
||||
const token = extractToken(req.headers);
|
||||
const access = await evaluateSpaceAccess(slug, token, req.method, {
|
||||
getSpaceConfig,
|
||||
});
|
||||
|
||||
if (!access.allowed) {
|
||||
return Response.json(
|
||||
{ error: access.reason },
|
||||
{ status: access.claims ? 403 : 401, headers: corsHeaders }
|
||||
);
|
||||
}
|
||||
|
||||
await loadCommunity(slug);
|
||||
const data = getDocumentData(slug);
|
||||
if (!data) {
|
||||
return Response.json(
|
||||
{ error: "Community not found" },
|
||||
{ status: 404, headers: corsHeaders }
|
||||
);
|
||||
}
|
||||
|
||||
return Response.json(
|
||||
{ shapes: data.shapes || {} },
|
||||
{ headers: corsHeaders }
|
||||
);
|
||||
}
|
||||
|
||||
// POST /api/communities/demo/reset - Reset demo community to seed data
|
||||
if (url.pathname === "/api/communities/demo/reset" && req.method === "POST") {
|
||||
const now = Date.now();
|
||||
if (now - lastDemoReset < DEMO_RESET_COOLDOWN) {
|
||||
const remaining = Math.ceil((DEMO_RESET_COOLDOWN - (now - lastDemoReset)) / 1000);
|
||||
return Response.json(
|
||||
{ error: `Demo reset on cooldown. Try again in ${remaining}s` },
|
||||
{ status: 429, headers: corsHeaders }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
lastDemoReset = now;
|
||||
await loadCommunity("demo");
|
||||
clearShapes("demo");
|
||||
await ensureDemoCommunity();
|
||||
|
||||
// Broadcast new state to all connected clients
|
||||
broadcastAutomergeSync("demo");
|
||||
broadcastJsonSnapshot("demo");
|
||||
|
||||
return Response.json(
|
||||
{ ok: true, message: "Demo community reset to seed data" },
|
||||
{ headers: corsHeaders }
|
||||
);
|
||||
} catch (e) {
|
||||
console.error("Failed to reset demo:", e);
|
||||
return Response.json(
|
||||
{ error: "Failed to reset demo community" },
|
||||
{ status: 500, headers: corsHeaders }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/communities/:slug - Get community info (respects visibility)
|
||||
if (url.pathname.startsWith("/api/communities/") && req.method === "GET") {
|
||||
const slug = url.pathname.split("/")[3];
|
||||
|
|
@ -535,23 +644,9 @@ async function handleAPI(req: Request, url: URL): Promise<Response> {
|
|||
// Add shapes to the Automerge document
|
||||
const ids = addShapes(slug, body.shapes);
|
||||
|
||||
// Broadcast sync messages to all connected WebSocket clients
|
||||
const clients = communityClients.get(slug);
|
||||
if (clients) {
|
||||
for (const [peerId, client] of clients) {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
const syncMsg = generateSyncMessageForPeer(slug, peerId);
|
||||
if (syncMsg) {
|
||||
client.send(
|
||||
JSON.stringify({
|
||||
type: "sync",
|
||||
data: Array.from(syncMsg),
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Broadcast to all connected WebSocket clients
|
||||
broadcastAutomergeSync(slug);
|
||||
broadcastJsonSnapshot(slug);
|
||||
|
||||
return Response.json(
|
||||
{ ok: true, ids },
|
||||
|
|
@ -569,4 +664,11 @@ async function handleAPI(req: Request, url: URL): Promise<Response> {
|
|||
return Response.json({ error: "Not found" }, { status: 404, headers: corsHeaders });
|
||||
}
|
||||
|
||||
// Ensure demo community exists on startup
|
||||
ensureDemoCommunity().then(() => {
|
||||
console.log("[Demo] Demo community ready");
|
||||
}).catch((e) => {
|
||||
console.error("[Demo] Failed to initialize demo community:", e);
|
||||
});
|
||||
|
||||
console.log(`rSpace server running on http://localhost:${PORT}`);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,439 @@
|
|||
/**
|
||||
* Demo community seeder for the r* ecosystem
|
||||
*
|
||||
* Creates/ensures a "demo" community with visibility: "public" (anon read+write)
|
||||
* and populates it with the "Alpine Explorer 2026" cross-app scenario.
|
||||
*
|
||||
* Shape IDs are deterministic so re-running is idempotent.
|
||||
*/
|
||||
|
||||
import {
|
||||
addShapes,
|
||||
communityExists,
|
||||
createCommunity,
|
||||
getDocumentData,
|
||||
loadCommunity,
|
||||
} from "./community-store";
|
||||
|
||||
// ── Alpine Explorer 2026 — Demo Scenario ──────────────────────────
|
||||
|
||||
const DEMO_SHAPES: Record<string, unknown>[] = [
|
||||
// ─── rTrips: Itinerary ──────────────────────────────────────
|
||||
{
|
||||
id: "demo-itinerary-alpine",
|
||||
type: "folk-itinerary",
|
||||
x: 50, y: 50, width: 400, height: 300, rotation: 0,
|
||||
tripTitle: "Alpine Explorer 2026",
|
||||
startDate: "2026-07-06",
|
||||
endDate: "2026-07-20",
|
||||
travelers: ["Maya", "Liam", "Priya", "Omar"],
|
||||
items: [
|
||||
{ date: "Jul 6", activity: "Fly Geneva → Chamonix shuttle", category: "travel" },
|
||||
{ date: "Jul 7", activity: "Acclimatization hike — Lac Blanc", category: "hike" },
|
||||
{ date: "Jul 8", activity: "Via Ferrata — Aiguille du Midi", category: "adventure" },
|
||||
{ date: "Jul 9", activity: "Rest day / Chamonix town", category: "rest" },
|
||||
{ date: "Jul 10", activity: "Train to Zermatt", category: "travel" },
|
||||
{ date: "Jul 11", activity: "Gornergrat sunrise hike", category: "hike" },
|
||||
{ date: "Jul 12", activity: "Matterhorn base camp trek", category: "hike" },
|
||||
{ date: "Jul 13", activity: "Paragliding over Zermatt", category: "adventure" },
|
||||
{ date: "Jul 14", activity: "Transfer to Dolomites", category: "travel" },
|
||||
{ date: "Jul 15", activity: "Tre Cime di Lavaredo loop", category: "hike" },
|
||||
{ date: "Jul 16", activity: "Lago di Braies kayaking", category: "adventure" },
|
||||
{ date: "Jul 17", activity: "Seceda ridgeline hike", category: "hike" },
|
||||
{ date: "Jul 18", activity: "Cooking class in Bolzano", category: "culture" },
|
||||
{ date: "Jul 19", activity: "Free day — shopping & packing", category: "rest" },
|
||||
{ date: "Jul 20", activity: "Fly home from Innsbruck", category: "travel" },
|
||||
],
|
||||
},
|
||||
|
||||
// ─── rTrips: Destinations ───────────────────────────────────
|
||||
{
|
||||
id: "demo-dest-chamonix",
|
||||
type: "folk-destination",
|
||||
x: 500, y: 50, width: 300, height: 200, rotation: 0,
|
||||
destName: "Chamonix",
|
||||
country: "France",
|
||||
lat: 45.9237,
|
||||
lng: 6.8694,
|
||||
arrivalDate: "2026-07-06",
|
||||
departureDate: "2026-07-10",
|
||||
notes: "Base for Mont Blanc region. Book Aiguille du Midi tickets in advance.",
|
||||
},
|
||||
{
|
||||
id: "demo-dest-zermatt",
|
||||
type: "folk-destination",
|
||||
x: 500, y: 280, width: 300, height: 200, rotation: 0,
|
||||
destName: "Zermatt",
|
||||
country: "Switzerland",
|
||||
lat: 46.0207,
|
||||
lng: 7.7491,
|
||||
arrivalDate: "2026-07-10",
|
||||
departureDate: "2026-07-14",
|
||||
notes: "Car-free village. Take the Glacier Express for the scenic route.",
|
||||
},
|
||||
{
|
||||
id: "demo-dest-dolomites",
|
||||
type: "folk-destination",
|
||||
x: 500, y: 510, width: 300, height: 200, rotation: 0,
|
||||
destName: "Dolomites",
|
||||
country: "Italy",
|
||||
lat: 46.4102,
|
||||
lng: 11.8440,
|
||||
arrivalDate: "2026-07-14",
|
||||
departureDate: "2026-07-20",
|
||||
notes: "Stay in Val Gardena. Rifugio Locatelli for Tre Cime views.",
|
||||
},
|
||||
|
||||
// ─── rNotes: Notebook ───────────────────────────────────────
|
||||
{
|
||||
id: "demo-notebook-trip",
|
||||
type: "folk-notebook",
|
||||
x: 850, y: 50, width: 300, height: 180, rotation: 0,
|
||||
notebookTitle: "Alpine Explorer Planning",
|
||||
description: "Shared knowledge base for our July 2026 trip across France, Switzerland, and Italy",
|
||||
noteCount: 5,
|
||||
collaborators: ["Maya", "Liam", "Priya", "Omar"],
|
||||
},
|
||||
|
||||
// ─── rNotes: Notes ──────────────────────────────────────────
|
||||
{
|
||||
id: "demo-note-packing",
|
||||
type: "folk-note",
|
||||
x: 850, y: 260, width: 300, height: 250, rotation: 0,
|
||||
noteTitle: "Packing Checklist",
|
||||
content: "## Essential Gear\n- [ ] Hiking boots (broken in!)\n- [x] Rain jacket\n- [x] Headlamp + batteries\n- [ ] Trekking poles\n- [x] First aid kit\n- [ ] Sunscreen SPF 50\n- [ ] Water filter\n\n## Documents\n- [x] Passports\n- [ ] Travel insurance\n- [x] Hut reservations printout",
|
||||
tags: ["packing", "gear", "checklist"],
|
||||
editor: "Maya",
|
||||
editedAt: "2 hours ago",
|
||||
},
|
||||
{
|
||||
id: "demo-note-gear",
|
||||
type: "folk-note",
|
||||
x: 1180, y: 50, width: 300, height: 200, rotation: 0,
|
||||
noteTitle: "Gear Research",
|
||||
content: "## Via Ferrata Kit\nNeed harness + lanyard + helmet. Can rent in Chamonix for ~€25/day.\n\n## Paragliding\nTandem flights in Zermatt: ~€180pp. Book at Paragliding Zermatt (best reviews).\n\n## Camera\nBring the drone for Tre Cime. Check Italian drone regulations!",
|
||||
tags: ["gear", "research", "equipment"],
|
||||
editor: "Liam",
|
||||
editedAt: "5 hours ago",
|
||||
},
|
||||
{
|
||||
id: "demo-note-huts",
|
||||
type: "folk-note",
|
||||
x: 1180, y: 280, width: 300, height: 200, rotation: 0,
|
||||
noteTitle: "Mountain Hut Reservations",
|
||||
content: "## Confirmed\n- **Rifugio Locatelli** (Jul 15): 4 beds, conf #TRE2026-089\n- **Refuge du Lac Blanc** (Jul 7): 4 beds, conf #LB2026-234\n\n## Pending\n- Hörnlihütte (Matterhorn base): Waitlisted for Jul 12",
|
||||
tags: ["accommodation", "reservations", "logistics"],
|
||||
editor: "Priya",
|
||||
editedAt: "Yesterday",
|
||||
},
|
||||
{
|
||||
id: "demo-note-emergency",
|
||||
type: "folk-note",
|
||||
x: 1180, y: 510, width: 300, height: 180, rotation: 0,
|
||||
noteTitle: "Emergency Contacts",
|
||||
content: "## Emergency Numbers\n- **France**: 112 (EU), PGHM: +33 4 50 53 16 89\n- **Switzerland**: 1414 (REGA air rescue)\n- **Italy**: 118 (medical), 112 (general)\n\n## Insurance\nPolicy #: WA-2026-7891\nEmergency line: +1-800-555-0199",
|
||||
tags: ["safety", "emergency", "contacts"],
|
||||
editor: "Omar",
|
||||
editedAt: "3 days ago",
|
||||
},
|
||||
{
|
||||
id: "demo-note-photos",
|
||||
type: "folk-note",
|
||||
x: 850, y: 540, width: 300, height: 180, rotation: 0,
|
||||
noteTitle: "Photo Spots",
|
||||
content: "## Must-Capture Locations\n1. Lac Blanc reflection of Mont Blanc (sunrise)\n2. Gornergrat panorama with Matterhorn\n3. Tre Cime from Rifugio Locatelli (golden hour)\n4. Seceda ridgeline drone shots\n5. Lago di Braies turquoise water\n\nBring ND filters for long exposure water shots.",
|
||||
tags: ["photography", "planning", "creative"],
|
||||
editor: "Liam",
|
||||
editedAt: "2 days ago",
|
||||
},
|
||||
|
||||
// ─── rVote: Polls ───────────────────────────────────────────
|
||||
{
|
||||
id: "demo-poll-day5",
|
||||
type: "demo-poll",
|
||||
x: 50, y: 400, width: 350, height: 200, rotation: 0,
|
||||
question: "Day 5 Activity — What should we do in Chamonix?",
|
||||
options: [
|
||||
{ label: "Via Ferrata at Aiguille du Midi", votes: 7 },
|
||||
{ label: "Kayaking on Lac d'Annecy", votes: 3 },
|
||||
{ label: "Rest day in town", votes: 2 },
|
||||
],
|
||||
totalVoters: 4,
|
||||
status: "active",
|
||||
endsAt: "2026-07-01",
|
||||
},
|
||||
{
|
||||
id: "demo-poll-dinner",
|
||||
type: "demo-poll",
|
||||
x: 50, y: 630, width: 350, height: 200, rotation: 0,
|
||||
question: "First night dinner in Zermatt?",
|
||||
options: [
|
||||
{ label: "Traditional fondue at Chez Vrony", votes: 5 },
|
||||
{ label: "Pizza at Grampi's", votes: 4 },
|
||||
{ label: "Cook at the Airbnb", votes: 1 },
|
||||
],
|
||||
totalVoters: 4,
|
||||
status: "active",
|
||||
endsAt: "2026-07-05",
|
||||
},
|
||||
|
||||
// ─── rCart: Group Shopping Items ─────────────────────────────
|
||||
{
|
||||
id: "demo-cart-firstaid",
|
||||
type: "demo-cart-item",
|
||||
x: 50, y: 870, width: 320, height: 60, rotation: 0,
|
||||
name: "Adventure First-Aid Kit",
|
||||
price: 85.00,
|
||||
funded: 85.00,
|
||||
status: "Funded",
|
||||
requestedBy: "Omar",
|
||||
store: "REI",
|
||||
},
|
||||
{
|
||||
id: "demo-cart-waterfilter",
|
||||
type: "demo-cart-item",
|
||||
x: 50, y: 940, width: 320, height: 60, rotation: 0,
|
||||
name: "Portable Water Filter (Sawyer Squeeze)",
|
||||
price: 45.00,
|
||||
funded: 45.00,
|
||||
status: "Funded",
|
||||
requestedBy: "Maya",
|
||||
store: "Amazon",
|
||||
},
|
||||
{
|
||||
id: "demo-cart-bearcan",
|
||||
type: "demo-cart-item",
|
||||
x: 50, y: 1010, width: 320, height: 60, rotation: 0,
|
||||
name: "Bear Canister 2x (BV500)",
|
||||
price: 120.00,
|
||||
funded: 80.00,
|
||||
status: "In Cart",
|
||||
requestedBy: "Liam",
|
||||
store: "REI",
|
||||
},
|
||||
{
|
||||
id: "demo-cart-stove",
|
||||
type: "demo-cart-item",
|
||||
x: 50, y: 1080, width: 320, height: 60, rotation: 0,
|
||||
name: "Camp Stove + Fuel Canister",
|
||||
price: 65.00,
|
||||
funded: 65.00,
|
||||
status: "Funded",
|
||||
requestedBy: "Priya",
|
||||
store: "Decathlon",
|
||||
},
|
||||
{
|
||||
id: "demo-cart-drone",
|
||||
type: "demo-cart-item",
|
||||
x: 50, y: 1150, width: 320, height: 60, rotation: 0,
|
||||
name: "DJI Mini 4 Pro Rental (2 weeks)",
|
||||
price: 350.00,
|
||||
funded: 100.00,
|
||||
status: "Needs Funding",
|
||||
requestedBy: "Liam",
|
||||
store: "LensRentals",
|
||||
},
|
||||
{
|
||||
id: "demo-cart-starlink",
|
||||
type: "demo-cart-item",
|
||||
x: 50, y: 1220, width: 320, height: 60, rotation: 0,
|
||||
name: "Starlink Mini Rental (2 weeks)",
|
||||
price: 200.00,
|
||||
funded: 50.00,
|
||||
status: "Needs Funding",
|
||||
requestedBy: "Omar",
|
||||
store: "StarRent.eu",
|
||||
},
|
||||
|
||||
// ─── rFunds: Expenses ───────────────────────────────────────
|
||||
{
|
||||
id: "demo-expense-shuttle",
|
||||
type: "demo-expense",
|
||||
x: 400, y: 870, width: 320, height: 60, rotation: 0,
|
||||
description: "Geneva → Chamonix shuttle (4 pax)",
|
||||
amount: 186.00,
|
||||
currency: "EUR",
|
||||
paidBy: "Maya",
|
||||
split: "equal",
|
||||
category: "transport",
|
||||
date: "2026-07-06",
|
||||
},
|
||||
{
|
||||
id: "demo-expense-hut",
|
||||
type: "demo-expense",
|
||||
x: 400, y: 940, width: 320, height: 60, rotation: 0,
|
||||
description: "Mountain hut reservations (3 nights total)",
|
||||
amount: 420.00,
|
||||
currency: "EUR",
|
||||
paidBy: "Priya",
|
||||
split: "equal",
|
||||
category: "accommodation",
|
||||
date: "2026-07-07",
|
||||
},
|
||||
{
|
||||
id: "demo-expense-viaferrata",
|
||||
type: "demo-expense",
|
||||
x: 400, y: 1010, width: 320, height: 60, rotation: 0,
|
||||
description: "Via Ferrata gear rental (4 sets)",
|
||||
amount: 144.00,
|
||||
currency: "EUR",
|
||||
paidBy: "Liam",
|
||||
split: "equal",
|
||||
category: "activity",
|
||||
date: "2026-07-08",
|
||||
},
|
||||
{
|
||||
id: "demo-expense-groceries",
|
||||
type: "demo-expense",
|
||||
x: 400, y: 1080, width: 320, height: 60, rotation: 0,
|
||||
description: "Groceries — Chamonix Carrefour",
|
||||
amount: 93.00,
|
||||
currency: "EUR",
|
||||
paidBy: "Omar",
|
||||
split: "equal",
|
||||
category: "food",
|
||||
date: "2026-07-06",
|
||||
},
|
||||
{
|
||||
id: "demo-expense-paragliding",
|
||||
type: "demo-expense",
|
||||
x: 400, y: 1150, width: 320, height: 60, rotation: 0,
|
||||
description: "Paragliding deposit (2 of 4 booked)",
|
||||
amount: 360.00,
|
||||
currency: "CHF",
|
||||
paidBy: "Maya",
|
||||
split: "custom",
|
||||
category: "activity",
|
||||
date: "2026-07-13",
|
||||
},
|
||||
|
||||
// ─── rFunds: Budget ─────────────────────────────────────────
|
||||
{
|
||||
id: "demo-budget-trip",
|
||||
type: "folk-budget",
|
||||
x: 400, y: 400, width: 350, height: 250, rotation: 0,
|
||||
budgetTitle: "Trip Budget",
|
||||
currency: "EUR",
|
||||
budgetTotal: 4000,
|
||||
spent: 1203,
|
||||
categories: [
|
||||
{ name: "Transport", budget: 800, spent: 186 },
|
||||
{ name: "Accommodation", budget: 1200, spent: 420 },
|
||||
{ name: "Activities", budget: 1000, spent: 504 },
|
||||
{ name: "Food", budget: 600, spent: 93 },
|
||||
{ name: "Gear", budget: 400, spent: 0 },
|
||||
],
|
||||
},
|
||||
|
||||
// ─── rMaps: Location Markers ────────────────────────────────
|
||||
{
|
||||
id: "demo-map-chamonix",
|
||||
type: "demo-map-marker",
|
||||
x: 750, y: 750, width: 40, height: 40, rotation: 0,
|
||||
name: "Chamonix",
|
||||
lat: 45.9237,
|
||||
lng: 6.8694,
|
||||
emoji: "🏔️",
|
||||
category: "destination",
|
||||
status: "Jul 6-10",
|
||||
},
|
||||
{
|
||||
id: "demo-map-zermatt",
|
||||
type: "demo-map-marker",
|
||||
x: 800, y: 750, width: 40, height: 40, rotation: 0,
|
||||
name: "Zermatt",
|
||||
lat: 46.0207,
|
||||
lng: 7.7491,
|
||||
emoji: "⛷️",
|
||||
category: "destination",
|
||||
status: "Jul 10-14",
|
||||
},
|
||||
{
|
||||
id: "demo-map-dolomites",
|
||||
type: "demo-map-marker",
|
||||
x: 850, y: 750, width: 40, height: 40, rotation: 0,
|
||||
name: "Dolomites",
|
||||
lat: 46.4102,
|
||||
lng: 11.8440,
|
||||
emoji: "🏞️",
|
||||
category: "destination",
|
||||
status: "Jul 14-20",
|
||||
},
|
||||
{
|
||||
id: "demo-map-lacblanc",
|
||||
type: "demo-map-marker",
|
||||
x: 900, y: 750, width: 40, height: 40, rotation: 0,
|
||||
name: "Lac Blanc Hike",
|
||||
lat: 45.9785,
|
||||
lng: 6.8891,
|
||||
emoji: "🥾",
|
||||
category: "activity",
|
||||
status: "Jul 7",
|
||||
},
|
||||
{
|
||||
id: "demo-map-viaferrata",
|
||||
type: "demo-map-marker",
|
||||
x: 950, y: 750, width: 40, height: 40, rotation: 0,
|
||||
name: "Via Ferrata",
|
||||
lat: 45.8786,
|
||||
lng: 6.8874,
|
||||
emoji: "🧗",
|
||||
category: "activity",
|
||||
status: "Jul 8",
|
||||
},
|
||||
{
|
||||
id: "demo-map-trecime",
|
||||
type: "demo-map-marker",
|
||||
x: 1000, y: 750, width: 40, height: 40, rotation: 0,
|
||||
name: "Tre Cime Loop",
|
||||
lat: 46.6190,
|
||||
lng: 12.3018,
|
||||
emoji: "🏔️",
|
||||
category: "activity",
|
||||
status: "Jul 15",
|
||||
},
|
||||
|
||||
// ─── rNotes: Packing List (shared with rTrips) ──────────────
|
||||
{
|
||||
id: "demo-packing-alpine",
|
||||
type: "folk-packing-list",
|
||||
x: 1200, y: 750, width: 300, height: 300, rotation: 0,
|
||||
listTitle: "Alpine Explorer Packing",
|
||||
items: [
|
||||
{ name: "Hiking boots (broken in)", packed: true, category: "footwear" },
|
||||
{ name: "Rain jacket", packed: true, category: "clothing" },
|
||||
{ name: "Trekking poles", packed: false, category: "gear" },
|
||||
{ name: "Headlamp + batteries", packed: true, category: "gear" },
|
||||
{ name: "Sunscreen SPF 50", packed: false, category: "personal" },
|
||||
{ name: "Water filter", packed: false, category: "gear" },
|
||||
{ name: "First aid kit", packed: true, category: "safety" },
|
||||
{ name: "Passport + insurance", packed: true, category: "documents" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Ensure the demo community exists and is seeded with data.
|
||||
* Called on server startup and after demo reset.
|
||||
*/
|
||||
export async function ensureDemoCommunity(): Promise<void> {
|
||||
const exists = await communityExists("demo");
|
||||
|
||||
if (!exists) {
|
||||
await createCommunity("r* Ecosystem Demo", "demo", null, "public");
|
||||
console.log("[Demo] Created demo community with visibility: public");
|
||||
} else {
|
||||
await loadCommunity("demo");
|
||||
}
|
||||
|
||||
// Check if already seeded (has shapes)
|
||||
const data = getDocumentData("demo");
|
||||
const shapeCount = data ? Object.keys(data.shapes || {}).length : 0;
|
||||
|
||||
if (shapeCount === 0) {
|
||||
addShapes("demo", DEMO_SHAPES);
|
||||
console.log(`[Demo] Seeded ${DEMO_SHAPES.length} shapes into demo community`);
|
||||
} else {
|
||||
console.log(`[Demo] Demo community already has ${shapeCount} shapes`);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,221 @@
|
|||
/**
|
||||
* useDemoSync — lightweight React hook for real-time demo data via rSpace
|
||||
*
|
||||
* Connects to rSpace WebSocket in JSON mode (no Automerge bundle needed).
|
||||
* All demo pages share the "demo" community, so changes in one app
|
||||
* propagate to every other app viewing the same shapes.
|
||||
*
|
||||
* Usage:
|
||||
* const { shapes, updateShape, deleteShape, connected, resetDemo } = useDemoSync({
|
||||
* filter: ['folk-note', 'folk-notebook'], // optional: only these shape types
|
||||
* });
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
|
||||
export interface DemoShape {
|
||||
type: string;
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
rotation: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface UseDemoSyncOptions {
|
||||
/** Community slug (default: 'demo') */
|
||||
slug?: string;
|
||||
/** Only subscribe to these shape types */
|
||||
filter?: string[];
|
||||
/** rSpace server URL (default: auto-detect based on environment) */
|
||||
serverUrl?: string;
|
||||
}
|
||||
|
||||
interface UseDemoSyncReturn {
|
||||
/** Current shapes (filtered if filter option set) */
|
||||
shapes: Record<string, DemoShape>;
|
||||
/** Update a shape by ID (partial update merged with existing) */
|
||||
updateShape: (id: string, data: Partial<DemoShape>) => void;
|
||||
/** Delete a shape by ID */
|
||||
deleteShape: (id: string) => void;
|
||||
/** Whether WebSocket is connected */
|
||||
connected: boolean;
|
||||
/** Reset demo to seed state */
|
||||
resetDemo: () => Promise<void>;
|
||||
}
|
||||
|
||||
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';
|
||||
// In development, use localhost
|
||||
if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') {
|
||||
return `http://${window.location.hostname}:3000`;
|
||||
}
|
||||
return 'https://rspace.online';
|
||||
}
|
||||
|
||||
export function useDemoSync(options?: UseDemoSyncOptions): UseDemoSyncReturn {
|
||||
const slug = options?.slug ?? DEFAULT_SLUG;
|
||||
const filter = options?.filter;
|
||||
const serverUrl = options?.serverUrl ?? getDefaultServerUrl();
|
||||
|
||||
const [shapes, setShapes] = useState<Record<string, DemoShape>>({});
|
||||
const [connected, setConnected] = useState(false);
|
||||
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const reconnectAttemptRef = useRef(0);
|
||||
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const pingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
// Stable filter reference for use in callbacks
|
||||
const filterRef = useRef(filter);
|
||||
filterRef.current = filter;
|
||||
|
||||
const applyFilter = useCallback((allShapes: Record<string, DemoShape>): Record<string, DemoShape> => {
|
||||
const f = filterRef.current;
|
||||
if (!f || f.length === 0) return allShapes;
|
||||
const filtered: Record<string, DemoShape> = {};
|
||||
for (const [id, shape] of Object.entries(allShapes)) {
|
||||
if (f.includes(shape.type)) {
|
||||
filtered[id] = shape;
|
||||
}
|
||||
}
|
||||
return filtered;
|
||||
}, []);
|
||||
|
||||
const connect = useCallback(() => {
|
||||
if (!mountedRef.current) return;
|
||||
|
||||
// Build WebSocket URL
|
||||
const wsProtocol = serverUrl.startsWith('https') ? 'wss' : 'ws';
|
||||
const host = serverUrl.replace(/^https?:\/\//, '');
|
||||
const wsUrl = `${wsProtocol}://${host}/ws/${slug}?mode=json`;
|
||||
|
||||
const ws = new WebSocket(wsUrl);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
if (!mountedRef.current) return;
|
||||
setConnected(true);
|
||||
reconnectAttemptRef.current = 0;
|
||||
|
||||
// Start ping keepalive
|
||||
pingTimerRef.current = setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'ping', timestamp: Date.now() }));
|
||||
}
|
||||
}, PING_INTERVAL_MS);
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
if (!mountedRef.current) return;
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.type === 'snapshot' && msg.shapes) {
|
||||
setShapes(applyFilter(msg.shapes));
|
||||
}
|
||||
// pong and error messages are silently handled
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
if (!mountedRef.current) return;
|
||||
setConnected(false);
|
||||
cleanup();
|
||||
scheduleReconnect();
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
// onclose will fire after onerror, so reconnect is handled there
|
||||
};
|
||||
}, [slug, serverUrl, applyFilter]);
|
||||
|
||||
const cleanup = useCallback(() => {
|
||||
if (pingTimerRef.current) {
|
||||
clearInterval(pingTimerRef.current);
|
||||
pingTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const scheduleReconnect = useCallback(() => {
|
||||
if (!mountedRef.current) return;
|
||||
const attempt = reconnectAttemptRef.current;
|
||||
const delay = Math.min(RECONNECT_BASE_MS * Math.pow(2, attempt), RECONNECT_MAX_MS);
|
||||
reconnectAttemptRef.current = attempt + 1;
|
||||
|
||||
reconnectTimerRef.current = setTimeout(() => {
|
||||
if (mountedRef.current) connect();
|
||||
}, delay);
|
||||
}, [connect]);
|
||||
|
||||
// Connect on mount
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
cleanup();
|
||||
if (reconnectTimerRef.current) {
|
||||
clearTimeout(reconnectTimerRef.current);
|
||||
reconnectTimerRef.current = null;
|
||||
}
|
||||
if (wsRef.current) {
|
||||
wsRef.current.onclose = null; // prevent reconnect on unmount
|
||||
wsRef.current.close();
|
||||
wsRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [connect, cleanup]);
|
||||
|
||||
const updateShape = useCallback((id: string, data: Partial<DemoShape>) => {
|
||||
const ws = wsRef.current;
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
|
||||
// Optimistic local update
|
||||
setShapes((prev) => {
|
||||
const existing = prev[id];
|
||||
if (!existing) return prev;
|
||||
const updated = { ...existing, ...data, id };
|
||||
const f = filterRef.current;
|
||||
if (f && f.length > 0 && !f.includes(updated.type)) return prev;
|
||||
return { ...prev, [id]: updated };
|
||||
});
|
||||
|
||||
// Send to server
|
||||
ws.send(JSON.stringify({ type: 'update', id, data: { ...data, id } }));
|
||||
}, []);
|
||||
|
||||
const deleteShape = useCallback((id: string) => {
|
||||
const ws = wsRef.current;
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
|
||||
// Optimistic local delete
|
||||
setShapes((prev) => {
|
||||
const { [id]: _, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
|
||||
ws.send(JSON.stringify({ type: 'delete', id }));
|
||||
}, []);
|
||||
|
||||
const resetDemo = useCallback(async () => {
|
||||
const res = await fetch(`${serverUrl}/api/communities/demo/reset`, { method: 'POST' });
|
||||
if (!res.ok) {
|
||||
const body = await res.text();
|
||||
throw new Error(`Reset failed: ${res.status} ${body}`);
|
||||
}
|
||||
// The server will broadcast new snapshot via WebSocket
|
||||
}, [serverUrl]);
|
||||
|
||||
return { shapes, updateShape, deleteShape, connected, resetDemo };
|
||||
}
|
||||
Loading…
Reference in New Issue