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:
commit
dc0661d58a
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -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',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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: '© <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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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).*)',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
@ -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);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
@ -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];
|
||||||
|
};
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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"]
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue