aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/App.tsx34
-rw-r--r--src/Layout.css44
-rw-r--r--src/Layout.tsx58
-rw-r--r--src/assets/react.svg1
-rw-r--r--src/components/LineIcon.css191
-rw-r--r--src/components/LineIcon.tsx17
-rw-r--r--src/components/StopItem.tsx23
-rw-r--r--src/data/StopDataProvider.ts5
-rw-r--r--src/main.tsx42
-rw-r--r--src/pages/Estimates.tsx137
-rw-r--r--src/pages/Home.tsx99
-rw-r--r--src/pages/Map.tsx76
-rw-r--r--src/pages/Stop.tsx131
-rw-r--r--src/pages/StopList.tsx102
-rw-r--r--src/styles/Estimates.css28
-rw-r--r--src/styles/Pages.css245
16 files changed, 956 insertions, 277 deletions
diff --git a/src/App.tsx b/src/App.tsx
deleted file mode 100644
index dde37c8..0000000
--- a/src/App.tsx
+++ /dev/null
@@ -1,34 +0,0 @@
-import { useState } from 'react'
-import reactLogo from './assets/react.svg'
-import viteLogo from '/vite.svg'
-
-function App() {
- const [count, setCount] = useState(0)
-
- return (
- <>
- <div>
- <a href="https://vitejs.dev" target="_blank">
- <img src={viteLogo} className="logo" alt="Vite logo" />
- </a>
- <a href="https://react.dev" target="_blank">
- <img src={reactLogo} className="logo react" alt="React logo" />
- </a>
- </div>
- <h1>Vite + React</h1>
- <div className="card">
- <button onClick={() => setCount((count) => count + 1)}>
- count is {count}
- </button>
- <p>
- Edit <code>src/App.tsx</code> and save to test HMR
- </p>
- </div>
- <p className="read-the-docs">
- Click on the Vite and React logos to learn more
- </p>
- </>
- )
-}
-
-export default App
diff --git a/src/Layout.css b/src/Layout.css
new file mode 100644
index 0000000..52b0262
--- /dev/null
+++ b/src/Layout.css
@@ -0,0 +1,44 @@
+.app-container {
+ display: flex;
+ flex-direction: column;
+ height: 100vh;
+ width: 100%;
+ overflow: hidden;
+}
+
+.main-content {
+ flex: 1;
+ overflow: auto;
+ padding: 16px;
+ padding-bottom: 70px; /* Extra padding to ensure content isn't hidden behind navbar */
+}
+
+.nav-bar {
+ position: fixed;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ background-color: #ffffff;
+ display: flex;
+ justify-content: space-around;
+ align-items: center;
+ height: 60px;
+ border-top: 1px solid #e0e0e0;
+ box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.1);
+}
+
+.nav-item {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 8px;
+ color: #616161;
+ text-decoration: none;
+ width: 33.3%;
+ font-size: 12px;
+}
+
+.nav-item.active {
+ color: #007bff;
+} \ No newline at end of file
diff --git a/src/Layout.tsx b/src/Layout.tsx
new file mode 100644
index 0000000..5aaafbf
--- /dev/null
+++ b/src/Layout.tsx
@@ -0,0 +1,58 @@
+import { ReactNode } from 'react';
+import { Link, useLocation } from 'react-router';
+import { MapPin, Map, Info } from 'lucide-react';
+import './Layout.css';
+
+interface LayoutProps {
+ children: ReactNode;
+}
+
+export function Layout({ children }: LayoutProps) {
+ const location = useLocation();
+
+ const navItems = [
+ {
+ name: 'Stops',
+ icon: MapPin,
+ path: '/stops'
+ },
+ {
+ name: 'Maps',
+ icon: Map,
+ path: '/map'
+ },
+ {
+ name: 'About',
+ icon: Info,
+ path: '/about'
+ }
+ ];
+
+ return (
+ <div className="app-container">
+ {/* Main content area */}
+ <main className="main-content">
+ {children}
+ </main>
+
+ {/* Android style bottom navigation bar */}
+ <nav className="nav-bar">
+ {navItems.map(item => {
+ const Icon = item.icon;
+ const isActive = location.pathname.startsWith(item.path);
+
+ return (
+ <Link
+ key={item.name}
+ to={item.path}
+ className={`nav-item ${isActive ? 'active' : ''}`}
+ >
+ <Icon size={24} />
+ <span>{item.name}</span>
+ </Link>
+ );
+ })}
+ </nav>
+ </div>
+ );
+} \ No newline at end of file
diff --git a/src/assets/react.svg b/src/assets/react.svg
deleted file mode 100644
index 6c87de9..0000000
--- a/src/assets/react.svg
+++ /dev/null
@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg> \ No newline at end of file
diff --git a/src/components/LineIcon.css b/src/components/LineIcon.css
new file mode 100644
index 0000000..8c9f7ee
--- /dev/null
+++ b/src/components/LineIcon.css
@@ -0,0 +1,191 @@
+.line-icon {
+ display: inline-block;
+ padding: 0.25rem 0.5rem;
+ margin-right: 0.5rem;
+ border-bottom: 3px solid;
+ font-size: 0.9rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ color: inherit;
+ /* Prevent color change on hover */
+}
+
+.line-c1 {
+ border-color: rgb(237, 71, 19);
+}
+
+.line-c3d {
+ border-color: rgb(255, 204, 0);
+}
+
+.line-c3i {
+ border-color: rgb(255, 204, 0);
+}
+
+.line-l4a {
+ border-color: rgb(0, 153, 0);
+}
+
+.line-l4c {
+ border-color: rgb(0, 153, 0);
+}
+
+.line-l5a {
+ border-color: rgb(0, 176, 240);
+}
+
+.line-l5b {
+ border-color: rgb(0, 176, 240);
+}
+
+.line-l6 {
+ border-color: rgb(204, 51, 153);
+}
+
+.line-l7 {
+ border-color: rgb(150, 220, 153);
+}
+
+.line-l9b {
+ border-color: rgb(244, 202, 140);
+}
+
+.line-l10 {
+ border-color: rgb(153, 51, 0);
+}
+
+.line-l11 {
+ border-color: rgb(226, 0, 38);
+}
+
+.line-l12a {
+ border-color: rgb(106, 150, 190);
+}
+
+.line-l12b {
+ border-color: rgb(106, 150, 190);
+}
+
+.line-l13 {
+ border-color: rgb(0, 176, 240);
+}
+
+.line-l14 {
+ border-color: rgb(129, 142, 126);
+}
+
+.line-l15a {
+ border-color: rgb(216, 168, 206);
+}
+
+.line-l15b {
+ border-color: rgb(216, 168, 206);
+}
+
+.line-l15c {
+ border-color: rgb(216, 168, 168);
+}
+
+.line-l16 {
+ border-color: rgb(129, 142, 126);
+}
+
+.line-l17 {
+ border-color: rgb(214, 245, 31);
+}
+
+.line-l18a {
+ border-color: rgb(212, 80, 168);
+}
+
+.line-l18b {
+ border-color: rgb(0, 0, 0);
+}
+
+.line-l18h {
+ border-color: rgb(0, 0, 0);
+}
+
+.line-l23 {
+ border-color: rgb(0, 70, 210);
+}
+
+.line-l24 {
+ border-color: rgb(191, 191, 191);
+}
+
+.line-l25 {
+ border-color: rgb(172, 100, 4);
+}
+
+.line-l27 {
+ border-color: rgb(112, 74, 42);
+}
+
+.line-l28 {
+ border-color: rgb(176, 189, 254);
+}
+
+.line-l29 {
+ border-color: rgb(248, 184, 90);
+}
+
+.line-l31 {
+ border-color: rgb(255, 255, 0);
+}
+
+.line-a {
+ border-color: rgb(119, 41, 143);
+}
+
+.line-h {
+ border-color: rgb(0, 96, 168);
+}
+
+.line-h1 {
+ border-color: rgb(0, 96, 168);
+}
+
+.line-h2 {
+ border-color: rgb(0, 96, 168);
+}
+
+.line-h3 {
+ border-color: rgb(0, 96, 168);
+}
+
+.line-lzd {
+ border-color: rgb(61, 78, 167);
+}
+
+.line-n1 {
+ border-color: rgb(191, 191, 191);
+}
+
+.line-n4 {
+ border-color: rgb(102, 51, 102);
+}
+
+.line-psa1 {
+ border-color: rgb(0, 153, 0);
+}
+
+.line-psa4 {
+ border-color: rgb(0, 153, 0);
+}
+
+.line-ptl {
+ border-color: rgb(150, 220, 153);
+}
+
+.line-turistico {
+ border-color: rgb(102, 51, 102);
+}
+
+.line-u1 {
+ border-color: rgb(172, 100, 4);
+}
+
+.line-u2 {
+ border-color: rgb(172, 100, 4);
+} \ No newline at end of file
diff --git a/src/components/LineIcon.tsx b/src/components/LineIcon.tsx
new file mode 100644
index 0000000..50fd1ec
--- /dev/null
+++ b/src/components/LineIcon.tsx
@@ -0,0 +1,17 @@
+import React from 'react';
+import './LineIcon.css';
+
+interface LineIconProps {
+ line: string;
+}
+
+const LineIcon: React.FC<LineIconProps> = ({ line }) => {
+ const formattedLine = /^[a-zA-Z]/.test(line) ? line : `L${line}`;
+ return (
+ <span className={`line-icon line-${formattedLine.toLowerCase()}`}>
+ {formattedLine}
+ </span>
+ );
+};
+
+export default LineIcon; \ No newline at end of file
diff --git a/src/components/StopItem.tsx b/src/components/StopItem.tsx
new file mode 100644
index 0000000..6b48899
--- /dev/null
+++ b/src/components/StopItem.tsx
@@ -0,0 +1,23 @@
+import React from 'react';
+import { Link } from 'react-router';
+import { Stop } from '../data/StopDataProvider';
+import LineIcon from './LineIcon';
+
+interface StopItemProps {
+ stop: Stop;
+}
+
+const StopItem: React.FC<StopItemProps> = ({ stop }) => {
+ return (
+ <li className="list-item">
+ <Link className="list-item-link" to={`/estimates/${stop.stopId}`}>
+ ({stop.stopId}) {stop.name}
+ <div className="line-icons">
+ {stop.lines?.map(line => <LineIcon key={line} line={line} />)}
+ </div>
+ </Link>
+ </li>
+ );
+};
+
+export default StopItem; \ No newline at end of file
diff --git a/src/data/StopDataProvider.ts b/src/data/StopDataProvider.ts
index 73075c8..11674fd 100644
--- a/src/data/StopDataProvider.ts
+++ b/src/data/StopDataProvider.ts
@@ -1,5 +1,3 @@
-import stops from './stops.json';
-
export interface CachedStopList {
timestamp: number;
data: Stop[];
@@ -22,6 +20,9 @@ export class StopDataProvider {
favouriteStops = JSON.parse(rawFavouriteStops) as number[];
}
+ const response = await fetch('/api/GetStopList');
+ const stops = await response.json() as Stop[];
+
return stops.map((stop: Stop) => {
return {
...stop,
diff --git a/src/main.tsx b/src/main.tsx
index f638946..cf9a20c 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -1,22 +1,44 @@
-import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
-import { createBrowserRouter, RouterProvider } from 'react-router-dom'
-import { Home } from './pages/Home.tsx'
-import { Stop } from './pages/Stop.tsx'
+import { createBrowserRouter, Navigate, RouterProvider } from 'react-router'
+import { StopList } from './pages/StopList.tsx'
+import { Estimates } from './pages/Estimates.tsx'
+import { StopMap } from './pages/Map.tsx'
+import { Layout } from './Layout.tsx'
+import './styles/Pages.css'
const router = createBrowserRouter([
{
path: '/',
- element: <Home />,
+ element: <Layout><Navigate to="/stops" /></Layout>,
},
{
- path: '/:stopId',
- element: <Stop />
+ path: '/stops',
+ element: <Layout><StopList /></Layout>,
+ },
+ {
+ path: '/map',
+ element: <Layout><StopMap /></Layout>,
+ },
+ {
+ path: '/estimates/:stopId',
+ element: <Layout><Estimates /></Layout>
+ },
+ {
+ path: '/about',
+ element: <Layout><About /></Layout>
}
])
+function About() {
+ return (
+ <div className="page-container about-page">
+ <h1 className="page-title">About InfoBus App</h1>
+ <p className="about-description">This application helps you find bus stops and check bus arrival estimates.</p>
+ <p className="about-version">Version 1.0.0</p>
+ </div>
+ )
+}
+
createRoot(document.getElementById('root')!).render(
- <StrictMode>
- <RouterProvider router={router} />
- </StrictMode>,
+ <RouterProvider router={router} />,
)
diff --git a/src/pages/Estimates.tsx b/src/pages/Estimates.tsx
new file mode 100644
index 0000000..d3b4ced
--- /dev/null
+++ b/src/pages/Estimates.tsx
@@ -0,0 +1,137 @@
+import { useEffect, useState } from "react";
+import { Link, useParams } from "react-router";
+import { StopDataProvider } from "../data/StopDataProvider";
+import LineIcon from "../components/LineIcon";
+import { Star } from 'lucide-react';
+import "../styles/Estimates.css";
+
+interface StopDetails {
+ stop: {
+ id: number;
+ name: string;
+ latitude: number;
+ longitude: number;
+ }
+ estimates: {
+ line: string;
+ route: string;
+ minutes: number;
+ meters: number;
+ }[]
+}
+
+export function Estimates(): JSX.Element {
+ const sdp = new StopDataProvider();
+ const [data, setData] = useState<StopDetails | null>(null);
+ const [favourited, setFavourited] = useState(false);
+ const params = useParams();
+
+ const loadData = () => {
+ fetch(`/api/GetStopEstimates?id=${params.stopId}`)
+ .then(r => r.json())
+ .then((body: StopDetails) => setData(body));
+ };
+
+ useEffect(() => {
+ loadData();
+
+ sdp.pushRecent(parseInt(params.stopId ?? ""));
+
+ setFavourited(
+ sdp.isFavourite(parseInt(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) {
+ sdp.removeFavourite(parseInt(params.stopId ?? ""));
+ setFavourited(false);
+ } else {
+ sdp.addFavourite(parseInt(params.stopId ?? ""));
+ setFavourited(true);
+ }
+ }
+
+ if (data === null) return <h1 className="page-title">Cargando datos en tiempo real...</h1>
+
+ return (
+ <div className="page-container">
+ <div className="estimates-header">
+ <h1 className="page-title">
+ <Star className={`star-icon ${favourited ? 'active' : ''}`} onClick={toggleFavourite} />
+ {data?.stop.name} <span className="estimates-stop-id">({data?.stop.id})</span>
+ </h1>
+ </div>
+
+ <div className="button-group">
+ <Link to="/stops" className="button">
+ 🔙 Volver al listado de paradas
+ </Link>
+
+ <button className="button" onClick={loadData}>⬇️ Recargar</button>
+ </div>
+
+ <div className="table-responsive">
+ <table className="table">
+ <caption>Estimaciones de llegadas</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>
+ </div>
+ </div>
+ )
+}
diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx
deleted file mode 100644
index b7c1675..0000000
--- a/src/pages/Home.tsx
+++ /dev/null
@@ -1,99 +0,0 @@
-import { useEffect, useMemo, useState } from "react";
-import { Link, useNavigate } from "react-router-dom";
-import { Stop, StopDataProvider } from "../data/StopDataProvider";
-
-const sdp = new StopDataProvider();
-
-export function Home() {
- const [data, setData] = useState<Stop[] | null>(null)
- const navigate = useNavigate();
-
- useEffect(() => {
- sdp.getStops().then((stops: Stop[]) => setData(stops))
- }, []);
-
- const handleStopSearch = async (event: React.FormEvent) => {
- event.preventDefault()
-
- const stopId = (event.target as HTMLFormElement).stopId.value
- const searchNumber = parseInt(stopId)
- if (data?.find(stop => stop.stopId === searchNumber)) {
- navigate(`/${searchNumber}`)
- } else {
- alert("Parada no encontrada")
- }
- }
-
- const favouritedStops = useMemo(() => {
- return data?.filter(stop => stop.favourite) ?? []
- }, [data])
-
- const recentStops = useMemo(() => {
- const recent = sdp.getRecent();
-
- if (recent.length === 0) return null;
-
- return recent.map(stopId => data?.find(stop => stop.stopId === stopId) as Stop).reverse();
- }, [data])
-
- if (data === null) return <h1>Loading...</h1>
-
- return (
- <>
- <h1>UrbanoVigo Web</h1>
-
- <form action="none" onSubmit={handleStopSearch}>
- <div>
- <label htmlFor="stopId">
- ID
- </label>
- <input type="number" placeholder="ID de parada" id="stopId" />
- </div>
-
- <button type="submit">Buscar</button>
- </form>
-
- <h2>Paradas favoritas</h2>
-
- {favouritedStops?.length == 1 && (
- <p>
- Accede a una parada y márcala como favorita para verla aquí.
- </p>
- )}
-
- <ul>
- {favouritedStops?.sort((a, b) => a.stopId - b.stopId).map((stop: Stop) => (
- <li key={stop.stopId}>
- <Link to={`/${stop.stopId}`}>
- ({stop.stopId}) {stop.name} - {stop.lines?.join(', ')}
- </Link>
- </li>
- ))}
- </ul>
-
- <h2>Recientes</h2>
-
- <ul>
- {recentStops?.map((stop: Stop) => (
- <li key={stop.stopId}>
- <Link to={`/${stop.stopId}`}>
- ({stop.stopId}) {stop.name} - {stop.lines?.join(', ')}
- </Link>
- </li>
- ))}
- </ul>
-
- <h2>Paradas</h2>
-
- <ul>
- {data?.sort((a, b) => a.stopId - b.stopId).map((stop: Stop) => (
- <li key={stop.stopId}>
- <Link to={`/${stop.stopId}`}>
- ({stop.stopId}) {stop.name} - {stop.lines?.join(', ')}
- </Link>
- </li>
- ))}
- </ul>
- </>
- )
-}
diff --git a/src/pages/Map.tsx b/src/pages/Map.tsx
new file mode 100644
index 0000000..dbf5b9f
--- /dev/null
+++ b/src/pages/Map.tsx
@@ -0,0 +1,76 @@
+import { useEffect, useMemo, useState } from "react";
+import { Link, useNavigate } from "react-router";
+import { Stop, StopDataProvider } from "../data/StopDataProvider";
+import LineIcon from "../components/LineIcon";
+
+const sdp = new StopDataProvider();
+
+export function StopMap() {
+ const [data, setData] = useState<Stop[] | null>(null)
+ const navigate = useNavigate();
+
+ useEffect(() => {
+ sdp.getStops().then((stops: Stop[]) => setData(stops))
+ }, []);
+
+ const handleStopSearch = async (event: React.FormEvent) => {
+ event.preventDefault()
+
+ const stopId = (event.target as HTMLFormElement).stopId.value
+ const searchNumber = parseInt(stopId)
+ if (data?.find(stop => stop.stopId === searchNumber)) {
+ navigate(`/estimates/${searchNumber}`)
+ } else {
+ alert("Parada no encontrada")
+ }
+ }
+
+ if (data === null) return <h1 className="page-title">Loading...</h1>
+
+ return (
+ <div className="page-container">
+ <h1 className="page-title">Map View</h1>
+
+ <div className="map-container">
+ {/* Map placeholder - in a real implementation, this would be a map component */}
+ <div style={{
+ height: '100%',
+ backgroundColor: '#f0f0f0',
+ display: 'flex',
+ justifyContent: 'center',
+ alignItems: 'center',
+ borderRadius: '8px'
+ }}>
+ <p>Map will be displayed here</p>
+ </div>
+ </div>
+
+ <form className="search-form" onSubmit={handleStopSearch}>
+ <div className="form-group">
+ <label className="form-label" htmlFor="stopId">
+ Find Stop by ID
+ </label>
+ <input className="form-input" type="number" placeholder="Stop ID" id="stopId" />
+ </div>
+
+ <button className="form-button" type="submit">Search</button>
+ </form>
+
+ <div className="list-container">
+ <h2 className="page-subtitle">Nearby Stops</h2>
+ <ul className="list">
+ {data?.slice(0, 5).map((stop: Stop) => (
+ <li className="list-item" key={stop.stopId}>
+ <Link className="list-item-link" to={`/estimates/${stop.stopId}`}>
+ ({stop.stopId}) {stop.name}
+ <div className="line-icons">
+ {stop.lines?.map(line => <LineIcon key={line} line={line} />)}
+ </div>
+ </Link>
+ </li>
+ ))}
+ </ul>
+ </div>
+ </div>
+ )
+}
diff --git a/src/pages/Stop.tsx b/src/pages/Stop.tsx
deleted file mode 100644
index aa6651c..0000000
--- a/src/pages/Stop.tsx
+++ /dev/null
@@ -1,131 +0,0 @@
-import { useEffect, useState } from "react";
-import { Link, useParams } from "react-router-dom";
-import { StopDataProvider } from "../data/StopDataProvider";
-
-interface StopDetails {
- stop: {
- id: number;
- name: string;
- latitude: number;
- longitude: number;
- }
- estimates: {
- line: string;
- route: string;
- minutes: number;
- meters: number;
- }[]
-}
-
-export function Stop(): JSX.Element {
- const sdp = new StopDataProvider();
- const [data, setData] = useState<StopDetails | null>(null);
- const [favourited, setFavourited] = useState(false);
- const params = useParams();
-
- const loadData = () => {
- fetch(`/api/GetStopEstimates?id=${params.stopId}`)
- .then(r => r.json())
- .then((body: StopDetails) => setData(body));
- };
-
- useEffect(() => {
- loadData();
-
- sdp.pushRecent(parseInt(params.stopId ?? ""));
-
- setFavourited(
- sdp.isFavourite(parseInt(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)
- }
-
- if (data === null) return <h1>Cargando datos en tiempo real...</h1>
-
- return (
- <>
- <div>
- <h1>{data?.stop.name} ({data?.stop.id})</h1>
- </div>
-
- <div style={{display: 'flex', gap: '1rem'}}>
- <Link to="/" className="button">
- 🔙 Volver al listado de paradas
- </Link>
-
- {!favourited && (
- <button type="button" onClick={() => {
- sdp.addFavourite(parseInt(params.stopId ?? ""));
- setFavourited(true);
- }}>
- ⭐ Añadir a favoritos
- </button>
- )}
-
- {favourited && (
- <button type="button" onClick={() => {
- sdp.removeFavourite(parseInt(params.stopId ?? ""));
- setFavourited(false);
- }}>
- ⭐Quitar de favoritos
- </button>
- )}
-
- <button onClick={loadData}>⬇️ Recargar</button>
- </div>
-
- <table>
- <caption>Estimaciones de llegadas</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>{estimate.line}</td>
- <td>{estimate.route}</td>
- <td>
- {estimate.minutes} ({absoluteArrivalTime(estimate.minutes)})
- </td>
- <td>
- {estimate.meters > -1
- ? `${estimate.meters} metros`
- : "No disponible"
- }
- </td>
- </tr>
- ))}
- </tbody>
-
- {data?.estimates.length === 0 && (
- <tfoot>
- <tr>
- <td colSpan={4}>No hay estimaciones disponibles</td>
- </tr>
- </tfoot>
- )}
- </table>
-
- <p>
- <Link to="/">Volver al inicio</Link>
- </p>
- </>
- )
-}
diff --git a/src/pages/StopList.tsx b/src/pages/StopList.tsx
new file mode 100644
index 0000000..2351b51
--- /dev/null
+++ b/src/pages/StopList.tsx
@@ -0,0 +1,102 @@
+import { useEffect, useMemo, useState } from "react";
+import { Stop, StopDataProvider } from "../data/StopDataProvider";
+import StopItem from "../components/StopItem";
+import Fuse from "fuse.js";
+
+const sdp = new StopDataProvider();
+
+export function StopList() {
+ const [data, setData] = useState<Stop[] | null>(null)
+ const [searchResults, setSearchResults] = useState<Stop[] | null>(null);
+
+ useEffect(() => {
+ sdp.getStops().then((stops: Stop[]) => setData(stops))
+ }, []);
+
+ const handleStopSearch = (event: React.ChangeEvent<HTMLInputElement>) => {
+ const stopName = event.target.value;
+ if (data) {
+ const fuse = new Fuse(data, { keys: ['name'], threshold: 0.3 });
+ const results = fuse.search(stopName).map(result => result.item);
+ setSearchResults(results);
+ }
+ }
+
+ const favouritedStops = useMemo(() => {
+ return data?.filter(stop => stop.favourite) ?? []
+ }, [data])
+
+ const recentStops = useMemo(() => {
+ const recent = sdp.getRecent();
+
+ if (recent.length === 0) return null;
+
+ return recent.map(stopId => data?.find(stop => stop.stopId === stopId) as Stop).reverse();
+ }, [data])
+
+ if (data === null) return <h1 className="page-title">Loading...</h1>
+
+ return (
+ <div className="page-container">
+ <h1 className="page-title">UrbanoVigo Web</h1>
+
+ <form className="search-form">
+ <div className="form-group">
+ <label className="form-label" htmlFor="stopName">
+ Nombre de la parada
+ </label>
+ <input className="form-input" type="text" placeholder="Nombre de la parada" id="stopName" onChange={handleStopSearch} />
+ </div>
+ </form>
+
+ {searchResults && searchResults.length > 0 && (
+ <div className="list-container">
+ <h2 className="page-subtitle">Resultados de la búsqueda</h2>
+ <ul className="list">
+ {searchResults.map((stop: Stop) => (
+ <StopItem key={stop.stopId} stop={stop} />
+ ))}
+ </ul>
+ </div>
+ )}
+
+ <div className="list-container">
+ <h2 className="page-subtitle">Paradas favoritas</h2>
+
+ {favouritedStops?.length === 0 && (
+ <p className="message">
+ Accede a una parada y márcala como favorita para verla aquí.
+ </p>
+ )}
+
+ <ul className="list">
+ {favouritedStops?.sort((a, b) => a.stopId - b.stopId).map((stop: Stop) => (
+ <StopItem key={stop.stopId} stop={stop} />
+ ))}
+ </ul>
+ </div>
+
+ {recentStops && recentStops.length > 0 && (
+ <div className="list-container">
+ <h2 className="page-subtitle">Recientes</h2>
+
+ <ul className="list">
+ {recentStops.map((stop: Stop) => (
+ <StopItem key={stop.stopId} stop={stop} />
+ ))}
+ </ul>
+ </div>
+ )}
+
+ <div className="list-container">
+ <h2 className="page-subtitle">Paradas</h2>
+
+ <ul className="list">
+ {data?.sort((a, b) => a.stopId - b.stopId).map((stop: Stop) => (
+ <StopItem key={stop.stopId} stop={stop} />
+ ))}
+ </ul>
+ </div>
+ </div>
+ )
+}
diff --git a/src/styles/Estimates.css b/src/styles/Estimates.css
new file mode 100644
index 0000000..d9fa0ab
--- /dev/null
+++ b/src/styles/Estimates.css
@@ -0,0 +1,28 @@
+.table-responsive {
+ overflow-x: auto;
+ margin-bottom: 1.5rem;
+}
+
+.table {
+ width: 100%;
+ border-collapse: collapse;
+}
+
+.table caption {
+ margin-bottom: 0.5rem;
+ font-weight: 500;
+}
+
+.table th, .table td {
+ padding: 0.75rem;
+ text-align: left;
+ border-bottom: 1px solid #eee;
+}
+
+.table th {
+ border-bottom: 2px solid #ddd;
+}
+
+.table tfoot td {
+ text-align: center;
+} \ No newline at end of file
diff --git a/src/styles/Pages.css b/src/styles/Pages.css
new file mode 100644
index 0000000..ecd7122
--- /dev/null
+++ b/src/styles/Pages.css
@@ -0,0 +1,245 @@
+/* Mobile-first page styles */
+
+/* Common page styles */
+.page-container {
+ max-width: 100%;
+ padding: 0 16px;
+}
+
+.page-title {
+ font-size: 1.8rem;
+ margin-bottom: 1rem;
+ font-weight: 600;
+ color: #333;
+}
+
+.page-subtitle {
+ font-size: 1.4rem;
+ margin-top: 1.5rem;
+ margin-bottom: 0.75rem;
+ font-weight: 500;
+ color: #444;
+}
+
+/* Form styles */
+.search-form {
+ margin-bottom: 1.5rem;
+}
+
+.form-group {
+ margin-bottom: 1rem;
+ display: flex;
+ flex-direction: column;
+}
+
+.form-label {
+ font-size: 0.9rem;
+ margin-bottom: 0.25rem;
+ font-weight: 500;
+}
+
+.form-input {
+ padding: 0.75rem;
+ font-size: 1rem;
+ border: 1px solid #ddd;
+ border-radius: 8px;
+}
+
+.form-button {
+ padding: 0.75rem 1rem;
+ background-color: #007bff;
+ color: white;
+ border: none;
+ border-radius: 8px;
+ font-size: 1rem;
+ font-weight: 500;
+ cursor: pointer;
+ width: 100%;
+ margin-top: 0.5rem;
+}
+
+.form-button:hover {
+ background-color: #0069d9;
+}
+
+/* List styles */
+.list-container {
+ margin-bottom: 1.5rem;
+}
+
+.list {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+}
+
+.list-item {
+ padding: 1rem;
+ border-bottom: 1px solid #eee;
+}
+
+.list-item:last-child {
+ border-bottom: none;
+}
+
+.list-item-link {
+ display: block;
+ color: #333;
+ text-decoration: none;
+ font-size: 1.1rem; /* Increased font size for stop name */
+}
+
+.list-item-link:hover {
+ color: #007bff;
+}
+
+.list-item-link:hover .line-icon {
+ color: #333;
+ }
+
+/* Message styles */
+.message {
+ padding: 1rem;
+ background-color: #f8f9fa;
+ border-radius: 8px;
+ margin-bottom: 1rem;
+}
+
+/* About page specific styles */
+.about-page {
+ text-align: center;
+ padding: 1rem;
+}
+
+.about-version {
+ color: #666;
+ font-size: 0.9rem;
+ margin-top: 2rem;
+}
+
+.about-description {
+ margin-top: 1rem;
+ line-height: 1.6;
+}
+
+/* Map page specific styles */
+.map-container {
+ height: calc(100vh - 140px);
+ margin: -16px;
+ margin-bottom: 1rem;
+}
+
+/* Estimates page specific styles */
+.estimates-header {
+ display: flex;
+ align-items: center;
+ margin-bottom: 1rem;
+}
+
+.estimates-stop-id {
+ font-size: 1rem;
+ color: #666;
+ 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: #007bff;
+ 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: #0069d9;
+}
+
+.button:disabled {
+ background-color: #cccccc;
+ cursor: not-allowed;
+}
+
+.star-icon {
+ margin-right: 0.5rem;
+ color: #ccc;
+ fill: none;
+}
+
+.star-icon.active {
+ color: #ffcc00; /* Yellow color for active star */
+ fill: #ffcc00;
+}
+
+/* Tablet and larger breakpoint */
+@media (min-width: 768px) {
+ .page-container {
+ width: 90%;
+ max-width: 768px;
+ margin: 0 auto;
+ }
+
+ .page-title {
+ font-size: 2.2rem;
+ }
+
+ .search-form {
+ display: flex;
+ align-items: flex-end;
+ gap: 1rem;
+ }
+
+ .form-group {
+ flex: 1;
+ margin-bottom: 0;
+ }
+
+ .form-button {
+ width: auto;
+ margin-top: 0;
+ }
+
+ .list {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
+ gap: 1rem;
+ }
+
+ .list-item {
+ border: 1px solid #eee;
+ border-radius: 8px;
+ margin-bottom: 0;
+ }
+}
+
+/* Desktop breakpoint */
+@media (min-width: 1024px) {
+ .page-container {
+ max-width: 1024px;
+ }
+
+ .list {
+ grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
+ }
+} \ No newline at end of file