aboutsummaryrefslogtreecommitdiff
path: root/src/frontend/app/routes
diff options
context:
space:
mode:
authorAriel Costas Guerrero <ariel@costas.dev>2025-06-24 13:29:50 +0200
committerAriel Costas Guerrero <ariel@costas.dev>2025-06-24 13:29:50 +0200
commit894e67863dbb89a4819e825fcdf7117021082b2a (patch)
treefb544ef7fa99ff86489717e793595f503783bb72 /src/frontend/app/routes
parent7dd9ea97a2f34a35e80c28d59d046f839eb6c60b (diff)
Replace leaflet for maplibre, use react-router in framework mode
Diffstat (limited to 'src/frontend/app/routes')
-rw-r--r--src/frontend/app/routes/estimates-$id.css105
-rw-r--r--src/frontend/app/routes/estimates-$id.tsx103
-rw-r--r--src/frontend/app/routes/index.tsx5
-rw-r--r--src/frontend/app/routes/map.css86
-rw-r--r--src/frontend/app/routes/map.tsx167
-rw-r--r--src/frontend/app/routes/settings.css94
-rw-r--r--src/frontend/app/routes/settings.tsx65
-rw-r--r--src/frontend/app/routes/stoplist.css310
-rw-r--r--src/frontend/app/routes/stoplist.tsx136
9 files changed, 1071 insertions, 0 deletions
diff --git a/src/frontend/app/routes/estimates-$id.css b/src/frontend/app/routes/estimates-$id.css
new file mode 100644
index 0000000..86ca09b
--- /dev/null
+++ b/src/frontend/app/routes/estimates-$id.css
@@ -0,0 +1,105 @@
+.table-responsive {
+ overflow-x: auto;
+ margin-bottom: 1.5rem;
+}
+
+.table {
+ width: 100%;
+ border-collapse: collapse;
+}
+
+.table caption {
+ margin-bottom: 0.5rem;
+ font-weight: 500;
+}
+
+.table th,
+.table td {
+ padding: 0.75rem;
+ text-align: left;
+ border-bottom: 1px solid #eee;
+}
+
+.table th {
+ border-bottom: 2px solid #ddd;
+}
+
+.table tfoot td {
+ text-align: center;
+}
+
+/* Estimates page specific styles */
+.estimates-header {
+ display: flex;
+ align-items: center;
+ margin-bottom: 1rem;
+}
+
+.estimates-stop-id {
+ font-size: 1rem;
+ color: var(--subtitle-color);
+ margin-left: 0.5rem;
+}
+
+.estimates-arrival {
+ color: #28a745;
+ font-weight: 500;
+}
+
+.estimates-delayed {
+ color: #dc3545;
+}
+
+.button-group {
+ display: flex;
+ gap: 1rem;
+ margin-bottom: 1.5rem;
+ flex-wrap: wrap;
+}
+
+.button {
+ padding: 0.75rem 1rem;
+ background-color: var(--button-background-color);
+ color: white;
+ border: none;
+ border-radius: 8px;
+ font-size: 1rem;
+ font-weight: 500;
+ cursor: pointer;
+ text-align: center;
+ text-decoration: none;
+ display: inline-block;
+}
+
+.button:hover {
+ background-color: var(--button-hover-background-color);
+}
+
+.button:disabled {
+ background-color: var(--button-disabled-background-color);
+ cursor: not-allowed;
+}
+
+.star-icon {
+ margin-right: 0.5rem;
+ color: #ccc;
+ fill: none;
+}
+
+.star-icon.active {
+ color: var(--star-color);
+ /* Yellow color for active star */
+ fill: var(--star-color);
+}
+
+/* Pencil (edit) icon next to header */
+.edit-icon {
+ margin-right: 0.5rem;
+ color: #ccc;
+ cursor: pointer;
+ stroke-width: 2px;
+}
+
+.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
new file mode 100644
index 0000000..761a8d4
--- /dev/null
+++ b/src/frontend/app/routes/estimates-$id.tsx
@@ -0,0 +1,103 @@
+import { type JSX, useEffect, useState } from "react";
+import { useParams } from "react-router";
+import StopDataProvider from "../data/StopDataProvider";
+import { Star, Edit2 } from 'lucide-react';
+import "./estimates-$id.css";
+import { RegularTable } from "../components/RegularTable";
+import { useApp } from "../AppContext";
+import { GroupedTable } from "../components/GroupedTable";
+
+export interface StopDetails {
+ 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();
+};
+
+export default function Estimates() {
+ 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));
+ })
+
+
+ StopDataProvider.pushRecent(parseInt(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);
+ }
+ };
+
+ if (data === null) return <h1 className="page-title">Cargando datos en tiempo real...</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>
+ )
+}
diff --git a/src/frontend/app/routes/index.tsx b/src/frontend/app/routes/index.tsx
new file mode 100644
index 0000000..7c8ab40
--- /dev/null
+++ b/src/frontend/app/routes/index.tsx
@@ -0,0 +1,5 @@
+import { Navigate, redirect, type LoaderFunction } from "react-router";
+
+export default function Index() {
+ return <Navigate to={"/stops"} replace />;
+}
diff --git a/src/frontend/app/routes/map.css b/src/frontend/app/routes/map.css
new file mode 100644
index 0000000..3af112a
--- /dev/null
+++ b/src/frontend/app/routes/map.css
@@ -0,0 +1,86 @@
+/* Map page specific styles */
+.map-container {
+ height: calc(100vh - 140px);
+ margin: -16px;
+ margin-bottom: 1rem;
+ position: relative;
+}
+
+/* Fullscreen map styles */
+.fullscreen-container {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100vw;
+ height: 100vh;
+ padding: 0;
+ margin: 0;
+ max-width: none;
+ overflow: hidden;
+}
+
+.fullscreen-map {
+ width: 100%;
+ height: 100%;
+}
+
+.fullscreen-loading {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ height: 100vh;
+ 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;
+}
+
+.stop-marker:hover {
+ transform: scale(1.2);
+}
+
+.maplibregl-popup {
+ max-width: 250px;
+}
+
+.maplibregl-popup-content {
+ 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;
+}
+
+.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;
+}
+
+.popup-link {
+ display: block;
+ margin-top: 8px;
+ color: var(--button-background-color);
+ text-decoration: none;
+ font-weight: 500;
+}
+
+.popup-link:hover {
+ text-decoration: underline;
+} \ No newline at end of file
diff --git a/src/frontend/app/routes/map.tsx b/src/frontend/app/routes/map.tsx
new file mode 100644
index 0000000..a938148
--- /dev/null
+++ b/src/frontend/app/routes/map.tsx
@@ -0,0 +1,167 @@
+import StopDataProvider from "../data/StopDataProvider";
+import './map.css';
+
+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 { loadStyle } from "app/maps/styleloader";
+import type { Feature as GeoJsonFeature, Point } from 'geojson';
+import LineIcon from "~/components/LineIcon";
+import { Link } from "react-router";
+
+// Default minimal fallback style before dynamic loading
+const defaultStyle: StyleSpecification = {
+ version: 8,
+ glyphs: `${window.location.origin}/maps/fonts/{fontstack}/{range}.pbf`,
+ sprite: `${window.location.origin}/maps/spritesheet/sprite`,
+ sources: {},
+ layers: []
+};
+
+// Componente principal del mapa
+export default function StopMap() {
+ 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");
+
+ // 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;
+
+ 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(() => {
+ 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);
+ };
+
+ 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]);
+
+ 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
+ }
+ });
+ });
+ };
+
+ 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 }}
+ />
+
+ <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>
+ );
+}
diff --git a/src/frontend/app/routes/settings.css b/src/frontend/app/routes/settings.css
new file mode 100644
index 0000000..8c612d3
--- /dev/null
+++ b/src/frontend/app/routes/settings.css
@@ -0,0 +1,94 @@
+/* About page specific styles */
+.about-page {
+ text-align: center;
+ padding: 1rem;
+}
+
+.about-version {
+ color: var(--subtitle-color);
+ font-size: 0.9rem;
+ margin-top: 2rem;
+}
+
+.about-description {
+ margin-top: 1rem;
+ line-height: 1.6;
+}
+
+.settings-section {
+ margin-bottom: 2em;
+ padding: 1rem;
+ border: 1px solid var(--border-color);
+ border-radius: 8px;
+ background-color: var(--message-background-color);
+ text-align: left;
+}
+
+.settings-section h2 {
+ margin-bottom: 1em;
+}
+
+.settings-content {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ margin-bottom: 1em;
+}
+
+.settings-content-inline {
+ display: flex;
+ align-items: center;
+ margin-bottom: 1em;
+}
+
+.settings-section .form-button {
+ margin-bottom: 1em;
+ padding: 0.75rem 1.5rem;
+ font-size: 1.1rem;
+}
+
+.settings-section .form-select-inline {
+ margin-left: 0.5em;
+ padding: 0.5rem;
+ font-size: 1rem;
+ border: 1px solid var(--border-color);
+ border-radius: 8px;
+}
+
+.settings-section .form-label-inline {
+ font-weight: 500;
+}
+
+.settings-section .form-label {
+ display: block;
+ margin-bottom: 0.5em;
+ font-weight: 500;
+}
+
+.settings-section .form-description {
+ margin-top: 0.5em;
+ font-size: 0.9rem;
+ color: var(--subtitle-color);
+}
+
+.settings-section .form-details {
+ margin-top: 0.5em;
+ font-size: 0.9rem;
+ color: var(--subtitle-color);
+ border: 1px solid var(--border-color);
+ border-radius: 8px;
+ padding: 0.5rem;
+}
+
+.settings-section .form-details summary {
+ cursor: pointer;
+ font-weight: 500;
+}
+
+.settings-section .form-details p {
+ margin-top: 0.5em;
+}
+
+.settings-section p {
+ margin-top: 0.5em;
+}
diff --git a/src/frontend/app/routes/settings.tsx b/src/frontend/app/routes/settings.tsx
new file mode 100644
index 0000000..b5e91f1
--- /dev/null
+++ b/src/frontend/app/routes/settings.tsx
@@ -0,0 +1,65 @@
+import { useApp } from "../AppContext";
+import "./settings.css";
+
+export default function Settings() {
+ const { theme, setTheme, tableStyle, setTableStyle, mapPositionMode, setMapPositionMode } = useApp();
+
+ return (
+ <div className="page-container">
+ <h1 className="page-title">Sobre UrbanoVigo Web</h1>
+ <p className="about-description">
+ Aplicación web para encontrar paradas y tiempos de llegada de los autobuses
+ urbanos de Vigo, España.
+ </p>
+ <section className="settings-section">
+ <h2>Ajustes</h2>
+ <div className="settings-content-inline">
+ <label htmlFor="theme" className="form-label-inline">Modo:</label>
+ <select id="theme" className="form-select-inline" value={theme} onChange={(e) => setTheme(e.target.value as "light" | "dark")}>
+ <option value="light">Claro</option>
+ <option value="dark">Oscuro</option>
+ </select>
+ </div>
+ <div className="settings-content-inline">
+ <label htmlFor="tableStyle" className="form-label-inline">Estilo de tabla:</label>
+ <select id="tableStyle" className="form-select-inline" value={tableStyle} onChange={(e) => setTableStyle(e.target.value as "regular" | "grouped")}>
+ <option value="regular">Mostrar por orden</option>
+ <option value="grouped">Agrupar por línea</option>
+ </select>
+ </div>
+ <div className="settings-content-inline">
+ <label htmlFor="mapPositionMode" className="form-label-inline">Posición del mapa:</label>
+ <select id="mapPositionMode" className="form-select-inline" value={mapPositionMode} onChange={e => setMapPositionMode(e.target.value as 'gps' | 'last')}>
+ <option value="gps">Posición GPS</option>
+ <option value="last">Donde lo dejé</option>
+ </select>
+ </div>
+ <details className="form-details">
+ <summary>¿Qué significa esto?</summary>
+ <p>
+ La tabla de horarios puede mostrarse de dos formas:
+ </p>
+ <dl>
+ <dt>Mostrar por orden</dt>
+ <dd>Las paradas se muestran en el orden en que se visitan. Aplicaciones como Infobus (Vitrasa) usan este estilo.</dd>
+ <dt>Agrupar por línea</dt>
+ <dd>Las paradas se agrupan por la línea de autobús. Aplicaciones como iTranvias (A Coruña) o Moovit (más o menos) usan este estilo.</dd>
+ </dl>
+ </details>
+ </section>
+ <h2>Créditos</h2>
+ <p>
+ <a href="https://github.com/arielcostas/urbanovigo-web" className="about-link" rel="nofollow noreferrer noopener">
+ Código en GitHub
+ </a> -
+ Desarrollado por <a href="https://www.costas.dev" className="about-link" rel="nofollow noreferrer noopener">
+ Ariel Costas
+ </a>
+ </p>
+ <p>
+ Datos obtenidos de <a href="https://datos.vigo.org" className="about-link" rel="nofollow noreferrer noopener">datos.vigo.org</a> bajo
+ licencia <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.css b/src/frontend/app/routes/stoplist.css
new file mode 100644
index 0000000..d65e048
--- /dev/null
+++ b/src/frontend/app/routes/stoplist.css
@@ -0,0 +1,310 @@
+/* Common page styles */
+.page-title {
+ font-size: 1.8rem;
+ margin-bottom: 1rem;
+ font-weight: 600;
+ color: var(--text-color);
+}
+
+.page-subtitle {
+ font-size: 1.4rem;
+ margin-top: 1.5rem;
+ margin-bottom: 0.75rem;
+ font-weight: 500;
+ color: var(--subtitle-color);
+}
+
+/* Form styles */
+.search-form {
+ margin-bottom: 1.5rem;
+}
+
+.form-group {
+ margin-bottom: 1rem;
+ display: flex;
+ flex-direction: column;
+}
+
+.form-label {
+ font-size: 0.9rem;
+ margin-bottom: 0.25rem;
+ font-weight: 500;
+}
+
+.form-input {
+ padding: 0.75rem;
+ font-size: 1rem;
+ border: 1px solid var(--border-color);
+ border-radius: 8px;
+}
+
+.form-button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 1rem;
+
+ padding: 0.75rem 1rem;
+ background-color: var(--button-background-color);
+ color: white;
+ border: none;
+ border-radius: 8px;
+ font-size: 1rem;
+ font-weight: 500;
+ cursor: pointer;
+ width: 100%;
+ margin-top: 0.5rem;
+}
+
+.form-button:hover {
+ background-color: var(--button-hover-background-color);
+}
+
+/* List styles */
+.list-container {
+ margin-bottom: 1.5rem;
+}
+
+.list {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+}
+
+.list-item {
+ padding: 1rem;
+ border-bottom: 1px solid var(--border-color);
+}
+
+.list-item-link {
+ display: block;
+ color: var(--text-color);
+ text-decoration: none;
+ font-size: 1.1rem; /* Increased font size for stop name */
+}
+
+.list-item-link:hover {
+ color: var(--button-background-color);
+}
+
+.list-item-link:hover .line-icon {
+ color: var(--text-color);
+}
+
+.distance-info {
+ font-size: 0.9rem;
+ color: var(--subtitle-color);
+}
+
+/* Message styles */
+.message {
+ padding: 1rem;
+ background-color: var(--message-background-color);
+ border-radius: 8px;
+ margin-bottom: 1rem;
+}
+
+/* About page specific styles */
+.about-page {
+ text-align: center;
+ padding: 1rem;
+}
+
+.about-version {
+ color: var(--subtitle-color);
+ font-size: 0.9rem;
+ margin-top: 2rem;
+}
+
+.about-description {
+ margin-top: 1rem;
+ line-height: 1.6;
+}
+
+/* Map page specific styles */
+.map-container {
+ height: calc(100vh - 140px);
+ margin: -16px;
+ margin-bottom: 1rem;
+ position: relative;
+}
+
+/* Fullscreen map styles */
+.fullscreen-container {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100vw;
+ height: 100vh;
+ padding: 0;
+ margin: 0;
+ max-width: none;
+ overflow: hidden;
+}
+
+.fullscreen-map {
+ width: 100%;
+ height: 100%;
+}
+
+.fullscreen-loading {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ height: 100vh;
+ 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;
+}
+
+.stop-marker:hover {
+ transform: scale(1.2);
+}
+
+.maplibregl-popup {
+ max-width: 250px;
+}
+
+.maplibregl-popup-content {
+ 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;
+}
+
+.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;
+}
+
+.popup-link {
+ display: block;
+ margin-top: 8px;
+ color: var(--button-background-color);
+ text-decoration: none;
+ font-weight: 500;
+}
+
+.popup-link:hover {
+ text-decoration: underline;
+}
+
+/* Estimates page specific styles */
+.estimates-header {
+ display: flex;
+ align-items: center;
+ margin-bottom: 1rem;
+}
+
+.estimates-stop-id {
+ font-size: 1rem;
+ color: var(--subtitle-color);
+ margin-left: 0.5rem;
+}
+
+.estimates-arrival {
+ color: #28a745;
+ font-weight: 500;
+}
+
+.estimates-delayed {
+ color: #dc3545;
+}
+
+.button-group {
+ display: flex;
+ gap: 1rem;
+ margin-bottom: 1.5rem;
+ flex-wrap: wrap;
+}
+
+.button {
+ padding: 0.75rem 1rem;
+ background-color: var(--button-background-color);
+ color: white;
+ border: none;
+ border-radius: 8px;
+ font-size: 1rem;
+ font-weight: 500;
+ cursor: pointer;
+ text-align: center;
+ text-decoration: none;
+ display: inline-block;
+}
+
+.button:hover {
+ background-color: var(--button-hover-background-color);
+}
+
+.button:disabled {
+ background-color: var(--button-disabled-background-color);
+ cursor: not-allowed;
+}
+
+.star-icon {
+ margin-right: 0.5rem;
+ color: #ccc;
+ fill: none;
+}
+
+.star-icon.active {
+ color: var(--star-color); /* Yellow color for active star */
+ fill: var(--star-color);
+}
+
+/* Tablet and larger breakpoint */
+@media (min-width: 768px) {
+ .search-form {
+ display: flex;
+ align-items: flex-end;
+ gap: 1rem;
+ }
+
+ .form-group {
+ flex: 1;
+ margin-bottom: 0;
+ }
+
+ .form-button {
+ width: auto;
+ margin-top: 0;
+ }
+
+ .list {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
+ gap: 1rem;
+ }
+
+ .list-item {
+ border: 1px solid var(--border-color);
+ border-radius: 8px;
+ margin-bottom: 0;
+ }
+}
+
+/* Desktop breakpoint */
+@media (min-width: 1024px) {
+ .list {
+ grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
+ }
+}
diff --git a/src/frontend/app/routes/stoplist.tsx b/src/frontend/app/routes/stoplist.tsx
new file mode 100644
index 0000000..ff1da71
--- /dev/null
+++ b/src/frontend/app/routes/stoplist.tsx
@@ -0,0 +1,136 @@
+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';
+
+const placeholders = [
+ "Urzaiz",
+ "Gran Vía",
+ "Castelao",
+ "García Barbón",
+ "Valladares",
+ "Florida",
+ "Pizarro",
+ "Estrada Madrid",
+ "Sanjurjo Badía"
+];
+
+export default function StopList() {
+ const [data, setData] = useState<Stop[] | null>(null)
+ const [searchResults, setSearchResults] = useState<Stop[] | null>(null);
+ const searchTimeout = useRef<NodeJS.Timeout | null>(null);
+
+ const randomPlaceholder = useMemo(() => placeholders[Math.floor(Math.random() * placeholders.length)], []);
+ const fuse = useMemo(() => new Fuse(data || [], { threshold: 0.3, keys: ['name.original'] }), [data]);
+
+ useEffect(() => {
+ StopDataProvider.getStops().then((stops: Stop[]) => setData(stops))
+ }, []);
+
+ const handleStopSearch = (event: React.ChangeEvent<HTMLInputElement>) => {
+ const stopName = event.target.value || "";
+
+ if (searchTimeout.current) {
+ clearTimeout(searchTimeout.current);
+ }
+
+ searchTimeout.current = setTimeout(() => {
+ if (stopName.length === 0) {
+ setSearchResults(null);
+ 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 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]);
+
+ if (data === null) return <h1 className="page-title">Loading...</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">
+ 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">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">Paradas favoritas</h2>
+
+ {favouritedStops?.length === 0 && (
+ <p className="message">
+ 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>
+
+ {recentStops && recentStops.length > 0 && (
+ <div className="list-container">
+ <h2 className="page-subtitle">Recientes</h2>
+
+ <ul className="list">
+ {recentStops.map((stop: Stop) => (
+ <StopItem key={stop.stopId} stop={stop} />
+ ))}
+ </ul>
+ </div>
+ )}
+
+ <div className="list-container">
+ <h2 className="page-subtitle">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>
+ )
+}