aboutsummaryrefslogtreecommitdiff
path: root/src/frontend/app
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
parent7dd9ea97a2f34a35e80c28d59d046f839eb6c60b (diff)
Replace leaflet for maplibre, use react-router in framework mode
Diffstat (limited to 'src/frontend/app')
-rw-r--r--src/frontend/app/AppContext.tsx243
-rw-r--r--src/frontend/app/ErrorBoundary.tsx46
-rw-r--r--src/frontend/app/components/GroupedTable.tsx74
-rw-r--r--src/frontend/app/components/LineIcon.css239
-rw-r--r--src/frontend/app/components/LineIcon.tsx17
-rw-r--r--src/frontend/app/components/RegularTable.tsx70
-rw-r--r--src/frontend/app/components/StopItem.css54
-rw-r--r--src/frontend/app/components/StopItem.tsx25
-rw-r--r--src/frontend/app/controls/LocateControl.ts67
-rw-r--r--src/frontend/app/data/StopDataProvider.ts160
-rw-r--r--src/frontend/app/maps/styleloader.ts49
-rw-r--r--src/frontend/app/root.css126
-rw-r--r--src/frontend/app/root.tsx161
-rw-r--r--src/frontend/app/routes.tsx9
-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
-rw-r--r--src/frontend/app/vite-env.d.ts1
24 files changed, 2412 insertions, 0 deletions
diff --git a/src/frontend/app/AppContext.tsx b/src/frontend/app/AppContext.tsx
new file mode 100644
index 0000000..7ca85bd
--- /dev/null
+++ b/src/frontend/app/AppContext.tsx
@@ -0,0 +1,243 @@
+/* eslint-disable react-refresh/only-export-components */
+import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
+import { type LngLatLike } from 'maplibre-gl';
+
+type Theme = 'light' | 'dark';
+type TableStyle = 'regular'|'grouped';
+type MapPositionMode = 'gps' | 'last';
+
+interface MapState {
+ center: LngLatLike;
+ zoom: number;
+ userLocation: LngLatLike | null;
+ hasLocationPermission: boolean;
+}
+
+interface AppContextProps {
+ theme: Theme;
+ setTheme: React.Dispatch<React.SetStateAction<Theme>>;
+ toggleTheme: () => void;
+
+ tableStyle: TableStyle;
+ setTableStyle: React.Dispatch<React.SetStateAction<TableStyle>>;
+ toggleTableStyle: () => void;
+
+ mapState: MapState;
+ setMapCenter: (center: LngLatLike) => void;
+ setMapZoom: (zoom: number) => void;
+ setUserLocation: (location: LngLatLike | null) => void;
+ setLocationPermission: (hasPermission: boolean) => void;
+ updateMapState: (center: LngLatLike, zoom: number) => void;
+
+ mapPositionMode: MapPositionMode;
+ setMapPositionMode: (mode: MapPositionMode) => void;
+}
+
+// Coordenadas por defecto centradas en Vigo
+const DEFAULT_CENTER: LngLatLike = [42.229188855975046, -8.72246955783102];
+const DEFAULT_ZOOM = 14;
+
+const AppContext = createContext<AppContextProps | undefined>(undefined);
+
+export const AppProvider = ({ children }: { children: ReactNode }) => {
+ //#region Theme
+ const [theme, setTheme] = useState<Theme>(() => {
+ const savedTheme = localStorage.getItem('theme');
+ if (savedTheme) {
+ return savedTheme as Theme;
+ }
+ const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
+ return prefersDark ? 'dark' : 'light';
+ });
+
+ const toggleTheme = () => {
+ setTheme((prevTheme) => (prevTheme === 'light' ? 'dark' : 'light'));
+ };
+
+ useEffect(() => {
+ document.documentElement.setAttribute('data-theme', theme);
+ localStorage.setItem('theme', theme);
+ }, [theme]);
+ //#endregion
+
+ //#region Table Style
+ const [tableStyle, setTableStyle] = useState<TableStyle>(() => {
+ const savedTableStyle = localStorage.getItem('tableStyle');
+ if (savedTableStyle) {
+ return savedTableStyle as TableStyle;
+ }
+ return 'regular';
+ });
+
+ const toggleTableStyle = () => {
+ setTableStyle((prevTableStyle) => (prevTableStyle === 'regular' ? 'grouped' : 'regular'));
+ }
+
+ useEffect(() => {
+ localStorage.setItem('tableStyle', tableStyle);
+ }, [tableStyle]);
+ //#endregion
+
+ //#region Map Position Mode
+ const [mapPositionMode, setMapPositionMode] = useState<MapPositionMode>(() => {
+ const saved = localStorage.getItem('mapPositionMode');
+ return saved === 'last' ? 'last' : 'gps';
+ });
+
+ useEffect(() => {
+ localStorage.setItem('mapPositionMode', mapPositionMode);
+ }, [mapPositionMode]);
+ //#endregion
+
+ //#region Map State
+ const [mapState, setMapState] = useState<MapState>(() => {
+ const savedMapState = localStorage.getItem('mapState');
+ if (savedMapState) {
+ try {
+ const parsed = JSON.parse(savedMapState);
+ return {
+ center: parsed.center || DEFAULT_CENTER,
+ zoom: parsed.zoom || DEFAULT_ZOOM,
+ userLocation: parsed.userLocation || null,
+ hasLocationPermission: parsed.hasLocationPermission || false
+ };
+ } catch (e) {
+ console.error('Error parsing saved map state', e);
+ }
+ }
+ return {
+ center: DEFAULT_CENTER,
+ zoom: DEFAULT_ZOOM,
+ userLocation: null,
+ hasLocationPermission: false
+ };
+ });
+
+ // Helper: check if coordinates are within Vigo bounds
+ function isWithinVigo(lngLat: LngLatLike): boolean {
+ let lng: number, lat: number;
+ if (Array.isArray(lngLat)) {
+ [lng, lat] = lngLat;
+ } else if ('lng' in lngLat && 'lat' in lngLat) {
+ lng = lngLat.lng;
+ lat = lngLat.lat;
+ } else {
+ return false;
+ }
+ // Rough bounding box for Vigo
+ return lat >= 42.18 && lat <= 42.30 && lng >= -8.78 && lng <= -8.65;
+ }
+
+ // On app load, if mapPositionMode is 'gps', try to get GPS and set map center
+ useEffect(() => {
+ if (mapPositionMode === 'gps') {
+ if (navigator.geolocation) {
+ navigator.geolocation.getCurrentPosition(
+ (position) => {
+ const { latitude, longitude } = position.coords;
+ const coords: LngLatLike = [latitude, longitude];
+ if (isWithinVigo(coords)) {
+ setMapState(prev => {
+ const newState = { ...prev, center: coords, zoom: 16, userLocation: coords };
+ localStorage.setItem('mapState', JSON.stringify(newState));
+ return newState;
+ });
+ }
+ },
+ () => {
+ // Ignore error, fallback to last
+ }
+ );
+ }
+ }
+ // If 'last', do nothing (already loaded from localStorage)
+ }, [mapPositionMode]);
+
+ const setMapCenter = (center: LngLatLike) => {
+ setMapState(prev => {
+ const newState = { ...prev, center };
+ localStorage.setItem('mapState', JSON.stringify(newState));
+ return newState;
+ });
+ };
+
+ const setMapZoom = (zoom: number) => {
+ setMapState(prev => {
+ const newState = { ...prev, zoom };
+ localStorage.setItem('mapState', JSON.stringify(newState));
+ return newState;
+ });
+ };
+
+ const setUserLocation = (userLocation: LngLatLike | null) => {
+ setMapState(prev => {
+ const newState = { ...prev, userLocation };
+ localStorage.setItem('mapState', JSON.stringify(newState));
+ return newState;
+ });
+ };
+
+ const setLocationPermission = (hasLocationPermission: boolean) => {
+ setMapState(prev => {
+ const newState = { ...prev, hasLocationPermission };
+ localStorage.setItem('mapState', JSON.stringify(newState));
+ return newState;
+ });
+ };
+
+ const updateMapState = (center: LngLatLike, zoom: number) => {
+ setMapState(prev => {
+ const newState = { ...prev, center, zoom };
+ localStorage.setItem('mapState', JSON.stringify(newState));
+ return newState;
+ });
+ };
+ //#endregion
+
+ // Tratar de obtener la ubicación del usuario cuando se carga la aplicación si ya se había concedido permiso antes
+ useEffect(() => {
+ if (mapState.hasLocationPermission && !mapState.userLocation) {
+ if (navigator.geolocation) {
+ navigator.geolocation.getCurrentPosition(
+ (position) => {
+ const { latitude, longitude } = position.coords;
+ setUserLocation([latitude, longitude]);
+ },
+ (error) => {
+ console.error('Error getting location:', error);
+ setLocationPermission(false);
+ }
+ );
+ }
+ }
+ }, [mapState.hasLocationPermission, mapState.userLocation]);
+
+ return (
+ <AppContext.Provider value={{
+ theme,
+ setTheme,
+ toggleTheme,
+ tableStyle,
+ setTableStyle,
+ toggleTableStyle,
+ mapState,
+ setMapCenter,
+ setMapZoom,
+ setUserLocation,
+ setLocationPermission,
+ updateMapState,
+ mapPositionMode,
+ setMapPositionMode
+ }}>
+ {children}
+ </AppContext.Provider>
+ );
+};
+
+export const useApp = () => {
+ const context = useContext(AppContext);
+ if (!context) {
+ throw new Error('useApp must be used within a AppProvider');
+ }
+ return context;
+};
diff --git a/src/frontend/app/ErrorBoundary.tsx b/src/frontend/app/ErrorBoundary.tsx
new file mode 100644
index 0000000..5c877b7
--- /dev/null
+++ b/src/frontend/app/ErrorBoundary.tsx
@@ -0,0 +1,46 @@
+import React, { Component, type ReactNode } from 'react';
+
+interface ErrorBoundaryProps {
+ children: ReactNode;
+}
+
+interface ErrorBoundaryState {
+ hasError: boolean;
+ error: Error | null;
+}
+
+class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
+ constructor(props: ErrorBoundaryProps) {
+ super(props);
+ this.state = {
+ hasError: false,
+ error: null
+ };
+ }
+
+ static getDerivedStateFromError(error: Error): ErrorBoundaryState {
+ return {
+ hasError: true,
+ error
+ };
+ }
+
+ componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
+ console.error("Uncaught error:", error, errorInfo);
+ }
+
+ render() {
+ if (this.state.hasError) {
+ return <>
+ <h1>Something went wrong.</h1>
+ <pre>
+ {this.state.error?.stack}
+ </pre>
+ </>;
+ }
+
+ return this.props.children;
+ }
+}
+
+export default ErrorBoundary;
diff --git a/src/frontend/app/components/GroupedTable.tsx b/src/frontend/app/components/GroupedTable.tsx
new file mode 100644
index 0000000..3a16d89
--- /dev/null
+++ b/src/frontend/app/components/GroupedTable.tsx
@@ -0,0 +1,74 @@
+import { type StopDetails } from "../routes/estimates-$id";
+import LineIcon from "./LineIcon";
+
+interface GroupedTable {
+ data: StopDetails;
+ dataDate: Date | null;
+}
+
+export const GroupedTable: React.FC<GroupedTable> = ({ data, dataDate }) => {
+ const formatDistance = (meters: number) => {
+ if (meters > 1024) {
+ return `${(meters / 1000).toFixed(1)} km`;
+ } else {
+ return `${meters} m`;
+ }
+ }
+
+ const groupedEstimates = data.estimates.reduce((acc, estimate) => {
+ if (!acc[estimate.line]) {
+ acc[estimate.line] = [];
+ }
+ acc[estimate.line].push(estimate);
+ return acc;
+ }, {} as Record<string, typeof data.estimates>);
+
+ const sortedLines = Object.keys(groupedEstimates).sort((a, b) => {
+ const firstArrivalA = groupedEstimates[a][0].minutes;
+ const firstArrivalB = groupedEstimates[b][0].minutes;
+ return firstArrivalA - firstArrivalB;
+ });
+
+ return <table className="table">
+ <caption>Estimaciones de llegadas a las {dataDate?.toLocaleTimeString()}</caption>
+
+ <thead>
+ <tr>
+ <th>Línea</th>
+ <th>Ruta</th>
+ <th>Llegada</th>
+ <th>Distancia</th>
+ </tr>
+ </thead>
+
+ <tbody>
+ {sortedLines.map((line) => (
+ groupedEstimates[line].map((estimate, idx) => (
+ <tr key={`${line}-${idx}`}>
+ {idx === 0 && (
+ <td rowSpan={groupedEstimates[line].length}>
+ <LineIcon line={line} />
+ </td>
+ )}
+ <td>{estimate.route}</td>
+ <td>{`${estimate.minutes} min`}</td>
+ <td>
+ {estimate.meters > -1
+ ? formatDistance(estimate.meters)
+ : "No disponible"
+ }
+ </td>
+ </tr>
+ ))
+ ))}
+ </tbody>
+
+ {data?.estimates.length === 0 && (
+ <tfoot>
+ <tr>
+ <td colSpan={4}>No hay estimaciones disponibles</td>
+ </tr>
+ </tfoot>
+ )}
+ </table>
+}
diff --git a/src/frontend/app/components/LineIcon.css b/src/frontend/app/components/LineIcon.css
new file mode 100644
index 0000000..e7e8949
--- /dev/null
+++ b/src/frontend/app/components/LineIcon.css
@@ -0,0 +1,239 @@
+:root {
+ --line-c1: rgb(237, 71, 19);
+ --line-c3d: rgb(255, 204, 0);
+ --line-c3i: rgb(255, 204, 0);
+ --line-l4a: rgb(0, 153, 0);
+ --line-l4c: rgb(0, 153, 0);
+ --line-l5a: rgb(0, 176, 240);
+ --line-l5b: rgb(0, 176, 240);
+ --line-l6: rgb(204, 51, 153);
+ --line-l7: rgb(150, 220, 153);
+ --line-l9b: rgb(244, 202, 140);
+ --line-l10: rgb(153, 51, 0);
+ --line-l11: rgb(226, 0, 38);
+ --line-l12a: rgb(106, 150, 190);
+ --line-l12b: rgb(106, 150, 190);
+ --line-l13: rgb(0, 176, 240);
+ --line-l14: rgb(129, 142, 126);
+ --line-l15a: rgb(216, 168, 206);
+ --line-l15b: rgb(216, 168, 206);
+ --line-l15c: rgb(216, 168, 168);
+ --line-l16: rgb(129, 142, 126);
+ --line-l17: rgb(214, 245, 31);
+ --line-l18a: rgb(212, 80, 168);
+ --line-l18b: rgb(0, 0, 0);
+ --line-l18h: rgb(0, 0, 0);
+ --line-l23: rgb(0, 70, 210);
+ --line-l24: rgb(191, 191, 191);
+ --line-l25: rgb(172, 100, 4);
+ --line-l27: rgb(112, 74, 42);
+ --line-l28: rgb(176, 189, 254);
+ --line-l29: rgb(248, 184, 90);
+ --line-l31: rgb(255, 255, 0);
+ --line-a: rgb(119, 41, 143);
+ --line-h: rgb(0, 96, 168);
+ --line-h1: rgb(0, 96, 168);
+ --line-h2: rgb(0, 96, 168);
+ --line-h3: rgb(0, 96, 168);
+ --line-lzd: rgb(61, 78, 167);
+ --line-n1: rgb(191, 191, 191);
+ --line-n4: rgb(102, 51, 102);
+ --line-psa1: rgb(0, 153, 0);
+ --line-psa4: rgb(0, 153, 0);
+ --line-ptl: rgb(150, 220, 153);
+ --line-turistico: rgb(102, 51, 102);
+ --line-u1: rgb(172, 100, 4);
+ --line-u2: rgb(172, 100, 4);
+}
+
+.line-icon {
+ display: inline-block;
+ padding: 0.25rem 0.5rem;
+ margin-right: 0.5rem;
+ border-bottom: 3px solid;
+ font-size: 0.9rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ color: inherit;
+ /* Prevent color change on hover */
+}
+
+.line-c1 {
+ border-color: var(--line-c1);
+}
+
+.line-c3d {
+ border-color: var(--line-c3d);
+}
+
+.line-c3i {
+ border-color: var(--line-c3i);
+}
+
+.line-l4a {
+ border-color: var(--line-l4a);
+}
+
+.line-l4c {
+ border-color: var(--line-l4c);
+}
+
+.line-l5a {
+ border-color: var(--line-l5a);
+}
+
+.line-l5b {
+ border-color: var(--line-l5b);
+}
+
+.line-l6 {
+ border-color: var(--line-l6);
+}
+
+.line-l7 {
+ border-color: var(--line-l7);
+}
+
+.line-l9b {
+ border-color: var(--line-l9b);
+}
+
+.line-l10 {
+ border-color: var(--line-l10);
+}
+
+.line-l11 {
+ border-color: var(--line-l11);
+}
+
+.line-l12a {
+ border-color: var(--line-l12a);
+}
+
+.line-l12b {
+ border-color: var(--line-l12b);
+}
+
+.line-l13 {
+ border-color: var(--line-l13);
+}
+
+.line-l14 {
+ border-color: var(--line-l14);
+}
+
+.line-l15a {
+ border-color: var(--line-l15a);
+}
+
+.line-l15b {
+ border-color: var(--line-l15b);
+}
+
+.line-l15c {
+ border-color: var(--line-l15c);
+}
+
+.line-l16 {
+ border-color: var(--line-l16);
+}
+
+.line-l17 {
+ border-color: var(--line-l17);
+}
+
+.line-l18a {
+ border-color: var(--line-l18a);
+}
+
+.line-l18b {
+ border-color: var(--line-l18b);
+}
+
+.line-l18h {
+ border-color: var(--line-l18h);
+}
+
+.line-l23 {
+ border-color: var(--line-l23);
+}
+
+.line-l24 {
+ border-color: var(--line-l24);
+}
+
+.line-l25 {
+ border-color: var(--line-l25);
+}
+
+.line-l27 {
+ border-color: var(--line-l27);
+}
+
+.line-l28 {
+ border-color: var(--line-l28);
+}
+
+.line-l29 {
+ border-color: var(--line-l29);
+}
+
+.line-l31 {
+ border-color: var(--line-l31);
+}
+
+.line-a {
+ border-color: var(--line-a);
+}
+
+.line-h {
+ border-color: var(--line-h);
+}
+
+.line-h1 {
+ border-color: var(--line-h1);
+}
+
+.line-h2 {
+ border-color: var(--line-h2);
+}
+
+.line-h3 {
+ border-color: var(--line-h3);
+}
+
+.line-lzd {
+ border-color: var(--line-lzd);
+}
+
+.line-n1 {
+ border-color: var(--line-n1);
+}
+
+.line-n4 {
+ border-color: var(--line-n4);
+}
+
+.line-psa1 {
+ border-color: var(--line-psa1);
+}
+
+.line-psa4 {
+ border-color: var(--line-psa4);
+}
+
+.line-ptl {
+ border-color: var(--line-ptl);
+}
+
+.line-turistico {
+ border-color: var(--line-turistico);
+}
+
+.line-u1 {
+ border-color: var(--line-u1);
+}
+
+.line-u2 {
+ border-color: var(--line-u2);
+} \ No newline at end of file
diff --git a/src/frontend/app/components/LineIcon.tsx b/src/frontend/app/components/LineIcon.tsx
new file mode 100644
index 0000000..291b444
--- /dev/null
+++ b/src/frontend/app/components/LineIcon.tsx
@@ -0,0 +1,17 @@
+import React from 'react';
+import './LineIcon.css';
+
+interface LineIconProps {
+ line: string;
+}
+
+const LineIcon: React.FC<LineIconProps> = ({ line }) => {
+ const formattedLine = /^[a-zA-Z]/.test(line) ? line : `L${line}`;
+ return (
+ <span className={`line-icon line-${formattedLine.toLowerCase()}`}>
+ {formattedLine}
+ </span>
+ );
+};
+
+export default LineIcon;
diff --git a/src/frontend/app/components/RegularTable.tsx b/src/frontend/app/components/RegularTable.tsx
new file mode 100644
index 0000000..75b598b
--- /dev/null
+++ b/src/frontend/app/components/RegularTable.tsx
@@ -0,0 +1,70 @@
+import { type StopDetails } from "../routes/estimates-$id";
+import LineIcon from "./LineIcon";
+
+interface RegularTableProps {
+ data: StopDetails;
+ dataDate: Date | null;
+}
+
+export const RegularTable: React.FC<RegularTableProps> = ({ data, dataDate }) => {
+
+ const absoluteArrivalTime = (minutes: number) => {
+ const now = new Date()
+ const arrival = new Date(now.getTime() + minutes * 60000)
+ return Intl.DateTimeFormat(navigator.language, {
+ hour: '2-digit',
+ minute: '2-digit'
+ }).format(arrival)
+ }
+
+ const formatDistance = (meters: number) => {
+ if (meters > 1024) {
+ return `${(meters / 1000).toFixed(1)} km`;
+ } else {
+ return `${meters} m`;
+ }
+ }
+
+ return <table className="table">
+ <caption>Estimaciones de llegadas a las {dataDate?.toLocaleTimeString()}</caption>
+
+ <thead>
+ <tr>
+ <th>Línea</th>
+ <th>Ruta</th>
+ <th>Llegada</th>
+ <th>Distancia</th>
+ </tr>
+ </thead>
+
+ <tbody>
+ {data.estimates
+ .sort((a, b) => a.minutes - b.minutes)
+ .map((estimate, idx) => (
+ <tr key={idx}>
+ <td><LineIcon line={estimate.line} /></td>
+ <td>{estimate.route}</td>
+ <td>
+ {estimate.minutes > 15
+ ? absoluteArrivalTime(estimate.minutes)
+ : `${estimate.minutes} min`}
+ </td>
+ <td>
+ {estimate.meters > -1
+ ? formatDistance(estimate.meters)
+ : "No disponible"
+ }
+ </td>
+ </tr>
+ ))}
+ </tbody>
+
+ {data?.estimates.length === 0 && (
+ <tfoot>
+ <tr>
+ <td colSpan={4}>No hay estimaciones disponibles</td>
+ </tr>
+ </tfoot>
+ )}
+ </table>
+}
diff --git a/src/frontend/app/components/StopItem.css b/src/frontend/app/components/StopItem.css
new file mode 100644
index 0000000..9feb2d1
--- /dev/null
+++ b/src/frontend/app/components/StopItem.css
@@ -0,0 +1,54 @@
+/* Stop Item Styling */
+
+.stop-notes {
+ font-size: 0.85rem;
+ font-style: italic;
+ color: #666;
+ margin: 2px 0;
+}
+
+.stop-amenities {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px;
+ margin-top: 4px;
+}
+
+.amenity-tag {
+ font-size: 0.75rem;
+ background-color: #e8f4f8;
+ color: #0078d4;
+ border-radius: 4px;
+ padding: 2px 6px;
+ display: inline-block;
+}
+
+/* Different colors for different amenity types */
+.amenity-tag[data-amenity="shelter"] {
+ background-color: #e3f1df;
+ color: #107c41;
+}
+
+.amenity-tag[data-amenity="bench"] {
+ background-color: #f0e8fc;
+ color: #5c2e91;
+}
+
+.amenity-tag[data-amenity="real-time display"] {
+ background-color: #fff4ce;
+ color: #986f0b;
+}
+
+/* When there are alternate names available, show an indicator */
+.has-alternate-names {
+ position: relative;
+}
+
+.has-alternate-names::after {
+ content: "⋯";
+ position: absolute;
+ right: -15px;
+ top: 0;
+ color: #0078d4;
+ font-weight: bold;
+} \ No newline at end of file
diff --git a/src/frontend/app/components/StopItem.tsx b/src/frontend/app/components/StopItem.tsx
new file mode 100644
index 0000000..29370b7
--- /dev/null
+++ b/src/frontend/app/components/StopItem.tsx
@@ -0,0 +1,25 @@
+import React from 'react';
+import { Link } from 'react-router';
+import StopDataProvider, { type Stop } from '../data/StopDataProvider';
+import LineIcon from './LineIcon';
+
+interface StopItemProps {
+ stop: Stop;
+}
+
+const StopItem: React.FC<StopItemProps> = ({ stop }) => {
+
+ return (
+ <li className="list-item">
+ <Link className="list-item-link" to={`/estimates/${stop.stopId}`}>
+ {stop.favourite && <span className="favourite-icon">★</span>} ({stop.stopId}) {StopDataProvider.getDisplayName(stop)}
+ <div className="line-icons">
+ {stop.lines?.map(line => <LineIcon key={line} line={line} />)}
+ </div>
+
+ </Link>
+ </li>
+ );
+};
+
+export default StopItem;
diff --git a/src/frontend/app/controls/LocateControl.ts b/src/frontend/app/controls/LocateControl.ts
new file mode 100644
index 0000000..26effa5
--- /dev/null
+++ b/src/frontend/app/controls/LocateControl.ts
@@ -0,0 +1,67 @@
+import { createControlComponent } from '@react-leaflet/core';
+import { LocateControl as LeafletLocateControl, type LocateOptions } from 'leaflet.locatecontrol';
+import "leaflet.locatecontrol/dist/L.Control.Locate.min.css";
+import { useEffect } from 'react';
+import { useMap } from 'react-leaflet';
+import { useApp } from '../AppContext';
+
+interface EnhancedLocateControlProps {
+ options?: LocateOptions;
+}
+
+// Componente que usa el contexto para manejar la localización
+export const EnhancedLocateControl = (props: EnhancedLocateControlProps) => {
+ const map = useMap();
+ const { mapState, setUserLocation, setLocationPermission } = useApp();
+
+ useEffect(() => {
+ // Configuración por defecto del control de localización
+ const defaultOptions: LocateOptions = {
+ position: 'topright',
+ strings: {
+ title: 'Mostrar mi ubicación',
+ },
+ flyTo: true,
+ onLocationError: (err) => {
+ console.error('Error en la localización:', err);
+ setLocationPermission(false);
+ },
+ returnToPrevBounds: true,
+ showPopup: false,
+ };
+
+ // Combinamos las opciones por defecto con las personalizadas
+ const options = { ...defaultOptions, ...props.options };
+
+ // Creamos la instancia del control
+ const locateControl = new LeafletLocateControl(options);
+
+ // Añadimos el control al mapa
+ locateControl.addTo(map);
+
+ // Si tenemos permiso de ubicación y ya conocemos la ubicación del usuario,
+ // podemos activarla automáticamente
+ if (mapState.hasLocationPermission && mapState.userLocation) {
+ // Esperamos a que el mapa esté listo
+ setTimeout(() => {
+ try {
+ locateControl.start();
+ } catch (e) {
+ console.error('Error al iniciar la localización automática', e);
+ }
+ }, 1000);
+ }
+
+ return () => {
+ // Limpieza al desmontar el componente
+ locateControl.remove();
+ };
+ }, [map, mapState.hasLocationPermission, mapState.userLocation, props.options, setLocationPermission, setUserLocation]);
+
+ return null;
+};
+
+// Exportamos también el control base por compatibilidad
+export const LocateControl = createControlComponent(
+ (props) => new LeafletLocateControl(props)
+);
diff --git a/src/frontend/app/data/StopDataProvider.ts b/src/frontend/app/data/StopDataProvider.ts
new file mode 100644
index 0000000..0c1e46e
--- /dev/null
+++ b/src/frontend/app/data/StopDataProvider.ts
@@ -0,0 +1,160 @@
+export interface CachedStopList {
+ timestamp: number;
+ data: Stop[];
+}
+
+export type StopName = {
+ original: string;
+ intersect?: string;
+}
+
+export interface Stop {
+ stopId: number;
+ name: StopName;
+ latitude?: number;
+ longitude?: number;
+ lines: string[];
+ favourite?: boolean;
+}
+
+// In-memory cache and lookup map
+let cachedStops: Stop[] | null = null;
+let stopsMap: Record<number, Stop> = {};
+// Custom names loaded from localStorage
+let customNames: Record<number, string> = {};
+
+// Initialize cachedStops and customNames once
+async function initStops() {
+ if (!cachedStops) {
+ const response = await fetch('/stops.json');
+ const stops = await response.json() as Stop[];
+ // build array and map
+ stopsMap = {};
+ cachedStops = stops.map(stop => {
+ const entry = { ...stop, favourite: false } as Stop;
+ stopsMap[stop.stopId] = entry;
+ return entry;
+ });
+ // load custom names
+ const rawCustom = localStorage.getItem('customStopNames');
+ if (rawCustom) customNames = JSON.parse(rawCustom) as Record<number, string>;
+ }
+}
+
+async function getStops(): Promise<Stop[]> {
+ await initStops();
+ // update favourites
+ const rawFav = localStorage.getItem('favouriteStops');
+ const favouriteStops = rawFav ? JSON.parse(rawFav) as number[] : [];
+ cachedStops!.forEach(stop => stop.favourite = favouriteStops.includes(stop.stopId));
+ return cachedStops!;
+}
+
+// New: get single stop by id
+async function getStopById(stopId: number): Promise<Stop | undefined> {
+ await initStops();
+ const stop = stopsMap[stopId];
+ if (stop) {
+ const rawFav = localStorage.getItem('favouriteStops');
+ const favouriteStops = rawFav ? JSON.parse(rawFav) as number[] : [];
+ stop.favourite = favouriteStops.includes(stopId);
+ }
+ return stop;
+}
+
+// Updated display name to include custom names
+function getDisplayName(stop: Stop): string {
+ if (customNames[stop.stopId]) return customNames[stop.stopId];
+ const nameObj = stop.name;
+ return nameObj.intersect || nameObj.original;
+}
+
+// New: set or remove custom names
+function setCustomName(stopId: number, label: string) {
+ customNames[stopId] = label;
+ localStorage.setItem('customStopNames', JSON.stringify(customNames));
+}
+
+function removeCustomName(stopId: number) {
+ delete customNames[stopId];
+ localStorage.setItem('customStopNames', JSON.stringify(customNames));
+}
+
+// New: get custom label for a stop
+function getCustomName(stopId: number): string | undefined {
+ return customNames[stopId];
+}
+
+function addFavourite(stopId: number) {
+ const rawFavouriteStops = localStorage.getItem('favouriteStops');
+ let favouriteStops: number[] = [];
+ if (rawFavouriteStops) {
+ favouriteStops = JSON.parse(rawFavouriteStops) as number[];
+ }
+
+ if (!favouriteStops.includes(stopId)) {
+ favouriteStops.push(stopId);
+ localStorage.setItem('favouriteStops', JSON.stringify(favouriteStops));
+ }
+}
+
+function removeFavourite(stopId: number) {
+ const rawFavouriteStops = localStorage.getItem('favouriteStops');
+ let favouriteStops: number[] = [];
+ if (rawFavouriteStops) {
+ favouriteStops = JSON.parse(rawFavouriteStops) as number[];
+ }
+
+ const newFavouriteStops = favouriteStops.filter(id => id !== stopId);
+ localStorage.setItem('favouriteStops', JSON.stringify(newFavouriteStops));
+}
+
+function isFavourite(stopId: number): boolean {
+ const rawFavouriteStops = localStorage.getItem('favouriteStops');
+ if (rawFavouriteStops) {
+ const favouriteStops = JSON.parse(rawFavouriteStops) as number[];
+ return favouriteStops.includes(stopId);
+ }
+ return false;
+}
+
+const RECENT_STOPS_LIMIT = 10;
+
+function pushRecent(stopId: number) {
+ const rawRecentStops = localStorage.getItem('recentStops');
+ let recentStops: Set<number> = new Set();
+ if (rawRecentStops) {
+ recentStops = new Set(JSON.parse(rawRecentStops) as number[]);
+ }
+
+ recentStops.add(stopId);
+ if (recentStops.size > RECENT_STOPS_LIMIT) {
+ const iterator = recentStops.values();
+ const val = iterator.next().value as number;
+ recentStops.delete(val);
+ }
+
+ localStorage.setItem('recentStops', JSON.stringify(Array.from(recentStops)));
+}
+
+function getRecent(): number[] {
+ const rawRecentStops = localStorage.getItem('recentStops');
+ if (rawRecentStops) {
+ return JSON.parse(rawRecentStops) as number[];
+ }
+ return [];
+}
+
+export default {
+ getStops,
+ getStopById,
+ getCustomName,
+ getDisplayName,
+ setCustomName,
+ removeCustomName,
+ addFavourite,
+ removeFavourite,
+ isFavourite,
+ pushRecent,
+ getRecent
+};
diff --git a/src/frontend/app/maps/styleloader.ts b/src/frontend/app/maps/styleloader.ts
new file mode 100644
index 0000000..f00aacc
--- /dev/null
+++ b/src/frontend/app/maps/styleloader.ts
@@ -0,0 +1,49 @@
+import type { StyleSpecification } from "react-map-gl/maplibre";
+
+export async function loadStyle(styleName: string, colorScheme: string): Promise<StyleSpecification> {
+ const stylePath = `/maps/styles/${styleName}-${colorScheme}.json`;
+ const resp = await fetch(stylePath);
+
+ if (!resp.ok) {
+ throw new Error(`Failed to load style: ${stylePath}`);
+ }
+
+ const style = await resp.json();
+
+ const baseUrl = window.location.origin;
+ const spritePath = style.sprite;
+
+ // Handle both string and array cases for spritePath
+ if (Array.isArray(spritePath)) {
+ // For array format, update each sprite object's URL to be absolute
+ style.sprite = spritePath.map(spriteObj => {
+ const isAbsoluteUrl = spriteObj.url.startsWith("http://") || spriteObj.url.startsWith("https://");
+ if (isAbsoluteUrl) {
+ return spriteObj;
+ }
+
+ return {
+ ...spriteObj,
+ url: `${baseUrl}${spriteObj.url}`
+ };
+ });
+ } else if (typeof spritePath === "string") {
+ if (!spritePath.startsWith("http://") && !spritePath.startsWith("https://")) {
+ style.sprite = `${baseUrl}${spritePath}`;
+ }
+ }
+
+ // Detect on each source if it the 'tiles' URLs are relative and convert them to absolute URLs
+ for (const sourceKey in style.sources) {
+ const source = style.sources[sourceKey];
+ for (const tileKey in source.tiles) {
+ const tileUrl = source.tiles[tileKey];
+ const isAbsoluteUrl = tileUrl.startsWith("http://") || tileUrl.startsWith("https://");
+ if (!isAbsoluteUrl) {
+ source.tiles[tileKey] = `${baseUrl}${tileUrl}`;
+ }
+ }
+ }
+
+ return style as StyleSpecification;
+}
diff --git a/src/frontend/app/root.css b/src/frontend/app/root.css
new file mode 100644
index 0000000..689b48e
--- /dev/null
+++ b/src/frontend/app/root.css
@@ -0,0 +1,126 @@
+:root {
+ --colour-scheme: light;
+ --background-color: #ffffff;
+ --text-color: #333333;
+ --subtitle-color: #444444;
+ --border-color: #eeeeee;
+ --button-background-color: #007bff;
+ --button-hover-background-color: #0069d9;
+ --button-disabled-background-color: #cccccc;
+ --star-color: #ffcc00;
+ --message-background-color: #f8f9fa;
+
+ font-family: 'Roboto Variable', Roboto, Arial, sans-serif;
+}
+
+[data-theme='dark'] {
+ --colour-scheme: dark;
+ --background-color: #121212;
+ --text-color: #ffffff;
+ --subtitle-color: #bbbbbb;
+ --border-color: #444444;
+ --button-background-color: #1e88e5;
+ --button-hover-background-color: #1565c0;
+ --button-disabled-background-color: #555555;
+ --star-color: #ffcc00;
+ --message-background-color: #333333;
+}
+
+body {
+ color-scheme: var(--colour-scheme, light);
+
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+
+ background-color: var(--background-color);
+
+ display: flex;
+ flex-direction: column;
+ height: 100vh;
+ width: 100%;
+ overflow: hidden;
+}
+
+.main-content {
+ flex: 1;
+ overflow: auto;
+}
+
+.navigation-bar {
+ display: flex;
+ justify-content: space-around;
+ align-items: center;
+ padding: 0.5rem 0;
+
+ background-color: var(--background-color);
+ border-top: 1px solid var(--border-color);
+}
+
+.navigation-bar__link {
+ flex: 1 0;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ text-decoration: none;
+ color: var(--text-color);
+ padding: .25rem 0;
+ border-radius: .5rem;
+}
+
+.navigation-bar__link svg {
+ width: 1.75rem;
+ height: 1.75rem;
+ margin-bottom: 5px;
+ fill: none;
+ stroke-width: 2;
+}
+
+.navigation-bar__link span {
+ font-size: 14px;
+ line-height: 1;
+}
+
+.navigation-bar__link.active {
+ color: var(--button-background-color);
+}
+
+.theme-toggle {
+ background: none;
+ border: none;
+ cursor: pointer;
+ color: inherit;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 8px;
+}
+
+.theme-toggle:hover {
+ color: var(--button-hover-background-color);
+}
+
+.page-container {
+ max-width: 100%;
+ padding: 0 16px;
+ background-color: var(--background-color);
+ color: var(--text-color);
+}
+
+@media (min-width: 768px) {
+ .page-container {
+ width: 90%;
+ max-width: 768px;
+ margin: 0 auto;
+ }
+
+ .page-title {
+ font-size: 2.2rem;
+ }
+}
+
+@media (min-width: 1024px) {
+ .page-container {
+ max-width: 1024px;
+ }
+}
diff --git a/src/frontend/app/root.tsx b/src/frontend/app/root.tsx
new file mode 100644
index 0000000..d90dba0
--- /dev/null
+++ b/src/frontend/app/root.tsx
@@ -0,0 +1,161 @@
+import {
+ isRouteErrorResponse,
+ Link,
+ Links,
+ Meta,
+ Outlet,
+ Scripts,
+ ScrollRestoration
+} from "react-router";
+
+import type { Route } from "./+types/root";
+import "@fontsource-variable/roboto";
+import "./root.css";
+
+//#region Maplibre setup
+import "maplibre-theme/icons.default.css";
+import "maplibre-theme/modern.css";
+import { Protocol } from "pmtiles";
+import maplibregl from "maplibre-gl";
+import { AppProvider } from "./AppContext";
+import { Map, MapPin, Settings } from "lucide-react";
+const pmtiles = new Protocol();
+maplibregl.addProtocol("pmtiles", pmtiles.tile);
+//#endregion
+
+if ('serviceWorker' in navigator) {
+ navigator.serviceWorker.register('/sw.js')
+ .then((registration) => {
+ console.log('Service Worker registered with scope:', registration.scope);
+ })
+ .catch((error) => {
+ console.error('Service Worker registration failed:', error);
+ });
+}
+
+export const links: Route.LinksFunction = () => [];
+
+export function HydrateFallback() {
+ return "Loading...";
+}
+
+export function Layout({ children }: { children: React.ReactNode }) {
+ return (
+ <html lang="es">
+ <head>
+ <meta charSet="utf-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
+
+ <link rel="icon" type="image/jpg" href="/logo-512.jpg" />
+ <link rel="icon" href="/favicon.ico" sizes="64x64" />
+ <link rel="apple-touch-icon" href="/logo-512.jpg" sizes="512x512" />
+ <meta name="theme-color" content="#007bff" />
+
+ <link rel="canonical" href="https://urbanovigo.costas.dev/" />
+
+ <meta name="description" content="Aplicación web para encontrar paradas y tiempos de llegada de los autobuses urbanos de Vigo, España." />
+ <meta name="keywords" content="Vigo, autobús, urbano, parada, tiempo, llegada, transporte, público, España" />
+ <meta name="author" content="Ariel Costas Guerrero" />
+
+ <meta property="og:title" content="UrbanoVigo Web" />
+ <meta property="og:type" content="website" />
+ <meta property="og:url" content="https://urbanovigo.costas.dev/" />
+ <meta property="og:image" content="https://urbanovigo.costas.dev/logo-512.jpg" />
+ <meta property="og:description" content="Aplicación web para encontrar paradas y tiempos de llegada de los autobuses urbanos de Vigo, España." />
+
+ <link rel="manifest" href="/manifest.webmanifest" />
+
+ <meta name="robots" content="noindex, nofollow" />
+ <meta name="googlebot" content="noindex, nofollow" />
+
+ <title>Busurbano</title>
+ <Meta />
+ <Links />
+ </head>
+ <body>
+ {children}
+ <ScrollRestoration />
+ <Scripts />
+ </body>
+ </html>
+ );
+}
+
+export default function App() {
+ const navItems = [
+ {
+ name: 'Paradas',
+ icon: MapPin,
+ path: '/stops'
+ },
+ {
+ name: 'Mapa',
+ icon: Map,
+ path: '/map'
+ },
+ {
+ name: 'Ajustes',
+ icon: Settings,
+ path: '/settings'
+ }
+ ];
+
+ return (
+ <AppProvider>
+ <main className="main-content">
+ <Outlet />
+ </main>
+ <footer>
+ <nav className="navigation-bar">
+ {navItems.map(item => {
+ const Icon = item.icon;
+ const isActive = location.pathname.startsWith(item.path);
+
+ return (
+ <Link
+ key={item.name}
+ to={item.path}
+ className={`navigation-bar__link ${isActive ? 'active' : ''}`}
+ >
+ <Icon size={24} />
+ <span>{item.name}</span>
+ </Link>
+ );
+ })}
+ </nav>
+ </footer>
+ </AppProvider>
+
+
+
+ );
+}
+
+export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
+ let message = "Oops!";
+ let details = "An unexpected error occurred.";
+ let stack: string | undefined;
+
+ if (isRouteErrorResponse(error)) {
+ message = error.status === 404 ? "404" : "Error";
+ details =
+ error.status === 404
+ ? "The requested page could not be found."
+ : error.statusText || details;
+ } else if (import.meta.env.DEV && error && error instanceof Error) {
+ details = error.message;
+ stack = error.stack;
+ }
+
+ return (
+ <main>
+ <h1>{message}</h1>
+ <p>{details}</p>
+ {stack && (
+ <pre>
+ <code>{stack}</code>
+ </pre>
+ )}
+ </main>
+ );
+}
diff --git a/src/frontend/app/routes.tsx b/src/frontend/app/routes.tsx
new file mode 100644
index 0000000..1bca5e8
--- /dev/null
+++ b/src/frontend/app/routes.tsx
@@ -0,0 +1,9 @@
+import { type RouteConfig, index, route } from "@react-router/dev/routes";
+
+export default [
+ index("routes/index.tsx"),
+ route("/stops", "routes/stoplist.tsx"),
+ route("/map", "routes/map.tsx"),
+ route("/estimates/:id", "routes/estimates-$id.tsx"),
+ route("/settings", "routes/settings.tsx")
+] satisfies RouteConfig;
diff --git a/src/frontend/app/routes/estimates-$id.css b/src/frontend/app/routes/estimates-$id.css
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>
+ )
+}
diff --git a/src/frontend/app/vite-env.d.ts b/src/frontend/app/vite-env.d.ts
new file mode 100644
index 0000000..11f02fe
--- /dev/null
+++ b/src/frontend/app/vite-env.d.ts
@@ -0,0 +1 @@
+/// <reference types="vite/client" />