aboutsummaryrefslogtreecommitdiff
path: root/src/frontend/app/components
diff options
context:
space:
mode:
Diffstat (limited to 'src/frontend/app/components')
-rw-r--r--src/frontend/app/components/Stops/ConsolidatedCirculationListSkeleton.tsx47
-rw-r--r--src/frontend/app/components/TimetableSkeleton.tsx74
-rw-r--r--src/frontend/app/components/UpdateNotification.css114
-rw-r--r--src/frontend/app/components/shared/AppMap.tsx213
-rw-r--r--src/frontend/app/components/stop/StopHelpModal.tsx (renamed from src/frontend/app/components/StopHelpModal.tsx)0
-rw-r--r--src/frontend/app/components/stop/StopMapModal.css (renamed from src/frontend/app/components/StopMapModal.css)0
-rw-r--r--src/frontend/app/components/stop/StopMapModal.tsx (renamed from src/frontend/app/components/StopMapModal.tsx)123
7 files changed, 253 insertions, 318 deletions
diff --git a/src/frontend/app/components/Stops/ConsolidatedCirculationListSkeleton.tsx b/src/frontend/app/components/Stops/ConsolidatedCirculationListSkeleton.tsx
deleted file mode 100644
index c99b883..0000000
--- a/src/frontend/app/components/Stops/ConsolidatedCirculationListSkeleton.tsx
+++ /dev/null
@@ -1,47 +0,0 @@
-import React from "react";
-import Skeleton, { SkeletonTheme } from "react-loading-skeleton";
-import "react-loading-skeleton/dist/skeleton.css";
-import "./ConsolidatedCirculationList.css";
-
-export const ConsolidatedCirculationListSkeleton: React.FC = () => {
- return (
- <SkeletonTheme
- baseColor="var(--skeleton-base)"
- highlightColor="var(--skeleton-highlight)"
- >
- <>
- <div className="consolidated-circulation-caption">
- <Skeleton width="60%" style={{ maxWidth: "300px" }} />
- </div>
-
- {[1, 2, 3, 4, 5].map((i) => (
- <div
- key={i}
- className="consolidated-circulation-card"
- style={{ marginBottom: "0.75rem" }}
- >
- <div className="card-row main">
- <div className="line-info">
- <Skeleton width={40} height={28} borderRadius={4} />
- </div>
-
- <div className="route-info">
- <Skeleton width="80%" height={18} />
- </div>
-
- <div className="eta-badge">
- <Skeleton width={50} height={40} borderRadius={12} />
- </div>
- </div>
-
- <div className="card-row meta">
- <Skeleton width={90} height={20} borderRadius={999} />
- <Skeleton width={70} height={20} borderRadius={999} />
- <Skeleton width={60} height={20} borderRadius={999} />
- </div>
- </div>
- ))}
- </>
- </SkeletonTheme>
- );
-};
diff --git a/src/frontend/app/components/TimetableSkeleton.tsx b/src/frontend/app/components/TimetableSkeleton.tsx
deleted file mode 100644
index 2d4fc29..0000000
--- a/src/frontend/app/components/TimetableSkeleton.tsx
+++ /dev/null
@@ -1,74 +0,0 @@
-import React from "react";
-import Skeleton, { SkeletonTheme } from "react-loading-skeleton";
-import "react-loading-skeleton/dist/skeleton.css";
-import { useTranslation } from "react-i18next";
-
-interface TimetableSkeletonProps {
- rows?: number;
-}
-
-export const TimetableSkeleton: React.FC<TimetableSkeletonProps> = ({
- rows = 4,
-}) => {
- const { t } = useTranslation();
-
- return (
- <SkeletonTheme
- baseColor="var(--skeleton-base)"
- highlightColor="var(--skeleton-highlight)"
- >
- <div className="timetable-container">
- <div className="timetable-caption">
- <Skeleton width="250px" height="1.1rem" />
- </div>
-
- <div className="timetable-cards">
- {Array.from({ length: rows }, (_, index) => (
- <div key={`timetable-skeleton-${index}`} className="timetable-card">
- <div className="card-header">
- <div className="line-info">
- <Skeleton
- width="40px"
- height="24px"
- style={{ borderRadius: "4px" }}
- />
- </div>
-
- <div className="destination-info">
- <Skeleton width="120px" height="0.95rem" />
- </div>
-
- <div className="time-info">
- <Skeleton
- width="60px"
- height="1.1rem"
- style={{ fontFamily: "monospace" }}
- />
- </div>
- </div>
-
- <div className="card-body">
- <div className="route-streets">
- <Skeleton
- width="50px"
- height="0.8rem"
- style={{
- display: "inline-block",
- borderRadius: "3px",
- marginRight: "0.5rem",
- }}
- />
- <Skeleton
- width="200px"
- height="0.85rem"
- style={{ display: "inline-block" }}
- />
- </div>
- </div>
- </div>
- ))}
- </div>
- </div>
- </SkeletonTheme>
- );
-};
diff --git a/src/frontend/app/components/UpdateNotification.css b/src/frontend/app/components/UpdateNotification.css
deleted file mode 100644
index 6183194..0000000
--- a/src/frontend/app/components/UpdateNotification.css
+++ /dev/null
@@ -1,114 +0,0 @@
-.update-notification {
- position: fixed;
- top: 0;
- left: 0;
- right: 0;
- z-index: 9999;
- background-color: var(--button-background-color);
- color: white;
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
- animation: slideDown 0.3s ease-out;
-}
-
-@keyframes slideDown {
- from {
- transform: translateY(-100%);
- }
- to {
- transform: translateY(0);
- }
-}
-
-.update-content {
- display: flex;
- align-items: center;
- padding: 12px 16px;
- gap: 12px;
- max-width: 100%;
-}
-
-.update-icon {
- flex-shrink: 0;
- display: flex;
- align-items: center;
- justify-content: center;
- width: 32px;
- height: 32px;
- background-color: rgba(255, 255, 255, 0.2);
- border-radius: 50%;
-}
-
-.update-text {
- flex: 1;
- min-width: 0;
-}
-
-.update-title {
- font-size: 0.9rem;
- font-weight: 600;
- margin-bottom: 2px;
-}
-
-.update-description {
- font-size: 0.8rem;
- opacity: 0.9;
- line-height: 1.2;
-}
-
-.update-actions {
- display: flex;
- align-items: center;
- gap: 8px;
- flex-shrink: 0;
-}
-
-.update-button {
- background: rgba(255, 255, 255, 0.2);
- border: 1px solid rgba(255, 255, 255, 0.3);
- color: white;
- padding: 6px 12px;
- border-radius: 6px;
- font-size: 0.8rem;
- font-weight: 500;
- cursor: pointer;
- transition: background-color 0.2s ease;
-}
-
-.update-button:hover:not(:disabled) {
- background: rgba(255, 255, 255, 0.3);
-}
-
-.update-button:disabled {
- opacity: 0.7;
- cursor: not-allowed;
-}
-
-.update-dismiss {
- background: none;
- border: none;
- color: white;
- padding: 6px;
- border-radius: 4px;
- cursor: pointer;
- display: flex;
- align-items: center;
- justify-content: center;
- transition: background-color 0.2s ease;
-}
-
-.update-dismiss:hover {
- background: rgba(255, 255, 255, 0.2);
-}
-
-@media (min-width: 768px) {
- .update-content {
- max-width: 768px;
- margin: 0 auto;
- }
-}
-
-@media (min-width: 1024px) {
- .update-content {
- max-width: 1024px;
- }
-}
diff --git a/src/frontend/app/components/shared/AppMap.tsx b/src/frontend/app/components/shared/AppMap.tsx
new file mode 100644
index 0000000..adf860d
--- /dev/null
+++ b/src/frontend/app/components/shared/AppMap.tsx
@@ -0,0 +1,213 @@
+import maplibregl from "maplibre-gl";
+import "maplibre-gl/dist/maplibre-gl.css";
+import {
+ forwardRef,
+ useEffect,
+ useImperativeHandle,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+import Map, {
+ GeolocateControl,
+ NavigationControl,
+ type MapLayerMouseEvent,
+ type MapRef,
+ type StyleSpecification,
+} from "react-map-gl/maplibre";
+import { useLocation } from "react-router";
+import { useApp } from "~/AppContext";
+import { APP_CONSTANTS } from "~/config/constants";
+import { DEFAULT_STYLE, loadStyle } from "~/maps/styleloader";
+
+interface AppMapProps {
+ children?: React.ReactNode;
+ showTraffic?: boolean;
+ showCameras?: boolean;
+ syncState?: boolean;
+ interactiveLayerIds?: string[];
+ onClick?: (e: MapLayerMouseEvent) => void;
+ initialViewState?: {
+ latitude: number;
+ longitude: number;
+ zoom: number;
+ };
+ style?: React.CSSProperties;
+ maxBounds?: [number, number, number, number] | null;
+ attributionControl?: boolean | any;
+ showNavigation?: boolean;
+ showGeolocate?: boolean;
+ onMove?: (e: any) => void;
+ onDragStart?: () => void;
+ onZoomStart?: () => void;
+ onRotateStart?: () => void;
+ onPitchStart?: () => void;
+ onLoad?: () => void;
+}
+
+export const AppMap = forwardRef<MapRef, AppMapProps>(
+ (
+ {
+ children,
+ showTraffic: propShowTraffic,
+ showCameras: propShowCameras,
+ syncState = false,
+ interactiveLayerIds,
+ onClick,
+ initialViewState,
+ style,
+ maxBounds = [
+ (APP_CONSTANTS.bounds.sw as [number, number])[0],
+ (APP_CONSTANTS.bounds.sw as [number, number])[1],
+ (APP_CONSTANTS.bounds.ne as [number, number])[0],
+ (APP_CONSTANTS.bounds.ne as [number, number])[1],
+ ],
+ attributionControl = false,
+ showNavigation = false,
+ showGeolocate = false,
+ onMove,
+ onDragStart,
+ onZoomStart,
+ onRotateStart,
+ onPitchStart,
+ onLoad,
+ },
+ ref
+ ) => {
+ const {
+ theme,
+ mapState,
+ updateMapState,
+ showTraffic: settingsShowTraffic,
+ showCameras: settingsShowCameras,
+ mapPositionMode,
+ } = useApp();
+ const mapRef = useRef<MapRef>(null);
+ const [mapStyle, setMapStyle] = useState<StyleSpecification>(DEFAULT_STYLE);
+ const location = useLocation();
+ const path = location.pathname;
+
+ // Use prop if provided, otherwise use settings
+ const showTraffic =
+ propShowTraffic !== undefined ? propShowTraffic : settingsShowTraffic;
+ const showCameras =
+ propShowCameras !== undefined ? propShowCameras : settingsShowCameras;
+
+ useImperativeHandle(ref, () => mapRef.current!);
+
+ useEffect(() => {
+ loadStyle("openfreemap", theme, { includeTraffic: showTraffic })
+ .then((style) => setMapStyle(style))
+ .catch((error) => console.error("Failed to load map style:", error));
+ }, [theme, showTraffic]);
+
+ useEffect(() => {
+ const handleMapChange = () => {
+ if (!syncState || !mapRef.current) return;
+ const map = mapRef.current.getMap();
+ if (!map) return;
+ const center = map.getCenter();
+ const zoom = map.getZoom();
+ updateMapState([center.lat, center.lng], zoom, path);
+ };
+
+ const handleStyleImageMissing = (e: any) => {
+ if (!mapRef.current) return;
+ const map = mapRef.current.getMap();
+ if (!map || map.hasImage(e.id)) return;
+
+ if (e.id.startsWith("stop-")) {
+ console.warn(`Missing icon image: ${e.id}`);
+ }
+
+ map.addImage(e.id, {
+ width: 1,
+ height: 1,
+ data: new Uint8Array(4),
+ });
+ };
+
+ if (mapRef.current) {
+ const map = mapRef.current.getMap();
+ if (map) {
+ map.on("moveend", handleMapChange);
+ map.on("styleimagemissing", handleStyleImageMissing);
+ }
+ }
+
+ return () => {
+ if (mapRef.current) {
+ const map = mapRef.current.getMap();
+ if (map) {
+ map.off("moveend", handleMapChange);
+ map.off("styleimagemissing", handleStyleImageMissing);
+ }
+ }
+ };
+ }, [syncState, updateMapState]);
+
+ const getLatitude = (center: any) =>
+ Array.isArray(center) ? center[0] : center.lat;
+ const getLongitude = (center: any) =>
+ Array.isArray(center) ? center[1] : center.lng;
+
+ const viewState = useMemo(() => {
+ if (initialViewState) return initialViewState;
+
+ if (mapPositionMode === "gps" && mapState.userLocation) {
+ return {
+ latitude: getLatitude(mapState.userLocation),
+ longitude: getLongitude(mapState.userLocation),
+ zoom: 16,
+ };
+ }
+
+ const pathState = mapState.paths[path];
+ if (pathState) {
+ return {
+ latitude: getLatitude(pathState.center),
+ longitude: getLongitude(pathState.center),
+ zoom: pathState.zoom,
+ };
+ }
+
+ return {
+ latitude: getLatitude(APP_CONSTANTS.defaultCenter),
+ longitude: getLongitude(APP_CONSTANTS.defaultCenter),
+ zoom: APP_CONSTANTS.defaultZoom,
+ };
+ }, [initialViewState, mapPositionMode, mapState, path]);
+
+ return (
+ <Map
+ ref={mapRef}
+ mapLib={maplibregl as any}
+ mapStyle={mapStyle}
+ style={{ width: "100%", height: "100%", ...style }}
+ initialViewState={viewState}
+ maxBounds={maxBounds || undefined}
+ attributionControl={attributionControl}
+ interactiveLayerIds={interactiveLayerIds}
+ onClick={onClick}
+ onMove={onMove}
+ onDragStart={onDragStart}
+ onZoomStart={onZoomStart}
+ onRotateStart={onRotateStart}
+ onPitchStart={onPitchStart}
+ onLoad={onLoad}
+ >
+ {showNavigation && <NavigationControl position="bottom-right" />}
+ {showGeolocate && (
+ <GeolocateControl
+ position="bottom-right"
+ trackUserLocation={true}
+ positionOptions={{ enableHighAccuracy: false }}
+ />
+ )}
+ {children}
+ </Map>
+ );
+ }
+);
+
+AppMap.displayName = "AppMap";
diff --git a/src/frontend/app/components/StopHelpModal.tsx b/src/frontend/app/components/stop/StopHelpModal.tsx
index e8157ab..e8157ab 100644
--- a/src/frontend/app/components/StopHelpModal.tsx
+++ b/src/frontend/app/components/stop/StopHelpModal.tsx
diff --git a/src/frontend/app/components/StopMapModal.css b/src/frontend/app/components/stop/StopMapModal.css
index f024b38..f024b38 100644
--- a/src/frontend/app/components/StopMapModal.css
+++ b/src/frontend/app/components/stop/StopMapModal.css
diff --git a/src/frontend/app/components/StopMapModal.tsx b/src/frontend/app/components/stop/StopMapModal.tsx
index d218af4..2e091b1 100644
--- a/src/frontend/app/components/StopMapModal.tsx
+++ b/src/frontend/app/components/stop/StopMapModal.tsx
@@ -1,4 +1,3 @@
-import maplibregl from "maplibre-gl";
import React, {
useCallback,
useEffect,
@@ -6,13 +5,13 @@ import React, {
useRef,
useState,
} from "react";
-import Map, { Layer, Marker, Source, type MapRef } from "react-map-gl/maplibre";
+import { Layer, Marker, Source, type MapRef } from "react-map-gl/maplibre";
import { Sheet } from "react-modal-sheet";
import { useApp } from "~/AppContext";
+import { AppMap } from "~/components/shared/AppMap";
import { APP_CONSTANTS } from "~/config/constants";
import { getLineColour } from "~/data/LineColors";
import type { Stop } from "~/data/StopDataProvider";
-import { loadStyle } from "~/maps/styleloader";
import "./StopMapModal.css";
export interface Position {
@@ -52,7 +51,6 @@ export const StopMapModal: React.FC<StopMapModalProps> = ({
selectedCirculationId,
}) => {
const { theme } = useApp();
- const [styleSpec, setStyleSpec] = useState<any | null>(null);
const mapRef = useRef<MapRef | null>(null);
const hasFitBounds = useRef(false);
const userInteracted = useRef(false);
@@ -296,19 +294,6 @@ export const StopMapModal: React.FC<StopMapModalProps> = ({
} catch {}
}, [stop, selectedBus, shapeData, previousShapeData]);
- // Load style without traffic layers for the stop map
- useEffect(() => {
- let mounted = true;
- loadStyle("openfreemap", theme, { includeTraffic: false })
- .then((style) => {
- if (mounted) setStyleSpec(style);
- })
- .catch((err) => console.error("Failed to load map style", err));
- return () => {
- mounted = false;
- };
- }, [theme]);
-
// Resize map and fit bounds when modal opens
useEffect(() => {
if (isOpen && mapRef.current) {
@@ -327,35 +312,11 @@ export const StopMapModal: React.FC<StopMapModalProps> = ({
// Fit bounds on initial load
useEffect(() => {
- if (!styleSpec || !mapRef.current || !isOpen)
- return;
-
- const map = mapRef.current.getMap();
-
- // Handle missing sprite images to suppress console warnings
- const handleStyleImageMissing = (e: any) => {
- if (!map || map.hasImage(e.id)) return;
- map.addImage(e.id, {
- width: 1,
- height: 1,
- data: new Uint8Array(4),
- });
- };
-
- map.on("styleimagemissing", handleStyleImageMissing);
+ if (!mapRef.current || !isOpen) return;
handleCenter();
hasFitBounds.current = true;
-
- return () => {
- if (mapRef.current) {
- const map = mapRef.current.getMap();
- if (map) {
- map.off("styleimagemissing", handleStyleImageMissing);
- }
- }
- };
- }, [styleSpec, stop, selectedBus, isOpen, handleCenter]);
+ }, [stop, selectedBus, isOpen, handleCenter]);
// Reset bounds when modal opens/closes
useEffect(() => {
@@ -398,45 +359,42 @@ export const StopMapModal: React.FC<StopMapModalProps> = ({
<div className="stop-map-modal">
{/* Map Container */}
<div className="stop-map-modal__map-container">
- {styleSpec && (
- <Map
- mapLib={maplibregl as any}
- initialViewState={{
- latitude: center.latitude,
- longitude: center.longitude,
- zoom: 16,
- }}
- style={{ width: "100%", height: "50vh" }}
- mapStyle={styleSpec}
- attributionControl={{
- compact: false,
- customAttribution:
- "Concello de Vigo & Viguesa de Transportes SL",
- }}
- ref={mapRef}
- interactive={true}
- onMove={(e) => {
- if (e.originalEvent) {
- userInteracted.current = true;
- }
- }}
- onDragStart={() => {
- userInteracted.current = true;
- }}
- onZoomStart={() => {
- userInteracted.current = true;
- }}
- onRotateStart={() => {
+ <AppMap
+ ref={mapRef}
+ initialViewState={{
+ latitude: center.latitude,
+ longitude: center.longitude,
+ zoom: 16,
+ }}
+ style={{ width: "100%", height: "50vh" }}
+ showTraffic={false}
+ attributionControl={{
+ compact: false,
+ customAttribution:
+ "Concello de Vigo & Viguesa de Transportes SL",
+ }}
+ onMove={(e) => {
+ if (e.originalEvent) {
userInteracted.current = true;
- }}
- onPitchStart={() => {
- userInteracted.current = true;
- }}
- onLoad={() => {
- handleCenter();
- }}
- >
- {/* Previous Shape Layer */}
+ }
+ }}
+ onDragStart={() => {
+ userInteracted.current = true;
+ }}
+ onZoomStart={() => {
+ userInteracted.current = true;
+ }}
+ onRotateStart={() => {
+ userInteracted.current = true;
+ }}
+ onPitchStart={() => {
+ userInteracted.current = true;
+ }}
+ onLoad={() => {
+ handleCenter();
+ }}
+ >
+ {/* Previous Shape Layer */}
{previousShapeData && selectedBus && (
<Source
id="prev-route-shape"
@@ -610,8 +568,7 @@ export const StopMapModal: React.FC<StopMapModalProps> = ({
</div>
</Marker>
)}
- </Map>
- )}
+ </AppMap>
{/* Floating controls */}
<div className="map-modal-controls">