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