From d51169f6411b68a226d76d2d39826904de484929 Mon Sep 17 00:00:00 2001 From: Ariel Costas Guerrero Date: Wed, 19 Nov 2025 15:04:55 +0100 Subject: feat: Add About and Favourites pages, update routing and context management - Added new routes for About and Favourites pages. - Implemented About page with version information and credits. - Created Favourites page with a placeholder message for empty favourites. - Refactored RegionConfig import paths for consistency. - Introduced PageTitleContext to manage page titles dynamically. - Updated various components to utilize the new context for setting page titles. - Enhanced AppShell layout with a responsive Drawer for navigation. - Added CSS styles for new components and pages. - Integrated commit hash display in the About page for version tracking. --- src/frontend/app/AppContext.tsx | 318 ++------------------- src/frontend/app/components/GroupedTable.tsx | 2 +- src/frontend/app/components/LineIcon.tsx | 2 +- src/frontend/app/components/RegionSelector.tsx | 2 +- src/frontend/app/components/RegularTable.tsx | 2 +- src/frontend/app/components/StopMapSheet.tsx | 8 +- src/frontend/app/components/StopSheet.tsx | 2 +- .../Stops/ConsolidatedCirculationCard.tsx | 2 +- .../Stops/ConsolidatedCirculationList.css | 2 +- .../Stops/ConsolidatedCirculationList.tsx | 2 +- src/frontend/app/components/layout/AppShell.css | 63 ++++ src/frontend/app/components/layout/AppShell.tsx | 42 +++ src/frontend/app/components/layout/Drawer.css | 93 ++++++ src/frontend/app/components/layout/Drawer.tsx | 52 ++++ src/frontend/app/components/layout/Header.css | 41 +++ src/frontend/app/components/layout/Header.tsx | 32 +++ src/frontend/app/components/ui/Button.css | 39 +++ src/frontend/app/components/ui/Button.tsx | 25 ++ src/frontend/app/components/ui/PageContainer.css | 20 ++ src/frontend/app/components/ui/PageContainer.tsx | 14 + src/frontend/app/config/AppConfig.ts | 5 + src/frontend/app/config/RegionConfig.ts | 51 ++++ src/frontend/app/contexts/MapContext.tsx | 151 ++++++++++ src/frontend/app/contexts/PageTitleContext.tsx | 46 +++ src/frontend/app/contexts/SettingsContext.tsx | 198 +++++++++++++ src/frontend/app/data/LineColors.ts | 2 +- src/frontend/app/data/RegionConfig.ts | 51 ---- src/frontend/app/data/StopDataProvider.ts | 2 +- src/frontend/app/i18n/locales/en-GB.json | 3 +- src/frontend/app/i18n/locales/es-ES.json | 3 +- src/frontend/app/i18n/locales/gl-ES.json | 3 +- src/frontend/app/root.tsx | 25 +- src/frontend/app/routes.tsx | 2 + src/frontend/app/routes/about.css | 7 + src/frontend/app/routes/about.tsx | 61 ++++ src/frontend/app/routes/estimates-$id.tsx | 2 +- src/frontend/app/routes/favourites.tsx | 13 + src/frontend/app/routes/home.tsx | 18 +- src/frontend/app/routes/map.tsx | 26 +- src/frontend/app/routes/settings.tsx | 51 +--- src/frontend/app/routes/stops-$id.css | 29 -- src/frontend/app/routes/stops-$id.tsx | 30 +- src/frontend/app/routes/timetable-$id.tsx | 26 +- src/frontend/app/vite-env.d.ts | 2 + src/frontend/vite.config.ts | 8 +- 45 files changed, 1070 insertions(+), 508 deletions(-) create mode 100644 src/frontend/app/components/layout/AppShell.css create mode 100644 src/frontend/app/components/layout/AppShell.tsx create mode 100644 src/frontend/app/components/layout/Drawer.css create mode 100644 src/frontend/app/components/layout/Drawer.tsx create mode 100644 src/frontend/app/components/layout/Header.css create mode 100644 src/frontend/app/components/layout/Header.tsx create mode 100644 src/frontend/app/components/ui/Button.css create mode 100644 src/frontend/app/components/ui/Button.tsx create mode 100644 src/frontend/app/components/ui/PageContainer.css create mode 100644 src/frontend/app/components/ui/PageContainer.tsx create mode 100644 src/frontend/app/config/AppConfig.ts create mode 100644 src/frontend/app/config/RegionConfig.ts create mode 100644 src/frontend/app/contexts/MapContext.tsx create mode 100644 src/frontend/app/contexts/PageTitleContext.tsx create mode 100644 src/frontend/app/contexts/SettingsContext.tsx delete mode 100644 src/frontend/app/data/RegionConfig.ts create mode 100644 src/frontend/app/routes/about.css create mode 100644 src/frontend/app/routes/about.tsx create mode 100644 src/frontend/app/routes/favourites.tsx diff --git a/src/frontend/app/AppContext.tsx b/src/frontend/app/AppContext.tsx index 9c2521f..59f2724 100644 --- a/src/frontend/app/AppContext.tsx +++ b/src/frontend/app/AppContext.tsx @@ -1,304 +1,34 @@ /* eslint-disable react-refresh/only-export-components */ +import { type ReactNode } from "react"; +import { type RegionId } from "./config/RegionConfig"; +import { MapProvider, useMap } from "./contexts/MapContext"; import { - createContext, - useContext, - useEffect, - useState, - type ReactNode, -} from "react"; -import { type LngLatLike } from "maplibre-gl"; -import { - type RegionId, - DEFAULT_REGION, - getRegionConfig, - isValidRegion, - REGIONS, -} from "./data/RegionConfig"; - -export type Theme = "light" | "dark" | "system"; -type TableStyle = "regular" | "grouped" | "experimental_consolidated"; -type MapPositionMode = "gps" | "last"; - -interface MapState { - center: LngLatLike; - zoom: number; - userLocation: LngLatLike | null; - hasLocationPermission: boolean; -} - -interface AppContextProps { - theme: Theme; - setTheme: React.Dispatch>; - toggleTheme: () => void; - - tableStyle: TableStyle; - setTableStyle: React.Dispatch>; - toggleTableStyle: () => void; - - mapState: MapState; - setMapCenter: (center: LngLatLike) => void; - setMapZoom: (zoom: number) => void; - setUserLocation: (location: LngLatLike | null) => void; - setLocationPermission: (hasPermission: boolean) => void; - updateMapState: (center: LngLatLike, zoom: number) => void; - - mapPositionMode: MapPositionMode; - setMapPositionMode: (mode: MapPositionMode) => void; - - region: RegionId; - setRegion: (region: RegionId) => void; -} - -const AppContext = createContext(undefined); - -export const AppProvider = ({ children }: { children: ReactNode }) => { - //#region Theme - const getPreferredScheme = () => { - if (typeof window === "undefined" || !window.matchMedia) { - return "light" as const; - } - return window.matchMedia("(prefers-color-scheme: dark)").matches - ? "dark" - : "light"; - }; - - const [systemTheme, setSystemTheme] = useState<"light" | "dark">( - getPreferredScheme - ); - - const [theme, setTheme] = useState(() => { - const savedTheme = localStorage.getItem("theme"); - if ( - savedTheme === "light" || - savedTheme === "dark" || - savedTheme === "system" - ) { - return savedTheme; - } - return "system"; - }); - - useEffect(() => { - if (typeof window === "undefined" || !window.matchMedia) { - return; - } - - const media = window.matchMedia("(prefers-color-scheme: dark)"); - const handleChange = (event: MediaQueryListEvent) => { - setSystemTheme(event.matches ? "dark" : "light"); - }; - - // Sync immediately in case theme changed before subscription - setSystemTheme(media.matches ? "dark" : "light"); - - if (media.addEventListener) { - media.addEventListener("change", handleChange); - } else { - media.addListener(handleChange); - } - - return () => { - if (media.removeEventListener) { - media.removeEventListener("change", handleChange); - } else { - media.removeListener(handleChange); - } - }; - }, []); + SettingsProvider, + useSettings, + type MapPositionMode, + type TableStyle, + type Theme, +} from "./contexts/SettingsContext"; - const resolvedTheme = theme === "system" ? systemTheme : theme; +// Re-export types for compatibility +export type { MapPositionMode, RegionId, TableStyle, Theme }; - const toggleTheme = () => { - setTheme((prevTheme) => { - if (prevTheme === "light") { - return "dark"; - } - if (prevTheme === "dark") { - return "system"; - } - return "light"; - }); - }; - - useEffect(() => { - document.documentElement.setAttribute("data-theme", resolvedTheme); - document.documentElement.style.colorScheme = resolvedTheme; - }, [resolvedTheme]); - - useEffect(() => { - localStorage.setItem("theme", theme); - }, [theme]); - //#endregion - - //#region Table Style - const [tableStyle, setTableStyle] = useState(() => { - const savedTableStyle = localStorage.getItem("tableStyle"); - if (savedTableStyle) { - return savedTableStyle as TableStyle; - } - return "regular"; - }); - - const toggleTableStyle = () => { - setTableStyle((prevTableStyle) => - prevTableStyle === "regular" ? "grouped" : "regular" - ); - }; - - useEffect(() => { - localStorage.setItem("tableStyle", tableStyle); - }, [tableStyle]); - //#endregion - - //#region Map Position Mode - const [mapPositionMode, setMapPositionMode] = useState( - () => { - const saved = localStorage.getItem("mapPositionMode"); - return saved === "last" ? "last" : "gps"; - } - ); - - useEffect(() => { - localStorage.setItem("mapPositionMode", mapPositionMode); - }, [mapPositionMode]); - //#endregion - - //#region Region - const [region, setRegionState] = useState(() => { - const savedRegion = localStorage.getItem("region"); - if (savedRegion && isValidRegion(savedRegion)) { - return savedRegion; - } - return DEFAULT_REGION; - }); - - const setRegion = (newRegion: RegionId) => { - setRegionState(newRegion); - localStorage.setItem("region", newRegion); - - // Update map to region's default center and zoom - const regionConfig = getRegionConfig(newRegion); - updateMapState(regionConfig.defaultCenter, regionConfig.defaultZoom); - }; - - useEffect(() => { - localStorage.setItem("region", region); - }, [region]); - //#endregion - - //#region Map State - const [mapState, setMapState] = useState(() => { - const savedMapState = localStorage.getItem("mapState"); - if (savedMapState) { - try { - const parsed = JSON.parse(savedMapState); - return { - center: parsed.center || REGIONS[region].defaultCenter, - zoom: parsed.zoom || REGIONS[region].defaultZoom, - userLocation: parsed.userLocation || null, - hasLocationPermission: parsed.hasLocationPermission || false, - }; - } catch (e) { - console.error("Error parsing saved map state", e); - } - } - return { - center: REGIONS[region].defaultCenter, - zoom: REGIONS[region].defaultZoom, - userLocation: null, - hasLocationPermission: false, - }; - }); - - const setMapCenter = (center: LngLatLike) => { - setMapState((prev) => { - const newState = { ...prev, center }; - localStorage.setItem("mapState", JSON.stringify(newState)); - return newState; - }); - }; - - const setMapZoom = (zoom: number) => { - setMapState((prev) => { - const newState = { ...prev, zoom }; - localStorage.setItem("mapState", JSON.stringify(newState)); - return newState; - }); - }; - - const setUserLocation = (userLocation: LngLatLike | null) => { - setMapState((prev) => { - const newState = { ...prev, userLocation }; - localStorage.setItem("mapState", JSON.stringify(newState)); - return newState; - }); - }; - - const setLocationPermission = (hasLocationPermission: boolean) => { - setMapState((prev) => { - const newState = { ...prev, hasLocationPermission }; - localStorage.setItem("mapState", JSON.stringify(newState)); - return newState; - }); - }; +// Combined hook for backward compatibility +export const useApp = () => { + const settings = useSettings(); + const map = useMap(); - const updateMapState = (center: LngLatLike, zoom: number) => { - setMapState((prev) => { - const newState = { ...prev, center, zoom }; - localStorage.setItem("mapState", JSON.stringify(newState)); - return newState; - }); + return { + ...settings, + ...map, }; - //#endregion - - // Tratar de obtener la ubicación del usuario cuando se carga la aplicación si ya se había concedido permiso antes - useEffect(() => { - if (mapState.hasLocationPermission && !mapState.userLocation) { - if (navigator.geolocation) { - navigator.geolocation.getCurrentPosition( - (position) => { - const { latitude, longitude } = position.coords; - setUserLocation([latitude, longitude]); - }, - (error) => { - console.error("Error getting location:", error); - setLocationPermission(false); - } - ); - } - } - }, [mapState.hasLocationPermission, mapState.userLocation]); +}; +// Wrapper provider +export const AppProvider = ({ children }: { children: ReactNode }) => { return ( - - {children} - + + {children} + ); }; - -export const useApp = () => { - const context = useContext(AppContext); - if (!context) { - throw new Error("useApp must be used within a AppProvider"); - } - return context; -}; diff --git a/src/frontend/app/components/GroupedTable.tsx b/src/frontend/app/components/GroupedTable.tsx index 3a799a7..175899a 100644 --- a/src/frontend/app/components/GroupedTable.tsx +++ b/src/frontend/app/components/GroupedTable.tsx @@ -1,6 +1,6 @@ +import { type RegionConfig } from "../config/RegionConfig"; import { type Estimate } from "../routes/estimates-$id"; import LineIcon from "./LineIcon"; -import { type RegionConfig } from "../data/RegionConfig"; interface GroupedTable { data: Estimate[]; diff --git a/src/frontend/app/components/LineIcon.tsx b/src/frontend/app/components/LineIcon.tsx index 8e9a4bd..3ad9293 100644 --- a/src/frontend/app/components/LineIcon.tsx +++ b/src/frontend/app/components/LineIcon.tsx @@ -1,6 +1,6 @@ import React, { useMemo } from "react"; +import { type RegionId } from "../config/RegionConfig"; import "./LineIcon.css"; -import { type RegionId } from "../data/RegionConfig"; interface LineIconProps { line: string; diff --git a/src/frontend/app/components/RegionSelector.tsx b/src/frontend/app/components/RegionSelector.tsx index 6c9fe8b..124b574 100644 --- a/src/frontend/app/components/RegionSelector.tsx +++ b/src/frontend/app/components/RegionSelector.tsx @@ -1,5 +1,5 @@ import { useApp } from "../AppContext"; -import { getAvailableRegions } from "../data/RegionConfig"; +import { getAvailableRegions } from "../config/RegionConfig"; import "./RegionSelector.css"; export function RegionSelector() { diff --git a/src/frontend/app/components/RegularTable.tsx b/src/frontend/app/components/RegularTable.tsx index 868332f..a738d03 100644 --- a/src/frontend/app/components/RegularTable.tsx +++ b/src/frontend/app/components/RegularTable.tsx @@ -1,7 +1,7 @@ import { useTranslation } from "react-i18next"; +import { type RegionConfig } from "../config/RegionConfig"; import { type Estimate } from "../routes/estimates-$id"; import LineIcon from "./LineIcon"; -import { type RegionConfig } from "../data/RegionConfig"; interface RegularTableProps { data: Estimate[]; diff --git a/src/frontend/app/components/StopMapSheet.tsx b/src/frontend/app/components/StopMapSheet.tsx index 8bdcfa9..e1f0bf7 100644 --- a/src/frontend/app/components/StopMapSheet.tsx +++ b/src/frontend/app/components/StopMapSheet.tsx @@ -1,13 +1,13 @@ import maplibregl from "maplibre-gl"; import React, { useEffect, useMemo, useRef, useState } from "react"; import Map, { - AttributionControl, - Marker, - type MapRef, + AttributionControl, + Marker, + type MapRef, } from "react-map-gl/maplibre"; import { useApp } from "~/AppContext"; +import type { RegionId } from "~/config/RegionConfig"; import { getLineColor } from "~/data/LineColors"; -import type { RegionId } from "~/data/RegionConfig"; import type { Stop } from "~/data/StopDataProvider"; import { loadStyle } from "~/maps/styleloader"; import "./StopMapSheet.css"; diff --git a/src/frontend/app/components/StopSheet.tsx b/src/frontend/app/components/StopSheet.tsx index 2a28d36..8749fe8 100644 --- a/src/frontend/app/components/StopSheet.tsx +++ b/src/frontend/app/components/StopSheet.tsx @@ -5,7 +5,7 @@ import { Sheet } from "react-modal-sheet"; import { Link } from "react-router"; import type { Stop } from "~/data/StopDataProvider"; import { useApp } from "../AppContext"; -import { type RegionId, getRegionConfig } from "../data/RegionConfig"; +import { type RegionId, getRegionConfig } from "../config/RegionConfig"; import { type ConsolidatedCirculation } from "../routes/stops-$id"; import { ErrorDisplay } from "./ErrorDisplay"; import LineIcon from "./LineIcon"; diff --git a/src/frontend/app/components/Stops/ConsolidatedCirculationCard.tsx b/src/frontend/app/components/Stops/ConsolidatedCirculationCard.tsx index 8c3e922..9733d89 100644 --- a/src/frontend/app/components/Stops/ConsolidatedCirculationCard.tsx +++ b/src/frontend/app/components/Stops/ConsolidatedCirculationCard.tsx @@ -1,7 +1,7 @@ import { Clock } from "lucide-react"; import { useTranslation } from "react-i18next"; +import { type RegionConfig } from "~/config/RegionConfig"; import LineIcon from "~components/LineIcon"; -import { type RegionConfig } from "~data/RegionConfig"; import { type ConsolidatedCirculation } from "~routes/stops-$id"; import "./ConsolidatedCirculationList.css"; diff --git a/src/frontend/app/components/Stops/ConsolidatedCirculationList.css b/src/frontend/app/components/Stops/ConsolidatedCirculationList.css index 939f40d..b79fc73 100644 --- a/src/frontend/app/components/Stops/ConsolidatedCirculationList.css +++ b/src/frontend/app/components/Stops/ConsolidatedCirculationList.css @@ -128,7 +128,7 @@ @media (max-width: 480px) { .consolidated-circulation-card .card-header { gap: 0.5rem; - padding: 0.75rem 0.875rem; + padding: 0.5rem 0.65rem; } .consolidated-circulation-card .arrival-time { diff --git a/src/frontend/app/components/Stops/ConsolidatedCirculationList.tsx b/src/frontend/app/components/Stops/ConsolidatedCirculationList.tsx index d95ee03..047dfd4 100644 --- a/src/frontend/app/components/Stops/ConsolidatedCirculationList.tsx +++ b/src/frontend/app/components/Stops/ConsolidatedCirculationList.tsx @@ -1,5 +1,5 @@ import { useTranslation } from "react-i18next"; -import { type RegionConfig } from "~data/RegionConfig"; +import { type RegionConfig } from "~/config/RegionConfig"; import { type ConsolidatedCirculation } from "~routes/stops-$id"; import { ConsolidatedCirculationCard } from "./ConsolidatedCirculationCard"; diff --git a/src/frontend/app/components/layout/AppShell.css b/src/frontend/app/components/layout/AppShell.css new file mode 100644 index 0000000..89a4d1f --- /dev/null +++ b/src/frontend/app/components/layout/AppShell.css @@ -0,0 +1,63 @@ +.app-shell { + display: flex; + flex-direction: column; + height: 100dvh; + width: 100%; + overflow: hidden; + background-color: var(--background-color); +} + +.app-shell__header { + flex-shrink: 0; + z-index: 10; +} + +.app-shell__body { + display: flex; + flex: 1; + overflow: hidden; + position: relative; +} + +.app-shell__sidebar { + display: none; /* Hidden on mobile */ + width: 80px; + border-right: 1px solid var(--border-color); + background: var(--background-color); + flex-shrink: 0; + z-index: 5; +} + +.app-shell__main { + flex: 1; + overflow: auto; + overflow-x: hidden; + position: relative; +} + +.app-shell__bottom-nav { + flex-shrink: 0; + display: block; /* Visible on mobile */ + z-index: 10; +} + +/* Desktop styles */ +@media (min-width: 768px) { + .app-shell__sidebar { + display: block; + } + + .app-shell__bottom-nav { + display: none; + } + + /* Override NavBar styles for sidebar */ + .app-shell__sidebar .navigation-bar { + flex-direction: column; + height: 100%; + justify-content: flex-start; + padding-top: 1rem; + gap: 1rem; + border-top: none; + } +} diff --git a/src/frontend/app/components/layout/AppShell.tsx b/src/frontend/app/components/layout/AppShell.tsx new file mode 100644 index 0000000..d0c0121 --- /dev/null +++ b/src/frontend/app/components/layout/AppShell.tsx @@ -0,0 +1,42 @@ +import React, { useState } from "react"; +import { Outlet } from "react-router"; +import { PageTitleProvider, usePageTitleContext } from "~/contexts/PageTitleContext"; +import NavBar from "../NavBar"; +import "./AppShell.css"; +import { Drawer } from "./Drawer"; +import { Header } from "./Header"; + +const AppShellContent: React.FC = () => { + const { title } = usePageTitleContext(); + const [isDrawerOpen, setIsDrawerOpen] = useState(false); + + return ( +
+
setIsDrawerOpen(true)} + /> + setIsDrawerOpen(false)} /> +
+ +
+ +
+
+
+ +
+
+ ); +}; + +export const AppShell: React.FC = () => { + return ( + + + + ); +}; diff --git a/src/frontend/app/components/layout/Drawer.css b/src/frontend/app/components/layout/Drawer.css new file mode 100644 index 0000000..27ccce6 --- /dev/null +++ b/src/frontend/app/components/layout/Drawer.css @@ -0,0 +1,93 @@ +.drawer-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); + z-index: 99; + opacity: 0; + visibility: hidden; + transition: opacity 0.3s ease, visibility 0.3s ease; +} + +.drawer-overlay.open { + opacity: 1; + visibility: visible; +} + +.drawer { + position: fixed; + top: 0; + right: 0; + width: 280px; + height: 100%; + background-color: var(--background-color); + z-index: 100; + transform: translateX(100%); + transition: transform 0.3s ease; + box-shadow: -2px 0 8px rgba(0, 0, 0, 0.1); + display: flex; + flex-direction: column; +} + +.drawer.open { + transform: translateX(0); +} + +.drawer__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.5rem 1rem; + border-bottom: 1px solid var(--border-color); + height: 60px; + box-sizing: border-box; +} + +.drawer__title { + margin: 0; + font-size: 1.25rem; + font-weight: 600; +} + +.drawer__close-btn { + background: none; + border: none; + cursor: pointer; + color: var(--text-color); + padding: 0.5rem; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; +} + +.drawer__close-btn:hover { + background-color: rgba(0, 0, 0, 0.05); +} + +.drawer__nav { + padding: 1rem 0; + display: flex; + flex-direction: column; +} + +.drawer__link { + display: flex; + align-items: center; + gap: 1rem; + padding: 0.75rem 1.5rem; + text-decoration: none; + color: var(--text-color); + font-size: 1rem; + transition: background-color 0.2s; +} + +.drawer__link:hover { + background-color: rgba(0, 0, 0, 0.05); +} + +.drawer__link svg { + color: var(--subtitle-color); +} diff --git a/src/frontend/app/components/layout/Drawer.tsx b/src/frontend/app/components/layout/Drawer.tsx new file mode 100644 index 0000000..55aa3a0 --- /dev/null +++ b/src/frontend/app/components/layout/Drawer.tsx @@ -0,0 +1,52 @@ +import { Info, Settings, Star, X } from "lucide-react"; +import React, { useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { Link, useLocation } from "react-router"; +import "./Drawer.css"; + +interface DrawerProps { + isOpen: boolean; + onClose: () => void; +} + +export const Drawer: React.FC = ({ isOpen, onClose }) => { + const { t } = useTranslation(); + const location = useLocation(); + + // Close drawer when route changes + useEffect(() => { + onClose(); + }, [location.pathname]); + + return ( + <> +