From e51cdd89afc08274ca622e18b8127feca29e90a3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 6 Nov 2025 22:49:47 +0000 Subject: Add gallery components and improve search functionality Co-authored-by: arielcostas <94913521+arielcostas@users.noreply.github.com> --- src/frontend/app/components/ServiceAlerts.css | 72 +++++++++++ src/frontend/app/components/ServiceAlerts.tsx | 22 ++++ src/frontend/app/components/StopGallery.css | 161 ++++++++++++++++++++++++ src/frontend/app/components/StopGallery.tsx | 58 +++++++++ src/frontend/app/components/StopGalleryItem.tsx | 38 ++++++ src/frontend/app/i18n/locales/en-GB.json | 8 +- src/frontend/app/i18n/locales/es-ES.json | 8 +- src/frontend/app/i18n/locales/gl-ES.json | 8 +- src/frontend/app/routes/stoplist.tsx | 99 ++++++++------- 9 files changed, 422 insertions(+), 52 deletions(-) create mode 100644 src/frontend/app/components/ServiceAlerts.css create mode 100644 src/frontend/app/components/ServiceAlerts.tsx create mode 100644 src/frontend/app/components/StopGallery.css create mode 100644 src/frontend/app/components/StopGallery.tsx create mode 100644 src/frontend/app/components/StopGalleryItem.tsx (limited to 'src') 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 ( +
+

{t("stoplist.service_alerts")}

+
+
ℹ️
+
+
{t("stoplist.alerts_coming_soon")}
+
{t("stoplist.alerts_description")}
+
+
+
+ ); +}; + +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 = ({ stops, title, emptyMessage }) => { + const scrollRef = useRef(null); + const x = useMotionValue(0); + + if (stops.length === 0 && emptyMessage) { + return ( +
+

{title}

+

{emptyMessage}

+
+ ); + } + + if (stops.length === 0) { + return null; + } + + return ( +
+
+

{title}

+ {stops.length} +
+ + +
+ {stops.map((stop) => ( + + ))} +
+
+ +
+ {stops.map((_, index) => ( +
+ ))} +
+
+ ); +}; + +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 = ({ stop }) => { + const { region } = useApp(); + + return ( +
+ +
+ {stop.favourite && } + ({stop.stopId}) +
+
+ {StopDataProvider.getDisplayName(region, stop)} +
+
+ {stop.lines?.slice(0, 3).map((line) => ( + + ))} + {stop.lines && stop.lines.length > 3 && ( + +{stop.lines.length - 3} + )} +
+ +
+ ); +}; + +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) => { - 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() {
)} -
-

{t("stoplist.favourites")}

- - {favouriteIds.length === 0 && ( -

- {t( - "stoplist.no_favourites", - "Accede a una parada y márcala como favorita para verla aquí.", - )} -

- )} - -
    - {loading && favouriteIds.length > 0 && - favouriteIds.map((id) => ( - - )) - } - {!loading && favouriteStops - .sort((a, b) => a.stopId - b.stopId) - .map((stop) => )} -
-
- - {(recentIds.length > 0 || (!loading && recentStops.length > 0)) && ( -
-

{t("stoplist.recents")}

+ {!loading && ( + + )} -
    - {loading && recentIds.length > 0 && - recentIds.map((id) => ( - - )) - } - {!loading && recentStops.map((stop) => ( - - ))} -
-
+ {!loading && ( + a.stopId - b.stopId)} + title={t("stoplist.favourites")} + emptyMessage={t("stoplist.no_favourites")} + /> )} + +
-

{t("stoplist.all_stops", "Paradas")}

+

+ {userLocation + ? t("stoplist.nearby_stops", "Nearby stops") + : t("stoplist.all_stops", "Paradas") + } +

    {loading && ( <> - {Array.from({ length: 8 }, (_, index) => ( + {Array.from({ length: 6 }, (_, index) => ( ))} )} {!loading && data - ? sortedAllStops.map((stop) => ) + ? (userLocation ? sortedAllStops.slice(0, 6) : sortedAllStops).map((stop) => ( + + )) : null}
-- cgit v1.3