diff options
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: { |
