diff options
| author | Copilot <198982749+Copilot@users.noreply.github.com> | 2025-06-26 23:44:25 +0200 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-06-26 23:44:25 +0200 |
| commit | 7b8594debceb93a1fa400d48fe1dcff943bd5af6 (patch) | |
| tree | 73e68c7238a91d8931d669364d395ce2994164f4 /src/frontend/app/components | |
| parent | 3dac17a9fb54c977c97280ed4c482e9d4266b7de (diff) | |
Implement stop sheet modal for map stop interactions (#27)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: arielcostas <94913521+arielcostas@users.noreply.github.com>
Co-authored-by: Ariel Costas Guerrero <ariel@costas.dev>
Diffstat (limited to 'src/frontend/app/components')
| -rw-r--r-- | src/frontend/app/components/GroupedTable.tsx | 124 | ||||
| -rw-r--r-- | src/frontend/app/components/LineIcon.css | 5 | ||||
| -rw-r--r-- | src/frontend/app/components/LineIcon.tsx | 4 | ||||
| -rw-r--r-- | src/frontend/app/components/NavBar.tsx | 30 | ||||
| -rw-r--r-- | src/frontend/app/components/RegularTable.tsx | 129 | ||||
| -rw-r--r-- | src/frontend/app/components/StopItem.css | 2 | ||||
| -rw-r--r-- | src/frontend/app/components/StopItem.tsx | 15 | ||||
| -rw-r--r-- | src/frontend/app/components/StopSheet.css | 146 | ||||
| -rw-r--r-- | src/frontend/app/components/StopSheet.tsx | 154 |
9 files changed, 465 insertions, 144 deletions
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<GroupedTable> = ({ 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<string, typeof data.estimates>); + 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; - }); + 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> + 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> + <thead> + <tr> + <th>Línea</th> + <th>Ruta</th> + <th>Llegada</th> + <th>Distancia</th> + </tr> + </thead> - {data?.estimates.length === 0 && ( - <tfoot> - <tr> - <td colSpan={4}>No hay estimaciones disponibles</td> - </tr> - </tfoot> + <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 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 ( <nav className="navigation-bar"> - {navItems.map(item => { + {navItems.map((item) => { const Icon = item.icon; const isActive = location.pathname.startsWith(item.path); @@ -71,7 +71,7 @@ export default function NavBar() { <Link key={item.name} to={item.path} - className={`navigation-bar__link ${isActive ? 'active' : ''}`} + className={`navigation-bar__link ${isActive ? "active" : ""}`} onClick={item.callback ? item.callback : undefined} title={item.name} aria-label={item.name} diff --git a/src/frontend/app/components/RegularTable.tsx b/src/frontend/app/components/RegularTable.tsx index e5b3782..8b01410 100644 --- a/src/frontend/app/components/RegularTable.tsx +++ b/src/frontend/app/components/RegularTable.tsx @@ -3,70 +3,85 @@ import { type StopDetails } from "../routes/estimates-$id"; import LineIcon from "./LineIcon"; interface RegularTableProps { - data: StopDetails; - dataDate: Date | null; + data: StopDetails; + dataDate: Date | null; } -export const RegularTable: React.FC<RegularTableProps> = ({ data, dataDate }) => { - const { t } = useTranslation(); +export const RegularTable: React.FC<RegularTableProps> = ({ + data, + dataDate, +}) => { + const { t } = useTranslation(); - 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 absoluteArrivalTime = (minutes: number) => { + const now = new Date(); + const arrival = new Date(now.getTime() + minutes * 60000); + return Intl.DateTimeFormat( + typeof navigator !== "undefined" ? navigator.language : "en", + { + hour: "2-digit", + minute: "2-digit", + } + ).format(arrival); + }; - const formatDistance = (meters: number) => { - if (meters > 1024) { - return `${(meters / 1000).toFixed(1)} km`; - } else { - return `${meters} ${t('estimates.meters', 'm')}`; - } + const formatDistance = (meters: number) => { + if (meters > 1024) { + return `${(meters / 1000).toFixed(1)} km`; + } else { + return `${meters} ${t("estimates.meters", "m")}`; } + }; - return <table className="table"> - <caption>{t('estimates.caption', 'Estimaciones de llegadas a las {{time}}', { time: dataDate?.toLocaleTimeString() })}</caption> + return ( + <table className="table"> + <caption> + {t("estimates.caption", "Estimaciones de llegadas a las {{time}}", { + time: dataDate?.toLocaleTimeString(), + })} + </caption> - <thead> - <tr> - <th>{t('estimates.line', 'Línea')}</th> - <th>{t('estimates.route', 'Ruta')}</th> - <th>{t('estimates.arrival', 'Llegada')}</th> - <th>{t('estimates.distance', 'Distancia')}</th> - </tr> - </thead> + <thead> + <tr> + <th>{t("estimates.line", "Línea")}</th> + <th>{t("estimates.route", "Ruta")}</th> + <th>{t("estimates.arrival", "Llegada")}</th> + <th>{t("estimates.distance", "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} ${t('estimates.minutes', 'min')}`} - </td> - <td> - {estimate.meters > -1 - ? formatDistance(estimate.meters) - : t('estimates.not_available', 'No disponible') - } - </td> - </tr> - ))} - </tbody> + <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} ${t("estimates.minutes", "min")}`} + </td> + <td> + {estimate.meters > -1 + ? formatDistance(estimate.meters) + : t("estimates.not_available", "No disponible")} + </td> + </tr> + ))} + </tbody> - {data?.estimates.length === 0 && ( - <tfoot> - <tr> - <td colSpan={4}>{t('estimates.none', 'No hay estimaciones disponibles')}</td> - </tr> - </tfoot> - )} + {data?.estimates.length === 0 && ( + <tfoot> + <tr> + <td colSpan={4}> + {t("estimates.none", "No hay estimaciones disponibles")} + </td> + </tr> + </tfoot> + )} </table> -} + ); +}; diff --git a/src/frontend/app/components/StopItem.css b/src/frontend/app/components/StopItem.css index 9feb2d1..54ab136 100644 --- a/src/frontend/app/components/StopItem.css +++ b/src/frontend/app/components/StopItem.css @@ -51,4 +51,4 @@ 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 index 29370b7..b781eb9 100644 --- a/src/frontend/app/components/StopItem.tsx +++ b/src/frontend/app/components/StopItem.tsx @@ -1,22 +1,21 @@ -import React from 'react'; -import { Link } from 'react-router'; -import StopDataProvider, { type Stop } from '../data/StopDataProvider'; -import LineIcon from './LineIcon'; +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)} + {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} />)} + {stop.lines?.map((line) => <LineIcon key={line} line={line} />)} </div> - </Link> </li> ); diff --git a/src/frontend/app/components/StopSheet.css b/src/frontend/app/components/StopSheet.css new file mode 100644 index 0000000..3f7621e --- /dev/null +++ b/src/frontend/app/components/StopSheet.css @@ -0,0 +1,146 @@ +/* Stop Sheet Styles */ +.stop-sheet-content { + padding: 16px; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.stop-sheet-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 16px; +} + +.stop-sheet-title { + font-size: 1.5rem; + font-weight: 600; + color: var(--text-color); + margin: 0; +} + +.stop-sheet-id { + font-size: 1rem; + color: var(--subtitle-color); +} + +.stop-sheet-loading { + display: flex; + justify-content: center; + align-items: center; + padding: 32px; + color: var(--subtitle-color); + font-size: 1rem; +} + +.stop-sheet-estimates { + flex: 1; + overflow-y: auto; + min-height: 0; +} + +.stop-sheet-subtitle { + font-size: 1.1rem; + font-weight: 500; + color: var(--text-color); + margin: 0 0 12px 0; +} + +.stop-sheet-no-estimates { + text-align: center; + padding: 32px 16px; + color: var(--subtitle-color); + font-size: 0.95rem; +} + +.stop-sheet-estimates-list { + display: flex; + flex-direction: column; + gap: 12px; +} + +.stop-sheet-estimate-item { + display: flex; + align-items: center; + gap: 12px; + padding: 12px; + background-color: var(--message-background-color); + border-radius: 8px; + border: 1px solid var(--border-color); +} + +.stop-sheet-estimate-line { + flex-shrink: 0; +} + +.stop-sheet-estimate-details { + flex: 1; + min-width: 0; +} + +.stop-sheet-estimate-route { + font-weight: 500; + color: var(--text-color); + font-size: 0.95rem; + margin-bottom: 2px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.stop-sheet-estimate-time { + font-size: 0.85rem; + color: var(--subtitle-color); +} + +.stop-sheet-estimate-distance { + color: var(--subtitle-color); +} + +.stop-sheet-view-all { + display: block; + padding: 12px 16px; + background-color: var(--button-background-color); + color: white; + text-decoration: none; + text-align: center; + border-radius: 8px; + font-weight: 500; + transition: background-color 0.2s ease; + + margin-block-start: 1rem; + margin-inline-start: auto; +} + +.stop-sheet-view-all:hover { + background-color: var(--button-hover-background-color); + text-decoration: none; +} + +/* Override react-modal-sheet styles for better integration */ +[data-rsbs-overlay] { + background-color: rgba(0, 0, 0, 0.3); +} + +[data-rsbs-header] { + background-color: var(--background-color); + border-bottom: 1px solid var(--border-color); +} + +[data-rsbs-header]:before { + background-color: var(--subtitle-color); +} + +[data-rsbs-root] [data-rsbs-overlay] { + border-top-left-radius: 16px; + border-top-right-radius: 16px; +} + +[data-rsbs-root] [data-rsbs-content] { + background-color: var(--background-color); + border-top-left-radius: 16px; + border-top-right-radius: 16px; + max-height: 95vh; + overflow: hidden; +} diff --git a/src/frontend/app/components/StopSheet.tsx b/src/frontend/app/components/StopSheet.tsx new file mode 100644 index 0000000..8075e9d --- /dev/null +++ b/src/frontend/app/components/StopSheet.tsx @@ -0,0 +1,154 @@ +import React, { useEffect, useState } from "react"; +import { Sheet } from "react-modal-sheet"; +import { Link } from "react-router"; +import { useTranslation } from "react-i18next"; +import LineIcon from "./LineIcon"; +import { type StopDetails } from "../routes/estimates-$id"; +import "./StopSheet.css"; + +interface StopSheetProps { + isOpen: boolean; + onClose: () => void; + stopId: number; + stopName: string; +} + +const loadStopData = async (stopId: number): Promise<StopDetails> => { + const resp = await fetch(`/api/GetStopEstimates?id=${stopId}`, { + headers: { + Accept: "application/json", + }, + }); + return await resp.json(); +}; + +export const StopSheet: React.FC<StopSheetProps> = ({ + isOpen, + onClose, + stopId, + stopName, +}) => { + const { t } = useTranslation(); + const [data, setData] = useState<StopDetails | null>(null); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (isOpen && stopId) { + setLoading(true); + setData(null); + loadStopData(stopId) + .then((stopData) => { + setData(stopData); + }) + .catch((error) => { + console.error("Failed to load stop data:", error); + }) + .finally(() => { + setLoading(false); + }); + } + }, [isOpen, stopId]); + + const formatTime = (minutes: number) => { + if (minutes > 15) { + const now = new Date(); + const arrival = new Date(now.getTime() + minutes * 60000); + return Intl.DateTimeFormat( + typeof navigator !== "undefined" ? navigator.language : "en", + { + hour: "2-digit", + minute: "2-digit", + } + ).format(arrival); + } else { + return `${minutes} ${t("estimates.minutes", "min")}`; + } + }; + + const formatDistance = (meters: number) => { + if (meters > 1024) { + return `${(meters / 1000).toFixed(1)} km`; + } else { + return `${meters} ${t("estimates.meters", "m")}`; + } + }; + + // Show only the next 4 arrivals + const limitedEstimates = + data?.estimates.sort((a, b) => a.minutes - b.minutes).slice(0, 4) || []; + + return ( + <Sheet + isOpen={isOpen} + onClose={onClose} + detent="content-height" + > + <Sheet.Container> + <Sheet.Header /> + <Sheet.Content> + <div className="stop-sheet-content"> + <div className="stop-sheet-header"> + <h2 className="stop-sheet-title">{stopName}</h2> + <span className="stop-sheet-id">({stopId})</span> + </div> + + {loading && ( + <div className="stop-sheet-loading"> + {t("common.loading", "Loading...")} + </div> + )} + + {data && !loading && ( + <> + <div className="stop-sheet-estimates"> + <h3 className="stop-sheet-subtitle"> + {t("estimates.next_arrivals", "Next arrivals")} + </h3> + + {limitedEstimates.length === 0 ? ( + <div className="stop-sheet-no-estimates"> + {t("estimates.none", "No hay estimaciones disponibles")} + </div> + ) : ( + <div className="stop-sheet-estimates-list"> + {limitedEstimates.map((estimate, idx) => ( + <div key={idx} className="stop-sheet-estimate-item"> + <div className="stop-sheet-estimate-line"> + <LineIcon line={estimate.line} /> + </div> + <div className="stop-sheet-estimate-details"> + <div className="stop-sheet-estimate-route"> + {estimate.route} + </div> + <div className="stop-sheet-estimate-time"> + {formatTime(estimate.minutes)} + {estimate.meters > -1 && ( + <span className="stop-sheet-estimate-distance"> + {" • "} + {formatDistance(estimate.meters)} + </span> + )} + </div> + </div> + </div> + ))} + </div> + )} + </div> + + <Link + to={`/estimates/${stopId}`} + className="stop-sheet-view-all" + onClick={onClose} + > + {t("map.view_all_estimates", "Ver todas las estimaciones")} + </Link> + </> + )} + </div> + </Sheet.Content> + </Sheet.Container> + <Sheet.Backdrop /> + </Sheet> + ); +}; |
