diff options
Diffstat (limited to 'src/frontend/app/routes')
| -rw-r--r-- | src/frontend/app/routes/home.tsx | 108 | ||||
| -rw-r--r-- | src/frontend/app/routes/map.tsx | 133 | ||||
| -rw-r--r-- | src/frontend/app/routes/planner.tsx | 33 |
3 files changed, 154 insertions, 120 deletions
diff --git a/src/frontend/app/routes/home.tsx b/src/frontend/app/routes/home.tsx index 45d7ddf..e71c788 100644 --- a/src/frontend/app/routes/home.tsx +++ b/src/frontend/app/routes/home.tsx @@ -1,8 +1,7 @@ -import { Clock, History, Star } from "lucide-react"; +import { Clock, History, MapPin, Star } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router"; -import { PlannerOverlay } from "~/components/PlannerOverlay"; import { usePageTitle } from "~/contexts/PageTitleContext"; import { usePlanner } from "~/hooks/usePlanner"; import StopItem from "../components/StopItem"; @@ -13,16 +12,12 @@ export default function StopList() { const { t } = useTranslation(); usePageTitle(t("navbar.stops", "Paradas")); const navigate = useNavigate(); - const { history, searchRoute, loadRoute } = usePlanner({ autoLoad: false }); + const { history, loadRoute } = usePlanner({ autoLoad: false }); const [data, setData] = useState<Stop[] | null>(null); const [loading, setLoading] = useState(true); const [searchResults, setSearchResults] = useState<Stop[] | null>(null); const [favouriteStops, setFavouriteStops] = useState<Stop[]>([]); const [recentStops, setRecentStops] = useState<Stop[]>([]); - const [userLocation, setUserLocation] = useState<{ - latitude: number; - longitude: number; - } | null>(null); const searchTimeout = useRef<NodeJS.Timeout | null>(null); const randomPlaceholder = useMemo( @@ -30,68 +25,6 @@ export default function StopList() { [t] ); - const requestUserLocation = useCallback(() => { - if (typeof window === "undefined" || !("geolocation" in navigator)) { - return; - } - - navigator.geolocation.getCurrentPosition( - (position) => { - setUserLocation({ - latitude: position.coords.latitude, - longitude: position.coords.longitude, - }); - }, - (error) => { - console.warn("Unable to obtain user location", error); - }, - { - enableHighAccuracy: false, - maximumAge: Infinity, - timeout: 10000, - } - ); - }, []); - - useEffect(() => { - if (typeof window === "undefined" || !("geolocation" in navigator)) { - return; - } - - let permissionStatus: PermissionStatus | null = null; - - const handlePermissionChange = () => { - if (permissionStatus?.state === "granted") { - requestUserLocation(); - } - }; - - const checkPermission = async () => { - try { - if (navigator.permissions?.query) { - permissionStatus = await navigator.permissions.query({ - name: "geolocation", - }); - if (permissionStatus.state === "granted") { - requestUserLocation(); - } - permissionStatus.addEventListener("change", handlePermissionChange); - } else { - requestUserLocation(); - } - } catch (error) { - console.warn("Geolocation permission check failed", error); - requestUserLocation(); - } - }; - - checkPermission(); - - return () => { - permissionStatus?.removeEventListener("change", handlePermissionChange); - }; - }, [requestUserLocation]); - // Load stops from network const loadStops = useCallback(async () => { try { @@ -164,33 +97,16 @@ export default function StopList() { <div className="flex flex-col gap-4 py-4 pb-8"> {/* Planner Section */} <div className="w-full px-4"> - <details className="group bg-surface border border-slate-200 dark:border-slate-700 shadow-sm"> - <summary className="list-none cursor-pointer focus:outline-none"> - <div className="flex items-center justify-between p-3 rounded-xl group-open:mb-3 transition-all"> - <div className="flex items-center gap-3"> - <History className="w-5 h-5 text-primary-600 dark:text-primary-400" /> - <span className="font-semibold text-text"> - {t("planner.where_to", "¿A dónde quieres ir?")} - </span> - </div> - <div className="text-muted group-open:rotate-180 transition-transform"> - ↓ - </div> - </div> - </summary> - - <PlannerOverlay - inline - forceExpanded - cardBackground="bg-transparent" - userLocation={userLocation} - autoLoad={false} - onSearch={(origin, destination, time, arriveBy) => { - searchRoute(origin, destination, time, arriveBy); - }} - onNavigateToPlanner={() => navigate("/planner")} - /> - </details> + <button + type="button" + onClick={() => navigate("/planner")} + className="w-full flex items-center gap-3 p-3 rounded-xl bg-surface border border-slate-200 dark:border-slate-700 shadow-sm hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors text-left" + > + <MapPin className="w-5 h-5 text-primary-600 dark:text-primary-400 shrink-0" /> + <span className="font-semibold text-text"> + {t("planner.where_to", "¿A dónde quieres ir?")} + </span> + </button> {history.length > 0 && ( <div className="mt-3 flex flex-col gap-2"> diff --git a/src/frontend/app/routes/map.tsx b/src/frontend/app/routes/map.tsx index f54f6cf..8149d30 100644 --- a/src/frontend/app/routes/map.tsx +++ b/src/frontend/app/routes/map.tsx @@ -1,6 +1,6 @@ -import { Check, MapPin, X } from "lucide-react"; +import { Check, MapPin, Navigation, X } from "lucide-react"; import type { FilterSpecification } from "maplibre-gl"; -import { useMemo, useRef, useState } from "react"; +import { useRef, useState, useMemo } from "react"; import { useTranslation } from "react-i18next"; import { Layer, @@ -53,6 +53,81 @@ export default function StopMap() { const [isConfirming, setIsConfirming] = useState(false); + // Context menu state (right-click / long-press) + interface ContextMenuState { + x: number; + y: number; + lat: number; + lng: number; + } + const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null); + const [contextMenuLoading, setContextMenuLoading] = useState< + "origin" | "destination" | null + >(null); + + const handleContextMenu = (e: MapLayerMouseEvent) => { + if (pickingMode) return; + e.preventDefault?.(); + setContextMenu({ + x: e.point.x, + y: e.point.y, + lat: e.lngLat.lat, + lng: e.lngLat.lng, + }); + }; + + const closeContextMenu = () => setContextMenu(null); + + const handleRouteFromHere = async () => { + if (!contextMenu) return; + setContextMenuLoading("origin"); + try { + const result = await reverseGeocode(contextMenu.lat, contextMenu.lng); + const place = { + name: + result?.name || + `${contextMenu.lat.toFixed(5)}, ${contextMenu.lng.toFixed(5)}`, + label: result?.label || "Map location", + lat: contextMenu.lat, + lon: contextMenu.lng, + layer: "map-pick", + }; + setOrigin(place); + addRecentPlace(place); + closeContextMenu(); + navigate("/planner"); + } catch { + closeContextMenu(); + } finally { + setContextMenuLoading(null); + } + }; + + const handleRouteToHere = async () => { + if (!contextMenu) return; + setContextMenuLoading("destination"); + try { + const result = await reverseGeocode(contextMenu.lat, contextMenu.lng); + const place = { + name: + result?.name || + `${contextMenu.lat.toFixed(5)}, ${contextMenu.lng.toFixed(5)}`, + label: result?.label || "Map location", + lat: contextMenu.lat, + lon: contextMenu.lng, + layer: "map-pick", + }; + setDestination(place); + addRecentPlace(place); + closeContextMenu(); + navigate("/planner"); + } catch { + closeContextMenu(); + } finally { + setContextMenuLoading(null); + } + }; + const handleConfirmPick = async () => { if (!mapRef.current || !pickingMode) return; const center = mapRef.current.getCenter(); @@ -252,9 +327,13 @@ export default function StopMap() { showGeolocate={true} showTraffic={pickingMode ? false : undefined} interactiveLayerIds={["stops", "stops-label"]} - onClick={onMapClick} + onClick={(e) => { + closeContextMenu(); + onMapClick(e); + }} onDragStart={onMapInteraction} onZoomStart={onMapInteraction} + onContextMenu={handleContextMenu} attributionControl={{ compact: false }} > <Source @@ -440,6 +519,54 @@ export default function StopMap() { </div> )} </AppMap> + + {contextMenu && ( + <> + {/* Dismiss backdrop */} + <div + className="absolute inset-0 z-30" + onClick={closeContextMenu} + /> + {/* Context menu */} + <div + className="absolute z-40 min-w-[180px] rounded-xl bg-white dark:bg-slate-900 shadow-2xl border border-slate-200 dark:border-slate-700 overflow-hidden" + style={{ + left: Math.min(contextMenu.x, window.innerWidth - 200), + top: Math.min(contextMenu.y, window.innerHeight - 120), + }} + > + <button + className="w-full flex items-center gap-3 px-4 py-3 text-sm text-left text-slate-800 dark:text-slate-100 hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors disabled:opacity-50" + onClick={handleRouteFromHere} + disabled={contextMenuLoading !== null} + > + {contextMenuLoading === "origin" ? ( + <div className="w-4 h-4 border-2 border-primary-500 border-t-transparent rounded-full animate-spin" /> + ) : ( + <Navigation className="w-4 h-4 text-primary-600 dark:text-primary-400 shrink-0" /> + )} + <span className="font-medium"> + {t("map.route_from_here", "Ruta desde aquí")} + </span> + </button> + <div className="h-px bg-slate-100 dark:bg-slate-800" /> + <button + className="w-full flex items-center gap-3 px-4 py-3 text-sm text-left text-slate-800 dark:text-slate-100 hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors disabled:opacity-50" + onClick={handleRouteToHere} + disabled={contextMenuLoading !== null} + > + {contextMenuLoading === "destination" ? ( + <div className="w-4 h-4 border-2 border-primary-500 border-t-transparent rounded-full animate-spin" /> + ) : ( + <MapPin className="w-4 h-4 text-primary-600 dark:text-primary-400 shrink-0" /> + )} + <span className="font-medium"> + {t("map.route_to_here", "Ruta hasta aquí")} + </span> + </button> + </div> + </> + )} </div> ); } diff --git a/src/frontend/app/routes/planner.tsx b/src/frontend/app/routes/planner.tsx index b7ecaf9..ff13225 100644 --- a/src/frontend/app/routes/planner.tsx +++ b/src/frontend/app/routes/planner.tsx @@ -13,6 +13,7 @@ import { AppMap } from "~/components/shared/AppMap"; import { APP_CONSTANTS } from "~/config/constants"; import { useBackButton, usePageTitle } from "~/contexts/PageTitleContext"; import { type Itinerary } from "~/data/PlannerApi"; +import { useGeolocation } from "~/hooks/useGeolocation"; import { usePlanner } from "~/hooks/usePlanner"; import "../tailwind-full.css"; @@ -721,6 +722,7 @@ export default function PlannerPage() { setOrigin, setDestination, } = usePlanner(); + const { userLocation } = useGeolocation(); const [selectedItinerary, setSelectedItinerary] = useState<Itinerary | null>( null ); @@ -816,27 +818,16 @@ export default function PlannerPage() { onClick={() => { clearRoute(); setDestination(null); - if (navigator.geolocation) { - navigator.geolocation.getCurrentPosition( - async (pos) => { - const initial = { - name: t("planner.current_location"), - label: "GPS", - lat: pos.coords.latitude, - lon: pos.coords.longitude, - layer: "current-location", - } as any; - setOrigin(initial); - }, - () => { - // If geolocation fails, just keep origin empty - }, - { - enableHighAccuracy: true, - timeout: 10000, - maximumAge: 60 * 60 * 1000, // 1 hour in milliseconds - } - ); + if (userLocation) { + setOrigin({ + name: t("planner.current_location"), + label: "GPS", + lat: userLocation.latitude, + lon: userLocation.longitude, + layer: "current-location", + }); + } else { + setOrigin(null); } }} className="text-sm text-red-500" |
