Fix Indoor Map button, add Meeting Point functionality, render waypoints

- Fix Indoor Map button being covered by participant panel (z-30)
- Implement Set Meeting Point modal with emoji selection
- Add waypoint markers rendering on the map
- Pass waypoints from room through DualMapView to MapView
- Fix TypeScript types for WaypointType

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2025-12-15 16:31:19 -05:00
parent 1a996931b5
commit 0a234f902a
5 changed files with 320 additions and 10 deletions

View File

@ -8,6 +8,7 @@ import { useLocationSharing } from '@/hooks/useLocationSharing';
import ParticipantList from '@/components/room/ParticipantList'; import ParticipantList from '@/components/room/ParticipantList';
import RoomHeader from '@/components/room/RoomHeader'; import RoomHeader from '@/components/room/RoomHeader';
import ShareModal from '@/components/room/ShareModal'; import ShareModal from '@/components/room/ShareModal';
import MeetingPointModal from '@/components/room/MeetingPointModal';
import type { Participant, ParticipantLocation } from '@/types'; import type { Participant, ParticipantLocation } from '@/types';
// Dynamic import for map to avoid SSR issues with MapLibre // Dynamic import for map to avoid SSR issues with MapLibre
@ -27,6 +28,7 @@ export default function RoomPage() {
const [showShare, setShowShare] = useState(false); const [showShare, setShowShare] = useState(false);
const [showParticipants, setShowParticipants] = useState(true); const [showParticipants, setShowParticipants] = useState(true);
const [showMeetingPoint, setShowMeetingPoint] = useState(false);
const [currentUser, setCurrentUser] = useState<{ name: string; emoji: string } | null>(null); const [currentUser, setCurrentUser] = useState<{ name: string; emoji: string } | null>(null);
const [selectedParticipant, setSelectedParticipant] = useState<Participant | null>(null); const [selectedParticipant, setSelectedParticipant] = useState<Participant | null>(null);
@ -201,6 +203,7 @@ export default function RoomPage() {
{/* Map */} {/* Map */}
<DualMapView <DualMapView
participants={participants} participants={participants}
waypoints={waypoints}
currentUserId={currentParticipantId || undefined} currentUserId={currentParticipantId || undefined}
currentLocation={currentLocation} currentLocation={currentLocation}
eventId="38c3" eventId="38c3"
@ -208,6 +211,9 @@ export default function RoomPage() {
setSelectedParticipant(p); setSelectedParticipant(p);
setShowParticipants(true); setShowParticipants(true);
}} }}
onWaypointClick={(w) => {
console.log('Waypoint clicked:', w.name);
}}
/> />
{/* Participant Panel */} {/* Participant Panel */}
@ -218,6 +224,7 @@ export default function RoomPage() {
currentUserId={currentUser.name} currentUserId={currentUser.name}
onClose={() => setShowParticipants(false)} onClose={() => setShowParticipants(false)}
onNavigateTo={handleNavigateTo} onNavigateTo={handleNavigateTo}
onSetMeetingPoint={() => setShowMeetingPoint(true)}
/> />
</div> </div>
)} )}
@ -234,6 +241,18 @@ export default function RoomPage() {
{showShare && ( {showShare && (
<ShareModal roomSlug={slug} onClose={() => setShowShare(false)} /> <ShareModal roomSlug={slug} onClose={() => setShowShare(false)} />
)} )}
{/* Meeting Point Modal */}
{showMeetingPoint && (
<MeetingPointModal
currentLocation={currentLocation}
onClose={() => setShowMeetingPoint(false)}
onSetMeetingPoint={(waypoint) => {
addWaypoint(waypoint);
setShowMeetingPoint(false);
}}
/>
)}
</div> </div>
); );
} }

View File

@ -2,7 +2,7 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
import type { Participant, MapViewport } from '@/types'; import type { Participant, MapViewport, Waypoint } from '@/types';
import { isInC3NavArea } from '@/lib/c3nav'; import { isInC3NavArea } from '@/lib/c3nav';
// Dynamic imports to avoid SSR issues // Dynamic imports to avoid SSR issues
@ -28,11 +28,13 @@ type MapMode = 'outdoor' | 'indoor' | 'auto';
interface DualMapViewProps { interface DualMapViewProps {
participants: Participant[]; participants: Participant[];
waypoints?: Waypoint[];
currentUserId?: string; currentUserId?: string;
currentLocation?: { latitude: number; longitude: number } | null; currentLocation?: { latitude: number; longitude: number } | null;
eventId?: string; eventId?: string;
initialMode?: MapMode; initialMode?: MapMode;
onParticipantClick?: (participant: Participant) => void; onParticipantClick?: (participant: Participant) => void;
onWaypointClick?: (waypoint: Waypoint) => void;
} }
// CCC venue bounds (Hamburg Congress Center) // CCC venue bounds (Hamburg Congress Center)
@ -45,11 +47,13 @@ const CCC_BOUNDS = {
export default function DualMapView({ export default function DualMapView({
participants, participants,
waypoints = [],
currentUserId, currentUserId,
currentLocation, currentLocation,
eventId = '38c3', eventId = '38c3',
initialMode = 'auto', initialMode = 'auto',
onParticipantClick, onParticipantClick,
onWaypointClick,
}: DualMapViewProps) { }: DualMapViewProps) {
const [mode, setMode] = useState<MapMode>(initialMode); const [mode, setMode] = useState<MapMode>(initialMode);
const [activeView, setActiveView] = useState<'outdoor' | 'indoor'>('outdoor'); const [activeView, setActiveView] = useState<'outdoor' | 'indoor'>('outdoor');
@ -86,8 +90,10 @@ export default function DualMapView({
{activeView === 'outdoor' ? ( {activeView === 'outdoor' ? (
<MapView <MapView
participants={participants} participants={participants}
waypoints={waypoints}
currentUserId={currentUserId} currentUserId={currentUserId}
onParticipantClick={onParticipantClick} onParticipantClick={onParticipantClick}
onWaypointClick={onWaypointClick}
/> />
) : ( ) : (
<C3NavEmbed <C3NavEmbed
@ -103,7 +109,7 @@ export default function DualMapView({
{activeView === 'outdoor' && ( {activeView === 'outdoor' && (
<button <button
onClick={goIndoor} onClick={goIndoor}
className="absolute bottom-4 left-4 bg-rmaps-dark/90 text-white px-3 py-2 rounded-lg text-sm hover:bg-rmaps-dark transition-colors flex items-center gap-2 z-10" className="absolute bottom-4 left-4 bg-rmaps-dark/90 text-white px-3 py-2 rounded-lg text-sm hover:bg-rmaps-dark transition-colors flex items-center gap-2 z-30"
> >
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path <path
@ -126,7 +132,7 @@ export default function DualMapView({
{/* Venue proximity indicator */} {/* Venue proximity indicator */}
{currentLocation && isInC3NavArea(currentLocation.latitude, currentLocation.longitude) && activeView === 'outdoor' && ( {currentLocation && isInC3NavArea(currentLocation.latitude, currentLocation.longitude) && activeView === 'outdoor' && (
<div className="absolute top-4 left-1/2 -translate-x-1/2 bg-rmaps-secondary/90 text-white text-sm px-3 py-1.5 rounded-full flex items-center gap-2 z-10"> <div className="absolute top-4 left-1/2 -translate-x-1/2 bg-rmaps-secondary/90 text-white text-sm px-3 py-1.5 rounded-full flex items-center gap-2 z-30">
<span>You&apos;re at the venue!</span> <span>You&apos;re at the venue!</span>
<button onClick={goIndoor} className="underline"> <button onClick={goIndoor} className="underline">
Switch to indoor Switch to indoor

View File

@ -3,14 +3,16 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import maplibregl from 'maplibre-gl'; import maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css'; import 'maplibre-gl/dist/maplibre-gl.css';
import type { Participant, MapViewport } from '@/types'; import type { Participant, MapViewport, Waypoint } from '@/types';
import FriendMarker from './FriendMarker'; import FriendMarker from './FriendMarker';
interface MapViewProps { interface MapViewProps {
participants: Participant[]; participants: Participant[];
waypoints?: Waypoint[];
currentUserId?: string; currentUserId?: string;
initialViewport?: MapViewport; initialViewport?: MapViewport;
onParticipantClick?: (participant: Participant) => void; onParticipantClick?: (participant: Participant) => void;
onWaypointClick?: (waypoint: Waypoint) => void;
onMapClick?: (lngLat: { lng: number; lat: number }) => void; onMapClick?: (lngLat: { lng: number; lat: number }) => void;
/** Auto-center on current user's location when first available */ /** Auto-center on current user's location when first available */
autoCenterOnUser?: boolean; autoCenterOnUser?: boolean;
@ -24,15 +26,18 @@ const DEFAULT_VIEWPORT: MapViewport = {
export default function MapView({ export default function MapView({
participants, participants,
waypoints = [],
currentUserId, currentUserId,
initialViewport = DEFAULT_VIEWPORT, initialViewport = DEFAULT_VIEWPORT,
onParticipantClick, onParticipantClick,
onWaypointClick,
onMapClick, onMapClick,
autoCenterOnUser = true, autoCenterOnUser = true,
}: MapViewProps) { }: MapViewProps) {
const mapContainer = useRef<HTMLDivElement>(null); const mapContainer = useRef<HTMLDivElement>(null);
const map = useRef<maplibregl.Map | null>(null); const map = useRef<maplibregl.Map | null>(null);
const markersRef = useRef<Map<string, maplibregl.Marker>>(new Map()); const markersRef = useRef<Map<string, maplibregl.Marker>>(new Map());
const waypointMarkersRef = useRef<Map<string, maplibregl.Marker>>(new Map());
const [mapLoaded, setMapLoaded] = useState(false); const [mapLoaded, setMapLoaded] = useState(false);
const hasCenteredOnUserRef = useRef(false); const hasCenteredOnUserRef = useRef(false);
@ -47,13 +52,15 @@ export default function MapView({
sources: { sources: {
osm: { osm: {
type: 'raster', type: 'raster',
// Use Carto's basemaps - more reliable than direct OSM tiles
tiles: [ tiles: [
'https://a.tile.openstreetmap.org/{z}/{x}/{y}.png', 'https://a.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}@2x.png',
'https://b.tile.openstreetmap.org/{z}/{x}/{y}.png', 'https://b.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}@2x.png',
'https://c.tile.openstreetmap.org/{z}/{x}/{y}.png', 'https://c.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}@2x.png',
'https://d.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}@2x.png',
], ],
tileSize: 256, tileSize: 256,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>', attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> &copy; <a href="https://carto.com/attributions">CARTO</a>',
}, },
}, },
layers: [ layers: [
@ -62,7 +69,7 @@ export default function MapView({
type: 'raster', type: 'raster',
source: 'osm', source: 'osm',
minzoom: 0, minzoom: 0,
maxzoom: 19, maxzoom: 20,
}, },
], ],
}, },
@ -172,6 +179,94 @@ export default function MapView({
} }
}, [participants, mapLoaded, currentUserId, onParticipantClick, autoCenterOnUser]); }, [participants, mapLoaded, currentUserId, onParticipantClick, autoCenterOnUser]);
// Update waypoint markers
useEffect(() => {
if (!map.current || !mapLoaded) return;
const currentWaypointMarkers = waypointMarkersRef.current;
const waypointIds = new Set(waypoints.map((w) => w.id));
// Remove markers for deleted waypoints
currentWaypointMarkers.forEach((marker, id) => {
if (!waypointIds.has(id)) {
marker.remove();
currentWaypointMarkers.delete(id);
}
});
// Add/update waypoint markers
waypoints.forEach((waypoint) => {
const { latitude, longitude } = waypoint.location;
let marker = currentWaypointMarkers.get(waypoint.id);
if (marker) {
// Update position if changed
marker.setLngLat([longitude, latitude]);
} else {
// Create new waypoint marker
const el = document.createElement('div');
el.className = 'waypoint-marker';
el.innerHTML = `
<div class="waypoint-icon">${waypoint.emoji || '📍'}</div>
<div class="waypoint-label">${waypoint.name}</div>
`;
el.style.cssText = `
display: flex;
flex-direction: column;
align-items: center;
cursor: pointer;
`;
const iconDiv = el.querySelector('.waypoint-icon') as HTMLElement;
if (iconDiv) {
iconDiv.style.cssText = `
width: 32px;
height: 32px;
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
border-radius: 50% 50% 50% 0;
transform: rotate(-45deg);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
`;
const innerSpan = document.createElement('span');
innerSpan.style.cssText = 'transform: rotate(45deg); font-size: 14px;';
innerSpan.textContent = waypoint.emoji || '📍';
iconDiv.innerHTML = '';
iconDiv.appendChild(innerSpan);
}
const labelDiv = el.querySelector('.waypoint-label') as HTMLElement;
if (labelDiv) {
labelDiv.style.cssText = `
background: rgba(0,0,0,0.8);
color: white;
font-size: 11px;
padding: 2px 6px;
border-radius: 4px;
margin-top: 4px;
white-space: nowrap;
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
`;
}
el.addEventListener('click', (e) => {
e.stopPropagation();
onWaypointClick?.(waypoint);
});
marker = new maplibregl.Marker({ element: el })
.setLngLat([longitude, latitude])
.addTo(map.current!);
currentWaypointMarkers.set(waypoint.id, marker);
}
});
}, [waypoints, mapLoaded, onWaypointClick]);
// Fit bounds to show all participants // Fit bounds to show all participants
const fitToParticipants = () => { const fitToParticipants = () => {
if (!map.current || participants.length === 0) return; if (!map.current || participants.length === 0) return;

View File

@ -0,0 +1,185 @@
'use client';
import { useState, useEffect } from 'react';
import type { ParticipantLocation, WaypointType } from '@/types';
interface MeetingPointModalProps {
currentLocation?: ParticipantLocation | null;
onClose: () => void;
onSetMeetingPoint: (waypoint: {
name: string;
emoji: string;
location: { latitude: number; longitude: number };
type: WaypointType;
}) => void;
}
const EMOJI_OPTIONS = ['📍', '🎯', '🏁', '⭐', '🍺', '☕', '🍕', '🎪', '🚻', '🚪'];
export default function MeetingPointModal({
currentLocation,
onClose,
onSetMeetingPoint,
}: MeetingPointModalProps) {
const [name, setName] = useState('');
const [emoji, setEmoji] = useState('📍');
const [useCurrentLocation, setUseCurrentLocation] = useState(true);
const [customLat, setCustomLat] = useState('');
const [customLng, setCustomLng] = useState('');
useEffect(() => {
if (currentLocation) {
setCustomLat(currentLocation.latitude.toFixed(6));
setCustomLng(currentLocation.longitude.toFixed(6));
}
}, [currentLocation]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
let latitude: number;
let longitude: number;
if (useCurrentLocation && currentLocation) {
latitude = currentLocation.latitude;
longitude = currentLocation.longitude;
} else {
latitude = parseFloat(customLat);
longitude = parseFloat(customLng);
if (isNaN(latitude) || isNaN(longitude)) {
alert('Please enter valid coordinates');
return;
}
}
onSetMeetingPoint({
name: name || 'Meeting Point',
emoji,
location: { latitude, longitude },
type: 'meetup',
});
onClose();
};
const hasLocation = currentLocation || (!isNaN(parseFloat(customLat)) && !isNaN(parseFloat(customLng)));
return (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center p-4 z-50">
<div className="room-panel rounded-2xl p-6 w-full max-w-md">
<h2 className="text-xl font-bold mb-4">Set Meeting Point</h2>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Name input */}
<div>
<label className="block text-sm text-white/60 mb-1">Name</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g., Main entrance, Food court..."
className="w-full bg-white/10 border border-white/20 rounded-lg px-3 py-2 text-white placeholder:text-white/40 focus:outline-none focus:border-rmaps-primary"
/>
</div>
{/* Emoji selector */}
<div>
<label className="block text-sm text-white/60 mb-1">Icon</label>
<div className="flex flex-wrap gap-2">
{EMOJI_OPTIONS.map((e) => (
<button
key={e}
type="button"
onClick={() => setEmoji(e)}
className={`w-10 h-10 rounded-lg text-xl flex items-center justify-center transition-colors ${
emoji === e
? 'bg-rmaps-primary'
: 'bg-white/10 hover:bg-white/20'
}`}
>
{e}
</button>
))}
</div>
</div>
{/* Location options */}
<div>
<label className="block text-sm text-white/60 mb-2">Location</label>
{currentLocation && (
<label className="flex items-center gap-2 mb-3 cursor-pointer">
<input
type="radio"
checked={useCurrentLocation}
onChange={() => setUseCurrentLocation(true)}
className="accent-rmaps-primary"
/>
<span className="text-sm">Use my current location</span>
</label>
)}
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
checked={!useCurrentLocation}
onChange={() => setUseCurrentLocation(false)}
className="accent-rmaps-primary"
/>
<span className="text-sm">Enter coordinates manually</span>
</label>
{!useCurrentLocation && (
<div className="mt-3 grid grid-cols-2 gap-2">
<div>
<label className="block text-xs text-white/40 mb-1">Latitude</label>
<input
type="text"
value={customLat}
onChange={(e) => setCustomLat(e.target.value)}
placeholder="53.5550"
className="w-full bg-white/10 border border-white/20 rounded-lg px-3 py-2 text-white text-sm placeholder:text-white/40 focus:outline-none focus:border-rmaps-primary"
/>
</div>
<div>
<label className="block text-xs text-white/40 mb-1">Longitude</label>
<input
type="text"
value={customLng}
onChange={(e) => setCustomLng(e.target.value)}
placeholder="9.9898"
className="w-full bg-white/10 border border-white/20 rounded-lg px-3 py-2 text-white text-sm placeholder:text-white/40 focus:outline-none focus:border-rmaps-primary"
/>
</div>
</div>
)}
{!hasLocation && !useCurrentLocation && (
<p className="text-xs text-yellow-400 mt-2">
Share your location first, or enter coordinates manually
</p>
)}
</div>
{/* Actions */}
<div className="flex gap-3 pt-2">
<button
type="button"
onClick={onClose}
className="btn-ghost flex-1"
>
Cancel
</button>
<button
type="submit"
className="btn-primary flex-1"
disabled={!hasLocation && useCurrentLocation}
>
Set Point
</button>
</div>
</form>
</div>
</div>
);
}

View File

@ -7,6 +7,7 @@ interface ParticipantListProps {
currentUserId?: string; currentUserId?: string;
onClose: () => void; onClose: () => void;
onNavigateTo: (participant: Participant) => void; onNavigateTo: (participant: Participant) => void;
onSetMeetingPoint?: () => void;
} }
export default function ParticipantList({ export default function ParticipantList({
@ -14,6 +15,7 @@ export default function ParticipantList({
currentUserId, currentUserId,
onClose, onClose,
onNavigateTo, onNavigateTo,
onSetMeetingPoint,
}: ParticipantListProps) { }: ParticipantListProps) {
const formatDistance = (participant: Participant, current: Participant | undefined) => { const formatDistance = (participant: Participant, current: Participant | undefined) => {
if (!participant.location || !current?.location) return null; if (!participant.location || !current?.location) return null;
@ -142,7 +144,10 @@ export default function ParticipantList({
{/* Footer actions */} {/* Footer actions */}
<div className="p-4 border-t border-white/10"> <div className="p-4 border-t border-white/10">
<button className="btn-secondary w-full text-sm"> <button
className="btn-secondary w-full text-sm"
onClick={onSetMeetingPoint}
>
Set Meeting Point Set Meeting Point
</button> </button>
</div> </div>