feat: rewrite demo page with live rSpace data via useDemoSync
Replace static SVG map with real-time WebSocket connection to the shared demo community. Alpine route map with interactive markers, all changes sync across the r* ecosystem in real-time. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
a4caa71621
commit
8e96d4eec0
|
|
@ -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<string, { fill: string; stroke: string; ring: string; bg: string; text: string; label: string }> = {
|
||||
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 (
|
||||
<div className="relative w-full rounded-xl bg-slate-900/60 overflow-hidden border border-slate-700/30">
|
||||
<svg
|
||||
viewBox="0 0 800 400"
|
||||
className="w-full h-auto cursor-pointer"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
onClick={(e) => {
|
||||
// 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 */}
|
||||
<rect width="800" height="400" fill="#0c1222" />
|
||||
|
||||
{/* Sky gradient */}
|
||||
<defs>
|
||||
<linearGradient id="skyGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#0f172a" />
|
||||
<stop offset="100%" stopColor="#1e293b" />
|
||||
</linearGradient>
|
||||
<linearGradient id="snowGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="rgba(255,255,255,0.5)" />
|
||||
<stop offset="100%" stopColor="rgba(255,255,255,0)" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="800" height="400" fill="url(#skyGrad)" />
|
||||
|
||||
{/* Stars */}
|
||||
{[120, 230, 380, 520, 670, 90, 450, 740].map((cx, i) => (
|
||||
<circle key={`star-${i}`} cx={cx} cy={20 + (i * 17) % 60} r={0.8 + (i % 3) * 0.3} fill="rgba(255,255,255,0.3)" />
|
||||
))}
|
||||
|
||||
{/* Far mountain silhouette (background) */}
|
||||
<path
|
||||
d="M0 320 L40 260 L80 290 L120 220 L160 260 L200 180 L240 220 L300 150 L340 190 L380 120 L420 170 L460 100 L500 150 L540 110 L580 160 L620 130 L680 170 L720 140 L760 180 L800 160 L800 400 L0 400 Z"
|
||||
fill="rgba(30,41,59,0.9)"
|
||||
/>
|
||||
|
||||
{/* Snow caps on far mountains */}
|
||||
<path d="M195 180 L200 180 L210 188 L202 185 Z" fill="rgba(255,255,255,0.35)" />
|
||||
<path d="M295 150 L300 150 L312 160 L304 156 Z" fill="rgba(255,255,255,0.4)" />
|
||||
<path d="M375 120 L380 120 L392 132 L384 128 Z" fill="rgba(255,255,255,0.45)" />
|
||||
<path d="M455 100 L460 100 L474 114 L464 108 Z" fill="rgba(255,255,255,0.5)" />
|
||||
<path d="M535 110 L540 110 L554 124 L544 118 Z" fill="rgba(255,255,255,0.4)" />
|
||||
<path d="M615 130 L620 130 L632 142 L624 138 Z" fill="rgba(255,255,255,0.35)" />
|
||||
<path d="M715 140 L720 140 L732 152 L724 148 Z" fill="rgba(255,255,255,0.35)" />
|
||||
|
||||
{/* Near mountain silhouette (midground) */}
|
||||
<path
|
||||
d="M0 350 L60 290 L100 320 L160 260 L220 300 L280 230 L340 270 L400 210 L440 250 L500 200 L560 240 L620 210 L680 250 L740 220 L800 260 L800 400 L0 400 Z"
|
||||
fill="rgba(51,65,85,0.7)"
|
||||
/>
|
||||
|
||||
{/* Valley/ground */}
|
||||
<path
|
||||
d="M0 370 L100 340 L200 355 L300 335 L400 350 L500 330 L600 345 L700 330 L800 345 L800 400 L0 400 Z"
|
||||
fill="rgba(15,23,42,0.9)"
|
||||
/>
|
||||
|
||||
{/* Country region shading */}
|
||||
<rect x="50" y="50" width="200" height="350" fill="rgba(20,184,166,0.03)" rx="8" />
|
||||
<rect x="250" y="50" width="200" height="350" fill="rgba(6,182,212,0.03)" rx="8" />
|
||||
<rect x="450" y="50" width="300" height="350" fill="rgba(139,92,246,0.03)" rx="8" />
|
||||
|
||||
{/* Country labels at bottom */}
|
||||
<text x="150" y="390" textAnchor="middle" fill="rgba(20,184,166,0.25)" fontSize="11" fontWeight="600">FRANCE</text>
|
||||
<text x="350" y="390" textAnchor="middle" fill="rgba(6,182,212,0.25)" fontSize="11" fontWeight="600">SWITZERLAND</text>
|
||||
<text x="600" y="390" textAnchor="middle" fill="rgba(139,92,246,0.25)" fontSize="11" fontWeight="600">ITALY</text>
|
||||
|
||||
{/* Route line (dashed) */}
|
||||
{routePath && (
|
||||
<>
|
||||
{/* Glow */}
|
||||
<path
|
||||
d={routePath}
|
||||
fill="none"
|
||||
stroke="rgba(94,234,212,0.2)"
|
||||
strokeWidth="8"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
{/* Main line */}
|
||||
<path
|
||||
d={routePath}
|
||||
fill="none"
|
||||
stroke="rgba(94,234,212,0.7)"
|
||||
strokeWidth="2.5"
|
||||
strokeDasharray="10 6"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Activity markers */}
|
||||
{markerPins.map((m) => {
|
||||
const isSelected = selectedId === m.id
|
||||
return (
|
||||
<g
|
||||
key={m.id}
|
||||
className="cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onSelect(isSelected ? null : m.id)
|
||||
}}
|
||||
>
|
||||
{/* Selection ring */}
|
||||
{isSelected && (
|
||||
<circle cx={m.x} cy={m.y} r="18" fill="none" stroke={m.colors.fill} strokeWidth="2" opacity="0.5">
|
||||
<animate attributeName="r" from="18" to="26" dur="1.5s" repeatCount="indefinite" />
|
||||
<animate attributeName="opacity" from="0.5" to="0" dur="1.5s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
)}
|
||||
{/* Dot */}
|
||||
<circle
|
||||
cx={m.x}
|
||||
cy={m.y}
|
||||
r={isSelected ? '10' : '8'}
|
||||
fill={`${m.colors.fill}40`}
|
||||
stroke={m.colors.fill}
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
{/* Emoji */}
|
||||
<text x={m.x} y={m.y + 4} textAnchor="middle" fontSize="11">{m.emoji}</text>
|
||||
{/* Name label */}
|
||||
<rect
|
||||
x={m.x - 28}
|
||||
y={m.y + 12}
|
||||
width="56"
|
||||
height="14"
|
||||
rx="3"
|
||||
fill="rgba(15,23,42,0.85)"
|
||||
stroke={`${m.colors.fill}60`}
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
<text x={m.x} y={m.y + 22} textAnchor="middle" fill="#cbd5e1" fontSize="8" fontWeight="500">
|
||||
{m.name}
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Destination pins (larger, more prominent) */}
|
||||
{destPins.map((d) => {
|
||||
const isSelected = selectedId === d.id
|
||||
return (
|
||||
<g
|
||||
key={d.id}
|
||||
className="cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onSelect(isSelected ? null : d.id)
|
||||
}}
|
||||
>
|
||||
{/* Pulse ring */}
|
||||
<circle cx={d.x} cy={d.y} r="14" fill="none" stroke={d.colors.fill} strokeWidth="2" opacity="0.3">
|
||||
<animate attributeName="r" from="14" to="24" dur="2s" repeatCount="indefinite" />
|
||||
<animate attributeName="opacity" from="0.4" to="0" dur="2s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
{/* Selection highlight */}
|
||||
{isSelected && (
|
||||
<circle cx={d.x} cy={d.y} r="20" fill={`${d.colors.fill}15`} stroke={d.colors.fill} strokeWidth="2" />
|
||||
)}
|
||||
{/* Pin */}
|
||||
<circle cx={d.x} cy={d.y} r="12" fill={d.colors.fill} stroke={d.colors.stroke} strokeWidth="2.5" />
|
||||
<circle cx={d.x} cy={d.y} r="4" fill="white" opacity="0.8" />
|
||||
{/* Name */}
|
||||
<text x={d.x} y={d.y + 28} textAnchor="middle" fill="#e2e8f0" fontSize="12" fontWeight="600">
|
||||
{d.destName}
|
||||
</text>
|
||||
{/* Dates */}
|
||||
<text x={d.x} y={d.y + 42} textAnchor="middle" fill="#64748b" fontSize="10">
|
||||
{d.arrivalDate}{d.departureDate ? ` - ${d.departureDate}` : ''}
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
</svg>
|
||||
|
||||
{/* Map legend */}
|
||||
<div className="absolute bottom-3 left-3 flex flex-wrap gap-3 text-xs text-slate-400">
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-2 h-2 rounded-full bg-teal-500" /> France
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-2 h-2 rounded-full bg-cyan-500" /> Switzerland
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-2 h-2 rounded-full bg-violet-500" /> Italy
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── 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 (
|
||||
<div className="bg-slate-800/50 rounded-2xl border border-slate-700/50 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="px-5 py-3 border-b border-slate-700/50 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xl">📍</span>
|
||||
<span className="font-semibold text-sm">Locations</span>
|
||||
</div>
|
||||
<span className="text-xs text-slate-400">
|
||||
{destinations.length} destinations, {markers.length} activities
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Destinations */}
|
||||
<div className="p-3">
|
||||
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wider px-3 mb-2">Destinations</p>
|
||||
<div className="space-y-1">
|
||||
{destinations.map((d) => {
|
||||
const colors = COUNTRY_COLORS[d.country] || COUNTRY_COLORS.France
|
||||
const isSelected = selectedId === d.id
|
||||
return (
|
||||
<button
|
||||
key={d.id}
|
||||
onClick={() => onSelect(isSelected ? null : d.id)}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors text-left ${
|
||||
isSelected ? 'bg-slate-700/50 ring-1 ring-slate-600' : 'hover:bg-slate-700/30'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className="w-9 h-9 rounded-full flex items-center justify-center text-sm font-bold flex-shrink-0"
|
||||
style={{ backgroundColor: `${colors.fill}20`, color: colors.fill, boxShadow: `0 0 0 2px ${colors.fill}` }}
|
||||
>
|
||||
{d.destName[0]}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-slate-200">{d.destName}</span>
|
||||
<span className="text-xs text-slate-500">{d.country}</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 truncate">
|
||||
{d.arrivalDate}{d.departureDate ? ` - ${d.departureDate}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Activities */}
|
||||
<div className="px-3 pb-3">
|
||||
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wider px-3 mb-2">Activities</p>
|
||||
<div className="space-y-1">
|
||||
{markers.map((m) => {
|
||||
const country = getCountryForCoords(m.lat, m.lng)
|
||||
const colors = COUNTRY_COLORS[country] || COUNTRY_COLORS.France
|
||||
const isSelected = selectedId === m.id
|
||||
return (
|
||||
<button
|
||||
key={m.id}
|
||||
onClick={() => onSelect(isSelected ? null : m.id)}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors text-left ${
|
||||
isSelected ? 'bg-slate-700/50 ring-1 ring-slate-600' : 'hover:bg-slate-700/30'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className="w-9 h-9 rounded-full flex items-center justify-center text-lg flex-shrink-0"
|
||||
style={{ backgroundColor: `${colors.fill}15` }}
|
||||
>
|
||||
{m.emoji}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-slate-200">{m.name}</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 truncate">{m.status}</p>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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 (
|
||||
<div className="px-5 py-4 border-t border-slate-700/50 bg-slate-800/30">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div
|
||||
className="w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold"
|
||||
style={{ backgroundColor: `${colors.fill}20`, color: colors.fill }}
|
||||
>
|
||||
{dest.destName[0]}
|
||||
</div>
|
||||
<h4 className="text-sm font-semibold text-slate-200">{dest.destName}</h4>
|
||||
<span className="text-xs text-slate-500">{dest.country}</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 mb-1">
|
||||
{dest.arrivalDate}{dest.departureDate ? ` - ${dest.departureDate}` : ''}
|
||||
</p>
|
||||
{dest.notes && (
|
||||
<p className="text-xs text-slate-300 mt-2">{dest.notes}</p>
|
||||
)}
|
||||
<p className="text-xs text-slate-600 mt-2">
|
||||
{dest.lat.toFixed(2)}N, {dest.lng.toFixed(2)}E
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (marker) {
|
||||
return (
|
||||
<div className="px-5 py-4 border-t border-slate-700/50 bg-slate-800/30">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-lg">{marker.emoji}</span>
|
||||
<h4 className="text-sm font-semibold text-slate-200">{marker.name}</h4>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400">{marker.status}</p>
|
||||
<p className="text-xs text-slate-600 mt-2">
|
||||
{marker.lat.toFixed(2)}N, {marker.lng.toFixed(2)}E
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})()}
|
||||
|
||||
{/* Connection status & reset */}
|
||||
<div className="px-5 py-3 border-t border-slate-700/50 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`w-2 h-2 rounded-full ${connected ? 'bg-emerald-500 animate-pulse' : 'bg-slate-500'}`}
|
||||
/>
|
||||
<span className="text-xs text-slate-400">
|
||||
{connected ? 'Connected to rSpace' : 'Connecting...'}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleReset}
|
||||
disabled={resetting || !connected}
|
||||
className="text-xs px-3 py-1.5 bg-slate-700/60 hover:bg-slate-600/60 rounded-lg text-slate-400 hover:text-white transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
{resetting ? 'Resetting...' : 'Reset Demo'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── Main Demo Content ─────────────────────────────────────── */
|
||||
|
||||
export default function DemoContent() {
|
||||
const { shapes, connected, resetDemo } = useDemoSync({
|
||||
filter: ['demo-map-marker', 'folk-destination'],
|
||||
})
|
||||
|
||||
const [selectedId, setSelectedId] = useState<string | null>(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 (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 text-white">
|
||||
{/* Nav */}
|
||||
<nav className="border-b border-slate-700/50 backdrop-blur-sm sticky top-0 z-50 bg-slate-900/80">
|
||||
<div className="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/" className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 bg-gradient-to-br from-emerald-400 to-emerald-600 rounded-lg flex items-center justify-center font-bold text-slate-900 text-sm">
|
||||
rM
|
||||
</div>
|
||||
<span className="font-semibold text-lg">rMaps</span>
|
||||
</Link>
|
||||
<span className="text-slate-600">/</span>
|
||||
<span className="text-sm text-slate-400">Demo</span>
|
||||
</div>
|
||||
<Link
|
||||
href="/"
|
||||
className="text-sm px-4 py-2 bg-emerald-600 hover:bg-emerald-500 rounded-lg transition-colors font-medium"
|
||||
>
|
||||
Create Your Map
|
||||
</Link>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Hero / Trip Header */}
|
||||
<section className="max-w-7xl mx-auto px-6 pt-12 pb-8">
|
||||
<div className="text-center max-w-3xl mx-auto">
|
||||
<div className="inline-flex items-center gap-2 px-4 py-1.5 bg-emerald-500/10 border border-emerald-500/20 rounded-full text-emerald-400 text-sm mb-6">
|
||||
<span className={`w-2 h-2 rounded-full ${connected ? 'bg-emerald-500 animate-pulse' : 'bg-slate-500'}`} />
|
||||
{isLive && connected ? 'Live -- connected to rSpace' : connected ? 'Live Demo' : 'Connecting...'}
|
||||
</div>
|
||||
<h1 className="text-4xl sm:text-5xl font-bold mb-4 bg-gradient-to-r from-emerald-300 via-teal-300 to-cyan-300 bg-clip-text text-transparent">
|
||||
Alpine Explorer 2026
|
||||
</h1>
|
||||
<p className="text-lg text-slate-300 mb-3">
|
||||
Chamonix → Zermatt → Dolomites
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center justify-center gap-4 text-sm text-slate-400 mb-6">
|
||||
<span>📅 Jul 6-20, 2026</span>
|
||||
<span>🏔️ 3 countries</span>
|
||||
<span>📍 {displayDests.length} destinations, {displayMarkers.length} activities</span>
|
||||
</div>
|
||||
|
||||
{/* Destination badges */}
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
{displayDests.map((d) => {
|
||||
const colors = COUNTRY_COLORS[d.country] || COUNTRY_COLORS.France
|
||||
return (
|
||||
<div
|
||||
key={d.id}
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-full border"
|
||||
style={{
|
||||
backgroundColor: `${colors.fill}10`,
|
||||
borderColor: `${colors.fill}30`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="w-5 h-5 rounded-full flex items-center justify-center text-xs font-bold"
|
||||
style={{ backgroundColor: `${colors.fill}30`, color: colors.fill }}
|
||||
>
|
||||
{d.destName[0]}
|
||||
</div>
|
||||
<span className="text-sm" style={{ color: colors.fill }}>{d.destName}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Intro text */}
|
||||
<section className="max-w-7xl mx-auto px-6 pb-6">
|
||||
<p className="text-center text-sm text-slate-400 max-w-2xl mx-auto">
|
||||
This demo shows how <span className="text-slate-200 font-medium">rMaps</span> visualizes trip
|
||||
routes with live data from <span className="text-slate-200 font-medium">rSpace</span>. Destinations
|
||||
and activity markers update in real time as data changes in the shared community.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Main Demo Content */}
|
||||
<section className="max-w-7xl mx-auto px-6 pb-16">
|
||||
<div className="grid lg:grid-cols-3 gap-6">
|
||||
{/* Map - takes 2 columns */}
|
||||
<div className="lg:col-span-2">
|
||||
<div className="bg-slate-800/50 rounded-2xl border border-slate-700/50 overflow-hidden">
|
||||
<div className="flex items-center justify-between px-5 py-3 border-b border-slate-700/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xl">🗺️</span>
|
||||
<span className="font-semibold text-sm">Route Map</span>
|
||||
{isLive && (
|
||||
<span className="flex items-center gap-1 text-xs text-emerald-400">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
|
||||
live
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-slate-400">
|
||||
{displayDests.length + displayMarkers.length} markers
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<AlpineRouteMap
|
||||
destinations={displayDests}
|
||||
markers={displayMarkers}
|
||||
selectedId={selectedId}
|
||||
onSelect={setSelectedId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Locations Panel - 1 column */}
|
||||
<div className="lg:col-span-1">
|
||||
<LocationsPanel
|
||||
destinations={displayDests}
|
||||
markers={displayMarkers}
|
||||
selectedId={selectedId}
|
||||
onSelect={setSelectedId}
|
||||
connected={connected}
|
||||
onReset={resetDemo}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* How It Works */}
|
||||
<section className="max-w-7xl mx-auto px-6 pb-16">
|
||||
<h2 className="text-2xl font-bold text-center mb-8">How rMaps Works for Trip Planning</h2>
|
||||
<div className="grid sm:grid-cols-3 gap-6">
|
||||
<div className="bg-slate-800/30 rounded-xl border border-slate-700/30 p-6 text-center">
|
||||
<div className="w-12 h-12 mx-auto mb-4 bg-emerald-500/10 rounded-xl flex items-center justify-center text-2xl">
|
||||
📍
|
||||
</div>
|
||||
<h3 className="font-semibold mb-2 text-slate-200">Plot Your Route</h3>
|
||||
<p className="text-sm text-slate-400">
|
||||
Pin destinations on the map and connect them into a route. Add activities near each stop.
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-slate-800/30 rounded-xl border border-slate-700/30 p-6 text-center">
|
||||
<div className="w-12 h-12 mx-auto mb-4 bg-indigo-500/10 rounded-xl flex items-center justify-center text-2xl">
|
||||
🔄
|
||||
</div>
|
||||
<h3 className="font-semibold mb-2 text-slate-200">Sync in Real Time</h3>
|
||||
<p className="text-sm text-slate-400">
|
||||
Powered by rSpace, every marker updates live. Your whole crew sees changes instantly.
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-slate-800/30 rounded-xl border border-slate-700/30 p-6 text-center">
|
||||
<div className="w-12 h-12 mx-auto mb-4 bg-rose-500/10 rounded-xl flex items-center justify-center text-2xl">
|
||||
🌍
|
||||
</div>
|
||||
<h3 className="font-semibold mb-2 text-slate-200">Explore Together</h3>
|
||||
<p className="text-sm text-slate-400">
|
||||
Click any marker for details. Track dates, notes, and status for every stop on the journey.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Bottom CTA */}
|
||||
<section className="max-w-7xl mx-auto px-6 pb-20 text-center">
|
||||
<div className="bg-slate-800/50 rounded-2xl border border-slate-700/50 p-10">
|
||||
<h2 className="text-3xl font-bold mb-3">Ready to Map Your Adventure?</h2>
|
||||
<p className="text-slate-400 mb-6 max-w-lg mx-auto">
|
||||
Create a map for your next trip. Pin destinations, mark activities, and share with your group -- all synced in real time.
|
||||
</p>
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-block px-8 py-4 bg-emerald-600 hover:bg-emerald-500 rounded-xl text-lg font-medium transition-all shadow-lg shadow-emerald-900/30"
|
||||
>
|
||||
Create Your Map
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="border-t border-slate-700/50 py-8">
|
||||
<div className="max-w-7xl mx-auto px-6">
|
||||
<div className="flex flex-wrap items-center justify-center gap-4 text-sm text-slate-500 mb-4">
|
||||
<span className="font-medium text-slate-400">r* Ecosystem</span>
|
||||
<a href="https://rspace.online" className="hover:text-slate-300 transition-colors">🌌 rSpace</a>
|
||||
<a href="https://rmaps.online" className="hover:text-slate-300 transition-colors font-medium text-slate-300">🗺️ rMaps</a>
|
||||
<a href="https://rnotes.online" className="hover:text-slate-300 transition-colors">📝 rNotes</a>
|
||||
<a href="https://rvote.online" className="hover:text-slate-300 transition-colors">🗳️ rVote</a>
|
||||
<a href="https://rfunds.online" className="hover:text-slate-300 transition-colors">💰 rFunds</a>
|
||||
<a href="https://rtrips.online" className="hover:text-slate-300 transition-colors">✈️ rTrips</a>
|
||||
<a href="https://rcart.online" className="hover:text-slate-300 transition-colors">🛒 rCart</a>
|
||||
<a href="https://rwallet.online" className="hover:text-slate-300 transition-colors">💼 rWallet</a>
|
||||
<a href="https://rfiles.online" className="hover:text-slate-300 transition-colors">📁 rFiles</a>
|
||||
<a href="https://rnetwork.online" className="hover:text-slate-300 transition-colors">🌐 rNetwork</a>
|
||||
</div>
|
||||
<p className="text-center text-xs text-slate-600">
|
||||
Part of the r* ecosystem -- collaborative tools for communities.
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div className="relative w-full rounded-xl bg-slate-900/60 overflow-hidden border border-slate-700/30">
|
||||
<svg viewBox="0 0 800 400" className="w-full h-auto" xmlns="http://www.w3.org/2000/svg">
|
||||
{/* Background terrain */}
|
||||
<rect width="800" height="400" fill="#0c1222" />
|
||||
|
||||
{/* Grass / ground patches */}
|
||||
<ellipse cx="400" cy="200" rx="380" ry="180" fill="#0f1a2e" />
|
||||
<ellipse cx="400" cy="200" rx="360" ry="170" fill="#111d33" />
|
||||
|
||||
{/* Paths - main walkways */}
|
||||
<path
|
||||
d="M100 200 L350 200 L500 150 L700 200"
|
||||
fill="none"
|
||||
stroke="rgba(148,163,184,0.15)"
|
||||
strokeWidth="20"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M350 200 L350 350"
|
||||
fill="none"
|
||||
stroke="rgba(148,163,184,0.15)"
|
||||
strokeWidth="16"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M500 150 L600 300"
|
||||
fill="none"
|
||||
stroke="rgba(148,163,184,0.15)"
|
||||
strokeWidth="14"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M350 200 L250 120"
|
||||
fill="none"
|
||||
stroke="rgba(148,163,184,0.12)"
|
||||
strokeWidth="12"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
|
||||
{/* ── Area: Main Stage (top right) ── */}
|
||||
<rect x="460" y="100" width="140" height="90" rx="12" fill="rgba(16,185,129,0.08)" stroke="rgba(16,185,129,0.25)" strokeWidth="1.5" />
|
||||
<rect x="490" y="115" width="80" height="30" rx="4" fill="rgba(16,185,129,0.15)" />
|
||||
<text x="530" y="135" textAnchor="middle" fill="rgba(16,185,129,0.7)" fontSize="11" fontWeight="600">STAGE</text>
|
||||
<text x="530" y="175" textAnchor="middle" fill="#94a3b8" fontSize="11" fontWeight="600">Main Stage</text>
|
||||
|
||||
{/* ── Area: Food Court (center-left) ── */}
|
||||
<rect x="290" y="220" width="130" height="80" rx="12" fill="rgba(249,115,22,0.08)" stroke="rgba(249,115,22,0.25)" strokeWidth="1.5" />
|
||||
{/* Food stall icons */}
|
||||
<rect x="305" y="235" width="20" height="15" rx="3" fill="rgba(249,115,22,0.2)" />
|
||||
<rect x="335" y="235" width="20" height="15" rx="3" fill="rgba(249,115,22,0.2)" />
|
||||
<rect x="365" y="235" width="20" height="15" rx="3" fill="rgba(249,115,22,0.2)" />
|
||||
<rect x="305" y="260" width="20" height="15" rx="3" fill="rgba(249,115,22,0.2)" />
|
||||
<rect x="335" y="260" width="20" height="15" rx="3" fill="rgba(249,115,22,0.2)" />
|
||||
<rect x="365" y="260" width="20" height="15" rx="3" fill="rgba(249,115,22,0.2)" />
|
||||
<text x="355" y="292" textAnchor="middle" fill="#94a3b8" fontSize="11" fontWeight="600">Food Court</text>
|
||||
|
||||
{/* ── Area: Workshops (right) ── */}
|
||||
<rect x="570" y="240" width="150" height="90" rx="12" fill="rgba(99,102,241,0.08)" stroke="rgba(99,102,241,0.25)" strokeWidth="1.5" />
|
||||
<rect x="585" y="255" width="50" height="25" rx="4" fill="rgba(99,102,241,0.12)" />
|
||||
<rect x="645" y="255" width="50" height="25" rx="4" fill="rgba(99,102,241,0.12)" />
|
||||
<rect x="585" y="288" width="50" height="25" rx="4" fill="rgba(99,102,241,0.12)" />
|
||||
<rect x="645" y="288" width="50" height="25" rx="4" fill="rgba(99,102,241,0.12)" />
|
||||
<text x="645" y="325" textAnchor="middle" fill="#94a3b8" fontSize="11" fontWeight="600">Workshops</text>
|
||||
|
||||
{/* ── Area: Camping (bottom-left) ── */}
|
||||
<rect x="90" y="260" width="160" height="100" rx="12" fill="rgba(234,179,8,0.06)" stroke="rgba(234,179,8,0.2)" strokeWidth="1.5" />
|
||||
{/* Tent icons */}
|
||||
<polygon points="120,300 135,280 150,300" fill="rgba(234,179,8,0.15)" stroke="rgba(234,179,8,0.3)" strokeWidth="1" />
|
||||
<polygon points="165,310 180,290 195,310" fill="rgba(234,179,8,0.15)" stroke="rgba(234,179,8,0.3)" strokeWidth="1" />
|
||||
<polygon points="120,335 135,315 150,335" fill="rgba(234,179,8,0.12)" stroke="rgba(234,179,8,0.25)" strokeWidth="1" />
|
||||
<polygon points="175,340 190,320 205,340" fill="rgba(234,179,8,0.12)" stroke="rgba(234,179,8,0.25)" strokeWidth="1" />
|
||||
<text x="170" y="355" textAnchor="middle" fill="#94a3b8" fontSize="11" fontWeight="600">Camping</text>
|
||||
|
||||
{/* ── Area: Soldering Village (top-left) ── */}
|
||||
<rect x="200" y="80" width="120" height="70" rx="12" fill="rgba(6,182,212,0.08)" stroke="rgba(6,182,212,0.25)" strokeWidth="1.5" />
|
||||
<rect x="215" y="95" width="30" height="20" rx="3" fill="rgba(6,182,212,0.15)" />
|
||||
<rect x="255" y="95" width="30" height="20" rx="3" fill="rgba(6,182,212,0.15)" />
|
||||
<text x="260" y="140" textAnchor="middle" fill="#94a3b8" fontSize="10" fontWeight="600">Soldering Village</text>
|
||||
|
||||
{/* ── Area: Info / Coffee (center) ── */}
|
||||
<circle cx="400" cy="180" r="22" fill="rgba(148,163,184,0.06)" stroke="rgba(148,163,184,0.2)" strokeWidth="1" />
|
||||
<text x="400" y="176" textAnchor="middle" fill="#64748b" fontSize="14">☕</text>
|
||||
<text x="400" y="192" textAnchor="middle" fill="#64748b" fontSize="7">INFO</text>
|
||||
|
||||
{/* ── Entrance marker ── */}
|
||||
<rect x="60" y="185" width="50" height="30" rx="6" fill="rgba(148,163,184,0.1)" stroke="rgba(148,163,184,0.2)" strokeWidth="1" />
|
||||
<text x="85" y="204" textAnchor="middle" fill="#64748b" fontSize="9" fontWeight="600">ENTER</text>
|
||||
|
||||
{/* ── Trees / decoration ── */}
|
||||
<circle cx="450" cy="350" r="8" fill="rgba(16,185,129,0.1)" />
|
||||
<circle cx="470" cy="345" r="6" fill="rgba(16,185,129,0.08)" />
|
||||
<circle cx="130" cy="230" r="6" fill="rgba(16,185,129,0.08)" />
|
||||
<circle cx="750" cy="150" r="10" fill="rgba(16,185,129,0.06)" />
|
||||
<circle cx="740" cy="170" r="7" fill="rgba(16,185,129,0.08)" />
|
||||
|
||||
{/* ── Friend markers ── */}
|
||||
{friends.map((f) => (
|
||||
<g key={f.name}>
|
||||
{/* Pulse ring for online friends */}
|
||||
{f.online && (
|
||||
<circle cx={f.x} cy={f.y} r="14" fill="none" stroke={f.color} strokeWidth="2" opacity="0.3">
|
||||
<animate attributeName="r" from="14" to="24" dur="2s" repeatCount="indefinite" />
|
||||
<animate attributeName="opacity" from="0.4" to="0" dur="2s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
)}
|
||||
{/* Dot */}
|
||||
<circle cx={f.x} cy={f.y} r="12" fill={f.color} stroke="#0f172a" strokeWidth="3" />
|
||||
{/* Emoji */}
|
||||
<text x={f.x} y={f.y + 4} textAnchor="middle" fontSize="12">{f.emoji}</text>
|
||||
{/* Name label */}
|
||||
<rect x={f.x - 20} y={f.y + 16} width="40" height="16" rx="4" fill="rgba(15,23,42,0.85)" stroke={f.color} strokeWidth="0.5" />
|
||||
<text x={f.x} y={f.y + 27} textAnchor="middle" fill="#e2e8f0" fontSize="9" fontWeight="500">{f.name}</text>
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
|
||||
{/* Map legend */}
|
||||
<div className="absolute bottom-3 left-3 flex flex-wrap gap-3 text-xs text-slate-400">
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-2 h-2 rounded-full bg-emerald-500" /> Stage
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-2 h-2 rounded-full bg-orange-500" /> Food
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-2 h-2 rounded-full bg-indigo-500" /> Workshops
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-2 h-2 rounded-full bg-yellow-500" /> Camping
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Simulated live badge */}
|
||||
<div className="absolute top-3 right-3 flex items-center gap-1.5 bg-slate-800/80 backdrop-blur-sm rounded-full px-3 py-1.5 border border-slate-700/50">
|
||||
<span className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" />
|
||||
<span className="text-xs text-slate-300 font-medium">Live</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── Friend List Panel ────────────────────────────────────── */
|
||||
|
||||
function FriendListPanel() {
|
||||
return (
|
||||
<div className="bg-slate-800/50 rounded-2xl border border-slate-700/50 overflow-hidden">
|
||||
<div className="px-5 py-3 border-b border-slate-700/50 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xl">👥</span>
|
||||
<span className="font-semibold text-sm">Friends in Room</span>
|
||||
</div>
|
||||
<span className="text-xs text-slate-400">{friends.filter((f) => f.online).length}/{friends.length} online</span>
|
||||
</div>
|
||||
<div className="p-3 space-y-1">
|
||||
{friends.map((f) => (
|
||||
<div
|
||||
key={f.name}
|
||||
className="flex items-center gap-3 px-3 py-2.5 rounded-lg hover:bg-slate-700/30 transition-colors"
|
||||
>
|
||||
{/* Avatar */}
|
||||
<div
|
||||
className="w-9 h-9 rounded-full flex items-center justify-center text-lg flex-shrink-0 ring-2"
|
||||
style={{ backgroundColor: `${f.color}20`, boxShadow: `0 0 0 2px ${f.color}` }}
|
||||
>
|
||||
{f.emoji}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-slate-200">{f.name}</span>
|
||||
<span
|
||||
className="w-2 h-2 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: f.online ? '#10b981' : '#6b7280', boxShadow: f.online ? '0 0 6px #10b981' : 'none' }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 truncate">{f.status}</p>
|
||||
</div>
|
||||
|
||||
{/* Last seen */}
|
||||
<span className="text-xs text-slate-500 flex-shrink-0">{f.lastSeen}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Status selector preview */}
|
||||
<div className="px-5 py-3 border-t border-slate-700/50">
|
||||
<p className="text-xs text-slate-500 mb-2">Your status:</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{statusOptions.slice(0, 5).map((s) => (
|
||||
<span
|
||||
key={s}
|
||||
className={`text-xs px-2.5 py-1 rounded-full border transition-colors cursor-default ${
|
||||
s === 'At Main Stage'
|
||||
? 'bg-emerald-500/15 border-emerald-500/40 text-emerald-400'
|
||||
: 'bg-slate-700/30 border-slate-600/40 text-slate-400 hover:border-slate-500'
|
||||
}`}
|
||||
>
|
||||
{s}
|
||||
</span>
|
||||
))}
|
||||
<span className="text-xs px-2.5 py-1 rounded-full bg-slate-700/30 border border-slate-600/40 text-slate-500 cursor-default">
|
||||
+{statusOptions.length - 5} more
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── Page ──────────────────────────────────────────────────── */
|
||||
|
||||
export default function DemoPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 text-white">
|
||||
{/* Nav */}
|
||||
<nav className="border-b border-slate-700/50 backdrop-blur-sm sticky top-0 z-50 bg-slate-900/80">
|
||||
<div className="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/" className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 bg-gradient-to-br from-emerald-400 to-emerald-600 rounded-lg flex items-center justify-center font-bold text-slate-900 text-sm">
|
||||
rM
|
||||
</div>
|
||||
<span className="font-semibold text-lg">rMaps</span>
|
||||
</Link>
|
||||
<span className="text-slate-600">/</span>
|
||||
<span className="text-sm text-slate-400">Demo</span>
|
||||
</div>
|
||||
<Link
|
||||
href="/"
|
||||
className="text-sm px-4 py-2 bg-emerald-600 hover:bg-emerald-500 rounded-lg transition-colors font-medium"
|
||||
>
|
||||
Create Your Map
|
||||
</Link>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Hero / Event Header */}
|
||||
<section className="max-w-7xl mx-auto px-6 pt-12 pb-8">
|
||||
<div className="text-center max-w-3xl mx-auto">
|
||||
<div className="inline-flex items-center gap-2 px-4 py-1.5 bg-emerald-500/10 border border-emerald-500/20 rounded-full text-emerald-400 text-sm mb-6">
|
||||
<span className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" />
|
||||
Live Demo
|
||||
</div>
|
||||
<h1 className="text-4xl sm:text-5xl font-bold mb-4 bg-gradient-to-r from-emerald-300 via-teal-300 to-cyan-300 bg-clip-text text-transparent">
|
||||
CCC Camp 2026
|
||||
</h1>
|
||||
<p className="text-lg text-slate-300 mb-3">
|
||||
Find your friends across the campground in real time
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center justify-center gap-4 text-sm text-slate-400 mb-6">
|
||||
<span>📍 Ziegeleipark Mildenberg</span>
|
||||
<span>📅 Aug 2026</span>
|
||||
<span>👥 6 friends connected</span>
|
||||
</div>
|
||||
|
||||
{/* Member avatars */}
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
{friends.map((f) => (
|
||||
<div
|
||||
key={f.name}
|
||||
className="w-10 h-10 rounded-full flex items-center justify-center text-lg ring-2 ring-slate-800"
|
||||
style={{ backgroundColor: `${f.color}30` }}
|
||||
title={f.name}
|
||||
>
|
||||
{f.emoji}
|
||||
</div>
|
||||
))}
|
||||
<span className="text-sm text-slate-400 ml-2">6 friends</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Intro text */}
|
||||
<section className="max-w-7xl mx-auto px-6 pb-6">
|
||||
<p className="text-center text-sm text-slate-400 max-w-2xl mx-auto">
|
||||
This demo shows how <span className="text-slate-200 font-medium">rMaps</span> 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.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Main Demo Content */}
|
||||
<section className="max-w-7xl mx-auto px-6 pb-16">
|
||||
<div className="grid lg:grid-cols-3 gap-6">
|
||||
{/* Map - takes 2 columns */}
|
||||
<div className="lg:col-span-2">
|
||||
<div className="bg-slate-800/50 rounded-2xl border border-slate-700/50 overflow-hidden">
|
||||
<div className="flex items-center justify-between px-5 py-3 border-b border-slate-700/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xl">🗺️</span>
|
||||
<span className="font-semibold text-sm">Camp Map</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-slate-400">Last updated 2 min ago</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<CampMap />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Friend List - 1 column */}
|
||||
<div className="lg:col-span-1">
|
||||
<FriendListPanel />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* How It Works (brief) */}
|
||||
<section className="max-w-7xl mx-auto px-6 pb-16">
|
||||
<h2 className="text-2xl font-bold text-center mb-8">How rMaps Works at Events</h2>
|
||||
<div className="grid sm:grid-cols-3 gap-6">
|
||||
<div className="bg-slate-800/30 rounded-xl border border-slate-700/30 p-6 text-center">
|
||||
<div className="w-12 h-12 mx-auto mb-4 bg-emerald-500/10 rounded-xl flex items-center justify-center text-2xl">
|
||||
📱
|
||||
</div>
|
||||
<h3 className="font-semibold mb-2 text-slate-200">Open & Share</h3>
|
||||
<p className="text-sm text-slate-400">
|
||||
Create a map room and share the link with your crew. No app install needed.
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-slate-800/30 rounded-xl border border-slate-700/30 p-6 text-center">
|
||||
<div className="w-12 h-12 mx-auto mb-4 bg-indigo-500/10 rounded-xl flex items-center justify-center text-2xl">
|
||||
📍
|
||||
</div>
|
||||
<h3 className="font-semibold mb-2 text-slate-200">See Everyone</h3>
|
||||
<p className="text-sm text-slate-400">
|
||||
Friends appear on the map in real time. Set your status so everyone knows where you are.
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-slate-800/30 rounded-xl border border-slate-700/30 p-6 text-center">
|
||||
<div className="w-12 h-12 mx-auto mb-4 bg-rose-500/10 rounded-xl flex items-center justify-center text-2xl">
|
||||
🔒
|
||||
</div>
|
||||
<h3 className="font-semibold mb-2 text-slate-200">Stay Private</h3>
|
||||
<p className="text-sm text-slate-400">
|
||||
Only people in your room see your location. Go invisible anytime. No tracking.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Bottom CTA */}
|
||||
<section className="max-w-7xl mx-auto px-6 pb-20 text-center">
|
||||
<div className="bg-slate-800/50 rounded-2xl border border-slate-700/50 p-10">
|
||||
<h2 className="text-3xl font-bold mb-3">Ready to Find Your Friends?</h2>
|
||||
<p className="text-slate-400 mb-6 max-w-lg mx-auto">
|
||||
Create a map room for your next event. Share the link, and never lose your crew in the crowd again.
|
||||
</p>
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-block px-8 py-4 bg-emerald-600 hover:bg-emerald-500 rounded-xl text-lg font-medium transition-all shadow-lg shadow-emerald-900/30"
|
||||
>
|
||||
Create Your Map
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="border-t border-slate-700/50 py-8">
|
||||
<div className="max-w-7xl mx-auto px-6">
|
||||
<div className="flex flex-wrap items-center justify-center gap-4 text-sm text-slate-500 mb-4">
|
||||
<span className="font-medium text-slate-400">r* Ecosystem</span>
|
||||
<a href="https://rspace.online" className="hover:text-slate-300 transition-colors">🌌 rSpace</a>
|
||||
<a href="https://rmaps.online" className="hover:text-slate-300 transition-colors font-medium text-slate-300">🗺️ rMaps</a>
|
||||
<a href="https://rnotes.online" className="hover:text-slate-300 transition-colors">📝 rNotes</a>
|
||||
<a href="https://rvote.online" className="hover:text-slate-300 transition-colors">🗳️ rVote</a>
|
||||
<a href="https://rfunds.online" className="hover:text-slate-300 transition-colors">💰 rFunds</a>
|
||||
<a href="https://rtrips.online" className="hover:text-slate-300 transition-colors">✈️ rTrips</a>
|
||||
<a href="https://rcart.online" className="hover:text-slate-300 transition-colors">🛒 rCart</a>
|
||||
<a href="https://rwallet.online" className="hover:text-slate-300 transition-colors">💼 rWallet</a>
|
||||
<a href="https://rfiles.online" className="hover:text-slate-300 transition-colors">📁 rFiles</a>
|
||||
<a href="https://rnetwork.online" className="hover:text-slate-300 transition-colors">🌐 rNetwork</a>
|
||||
</div>
|
||||
<p className="text-center text-xs text-slate-600">
|
||||
Part of the r* ecosystem — collaborative tools for communities.
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
return <DemoContent />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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