diff options
Diffstat (limited to 'src/frontend/app/components')
| -rw-r--r-- | src/frontend/app/components/ErrorDisplay.css | 82 | ||||
| -rw-r--r-- | src/frontend/app/components/ErrorDisplay.tsx | 84 | ||||
| -rw-r--r-- | src/frontend/app/components/EstimatesTableSkeleton.tsx | 114 | ||||
| -rw-r--r-- | src/frontend/app/components/PullToRefresh.css | 64 | ||||
| -rw-r--r-- | src/frontend/app/components/PullToRefresh.tsx | 160 | ||||
| -rw-r--r-- | src/frontend/app/components/StopItemSkeleton.tsx | 42 | ||||
| -rw-r--r-- | src/frontend/app/components/StopSheet.css | 97 | ||||
| -rw-r--r-- | src/frontend/app/components/StopSheet.tsx | 127 | ||||
| -rw-r--r-- | src/frontend/app/components/StopSheetSkeleton.tsx | 66 | ||||
| -rw-r--r-- | src/frontend/app/components/TimetableSkeleton.tsx | 67 |
10 files changed, 870 insertions, 33 deletions
diff --git a/src/frontend/app/components/ErrorDisplay.css b/src/frontend/app/components/ErrorDisplay.css new file mode 100644 index 0000000..096182a --- /dev/null +++ b/src/frontend/app/components/ErrorDisplay.css @@ -0,0 +1,82 @@ +.error-display { + display: flex; + justify-content: center; + align-items: center; + padding: 2rem; + min-height: 200px; +} + +.error-content { + text-align: center; + max-width: 400px; +} + +.error-icon { + width: 48px; + height: 48px; + color: #e74c3c; + margin: 0 auto 1rem; + display: block; +} + +.error-title { + font-size: 1.5rem; + font-weight: 600; + color: #2c3e50; + margin: 0 0 0.5rem; +} + +.error-message { + color: #7f8c8d; + margin: 0 0 1.5rem; + line-height: 1.5; +} + +.error-retry-button { + display: inline-flex; + align-items: center; + gap: 0.5rem; + background: #3498db; + color: white; + border: none; + padding: 0.75rem 1.5rem; + border-radius: 0.5rem; + font-size: 0.9rem; + font-weight: 500; + cursor: pointer; + transition: background-color 0.2s ease; +} + +.error-retry-button:hover { + background: #2980b9; +} + +.error-retry-button:active { + transform: translateY(1px); +} + +.retry-icon { + width: 16px; + height: 16px; +} + +/* Compact version for smaller spaces */ +.error-display.compact { + min-height: 120px; + padding: 1rem; +} + +.error-display.compact .error-icon { + width: 32px; + height: 32px; + margin-bottom: 0.75rem; +} + +.error-display.compact .error-title { + font-size: 1.2rem; +} + +.error-display.compact .error-message { + font-size: 0.9rem; + margin-bottom: 1rem; +} diff --git a/src/frontend/app/components/ErrorDisplay.tsx b/src/frontend/app/components/ErrorDisplay.tsx new file mode 100644 index 0000000..3c91db6 --- /dev/null +++ b/src/frontend/app/components/ErrorDisplay.tsx @@ -0,0 +1,84 @@ +import React from "react"; +import { AlertTriangle, RefreshCw, Wifi, WifiOff } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import "./ErrorDisplay.css"; + +interface ErrorDisplayProps { + error: { + type: 'network' | 'server' | 'unknown'; + status?: number; + message?: string; + }; + onRetry?: () => void; + title?: string; + className?: string; +} + +export const ErrorDisplay: React.FC<ErrorDisplayProps> = ({ + error, + onRetry, + title, + className = "" +}) => { + const { t } = useTranslation(); + + const getErrorIcon = () => { + switch (error.type) { + case 'network': + return <WifiOff className="error-icon" />; + case 'server': + return <AlertTriangle className="error-icon" />; + default: + return <AlertTriangle className="error-icon" />; + } + }; + + const getErrorMessage = () => { + switch (error.type) { + case 'network': + return t("errors.network", "No hay conexión a internet. Comprueba tu conexión y vuelve a intentarlo."); + case 'server': + if (error.status === 404) { + return t("errors.not_found", "No se encontraron datos para esta parada."); + } + if (error.status === 500) { + return t("errors.server_error", "Error del servidor. Inténtalo de nuevo más tarde."); + } + if (error.status && error.status >= 400) { + return t("errors.client_error", "Error en la solicitud. Verifica que la parada existe."); + } + return t("errors.server_generic", "Error del servidor ({{status}})", { status: error.status || 'desconocido' }); + default: + return error.message || t("errors.unknown", "Ha ocurrido un error inesperado."); + } + }; + + const getErrorTitle = () => { + if (title) return title; + + switch (error.type) { + case 'network': + return t("errors.network_title", "Sin conexión"); + case 'server': + return t("errors.server_title", "Error del servidor"); + default: + return t("errors.unknown_title", "Error"); + } + }; + + return ( + <div className={`error-display ${className}`}> + <div className="error-content"> + {getErrorIcon()} + <h3 className="error-title">{getErrorTitle()}</h3> + <p className="error-message">{getErrorMessage()}</p> + {onRetry && ( + <button className="error-retry-button" onClick={onRetry}> + <RefreshCw className="retry-icon" /> + {t("errors.retry", "Reintentar")} + </button> + )} + </div> + </div> + ); +}; diff --git a/src/frontend/app/components/EstimatesTableSkeleton.tsx b/src/frontend/app/components/EstimatesTableSkeleton.tsx new file mode 100644 index 0000000..2ef770b --- /dev/null +++ b/src/frontend/app/components/EstimatesTableSkeleton.tsx @@ -0,0 +1,114 @@ +import React from "react"; +import Skeleton, { SkeletonTheme } from "react-loading-skeleton"; +import "react-loading-skeleton/dist/skeleton.css"; +import { useTranslation } from "react-i18next"; + +interface EstimatesTableSkeletonProps { + rows?: number; +} + +export const EstimatesTableSkeleton: React.FC<EstimatesTableSkeletonProps> = ({ + rows = 3 +}) => { + const { t } = useTranslation(); + + return ( + <SkeletonTheme baseColor="#f0f0f0" highlightColor="#e0e0e0"> + <table className="table"> + <caption> + <Skeleton width="250px" /> + </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> + + <tbody> + {Array.from({ length: rows }, (_, index) => ( + <tr key={`skeleton-${index}`}> + <td> + <Skeleton width="40px" height="24px" style={{ borderRadius: "4px" }} /> + </td> + <td> + <Skeleton width="120px" /> + </td> + <td> + <div style={{ display: "flex", flexDirection: "column", gap: "2px" }}> + <Skeleton width="60px" /> + <Skeleton width="40px" /> + </div> + </td> + <td> + <Skeleton width="50px" /> + </td> + </tr> + ))} + </tbody> + </table> + </SkeletonTheme> + ); +}; + +interface EstimatesGroupedSkeletonProps { + groups?: number; + rowsPerGroup?: number; +} + +export const EstimatesGroupedSkeleton: React.FC<EstimatesGroupedSkeletonProps> = ({ + groups = 3, + rowsPerGroup = 2 +}) => { + const { t } = useTranslation(); + + return ( + <SkeletonTheme baseColor="#f0f0f0" highlightColor="#e0e0e0"> + <table className="table grouped-table"> + <caption> + <Skeleton width="250px" /> + </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> + + <tbody> + {Array.from({ length: groups }, (_, groupIndex) => ( + <React.Fragment key={`group-${groupIndex}`}> + {Array.from({ length: rowsPerGroup }, (_, rowIndex) => ( + <tr key={`skeleton-${groupIndex}-${rowIndex}`} className={rowIndex === 0 ? "group-start" : ""}> + <td> + {rowIndex === 0 && ( + <Skeleton width="40px" height="24px" style={{ borderRadius: "4px" }} /> + )} + </td> + <td> + <Skeleton width="120px" /> + </td> + <td> + <div style={{ display: "flex", flexDirection: "column", gap: "2px" }}> + <Skeleton width="60px" /> + <Skeleton width="40px" /> + </div> + </td> + <td> + <Skeleton width="50px" /> + </td> + </tr> + ))} + </React.Fragment> + ))} + </tbody> + </table> + </SkeletonTheme> + ); +}; diff --git a/src/frontend/app/components/PullToRefresh.css b/src/frontend/app/components/PullToRefresh.css index e69de29..d4946d2 100644 --- a/src/frontend/app/components/PullToRefresh.css +++ b/src/frontend/app/components/PullToRefresh.css @@ -0,0 +1,64 @@ +.pull-to-refresh-container { + position: relative; + width: 100%; + height: 100%; +} + +.pull-to-refresh-indicator { + position: fixed; + top: 20px; + left: 50%; + transform: translateX(-50%); + z-index: 1000; + pointer-events: none; +} + +.refresh-icon-container { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border-radius: 50%; + background: var(--surface-color, #f8f9fa); + border: 2px solid var(--border-color, #e9ecef); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + transition: all 0.2s ease; +} + +.refresh-icon-container.active { + background: var(--primary-color, #007bff); + border-color: var(--primary-color, #007bff); +} + +.refresh-icon { + width: 20px; + height: 20px; + color: var(--text-secondary, #6c757d); + transition: color 0.2s ease; +} + +.refresh-icon-container.active .refresh-icon { + color: white; +} + +.refresh-icon.spinning { + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +.pull-to-refresh-content { + width: 100%; + height: 100%; + /* Completely normal scrolling */ + overflow: visible; + touch-action: auto; +} diff --git a/src/frontend/app/components/PullToRefresh.tsx b/src/frontend/app/components/PullToRefresh.tsx index e69de29..f8ea590 100644 --- a/src/frontend/app/components/PullToRefresh.tsx +++ b/src/frontend/app/components/PullToRefresh.tsx @@ -0,0 +1,160 @@ +import React, { useRef, useState, useEffect, useCallback } from "react"; +import { motion, useMotionValue, useTransform } from "framer-motion"; +import { RefreshCw } from "lucide-react"; +import "./PullToRefresh.css"; + +interface PullToRefreshProps { + onRefresh: () => Promise<void> | void; + isRefreshing?: boolean; + children: React.ReactNode; + threshold?: number; +} + +export const PullToRefresh: React.FC<PullToRefreshProps> = ({ + onRefresh, + isRefreshing = false, + children, + threshold = 60, +}) => { + const [isActive, setIsActive] = useState(false); + const [isPulling, setIsPulling] = useState(false); + const containerRef = useRef<HTMLDivElement>(null); + const startY = useRef<number>(0); + + const y = useMotionValue(0); + const opacity = useTransform(y, [0, threshold], [0, 1]); + const scale = useTransform(y, [0, threshold], [0.5, 1]); + const rotate = useTransform(y, [0, threshold], [0, 180]); + + const isAtPageTop = useCallback(() => { + const scrollTop = window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop || 0; + return scrollTop <= 10; // Increased tolerance to 10px + }, []); + + const handleTouchStart = useCallback((e: TouchEvent) => { + // Very strict check - must be at absolute top + const windowScroll = window.pageYOffset || window.scrollY || 0; + const htmlScroll = document.documentElement.scrollTop; + const bodyScroll = document.body.scrollTop; + const containerScroll = containerRef.current?.scrollTop || 0; + const parentScroll = containerRef.current?.parentElement?.scrollTop || 0; + const maxScroll = Math.max(windowScroll, htmlScroll, bodyScroll, containerScroll, parentScroll); + + if (maxScroll > 0 || isRefreshing) { + setIsPulling(false); + setIsActive(false); + return; + } + + startY.current = e.touches[0].clientY; + setIsPulling(true); + }, [isRefreshing]); + + const handleTouchMove = useCallback((e: TouchEvent) => { + if (!isPulling) return; + + // Continuously check if we're still at the top during the gesture + const windowScroll = window.pageYOffset || window.scrollY || 0; + const htmlScroll = document.documentElement.scrollTop; + const bodyScroll = document.body.scrollTop; + const containerScroll = containerRef.current?.scrollTop || 0; + const parentScroll = containerRef.current?.parentElement?.scrollTop || 0; + const maxScroll = Math.max(windowScroll, htmlScroll, bodyScroll, containerScroll, parentScroll); + + if (maxScroll > 10) { + // Cancel pull-to-refresh if we've scrolled away from top + setIsPulling(false); + setIsActive(false); + y.set(0); + return; + } + + const currentY = e.touches[0].clientY; + const pullDistance = currentY - startY.current; + + if (pullDistance > 0) { + // Only prevent default when the event is cancelable + if (e.cancelable) { + e.preventDefault(); + } + + const dampedDistance = Math.min(pullDistance * 0.5, threshold * 1.2); + y.set(dampedDistance); + + if (dampedDistance >= threshold && !isActive) { + setIsActive(true); + // Only vibrate if user activation is available and vibrate is supported + if (navigator.vibrate && navigator.userActivation?.hasBeenActive) { + navigator.vibrate(50); + } + } else if (dampedDistance < threshold && isActive) { + setIsActive(false); + } + } else { + // Reset if pulling up + y.set(0); + setIsActive(false); + } + }, [isPulling, threshold, isActive, y]); + + const handleTouchEnd = useCallback(async () => { + if (!isPulling) return; + + setIsPulling(false); + + if (isActive && y.get() >= threshold && !isRefreshing) { + try { + await onRefresh(); + } catch (error) { + console.error('Refresh failed:', error); + } + } + + // Always reset state + setIsActive(false); + y.set(0); + startY.current = 0; + }, [isPulling, isActive, threshold, isRefreshing, onRefresh, y]); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + // Use passive: false for touchmove to allow preventDefault + container.addEventListener('touchstart', handleTouchStart, { passive: true }); + container.addEventListener('touchmove', handleTouchMove, { passive: false }); + container.addEventListener('touchend', handleTouchEnd, { passive: true }); + + return () => { + container.removeEventListener('touchstart', handleTouchStart); + container.removeEventListener('touchmove', handleTouchMove); + container.removeEventListener('touchend', handleTouchEnd); + }; + }, [handleTouchStart, handleTouchMove, handleTouchEnd]); + + return ( + <div className="pull-to-refresh-container" ref={containerRef}> + {/* Simple indicator */} + {isPulling && ( + <motion.div + className="pull-to-refresh-indicator" + style={{ opacity }} + > + <motion.div + className={`refresh-icon-container ${isActive ? 'active' : ''}`} + style={{ scale, rotate: isRefreshing ? 0 : rotate }} + > + <RefreshCw + className={`refresh-icon ${isRefreshing ? 'spinning' : ''}`} + /> + </motion.div> + </motion.div> + )} + + {/* Normal content - no transform interference */} + <div className="pull-to-refresh-content"> + {children} + </div> + </div> + ); +}; diff --git a/src/frontend/app/components/StopItemSkeleton.tsx b/src/frontend/app/components/StopItemSkeleton.tsx new file mode 100644 index 0000000..72f7506 --- /dev/null +++ b/src/frontend/app/components/StopItemSkeleton.tsx @@ -0,0 +1,42 @@ +import React from "react"; +import Skeleton, { SkeletonTheme } from "react-loading-skeleton"; +import "react-loading-skeleton/dist/skeleton.css"; + +interface StopItemSkeletonProps { + showId?: boolean; + stopId?: number; +} + +const StopItemSkeleton: React.FC<StopItemSkeletonProps> = ({ + showId = false, + stopId +}) => { + return ( + <SkeletonTheme baseColor="#f0f0f0" highlightColor="#e0e0e0"> + <li className="list-item"> + <div className="list-item-link"> + <span> + {showId && stopId && ( + <>({stopId}) </> + )} + </span> + <Skeleton + width={showId ? "60%" : "80%"} + style={{ display: "inline-block" }} + /> + <div className="line-icons" style={{ marginTop: "4px" }}> + <Skeleton + count={3} + width="30px" + height="20px" + inline={true} + style={{ marginRight: "0.5rem" }} + /> + </div> + </div> + </li> + </SkeletonTheme> + ); +}; + +export default StopItemSkeleton; diff --git a/src/frontend/app/components/StopSheet.css b/src/frontend/app/components/StopSheet.css index 735e6cf..165f9fe 100644 --- a/src/frontend/app/components/StopSheet.css +++ b/src/frontend/app/components/StopSheet.css @@ -102,19 +102,83 @@ color: var(--subtitle-color); } +.stop-sheet-footer { + display: flex; + flex-direction: column; + gap: 0.75rem; + margin: 1rem 0; + padding-top: 0.75rem; + border-top: 1px solid var(--border-color); +} + +.stop-sheet-timestamp { + font-size: 0.8rem; + color: var(--subtitle-color); + text-align: center; +} + +.stop-sheet-actions { + display: flex; + gap: 0.75rem; +} + +.stop-sheet-reload { + display: inline-flex; + align-items: center; + gap: 0.4rem; + background: transparent; + border: 1px solid var(--border-color); + color: var(--text-color); + padding: 0.5rem 0.75rem; + border-radius: 6px; + font-size: 0.85rem; + cursor: pointer; + transition: all 0.2s ease; + flex: 1; + justify-content: center; +} + +.stop-sheet-reload:hover:not(:disabled) { + background: var(--message-background-color); + border-color: var(--button-background-color); +} + +.stop-sheet-reload:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.reload-icon { + width: 14px; + height: 14px; + transition: transform 0.5s ease; +} + +.reload-icon.spinning { + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + .stop-sheet-view-all { display: block; - padding: 12px 16px; + padding: 0.5rem 0.75rem; background-color: var(--button-background-color); color: white; text-decoration: none; text-align: center; - border-radius: 8px; + border-radius: 6px; font-weight: 500; + font-size: 0.85rem; transition: background-color 0.2s ease; - - margin-block-start: 1rem; - margin-inline-start: auto; + flex: 2; } .stop-sheet-view-all:hover { @@ -122,6 +186,29 @@ text-decoration: none; } +/* Error display adjustments for sheet */ +.stop-sheet-content .error-display { + margin: 1rem 0; +} + +.stop-sheet-content .error-display.compact { + min-height: 100px; + padding: 1rem; +} + +.stop-sheet-content .error-display.compact .error-icon { + width: 28px; + height: 28px; +} + +.stop-sheet-content .error-display.compact .error-title { + font-size: 1.1rem; +} + +.stop-sheet-content .error-display.compact .error-message { + font-size: 0.85rem; +} + [data-rsbs-overlay] { background-color: rgba(0, 0, 0, 0.3); } diff --git a/src/frontend/app/components/StopSheet.tsx b/src/frontend/app/components/StopSheet.tsx index 8075e9d..e8000d1 100644 --- a/src/frontend/app/components/StopSheet.tsx +++ b/src/frontend/app/components/StopSheet.tsx @@ -2,7 +2,10 @@ import React, { useEffect, useState } from "react"; import { Sheet } from "react-modal-sheet"; import { Link } from "react-router"; import { useTranslation } from "react-i18next"; +import { RefreshCw } from "lucide-react"; import LineIcon from "./LineIcon"; +import { StopSheetSkeleton } from "./StopSheetSkeleton"; +import { ErrorDisplay } from "./ErrorDisplay"; import { type StopDetails } from "../routes/estimates-$id"; import "./StopSheet.css"; @@ -13,12 +16,26 @@ interface StopSheetProps { stopName: string; } +interface ErrorInfo { + type: 'network' | 'server' | 'unknown'; + status?: number; + message?: string; +} + const loadStopData = async (stopId: number): Promise<StopDetails> => { + // Add delay to see skeletons in action (remove in production) + await new Promise(resolve => setTimeout(resolve, 1000)); + const resp = await fetch(`/api/GetStopEstimates?id=${stopId}`, { headers: { Accept: "application/json", }, }); + + if (!resp.ok) { + throw new Error(`HTTP ${resp.status}: ${resp.statusText}`); + } + return await resp.json(); }; @@ -31,21 +48,47 @@ export const StopSheet: React.FC<StopSheetProps> = ({ const { t } = useTranslation(); const [data, setData] = useState<StopDetails | null>(null); const [loading, setLoading] = useState(false); + const [error, setError] = useState<ErrorInfo | null>(null); + const [lastUpdated, setLastUpdated] = useState<Date | null>(null); - useEffect(() => { - if (isOpen && stopId) { + const parseError = (error: any): ErrorInfo => { + if (!navigator.onLine) { + return { type: 'network', message: 'No internet connection' }; + } + + if (error.message?.includes('Failed to fetch') || error.message?.includes('NetworkError')) { + return { type: 'network' }; + } + + if (error.message?.includes('HTTP')) { + const statusMatch = error.message.match(/HTTP (\d+):/); + const status = statusMatch ? parseInt(statusMatch[1]) : undefined; + return { type: 'server', status }; + } + + return { type: 'unknown', message: error.message }; + }; + + const loadData = async () => { + try { setLoading(true); + setError(null); setData(null); - loadStopData(stopId) - .then((stopData) => { - setData(stopData); - }) - .catch((error) => { - console.error("Failed to load stop data:", error); - }) - .finally(() => { - setLoading(false); - }); + + const stopData = await loadStopData(stopId); + setData(stopData); + setLastUpdated(new Date()); + } catch (err) { + console.error("Failed to load stop data:", err); + setError(parseError(err)); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + if (isOpen && stopId) { + loadData(); } }, [isOpen, stopId]); @@ -81,7 +124,7 @@ export const StopSheet: React.FC<StopSheetProps> = ({ <Sheet isOpen={isOpen} onClose={onClose} - detent="content-height" + detent={"content-height" as any} > <Sheet.Container> <Sheet.Header /> @@ -92,13 +135,16 @@ export const StopSheet: React.FC<StopSheetProps> = ({ <span className="stop-sheet-id">({stopId})</span> </div> - {loading && ( - <div className="stop-sheet-loading"> - {t("common.loading", "Loading...")} - </div> - )} - - {data && !loading && ( + {loading ? ( + <StopSheetSkeleton /> + ) : error ? ( + <ErrorDisplay + error={error} + onRetry={loadData} + title={t("errors.estimates_title", "Error al cargar estimaciones")} + className="compact" + /> + ) : data ? ( <> <div className="stop-sheet-estimates"> <h3 className="stop-sheet-subtitle"> @@ -136,15 +182,40 @@ export const StopSheet: React.FC<StopSheetProps> = ({ )} </div> - <Link - to={`/estimates/${stopId}`} - className="stop-sheet-view-all" - onClick={onClose} - > - {t("map.view_all_estimates", "Ver todas las estimaciones")} - </Link> + <div className="stop-sheet-footer"> + {lastUpdated && ( + <div className="stop-sheet-timestamp"> + {t("estimates.last_updated", "Actualizado a las")}{" "} + {lastUpdated.toLocaleTimeString(undefined, { + hour: "2-digit", + minute: "2-digit", + second: "2-digit" + })} + </div> + )} + + <div className="stop-sheet-actions"> + <button + className="stop-sheet-reload" + onClick={loadData} + disabled={loading} + title={t("estimates.reload", "Recargar estimaciones")} + > + <RefreshCw className={`reload-icon ${loading ? 'spinning' : ''}`} /> + {t("estimates.reload", "Recargar")} + </button> + + <Link + to={`/estimates/${stopId}`} + className="stop-sheet-view-all" + onClick={onClose} + > + {t("map.view_all_estimates", "Ver todas las estimaciones")} + </Link> + </div> + </div> </> - )} + ) : null} </div> </Sheet.Content> </Sheet.Container> diff --git a/src/frontend/app/components/StopSheetSkeleton.tsx b/src/frontend/app/components/StopSheetSkeleton.tsx new file mode 100644 index 0000000..91ea74f --- /dev/null +++ b/src/frontend/app/components/StopSheetSkeleton.tsx @@ -0,0 +1,66 @@ +import React from "react"; +import Skeleton, { SkeletonTheme } from "react-loading-skeleton"; +import "react-loading-skeleton/dist/skeleton.css"; +import { useTranslation } from "react-i18next"; + +interface StopSheetSkeletonProps { + rows?: number; +} + +export const StopSheetSkeleton: React.FC<StopSheetSkeletonProps> = ({ + rows = 4 +}) => { + const { t } = useTranslation(); + + return ( + <SkeletonTheme baseColor="#f0f0f0" highlightColor="#e0e0e0"> + <div className="stop-sheet-estimates"> + <h3 className="stop-sheet-subtitle"> + {t("estimates.next_arrivals", "Next arrivals")} + </h3> + + <div className="stop-sheet-estimates-list"> + {Array.from({ length: rows }, (_, index) => ( + <div key={`skeleton-${index}`} className="stop-sheet-estimate-item"> + <div className="stop-sheet-estimate-line"> + <Skeleton width="40px" height="24px" style={{ borderRadius: "4px" }} /> + </div> + + <div className="stop-sheet-estimate-details"> + <div className="stop-sheet-estimate-route"> + <Skeleton width="120px" height="0.95rem" /> + </div> + <div className="stop-sheet-estimate-time"> + <Skeleton width="80px" height="0.85rem" /> + </div> + </div> + </div> + ))} + </div> + </div> + + <div className="stop-sheet-footer"> + <div className="stop-sheet-timestamp"> + <Skeleton width="140px" height="0.8rem" /> + </div> + + <div className="stop-sheet-actions"> + <div className="stop-sheet-reload" style={{ + opacity: 0.6, + pointerEvents: "none" + }}> + <Skeleton width="70px" height="0.85rem" /> + </div> + + <div className="stop-sheet-view-all" style={{ + background: "#f0f0f0", + cursor: "not-allowed", + pointerEvents: "none" + }}> + <Skeleton width="180px" height="0.85rem" /> + </div> + </div> + </div> + </SkeletonTheme> + ); +}; diff --git a/src/frontend/app/components/TimetableSkeleton.tsx b/src/frontend/app/components/TimetableSkeleton.tsx new file mode 100644 index 0000000..01956ee --- /dev/null +++ b/src/frontend/app/components/TimetableSkeleton.tsx @@ -0,0 +1,67 @@ +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="#f0f0f0" highlightColor="#e0e0e0"> + <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> + ); +}; |
