diff options
Diffstat (limited to 'src/frontend/app/routes')
| -rw-r--r-- | src/frontend/app/routes/estimates-$id.css | 2 | ||||
| -rw-r--r-- | src/frontend/app/routes/estimates-$id.tsx | 162 | ||||
| -rw-r--r-- | src/frontend/app/routes/index.tsx | 2 | ||||
| -rw-r--r-- | src/frontend/app/routes/map.css | 96 | ||||
| -rw-r--r-- | src/frontend/app/routes/map.tsx | 284 | ||||
| -rw-r--r-- | src/frontend/app/routes/settings.tsx | 192 | ||||
| -rw-r--r-- | src/frontend/app/routes/stoplist.tsx | 214 |
7 files changed, 518 insertions, 434 deletions
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> + ); } |
