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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2025-12-15 12:23:13 -05:00
commit dc0661d58a
26 changed files with 2397 additions and 0 deletions

14
.env.example Normal file
View File

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

44
.gitignore vendored Normal file
View File

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

46
Dockerfile Normal file
View File

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

88
README.md Normal file
View File

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

36
docker-compose.yml Normal file
View File

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

43
next.config.js Normal file
View File

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

37
package.json Normal file
View File

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

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

25
public/manifest.json Normal file
View File

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

View File

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

176
src/app/globals.css Normal file
View File

@ -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;
}

48
src/app/layout.tsx Normal file
View File

@ -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 (
<html lang="en">
<body className="antialiased">
{children}
</body>
</html>
);
}

188
src/app/page.tsx Normal file
View File

@ -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 (
<main className="min-h-screen flex flex-col items-center justify-center p-4">
<div className="max-w-md w-full space-y-8">
{/* Logo/Title */}
<div className="text-center">
<h1 className="text-5xl font-bold mb-2">
<span className="text-rmaps-primary">r</span>Maps
</h1>
<p className="text-white/60">Find your friends at events</p>
</div>
{/* Main Card */}
<div className="room-panel rounded-2xl p-6 space-y-6">
{/* User Setup */}
<div className="space-y-4">
<h2 className="text-lg font-medium">Your Profile</h2>
<div>
<label className="block text-sm text-white/60 mb-2">Your Name</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Enter your name"
className="input w-full"
maxLength={20}
/>
</div>
<div>
<label className="block text-sm text-white/60 mb-2">Your Avatar</label>
<div className="flex gap-2 flex-wrap">
{EMOJI_OPTIONS.map((e) => (
<button
key={e}
onClick={() => setEmoji(e)}
className={`w-10 h-10 rounded-lg text-xl flex items-center justify-center transition-all ${
emoji === e
? 'bg-rmaps-primary scale-110'
: 'bg-white/10 hover:bg-white/20'
}`}
>
{e}
</button>
))}
</div>
</div>
</div>
<hr className="border-white/10" />
{/* Create Room */}
{!isCreating ? (
<div className="space-y-4">
<button
onClick={() => setIsCreating(true)}
className="btn-primary w-full text-lg py-3"
disabled={!name.trim()}
>
Create New Map
</button>
<div className="text-center text-white/40 text-sm">or</div>
{/* Join Room */}
<div className="space-y-3">
<input
type="text"
value={joinSlug}
onChange={(e) => setJoinSlug(e.target.value)}
placeholder="Enter room name or code"
className="input w-full"
/>
<button
onClick={handleJoinRoom}
className="btn-secondary w-full"
disabled={!name.trim() || !joinSlug.trim()}
>
Join Existing Map
</button>
</div>
</div>
) : (
<div className="space-y-4">
<div>
<label className="block text-sm text-white/60 mb-2">
Room Name (optional)
</label>
<div className="flex items-center gap-2">
<input
type="text"
value={roomName}
onChange={(e) => setRoomName(e.target.value)}
placeholder="e.g., 38c3-crew"
className="input flex-1"
maxLength={20}
/>
<span className="text-white/40">.rmaps.online</span>
</div>
<p className="text-xs text-white/40 mt-1">
Leave blank for a random code
</p>
</div>
<div className="flex gap-3">
<button
onClick={() => setIsCreating(false)}
className="btn-ghost flex-1"
>
Cancel
</button>
<button
onClick={handleCreateRoom}
className="btn-primary flex-1"
disabled={!name.trim()}
>
Create Map
</button>
</div>
</div>
)}
</div>
{/* Footer */}
<div className="text-center text-white/40 text-sm space-y-2">
<p>Privacy-first location sharing</p>
<p>
Built for{' '}
<a
href="https://events.ccc.de/"
target="_blank"
rel="noopener noreferrer"
className="text-rmaps-primary hover:underline"
>
CCC events
</a>
</p>
</div>
</div>
</main>
);
}

View File

@ -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: () => (
<div className="w-full h-full bg-rmaps-dark flex items-center justify-center">
<div className="text-white/60">Loading map...</div>
</div>
),
});
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 (
<div className="min-h-screen flex items-center justify-center p-4">
<div className="room-panel rounded-2xl p-6 max-w-md text-center">
<h2 className="text-xl font-bold text-red-400 mb-2">Error</h2>
<p className="text-white/60 mb-4">{error}</p>
<a href="/" className="btn-primary inline-block">
Go Home
</a>
</div>
</div>
);
}
return (
<div className="h-screen w-screen flex flex-col overflow-hidden">
{/* Header */}
<RoomHeader
roomSlug={slug}
participantCount={participants.length}
isSharing={isSharing}
onToggleSharing={() => (isSharing ? stopSharing() : startSharing())}
onShare={() => setShowShare(true)}
onToggleParticipants={() => setShowParticipants(!showParticipants)}
/>
{/* Main Content */}
<div className="flex-1 relative">
{/* Map */}
<MapView
participants={participants}
currentUserId={room?.participants ? Array.from(room.participants.keys())[0] : undefined}
onParticipantClick={(p) => console.log('Clicked participant:', p)}
/>
{/* Participant Panel (mobile: bottom sheet, desktop: sidebar) */}
{showParticipants && (
<div className="absolute bottom-0 left-0 right-0 md:top-0 md:right-auto md:w-80 md:bottom-auto md:h-full">
<ParticipantList
participants={participants}
currentUserId={currentUser?.name}
onClose={() => setShowParticipants(false)}
onNavigateTo={(p) => console.log('Navigate to:', p)}
/>
</div>
)}
</div>
{/* Share Modal */}
{showShare && (
<ShareModal roomSlug={slug} onClose={() => setShowShare(false)} />
)}
</div>
);
}

View File

@ -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 (
<div
className={`friend-marker ${isCurrentUser ? 'sharing' : ''} ${isStale ? 'opacity-50' : ''}`}
style={{ backgroundColor: color }}
onClick={onClick}
title={`${name} - ${status}${locationAge ? ` (${locationAge})` : ''}`}
>
<span className="text-xl">{emoji}</span>
{/* Status indicator */}
<div
className={`absolute -bottom-1 -right-1 w-3 h-3 rounded-full border-2 border-rmaps-dark status-dot ${status}`}
/>
{/* Heading indicator */}
{location?.heading !== undefined && (
<div
className="absolute -top-2 left-1/2 -translate-x-1/2 w-0 h-0"
style={{
borderLeft: '4px solid transparent',
borderRight: '4px solid transparent',
borderBottom: `8px solid ${color}`,
transform: `translateX(-50%) rotate(${location.heading}deg)`,
}}
/>
)}
</div>
);
}

View File

@ -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<HTMLDivElement>(null);
const map = useRef<maplibregl.Map | null>(null);
const markersRef = useRef<Map<string, maplibregl.Marker>>(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: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
},
},
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 (
<div className="relative w-full h-full">
<div ref={mapContainer} className="w-full h-full" />
{/* Fit all button */}
{participants.some((p) => p.location) && (
<button
onClick={fitToParticipants}
className="absolute bottom-4 right-4 bg-rmaps-dark/90 text-white px-3 py-2 rounded-lg text-sm hover:bg-rmaps-dark transition-colors"
title="Show all friends"
>
Show All
</button>
)}
{/* Loading overlay */}
{!mapLoaded && (
<div className="absolute inset-0 bg-rmaps-dark flex items-center justify-center">
<div className="text-white/60">Loading map...</div>
</div>
)}
</div>
);
}

View File

@ -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 (
<div className="room-panel h-full md:h-auto md:max-h-[calc(100vh-4rem)] rounded-t-2xl md:rounded-2xl md:m-4 overflow-hidden flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-white/10">
<h2 className="font-semibold">Friends ({participants.length})</h2>
<button
onClick={onClose}
className="p-1 rounded hover:bg-white/10 transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
{/* Participant list */}
<div className="flex-1 overflow-y-auto p-2">
{participants.length === 0 ? (
<div className="text-center text-white/40 py-8">
No one else is here yet
</div>
) : (
<div className="space-y-1">
{participants.map((participant) => {
const isMe = participant.name === currentUserId;
const distance = !isMe
? formatDistance(participant, currentParticipant)
: null;
return (
<div
key={participant.id}
className="participant-item cursor-pointer"
onClick={() => !isMe && onNavigateTo(participant)}
>
{/* Avatar */}
<div
className="w-10 h-10 rounded-full flex items-center justify-center text-xl"
style={{ backgroundColor: participant.color }}
>
{participant.emoji}
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium truncate">
{participant.name}
{isMe && (
<span className="text-white/40 text-sm ml-1">(you)</span>
)}
</span>
<div className={`status-dot ${participant.status}`} />
</div>
{participant.location && (
<div className="text-xs text-white/40">
{distance ? `${distance} away` : 'Location shared'}
</div>
)}
{participant.status === 'ghost' && (
<div className="text-xs text-white/40">Location hidden</div>
)}
</div>
{/* Navigate button */}
{!isMe && participant.location && (
<button
className="p-2 rounded-lg bg-white/10 hover:bg-white/20 transition-colors"
title="Navigate to"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7"
/>
</svg>
</button>
)}
</div>
);
})}
</div>
)}
</div>
{/* Footer actions */}
<div className="p-4 border-t border-white/10">
<button className="btn-secondary w-full text-sm">
Set Meeting Point
</button>
</div>
</div>
);
}

View File

@ -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 (
<header className="bg-rmaps-dark/95 backdrop-blur border-b border-white/10 px-4 py-3 flex items-center justify-between z-10">
{/* Left: Room info */}
<div className="flex items-center gap-3">
<a href="/" className="text-xl font-bold">
<span className="text-rmaps-primary">r</span>Maps
</a>
<div className="h-4 w-px bg-white/20" />
<span className="text-white/60 text-sm">{roomSlug}</span>
</div>
{/* Center: Participant count */}
<button
onClick={onToggleParticipants}
className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-white/10 hover:bg-white/15 transition-colors"
>
<span className="text-lg">👥</span>
<span className="text-sm font-medium">{participantCount}</span>
</button>
{/* Right: Actions */}
<div className="flex items-center gap-2">
{/* Share button */}
<button
onClick={onShare}
className="p-2 rounded-lg bg-white/10 hover:bg-white/15 transition-colors"
title="Share room"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z"
/>
</svg>
</button>
{/* Location sharing toggle */}
<button
onClick={onToggleSharing}
className={`p-2 rounded-lg transition-colors ${
isSharing
? 'bg-rmaps-primary text-white'
: 'bg-white/10 hover:bg-white/15'
}`}
title={isSharing ? 'Stop sharing location' : 'Share my location'}
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
</button>
</div>
</header>
);
}

View File

@ -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 <slug>.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 (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={onClose}
/>
{/* Modal */}
<div className="room-panel rounded-2xl p-6 max-w-md w-full relative">
{/* Close button */}
<button
onClick={onClose}
className="absolute top-4 right-4 p-1 rounded hover:bg-white/10 transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
<h2 className="text-xl font-bold mb-4">Share this Map</h2>
<p className="text-white/60 text-sm mb-4">
Invite friends to join your map. Anyone with this link can see your
shared location.
</p>
{/* URL display */}
<div className="bg-white/5 rounded-lg p-3 mb-4">
<div className="text-xs text-white/40 mb-1">Room Link</div>
<div className="font-mono text-sm break-all">{shareUrl}</div>
</div>
{/* Subdomain preview */}
<div className="bg-rmaps-primary/10 border border-rmaps-primary/30 rounded-lg p-3 mb-6">
<div className="text-xs text-rmaps-primary mb-1">Coming soon</div>
<div className="font-mono text-sm text-rmaps-primary">{subdomainUrl}</div>
</div>
{/* Actions */}
<div className="flex gap-3">
<button onClick={handleCopy} className="btn-ghost flex-1">
{copied ? '✓ Copied!' : 'Copy Link'}
</button>
<button onClick={handleShare} className="btn-primary flex-1">
Share
</button>
</div>
{/* QR Code placeholder */}
<div className="mt-6 pt-6 border-t border-white/10 text-center">
<div className="text-xs text-white/40 mb-2">Or scan QR code</div>
<div className="w-32 h-32 mx-auto bg-white rounded-lg flex items-center justify-center">
<span className="text-rmaps-dark text-xs">QR Coming Soon</span>
</div>
</div>
</div>
</div>
);
}

View File

@ -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<ParticipantLocation | null>(null);
const [error, setError] = useState<GeolocationPositionError | null>(null);
const [permissionState, setPermissionState] = useState<PermissionState | null>(null);
const watchIdRef = useRef<number | null>(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,
};
}

190
src/lib/c3nav.ts Normal file
View File

@ -0,0 +1,190 @@
/**
* c3nav API client for indoor navigation at CCC events
* API docs: https://<event>.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<C3NavLocation[]> {
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<C3NavLocation> {
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<C3NavRouteResponse> {
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<Record<string, unknown>> {
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
);
}

62
src/middleware.ts Normal file
View File

@ -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
* - <room>.rmaps.online -> /room/<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: <room>.rmaps.online
// Development: <room>.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).*)',
],
};

170
src/stores/room.ts Normal file
View File

@ -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<Participant>) => void;
updateLocation: (location: ParticipantLocation) => void;
setStatus: (status: ParticipantStatus) => void;
addWaypoint: (waypoint: Omit<Waypoint, 'id' | 'createdAt' | 'createdBy'>) => void;
removeWaypoint: (waypointId: string) => void;
// Internal
_syncFromDocument: (doc: unknown) => void;
}
export const useRoomStore = create<RoomState>((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<Participant>) => {
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);
},
}));

247
src/types/index.ts Normal file
View File

@ -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: <slug>.rmaps.online
name: string;
createdAt: Date;
createdBy: string; // participant ID
expiresAt: Date; // auto-cleanup after inactivity
settings: RoomSettings;
participants: Map<string, Participant>;
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<string, unknown>;
}
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<Participant> & { 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<RoomSettings> };
// ============================================================================
// 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<T> {
success: boolean;
data?: T;
error?: string;
}
export type DeepPartial<T> = {
[P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
};

28
tailwind.config.ts Normal file
View File

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

26
tsconfig.json Normal file
View File

@ -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"]
}