aboutsummaryrefslogtreecommitdiff
path: root/src/frontend/app/routes
diff options
context:
space:
mode:
authorcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>2026-03-13 11:12:36 +0000
committercopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>2026-03-13 11:12:36 +0000
commit8b4252dc937d6c937bd718515f03dd48948a1519 (patch)
treee26f0455ebbf8ac0d28bcda749da26618b67cefe /src/frontend/app/routes
parent1953bb738bb845c47e63ebc0789308a3cd00ddc2 (diff)
feat: geolocation hook, map context menu, simplified home planner widget"
Co-authored-by: arielcostas <94913521+arielcostas@users.noreply.github.com>
Diffstat (limited to 'src/frontend/app/routes')
-rw-r--r--src/frontend/app/routes/home.tsx108
-rw-r--r--src/frontend/app/routes/map.tsx133
-rw-r--r--src/frontend/app/routes/planner.tsx33
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"