diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/frontend/app/components/ServiceAlerts.css | 72 | ||||
| -rw-r--r-- | src/frontend/app/components/ServiceAlerts.tsx | 22 | ||||
| -rw-r--r-- | src/frontend/app/components/StopGallery.css | 161 | ||||
| -rw-r--r-- | src/frontend/app/components/StopGallery.tsx | 58 | ||||
| -rw-r--r-- | src/frontend/app/components/StopGalleryItem.tsx | 38 | ||||
| -rw-r--r-- | src/frontend/app/i18n/locales/en-GB.json | 8 | ||||
| -rw-r--r-- | src/frontend/app/i18n/locales/es-ES.json | 8 | ||||
| -rw-r--r-- | src/frontend/app/i18n/locales/gl-ES.json | 8 | ||||
| -rw-r--r-- | src/frontend/app/routes/stoplist.tsx | 99 |
9 files changed, 422 insertions, 52 deletions
diff --git a/src/frontend/app/components/ServiceAlerts.css b/src/frontend/app/components/ServiceAlerts.css new file mode 100644 index 0000000..484b45b --- /dev/null +++ b/src/frontend/app/components/ServiceAlerts.css @@ -0,0 +1,72 @@ +/* Service Alerts Container */ +.service-alerts-container { + margin-bottom: 1.5rem; +} + +.service-alert { + display: flex; + gap: 1rem; + padding: 1rem; + border-radius: 8px; + margin-bottom: 0.75rem; + border: 1px solid; +} + +.service-alert.info { + background-color: #e3f2fd; + border-color: #2196f3; + color: #0d47a1; +} + +.service-alert.warning { + background-color: #fff3e0; + border-color: #ff9800; + color: #e65100; +} + +.service-alert.error { + background-color: #ffebee; + border-color: #f44336; + color: #b71c1c; +} + +/* Dark mode adjustments */ +@media (prefers-color-scheme: dark) { + .service-alert.info { + background-color: #0d47a1; + border-color: #2196f3; + color: #e3f2fd; + } + + .service-alert.warning { + background-color: #e65100; + border-color: #ff9800; + color: #fff3e0; + } + + .service-alert.error { + background-color: #b71c1c; + border-color: #f44336; + color: #ffebee; + } +} + +.alert-icon { + font-size: 1.5rem; + flex-shrink: 0; +} + +.alert-content { + flex: 1; +} + +.alert-title { + font-weight: 600; + font-size: 1rem; + margin-bottom: 0.25rem; +} + +.alert-message { + font-size: 0.9rem; + line-height: 1.4; +} diff --git a/src/frontend/app/components/ServiceAlerts.tsx b/src/frontend/app/components/ServiceAlerts.tsx new file mode 100644 index 0000000..7966f2a --- /dev/null +++ b/src/frontend/app/components/ServiceAlerts.tsx @@ -0,0 +1,22 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import "./ServiceAlerts.css"; + +const ServiceAlerts: React.FC = () => { + const { t } = useTranslation(); + + return ( + <div className="service-alerts-container"> + <h2 className="page-subtitle">{t("stoplist.service_alerts")}</h2> + <div className="service-alert info"> + <div className="alert-icon">ℹ️</div> + <div className="alert-content"> + <div className="alert-title">{t("stoplist.alerts_coming_soon")}</div> + <div className="alert-message">{t("stoplist.alerts_description")}</div> + </div> + </div> + </div> + ); +}; + +export default ServiceAlerts; diff --git a/src/frontend/app/components/StopGallery.css b/src/frontend/app/components/StopGallery.css new file mode 100644 index 0000000..f53f2a5 --- /dev/null +++ b/src/frontend/app/components/StopGallery.css @@ -0,0 +1,161 @@ +/* Gallery Container */ +.gallery-container { + margin-bottom: 1.5rem; +} + +.gallery-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.75rem; +} + +.gallery-counter { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 2rem; + height: 2rem; + padding: 0 0.5rem; + background-color: var(--button-background-color); + color: white; + border-radius: 1rem; + font-size: 0.9rem; + font-weight: 600; +} + +/* Scroll Container */ +.gallery-scroll-container { + overflow-x: auto; + overflow-y: hidden; + -webkit-overflow-scrolling: touch; + scroll-snap-type: x mandatory; + scrollbar-width: none; /* Firefox */ + -ms-overflow-style: none; /* IE and Edge */ + margin: 0 -1rem; + padding: 0 1rem; +} + +.gallery-scroll-container::-webkit-scrollbar { + display: none; /* Chrome, Safari, Opera */ +} + +/* Gallery Track */ +.gallery-track { + display: flex; + gap: 1rem; + padding-bottom: 0.5rem; +} + +/* Gallery Item */ +.gallery-item { + flex: 0 0 280px; + scroll-snap-align: start; + scroll-snap-stop: always; +} + +.gallery-item-link { + display: block; + padding: 1rem; + background-color: var(--card-background-color, var(--message-background-color)); + border: 1px solid var(--border-color); + border-radius: 12px; + text-decoration: none; + color: var(--text-color); + transition: all 0.2s ease-in-out; + height: 100%; + min-height: 120px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} + +.gallery-item-link:hover { + transform: translateY(-2px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15); + border-color: var(--button-background-color); +} + +.gallery-item-header { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.5rem; +} + +.gallery-item-header .favourite-icon { + color: var(--star-color); + font-size: 1rem; +} + +.gallery-item-code { + font-size: 0.85rem; + color: var(--subtitle-color); + font-weight: 500; +} + +.gallery-item-name { + font-size: 1rem; + font-weight: 600; + margin-bottom: 0.75rem; + line-height: 1.3; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + min-height: 2.6em; +} + +.gallery-item-lines { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; + align-items: center; +} + +.more-lines { + font-size: 0.75rem; + color: var(--subtitle-color); + font-weight: 500; + padding: 0.125rem 0.375rem; + background-color: var(--border-color); + border-radius: 4px; +} + +/* Gallery Indicators */ +.gallery-indicators { + display: flex; + justify-content: center; + gap: 0.5rem; + margin-top: 1rem; + padding: 0.5rem 0; +} + +.gallery-indicator { + width: 8px; + height: 8px; + border-radius: 50%; + background-color: var(--border-color); + transition: background-color 0.2s ease-in-out; +} + +.gallery-indicator.active { + background-color: var(--button-background-color); +} + +/* Tablet and larger */ +@media (min-width: 768px) { + .gallery-item { + flex: 0 0 320px; + } + + .gallery-scroll-container { + margin: 0; + padding: 0; + } +} + +/* Desktop */ +@media (min-width: 1024px) { + .gallery-item { + flex: 0 0 340px; + } +} diff --git a/src/frontend/app/components/StopGallery.tsx b/src/frontend/app/components/StopGallery.tsx new file mode 100644 index 0000000..7dda637 --- /dev/null +++ b/src/frontend/app/components/StopGallery.tsx @@ -0,0 +1,58 @@ +import React, { useRef } from "react"; +import { motion, useMotionValue } from "framer-motion"; +import { type Stop } from "../data/StopDataProvider"; +import StopGalleryItem from "./StopGalleryItem"; +import "./StopGallery.css"; + +interface StopGalleryProps { + stops: Stop[]; + title: string; + emptyMessage?: string; +} + +const StopGallery: React.FC<StopGalleryProps> = ({ stops, title, emptyMessage }) => { + const scrollRef = useRef<HTMLDivElement>(null); + const x = useMotionValue(0); + + if (stops.length === 0 && emptyMessage) { + return ( + <div className="gallery-container"> + <h2 className="page-subtitle">{title}</h2> + <p className="message">{emptyMessage}</p> + </div> + ); + } + + if (stops.length === 0) { + return null; + } + + return ( + <div className="gallery-container"> + <div className="gallery-header"> + <h2 className="page-subtitle">{title}</h2> + <span className="gallery-counter">{stops.length}</span> + </div> + + <motion.div + className="gallery-scroll-container" + ref={scrollRef} + style={{ x }} + > + <div className="gallery-track"> + {stops.map((stop) => ( + <StopGalleryItem key={stop.stopId} stop={stop} /> + ))} + </div> + </motion.div> + + <div className="gallery-indicators"> + {stops.map((_, index) => ( + <div key={index} className="gallery-indicator" /> + ))} + </div> + </div> + ); +}; + +export default StopGallery; diff --git a/src/frontend/app/components/StopGalleryItem.tsx b/src/frontend/app/components/StopGalleryItem.tsx new file mode 100644 index 0000000..24d92a2 --- /dev/null +++ b/src/frontend/app/components/StopGalleryItem.tsx @@ -0,0 +1,38 @@ +import React from "react"; +import { Link } from "react-router"; +import { type Stop } from "../data/StopDataProvider"; +import LineIcon from "./LineIcon"; +import { useApp } from "../AppContext"; +import StopDataProvider from "../data/StopDataProvider"; + +interface StopGalleryItemProps { + stop: Stop; +} + +const StopGalleryItem: React.FC<StopGalleryItemProps> = ({ stop }) => { + const { region } = useApp(); + + return ( + <div className="gallery-item"> + <Link className="gallery-item-link" to={`/estimates/${stop.stopId}`}> + <div className="gallery-item-header"> + {stop.favourite && <span className="favourite-icon">★</span>} + <span className="gallery-item-code">({stop.stopId})</span> + </div> + <div className="gallery-item-name"> + {StopDataProvider.getDisplayName(region, stop)} + </div> + <div className="gallery-item-lines"> + {stop.lines?.slice(0, 3).map((line) => ( + <LineIcon key={line} line={line} region={region} /> + ))} + {stop.lines && stop.lines.length > 3 && ( + <span className="more-lines">+{stop.lines.length - 3}</span> + )} + </div> + </Link> + </div> + ); +}; + +export default StopGalleryItem; diff --git a/src/frontend/app/i18n/locales/en-GB.json b/src/frontend/app/i18n/locales/en-GB.json index 992a311..d5dfed0 100644 --- a/src/frontend/app/i18n/locales/en-GB.json +++ b/src/frontend/app/i18n/locales/en-GB.json @@ -40,13 +40,17 @@ "details_grouped": "Stops are grouped by bus line. Apps like iTranvias (A Coruña) or Moovit (more or less) use this style." }, "stoplist": { - "search_placeholder": "Search stop...", + "search_placeholder": "Search stop by name or code...", "search_label": "Search stops", "search_results": "Search results", "favourites": "Favourite stops", "no_favourites": "Go to a stop and mark it as favourite to see it here.", "recents": "Recent", - "all_stops": "Stops" + "all_stops": "Stops", + "nearby_stops": "Nearby stops", + "service_alerts": "Service alerts", + "alerts_coming_soon": "Feature coming soon", + "alerts_description": "Service alerts and disruption notifications will be available here soon." }, "estimates": { "minutes": "min", diff --git a/src/frontend/app/i18n/locales/es-ES.json b/src/frontend/app/i18n/locales/es-ES.json index 1df399d..357da23 100644 --- a/src/frontend/app/i18n/locales/es-ES.json +++ b/src/frontend/app/i18n/locales/es-ES.json @@ -40,13 +40,17 @@ "details_grouped": "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." }, "stoplist": { - "search_placeholder": "Buscar parada...", + "search_placeholder": "Buscar parada por nombre o código...", "search_label": "Buscar paradas", "search_results": "Resultados de la búsqueda", "favourites": "Paradas favoritas", "no_favourites": "Accede a una parada y márcala como favorita para verla aquí.", "recents": "Recientes", - "all_stops": "Paradas" + "all_stops": "Paradas", + "nearby_stops": "Paradas cercanas", + "service_alerts": "Alertas del servicio", + "alerts_coming_soon": "Función próximamente", + "alerts_description": "Las alertas del servicio y notificaciones de interrupciones estarán disponibles aquí pronto." }, "estimates": { "minutes": "min", diff --git a/src/frontend/app/i18n/locales/gl-ES.json b/src/frontend/app/i18n/locales/gl-ES.json index 5a04aff..9dab87a 100644 --- a/src/frontend/app/i18n/locales/gl-ES.json +++ b/src/frontend/app/i18n/locales/gl-ES.json @@ -40,13 +40,17 @@ "details_grouped": "As paradas agrúpanse pola liña de autobús. Aplicacións como iTranvias (A Coruña) ou Moovit (máis ou menos) usan este estilo." }, "stoplist": { - "search_placeholder": "Buscar parada...", + "search_placeholder": "Buscar parada por nome ou código...", "search_label": "Buscar paradas", "search_results": "Resultados da busca", "favourites": "Paradas favoritas", "no_favourites": "Accede a unha parada e márcaa como favorita para vela aquí.", "recents": "Recentes", - "all_stops": "Paradas" + "all_stops": "Paradas", + "nearby_stops": "Paradas cercanas", + "service_alerts": "Alertas do servizo", + "alerts_coming_soon": "Función proximamente", + "alerts_description": "As alertas do servizo e notificacións de interrupcións estarán dispoñibles aquí pronto." }, "estimates": { "minutes": "min", diff --git a/src/frontend/app/routes/stoplist.tsx b/src/frontend/app/routes/stoplist.tsx index e77dfb8..80267ea 100644 --- a/src/frontend/app/routes/stoplist.tsx +++ b/src/frontend/app/routes/stoplist.tsx @@ -2,6 +2,8 @@ import { useEffect, useMemo, useRef, useState, useCallback } from "react"; import StopDataProvider, { type Stop } from "../data/StopDataProvider"; import StopItem from "../components/StopItem"; import StopItemSkeleton from "../components/StopItemSkeleton"; +import StopGallery from "../components/StopGallery"; +import ServiceAlerts from "../components/ServiceAlerts"; import Fuse from "fuse.js"; import "./stoplist.css"; import { useTranslation } from "react-i18next"; @@ -30,7 +32,10 @@ export default function StopList() { ); const fuse = useMemo( - () => new Fuse(data || [], { threshold: 0.3, keys: ["name.original"] }), + () => new Fuse(data || [], { + threshold: 0.3, + keys: ["name.original", "name.intersect", "stopId"] + }), [data], ); @@ -188,14 +193,14 @@ export default function StopList() { }, [loadStops]); const handleStopSearch = (event: React.ChangeEvent<HTMLInputElement>) => { - const stopName = event.target.value || ""; + const searchQuery = event.target.value || ""; if (searchTimeout.current) { clearTimeout(searchTimeout.current); } searchTimeout.current = setTimeout(() => { - if (stopName.length === 0) { + if (searchQuery.length === 0) { setSearchResults(null); return; } @@ -205,8 +210,27 @@ export default function StopList() { return; } - const results = fuse.search(stopName); - const items = results.map((result) => result.item); + // Check if search query is a number (stop code search) + const isNumericSearch = /^\d+$/.test(searchQuery.trim()); + + let items: Stop[]; + if (isNumericSearch) { + // Direct match for stop codes + const stopId = parseInt(searchQuery.trim(), 10); + const exactMatch = data.filter(stop => stop.stopId === stopId); + if (exactMatch.length > 0) { + items = exactMatch; + } else { + // Fuzzy search if no exact match + const results = fuse.search(searchQuery); + items = results.map((result) => result.item); + } + } else { + // Text search using Fuse.js + const results = fuse.search(searchQuery); + items = results.map((result) => result.item); + } + setSearchResults(items); }, 300); }; @@ -243,60 +267,43 @@ export default function StopList() { </div> )} - <div className="list-container"> - <h2 className="page-subtitle">{t("stoplist.favourites")}</h2> - - {favouriteIds.length === 0 && ( - <p className="message"> - {t( - "stoplist.no_favourites", - "Accede a una parada y márcala como favorita para verla aquí.", - )} - </p> - )} - - <ul className="list"> - {loading && favouriteIds.length > 0 && - favouriteIds.map((id) => ( - <StopItemSkeleton key={id} showId={true} stopId={id} /> - )) - } - {!loading && favouriteStops - .sort((a, b) => a.stopId - b.stopId) - .map((stop) => <StopItem key={stop.stopId} stop={stop} />)} - </ul> - </div> - - {(recentIds.length > 0 || (!loading && recentStops.length > 0)) && ( - <div className="list-container"> - <h2 className="page-subtitle">{t("stoplist.recents")}</h2> + {!loading && ( + <StopGallery + stops={recentStops.slice(0, 5)} + title={t("stoplist.recents")} + /> + )} - <ul className="list"> - {loading && recentIds.length > 0 && - recentIds.map((id) => ( - <StopItemSkeleton key={id} showId={true} stopId={id} /> - )) - } - {!loading && recentStops.map((stop) => ( - <StopItem key={stop.stopId} stop={stop} /> - ))} - </ul> - </div> + {!loading && ( + <StopGallery + stops={favouriteStops.sort((a, b) => a.stopId - b.stopId)} + title={t("stoplist.favourites")} + emptyMessage={t("stoplist.no_favourites")} + /> )} + <ServiceAlerts /> + <div className="list-container"> - <h2 className="page-subtitle">{t("stoplist.all_stops", "Paradas")}</h2> + <h2 className="page-subtitle"> + {userLocation + ? t("stoplist.nearby_stops", "Nearby stops") + : t("stoplist.all_stops", "Paradas") + } + </h2> <ul className="list"> {loading && ( <> - {Array.from({ length: 8 }, (_, index) => ( + {Array.from({ length: 6 }, (_, index) => ( <StopItemSkeleton key={`skeleton-${index}`} /> ))} </> )} {!loading && data - ? sortedAllStops.map((stop) => <StopItem key={stop.stopId} stop={stop} />) + ? (userLocation ? sortedAllStops.slice(0, 6) : sortedAllStops).map((stop) => ( + <StopItem key={stop.stopId} stop={stop} /> + )) : null} </ul> </div> |
