From 56901d8e45d1c7d229d17487dc41e0c86ceb5cb4 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Wed, 25 Feb 2026 13:19:57 -0800 Subject: [PATCH] feat: add space subdomain routing and ownership support - Traefik wildcard HostRegexp for .r*.online subdomains - Middleware subdomain extraction and path rewriting - Provision endpoint with owner_did acknowledgement - Registry enforces space ownership via EncryptID JWT Co-Authored-By: Claude Opus 4.6 --- docker-compose.yml | 2 +- package-lock.json | 12 +- src/app/api/internal/provision/route.ts | 15 +- src/app/demo/demo-content.tsx | 779 ------------------------ src/app/demo/page.tsx | 18 - src/app/layout.tsx | 14 +- src/app/page.tsx | 13 +- src/lib/demo-sync.ts | 221 ------- 8 files changed, 39 insertions(+), 1035 deletions(-) delete mode 100644 src/app/demo/demo-content.tsx delete mode 100644 src/app/demo/page.tsx delete mode 100644 src/lib/demo-sync.ts diff --git a/docker-compose.yml b/docker-compose.yml index c0868f1..792c993 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,7 +14,7 @@ services: - PORT=3000 labels: - "traefik.enable=true" - - "traefik.http.routers.rmaps.rule=Host(`rmaps.online`) || Host(`www.rmaps.online`)" + - "traefik.http.routers.rmaps.rule=Host(`rmaps.online`) || Host(`www.rmaps.online`) || HostRegexp(`{subdomain:[a-z0-9-]+}.rmaps.online`)" - "traefik.http.routers.rmaps.entrypoints=web" - "traefik.http.services.rmaps.loadbalancer.server.port=3000" networks: diff --git a/package-lock.json b/package-lock.json index 8ab2edc..afd26a9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "maplibre-gl": "^5.0.0", "nanoid": "^5.0.9", "next": "^14.2.28", + "qrcode.react": "^4.2.0", "react": "^18.3.1", "react-dom": "^18.3.1", "zustand": "^5.0.2" @@ -31,7 +32,7 @@ }, "../encryptid-sdk": { "name": "@encryptid/sdk", - "version": "0.1.0", + "version": "0.2.0", "license": "MIT", "dependencies": { "@noble/curves": "^2.0.1", @@ -5000,6 +5001,15 @@ "node": ">=6" } }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", diff --git a/src/app/api/internal/provision/route.ts b/src/app/api/internal/provision/route.ts index baf3321..2b23fb9 100644 --- a/src/app/api/internal/provision/route.ts +++ b/src/app/api/internal/provision/route.ts @@ -6,6 +6,13 @@ import { NextResponse } from "next/server"; * * rmaps uses client-side rooms keyed by space slug. * Acknowledges provisioning; room is created when users join. + * + * Payload from registry: + * { space, description, admin_email, public, owner_did } + * + * The owner_did identifies who registered the space and is the only person + * (besides admin API key holders) who can modify it via the registry. + * Apps can query GET registry.rspace.online/spaces/{name}/owner to verify. */ export async function POST(request: Request) { const body = await request.json(); @@ -13,5 +20,11 @@ export async function POST(request: Request) { if (!space) { return NextResponse.json({ error: "Missing space name" }, { status: 400 }); } - return NextResponse.json({ status: "ok", space, message: "rmaps space acknowledged, room created on first join" }); + const ownerDid: string = body.owner_did || ""; + return NextResponse.json({ + status: "ok", + space, + owner_did: ownerDid, + message: "rmaps space acknowledged, room created on first join", + }); } diff --git a/src/app/demo/demo-content.tsx b/src/app/demo/demo-content.tsx deleted file mode 100644 index e6c7948..0000000 --- a/src/app/demo/demo-content.tsx +++ /dev/null @@ -1,779 +0,0 @@ -'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 deleted file mode 100644 index 11bb324..0000000 --- a/src/app/demo/page.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import type { Metadata } from 'next' -import DemoContent from './demo-content' - -export const metadata: Metadata = { - 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 - 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', - }, -} - -export default function DemoPage() { - return -} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index ea67bbe..ca107ab 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -3,21 +3,21 @@ import './globals.css'; import { Header } from '@/components/Header'; export const metadata: Metadata = { - title: 'rMaps - Find Your Friends', - description: 'Collaborative real-time friend-finding navigation for events', - keywords: ['maps', 'navigation', 'friends', 'realtime', 'CCC', '39c3'], + title: 'rMaps - Collaborative Maps', + description: 'A flexible, collaborative map tool for real-time location sharing, event navigation, and group coordination.', + keywords: ['maps', 'collaborative', 'real-time', 'location sharing', 'navigation', 'events'], authors: [{ name: 'Jeff Emmett' }], openGraph: { - title: 'rMaps - Find Your Friends', - description: 'Collaborative real-time friend-finding navigation for events', + title: 'rMaps - Collaborative Maps', + description: 'A flexible, collaborative map tool for real-time location sharing, event navigation, and group coordination.', url: 'https://rmaps.online', siteName: 'rMaps', type: 'website', }, twitter: { card: 'summary_large_image', - title: 'rMaps - Find Your Friends', - description: 'Collaborative real-time friend-finding navigation for events', + title: 'rMaps - Collaborative Maps', + description: 'A flexible, collaborative map tool for real-time location sharing, event navigation, and group coordination.', }, manifest: '/manifest.json', icons: { diff --git a/src/app/page.tsx b/src/app/page.tsx index 9feafe1..770b2f5 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -2,7 +2,6 @@ import { useState, useEffect } from 'react'; import { useRouter } from 'next/navigation'; -import Link from 'next/link'; import { nanoid } from 'nanoid'; import { AuthButton } from '@/components/AuthButton'; import { EcosystemFooter } from '@/components/EcosystemFooter'; @@ -125,21 +124,21 @@ export default function HomePage() { {/* Headline */}

- Find Your Friends, Anywhere + Collaborative Maps for Everyone

- Privacy-first real-time location sharing for events, festivals, and camps. - See where your crew is without trusting a central server. + A flexible, privacy-first map tool for real-time location sharing, event navigation, and group coordination. + Create a room, share a link, and see your crew on the map.

{/* CTA buttons */}
- Try the Demo - + ; - /** 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 }; -}