feat: add space subdomain routing and ownership support

- Traefik wildcard HostRegexp for <space>.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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-25 13:19:57 -08:00
parent 9a815db28e
commit 56901d8e45
8 changed files with 39 additions and 1035 deletions

View File

@ -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:

12
package-lock.json generated
View File

@ -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",

View File

@ -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",
});
}

View File

@ -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<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 &rarr; Zermatt &rarr; 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://rinbox.online" className="hover:text-slate-300 transition-colors"> rInbox</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>
)
}

View File

@ -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 <DemoContent />
}

View File

@ -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: {

View File

@ -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 */}
<h2 className="text-3xl sm:text-5xl font-bold mb-4 bg-gradient-to-r from-emerald-300 via-teal-200 to-cyan-300 bg-clip-text text-transparent leading-tight">
Find Your Friends, Anywhere
Collaborative Maps for Everyone
</h2>
<p className="text-lg sm:text-xl text-white/60 max-w-2xl mx-auto mb-8">
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.
</p>
{/* CTA buttons */}
<div className="flex flex-col sm:flex-row items-center justify-center gap-4 mb-6">
<Link
href="/demo"
<a
href="https://demo.rmaps.online"
className="px-8 py-3.5 bg-emerald-600 hover:bg-emerald-500 text-white font-medium rounded-xl transition-all shadow-lg shadow-emerald-900/30 text-lg"
>
Try the Demo
</Link>
</a>
<a
href="#get-started"
className="px-8 py-3.5 bg-white/5 hover:bg-white/10 text-white font-medium rounded-xl transition-colors border border-white/10 text-lg"

View File

@ -1,221 +0,0 @@
/**
* 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 };
}