From 12ecc97b07093f3cac6567c70ff75d57b429c674 Mon Sep 17 00:00:00 2001 From: Ariel Costas Guerrero Date: Tue, 21 Oct 2025 17:38:01 +0200 Subject: Implement new Santiago region (WIP) --- .../GetStopEstimates.cs | 127 - .../SantiagoController.cs | 81 + src/Costasdev.Busurbano.Backend/VigoController.cs | 127 + src/frontend/app/AppContext.tsx | 29 + src/frontend/app/components/GroupedTable.tsx | 24 +- src/frontend/app/components/LineIcon.css | 295 +- src/frontend/app/components/LineIcon.tsx | 17 +- src/frontend/app/components/RegionSelector.tsx | 33 + src/frontend/app/components/RegularTable.tsx | 23 +- src/frontend/app/components/StopItem.tsx | 7 +- src/frontend/app/components/StopSheet.tsx | 17 +- src/frontend/app/components/TimetableTable.tsx | 4 +- src/frontend/app/data/RegionConfig.ts | 49 + src/frontend/app/data/StopDataProvider.ts | 113 +- src/frontend/app/routes/estimates-$id.tsx | 54 +- src/frontend/app/routes/map.tsx | 8 +- src/frontend/app/routes/settings.tsx | 22 + src/frontend/app/routes/stoplist.tsx | 16 +- src/frontend/app/routes/timetable-$id.tsx | 35 +- src/frontend/public/pwa-worker.js | 5 +- src/frontend/public/stops.json | 15138 ------------------- src/frontend/public/stops/santiago.json | 8585 +++++++++++ src/frontend/public/stops/vigo.json | 15138 +++++++++++++++++++ 23 files changed, 24340 insertions(+), 15607 deletions(-) delete mode 100644 src/Costasdev.Busurbano.Backend/GetStopEstimates.cs create mode 100644 src/Costasdev.Busurbano.Backend/SantiagoController.cs create mode 100644 src/Costasdev.Busurbano.Backend/VigoController.cs create mode 100644 src/frontend/app/components/RegionSelector.tsx create mode 100644 src/frontend/app/data/RegionConfig.ts delete mode 100644 src/frontend/public/stops.json create mode 100644 src/frontend/public/stops/santiago.json create mode 100644 src/frontend/public/stops/vigo.json (limited to 'src') diff --git a/src/Costasdev.Busurbano.Backend/GetStopEstimates.cs b/src/Costasdev.Busurbano.Backend/GetStopEstimates.cs deleted file mode 100644 index 8fcdd8e..0000000 --- a/src/Costasdev.Busurbano.Backend/GetStopEstimates.cs +++ /dev/null @@ -1,127 +0,0 @@ -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Caching.Memory; -using Costasdev.VigoTransitApi; -using System.Text.Json; - -namespace Costasdev.Busurbano.Backend; - -[ApiController] -[Route("api")] -public class ApiController : ControllerBase -{ - private readonly VigoTransitApiClient _api; - private readonly IMemoryCache _cache; - private readonly HttpClient _httpClient; - - public ApiController(HttpClient http, IMemoryCache cache) - { - _api = new VigoTransitApiClient(http); - _cache = cache; - _httpClient = http; - } - - [HttpGet("GetStopEstimates")] - public async Task Run() - { - var argumentAvailable = Request.Query.TryGetValue("id", out var requestedStopIdString); - if (!argumentAvailable) - { - return BadRequest("Please provide a stop id as a query parameter with the name 'id'."); - } - - var argumentNumber = int.TryParse(requestedStopIdString, out var requestedStopId); - if (!argumentNumber) - { - return BadRequest("The provided stop id is not a valid number."); - } - - try - { - var estimates = await _api.GetStopEstimates(requestedStopId); - return new OkObjectResult(estimates); - } - catch (InvalidOperationException) - { - return new BadRequestObjectResult("Stop not found"); - } - } - - [HttpGet("GetStopTimetable")] - public async Task GetStopTimetable() - { - // Get date parameter (default to today if not provided) - var dateString = Request.Query.TryGetValue("date", out var requestedDate) - ? requestedDate.ToString() - : DateTime.Today.ToString("yyyy-MM-dd"); - - // Validate date format - if (!DateTime.TryParseExact(dateString, "yyyy-MM-dd", null, System.Globalization.DateTimeStyles.None, out var parsedDate)) - { - return BadRequest("Invalid date format. Please use yyyy-MM-dd format."); - } - - // Get stopId parameter - if (!Request.Query.TryGetValue("stopId", out var requestedStopIdString)) - { - return BadRequest("Please provide a stop id as a query parameter with the name 'stopId'."); - } - - if (!int.TryParse(requestedStopIdString, out var requestedStopId)) - { - return BadRequest("The provided stop id is not a valid number."); - } - - // Create cache key - var cacheKey = $"timetable_{dateString}_{requestedStopId}"; - - // Try to get from cache first - if (_cache.TryGetValue(cacheKey, out var cachedData)) - { - return new OkObjectResult(cachedData); - } - - try - { - // Fetch data from external API - var url = $"https://costas.dev/static-storage/vitrasa_svc/stops/{dateString}/{requestedStopId}.json"; - var response = await _httpClient.GetAsync(url); - - if (!response.IsSuccessStatusCode) - { - if (response.StatusCode == System.Net.HttpStatusCode.NotFound) - { - return NotFound($"Timetable data not found for stop {requestedStopId} on {dateString}"); - } - return StatusCode((int)response.StatusCode, "Error fetching timetable data"); - } - - var jsonContent = await response.Content.ReadAsStringAsync(); - var timetableData = JsonSerializer.Deserialize(jsonContent); - - // Cache the data for 12 hours - var cacheOptions = new MemoryCacheEntryOptions - { - AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(12), - SlidingExpiration = TimeSpan.FromHours(6), // Refresh cache if accessed within 6 hours of expiry - Priority = CacheItemPriority.Normal - }; - - _cache.Set(cacheKey, timetableData, cacheOptions); - - return new OkObjectResult(timetableData); - } - catch (HttpRequestException ex) - { - return StatusCode(500, $"Error fetching timetable data: {ex.Message}"); - } - catch (JsonException ex) - { - return StatusCode(500, $"Error parsing timetable data: {ex.Message}"); - } - catch (Exception ex) - { - return StatusCode(500, $"Unexpected error: {ex.Message}"); - } - } -} - diff --git a/src/Costasdev.Busurbano.Backend/SantiagoController.cs b/src/Costasdev.Busurbano.Backend/SantiagoController.cs new file mode 100644 index 0000000..51a76c6 --- /dev/null +++ b/src/Costasdev.Busurbano.Backend/SantiagoController.cs @@ -0,0 +1,81 @@ +using System.Text.Json; +using Costasdev.VigoTransitApi.Types; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Caching.Memory; + +namespace Costasdev.Busurbano.Backend; + +[ApiController] +[Route("api/santiago")] +public class SantiagoController : ControllerBase +{ + private readonly IMemoryCache _cache; + private readonly HttpClient _httpClient; + + public SantiagoController(HttpClient http, IMemoryCache cache) + { + _cache = cache; + _httpClient = http; + } + + [HttpGet("GetStopEstimates")] + public async Task Run() + { + var argumentAvailable = Request.Query.TryGetValue("id", out var requestedStopIdString); + if (!argumentAvailable) + { + return BadRequest("Please provide a stop id as a query parameter with the name 'id'."); + } + + var argumentNumber = int.TryParse(requestedStopIdString, out var requestedStopId); + if (!argumentNumber) + { + return BadRequest("The provided stop id is not a valid number."); + } + + try + { + var obj = await _httpClient.GetFromJsonAsync( + $"https://app.tussa.org/tussa/api/paradas/{requestedStopId}"); + + if (obj is null) + { + return BadRequest("No response returned from the API, or whatever"); + } + + var root = obj.RootElement; + + List estimates = root + .GetProperty("lineas") + .EnumerateArray() + .Select(el => new StopEstimate( + el.GetProperty("sinoptico").GetString() ?? string.Empty, + el.GetProperty("nombre").GetString() ?? string.Empty, + el.GetProperty("minutosProximoPaso").GetInt32(), + 0 + )).ToList(); + + return new OkObjectResult(new StopEstimateResponse + { + Stop = new StopEstimateResponse.StopInfo + { + Name = root.GetProperty("nombre").GetString() ?? string.Empty, + Id = root.GetProperty("id").GetInt32(), + Latitude = root.GetProperty("coordenadas").GetProperty("latitud").GetDecimal(), + Longitude = root.GetProperty("coordenadas").GetProperty("longitud").GetDecimal() + }, + Estimates = estimates + }); + } + catch (InvalidOperationException) + { + return new BadRequestObjectResult("Stop not found"); + } + } + + [HttpGet("GetStopTimetable")] + public async Task GetStopTimetable() + { + throw new NotImplementedException(); + } +} diff --git a/src/Costasdev.Busurbano.Backend/VigoController.cs b/src/Costasdev.Busurbano.Backend/VigoController.cs new file mode 100644 index 0000000..41a8765 --- /dev/null +++ b/src/Costasdev.Busurbano.Backend/VigoController.cs @@ -0,0 +1,127 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Caching.Memory; +using Costasdev.VigoTransitApi; +using System.Text.Json; + +namespace Costasdev.Busurbano.Backend; + +[ApiController] +[Route("api/vigo")] +public class VigoController : ControllerBase +{ + private readonly VigoTransitApiClient _api; + private readonly IMemoryCache _cache; + private readonly HttpClient _httpClient; + + public VigoController(HttpClient http, IMemoryCache cache) + { + _api = new VigoTransitApiClient(http); + _cache = cache; + _httpClient = http; + } + + [HttpGet("GetStopEstimates")] + public async Task Run() + { + var argumentAvailable = Request.Query.TryGetValue("id", out var requestedStopIdString); + if (!argumentAvailable) + { + return BadRequest("Please provide a stop id as a query parameter with the name 'id'."); + } + + var argumentNumber = int.TryParse(requestedStopIdString, out var requestedStopId); + if (!argumentNumber) + { + return BadRequest("The provided stop id is not a valid number."); + } + + try + { + var estimates = await _api.GetStopEstimates(requestedStopId); + return new OkObjectResult(estimates); + } + catch (InvalidOperationException) + { + return new BadRequestObjectResult("Stop not found"); + } + } + + [HttpGet("GetStopTimetable")] + public async Task GetStopTimetable() + { + // Get date parameter (default to today if not provided) + var dateString = Request.Query.TryGetValue("date", out var requestedDate) + ? requestedDate.ToString() + : DateTime.Today.ToString("yyyy-MM-dd"); + + // Validate date format + if (!DateTime.TryParseExact(dateString, "yyyy-MM-dd", null, System.Globalization.DateTimeStyles.None, out var parsedDate)) + { + return BadRequest("Invalid date format. Please use yyyy-MM-dd format."); + } + + // Get stopId parameter + if (!Request.Query.TryGetValue("stopId", out var requestedStopIdString)) + { + return BadRequest("Please provide a stop id as a query parameter with the name 'stopId'."); + } + + if (!int.TryParse(requestedStopIdString, out var requestedStopId)) + { + return BadRequest("The provided stop id is not a valid number."); + } + + // Create cache key + var cacheKey = $"timetable_{dateString}_{requestedStopId}"; + + // Try to get from cache first + if (_cache.TryGetValue(cacheKey, out var cachedData)) + { + return new OkObjectResult(cachedData); + } + + try + { + // Fetch data from external API + var url = $"https://costas.dev/static-storage/vitrasa_svc/stops/{dateString}/{requestedStopId}.json"; + var response = await _httpClient.GetAsync(url); + + if (!response.IsSuccessStatusCode) + { + if (response.StatusCode == System.Net.HttpStatusCode.NotFound) + { + return NotFound($"Timetable data not found for stop {requestedStopId} on {dateString}"); + } + return StatusCode((int)response.StatusCode, "Error fetching timetable data"); + } + + var jsonContent = await response.Content.ReadAsStringAsync(); + var timetableData = JsonSerializer.Deserialize(jsonContent); + + // Cache the data for 12 hours + var cacheOptions = new MemoryCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(12), + SlidingExpiration = TimeSpan.FromHours(6), // Refresh cache if accessed within 6 hours of expiry + Priority = CacheItemPriority.Normal + }; + + _cache.Set(cacheKey, timetableData, cacheOptions); + + return new OkObjectResult(timetableData); + } + catch (HttpRequestException ex) + { + return StatusCode(500, $"Error fetching timetable data: {ex.Message}"); + } + catch (JsonException ex) + { + return StatusCode(500, $"Error parsing timetable data: {ex.Message}"); + } + catch (Exception ex) + { + return StatusCode(500, $"Unexpected error: {ex.Message}"); + } + } +} + diff --git a/src/frontend/app/AppContext.tsx b/src/frontend/app/AppContext.tsx index 9013463..1a9b511 100644 --- a/src/frontend/app/AppContext.tsx +++ b/src/frontend/app/AppContext.tsx @@ -7,6 +7,7 @@ import { type ReactNode, } from "react"; import { type LngLatLike } from "maplibre-gl"; +import { type RegionId, DEFAULT_REGION, getRegionConfig, isValidRegion } from "./data/RegionConfig"; export type Theme = "light" | "dark" | "system"; type TableStyle = "regular" | "grouped"; @@ -37,6 +38,9 @@ interface AppContextProps { mapPositionMode: MapPositionMode; setMapPositionMode: (mode: MapPositionMode) => void; + + region: RegionId; + setRegion: (region: RegionId) => void; } // Coordenadas por defecto centradas en Vigo @@ -153,6 +157,29 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { }, [mapPositionMode]); //#endregion + //#region Region + const [region, setRegionState] = useState(() => { + const savedRegion = localStorage.getItem("region"); + if (savedRegion && isValidRegion(savedRegion)) { + return savedRegion; + } + return DEFAULT_REGION; + }); + + const setRegion = (newRegion: RegionId) => { + setRegionState(newRegion); + localStorage.setItem("region", newRegion); + + // Update map to region's default center and zoom + const regionConfig = getRegionConfig(newRegion); + updateMapState(regionConfig.defaultCenter, regionConfig.defaultZoom); + }; + + useEffect(() => { + localStorage.setItem("region", region); + }, [region]); + //#endregion + //#region Map State const [mapState, setMapState] = useState(() => { const savedMapState = localStorage.getItem("mapState"); @@ -253,6 +280,8 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { updateMapState, mapPositionMode, setMapPositionMode, + region, + setRegion, }} > {children} diff --git a/src/frontend/app/components/GroupedTable.tsx b/src/frontend/app/components/GroupedTable.tsx index 47c2d31..fd97d5b 100644 --- a/src/frontend/app/components/GroupedTable.tsx +++ b/src/frontend/app/components/GroupedTable.tsx @@ -1,12 +1,14 @@ import { type StopDetails } from "../routes/estimates-$id"; import LineIcon from "./LineIcon"; +import { type RegionConfig } from "../data/RegionConfig"; interface GroupedTable { data: StopDetails; dataDate: Date | null; + regionConfig: RegionConfig; } -export const GroupedTable: React.FC = ({ data, dataDate }) => { +export const GroupedTable: React.FC = ({ data, dataDate, regionConfig }) => { const formatDistance = (meters: number) => { if (meters > 1024) { return `${(meters / 1000).toFixed(1)} km`; @@ -43,7 +45,7 @@ export const GroupedTable: React.FC = ({ data, dataDate }) => { Línea Ruta Llegada - Distancia + {regionConfig.showMeters && Distancia} @@ -53,16 +55,18 @@ export const GroupedTable: React.FC = ({ data, dataDate }) => { {idx === 0 && ( - + )} {estimate.route} {`${estimate.minutes} min`} - - {estimate.meters > -1 - ? formatDistance(estimate.meters) - : "No disponible"} - + {regionConfig.showMeters && ( + + {estimate.meters > -1 + ? formatDistance(estimate.meters) + : "No disponible"} + + )} )), )} @@ -71,7 +75,9 @@ export const GroupedTable: React.FC = ({ data, dataDate }) => { {data?.estimates.length === 0 && ( - No hay estimaciones disponibles + + No hay estimaciones disponibles + )} diff --git a/src/frontend/app/components/LineIcon.css b/src/frontend/app/components/LineIcon.css index 4613a85..7d46b98 100644 --- a/src/frontend/app/components/LineIcon.css +++ b/src/frontend/app/components/LineIcon.css @@ -1,49 +1,75 @@ +/* Vigo line colors */ :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-vigo-c1: rgb(237, 71, 19); + --line-vigo-c3d: rgb(255, 204, 0); + --line-vigo-c3i: rgb(255, 204, 0); + --line-vigo-l4a: rgb(0, 153, 0); + --line-vigo-l4c: rgb(0, 153, 0); + --line-vigo-l5a: rgb(0, 176, 240); + --line-vigo-l5b: rgb(0, 176, 240); + --line-vigo-l6: rgb(204, 51, 153); + --line-vigo-l7: rgb(150, 220, 153); + --line-vigo-l9b: rgb(244, 202, 140); + --line-vigo-l10: rgb(153, 51, 0); + --line-vigo-l11: rgb(226, 0, 38); + --line-vigo-l12a: rgb(106, 150, 190); + --line-vigo-l12b: rgb(106, 150, 190); + --line-vigo-l13: rgb(0, 176, 240); + --line-vigo-l14: rgb(129, 142, 126); + --line-vigo-l15a: rgb(216, 168, 206); + --line-vigo-l15b: rgb(216, 168, 206); + --line-vigo-l15c: rgb(216, 168, 168); + --line-vigo-l16: rgb(129, 142, 126); + --line-vigo-l17: rgb(214, 245, 31); + --line-vigo-l18a: rgb(212, 80, 168); + --line-vigo-l18b: rgb(0, 0, 0); + --line-vigo-l18h: rgb(0, 0, 0); + --line-vigo-l23: rgb(0, 70, 210); + --line-vigo-l24: rgb(191, 191, 191); + --line-vigo-l25: rgb(172, 100, 4); + --line-vigo-l27: rgb(112, 74, 42); + --line-vigo-l28: rgb(176, 189, 254); + --line-vigo-l29: rgb(248, 184, 90); + --line-vigo-l31: rgb(255, 255, 0); + --line-vigo-a: rgb(119, 41, 143); + --line-vigo-h: rgb(0, 96, 168); + --line-vigo-h1: rgb(0, 96, 168); + --line-vigo-h2: rgb(0, 96, 168); + --line-vigo-h3: rgb(0, 96, 168); + --line-vigo-lzd: rgb(61, 78, 167); + --line-vigo-n1: rgb(191, 191, 191); + --line-vigo-n4: rgb(102, 51, 102); + --line-vigo-psa1: rgb(0, 153, 0); + --line-vigo-psa4: rgb(0, 153, 0); + --line-vigo-ptl: rgb(150, 220, 153); + --line-vigo-turistico: rgb(102, 51, 102); + --line-vigo-u1: rgb(172, 100, 4); + --line-vigo-u2: rgb(172, 100, 4); + + --line-santiago-l1: #f32621; + --line-santiago-l4: #ffcc33; + --line-santiago-l5: #fa8405; + --line-santiago-l6: #d73983; + --line-santiago-l6a: #d73983; + --line-santiago-l7: #488bc1; + --line-santiago-l8: #6aaf48; + --line-santiago-l9: #46b8bb; + --line-santiago-c11: #aec741; + --line-santiago-l12: #842e14; + --line-santiago-l13: #336600; + --line-santiago-l15: #7a4b2a; + --line-santiago-c2: #283a87; + --line-santiago-c4: #283a87; + --line-santiago-c5: #999999; + --line-santiago-c6: #006666; + --line-santiago-p1: #537eb3; + --line-santiago-p2: #d23354; + --line-santiago-p3: #75bd96; + --line-santiago-p4: #f1c54f; + --line-santiago-p6: #999999; + --line-santiago-p7: #d2438c; + --line-santiago-p8: #e28c3a; + } .line-icon { @@ -55,187 +81,8 @@ font-weight: 600; text-transform: uppercase; border-radius: 0.25rem 0.25rem 0 0; - color: var(--text-color); background-color: var(--background-color); } -.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); -} diff --git a/src/frontend/app/components/LineIcon.tsx b/src/frontend/app/components/LineIcon.tsx index 3d613e6..4f4bfd9 100644 --- a/src/frontend/app/components/LineIcon.tsx +++ b/src/frontend/app/components/LineIcon.tsx @@ -1,14 +1,23 @@ -import React from "react"; +import React, { useMemo } from "react"; import "./LineIcon.css"; +import { type RegionId } from "../data/RegionConfig"; interface LineIconProps { line: string; + region?: RegionId; } -const LineIcon: React.FC = ({ line }) => { - const formattedLine = /^[a-zA-Z]/.test(line) ? line : `L${line}`; +const LineIcon: React.FC = ({ line, region = "vigo" }) => { + const formattedLine = useMemo(() => { + return /^[a-zA-Z]/.test(line) ? line : `L${line}`; + }, [line]); + const cssVarName = `--line-${region}-${formattedLine.toLowerCase()}`; + return ( - + {formattedLine} ); diff --git a/src/frontend/app/components/RegionSelector.tsx b/src/frontend/app/components/RegionSelector.tsx new file mode 100644 index 0000000..6c9fe8b --- /dev/null +++ b/src/frontend/app/components/RegionSelector.tsx @@ -0,0 +1,33 @@ +import { useApp } from "../AppContext"; +import { getAvailableRegions } from "../data/RegionConfig"; +import "./RegionSelector.css"; + +export function RegionSelector() { + const { region, setRegion } = useApp(); + const regions = getAvailableRegions(); + + const handleRegionChange = (e: React.ChangeEvent) => { + const newRegion = e.target.value as any; + setRegion(newRegion); + }; + + return ( +
+ + +
+ ); +} diff --git a/src/frontend/app/components/RegularTable.tsx b/src/frontend/app/components/RegularTable.tsx index 8b01410..68b732a 100644 --- a/src/frontend/app/components/RegularTable.tsx +++ b/src/frontend/app/components/RegularTable.tsx @@ -1,15 +1,18 @@ import { useTranslation } from "react-i18next"; import { type StopDetails } from "../routes/estimates-$id"; import LineIcon from "./LineIcon"; +import { type RegionConfig } from "../data/RegionConfig"; interface RegularTableProps { data: StopDetails; dataDate: Date | null; + regionConfig: RegionConfig; } export const RegularTable: React.FC = ({ data, dataDate, + regionConfig, }) => { const { t } = useTranslation(); @@ -46,7 +49,9 @@ export const RegularTable: React.FC = ({ {t("estimates.line", "Línea")} {t("estimates.route", "Ruta")} {t("estimates.arrival", "Llegada")} - {t("estimates.distance", "Distancia")} + {regionConfig.showMeters && ( + {t("estimates.distance", "Distancia")} + )} @@ -56,7 +61,7 @@ export const RegularTable: React.FC = ({ .map((estimate, idx) => ( - + {estimate.route} @@ -64,11 +69,13 @@ export const RegularTable: React.FC = ({ ? absoluteArrivalTime(estimate.minutes) : `${estimate.minutes} ${t("estimates.minutes", "min")}`} - - {estimate.meters > -1 - ? formatDistance(estimate.meters) - : t("estimates.not_available", "No disponible")} - + {regionConfig.showMeters && ( + + {estimate.meters > -1 + ? formatDistance(estimate.meters) + : t("estimates.not_available", "No disponible")} + + )} ))} @@ -76,7 +83,7 @@ export const RegularTable: React.FC = ({ {data?.estimates.length === 0 && ( - + {t("estimates.none", "No hay estimaciones disponibles")} diff --git a/src/frontend/app/components/StopItem.tsx b/src/frontend/app/components/StopItem.tsx index b781eb9..7d89d7d 100644 --- a/src/frontend/app/components/StopItem.tsx +++ b/src/frontend/app/components/StopItem.tsx @@ -2,19 +2,22 @@ import React from "react"; import { Link } from "react-router"; import StopDataProvider, { type Stop } from "../data/StopDataProvider"; import LineIcon from "./LineIcon"; +import { useApp } from "../AppContext"; interface StopItemProps { stop: Stop; } const StopItem: React.FC = ({ stop }) => { + const { region } = useApp(); + return (
  • {stop.favourite && } ( - {stop.stopId}) {StopDataProvider.getDisplayName(stop)} + {stop.stopId}) {StopDataProvider.getDisplayName(region, stop)}
    - {stop.lines?.map((line) => )} + {stop.lines?.map((line) => )}
  • diff --git a/src/frontend/app/components/StopSheet.tsx b/src/frontend/app/components/StopSheet.tsx index 702c574..7255884 100644 --- a/src/frontend/app/components/StopSheet.tsx +++ b/src/frontend/app/components/StopSheet.tsx @@ -7,6 +7,8 @@ import LineIcon from "./LineIcon"; import { StopSheetSkeleton } from "./StopSheetSkeleton"; import { ErrorDisplay } from "./ErrorDisplay"; import { type StopDetails } from "../routes/estimates-$id"; +import { type RegionId, getRegionConfig } from "../data/RegionConfig"; +import { useApp } from "../AppContext"; import "./StopSheet.css"; interface StopSheetProps { @@ -22,8 +24,9 @@ interface ErrorInfo { message?: string; } -const loadStopData = async (stopId: number): Promise => { - const resp = await fetch(`/api/GetStopEstimates?id=${stopId}`, { +const loadStopData = async (region: RegionId, stopId: number): Promise => { + const regionConfig = getRegionConfig(region); + const resp = await fetch(`${regionConfig.estimatesEndpoint}?id=${stopId}`, { headers: { Accept: "application/json", }, @@ -43,6 +46,8 @@ export const StopSheet: React.FC = ({ stopName, }) => { const { t } = useTranslation(); + const { region } = useApp(); + const regionConfig = getRegionConfig(region); const [data, setData] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -72,7 +77,7 @@ export const StopSheet: React.FC = ({ setError(null); setData(null); - const stopData = await loadStopData(stopId); + const stopData = await loadStopData(region, stopId); setData(stopData); setLastUpdated(new Date()); } catch (err) { @@ -87,7 +92,7 @@ export const StopSheet: React.FC = ({ if (isOpen && stopId) { loadData(); } - }, [isOpen, stopId]); + }, [isOpen, stopId, region]); const formatTime = (minutes: number) => { if (minutes > 15) { @@ -157,7 +162,7 @@ export const StopSheet: React.FC = ({ {limitedEstimates.map((estimate, idx) => (
    - +
    @@ -165,7 +170,7 @@ export const StopSheet: React.FC = ({
    {formatTime(estimate.minutes)} - {estimate.meters > -1 && ( + {regionConfig.showMeters && estimate.meters > -1 && ( {" • "} {formatDistance(estimate.meters)} diff --git a/src/frontend/app/components/TimetableTable.tsx b/src/frontend/app/components/TimetableTable.tsx index 86896ca..8215141 100644 --- a/src/frontend/app/components/TimetableTable.tsx +++ b/src/frontend/app/components/TimetableTable.tsx @@ -1,6 +1,7 @@ import { useTranslation } from "react-i18next"; import LineIcon from "./LineIcon"; import "./TimetableTable.css"; +import { useApp } from "../AppContext"; export interface TimetableEntry { line: { @@ -97,6 +98,7 @@ export const TimetableTable: React.FC = ({ currentTime }) => { const { t } = useTranslation(); + const { region } = useApp(); const displayData = showAll ? data : findNearbyEntries(data, currentTime || ''); const nowMinutes = currentTime ? timeToMinutes(currentTime) : timeToMinutes(new Date().toTimeString().slice(0, 8)); @@ -126,7 +128,7 @@ export const TimetableTable: React.FC = ({ >
    - +
    diff --git a/src/frontend/app/data/RegionConfig.ts b/src/frontend/app/data/RegionConfig.ts new file mode 100644 index 0000000..0ce66e6 --- /dev/null +++ b/src/frontend/app/data/RegionConfig.ts @@ -0,0 +1,49 @@ +export type RegionId = "vigo" | "santiago"; + +export interface RegionConfig { + id: RegionId; + name: string; + stopsEndpoint: string; + estimatesEndpoint: string; + timetableEndpoint: string | null; + defaultCenter: [number, number]; // [lat, lng] + defaultZoom: number; + showMeters: boolean; // Whether to show distance in meters +} + +export const REGIONS: Record = { + vigo: { + id: "vigo", + name: "Vigo", + stopsEndpoint: "/stops/vigo.json", + estimatesEndpoint: "/api/vigo/GetStopEstimates", + timetableEndpoint: "/api/vigo/GetStopTimetable", + defaultCenter: [42.229188855975046, -8.72246955783102], + defaultZoom: 14, + showMeters: true, + }, + santiago: { + id: "santiago", + name: "Santiago de Compostela", + stopsEndpoint: "/stops/santiago.json", + estimatesEndpoint: "/api/santiago/GetStopEstimates", + timetableEndpoint: null, // Not available for Santiago + defaultCenter: [42.8782, -8.5448], + defaultZoom: 14, + showMeters: false, // Santiago doesn't provide distance data + }, +}; + +export const DEFAULT_REGION: RegionId = "vigo"; + +export function getRegionConfig(regionId: RegionId): RegionConfig { + return REGIONS[regionId]; +} + +export function getAvailableRegions(): RegionConfig[] { + return Object.values(REGIONS); +} + +export function isValidRegion(regionId: string): regionId is RegionId { + return regionId === "vigo" || regionId === "santiago"; +} diff --git a/src/frontend/app/data/StopDataProvider.ts b/src/frontend/app/data/StopDataProvider.ts index 3959400..e49faaa 100644 --- a/src/frontend/app/data/StopDataProvider.ts +++ b/src/frontend/app/data/StopDataProvider.ts @@ -1,3 +1,5 @@ +import { type RegionId, getRegionConfig } from "./RegionConfig"; + export interface CachedStopList { timestamp: number; data: Stop[]; @@ -17,48 +19,52 @@ export interface Stop { favourite?: boolean; } -// In-memory cache and lookup map -let cachedStops: Stop[] | null = null; -let stopsMap: Record = {}; -// Custom names loaded from localStorage -let customNames: Record = {}; +// In-memory cache and lookup map per region +const cachedStopsByRegion: Record = {}; +const stopsMapByRegion: Record> = {}; +// Custom names loaded from localStorage per region +const customNamesByRegion: Record> = {}; -// Initialize cachedStops and customNames once -async function initStops() { - if (!cachedStops) { - const response = await fetch("/stops.json"); +// Initialize cachedStops and customNames once per region +async function initStops(region: RegionId) { + if (!cachedStopsByRegion[region]) { + const regionConfig = getRegionConfig(region); + const response = await fetch(regionConfig.stopsEndpoint); const stops = (await response.json()) as Stop[]; // build array and map - stopsMap = {}; - cachedStops = stops.map((stop) => { + stopsMapByRegion[region] = {}; + cachedStopsByRegion[region] = stops.map((stop) => { const entry = { ...stop, favourite: false } as Stop; - stopsMap[stop.stopId] = entry; + stopsMapByRegion[region][stop.stopId] = entry; return entry; }); // load custom names - const rawCustom = localStorage.getItem("customStopNames"); - if (rawCustom) - customNames = JSON.parse(rawCustom) as Record; + const rawCustom = localStorage.getItem(`customStopNames_${region}`); + if (rawCustom) { + customNamesByRegion[region] = JSON.parse(rawCustom) as Record; + } else { + customNamesByRegion[region] = {}; + } } } -async function getStops(): Promise { - await initStops(); +async function getStops(region: RegionId): Promise { + await initStops(region); // update favourites - const rawFav = localStorage.getItem("favouriteStops"); + const rawFav = localStorage.getItem(`favouriteStops_${region}`); const favouriteStops = rawFav ? (JSON.parse(rawFav) as number[]) : []; - cachedStops!.forEach( + cachedStopsByRegion[region]!.forEach( (stop) => (stop.favourite = favouriteStops.includes(stop.stopId)), ); - return cachedStops!; + return cachedStopsByRegion[region]!; } // New: get single stop by id -async function getStopById(stopId: number): Promise { - await initStops(); - const stop = stopsMap[stopId]; +async function getStopById(region: RegionId, stopId: number): Promise { + await initStops(region); + const stop = stopsMapByRegion[region]?.[stopId]; if (stop) { - const rawFav = localStorage.getItem("favouriteStops"); + const rawFav = localStorage.getItem(`favouriteStops_${region}`); const favouriteStops = rawFav ? (JSON.parse(rawFav) as number[]) : []; stop.favourite = favouriteStops.includes(stopId); } @@ -66,30 +72,36 @@ async function getStopById(stopId: number): Promise { } // Updated display name to include custom names -function getDisplayName(stop: Stop): string { +function getDisplayName(region: RegionId, stop: Stop): string { + const customNames = customNamesByRegion[region] || {}; 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 setCustomName(region: RegionId, stopId: number, label: string) { + if (!customNamesByRegion[region]) { + customNamesByRegion[region] = {}; + } + customNamesByRegion[region][stopId] = label; + localStorage.setItem(`customStopNames_${region}`, JSON.stringify(customNamesByRegion[region])); } -function removeCustomName(stopId: number) { - delete customNames[stopId]; - localStorage.setItem("customStopNames", JSON.stringify(customNames)); +function removeCustomName(region: RegionId, stopId: number) { + if (customNamesByRegion[region]) { + delete customNamesByRegion[region][stopId]; + localStorage.setItem(`customStopNames_${region}`, JSON.stringify(customNamesByRegion[region])); + } } // New: get custom label for a stop -function getCustomName(stopId: number): string | undefined { - return customNames[stopId]; +function getCustomName(region: RegionId, stopId: number): string | undefined { + return customNamesByRegion[region]?.[stopId]; } -function addFavourite(stopId: number) { - const rawFavouriteStops = localStorage.getItem("favouriteStops"); +function addFavourite(region: RegionId, stopId: number) { + const rawFavouriteStops = localStorage.getItem(`favouriteStops_${region}`); let favouriteStops: number[] = []; if (rawFavouriteStops) { favouriteStops = JSON.parse(rawFavouriteStops) as number[]; @@ -97,23 +109,23 @@ function addFavourite(stopId: number) { if (!favouriteStops.includes(stopId)) { favouriteStops.push(stopId); - localStorage.setItem("favouriteStops", JSON.stringify(favouriteStops)); + localStorage.setItem(`favouriteStops_${region}`, JSON.stringify(favouriteStops)); } } -function removeFavourite(stopId: number) { - const rawFavouriteStops = localStorage.getItem("favouriteStops"); +function removeFavourite(region: RegionId, stopId: number) { + const rawFavouriteStops = localStorage.getItem(`favouriteStops_${region}`); let favouriteStops: number[] = []; if (rawFavouriteStops) { favouriteStops = JSON.parse(rawFavouriteStops) as number[]; } const newFavouriteStops = favouriteStops.filter((id) => id !== stopId); - localStorage.setItem("favouriteStops", JSON.stringify(newFavouriteStops)); + localStorage.setItem(`favouriteStops_${region}`, JSON.stringify(newFavouriteStops)); } -function isFavourite(stopId: number): boolean { - const rawFavouriteStops = localStorage.getItem("favouriteStops"); +function isFavourite(region: RegionId, stopId: number): boolean { + const rawFavouriteStops = localStorage.getItem(`favouriteStops_${region}`); if (rawFavouriteStops) { const favouriteStops = JSON.parse(rawFavouriteStops) as number[]; return favouriteStops.includes(stopId); @@ -123,8 +135,8 @@ function isFavourite(stopId: number): boolean { const RECENT_STOPS_LIMIT = 10; -function pushRecent(stopId: number) { - const rawRecentStops = localStorage.getItem("recentStops"); +function pushRecent(region: RegionId, stopId: number) { + const rawRecentStops = localStorage.getItem(`recentStops_${region}`); let recentStops: Set = new Set(); if (rawRecentStops) { recentStops = new Set(JSON.parse(rawRecentStops) as number[]); @@ -137,19 +149,19 @@ function pushRecent(stopId: number) { recentStops.delete(val); } - localStorage.setItem("recentStops", JSON.stringify(Array.from(recentStops))); + localStorage.setItem(`recentStops_${region}`, JSON.stringify(Array.from(recentStops))); } -function getRecent(): number[] { - const rawRecentStops = localStorage.getItem("recentStops"); +function getRecent(region: RegionId): number[] { + const rawRecentStops = localStorage.getItem(`recentStops_${region}`); if (rawRecentStops) { return JSON.parse(rawRecentStops) as number[]; } return []; } -function getFavouriteIds(): number[] { - const rawFavouriteStops = localStorage.getItem("favouriteStops"); +function getFavouriteIds(region: RegionId): number[] { + const rawFavouriteStops = localStorage.getItem(`favouriteStops_${region}`); if (rawFavouriteStops) { return JSON.parse(rawFavouriteStops) as number[]; } @@ -157,8 +169,9 @@ function getFavouriteIds(): number[] { } // New function to load stops from network -async function loadStopsFromNetwork(): Promise { - const response = await fetch("/stops.json"); +async function loadStopsFromNetwork(region: RegionId): Promise { + const regionConfig = getRegionConfig(region); + const response = await fetch(regionConfig.stopsEndpoint); const stops = (await response.json()) as Stop[]; return stops.map((stop) => ({ ...stop, favourite: false } as Stop)); } diff --git a/src/frontend/app/routes/estimates-$id.tsx b/src/frontend/app/routes/estimates-$id.tsx index dc45198..c48932c 100644 --- a/src/frontend/app/routes/estimates-$id.tsx +++ b/src/frontend/app/routes/estimates-$id.tsx @@ -13,6 +13,7 @@ import { TimetableSkeleton } from "../components/TimetableSkeleton"; import { ErrorDisplay } from "../components/ErrorDisplay"; import { PullToRefresh } from "../components/PullToRefresh"; import { useAutoRefresh } from "../hooks/useAutoRefresh"; +import { type RegionId, getRegionConfig } from "../data/RegionConfig"; export interface StopDetails { stop: { @@ -35,8 +36,9 @@ interface ErrorInfo { message?: string; } -const loadData = async (stopId: string): Promise => { - const resp = await fetch(`/api/GetStopEstimates?id=${stopId}`, { +const loadData = async (region: RegionId, stopId: string): Promise => { + const regionConfig = getRegionConfig(region); + const resp = await fetch(`${regionConfig.estimatesEndpoint}?id=${stopId}`, { headers: { Accept: "application/json", }, @@ -49,9 +51,16 @@ const loadData = async (stopId: string): Promise => { return await resp.json(); }; -const loadTimetableData = async (stopId: string): Promise => { +const loadTimetableData = async (region: RegionId, stopId: string): Promise => { + const regionConfig = getRegionConfig(region); + + // Check if timetable is available for this region + if (!regionConfig.timetableEndpoint) { + throw new Error("Timetable not available for this region"); + } + const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD format - const resp = await fetch(`/api/GetStopTimetable?date=${today}&stopId=${stopId}`, { + const resp = await fetch(`${regionConfig.timetableEndpoint}?date=${today}&stopId=${stopId}`, { headers: { Accept: "application/json", }, @@ -83,7 +92,8 @@ export default function Estimates() { const [favourited, setFavourited] = useState(false); const [isManualRefreshing, setIsManualRefreshing] = useState(false); - const { tableStyle } = useApp(); + const { tableStyle, region } = useApp(); + const regionConfig = getRegionConfig(region); const parseError = (error: any): ErrorInfo => { if (!navigator.onLine) { @@ -108,10 +118,10 @@ export default function Estimates() { setEstimatesLoading(true); setEstimatesError(null); - const body = await loadData(params.id!); + const body = await loadData(region, params.id!); setData(body); setDataDate(new Date()); - setCustomName(StopDataProvider.getCustomName(stopIdNum)); + setCustomName(StopDataProvider.getCustomName(region, stopIdNum)); } catch (error) { console.error('Error loading estimates data:', error); setEstimatesError(parseError(error)); @@ -120,14 +130,20 @@ export default function Estimates() { } finally { setEstimatesLoading(false); } - }, [params.id, stopIdNum]); + }, [params.id, stopIdNum, region]); const loadTimetableDataAsync = useCallback(async () => { + // Skip loading timetable if not available for this region + if (!regionConfig.timetableEndpoint) { + setTimetableLoading(false); + return; + } + try { setTimetableLoading(true); setTimetableError(null); - const timetableBody = await loadTimetableData(params.id!); + const timetableBody = await loadTimetableData(region, params.id!); setTimetableData(timetableBody); } catch (error) { console.error('Error loading timetable data:', error); @@ -136,7 +152,7 @@ export default function Estimates() { } finally { setTimetableLoading(false); } - }, [params.id]); + }, [params.id, region, regionConfig.timetableEndpoint]); const refreshData = useCallback(async () => { await Promise.all([ @@ -168,16 +184,16 @@ export default function Estimates() { loadEstimatesData(); loadTimetableDataAsync(); - StopDataProvider.pushRecent(parseInt(params.id ?? "")); - setFavourited(StopDataProvider.isFavourite(parseInt(params.id ?? ""))); - }, [params.id, loadEstimatesData, loadTimetableDataAsync]); + StopDataProvider.pushRecent(region, parseInt(params.id ?? "")); + setFavourited(StopDataProvider.isFavourite(region, parseInt(params.id ?? ""))); + }, [params.id, region, loadEstimatesData, loadTimetableDataAsync]); const toggleFavourite = () => { if (favourited) { - StopDataProvider.removeFavourite(stopIdNum); + StopDataProvider.removeFavourite(region, stopIdNum); setFavourited(false); } else { - StopDataProvider.addFavourite(stopIdNum); + StopDataProvider.addFavourite(region, stopIdNum); setFavourited(true); } }; @@ -188,10 +204,10 @@ export default function Estimates() { if (input === null) return; // cancelled const trimmed = input.trim(); if (trimmed === "") { - StopDataProvider.removeCustomName(stopIdNum); + StopDataProvider.removeCustomName(region, stopIdNum); setCustomName(undefined); } else { - StopDataProvider.setCustomName(stopIdNum, trimmed); + StopDataProvider.setCustomName(region, stopIdNum, trimmed); setCustomName(trimmed); } }; @@ -270,9 +286,9 @@ export default function Estimates() { /> ) : data ? ( tableStyle === "grouped" ? ( - + ) : ( - + ) ) : null}
    diff --git a/src/frontend/app/routes/map.tsx b/src/frontend/app/routes/map.tsx index 56a9c79..c3a1308 100644 --- a/src/frontend/app/routes/map.tsx +++ b/src/frontend/app/routes/map.tsx @@ -38,7 +38,7 @@ export default function StopMap() { name: string; } | null>(null); const [isSheetOpen, setIsSheetOpen] = useState(false); - const { mapState, updateMapState, theme } = useApp(); + const { mapState, updateMapState, theme, region } = useApp(); const mapRef = useRef(null); const [mapStyleKey, setMapStyleKey] = useState("light"); @@ -56,7 +56,7 @@ export default function StopMap() { }; useEffect(() => { - StopDataProvider.getStops().then((data) => { + StopDataProvider.getStops(region).then((data) => { const features: GeoJsonFeature< Point, { stopId: number; name: string; lines: string[] } @@ -70,7 +70,7 @@ export default function StopMap() { })); setStops(features); }); - }, []); + }, [region]); useEffect(() => { //const styleName = "carto"; @@ -115,7 +115,7 @@ export default function StopMap() { const handlePointClick = (feature: any) => { const props: any = feature.properties; // fetch full stop to get lines array - StopDataProvider.getStopById(props.stopId).then((stop) => { + StopDataProvider.getStopById(region, props.stopId).then((stop) => { if (!stop) return; setSelectedStop({ stopId: stop.stopId, diff --git a/src/frontend/app/routes/settings.tsx b/src/frontend/app/routes/settings.tsx index bcda311..eae6ad8 100644 --- a/src/frontend/app/routes/settings.tsx +++ b/src/frontend/app/routes/settings.tsx @@ -2,6 +2,7 @@ import { type Theme, useApp } from "../AppContext"; import "./settings.css"; import { useTranslation } from "react-i18next"; import { useState } from "react"; +import { getAvailableRegions } from "../data/RegionConfig"; export default function Settings() { const { t, i18n } = useTranslation(); @@ -12,14 +13,35 @@ export default function Settings() { setTableStyle, mapPositionMode, setMapPositionMode, + region, + setRegion, } = useApp(); + const regions = getAvailableRegions(); + return (

    {t("about.title")}

    {t("about.description")}

    {t("about.settings")}

    +
    + + +