diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/AppContext.tsx | 70 | ||||
| -rw-r--r-- | src/ThemeContext.tsx | 44 | ||||
| -rw-r--r-- | src/components/GroupedTable.tsx | 84 | ||||
| -rw-r--r-- | src/components/RegularTable.tsx | 70 | ||||
| -rw-r--r-- | src/main.tsx | 6 | ||||
| -rw-r--r-- | src/pages/About.tsx | 45 | ||||
| -rw-r--r-- | src/pages/Estimates.tsx | 68 | ||||
| -rw-r--r-- | src/styles/About.css | 94 | ||||
| -rw-r--r-- | src/styles/Estimates.css | 63 | ||||
| -rw-r--r-- | src/styles/Map.css | 86 |
10 files changed, 513 insertions, 117 deletions
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<React.SetStateAction<Theme>>; + toggleTheme: () => void; + + tableStyle: TableStyle; + setTableStyle: React.Dispatch<React.SetStateAction<TableStyle>>; + toggleTableStyle: () => void; +} + +const AppContext = createContext<AppContextProps | undefined>(undefined); + +export const AppProvider = ({ children }: { children: ReactNode }) => { + //#region Theme + const [theme, setTheme] = useState<Theme>(() => { + 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<TableStyle>(() => { + 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 ( + <AppContext.Provider value={{ theme, setTheme, toggleTheme, tableStyle, setTableStyle, toggleTableStyle }}> + {children} + </AppContext.Provider> + ); +}; + +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<ThemeContextProps | undefined>(undefined); - -export const ThemeProvider = ({ children }: { children: ReactNode }) => { - const [theme, setTheme] = useState<Theme>(() => { - 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 ( - <ThemeContext.Provider value={{ theme, toggleTheme }}> - {children} - </ThemeContext.Provider> - ); -}; - -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<GroupedTable> = ({ 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<string, typeof data.estimates>); + + const sortedLines = Object.keys(groupedEstimates).sort((a, b) => { + const firstArrivalA = groupedEstimates[a][0].minutes; + const firstArrivalB = groupedEstimates[b][0].minutes; + return firstArrivalA - firstArrivalB; + }); + + return <table className="table"> + <caption>Estimaciones de llegadas a las {dataDate?.toLocaleTimeString()}</caption> + + <thead> + <tr> + <th>Línea</th> + <th>Ruta</th> + <th>Llegada</th> + <th>Distancia</th> + </tr> + </thead> + + <tbody> + {sortedLines.map((line) => ( + groupedEstimates[line].map((estimate, idx) => ( + <tr key={`${line}-${idx}`}> + {idx === 0 && ( + <td rowSpan={groupedEstimates[line].length} style={{ verticalAlign: 'top' }}> + <LineIcon line={line} /> + </td> + )} + <td>{estimate.route}</td> + <td>{`${estimate.minutes} min`}</td> + <td> + {estimate.meters > -1 + ? formatDistance(estimate.meters) + : "No disponible" + } + </td> + </tr> + )) + ))} + </tbody> + + {data?.estimates.length === 0 && ( + <tfoot> + <tr> + <td colSpan={4}>No hay estimaciones disponibles</td> + </tr> + </tfoot> + )} + </table> +}
\ 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<RegularTableProps> = ({ 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 <table className="table"> + <caption>Estimaciones de llegadas a las {dataDate?.toLocaleTimeString()}</caption> + + <thead> + <tr> + <th>Línea</th> + <th>Ruta</th> + <th>Llegada</th> + <th>Distancia</th> + </tr> + </thead> + + <tbody> + {data.estimates + .sort((a, b) => a.minutes - b.minutes) + .map((estimate, idx) => ( + <tr key={idx}> + <td><LineIcon line={estimate.line} /></td> + <td>{estimate.route}</td> + <td> + {estimate.minutes > 15 + ? absoluteArrivalTime(estimate.minutes) + : `${estimate.minutes} min`} + </td> + <td> + {estimate.meters > -1 + ? formatDistance(estimate.meters) + : "No disponible" + } + </td> + </tr> + ))} + </tbody> + + {data?.estimates.length === 0 && ( + <tfoot> + <tr> + <td colSpan={4}>No hay estimaciones disponibles</td> + </tr> + </tfoot> + )} + </table> +}
\ 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( <ErrorBoundary> - <ThemeProvider> + <AppProvider> <RouterProvider router={router} /> - </ThemeProvider> + </AppProvider> </ErrorBoundary> ) 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 ( <div className="about-page"> @@ -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. </p> - <button className="form-button" onClick={toggleTheme}> - {theme === 'light' ? - <><Moon size={24} /> Usar modo oscuro</> : - <><Sun size={24} /> Usar modo claro</> - } - </button> + <section className="settings-section"> + <h2>Ajustes</h2> + <div className="settings-content-inline"> + <label htmlFor="theme" className="form-label-inline">Modo:</label> + <select id="theme" className="form-select-inline" value={theme} onChange={(e) => setTheme(e.target.value)}> + <option value="light">Claro</option> + <option value="dark">Oscuro</option> + </select> + </div> + <div className="settings-content-inline"> + <label htmlFor="tableStyle" className="form-label-inline">Estilo de tabla:</label> + <select id="tableStyle" className="form-select-inline" value={tableStyle} onChange={(e) => setTableStyle(e.target.value)}> + <option value="regular">Mostrar por orden</option> + <option value="grouped">Agrupar por línea</option> + </select> + </div> + <details className="form-details"> + <summary>¿Qué significa esto?</summary> + <p> + La tabla de horarios puede mostrarse de dos formas: + </p> + <dl> + <dt>Mostrar por orden</dt> + <dd>Las paradas se muestran en el orden en que se visitan. Aplicaciones como Infobus (Vitrasa) usan este estilo.</dd> + <dt>Agrupar por línea</dt> + <dd>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.</dd> + </dl> + </details> + </section> + <h2>Créditos</h2> <p> <a href="https://github.com/arielcostas/urbanovigo-web" className="about-link"> Código en GitHub @@ -26,7 +51,7 @@ export function About() { </a> </p> <p> - Datos obtenidos de <a href="https://datos.vigo.org">datos.vigo.org</a> bajo + Datos obtenidos de <a href="https://datos.vigo.org">datos.vigo.org</a> bajo licencia <a href="https://opendefinition.org/licenses/odc-by/">Open Data Commons Attribution License</a> </p> </div> 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<Date | null>(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 { </div> <div className="table-responsive"> - <table className="table"> - <caption>Estimaciones de llegadas a las {dataDate?.toLocaleTimeString()}</caption> - - <thead> - <tr> - <th>Línea</th> - <th>Ruta</th> - <th>Minutos</th> - <th>Metros</th> - </tr> - </thead> - - <tbody> - {data.estimates - .sort((a, b) => a.minutes - b.minutes) - .map((estimate, idx) => ( - <tr key={idx}> - <td><LineIcon line={estimate.line} /></td> - <td>{estimate.route}</td> - <td> - {estimate.minutes > 15 - ? absoluteArrivalTime(estimate.minutes) - : `${estimate.minutes} min`} - </td> - <td> - {estimate.meters > -1 - ? formatDistance(estimate.meters) - : "No disponible" - } - </td> - </tr> - ))} - </tbody> - - {data?.estimates.length === 0 && ( - <tfoot> - <tr> - <td colSpan={4}>No hay estimaciones disponibles</td> - </tr> - </tfoot> - )} - </table> + {tableStyle === 'grouped' ? + <GroupedTable data={data} dataDate={dataDate} /> : + <RegularTable data={data} dataDate={dataDate} />} </div> </div> ) 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 |
