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:
parent
1a996931b5
commit
0a234f902a
|
|
@ -8,6 +8,7 @@ import { useLocationSharing } from '@/hooks/useLocationSharing';
|
|||
import ParticipantList from '@/components/room/ParticipantList';
|
||||
import RoomHeader from '@/components/room/RoomHeader';
|
||||
import ShareModal from '@/components/room/ShareModal';
|
||||
import MeetingPointModal from '@/components/room/MeetingPointModal';
|
||||
import type { Participant, ParticipantLocation } from '@/types';
|
||||
|
||||
// Dynamic import for map to avoid SSR issues with MapLibre
|
||||
|
|
@ -27,6 +28,7 @@ export default function RoomPage() {
|
|||
|
||||
const [showShare, setShowShare] = useState(false);
|
||||
const [showParticipants, setShowParticipants] = useState(true);
|
||||
const [showMeetingPoint, setShowMeetingPoint] = useState(false);
|
||||
const [currentUser, setCurrentUser] = useState<{ name: string; emoji: string } | null>(null);
|
||||
const [selectedParticipant, setSelectedParticipant] = useState<Participant | null>(null);
|
||||
|
||||
|
|
@ -201,6 +203,7 @@ export default function RoomPage() {
|
|||
{/* Map */}
|
||||
<DualMapView
|
||||
participants={participants}
|
||||
waypoints={waypoints}
|
||||
currentUserId={currentParticipantId || undefined}
|
||||
currentLocation={currentLocation}
|
||||
eventId="38c3"
|
||||
|
|
@ -208,6 +211,9 @@ export default function RoomPage() {
|
|||
setSelectedParticipant(p);
|
||||
setShowParticipants(true);
|
||||
}}
|
||||
onWaypointClick={(w) => {
|
||||
console.log('Waypoint clicked:', w.name);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Participant Panel */}
|
||||
|
|
@ -218,6 +224,7 @@ export default function RoomPage() {
|
|||
currentUserId={currentUser.name}
|
||||
onClose={() => setShowParticipants(false)}
|
||||
onNavigateTo={handleNavigateTo}
|
||||
onSetMeetingPoint={() => setShowMeetingPoint(true)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -234,6 +241,18 @@ export default function RoomPage() {
|
|||
{showShare && (
|
||||
<ShareModal roomSlug={slug} onClose={() => setShowShare(false)} />
|
||||
)}
|
||||
|
||||
{/* Meeting Point Modal */}
|
||||
{showMeetingPoint && (
|
||||
<MeetingPointModal
|
||||
currentLocation={currentLocation}
|
||||
onClose={() => setShowMeetingPoint(false)}
|
||||
onSetMeetingPoint={(waypoint) => {
|
||||
addWaypoint(waypoint);
|
||||
setShowMeetingPoint(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import type { Participant, MapViewport } from '@/types';
|
||||
import type { Participant, MapViewport, Waypoint } from '@/types';
|
||||
import { isInC3NavArea } from '@/lib/c3nav';
|
||||
|
||||
// Dynamic imports to avoid SSR issues
|
||||
|
|
@ -28,11 +28,13 @@ type MapMode = 'outdoor' | 'indoor' | 'auto';
|
|||
|
||||
interface DualMapViewProps {
|
||||
participants: Participant[];
|
||||
waypoints?: Waypoint[];
|
||||
currentUserId?: string;
|
||||
currentLocation?: { latitude: number; longitude: number } | null;
|
||||
eventId?: string;
|
||||
initialMode?: MapMode;
|
||||
onParticipantClick?: (participant: Participant) => void;
|
||||
onWaypointClick?: (waypoint: Waypoint) => void;
|
||||
}
|
||||
|
||||
// CCC venue bounds (Hamburg Congress Center)
|
||||
|
|
@ -45,11 +47,13 @@ const CCC_BOUNDS = {
|
|||
|
||||
export default function DualMapView({
|
||||
participants,
|
||||
waypoints = [],
|
||||
currentUserId,
|
||||
currentLocation,
|
||||
eventId = '38c3',
|
||||
initialMode = 'auto',
|
||||
onParticipantClick,
|
||||
onWaypointClick,
|
||||
}: DualMapViewProps) {
|
||||
const [mode, setMode] = useState<MapMode>(initialMode);
|
||||
const [activeView, setActiveView] = useState<'outdoor' | 'indoor'>('outdoor');
|
||||
|
|
@ -86,8 +90,10 @@ export default function DualMapView({
|
|||
{activeView === 'outdoor' ? (
|
||||
<MapView
|
||||
participants={participants}
|
||||
waypoints={waypoints}
|
||||
currentUserId={currentUserId}
|
||||
onParticipantClick={onParticipantClick}
|
||||
onWaypointClick={onWaypointClick}
|
||||
/>
|
||||
) : (
|
||||
<C3NavEmbed
|
||||
|
|
@ -103,7 +109,7 @@ export default function DualMapView({
|
|||
{activeView === 'outdoor' && (
|
||||
<button
|
||||
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">
|
||||
<path
|
||||
|
|
@ -126,7 +132,7 @@ export default function DualMapView({
|
|||
|
||||
{/* Venue proximity indicator */}
|
||||
{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're at the venue!</span>
|
||||
<button onClick={goIndoor} className="underline">
|
||||
Switch to indoor
|
||||
|
|
|
|||
|
|
@ -3,14 +3,16 @@
|
|||
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 type { Participant, MapViewport, Waypoint } from '@/types';
|
||||
import FriendMarker from './FriendMarker';
|
||||
|
||||
interface MapViewProps {
|
||||
participants: Participant[];
|
||||
waypoints?: Waypoint[];
|
||||
currentUserId?: string;
|
||||
initialViewport?: MapViewport;
|
||||
onParticipantClick?: (participant: Participant) => void;
|
||||
onWaypointClick?: (waypoint: Waypoint) => void;
|
||||
onMapClick?: (lngLat: { lng: number; lat: number }) => void;
|
||||
/** Auto-center on current user's location when first available */
|
||||
autoCenterOnUser?: boolean;
|
||||
|
|
@ -24,15 +26,18 @@ const DEFAULT_VIEWPORT: MapViewport = {
|
|||
|
||||
export default function MapView({
|
||||
participants,
|
||||
waypoints = [],
|
||||
currentUserId,
|
||||
initialViewport = DEFAULT_VIEWPORT,
|
||||
onParticipantClick,
|
||||
onWaypointClick,
|
||||
onMapClick,
|
||||
autoCenterOnUser = true,
|
||||
}: MapViewProps) {
|
||||
const mapContainer = useRef<HTMLDivElement>(null);
|
||||
const map = useRef<maplibregl.Map | null>(null);
|
||||
const markersRef = useRef<Map<string, maplibregl.Marker>>(new Map());
|
||||
const waypointMarkersRef = useRef<Map<string, maplibregl.Marker>>(new Map());
|
||||
const [mapLoaded, setMapLoaded] = useState(false);
|
||||
const hasCenteredOnUserRef = useRef(false);
|
||||
|
||||
|
|
@ -47,13 +52,15 @@ export default function MapView({
|
|||
sources: {
|
||||
osm: {
|
||||
type: 'raster',
|
||||
// Use Carto's basemaps - more reliable than direct OSM tiles
|
||||
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',
|
||||
'https://a.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}@2x.png',
|
||||
'https://b.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}@2x.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,
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> © <a href="https://carto.com/attributions">CARTO</a>',
|
||||
},
|
||||
},
|
||||
layers: [
|
||||
|
|
@ -62,7 +69,7 @@ export default function MapView({
|
|||
type: 'raster',
|
||||
source: 'osm',
|
||||
minzoom: 0,
|
||||
maxzoom: 19,
|
||||
maxzoom: 20,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
@ -172,6 +179,94 @@ export default function MapView({
|
|||
}
|
||||
}, [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
|
||||
const fitToParticipants = () => {
|
||||
if (!map.current || participants.length === 0) return;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ interface ParticipantListProps {
|
|||
currentUserId?: string;
|
||||
onClose: () => void;
|
||||
onNavigateTo: (participant: Participant) => void;
|
||||
onSetMeetingPoint?: () => void;
|
||||
}
|
||||
|
||||
export default function ParticipantList({
|
||||
|
|
@ -14,6 +15,7 @@ export default function ParticipantList({
|
|||
currentUserId,
|
||||
onClose,
|
||||
onNavigateTo,
|
||||
onSetMeetingPoint,
|
||||
}: ParticipantListProps) {
|
||||
const formatDistance = (participant: Participant, current: Participant | undefined) => {
|
||||
if (!participant.location || !current?.location) return null;
|
||||
|
|
@ -142,7 +144,10 @@ export default function ParticipantList({
|
|||
|
||||
{/* Footer actions */}
|
||||
<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
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in New Issue