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/contexts/MapContext.tsx | 151 +++++++++++++++++++ src/frontend/app/contexts/PageTitleContext.tsx | 46 ++++++ src/frontend/app/contexts/SettingsContext.tsx | 198 +++++++++++++++++++++++++ 3 files changed, 395 insertions(+) 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 (limited to 'src/frontend/app/contexts') diff --git a/src/frontend/app/contexts/MapContext.tsx b/src/frontend/app/contexts/MapContext.tsx new file mode 100644 index 0000000..b47b67f --- /dev/null +++ b/src/frontend/app/contexts/MapContext.tsx @@ -0,0 +1,151 @@ +import { type LngLatLike } from "maplibre-gl"; +import { + createContext, + useContext, + useEffect, + useState, + type ReactNode, +} from "react"; +import { getRegionConfig } from "../config/RegionConfig"; +import { useSettings } from "./SettingsContext"; + +interface MapState { + center: LngLatLike; + zoom: number; + userLocation: LngLatLike | null; + hasLocationPermission: boolean; +} + +interface MapContextProps { + 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; +} + +const MapContext = createContext(undefined); + +export const MapProvider = ({ children }: { children: ReactNode }) => { + const { region } = useSettings(); + const [prevRegion, setPrevRegion] = useState(region); + + const [mapState, setMapState] = useState(() => { + const savedMapState = localStorage.getItem("mapState"); + if (savedMapState) { + try { + const parsed = JSON.parse(savedMapState); + // Validate that the saved center is valid if needed, or just trust it. + // We might want to ensure we have a fallback if the region changed while the app was closed? + // But for now, let's stick to the existing logic. + const regionConfig = getRegionConfig(region); + return { + center: parsed.center || regionConfig.defaultCenter, + zoom: parsed.zoom || regionConfig.defaultZoom, + userLocation: parsed.userLocation || null, + hasLocationPermission: parsed.hasLocationPermission || false, + }; + } catch (e) { + console.error("Error parsing saved map state", e); + } + } + const regionConfig = getRegionConfig(region); + return { + center: regionConfig.defaultCenter, + zoom: regionConfig.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; + }); + }; + + const updateMapState = (center: LngLatLike, zoom: number) => { + setMapState((prev) => { + const newState = { ...prev, center, zoom }; + localStorage.setItem("mapState", JSON.stringify(newState)); + return newState; + }); + }; + + // Sync map state when region changes + useEffect(() => { + if (region !== prevRegion) { + const regionConfig = getRegionConfig(region); + updateMapState(regionConfig.defaultCenter, regionConfig.defaultZoom); + setPrevRegion(region); + } + }, [region, prevRegion]); + + // Try to get user location on load if permission was granted + 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]); + + return ( + + {children} + + ); +}; + +export const useMap = () => { + const context = useContext(MapContext); + if (!context) { + throw new Error("useMap must be used within a MapProvider"); + } + return context; +}; diff --git a/src/frontend/app/contexts/PageTitleContext.tsx b/src/frontend/app/contexts/PageTitleContext.tsx new file mode 100644 index 0000000..396e409 --- /dev/null +++ b/src/frontend/app/contexts/PageTitleContext.tsx @@ -0,0 +1,46 @@ +import React, { createContext, useContext, useEffect, useState } from "react"; + +interface PageTitleContextProps { + title: string; + setTitle: (title: string) => void; +} + +const PageTitleContext = createContext( + undefined +); + +export const PageTitleProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const [title, setTitle] = useState("Busurbano"); + + return ( + + {children} + + ); +}; + +export const usePageTitleContext = () => { + const context = useContext(PageTitleContext); + if (!context) { + throw new Error("usePageTitleContext must be used within a PageTitleProvider"); + } + return context; +}; + +export const usePageTitle = (title: string) => { + const { setTitle } = usePageTitleContext(); + + useEffect(() => { + setTitle(title); + document.title = `${title} - Busurbano`; + + return () => { + // Optional: Reset title on unmount? + // Usually not needed as the next page will set its own title. + // But if we navigate to a page without usePageTitle, it might be stale. + // Let's leave it for now. + }; + }, [title, setTitle]); +}; diff --git a/src/frontend/app/contexts/SettingsContext.tsx b/src/frontend/app/contexts/SettingsContext.tsx new file mode 100644 index 0000000..ed20fcb --- /dev/null +++ b/src/frontend/app/contexts/SettingsContext.tsx @@ -0,0 +1,198 @@ +import { + createContext, + useContext, + useEffect, + useState, + type ReactNode, +} from "react"; +import { APP_CONFIG } from "../config/AppConfig"; +import { + DEFAULT_REGION, + isValidRegion, + type RegionId +} from "../config/RegionConfig"; + +export type Theme = "light" | "dark" | "system"; +export type TableStyle = "regular" | "grouped" | "experimental_consolidated"; +export type MapPositionMode = "gps" | "last"; + +interface SettingsContextProps { + theme: Theme; + setTheme: React.Dispatch>; + toggleTheme: () => void; + + tableStyle: TableStyle; + setTableStyle: React.Dispatch>; + toggleTableStyle: () => void; + + mapPositionMode: MapPositionMode; + setMapPositionMode: (mode: MapPositionMode) => void; + + region: RegionId; + setRegion: (region: RegionId) => void; +} + +const SettingsContext = createContext( + undefined +); + +export const SettingsProvider = ({ 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 APP_CONFIG.defaultTheme; + }); + + 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); + } + }; + }, []); + + const resolvedTheme = theme === "system" ? systemTheme : 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 APP_CONFIG.defaultTableStyle; + }); + + 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" || saved === "gps" + ? (saved as MapPositionMode) + : APP_CONFIG.defaultMapPositionMode; + } + ); + + 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); + }; + + useEffect(() => { + localStorage.setItem("region", region); + }, [region]); + //#endregion + + return ( + + {children} + + ); +}; + +export const useSettings = () => { + const context = useContext(SettingsContext); + if (!context) { + throw new Error("useSettings must be used within a SettingsProvider"); + } + return context; +}; -- cgit v1.3