aboutsummaryrefslogtreecommitdiff
path: root/src/frontend/app/contexts
diff options
context:
space:
mode:
authorAriel Costas Guerrero <ariel@costas.dev>2025-11-19 15:04:55 +0100
committerAriel Costas Guerrero <ariel@costas.dev>2025-11-19 15:05:34 +0100
commitd51169f6411b68a226d76d2d39826904de484929 (patch)
tree4d8a403dfcc5b17671a92b8cc1e5d71d20ed9537 /src/frontend/app/contexts
parentd434204860fc0409ad6343e815d0057b97ce3573 (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.tsx151
-rw-r--r--src/frontend/app/contexts/PageTitleContext.tsx46
-rw-r--r--src/frontend/app/contexts/SettingsContext.tsx198
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;
+};