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