aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorAriel Costas Guerrero <94913521+arielcostas@users.noreply.github.com>2025-03-04 00:51:42 +0100
committerAriel Costas Guerrero <94913521+arielcostas@users.noreply.github.com>2025-03-04 00:51:42 +0100
commit79f3c42b0c04c7fd77481c14e6e9c345677b8c42 (patch)
tree6022fdb9d4cad1a77bc98eea88ad29803290f67d /src
parent0a96e26ade5d3eafe64807fcbd877742d6bcf6da (diff)
Add table layout like iTranvias, remake settings page
Diffstat (limited to 'src')
-rw-r--r--src/AppContext.tsx70
-rw-r--r--src/ThemeContext.tsx44
-rw-r--r--src/components/GroupedTable.tsx84
-rw-r--r--src/components/RegularTable.tsx70
-rw-r--r--src/main.tsx6
-rw-r--r--src/pages/About.tsx45
-rw-r--r--src/pages/Estimates.tsx68
-rw-r--r--src/styles/About.css94
-rw-r--r--src/styles/Estimates.css63
-rw-r--r--src/styles/Map.css86
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