diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..0b8d220e --- /dev/null +++ b/.gitignore @@ -0,0 +1,64 @@ +.env +.env.local +.env.*.local + +node_modules/ +package-lock.json + +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +.DS_Store +.AppleDouble +.LSOverride +._* + +Thumbs.db +ehthumbs.db +Desktop.ini +$RECYCLE.BIN/ + +*~ +.fuse_hidden* + +.vscode/ + +.vscode/.history + +.idea/ +*.swp +*.swo +*~ +.project +.classpath +.settings/ + +*.log +logs/ + +tmp/ +temp/ +.cache/ + +coverage/ +.nyc_output/ +*.lcov + +*.pid +*.seed +*.pid.lock + +dist/ +build/ +out/ + +*.key +*.pem +*.crt +*.p12 +*.pfx + +*password* +*secret* +*token* \ No newline at end of file diff --git a/README.md b/README.md index 163d41b9..88fd4766 100644 --- a/README.md +++ b/README.md @@ -1,61 +1,35 @@ -# Безопасность веб-приложений. Лабораторка №2 +# Лабораторная работа №2 -## Схема сдачи +## О лабораторной работе -1. Получить задание -2. Сделать форк данного репозитория -3. Выполнить задание согласно полученному варианту -4. Сделать PR (pull request) в данный репозиторий -6. Исправить замечания после code review -7. Получить approve -8. Прийти на занятие и защитить работу +Это лабораторная работа №2 по дисциплине «Безопасность веб-приложений». По варианту 2 реализован веб-аналог «Прибывалки» для электричек: поиск станций, просмотр расписания по станции, выбор любимых станций, просмотр маршрутов проходящих между станций и через станции и адаптация интерфейса под мобильные устройства. -Что нужно проявить в работе: -- умение разработать завершенное целое веб-приложение, с клиентской и серверной частями (допустимы открытые АПИ) -- навыки верстки на html в объеме 200-300 тегов -- навыки применения css для лейаута и стилизации, желательно с адаптацией к мобилке -- использование jQuery или аналогичных JS-фреймворков -- динамическая подгрузка контента -- динамическое изменение DOM и CSSOM - -Если у вас своя идея по заданию, то расскажите, обсудим и подкорректирую. - -## Вариант 1. Расписания - -Сделать аналог раздела https://ssau.ru/rasp?groupId=531030143 - -Какие нужны возможности: -- справочники групп, табличные данные по расписаниям добывать с настоящего сайта на серверной стороне приложения -- в клиентскую часть подгружать эти сведения динамически по JSON-API -- обеспечить возможность смотреть расписания в разрезе группы или препода -- обеспечить возможность выбора учебной недели (по умолчанию выбирается автоматически) - -## Вариант 2. Аналог Прибывалки для электричек - -Сделать веб-версию Прибывалки, только для электричек - -Какие нужны возможности: -- находить желаемую ЖД-станцию поиском по названию и по карте -- отображать расписания всех проходящих поездов через выбранную станцию -- отображать расписания для поездов между двумя станциями -- работа через АПИ Яндекс.Расписаний https://yandex.ru/dev/rasp/doc/ru/ (доступ получите сами) -- хорошая работа в условиях экрана смартфона -- бонус: функция "любимых остановок" - -## Вариант 3. Прогноз погоды - -Сделать одностраничный сайт с картой, на которой можно выбрать населенный пункт и получить прогноз погоды на несколько дней по нему. - -Какие нужны возможности: - - увидеть на карте точки с населенными пунктами. Координаты населенных пунктов взять из https://tochno.st/datasets/allsettlements - но все 150 тысяч не нужно, выберите 1 тысячу с самым большим населением. - - при нажатии на точку получить всплывающее окошко с графиками изменения температуры, осадков, силы ветра. API для прогнозов возьмите с https://projecteol.ru/ru/ с соблюдением правил. - - графики рисовать каким-нибудь приличным компонентом, например, https://www.chartjs.org/ - - находить населенный пункт по названию - - можете реализовать с собственным серверным компонентом или придумать, как обойтись без него +## Что реализовано +- Поиск железнодорожных станций по названию и выбор станций на карте. +- Просмотр расписания всех проходящих через выбранную станцию поездов. +- Поиск маршрутов между станций и через станции. +- Избранные станции с сохранением в localStorage. +- Адаптивный интерфейс для смартфонов и десктопа. +## Технологии +- Frontend: React, Vite, React Router, React Leaflet, CSS. +- Backend: Node.js, Express, Axios, CORS, dotenv. +- Внешний источник данных: API Яндекс.Расписаний. +- Пакетный менеджер: npm. +## Как запустить проект +1. Установите зависимости для backend: `npm install` в папке backend. +2. Создайте файл `backend/.env` и добавьте ключ: `YANDEX_RASP_API_KEY=ваш_ключ`. При необходимости добавьте `PORT=5000`. +3. Запустите backend: `npm run dev`. +4. Установите зависимости для frontend: `npm install` в папке frontend. +5. Запустите frontend: `npm run dev`. +6. Откройте адрес из вывода Vite в браузере (обычно `http://localhost:5173`). +## Структура приложения +- Вкладка «По станции»: загрузка расписания для выбранной станции. +- Вкладка «Маршрут»: электрички между станциями отправления и прибытия/целиком проходящие через этот маршрут. +- Backend API: `/api/search-station`, `/api/station-schedule`, `/api/train-route`. \ No newline at end of file diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 00000000..fe8573de --- /dev/null +++ b/backend/package.json @@ -0,0 +1,19 @@ +{ + "name": "train-arrival-backend", + "version": "1.0.0", + "description": "Backend for train schedule app", + "main": "server.js", + "scripts": { + "start": "node server.js", + "dev": "nodemon server.js" + }, + "dependencies": { + "axios": "^1.6.0", + "cors": "^2.8.5", + "dotenv": "^16.4.0", + "express": "^4.18.2" + }, + "devDependencies": { + "nodemon": "^3.1.14" + } +} diff --git a/backend/routes/routes.js b/backend/routes/routes.js new file mode 100644 index 00000000..7ac0ab83 --- /dev/null +++ b/backend/routes/routes.js @@ -0,0 +1,16 @@ +const express = require('express'); +const router = express.Router(); +const { getRoutes } = require('../services/yandexApi'); + +router.get('/', async (req, res) => { + try { + const from = req.query.from; + const to = req.query.to; + const routes = await getRoutes(from, to); + res.json(routes); + } catch (error) { + res.status(500).json({ error: 'Ошибка получения маршрута' }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/backend/routes/schedule.js b/backend/routes/schedule.js new file mode 100644 index 00000000..19941004 --- /dev/null +++ b/backend/routes/schedule.js @@ -0,0 +1,21 @@ +const express = require('express'); +const router = express.Router(); +const { getSchedule } = require('../services/yandexApi'); +const { formatSchedule } = require('../utils/formatters'); + +router.get('/', async (req, res) => { + try { + const stationCode = req.query.station; + if (!stationCode) { + return res.status(400).json({ error: 'Параметр station обязателен' }); + } + + const schedule = await getSchedule(stationCode); + const formatted = formatSchedule(schedule); + res.json(formatted); + } catch (error) { + res.status(500).json({ error: error.message || 'Ошибка получения расписания' }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/backend/routes/stations.js b/backend/routes/stations.js new file mode 100644 index 00000000..8110c41a --- /dev/null +++ b/backend/routes/stations.js @@ -0,0 +1,36 @@ +const express = require('express'); +const router = express.Router(); +const { searchStations, getStationsInBounds } = require('../services/yandexApi'); + +router.get('/', async (req, res) => { + try { + const query = req.query.query || ''; + const zoom = Number(req.query.zoom); + const minLat = Number(req.query.minLat); + const minLng = Number(req.query.minLng); + const maxLat = Number(req.query.maxLat); + const maxLng = Number(req.query.maxLng); + + const hasBounds = + Number.isFinite(minLat) && + Number.isFinite(minLng) && + Number.isFinite(maxLat) && + Number.isFinite(maxLng); + + if (hasBounds) { + if (!Number.isFinite(zoom) || zoom < 9) { + return res.json([]); + } + const stations = await getStationsInBounds({ minLat, minLng, maxLat, maxLng }); + return res.json(stations); + } + + const stations = await searchStations(query); + return res.json(stations); + } catch (error) { + console.error('Ошибка поиска станции:', error.message); + res.status(500).json({ error: error.message }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/backend/server.js b/backend/server.js new file mode 100644 index 00000000..02aede98 --- /dev/null +++ b/backend/server.js @@ -0,0 +1,27 @@ +require('dotenv').config(); +const express = require('express'); +const cors = require('cors'); + + +const stationRoutes = require('./routes/stations'); +const scheduleRoutes = require('./routes/schedule'); +const routeRoutes = require('./routes/routes'); + +const app = express(); + +app.use(cors()); +app.use(express.json()); + +app.use('/api/search-station', stationRoutes); +app.use('/api/station-schedule', scheduleRoutes); +app.use('/api/train-route', routeRoutes); + +app.get('/', (req, res) => { + res.send('API Прибывалка.Электрички работает'); +}); + +const PORT = process.env.PORT || 5000; + +app.listen(PORT, () => { + console.log(`Server started on port ${PORT}`); +}); \ No newline at end of file diff --git a/backend/services/yandexApi.js b/backend/services/yandexApi.js new file mode 100644 index 00000000..ab362747 --- /dev/null +++ b/backend/services/yandexApi.js @@ -0,0 +1,152 @@ +const axios = require('axios'); + +const API_KEY = process.env.YANDEX_RASP_API_KEY; +const BASE_URL = 'https://api.rasp.yandex.net/v3.0/'; +const STATIONS_CACHE_TTL_MS = 10 * 60 * 1000; +let stationsCache = { + ts: 0, + data: [] +}; + +function normalizeText(value = '') { + return value.toLowerCase().trim().replaceAll('ё', 'е'); +} + +function getStationCoords(station) { + let lat = Number(station.latitude ?? station.lat ?? station.point?.lat); + let lng = Number(station.longitude ?? station.lng ?? station.lon ?? station.point?.lng); + + if (Number.isFinite(lat) && Number.isFinite(lng) && Math.abs(lat) > 90 && Math.abs(lng) <= 90) { + const swappedLat = lng; + const swappedLng = lat; + lat = swappedLat; + lng = swappedLng; + } + + return { + lat: Number.isFinite(lat) ? lat : null, + lng: Number.isFinite(lng) ? lng : null + }; +} + +function scoreStationMatch(title, query) { + const stationTitle = normalizeText(title); + const q = normalizeText(query); + + if (stationTitle === q) return 100; + if (stationTitle.startsWith(`${q} `) || stationTitle.startsWith(`${q}-`)) return 95; + if (stationTitle.startsWith(q)) return 90; + if (stationTitle.split(/[\s-]+/).some(word => word.startsWith(q))) return 75; + if (stationTitle.includes(q)) return 50; + return 0; +} + +async function getFlatStations() { + const cacheIsFresh = Date.now() - stationsCache.ts < STATIONS_CACHE_TTL_MS; + if (cacheIsFresh && stationsCache.data.length > 0) { + return stationsCache.data; + } + + const url = `${BASE_URL}stations_list/?apikey=${API_KEY}&format=json&lang=ru_RU&transport_types=train`; + const response = await axios.get(url); + const uniqueStations = new Map(); + + const addStation = ({ station, regionTitle, cityTitle }) => { + if (!station || station.transport_type !== 'train' || !station.title) return; + const code = station.codes?.yandex_code; + if (!code) return; + + const { lat, lng } = getStationCoords(station); + if (!Number.isFinite(lat) || !Number.isFinite(lng)) return; + + const locationParts = [cityTitle, regionTitle].filter(Boolean); + const location = locationParts.join(', '); + const displayTitle = location ? `${station.title} (${location})` : station.title; + + if (!uniqueStations.has(code)) { + uniqueStations.set(code, { + title: station.title, + displayTitle, + location, + code, + lat, + lng + }); + } + }; + + (response.data.countries || []).forEach(country => { + (country.regions || []).forEach(region => { + (region.stations || []).forEach(station => + addStation({ station, regionTitle: region.title, cityTitle: '' }) + ); + (region.settlements || []).forEach(city => { + (city.stations || []).forEach(station => + addStation({ station, regionTitle: region.title, cityTitle: city.title }) + ); + }); + }); + }); + + stationsCache = { + ts: Date.now(), + data: Array.from(uniqueStations.values()) + }; + + return stationsCache.data; +} + +async function searchStations(query) { + const stations = await getFlatStations(); + const normalizedQuery = normalizeText(query); + + if (!normalizedQuery) { + return stations; + } + + return stations + .map(station => ({ + ...station, + score: Math.max( + scoreStationMatch(station.title, normalizedQuery), + scoreStationMatch(station.displayTitle, normalizedQuery) + ) + })) + .filter(station => station.score > 0) + .sort((a, b) => b.score - a.score || a.displayTitle.localeCompare(b.displayTitle, 'ru')) + .slice(0, 40) + .map(({ score, ...station }) => station); +} + +async function getStationsInBounds({ minLat, minLng, maxLat, maxLng, limit = 600 }) { + const stations = await getFlatStations(); + + return stations + .filter( + station => + station.lat >= minLat && + station.lat <= maxLat && + station.lng >= minLng && + station.lng <= maxLng + ) + .slice(0, limit); +} + +async function getSchedule(stationCode) { + const url = `${BASE_URL}schedule/?apikey=${API_KEY}&station=${stationCode}&lang=ru_RU&transport_types=train`; + const response = await axios.get(url); + return response.data.schedule; +} + +async function getRoutes(from, to) { + const url = `${BASE_URL}search/?apikey=${API_KEY}&from=${from}&to=${to}&lang=ru_RU&transport_types=train`; + const response = await axios.get(url); + return response.data.segments; +} + +module.exports = { + searchStations, + getStationsInBounds, + getSchedule, + getRoutes +}; \ No newline at end of file diff --git a/backend/utils/formatters.js b/backend/utils/formatters.js new file mode 100644 index 00000000..f365f40c --- /dev/null +++ b/backend/utils/formatters.js @@ -0,0 +1,40 @@ +function formatSchedule(schedule) { + if (!Array.isArray(schedule)) { + return []; + } + + const seen = new Set(); + + return schedule + .map(item => { + return { + departure: item?.departure || '—', + arrival: item?.arrival || '—', + train: item?.thread?.title || '—', + direction: item?.thread?.short_title || item?.thread?.title || '—', + platform: item?.platform || '—', + status: item?.except_days || '' + }; + }) + .filter(item => { + const key = [ + item.departure, + item.arrival, + item.train, + item.direction, + item.platform, + item.status + ].join('|'); + + if (seen.has(key)) { + return false; + } + + seen.add(key); + return true; + }); +} + +module.exports = { + formatSchedule +}; diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 00000000..a547bf36 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 00000000..a36934d8 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,16 @@ +# React + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project. diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 00000000..4fa125da --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,29 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{js,jsx}'], + extends: [ + js.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + parserOptions: { + ecmaVersion: 'latest', + ecmaFeatures: { jsx: true }, + sourceType: 'module', + }, + }, + rules: { + 'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }], + }, + }, +]) diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 00000000..38162c9c --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + Прибывалка.Электрички + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 00000000..1e9216fc --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,30 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "leaflet": "^1.9.4", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "react-leaflet": "^5.0.0", + "react-router-dom": "^7.14.0" + }, + "devDependencies": { + "@eslint/js": "^9.39.4", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "eslint": "^9.39.4", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.4.0", + "vite": "^8.0.4" + } +} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 00000000..6893eb13 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/icons.svg b/frontend/public/icons.svg new file mode 100644 index 00000000..e9522193 --- /dev/null +++ b/frontend/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 00000000..1b56ecbe --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,23 @@ +import { Routes, Route } from 'react-router-dom'; +import Header from './layout/Header'; + +import StationPage from './pages/StationPage'; +import RoutePage from './pages/RoutePage'; +//import FavoritesPage from './pages/FavoritesPage'; +import AboutPage from './pages/AboutPage'; + +export default function App() { + return ( + <> +
+ + + } /> + } /> + } /> + {/*} />*/} + } /> + + + ); +} diff --git a/frontend/src/api/api.js b/frontend/src/api/api.js new file mode 100644 index 00000000..062b73ae --- /dev/null +++ b/frontend/src/api/api.js @@ -0,0 +1,41 @@ +const BASE = 'http://localhost:5000/api'; + +async function requestJson(url) { + const res = await fetch(url); + const payload = await res.json(); + + if (!res.ok) { + throw new Error(payload?.error || 'Ошибка запроса'); + } + + return payload; +} + +export async function searchStation(query) { + const data = await requestJson(`${BASE}/search-station?query=${encodeURIComponent(query)}`); + return Array.isArray(data) ? data : []; +} + +export async function getStationsInBounds(bounds) { + const params = new URLSearchParams({ + minLat: String(bounds.minLat), + minLng: String(bounds.minLng), + maxLat: String(bounds.maxLat), + maxLng: String(bounds.maxLng), + zoom: String(bounds.zoom) + }); + const data = await requestJson(`${BASE}/search-station?${params.toString()}`); + return Array.isArray(data) ? data : []; +} + +export async function getSchedule(code) { + const data = await requestJson(`${BASE}/station-schedule?station=${encodeURIComponent(code)}`); + return Array.isArray(data) ? data : []; +} + +export async function getRoutes(from, to) { + const data = await requestJson( + `${BASE}/train-route?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}` + ); + return Array.isArray(data) ? data : []; +} \ No newline at end of file diff --git a/frontend/src/components/Navbar.jsx b/frontend/src/components/Navbar.jsx new file mode 100644 index 00000000..7c1e84e5 --- /dev/null +++ b/frontend/src/components/Navbar.jsx @@ -0,0 +1,12 @@ +import { Link } from 'react-router-dom'; + +export default function Navbar() { + return ( + + ); +} \ No newline at end of file diff --git a/frontend/src/components/ScheduleCard.jsx b/frontend/src/components/ScheduleCard.jsx new file mode 100644 index 00000000..115d68f9 --- /dev/null +++ b/frontend/src/components/ScheduleCard.jsx @@ -0,0 +1,21 @@ +export default function ScheduleCard({ train }) { + return ( +
+
+ {train.direction} +
+ +
+ Отправление: {train.departure} +
+ +
+ Поезд: {train.train} +
+ +
+ Платформа: {train.platform} +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/ScheduleTable.jsx b/frontend/src/components/ScheduleTable.jsx new file mode 100644 index 00000000..4ccba54d --- /dev/null +++ b/frontend/src/components/ScheduleTable.jsx @@ -0,0 +1,22 @@ +export default function ScheduleTable({ data }) { + return ( + + + + + + + + + + {data.map((item, i) => ( + + + + + + ))} + +
ВремяПоездНаправление
{item.departure}{item.train}{item.direction}
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/StationAutocomplete.jsx b/frontend/src/components/StationAutocomplete.jsx new file mode 100644 index 00000000..76a58979 --- /dev/null +++ b/frontend/src/components/StationAutocomplete.jsx @@ -0,0 +1,126 @@ +import { useState, useRef, useEffect } from 'react'; +import { createPortal } from 'react-dom'; + +export default function StationAutocomplete({ + placeholder, + value, + suggestions, + loading, + onInputChange, + onSelect, +}) { + const [isOpen, setIsOpen] = useState(false); + const [dropdownStyle, setDropdownStyle] = useState({}); + const inputRef = useRef(null); + const wrapperRef = useRef(null); + + const updateDropdownPosition = () => { + if (inputRef.current) { + const rect = inputRef.current.getBoundingClientRect(); + setDropdownStyle({ + position: 'absolute', + top: rect.bottom + window.scrollY + 4, + left: rect.left + window.scrollX, + width: rect.width, + zIndex: 9999, + boxSizing: 'border-box', + }); + } + }; + + const openDropdown = () => { + updateDropdownPosition(); + setIsOpen(true); + }; + + const closeDropdown = () => setIsOpen(false); + + useEffect(() => { + if (!isOpen) return; + + const handleUpdate = () => updateDropdownPosition(); + window.addEventListener('scroll', handleUpdate, true); + window.addEventListener('resize', handleUpdate); + + const resizeObserver = new ResizeObserver(handleUpdate); + if (inputRef.current) resizeObserver.observe(inputRef.current); + + return () => { + window.removeEventListener('scroll', handleUpdate, true); + window.removeEventListener('resize', handleUpdate); + resizeObserver.disconnect(); + }; + }, [isOpen]); + + useEffect(() => { + const handleClickOutside = (e) => { + if ( + wrapperRef.current && + !wrapperRef.current.contains(e.target) && + !document.querySelector('.autocomplete-dropdown')?.contains(e.target) + ) { + closeDropdown(); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + const handleInputChange = (e) => { + const newValue = e.target.value; + onInputChange(newValue); + openDropdown(); + }; + + const handleSelect = (station) => { + onSelect(station); + closeDropdown(); + inputRef.current?.blur(); + }; + + let dropdownContent = null; + if (loading) { + dropdownContent =
Поиск…
; + } else if (suggestions.length === 0 && value.trim() !== '') { + dropdownContent =
Ничего не найдено
; + } else if (suggestions.length > 0) { + dropdownContent = suggestions.map((station, idx) => ( + + )); + } + + return ( +
+ { + if ((suggestions.length > 0 || loading) && !isOpen) { + openDropdown(); + } + }} + autoComplete="off" + /> + {isOpen && dropdownContent && createPortal( +
e.preventDefault()} + > + {dropdownContent} +
, + document.body + )} +
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/StationMap.jsx b/frontend/src/components/StationMap.jsx new file mode 100644 index 00000000..c5b9e413 --- /dev/null +++ b/frontend/src/components/StationMap.jsx @@ -0,0 +1,122 @@ +import { CircleMarker, MapContainer, Popup, TileLayer, useMapEvents } from 'react-leaflet'; +import { useEffect } from 'react'; +import 'leaflet/dist/leaflet.css'; + +const DEFAULT_CENTER = [53.1959, 50.1008]; +const DEFAULT_ZOOM = 10; + +function ViewportEvents({ onViewportChange }) { + const map = useMapEvents({ + moveend: () => { + const bounds = map.getBounds(); + onViewportChange?.({ + minLat: bounds.getSouth(), + minLng: bounds.getWest(), + maxLat: bounds.getNorth(), + maxLng: bounds.getEast(), + zoom: map.getZoom() + }); + }, + zoomend: () => { + const bounds = map.getBounds(); + onViewportChange?.({ + minLat: bounds.getSouth(), + minLng: bounds.getWest(), + maxLat: bounds.getNorth(), + maxLng: bounds.getEast(), + zoom: map.getZoom() + }); + } + }); + + useEffect(() => { + const bounds = map.getBounds(); + onViewportChange?.({ + minLat: bounds.getSouth(), + minLng: bounds.getWest(), + maxLat: bounds.getNorth(), + maxLng: bounds.getEast(), + zoom: map.getZoom() + }); + }, [map, onViewportChange]); + + return null; +} + +export default function StationMap({ + stations, + onPickStation, + selectedFromCode, + selectedToCode, + onViewportChange +}) { + const safeStations = Array.isArray(stations) ? stations : []; + const uniqueByCode = new Map(); + + const stationsWithCoords = safeStations + .filter(station => station && typeof station === 'object') + .map(station => { + const lat = Number(station.lat); + const lng = Number(station.lng); + return { + ...station, + lat, + lng + }; + }) + .filter( + station => + Number.isFinite(station.lat) && + Number.isFinite(station.lng) && + Math.abs(station.lat) <= 90 && + Math.abs(station.lng) <= 180 + ) + .filter(station => { + const key = station.code || `${station.title}-${station.lat}-${station.lng}`; + if (uniqueByCode.has(key)) return false; + uniqueByCode.set(key, true); + return true; + }); + + return ( +
+ + + + {stationsWithCoords.map(station => { + const isFrom = station.code === selectedFromCode; + const isTo = station.code === selectedToCode; + const color = isFrom ? '#2563eb' : isTo ? '#16a34a' : '#ef4444'; + const radius = isFrom || isTo ? 8 : 5; + const position = { lat: station.lat, lng: station.lng }; + + if (!Number.isFinite(position.lat) || !Number.isFinite(position.lng)) { + return null; + } + + return ( + onPickStation(station) + }} + > + {station.displayTitle || station.title} + + ); + })} + +
+ ); +} diff --git a/frontend/src/context/AppContext.jsx b/frontend/src/context/AppContext.jsx new file mode 100644 index 00000000..92bf8bc8 --- /dev/null +++ b/frontend/src/context/AppContext.jsx @@ -0,0 +1,13 @@ +import { useState } from 'react'; +import { AppContext } from './AppContextInstance'; +export { AppContext } from './AppContextInstance'; + +export function AppProvider({ children }) { + const [schedule, setSchedule] = useState([]); + + return ( + + {children} + + ); +} \ No newline at end of file diff --git a/frontend/src/context/AppContextInstance.js b/frontend/src/context/AppContextInstance.js new file mode 100644 index 00000000..e9220de9 --- /dev/null +++ b/frontend/src/context/AppContextInstance.js @@ -0,0 +1,3 @@ +import { createContext } from 'react'; + +export const AppContext = createContext(); diff --git a/frontend/src/layout/Container.jsx b/frontend/src/layout/Container.jsx new file mode 100644 index 00000000..a9f2181b --- /dev/null +++ b/frontend/src/layout/Container.jsx @@ -0,0 +1,3 @@ +export default function Container({ children }) { + return
{children}
; +} \ No newline at end of file diff --git a/frontend/src/layout/Header.jsx b/frontend/src/layout/Header.jsx new file mode 100644 index 00000000..275bae4c --- /dev/null +++ b/frontend/src/layout/Header.jsx @@ -0,0 +1,31 @@ +import { NavLink } from 'react-router-dom'; + +export default function Header() { + const navItems = [ + { to: '/', label: 'По станции' }, + { to: '/route', label: 'Маршрут' }, + { to: '/about', label: 'О сайте' } + ]; + + return ( +
+
+
Прибывалка.Электрички
+
Расписание пригородных поездов
+
+ + +
+ ); +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx new file mode 100644 index 00000000..096e9a8a --- /dev/null +++ b/frontend/src/main.jsx @@ -0,0 +1,18 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App.jsx' +import { BrowserRouter } from 'react-router-dom' +import { AppProvider } from './context/AppContext.jsx' + +import './styles/main.css' +import './styles/cards.css' +import './styles/mobile.css' +import './styles/styles.css' + +ReactDOM.createRoot(document.getElementById('root')).render( + + + + + +) \ No newline at end of file diff --git a/frontend/src/pages/AboutPage.jsx b/frontend/src/pages/AboutPage.jsx new file mode 100644 index 00000000..9c8b34e4 --- /dev/null +++ b/frontend/src/pages/AboutPage.jsx @@ -0,0 +1,61 @@ +import Container from '../layout/Container'; + +export default function AboutPage() { + return ( + +
+

О лабораторной работе

+

+ Это лабораторная работа №2 по дисциплине «Безопасность веб-приложений». По варианту 2 + реализован веб-аналог «Прибывалки» для электричек: поиск станций, просмотр расписания по + станции, выбор любимых станций, просмотр маршрутов проходящих между станций и через станции и адаптация интерфейса под мобильные устройства. +

+ +
+

Что реализовано

+
    +
  • Поиск железнодорожных станций по названию и выбор станций на карте.
  • +
  • Просмотр расписания всех проходящих через выбранную станцию поездов.
  • +
  • Поиск маршрутов между станций и через станции.
  • +
  • Избранные станции с сохранением в localStorage.
  • +
  • Адаптивный интерфейс для смартфонов и десктопа.
  • +
+
+ +
+

Технологии

+
    +
  • Frontend: React, Vite, React Router, React Leaflet, CSS.
  • +
  • Backend: Node.js, Express, Axios, CORS, dotenv.
  • +
  • Внешний источник данных: API Яндекс.Расписаний.
  • +
  • Пакетный менеджер: npm.
  • +
+
+ +
+

Как запустить проект

+
    +
  1. Установите зависимости для backend: npm install в папке backend.
  2. +
  3. + Создайте файл backend/.env и добавьте ключ: YANDEX_RASP_API_KEY=*ваш_ключ*. При + необходимости добавьте PORT=5000. +
  4. +
  5. Запустите backend: npm run dev.
  6. +
  7. Установите зависимости для frontend: npm install в папке frontend.
  8. +
  9. Запустите frontend: npm dev.
  10. +
  11. Откройте адрес из вывода Vite в браузере (обычно http://localhost:5173).
  12. +
+
+ +
+

Структура приложения

+
    +
  • Вкладка «По станции»: загрузка расписания для выбранной станции.
  • +
  • Вкладка «Маршрут»: электрички между станциями отправления и прибытия/целиком проходищие через этот маршрут.
  • +
  • Backend API: /api/search-station, /api/station-schedule, /api/train-route.
  • +
+
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/pages/RoutePage.jsx b/frontend/src/pages/RoutePage.jsx new file mode 100644 index 00000000..798ce48a --- /dev/null +++ b/frontend/src/pages/RoutePage.jsx @@ -0,0 +1,246 @@ +import { useEffect, useRef, useState } from 'react'; +import { getRoutes, searchStation, getStationsInBounds } from '../api/api'; +import Container from '../layout/Container'; +import ScheduleCard from '../components/ScheduleCard'; +import StationAutocomplete from '../components/StationAutocomplete'; +import StationMap from '../components/StationMap'; + +export default function RoutePage() { + const [fromQuery, setFromQuery] = useState(''); + const [toQuery, setToQuery] = useState(''); + const [fromStations, setFromStations] = useState([]); + const [toStations, setToStations] = useState([]); + const [fromCode, setFromCode] = useState(''); + const [toCode, setToCode] = useState(''); + const [routes, setRoutes] = useState([]); + const [fromLoading, setFromLoading] = useState(false); + const [toLoading, setToLoading] = useState(false); + const [routesLoading, setRoutesLoading] = useState(false); + const [message, setMessage] = useState(''); + const [mapTarget, setMapTarget] = useState('from'); + const [mapStations, setMapStations] = useState([]); + const fromRequestId = useRef(0); + const toRequestId = useRef(0); + const viewportRequestId = useRef(0); + + const searchFromStations = value => { + setFromQuery(value); + setFromCode(''); + setRoutes([]); + }; + + const searchToStations = value => { + setToQuery(value); + setToCode(''); + setRoutes([]); + }; + + useEffect(() => { + const value = fromQuery.trim(); + if (!value) { + setFromStations([]); + setFromLoading(false); + return undefined; + } + + const requestId = ++fromRequestId.current; + setFromLoading(true); + setMessage(''); + + const timerId = setTimeout(async () => { + try { + const stations = await searchStation(value); + if (requestId !== fromRequestId.current) return; + setFromStations(stations); + if (stations.length === 0) setMessage('Станция отправления не найдена.'); + } catch { + if (requestId !== fromRequestId.current) return; + setFromStations([]); + setMessage('Ошибка поиска станции отправления.'); + } finally { + if (requestId === fromRequestId.current) setFromLoading(false); + } + }, 350); + + return () => clearTimeout(timerId); + }, [fromQuery]); + + useEffect(() => { + const value = toQuery.trim(); + if (!value) { + setToStations([]); + setToLoading(false); + return undefined; + } + + const requestId = ++toRequestId.current; + setToLoading(true); + setMessage(''); + + const timerId = setTimeout(async () => { + try { + const stations = await searchStation(value); + if (requestId !== toRequestId.current) return; + setToStations(stations); + if (stations.length === 0) setMessage('Станция прибытия не найдена.'); + } catch { + if (requestId !== toRequestId.current) return; + setToStations([]); + setMessage('Ошибка поиска станции прибытия.'); + } finally { + if (requestId === toRequestId.current) setToLoading(false); + } + }, 350); + + return () => clearTimeout(timerId); + }, [toQuery]); + + const search = async () => { + if (!fromCode || !toCode) { + setMessage('Выберите станции отправления и прибытия.'); + return; + } + + setRoutesLoading(true); + setMessage(''); + try { + const data = await getRoutes(fromCode, toCode); + setRoutes(data); + if (data.length === 0) setMessage('Маршруты не найдены.'); + } catch { + setMessage('Не удалось загрузить маршруты.'); + } finally { + setRoutesLoading(false); + } + }; + + const handlePickStation = station => { + setMessage(''); + if (mapTarget === 'from') { + setFromCode(station.code); + setFromQuery(station.displayTitle || station.title); + return; + } + + setToCode(station.code); + setToQuery(station.displayTitle || station.title); + }; + + const handleViewportChange = viewport => { + const requestId = ++viewportRequestId.current; + setTimeout(async () => { + if (requestId !== viewportRequestId.current) return; + try { + const stations = await getStationsInBounds(viewport); + if (requestId !== viewportRequestId.current) return; + setMapStations(stations); + } catch { + if (requestId !== viewportRequestId.current) return; + setMapStations([]); + } + }, 220); + }; + + return ( + +

Маршрут

+

Введите станции, выберите их из подсказок или на карте и посмотрите электрички между ними.

+ +
+ { + setFromCode(station.code); + setFromQuery(station.displayTitle || station.title); + }} + /> +
+ +
+ { + setToCode(station.code); + setToQuery(station.displayTitle || station.title); + }} + /> +
+ +
+
+ + +
+ +
+ + + +
+ +
+ + {message &&

{message}

} + + {routes.map((route, i) => ( + + ))} +
+ ); +} \ No newline at end of file diff --git a/frontend/src/pages/StationPage.jsx b/frontend/src/pages/StationPage.jsx new file mode 100644 index 00000000..8a6b26e2 --- /dev/null +++ b/frontend/src/pages/StationPage.jsx @@ -0,0 +1,305 @@ +import { useState, useContext, useEffect, useRef } from 'react'; +import { AppContext } from '../context/AppContextInstance'; +import { searchStation, getSchedule, getStationsInBounds } from '../api/api'; +import ScheduleCard from '../components/ScheduleCard'; +import StationAutocomplete from '../components/StationAutocomplete'; +import StationMap from '../components/StationMap'; +import Container from '../layout/Container'; + +const FAVORITES_STORAGE_KEY = 'favoriteStationSchedules'; + +function loadFavoritesFromStorage() { + if (typeof window === 'undefined') { + return []; + } + + try { + const raw = window.localStorage.getItem(FAVORITES_STORAGE_KEY); + if (!raw) { + return []; + } + + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) { + return []; + } + + return parsed + .filter( + item => + item && + typeof item === 'object' && + item.station && + typeof item.station === 'object' && + typeof item.station.code === 'string' + ) + .map(item => ({ station: item.station })); + } catch { + return []; + } +} + +function saveFavoritesToStorage(favorites) { + if (typeof window === 'undefined') { + return; + } + + try { + window.localStorage.setItem(FAVORITES_STORAGE_KEY, JSON.stringify(favorites)); + } catch { + // Ignore storage errors (private mode, quota, etc.) so schedule page stays usable. + } +} + +export default function StationPage() { + const [query, setQuery] = useState(''); + const [suggestions, setSuggestions] = useState([]); + const [mapStations, setMapStations] = useState([]); + const [selectedStation, setSelectedStation] = useState(null); + const [searchLoading, setSearchLoading] = useState(false); + const [scheduleLoading, setScheduleLoading] = useState(false); + const [message, setMessage] = useState(''); + const [favorites, setFavorites] = useState(loadFavoritesFromStorage); + const { schedule, setSchedule } = useContext(AppContext); + const searchRequestId = useRef(0); + const viewportRequestId = useRef(0); + + useEffect(() => { + saveFavoritesToStorage(favorites); + }, [favorites]); + + const handleSearch = value => { + setQuery(value); + if (!value) { + setSelectedStation(null); + } + setSchedule([]); + }; + + useEffect(() => { + const value = query.trim(); + if (!value) { + setSuggestions([]); + setSearchLoading(false); + return undefined; + } + + const requestId = ++searchRequestId.current; + setSearchLoading(true); + setMessage(''); + + const timerId = setTimeout(async () => { + try { + const foundStations = await searchStation(value); + if (requestId !== searchRequestId.current) return; + setSuggestions(foundStations); + if (foundStations.length === 0) { + setMessage('Станции не найдены.'); + } + } catch { + if (requestId !== searchRequestId.current) return; + setSuggestions([]); + setMessage('Не удалось выполнить поиск станции.'); + } finally { + if (requestId === searchRequestId.current) { + setSearchLoading(false); + } + } + }, 350); + + return () => clearTimeout(timerId); + }, [query]); + + const handleViewportChange = viewport => { + const requestId = ++viewportRequestId.current; + setTimeout(async () => { + if (requestId !== viewportRequestId.current) return; + try { + const stations = await getStationsInBounds(viewport); + if (requestId !== viewportRequestId.current) return; + setMapStations(stations); + } catch { + if (requestId !== viewportRequestId.current) return; + setMapStations([]); + } + }, 220); + }; + + const handlePickStation = station => { + setSelectedStation(station); + setQuery(station.displayTitle || station.title); + setMessage(''); + }; + + const handleLoadSchedule = async () => { + if (!selectedStation?.code) { + setMessage('Выберите станцию из выпадающего списка или на карте.'); + return; + } + + setScheduleLoading(true); + setMessage(''); + setSchedule([]); + try { + const data = await getSchedule(selectedStation.code); + const safeData = Array.isArray(data) ? data : []; + setSchedule(safeData); + if (safeData.length === 0) { + setMessage('Для выбранной станции расписание не найдено.'); + } + } catch { + setMessage('Не удалось загрузить расписание.'); + } finally { + setScheduleLoading(false); + } + }; + + const handleShowFavorite = async favorite => { + if (!favorite?.station?.code) { + setMessage('Не удалось открыть избранную станцию.'); + return; + } + + setSelectedStation(favorite.station); + setQuery(favorite.station.displayTitle || favorite.station.title || favorite.station.code); + setSuggestions([]); + + setScheduleLoading(true); + setMessage(''); + setSchedule([]); + try { + const data = await getSchedule(favorite.station.code); + const safeData = Array.isArray(data) ? data : []; + setSchedule(safeData); + if (safeData.length === 0) { + setMessage('Для выбранной станции расписание не найдено.'); + } + } catch { + setMessage('Не удалось загрузить расписание.'); + } finally { + setScheduleLoading(false); + } + }; + + const handleSaveFavorite = () => { + if (!selectedStation?.code) { + setMessage('Сначала выберите станцию.'); + return; + } + + const isUpdate = favorites.some(item => item.station.code === selectedStation.code); + const stationToSave = { + code: selectedStation.code, + title: selectedStation.title || selectedStation.displayTitle || selectedStation.code, + displayTitle: selectedStation.displayTitle || selectedStation.title || selectedStation.code, + location: selectedStation.location || '', + lat: selectedStation.lat ?? null, + lng: selectedStation.lng ?? null + }; + + setFavorites(prev => { + const withoutCurrent = prev.filter(item => item.station.code !== stationToSave.code); + return [ + { + station: stationToSave + }, + ...withoutCurrent + ]; + }); + + setMessage(isUpdate ? 'Избранное обновлено.' : 'Станция добавлена в избранное.'); + }; + + const handleRemoveFavorite = stationCode => { + setFavorites(prev => prev.filter(item => item.station.code !== stationCode)); + setMessage('Станция удалена из избранного.'); + }; + + return ( + +

Поиск станции

+

Введите станцию, выберите из подсказок или на карте и загрузите расписание электричек.

+ +
+ + + + +
+ +
+

Избранные станции

+ {favorites.length === 0 ? ( +

Пока нет сохраненных станций.

+ ) : ( +
+ {favorites.map(favorite => ( +
+
+ + {favorite.station.displayTitle || favorite.station.title || favorite.station.code} + + Код станции: {favorite.station.code} +
+
+ + +
+
+ ))} +
+ )} +
+ + + + {message &&

{message}

} + + {(Array.isArray(schedule) ? schedule : []).map((train, i) => ( + + ))} +
+ ); +} diff --git a/frontend/src/styles/cards.css b/frontend/src/styles/cards.css new file mode 100644 index 00000000..a0660678 --- /dev/null +++ b/frontend/src/styles/cards.css @@ -0,0 +1,16 @@ +.card { + background: white; + padding: 15px; + margin-top: 15px; + border-radius: 10px; + box-shadow: 0 2px 5px rgba(0,0,0,0.1); +} + +.train-title { + font-size: 18px; + font-weight: bold; +} + +.train-time { + margin-top: 5px; +} \ No newline at end of file diff --git a/frontend/src/styles/main.css b/frontend/src/styles/main.css new file mode 100644 index 00000000..b7c23c14 --- /dev/null +++ b/frontend/src/styles/main.css @@ -0,0 +1,284 @@ +body { + margin: 0; + font-family: Arial, sans-serif; + background: #f4f6f8; +} + +.header { + background: linear-gradient(135deg, #0f172a 0%, #1e293b 50%, #334155 100%); + color: #f8fafc; + padding: 16px 20px; + box-shadow: 0 8px 24px rgba(15, 23, 42, 0.18); +} + +.header-brand { + display: grid; + gap: 4px; +} + +.header-title { + font-size: 22px; + font-weight: 700; + letter-spacing: 0.2px; +} + +.header-subtitle { + font-size: 14px; + color: rgba(248, 250, 252, 0.85); +} + +.nav { + margin-top: 14px; + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +.header .nav .nav-link { + color: #e2e8f0; + background: rgba(148, 163, 184, 0.16); + border: 1px solid rgba(226, 232, 240, 0.18); + border-radius: 999px; + padding: 8px 14px; + text-decoration: none; + transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease; +} + +.header .nav .nav-link:hover { + color: #ffffff; + background: rgba(59, 130, 246, 0.25); + border-color: rgba(96, 165, 250, 0.5); +} + +.header .nav .nav-link.active { + color: #0f172a; + background: #e2e8f0; + border-color: #e2e8f0; +} + +.container { + max-width: 900px; + margin: auto; + padding: 20px; +} + +select, +input { + padding: 8px; + margin-right: 10px; +} + +.controls-row { + display: flex; + gap: 10px; + flex-wrap: wrap; + margin: 10px 0; +} + +.autocomplete { + position: relative; + min-width: 280px; + flex: 1; + z-index: 20; +} + +.autocomplete input { + width: 100%; + box-sizing: border-box; +} + +.autocomplete-dropdown { + background: #fff; + border: 1px solid #d1d5db; + border-radius: 8px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); + max-height: 240px; + overflow-y: auto; + box-sizing: border-box; + margin: 0; + padding: 0; + font-size: inherit; + font-family: inherit; +} + +.autocomplete-item { + display: block; + width: 100%; + text-align: left; + background: transparent; + border: 0; + color: #111827; + padding: 10px 12px; + cursor: pointer; +} + +.autocomplete-item:hover { + background: #f3f4f6; +} + +.map-wrapper { + margin-top: 14px; + border-radius: 10px; + overflow: hidden; + border: 1px solid #e5e7eb; + position: relative; + z-index: 1; +} + +select, +input { + min-width: 220px; +} + +button { + padding: 8px 12px; + background: #2563eb; + color: white; + border: none; + cursor: pointer; +} + +.map-target-row { + align-items: center; +} + +.target-switch { + display: inline-flex; + border: 1px solid #cbd5e1; + border-radius: 10px; + overflow: hidden; +} + +.target-option { + border-radius: 0; + background: #e2e8f0; + color: #1e293b; +} + +.target-option.active { + background: #2563eb; + color: #fff; +} + +button:hover { + background: #1d4ed8; +} + +button:disabled { + opacity: 0.7; + cursor: not-allowed; +} + +.favorites-panel { + margin-top: 20px; + padding: 14px; + border: 1px solid #e2e8f0; + border-radius: 12px; + background: #ffffff; +} + +.favorites-panel h3 { + margin: 0 0 12px; +} + +.favorites-empty { + margin: 0; + color: #64748b; +} + +.favorites-list { + display: grid; + gap: 10px; +} + +.favorite-item { + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; + border: 1px solid #e5e7eb; + border-radius: 10px; + padding: 10px; + background: #f8fafc; +} + +.favorite-meta { + display: grid; + gap: 3px; +} + +.favorite-meta span { + color: #475569; + font-size: 14px; +} + +.favorite-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.danger-button { + background: #dc2626; +} + +.danger-button:hover { + background: #b91c1c; +} + +.autocomplete-loading-item { + display: block; + width: 100%; + text-align: left; + background: transparent; + border: 0; + color: #6b7280; + font-style: italic; + padding: 10px 12px; + cursor: default; +} + +.autocomplete-empty-item { + display: block; + width: 100%; + text-align: left; + background: transparent; + border: 0; + color: #6b7280; + padding: 10px 12px; + cursor: default; +} + +.about-page { + display: grid; + gap: 14px; +} + +.about-page h2 { + margin: 0; +} + +.about-page p { + margin: 0; + line-height: 1.55; +} + +.about-section { + background: #ffffff; + border: 1px solid #e2e8f0; + border-radius: 12px; + padding: 14px; +} + +.about-section h3 { + margin-top: 0; + margin-bottom: 8px; +} + +.about-section ul, +.about-section ol { + margin: 0; + padding-left: 22px; + line-height: 1.5; +} + diff --git a/frontend/src/styles/mobile.css b/frontend/src/styles/mobile.css new file mode 100644 index 00000000..35fa1ec8 --- /dev/null +++ b/frontend/src/styles/mobile.css @@ -0,0 +1,66 @@ +@media (max-width: 600px) { + .header { + padding: 14px; + } + + .header-title { + font-size: 20px; + } + + .nav { + flex-direction: column; + align-items: stretch; + gap: 8px; + } + + .header .nav .nav-link { + width: 100%; + box-sizing: border-box; + text-align: center; + border-radius: 12px; + } + + .container { + padding: 10px; + } + + .controls-row { + flex-direction: column; + } + + .controls-row button, + .controls-row input, + .controls-row select { + width: 100%; + margin-right: 0; + } + + .target-switch { + width: 100%; + } + + .target-option { + width: 50%; + } + + .train-title { + font-size: 16px; + } + + .favorite-item { + flex-direction: column; + align-items: stretch; + } + + .favorite-actions { + width: 100%; + } + + .favorite-actions button { + flex: 1; + } + + .about-section { + padding: 12px; + } +} diff --git a/frontend/src/styles/styles.css b/frontend/src/styles/styles.css new file mode 100644 index 00000000..b22a10c9 --- /dev/null +++ b/frontend/src/styles/styles.css @@ -0,0 +1,16 @@ +table { + margin-top: 20px; + border-collapse: collapse; + width: 100%; +} + +td, th { + border: 1px solid #ccc; + padding: 6px; +} + +@media (max-width: 600px) { + table { + font-size: 12px; + } +} diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 00000000..8b0f57b9 --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react()], +})