diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/App.tsx | 34 | ||||
| -rw-r--r-- | src/Layout.css | 44 | ||||
| -rw-r--r-- | src/Layout.tsx | 58 | ||||
| -rw-r--r-- | src/assets/react.svg | 1 | ||||
| -rw-r--r-- | src/components/LineIcon.css | 191 | ||||
| -rw-r--r-- | src/components/LineIcon.tsx | 17 | ||||
| -rw-r--r-- | src/components/StopItem.tsx | 23 | ||||
| -rw-r--r-- | src/data/StopDataProvider.ts | 5 | ||||
| -rw-r--r-- | src/main.tsx | 42 | ||||
| -rw-r--r-- | src/pages/Estimates.tsx | 137 | ||||
| -rw-r--r-- | src/pages/Home.tsx | 99 | ||||
| -rw-r--r-- | src/pages/Map.tsx | 76 | ||||
| -rw-r--r-- | src/pages/Stop.tsx | 131 | ||||
| -rw-r--r-- | src/pages/StopList.tsx | 102 | ||||
| -rw-r--r-- | src/styles/Estimates.css | 28 | ||||
| -rw-r--r-- | src/styles/Pages.css | 245 |
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 |
