aboutsummaryrefslogtreecommitdiff
path: root/src/frontend/app/routes
diff options
context:
space:
mode:
authorCopilot <198982749+Copilot@users.noreply.github.com>2025-06-26 23:44:25 +0200
committerGitHub <noreply@github.com>2025-06-26 23:44:25 +0200
commit7b8594debceb93a1fa400d48fe1dcff943bd5af6 (patch)
tree73e68c7238a91d8931d669364d395ce2994164f4 /src/frontend/app/routes
parent3dac17a9fb54c977c97280ed4c482e9d4266b7de (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/routes')
-rw-r--r--src/frontend/app/routes/estimates-$id.css2
-rw-r--r--src/frontend/app/routes/estimates-$id.tsx162
-rw-r--r--src/frontend/app/routes/index.tsx2
-rw-r--r--src/frontend/app/routes/map.css96
-rw-r--r--src/frontend/app/routes/map.tsx284
-rw-r--r--src/frontend/app/routes/settings.tsx192
-rw-r--r--src/frontend/app/routes/stoplist.tsx214
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>
+ );
}