aboutsummaryrefslogtreecommitdiff
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
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.
-rw-r--r--src/frontend/app/AppContext.tsx318
-rw-r--r--src/frontend/app/components/GroupedTable.tsx2
-rw-r--r--src/frontend/app/components/LineIcon.tsx2
-rw-r--r--src/frontend/app/components/RegionSelector.tsx2
-rw-r--r--src/frontend/app/components/RegularTable.tsx2
-rw-r--r--src/frontend/app/components/StopMapSheet.tsx8
-rw-r--r--src/frontend/app/components/StopSheet.tsx2
-rw-r--r--src/frontend/app/components/Stops/ConsolidatedCirculationCard.tsx2
-rw-r--r--src/frontend/app/components/Stops/ConsolidatedCirculationList.css2
-rw-r--r--src/frontend/app/components/Stops/ConsolidatedCirculationList.tsx2
-rw-r--r--src/frontend/app/components/layout/AppShell.css63
-rw-r--r--src/frontend/app/components/layout/AppShell.tsx42
-rw-r--r--src/frontend/app/components/layout/Drawer.css93
-rw-r--r--src/frontend/app/components/layout/Drawer.tsx52
-rw-r--r--src/frontend/app/components/layout/Header.css41
-rw-r--r--src/frontend/app/components/layout/Header.tsx32
-rw-r--r--src/frontend/app/components/ui/Button.css39
-rw-r--r--src/frontend/app/components/ui/Button.tsx25
-rw-r--r--src/frontend/app/components/ui/PageContainer.css20
-rw-r--r--src/frontend/app/components/ui/PageContainer.tsx14
-rw-r--r--src/frontend/app/config/AppConfig.ts5
-rw-r--r--src/frontend/app/config/RegionConfig.ts (renamed from src/frontend/app/data/RegionConfig.ts)0
-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
-rw-r--r--src/frontend/app/data/LineColors.ts2
-rw-r--r--src/frontend/app/data/StopDataProvider.ts2
-rw-r--r--src/frontend/app/i18n/locales/en-GB.json3
-rw-r--r--src/frontend/app/i18n/locales/es-ES.json3
-rw-r--r--src/frontend/app/i18n/locales/gl-ES.json3
-rw-r--r--src/frontend/app/root.tsx25
-rw-r--r--src/frontend/app/routes.tsx2
-rw-r--r--src/frontend/app/routes/about.css7
-rw-r--r--src/frontend/app/routes/about.tsx61
-rw-r--r--src/frontend/app/routes/estimates-$id.tsx2
-rw-r--r--src/frontend/app/routes/favourites.tsx13
-rw-r--r--src/frontend/app/routes/home.tsx18
-rw-r--r--src/frontend/app/routes/map.tsx26
-rw-r--r--src/frontend/app/routes/settings.tsx51
-rw-r--r--src/frontend/app/routes/stops-$id.css29
-rw-r--r--src/frontend/app/routes/stops-$id.tsx30
-rw-r--r--src/frontend/app/routes/timetable-$id.tsx26
-rw-r--r--src/frontend/app/vite-env.d.ts2
-rw-r--r--src/frontend/vite.config.ts8
44 files changed, 1019 insertions, 457 deletions
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<React.SetStateAction<Theme>>;
- toggleTheme: () => void;
-
- tableStyle: TableStyle;
- setTableStyle: React.Dispatch<React.SetStateAction<TableStyle>>;
- 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<AppContextProps | undefined>(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<Theme>(() => {
- 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<TableStyle>(() => {
- 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<MapPositionMode>(
- () => {
- const saved = localStorage.getItem("mapPositionMode");
- return saved === "last" ? "last" : "gps";
- }
- );
-
- 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);
-
- // 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<MapState>(() => {
- 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 (
- <AppContext.Provider
- value={{
- theme,
- setTheme,
- toggleTheme,
- tableStyle,
- setTableStyle,
- toggleTableStyle,
- mapState,
- setMapCenter,
- setMapZoom,
- setUserLocation,
- setLocationPermission,
- updateMapState,
- mapPositionMode,
- setMapPositionMode,
- region,
- setRegion,
- }}
- >
- {children}
- </AppContext.Provider>
+ <SettingsProvider>
+ <MapProvider>{children}</MapProvider>
+ </SettingsProvider>
);
};
-
-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 (
+ <div className="app-shell">
+ <Header
+ className="app-shell__header"
+ title={title}
+ onMenuClick={() => setIsDrawerOpen(true)}
+ />
+ <Drawer isOpen={isDrawerOpen} onClose={() => setIsDrawerOpen(false)} />
+ <div className="app-shell__body">
+ <aside className="app-shell__sidebar">
+ <NavBar />
+ </aside>
+ <main className="app-shell__main">
+ <Outlet />
+ </main>
+ </div>
+ <footer className="app-shell__bottom-nav">
+ <NavBar />
+ </footer>
+ </div>
+ );
+};
+
+export const AppShell: React.FC = () => {
+ return (
+ <PageTitleProvider>
+ <AppShellContent />
+ </PageTitleProvider>
+ );
+};
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<DrawerProps> = ({ isOpen, onClose }) => {
+ const { t } = useTranslation();
+ const location = useLocation();
+
+ // Close drawer when route changes
+ useEffect(() => {
+ onClose();
+ }, [location.pathname]);
+
+ return (
+ <>
+ <div
+ className={`drawer-overlay ${isOpen ? "open" : ""}`}
+ onClick={onClose}
+ aria-hidden="true"
+ />
+ <div className={`drawer ${isOpen ? "open" : ""}`}>
+ <div className="drawer__header">
+ <h2 className="drawer__title">Menu</h2>
+ <button className="drawer__close-btn" onClick={onClose}>
+ <X size={24} />
+ </button>
+ </div>
+ <nav className="drawer__nav">
+ <Link to="/favourites" className="drawer__link">
+ <Star size={20} />
+ <span>{t("navbar.favourites", "Favoritos")}</span>
+ </Link>
+ <Link to="/settings" className="drawer__link">
+ <Settings size={20} />
+ <span>{t("navbar.settings", "Ajustes")}</span>
+ </Link>
+ <Link to="/about" className="drawer__link">
+ <Info size={20} />
+ <span>{t("about.title", "Acerca de")}</span>
+ </Link>
+ </nav>
+ </div>
+ </>
+ );
+};
diff --git a/src/frontend/app/components/layout/Header.css b/src/frontend/app/components/layout/Header.css
new file mode 100644
index 0000000..c95226f
--- /dev/null
+++ b/src/frontend/app/components/layout/Header.css
@@ -0,0 +1,41 @@
+.app-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0.5rem 1rem;
+ background-color: var(--background-color);
+ border-bottom: 1px solid var(--border-color);
+ height: 60px;
+ box-sizing: border-box;
+ width: 100%;
+}
+
+.app-header__left {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+}
+
+.app-header__menu-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%;
+ transition: background-color 0.2s;
+}
+
+.app-header__menu-btn:hover {
+ background-color: rgba(0, 0, 0, 0.05);
+}
+
+.app-header__title {
+ font-size: 1.25rem;
+ font-weight: 600;
+ margin: 0;
+ color: var(--text-color);
+}
diff --git a/src/frontend/app/components/layout/Header.tsx b/src/frontend/app/components/layout/Header.tsx
new file mode 100644
index 0000000..2bdd764
--- /dev/null
+++ b/src/frontend/app/components/layout/Header.tsx
@@ -0,0 +1,32 @@
+import { Menu } from "lucide-react";
+import React from "react";
+import "./Header.css";
+
+interface HeaderProps {
+ title?: string;
+ onMenuClick?: () => void;
+ className?: string;
+}
+
+export const Header: React.FC<HeaderProps> = ({
+ title = "Busurbano",
+ onMenuClick,
+ className = "",
+}) => {
+ return (
+ <header className={`app-header ${className}`}>
+ <div className="app-header__left">
+ <h1 className="app-header__title">{title}</h1>
+ </div>
+ <div className="app-header__right">
+ <button
+ className="app-header__menu-btn"
+ onClick={onMenuClick}
+ aria-label="Menu"
+ >
+ <Menu size={24} />
+ </button>
+ </div>
+ </header>
+ );
+};
diff --git a/src/frontend/app/components/ui/Button.css b/src/frontend/app/components/ui/Button.css
new file mode 100644
index 0000000..bf02a7c
--- /dev/null
+++ b/src/frontend/app/components/ui/Button.css
@@ -0,0 +1,39 @@
+.ui-button {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.75rem 1.5rem;
+ border-radius: 0.5rem;
+ font-size: 0.9rem;
+ font-weight: 500;
+ cursor: pointer;
+ transition: background-color 0.2s ease, transform 0.1s ease;
+ border: none;
+}
+
+.ui-button:active {
+ transform: translateY(1px);
+}
+
+.ui-button--primary {
+ background: var(--button-background-color);
+ color: white;
+}
+
+.ui-button--primary:hover {
+ background: var(--button-hover-background-color);
+}
+
+.ui-button--secondary {
+ background: var(--border-color);
+ color: var(--text-color);
+}
+
+.ui-button--secondary:hover {
+ background: #e0e0e0;
+}
+
+.ui-button__icon {
+ display: flex;
+ align-items: center;
+}
diff --git a/src/frontend/app/components/ui/Button.tsx b/src/frontend/app/components/ui/Button.tsx
new file mode 100644
index 0000000..18a15b2
--- /dev/null
+++ b/src/frontend/app/components/ui/Button.tsx
@@ -0,0 +1,25 @@
+import React from "react";
+import "./Button.css";
+
+interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
+ variant?: "primary" | "secondary" | "outline";
+ icon?: React.ReactNode;
+}
+
+export const Button: React.FC<ButtonProps> = ({
+ children,
+ variant = "primary",
+ icon,
+ className = "",
+ ...props
+}) => {
+ return (
+ <button
+ className={`ui-button ui-button--${variant} ${className}`}
+ {...props}
+ >
+ {icon && <span className="ui-button__icon">{icon}</span>}
+ {children}
+ </button>
+ );
+};
diff --git a/src/frontend/app/components/ui/PageContainer.css b/src/frontend/app/components/ui/PageContainer.css
new file mode 100644
index 0000000..8a86035
--- /dev/null
+++ b/src/frontend/app/components/ui/PageContainer.css
@@ -0,0 +1,20 @@
+.page-container {
+ max-width: 100%;
+ padding: 0 16px;
+ background-color: var(--background-color);
+ color: var(--text-color);
+}
+
+@media (min-width: 768px) {
+ .page-container {
+ width: 90%;
+ max-width: 768px;
+ margin: 0 auto;
+ }
+}
+
+@media (min-width: 1024px) {
+ .page-container {
+ max-width: 1024px;
+ }
+}
diff --git a/src/frontend/app/components/ui/PageContainer.tsx b/src/frontend/app/components/ui/PageContainer.tsx
new file mode 100644
index 0000000..4c9684a
--- /dev/null
+++ b/src/frontend/app/components/ui/PageContainer.tsx
@@ -0,0 +1,14 @@
+import React from "react";
+import "./PageContainer.css";
+
+interface PageContainerProps {
+ children: React.ReactNode;
+ className?: string;
+}
+
+export const PageContainer: React.FC<PageContainerProps> = ({
+ children,
+ className = "",
+}) => {
+ return <div className={`page-container ${className}`}>{children}</div>;
+};
diff --git a/src/frontend/app/config/AppConfig.ts b/src/frontend/app/config/AppConfig.ts
new file mode 100644
index 0000000..523343e
--- /dev/null
+++ b/src/frontend/app/config/AppConfig.ts
@@ -0,0 +1,5 @@
+export const APP_CONFIG = {
+ defaultTheme: "system" as const,
+ defaultTableStyle: "regular" as const,
+ defaultMapPositionMode: "gps" as const,
+};
diff --git a/src/frontend/app/data/RegionConfig.ts b/src/frontend/app/config/RegionConfig.ts
index 8acfbbf..8acfbbf 100644
--- a/src/frontend/app/data/RegionConfig.ts
+++ b/src/frontend/app/config/RegionConfig.ts
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;
+};
diff --git a/src/frontend/app/data/LineColors.ts b/src/frontend/app/data/LineColors.ts
index 85a7c54..fba150d 100644
--- a/src/frontend/app/data/LineColors.ts
+++ b/src/frontend/app/data/LineColors.ts
@@ -1,4 +1,4 @@
-import type { RegionId } from "./RegionConfig";
+import type { RegionId } from "../config/RegionConfig";
interface LineColorInfo {
background: string;
diff --git a/src/frontend/app/data/StopDataProvider.ts b/src/frontend/app/data/StopDataProvider.ts
index b4e877f..2f13e43 100644
--- a/src/frontend/app/data/StopDataProvider.ts
+++ b/src/frontend/app/data/StopDataProvider.ts
@@ -1,4 +1,4 @@
-import { type RegionId, getRegionConfig } from "./RegionConfig";
+import { type RegionId, getRegionConfig } from "../config/RegionConfig";
export interface CachedStopList {
timestamp: number;
diff --git a/src/frontend/app/i18n/locales/en-GB.json b/src/frontend/app/i18n/locales/en-GB.json
index 6fabd8a..25ab97f 100644
--- a/src/frontend/app/i18n/locales/en-GB.json
+++ b/src/frontend/app/i18n/locales/en-GB.json
@@ -76,8 +76,7 @@
"trip": "trip",
"last_updated": "Updated at",
"reload": "Reload",
- "unknown_service": "Unknown service. It may be a reinforcement or the service has a different name than planned.",
- "experimental_feature": "Experimental feature"
+ "unknown_service": "Unknown service. It may be a reinforcement or the service has a different name than planned."
},
"timetable": {
"fullCaption": "Theoretical timetables for this stop",
diff --git a/src/frontend/app/i18n/locales/es-ES.json b/src/frontend/app/i18n/locales/es-ES.json
index 0b31825..d567bb3 100644
--- a/src/frontend/app/i18n/locales/es-ES.json
+++ b/src/frontend/app/i18n/locales/es-ES.json
@@ -76,8 +76,7 @@
"trip": "viaje",
"last_updated": "Actualizado a las",
"reload": "Recargar",
- "unknown_service": "Servicio desconocido. Puede tratarse de un refuerzo o de que el servicio lleva un nombre distinto al planificado.",
- "experimental_feature": "Función experimental"
+ "unknown_service": "Servicio desconocido. Puede tratarse de un refuerzo o de que el servicio lleva un nombre distinto al planificado."
},
"timetable": {
"fullCaption": "Horarios teóricos de la parada",
diff --git a/src/frontend/app/i18n/locales/gl-ES.json b/src/frontend/app/i18n/locales/gl-ES.json
index a56f2de..aab3140 100644
--- a/src/frontend/app/i18n/locales/gl-ES.json
+++ b/src/frontend/app/i18n/locales/gl-ES.json
@@ -76,8 +76,7 @@
"trip": "viaxe",
"last_updated": "Actualizado ás",
"reload": "Recargar",
- "unknown_service": "Servizo descoñecido. Pode tratarse dun reforzo ou de que o servizo leva un nome distinto ó planificado.",
- "experimental_feature": "Función experimental"
+ "unknown_service": "Servizo descoñecido. Pode tratarse dun reforzo ou de que o servizo leva un nome distinto ó planificado."
},
"timetable": {
"fullCaption": "Horarios teóricos da parada",
diff --git a/src/frontend/app/root.tsx b/src/frontend/app/root.tsx
index 5891097..8f0c916 100644
--- a/src/frontend/app/root.tsx
+++ b/src/frontend/app/root.tsx
@@ -1,22 +1,20 @@
import {
- isRouteErrorResponse,
- Link,
- Links,
- Meta,
- Outlet,
- Scripts,
- ScrollRestoration,
+ isRouteErrorResponse,
+ Links,
+ Meta,
+ Scripts,
+ ScrollRestoration
} from "react-router";
-import type { Route } from "./+types/root";
import "@fontsource-variable/roboto";
+import type { Route } from "./+types/root";
import "./root.css";
//#region Maplibre setup
+import maplibregl from "maplibre-gl";
import "maplibre-theme/icons.default.css";
import "maplibre-theme/modern.css";
import { Protocol } from "pmtiles";
-import maplibregl from "maplibre-gl";
import { AppProvider } from "./AppContext";
const pmtiles = new Protocol();
maplibregl.addProtocol("pmtiles", pmtiles.tile);
@@ -87,7 +85,7 @@ export function Layout({ children }: { children: React.ReactNode }) {
);
}
-import NavBar from "./components/NavBar";
+import { AppShell } from "./components/layout/AppShell";
export default function App() {
if ("serviceWorker" in navigator) {
@@ -98,12 +96,7 @@ export default function App() {
return (
<AppProvider>
- <main className="main-content">
- <Outlet />
- </main>
- <footer>
- <NavBar />
- </footer>
+ <AppShell />
</AppProvider>
);
}
diff --git a/src/frontend/app/routes.tsx b/src/frontend/app/routes.tsx
index 1316b5e..60671cd 100644
--- a/src/frontend/app/routes.tsx
+++ b/src/frontend/app/routes.tsx
@@ -8,4 +8,6 @@ export default [
route("/estimates/:id", "routes/estimates-$id.tsx"),
route("/timetable/:id", "routes/timetable-$id.tsx"),
route("/settings", "routes/settings.tsx"),
+ route("/about", "routes/about.tsx"),
+ route("/favourites", "routes/favourites.tsx"),
] satisfies RouteConfig;
diff --git a/src/frontend/app/routes/about.css b/src/frontend/app/routes/about.css
new file mode 100644
index 0000000..8f13015
--- /dev/null
+++ b/src/frontend/app/routes/about.css
@@ -0,0 +1,7 @@
+.about-version {
+ margin-top: 2rem;
+ text-align: center;
+ color: var(--subtitle-color);
+ border-top: 1px solid var(--border-color);
+ padding-top: 1rem;
+}
diff --git a/src/frontend/app/routes/about.tsx b/src/frontend/app/routes/about.tsx
new file mode 100644
index 0000000..d41268d
--- /dev/null
+++ b/src/frontend/app/routes/about.tsx
@@ -0,0 +1,61 @@
+import { useTranslation } from "react-i18next";
+import { usePageTitle } from "~/contexts/PageTitleContext";
+import { useApp } from "../AppContext";
+import "./about.css";
+import "./settings.css"; // Reusing settings CSS for now
+
+export default function About() {
+ const { t } = useTranslation();
+ usePageTitle(t("about.title", "Acerca de"));
+ const { region } = useApp();
+
+ return (
+ <div className="page-container">
+ <p className="about-description">{t("about.description")}</p>
+
+ <h2>{t("about.credits")}</h2>
+ <p>
+ <a
+ href="https://github.com/arielcostas/busurbano"
+ className="about-link"
+ rel="nofollow noreferrer noopener"
+ >
+ {t("about.github")}
+ </a>{" "}
+ - {t("about.developed_by")}{" "}
+ <a
+ href="https://www.costas.dev"
+ className="about-link"
+ rel="nofollow noreferrer noopener"
+ >
+ Ariel Costas
+ </a>
+ </p>
+ {region === "vigo" && (
+ <p>
+ {t("about.data_source_prefix")}{" "}
+ <a
+ href="https://datos.vigo.org"
+ className="about-link"
+ rel="nofollow noreferrer noopener"
+ >
+ datos.vigo.org
+ </a>{" "}
+ {t("about.data_source_middle")}{" "}
+ <a
+ href="https://opendefinition.org/licenses/odc-by/"
+ className="about-link"
+ rel="nofollow noreferrer noopener"
+ >
+ Open Data Commons Attribution License
+ </a>
+ .
+ </p>
+ )}
+
+ <div className="about-version">
+ <small>Version: {__COMMIT_HASH__}</small>
+ </div>
+ </div>
+ );
+}
diff --git a/src/frontend/app/routes/estimates-$id.tsx b/src/frontend/app/routes/estimates-$id.tsx
index 74f24e6..b92e59d 100644
--- a/src/frontend/app/routes/estimates-$id.tsx
+++ b/src/frontend/app/routes/estimates-$id.tsx
@@ -15,7 +15,7 @@ import {
} from "~/components/SchedulesTableSkeleton";
import { StopAlert } from "~/components/StopAlert";
import { TimetableSkeleton } from "~/components/TimetableSkeleton";
-import { type RegionId, getRegionConfig } from "~/data/RegionConfig";
+import { type RegionId, getRegionConfig } from "~/config/RegionConfig";
import { useAutoRefresh } from "~/hooks/useAutoRefresh";
import { useApp } from "../AppContext";
import { GroupedTable } from "../components/GroupedTable";
diff --git a/src/frontend/app/routes/favourites.tsx b/src/frontend/app/routes/favourites.tsx
new file mode 100644
index 0000000..5b74391
--- /dev/null
+++ b/src/frontend/app/routes/favourites.tsx
@@ -0,0 +1,13 @@
+import { useTranslation } from "react-i18next";
+import { usePageTitle } from "~/contexts/PageTitleContext";
+
+export default function Favourites() {
+ const { t } = useTranslation();
+ usePageTitle(t("navbar.favourites", "Favoritos"));
+
+ return (
+ <div className="page-container">
+ <p>{t("favourites.empty", "No tienes paradas favoritas.")}</p>
+ </div>
+ );
+}
diff --git a/src/frontend/app/routes/home.tsx b/src/frontend/app/routes/home.tsx
index 2909999..8a1e3b3 100644
--- a/src/frontend/app/routes/home.tsx
+++ b/src/frontend/app/routes/home.tsx
@@ -1,18 +1,20 @@
"use client";
-import { useEffect, useMemo, useRef, useState, useCallback } from "react";
-import StopDataProvider, { type Stop } from "../data/StopDataProvider";
-import StopItem from "../components/StopItem";
-import StopItemSkeleton from "../components/StopItemSkeleton";
-import StopGallery from "../components/StopGallery";
-import ServiceAlerts from "../components/ServiceAlerts";
import Fuse from "fuse.js";
-import "./home.css";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
+import { REGIONS } from "~/config/RegionConfig";
+import { usePageTitle } from "~/contexts/PageTitleContext";
import { useApp } from "../AppContext";
-import { REGIONS } from "~/data/RegionConfig";
+import ServiceAlerts from "../components/ServiceAlerts";
+import StopGallery from "../components/StopGallery";
+import StopItem from "../components/StopItem";
+import StopItemSkeleton from "../components/StopItemSkeleton";
+import StopDataProvider, { type Stop } from "../data/StopDataProvider";
+import "./home.css";
export default function StopList() {
const { t } = useTranslation();
+ usePageTitle(t("navbar.stops", "Paradas"));
const { region } = useApp();
const [data, setData] = useState<Stop[] | null>(null);
const [loading, setLoading] = useState(true);
diff --git a/src/frontend/app/routes/map.tsx b/src/frontend/app/routes/map.tsx
index b8fb881..57fb04e 100644
--- a/src/frontend/app/routes/map.tsx
+++ b/src/frontend/app/routes/map.tsx
@@ -6,17 +6,18 @@ import type { Feature as GeoJsonFeature, Point } from "geojson";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import Map, {
- GeolocateControl,
- Layer,
- NavigationControl,
- Source,
- type MapLayerMouseEvent,
- type MapRef,
- type StyleSpecification
+ GeolocateControl,
+ Layer,
+ NavigationControl,
+ Source,
+ type MapLayerMouseEvent,
+ type MapRef,
+ type StyleSpecification
} from "react-map-gl/maplibre";
-import { useApp } from "~/AppContext";
import { StopSheet } from "~/components/StopSheet";
-import { REGIONS } from "~/data/RegionConfig";
+import { getRegionConfig } from "~/config/RegionConfig";
+import { usePageTitle } from "~/contexts/PageTitleContext";
+import { useApp } from "../AppContext";
// Default minimal fallback style before dynamic loading
const defaultStyle: StyleSpecification = {
@@ -30,6 +31,7 @@ const defaultStyle: StyleSpecification = {
// Componente principal del mapa
export default function StopMap() {
const { t } = useTranslation();
+ usePageTitle(t("navbar.map", "Mapa"));
const [stops, setStops] = useState<
GeoJsonFeature<
Point,
@@ -162,8 +164,8 @@ export default function StopMap() {
}}
attributionControl={{ compact: false }}
maxBounds={
- REGIONS[region].bounds
- ? [REGIONS[region].bounds!.sw, REGIONS[region].bounds!.ne]
+ getRegionConfig(region).bounds
+ ? [getRegionConfig(region).bounds!.sw, getRegionConfig(region).bounds!.ne]
: undefined
}
>
@@ -218,7 +220,7 @@ export default function StopMap() {
"text-size": ["interpolate", ["linear"], ["zoom"], 11, 8, 22, 16],
}}
paint={{
- "text-color": `${REGIONS[region].textColour || "#000"}`,
+ "text-color": `${getRegionConfig(region).textColour || "#000"}`,
"text-halo-color": "#FFF",
"text-halo-width": 1,
}}
diff --git a/src/frontend/app/routes/settings.tsx b/src/frontend/app/routes/settings.tsx
index 2134b4c..351ccf0 100644
--- a/src/frontend/app/routes/settings.tsx
+++ b/src/frontend/app/routes/settings.tsx
@@ -1,12 +1,14 @@
-import { type Theme, useApp } from "../AppContext";
-import "./settings.css";
-import { useTranslation } from "react-i18next";
import { useState } from "react";
-import { getAvailableRegions, REGIONS } from "../data/RegionConfig";
+import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router";
+import { usePageTitle } from "~/contexts/PageTitleContext";
+import { type Theme, useApp } from "../AppContext";
+import { getAvailableRegions } from "../config/RegionConfig";
+import "./settings.css";
export default function Settings() {
const { t, i18n } = useTranslation();
+ usePageTitle(t("navbar.settings", "Ajustes"));
const navigate = useNavigate();
const {
theme,
@@ -46,8 +48,6 @@ export default function Settings() {
return (
<div className="page-container">
- <h1 className="page-title">{t("about.title")}</h1>
- <p className="about-description">{t("about.description")}</p>
<section className="settings-section">
<h2>{t("about.settings")}</h2>
<div className="settings-content-inline">
@@ -151,45 +151,6 @@ export default function Settings() {
</dl>
</details>
</section>
- <h2>{t("about.credits")}</h2>
- <p>
- <a
- href="https://github.com/arielcostas/busurbano"
- className="about-link"
- rel="nofollow noreferrer noopener"
- >
- {t("about.github")}
- </a>{" "}
- - {t("about.developed_by")}{" "}
- <a
- href="https://www.costas.dev"
- className="about-link"
- rel="nofollow noreferrer noopener"
- >
- Ariel Costas
- </a>
- </p>
- {region === "vigo" && (
- <p>
- {t("about.data_source_prefix")}{" "}
- <a
- href="https://datos.vigo.org"
- className="about-link"
- rel="nofollow noreferrer noopener"
- >
- datos.vigo.org
- </a>{" "}
- {t("about.data_source_middle")}{" "}
- <a
- href="https://opendefinition.org/licenses/odc-by/"
- className="about-link"
- rel="nofollow noreferrer noopener"
- >
- Open Data Commons Attribution License
- </a>
- .
- </p>
- )}
{showModal && (
<div className="modal-overlay" onClick={cancelRegionChange}>
diff --git a/src/frontend/app/routes/stops-$id.css b/src/frontend/app/routes/stops-$id.css
index c515435..9ecac16 100644
--- a/src/frontend/app/routes/stops-$id.css
+++ b/src/frontend/app/routes/stops-$id.css
@@ -1,8 +1,3 @@
-.page-title {
- margin-block: 0;
- font-size: 1.5rem;
-}
-
.estimates-content-wrapper {
display: flex;
flex-direction: column;
@@ -266,30 +261,6 @@
flex-shrink: 0;
}
-.experimental-notice {
- background-color: #fff3cd;
- border: 1px solid #ffc107;
- border-radius: 8px;
- padding: 0.5rem 1rem;
- color: #856404;
- flex-shrink: 0;
-}
-
-.experimental-notice strong {
- display: block;
- color: #856404;
-}
-
-[data-theme="dark"] .experimental-notice {
- background-color: #3d3100;
- border-color: #ffc107;
- color: #ffd966;
-}
-
-[data-theme="dark"] .experimental-notice strong {
- color: #ffd966;
-}
-
.refresh-status {
display: flex;
align-items: center;
diff --git a/src/frontend/app/routes/stops-$id.tsx b/src/frontend/app/routes/stops-$id.tsx
index 372582b..f340009 100644
--- a/src/frontend/app/routes/stops-$id.tsx
+++ b/src/frontend/app/routes/stops-$id.tsx
@@ -8,7 +8,8 @@ import { StopAlert } from "~/components/StopAlert";
import { StopMap } from "~/components/StopMapSheet";
import { ConsolidatedCirculationList } from "~/components/Stops/ConsolidatedCirculationList";
import { ConsolidatedCirculationListSkeleton } from "~/components/Stops/ConsolidatedCirculationListSkeleton";
-import { type RegionId, getRegionConfig } from "~/data/RegionConfig";
+import { type RegionId, getRegionConfig } from "~/config/RegionConfig";
+import { usePageTitle } from "~/contexts/PageTitleContext";
import { useAutoRefresh } from "~/hooks/useAutoRefresh";
import { useApp } from "../AppContext";
import StopDataProvider, { type Stop } from "../data/StopDataProvider";
@@ -79,6 +80,16 @@ export default function Estimates() {
const { region } = useApp();
const regionConfig = getRegionConfig(region);
+ // Helper function to get the display name for the stop
+ const getStopDisplayName = useCallback(() => {
+ if (customName) return customName;
+ if (stopData?.name.intersect) return stopData.name.intersect;
+ if (stopData?.name.original) return stopData.name.original;
+ return `Parada ${stopIdNum}`;
+ }, [customName, stopData, stopIdNum]);
+
+ usePageTitle(getStopDisplayName());
+
const parseError = (error: any): ErrorInfo => {
if (!navigator.onLine) {
return { type: "network", message: "No internet connection" };
@@ -165,14 +176,6 @@ export default function Estimates() {
}
};
- // Helper function to get the display name for the stop
- const getStopDisplayName = () => {
- if (customName) return customName;
- if (stopData?.name.intersect) return stopData.name.intersect;
- if (stopData?.name.original) return stopData.name.original;
- return `Parada ${stopIdNum}`;
- };
-
const handleRename = () => {
const current = getStopDisplayName();
const input = window.prompt("Custom name for this stop:", current);
@@ -202,9 +205,6 @@ export default function Estimates() {
onClick={handleRename}
width={20} />
</div>
- <h1 className="page-title">
- {getStopDisplayName()}
- </h1>
<button
className="manual-refresh-button"
@@ -230,12 +230,6 @@ export default function Estimates() {
{stopData && <StopAlert stop={stopData} />}
- <div className="experimental-notice">
- <strong>
- {t("estimates.experimental_feature", "Experimental feature")}
- </strong>
- </div>
-
<div className="estimates-list-container">
{dataLoading ? (
<ConsolidatedCirculationListSkeleton />
diff --git a/src/frontend/app/routes/timetable-$id.tsx b/src/frontend/app/routes/timetable-$id.tsx
index 8a1cba7..c036cb3 100644
--- a/src/frontend/app/routes/timetable-$id.tsx
+++ b/src/frontend/app/routes/timetable-$id.tsx
@@ -1,21 +1,21 @@
-import { useEffect, useState, useRef } from "react";
-import { useParams, Link } from "react-router";
-import StopDataProvider from "../data/StopDataProvider";
import {
- ArrowLeft,
- Eye,
- EyeOff,
- ChevronUp,
- ChevronDown,
- Clock,
+ ArrowLeft,
+ ChevronDown,
+ ChevronUp,
+ Clock,
+ Eye,
+ EyeOff,
} from "lucide-react";
+import { useEffect, useRef, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { Link, useParams } from "react-router";
+import { useApp } from "~/AppContext";
+import { ErrorDisplay } from "~/components/ErrorDisplay";
import { type ScheduledTable } from "~/components/SchedulesTable";
import { TimetableSkeleton } from "~/components/TimetableSkeleton";
-import { ErrorDisplay } from "~/components/ErrorDisplay";
+import { type RegionId, getRegionConfig } from "~/config/RegionConfig";
import LineIcon from "../components/LineIcon";
-import { useTranslation } from "react-i18next";
-import { type RegionId, getRegionConfig } from "~/data/RegionConfig";
-import { useApp } from "~/AppContext";
+import StopDataProvider from "../data/StopDataProvider";
import "./timetable-$id.css";
interface ErrorInfo {
diff --git a/src/frontend/app/vite-env.d.ts b/src/frontend/app/vite-env.d.ts
index 11f02fe..1661f17 100644
--- a/src/frontend/app/vite-env.d.ts
+++ b/src/frontend/app/vite-env.d.ts
@@ -1 +1,3 @@
/// <reference types="vite/client" />
+
+declare const __COMMIT_HASH__: string;
diff --git a/src/frontend/vite.config.ts b/src/frontend/vite.config.ts
index 430ad40..89623c0 100644
--- a/src/frontend/vite.config.ts
+++ b/src/frontend/vite.config.ts
@@ -1,9 +1,15 @@
+import { reactRouter } from "@react-router/dev/vite";
+import { execSync } from "child_process";
import { defineConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
-import { reactRouter } from "@react-router/dev/vite";
+
+const commitHash = execSync("git rev-parse --short HEAD").toString().trim();
// https://vitejs.dev/config/
export default defineConfig({
+ define: {
+ __COMMIT_HASH__: JSON.stringify(commitHash),
+ },
plugins: [reactRouter(), tsconfigPaths()],
server: {
proxy: {