diff --git a/src/app/demo/demo-content.tsx b/src/app/demo/demo-content.tsx new file mode 100644 index 0000000..11dc040 --- /dev/null +++ b/src/app/demo/demo-content.tsx @@ -0,0 +1,778 @@ +'use client' + +import Link from 'next/link' +import { useState, useMemo } from 'react' +import { useDemoSync, type DemoShape } from '@/lib/demo-sync' + +/* ─── Types ─────────────────────────────────────────────────── */ + +interface MapMarker { + id: string + name: string + lat: number + lng: number + emoji: string + category: 'destination' | 'activity' + status: string +} + +interface Destination { + id: string + destName: string + country: string + lat: number + lng: number + arrivalDate: string + departureDate: string + notes: string +} + +/* ─── Coordinate Projection ─────────────────────────────────── */ + +/** Project lat/lng onto SVG viewport (800x400) for the Alpine region */ +function toSvg(lat: number, lng: number): { x: number; y: number } { + const x = ((lng - 6.5) / (12.5 - 6.5)) * 700 + 50 + const y = ((47.0 - lat) / (47.0 - 45.5)) * 300 + 50 + return { x, y } +} + +/* ─── Country colors ────────────────────────────────────────── */ + +const COUNTRY_COLORS: Record = { + France: { + fill: '#14b8a6', + stroke: '#0d9488', + ring: 'rgba(20,184,166,0.3)', + bg: 'bg-teal-500/15', + text: 'text-teal-400', + label: 'teal', + }, + Switzerland: { + fill: '#06b6d4', + stroke: '#0891b2', + ring: 'rgba(6,182,212,0.3)', + bg: 'bg-cyan-500/15', + text: 'text-cyan-400', + label: 'cyan', + }, + Italy: { + fill: '#8b5cf6', + stroke: '#7c3aed', + ring: 'rgba(139,92,246,0.3)', + bg: 'bg-violet-500/15', + text: 'text-violet-400', + label: 'violet', + }, +} + +function getCountryForCoords(lat: number, lng: number): string { + // Simple heuristic based on longitude ranges in the Alps + if (lng < 8.0) return 'France' + if (lng < 10.0) return 'Switzerland' + return 'Italy' +} + +/* ─── Shape → typed helpers ─────────────────────────────────── */ + +function shapeToMarker(shape: DemoShape): MapMarker | null { + if (shape.type !== 'demo-map-marker') return null + return { + id: shape.id, + name: (shape.name as string) || 'Marker', + lat: (shape.lat as number) || 0, + lng: (shape.lng as number) || 0, + emoji: (shape.emoji as string) || '', + category: (shape.category as 'destination' | 'activity') || 'activity', + status: (shape.status as string) || '', + } +} + +function shapeToDestination(shape: DemoShape): Destination | null { + if (shape.type !== 'folk-destination') return null + return { + id: shape.id, + destName: (shape.destName as string) || 'Destination', + country: (shape.country as string) || '', + lat: (shape.lat as number) || 0, + lng: (shape.lng as number) || 0, + arrivalDate: (shape.arrivalDate as string) || '', + departureDate: (shape.departureDate as string) || '', + notes: (shape.notes as string) || '', + } +} + +/* ─── Fallback data ─────────────────────────────────────────── */ + +const FALLBACK_DESTINATIONS: Destination[] = [ + { id: 'fd-1', destName: 'Chamonix', country: 'France', lat: 45.92, lng: 6.87, arrivalDate: 'Jul 6', departureDate: 'Jul 11', notes: 'Base camp for Mont Blanc region. Hike to Lac Blanc and try via ferrata.' }, + { id: 'fd-2', destName: 'Zermatt', country: 'Switzerland', lat: 46.02, lng: 7.75, arrivalDate: 'Jul 12', departureDate: 'Jul 16', notes: 'Matterhorn views, Gorner Gorge, mountain biking.' }, + { id: 'fd-3', destName: 'Dolomites', country: 'Italy', lat: 46.41, lng: 11.84, arrivalDate: 'Jul 17', departureDate: 'Jul 20', notes: 'Tre Cime circuit and Lake Braies.' }, +] + +const FALLBACK_MARKERS: MapMarker[] = [ + { id: 'fm-1', name: 'Lac Blanc', lat: 45.97, lng: 6.88, emoji: '🥾', category: 'activity', status: 'Day hike - Jul 8' }, + { id: 'fm-2', name: 'Via Ferrata', lat: 45.90, lng: 6.92, emoji: '🧗', category: 'activity', status: 'Adventure day - Jul 11' }, + { id: 'fm-3', name: 'Gornergrat', lat: 46.00, lng: 7.78, emoji: '🚞', category: 'activity', status: 'Cog railway - Jul 13' }, + { id: 'fm-4', name: 'Paragliding', lat: 46.05, lng: 7.72, emoji: '🪂', category: 'activity', status: 'Weather permitting - Jul 15' }, + { id: 'fm-5', name: 'Tre Cime', lat: 46.62, lng: 12.30, emoji: '🏔️', category: 'activity', status: 'Iconic circuit - Jul 18' }, + { id: 'fm-6', name: 'Lake Braies', lat: 46.69, lng: 12.08, emoji: '🛶', category: 'activity', status: 'Kayaking - Jul 19' }, +] + +/* ─── Alpine Route Map SVG ──────────────────────────────────── */ + +function AlpineRouteMap({ + destinations, + markers, + selectedId, + onSelect, +}: { + destinations: Destination[] + markers: MapMarker[] + selectedId: string | null + onSelect: (id: string | null) => void +}) { + // Project destinations to SVG coords + const destPins = destinations.map((d) => ({ + ...d, + ...toSvg(d.lat, d.lng), + colors: COUNTRY_COLORS[d.country] || COUNTRY_COLORS.France, + })) + + // Project markers to SVG coords + const markerPins = markers.map((m) => { + const country = getCountryForCoords(m.lat, m.lng) + return { + ...m, + ...toSvg(m.lat, m.lng), + colors: COUNTRY_COLORS[country] || COUNTRY_COLORS.France, + } + }) + + // Build route path through destinations (sorted by longitude, west to east) + const sorted = [...destPins].sort((a, b) => a.lng - b.lng) + let routePath = '' + if (sorted.length >= 2) { + routePath = `M${sorted[0].x} ${sorted[0].y}` + for (let i = 1; i < sorted.length; i++) { + const prev = sorted[i - 1] + const curr = sorted[i] + const cx1 = prev.x + (curr.x - prev.x) * 0.4 + const cy1 = prev.y - 20 + const cx2 = prev.x + (curr.x - prev.x) * 0.6 + const cy2 = curr.y + 20 + routePath += ` C${cx1} ${cy1}, ${cx2} ${cy2}, ${curr.x} ${curr.y}` + } + } + + return ( +
+ { + // Click on background deselects + if ((e.target as SVGElement).tagName === 'svg' || (e.target as SVGElement).tagName === 'rect' || (e.target as SVGElement).tagName === 'path') { + onSelect(null) + } + }} + > + {/* Background */} + + + {/* Sky gradient */} + + + + + + + + + + + + + {/* Stars */} + {[120, 230, 380, 520, 670, 90, 450, 740].map((cx, i) => ( + + ))} + + {/* Far mountain silhouette (background) */} + + + {/* Snow caps on far mountains */} + + + + + + + + + {/* Near mountain silhouette (midground) */} + + + {/* Valley/ground */} + + + {/* Country region shading */} + + + + + {/* Country labels at bottom */} + FRANCE + SWITZERLAND + ITALY + + {/* Route line (dashed) */} + {routePath && ( + <> + {/* Glow */} + + {/* Main line */} + + + )} + + {/* Activity markers */} + {markerPins.map((m) => { + const isSelected = selectedId === m.id + return ( + { + e.stopPropagation() + onSelect(isSelected ? null : m.id) + }} + > + {/* Selection ring */} + {isSelected && ( + + + + + )} + {/* Dot */} + + {/* Emoji */} + {m.emoji} + {/* Name label */} + + + {m.name} + + + ) + })} + + {/* Destination pins (larger, more prominent) */} + {destPins.map((d) => { + const isSelected = selectedId === d.id + return ( + { + e.stopPropagation() + onSelect(isSelected ? null : d.id) + }} + > + {/* Pulse ring */} + + + + + {/* Selection highlight */} + {isSelected && ( + + )} + {/* Pin */} + + + {/* Name */} + + {d.destName} + + {/* Dates */} + + {d.arrivalDate}{d.departureDate ? ` - ${d.departureDate}` : ''} + + + ) + })} + + + {/* Map legend */} +
+ + France + + + Switzerland + + + Italy + +
+
+ ) +} + +/* ─── Locations Panel ────────────────────────────────────────── */ + +function LocationsPanel({ + destinations, + markers, + selectedId, + onSelect, + connected, + onReset, +}: { + destinations: Destination[] + markers: MapMarker[] + selectedId: string | null + onSelect: (id: string | null) => void + connected: boolean + onReset: () => void +}) { + const [resetting, setResetting] = useState(false) + + const handleReset = async () => { + setResetting(true) + try { + await onReset() + } catch { + // ignore + } finally { + setTimeout(() => setResetting(false), 1000) + } + } + + return ( +
+ {/* Header */} +
+
+ 📍 + Locations +
+ + {destinations.length} destinations, {markers.length} activities + +
+ + {/* Destinations */} +
+

Destinations

+
+ {destinations.map((d) => { + const colors = COUNTRY_COLORS[d.country] || COUNTRY_COLORS.France + const isSelected = selectedId === d.id + return ( + + ) + })} +
+
+ + {/* Activities */} +
+

Activities

+
+ {markers.map((m) => { + const country = getCountryForCoords(m.lat, m.lng) + const colors = COUNTRY_COLORS[country] || COUNTRY_COLORS.France + const isSelected = selectedId === m.id + return ( + + ) + })} +
+
+ + {/* Detail panel for selected item */} + {selectedId && (() => { + const dest = destinations.find((d) => d.id === selectedId) + const marker = markers.find((m) => m.id === selectedId) + if (dest) { + const colors = COUNTRY_COLORS[dest.country] || COUNTRY_COLORS.France + return ( +
+
+
+ {dest.destName[0]} +
+

{dest.destName}

+ {dest.country} +
+

+ {dest.arrivalDate}{dest.departureDate ? ` - ${dest.departureDate}` : ''} +

+ {dest.notes && ( +

{dest.notes}

+ )} +

+ {dest.lat.toFixed(2)}N, {dest.lng.toFixed(2)}E +

+
+ ) + } + if (marker) { + return ( +
+
+ {marker.emoji} +

{marker.name}

+
+

{marker.status}

+

+ {marker.lat.toFixed(2)}N, {marker.lng.toFixed(2)}E +

+
+ ) + } + return null + })()} + + {/* Connection status & reset */} +
+
+ + + {connected ? 'Connected to rSpace' : 'Connecting...'} + +
+ +
+
+ ) +} + +/* ─── Main Demo Content ─────────────────────────────────────── */ + +export default function DemoContent() { + const { shapes, connected, resetDemo } = useDemoSync({ + filter: ['demo-map-marker', 'folk-destination'], + }) + + const [selectedId, setSelectedId] = useState(null) + + // Parse shapes into typed arrays + const { destinations, markers } = useMemo(() => { + const dests: Destination[] = [] + const marks: MapMarker[] = [] + + for (const shape of Object.values(shapes)) { + const d = shapeToDestination(shape) + if (d) { + dests.push(d) + continue + } + const m = shapeToMarker(shape) + if (m) { + marks.push(m) + } + } + + return { destinations: dests, markers: marks } + }, [shapes]) + + // Use live data if available, otherwise fallback + const displayDests = destinations.length > 0 ? destinations : FALLBACK_DESTINATIONS + const displayMarkers = markers.length > 0 ? markers : FALLBACK_MARKERS + const isLive = destinations.length > 0 || markers.length > 0 + + return ( +
+ {/* Nav */} + + + {/* Hero / Trip Header */} +
+
+
+ + {isLive && connected ? 'Live -- connected to rSpace' : connected ? 'Live Demo' : 'Connecting...'} +
+

+ Alpine Explorer 2026 +

+

+ Chamonix → Zermatt → Dolomites +

+
+ 📅 Jul 6-20, 2026 + 🏔️ 3 countries + 📍 {displayDests.length} destinations, {displayMarkers.length} activities +
+ + {/* Destination badges */} +
+ {displayDests.map((d) => { + const colors = COUNTRY_COLORS[d.country] || COUNTRY_COLORS.France + return ( +
+
+ {d.destName[0]} +
+ {d.destName} +
+ ) + })} +
+
+
+ + {/* Intro text */} +
+

+ This demo shows how rMaps visualizes trip + routes with live data from rSpace. Destinations + and activity markers update in real time as data changes in the shared community. +

+
+ + {/* Main Demo Content */} +
+
+ {/* Map - takes 2 columns */} +
+
+
+
+ 🗺️ + Route Map + {isLive && ( + + + live + + )} +
+
+ + {displayDests.length + displayMarkers.length} markers + +
+
+
+ +
+
+
+ + {/* Locations Panel - 1 column */} +
+ +
+
+
+ + {/* How It Works */} +
+

How rMaps Works for Trip Planning

+
+
+
+ 📍 +
+

Plot Your Route

+

+ Pin destinations on the map and connect them into a route. Add activities near each stop. +

+
+
+
+ 🔄 +
+

Sync in Real Time

+

+ Powered by rSpace, every marker updates live. Your whole crew sees changes instantly. +

+
+
+
+ 🌍 +
+

Explore Together

+

+ Click any marker for details. Track dates, notes, and status for every stop on the journey. +

+
+
+
+ + {/* Bottom CTA */} +
+
+

Ready to Map Your Adventure?

+

+ Create a map for your next trip. Pin destinations, mark activities, and share with your group -- all synced in real time. +

+ + Create Your Map + +
+
+ + {/* Footer */} + +
+ ) +} diff --git a/src/app/demo/page.tsx b/src/app/demo/page.tsx index 734bd82..11bb324 100644 --- a/src/app/demo/page.tsx +++ b/src/app/demo/page.tsx @@ -1,481 +1,18 @@ -import Link from 'next/link' import type { Metadata } from 'next' +import DemoContent from './demo-content' export const metadata: Metadata = { - title: 'rMaps Demo - CCC Camp 2026', - description: 'See how rMaps helps you find your friends at events. A demo showcasing real-time location sharing, event maps, and privacy-first friend finding at CCC Camp 2026.', + title: 'rMaps Demo - Alpine Explorer 2026', + description: 'See how rMaps visualizes trip routes with live data from rSpace. A demo showcasing real-time map markers, destination pins, and activity tracking across the Alps.', openGraph: { - title: 'rMaps Demo - CCC Camp 2026', - description: 'Privacy-first real-time location sharing for events. Find your crew at CCC Camp 2026.', + title: 'rMaps Demo - Alpine Explorer 2026', + description: 'Real-time trip route mapping powered by rSpace. Track destinations and activities across Chamonix, Zermatt, and the Dolomites.', url: 'https://rmaps.online/demo', siteName: 'rMaps', type: 'website', }, } -/* ─── Mock Data ─────────────────────────────────────────────── */ - -const friends = [ - { - name: 'Mika', - emoji: '🦊', - color: '#10b981', - status: 'At Main Stage', - lastSeen: '1 min ago', - x: 520, - y: 155, - online: true, - }, - { - name: 'Zara', - emoji: '🐙', - color: '#6366f1', - status: 'Heading to Food Court', - lastSeen: '3 min ago', - x: 340, - y: 260, - online: true, - }, - { - name: 'Leo', - emoji: '🐻', - color: '#f59e0b', - status: 'In Tent', - lastSeen: '12 min ago', - x: 160, - y: 310, - online: false, - }, - { - name: 'Ren', - emoji: '🦉', - color: '#ef4444', - status: 'At Workshop Hall', - lastSeen: '2 min ago', - x: 640, - y: 290, - online: true, - }, - { - name: 'Juno', - emoji: '🐧', - color: '#ec4899', - status: 'Getting coffee', - lastSeen: '5 min ago', - x: 400, - y: 180, - online: true, - }, - { - name: 'Kai', - emoji: '🐝', - color: '#06b6d4', - status: 'At Soldering Village', - lastSeen: '8 min ago', - x: 280, - y: 130, - online: true, - }, -] - -const statusOptions = [ - 'At Main Stage', - 'Heading to Food Court', - 'In Tent', - 'At Workshop Hall', - 'Getting coffee', - 'At Soldering Village', - 'Exploring', - 'At the Lake', - 'On my way!', -] - -/* ─── SVG Camp Map ─────────────────────────────────────────── */ - -function CampMap() { - return ( -
- - {/* Background terrain */} - - - {/* Grass / ground patches */} - - - - {/* Paths - main walkways */} - - - - - - {/* ── Area: Main Stage (top right) ── */} - - - STAGE - Main Stage - - {/* ── Area: Food Court (center-left) ── */} - - {/* Food stall icons */} - - - - - - - Food Court - - {/* ── Area: Workshops (right) ── */} - - - - - - Workshops - - {/* ── Area: Camping (bottom-left) ── */} - - {/* Tent icons */} - - - - - Camping - - {/* ── Area: Soldering Village (top-left) ── */} - - - - Soldering Village - - {/* ── Area: Info / Coffee (center) ── */} - - - INFO - - {/* ── Entrance marker ── */} - - ENTER - - {/* ── Trees / decoration ── */} - - - - - - - {/* ── Friend markers ── */} - {friends.map((f) => ( - - {/* Pulse ring for online friends */} - {f.online && ( - - - - - )} - {/* Dot */} - - {/* Emoji */} - {f.emoji} - {/* Name label */} - - {f.name} - - ))} - - - {/* Map legend */} -
- - Stage - - - Food - - - Workshops - - - Camping - -
- - {/* Simulated live badge */} -
- - Live -
-
- ) -} - -/* ─── Friend List Panel ────────────────────────────────────── */ - -function FriendListPanel() { - return ( -
-
-
- 👥 - Friends in Room -
- {friends.filter((f) => f.online).length}/{friends.length} online -
-
- {friends.map((f) => ( -
- {/* Avatar */} -
- {f.emoji} -
- - {/* Info */} -
-
- {f.name} - -
-

{f.status}

-
- - {/* Last seen */} - {f.lastSeen} -
- ))} -
- - {/* Status selector preview */} -
-

Your status:

-
- {statusOptions.slice(0, 5).map((s) => ( - - {s} - - ))} - - +{statusOptions.length - 5} more - -
-
-
- ) -} - -/* ─── Page ──────────────────────────────────────────────────── */ - export default function DemoPage() { - return ( -
- {/* Nav */} - - - {/* Hero / Event Header */} -
-
-
- - Live Demo -
-

- CCC Camp 2026 -

-

- Find your friends across the campground in real time -

-
- 📍 Ziegeleipark Mildenberg - 📅 Aug 2026 - 👥 6 friends connected -
- - {/* Member avatars */} -
- {friends.map((f) => ( -
- {f.emoji} -
- ))} - 6 friends -
-
-
- - {/* Intro text */} -
-

- This demo shows how rMaps works at a - real event. See your friends on the camp map, check their status, and find each other without - sharing your location with any central server. -

-
- - {/* Main Demo Content */} -
-
- {/* Map - takes 2 columns */} -
-
-
-
- 🗺️ - Camp Map -
-
- Last updated 2 min ago -
-
-
- -
-
-
- - {/* Friend List - 1 column */} -
- -
-
-
- - {/* How It Works (brief) */} -
-

How rMaps Works at Events

-
-
-
- 📱 -
-

Open & Share

-

- Create a map room and share the link with your crew. No app install needed. -

-
-
-
- 📍 -
-

See Everyone

-

- Friends appear on the map in real time. Set your status so everyone knows where you are. -

-
-
-
- 🔒 -
-

Stay Private

-

- Only people in your room see your location. Go invisible anytime. No tracking. -

-
-
-
- - {/* Bottom CTA */} -
-
-

Ready to Find Your Friends?

-

- Create a map room for your next event. Share the link, and never lose your crew in the crowd again. -

- - Create Your Map - -
-
- - {/* Footer */} - -
- ) + return } diff --git a/src/lib/demo-sync.ts b/src/lib/demo-sync.ts new file mode 100644 index 0000000..6487586 --- /dev/null +++ b/src/lib/demo-sync.ts @@ -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; + /** Update a shape by ID (partial update merged with existing) */ + updateShape: (id: string, data: Partial) => void; + /** Delete a shape by ID */ + deleteShape: (id: string) => void; + /** Whether WebSocket is connected */ + connected: boolean; + /** Reset demo to seed state */ + resetDemo: () => Promise; +} + +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>({}); + const [connected, setConnected] = useState(false); + + const wsRef = useRef(null); + const reconnectAttemptRef = useRef(0); + const reconnectTimerRef = useRef | null>(null); + const pingTimerRef = useRef | 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): Record => { + const f = filterRef.current; + if (!f || f.length === 0) return allShapes; + const filtered: Record = {}; + 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) => { + 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 }; +}