From 9773602995e014ca7a2fb94ffc59ad15b52eb463 Mon Sep 17 00:00:00 2001 From: exxxpm <127376127+exxxpm@users.noreply.github.com> Date: Mon, 6 Apr 2026 15:56:42 +0400 Subject: [PATCH 1/2] lab2 --- .editorconfig | 12 + .gitignore | 11 + .prettierrc.json | 8 + backend/.env.example | 8 + backend/config.js | 26 + backend/package.json | 14 + backend/server.js | 128 +++ backend/services/stationIndex.js | 362 ++++++++ backend/services/yandexRasp.js | 236 +++++ backend/utils/fetchJson.js | 37 + backend/utils/normalizeText.js | 8 + client/.env.example | 3 + client/index.html | 17 + client/package.json | 19 + client/public/favicon.svg | 11 + client/src/App.jsx | 233 +++++ client/src/api/client.js | 63 ++ client/src/components/AppHeader.jsx | 55 ++ client/src/components/EmptyState.jsx | 8 + client/src/components/FavoritesPanel.jsx | 82 ++ client/src/components/LoadingBlock.jsx | 8 + client/src/components/RouteSearchPanel.jsx | 158 ++++ client/src/components/StationAutocomplete.jsx | 127 +++ client/src/components/StationMap.jsx | 131 +++ .../src/components/StationSchedulePanel.jsx | 114 +++ client/src/components/StationSearchPanel.jsx | 146 ++++ client/src/config/app.js | 8 + client/src/config/map.js | 3 + client/src/hooks/useFavorites.js | 70 ++ client/src/main.jsx | 6 + client/src/styles/app.css | 810 ++++++++++++++++++ client/src/utils/dates.js | 27 + client/src/utils/formatters.js | 78 ++ client/src/utils/storage.js | 61 ++ client/vite.config.js | 9 + 35 files changed, 3097 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitignore create mode 100644 .prettierrc.json create mode 100644 backend/.env.example create mode 100644 backend/config.js create mode 100644 backend/package.json create mode 100644 backend/server.js create mode 100644 backend/services/stationIndex.js create mode 100644 backend/services/yandexRasp.js create mode 100644 backend/utils/fetchJson.js create mode 100644 backend/utils/normalizeText.js create mode 100644 client/.env.example create mode 100644 client/index.html create mode 100644 client/package.json create mode 100644 client/public/favicon.svg create mode 100644 client/src/App.jsx create mode 100644 client/src/api/client.js create mode 100644 client/src/components/AppHeader.jsx create mode 100644 client/src/components/EmptyState.jsx create mode 100644 client/src/components/FavoritesPanel.jsx create mode 100644 client/src/components/LoadingBlock.jsx create mode 100644 client/src/components/RouteSearchPanel.jsx create mode 100644 client/src/components/StationAutocomplete.jsx create mode 100644 client/src/components/StationMap.jsx create mode 100644 client/src/components/StationSchedulePanel.jsx create mode 100644 client/src/components/StationSearchPanel.jsx create mode 100644 client/src/config/app.js create mode 100644 client/src/config/map.js create mode 100644 client/src/hooks/useFavorites.js create mode 100644 client/src/main.jsx create mode 100644 client/src/styles/app.css create mode 100644 client/src/utils/dates.js create mode 100644 client/src/utils/formatters.js create mode 100644 client/src/utils/storage.js create mode 100644 client/vite.config.js diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..56f3b6bf --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..694b1719 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +.DS_Store +.playwright-cli/ +.cache/ +backend/node_modules/ +backend/.cache/ +backend/.env +backend/package-lock.json +client/node_modules/ +client/dist/ +client/.env +client/package-lock.json diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 00000000..acd9e4a4 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,8 @@ +{ + "tabWidth": 4, + "useTabs": false, + "semi": true, + "singleQuote": false, + "trailingComma": "all", + "printWidth": 100 +} diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 00000000..8eae064d --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,8 @@ +PORT=3001 +CLIENT_ORIGIN=http://localhost:5173 +CLIENT_ORIGINS=http://localhost:5173,http://127.0.0.1:5173,http://localhost:4173,http://127.0.0.1:4173,http://localhost:4174,http://127.0.0.1:4174 +YANDEX_RASP_API_KEY= +YANDEX_RASP_API_BASE=https://api.rasp.yandex-net.ru/v3.0 +STATION_CACHE_MAX_AGE_HOURS=24 +STATION_SEARCH_LIMIT=12 +NEAREST_STATION_LIMIT=8 diff --git a/backend/config.js b/backend/config.js new file mode 100644 index 00000000..f807e288 --- /dev/null +++ b/backend/config.js @@ -0,0 +1,26 @@ +import "dotenv/config"; + +export const PORT = Number(process.env.PORT || 3001); +export const CLIENT_ORIGIN = process.env.CLIENT_ORIGIN || "http://localhost:5173"; +export const CLIENT_ORIGINS = String(process.env.CLIENT_ORIGINS || CLIENT_ORIGIN) + .split(",") + .map((origin) => origin.trim()) + .filter(Boolean); +export const YANDEX_RASP_API_KEY = process.env.YANDEX_RASP_API_KEY || ""; +export const YANDEX_RASP_API_BASE = + process.env.YANDEX_RASP_API_BASE || "https://api.rasp.yandex-net.ru/v3.0"; +export const STATION_CACHE_MAX_AGE_HOURS = Number(process.env.STATION_CACHE_MAX_AGE_HOURS || 24); +export const STATION_SEARCH_LIMIT = Number(process.env.STATION_SEARCH_LIMIT || 12); +export const NEAREST_STATION_LIMIT = Number(process.env.NEAREST_STATION_LIMIT || 8); + +export const RAIL_STATION_TYPES = [ + "station", + "platform", + "stop", + "checkpoint", + "post", + "crossing", + "overtaking_point", + "train_station", + "unknown", +]; diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 00000000..c5919291 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,14 @@ +{ + "name": "suburban-backend", + "private": true, + "type": "module", + "scripts": { + "dev": "node --watch server.js", + "start": "node server.js" + }, + "dependencies": { + "cors": "^2.8.5", + "dotenv": "^16.4.7", + "express": "^4.21.2" + } +} diff --git a/backend/server.js b/backend/server.js new file mode 100644 index 00000000..a6d5633e --- /dev/null +++ b/backend/server.js @@ -0,0 +1,128 @@ +import cors from "cors"; +import express from "express"; +import { CLIENT_ORIGINS, NEAREST_STATION_LIMIT, PORT, STATION_SEARCH_LIMIT } from "./config.js"; +import { searchNearbyStationsFromIndex, searchStationsByName } from "./services/stationIndex.js"; +import { getStationSchedule, searchRoutesBetweenStations } from "./services/yandexRasp.js"; + +const app = express(); + +function isAllowedOrigin(origin) { + if (!origin) { + return true; + } + + if (CLIENT_ORIGINS.includes(origin)) { + return true; + } + + return /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin); +} + +app.use( + cors({ + origin(origin, callback) { + if (isAllowedOrigin(origin)) { + callback(null, true); + return; + } + + callback(new Error(`CORS blocked for origin: ${origin}`)); + }, + }), +); +app.use(express.json()); + +function sendError(response, error, fallbackMessage) { + console.error("[api]", error); + response.status(error.statusCode || 500).json({ + error: error.message || fallbackMessage, + }); +} + +app.get("/api/ping", (_request, response) => { + response.json({ + ok: true, + now: new Date().toISOString(), + }); +}); + +app.get("/api/stations/suggest", async (request, response) => { + try { + const searchText = String(request.query.searchText || ""); + const limit = Number(request.query.limit || STATION_SEARCH_LIMIT); + const stations = await searchStationsByName(searchText, limit); + + response.json({ + stations, + }); + } catch (error) { + sendError(response, error, "Не удалось выполнить поиск станций."); + } +}); + +app.get("/api/stations/nearby", async (request, response) => { + try { + const latitude = Number(request.query.lat); + const longitude = Number(request.query.lng); + const distance = Number(request.query.distance || 15); + const limit = Number(request.query.limit || NEAREST_STATION_LIMIT); + + if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) { + response.status(400).json({ + error: "Для поиска по карте нужны координаты lat и lng.", + }); + return; + } + + const stations = await searchNearbyStationsFromIndex(latitude, longitude, distance, limit); + + response.json({ + stations, + }); + } catch (error) { + sendError(response, error, "Не удалось получить станции рядом с выбранной точкой."); + } +}); + +app.get("/api/stations/:stationCode/schedule", async (request, response) => { + try { + const stationCode = String(request.params.stationCode || ""); + const date = String(request.query.date || ""); + + if (!stationCode) { + response.status(400).json({ + error: "Не передан код станции.", + }); + return; + } + + const schedule = await getStationSchedule(stationCode, date); + response.json(schedule); + } catch (error) { + sendError(response, error, "Не удалось загрузить расписание по станции."); + } +}); + +app.get("/api/routes/search", async (request, response) => { + try { + const fromStationCode = String(request.query.from || ""); + const toStationCode = String(request.query.to || ""); + const date = String(request.query.date || ""); + + if (!fromStationCode || !toStationCode) { + response.status(400).json({ + error: "Для поиска маршрута нужно выбрать станции отправления и прибытия.", + }); + return; + } + + const routes = await searchRoutesBetweenStations(fromStationCode, toStationCode, date); + response.json(routes); + } catch (error) { + sendError(response, error, "Не удалось загрузить маршруты между станциями."); + } +}); + +app.listen(PORT, () => { + console.log(`Backend started on http://localhost:${PORT}`); +}); diff --git a/backend/services/stationIndex.js b/backend/services/stationIndex.js new file mode 100644 index 00000000..bee66eee --- /dev/null +++ b/backend/services/stationIndex.js @@ -0,0 +1,362 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + RAIL_STATION_TYPES, + STATION_CACHE_MAX_AGE_HOURS, + STATION_SEARCH_LIMIT, +} from "../config.js"; +import { normalizeText } from "../utils/normalizeText.js"; +import { requestYandex } from "./yandexRasp.js"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const cacheDirectory = path.join(__dirname, "..", ".cache"); +const cacheFilePath = path.join(cacheDirectory, "suburban-stations.json"); +const cacheMaxAgeMs = STATION_CACHE_MAX_AGE_HOURS * 60 * 60 * 1000; + +let stationIndex = null; +let stationIndexPromise = null; + +const SEARCH_SCORE = { + exactTitle: 120, + exactAlternativeTitle: 100, + titlePrefix: 70, + alternativeTitlePrefix: 60, + settlementExactMatch: 50, + titleWordBoundaryPrefix: 40, + regionExactMatch: 20, + suburbanTransport: 12, + stationTypePriority: 8, +}; + +const PREFERRED_STATION_TYPES = new Set(["train_station", "station"]); + +function getStationSearchFields(station) { + return { + title: normalizeText(station.title), + popularTitle: normalizeText(station.popularTitle), + shortTitle: normalizeText(station.shortTitle), + settlement: normalizeText(station.settlement), + region: normalizeText(station.region), + }; +} + +function startsWithWholeWord(value, query) { + return value.startsWith(`${query} `) || value.startsWith(`${query}-`); +} + +function getStationTypePriority(station) { + if (PREFERRED_STATION_TYPES.has(station.stationType)) { + return SEARCH_SCORE.stationTypePriority; + } + + return 0; +} + +function getTransportPriority(station) { + if (station.transportType === "suburban") { + return SEARCH_SCORE.suburbanTransport; + } + + return 0; +} + +function getSourcePriority(station) { + if (station.transportType === "suburban") { + return 2; + } + + if (station.direction) { + return 1; + } + + return 0; +} + +function isRailOrSuburbanStation(station) { + if (!station?.codes?.yandex_code) { + return false; + } + + if (station.transport_type === "suburban" || station.transport_type === "train") { + return true; + } + + if (station.direction) { + return true; + } + + return RAIL_STATION_TYPES.includes(station.station_type); +} + +function buildStationRecord(station, settlementTitle, regionTitle, countryTitle) { + const stationCode = station.codes?.yandex_code; + + return { + code: stationCode, + title: station.title, + popularTitle: station.popular_title || "", + shortTitle: station.short_title || "", + stationType: station.station_type, + transportType: station.transport_type, + direction: station.direction || "", + lat: station.latitude ?? null, + lng: station.longitude ?? null, + settlement: settlementTitle || "", + region: regionTitle || "", + country: countryTitle || "", + displayLabel: [station.title, settlementTitle, regionTitle].filter(Boolean).join(", "), + searchKey: normalizeText( + [ + station.title, + station.popular_title, + station.short_title, + settlementTitle, + regionTitle, + countryTitle, + ] + .filter(Boolean) + .join(" "), + ), + }; +} + +function calculateDistanceKm(fromLatitude, fromLongitude, toLatitude, toLongitude) { + const earthRadiusKm = 6371; + const latitudeDelta = ((toLatitude - fromLatitude) * Math.PI) / 180; + const longitudeDelta = ((toLongitude - fromLongitude) * Math.PI) / 180; + const latitudeOne = (fromLatitude * Math.PI) / 180; + const latitudeTwo = (toLatitude * Math.PI) / 180; + + const haversine = + Math.sin(latitudeDelta / 2) * Math.sin(latitudeDelta / 2) + + Math.cos(latitudeOne) * + Math.cos(latitudeTwo) * + Math.sin(longitudeDelta / 2) * + Math.sin(longitudeDelta / 2); + + const angularDistance = 2 * Math.atan2(Math.sqrt(haversine), Math.sqrt(1 - haversine)); + return earthRadiusKm * angularDistance; +} + +function getSearchScore(station, normalizedQuery) { + const searchFields = getStationSearchFields(station); + const hasExactAlternativeTitle = + searchFields.popularTitle === normalizedQuery || + searchFields.shortTitle === normalizedQuery; + const hasAlternativeTitlePrefix = + searchFields.popularTitle.startsWith(normalizedQuery) || + searchFields.shortTitle.startsWith(normalizedQuery); + let score = 0; + + if (searchFields.title === normalizedQuery) { + score += SEARCH_SCORE.exactTitle; + } + + if (hasExactAlternativeTitle) { + score += SEARCH_SCORE.exactAlternativeTitle; + } + + if (startsWithWholeWord(searchFields.title, normalizedQuery)) { + score += SEARCH_SCORE.titleWordBoundaryPrefix; + } + + if (searchFields.title.startsWith(normalizedQuery)) { + score += SEARCH_SCORE.titlePrefix; + } + + if (hasAlternativeTitlePrefix) { + score += SEARCH_SCORE.alternativeTitlePrefix; + } + + if (searchFields.settlement === normalizedQuery) { + score += SEARCH_SCORE.settlementExactMatch; + } + + if (searchFields.region === normalizedQuery) { + score += SEARCH_SCORE.regionExactMatch; + } + + score += getTransportPriority(station); + score += getStationTypePriority(station); + + return score; +} + +function flattenStationsList(payload) { + const stationMap = new Map(); + + for (const country of payload.countries || []) { + for (const region of country.regions || []) { + for (const settlement of region.settlements || []) { + for (const station of settlement.stations || []) { + if (!isRailOrSuburbanStation(station)) { + continue; + } + + const stationRecord = buildStationRecord( + station, + settlement.title, + region.title, + country.title, + ); + + if (!stationRecord.code) { + continue; + } + + const existingStation = stationMap.get(stationRecord.code); + + if (!existingStation) { + stationMap.set(stationRecord.code, stationRecord); + continue; + } + + const currentPriority = getSourcePriority(existingStation); + const nextPriority = getSourcePriority(stationRecord); + + if (nextPriority > currentPriority) { + stationMap.set(stationRecord.code, stationRecord); + } + } + } + } + } + + return [...stationMap.values()].sort((leftStation, rightStation) => { + if (leftStation.transportType !== rightStation.transportType) { + return leftStation.transportType === "suburban" ? -1 : 1; + } + + return leftStation.displayLabel.localeCompare(rightStation.displayLabel, "ru"); + }); +} + +async function readCacheFile() { + try { + const rawFile = await fs.readFile(cacheFilePath, "utf8"); + const parsedFile = JSON.parse(rawFile); + + if (!parsedFile?.createdAt || !Array.isArray(parsedFile?.stations)) { + return null; + } + + const ageMs = Date.now() - new Date(parsedFile.createdAt).getTime(); + if (ageMs > cacheMaxAgeMs) { + return null; + } + + return parsedFile.stations; + } catch { + return null; + } +} + +async function writeCacheFile(stations) { + await fs.mkdir(cacheDirectory, { recursive: true }); + await fs.writeFile( + cacheFilePath, + JSON.stringify( + { + createdAt: new Date().toISOString(), + stations, + }, + null, + 2, + ), + "utf8", + ); +} + +async function downloadStationsList() { + const payload = await requestYandex("stations_list/", { + lang: "ru_RU", + format: "json", + }); + + const stations = flattenStationsList(payload); + await writeCacheFile(stations); + return stations; +} + +export async function getStationIndex() { + if (stationIndex) { + return stationIndex; + } + + if (!stationIndexPromise) { + stationIndexPromise = (async () => { + const cachedStations = await readCacheFile(); + + if (cachedStations) { + stationIndex = cachedStations; + return cachedStations; + } + + const downloadedStations = await downloadStationsList(); + stationIndex = downloadedStations; + return downloadedStations; + })(); + } + + try { + return await stationIndexPromise; + } finally { + stationIndexPromise = null; + } +} + +export async function searchStationsByName(searchText, limit = STATION_SEARCH_LIMIT) { + const normalizedQuery = normalizeText(searchText); + + if (!normalizedQuery) { + return []; + } + + const stations = await getStationIndex(); + + return stations + .filter((station) => station.searchKey.includes(normalizedQuery)) + .map((station) => ({ + station, + score: getSearchScore(station, normalizedQuery), + })) + .sort((leftMatch, rightMatch) => { + if (leftMatch.score !== rightMatch.score) { + return rightMatch.score - leftMatch.score; + } + + return leftMatch.station.displayLabel.localeCompare( + rightMatch.station.displayLabel, + "ru", + ); + }) + .slice(0, limit) + .map((match) => match.station); +} + +export async function searchNearbyStationsFromIndex(latitude, longitude, distanceKm, limit) { + const stations = await getStationIndex(); + + const closestStations = stations + .filter((station) => station.lat != null && station.lng != null) + .map((station) => ({ + ...station, + distance: calculateDistanceKm(latitude, longitude, station.lat, station.lng), + })) + .sort((leftStation, rightStation) => leftStation.distance - rightStation.distance); + + const stationsInsideRadius = closestStations.filter( + (station) => station.distance <= distanceKm, + ); + const resultStations = + stationsInsideRadius.length > 0 + ? stationsInsideRadius.slice(0, limit) + : closestStations.slice(0, limit); + + return resultStations.map((station) => ({ + ...station, + stationTypeName: "", + typeChoices: {}, + })); +} diff --git a/backend/services/yandexRasp.js b/backend/services/yandexRasp.js new file mode 100644 index 00000000..afd1ad76 --- /dev/null +++ b/backend/services/yandexRasp.js @@ -0,0 +1,236 @@ +import { NEAREST_STATION_LIMIT, YANDEX_RASP_API_BASE, YANDEX_RASP_API_KEY } from "../config.js"; +import { createHttpError, fetchJson } from "../utils/fetchJson.js"; + +function ensureApiKey() { + if (!YANDEX_RASP_API_KEY) { + throw createHttpError( + 503, + "Не указан ключ Яндекс.Расписаний. Добавьте YANDEX_RASP_API_KEY в backend/.env.", + ); + } +} + +function buildUrl(pathname, params) { + const url = new URL(pathname, `${YANDEX_RASP_API_BASE}/`); + url.searchParams.set("apikey", YANDEX_RASP_API_KEY); + + for (const [parameterName, parameterValue] of Object.entries(params || {})) { + if (parameterValue === undefined || parameterValue === null || parameterValue === "") { + continue; + } + + url.searchParams.set(parameterName, String(parameterValue)); + } + + return url; +} + +export async function requestYandex(pathname, params) { + ensureApiKey(); + const normalizedPathname = pathname.replace(/^\//, ""); + const requestUrl = buildUrl(normalizedPathname, params); + return fetchJson(requestUrl, `yandex:${pathname}`); +} + +function mapStationInfo(station) { + return { + code: station.code || station.codes?.yandex_code || "", + title: station.title || "", + popularTitle: station.popular_title || "", + shortTitle: station.short_title || "", + stationType: station.station_type || "", + stationTypeName: station.station_type_name || "", + transportType: station.transport_type || "", + direction: station.direction || "", + settlement: station.settlement || "", + region: station.region || "", + lat: station.lat ?? station.latitude ?? null, + lng: station.lng ?? station.longitude ?? null, + displayLabel: [station.title, station.settlement, station.region] + .filter(Boolean) + .join(", "), + }; +} + +function normalizeScheduleEntry(rawEntry) { + return { + key: [ + rawEntry.thread?.uid || rawEntry.thread?.title || "thread", + rawEntry.arrival || "", + rawEntry.departure || "", + ].join("|"), + arrival: rawEntry.arrival || "", + departure: rawEntry.departure || "", + isFuzzy: Boolean(rawEntry.is_fuzzy), + days: rawEntry.days || "", + exceptDays: rawEntry.except_days || "", + stops: rawEntry.stops || "", + platform: rawEntry.platform || "", + terminal: rawEntry.terminal || "", + thread: { + uid: rawEntry.thread?.uid || "", + title: rawEntry.thread?.title || "", + shortTitle: rawEntry.thread?.short_title || "", + number: rawEntry.thread?.number || "", + carrier: rawEntry.thread?.carrier?.title || "", + transportType: rawEntry.thread?.transport_type || "", + transportSubtype: rawEntry.thread?.transport_subtype?.title || "", + expressType: rawEntry.thread?.express_type || "", + }, + }; +} + +function mergeScheduleResponses(arrivalResponse, departureResponse) { + const mergedEntries = new Map(); + + for (const responseChunk of [arrivalResponse, departureResponse]) { + for (const rawEntry of responseChunk.schedule || []) { + const normalizedEntry = normalizeScheduleEntry(rawEntry); + const existingEntry = mergedEntries.get(normalizedEntry.key); + + if (!existingEntry) { + mergedEntries.set(normalizedEntry.key, normalizedEntry); + continue; + } + + mergedEntries.set(normalizedEntry.key, { + ...existingEntry, + ...normalizedEntry, + arrival: existingEntry.arrival || normalizedEntry.arrival, + departure: existingEntry.departure || normalizedEntry.departure, + platform: existingEntry.platform || normalizedEntry.platform, + terminal: existingEntry.terminal || normalizedEntry.terminal, + }); + } + } + + return [...mergedEntries.values()].sort((leftEntry, rightEntry) => { + const leftTime = Date.parse(leftEntry.departure || leftEntry.arrival || 0); + const rightTime = Date.parse(rightEntry.departure || rightEntry.arrival || 0); + return leftTime - rightTime; + }); +} + +function formatTicketInfo(ticketInfo) { + const firstPlace = ticketInfo?.places?.[0]; + + if (!firstPlace?.price?.whole) { + return ""; + } + + const currencySymbol = firstPlace.currency === "RUB" ? "₽" : firstPlace.currency; + return `от ${firstPlace.price.whole} ${currencySymbol}`; +} + +function mapRouteSegment(rawSegment, isInterval = false) { + return { + key: [ + rawSegment.thread?.uid || rawSegment.thread?.title || "route", + rawSegment.departure || "", + rawSegment.arrival || "", + isInterval ? "interval" : "regular", + ].join("|"), + isInterval, + departure: rawSegment.departure || "", + arrival: rawSegment.arrival || "", + departurePlatform: rawSegment.departure_platform || "", + arrivalPlatform: rawSegment.arrival_platform || "", + departureTerminal: rawSegment.departure_terminal || "", + arrivalTerminal: rawSegment.arrival_terminal || "", + duration: rawSegment.duration || 0, + startDate: rawSegment.start_date || "", + stops: rawSegment.stops || "", + days: rawSegment.days || "", + hasTransfers: Boolean(rawSegment.has_transfers), + ticketText: formatTicketInfo(rawSegment.tickets_info), + from: mapStationInfo(rawSegment.from || {}), + to: mapStationInfo(rawSegment.to || {}), + thread: { + uid: rawSegment.thread?.uid || "", + title: rawSegment.thread?.title || "", + shortTitle: rawSegment.thread?.short_title || "", + number: rawSegment.thread?.number || "", + carrier: rawSegment.thread?.carrier?.title || "", + vehicle: rawSegment.thread?.vehicle || "", + transportType: rawSegment.thread?.transport_type || "", + transportSubtype: rawSegment.thread?.transport_subtype?.title || "", + intervalDensity: rawSegment.thread?.interval?.density || "", + }, + }; +} + +export async function getNearbyStations( + latitude, + longitude, + distance, + limit = NEAREST_STATION_LIMIT, +) { + const response = await requestYandex("nearest_stations/", { + lang: "ru_RU", + format: "json", + lat: latitude, + lng: longitude, + distance, + limit, + transport_types: "suburban", + station_types: + "station,platform,stop,train_station,checkpoint,post,crossing,overtaking_point,unknown", + }); + + return (response.stations || []).map((station) => ({ + ...mapStationInfo(station), + distance: station.distance || 0, + typeChoices: station.type_choices || {}, + })); +} + +export async function getStationSchedule(stationCode, date) { + const requestParams = { + station: stationCode, + date, + lang: "ru_RU", + format: "json", + transport_types: "suburban", + }; + + const [arrivalResponse, departureResponse] = await Promise.all([ + requestYandex("schedule/", { ...requestParams, event: "arrival" }), + requestYandex("schedule/", { ...requestParams, event: "departure" }), + ]); + + return { + date: departureResponse.date || arrivalResponse.date || date, + station: mapStationInfo(departureResponse.station || arrivalResponse.station || {}), + directions: departureResponse.directions || arrivalResponse.directions || [], + entries: mergeScheduleResponses(arrivalResponse, departureResponse), + }; +} + +export async function searchRoutesBetweenStations(fromStationCode, toStationCode, date) { + const response = await requestYandex("search/", { + from: fromStationCode, + to: toStationCode, + date, + lang: "ru_RU", + format: "json", + transport_types: "suburban", + transfers: "true", + limit: 50, + }); + + const intervalSegments = (response.interval_segments || []).map((segment) => + mapRouteSegment(segment, true), + ); + const regularSegments = (response.segments || []).map((segment) => mapRouteSegment(segment)); + + return { + search: response.search || {}, + from: mapStationInfo(response.search?.from || {}), + to: mapStationInfo(response.search?.to || {}), + segments: [...regularSegments, ...intervalSegments].sort((leftSegment, rightSegment) => { + const leftTime = Date.parse(leftSegment.departure || 0); + const rightTime = Date.parse(rightSegment.departure || 0); + return leftTime - rightTime; + }), + }; +} diff --git a/backend/utils/fetchJson.js b/backend/utils/fetchJson.js new file mode 100644 index 00000000..e3bde035 --- /dev/null +++ b/backend/utils/fetchJson.js @@ -0,0 +1,37 @@ +export function createHttpError(statusCode, message) { + const error = new Error(message); + error.statusCode = statusCode; + return error; +} + +export async function fetchJson(url, requestLabel, options = {}) { + let response; + + try { + response = await fetch(url, options); + } catch (error) { + console.error(`[${requestLabel}] network error`, error); + throw createHttpError(502, "Не удалось связаться с API Яндекс.Расписаний."); + } + + const responseText = await response.text(); + + if (!response.ok) { + console.error(`[${requestLabel}] bad response`, response.status, responseText); + throw createHttpError( + response.status, + "Яндекс.Расписания вернули ошибку. Проверьте ключ и параметры запроса.", + ); + } + + if (!responseText) { + return null; + } + + try { + return JSON.parse(responseText); + } catch (error) { + console.error(`[${requestLabel}] invalid json`, error, responseText); + throw createHttpError(502, "API Яндекс.Расписаний вернуло некорректный JSON."); + } +} diff --git a/backend/utils/normalizeText.js b/backend/utils/normalizeText.js new file mode 100644 index 00000000..9eff34bb --- /dev/null +++ b/backend/utils/normalizeText.js @@ -0,0 +1,8 @@ +export function normalizeText(value) { + return String(value || "") + .toLowerCase() + .replace(/ё/g, "е") + .replace(/[^\p{L}\p{N}\s-]/gu, " ") + .replace(/\s+/g, " ") + .trim(); +} diff --git a/client/.env.example b/client/.env.example new file mode 100644 index 00000000..6dca0fde --- /dev/null +++ b/client/.env.example @@ -0,0 +1,3 @@ +VITE_API_BASE_URL=http://localhost:3001/api +VITE_FAVORITES_STORAGE_KEY=suburban_lab_favorites +VITE_THEME_STORAGE_KEY=suburban_lab_theme diff --git a/client/index.html b/client/index.html new file mode 100644 index 00000000..fc45b2ad --- /dev/null +++ b/client/index.html @@ -0,0 +1,17 @@ + + + + + + + + Прибывалка для электричек + + +
+ + + diff --git a/client/package.json b/client/package.json new file mode 100644 index 00000000..fbb28842 --- /dev/null +++ b/client/package.json @@ -0,0 +1,19 @@ +{ + "name": "suburban-client", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "ol": "^10.6.1", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.4.1", + "vite": "^6.2.1" + } +} diff --git a/client/public/favicon.svg b/client/public/favicon.svg new file mode 100644 index 00000000..5ada8615 --- /dev/null +++ b/client/public/favicon.svg @@ -0,0 +1,11 @@ + + + + + + + + diff --git a/client/src/App.jsx b/client/src/App.jsx new file mode 100644 index 00000000..abf8aa0b --- /dev/null +++ b/client/src/App.jsx @@ -0,0 +1,233 @@ +import { useEffect, useState } from "react"; +import { loadNearbyStations, loadRoutesBetweenStations, loadStationSchedule } from "./api/client"; +import { AppHeader } from "./components/AppHeader"; +import { FavoritesPanel } from "./components/FavoritesPanel"; +import { RouteSearchPanel } from "./components/RouteSearchPanel"; +import { StationSchedulePanel } from "./components/StationSchedulePanel"; +import { StationSearchPanel } from "./components/StationSearchPanel"; +import { THEME_STORAGE_KEY } from "./config/app"; +import { MAP_POINT_SEARCH_DISTANCE } from "./config/map"; +import { useFavorites } from "./hooks/useFavorites"; +import { todayDateValue } from "./utils/dates"; +import { readStoredValue, writeStoredValue } from "./utils/storage"; + +const DEFAULT_THEME = "light"; + +function getSavedTheme() { + const savedTheme = readStoredValue(THEME_STORAGE_KEY, DEFAULT_THEME); + return savedTheme === "dark" ? "dark" : DEFAULT_THEME; +} + +function openSection(sectionId) { + window.location.hash = sectionId; +} + +export default function App() { + const [theme, setTheme] = useState(getSavedTheme); + const [selectedStation, setSelectedStation] = useState(null); + const [pickedMapPoint, setPickedMapPoint] = useState(null); + const [stationDate, setStationDate] = useState(todayDateValue()); + const [scheduleEntries, setScheduleEntries] = useState([]); + const [scheduleLoading, setScheduleLoading] = useState(false); + const [scheduleError, setScheduleError] = useState(""); + + const [nearbyStations, setNearbyStations] = useState([]); + const [nearbyStationsLoading, setNearbyStationsLoading] = useState(false); + const [nearbyStationsError, setNearbyStationsError] = useState(""); + + const [routeFromStation, setRouteFromStation] = useState(null); + const [routeToStation, setRouteToStation] = useState(null); + const [routeDate, setRouteDate] = useState(todayDateValue()); + const [routeSegments, setRouteSegments] = useState([]); + const [routeLoading, setRouteLoading] = useState(false); + const [routeError, setRouteError] = useState(""); + + const { favoriteStations, addFavorite, removeFavorite, hasFavorite } = useFavorites(); + + useEffect(() => { + document.documentElement.dataset.theme = theme; + document.documentElement.style.colorScheme = theme === "dark" ? "dark" : "light"; + writeStoredValue(THEME_STORAGE_KEY, theme); + }, [theme]); + + useEffect(() => { + if (!selectedStation?.code) { + setScheduleEntries([]); + setScheduleError(""); + return; + } + + let isCancelled = false; + + async function fetchSchedule() { + setScheduleLoading(true); + setScheduleError(""); + + try { + const response = await loadStationSchedule(selectedStation.code, stationDate); + + if (isCancelled) { + return; + } + + setScheduleEntries(response.entries || []); + } catch (error) { + if (!isCancelled) { + setScheduleError(error.message); + } + } finally { + if (!isCancelled) { + setScheduleLoading(false); + } + } + } + + void fetchSchedule(); + + return () => { + isCancelled = true; + }; + }, [selectedStation, stationDate]); + + function selectStation(station) { + setSelectedStation(station); + + if (station?.lat != null && station?.lng != null) { + setPickedMapPoint({ + lat: station.lat, + lng: station.lng, + }); + } + } + + async function handleMapPointPick(point) { + setPickedMapPoint(point); + setNearbyStationsLoading(true); + setNearbyStationsError(""); + + try { + const response = await loadNearbyStations( + point.lat, + point.lng, + MAP_POINT_SEARCH_DISTANCE, + ); + + setNearbyStations(response.stations || []); + } catch (error) { + setNearbyStationsError(error.message); + } finally { + setNearbyStationsLoading(false); + } + } + + function handleFavoriteToggle(station) { + if (station && hasFavorite(station.code)) { + removeFavorite(station.code); + return; + } + + if (station) { + addFavorite(station); + } + } + + async function handleRouteSubmit(event) { + event.preventDefault(); + + if (!routeFromStation?.code || !routeToStation?.code) { + setRouteError("Выберите обе станции, прежде чем запускать поиск."); + return; + } + + setRouteLoading(true); + setRouteError(""); + + try { + const response = await loadRoutesBetweenStations( + routeFromStation.code, + routeToStation.code, + routeDate, + ); + + setRouteSegments(response.segments || []); + } catch (error) { + setRouteError(error.message); + } finally { + setRouteLoading(false); + } + } + + function handleFavoritePick(station) { + selectStation(station); + openSection("#station-panel"); + } + + function handleFavoriteUseAsRouteFrom(station) { + setRouteFromStation(station); + openSection("#route-panel"); + } + + function handleFavoriteUseAsRouteTo(station) { + setRouteToStation(station); + openSection("#route-panel"); + } + + function handleThemeToggle() { + setTheme((currentTheme) => (currentTheme === "dark" ? "light" : "dark")); + } + + return ( +
+ + +
+
+ + + + + +
+ + +
+
+ ); +} diff --git a/client/src/api/client.js b/client/src/api/client.js new file mode 100644 index 00000000..f47eb98b --- /dev/null +++ b/client/src/api/client.js @@ -0,0 +1,63 @@ +import { API_BASE_URL } from "../config/app"; + +async function requestJson(pathname, params = {}) { + const requestUrl = new URL(pathname, `${API_BASE_URL}/`); + + for (const [parameterName, parameterValue] of Object.entries(params)) { + if (parameterValue === undefined || parameterValue === null || parameterValue === "") { + continue; + } + + requestUrl.searchParams.set(parameterName, String(parameterValue)); + } + + let response; + + try { + response = await fetch(requestUrl); + } catch (error) { + console.error("API network error", requestUrl.toString(), error); + throw new Error( + `Не удалось подключиться к серверу. Проверьте, что backend запущен по адресу ${API_BASE_URL}.`, + ); + } + + const responseText = await response.text(); + let payload = {}; + + if (responseText) { + try { + payload = JSON.parse(responseText); + } catch (error) { + console.error("API response parse error", requestUrl.toString(), responseText); + throw new Error("Сервер вернул некорректный ответ."); + } + } + + if (!response.ok) { + console.error("API request failed", response.status, payload); + throw new Error(payload.error || "Не удалось выполнить запрос."); + } + + return payload; +} + +export async function suggestStations(searchText, limit = 8) { + return requestJson("stations/suggest", { searchText, limit }); +} + +export async function loadNearbyStations(lat, lng, distance, limit = 8) { + return requestJson("stations/nearby", { lat, lng, distance, limit }); +} + +export async function loadStationSchedule(stationCode, date) { + return requestJson(`stations/${stationCode}/schedule`, { date }); +} + +export async function loadRoutesBetweenStations(fromStationCode, toStationCode, date) { + return requestJson("routes/search", { + from: fromStationCode, + to: toStationCode, + date, + }); +} diff --git a/client/src/components/AppHeader.jsx b/client/src/components/AppHeader.jsx new file mode 100644 index 00000000..e0ec7d89 --- /dev/null +++ b/client/src/components/AppHeader.jsx @@ -0,0 +1,55 @@ +import { APP_NAME } from "../config/app"; + +function SunIcon() { + return ( + + ); +} + +function MoonIcon() { + return ( + + ); +} + +export function AppHeader({ theme, onThemeToggle }) { + const nextThemeLabel = theme === "dark" ? "Включить светлую тему" : "Включить тёмную тему"; + + return ( +
+
+
+

Пригородные поезда

+

{APP_NAME}

+

+ Поиск станции, расписание и маршруты по Самаре и области. +

+
+
+ + + +
+
+
+ ); +} diff --git a/client/src/components/EmptyState.jsx b/client/src/components/EmptyState.jsx new file mode 100644 index 00000000..e6548925 --- /dev/null +++ b/client/src/components/EmptyState.jsx @@ -0,0 +1,8 @@ +export function EmptyState({ title, text }) { + return ( +
+

{title}

+

{text}

+
+ ); +} diff --git a/client/src/components/FavoritesPanel.jsx b/client/src/components/FavoritesPanel.jsx new file mode 100644 index 00000000..33444024 --- /dev/null +++ b/client/src/components/FavoritesPanel.jsx @@ -0,0 +1,82 @@ +import { formatStationLabel } from "../utils/formatters"; +import { EmptyState } from "./EmptyState"; + +export function FavoritesPanel({ + favoriteStations, + onPickStation, + onRemoveFavorite, + onUseAsRouteFrom, + onUseAsRouteTo, + selectedStationCode, +}) { + function renderEmptyState() { + return ( + + ); + } + + function renderFavoriteList() { + return ( +
+ {favoriteStations.map((station) => ( +
+
+

{station.title}

+

{formatStationLabel(station)}

+
+
+ + + + +
+
+ ))} +
+ ); + } + + return ( +
+
+
+

Избранное

+

Сохранённые станции

+

Список станций, которые вы открываете чаще.

+
+ {favoriteStations.length} +
+ {favoriteStations.length === 0 ? renderEmptyState() : renderFavoriteList()} +
+ ); +} diff --git a/client/src/components/LoadingBlock.jsx b/client/src/components/LoadingBlock.jsx new file mode 100644 index 00000000..8800381c --- /dev/null +++ b/client/src/components/LoadingBlock.jsx @@ -0,0 +1,8 @@ +export function LoadingBlock({ text = "Загрузка..." }) { + return ( +
+ + {text} +
+ ); +} diff --git a/client/src/components/RouteSearchPanel.jsx b/client/src/components/RouteSearchPanel.jsx new file mode 100644 index 00000000..0f25eca8 --- /dev/null +++ b/client/src/components/RouteSearchPanel.jsx @@ -0,0 +1,158 @@ +import { formatDateTime } from "../utils/dates"; +import { formatDuration, formatStopsLabel, formatTransportLabel } from "../utils/formatters"; +import { EmptyState } from "./EmptyState"; +import { LoadingBlock } from "./LoadingBlock"; +import { StationAutocomplete } from "./StationAutocomplete"; + +function RouteCard({ segment }) { + return ( +
+
+
+

{segment.thread.shortTitle || segment.thread.title}

+

+ {segment.thread.number ? `№ ${segment.thread.number}` : "Без номера"} + {segment.thread.transportSubtype + ? ` • ${segment.thread.transportSubtype}` + : ""} + {segment.hasTransfers ? " • с пересадками" : " • прямой"} +

+
+
+ {segment.ticketText ? ( + + {segment.ticketText} + + ) : null} + {segment.isInterval ? ( + + интервальный + + ) : null} +
+
+ +
+
+ {segment.from.title} + {formatDateTime(segment.departure)} + + {segment.departurePlatform + ? `платформа ${segment.departurePlatform}` + : "платформа не указана"} + +
+
{formatDuration(segment.duration)}
+
+ {segment.to.title} + {formatDateTime(segment.arrival)} + + {segment.arrivalPlatform + ? `платформа ${segment.arrivalPlatform}` + : "платформа не указана"} + +
+
+ +
+
+
Перевозчик
+
{segment.thread.carrier || "не указан"}
+
+
+
Остановки
+
{formatStopsLabel(segment.stops)}
+
+
+
Дни следования
+
{segment.days || segment.thread.intervalDensity || "нет данных"}
+
+
+
Транспорт
+
+ {segment.thread.vehicle || + formatTransportLabel(segment.thread.transportType)} +
+
+
+
+ ); +} + +export function RouteSearchPanel({ + fromStation, + toStation, + selectedDate, + onFromStationSelect, + onToStationSelect, + onDateChange, + onSubmit, + isLoading, + errorText, + routeSegments, +}) { + const visibleRouteSegments = routeSegments.slice(0, 12); + + return ( +
+
+
+

Маршрут между станциями

+

Расписание электричек между двумя станциями

+
+
+ +
+ + + + + + + {isLoading ? : null} + {errorText ?

{errorText}

: null} + {!isLoading && !errorText && routeSegments.length > 12 ? ( +

+ Показаны первые 12 вариантов из {routeSegments.length}. +

+ ) : null} + + {!isLoading && !errorText && routeSegments.length === 0 ? ( + + ) : null} + + {routeSegments.length > 0 ? ( +
+ {visibleRouteSegments.map((segment) => ( + + ))} +
+ ) : null} +
+ ); +} diff --git a/client/src/components/StationAutocomplete.jsx b/client/src/components/StationAutocomplete.jsx new file mode 100644 index 00000000..3dca2863 --- /dev/null +++ b/client/src/components/StationAutocomplete.jsx @@ -0,0 +1,127 @@ +import { useEffect, useState } from "react"; +import { suggestStations } from "../api/client"; +import { formatStationLabel } from "../utils/formatters"; +import { LoadingBlock } from "./LoadingBlock"; + +export function StationAutocomplete({ label, placeholder, selectedStation, onSelect, inputId }) { + const [inputValue, setInputValue] = useState( + selectedStation ? formatStationLabel(selectedStation) : "", + ); + const [stationSuggestions, setStationSuggestions] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [errorText, setErrorText] = useState(""); + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + + useEffect(() => { + setInputValue(selectedStation ? formatStationLabel(selectedStation) : ""); + setStationSuggestions([]); + setIsDropdownOpen(false); + }, [selectedStation]); + + useEffect(() => { + const trimmedValue = inputValue.trim(); + + if (trimmedValue.length < 2) { + setStationSuggestions([]); + setIsDropdownOpen(false); + setErrorText(""); + return undefined; + } + + let isCancelled = false; + const timeoutId = window.setTimeout(async () => { + setIsLoading(true); + setErrorText(""); + + try { + const response = await suggestStations(trimmedValue); + + if (isCancelled) { + return; + } + + setStationSuggestions(response.stations || []); + setIsDropdownOpen(true); + } catch (error) { + if (isCancelled) { + return; + } + + setErrorText(error.message); + } finally { + if (!isCancelled) { + setIsLoading(false); + } + } + }, 350); + + return () => { + isCancelled = true; + window.clearTimeout(timeoutId); + }; + }, [inputValue]); + + function handleSuggestionClick(station) { + onSelect(station); + setInputValue(formatStationLabel(station)); + setIsDropdownOpen(false); + } + + function handleClearClick() { + setInputValue(""); + setStationSuggestions([]); + setIsDropdownOpen(false); + onSelect(null); + } + + return ( +
+ +
+ setInputValue(event.target.value)} + placeholder={placeholder} + autoComplete="off" + /> + {inputValue ? ( + + ) : null} +
+ + {isLoading ? : null} + {errorText ?

{errorText}

: null} + + {isDropdownOpen && stationSuggestions.length > 0 ? ( +
    + {stationSuggestions.map((station) => ( +
  • + +
  • + ))} +
+ ) : null} +
+ ); +} diff --git a/client/src/components/StationMap.jsx b/client/src/components/StationMap.jsx new file mode 100644 index 00000000..cd98a6a7 --- /dev/null +++ b/client/src/components/StationMap.jsx @@ -0,0 +1,131 @@ +import { useEffect, useRef } from "react"; +import Feature from "ol/Feature.js"; +import Map from "ol/Map.js"; +import View from "ol/View.js"; +import { Point } from "ol/geom.js"; +import TileLayer from "ol/layer/Tile.js"; +import VectorLayer from "ol/layer/Vector.js"; +import { fromLonLat, toLonLat } from "ol/proj.js"; +import OSM from "ol/source/OSM.js"; +import VectorSource from "ol/source/Vector.js"; +import { Circle as CircleStyle, Fill, Stroke, Style } from "ol/style.js"; +import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM } from "../config/map"; + +const defaultMarkerStyle = new Style({ + image: new CircleStyle({ + radius: 6, + fill: new Fill({ color: "#8b2332" }), + stroke: new Stroke({ color: "#fff7ef", width: 2 }), + }), +}); + +const activeMarkerStyle = new Style({ + image: new CircleStyle({ + radius: 8, + fill: new Fill({ color: "#0d6efd" }), + stroke: new Stroke({ color: "#ffffff", width: 3 }), + }), +}); + +const pickedPointStyle = new Style({ + image: new CircleStyle({ + radius: 7, + fill: new Fill({ color: "#dc3545" }), + stroke: new Stroke({ color: "#ffffff", width: 3 }), + }), +}); + +function hasCoordinates(point) { + return point?.lng != null && point?.lat != null; +} + +function makePointFeature(longitude, latitude, style) { + const feature = new Feature({ + geometry: new Point(fromLonLat([longitude, latitude])), + }); + + feature.setStyle(style); + return feature; +} + +export function StationMap({ nearbyStations, selectedStation, pickedPoint, onMapPointPick }) { + const mapElementRef = useRef(null); + const mapInstanceRef = useRef(null); + const markersRef = useRef(new VectorSource()); + + useEffect(() => { + if (!mapElementRef.current || mapInstanceRef.current) { + return; + } + + const markerLayer = new VectorLayer({ + source: markersRef.current, + }); + + const map = new Map({ + target: mapElementRef.current, + layers: [ + new TileLayer({ + source: new OSM(), + }), + markerLayer, + ], + view: new View({ + center: fromLonLat(DEFAULT_MAP_CENTER), + zoom: DEFAULT_MAP_ZOOM, + }), + }); + + map.on("click", (event) => { + const [longitude, latitude] = toLonLat(event.coordinate); + onMapPointPick({ + lat: Number(latitude.toFixed(6)), + lng: Number(longitude.toFixed(6)), + }); + }); + + mapInstanceRef.current = map; + }, [onMapPointPick]); + + useEffect(() => { + const source = markersRef.current; + source.clear(); + const renderedCodes = new Set(); + + if (hasCoordinates(pickedPoint)) { + source.addFeature(makePointFeature(pickedPoint.lng, pickedPoint.lat, pickedPointStyle)); + } + + for (const station of nearbyStations) { + if (!hasCoordinates(station)) { + continue; + } + + const markerStyle = + station.code === selectedStation?.code ? activeMarkerStyle : defaultMarkerStyle; + source.addFeature(makePointFeature(station.lng, station.lat, markerStyle)); + renderedCodes.add(station.code); + } + + if (selectedStation?.code && hasCoordinates(selectedStation)) { + if (!renderedCodes.has(selectedStation.code)) { + source.addFeature( + makePointFeature(selectedStation.lng, selectedStation.lat, activeMarkerStyle), + ); + } + } + + if (hasCoordinates(selectedStation) && mapInstanceRef.current) { + mapInstanceRef.current.getView().animate({ + center: fromLonLat([selectedStation.lng, selectedStation.lat]), + zoom: 12, + }); + } else if (hasCoordinates(pickedPoint) && mapInstanceRef.current) { + mapInstanceRef.current + .getView() + .animate({ center: fromLonLat([pickedPoint.lng, pickedPoint.lat]), zoom: 12 }); + } + }, [nearbyStations, pickedPoint, selectedStation]); + + return
; +} diff --git a/client/src/components/StationSchedulePanel.jsx b/client/src/components/StationSchedulePanel.jsx new file mode 100644 index 00000000..84379dc0 --- /dev/null +++ b/client/src/components/StationSchedulePanel.jsx @@ -0,0 +1,114 @@ +import { formatDateTime, formatTime } from "../utils/dates"; +import { formatStopsLabel } from "../utils/formatters"; +import { EmptyState } from "./EmptyState"; +import { LoadingBlock } from "./LoadingBlock"; + +function ScheduleCard({ entry }) { + return ( +
+
+
+

{entry.thread.shortTitle || entry.thread.title}

+

+ {entry.thread.number ? `№ ${entry.thread.number}` : "Без номера"} + {entry.thread.transportSubtype ? ` • ${entry.thread.transportSubtype}` : ""} +

+
+ + {entry.thread.carrier || "Перевозчик не указан"} + +
+ +
+
+ Прибытие + {formatTime(entry.arrival)} + {formatDateTime(entry.arrival)} +
+
+ Отправление + {formatTime(entry.departure)} + {formatDateTime(entry.departure)} +
+
+ +
+
+
Платформа
+
{entry.platform || "не указана"}
+
+
+
Терминал
+
{entry.terminal || "не указан"}
+
+
+
Остановки
+
{formatStopsLabel(entry.stops)}
+
+
+
Дни следования
+
{entry.days || entry.exceptDays || "нет данных"}
+
+
+
+ ); +} + +export function StationSchedulePanel({ + selectedStation, + selectedDate, + onDateChange, + isLoading, + errorText, + scheduleEntries, +}) { + const hasSelectedStation = Boolean(selectedStation?.code); + + return ( +
+
+
+

Расписание по станции

+

Все проходящие электрички через выбранную станцию

+
+ +
+ + {!hasSelectedStation ? ( + + ) : null} + + {hasSelectedStation && isLoading ? ( + + ) : null} + {hasSelectedStation && errorText ?

{errorText}

: null} + + {hasSelectedStation && !isLoading && !errorText && scheduleEntries.length === 0 ? ( + + ) : null} + + {hasSelectedStation && scheduleEntries.length > 0 ? ( +
+ {scheduleEntries.map((entry) => ( + + ))} +
+ ) : null} +
+ ); +} diff --git a/client/src/components/StationSearchPanel.jsx b/client/src/components/StationSearchPanel.jsx new file mode 100644 index 00000000..88913b94 --- /dev/null +++ b/client/src/components/StationSearchPanel.jsx @@ -0,0 +1,146 @@ +import { MAP_POINT_SEARCH_DISTANCE } from "../config/map"; +import { + formatDistance, + formatStationLabel, + formatStationTypeLabel, + formatTransportLabel, +} from "../utils/formatters"; +import { EmptyState } from "./EmptyState"; +import { LoadingBlock } from "./LoadingBlock"; +import { StationAutocomplete } from "./StationAutocomplete"; +import { StationMap } from "./StationMap"; + +function FavoriteButton({ active, onClick }) { + return ( + + ); +} + +export function StationSearchPanel({ + selectedStation, + onStationSelect, + onFavoriteToggle, + isFavorite, + onMapPointPick, + pickedPoint, + nearbyStations, + nearbyStationsLoading, + nearbyStationsError, +}) { + return ( +
+
+
+

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

+

Выберите станцию по названию или по карте

+
+ {selectedStation ? ( + onFavoriteToggle(selectedStation)} + /> + ) : null} +
+ +
+
+ + + {selectedStation ? ( +
+
+

{selectedStation.title}

+

{formatStationLabel(selectedStation)}

+
+
+
+
Код станции
+
{selectedStation.code}
+
+
+
Тип
+
{formatStationTypeLabel(selectedStation.stationType)}
+
+
+
Транспорт
+
{formatTransportLabel(selectedStation.transportType)}
+
+
+
+ ) : ( + + )} +
+ +
+
+

+ Клик по карте ищет ближайшие станции в радиусе{" "} + {MAP_POINT_SEARCH_DISTANCE} км. +

+
+ + + {nearbyStationsLoading ? : null} + {nearbyStationsError ? ( +

{nearbyStationsError}

+ ) : null} + {!nearbyStationsLoading && + !nearbyStationsError && + pickedPoint && + nearbyStations.length === 0 ? ( + + ) : null} + + {nearbyStations.length > 0 ? ( +
+ {nearbyStations.map((station) => ( + + ))} +
+ ) : null} +
+
+
+ ); +} diff --git a/client/src/config/app.js b/client/src/config/app.js new file mode 100644 index 00000000..1abe5ed7 --- /dev/null +++ b/client/src/config/app.js @@ -0,0 +1,8 @@ +export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || "http://localhost:3001/api"; + +export const FAVORITES_STORAGE_KEY = + import.meta.env.VITE_FAVORITES_STORAGE_KEY || "suburban_lab_favorites"; + +export const THEME_STORAGE_KEY = import.meta.env.VITE_THEME_STORAGE_KEY || "suburban_lab_theme"; + +export const APP_NAME = "Прибывалка для электричек"; diff --git a/client/src/config/map.js b/client/src/config/map.js new file mode 100644 index 00000000..e32eb0e7 --- /dev/null +++ b/client/src/config/map.js @@ -0,0 +1,3 @@ +export const DEFAULT_MAP_CENTER = [50.121252, 53.185568]; +export const DEFAULT_MAP_ZOOM = 11; +export const MAP_POINT_SEARCH_DISTANCE = 25; diff --git a/client/src/hooks/useFavorites.js b/client/src/hooks/useFavorites.js new file mode 100644 index 00000000..693391f4 --- /dev/null +++ b/client/src/hooks/useFavorites.js @@ -0,0 +1,70 @@ +import { useEffect, useState } from "react"; +import { FAVORITES_STORAGE_KEY } from "../config/app"; +import { readStoredList, writeStoredList } from "../utils/storage"; + +function normalizeFavorites(items) { + return [...items].sort((leftItem, rightItem) => + `${leftItem.title} ${leftItem.region}`.localeCompare( + `${rightItem.title} ${rightItem.region}`, + "ru", + ), + ); +} + +function hasFavoriteInList(items, stationCode) { + return items.some((station) => station.code === stationCode); +} + +export function useFavorites() { + const [favoriteStations, setFavoriteStations] = useState(() => + normalizeFavorites(readStoredList(FAVORITES_STORAGE_KEY)), + ); + + useEffect(() => { + if (typeof window === "undefined") { + return undefined; + } + + function handleStorageChange(event) { + if (event.key && event.key !== FAVORITES_STORAGE_KEY) { + return; + } + + setFavoriteStations(normalizeFavorites(readStoredList(FAVORITES_STORAGE_KEY))); + } + + window.addEventListener("storage", handleStorageChange); + return () => window.removeEventListener("storage", handleStorageChange); + }, []); + + function addFavorite(station) { + setFavoriteStations((currentStations) => { + if (hasFavoriteInList(currentStations, station.code)) { + return currentStations; + } + + const nextStations = normalizeFavorites([...currentStations, station]); + writeStoredList(FAVORITES_STORAGE_KEY, nextStations); + return nextStations; + }); + } + + function removeFavorite(stationCode) { + setFavoriteStations((currentStations) => { + const nextStations = currentStations.filter((station) => station.code !== stationCode); + writeStoredList(FAVORITES_STORAGE_KEY, nextStations); + return nextStations; + }); + } + + function hasFavorite(stationCode) { + return hasFavoriteInList(favoriteStations, stationCode); + } + + return { + favoriteStations, + addFavorite, + removeFavorite, + hasFavorite, + }; +} diff --git a/client/src/main.jsx b/client/src/main.jsx new file mode 100644 index 00000000..2d3bef59 --- /dev/null +++ b/client/src/main.jsx @@ -0,0 +1,6 @@ +import ReactDOM from "react-dom/client"; +import "ol/ol.css"; +import App from "./App"; +import "./styles/app.css"; + +ReactDOM.createRoot(document.getElementById("root")).render(); diff --git a/client/src/styles/app.css b/client/src/styles/app.css new file mode 100644 index 00000000..8d54e52d --- /dev/null +++ b/client/src/styles/app.css @@ -0,0 +1,810 @@ +:root { + color-scheme: light; + --page-bg: #f3f6fa; + --surface: #ffffff; + --surface-muted: #f8fafc; + --surface-strong: #ffffff; + --border: #d7e0ea; + --text-main: #1f2937; + --text-soft: #6b7280; + --primary: #0d6efd; + --primary-dark: #0b5ed7; + --danger: #dc3545; + --success: #198754; + --input-bg: #ffffff; + --input-border: #c6d0db; + --accent-surface: #eef5ff; + --accent-border: #cfe2ff; + --accent-strong-border: #9ec5fe; + --accent-text: #0d6efd; + --hero-bg: linear-gradient(135deg, #17396d 0%, #1565df 56%, #8ebdff 100%); + --hero-text: #ffffff; + --hero-soft: rgba(255, 255, 255, 0.8); + --hero-chip-bg: rgba(255, 255, 255, 0.14); + --hero-chip-border: rgba(255, 255, 255, 0.24); + --shadow: 0 10px 24px rgba(15, 23, 42, 0.06); + --radius-lg: 18px; + --radius-md: 14px; + --radius-sm: 10px; +} + +:root[data-theme="dark"] { + color-scheme: dark; + --page-bg: #09121f; + --surface: #0f1a2b; + --surface-muted: #132237; + --surface-strong: #18283f; + --border: #27405f; + --text-main: #edf3ff; + --text-soft: #9eb1ca; + --primary: #6aa6ff; + --primary-dark: #4d8ef0; + --danger: #ff7f8d; + --success: #48d597; + --input-bg: #0d1726; + --input-border: #35506f; + --accent-surface: rgba(106, 166, 255, 0.14); + --accent-border: rgba(106, 166, 255, 0.28); + --accent-strong-border: rgba(106, 166, 255, 0.55); + --accent-text: #92beff; + --hero-bg: linear-gradient(135deg, #0d2346 0%, #15345f 56%, #2d5f9f 100%); + --hero-text: #f7fbff; + --hero-soft: rgba(247, 251, 255, 0.76); + --hero-chip-bg: rgba(8, 18, 33, 0.3); + --hero-chip-border: rgba(190, 219, 255, 0.16); + --shadow: 0 18px 32px rgba(0, 0, 0, 0.28); +} + +* { + box-sizing: border-box; +} + +html { + scroll-behavior: smooth; +} + +body { + margin: 0; + font-family: "Segoe UI", Roboto, Arial, sans-serif; + color: var(--text-main); + background: var(--page-bg); + transition: + background-color 0.25s ease, + color 0.25s ease; +} + +a { + color: inherit; +} + +button, +input { + font: inherit; +} + +button { + cursor: pointer; +} + +.app-shell { + width: min(1280px, calc(100% - 24px)); + margin: 0 auto; + padding: 16px 0 40px; +} + +.page-header { + margin-bottom: 20px; + border: 1px solid var(--hero-chip-border); + border-radius: 22px; + background: var(--hero-bg); + box-shadow: 0 18px 36px rgba(16, 31, 57, 0.18); + color: var(--hero-text); +} + +.page-header__inner { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 24px; + padding: 22px; +} + +.page-header__label, +.section-kicker { + margin: 0 0 6px; + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.page-header__label { + color: var(--hero-soft); +} + +.section-kicker { + color: var(--primary); +} + +.page-header__main h1, +.panel h2 { + margin: 0; + font-size: clamp(1.55rem, 3vw, 2.2rem); + line-height: 1.15; +} + +.page-header__text { + margin: 8px 0 0; + color: var(--hero-soft); + line-height: 1.5; + max-width: 700px; +} + +.page-header__controls { + display: flex; + align-items: center; + gap: 12px; + margin-left: auto; + flex-wrap: nowrap; +} + +.theme-switch { + display: inline-grid; + place-items: center; + width: 54px; + height: 54px; + padding: 0; + border: 1px solid var(--hero-chip-border); + border-radius: 16px; + background: var(--hero-chip-bg); + color: var(--hero-text); + transition: + transform 0.2s ease, + background-color 0.2s ease, + border-color 0.2s ease; +} + +.theme-switch:hover { + transform: translateY(-1px); +} + +.theme-switch--light { + background: rgba(255, 255, 255, 0.2); +} + +.theme-switch--dark { + background: rgba(6, 14, 28, 0.34); +} + +.theme-switch__icon { + display: inline-grid; + place-items: center; + width: 26px; + height: 26px; +} + +.theme-switch__icon svg { + width: 24px; + height: 24px; + fill: none; + stroke: currentColor; + stroke-width: 1.8; + stroke-linecap: round; + stroke-linejoin: round; +} + +.theme-switch--light .theme-switch__icon { + color: #fff4b0; +} + +.theme-switch--dark .theme-switch__icon { + color: #d9e6ff; +} + +.page-header__nav { + display: flex; + flex-wrap: nowrap; + gap: 10px; + justify-content: flex-start; +} + +.page-header__nav a { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 40px; + padding: 0 14px; + border: 1px solid var(--hero-chip-border); + border-radius: 999px; + background: var(--hero-chip-bg); + color: var(--hero-text); + text-decoration: none; + font-weight: 600; +} + +.layout { + display: grid; + gap: 20px; +} + +.layout__main, +.layout__aside { + display: grid; + gap: 20px; +} + +.layout__aside { + align-content: start; +} + +.panel { + border: 1px solid var(--border); + border-radius: 22px; + background: var(--surface); + box-shadow: var(--shadow); + padding: 20px; +} + +.panel--compact { + padding: 18px; +} + +.panel__heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-bottom: 18px; +} + +.panel-grid { + display: grid; + gap: 16px; + align-items: start; +} + +.panel-card { + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: var(--surface-muted); + padding: 16px; + min-height: 100%; +} + +.field-label, +.date-field span { + display: block; + margin-bottom: 8px; + font-size: 0.92rem; + font-weight: 600; +} + +.station-autocomplete, +.date-field { + width: 100%; +} + +.station-autocomplete__control { + position: relative; +} + +input { + width: 100%; + min-height: 46px; + border: 1px solid var(--input-border); + border-radius: 12px; + background: var(--input-bg); + padding: 10px 14px; + color: var(--text-main); +} + +input:focus { + outline: 0; + border-color: #8ab4ff; + box-shadow: 0 0 0 3px rgba(13, 110, 253, 0.15); +} + +.icon-button { + position: absolute; + top: 50%; + right: 8px; + width: 32px; + height: 32px; + border: 1px solid var(--border); + border-radius: 50%; + background: var(--input-bg); + color: var(--text-main); + transform: translateY(-50%); +} + +.suggestions-list, +.plain-list { + margin: 12px 0 0; + padding: 0; + list-style: none; +} + +.suggestions-list { + display: grid; + gap: 8px; +} + +.suggestions-list__item, +.nearby-list__item { + width: 100%; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--surface-strong); + padding: 12px 14px; + text-align: left; +} + +.suggestions-list__item span, +.nearby-list__item span, +.station-summary__header p, +.favorite-card p, +.schedule-card__subtitle, +.route-card__header p { + display: block; + margin-top: 4px; + color: var(--text-soft); +} + +.loading-block { + display: inline-flex; + align-items: center; + gap: 10px; + margin-top: 12px; + padding: 10px 12px; + border: 1px solid var(--accent-border); + border-radius: 999px; + background: var(--accent-surface); + color: var(--accent-text); +} + +.loading-block__dot { + width: 10px; + height: 10px; + border-radius: 50%; + background: currentColor; + animation: pulse 1s infinite ease-in-out; +} + +.field-error { + margin: 12px 0 0; + color: var(--danger); +} + +.empty-state { + border: 1px dashed var(--border); + border-radius: 14px; + padding: 16px; + background: var(--surface-muted); +} + +.empty-state h3 { + margin: 0 0 8px; + font-size: 1rem; +} + +.empty-state p, +.map-note p, +.plain-list li { + margin: 0; + line-height: 1.55; + color: var(--text-soft); +} + +.plain-list { + display: grid; + gap: 10px; +} + +.map-note { + margin-bottom: 12px; +} + +.station-summary { + margin-top: 14px; + border: 1px solid var(--border); + border-radius: 14px; + background: var(--surface-strong); + padding: 14px; +} + +.station-summary__header h3, +.favorite-card h3, +.schedule-card h3, +.route-card h3 { + margin: 0; + font-size: 1.05rem; +} + +.station-summary__meta, +.schedule-card__meta, +.route-card__meta { + display: grid; + gap: 10px; + margin: 14px 0 0; +} + +.station-summary__meta div, +.schedule-card__meta div, +.route-card__meta div { + padding: 12px; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--surface-muted); +} + +.station-summary__meta dt, +.schedule-card__meta dt, +.route-card__meta dt { + margin-bottom: 4px; + font-size: 0.82rem; + color: var(--text-soft); +} + +.station-summary__meta dd, +.schedule-card__meta dd, +.route-card__meta dd { + margin: 0; +} + +.station-map { + height: 340px; + border: 1px solid var(--border); + border-radius: 16px; + overflow: hidden; + background: #dde7f0; +} + +.nearby-list, +.favorites-list, +.schedule-list, +.route-list { + display: grid; + gap: 12px; +} + +.nearby-list { + margin-top: 14px; +} + +.route-list { + margin-top: 16px; +} + +.nearby-list__item.is-selected { + border-color: var(--accent-strong-border); + background: var(--accent-surface); +} + +.date-field { + min-width: 172px; +} + +.schedule-card, +.route-card, +.favorite-card { + border: 1px solid var(--border); + border-radius: 16px; + background: var(--surface); + padding: 16px; +} + +.schedule-card__header, +.route-card__header, +.favorite-card, +.favorite-card__actions { + display: flex; + gap: 12px; +} + +.schedule-card__header, +.route-card__header, +.favorite-card { + justify-content: space-between; + align-items: flex-start; +} + +.schedule-card__carrier { + max-width: 220px; + text-align: right; + color: var(--text-soft); +} + +.schedule-times, +.route-card__timeline, +.route-form { + display: grid; + gap: 12px; +} + +.route-form { + margin-bottom: 12px; +} + +.schedule-times { + margin: 16px 0; +} + +.schedule-times div, +.route-card__timeline div, +.route-card__duration { + padding: 12px; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--surface-muted); +} + +.schedule-times span, +.route-card__timeline span, +.schedule-times small, +.route-card__timeline small { + display: block; + margin-top: 4px; + color: var(--text-soft); +} + +.schedule-times strong, +.route-card__timeline strong { + font-size: 1.1rem; +} + +.route-card__duration { + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; + color: var(--primary); +} + +.route-card__badges { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px; + align-items: flex-start; +} + +.route-card__badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 32px; + padding: 6px 12px; + border-radius: 999px; + white-space: nowrap; + font-size: 0.9rem; + font-weight: 600; +} + +.route-card__badge--price { + background: var(--accent-surface); + color: var(--accent-text); + border: 1px solid var(--accent-border); +} + +.route-card__badge--interval { + background: #f3f4f6; + color: var(--text-soft); + border: 1px solid var(--border); +} + +.primary-button, +.secondary-button, +.favorite-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + min-height: 44px; + padding: 10px 16px; + border-radius: 12px; + border: 1px solid transparent; + font-weight: 600; +} + +.route-form .primary-button { + width: 100%; +} + +.primary-button { + background: var(--primary); + color: #fff; +} + +.primary-button:hover, +.page-header__nav a:hover { + background: var(--primary-dark); + color: #fff; + border-color: var(--primary-dark); +} + +.secondary-button { + border-color: var(--border); + background: var(--surface-strong); + color: var(--text-main); +} + +.secondary-button--danger { + color: var(--danger); +} + +.favorite-button { + flex-shrink: 0; + min-width: auto; + min-height: 42px; + padding: 8px 14px; + background: var(--accent-surface); + border-color: var(--accent-strong-border); + color: var(--accent-text); + white-space: nowrap; +} + +.favorite-button.is-active { + background: var(--accent-surface); + border-color: var(--accent-strong-border); +} + +.favorite-button__icon { + font-size: 1.05rem; + line-height: 1; +} + +.favorite-button__text { + line-height: 1; +} + +.favorite-card { + flex-direction: column; + background: linear-gradient( + 180deg, + var(--accent-surface) 0, + var(--accent-surface) 56px, + var(--surface) 56px + ); +} + +.favorite-card.is-current { + border-color: var(--accent-strong-border); + box-shadow: inset 0 0 0 1px var(--accent-strong-border); +} + +.favorite-card__actions { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; + align-items: stretch; +} + +.favorite-card__actions .secondary-button { + width: 100%; + min-height: 42px; + padding: 8px 10px; + font-size: 0.95rem; + white-space: nowrap; +} + +.panel--favorites { + background: linear-gradient(180deg, var(--surface-strong) 0, var(--surface) 100%); +} + +.favorites-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 14px; + margin-bottom: 18px; +} + +.favorites-text { + margin: 8px 0 0; + color: var(--text-soft); + line-height: 1.5; +} + +.favorites-count { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 52px; + height: 52px; + padding: 0 14px; + border-radius: 16px; + border: 1px solid var(--accent-border); + background: var(--accent-surface); + color: var(--accent-text); + font-size: 1.15rem; + font-weight: 700; +} + +.results-note { + margin: 12px 0 0; + color: var(--text-soft); +} + +@keyframes pulse { + 0%, + 100% { + opacity: 0.35; + transform: scale(0.92); + } + 50% { + opacity: 1; + transform: scale(1); + } +} + +@media (min-width: 768px) { + .panel-grid, + .route-form, + .schedule-times, + .station-summary__meta { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .schedule-card__meta, + .route-card__meta { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + + .route-card__timeline { + grid-template-columns: minmax(0, 1fr) 140px minmax(0, 1fr); + align-items: stretch; + } +} + +@media (min-width: 1100px) { + .layout { + grid-template-columns: minmax(0, 2.2fr) minmax(320px, 0.9fr); + align-items: start; + } +} + +@media (max-width: 767px) { + .page-header__inner { + flex-direction: column; + align-items: flex-start; + } + + .page-header__controls, + .page-header__nav { + width: 100%; + } + + .page-header__controls { + justify-content: space-between; + align-items: flex-start; + } + + .page-header__nav { + flex-wrap: wrap; + justify-content: flex-start; + } + + .panel__heading, + .favorites-head, + .schedule-card__header, + .route-card__header { + flex-direction: column; + } + + .theme-switch { + flex: 0 0 auto; + } + + .favorite-button { + width: 100%; + } + + .route-card__badges { + justify-content: flex-start; + } + + .schedule-card__carrier { + max-width: none; + text-align: left; + } +} diff --git a/client/src/utils/dates.js b/client/src/utils/dates.js new file mode 100644 index 00000000..97c6c4bc --- /dev/null +++ b/client/src/utils/dates.js @@ -0,0 +1,27 @@ +export function todayDateValue() { + return new Date().toISOString().slice(0, 10); +} + +export function formatDateTime(isoString) { + if (!isoString) { + return "—"; + } + + return new Intl.DateTimeFormat("ru-RU", { + day: "2-digit", + month: "2-digit", + hour: "2-digit", + minute: "2-digit", + }).format(new Date(isoString)); +} + +export function formatTime(isoString) { + if (!isoString) { + return "—"; + } + + return new Intl.DateTimeFormat("ru-RU", { + hour: "2-digit", + minute: "2-digit", + }).format(new Date(isoString)); +} diff --git a/client/src/utils/formatters.js b/client/src/utils/formatters.js new file mode 100644 index 00000000..18a974e9 --- /dev/null +++ b/client/src/utils/formatters.js @@ -0,0 +1,78 @@ +export function formatStationLabel(station) { + if (!station) { + return ""; + } + + return [station.title, station.settlement, station.region].filter(Boolean).join(", "); +} + +export function formatDistance(distanceKm) { + if (!distanceKm && distanceKm !== 0) { + return ""; + } + + return `${distanceKm.toFixed(1)} км`; +} + +export function formatDuration(totalSeconds) { + if (!totalSeconds) { + return "—"; + } + + const totalMinutes = Math.round(totalSeconds / 60); + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + + if (hours && minutes) { + return `${hours} ч ${minutes} мин`; + } + + if (hours) { + return `${hours} ч`; + } + + return `${minutes} мин`; +} + +export function formatStopsLabel(stopsText) { + if (!stopsText) { + return "остановки не указаны"; + } + + return stopsText; +} + +export function formatTransportLabel(transportType) { + if (!transportType) { + return "электричка"; + } + + if (transportType === "suburban") { + return "электричка"; + } + + if (transportType === "train") { + return "поезд"; + } + + return transportType; +} + +export function formatStationTypeLabel(stationType) { + if (!stationType) { + return "не указан"; + } + + const labels = { + train_station: "вокзал", + station: "станция", + platform: "платформа", + stop: "остановочный пункт", + checkpoint: "пункт", + post: "пост", + crossing: "разъезд", + overtaking_point: "обгонный пункт", + }; + + return labels[stationType] || stationType; +} diff --git a/client/src/utils/storage.js b/client/src/utils/storage.js new file mode 100644 index 00000000..1808b7e8 --- /dev/null +++ b/client/src/utils/storage.js @@ -0,0 +1,61 @@ +export function canUseStorage() { + return typeof window !== "undefined" && typeof window.localStorage !== "undefined"; +} + +export function readStoredList(storageKey) { + if (!canUseStorage()) { + return []; + } + + try { + const rawValue = window.localStorage.getItem(storageKey); + + if (!rawValue) { + return []; + } + + const parsedValue = JSON.parse(rawValue); + return Array.isArray(parsedValue) ? parsedValue : []; + } catch (error) { + console.error("Не удалось прочитать localStorage", error); + return []; + } +} + +export function readStoredValue(storageKey, fallbackValue = "") { + if (!canUseStorage()) { + return fallbackValue; + } + + try { + const rawValue = window.localStorage.getItem(storageKey); + return rawValue ?? fallbackValue; + } catch (error) { + console.error("Не удалось прочитать localStorage", error); + return fallbackValue; + } +} + +export function writeStoredList(storageKey, items) { + if (!canUseStorage()) { + return; + } + + try { + window.localStorage.setItem(storageKey, JSON.stringify(items)); + } catch (error) { + console.error("Не удалось записать localStorage", error); + } +} + +export function writeStoredValue(storageKey, value) { + if (!canUseStorage()) { + return; + } + + try { + window.localStorage.setItem(storageKey, String(value)); + } catch (error) { + console.error("Не удалось записать localStorage", error); + } +} diff --git a/client/vite.config.js b/client/vite.config.js new file mode 100644 index 00000000..9780efaa --- /dev/null +++ b/client/vite.config.js @@ -0,0 +1,9 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + server: { + port: 5173, + }, +}); From 2e19458e277b76d045ebb47588f7e21f07c37bc1 Mon Sep 17 00:00:00 2001 From: exxxpm <127376127+exxxpm@users.noreply.github.com> Date: Mon, 13 Apr 2026 17:06:39 +0400 Subject: [PATCH 2/2] Fix --- client/package.json | 1 + client/src/App.jsx | 11 ++- client/src/assets/moon.svg | 3 + client/src/assets/sun.svg | 11 +++ client/src/components/AppHeader.jsx | 22 +---- client/src/components/FavoriteButton.jsx | 14 +++ client/src/components/RouteCard.jsx | 74 ++++++++++++++++ client/src/components/RouteSearchPanel.jsx | 87 +------------------ client/src/components/ScheduleCard.jsx | 53 +++++++++++ client/src/components/StationAutocomplete.jsx | 16 ++-- client/src/components/StationMap.jsx | 29 ++++--- .../src/components/StationSchedulePanel.jsx | 58 +------------ client/src/components/StationSearchPanel.jsx | 35 ++------ client/src/config/app.js | 2 + client/src/utils/map.js | 15 ++++ client/src/utils/storage.js | 42 +++++++-- 16 files changed, 247 insertions(+), 226 deletions(-) create mode 100644 client/src/assets/moon.svg create mode 100644 client/src/assets/sun.svg create mode 100644 client/src/components/FavoriteButton.jsx create mode 100644 client/src/components/RouteCard.jsx create mode 100644 client/src/components/ScheduleCard.jsx create mode 100644 client/src/utils/map.js diff --git a/client/package.json b/client/package.json index fbb28842..c5dcd71e 100644 --- a/client/package.json +++ b/client/package.json @@ -13,6 +13,7 @@ "react-dom": "^18.3.1" }, "devDependencies": { + "@rollup/rollup-linux-x64-gnu": "^4.60.1", "@vitejs/plugin-react": "^4.4.1", "vite": "^6.2.1" } diff --git a/client/src/App.jsx b/client/src/App.jsx index abf8aa0b..f59164b6 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -5,13 +5,12 @@ import { FavoritesPanel } from "./components/FavoritesPanel"; import { RouteSearchPanel } from "./components/RouteSearchPanel"; import { StationSchedulePanel } from "./components/StationSchedulePanel"; import { StationSearchPanel } from "./components/StationSearchPanel"; -import { THEME_STORAGE_KEY } from "./config/app"; +import { DEFAULT_THEME, THEME_STORAGE_KEY } from "./config/app"; import { MAP_POINT_SEARCH_DISTANCE } from "./config/map"; import { useFavorites } from "./hooks/useFavorites"; import { todayDateValue } from "./utils/dates"; import { readStoredValue, writeStoredValue } from "./utils/storage"; -const DEFAULT_THEME = "light"; function getSavedTheme() { const savedTheme = readStoredValue(THEME_STORAGE_KEY, DEFAULT_THEME); @@ -19,7 +18,7 @@ function getSavedTheme() { } function openSection(sectionId) { - window.location.hash = sectionId; + window.location.hash = `#${sectionId}`; } export default function App() { @@ -159,17 +158,17 @@ export default function App() { function handleFavoritePick(station) { selectStation(station); - openSection("#station-panel"); + openSection("station-panel"); } function handleFavoriteUseAsRouteFrom(station) { setRouteFromStation(station); - openSection("#route-panel"); + openSection("route-panel"); } function handleFavoriteUseAsRouteTo(station) { setRouteToStation(station); - openSection("#route-panel"); + openSection("route-panel"); } function handleThemeToggle() { diff --git a/client/src/assets/moon.svg b/client/src/assets/moon.svg new file mode 100644 index 00000000..f1c4ce79 --- /dev/null +++ b/client/src/assets/moon.svg @@ -0,0 +1,3 @@ + diff --git a/client/src/assets/sun.svg b/client/src/assets/sun.svg new file mode 100644 index 00000000..8ed99ea8 --- /dev/null +++ b/client/src/assets/sun.svg @@ -0,0 +1,11 @@ + \ No newline at end of file diff --git a/client/src/components/AppHeader.jsx b/client/src/components/AppHeader.jsx index e0ec7d89..11bdecc3 100644 --- a/client/src/components/AppHeader.jsx +++ b/client/src/components/AppHeader.jsx @@ -1,24 +1,10 @@ +import moonIconUrl from "../assets/moon.svg"; +import sunIconUrl from "../assets/sun.svg"; import { APP_NAME } from "../config/app"; -function SunIcon() { - return ( - - ); -} - -function MoonIcon() { - return ( - - ); -} - export function AppHeader({ theme, onThemeToggle }) { const nextThemeLabel = theme === "dark" ? "Включить светлую тему" : "Включить тёмную тему"; + const themeIconUrl = theme === "dark" ? moonIconUrl : sunIconUrl; return (
@@ -45,7 +31,7 @@ export function AppHeader({ theme, onThemeToggle }) { title={nextThemeLabel} >
diff --git a/client/src/components/FavoriteButton.jsx b/client/src/components/FavoriteButton.jsx new file mode 100644 index 00000000..eb387b98 --- /dev/null +++ b/client/src/components/FavoriteButton.jsx @@ -0,0 +1,14 @@ +export function FavoriteButton({ active, onClick }) { + return ( + + ); +} diff --git a/client/src/components/RouteCard.jsx b/client/src/components/RouteCard.jsx new file mode 100644 index 00000000..815b3bac --- /dev/null +++ b/client/src/components/RouteCard.jsx @@ -0,0 +1,74 @@ +import { formatDateTime } from "../utils/dates"; +import { formatDuration, formatStopsLabel, formatTransportLabel } from "../utils/formatters"; + +export function RouteCard({ segment }) { + return ( +
+
+
+

{segment.thread.shortTitle || segment.thread.title}

+

+ {segment.thread.number ? `№ ${segment.thread.number}` : "Без номера"} + {segment.thread.transportSubtype ? ` • ${segment.thread.transportSubtype}` : ""} + {segment.hasTransfers ? " • с пересадками" : " • прямой"} +

+
+
+ {segment.ticketText ? ( + + {segment.ticketText} + + ) : null} + {segment.isInterval ? ( + + интервальный + + ) : null} +
+
+ +
+
+ {segment.from.title} + {formatDateTime(segment.departure)} + + {segment.departurePlatform + ? `платформа ${segment.departurePlatform}` + : "платформа не указана"} + +
+
{formatDuration(segment.duration)}
+
+ {segment.to.title} + {formatDateTime(segment.arrival)} + + {segment.arrivalPlatform + ? `платформа ${segment.arrivalPlatform}` + : "платформа не указана"} + +
+
+ +
+
+
Перевозчик
+
{segment.thread.carrier || "не указан"}
+
+
+
Остановки
+
{formatStopsLabel(segment.stops)}
+
+
+
Дни следования
+
{segment.days || segment.thread.intervalDensity || "нет данных"}
+
+
+
Транспорт
+
+ {segment.thread.vehicle || formatTransportLabel(segment.thread.transportType)} +
+
+
+
+ ); +} diff --git a/client/src/components/RouteSearchPanel.jsx b/client/src/components/RouteSearchPanel.jsx index 0f25eca8..aaa659bc 100644 --- a/client/src/components/RouteSearchPanel.jsx +++ b/client/src/components/RouteSearchPanel.jsx @@ -1,84 +1,8 @@ -import { formatDateTime } from "../utils/dates"; -import { formatDuration, formatStopsLabel, formatTransportLabel } from "../utils/formatters"; import { EmptyState } from "./EmptyState"; import { LoadingBlock } from "./LoadingBlock"; +import { RouteCard } from "./RouteCard"; import { StationAutocomplete } from "./StationAutocomplete"; -function RouteCard({ segment }) { - return ( -
-
-
-

{segment.thread.shortTitle || segment.thread.title}

-

- {segment.thread.number ? `№ ${segment.thread.number}` : "Без номера"} - {segment.thread.transportSubtype - ? ` • ${segment.thread.transportSubtype}` - : ""} - {segment.hasTransfers ? " • с пересадками" : " • прямой"} -

-
-
- {segment.ticketText ? ( - - {segment.ticketText} - - ) : null} - {segment.isInterval ? ( - - интервальный - - ) : null} -
-
- -
-
- {segment.from.title} - {formatDateTime(segment.departure)} - - {segment.departurePlatform - ? `платформа ${segment.departurePlatform}` - : "платформа не указана"} - -
-
{formatDuration(segment.duration)}
-
- {segment.to.title} - {formatDateTime(segment.arrival)} - - {segment.arrivalPlatform - ? `платформа ${segment.arrivalPlatform}` - : "платформа не указана"} - -
-
- -
-
-
Перевозчик
-
{segment.thread.carrier || "не указан"}
-
-
-
Остановки
-
{formatStopsLabel(segment.stops)}
-
-
-
Дни следования
-
{segment.days || segment.thread.intervalDensity || "нет данных"}
-
-
-
Транспорт
-
- {segment.thread.vehicle || - formatTransportLabel(segment.thread.transportType)} -
-
-
-
- ); -} - export function RouteSearchPanel({ fromStation, toStation, @@ -134,16 +58,11 @@ export function RouteSearchPanel({ {isLoading ? : null} {errorText ?

{errorText}

: null} {!isLoading && !errorText && routeSegments.length > 12 ? ( -

- Показаны первые 12 вариантов из {routeSegments.length}. -

+

Показаны первые 12 вариантов из {routeSegments.length}.

) : null} {!isLoading && !errorText && routeSegments.length === 0 ? ( - + ) : null} {routeSegments.length > 0 ? ( diff --git a/client/src/components/ScheduleCard.jsx b/client/src/components/ScheduleCard.jsx new file mode 100644 index 00000000..2ae7bc44 --- /dev/null +++ b/client/src/components/ScheduleCard.jsx @@ -0,0 +1,53 @@ +import { formatDateTime, formatTime } from "../utils/dates"; +import { formatStopsLabel } from "../utils/formatters"; + +export function ScheduleCard({ entry }) { + return ( +
+
+
+

{entry.thread.shortTitle || entry.thread.title}

+

+ {entry.thread.number ? `№ ${entry.thread.number}` : "Без номера"} + {entry.thread.transportSubtype ? ` • ${entry.thread.transportSubtype}` : ""} +

+
+ + {entry.thread.carrier || "Перевозчик не указан"} + +
+ +
+
+ Прибытие + {formatTime(entry.arrival)} + {formatDateTime(entry.arrival)} +
+
+ Отправление + {formatTime(entry.departure)} + {formatDateTime(entry.departure)} +
+
+ +
+
+
Платформа
+
{entry.platform || "не указана"}
+
+
+
Терминал
+
{entry.terminal || "не указан"}
+
+
+
Остановки
+
{formatStopsLabel(entry.stops)}
+
+
+
Дни следования
+
{entry.days || entry.exceptDays || "нет данных"}
+
+
+
+ ); +} diff --git a/client/src/components/StationAutocomplete.jsx b/client/src/components/StationAutocomplete.jsx index 3dca2863..635f84a9 100644 --- a/client/src/components/StationAutocomplete.jsx +++ b/client/src/components/StationAutocomplete.jsx @@ -3,10 +3,8 @@ import { suggestStations } from "../api/client"; import { formatStationLabel } from "../utils/formatters"; import { LoadingBlock } from "./LoadingBlock"; -export function StationAutocomplete({ label, placeholder, selectedStation, onSelect, inputId }) { - const [inputValue, setInputValue] = useState( - selectedStation ? formatStationLabel(selectedStation) : "", - ); +export function StationAutocomplete({ label, inputId, placeholder, selectedStation, onSelect }) { + const [inputValue, setInputValue] = useState(""); const [stationSuggestions, setStationSuggestions] = useState([]); const [isLoading, setIsLoading] = useState(false); const [errorText, setErrorText] = useState(""); @@ -29,7 +27,7 @@ export function StationAutocomplete({ label, placeholder, selectedStation, onSel } let isCancelled = false; - const timeoutId = window.setTimeout(async () => { + const timeoutId = setTimeout(async () => { setIsLoading(true); setErrorText(""); @@ -57,7 +55,7 @@ export function StationAutocomplete({ label, placeholder, selectedStation, onSel return () => { isCancelled = true; - window.clearTimeout(timeoutId); + clearTimeout(timeoutId); }; }, [inputValue]); @@ -112,11 +110,7 @@ export function StationAutocomplete({ label, placeholder, selectedStation, onSel onClick={() => handleSuggestionClick(station)} > {station.title} - - {[station.settlement, station.region] - .filter(Boolean) - .join(", ")} - + {[station.settlement, station.region].filter(Boolean).join(", ")} ))} diff --git a/client/src/components/StationMap.jsx b/client/src/components/StationMap.jsx index cd98a6a7..00d2d8b6 100644 --- a/client/src/components/StationMap.jsx +++ b/client/src/components/StationMap.jsx @@ -10,6 +10,9 @@ import OSM from "ol/source/OSM.js"; import VectorSource from "ol/source/Vector.js"; import { Circle as CircleStyle, Fill, Stroke, Style } from "ol/style.js"; import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM } from "../config/map"; +import { getVectorSourceByLayerName } from "../utils/map"; + +const MARKERS_LAYER_NAME = "station-markers"; const defaultMarkerStyle = new Style({ image: new CircleStyle({ @@ -51,7 +54,6 @@ function makePointFeature(longitude, latitude, style) { export function StationMap({ nearbyStations, selectedStation, pickedPoint, onMapPointPick }) { const mapElementRef = useRef(null); const mapInstanceRef = useRef(null); - const markersRef = useRef(new VectorSource()); useEffect(() => { if (!mapElementRef.current || mapInstanceRef.current) { @@ -59,8 +61,9 @@ export function StationMap({ nearbyStations, selectedStation, pickedPoint, onMap } const markerLayer = new VectorLayer({ - source: markersRef.current, + source: new VectorSource(), }); + markerLayer.set("name", MARKERS_LAYER_NAME); const map = new Map({ target: mapElementRef.current, @@ -88,7 +91,12 @@ export function StationMap({ nearbyStations, selectedStation, pickedPoint, onMap }, [onMapPointPick]); useEffect(() => { - const source = markersRef.current; + const source = getVectorSourceByLayerName(mapInstanceRef.current, MARKERS_LAYER_NAME); + + if (!source) { + return; + } + source.clear(); const renderedCodes = new Set(); @@ -107,12 +115,8 @@ export function StationMap({ nearbyStations, selectedStation, pickedPoint, onMap renderedCodes.add(station.code); } - if (selectedStation?.code && hasCoordinates(selectedStation)) { - if (!renderedCodes.has(selectedStation.code)) { - source.addFeature( - makePointFeature(selectedStation.lng, selectedStation.lat, activeMarkerStyle), - ); - } + if (selectedStation?.code && hasCoordinates(selectedStation) && !renderedCodes.has(selectedStation.code)) { + source.addFeature(makePointFeature(selectedStation.lng, selectedStation.lat, activeMarkerStyle)); } if (hasCoordinates(selectedStation) && mapInstanceRef.current) { @@ -121,9 +125,10 @@ export function StationMap({ nearbyStations, selectedStation, pickedPoint, onMap zoom: 12, }); } else if (hasCoordinates(pickedPoint) && mapInstanceRef.current) { - mapInstanceRef.current - .getView() - .animate({ center: fromLonLat([pickedPoint.lng, pickedPoint.lat]), zoom: 12 }); + mapInstanceRef.current.getView().animate({ + center: fromLonLat([pickedPoint.lng, pickedPoint.lat]), + zoom: 12, + }); } }, [nearbyStations, pickedPoint, selectedStation]); diff --git a/client/src/components/StationSchedulePanel.jsx b/client/src/components/StationSchedulePanel.jsx index 84379dc0..b531f2ed 100644 --- a/client/src/components/StationSchedulePanel.jsx +++ b/client/src/components/StationSchedulePanel.jsx @@ -1,58 +1,6 @@ -import { formatDateTime, formatTime } from "../utils/dates"; -import { formatStopsLabel } from "../utils/formatters"; import { EmptyState } from "./EmptyState"; import { LoadingBlock } from "./LoadingBlock"; - -function ScheduleCard({ entry }) { - return ( -
-
-
-

{entry.thread.shortTitle || entry.thread.title}

-

- {entry.thread.number ? `№ ${entry.thread.number}` : "Без номера"} - {entry.thread.transportSubtype ? ` • ${entry.thread.transportSubtype}` : ""} -

-
- - {entry.thread.carrier || "Перевозчик не указан"} - -
- -
-
- Прибытие - {formatTime(entry.arrival)} - {formatDateTime(entry.arrival)} -
-
- Отправление - {formatTime(entry.departure)} - {formatDateTime(entry.departure)} -
-
- -
-
-
Платформа
-
{entry.platform || "не указана"}
-
-
-
Терминал
-
{entry.terminal || "не указан"}
-
-
-
Остановки
-
{formatStopsLabel(entry.stops)}
-
-
-
Дни следования
-
{entry.days || entry.exceptDays || "нет данных"}
-
-
-
- ); -} +import { ScheduleCard } from "./ScheduleCard"; export function StationSchedulePanel({ selectedStation, @@ -90,9 +38,7 @@ export function StationSchedulePanel({ /> ) : null} - {hasSelectedStation && isLoading ? ( - - ) : null} + {hasSelectedStation && isLoading ? : null} {hasSelectedStation && errorText ?

{errorText}

: null} {hasSelectedStation && !isLoading && !errorText && scheduleEntries.length === 0 ? ( diff --git a/client/src/components/StationSearchPanel.jsx b/client/src/components/StationSearchPanel.jsx index 88913b94..7c22d6ce 100644 --- a/client/src/components/StationSearchPanel.jsx +++ b/client/src/components/StationSearchPanel.jsx @@ -6,25 +6,11 @@ import { formatTransportLabel, } from "../utils/formatters"; import { EmptyState } from "./EmptyState"; +import { FavoriteButton } from "./FavoriteButton"; import { LoadingBlock } from "./LoadingBlock"; import { StationAutocomplete } from "./StationAutocomplete"; import { StationMap } from "./StationMap"; -function FavoriteButton({ active, onClick }) { - return ( - - ); -} - export function StationSearchPanel({ selectedStation, onStationSelect, @@ -92,10 +78,7 @@ export function StationSearchPanel({
-

- Клик по карте ищет ближайшие станции в радиусе{" "} - {MAP_POINT_SEARCH_DISTANCE} км. -

+

Клик по карте ищет ближайшие станции в радиусе {MAP_POINT_SEARCH_DISTANCE} км.

{nearbyStationsLoading ? : null} - {nearbyStationsError ? ( -

{nearbyStationsError}

- ) : null} - {!nearbyStationsLoading && - !nearbyStationsError && - pickedPoint && - nearbyStations.length === 0 ? ( - + {nearbyStationsError ?

{nearbyStationsError}

: null} + {!nearbyStationsLoading && !nearbyStationsError && pickedPoint && nearbyStations.length === 0 ? ( + ) : null} {nearbyStations.length > 0 ? ( diff --git a/client/src/config/app.js b/client/src/config/app.js index 1abe5ed7..7b23591d 100644 --- a/client/src/config/app.js +++ b/client/src/config/app.js @@ -6,3 +6,5 @@ export const FAVORITES_STORAGE_KEY = export const THEME_STORAGE_KEY = import.meta.env.VITE_THEME_STORAGE_KEY || "suburban_lab_theme"; export const APP_NAME = "Прибывалка для электричек"; + +export const DEFAULT_THEME = import.meta.env.VITE_DEFAULT_THEME || "light"; diff --git a/client/src/utils/map.js b/client/src/utils/map.js new file mode 100644 index 00000000..c4cd4db9 --- /dev/null +++ b/client/src/utils/map.js @@ -0,0 +1,15 @@ +export function getVectorSourceByLayerName(mapObject, layerName) { + if (!mapObject) { + return null; + } + + const vectorLayer = mapObject + .getAllLayers() + .find((layer) => layer.get("name") === layerName); + + if (!vectorLayer) { + return null; + } + + return vectorLayer.getSource(); +} diff --git a/client/src/utils/storage.js b/client/src/utils/storage.js index 1808b7e8..3aa422ce 100644 --- a/client/src/utils/storage.js +++ b/client/src/utils/storage.js @@ -1,14 +1,32 @@ +function getStorageSafe(storageName) { + if (typeof window === "undefined") { + return null; + } + + try { + const storage = window[storageName]; + const testKey = "__storage_test__"; + storage.setItem(testKey, testKey); + storage.removeItem(testKey); + return storage; + } catch { + return null; + } +} + export function canUseStorage() { - return typeof window !== "undefined" && typeof window.localStorage !== "undefined"; + return Boolean(getStorageSafe("localStorage")); } export function readStoredList(storageKey) { - if (!canUseStorage()) { + const storage = getStorageSafe("localStorage"); + + if (!storage) { return []; } try { - const rawValue = window.localStorage.getItem(storageKey); + const rawValue = storage.getItem(storageKey); if (!rawValue) { return []; @@ -23,12 +41,14 @@ export function readStoredList(storageKey) { } export function readStoredValue(storageKey, fallbackValue = "") { - if (!canUseStorage()) { + const storage = getStorageSafe("localStorage"); + + if (!storage) { return fallbackValue; } try { - const rawValue = window.localStorage.getItem(storageKey); + const rawValue = storage.getItem(storageKey); return rawValue ?? fallbackValue; } catch (error) { console.error("Не удалось прочитать localStorage", error); @@ -37,24 +57,28 @@ export function readStoredValue(storageKey, fallbackValue = "") { } export function writeStoredList(storageKey, items) { - if (!canUseStorage()) { + const storage = getStorageSafe("localStorage"); + + if (!storage) { return; } try { - window.localStorage.setItem(storageKey, JSON.stringify(items)); + storage.setItem(storageKey, JSON.stringify(items)); } catch (error) { console.error("Не удалось записать localStorage", error); } } export function writeStoredValue(storageKey, value) { - if (!canUseStorage()) { + const storage = getStorageSafe("localStorage"); + + if (!storage) { return; } try { - window.localStorage.setItem(storageKey, String(value)); + storage.setItem(storageKey, String(value)); } catch (error) { console.error("Не удалось записать localStorage", error); }