diff options
| author | Ariel Costas Guerrero <ariel@costas.dev> | 2025-11-19 15:04:55 +0100 |
|---|---|---|
| committer | Ariel Costas Guerrero <ariel@costas.dev> | 2025-11-19 15:05:34 +0100 |
| commit | d51169f6411b68a226d76d2d39826904de484929 (patch) | |
| tree | 4d8a403dfcc5b17671a92b8cc1e5d71d20ed9537 /src/frontend/app/contexts | |
| parent | d434204860fc0409ad6343e815d0057b97ce3573 (diff) | |
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.
Diffstat (limited to 'src/frontend/app/contexts')
| -rw-r--r-- | src/frontend/app/contexts/MapContext.tsx | 151 | ||||
| -rw-r--r-- | src/frontend/app/contexts/PageTitleContext.tsx | 46 | ||||
| -rw-r--r-- | src/frontend/app/contexts/SettingsContext.tsx | 198 |
3 files changed, 395 insertions, 0 deletions
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<MapContextProps | undefined>(undefined); + +export const MapProvider = ({ children }: { children: ReactNode }) => { + const { region } = useSettings(); + const [prevRegion, setPrevRegion] = useState(region); + + const [mapState, setMapState] = useState<MapState>(() => { + 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 ( + <MapContext.Provider + value={{ + mapState, + setMapCenter, + setMapZoom, + setUserLocation, + setLocationPermission, + updateMapState, + }} + > + {children} + </MapContext.Provider> + ); +}; + +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<PageTitleContextProps | undefined>( + undefined +); + +export const PageTitleProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const [title, setTitle] = useState("Busurbano"); + + return ( + <PageTitleContext.Provider value={{ title, setTitle }}> + {children} + </PageTitleContext.Provider> + ); +}; + +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<React.SetStateAction<Theme>>; + toggleTheme: () => void; + + tableStyle: TableStyle; + setTableStyle: React.Dispatch<React.SetStateAction<TableStyle>>; + toggleTableStyle: () => void; + + mapPositionMode: MapPositionMode; + setMapPositionMode: (mode: MapPositionMode) => void; + + region: RegionId; + setRegion: (region: RegionId) => void; +} + +const SettingsContext = createContext<SettingsContextProps | undefined>( + 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<Theme>(() => { + 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<TableStyle>(() => { + 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<MapPositionMode>( + () => { + 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<RegionId>(() => { + 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 ( + <SettingsContext.Provider + value={{ + theme, + setTheme, + toggleTheme, + tableStyle, + setTableStyle, + toggleTableStyle, + mapPositionMode, + setMapPositionMode, + region, + setRegion, + }} + > + {children} + </SettingsContext.Provider> + ); +}; + +export const useSettings = () => { + const context = useContext(SettingsContext); + if (!context) { + throw new Error("useSettings must be used within a SettingsProvider"); + } + return context; +}; |
