aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/frontend/app/components/layout/AppShell.tsx5
-rw-r--r--src/frontend/app/components/layout/Header.css4
-rw-r--r--src/frontend/app/components/layout/Header.tsx28
-rw-r--r--src/frontend/app/contexts/PageTitleContext.tsx67
-rw-r--r--src/frontend/app/routes/planner.tsx13
-rw-r--r--src/frontend/app/routes/routes-$id.tsx110
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")}