- Header: shrink-0 logo + whitespace-nowrap + gap, nav shrinks/scrolls so it can never collide with (or glow over) the SkateSpots wordmark; tighter item padding. Fixes the top-left logo/nav overlap. - Amber: useReadState keeps read markers LOCAL-ONLY for interactive signers, so merely opening the bell/DM no longer publishes (encrypt + sign = 2 popups). - Reputation: lead with verifiedCounterparties (distinct people), cap each partner at 3 in the WoT-weighted score, warn when every confirmation is with one account. NIP.md documents the collusion limit honestly. - Directions: skip the live GPS call when the cached fix is <2 min old; the blocking 'Finding your location' overlay only shows with no seed (silent refine chip otherwise). Near Me also flips the sort to proximity. - .gitignore: cvm-server/package.json (local dev copy of package.cvm.json).
440 lines
16 KiB
TypeScript
440 lines
16 KiB
TypeScript
import { useEffect, useRef, useState, useCallback } from 'react';
|
|
import L from 'leaflet';
|
|
import { Loader2, MapPin, Navigation, AlertTriangle, LocateFixed } from 'lucide-react';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogDescription,
|
|
} from '@/components/ui/dialog';
|
|
import { Button } from '@/components/ui/button';
|
|
import { cn } from '@/lib/utils';
|
|
import { getCurrentPosition, GeolocationError } from '@/lib/geolocation';
|
|
import { getLastLocation } from '@/lib/lastLocation';
|
|
import {
|
|
fetchRoute,
|
|
fetchTransitRoute,
|
|
formatClock,
|
|
formatDistance,
|
|
formatDuration,
|
|
TRAVEL_MODES,
|
|
TRANSIT_ATTRIBUTION,
|
|
type TravelMode,
|
|
type RouteResult,
|
|
} from '@/lib/routing';
|
|
|
|
// ─── Transit presentation helpers ────────────────────────────────────────────
|
|
|
|
/** Line colour for a transit leg, keyed by the MOTIS mode. */
|
|
function transitLegColor(mode: string): string {
|
|
switch (mode) {
|
|
case 'BUS': return '#22c55e';
|
|
case 'TRAM': return '#eab308';
|
|
case 'SUBWAY':
|
|
case 'METRO': return '#3b82f6';
|
|
case 'RAIL':
|
|
case 'REGIONAL_RAIL':
|
|
case 'REGIONAL_FAST_RAIL':
|
|
case 'LONG_DISTANCE':
|
|
case 'HIGHSPEED_RAIL':
|
|
case 'NIGHT_RAIL':
|
|
case 'SUBURBAN': return '#ef4444';
|
|
case 'FERRY': return '#06b6d4';
|
|
default: return '#ccff00';
|
|
}
|
|
}
|
|
|
|
/** Emoji for a transit leg's mode. */
|
|
function transitModeEmoji(mode: string): string {
|
|
switch (mode) {
|
|
case 'BUS': return '🚌';
|
|
case 'TRAM': return '🚊';
|
|
case 'SUBWAY':
|
|
case 'METRO': return '🚇';
|
|
case 'RAIL':
|
|
case 'REGIONAL_RAIL':
|
|
case 'REGIONAL_FAST_RAIL':
|
|
case 'LONG_DISTANCE':
|
|
case 'HIGHSPEED_RAIL':
|
|
case 'NIGHT_RAIL':
|
|
case 'SUBURBAN': return '🚆';
|
|
case 'FERRY': return '⛴️';
|
|
case 'WALK': return '🚶';
|
|
case 'BIKE': return '🚲';
|
|
default: return '🚇';
|
|
}
|
|
}
|
|
|
|
interface RouteDialogProps {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
/** Destination coordinates. */
|
|
destination: { lat: number; lng: number } | null;
|
|
/** Destination label shown in the header. */
|
|
destinationLabel?: string;
|
|
}
|
|
|
|
/**
|
|
* In-app routing dialog.
|
|
*
|
|
* Shows a self-contained Leaflet map with a route drawn from the user's current
|
|
* location to a destination, with a foot/bike/moto/car mode toggle. No external
|
|
* maps app is opened — the route comes from the keyless public OSRM server and
|
|
* is drawn here. See src/lib/routing.ts for the honest scope notes.
|
|
*/
|
|
export function RouteDialog({
|
|
open,
|
|
onOpenChange,
|
|
destination,
|
|
destinationLabel = 'Destination',
|
|
}: RouteDialogProps) {
|
|
const mapDivRef = useRef<HTMLDivElement>(null);
|
|
const mapRef = useRef<L.Map | null>(null);
|
|
const routeLayersRef = useRef<L.LayerGroup | null>(null);
|
|
const originMarkerRef = useRef<L.Marker | null>(null);
|
|
const destMarkerRef = useRef<L.Marker | null>(null);
|
|
|
|
const [origin, setOrigin] = useState<{ lat: number; lng: number } | null>(null);
|
|
const [mode, setMode] = useState<TravelMode>('foot');
|
|
const [route, setRoute] = useState<RouteResult | null>(null);
|
|
const [locating, setLocating] = useState(false);
|
|
const [routing, setRouting] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [locError, setLocError] = useState<string | null>(null);
|
|
|
|
// ─── Get the user's location when the dialog opens ─────────────────────────
|
|
const locate = useCallback(async () => {
|
|
setLocating(true);
|
|
setLocError(null);
|
|
try {
|
|
const pos = await getCurrentPosition();
|
|
setOrigin(pos);
|
|
} catch (err) {
|
|
const msg =
|
|
err instanceof GeolocationError
|
|
? err.kind === 'denied'
|
|
? 'Location permission denied. Enable location to route from where you are.'
|
|
: 'Could not get your location. Try again.'
|
|
: 'Could not get your location.';
|
|
setLocError(msg);
|
|
} finally {
|
|
setLocating(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
// Seed from the last known fix so a route can be drawn immediately, then
|
|
// refine with a live reading. This is what makes opening Directions feel
|
|
// instant instead of hanging on "Finding your location…".
|
|
const cached = getLastLocation();
|
|
const fresh = !!cached && Date.now() - cached.at < 2 * 60 * 1000;
|
|
if (!origin && cached) setOrigin({ lat: cached.lat, lng: cached.lng });
|
|
// Only ask for a live GPS fix when the cached one is missing or stale. A
|
|
// recent fix means the route can draw instantly with no "Finding your
|
|
// location…" wait (and no needless location request on every open).
|
|
if (!fresh) locate();
|
|
// Re-run only when the dialog opens (locate is stable).
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [open]);
|
|
|
|
// Reset transient state when closed
|
|
useEffect(() => {
|
|
if (!open) {
|
|
setRoute(null);
|
|
setError(null);
|
|
}
|
|
}, [open]);
|
|
|
|
// ─── Initialize the Leaflet map once the dialog content is mounted ─────────
|
|
useEffect(() => {
|
|
if (!open || !mapDivRef.current || mapRef.current || !destination) return;
|
|
|
|
const map = L.map(mapDivRef.current, {
|
|
center: [destination.lat, destination.lng],
|
|
zoom: 14,
|
|
scrollWheelZoom: true,
|
|
attributionControl: false,
|
|
minZoom: 2,
|
|
});
|
|
|
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
attribution: '',
|
|
subdomains: 'abc',
|
|
maxZoom: 19,
|
|
className: 'skatespots-dark-tiles',
|
|
}).addTo(map);
|
|
|
|
// Destination marker
|
|
const destIcon = L.divIcon({
|
|
className: 'route-dest-icon',
|
|
html: `<div style="width:32px;height:32px;background:linear-gradient(135deg,#ff0040,#ff6600);border-radius:50% 50% 50% 0;transform:rotate(-45deg);box-shadow:0 4px 12px rgba(255,0,64,0.5);border:3px solid rgba(255,255,255,0.9);"></div>`,
|
|
iconSize: [32, 32],
|
|
iconAnchor: [16, 30],
|
|
});
|
|
destMarkerRef.current = L.marker([destination.lat, destination.lng], {
|
|
icon: destIcon,
|
|
}).addTo(map);
|
|
|
|
mapRef.current = map;
|
|
|
|
setTimeout(() => map.invalidateSize(), 120);
|
|
|
|
return () => {
|
|
map.remove();
|
|
mapRef.current = null;
|
|
routeLayersRef.current = null;
|
|
originMarkerRef.current = null;
|
|
destMarkerRef.current = null;
|
|
};
|
|
}, [open, destination]);
|
|
|
|
// ─── Draw / update the origin marker when we have the user's location ──────
|
|
useEffect(() => {
|
|
const map = mapRef.current;
|
|
if (!map || !origin) return;
|
|
|
|
if (originMarkerRef.current) {
|
|
originMarkerRef.current.setLatLng([origin.lat, origin.lng]);
|
|
} else {
|
|
const userIcon = L.divIcon({
|
|
className: 'route-origin-icon',
|
|
html: `<div style="width:18px;height:18px;background:#00b3ff;border-radius:50%;box-shadow:0 0 0 6px rgba(0,179,255,0.25),0 2px 8px rgba(0,0,0,0.4);border:3px solid #fff;"></div>`,
|
|
iconSize: [18, 18],
|
|
iconAnchor: [9, 9],
|
|
});
|
|
originMarkerRef.current = L.marker([origin.lat, origin.lng], {
|
|
icon: userIcon,
|
|
}).addTo(map);
|
|
}
|
|
}, [origin]);
|
|
|
|
// ─── Fetch + draw the route whenever origin / mode changes ─────────────────
|
|
useEffect(() => {
|
|
if (!open || !origin || !destination) return;
|
|
|
|
const controller = new AbortController();
|
|
let cancelled = false;
|
|
|
|
(async () => {
|
|
setRouting(true);
|
|
setError(null);
|
|
try {
|
|
const result = mode === 'transit'
|
|
? await fetchTransitRoute(origin, destination, controller.signal)
|
|
: await fetchRoute(origin, destination, mode, controller.signal);
|
|
if (cancelled) return;
|
|
setRoute(result);
|
|
|
|
const map = mapRef.current;
|
|
if (map) {
|
|
if (routeLayersRef.current) {
|
|
routeLayersRef.current.remove();
|
|
routeLayersRef.current = null;
|
|
}
|
|
const group = L.layerGroup().addTo(map);
|
|
|
|
if (result.transit) {
|
|
// Public transport: draw each leg — walking dashed grey, transit
|
|
// legs solid and coloured by mode.
|
|
for (const leg of result.transit.legs) {
|
|
if (leg.coordinates.length < 2) continue;
|
|
const isWalk = leg.mode === 'WALK';
|
|
L.polyline(leg.coordinates, {
|
|
color: isWalk ? '#9ca3af' : transitLegColor(leg.mode),
|
|
weight: isWalk ? 3 : 5,
|
|
opacity: isWalk ? 0.7 : 0.9,
|
|
dashArray: isWalk ? '4 6' : undefined,
|
|
lineJoin: 'round',
|
|
lineCap: 'round',
|
|
}).addTo(group);
|
|
}
|
|
} else {
|
|
L.polyline(result.coordinates, {
|
|
color: '#ccff00',
|
|
weight: 5,
|
|
opacity: 0.9,
|
|
lineJoin: 'round',
|
|
lineCap: 'round',
|
|
}).addTo(group);
|
|
}
|
|
|
|
routeLayersRef.current = group;
|
|
if (result.coordinates.length > 0) {
|
|
map.fitBounds(L.latLngBounds(result.coordinates), { padding: [48, 48], maxZoom: 16 });
|
|
}
|
|
}
|
|
} catch (err) {
|
|
if (cancelled) return;
|
|
if (err instanceof DOMException && err.name === 'AbortError') return;
|
|
setError(
|
|
mode === 'transit'
|
|
? err instanceof Error && err.message === 'No transit route found'
|
|
? 'No public-transport route found here — this area may not publish open timetables.'
|
|
: "Couldn't reach the transit service. Check your connection and try again."
|
|
: err instanceof Error && err.message === 'No route found'
|
|
? 'No route found for this mode. Try another mode.'
|
|
: "Couldn't reach the routing service. Check your connection and try again.",
|
|
);
|
|
} finally {
|
|
if (!cancelled) setRouting(false);
|
|
}
|
|
})();
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
controller.abort();
|
|
};
|
|
}, [open, origin, destination, mode]);
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="max-w-lg p-0 gap-0 overflow-hidden">
|
|
<DialogHeader className="p-4 pb-3">
|
|
<DialogTitle className="flex items-center gap-2 text-lg">
|
|
<Navigation className="h-5 w-5 text-[#ccff00]" />
|
|
Route to {destinationLabel}
|
|
</DialogTitle>
|
|
<DialogDescription className="text-xs">
|
|
{mode === 'transit'
|
|
? 'Public transport via Transitous (open timetables + OpenStreetMap)'
|
|
: 'Routed in-app · nothing leaves to Google or Apple Maps'}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
{/* Mode toggle */}
|
|
<div className="px-4 pb-3 flex gap-1.5">
|
|
{TRAVEL_MODES.map((m) => (
|
|
<button
|
|
key={m.mode}
|
|
type="button"
|
|
onClick={() => setMode(m.mode)}
|
|
className={cn(
|
|
'flex-1 flex flex-col items-center gap-0.5 rounded-xl py-2 text-xs font-bold uppercase tracking-wide transition-all border-2',
|
|
mode === m.mode
|
|
? 'bg-[#ccff00]/15 border-[#ccff00] text-[#ccff00]'
|
|
: 'border-transparent bg-muted/50 text-muted-foreground hover:bg-muted',
|
|
)}
|
|
aria-pressed={mode === m.mode}
|
|
>
|
|
<span className="text-lg leading-none">{m.emoji}</span>
|
|
<span className="text-[10px] leading-tight text-center">{m.label}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Map */}
|
|
<div className="relative">
|
|
<div
|
|
ref={mapDivRef}
|
|
className="h-64 w-full bg-muted"
|
|
aria-label="Route map"
|
|
/>
|
|
|
|
{/* Overlays */}
|
|
{/* Blocking overlay only when we have nothing to show yet. With a
|
|
cached origin we keep the map visible and refine quietly. */}
|
|
{((locating && !origin) || (routing && !route)) && (
|
|
<div className="absolute inset-0 flex items-center justify-center bg-background/60 backdrop-blur-sm">
|
|
<div className="flex items-center gap-2 text-sm font-medium">
|
|
<Loader2 className="h-4 w-4 animate-spin text-[#ccff00]" />
|
|
{locating && !origin ? 'Finding your location…' : 'Plotting your route…'}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{locating && origin && (
|
|
<div className="pointer-events-none absolute bottom-2 left-2 flex items-center gap-1.5 rounded-full bg-background/80 px-2 py-1 text-[10px] font-medium text-muted-foreground backdrop-blur-sm">
|
|
<Loader2 className="h-3 w-3 animate-spin" />
|
|
Refining location…
|
|
</div>
|
|
)}
|
|
|
|
{locError && !origin && (
|
|
<div className="absolute inset-0 flex items-center justify-center bg-background/80 p-6">
|
|
<div className="text-center space-y-3 max-w-xs">
|
|
<MapPin className="h-8 w-8 mx-auto text-muted-foreground" />
|
|
<p className="text-sm text-muted-foreground">{locError}</p>
|
|
<Button size="sm" variant="outline" onClick={locate} className="gap-2">
|
|
<LocateFixed className="h-4 w-4" />
|
|
Try again
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Summary bar */}
|
|
<div className="p-4 border-t bg-muted/30 min-h-[64px] flex items-center">
|
|
{error ? (
|
|
<div className="flex items-center gap-2 text-sm text-amber-500">
|
|
<AlertTriangle className="h-4 w-4 shrink-0" />
|
|
<span>{error}</span>
|
|
</div>
|
|
) : route?.transit ? (
|
|
<div className="w-full space-y-2">
|
|
<div className="flex items-center gap-3">
|
|
<div className="text-2xl leading-none">🚇</div>
|
|
<div className="min-w-0">
|
|
<div className="text-lg font-black tabular-nums">
|
|
{formatClock(route.transit.departISO)} → {formatClock(route.transit.arriveISO)}
|
|
</div>
|
|
<div className="text-xs text-muted-foreground">
|
|
{formatDuration(route.transit.durationS)} ·{' '}
|
|
{route.transit.transfers === 0
|
|
? 'direct'
|
|
: `${route.transit.transfers} transfer${route.transit.transfers > 1 ? 's' : ''}`}
|
|
{routing && <Loader2 className="ml-1 inline h-3 w-3 animate-spin" />}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{route.transit.legs.some((l) => l.mode !== 'WALK') && (
|
|
<div className="flex flex-wrap gap-1">
|
|
{route.transit.legs
|
|
.filter((l) => l.mode !== 'WALK')
|
|
.map((l, i) => (
|
|
<span
|
|
key={`${l.mode}-${l.line}-${i}`}
|
|
className="inline-flex items-center gap-1 rounded-full border border-white/10 bg-white/5 px-2 py-0.5 text-[10px] font-bold text-gray-300"
|
|
>
|
|
<span aria-hidden>{transitModeEmoji(l.mode)}</span>
|
|
{l.line ?? l.mode}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
<p className="text-[10px] text-muted-foreground/70">{TRANSIT_ATTRIBUTION}</p>
|
|
</div>
|
|
) : route ? (
|
|
<div className="flex items-center justify-between w-full">
|
|
<div className="flex items-center gap-3">
|
|
<div className="text-2xl leading-none">
|
|
{TRAVEL_MODES.find((m) => m.mode === mode)?.emoji}
|
|
</div>
|
|
<div>
|
|
<div className="text-xl font-black tabular-nums">
|
|
{formatDistance(route.distanceM / 1000)}
|
|
</div>
|
|
<div className="text-xs text-muted-foreground">
|
|
{formatDuration(route.durationS)}
|
|
{TRAVEL_MODES.find((m) => m.mode === mode)?.approximate && (
|
|
<span className="ml-1">· uses driving roads</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{routing && (
|
|
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="text-sm text-muted-foreground">
|
|
{origin ? 'Pick a mode to see your route.' : 'Waiting for your location…'}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|