diff options
| author | Ariel Costas Guerrero <ariel@costas.dev> | 2026-01-30 19:29:55 +0100 |
|---|---|---|
| committer | Ariel Costas Guerrero <ariel@costas.dev> | 2026-01-30 19:30:14 +0100 |
| commit | 7348781b89178589036620b33f3554b2e7271c5f (patch) | |
| tree | b465b8b1b1e10baeefbde840b4d7fd64a1aeb4a2 /src/frontend | |
| parent | 16217f0530716892abe65062e6db4092caf4a8e9 (diff) | |
feat: Enhance header and app shell with back navigation and dynamic title support
Diffstat (limited to 'src/frontend')
| -rw-r--r-- | src/frontend/app/components/layout/AppShell.tsx | 5 | ||||
| -rw-r--r-- | src/frontend/app/components/layout/Header.css | 4 | ||||
| -rw-r--r-- | src/frontend/app/components/layout/Header.tsx | 28 | ||||
| -rw-r--r-- | src/frontend/app/contexts/PageTitleContext.tsx | 67 | ||||
| -rw-r--r-- | src/frontend/app/routes/planner.tsx | 13 | ||||
| -rw-r--r-- | src/frontend/app/routes/routes-$id.tsx | 110 |
6 files changed, 171 insertions, 56 deletions
diff --git a/src/frontend/app/components/layout/AppShell.tsx b/src/frontend/app/components/layout/AppShell.tsx index afc19f3..50f5742 100644 --- a/src/frontend/app/components/layout/AppShell.tsx +++ b/src/frontend/app/components/layout/AppShell.tsx @@ -1,5 +1,5 @@ import React, { useState } from "react"; -import { Outlet } from "react-router"; +import { Outlet, useLocation } from "react-router"; import { PageTitleProvider, usePageTitleContext, @@ -13,6 +13,7 @@ import NavBar from "./NavBar"; const AppShellContent: React.FC = () => { const { title } = usePageTitleContext(); const [isDrawerOpen, setIsDrawerOpen] = useState(false); + const location = useLocation(); return ( <div className="app-shell"> @@ -25,7 +26,7 @@ const AppShellContent: React.FC = () => { <Drawer isOpen={isDrawerOpen} onClose={() => setIsDrawerOpen(false)} /> <div className="app-shell__body"> <main className="app-shell__main"> - <Outlet /> + <Outlet key={location.pathname} /> </main> </div> <footer className="app-shell__bottom-nav"> diff --git a/src/frontend/app/components/layout/Header.css b/src/frontend/app/components/layout/Header.css index 0bba747..2184786 100644 --- a/src/frontend/app/components/layout/Header.css +++ b/src/frontend/app/components/layout/Header.css @@ -2,17 +2,15 @@ display: flex; align-items: center; justify-content: space-between; - padding: 0.5rem 1rem; + padding: 0.5rem; background-color: var(--background-color); height: 60px; box-sizing: border-box; - width: 100%; } .app-header__left { display: flex; align-items: center; - gap: 1rem; } .app-header__menu-btn { diff --git a/src/frontend/app/components/layout/Header.tsx b/src/frontend/app/components/layout/Header.tsx index 8235636..4378e59 100644 --- a/src/frontend/app/components/layout/Header.tsx +++ b/src/frontend/app/components/layout/Header.tsx @@ -1,4 +1,6 @@ -import { Menu } from "lucide-react"; +import { ArrowLeft, Menu } from "lucide-react"; +import { Link } from "react-router"; +import { usePageTitleContext } from "~/contexts/PageTitleContext"; import "./Header.css"; interface HeaderProps { @@ -12,10 +14,32 @@ export const Header: React.FC<HeaderProps> = ({ onMenuClick, className = "", }) => { + const { onBack, backTo, titleNode } = usePageTitleContext(); + return ( <header className={`app-header ${className}`}> <div className="app-header__left"> - <h1 className="app-header__title">{title}</h1> + {backTo && ( + <Link + className="app-header__menu-btn" + to={backTo} + aria-label="Atrás" + style={{ marginRight: "8px" }} + > + <ArrowLeft size={24} /> + </Link> + )} + {!backTo && onBack && ( + <button + className="app-header__menu-btn" + onClick={onBack} + aria-label="Atrás" + style={{ marginRight: "8px" }} + > + <ArrowLeft size={24} /> + </button> + )} + {titleNode ? titleNode : <h1 className="app-header__title">{title}</h1>} </div> <div className="app-header__right"> <button diff --git a/src/frontend/app/contexts/PageTitleContext.tsx b/src/frontend/app/contexts/PageTitleContext.tsx index a6bf348..8610518 100644 --- a/src/frontend/app/contexts/PageTitleContext.tsx +++ b/src/frontend/app/contexts/PageTitleContext.tsx @@ -3,6 +3,12 @@ import React, { createContext, useContext, useEffect, useState } from "react"; interface PageTitleContextProps { title: string; setTitle: (title: string) => void; + titleNode?: React.ReactNode; + onBack?: () => void; + backTo?: string; + setOnBack: (callback?: () => void) => void; + setBackTo: (to?: string) => void; + setTitleNode: (node?: React.ReactNode) => void; } const PageTitleContext = createContext<PageTitleContextProps | undefined>( @@ -13,9 +19,39 @@ export const PageTitleProvider: React.FC<{ children: React.ReactNode }> = ({ children, }) => { const [title, setTitle] = useState("EnMarcha"); + const [titleNode, setTitleNodeState] = useState<React.ReactNode | undefined>( + undefined + ); + const [onBack, setOnBackState] = useState<(() => void) | undefined>( + undefined + ); + const [backTo, setBackToState] = useState<string | undefined>(undefined); + + const setOnBack = (callback?: () => void) => { + setOnBackState(() => callback); + }; + + const setBackTo = (to?: string) => { + setBackToState(to); + }; + + const setTitleNode = (node?: React.ReactNode) => { + setTitleNodeState(node); + }; return ( - <PageTitleContext.Provider value={{ title, setTitle }}> + <PageTitleContext.Provider + value={{ + title, + setTitle, + titleNode, + onBack, + backTo, + setOnBack, + setBackTo, + setTitleNode, + }} + > {children} </PageTitleContext.Provider> ); @@ -41,3 +77,32 @@ export const usePageTitle = (title: string) => { return () => {}; }, [title, setTitle]); }; + +export const useBackButton = (options?: { + onBack?: () => void; + to?: string; +}) => { + const { setOnBack, setBackTo } = usePageTitleContext(); + + useEffect(() => { + setOnBack(options?.onBack); + setBackTo(options?.to); + + return () => { + setOnBack(undefined); + setBackTo(undefined); + }; + }, [options?.onBack, options?.to, setOnBack, setBackTo]); +}; + +export const usePageTitleNode = (node?: React.ReactNode) => { + const { setTitleNode } = usePageTitleContext(); + + useEffect(() => { + setTitleNode(node); + + return () => { + setTitleNode(undefined); + }; + }, [node, setTitleNode]); +}; diff --git a/src/frontend/app/routes/planner.tsx b/src/frontend/app/routes/planner.tsx index 32c37c0..1f64590 100644 --- a/src/frontend/app/routes/planner.tsx +++ b/src/frontend/app/routes/planner.tsx @@ -11,7 +11,7 @@ import LineIcon from "~/components/LineIcon"; import { PlannerOverlay } from "~/components/PlannerOverlay"; import { AppMap } from "~/components/shared/AppMap"; import { APP_CONSTANTS } from "~/config/constants"; -import { usePageTitle } from "~/contexts/PageTitleContext"; +import { useBackButton, usePageTitle } from "~/contexts/PageTitleContext"; import { type Itinerary } from "~/data/PlannerApi"; import { usePlanner } from "~/hooks/usePlanner"; import "../tailwind-full.css"; @@ -185,6 +185,7 @@ const ItineraryDetail = ({ onClose: () => void; }) => { const { t } = useTranslation(); + useBackButton({ onBack: onClose }); const mapRef = useRef<MapRef>(null); const { destination: userDestination } = usePlanner(); const [nextArrivals, setNextArrivals] = useState< @@ -319,7 +320,8 @@ const ItineraryDetail = ({ ); if (resp.ok) { - arrivalsByStop[stopKey] = await resp.json() satisfies ConsolidatedCirculation[]; + arrivalsByStop[stopKey] = + (await resp.json()) satisfies ConsolidatedCirculation[]; } } catch (err) { console.warn( @@ -463,13 +465,6 @@ const ItineraryDetail = ({ /> </Source> </AppMap> - - <button - onClick={onClose} - className="absolute top-4 left-4 bg-white dark:bg-slate-800 p-2 px-4 rounded-lg shadow-lg z-10 font-semibold text-sm hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors" - > - {t("planner.back")} - </button> </div> {/* Details Panel */} diff --git a/src/frontend/app/routes/routes-$id.tsx b/src/frontend/app/routes/routes-$id.tsx index 3b61ba6..62de642 100644 --- a/src/frontend/app/routes/routes-$id.tsx +++ b/src/frontend/app/routes/routes-$id.tsx @@ -1,5 +1,5 @@ import { useQuery } from "@tanstack/react-query"; -import { useRef, useState } from "react"; +import { useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { AttributionControl, @@ -10,7 +10,11 @@ import { import { useParams } from "react-router"; import { fetchRouteDetails } from "~/api/transit"; import { AppMap } from "~/components/shared/AppMap"; -import { usePageTitle } from "~/contexts/PageTitleContext"; +import { + useBackButton, + usePageTitle, + usePageTitleNode, +} from "~/contexts/PageTitleContext"; import "../tailwind-full.css"; export default function RouteDetailsPage() { @@ -35,6 +39,38 @@ export default function RouteDetailsPage() { : t("routes.details", "Detalles de ruta") ); + const titleNode = useMemo(() => { + if (!route) { + return ( + <span className="text-base font-semibold text-text"> + {t("routes.details", "Detalles de ruta")} + </span> + ); + } + + return ( + <div className="flex flex-col min-w-0"> + <div className="flex items-center gap-2"> + <span + className="text-lg font-bold leading-none" + style={{ + color: route.color ? `#${route.color}` : "var(--text-color)", + }} + > + {route.shortName || route.longName} + </span> + <span className="text-sm text-text/90 truncate text-wrap tracking-tight leading-none"> + {route.longName} + </span> + </div> + </div> + ); + }, [route, t]); + + usePageTitleNode(titleNode); + + useBackButton({ to: "/routes" }); + if (isLoading) { return ( <div className="flex justify-center py-12"> @@ -122,38 +158,6 @@ export default function RouteDetailsPage() { return ( <div className="flex flex-col h-full overflow-hidden"> - <div className="p-4 bg-surface border-b border-border"> - <select - className="w-full p-2 rounded-lg border border-border bg-background text-text focus:ring-2 focus:ring-primary outline-none" - value={selectedPattern?.id} - onChange={(e) => { - setSelectedPatternId(e.target.value); - setSelectedStopId(null); - }} - > - {Object.entries(patternsByDirection).map(([dir, patterns]) => ( - <optgroup - key={dir} - label={ - dir === "0" - ? t("routes.direction_outbound", "Ida") - : t("routes.direction_inbound", "Vuelta") - } - > - {patterns.map((pattern) => ( - <option key={pattern.id} value={pattern.id}> - {pattern.code - ? `${parseInt(pattern.code.slice(-2)).toString()}: ` - : ""} - {pattern.headsign || pattern.name}{" "} - {t("routes.trip_count_short", { count: pattern.tripCount })} - </option> - ))} - </optgroup> - ))} - </select> - </div> - <div className="flex-1 flex flex-col overflow-hidden"> <div className="flex-1 flex flex-col relative overflow-hidden"> <div className="h-1/2 relative"> @@ -179,11 +183,7 @@ export default function RouteDetailsPage() { showTraffic={false} attributionControl={false} > - <AttributionControl - position="bottom-right" - compact={false} - customAttribution={route.agencyName || undefined} - /> + <AttributionControl position="bottom-left" compact={true} /> {selectedPattern?.geometry && ( <Source type="geojson" data={geojson}> <Layer @@ -214,6 +214,38 @@ export default function RouteDetailsPage() { </AppMap> </div> + <select + className="px-4 py-2 box-border bg-surface text-text focus:ring-2 focus:ring-primary outline-none" + value={selectedPattern?.id} + onChange={(e) => { + setSelectedPatternId(e.target.value); + setSelectedStopId(null); + }} + > + {Object.entries(patternsByDirection).map(([dir, patterns]) => ( + <optgroup + key={dir} + label={ + dir === "0" + ? t("routes.direction_outbound", "Ida") + : t("routes.direction_inbound", "Vuelta") + } + > + {patterns.map((pattern) => ( + <option key={pattern.id} value={pattern.id}> + {pattern.code + ? `${parseInt(pattern.code.slice(-2)).toString()}: ` + : ""} + {pattern.headsign || pattern.name}{" "} + {t("routes.trip_count_short", { + count: pattern.tripCount, + })} + </option> + ))} + </optgroup> + ))} + </select> + <div className="flex-1 overflow-y-auto p-4 bg-background"> <h3 className="text-lg font-bold mb-4"> {t("routes.stops", "Paradas")} |
