From 79f3c42b0c04c7fd77481c14e6e9c345677b8c42 Mon Sep 17 00:00:00 2001 From: Ariel Costas Guerrero <94913521+arielcostas@users.noreply.github.com> Date: Tue, 4 Mar 2025 00:51:42 +0100 Subject: Add table layout like iTranvias, remake settings page --- src/AppContext.tsx | 70 ++++++++++++++++++++++++++++++ src/ThemeContext.tsx | 44 ------------------- src/components/GroupedTable.tsx | 84 ++++++++++++++++++++++++++++++++++++ src/components/RegularTable.tsx | 70 ++++++++++++++++++++++++++++++ src/main.tsx | 6 +-- src/pages/About.tsx | 45 +++++++++++++++----- src/pages/Estimates.tsx | 68 ++++------------------------- src/styles/About.css | 94 +++++++++++++++++++++++++++++++++++++++++ src/styles/Estimates.css | 63 +++++++++++++++++++++++++++ src/styles/Map.css | 86 +++++++++++++++++++++++++++++++++++++ 10 files changed, 513 insertions(+), 117 deletions(-) create mode 100644 src/AppContext.tsx delete mode 100644 src/ThemeContext.tsx create mode 100644 src/components/GroupedTable.tsx create mode 100644 src/components/RegularTable.tsx create mode 100644 src/styles/About.css create mode 100644 src/styles/Map.css (limited to 'src') diff --git a/src/AppContext.tsx b/src/AppContext.tsx new file mode 100644 index 0000000..373e624 --- /dev/null +++ b/src/AppContext.tsx @@ -0,0 +1,70 @@ +import { createContext, useContext, useEffect, useState, ReactNode } from 'react'; + +type Theme = 'light' | 'dark'; +type TableStyle = 'regular'|'grouped'; + +interface AppContextProps { + theme: Theme; + setTheme: React.Dispatch>; + toggleTheme: () => void; + + tableStyle: TableStyle; + setTableStyle: React.Dispatch>; + toggleTableStyle: () => void; +} + +const AppContext = createContext(undefined); + +export const AppProvider = ({ children }: { children: ReactNode }) => { + //#region Theme + const [theme, setTheme] = useState(() => { + const savedTheme = localStorage.getItem('theme'); + if (savedTheme) { + return savedTheme as Theme; + } + const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; + return prefersDark ? 'dark' : 'light'; + }); + + const toggleTheme = () => { + setTheme((prevTheme) => (prevTheme === 'light' ? 'dark' : 'light')); + }; + + useEffect(() => { + document.documentElement.setAttribute('data-theme', theme); + localStorage.setItem('theme', theme); + }, [theme]); + //#endregion + + //#region Table Style + const [tableStyle, setTableStyle] = useState(() => { + const savedTableStyle = localStorage.getItem('tableStyle'); + if (savedTableStyle) { + return savedTableStyle as TableStyle; + } + return 'regular'; + }); + + const toggleTableStyle = () => { + setTableStyle((prevTableStyle) => (prevTableStyle === 'regular' ? 'grouped' : 'regular')); + } + + useEffect(() => { + localStorage.setItem('tableStyle', tableStyle); + }, [tableStyle]); + //#endregion + + return ( + + {children} + + ); +}; + +export const useApp = () => { + const context = useContext(AppContext); + if (!context) { + throw new Error('useApp must be used within a AppProvider'); + } + return context; +}; \ No newline at end of file diff --git a/src/ThemeContext.tsx b/src/ThemeContext.tsx deleted file mode 100644 index 203b70a..0000000 --- a/src/ThemeContext.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { createContext, useContext, useEffect, useState, ReactNode } from 'react'; - -type Theme = 'light' | 'dark'; - -interface ThemeContextProps { - theme: Theme; - toggleTheme: () => void; -} - -const ThemeContext = createContext(undefined); - -export const ThemeProvider = ({ children }: { children: ReactNode }) => { - const [theme, setTheme] = useState(() => { - const savedTheme = localStorage.getItem('theme'); - if (savedTheme) { - return savedTheme as Theme; - } - const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; - return prefersDark ? 'dark' : 'light'; - }); - - useEffect(() => { - document.documentElement.setAttribute('data-theme', theme); - localStorage.setItem('theme', theme); - }, [theme]); - - const toggleTheme = () => { - setTheme((prevTheme) => (prevTheme === 'light' ? 'dark' : 'light')); - }; - - return ( - - {children} - - ); -}; - -export const useTheme = () => { - const context = useContext(ThemeContext); - if (!context) { - throw new Error('useTheme must be used within a ThemeProvider'); - } - return context; -}; \ No newline at end of file diff --git a/src/components/GroupedTable.tsx b/src/components/GroupedTable.tsx new file mode 100644 index 0000000..6581967 --- /dev/null +++ b/src/components/GroupedTable.tsx @@ -0,0 +1,84 @@ +import { StopDetails } from "../pages/Estimates"; +import LineIcon from "./LineIcon"; + +interface GroupedTable { + data: StopDetails; + dataDate: Date | null; +} + +export const GroupedTable: React.FC = ({ data, dataDate }) => { + + const absoluteArrivalTime = (minutes: number) => { + const now = new Date() + const arrival = new Date(now.getTime() + minutes * 60000) + return Intl.DateTimeFormat(navigator.language, { + hour: '2-digit', + minute: '2-digit' + }).format(arrival) + } + + const formatDistance = (meters: number) => { + if (meters > 1024) { + return `${(meters / 1000).toFixed(1)} km`; + } else { + return `${meters} m`; + } + } + + const groupedEstimates = data.estimates.reduce((acc, estimate) => { + if (!acc[estimate.line]) { + acc[estimate.line] = []; + } + acc[estimate.line].push(estimate); + return acc; + }, {} as Record); + + const sortedLines = Object.keys(groupedEstimates).sort((a, b) => { + const firstArrivalA = groupedEstimates[a][0].minutes; + const firstArrivalB = groupedEstimates[b][0].minutes; + return firstArrivalA - firstArrivalB; + }); + + return + + + + + + + + + + + + + {sortedLines.map((line) => ( + groupedEstimates[line].map((estimate, idx) => ( + + {idx === 0 && ( + + )} + + + + + )) + ))} + + + {data?.estimates.length === 0 && ( + + + + + + )} +
Estimaciones de llegadas a las {dataDate?.toLocaleTimeString()}
LíneaRutaLlegadaDistancia
+ + {estimate.route}{`${estimate.minutes} min`} + {estimate.meters > -1 + ? formatDistance(estimate.meters) + : "No disponible" + } +
No hay estimaciones disponibles
+} \ No newline at end of file diff --git a/src/components/RegularTable.tsx b/src/components/RegularTable.tsx new file mode 100644 index 0000000..8f0605f --- /dev/null +++ b/src/components/RegularTable.tsx @@ -0,0 +1,70 @@ +import { StopDetails } from "../pages/Estimates"; +import LineIcon from "./LineIcon"; + +interface RegularTableProps { + data: StopDetails; + dataDate: Date | null; +} + +export const RegularTable: React.FC = ({ data, dataDate }) => { + + const absoluteArrivalTime = (minutes: number) => { + const now = new Date() + const arrival = new Date(now.getTime() + minutes * 60000) + return Intl.DateTimeFormat(navigator.language, { + hour: '2-digit', + minute: '2-digit' + }).format(arrival) + } + + const formatDistance = (meters: number) => { + if (meters > 1024) { + return `${(meters / 1000).toFixed(1)} km`; + } else { + return `${meters} m`; + } + } + + return + + + + + + + + + + + + + {data.estimates + .sort((a, b) => a.minutes - b.minutes) + .map((estimate, idx) => ( + + + + + + + ))} + + + {data?.estimates.length === 0 && ( + + + + + + )} +
Estimaciones de llegadas a las {dataDate?.toLocaleTimeString()}
LíneaRutaLlegadaDistancia
{estimate.route} + {estimate.minutes > 15 + ? absoluteArrivalTime(estimate.minutes) + : `${estimate.minutes} min`} + + {estimate.meters > -1 + ? formatDistance(estimate.meters) + : "No disponible" + } +
No hay estimaciones disponibles
+} \ No newline at end of file diff --git a/src/main.tsx b/src/main.tsx index 762aa0d..060e1b8 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -8,7 +8,7 @@ import { Estimates } from './pages/Estimates.tsx' import { StopMap } from './pages/Map.tsx' import { Layout } from './Layout.tsx' import { About } from './pages/About.tsx' -import { ThemeProvider } from './ThemeContext' +import { AppProvider } from './AppContext.tsx' import ErrorBoundary from './ErrorBoundary' const router = createBrowserRouter([ @@ -36,8 +36,8 @@ const router = createBrowserRouter([ createRoot(document.getElementById('root')!).render( - + - + ) diff --git a/src/pages/About.tsx b/src/pages/About.tsx index 918d484..7e1b0d3 100644 --- a/src/pages/About.tsx +++ b/src/pages/About.tsx @@ -1,8 +1,9 @@ -import { Moon, Sun } from "lucide-react"; -import { useTheme } from "../ThemeContext"; +import { List, Moon, Sun, Table, Table2, TableCellsMerge, TableColumnsSplit } from "lucide-react"; +import { useApp } from "../AppContext"; +import "../styles/About.css"; export function About() { - const {theme, toggleTheme} = useTheme(); + const { theme, setTheme, tableStyle, setTableStyle } = useApp(); return (
@@ -11,12 +12,36 @@ export function About() { Aplicación web para encontrar paradas y tiempos de llegada de los autobuses urbanos de Vigo, España.

- +
+

Ajustes

+
+ + +
+
+ + +
+
+ ¿Qué significa esto? +

+ La tabla de horarios puede mostrarse de dos formas: +

+
+
Mostrar por orden
+
Las paradas se muestran en el orden en que se visitan. Aplicaciones como Infobus (Vitrasa) usan este estilo.
+
Agrupar por línea
+
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.
+
+
+
+

Créditos

Código en GitHub @@ -26,7 +51,7 @@ export function About() {

- Datos obtenidos de datos.vigo.org bajo + Datos obtenidos de datos.vigo.org bajo licencia Open Data Commons Attribution License

diff --git a/src/pages/Estimates.tsx b/src/pages/Estimates.tsx index a445300..900ffc5 100644 --- a/src/pages/Estimates.tsx +++ b/src/pages/Estimates.tsx @@ -1,11 +1,13 @@ import { JSX, useEffect, useState } from "react"; import { useParams } from "react-router"; import { StopDataProvider } from "../data/StopDataProvider"; -import LineIcon from "../components/LineIcon"; import { Star } from 'lucide-react'; import "../styles/Estimates.css"; +import { RegularTable } from "../components/RegularTable"; +import { useApp } from "../AppContext"; +import { GroupedTable } from "../components/GroupedTable"; -interface StopDetails { +export interface StopDetails { stop: { id: number; name: string; @@ -32,6 +34,7 @@ export function Estimates(): JSX.Element { const [dataDate, setDataDate] = useState(null); const [favourited, setFavourited] = useState(false); const params = useParams(); + const { tableStyle } = useApp(); useEffect(() => { loadData(params.stopId!) @@ -48,22 +51,6 @@ export function Estimates(): JSX.Element { ); }, [params.stopId]); - const absoluteArrivalTime = (minutes: number) => { - const now = new Date() - const arrival = new Date(now.getTime() + minutes * 60000) - return Intl.DateTimeFormat(navigator.language, { - hour: '2-digit', - minute: '2-digit' - }).format(arrival) - } - - const formatDistance = (meters: number) => { - if (meters > 1024) { - return `${(meters / 1000).toFixed(1)} km`; - } else { - return `${meters} m`; - } - } const toggleFavourite = () => { if (favourited) { @@ -87,48 +74,9 @@ export function Estimates(): JSX.Element {
- - - - - - - - - - - - - - {data.estimates - .sort((a, b) => a.minutes - b.minutes) - .map((estimate, idx) => ( - - - - - - - ))} - - - {data?.estimates.length === 0 && ( - - - - - - )} -
Estimaciones de llegadas a las {dataDate?.toLocaleTimeString()}
LíneaRutaMinutosMetros
{estimate.route} - {estimate.minutes > 15 - ? absoluteArrivalTime(estimate.minutes) - : `${estimate.minutes} min`} - - {estimate.meters > -1 - ? formatDistance(estimate.meters) - : "No disponible" - } -
No hay estimaciones disponibles
+ {tableStyle === 'grouped' ? + : + }
) diff --git a/src/styles/About.css b/src/styles/About.css new file mode 100644 index 0000000..934577d --- /dev/null +++ b/src/styles/About.css @@ -0,0 +1,94 @@ +/* About page specific styles */ +.about-page { + text-align: center; + padding: 1rem; +} + +.about-version { + color: var(--subtitle-color); + font-size: 0.9rem; + margin-top: 2rem; +} + +.about-description { + margin-top: 1rem; + line-height: 1.6; +} + +.settings-section { + margin-bottom: 2em; + padding: 1rem; + border: 1px solid var(--border-color); + border-radius: 8px; + background-color: var(--message-background-color); + text-align: left; +} + +.settings-section h2 { + margin-bottom: 1em; +} + +.settings-content { + display: flex; + flex-direction: column; + align-items: flex-start; + margin-bottom: 1em; +} + +.settings-content-inline { + display: flex; + align-items: center; + margin-bottom: 1em; +} + +.settings-section .form-button { + margin-bottom: 1em; + padding: 0.75rem 1.5rem; + font-size: 1.1rem; +} + +.settings-section .form-select-inline { + margin-left: 0.5em; + padding: 0.5rem; + font-size: 1rem; + border: 1px solid var(--border-color); + border-radius: 8px; +} + +.settings-section .form-label-inline { + font-weight: 500; +} + +.settings-section .form-label { + display: block; + margin-bottom: 0.5em; + font-weight: 500; +} + +.settings-section .form-description { + margin-top: 0.5em; + font-size: 0.9rem; + color: var(--subtitle-color); +} + +.settings-section .form-details { + margin-top: 0.5em; + font-size: 0.9rem; + color: var(--subtitle-color); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 0.5rem; +} + +.settings-section .form-details summary { + cursor: pointer; + font-weight: 500; +} + +.settings-section .form-details p { + margin-top: 0.5em; +} + +.settings-section p { + margin-top: 0.5em; +} \ No newline at end of file diff --git a/src/styles/Estimates.css b/src/styles/Estimates.css index d9fa0ab..1fce445 100644 --- a/src/styles/Estimates.css +++ b/src/styles/Estimates.css @@ -25,4 +25,67 @@ .table tfoot td { text-align: center; +} + +/* Estimates page specific styles */ +.estimates-header { + display: flex; + align-items: center; + margin-bottom: 1rem; +} + +.estimates-stop-id { + font-size: 1rem; + color: var(--subtitle-color); + margin-left: 0.5rem; +} + +.estimates-arrival { + color: #28a745; + font-weight: 500; +} + +.estimates-delayed { + color: #dc3545; +} + +.button-group { + display: flex; + gap: 1rem; + margin-bottom: 1.5rem; + flex-wrap: wrap; +} + +.button { + padding: 0.75rem 1rem; + background-color: var(--button-background-color); + color: white; + border: none; + border-radius: 8px; + font-size: 1rem; + font-weight: 500; + cursor: pointer; + text-align: center; + text-decoration: none; + display: inline-block; +} + +.button:hover { + background-color: var(--button-hover-background-color); +} + +.button:disabled { + background-color: var(--button-disabled-background-color); + cursor: not-allowed; +} + +.star-icon { + margin-right: 0.5rem; + color: #ccc; + fill: none; +} + +.star-icon.active { + color: var(--star-color); /* Yellow color for active star */ + fill: var(--star-color); } \ No newline at end of file diff --git a/src/styles/Map.css b/src/styles/Map.css new file mode 100644 index 0000000..3af112a --- /dev/null +++ b/src/styles/Map.css @@ -0,0 +1,86 @@ +/* Map page specific styles */ +.map-container { + height: calc(100vh - 140px); + margin: -16px; + margin-bottom: 1rem; + position: relative; +} + +/* Fullscreen map styles */ +.fullscreen-container { + position: absolute; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + padding: 0; + margin: 0; + max-width: none; + overflow: hidden; +} + +.fullscreen-map { + width: 100%; + height: 100%; +} + +.fullscreen-loading { + display: flex; + justify-content: center; + align-items: center; + height: 100vh; + width: 100vw; + font-size: 1.8rem; + font-weight: 600; + color: var(--text-color); +} + +/* Map marker and popup styles */ +.stop-marker { + box-shadow: 0 0 5px rgba(0, 0, 0, 0.3); + transition: all 0.2s ease-in-out; +} + +.stop-marker:hover { + transform: scale(1.2); +} + +.maplibregl-popup { + max-width: 250px; +} + +.maplibregl-popup-content { + padding: 12px; + border-radius: 8px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); +} + +.popup-line-icons { + display: flex; + flex-wrap: wrap; + margin: 6px 0; + gap: 5px; +} + +.popup-line { + display: inline-block; + background-color: var(--button-background-color); + color: white; + padding: 2px 6px; + margin-right: 4px; + border-radius: 4px; + font-size: 0.8rem; + font-weight: 500; +} + +.popup-link { + display: block; + margin-top: 8px; + color: var(--button-background-color); + text-decoration: none; + font-weight: 500; +} + +.popup-link:hover { + text-decoration: underline; +} \ No newline at end of file -- cgit v1.3