diff options
| author | Copilot <198982749+Copilot@users.noreply.github.com> | 2025-06-26 23:44:25 +0200 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-06-26 23:44:25 +0200 |
| commit | 7b8594debceb93a1fa400d48fe1dcff943bd5af6 (patch) | |
| tree | 73e68c7238a91d8931d669364d395ce2994164f4 /src/frontend/app | |
| parent | 3dac17a9fb54c977c97280ed4c482e9d4266b7de (diff) | |
Implement stop sheet modal for map stop interactions (#27)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: arielcostas <94913521+arielcostas@users.noreply.github.com>
Co-authored-by: Ariel Costas Guerrero <ariel@costas.dev>
Diffstat (limited to 'src/frontend/app')
27 files changed, 1271 insertions, 824 deletions
diff --git a/src/frontend/app/AppContext.tsx b/src/frontend/app/AppContext.tsx index d8db66d..e6d8971 100644 --- a/src/frontend/app/AppContext.tsx +++ b/src/frontend/app/AppContext.tsx @@ -1,10 +1,16 @@ /* eslint-disable react-refresh/only-export-components */ -import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'; -import { type LngLatLike } from 'maplibre-gl'; +import { + createContext, + useContext, + useEffect, + useState, + type ReactNode, +} from "react"; +import { type LngLatLike } from "maplibre-gl"; -type Theme = 'light' | 'dark'; -type TableStyle = 'regular'|'grouped'; -type MapPositionMode = 'gps' | 'last'; +type Theme = "light" | "dark"; +type TableStyle = "regular" | "grouped"; +type MapPositionMode = "gps" | "last"; interface MapState { center: LngLatLike; @@ -42,56 +48,62 @@ const AppContext = createContext<AppContextProps | undefined>(undefined); export const AppProvider = ({ children }: { children: ReactNode }) => { //#region Theme const [theme, setTheme] = useState<Theme>(() => { - const savedTheme = localStorage.getItem('theme'); + const savedTheme = localStorage.getItem("theme"); if (savedTheme) { return savedTheme as Theme; } - const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; - return prefersDark ? 'dark' : 'light'; + const prefersDark = + window.matchMedia && + window.matchMedia("(prefers-color-scheme: dark)").matches; + return prefersDark ? "dark" : "light"; }); const toggleTheme = () => { - setTheme((prevTheme) => (prevTheme === 'light' ? 'dark' : 'light')); + setTheme((prevTheme) => (prevTheme === "light" ? "dark" : "light")); }; useEffect(() => { - document.documentElement.setAttribute('data-theme', theme); - localStorage.setItem('theme', theme); + document.documentElement.setAttribute("data-theme", theme); + localStorage.setItem("theme", theme); }, [theme]); //#endregion //#region Table Style const [tableStyle, setTableStyle] = useState<TableStyle>(() => { - const savedTableStyle = localStorage.getItem('tableStyle'); + const savedTableStyle = localStorage.getItem("tableStyle"); if (savedTableStyle) { return savedTableStyle as TableStyle; } - return 'regular'; + return "regular"; }); const toggleTableStyle = () => { - setTableStyle((prevTableStyle) => (prevTableStyle === 'regular' ? 'grouped' : 'regular')); - } + setTableStyle((prevTableStyle) => + prevTableStyle === "regular" ? "grouped" : "regular", + ); + }; useEffect(() => { - localStorage.setItem('tableStyle', tableStyle); + 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'; - }); + const [mapPositionMode, setMapPositionMode] = useState<MapPositionMode>( + () => { + const saved = localStorage.getItem("mapPositionMode"); + return saved === "last" ? "last" : "gps"; + }, + ); useEffect(() => { - localStorage.setItem('mapPositionMode', mapPositionMode); + localStorage.setItem("mapPositionMode", mapPositionMode); }, [mapPositionMode]); //#endregion //#region Map State const [mapState, setMapState] = useState<MapState>(() => { - const savedMapState = localStorage.getItem('mapState'); + const savedMapState = localStorage.getItem("mapState"); if (savedMapState) { try { const parsed = JSON.parse(savedMapState); @@ -99,56 +111,56 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { center: parsed.center || DEFAULT_CENTER, zoom: parsed.zoom || DEFAULT_ZOOM, userLocation: parsed.userLocation || null, - hasLocationPermission: parsed.hasLocationPermission || false + hasLocationPermission: parsed.hasLocationPermission || false, }; } catch (e) { - console.error('Error parsing saved map state', e); + console.error("Error parsing saved map state", e); } } return { center: DEFAULT_CENTER, zoom: DEFAULT_ZOOM, userLocation: null, - hasLocationPermission: false + hasLocationPermission: false, }; }); const setMapCenter = (center: LngLatLike) => { - setMapState(prev => { + setMapState((prev) => { const newState = { ...prev, center }; - localStorage.setItem('mapState', JSON.stringify(newState)); + localStorage.setItem("mapState", JSON.stringify(newState)); return newState; }); }; const setMapZoom = (zoom: number) => { - setMapState(prev => { + setMapState((prev) => { const newState = { ...prev, zoom }; - localStorage.setItem('mapState', JSON.stringify(newState)); + localStorage.setItem("mapState", JSON.stringify(newState)); return newState; }); }; const setUserLocation = (userLocation: LngLatLike | null) => { - setMapState(prev => { + setMapState((prev) => { const newState = { ...prev, userLocation }; - localStorage.setItem('mapState', JSON.stringify(newState)); + localStorage.setItem("mapState", JSON.stringify(newState)); return newState; }); }; const setLocationPermission = (hasLocationPermission: boolean) => { - setMapState(prev => { + setMapState((prev) => { const newState = { ...prev, hasLocationPermission }; - localStorage.setItem('mapState', JSON.stringify(newState)); + localStorage.setItem("mapState", JSON.stringify(newState)); return newState; }); }; const updateMapState = (center: LngLatLike, zoom: number) => { - setMapState(prev => { + setMapState((prev) => { const newState = { ...prev, center, zoom }; - localStorage.setItem('mapState', JSON.stringify(newState)); + localStorage.setItem("mapState", JSON.stringify(newState)); return newState; }); }; @@ -164,31 +176,33 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { setUserLocation([latitude, longitude]); }, (error) => { - console.error('Error getting location:', error); + console.error("Error getting location:", error); setLocationPermission(false); - } + }, ); } } }, [mapState.hasLocationPermission, mapState.userLocation]); return ( - <AppContext.Provider value={{ - theme, - setTheme, - toggleTheme, - tableStyle, - setTableStyle, - toggleTableStyle, - mapState, - setMapCenter, - setMapZoom, - setUserLocation, - setLocationPermission, - updateMapState, - mapPositionMode, - setMapPositionMode - }}> + <AppContext.Provider + value={{ + theme, + setTheme, + toggleTheme, + tableStyle, + setTableStyle, + toggleTableStyle, + mapState, + setMapCenter, + setMapZoom, + setUserLocation, + setLocationPermission, + updateMapState, + mapPositionMode, + setMapPositionMode, + }} + > {children} </AppContext.Provider> ); @@ -197,7 +211,7 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { export const useApp = () => { const context = useContext(AppContext); if (!context) { - throw new Error('useApp must be used within a AppProvider'); + throw new Error("useApp must be used within a AppProvider"); } return context; }; diff --git a/src/frontend/app/ErrorBoundary.tsx b/src/frontend/app/ErrorBoundary.tsx index 5c877b7..c11a82b 100644 --- a/src/frontend/app/ErrorBoundary.tsx +++ b/src/frontend/app/ErrorBoundary.tsx @@ -1,4 +1,4 @@ -import React, { Component, type ReactNode } from 'react'; +import React, { Component, type ReactNode } from "react"; interface ErrorBoundaryProps { children: ReactNode; @@ -14,14 +14,14 @@ class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> { super(props); this.state = { hasError: false, - error: null + error: null, }; } static getDerivedStateFromError(error: Error): ErrorBoundaryState { return { hasError: true, - error + error, }; } @@ -31,12 +31,12 @@ class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> { render() { if (this.state.hasError) { - return <> - <h1>Something went wrong.</h1> - <pre> - {this.state.error?.stack} - </pre> - </>; + return ( + <> + <h1>Something went wrong.</h1> + <pre>{this.state.error?.stack}</pre> + </> + ); } return this.props.children; diff --git a/src/frontend/app/components/GroupedTable.tsx b/src/frontend/app/components/GroupedTable.tsx index 3a16d89..47c2d31 100644 --- a/src/frontend/app/components/GroupedTable.tsx +++ b/src/frontend/app/components/GroupedTable.tsx @@ -2,73 +2,79 @@ import { type StopDetails } from "../routes/estimates-$id"; import LineIcon from "./LineIcon"; interface GroupedTable { - data: StopDetails; - dataDate: Date | null; + data: StopDetails; + dataDate: Date | null; } export const GroupedTable: React.FC<GroupedTable> = ({ data, dataDate }) => { - const formatDistance = (meters: number) => { - if (meters > 1024) { - return `${(meters / 1000).toFixed(1)} km`; - } else { - return `${meters} m`; - } + const formatDistance = (meters: number) => { + if (meters > 1024) { + return `${(meters / 1000).toFixed(1)} km`; + } else { + return `${meters} m`; } + }; - const groupedEstimates = data.estimates.reduce((acc, estimate) => { - if (!acc[estimate.line]) { - acc[estimate.line] = []; - } - acc[estimate.line].push(estimate); - return acc; - }, {} as Record<string, typeof data.estimates>); + const groupedEstimates = data.estimates.reduce( + (acc, estimate) => { + if (!acc[estimate.line]) { + acc[estimate.line] = []; + } + acc[estimate.line].push(estimate); + return acc; + }, + {} as Record<string, typeof data.estimates>, + ); - const sortedLines = Object.keys(groupedEstimates).sort((a, b) => { - const firstArrivalA = groupedEstimates[a][0].minutes; - const firstArrivalB = groupedEstimates[b][0].minutes; - return firstArrivalA - firstArrivalB; - }); + const sortedLines = Object.keys(groupedEstimates).sort((a, b) => { + const firstArrivalA = groupedEstimates[a][0].minutes; + const firstArrivalB = groupedEstimates[b][0].minutes; + return firstArrivalA - firstArrivalB; + }); - return <table className="table"> - <caption>Estimaciones de llegadas a las {dataDate?.toLocaleTimeString()}</caption> + return ( + <table className="table"> + <caption> + Estimaciones de llegadas a las {dataDate?.toLocaleTimeString()} + </caption> - <thead> - <tr> - <th>Línea</th> - <th>Ruta</th> - <th>Llegada</th> - <th>Distancia</th> - </tr> - </thead> - - <tbody> - {sortedLines.map((line) => ( - groupedEstimates[line].map((estimate, idx) => ( - <tr key={`${line}-${idx}`}> - {idx === 0 && ( - <td rowSpan={groupedEstimates[line].length}> - <LineIcon line={line} /> - </td> - )} - <td>{estimate.route}</td> - <td>{`${estimate.minutes} min`}</td> - <td> - {estimate.meters > -1 - ? formatDistance(estimate.meters) - : "No disponible" - } - </td> - </tr> - )) - ))} - </tbody> + <thead> + <tr> + <th>Línea</th> + <th>Ruta</th> + <th>Llegada</th> + <th>Distancia</th> + </tr> + </thead> - {data?.estimates.length === 0 && ( - <tfoot> - <tr> - <td colSpan={4}>No hay estimaciones disponibles</td> - </tr> - </tfoot> + <tbody> + {sortedLines.map((line) => + groupedEstimates[line].map((estimate, idx) => ( + <tr key={`${line}-${idx}`}> + {idx === 0 && ( + <td rowSpan={groupedEstimates[line].length}> + <LineIcon line={line} /> + </td> + )} + <td>{estimate.route}</td> + <td>{`${estimate.minutes} min`}</td> + <td> + {estimate.meters > -1 + ? formatDistance(estimate.meters) + : "No disponible"} + </td> + </tr> + )), )} + </tbody> + + {data?.estimates.length === 0 && ( + <tfoot> + <tr> + <td colSpan={4}>No hay estimaciones disponibles</td> + </tr> + </tfoot> + )} </table> -} + ); +}; diff --git a/src/frontend/app/components/LineIcon.css b/src/frontend/app/components/LineIcon.css index e7e8949..4b39351 100644 --- a/src/frontend/app/components/LineIcon.css +++ b/src/frontend/app/components/LineIcon.css @@ -55,7 +55,8 @@ font-weight: 600; text-transform: uppercase; color: inherit; - /* Prevent color change on hover */ + background-color: white; + border-radius: 0.25rem 0.25rem 0 0; } .line-c1 { @@ -236,4 +237,4 @@ .line-u2 { border-color: var(--line-u2); -}
\ No newline at end of file +} diff --git a/src/frontend/app/components/LineIcon.tsx b/src/frontend/app/components/LineIcon.tsx index 291b444..3d613e6 100644 --- a/src/frontend/app/components/LineIcon.tsx +++ b/src/frontend/app/components/LineIcon.tsx @@ -1,5 +1,5 @@ -import React from 'react'; -import './LineIcon.css'; +import React from "react"; +import "./LineIcon.css"; interface LineIconProps { line: string; diff --git a/src/frontend/app/components/NavBar.tsx b/src/frontend/app/components/NavBar.tsx index eba7196..6a06e63 100644 --- a/src/frontend/app/components/NavBar.tsx +++ b/src/frontend/app/components/NavBar.tsx @@ -9,14 +9,14 @@ function isWithinVigo(lngLat: LngLatLike): boolean { let lng: number, lat: number; if (Array.isArray(lngLat)) { [lng, lat] = lngLat; - } else if ('lng' in lngLat && 'lat' in lngLat) { + } else if ("lng" in lngLat && "lat" in lngLat) { lng = lngLat.lng; lat = lngLat.lat; } else { return false; } // Rough bounding box for Vigo - return lat >= 42.18 && lat <= 42.30 && lng >= -8.78 && lng <= -8.65; + return lat >= 42.18 && lat <= 42.3 && lng >= -8.78 && lng <= -8.65; } export default function NavBar() { @@ -25,20 +25,20 @@ export default function NavBar() { const navItems = [ { - name: t('navbar.stops', 'Paradas'), + name: t("navbar.stops", "Paradas"), icon: MapPin, - path: '/stops' + path: "/stops", }, { - name: t('navbar.map', 'Mapa'), + name: t("navbar.map", "Mapa"), icon: Map, - path: '/map', + path: "/map", callback: () => { - if (mapPositionMode !== 'gps') { + if (mapPositionMode !== "gps") { return; } - if (!('geolocation' in navigator)) { + if (!("geolocation" in navigator)) { return; } @@ -50,20 +50,20 @@ export default function NavBar() { updateMapState(coords, 16); } }, - () => { } + () => {}, ); - } + }, }, { - name: t('navbar.settings', 'Ajustes'), + name: t("navbar.settings", "Ajustes"), icon: Settings, - path: '/settings' - } + path: "/settings", + }, ]; return ( <nav className="navigation-bar"> - {navItems.map(item => { + {navItems.map((item) => { const Icon = item.icon; const isActive = location.pathname.startsWith(item.path); @@ -71,7 +71,7 @@ export default function NavBar() { <Link key={item.name} to={item.path} - className={`navigation-bar__link ${isActive ? 'active' : ''}`} + className={`navigation-bar__link ${isActive ? "active" : ""}`} onClick={item.callback ? item.callback : undefined} title={item.name} aria-label={item.name} diff --git a/src/frontend/app/components/RegularTable.tsx b/src/frontend/app/components/RegularTable.tsx index e5b3782..8b01410 100644 --- a/src/frontend/app/components/RegularTable.tsx +++ b/src/frontend/app/components/RegularTable.tsx @@ -3,70 +3,85 @@ import { type StopDetails } from "../routes/estimates-$id"; import LineIcon from "./LineIcon"; interface RegularTableProps { - data: StopDetails; - dataDate: Date | null; + data: StopDetails; + dataDate: Date | null; } -export const RegularTable: React.FC<RegularTableProps> = ({ data, dataDate }) => { - const { t } = useTranslation(); +export const RegularTable: React.FC<RegularTableProps> = ({ + data, + dataDate, +}) => { + const { t } = useTranslation(); - const absoluteArrivalTime = (minutes: number) => { - const now = new Date() - const arrival = new Date(now.getTime() + minutes * 60000) - return Intl.DateTimeFormat(navigator.language, { - hour: '2-digit', - minute: '2-digit' - }).format(arrival) - } + const absoluteArrivalTime = (minutes: number) => { + const now = new Date(); + const arrival = new Date(now.getTime() + minutes * 60000); + return Intl.DateTimeFormat( + typeof navigator !== "undefined" ? navigator.language : "en", + { + hour: "2-digit", + minute: "2-digit", + } + ).format(arrival); + }; - const formatDistance = (meters: number) => { - if (meters > 1024) { - return `${(meters / 1000).toFixed(1)} km`; - } else { - return `${meters} ${t('estimates.meters', 'm')}`; - } + const formatDistance = (meters: number) => { + if (meters > 1024) { + return `${(meters / 1000).toFixed(1)} km`; + } else { + return `${meters} ${t("estimates.meters", "m")}`; } + }; - return <table className="table"> - <caption>{t('estimates.caption', 'Estimaciones de llegadas a las {{time}}', { time: dataDate?.toLocaleTimeString() })}</caption> + return ( + <table className="table"> + <caption> + {t("estimates.caption", "Estimaciones de llegadas a las {{time}}", { + time: dataDate?.toLocaleTimeString(), + })} + </caption> - <thead> - <tr> - <th>{t('estimates.line', 'Línea')}</th> - <th>{t('estimates.route', 'Ruta')}</th> - <th>{t('estimates.arrival', 'Llegada')}</th> - <th>{t('estimates.distance', 'Distancia')}</th> - </tr> - </thead> + <thead> + <tr> + <th>{t("estimates.line", "Línea")}</th> + <th>{t("estimates.route", "Ruta")}</th> + <th>{t("estimates.arrival", "Llegada")}</th> + <th>{t("estimates.distance", "Distancia")}</th> + </tr> + </thead> - <tbody> - {data.estimates - .sort((a, b) => a.minutes - b.minutes) - .map((estimate, idx) => ( - <tr key={idx}> - <td><LineIcon line={estimate.line} /></td> - <td>{estimate.route}</td> - <td> - {estimate.minutes > 15 - ? absoluteArrivalTime(estimate.minutes) - : `${estimate.minutes} ${t('estimates.minutes', 'min')}`} - </td> - <td> - {estimate.meters > -1 - ? formatDistance(estimate.meters) - : t('estimates.not_available', 'No disponible') - } - </td> - </tr> - ))} - </tbody> + <tbody> + {data.estimates + .sort((a, b) => a.minutes - b.minutes) + .map((estimate, idx) => ( + <tr key={idx}> + <td> + <LineIcon line={estimate.line} /> + </td> + <td>{estimate.route}</td> + <td> + {estimate.minutes > 15 + ? absoluteArrivalTime(estimate.minutes) + : `${estimate.minutes} ${t("estimates.minutes", "min")}`} + </td> + <td> + {estimate.meters > -1 + ? formatDistance(estimate.meters) + : t("estimates.not_available", "No disponible")} + </td> + </tr> + ))} + </tbody> - {data?.estimates.length === 0 && ( - <tfoot> - <tr> - <td colSpan={4}>{t('estimates.none', 'No hay estimaciones disponibles')}</td> - </tr> - </tfoot> - )} + {data?.estimates.length === 0 && ( + <tfoot> + <tr> + <td colSpan={4}> + {t("estimates.none", "No hay estimaciones disponibles")} + </td> + </tr> + </tfoot> + )} </table> -} + ); +}; diff --git a/src/frontend/app/components/StopItem.css b/src/frontend/app/components/StopItem.css index 9feb2d1..54ab136 100644 --- a/src/frontend/app/components/StopItem.css +++ b/src/frontend/app/components/StopItem.css @@ -51,4 +51,4 @@ top: 0; color: #0078d4; font-weight: bold; -}
\ No newline at end of file +} diff --git a/src/frontend/app/components/StopItem.tsx b/src/frontend/app/components/StopItem.tsx index 29370b7..b781eb9 100644 --- a/src/frontend/app/components/StopItem.tsx +++ b/src/frontend/app/components/StopItem.tsx @@ -1,22 +1,21 @@ -import React from 'react'; -import { Link } from 'react-router'; -import StopDataProvider, { type Stop } from '../data/StopDataProvider'; -import LineIcon from './LineIcon'; +import React from "react"; +import { Link } from "react-router"; +import StopDataProvider, { type Stop } from "../data/StopDataProvider"; +import LineIcon from "./LineIcon"; interface StopItemProps { stop: Stop; } const StopItem: React.FC<StopItemProps> = ({ stop }) => { - return ( <li className="list-item"> <Link className="list-item-link" to={`/estimates/${stop.stopId}`}> - {stop.favourite && <span className="favourite-icon">★</span>} ({stop.stopId}) {StopDataProvider.getDisplayName(stop)} + {stop.favourite && <span className="favourite-icon">★</span>} ( + {stop.stopId}) {StopDataProvider.getDisplayName(stop)} <div className="line-icons"> - {stop.lines?.map(line => <LineIcon key={line} line={line} />)} + {stop.lines?.map((line) => <LineIcon key={line} line={line} />)} </div> - </Link> </li> ); diff --git a/src/frontend/app/components/StopSheet.css b/src/frontend/app/components/StopSheet.css new file mode 100644 index 0000000..3f7621e --- /dev/null +++ b/src/frontend/app/components/StopSheet.css @@ -0,0 +1,146 @@ +/* Stop Sheet Styles */ +.stop-sheet-content { + padding: 16px; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.stop-sheet-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 16px; +} + +.stop-sheet-title { + font-size: 1.5rem; + font-weight: 600; + color: var(--text-color); + margin: 0; +} + +.stop-sheet-id { + font-size: 1rem; + color: var(--subtitle-color); +} + +.stop-sheet-loading { + display: flex; + justify-content: center; + align-items: center; + padding: 32px; + color: var(--subtitle-color); + font-size: 1rem; +} + +.stop-sheet-estimates { + flex: 1; + overflow-y: auto; + min-height: 0; +} + +.stop-sheet-subtitle { + font-size: 1.1rem; + font-weight: 500; + color: var(--text-color); + margin: 0 0 12px 0; +} + +.stop-sheet-no-estimates { + text-align: center; + padding: 32px 16px; + color: var(--subtitle-color); + font-size: 0.95rem; +} + +.stop-sheet-estimates-list { + display: flex; + flex-direction: column; + gap: 12px; +} + +.stop-sheet-estimate-item { + display: flex; + align-items: center; + gap: 12px; + padding: 12px; + background-color: var(--message-background-color); + border-radius: 8px; + border: 1px solid var(--border-color); +} + +.stop-sheet-estimate-line { + flex-shrink: 0; +} + +.stop-sheet-estimate-details { + flex: 1; + min-width: 0; +} + +.stop-sheet-estimate-route { + font-weight: 500; + color: var(--text-color); + font-size: 0.95rem; + margin-bottom: 2px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.stop-sheet-estimate-time { + font-size: 0.85rem; + color: var(--subtitle-color); +} + +.stop-sheet-estimate-distance { + color: var(--subtitle-color); +} + +.stop-sheet-view-all { + display: block; + padding: 12px 16px; + background-color: var(--button-background-color); + color: white; + text-decoration: none; + text-align: center; + border-radius: 8px; + font-weight: 500; + transition: background-color 0.2s ease; + + margin-block-start: 1rem; + margin-inline-start: auto; +} + +.stop-sheet-view-all:hover { + background-color: var(--button-hover-background-color); + text-decoration: none; +} + +/* Override react-modal-sheet styles for better integration */ +[data-rsbs-overlay] { + background-color: rgba(0, 0, 0, 0.3); +} + +[data-rsbs-header] { + background-color: var(--background-color); + border-bottom: 1px solid var(--border-color); +} + +[data-rsbs-header]:before { + background-color: var(--subtitle-color); +} + +[data-rsbs-root] [data-rsbs-overlay] { + border-top-left-radius: 16px; + border-top-right-radius: 16px; +} + +[data-rsbs-root] [data-rsbs-content] { + background-color: var(--background-color); + border-top-left-radius: 16px; + border-top-right-radius: 16px; + max-height: 95vh; + overflow: hidden; +} diff --git a/src/frontend/app/components/StopSheet.tsx b/src/frontend/app/components/StopSheet.tsx new file mode 100644 index 0000000..8075e9d --- /dev/null +++ b/src/frontend/app/components/StopSheet.tsx @@ -0,0 +1,154 @@ +import React, { useEffect, useState } from "react"; +import { Sheet } from "react-modal-sheet"; +import { Link } from "react-router"; +import { useTranslation } from "react-i18next"; +import LineIcon from "./LineIcon"; +import { type StopDetails } from "../routes/estimates-$id"; +import "./StopSheet.css"; + +interface StopSheetProps { + isOpen: boolean; + onClose: () => void; + stopId: number; + stopName: string; +} + +const loadStopData = async (stopId: number): Promise<StopDetails> => { + const resp = await fetch(`/api/GetStopEstimates?id=${stopId}`, { + headers: { + Accept: "application/json", + }, + }); + return await resp.json(); +}; + +export const StopSheet: React.FC<StopSheetProps> = ({ + isOpen, + onClose, + stopId, + stopName, +}) => { + const { t } = useTranslation(); + const [data, setData] = useState<StopDetails | null>(null); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (isOpen && stopId) { + setLoading(true); + setData(null); + loadStopData(stopId) + .then((stopData) => { + setData(stopData); + }) + .catch((error) => { + console.error("Failed to load stop data:", error); + }) + .finally(() => { + setLoading(false); + }); + } + }, [isOpen, stopId]); + + const formatTime = (minutes: number) => { + if (minutes > 15) { + const now = new Date(); + const arrival = new Date(now.getTime() + minutes * 60000); + return Intl.DateTimeFormat( + typeof navigator !== "undefined" ? navigator.language : "en", + { + hour: "2-digit", + minute: "2-digit", + } + ).format(arrival); + } else { + return `${minutes} ${t("estimates.minutes", "min")}`; + } + }; + + const formatDistance = (meters: number) => { + if (meters > 1024) { + return `${(meters / 1000).toFixed(1)} km`; + } else { + return `${meters} ${t("estimates.meters", "m")}`; + } + }; + + // Show only the next 4 arrivals + const limitedEstimates = + data?.estimates.sort((a, b) => a.minutes - b.minutes).slice(0, 4) || []; + + return ( + <Sheet + isOpen={isOpen} + onClose={onClose} + detent="content-height" + > + <Sheet.Container> + <Sheet.Header /> + <Sheet.Content> + <div className="stop-sheet-content"> + <div className="stop-sheet-header"> + <h2 className="stop-sheet-title">{stopName}</h2> + <span className="stop-sheet-id">({stopId})</span> + </div> + + {loading && ( + <div className="stop-sheet-loading"> + {t("common.loading", "Loading...")} + </div> + )} + + {data && !loading && ( + <> + <div className="stop-sheet-estimates"> + <h3 className="stop-sheet-subtitle"> + {t("estimates.next_arrivals", "Next arrivals")} + </h3> + + {limitedEstimates.length === 0 ? ( + <div className="stop-sheet-no-estimates"> + {t("estimates.none", "No hay estimaciones disponibles")} + </div> + ) : ( + <div className="stop-sheet-estimates-list"> + {limitedEstimates.map((estimate, idx) => ( + <div key={idx} className="stop-sheet-estimate-item"> + <div className="stop-sheet-estimate-line"> + <LineIcon line={estimate.line} /> + </div> + <div className="stop-sheet-estimate-details"> + <div className="stop-sheet-estimate-route"> + {estimate.route} + </div> + <div className="stop-sheet-estimate-time"> + {formatTime(estimate.minutes)} + {estimate.meters > -1 && ( + <span className="stop-sheet-estimate-distance"> + {" • "} + {formatDistance(estimate.meters)} + </span> + )} + </div> + </div> + </div> + ))} + </div> + )} + </div> + + <Link + to={`/estimates/${stopId}`} + className="stop-sheet-view-all" + onClick={onClose} + > + {t("map.view_all_estimates", "Ver todas las estimaciones")} + </Link> + </> + )} + </div> + </Sheet.Content> + </Sheet.Container> + <Sheet.Backdrop /> + </Sheet> + ); +}; diff --git a/src/frontend/app/data/StopDataProvider.ts b/src/frontend/app/data/StopDataProvider.ts index 0c1e46e..efb0414 100644 --- a/src/frontend/app/data/StopDataProvider.ts +++ b/src/frontend/app/data/StopDataProvider.ts @@ -1,20 +1,20 @@ export interface CachedStopList { - timestamp: number; - data: Stop[]; + timestamp: number; + data: Stop[]; } export type StopName = { - original: string; - intersect?: string; -} + original: string; + intersect?: string; +}; export interface Stop { - stopId: number; - name: StopName; - latitude?: number; - longitude?: number; - lines: string[]; - favourite?: boolean; + stopId: number; + name: StopName; + latitude?: number; + longitude?: number; + lines: string[]; + favourite?: boolean; } // In-memory cache and lookup map @@ -25,136 +25,139 @@ let customNames: Record<number, string> = {}; // Initialize cachedStops and customNames once async function initStops() { - if (!cachedStops) { - const response = await fetch('/stops.json'); - const stops = await response.json() as Stop[]; - // build array and map - stopsMap = {}; - cachedStops = stops.map(stop => { - const entry = { ...stop, favourite: false } as Stop; - stopsMap[stop.stopId] = entry; - return entry; - }); - // load custom names - const rawCustom = localStorage.getItem('customStopNames'); - if (rawCustom) customNames = JSON.parse(rawCustom) as Record<number, string>; - } + if (!cachedStops) { + const response = await fetch("/stops.json"); + const stops = (await response.json()) as Stop[]; + // build array and map + stopsMap = {}; + cachedStops = stops.map((stop) => { + const entry = { ...stop, favourite: false } as Stop; + stopsMap[stop.stopId] = entry; + return entry; + }); + // load custom names + const rawCustom = localStorage.getItem("customStopNames"); + if (rawCustom) + customNames = JSON.parse(rawCustom) as Record<number, string>; + } } async function getStops(): Promise<Stop[]> { - await initStops(); - // update favourites - const rawFav = localStorage.getItem('favouriteStops'); - const favouriteStops = rawFav ? JSON.parse(rawFav) as number[] : []; - cachedStops!.forEach(stop => stop.favourite = favouriteStops.includes(stop.stopId)); - return cachedStops!; + await initStops(); + // update favourites + const rawFav = localStorage.getItem("favouriteStops"); + const favouriteStops = rawFav ? (JSON.parse(rawFav) as number[]) : []; + cachedStops!.forEach( + (stop) => (stop.favourite = favouriteStops.includes(stop.stopId)), + ); + return cachedStops!; } // New: get single stop by id async function getStopById(stopId: number): Promise<Stop | undefined> { - await initStops(); - const stop = stopsMap[stopId]; - if (stop) { - const rawFav = localStorage.getItem('favouriteStops'); - const favouriteStops = rawFav ? JSON.parse(rawFav) as number[] : []; - stop.favourite = favouriteStops.includes(stopId); - } - return stop; + await initStops(); + const stop = stopsMap[stopId]; + if (stop) { + const rawFav = localStorage.getItem("favouriteStops"); + const favouriteStops = rawFav ? (JSON.parse(rawFav) as number[]) : []; + stop.favourite = favouriteStops.includes(stopId); + } + return stop; } // Updated display name to include custom names function getDisplayName(stop: Stop): string { - if (customNames[stop.stopId]) return customNames[stop.stopId]; - const nameObj = stop.name; - return nameObj.intersect || nameObj.original; + if (customNames[stop.stopId]) return customNames[stop.stopId]; + const nameObj = stop.name; + return nameObj.intersect || nameObj.original; } // New: set or remove custom names function setCustomName(stopId: number, label: string) { - customNames[stopId] = label; - localStorage.setItem('customStopNames', JSON.stringify(customNames)); + customNames[stopId] = label; + localStorage.setItem("customStopNames", JSON.stringify(customNames)); } function removeCustomName(stopId: number) { - delete customNames[stopId]; - localStorage.setItem('customStopNames', JSON.stringify(customNames)); + delete customNames[stopId]; + localStorage.setItem("customStopNames", JSON.stringify(customNames)); } // New: get custom label for a stop function getCustomName(stopId: number): string | undefined { - return customNames[stopId]; + return customNames[stopId]; } function addFavourite(stopId: number) { - const rawFavouriteStops = localStorage.getItem('favouriteStops'); - let favouriteStops: number[] = []; - if (rawFavouriteStops) { - favouriteStops = JSON.parse(rawFavouriteStops) as number[]; - } + const rawFavouriteStops = localStorage.getItem("favouriteStops"); + let favouriteStops: number[] = []; + if (rawFavouriteStops) { + favouriteStops = JSON.parse(rawFavouriteStops) as number[]; + } - if (!favouriteStops.includes(stopId)) { - favouriteStops.push(stopId); - localStorage.setItem('favouriteStops', JSON.stringify(favouriteStops)); - } + if (!favouriteStops.includes(stopId)) { + favouriteStops.push(stopId); + localStorage.setItem("favouriteStops", JSON.stringify(favouriteStops)); + } } function removeFavourite(stopId: number) { - const rawFavouriteStops = localStorage.getItem('favouriteStops'); - let favouriteStops: number[] = []; - if (rawFavouriteStops) { - favouriteStops = JSON.parse(rawFavouriteStops) as number[]; - } + const rawFavouriteStops = localStorage.getItem("favouriteStops"); + let favouriteStops: number[] = []; + if (rawFavouriteStops) { + favouriteStops = JSON.parse(rawFavouriteStops) as number[]; + } - const newFavouriteStops = favouriteStops.filter(id => id !== stopId); - localStorage.setItem('favouriteStops', JSON.stringify(newFavouriteStops)); + const newFavouriteStops = favouriteStops.filter((id) => id !== stopId); + localStorage.setItem("favouriteStops", JSON.stringify(newFavouriteStops)); } function isFavourite(stopId: number): boolean { - const rawFavouriteStops = localStorage.getItem('favouriteStops'); - if (rawFavouriteStops) { - const favouriteStops = JSON.parse(rawFavouriteStops) as number[]; - return favouriteStops.includes(stopId); - } - return false; + const rawFavouriteStops = localStorage.getItem("favouriteStops"); + if (rawFavouriteStops) { + const favouriteStops = JSON.parse(rawFavouriteStops) as number[]; + return favouriteStops.includes(stopId); + } + return false; } const RECENT_STOPS_LIMIT = 10; function pushRecent(stopId: number) { - const rawRecentStops = localStorage.getItem('recentStops'); - let recentStops: Set<number> = new Set(); - if (rawRecentStops) { - recentStops = new Set(JSON.parse(rawRecentStops) as number[]); - } + const rawRecentStops = localStorage.getItem("recentStops"); + let recentStops: Set<number> = new Set(); + if (rawRecentStops) { + recentStops = new Set(JSON.parse(rawRecentStops) as number[]); + } - recentStops.add(stopId); - if (recentStops.size > RECENT_STOPS_LIMIT) { - const iterator = recentStops.values(); - const val = iterator.next().value as number; - recentStops.delete(val); - } + recentStops.add(stopId); + if (recentStops.size > RECENT_STOPS_LIMIT) { + const iterator = recentStops.values(); + const val = iterator.next().value as number; + recentStops.delete(val); + } - localStorage.setItem('recentStops', JSON.stringify(Array.from(recentStops))); + localStorage.setItem("recentStops", JSON.stringify(Array.from(recentStops))); } function getRecent(): number[] { - const rawRecentStops = localStorage.getItem('recentStops'); - if (rawRecentStops) { - return JSON.parse(rawRecentStops) as number[]; - } - return []; + const rawRecentStops = localStorage.getItem("recentStops"); + if (rawRecentStops) { + return JSON.parse(rawRecentStops) as number[]; + } + return []; } export default { - getStops, - getStopById, - getCustomName, - getDisplayName, - setCustomName, - removeCustomName, - addFavourite, - removeFavourite, - isFavourite, - pushRecent, - getRecent + getStops, + getStopById, + getCustomName, + getDisplayName, + setCustomName, + removeCustomName, + addFavourite, + removeFavourite, + isFavourite, + pushRecent, + getRecent, }; diff --git a/src/frontend/app/i18n/index.ts b/src/frontend/app/i18n/index.ts index a7ba6aa..492a9a9 100644 --- a/src/frontend/app/i18n/index.ts +++ b/src/frontend/app/i18n/index.ts @@ -1,15 +1,15 @@ -import i18n from 'i18next'; -import { initReactI18next } from 'react-i18next'; -import LanguageDetector from 'i18next-browser-languagedetector'; -import esES from './locales/es-ES.json'; -import glES from './locales/gl-ES.json'; -import enGB from './locales/en-GB.json'; +import i18n from "i18next"; +import { initReactI18next } from "react-i18next"; +import LanguageDetector from "i18next-browser-languagedetector"; +import esES from "./locales/es-ES.json"; +import glES from "./locales/gl-ES.json"; +import enGB from "./locales/en-GB.json"; // Add more languages as needed const resources = { - 'es-ES': { translation: esES }, - 'gl-ES': { translation: glES }, - 'en-GB': { translation: enGB }, + "es-ES": { translation: esES }, + "gl-ES": { translation: glES }, + "en-GB": { translation: enGB }, }; i18n @@ -17,14 +17,14 @@ i18n .use(initReactI18next) .init({ resources, - fallbackLng: 'es-ES', + fallbackLng: "es-ES", interpolation: { escapeValue: false, }, - supportedLngs: ['es-ES', 'gl-ES', 'en-GB'], + supportedLngs: ["es-ES", "gl-ES", "en-GB"], detection: { - order: ['querystring', 'cookie', 'localStorage', 'navigator', 'htmlTag'], - caches: ['localStorage', 'cookie'], + order: ["querystring", "cookie", "localStorage", "navigator", "htmlTag"], + caches: ["localStorage", "cookie"], }, }); diff --git a/src/frontend/app/i18n/locales/en-GB.json b/src/frontend/app/i18n/locales/en-GB.json index cd0780c..264290b 100644 --- a/src/frontend/app/i18n/locales/en-GB.json +++ b/src/frontend/app/i18n/locales/en-GB.json @@ -43,11 +43,13 @@ "arrival": "Arrival", "distance": "Distance", "not_available": "Not available", - "none": "No estimates available" + "none": "No estimates available", + "next_arrivals": "Next arrivals" }, "map": { "popup_title": "Stop", - "lines": "Lines" + "lines": "Lines", + "view_all_estimates": "View all estimates" }, "common": { "loading": "Loading...", diff --git a/src/frontend/app/i18n/locales/es-ES.json b/src/frontend/app/i18n/locales/es-ES.json index 2f2bb86..d7d78ad 100644 --- a/src/frontend/app/i18n/locales/es-ES.json +++ b/src/frontend/app/i18n/locales/es-ES.json @@ -43,11 +43,13 @@ "arrival": "Llegada", "distance": "Distancia", "not_available": "No disponible", - "none": "No hay estimaciones disponibles" + "none": "No hay estimaciones disponibles", + "next_arrivals": "Próximas llegadas" }, "map": { "popup_title": "Parada", - "lines": "Líneas" + "lines": "Líneas", + "view_all_estimates": "Ver todas las estimaciones" }, "common": { "loading": "Cargando...", diff --git a/src/frontend/app/i18n/locales/gl-ES.json b/src/frontend/app/i18n/locales/gl-ES.json index d2558e5..3012638 100644 --- a/src/frontend/app/i18n/locales/gl-ES.json +++ b/src/frontend/app/i18n/locales/gl-ES.json @@ -43,11 +43,13 @@ "arrival": "Chegada", "distance": "Distancia", "not_available": "Non dispoñible", - "none": "Non hai estimacións dispoñibles" + "none": "Non hai estimacións dispoñibles", + "next_arrivals": "Próximas chegadas" }, "map": { "popup_title": "Parada", - "lines": "Liñas" + "lines": "Liñas", + "view_all_estimates": "Ver todas as estimacións" }, "common": { "loading": "Cargando...", diff --git a/src/frontend/app/maps/styleloader.ts b/src/frontend/app/maps/styleloader.ts index f00aacc..cf285a5 100644 --- a/src/frontend/app/maps/styleloader.ts +++ b/src/frontend/app/maps/styleloader.ts @@ -1,49 +1,58 @@ import type { StyleSpecification } from "react-map-gl/maplibre"; -export async function loadStyle(styleName: string, colorScheme: string): Promise<StyleSpecification> { - const stylePath = `/maps/styles/${styleName}-${colorScheme}.json`; - const resp = await fetch(stylePath); +export async function loadStyle( + styleName: string, + colorScheme: string, +): Promise<StyleSpecification> { + const stylePath = `/maps/styles/${styleName}-${colorScheme}.json`; + const resp = await fetch(stylePath); - if (!resp.ok) { - throw new Error(`Failed to load style: ${stylePath}`); - } + if (!resp.ok) { + throw new Error(`Failed to load style: ${stylePath}`); + } - const style = await resp.json(); + const style = await resp.json(); - const baseUrl = window.location.origin; - const spritePath = style.sprite; + const baseUrl = window.location.origin; + const spritePath = style.sprite; - // Handle both string and array cases for spritePath - if (Array.isArray(spritePath)) { - // For array format, update each sprite object's URL to be absolute - style.sprite = spritePath.map(spriteObj => { - const isAbsoluteUrl = spriteObj.url.startsWith("http://") || spriteObj.url.startsWith("https://"); - if (isAbsoluteUrl) { - return spriteObj; - } + // Handle both string and array cases for spritePath + if (Array.isArray(spritePath)) { + // For array format, update each sprite object's URL to be absolute + style.sprite = spritePath.map((spriteObj) => { + const isAbsoluteUrl = + spriteObj.url.startsWith("http://") || + spriteObj.url.startsWith("https://"); + if (isAbsoluteUrl) { + return spriteObj; + } - return { - ...spriteObj, - url: `${baseUrl}${spriteObj.url}` - }; - }); - } else if (typeof spritePath === "string") { - if (!spritePath.startsWith("http://") && !spritePath.startsWith("https://")) { - style.sprite = `${baseUrl}${spritePath}`; - } + return { + ...spriteObj, + url: `${baseUrl}${spriteObj.url}`, + }; + }); + } else if (typeof spritePath === "string") { + if ( + !spritePath.startsWith("http://") && + !spritePath.startsWith("https://") + ) { + style.sprite = `${baseUrl}${spritePath}`; } + } - // Detect on each source if it the 'tiles' URLs are relative and convert them to absolute URLs - for (const sourceKey in style.sources) { - const source = style.sources[sourceKey]; - for (const tileKey in source.tiles) { - const tileUrl = source.tiles[tileKey]; - const isAbsoluteUrl = tileUrl.startsWith("http://") || tileUrl.startsWith("https://"); - if (!isAbsoluteUrl) { - source.tiles[tileKey] = `${baseUrl}${tileUrl}`; - } - } + // Detect on each source if it the 'tiles' URLs are relative and convert them to absolute URLs + for (const sourceKey in style.sources) { + const source = style.sources[sourceKey]; + for (const tileKey in source.tiles) { + const tileUrl = source.tiles[tileKey]; + const isAbsoluteUrl = + tileUrl.startsWith("http://") || tileUrl.startsWith("https://"); + if (!isAbsoluteUrl) { + source.tiles[tileKey] = `${baseUrl}${tileUrl}`; + } } + } - return style as StyleSpecification; + return style as StyleSpecification; } diff --git a/src/frontend/app/root.css b/src/frontend/app/root.css index a5024df..da3ab67 100644 --- a/src/frontend/app/root.css +++ b/src/frontend/app/root.css @@ -10,10 +10,10 @@ --star-color: #ffcc00; --message-background-color: #f8f9fa; - font-family: 'Roboto Variable', Roboto, Arial, sans-serif; + font-family: "Roboto Variable", Roboto, Arial, sans-serif; } -[data-theme='dark'] { +[data-theme="dark"] { --colour-scheme: dark; --background-color: #121212; --text-color: #ffffff; @@ -65,8 +65,8 @@ body { align-items: center; text-decoration: none; color: var(--text-color); - padding: .25rem 0; - border-radius: .5rem; + padding: 0.25rem 0; + border-radius: 0.5rem; } .navigation-bar__link svg { diff --git a/src/frontend/app/root.tsx b/src/frontend/app/root.tsx index 8ffbe86..55c6c16 100644 --- a/src/frontend/app/root.tsx +++ b/src/frontend/app/root.tsx @@ -5,7 +5,7 @@ import { Meta, Outlet, Scripts, - ScrollRestoration + ScrollRestoration, } from "react-router"; import type { Route } from "./+types/root"; @@ -24,13 +24,14 @@ maplibregl.addProtocol("pmtiles", pmtiles.tile); import "./i18n"; -if ('serviceWorker' in navigator) { - navigator.serviceWorker.register('/sw.js') +if ("serviceWorker" in navigator) { + navigator.serviceWorker + .register("/sw.js") .then((registration) => { - console.log('Service Worker registered with scope:', registration.scope); + console.log("Service Worker registered with scope:", registration.scope); }) .catch((error) => { - console.error('Service Worker registration failed:', error); + console.error("Service Worker registration failed:", error); }); } @@ -54,15 +55,27 @@ export function Layout({ children }: { children: React.ReactNode }) { <link rel="canonical" href="https://urbanovigo.costas.dev/" /> - <meta name="description" content="Aplicación web para encontrar paradas y tiempos de llegada de los autobuses urbanos de Vigo, España." /> - <meta name="keywords" content="Vigo, autobús, urbano, parada, tiempo, llegada, transporte, público, España" /> + <meta + name="description" + content="Aplicación web para encontrar paradas y tiempos de llegada de los autobuses urbanos de Vigo, España." + /> + <meta + name="keywords" + content="Vigo, autobús, urbano, parada, tiempo, llegada, transporte, público, España" + /> <meta name="author" content="Ariel Costas Guerrero" /> <meta property="og:title" content="UrbanoVigo Web" /> <meta property="og:type" content="website" /> <meta property="og:url" content="https://urbanovigo.costas.dev/" /> - <meta property="og:image" content="https://urbanovigo.costas.dev/logo-512.jpg" /> - <meta property="og:description" content="Aplicación web para encontrar paradas y tiempos de llegada de los autobuses urbanos de Vigo, España." /> + <meta + property="og:image" + content="https://urbanovigo.costas.dev/logo-512.jpg" + /> + <meta + property="og:description" + content="Aplicación web para encontrar paradas y tiempos de llegada de los autobuses urbanos de Vigo, España." + /> <link rel="manifest" href="/manifest.webmanifest" /> @@ -82,20 +95,20 @@ export function Layout({ children }: { children: React.ReactNode }) { ); } - // Helper: check if coordinates are within Vigo bounds - function isWithinVigo(lngLat: LngLatLike): boolean { - let lng: number, lat: number; - if (Array.isArray(lngLat)) { - [lng, lat] = lngLat; - } else if ('lng' in lngLat && 'lat' in lngLat) { - lng = lngLat.lng; - lat = lngLat.lat; - } else { - return false; - } - // Rough bounding box for Vigo - return lat >= 42.18 && lat <= 42.30 && lng >= -8.78 && lng <= -8.65; +// Helper: check if coordinates are within Vigo bounds +function isWithinVigo(lngLat: LngLatLike): boolean { + let lng: number, lat: number; + if (Array.isArray(lngLat)) { + [lng, lat] = lngLat; + } else if ("lng" in lngLat && "lat" in lngLat) { + lng = lngLat.lng; + lat = lngLat.lat; + } else { + return false; } + // Rough bounding box for Vigo + return lat >= 42.18 && lat <= 42.3 && lng >= -8.78 && lng <= -8.65; +} import NavBar from "./components/NavBar"; @@ -109,9 +122,6 @@ export default function App() { <NavBar /> </footer> </AppProvider> - - - ); } diff --git a/src/frontend/app/routes.tsx b/src/frontend/app/routes.tsx index 1bca5e8..9dd8a66 100644 --- a/src/frontend/app/routes.tsx +++ b/src/frontend/app/routes.tsx @@ -5,5 +5,5 @@ export default [ route("/stops", "routes/stoplist.tsx"), route("/map", "routes/map.tsx"), route("/estimates/:id", "routes/estimates-$id.tsx"), - route("/settings", "routes/settings.tsx") + route("/settings", "routes/settings.tsx"), ] satisfies RouteConfig; diff --git a/src/frontend/app/routes/estimates-$id.css b/src/frontend/app/routes/estimates-$id.css index 86ca09b..3905f3e 100644 --- a/src/frontend/app/routes/estimates-$id.css +++ b/src/frontend/app/routes/estimates-$id.css @@ -102,4 +102,4 @@ .edit-icon:hover { color: var(--star-color); -}
\ No newline at end of file +} diff --git a/src/frontend/app/routes/estimates-$id.tsx b/src/frontend/app/routes/estimates-$id.tsx index e0e4fff..f2ef83a 100644 --- a/src/frontend/app/routes/estimates-$id.tsx +++ b/src/frontend/app/routes/estimates-$id.tsx @@ -1,7 +1,7 @@ import { type JSX, useEffect, useState } from "react"; import { useParams } from "react-router"; import StopDataProvider from "../data/StopDataProvider"; -import { Star, Edit2 } from 'lucide-react'; +import { Star, Edit2 } from "lucide-react"; import "./estimates-$id.css"; import { RegularTable } from "../components/RegularTable"; import { useApp } from "../AppContext"; @@ -9,97 +9,99 @@ import { GroupedTable } from "../components/GroupedTable"; import { useTranslation } from "react-i18next"; export interface StopDetails { - stop: { - id: number; - name: string; - latitude: number; - longitude: number; - } - estimates: { - line: string; - route: string; - minutes: number; - meters: number; - }[] + stop: { + id: number; + name: string; + latitude: number; + longitude: number; + }; + estimates: { + line: string; + route: string; + minutes: number; + meters: number; + }[]; } const loadData = async (stopId: string) => { - const resp = await fetch(`/api/GetStopEstimates?id=${stopId}`, { - headers: { - 'Accept': 'application/json', - } - }); - return await resp.json(); + const resp = await fetch(`/api/GetStopEstimates?id=${stopId}`, { + headers: { + Accept: "application/json", + }, + }); + return await resp.json(); }; export default function Estimates() { - const { t } = useTranslation(); - const params = useParams(); - const stopIdNum = parseInt(params.id ?? ""); - const [customName, setCustomName] = useState<string | undefined>(undefined); - const [data, setData] = useState<StopDetails | null>(null); - const [dataDate, setDataDate] = useState<Date | null>(null); - const [favourited, setFavourited] = useState(false); - const { tableStyle } = useApp(); + const { t } = useTranslation(); + const params = useParams(); + const stopIdNum = parseInt(params.id ?? ""); + const [customName, setCustomName] = useState<string | undefined>(undefined); + const [data, setData] = useState<StopDetails | null>(null); + const [dataDate, setDataDate] = useState<Date | null>(null); + const [favourited, setFavourited] = useState(false); + const { tableStyle } = useApp(); - useEffect(() => { - loadData(params.id!) - .then((body: StopDetails) => { - setData(body); - setDataDate(new Date()); - setCustomName(StopDataProvider.getCustomName(stopIdNum)); - }) + useEffect(() => { + loadData(params.id!).then((body: StopDetails) => { + setData(body); + setDataDate(new Date()); + setCustomName(StopDataProvider.getCustomName(stopIdNum)); + }); + StopDataProvider.pushRecent(parseInt(params.id ?? "")); - StopDataProvider.pushRecent(parseInt(params.id ?? "")); + setFavourited(StopDataProvider.isFavourite(parseInt(params.id ?? ""))); + }, [params.id]); - setFavourited( - StopDataProvider.isFavourite(parseInt(params.id ?? "")) - ); - }, [params.id]); + const toggleFavourite = () => { + if (favourited) { + StopDataProvider.removeFavourite(stopIdNum); + setFavourited(false); + } else { + StopDataProvider.addFavourite(stopIdNum); + setFavourited(true); + } + }; + const handleRename = () => { + const current = customName ?? data?.stop.name; + const input = window.prompt("Custom name for this stop:", current); + if (input === null) return; // cancelled + const trimmed = input.trim(); + if (trimmed === "") { + StopDataProvider.removeCustomName(stopIdNum); + setCustomName(undefined); + } else { + StopDataProvider.setCustomName(stopIdNum, trimmed); + setCustomName(trimmed); + } + }; - const toggleFavourite = () => { - if (favourited) { - StopDataProvider.removeFavourite(stopIdNum); - setFavourited(false); - } else { - StopDataProvider.addFavourite(stopIdNum); - setFavourited(true); - } - } + if (data === null) + return <h1 className="page-title">{t("common.loading")}</h1>; - const handleRename = () => { - const current = customName ?? data?.stop.name; - const input = window.prompt('Custom name for this stop:', current); - if (input === null) return; // cancelled - const trimmed = input.trim(); - if (trimmed === '') { - StopDataProvider.removeCustomName(stopIdNum); - setCustomName(undefined); - } else { - StopDataProvider.setCustomName(stopIdNum, trimmed); - setCustomName(trimmed); - } - }; + return ( + <div className="page-container"> + <div className="estimates-header"> + <h1 className="page-title"> + <Star + className={`star-icon ${favourited ? "active" : ""}`} + onClick={toggleFavourite} + /> + <Edit2 className="edit-icon" onClick={handleRename} /> + {customName ?? data.stop.name}{" "} + <span className="estimates-stop-id">({data.stop.id})</span> + </h1> + </div> - if (data === null) return <h1 className="page-title">{t('common.loading')}</h1> - - return ( - <div className="page-container"> - <div className="estimates-header"> - <h1 className="page-title"> - <Star className={`star-icon ${favourited ? 'active' : ''}`} onClick={toggleFavourite} /> - <Edit2 className="edit-icon" onClick={handleRename} /> - {(customName ?? data.stop.name)} <span className="estimates-stop-id">({data.stop.id})</span> - </h1> - </div> - - <div className="table-responsive"> - {tableStyle === 'grouped' ? - <GroupedTable data={data} dataDate={dataDate} /> : - <RegularTable data={data} dataDate={dataDate} />} - </div> - </div> - ) + <div className="table-responsive"> + {tableStyle === "grouped" ? ( + <GroupedTable data={data} dataDate={dataDate} /> + ) : ( + <RegularTable data={data} dataDate={dataDate} /> + )} + </div> + </div> + ); } diff --git a/src/frontend/app/routes/index.tsx b/src/frontend/app/routes/index.tsx index 7c8ab40..252abec 100644 --- a/src/frontend/app/routes/index.tsx +++ b/src/frontend/app/routes/index.tsx @@ -1,5 +1,5 @@ import { Navigate, redirect, type LoaderFunction } from "react-router"; export default function Index() { - return <Navigate to={"/stops"} replace />; + return <Navigate to={"/stops"} replace />; } diff --git a/src/frontend/app/routes/map.css b/src/frontend/app/routes/map.css index 115df46..0b3ebe5 100644 --- a/src/frontend/app/routes/map.css +++ b/src/frontend/app/routes/map.css @@ -1,86 +1,86 @@ /* Map page specific styles */ .map-container { - height: calc(100dvh - 140px); - margin: -16px; - margin-bottom: 1rem; - position: relative; + height: calc(100dvh - 140px); + margin: -16px; + margin-bottom: 1rem; + position: relative; } /* Fullscreen map styles */ .fullscreen-container { - position: absolute; - top: 0; - left: 0; - width: 100vw; - height: 100dvh; - padding: 0; - margin: 0; - max-width: none; - overflow: hidden; + position: absolute; + top: 0; + left: 0; + width: 100vw; + height: 100dvh; + padding: 0; + margin: 0; + max-width: none; + overflow: hidden; } .fullscreen-map { - width: 100%; - height: 100%; + width: 100%; + height: 100%; } .fullscreen-loading { - display: flex; - justify-content: center; - align-items: center; - height: 100dvh; - width: 100vw; - font-size: 1.8rem; - font-weight: 600; - color: var(--text-color); + display: flex; + justify-content: center; + align-items: center; + height: 100dvh; + width: 100vw; + font-size: 1.8rem; + font-weight: 600; + color: var(--text-color); } /* Map marker and popup styles */ .stop-marker { - box-shadow: 0 0 5px rgba(0, 0, 0, 0.3); - transition: all 0.2s ease-in-out; + box-shadow: 0 0 5px rgba(0, 0, 0, 0.3); + transition: all 0.2s ease-in-out; } .stop-marker:hover { - transform: scale(1.2); + transform: scale(1.2); } .maplibregl-popup { - max-width: 250px; + max-width: 250px; } .maplibregl-popup-content { - padding: 12px; - border-radius: 8px; - box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); + padding: 12px; + border-radius: 8px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); } .popup-line-icons { - display: flex; - flex-wrap: wrap; - margin: 6px 0; - gap: 5px; + display: flex; + flex-wrap: wrap; + margin: 6px 0; + gap: 5px; } .popup-line { - display: inline-block; - background-color: var(--button-background-color); - color: white; - padding: 2px 6px; - margin-right: 4px; - border-radius: 4px; - font-size: 0.8rem; - font-weight: 500; + display: inline-block; + background-color: var(--button-background-color); + color: white; + padding: 2px 6px; + margin-right: 4px; + border-radius: 4px; + font-size: 0.8rem; + font-weight: 500; } .popup-link { - display: block; - margin-top: 8px; - color: var(--button-background-color); - text-decoration: none; - font-weight: 500; + display: block; + margin-top: 8px; + color: var(--button-background-color); + text-decoration: none; + font-weight: 500; } .popup-link:hover { - text-decoration: underline; + text-decoration: underline; } diff --git a/src/frontend/app/routes/map.tsx b/src/frontend/app/routes/map.tsx index ca095e2..5887b9c 100644 --- a/src/frontend/app/routes/map.tsx +++ b/src/frontend/app/routes/map.tsx @@ -1,13 +1,21 @@ import StopDataProvider from "../data/StopDataProvider"; -import './map.css'; +import "./map.css"; -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useRef, useState } from "react"; import { useApp } from "../AppContext"; -import Map, { AttributionControl, GeolocateControl, Layer, NavigationControl, Popup, Source, type MapRef, type MapLayerMouseEvent, type StyleSpecification } from "react-map-gl/maplibre"; +import Map, { + AttributionControl, + GeolocateControl, + Layer, + NavigationControl, + Source, + type MapRef, + type MapLayerMouseEvent, + type StyleSpecification, +} from "react-map-gl/maplibre"; import { loadStyle } from "app/maps/styleloader"; -import type { Feature as GeoJsonFeature, Point } from 'geojson'; -import LineIcon from "~/components/LineIcon"; -import { Link } from "react-router"; +import type { Feature as GeoJsonFeature, Point } from "geojson"; +import { StopSheet } from "~/components/StopSheet"; import { useTranslation } from "react-i18next"; // Default minimal fallback style before dynamic loading @@ -16,154 +24,154 @@ const defaultStyle: StyleSpecification = { glyphs: `${window.location.origin}/maps/fonts/{fontstack}/{range}.pbf`, sprite: `${window.location.origin}/maps/spritesheet/sprite`, sources: {}, - layers: [] + layers: [], }; // Componente principal del mapa export default function StopMap() { - const { t } = useTranslation(); - const [stops, setStops] = useState<GeoJsonFeature<Point, { stopId: number; name: string; lines: string[] }>[]>([]); - const [popupInfo, setPopupInfo] = useState<any>(null); - const { mapState, updateMapState, theme } = useApp(); - const mapRef = useRef<MapRef>(null); - const [mapStyleKey, setMapStyleKey] = useState<string>("light"); + const { t } = useTranslation(); + const [stops, setStops] = useState< + GeoJsonFeature<Point, { stopId: number; name: string; lines: string[] }>[] + >([]); + const [selectedStop, setSelectedStop] = useState<{ + stopId: number; + name: string; + } | null>(null); + const [isSheetOpen, setIsSheetOpen] = useState(false); + const { mapState, updateMapState, theme } = useApp(); + const mapRef = useRef<MapRef>(null); + const [mapStyleKey, setMapStyleKey] = useState<string>("light"); - // Style state for Map component - const [mapStyle, setMapStyle] = useState<StyleSpecification>(defaultStyle); + // Style state for Map component + const [mapStyle, setMapStyle] = useState<StyleSpecification>(defaultStyle); - // Handle click events on clusters and individual stops - const onMapClick = (e: MapLayerMouseEvent) => { - const features = e.features; - if (!features || features.length === 0) return; - const feature = features[0]; - const props: any = feature.properties; + // Handle click events on clusters and individual stops + const onMapClick = (e: MapLayerMouseEvent) => { + const features = e.features; + if (!features || features.length === 0) return; + const feature = features[0]; + const props: any = feature.properties; - handlePointClick(feature); - }; + handlePointClick(feature); + }; - useEffect(() => { - StopDataProvider.getStops().then(data => { - const features: GeoJsonFeature<Point, { stopId: number; name: string; lines: string[] }>[] = data.map(s => ({ - type: "Feature", - geometry: { type: "Point", coordinates: [s.longitude as number, s.latitude as number] }, - properties: { stopId: s.stopId, name: s.name.original, lines: s.lines } - })); - setStops(features); - }); - }, []); + useEffect(() => { + StopDataProvider.getStops().then((data) => { + const features: GeoJsonFeature< + Point, + { stopId: number; name: string; lines: string[] } + >[] = data.map((s) => ({ + type: "Feature", + geometry: { + type: "Point", + coordinates: [s.longitude as number, s.latitude as number], + }, + properties: { stopId: s.stopId, name: s.name.original, lines: s.lines }, + })); + setStops(features); + }); + }, []); - useEffect(() => { - const styleName = "carto"; - loadStyle(styleName, theme) - .then(style => setMapStyle(style)) - .catch(error => console.error("Failed to load map style:", error)); - }, [mapStyleKey, theme]); + useEffect(() => { + const styleName = "carto"; + loadStyle(styleName, theme) + .then((style) => setMapStyle(style)) + .catch((error) => console.error("Failed to load map style:", error)); + }, [mapStyleKey, theme]); - useEffect(() => { - const handleMapChange = () => { - if (!mapRef.current) return; - const map = mapRef.current.getMap(); - if (!map) return; - const center = map.getCenter(); - const zoom = map.getZoom(); - updateMapState([center.lat, center.lng], zoom); - }; + useEffect(() => { + const handleMapChange = () => { + if (!mapRef.current) return; + const map = mapRef.current.getMap(); + if (!map) return; + const center = map.getCenter(); + const zoom = map.getZoom(); + updateMapState([center.lat, center.lng], zoom); + }; - if (mapRef.current) { - const map = mapRef.current.getMap(); - if (map) { - map.on('moveend', handleMapChange); - } - } + if (mapRef.current) { + const map = mapRef.current.getMap(); + if (map) { + map.on("moveend", handleMapChange); + } + } - return () => { - if (mapRef.current) { - const map = mapRef.current.getMap(); - if (map) { - map.off('moveend', handleMapChange); - } - } - }; - }, [mapRef.current]); + return () => { + if (mapRef.current) { + const map = mapRef.current.getMap(); + if (map) { + map.off("moveend", handleMapChange); + } + } + }; + }, [mapRef.current]); - const getLatitude = (center: any) => Array.isArray(center) ? center[0] : center.lat; - const getLongitude = (center: any) => Array.isArray(center) ? center[1] : center.lng; + const getLatitude = (center: any) => + Array.isArray(center) ? center[0] : center.lat; + const getLongitude = (center: any) => + Array.isArray(center) ? center[1] : center.lng; - const handlePointClick = (feature: any) => { - const props: any = feature.properties; - // fetch full stop to get lines array - StopDataProvider.getStopById(props.stopId).then(stop => { - if (!stop) return; - setPopupInfo({ - geometry: feature.geometry, - properties: { - stopId: stop.stopId, - name: stop.name.original, - lines: stop.lines - } - }); - }); - }; + const handlePointClick = (feature: any) => { + const props: any = feature.properties; + // fetch full stop to get lines array + StopDataProvider.getStopById(props.stopId).then((stop) => { + if (!stop) return; + setSelectedStop({ + stopId: stop.stopId, + name: stop.name.original, + }); + setIsSheetOpen(true); + }); + }; - return ( - <Map - mapStyle={mapStyle} - style={{ width: '100%', height: '100%' }} - interactiveLayerIds={["stops"]} - onClick={onMapClick} - minZoom={11} - scrollZoom - pitch={0} - roll={0} - ref={mapRef} - initialViewState={{ - latitude: getLatitude(mapState.center), - longitude: getLongitude(mapState.center), - zoom: mapState.zoom, - }} - attributionControl={false} - > - <NavigationControl position="top-right" /> - <GeolocateControl position="top-right" trackUserLocation={true} /> - <AttributionControl position="bottom-right" compact={false} /> + return ( + <Map + mapStyle={mapStyle} + style={{ width: "100%", height: "100%" }} + interactiveLayerIds={["stops"]} + onClick={onMapClick} + minZoom={11} + scrollZoom + pitch={0} + roll={0} + ref={mapRef} + initialViewState={{ + latitude: getLatitude(mapState.center), + longitude: getLongitude(mapState.center), + zoom: mapState.zoom, + }} + attributionControl={false} + > + <NavigationControl position="top-right" /> + <GeolocateControl position="top-right" trackUserLocation={true} /> + <AttributionControl position="bottom-right" compact={false} /> - <Source - id="stops-source" - type="geojson" - data={{ type: "FeatureCollection", features: stops }} - /> + <Source + id="stops-source" + type="geojson" + data={{ type: "FeatureCollection", features: stops }} + /> - <Layer - id="stops" - type="symbol" - source="stops-source" - layout={{ - "icon-image": "stop", - "icon-size": ["interpolate", ["linear"], ["zoom"], 11, 0.4, 16, 0.8], - "icon-allow-overlap": true, - "icon-ignore-placement": true, - }} - /> + <Layer + id="stops" + type="symbol" + source="stops-source" + layout={{ + "icon-image": "stop", + "icon-size": ["interpolate", ["linear"], ["zoom"], 11, 0.4, 16, 0.8], + "icon-allow-overlap": true, + "icon-ignore-placement": true, + }} + /> - {popupInfo && ( - <Popup - latitude={popupInfo.geometry.coordinates[1]} - longitude={popupInfo.geometry.coordinates[0]} - onClose={() => setPopupInfo(null)} - > - <div> - <h3>{popupInfo.properties.name}</h3> - <div> - {popupInfo.properties.lines.map((line: string) => ( - <LineIcon line={line} key={line} /> - ))} - </div> - <Link to={`/estimates/${popupInfo.properties.stopId}`} className="popup-link"> - Ver parada - </Link> - </div> - </Popup> - )} - </Map> - ); + {selectedStop && ( + <StopSheet + isOpen={isSheetOpen} + onClose={() => setIsSheetOpen(false)} + stopId={selectedStop.stopId} + stopName={selectedStop.name} + /> + )} + </Map> + ); } diff --git a/src/frontend/app/routes/settings.tsx b/src/frontend/app/routes/settings.tsx index e657c03..c08b2c9 100644 --- a/src/frontend/app/routes/settings.tsx +++ b/src/frontend/app/routes/settings.tsx @@ -3,74 +3,130 @@ import "./settings.css"; import { useTranslation } from "react-i18next"; export default function Settings() { - const { t, i18n } = useTranslation(); - const { theme, setTheme, tableStyle, setTableStyle, mapPositionMode, setMapPositionMode } = useApp(); + const { t, i18n } = useTranslation(); + const { + theme, + setTheme, + tableStyle, + setTableStyle, + mapPositionMode, + setMapPositionMode, + } = useApp(); - 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"> - <label htmlFor="theme" className="form-label-inline">{t('about.theme')}</label> - <select id="theme" className="form-select-inline" value={theme} onChange={(e) => setTheme(e.target.value as "light" | "dark")}> - <option value="light">{t('about.theme_light')}</option> - <option value="dark">{t('about.theme_dark')}</option> - </select> - </div> - <div className="settings-content-inline"> - <label htmlFor="tableStyle" className="form-label-inline">{t('about.table_style')}</label> - <select id="tableStyle" className="form-select-inline" value={tableStyle} onChange={(e) => setTableStyle(e.target.value as "regular" | "grouped")}> - <option value="regular">{t('about.table_style_regular')}</option> - <option value="grouped">{t('about.table_style_grouped')}</option> - </select> - </div> - <div className="settings-content-inline"> - <label htmlFor="mapPositionMode" className="form-label-inline">{t('about.map_position_mode')}</label> - <select id="mapPositionMode" className="form-select-inline" value={mapPositionMode} onChange={e => setMapPositionMode(e.target.value as 'gps' | 'last')}> - <option value="gps">{t('about.map_position_gps')}</option> - <option value="last">{t('about.map_position_last')}</option> - </select> - </div> - <div className="settings-content-inline"> - <label htmlFor="language" className="form-label-inline">Idioma:</label> - <select - id="language" - className="form-select-inline" - value={i18n.language} - onChange={e => i18n.changeLanguage(e.target.value)} - > - <option value="es-ES">Español</option> - <option value="gl-ES">Galego</option> - <option value="en-GB">English</option> - </select> - </div> - <details className="form-details"> - <summary>{t('about.details_summary')}</summary> - <p>{t('about.details_table')}</p> - <dl> - <dt>{t('about.table_style_regular')}</dt> - <dd>{t('about.details_regular')}</dd> - <dt>{t('about.table_style_grouped')}</dt> - <dd>{t('about.details_grouped')}</dd> - </dl> - </details> - </section> - <h2>{t('about.credits')}</h2> - <p> - <a href="https://github.com/arielcostas/urbanovigo-web" 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> - <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> + 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"> + <label htmlFor="theme" className="form-label-inline"> + {t("about.theme")} + </label> + <select + id="theme" + className="form-select-inline" + value={theme} + onChange={(e) => setTheme(e.target.value as "light" | "dark")} + > + <option value="light">{t("about.theme_light")}</option> + <option value="dark">{t("about.theme_dark")}</option> + </select> </div> - ) + <div className="settings-content-inline"> + <label htmlFor="tableStyle" className="form-label-inline"> + {t("about.table_style")} + </label> + <select + id="tableStyle" + className="form-select-inline" + value={tableStyle} + onChange={(e) => + setTableStyle(e.target.value as "regular" | "grouped") + } + > + <option value="regular">{t("about.table_style_regular")}</option> + <option value="grouped">{t("about.table_style_grouped")}</option> + </select> + </div> + <div className="settings-content-inline"> + <label htmlFor="mapPositionMode" className="form-label-inline"> + {t("about.map_position_mode")} + </label> + <select + id="mapPositionMode" + className="form-select-inline" + value={mapPositionMode} + onChange={(e) => + setMapPositionMode(e.target.value as "gps" | "last") + } + > + <option value="gps">{t("about.map_position_gps")}</option> + <option value="last">{t("about.map_position_last")}</option> + </select> + </div> + <div className="settings-content-inline"> + <label htmlFor="language" className="form-label-inline"> + Idioma: + </label> + <select + id="language" + className="form-select-inline" + value={i18n.language} + onChange={(e) => i18n.changeLanguage(e.target.value)} + > + <option value="es-ES">Español</option> + <option value="gl-ES">Galego</option> + <option value="en-GB">English</option> + </select> + </div> + <details className="form-details"> + <summary>{t("about.details_summary")}</summary> + <p>{t("about.details_table")}</p> + <dl> + <dt>{t("about.table_style_regular")}</dt> + <dd>{t("about.details_regular")}</dd> + <dt>{t("about.table_style_grouped")}</dt> + <dd>{t("about.details_grouped")}</dd> + </dl> + </details> + </section> + <h2>{t("about.credits")}</h2> + <p> + <a + href="https://github.com/arielcostas/urbanovigo-web" + 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> + <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> + ); } diff --git a/src/frontend/app/routes/stoplist.tsx b/src/frontend/app/routes/stoplist.tsx index 9404b39..58cdab4 100644 --- a/src/frontend/app/routes/stoplist.tsx +++ b/src/frontend/app/routes/stoplist.tsx @@ -2,125 +2,143 @@ import { useEffect, useMemo, useRef, useState } from "react"; import StopDataProvider, { type Stop } from "../data/StopDataProvider"; import StopItem from "../components/StopItem"; import Fuse from "fuse.js"; -import './stoplist.css'; +import "./stoplist.css"; import { useTranslation } from "react-i18next"; export default function StopList() { - const { t } = useTranslation(); - const [data, setData] = useState<Stop[] | null>(null) - const [searchResults, setSearchResults] = useState<Stop[] | null>(null); - const searchTimeout = useRef<NodeJS.Timeout | null>(null); + const { t } = useTranslation(); + const [data, setData] = useState<Stop[] | null>(null); + const [searchResults, setSearchResults] = useState<Stop[] | null>(null); + const searchTimeout = useRef<NodeJS.Timeout | null>(null); - const randomPlaceholder = useMemo(() => t('stoplist.search_placeholder'), [t]); - const fuse = useMemo(() => new Fuse(data || [], { threshold: 0.3, keys: ['name.original'] }), [data]); + const randomPlaceholder = useMemo( + () => t("stoplist.search_placeholder"), + [t], + ); + const fuse = useMemo( + () => new Fuse(data || [], { threshold: 0.3, keys: ["name.original"] }), + [data], + ); - useEffect(() => { - StopDataProvider.getStops().then((stops: Stop[]) => setData(stops)) - }, []); + useEffect(() => { + StopDataProvider.getStops().then((stops: Stop[]) => setData(stops)); + }, []); - const handleStopSearch = (event: React.ChangeEvent<HTMLInputElement>) => { - const stopName = event.target.value || ""; + const handleStopSearch = (event: React.ChangeEvent<HTMLInputElement>) => { + const stopName = event.target.value || ""; - if (searchTimeout.current) { - clearTimeout(searchTimeout.current); - } + if (searchTimeout.current) { + clearTimeout(searchTimeout.current); + } - searchTimeout.current = setTimeout(() => { - if (stopName.length === 0) { - setSearchResults(null); - return; - } + searchTimeout.current = setTimeout(() => { + if (stopName.length === 0) { + setSearchResults(null); + return; + } - if (!data) { - console.error("No data available for search"); - return; - } + if (!data) { + console.error("No data available for search"); + return; + } - const results = fuse.search(stopName); - const items = results.map(result => result.item); - setSearchResults(items); - }, 300); - } + const results = fuse.search(stopName); + const items = results.map((result) => result.item); + setSearchResults(items); + }, 300); + }; - const favouritedStops = useMemo(() => { - return data?.filter(stop => stop.favourite) ?? [] - }, [data]) + const favouritedStops = useMemo(() => { + return data?.filter((stop) => stop.favourite) ?? []; + }, [data]); - const recentStops = useMemo(() => { - // no recent items if data not loaded - if (!data) return null; - const recentIds = StopDataProvider.getRecent(); - if (recentIds.length === 0) return null; - // map and filter out missing entries - const stopsList = recentIds - .map(id => data.find(stop => stop.stopId === id)) - .filter((s): s is Stop => Boolean(s)); - return stopsList.reverse(); - }, [data]); + const recentStops = useMemo(() => { + // no recent items if data not loaded + if (!data) return null; + const recentIds = StopDataProvider.getRecent(); + if (recentIds.length === 0) return null; + // map and filter out missing entries + const stopsList = recentIds + .map((id) => data.find((stop) => stop.stopId === id)) + .filter((s): s is Stop => Boolean(s)); + return stopsList.reverse(); + }, [data]); - if (data === null) return <h1 className="page-title">{t('common.loading')}</h1> + if (data === null) + return <h1 className="page-title">{t("common.loading")}</h1>; - return ( - <div className="page-container"> - <h1 className="page-title">UrbanoVigo Web</h1> + return ( + <div className="page-container"> + <h1 className="page-title">UrbanoVigo Web</h1> - <form className="search-form"> - <div className="form-group"> - <label className="form-label" htmlFor="stopName"> - {t('stoplist.search_label', 'Buscar paradas')} - </label> - <input className="form-input" type="text" placeholder={randomPlaceholder} id="stopName" onChange={handleStopSearch} /> - </div> - </form> + <form className="search-form"> + <div className="form-group"> + <label className="form-label" htmlFor="stopName"> + {t("stoplist.search_label", "Buscar paradas")} + </label> + <input + className="form-input" + type="text" + placeholder={randomPlaceholder} + id="stopName" + onChange={handleStopSearch} + /> + </div> + </form> - {searchResults && searchResults.length > 0 && ( - <div className="list-container"> - <h2 className="page-subtitle">{t('stoplist.search_results', 'Resultados de la búsqueda')}</h2> - <ul className="list"> - {searchResults.map((stop: Stop) => ( - <StopItem key={stop.stopId} stop={stop} /> - ))} - </ul> - </div> - )} + {searchResults && searchResults.length > 0 && ( + <div className="list-container"> + <h2 className="page-subtitle"> + {t("stoplist.search_results", "Resultados de la búsqueda")} + </h2> + <ul className="list"> + {searchResults.map((stop: Stop) => ( + <StopItem key={stop.stopId} stop={stop} /> + ))} + </ul> + </div> + )} - <div className="list-container"> - <h2 className="page-subtitle">{t('stoplist.favourites')}</h2> + <div className="list-container"> + <h2 className="page-subtitle">{t("stoplist.favourites")}</h2> - {favouritedStops?.length === 0 && ( - <p className="message"> - {t('stoplist.no_favourites', 'Accede a una parada y márcala como favorita para verla aquí.')} - </p> - )} + {favouritedStops?.length === 0 && ( + <p className="message"> + {t( + "stoplist.no_favourites", + "Accede a una parada y márcala como favorita para verla aquí.", + )} + </p> + )} - <ul className="list"> - {favouritedStops?.sort((a, b) => a.stopId - b.stopId).map((stop: Stop) => ( - <StopItem key={stop.stopId} stop={stop} /> - ))} - </ul> - </div> + <ul className="list"> + {favouritedStops + ?.sort((a, b) => a.stopId - b.stopId) + .map((stop: Stop) => <StopItem key={stop.stopId} stop={stop} />)} + </ul> + </div> - {recentStops && recentStops.length > 0 && ( - <div className="list-container"> - <h2 className="page-subtitle">{t('stoplist.recents')}</h2> + {recentStops && recentStops.length > 0 && ( + <div className="list-container"> + <h2 className="page-subtitle">{t("stoplist.recents")}</h2> - <ul className="list"> - {recentStops.map((stop: Stop) => ( - <StopItem key={stop.stopId} stop={stop} /> - ))} - </ul> - </div> - )} + <ul className="list"> + {recentStops.map((stop: Stop) => ( + <StopItem key={stop.stopId} stop={stop} /> + ))} + </ul> + </div> + )} - <div className="list-container"> - <h2 className="page-subtitle">{t('stoplist.all_stops', 'Paradas')}</h2> + <div className="list-container"> + <h2 className="page-subtitle">{t("stoplist.all_stops", "Paradas")}</h2> - <ul className="list"> - {data?.sort((a, b) => a.stopId - b.stopId).map((stop: Stop) => ( - <StopItem key={stop.stopId} stop={stop} /> - ))} - </ul> - </div> - </div> - ) + <ul className="list"> + {data + ?.sort((a, b) => a.stopId - b.stopId) + .map((stop: Stop) => <StopItem key={stop.stopId} stop={stop} />)} + </ul> + </div> + </div> + ); } |
