commit dc0661d58ad550c6cf92818e81174472935cbcd7 Author: Jeff Emmett Date: Mon Dec 15 12:23:13 2025 -0500 Initial scaffold for rMaps.online Collaborative real-time friend-finding navigation for events: - Next.js 14 with TypeScript and Tailwind CSS - MapLibre GL for outdoor OpenStreetMap rendering - c3nav API client for CCC indoor navigation - Zustand for state management - Location sharing hook with privacy controls - Room system with subdomain routing middleware - Docker + docker-compose with Traefik labels 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..32d9271 --- /dev/null +++ b/.env.example @@ -0,0 +1,14 @@ +# rMaps.online Environment Variables + +# Application +NODE_ENV=development +PORT=3000 + +# c3nav Integration (optional - defaults to 38c3) +C3NAV_BASE_URL=https://38c3.c3nav.de + +# Automerge Sync Server (optional - for real-time sync) +AUTOMERGE_SYNC_URL=wss://sync.rmaps.online + +# Analytics (optional) +# NEXT_PUBLIC_PLAUSIBLE_DOMAIN=rmaps.online diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8e902c8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,44 @@ +# Dependencies +/node_modules +/.pnp +.pnp.js +.yarn/install-state.gz + +# Testing +/coverage + +# Next.js +/.next/ +/out/ + +# Production +/build + +# Misc +.DS_Store +*.pem + +# Debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Local env files +.env*.local +.env + +# Vercel +.vercel + +# TypeScript +*.tsbuildinfo +next-env.d.ts + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS +Thumbs.db diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3f583d7 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,46 @@ +# rMaps.online - Friend-finding navigation +# Multi-stage build for optimized production image + +# Stage 1: Dependencies +FROM node:20-alpine AS deps +RUN apk add --no-cache libc6-compat +WORKDIR /app + +COPY package.json package-lock.json* ./ +RUN npm ci + +# Stage 2: Build +FROM node:20-alpine AS builder +WORKDIR /app + +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +ENV NEXT_TELEMETRY_DISABLED=1 +ENV NODE_ENV=production + +RUN npm run build + +# Stage 3: Production +FROM node:20-alpine AS runner +WORKDIR /app + +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 + +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs + +# Copy built assets +COPY --from=builder /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +USER nextjs + +EXPOSE 3000 + +ENV PORT=3000 +ENV HOSTNAME="0.0.0.0" + +CMD ["node", "server.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..357d9de --- /dev/null +++ b/README.md @@ -0,0 +1,88 @@ +# rMaps.online + +Collaborative real-time friend-finding navigation for events. + +## Features + +- **Real-time GPS Sharing**: See your friends' locations on the map +- **Privacy-First**: Control who sees your location and at what precision +- **c3nav Integration**: Indoor navigation for CCC events (38C3, Easterhegg, Camp) +- **Ephemeral Rooms**: Create a room, share the link, meet up +- **No Account Required**: Just enter your name and go + +## Quick Start + +```bash +# Install dependencies +npm install + +# Run development server +npm run dev +``` + +Open [http://localhost:3000](http://localhost:3000) + +## Architecture + +``` +┌─────────────────────────────────────────────────┐ +│ rMaps.online │ +├─────────────────────────────────────────────────┤ +│ Frontend: Next.js + React + MapLibre GL │ +│ State: Zustand + Automerge (CRDT) │ +│ Maps: OpenStreetMap (outdoor) + c3nav (indoor) │ +│ Sync: WebSocket / Automerge Repo │ +└─────────────────────────────────────────────────┘ +``` + +## Room URLs + +- **Path-based**: `rmaps.online/room/my-crew` +- **Subdomain** (planned): `my-crew.rmaps.online` + +## c3nav Integration + +rMaps integrates with [c3nav](https://github.com/c3nav/c3nav) for indoor navigation at CCC events: + +- Automatic detection when entering venue area +- Indoor positioning via WiFi/BLE +- Floor-aware navigation +- Route planning to friends, events, and POIs + +## Development + +```bash +# Type checking +npm run type-check + +# Linting +npm run lint + +# Build for production +npm run build +``` + +## Deployment + +### Docker + +```bash +docker compose up -d --build +``` + +### Traefik Labels + +The docker-compose.yml includes Traefik labels for: +- Main domain routing (`rmaps.online`) +- Wildcard subdomain routing (`*.rmaps.online`) + +## Privacy + +- **No tracking**: We don't store location history +- **Ephemeral rooms**: Auto-delete after 7 days of inactivity +- **Precision control**: Choose how accurately to share your location +- **Ghost mode**: Hide your location while staying in the room + +## License + +MIT diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6ebd69d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,36 @@ +version: '3.8' + +services: + rmaps: + build: + context: . + dockerfile: Dockerfile + container_name: rmaps-online + restart: unless-stopped + environment: + - NODE_ENV=production + - PORT=3000 + labels: + # Traefik routing + - "traefik.enable=true" + # Main domain + - "traefik.http.routers.rmaps.rule=Host(`rmaps.online`) || Host(`www.rmaps.online`)" + - "traefik.http.routers.rmaps.entrypoints=web,websecure" + - "traefik.http.services.rmaps.loadbalancer.server.port=3000" + # Wildcard subdomain routing (*.rmaps.online) + - "traefik.http.routers.rmaps-subdomain.rule=HostRegexp(`{subdomain:[a-z0-9-]+}.rmaps.online`)" + - "traefik.http.routers.rmaps-subdomain.entrypoints=web,websecure" + - "traefik.http.routers.rmaps-subdomain.service=rmaps" + - "traefik.http.routers.rmaps-subdomain.priority=1" + networks: + - traefik-public + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://localhost:3000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + +networks: + traefik-public: + external: true diff --git a/next.config.js b/next.config.js new file mode 100644 index 0000000..c8fd2ed --- /dev/null +++ b/next.config.js @@ -0,0 +1,43 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + reactStrictMode: true, + output: 'standalone', + + // Handle subdomain routing + async rewrites() { + return { + beforeFiles: [ + // Health check endpoint + { + source: '/health', + destination: '/api/health', + }, + ], + }; + }, + + // Security headers + async headers() { + return [ + { + source: '/:path*', + headers: [ + { + key: 'X-Frame-Options', + value: 'SAMEORIGIN', + }, + { + key: 'X-Content-Type-Options', + value: 'nosniff', + }, + { + key: 'Permissions-Policy', + value: 'geolocation=(self)', + }, + ], + }, + ]; + }, +}; + +module.exports = nextConfig; diff --git a/package.json b/package.json new file mode 100644 index 0000000..a173aa6 --- /dev/null +++ b/package.json @@ -0,0 +1,37 @@ +{ + "name": "rmaps-online", + "version": "0.1.0", + "private": true, + "description": "Collaborative real-time friend-finding navigation for events", + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint", + "type-check": "tsc --noEmit" + }, + "dependencies": { + "@automerge/automerge": "^2.2.8", + "@automerge/automerge-repo": "^1.2.1", + "@automerge/automerge-repo-network-websocket": "^1.2.1", + "@automerge/automerge-repo-react-hooks": "^1.2.1", + "@automerge/automerge-repo-storage-indexeddb": "^1.2.1", + "maplibre-gl": "^5.0.0", + "next": "14.2.21", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "nanoid": "^5.0.9", + "zustand": "^5.0.2" + }, + "devDependencies": { + "@types/node": "^22.10.2", + "@types/react": "^18.3.17", + "@types/react-dom": "^18.3.5", + "typescript": "^5.7.2", + "eslint": "^9.17.0", + "eslint-config-next": "14.2.21", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.17" + } +} diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..12a703d --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 0000000..79a798b --- /dev/null +++ b/public/manifest.json @@ -0,0 +1,25 @@ +{ + "name": "rMaps - Find Your Friends", + "short_name": "rMaps", + "description": "Collaborative real-time friend-finding navigation for events", + "start_url": "/", + "display": "standalone", + "background_color": "#0f172a", + "theme_color": "#10b981", + "orientation": "portrait-primary", + "icons": [ + { + "src": "/icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/icon-512.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "categories": ["navigation", "social"], + "lang": "en", + "dir": "ltr" +} diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts new file mode 100644 index 0000000..30169ff --- /dev/null +++ b/src/app/api/health/route.ts @@ -0,0 +1,10 @@ +import { NextResponse } from 'next/server'; + +export async function GET() { + return NextResponse.json({ + status: 'healthy', + timestamp: new Date().toISOString(), + service: 'rmaps-online', + version: process.env.npm_package_version || '0.1.0', + }); +} diff --git a/src/app/globals.css b/src/app/globals.css new file mode 100644 index 0000000..d92465d --- /dev/null +++ b/src/app/globals.css @@ -0,0 +1,176 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + --rmaps-primary: #10b981; + --rmaps-secondary: #6366f1; + --rmaps-dark: #0f172a; + --rmaps-light: #f8fafc; +} + +* { + box-sizing: border-box; + padding: 0; + margin: 0; +} + +html, +body { + max-width: 100vw; + overflow-x: hidden; + height: 100%; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, + Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; + background: var(--rmaps-dark); + color: var(--rmaps-light); +} + +/* MapLibre GL overrides */ +.maplibregl-map { + font-family: inherit; +} + +.maplibregl-ctrl-group { + background: rgba(15, 23, 42, 0.9) !important; + border: 1px solid rgba(255, 255, 255, 0.1) !important; +} + +.maplibregl-ctrl-group button { + background-color: transparent !important; +} + +.maplibregl-ctrl-group button:hover { + background-color: rgba(255, 255, 255, 0.1) !important; +} + +.maplibregl-ctrl-group button span { + filter: invert(1); +} + +/* Friend marker styles */ +.friend-marker { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border-radius: 50%; + font-size: 1.5rem; + cursor: pointer; + transition: transform 0.2s ease; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); +} + +.friend-marker:hover { + transform: scale(1.2); +} + +.friend-marker.sharing { + animation: pulse-ring 2s infinite; +} + +@keyframes pulse-ring { + 0% { + box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.4); + } + 70% { + box-shadow: 0 0 0 15px rgba(16, 185, 129, 0); + } + 100% { + box-shadow: 0 0 0 0 rgba(16, 185, 129, 0); + } +} + +/* Accuracy circle */ +.accuracy-circle { + position: absolute; + border-radius: 50%; + background: rgba(16, 185, 129, 0.15); + border: 2px solid rgba(16, 185, 129, 0.3); + pointer-events: none; +} + +/* Room panel */ +.room-panel { + background: rgba(15, 23, 42, 0.95); + backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, 0.1); +} + +/* Participant list item */ +.participant-item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem; + border-radius: 0.5rem; + transition: background 0.2s ease; +} + +.participant-item:hover { + background: rgba(255, 255, 255, 0.05); +} + +/* Status indicators */ +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; +} + +.status-dot.online { + background: #10b981; + box-shadow: 0 0 6px #10b981; +} + +.status-dot.away { + background: #f59e0b; +} + +.status-dot.ghost { + background: #6b7280; +} + +.status-dot.offline { + background: #ef4444; +} + +/* Button styles */ +.btn-primary { + @apply bg-rmaps-primary hover:bg-emerald-600 text-white font-medium py-2 px-4 rounded-lg transition-colors; +} + +.btn-secondary { + @apply bg-rmaps-secondary hover:bg-indigo-600 text-white font-medium py-2 px-4 rounded-lg transition-colors; +} + +.btn-ghost { + @apply bg-transparent hover:bg-white/10 text-white font-medium py-2 px-4 rounded-lg transition-colors border border-white/20; +} + +/* Input styles */ +.input { + @apply bg-white/10 border border-white/20 rounded-lg px-4 py-2 text-white placeholder-white/50 focus:outline-none focus:border-rmaps-primary focus:ring-1 focus:ring-rmaps-primary; +} + +/* c3nav iframe container */ +.c3nav-container { + position: relative; + width: 100%; + height: 100%; +} + +.c3nav-container iframe { + width: 100%; + height: 100%; + border: none; +} + +/* Loading states */ +.skeleton { + @apply bg-white/10 animate-pulse rounded; +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx new file mode 100644 index 0000000..585b98b --- /dev/null +++ b/src/app/layout.tsx @@ -0,0 +1,48 @@ +import type { Metadata, Viewport } from 'next'; +import './globals.css'; + +export const metadata: Metadata = { + title: 'rMaps - Find Your Friends', + description: 'Collaborative real-time friend-finding navigation for events', + keywords: ['maps', 'navigation', 'friends', 'realtime', 'CCC', '38c3'], + authors: [{ name: 'Jeff Emmett' }], + openGraph: { + title: 'rMaps - Find Your Friends', + description: 'Collaborative real-time friend-finding navigation for events', + 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', + }, + manifest: '/manifest.json', + icons: { + icon: '/favicon.ico', + apple: '/apple-touch-icon.png', + }, +}; + +export const viewport: Viewport = { + width: 'device-width', + initialScale: 1, + maximumScale: 1, + userScalable: false, + themeColor: '#0f172a', +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + {children} + + + ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx new file mode 100644 index 0000000..f448259 --- /dev/null +++ b/src/app/page.tsx @@ -0,0 +1,188 @@ +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { nanoid } from 'nanoid'; + +// Emoji options for avatars +const EMOJI_OPTIONS = ['🐙', '🦊', '🐻', '🐱', '🦝', '🐸', '🦉', '🐧', '🦋', '🐝']; + +// Generate a URL-safe room slug +function generateSlug(): string { + return nanoid(8).toLowerCase(); +} + +export default function HomePage() { + const router = useRouter(); + const [isCreating, setIsCreating] = useState(false); + const [joinSlug, setJoinSlug] = useState(''); + const [name, setName] = useState(''); + const [emoji, setEmoji] = useState(EMOJI_OPTIONS[Math.floor(Math.random() * EMOJI_OPTIONS.length)]); + const [roomName, setRoomName] = useState(''); + + const handleCreateRoom = async () => { + if (!name.trim()) return; + + const slug = roomName.trim() + ? roomName.toLowerCase().replace(/[^a-z0-9]/g, '-').slice(0, 20) + : generateSlug(); + + // Store user info in localStorage for the session + localStorage.setItem('rmaps_user', JSON.stringify({ name, emoji })); + + // Navigate to the room (will create it if it doesn't exist) + router.push(`/room/${slug}`); + }; + + const handleJoinRoom = () => { + if (!name.trim() || !joinSlug.trim()) return; + + localStorage.setItem('rmaps_user', JSON.stringify({ name, emoji })); + + // Clean the slug + const cleanSlug = joinSlug.toLowerCase().replace(/[^a-z0-9-]/g, ''); + router.push(`/room/${cleanSlug}`); + }; + + return ( +
+
+ {/* Logo/Title */} +
+

+ rMaps +

+

Find your friends at events

+
+ + {/* Main Card */} +
+ {/* User Setup */} +
+

Your Profile

+ +
+ + setName(e.target.value)} + placeholder="Enter your name" + className="input w-full" + maxLength={20} + /> +
+ +
+ +
+ {EMOJI_OPTIONS.map((e) => ( + + ))} +
+
+
+ +
+ + {/* Create Room */} + {!isCreating ? ( +
+ + +
or
+ + {/* Join Room */} +
+ setJoinSlug(e.target.value)} + placeholder="Enter room name or code" + className="input w-full" + /> + +
+
+ ) : ( +
+
+ +
+ setRoomName(e.target.value)} + placeholder="e.g., 38c3-crew" + className="input flex-1" + maxLength={20} + /> + .rmaps.online +
+

+ Leave blank for a random code +

+
+ +
+ + +
+
+ )} +
+ + {/* Footer */} +
+

Privacy-first location sharing

+

+ Built for{' '} + + CCC events + +

+
+
+
+ ); +} diff --git a/src/app/room/[slug]/page.tsx b/src/app/room/[slug]/page.tsx new file mode 100644 index 0000000..930446b --- /dev/null +++ b/src/app/room/[slug]/page.tsx @@ -0,0 +1,127 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useParams } from 'next/navigation'; +import dynamic from 'next/dynamic'; +import { useRoomStore } from '@/stores/room'; +import { useLocationSharing } from '@/hooks/useLocationSharing'; +import ParticipantList from '@/components/room/ParticipantList'; +import RoomHeader from '@/components/room/RoomHeader'; +import ShareModal from '@/components/room/ShareModal'; +import type { Participant } from '@/types'; + +// Dynamic import for map to avoid SSR issues with MapLibre +const MapView = dynamic(() => import('@/components/map/MapView'), { + ssr: false, + loading: () => ( +
+
Loading map...
+
+ ), +}); + +export default function RoomPage() { + const params = useParams(); + const slug = params.slug as string; + + const [showShare, setShowShare] = useState(false); + const [showParticipants, setShowParticipants] = useState(true); + const [currentUser, setCurrentUser] = useState<{ name: string; emoji: string } | null>(null); + + const { + room, + participants, + isConnected, + error, + joinRoom, + leaveRoom, + updateParticipant, + } = useRoomStore(); + + const { isSharing, startSharing, stopSharing, currentLocation } = useLocationSharing({ + onLocationUpdate: (location) => { + if (currentUser) { + updateParticipant({ location }); + } + }, + }); + + // Load user from localStorage and join room + useEffect(() => { + const stored = localStorage.getItem('rmaps_user'); + if (stored) { + const user = JSON.parse(stored); + setCurrentUser(user); + joinRoom(slug, user.name, user.emoji); + } else { + // Redirect to home if no user info + window.location.href = '/'; + } + + return () => { + leaveRoom(); + }; + }, [slug, joinRoom, leaveRoom]); + + // Auto-start location sharing when joining + useEffect(() => { + if (isConnected && currentUser && !isSharing) { + startSharing(); + } + }, [isConnected, currentUser, isSharing, startSharing]); + + if (error) { + return ( +
+
+

Error

+

{error}

+ + Go Home + +
+
+ ); + } + + return ( +
+ {/* Header */} + (isSharing ? stopSharing() : startSharing())} + onShare={() => setShowShare(true)} + onToggleParticipants={() => setShowParticipants(!showParticipants)} + /> + + {/* Main Content */} +
+ {/* Map */} + console.log('Clicked participant:', p)} + /> + + {/* Participant Panel (mobile: bottom sheet, desktop: sidebar) */} + {showParticipants && ( +
+ setShowParticipants(false)} + onNavigateTo={(p) => console.log('Navigate to:', p)} + /> +
+ )} +
+ + {/* Share Modal */} + {showShare && ( + setShowShare(false)} /> + )} +
+ ); +} diff --git a/src/components/map/FriendMarker.tsx b/src/components/map/FriendMarker.tsx new file mode 100644 index 0000000..caa8155 --- /dev/null +++ b/src/components/map/FriendMarker.tsx @@ -0,0 +1,60 @@ +'use client'; + +import type { Participant } from '@/types'; + +interface FriendMarkerProps { + participant: Participant; + isCurrentUser?: boolean; + onClick?: () => void; +} + +export default function FriendMarker({ + participant, + isCurrentUser = false, + onClick, +}: FriendMarkerProps) { + const { emoji, color, name, status, location } = participant; + + // Calculate how stale the location is + const getLocationAge = () => { + if (!location) return null; + const ageMs = Date.now() - location.timestamp.getTime(); + const ageSec = Math.floor(ageMs / 1000); + if (ageSec < 60) return `${ageSec}s ago`; + const ageMin = Math.floor(ageSec / 60); + if (ageMin < 60) return `${ageMin}m ago`; + return 'stale'; + }; + + const locationAge = getLocationAge(); + const isStale = locationAge === 'stale'; + + return ( +
+ {emoji} + + {/* Status indicator */} +
+ + {/* Heading indicator */} + {location?.heading !== undefined && ( +
+ )} +
+ ); +} diff --git a/src/components/map/MapView.tsx b/src/components/map/MapView.tsx new file mode 100644 index 0000000..b587cd3 --- /dev/null +++ b/src/components/map/MapView.tsx @@ -0,0 +1,198 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import maplibregl from 'maplibre-gl'; +import 'maplibre-gl/dist/maplibre-gl.css'; +import type { Participant, MapViewport } from '@/types'; +import FriendMarker from './FriendMarker'; + +interface MapViewProps { + participants: Participant[]; + currentUserId?: string; + initialViewport?: MapViewport; + onParticipantClick?: (participant: Participant) => void; + onMapClick?: (lngLat: { lng: number; lat: number }) => void; +} + +// Default to Hamburg CCH area for CCC events +const DEFAULT_VIEWPORT: MapViewport = { + center: [9.9898, 53.5550], // Hamburg CCH + zoom: 15, +}; + +export default function MapView({ + participants, + currentUserId, + initialViewport = DEFAULT_VIEWPORT, + onParticipantClick, + onMapClick, +}: MapViewProps) { + const mapContainer = useRef(null); + const map = useRef(null); + const markersRef = useRef>(new Map()); + const [mapLoaded, setMapLoaded] = useState(false); + + // Initialize map + useEffect(() => { + if (!mapContainer.current || map.current) return; + + map.current = new maplibregl.Map({ + container: mapContainer.current, + style: { + version: 8, + sources: { + osm: { + type: 'raster', + tiles: [ + 'https://a.tile.openstreetmap.org/{z}/{x}/{y}.png', + 'https://b.tile.openstreetmap.org/{z}/{x}/{y}.png', + 'https://c.tile.openstreetmap.org/{z}/{x}/{y}.png', + ], + tileSize: 256, + attribution: '© OpenStreetMap', + }, + }, + layers: [ + { + id: 'osm', + type: 'raster', + source: 'osm', + minzoom: 0, + maxzoom: 19, + }, + ], + }, + center: initialViewport.center, + zoom: initialViewport.zoom, + bearing: initialViewport.bearing ?? 0, + pitch: initialViewport.pitch ?? 0, + }); + + // Add controls + map.current.addControl(new maplibregl.NavigationControl(), 'top-right'); + map.current.addControl( + new maplibregl.GeolocateControl({ + positionOptions: { + enableHighAccuracy: true, + }, + trackUserLocation: true, + showUserHeading: true, + }), + 'top-right' + ); + map.current.addControl(new maplibregl.ScaleControl(), 'bottom-left'); + + // Handle click events + map.current.on('click', (e) => { + onMapClick?.({ lng: e.lngLat.lng, lat: e.lngLat.lat }); + }); + + map.current.on('load', () => { + setMapLoaded(true); + }); + + return () => { + map.current?.remove(); + map.current = null; + }; + }, [initialViewport, onMapClick]); + + // Update markers when participants change + useEffect(() => { + if (!map.current || !mapLoaded) return; + + const currentMarkers = markersRef.current; + const participantIds = new Set(participants.map((p) => p.id)); + + // Remove markers for participants who left + currentMarkers.forEach((marker, id) => { + if (!participantIds.has(id)) { + marker.remove(); + currentMarkers.delete(id); + } + }); + + // Add/update markers for current participants + participants.forEach((participant) => { + if (!participant.location) return; + + const { latitude, longitude } = participant.location; + let marker = currentMarkers.get(participant.id); + + if (marker) { + // Update existing marker position + marker.setLngLat([longitude, latitude]); + } else { + // Create new marker + const el = document.createElement('div'); + el.className = 'friend-marker'; + el.style.backgroundColor = participant.color; + el.innerHTML = participant.emoji; + + if (participant.id === currentUserId) { + el.classList.add('sharing'); + } + + el.addEventListener('click', (e) => { + e.stopPropagation(); + onParticipantClick?.(participant); + }); + + marker = new maplibregl.Marker({ element: el }) + .setLngLat([longitude, latitude]) + .addTo(map.current!); + + currentMarkers.set(participant.id, marker); + } + + // Add accuracy circle if available + // TODO: Implement accuracy circles as a layer + }); + }, [participants, mapLoaded, currentUserId, onParticipantClick]); + + // Fit bounds to show all participants + const fitToParticipants = () => { + if (!map.current || participants.length === 0) return; + + const locatedParticipants = participants.filter((p) => p.location); + if (locatedParticipants.length === 0) return; + + if (locatedParticipants.length === 1) { + const loc = locatedParticipants[0].location!; + map.current.flyTo({ + center: [loc.longitude, loc.latitude], + zoom: 16, + }); + } else { + const bounds = new maplibregl.LngLatBounds(); + locatedParticipants.forEach((p) => { + bounds.extend([p.location!.longitude, p.location!.latitude]); + }); + map.current.fitBounds(bounds, { padding: 50 }); + } + }; + + return ( +
+
+ + {/* Fit all button */} + {participants.some((p) => p.location) && ( + + )} + + {/* Loading overlay */} + {!mapLoaded && ( +
+
Loading map...
+
+ )} +
+ ); +} diff --git a/src/components/room/ParticipantList.tsx b/src/components/room/ParticipantList.tsx new file mode 100644 index 0000000..e584778 --- /dev/null +++ b/src/components/room/ParticipantList.tsx @@ -0,0 +1,151 @@ +'use client'; + +import type { Participant } from '@/types'; + +interface ParticipantListProps { + participants: Participant[]; + currentUserId?: string; + onClose: () => void; + onNavigateTo: (participant: Participant) => void; +} + +export default function ParticipantList({ + participants, + currentUserId, + onClose, + onNavigateTo, +}: ParticipantListProps) { + const formatDistance = (participant: Participant, current: Participant | undefined) => { + if (!participant.location || !current?.location) return null; + + // Haversine distance calculation + const R = 6371e3; // Earth radius in meters + const lat1 = (current.location.latitude * Math.PI) / 180; + const lat2 = (participant.location.latitude * Math.PI) / 180; + const deltaLat = + ((participant.location.latitude - current.location.latitude) * Math.PI) / 180; + const deltaLng = + ((participant.location.longitude - current.location.longitude) * Math.PI) / 180; + + const a = + Math.sin(deltaLat / 2) * Math.sin(deltaLat / 2) + + Math.cos(lat1) * + Math.cos(lat2) * + Math.sin(deltaLng / 2) * + Math.sin(deltaLng / 2); + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + const distance = R * c; + + if (distance < 50) return 'nearby'; + if (distance < 1000) return `${Math.round(distance)}m`; + return `${(distance / 1000).toFixed(1)}km`; + }; + + const currentParticipant = participants.find((p) => p.name === currentUserId); + + return ( +
+ {/* Header */} +
+

Friends ({participants.length})

+ +
+ + {/* Participant list */} +
+ {participants.length === 0 ? ( +
+ No one else is here yet +
+ ) : ( +
+ {participants.map((participant) => { + const isMe = participant.name === currentUserId; + const distance = !isMe + ? formatDistance(participant, currentParticipant) + : null; + + return ( +
!isMe && onNavigateTo(participant)} + > + {/* Avatar */} +
+ {participant.emoji} +
+ + {/* Info */} +
+
+ + {participant.name} + {isMe && ( + (you) + )} + +
+
+ {participant.location && ( +
+ {distance ? `${distance} away` : 'Location shared'} +
+ )} + {participant.status === 'ghost' && ( +
Location hidden
+ )} +
+ + {/* Navigate button */} + {!isMe && participant.location && ( + + )} +
+ ); + })} +
+ )} +
+ + {/* Footer actions */} +
+ +
+
+ ); +} diff --git a/src/components/room/RoomHeader.tsx b/src/components/room/RoomHeader.tsx new file mode 100644 index 0000000..aacf9d0 --- /dev/null +++ b/src/components/room/RoomHeader.tsx @@ -0,0 +1,96 @@ +'use client'; + +interface RoomHeaderProps { + roomSlug: string; + participantCount: number; + isSharing: boolean; + onToggleSharing: () => void; + onShare: () => void; + onToggleParticipants: () => void; +} + +export default function RoomHeader({ + roomSlug, + participantCount, + isSharing, + onToggleSharing, + onShare, + onToggleParticipants, +}: RoomHeaderProps) { + return ( +
+ {/* Left: Room info */} +
+ + rMaps + +
+ {roomSlug} +
+ + {/* Center: Participant count */} + + + {/* Right: Actions */} +
+ {/* Share button */} + + + {/* Location sharing toggle */} + +
+
+ ); +} diff --git a/src/components/room/ShareModal.tsx b/src/components/room/ShareModal.tsx new file mode 100644 index 0000000..813d3c2 --- /dev/null +++ b/src/components/room/ShareModal.tsx @@ -0,0 +1,113 @@ +'use client'; + +import { useState } from 'react'; + +interface ShareModalProps { + roomSlug: string; + onClose: () => void; +} + +export default function ShareModal({ roomSlug, onClose }: ShareModalProps) { + const [copied, setCopied] = useState(false); + + // In production, this would be .rmaps.online + // For now, use path-based routing + const shareUrl = + typeof window !== 'undefined' + ? `${window.location.origin}/room/${roomSlug}` + : `https://rmaps.online/room/${roomSlug}`; + + const subdomainUrl = `https://${roomSlug}.rmaps.online`; + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(shareUrl); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.error('Failed to copy:', err); + } + }; + + const handleShare = async () => { + if (navigator.share) { + try { + await navigator.share({ + title: `Join my rMaps room: ${roomSlug}`, + text: 'Find me on rMaps!', + url: shareUrl, + }); + } catch (err) { + // User cancelled or share failed + console.log('Share cancelled:', err); + } + } else { + handleCopy(); + } + }; + + return ( +
+ {/* Backdrop */} +
+ + {/* Modal */} +
+ {/* Close button */} + + +

Share this Map

+ +

+ Invite friends to join your map. Anyone with this link can see your + shared location. +

+ + {/* URL display */} +
+
Room Link
+
{shareUrl}
+
+ + {/* Subdomain preview */} +
+
Coming soon
+
{subdomainUrl}
+
+ + {/* Actions */} +
+ + +
+ + {/* QR Code placeholder */} +
+
Or scan QR code
+
+ QR Coming Soon +
+
+
+
+ ); +} diff --git a/src/hooks/useLocationSharing.ts b/src/hooks/useLocationSharing.ts new file mode 100644 index 0000000..8436964 --- /dev/null +++ b/src/hooks/useLocationSharing.ts @@ -0,0 +1,168 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; +import type { ParticipantLocation, LocationSource } from '@/types'; + +interface UseLocationSharingOptions { + /** Called when location updates */ + onLocationUpdate?: (location: ParticipantLocation) => void; + /** Update interval in milliseconds (default: 5000) */ + updateInterval?: number; + /** Enable high accuracy mode (uses more battery) */ + highAccuracy?: boolean; + /** Maximum age of cached position in ms (default: 10000) */ + maxAge?: number; + /** Timeout for position request in ms (default: 10000) */ + timeout?: number; +} + +interface UseLocationSharingReturn { + /** Whether location sharing is currently active */ + isSharing: boolean; + /** Current location (if available) */ + currentLocation: ParticipantLocation | null; + /** Any error that occurred */ + error: GeolocationPositionError | null; + /** Start sharing location */ + startSharing: () => void; + /** Stop sharing location */ + stopSharing: () => void; + /** Request a single location update */ + requestUpdate: () => void; + /** Permission state */ + permissionState: PermissionState | null; +} + +export function useLocationSharing( + options: UseLocationSharingOptions = {} +): UseLocationSharingReturn { + const { + onLocationUpdate, + updateInterval = 5000, + highAccuracy = true, + maxAge = 10000, + timeout = 10000, + } = options; + + const [isSharing, setIsSharing] = useState(false); + const [currentLocation, setCurrentLocation] = useState(null); + const [error, setError] = useState(null); + const [permissionState, setPermissionState] = useState(null); + + const watchIdRef = useRef(null); + const onLocationUpdateRef = useRef(onLocationUpdate); + + // Keep callback ref updated + useEffect(() => { + onLocationUpdateRef.current = onLocationUpdate; + }, [onLocationUpdate]); + + // Check permission state + useEffect(() => { + if ('permissions' in navigator) { + navigator.permissions + .query({ name: 'geolocation' }) + .then((result) => { + setPermissionState(result.state); + result.addEventListener('change', () => { + setPermissionState(result.state); + }); + }) + .catch(() => { + // Permissions API not supported, will check on first request + }); + } + }, []); + + const handlePosition = useCallback((position: GeolocationPosition) => { + const location: ParticipantLocation = { + latitude: position.coords.latitude, + longitude: position.coords.longitude, + accuracy: position.coords.accuracy, + altitude: position.coords.altitude ?? undefined, + altitudeAccuracy: position.coords.altitudeAccuracy ?? undefined, + heading: position.coords.heading ?? undefined, + speed: position.coords.speed ?? undefined, + timestamp: new Date(position.timestamp), + source: 'gps' as LocationSource, + }; + + setCurrentLocation(location); + setError(null); + onLocationUpdateRef.current?.(location); + }, []); + + const handleError = useCallback((err: GeolocationPositionError) => { + setError(err); + console.error('Geolocation error:', err.message); + }, []); + + const startSharing = useCallback(() => { + if (!('geolocation' in navigator)) { + console.error('Geolocation is not supported'); + return; + } + + if (watchIdRef.current !== null) { + return; // Already watching + } + + const geoOptions: PositionOptions = { + enableHighAccuracy: highAccuracy, + maximumAge: maxAge, + timeout, + }; + + // Start watching position + watchIdRef.current = navigator.geolocation.watchPosition( + handlePosition, + handleError, + geoOptions + ); + + setIsSharing(true); + console.log('Started location sharing'); + }, [highAccuracy, maxAge, timeout, handlePosition, handleError]); + + const stopSharing = useCallback(() => { + if (watchIdRef.current !== null) { + navigator.geolocation.clearWatch(watchIdRef.current); + watchIdRef.current = null; + } + setIsSharing(false); + console.log('Stopped location sharing'); + }, []); + + const requestUpdate = useCallback(() => { + if (!('geolocation' in navigator)) { + return; + } + + navigator.geolocation.getCurrentPosition( + handlePosition, + handleError, + { + enableHighAccuracy: highAccuracy, + maximumAge: 0, + timeout, + } + ); + }, [highAccuracy, timeout, handlePosition, handleError]); + + // Cleanup on unmount + useEffect(() => { + return () => { + if (watchIdRef.current !== null) { + navigator.geolocation.clearWatch(watchIdRef.current); + } + }; + }, []); + + return { + isSharing, + currentLocation, + error, + startSharing, + stopSharing, + requestUpdate, + permissionState, + }; +} diff --git a/src/lib/c3nav.ts b/src/lib/c3nav.ts new file mode 100644 index 0000000..e5bf2a8 --- /dev/null +++ b/src/lib/c3nav.ts @@ -0,0 +1,190 @@ +/** + * c3nav API client for indoor navigation at CCC events + * API docs: https://.c3nav.de/api/v2/ + */ + +import type { + C3NavLocation, + C3NavRouteRequest, + C3NavRouteResponse, +} from '@/types'; + +// Default to 38c3, can be overridden per-room +const DEFAULT_C3NAV_BASE = 'https://38c3.c3nav.de'; + +export class C3NavClient { + private baseUrl: string; + + constructor(baseUrl: string = DEFAULT_C3NAV_BASE) { + this.baseUrl = baseUrl; + } + + /** + * Get all locations (rooms, POIs, etc.) + */ + async getLocations(options?: { + searchable?: boolean; + geometry?: boolean; + }): Promise { + const params = new URLSearchParams(); + if (options?.searchable !== undefined) { + params.set('searchable', String(options.searchable)); + } + if (options?.geometry !== undefined) { + params.set('geometry', String(options.geometry)); + } + + const response = await fetch( + `${this.baseUrl}/api/v2/map/locations/?${params}` + ); + + if (!response.ok) { + throw new Error(`c3nav API error: ${response.status}`); + } + + return response.json(); + } + + /** + * Get a specific location by slug + */ + async getLocation(slug: string): Promise { + const response = await fetch( + `${this.baseUrl}/api/v2/map/locations/by-slug/${slug}/` + ); + + if (!response.ok) { + throw new Error(`c3nav location not found: ${slug}`); + } + + return response.json(); + } + + /** + * Calculate a route between two points + */ + async getRoute(request: C3NavRouteRequest): Promise { + const response = await fetch(`${this.baseUrl}/api/v2/routing/route/`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(request), + }); + + if (!response.ok) { + throw new Error(`c3nav routing error: ${response.status}`); + } + + return response.json(); + } + + /** + * Get map settings (includes projection info for coordinate conversion) + */ + async getMapSettings(): Promise> { + const response = await fetch(`${this.baseUrl}/api/v2/map/settings/`); + + if (!response.ok) { + throw new Error(`c3nav settings error: ${response.status}`); + } + + return response.json(); + } + + /** + * Get map bounds + */ + async getMapBounds(): Promise<{ + bounds: [number, number, number, number]; + levels: Array<{ level: number; title: string }>; + }> { + const response = await fetch(`${this.baseUrl}/api/v2/map/bounds/`); + + if (!response.ok) { + throw new Error(`c3nav bounds error: ${response.status}`); + } + + return response.json(); + } + + /** + * Position using WiFi/BLE measurements + */ + async locate(measurements: { + wifi?: Array<{ bssid: string; rssi: number }>; + ble?: Array<{ uuid: string; major: number; minor: number; rssi: number }>; + }): Promise<{ + x: number; + y: number; + level: number; + accuracy: number; + } | null> { + const response = await fetch(`${this.baseUrl}/api/v2/positioning/locate/`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(measurements), + }); + + if (!response.ok) { + return null; + } + + return response.json(); + } + + /** + * Get the embed URL for iframe integration + */ + getEmbedUrl(options?: { + location?: string; + origin?: string; + destination?: string; + level?: number; + }): string { + const params = new URLSearchParams(); + params.set('embed', '1'); + + if (options?.location) { + params.set('o', options.location); + } + if (options?.origin) { + params.set('origin', options.origin); + } + if (options?.destination) { + params.set('destination', options.destination); + } + if (options?.level !== undefined) { + params.set('level', String(options.level)); + } + + return `${this.baseUrl}/?${params}`; + } +} + +// Singleton instance +export const c3nav = new C3NavClient(); + +// Helper to check if coordinates are within c3nav coverage +export function isInC3NavArea( + lat: number, + lng: number, + eventBounds?: { north: number; south: number; east: number; west: number } +): boolean { + // Default: Hamburg CCH bounds + const bounds = eventBounds ?? { + north: 53.558, + south: 53.552, + east: 9.995, + west: 9.985, + }; + + return ( + lat >= bounds.south && + lat <= bounds.north && + lng >= bounds.west && + lng <= bounds.east + ); +} diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000..3c976d2 --- /dev/null +++ b/src/middleware.ts @@ -0,0 +1,62 @@ +import { NextResponse } from 'next/server'; +import type { NextRequest } from 'next/server'; + +/** + * Middleware to handle subdomain-based room routing + * + * Routes: + * - rmaps.online -> home page + * - www.rmaps.online -> home page + * - .rmaps.online -> /room/ + * + * Also handles localhost for development + */ +export function middleware(request: NextRequest) { + const url = request.nextUrl.clone(); + const hostname = request.headers.get('host') || ''; + + // Extract subdomain + // Production: .rmaps.online + // Development: .localhost:3000 + let subdomain: string | null = null; + + if (hostname.includes('rmaps.online')) { + // Production + const parts = hostname.split('.rmaps.online')[0].split('.'); + if (parts.length > 0 && parts[0] !== 'www' && parts[0] !== 'rmaps') { + subdomain = parts[parts.length - 1]; + } + } else if (hostname.includes('localhost')) { + // Development: check for subdomain.localhost:port + const parts = hostname.split('.localhost')[0].split('.'); + if (parts.length > 0 && parts[0] !== 'localhost') { + subdomain = parts[parts.length - 1]; + } + } + + // If we have a subdomain, rewrite to the room page + if (subdomain && subdomain.length > 0) { + // Don't rewrite if already on /room/ path + if (!url.pathname.startsWith('/room/')) { + url.pathname = `/room/${subdomain}${url.pathname === '/' ? '' : url.pathname}`; + return NextResponse.rewrite(url); + } + } + + return NextResponse.next(); +} + +export const config = { + // Match all paths except static files and API routes + matcher: [ + /* + * Match all request paths except: + * - _next/static (static files) + * - _next/image (image optimization files) + * - favicon.ico (favicon file) + * - public files (public directory) + * - api routes (handled separately) + */ + '/((?!_next/static|_next/image|favicon.ico|.*\\..*|api).*)', + ], +}; diff --git a/src/stores/room.ts b/src/stores/room.ts new file mode 100644 index 0000000..0fb3cb0 --- /dev/null +++ b/src/stores/room.ts @@ -0,0 +1,170 @@ +import { create } from 'zustand'; +import { nanoid } from 'nanoid'; +import type { + Room, + Participant, + ParticipantLocation, + ParticipantStatus, + Waypoint, + RoomSettings, + PrecisionLevel, +} from '@/types'; + +// Color palette for participants +const COLORS = [ + '#10b981', // emerald + '#6366f1', // indigo + '#f59e0b', // amber + '#ef4444', // red + '#8b5cf6', // violet + '#ec4899', // pink + '#14b8a6', // teal + '#f97316', // orange + '#84cc16', // lime + '#06b6d4', // cyan +]; + +interface RoomState { + room: Room | null; + participants: Participant[]; + currentParticipantId: string | null; + isConnected: boolean; + error: string | null; + + // Actions + joinRoom: (slug: string, name: string, emoji: string) => void; + leaveRoom: () => void; + updateParticipant: (updates: Partial) => void; + updateLocation: (location: ParticipantLocation) => void; + setStatus: (status: ParticipantStatus) => void; + addWaypoint: (waypoint: Omit) => void; + removeWaypoint: (waypointId: string) => void; + + // Internal + _syncFromDocument: (doc: unknown) => void; +} + +export const useRoomStore = create((set, get) => ({ + room: null, + participants: [], + currentParticipantId: null, + isConnected: false, + error: null, + + joinRoom: (slug: string, name: string, emoji: string) => { + const participantId = nanoid(); + const colorIndex = Math.floor(Math.random() * COLORS.length); + + const participant: Participant = { + id: participantId, + name, + emoji, + color: COLORS[colorIndex], + joinedAt: new Date(), + lastSeen: new Date(), + status: 'online', + privacySettings: { + sharingEnabled: true, + defaultPrecision: 'exact' as PrecisionLevel, + showIndoorFloor: true, + ghostMode: false, + }, + }; + + // Create or join room + const room: Room = { + id: nanoid(), + slug, + name: slug, + createdAt: new Date(), + createdBy: participantId, + expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days + settings: { + maxParticipants: 10, + defaultPrecision: 'exact' as PrecisionLevel, + allowGuestJoin: true, + showC3NavIndoor: true, + }, + participants: new Map([[participantId, participant]]), + waypoints: [], + }; + + set({ + room, + participants: [participant], + currentParticipantId: participantId, + isConnected: true, + error: null, + }); + + // TODO: Connect to Automerge sync server + console.log(`Joined room: ${slug} as ${name} (${emoji})`); + }, + + leaveRoom: () => { + const { room, currentParticipantId } = get(); + if (room && currentParticipantId) { + room.participants.delete(currentParticipantId); + } + + set({ + room: null, + participants: [], + currentParticipantId: null, + isConnected: false, + }); + }, + + updateParticipant: (updates: Partial) => { + const { room, currentParticipantId, participants } = get(); + if (!room || !currentParticipantId) return; + + const current = room.participants.get(currentParticipantId); + if (!current) return; + + const updated = { ...current, ...updates, lastSeen: new Date() }; + room.participants.set(currentParticipantId, updated); + + set({ + participants: participants.map((p) => + p.id === currentParticipantId ? updated : p + ), + }); + }, + + updateLocation: (location: ParticipantLocation) => { + get().updateParticipant({ location }); + }, + + setStatus: (status: ParticipantStatus) => { + get().updateParticipant({ status }); + }, + + addWaypoint: (waypoint) => { + const { room, currentParticipantId } = get(); + if (!room || !currentParticipantId) return; + + const newWaypoint: Waypoint = { + ...waypoint, + id: nanoid(), + createdAt: new Date(), + createdBy: currentParticipantId, + }; + + room.waypoints.push(newWaypoint); + set({ room: { ...room } }); + }, + + removeWaypoint: (waypointId: string) => { + const { room } = get(); + if (!room) return; + + room.waypoints = room.waypoints.filter((w) => w.id !== waypointId); + set({ room: { ...room } }); + }, + + _syncFromDocument: (doc: unknown) => { + // TODO: Implement Automerge document sync + console.log('Sync from document:', doc); + }, +})); diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..30ffbb8 --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,247 @@ +/** + * Core types for rMaps.online + * Collaborative real-time friend-finding navigation + */ + +// ============================================================================ +// Room Types +// ============================================================================ + +export interface Room { + id: string; + slug: string; // subdomain: .rmaps.online + name: string; + createdAt: Date; + createdBy: string; // participant ID + expiresAt: Date; // auto-cleanup after inactivity + settings: RoomSettings; + participants: Map; + waypoints: Waypoint[]; +} + +export interface RoomSettings { + maxParticipants: number; // default: 10 + password?: string; // optional room password + defaultPrecision: PrecisionLevel; + allowGuestJoin: boolean; + showC3NavIndoor: boolean; // enable c3nav integration + eventId?: string; // e.g., '38c3', 'eh2025' +} + +// ============================================================================ +// Participant Types +// ============================================================================ + +export interface Participant { + id: string; + name: string; + emoji: string; // avatar emoji + color: string; // unique marker color + joinedAt: Date; + lastSeen: Date; + status: ParticipantStatus; + location?: ParticipantLocation; + privacySettings: PrivacySettings; +} + +export type ParticipantStatus = + | 'online' // actively sharing + | 'away' // app backgrounded + | 'ghost' // hidden location + | 'offline'; // disconnected + +export interface ParticipantLocation { + latitude: number; + longitude: number; + accuracy: number; // meters + altitude?: number; + altitudeAccuracy?: number; + heading?: number; // degrees from north + speed?: number; // m/s + timestamp: Date; + source: LocationSource; + indoor?: IndoorLocation; // c3nav indoor data +} + +export type LocationSource = + | 'gps' // device GPS + | 'network' // WiFi/cell triangulation + | 'manual' // user-set location + | 'c3nav'; // c3nav positioning + +export interface IndoorLocation { + level: number; // floor/level number + x: number; // c3nav local X coordinate + y: number; // c3nav local Y coordinate + spaceName?: string; // e.g., "Saal 1", "Assembly XY" +} + +// ============================================================================ +// Privacy Types +// ============================================================================ + +export type PrecisionLevel = + | 'exact' // <5m - full precision + | 'building' // ~50m - same building + | 'area' // ~500m - nearby area + | 'approximate'; // ~2km - general vicinity + +export interface PrivacySettings { + sharingEnabled: boolean; + defaultPrecision: PrecisionLevel; + showIndoorFloor: boolean; + ghostMode: boolean; // hide completely +} + +// ============================================================================ +// Navigation Types +// ============================================================================ + +export interface Waypoint { + id: string; + name: string; + emoji?: string; + location: { + latitude: number; + longitude: number; + indoor?: IndoorLocation; + }; + createdBy: string; // participant ID + createdAt: Date; + type: WaypointType; + metadata?: Record; +} + +export type WaypointType = + | 'meetup' // meeting point + | 'event' // scheduled event + | 'poi' // point of interest + | 'custom'; // user-created + +export interface Route { + id: string; + from: RoutePoint; + to: RoutePoint; + segments: RouteSegment[]; + totalDistance: number; // meters + estimatedTime: number; // seconds + createdAt: Date; +} + +export interface RoutePoint { + type: 'participant' | 'waypoint' | 'coordinates'; + id?: string; // participant or waypoint ID + coordinates?: { + latitude: number; + longitude: number; + indoor?: IndoorLocation; + }; +} + +export interface RouteSegment { + type: 'outdoor' | 'indoor' | 'transition'; + coordinates: Array<[number, number]>; // [lng, lat] for GeoJSON + distance: number; + duration: number; + instructions?: string; + level?: number; // for indoor segments +} + +// ============================================================================ +// c3nav Integration Types +// ============================================================================ + +export interface C3NavLocation { + id: number; + slug: string; + title: string; + subtitle?: string; + can_search: boolean; + can_describe: boolean; + geometry?: GeoJSON.Geometry; + level?: number; +} + +export interface C3NavRouteRequest { + origin: C3NavPoint; + destination: C3NavPoint; + options?: C3NavRouteOptions; +} + +export interface C3NavPoint { + coordinates?: [number, number, number]; // [x, y, level] + slug?: string; // location slug +} + +export interface C3NavRouteOptions { + mode?: 'fastest' | 'shortest'; + avoid_stairs?: boolean; + avoid_escalators?: boolean; + wheelchair?: boolean; +} + +export interface C3NavRouteResponse { + status: 'ok' | 'no_route'; + request?: C3NavRouteRequest; + origin?: C3NavLocation; + destination?: C3NavLocation; + distance?: number; + duration?: number; + path?: Array<{ + coordinates: [number, number, number]; + level: number; + }>; +} + +// ============================================================================ +// Event Types (for real-time updates) +// ============================================================================ + +export type RoomEvent = + | { type: 'participant_joined'; participant: Participant } + | { type: 'participant_left'; participantId: string } + | { type: 'participant_updated'; participant: Partial & { id: string } } + | { type: 'location_updated'; participantId: string; location: ParticipantLocation } + | { type: 'waypoint_added'; waypoint: Waypoint } + | { type: 'waypoint_removed'; waypointId: string } + | { type: 'room_settings_changed'; settings: Partial }; + +// ============================================================================ +// Map Types +// ============================================================================ + +export interface MapViewport { + center: [number, number]; // [lng, lat] + zoom: number; + bearing?: number; + pitch?: number; +} + +export interface MapBounds { + north: number; + south: number; + east: number; + west: number; +} + +// CCC venue bounds (Hamburg Congress Center) +export const CCC_VENUE_BOUNDS: MapBounds = { + north: 53.5580, + south: 53.5520, + east: 9.9950, + west: 9.9850, +}; + +// ============================================================================ +// Utility Types +// ============================================================================ + +export interface ApiResponse { + success: boolean; + data?: T; + error?: string; +} + +export type DeepPartial = { + [P in keyof T]?: T[P] extends object ? DeepPartial : T[P]; +}; diff --git a/tailwind.config.ts b/tailwind.config.ts new file mode 100644 index 0000000..1a7a1c5 --- /dev/null +++ b/tailwind.config.ts @@ -0,0 +1,28 @@ +import type { Config } from 'tailwindcss'; + +const config: Config = { + content: [ + './src/pages/**/*.{js,ts,jsx,tsx,mdx}', + './src/components/**/*.{js,ts,jsx,tsx,mdx}', + './src/app/**/*.{js,ts,jsx,tsx,mdx}', + ], + theme: { + extend: { + colors: { + 'rmaps': { + primary: '#10b981', // Emerald green + secondary: '#6366f1', // Indigo + dark: '#0f172a', // Slate 900 + light: '#f8fafc', // Slate 50 + }, + }, + animation: { + 'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite', + 'ping-slow': 'ping 2s cubic-bezier(0, 0, 0.2, 1) infinite', + }, + }, + }, + plugins: [], +}; + +export default config; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..7b28589 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +}