diff --git a/src/app/api/c3nav/[event]/route.ts b/src/app/api/c3nav/[event]/route.ts new file mode 100644 index 0000000..c8b7b7b --- /dev/null +++ b/src/app/api/c3nav/[event]/route.ts @@ -0,0 +1,153 @@ +import { NextRequest, NextResponse } from 'next/server'; + +// Proxy c3nav API calls +// URL pattern: /api/c3nav/{event}?endpoint=map/locations +// Proxies to: https://{event}.c3nav.de/api/v2/{endpoint} + +interface RouteParams { + params: { + event: string; + }; +} + +// Valid c3nav events +const VALID_EVENTS = ['38c3', '37c3', 'eh22', 'eh2025', 'camp2023']; + +// Allowed API endpoints (whitelist for security) +const ALLOWED_ENDPOINTS = [ + 'map/settings', + 'map/bounds', + 'map/locations', + 'map/locations/full', + 'map/projection', +]; + +// Cache for session cookies per event +const sessionCache = new Map(); + +// Get a valid session cookie for an event +async function getSessionCookie(event: string): Promise { + const cached = sessionCache.get(event); + if (cached && cached.expires > Date.now()) { + return cached.cookie; + } + + try { + // Get session by visiting the main page + const response = await fetch(`https://${event}.c3nav.de/`, { + redirect: 'follow', + }); + + const setCookie = response.headers.get('set-cookie'); + if (setCookie) { + // Extract tile_access cookie + const match = setCookie.match(/c3nav_tile_access="([^"]+)"/); + if (match) { + const cookie = `c3nav_tile_access="${match[1]}"`; + // Cache for 50 seconds (cookie lasts 60s) + sessionCache.set(event, { + cookie, + expires: Date.now() + 50000 + }); + return cookie; + } + } + } catch (e) { + console.error('Failed to get c3nav session:', e); + } + + return null; +} + +export async function GET(request: NextRequest, { params }: RouteParams) { + const { event } = params; + const { searchParams } = new URL(request.url); + const endpoint = searchParams.get('endpoint') || 'map/bounds'; + + // Validate event + if (!VALID_EVENTS.includes(event)) { + return NextResponse.json( + { error: 'Invalid event' }, + { status: 400 } + ); + } + + // Check if endpoint is allowed (basic path check) + const isAllowed = ALLOWED_ENDPOINTS.some( + (allowed) => endpoint === allowed || endpoint.startsWith(allowed + '/') + ); + + if (!isAllowed && !endpoint.startsWith('map/locations/')) { + return NextResponse.json( + { error: 'Endpoint not allowed' }, + { status: 403 } + ); + } + + // Build c3nav API URL (trailing slash required to avoid redirect) + const apiUrl = `https://${event}.c3nav.de/api/v2/${endpoint}/`; + + try { + // Get session cookie + const sessionCookie = await getSessionCookie(event); + + const headers: HeadersInit = { + 'X-API-Key': 'anonymous', + 'Accept': 'application/json', + 'User-Agent': 'rMaps.online/1.0', + }; + + if (sessionCookie) { + headers['Cookie'] = sessionCookie; + } + + // Create AbortController for timeout + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 15000); + + const response = await fetch(apiUrl, { + headers, + redirect: 'follow', + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({ detail: 'Unknown error' })); + return NextResponse.json(errorData, { + status: response.status, + headers: { + 'Access-Control-Allow-Origin': '*', + }, + }); + } + + const data = await response.json(); + + return NextResponse.json(data, { + status: 200, + headers: { + 'Cache-Control': 'public, max-age=300', // Cache for 5 minutes + 'Access-Control-Allow-Origin': '*', + }, + }); + } catch (error) { + console.error('c3nav API proxy error:', error); + return NextResponse.json( + { error: 'Failed to fetch from c3nav' }, + { status: 500 } + ); + } +} + +export async function OPTIONS() { + return new NextResponse(null, { + status: 200, + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type', + }, + }); +} diff --git a/src/app/api/c3nav/tiles/[event]/[level]/[z]/[x]/[y]/route.ts b/src/app/api/c3nav/tiles/[event]/[level]/[z]/[x]/[y]/route.ts new file mode 100644 index 0000000..e36249f --- /dev/null +++ b/src/app/api/c3nav/tiles/[event]/[level]/[z]/[x]/[y]/route.ts @@ -0,0 +1,98 @@ +import { NextRequest, NextResponse } from 'next/server'; + +// Proxy c3nav tiles to add CORS headers +// URL pattern: /api/c3nav/tiles/{event}/{level}/{z}/{x}/{y} +// Proxies to: https://tiles.{event}.c3nav.de/{level}/{z}/{x}/{y}.png + +interface RouteParams { + params: { + event: string; + level: string; + z: string; + x: string; + y: string; + }; +} + +// Valid c3nav events +const VALID_EVENTS = ['38c3', '37c3', 'eh22', 'eh2025', 'camp2023']; + +export async function GET(request: NextRequest, { params }: RouteParams) { + const { event, level, z, x, y } = params; + + // Validate event + if (!VALID_EVENTS.includes(event)) { + return NextResponse.json( + { error: 'Invalid event' }, + { status: 400 } + ); + } + + // Validate numeric params + const levelNum = parseInt(level, 10); + const zNum = parseInt(z, 10); + const xNum = parseInt(x, 10); + const yNum = parseInt(y, 10); + + if ([levelNum, zNum, xNum, yNum].some(isNaN)) { + return NextResponse.json( + { error: 'Invalid tile coordinates' }, + { status: 400 } + ); + } + + // Build c3nav tile URL + const tileUrl = `https://tiles.${event}.c3nav.de/${level}/${z}/${x}/${y}.png`; + + try { + const response = await fetch(tileUrl, { + headers: { + 'User-Agent': 'rMaps.online/1.0 (c3nav tile proxy)', + }, + }); + + if (!response.ok) { + // Pass through error responses + const errorText = await response.text(); + return new NextResponse(errorText, { + status: response.status, + headers: { + 'Content-Type': 'text/plain', + 'Access-Control-Allow-Origin': '*', + }, + }); + } + + // Get the tile image + const imageBuffer = await response.arrayBuffer(); + + // Return with CORS headers + return new NextResponse(imageBuffer, { + status: 200, + headers: { + 'Content-Type': 'image/png', + 'Cache-Control': 'public, max-age=3600', // Cache for 1 hour + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET', + }, + }); + } catch (error) { + console.error('Tile proxy error:', error); + return NextResponse.json( + { error: 'Failed to fetch tile' }, + { status: 500 } + ); + } +} + +// Handle CORS preflight +export async function OPTIONS() { + return new NextResponse(null, { + status: 200, + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type', + }, + }); +} diff --git a/src/app/room/[slug]/page.tsx b/src/app/room/[slug]/page.tsx index 25c2dc7..00ddc63 100644 --- a/src/app/room/[slug]/page.tsx +++ b/src/app/room/[slug]/page.tsx @@ -53,6 +53,7 @@ export default function RoomPage() { currentParticipantId, roomName, updateLocation, + updateIndoorPosition, clearLocation, setStatus, addWaypoint, @@ -214,6 +215,7 @@ export default function RoomPage() { onWaypointClick={(w) => { console.log('Waypoint clicked:', w.name); }} + onIndoorPositionSet={updateIndoorPosition} /> {/* Participant Panel */} diff --git a/src/components/map/DualMapView.tsx b/src/components/map/DualMapView.tsx index 3a2097a..d30ee67 100644 --- a/src/components/map/DualMapView.tsx +++ b/src/components/map/DualMapView.tsx @@ -11,7 +11,7 @@ const MapView = dynamic(() => import('./MapView'), { loading: () => , }); -const C3NavEmbed = dynamic(() => import('./C3NavEmbed'), { +const IndoorMapView = dynamic(() => import('./IndoorMapView'), { ssr: false, loading: () => , }); @@ -35,6 +35,7 @@ interface DualMapViewProps { initialMode?: MapMode; onParticipantClick?: (participant: Participant) => void; onWaypointClick?: (waypoint: Waypoint) => void; + onIndoorPositionSet?: (position: { level: number; x: number; y: number }) => void; } // CCC venue bounds (Hamburg Congress Center) @@ -54,6 +55,7 @@ export default function DualMapView({ initialMode = 'auto', onParticipantClick, onWaypointClick, + onIndoorPositionSet, }: DualMapViewProps) { const [mode, setMode] = useState(initialMode); const [activeView, setActiveView] = useState<'outdoor' | 'indoor'>('outdoor'); @@ -96,21 +98,20 @@ export default function DualMapView({ onWaypointClick={onWaypointClick} /> ) : ( - )} - {/* Indoor Map button - opens c3nav in new tab (iframe embedding blocked) */} + {/* Indoor Map button - switch to indoor view */} {activeView === 'outdoor' && ( - @@ -122,10 +123,7 @@ export default function DualMapView({ /> Indoor Map - - - - + )} {/* Auto-mode indicator */} diff --git a/src/components/map/IndoorMapView.tsx b/src/components/map/IndoorMapView.tsx new file mode 100644 index 0000000..1ac4100 --- /dev/null +++ b/src/components/map/IndoorMapView.tsx @@ -0,0 +1,414 @@ +'use client'; + +import { useEffect, useRef, useState, useCallback } from 'react'; +import maplibregl from 'maplibre-gl'; +import 'maplibre-gl/dist/maplibre-gl.css'; +import type { Participant } from '@/types'; + +interface Level { + id: number; + slug: string; + title: string; + level_index: string; +} + +// Easter egg: The mythical Level -1 +const LEVEL_MINUS_ONE: Level = { + id: -1, + slug: 'level--1', + title: '🕳️ The Underground of the Underground', + level_index: '-1', +}; + +interface IndoorMapViewProps { + eventId: string; + participants: Participant[]; + currentUserId?: string; + onPositionSet?: (position: { level: number; x: number; y: number }) => void; + onParticipantClick?: (participant: Participant) => void; + onSwitchToOutdoor?: () => void; +} + +export default function IndoorMapView({ + eventId, + participants, + currentUserId, + onPositionSet, + onParticipantClick, + onSwitchToOutdoor, +}: IndoorMapViewProps) { + const mapContainer = useRef(null); + const map = useRef(null); + const markersRef = useRef>(new Map()); + + const [mapLoaded, setMapLoaded] = useState(false); + const [levels, setLevels] = useState([]); + const [currentLevel, setCurrentLevel] = useState(null); + const [bounds, setBounds] = useState<[[number, number], [number, number]] | null>(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + // Easter egg state + const [level0Clicks, setLevel0Clicks] = useState(0); + const [showLevelMinus1, setShowLevelMinus1] = useState(false); + const clickTimeoutRef = useRef(null); + + // Fetch levels and bounds from c3nav API + useEffect(() => { + async function fetchMapData() { + setIsLoading(true); + setError(null); + + try { + // Fetch bounds + const boundsRes = await fetch(`/api/c3nav/${eventId}?endpoint=map/bounds`); + if (!boundsRes.ok) throw new Error('Failed to fetch bounds'); + const boundsData = await boundsRes.json(); + setBounds(boundsData.bounds); + + // Fetch locations to get levels + const locationsRes = await fetch(`/api/c3nav/${eventId}?endpoint=map/locations`); + if (!locationsRes.ok) throw new Error('Failed to fetch locations'); + const locationsData = await locationsRes.json(); + + // Filter levels and sort by level_index + const levelLocations = locationsData + .filter((loc: any) => loc.locationtype === 'level') + .sort((a: any, b: any) => parseInt(a.level_index) - parseInt(b.level_index)); + + setLevels(levelLocations); + + // Set default level (Level 0 or first available) + const defaultLevel = levelLocations.find((l: Level) => l.level_index === '0') || levelLocations[0]; + setCurrentLevel(defaultLevel); + } catch (err) { + console.error('Failed to fetch c3nav data:', err); + setError('Failed to load indoor map data'); + } finally { + setIsLoading(false); + } + } + + fetchMapData(); + }, [eventId]); + + // Initialize/update map when level changes + useEffect(() => { + if (!mapContainer.current || !bounds || !currentLevel) return; + + // Calculate center from bounds + const centerX = (bounds[0][0] + bounds[1][0]) / 2; + const centerY = (bounds[0][1] + bounds[1][1]) / 2; + + // Destroy existing map if level changed + if (map.current) { + map.current.remove(); + map.current = null; + markersRef.current.clear(); + } + + // Create map with c3nav tile source + map.current = new maplibregl.Map({ + container: mapContainer.current, + style: { + version: 8, + sources: { + c3nav: { + type: 'raster', + tiles: [ + `/api/c3nav/tiles/${eventId}/${currentLevel.id}/{z}/{x}/{y}`, + ], + tileSize: 257, + minzoom: 0, + maxzoom: 5, + bounds: [bounds[0][0], bounds[0][1], bounds[1][0], bounds[1][1]], + }, + }, + layers: [ + { + id: 'background', + type: 'background', + paint: { + 'background-color': '#1a1a2e', + }, + }, + { + id: 'c3nav-tiles', + type: 'raster', + source: 'c3nav', + }, + ], + }, + center: [centerX, centerY], + zoom: 2, + minZoom: 0, + maxZoom: 5, + // c3nav uses a simple coordinate system, not geographic + renderWorldCopies: false, + }); + + // Add controls + map.current.addControl(new maplibregl.NavigationControl(), 'top-right'); + + // Handle click to set position + map.current.on('click', (e) => { + if (onPositionSet && currentLevel) { + onPositionSet({ + level: currentLevel.id, + x: e.lngLat.lng, + y: e.lngLat.lat, + }); + } + }); + + map.current.on('load', () => { + setMapLoaded(true); + }); + + return () => { + map.current?.remove(); + map.current = null; + }; + }, [eventId, bounds, currentLevel, onPositionSet]); + + // Update participant markers + useEffect(() => { + if (!map.current || !mapLoaded || !currentLevel) return; + + const currentMarkers = markersRef.current; + + // Filter participants on current level with indoor location + const indoorParticipants = participants.filter( + (p) => p.location?.indoor && p.location.indoor.level === currentLevel.id + ); + + const participantIds = new Set(indoorParticipants.map((p) => p.id)); + + // Remove markers for participants not on this level + currentMarkers.forEach((marker, id) => { + if (!participantIds.has(id)) { + marker.remove(); + currentMarkers.delete(id); + } + }); + + // Add/update markers for participants on this level + indoorParticipants.forEach((participant) => { + if (!participant.location?.indoor) return; + + const { x, y } = participant.location.indoor; + let marker = currentMarkers.get(participant.id); + + if (marker) { + marker.setLngLat([x, y]); + } else { + // Create marker element + const el = document.createElement('div'); + el.className = 'indoor-marker'; + el.style.cssText = ` + width: 36px; + height: 36px; + border-radius: 50%; + background: ${participant.color}; + border: 3px solid white; + box-shadow: 0 2px 8px rgba(0,0,0,0.3); + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + cursor: pointer; + `; + el.innerHTML = participant.emoji; + + if (participant.id === currentUserId) { + el.style.border = '3px solid #10b981'; + el.style.boxShadow = '0 0 0 3px rgba(16, 185, 129, 0.3), 0 2px 8px rgba(0,0,0,0.3)'; + } + + el.addEventListener('click', (e) => { + e.stopPropagation(); + onParticipantClick?.(participant); + }); + + marker = new maplibregl.Marker({ element: el }) + .setLngLat([x, y]) + .addTo(map.current!); + + currentMarkers.set(participant.id, marker); + } + }); + }, [participants, mapLoaded, currentLevel, currentUserId, onParticipantClick]); + + // Handle level change with easter egg + const handleLevelChange = useCallback((level: Level) => { + // Easter egg: Triple-click Level 0 to reveal Level -1 + if (level.level_index === '0') { + if (clickTimeoutRef.current) { + clearTimeout(clickTimeoutRef.current); + } + const newClicks = level0Clicks + 1; + setLevel0Clicks(newClicks); + + if (newClicks >= 3 && !showLevelMinus1) { + setShowLevelMinus1(true); + setLevel0Clicks(0); + // Brief "glitch" effect could go here + console.log('🕳️ You found Level -1! The rabbit hole goes deeper...'); + return; // Don't change level, just reveal the easter egg + } + + // Reset click counter after 500ms + clickTimeoutRef.current = setTimeout(() => { + setLevel0Clicks(0); + }, 500); + } else { + setLevel0Clicks(0); + } + + // Handle Level -1 easter egg + if (level.id === -1) { + setCurrentLevel(level); + setMapLoaded(false); + return; + } + + setCurrentLevel(level); + setMapLoaded(false); + }, [level0Clicks, showLevelMinus1]); + + if (isLoading) { + return ( +
+
+
+
Loading indoor map...
+
+
+ ); + } + + if (error) { + return ( +
+
+
{error}
+ +
+
+ ); + } + + return ( +
+ {/* Map container */} +
+ + {/* Level selector */} + {levels.length > 1 && ( +
+
+ Floor +
+
+ {/* Easter egg: Level -1 appears at the bottom when unlocked */} + {showLevelMinus1 && ( + + )} + {levels.map((level) => ( + + ))} +
+
+ )} + + {/* Current level indicator */} +
+ {currentLevel?.title || 'Indoor Map'} +
+ + {/* Level -1 Easter Egg Overlay */} + {currentLevel?.id === -1 && ( +
+
+
🕳️
+

+ THE UNDERGROUND OF THE UNDERGROUND +

+

+ > ACCESS GRANTED
+ > Welcome, fellow hacker.
+ > You found the secret level.
+ > Some say it's where the real
+ > Congress happens... 🐇 +

+
+ // TODO: Add actual underground map
+ // when we find the blueprints +
+ +
+
+ )} + + {/* Switch to outdoor button */} + {onSwitchToOutdoor && ( + + )} + + {/* Tap to set position hint */} + {onPositionSet && ( +
+ Tap map to set your position +
+ )} + + {/* Loading overlay for level change */} + {!mapLoaded && !isLoading && ( +
+
+
+ )} +
+ ); +} diff --git a/src/hooks/useRoom.ts b/src/hooks/useRoom.ts index eca0408..46d657b 100644 --- a/src/hooks/useRoom.ts +++ b/src/hooks/useRoom.ts @@ -42,6 +42,7 @@ interface UseRoomReturn { currentParticipantId: string | null; roomName: string; updateLocation: (location: ParticipantLocation) => void; + updateIndoorPosition: (position: { level: number; x: number; y: number }) => void; clearLocation: () => void; setStatus: (status: Participant['status']) => void; addWaypoint: (waypoint: Omit) => void; @@ -74,7 +75,11 @@ export function useRoom({ slug, userName, userEmoji }: UseRoomOptions): UseRoomR // Initialize room connection useEffect(() => { - if (!userName) return; + if (!userName) { + // No user yet - not loading, just waiting + setIsLoading(false); + return; + } setIsLoading(true); setError(null); @@ -143,6 +148,12 @@ export function useRoom({ slug, userName, userEmoji }: UseRoomOptions): UseRoomR syncRef.current.clearLocation(); }, []); + // Update indoor position (from c3nav map tap) + const updateIndoorPosition = useCallback((position: { level: number; x: number; y: number }) => { + if (!syncRef.current) return; + syncRef.current.updateIndoorPosition(position); + }, []); + // Set status const setStatus = useCallback((status: Participant['status']) => { if (!syncRef.current) return; @@ -194,6 +205,7 @@ export function useRoom({ slug, userName, userEmoji }: UseRoomOptions): UseRoomR currentParticipantId: participantIdRef.current, roomName, updateLocation, + updateIndoorPosition, clearLocation, setStatus, addWaypoint, diff --git a/src/lib/sync.ts b/src/lib/sync.ts index 0995cd1..58ca910 100644 --- a/src/lib/sync.ts +++ b/src/lib/sync.ts @@ -302,6 +302,37 @@ export class RoomSync { } } + updateIndoorPosition(indoor: { level: number; x: number; y: number }): void { + console.log('RoomSync.updateIndoorPosition called:', indoor.level, indoor.x, indoor.y); + if (this.state.participants[this.participantId]) { + // Update or create location with indoor data + const existingLocation = this.state.participants[this.participantId].location; + const location: LocationState = existingLocation || { + latitude: 0, + longitude: 0, + accuracy: 0, + timestamp: new Date().toISOString(), + source: 'manual', + }; + + location.indoor = { + level: indoor.level, + x: indoor.x, + y: indoor.y, + }; + location.timestamp = new Date().toISOString(); + location.source = 'manual'; + + this.state.participants[this.participantId].location = location; + this.state.participants[this.participantId].lastSeen = new Date().toISOString(); + this.send({ type: 'location', participantId: this.participantId, location }); + console.log('Indoor position set for participant:', this.participantId); + this.notifyStateChange(); + } else { + console.warn('Cannot update indoor position - participant not found:', this.participantId); + } + } + addWaypoint(waypoint: WaypointState): void { this.state.waypoints.push(waypoint); this.send({ type: 'waypoint_add', waypoint });