From 7b8594debceb93a1fa400d48fe1dcff943bd5af6 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Jun 2025 23:44:25 +0200 Subject: Implement stop sheet modal for map stop interactions (#27) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: arielcostas <94913521+arielcostas@users.noreply.github.com> Co-authored-by: Ariel Costas Guerrero --- package-lock.json | 87 +- src/frontend/app/AppContext.tsx | 124 +- src/frontend/app/ErrorBoundary.tsx | 18 +- src/frontend/app/components/GroupedTable.tsx | 124 +- src/frontend/app/components/LineIcon.css | 5 +- src/frontend/app/components/LineIcon.tsx | 4 +- src/frontend/app/components/NavBar.tsx | 30 +- src/frontend/app/components/RegularTable.tsx | 129 +- src/frontend/app/components/StopItem.css | 2 +- src/frontend/app/components/StopItem.tsx | 15 +- src/frontend/app/components/StopSheet.css | 146 + src/frontend/app/components/StopSheet.tsx | 154 + src/frontend/app/data/StopDataProvider.ts | 205 +- src/frontend/app/i18n/index.ts | 26 +- src/frontend/app/i18n/locales/en-GB.json | 6 +- src/frontend/app/i18n/locales/es-ES.json | 6 +- src/frontend/app/i18n/locales/gl-ES.json | 6 +- src/frontend/app/maps/styleloader.ts | 95 +- src/frontend/app/root.css | 8 +- src/frontend/app/root.tsx | 60 +- src/frontend/app/routes.tsx | 2 +- src/frontend/app/routes/estimates-$id.css | 2 +- src/frontend/app/routes/estimates-$id.tsx | 162 +- src/frontend/app/routes/index.tsx | 2 +- src/frontend/app/routes/map.css | 96 +- src/frontend/app/routes/map.tsx | 310 +- src/frontend/app/routes/settings.tsx | 192 +- src/frontend/app/routes/stoplist.tsx | 254 +- src/frontend/index.html | 114 +- src/frontend/package.json | 1 + src/frontend/public/manifest.webmanifest | 164 +- src/frontend/public/maps/spritesheet/sprite.json | 16 +- .../public/maps/spritesheet/sprite@2x.json | 16 +- src/frontend/public/maps/styles/carto-dark.json | 59 +- src/frontend/public/maps/styles/carto-light.json | 61 +- src/frontend/public/stops.json | 6398 ++++---------------- src/frontend/public/sw.js | 67 +- src/frontend/react-router.config.ts | 4 +- src/frontend/vite.config.ts | 16 +- 39 files changed, 2827 insertions(+), 6359 deletions(-) create mode 100644 src/frontend/app/components/StopSheet.css create mode 100644 src/frontend/app/components/StopSheet.tsx diff --git a/package-lock.json b/package-lock.json index 07872b2..0d0fabb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3727,6 +3727,34 @@ "node": ">= 0.6" } }, + "node_modules/framer-motion": { + "version": "12.19.1", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.19.1.tgz", + "integrity": "sha512-nq9hwWAEKf4gzprbOZzKugLV5OVKF7zrNDY6UOVu+4D3ZgIkg8L9Jy6AMrpBM06fhbKJ6LEG6UY5+t7Eq6wNlg==", + "license": "MIT", + "peer": true, + "dependencies": { + "motion-dom": "^12.19.0", + "motion-utils": "^12.19.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/fresh": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", @@ -4844,6 +4872,50 @@ "node": ">= 0.8" } }, + "node_modules/motion": { + "version": "12.19.1", + "resolved": "https://registry.npmjs.org/motion/-/motion-12.19.1.tgz", + "integrity": "sha512-OhoHWrht+zwDPccr2wGltJdwgz2elFBBt/sLei2g0hwICvy2hOBFUkA4Ylup3VnDgz+vUtecf694EV7bJK4XjA==", + "license": "MIT", + "peer": true, + "dependencies": { + "framer-motion": "^12.19.1", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/motion-dom": { + "version": "12.19.0", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.19.0.tgz", + "integrity": "sha512-m96uqq8VbwxFLU0mtmlsIVe8NGGSdpBvBSHbnnOJQxniPaabvVdGgxSamhuDwBsRhwX7xPxdICgVJlOpzn/5bw==", + "license": "MIT", + "peer": true, + "dependencies": { + "motion-utils": "^12.19.0" + } + }, + "node_modules/motion-utils": { + "version": "12.19.0", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.19.0.tgz", + "integrity": "sha512-BuFTHINYmV07pdWs6lj6aI63vr2N4dg0vR+td0rtrdpWOhBzIkEklZyLcvKBoEtwSqx8Jg06vUB5RS0xDiUybw==", + "license": "MIT", + "peer": true + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -5488,6 +5560,19 @@ } } }, + "node_modules/react-modal-sheet": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/react-modal-sheet/-/react-modal-sheet-4.4.0.tgz", + "integrity": "sha512-ub42vR7iwjdM/2Zl6uZoH5M5Dcb6KTuVaOQ+uQCIlo10SdlYAhksb6u3eIFSLohFrX5q03rgFnR6Y0PKhjjl/Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "motion": ">=11", + "react": ">=16" + } + }, "node_modules/react-refresh": { "version": "0.14.2", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", @@ -6317,7 +6402,6 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, "license": "0BSD" }, "node_modules/type-check": { @@ -6861,6 +6945,7 @@ "react-dom": "^19.1.0", "react-leaflet": "^5.0.0", "react-leaflet-markercluster": "^5.0.0-rc.0", + "react-modal-sheet": "^4.4.0", "react-router": "^7.6.0" }, "devDependencies": { diff --git a/src/frontend/app/AppContext.tsx b/src/frontend/app/AppContext.tsx index d8db66d..e6d8971 100644 --- a/src/frontend/app/AppContext.tsx +++ b/src/frontend/app/AppContext.tsx @@ -1,10 +1,16 @@ /* 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'; +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; @@ -42,56 +48,62 @@ const AppContext = createContext(undefined); export const AppProvider = ({ children }: { children: ReactNode }) => { //#region Theme const [theme, setTheme] = useState(() => { - const savedTheme = localStorage.getItem('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 prefersDark = + window.matchMedia && + window.matchMedia("(prefers-color-scheme: dark)").matches; + return prefersDark ? "dark" : "light"; }); const toggleTheme = () => { - setTheme((prevTheme) => (prevTheme === 'light' ? 'dark' : 'light')); + setTheme((prevTheme) => (prevTheme === "light" ? "dark" : "light")); }; useEffect(() => { - document.documentElement.setAttribute('data-theme', theme); - localStorage.setItem('theme', theme); + document.documentElement.setAttribute("data-theme", theme); + localStorage.setItem("theme", theme); }, [theme]); //#endregion //#region Table Style const [tableStyle, setTableStyle] = useState(() => { - const savedTableStyle = localStorage.getItem('tableStyle'); + const savedTableStyle = localStorage.getItem("tableStyle"); if (savedTableStyle) { return savedTableStyle as TableStyle; } - return 'regular'; + return "regular"; }); const toggleTableStyle = () => { - setTableStyle((prevTableStyle) => (prevTableStyle === 'regular' ? 'grouped' : 'regular')); - } + setTableStyle((prevTableStyle) => + prevTableStyle === "regular" ? "grouped" : "regular", + ); + }; useEffect(() => { - localStorage.setItem('tableStyle', tableStyle); + localStorage.setItem("tableStyle", tableStyle); }, [tableStyle]); //#endregion //#region Map Position Mode - const [mapPositionMode, setMapPositionMode] = useState(() => { - const saved = localStorage.getItem('mapPositionMode'); - return saved === 'last' ? 'last' : 'gps'; - }); + const [mapPositionMode, setMapPositionMode] = useState( + () => { + const saved = localStorage.getItem("mapPositionMode"); + return saved === "last" ? "last" : "gps"; + }, + ); useEffect(() => { - localStorage.setItem('mapPositionMode', mapPositionMode); + localStorage.setItem("mapPositionMode", mapPositionMode); }, [mapPositionMode]); //#endregion //#region Map State const [mapState, setMapState] = useState(() => { - const savedMapState = localStorage.getItem('mapState'); + const savedMapState = localStorage.getItem("mapState"); if (savedMapState) { try { const parsed = JSON.parse(savedMapState); @@ -99,56 +111,56 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { center: parsed.center || DEFAULT_CENTER, zoom: parsed.zoom || DEFAULT_ZOOM, userLocation: parsed.userLocation || null, - hasLocationPermission: parsed.hasLocationPermission || false + hasLocationPermission: parsed.hasLocationPermission || false, }; } catch (e) { - console.error('Error parsing saved map state', e); + console.error("Error parsing saved map state", e); } } return { center: DEFAULT_CENTER, zoom: DEFAULT_ZOOM, userLocation: null, - hasLocationPermission: false + hasLocationPermission: false, }; }); const setMapCenter = (center: LngLatLike) => { - setMapState(prev => { + setMapState((prev) => { const newState = { ...prev, center }; - localStorage.setItem('mapState', JSON.stringify(newState)); + localStorage.setItem("mapState", JSON.stringify(newState)); return newState; }); }; const setMapZoom = (zoom: number) => { - setMapState(prev => { + setMapState((prev) => { const newState = { ...prev, zoom }; - localStorage.setItem('mapState', JSON.stringify(newState)); + localStorage.setItem("mapState", JSON.stringify(newState)); return newState; }); }; const setUserLocation = (userLocation: LngLatLike | null) => { - setMapState(prev => { + setMapState((prev) => { const newState = { ...prev, userLocation }; - localStorage.setItem('mapState', JSON.stringify(newState)); + localStorage.setItem("mapState", JSON.stringify(newState)); return newState; }); }; const setLocationPermission = (hasLocationPermission: boolean) => { - setMapState(prev => { + setMapState((prev) => { const newState = { ...prev, hasLocationPermission }; - localStorage.setItem('mapState', JSON.stringify(newState)); + localStorage.setItem("mapState", JSON.stringify(newState)); return newState; }); }; const updateMapState = (center: LngLatLike, zoom: number) => { - setMapState(prev => { + setMapState((prev) => { const newState = { ...prev, center, zoom }; - localStorage.setItem('mapState', JSON.stringify(newState)); + localStorage.setItem("mapState", JSON.stringify(newState)); return newState; }); }; @@ -164,31 +176,33 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { setUserLocation([latitude, longitude]); }, (error) => { - console.error('Error getting location:', error); + console.error("Error getting location:", error); setLocationPermission(false); - } + }, ); } } }, [mapState.hasLocationPermission, mapState.userLocation]); return ( - + {children} ); @@ -197,7 +211,7 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { export const useApp = () => { const context = useContext(AppContext); if (!context) { - throw new Error('useApp must be used within a AppProvider'); + 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 index 5c877b7..c11a82b 100644 --- a/src/frontend/app/ErrorBoundary.tsx +++ b/src/frontend/app/ErrorBoundary.tsx @@ -1,4 +1,4 @@ -import React, { Component, type ReactNode } from 'react'; +import React, { Component, type ReactNode } from "react"; interface ErrorBoundaryProps { children: ReactNode; @@ -14,14 +14,14 @@ class ErrorBoundary extends Component { super(props); this.state = { hasError: false, - error: null + error: null, }; } static getDerivedStateFromError(error: Error): ErrorBoundaryState { return { hasError: true, - error + error, }; } @@ -31,12 +31,12 @@ class ErrorBoundary extends Component { render() { if (this.state.hasError) { - return <> -

Something went wrong.

-
-          {this.state.error?.stack}
-        
- ; + return ( + <> +

Something went wrong.

+
{this.state.error?.stack}
+ + ); } return this.props.children; diff --git a/src/frontend/app/components/GroupedTable.tsx b/src/frontend/app/components/GroupedTable.tsx index 3a16d89..47c2d31 100644 --- a/src/frontend/app/components/GroupedTable.tsx +++ b/src/frontend/app/components/GroupedTable.tsx @@ -2,73 +2,79 @@ import { type StopDetails } from "../routes/estimates-$id"; import LineIcon from "./LineIcon"; interface GroupedTable { - data: StopDetails; - dataDate: Date | null; + data: StopDetails; + dataDate: Date | null; } export const GroupedTable: React.FC = ({ data, dataDate }) => { - const formatDistance = (meters: number) => { - if (meters > 1024) { - return `${(meters / 1000).toFixed(1)} km`; - } else { - return `${meters} m`; - } + 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); + const groupedEstimates = data.estimates.reduce( + (acc, estimate) => { + if (!acc[estimate.line]) { + acc[estimate.line] = []; + } + acc[estimate.line].push(estimate); + return acc; + }, + {} as Record, + ); - const sortedLines = Object.keys(groupedEstimates).sort((a, b) => { - const firstArrivalA = groupedEstimates[a][0].minutes; - const firstArrivalB = groupedEstimates[b][0].minutes; - return firstArrivalA - firstArrivalB; - }); + const sortedLines = Object.keys(groupedEstimates).sort((a, b) => { + const firstArrivalA = groupedEstimates[a][0].minutes; + const firstArrivalB = groupedEstimates[b][0].minutes; + return firstArrivalA - firstArrivalB; + }); - return - + return ( +
Estimaciones de llegadas a las {dataDate?.toLocaleTimeString()}
+ - - - - - - - - - - - {sortedLines.map((line) => ( - groupedEstimates[line].map((estimate, idx) => ( - - {idx === 0 && ( - - )} - - - - - )) - ))} - + + + + + + + + - {data?.estimates.length === 0 && ( - - - - - + + {sortedLines.map((line) => + groupedEstimates[line].map((estimate, idx) => ( + + {idx === 0 && ( + + )} + + + + + )), )} + + + {data?.estimates.length === 0 && ( + + + + + + )}
+ Estimaciones de llegadas a las {dataDate?.toLocaleTimeString()} +
LíneaRutaLlegadaDistancia
- - {estimate.route}{`${estimate.minutes} min`} - {estimate.meters > -1 - ? formatDistance(estimate.meters) - : "No disponible" - } -
LíneaRutaLlegadaDistancia
No hay estimaciones disponibles
+ + {estimate.route}{`${estimate.minutes} min`} + {estimate.meters > -1 + ? formatDistance(estimate.meters) + : "No disponible"} +
No hay estimaciones disponibles
-} + ); +}; diff --git a/src/frontend/app/components/LineIcon.css b/src/frontend/app/components/LineIcon.css index e7e8949..4b39351 100644 --- a/src/frontend/app/components/LineIcon.css +++ b/src/frontend/app/components/LineIcon.css @@ -55,7 +55,8 @@ font-weight: 600; text-transform: uppercase; color: inherit; - /* Prevent color change on hover */ + background-color: white; + border-radius: 0.25rem 0.25rem 0 0; } .line-c1 { @@ -236,4 +237,4 @@ .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 index 291b444..3d613e6 100644 --- a/src/frontend/app/components/LineIcon.tsx +++ b/src/frontend/app/components/LineIcon.tsx @@ -1,5 +1,5 @@ -import React from 'react'; -import './LineIcon.css'; +import React from "react"; +import "./LineIcon.css"; interface LineIconProps { line: string; diff --git a/src/frontend/app/components/NavBar.tsx b/src/frontend/app/components/NavBar.tsx index eba7196..6a06e63 100644 --- a/src/frontend/app/components/NavBar.tsx +++ b/src/frontend/app/components/NavBar.tsx @@ -9,14 +9,14 @@ 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) { + } 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; + return lat >= 42.18 && lat <= 42.3 && lng >= -8.78 && lng <= -8.65; } export default function NavBar() { @@ -25,20 +25,20 @@ export default function NavBar() { const navItems = [ { - name: t('navbar.stops', 'Paradas'), + name: t("navbar.stops", "Paradas"), icon: MapPin, - path: '/stops' + path: "/stops", }, { - name: t('navbar.map', 'Mapa'), + name: t("navbar.map", "Mapa"), icon: Map, - path: '/map', + path: "/map", callback: () => { - if (mapPositionMode !== 'gps') { + if (mapPositionMode !== "gps") { return; } - if (!('geolocation' in navigator)) { + if (!("geolocation" in navigator)) { return; } @@ -50,20 +50,20 @@ export default function NavBar() { updateMapState(coords, 16); } }, - () => { } + () => {}, ); - } + }, }, { - name: t('navbar.settings', 'Ajustes'), + name: t("navbar.settings", "Ajustes"), icon: Settings, - path: '/settings' - } + path: "/settings", + }, ]; return (