58 Commits

Author SHA1 Message Date
e3469763ce feat: update colors and home icon 2026-05-01 02:43:22 +03:00
7f8a327329 feat: update right widget and add new 3d widget 2026-04-30 22:57:14 +03:00
53b8ce7095 feat: udpate route widget 2026-04-28 11:04:09 +03:00
94f512e0e4 feat: update demo page + add city_id for media and articles 2026-04-28 10:57:42 +03:00
60c6840db4 feat: cache delete + empty snapshot + route page 2026-04-28 03:50:29 +03:00
248eea6f85 feat: delete select article and disable double click row selection 2026-04-26 03:21:03 +03:00
7f8b90c15e fix: fix build errors 2026-04-24 16:44:18 +03:00
d67df0c2e1 feat: demo page realisation 2026-04-24 13:17:27 +03:00
b42802aac0 feat: update route and station lists 2026-04-20 12:53:52 +03:00
938a7e6d1e feat: add sight short_name and sight features 2026-04-19 22:36:14 +03:00
beb9e932ef feat: add description in devices page 2026-04-19 21:07:14 +03:00
a3a4d2eb18 feat: testing mode banner + snapshot storage fields fix 2026-04-17 15:31:14 +03:00
d380b2570f feat: update right widget preview 2026-04-10 16:09:48 +03:00
fbf8232ce3 feat: update station names 2026-04-09 20:07:18 +03:00
8d1de769c5 feat: add snapshot memory and blocked create button 2026-04-09 18:50:40 +03:00
4b02c6e9d3 feat: trim for aplhabet sort 2026-04-06 13:23:25 +03:00
a58f438dce feat: add search for list pages 2026-04-05 15:31:42 +03:00
5e0b56c7dc feat: filter route by city_id 2026-04-05 14:37:27 +03:00
a182a52111 feat: add default size for icon and en trasnlate for secondary name when ru language is selected 2026-04-05 14:24:29 +03:00
dd5aee58e6 fix: map preview scale 2026-03-26 23:43:15 +03:00
442160ba38 v1.0.5 2026-03-23 01:21:44 +03:00
b6a9cecba6 v1.0.4 2026-03-19 01:13:10 +03:00
591ca8104d feat: role system fix 2026-03-18 21:38:50 +03:00
c3127b8d47 feat: role system 2026-03-18 20:11:07 +03:00
73070fe233 v1.0.3 2026-02-28 20:00:21 +03:00
7cf188a55c fix: do only after ot 2026-02-22 19:45:03 +03:00
2a9449ba58 feat: add txt export 2026-02-22 19:37:02 +03:00
1c097a4ca2 v1.0.2 2026-02-22 19:36:53 +03:00
048848faa0 v1.0.1 2026-02-20 19:04:04 +03:00
8fe6505249 fix: fix bug with stations in the route 2026-02-04 20:29:09 +03:00
58abe15ec4 fix: fix null password 2026-02-02 15:58:27 +03:00
144e7cb00c fix: update 3d models 2026-02-02 11:06:33 +03:00
d557664b25 feat: big major update 2026-02-02 04:00:37 +03:00
bbab6fc46a v1.0.0 2026-01-28 14:41:00 +03:00
25155a66bc feat: update tail_number to string 2026-01-07 18:16:59 +03:00
a3d574a79c feat: pagination for tables with deleted station directions 2025-12-24 15:43:25 +03:00
39e11ad5ca feat: add deleting vehicles in device page 2025-12-10 15:20:53 +03:00
7e068e49f5 feat: update transfers 2025-12-07 19:36:49 +03:00
79539d0583 fix: add anchor for station 2025-11-28 02:36:03 +03:00
c5c5f835bc feat: update map anchor for station and desciprtion search 2025-11-27 20:17:23 +03:00
5481d264e0 feat: update center route-preview and align buttons 2025-11-27 10:58:51 +03:00
d6772b1e3a feat: add loader for saving route and description for stations 2025-11-26 21:45:59 +03:00
11133b6839 fix: update preview-page bug fix sights 2025-11-26 19:37:26 +03:00
aaeaed3fa5 fix: delete try catch in try catch... 2025-11-26 13:33:55 +03:00
95fe297aae feat: update route center calculating 2025-11-26 04:29:19 +03:00
04a9ac452e feat: snapshot realization update for polling usage 2025-11-24 16:16:57 +03:00
85c71563c1 feat: Improving page loading 2025-11-20 20:17:52 +03:00
6f32c6e671 feat: Update left sidebar and fetch queries 2025-11-12 03:14:03 +03:00
0a6192c7da feat: Корректировки 07.11.25 2025-11-11 09:13:58 +03:00
b1ba3b4cd5 feat: Update 2025-11-11 03:33:26 +03:00
1917b2cf5a fix: Delete ai comments 2025-11-06 00:58:10 +03:00
5298fb9f60 feat: Add description for stations in sightbar 2025-11-06 00:32:19 +03:00
c95a6517e9 fix: 01.11.25 MapPage update + sight/station relation + preview base 2025-11-06 00:21:45 +03:00
79f523e9cb hotfix-chunks (#17)
Co-authored-by: Микаэл Оганесян <mikaeloganesan@MacBook-Pro-Mikael.local>
Reviewed-on: #17
Reviewed-by: Илья Куприец <kkzemeow@gmail.com>
2025-10-31 14:25:46 +00:00
90f3d66b22 #14 Перепись редактирования и создания маршрута (#16)
Добавлено новое поле route_name:

Текстовые поля на двух страницах
Поле в списке маршрутов

Добавлено выбор видео на двух страниц вместе с редактором статей в виде модального окна

Модальное окно позволяет создать статью, выбрать готовую, отредактировать выбранную сразу на трех языках

Микаэл:

Пожалуйста, перепроверь код, вдруг чего найдешь как улучшить

+

захости локально и потыкай пж:

создай с 0 маршрут и прикрепи к нему созданную / какую-нибудь статью с видео, можешь попробовать загрузить либо взять готовое

после того как создашь, попробуй потыкать и поменять чего-нибудь

(проще обьясню: представь, что ты Руслан)

Reviewed-on: #16
Reviewed-by: Микаэл Оганесян <15lu.akari@unprism.ru>
Co-authored-by: fisenko <kkzemeow@gmail.com>
Co-committed-by: fisenko <kkzemeow@gmail.com>
2025-10-31 11:13:08 +00:00
2b48ade2f1 #13 Чистка, исправление кода
Reviewed-on: #15
Reviewed-by: Илья Куприец <kkzemeow@gmail.com>
2025-10-29 11:21:55 +00:00
b0fdf03cc6 fix: Fix icons in `top map sightbar 2025-10-29 14:19:00 +03:00
Микаэл Оганесян
349c7009c6 закрыл задачу 13 2025-10-29 02:32:14 +03:00
115 changed files with 4655 additions and 3911 deletions

26
.gitignore vendored
View File

@@ -1,7 +1,14 @@
# Logs # Logs
<<<<<<< HEAD
logs
_.log
npm-debug.log_
=======
logs logs
*.log *.log
npm-debug.log* npm-debug.log*
>>>>>>> 6b9aa78 (init: Init React Application)
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
pnpm-debug.log* pnpm-debug.log*
@@ -10,10 +17,22 @@ lerna-debug.log*
node_modules node_modules
dist dist
dist-ssr dist-ssr
*.local <<<<<<< HEAD
\*.local
# Build artifacts # Editor directories and files
*.tsbuildinfo
.vscode/_
!.vscode/extensions.json
.idea
.DS_Store
_.suo
_.ntvs_
_.njsproj
_.sln
\*.sw?
=======
*.local
# Editor directories and files # Editor directories and files
.vscode/* .vscode/*
@@ -25,3 +44,4 @@ dist-ssr
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
>>>>>>> 6b9aa78 (init: Init React Application)

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "white-nights", "name": "white-nights",
"version": "1.0.8", "version": "1.0.6",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "white-nights", "name": "white-nights",
"version": "1.0.8", "version": "1.0.6",
"license": "UNLICENSED", "license": "UNLICENSED",
"dependencies": { "dependencies": {
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",

View File

@@ -1,7 +1,7 @@
{ {
"name": "white-nights", "name": "white-nights",
"private": true, "private": true,
"version": "1.0.8", "version": "1.0.6",
"type": "module", "type": "module",
"license": "UNLICENSED", "license": "UNLICENSED",
"scripts": { "scripts": {
@@ -18,6 +18,7 @@
"@mui/material": "^7.1.0", "@mui/material": "^7.1.0",
"@mui/x-data-grid": "^8.5.1", "@mui/x-data-grid": "^8.5.1",
"@photo-sphere-viewer/core": "^5.13.2", "@photo-sphere-viewer/core": "^5.13.2",
"@pixi/react": "^8.0.2",
"@react-three/drei": "^10.1.2", "@react-three/drei": "^10.1.2",
"@react-three/fiber": "^9.1.2", "@react-three/fiber": "^9.1.2",
"@tailwindcss/vite": "^4.1.8", "@tailwindcss/vite": "^4.1.8",
@@ -32,6 +33,7 @@
"overlayscrollbars": "^2.15.1", "overlayscrollbars": "^2.15.1",
"overlayscrollbars-react": "^0.5.6", "overlayscrollbars-react": "^0.5.6",
"path": "^0.12.7", "path": "^0.12.7",
"pixi.js": "^8.10.1",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",

View File

@@ -1,25 +1,18 @@
import * as React from "react"; import * as React from "react";
import { Router } from "./router"; import { Router } from "./router";
import { CustomTheme, languageStore } from "@shared"; import { CustomTheme } from "@shared";
import { ThemeProvider } from "@mui/material/styles"; import { ThemeProvider } from "@mui/material/styles";
import { ToastContainer } from "react-toastify"; import { ToastContainer } from "react-toastify";
import { GlobalErrorBoundary } from "./GlobalErrorBoundary"; import { GlobalErrorBoundary } from "./GlobalErrorBoundary";
import { TestingModeBanner } from "@widgets"; import { TestingModeBanner } from "@widgets";
import { observer } from "mobx-react-lite";
export const App: React.FC = observer(() => { export const App: React.FC = () => (
React.useEffect(() => { <GlobalErrorBoundary>
document.documentElement.lang = languageStore.language; <ThemeProvider theme={CustomTheme.Light}>
}, [languageStore.language]); <TestingModeBanner />
<ToastContainer />
return ( <Router />
<GlobalErrorBoundary> </ThemeProvider>
<ThemeProvider theme={CustomTheme.Light}> </GlobalErrorBoundary>
<TestingModeBanner /> );
<ToastContainer />
<Router />
</ThemeProvider>
</GlobalErrorBoundary>
);
});

View File

@@ -92,9 +92,6 @@ const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
requiredPermissions.length > 0 && requiredPermissions.length > 0 &&
!requiredPermissions.every((permission) => authStore.canAccess(permission)) !requiredPermissions.every((permission) => authStore.canAccess(permission))
) { ) {
if (location.pathname === "/devices" && authStore.hasRole("devices_maintenance_rw")) {
return <>{children}</>;
}
return <Navigate to="/" replace />; return <Navigate to="/" replace />;
} }

View File

@@ -23,7 +23,6 @@ const App = observer(() => {
loadingStatus, loadingStatus,
loadingProgress, loadingProgress,
context, context,
carrier,
setIsLoading, setIsLoading,
setLoadingStatus, setLoadingStatus,
setLoadingProgress, setLoadingProgress,
@@ -173,7 +172,6 @@ const App = observer(() => {
loadingStatus={loadingStatus} loadingStatus={loadingStatus}
loadingProgress={loadingProgress} loadingProgress={loadingProgress}
onToggleDebug={() => setIsDebugVisible((prev) => !prev)} onToggleDebug={() => setIsDebugVisible((prev) => !prev)}
videoId={carrier?.video_preview}
/> />
) : ( ) : (
<div> <div>

View File

@@ -24,54 +24,6 @@ import {
// @ts-ignore // @ts-ignore
import { orderStationsByRoute } from "../../utils/routeStationsUtils"; import { orderStationsByRoute } from "../../utils/routeStationsUtils";
import { resamplePath } from "../../utils/animationUtils"; import { resamplePath } from "../../utils/animationUtils";
import { colorStore } from "../../stores/ColorStore";
function hexToRgbString(hex: string): string | null {
const clean = hex.trim().replace(/^#/, "");
const full = clean.length === 3 ? clean.split("").map((c) => c + c).join("") : clean;
if (full.length !== 6) return null;
const r = parseInt(full.slice(0, 2), 16);
const g = parseInt(full.slice(2, 4), 16);
const b = parseInt(full.slice(4, 6), 16);
return `${r}, ${g}, ${b}`;
}
function darkenHex(hex: string, amount: number): string {
const rgb = hexToRgbString(hex);
if (!rgb) return hex;
const [r, g, b] = rgb.split(",").map(Number);
const factor = 1 - amount;
const dr = Math.round(r * factor);
const dg = Math.round(g * factor);
const db = Math.round(b * factor);
return `#${dr.toString(16).padStart(2, "0")}${dg.toString(16).padStart(2, "0")}${db.toString(16).padStart(2, "0")}`;
}
function lightenRgbString(hex: string, amount: number): string | null {
const rgb = hexToRgbString(hex);
if (!rgb) return null;
const [r, g, b] = rgb.split(",").map(Number);
const lr = Math.round(r + (255 - r) * amount);
const lg = Math.round(g + (255 - g) * amount);
const lb = Math.round(b + (255 - b) * amount);
return `${lr}, ${lg}, ${lb}`;
}
function applyCarrierColors(carrier: { main_color?: string; left_color?: string; right_color?: string }) {
const mainColor = carrier.main_color || "#006f3a";
const leftColor = carrier.left_color || "#006f3a";
const rightColor = carrier.right_color || "#006f3a";
const mainDark = darkenHex(mainColor, 0.3);
document.documentElement.style.setProperty("--carrier-main", mainColor);
document.documentElement.style.setProperty("--carrier-main-rgb", hexToRgbString(mainColor) ?? "0, 111, 58");
document.documentElement.style.setProperty("--carrier-main-dark", mainDark);
document.documentElement.style.setProperty("--carrier-left", leftColor);
document.documentElement.style.setProperty("--carrier-left-rgb", hexToRgbString(leftColor) ?? "0, 111, 58");
document.documentElement.style.setProperty("--carrier-right", rightColor);
document.documentElement.style.setProperty("--carrier-right-rgb", hexToRgbString(rightColor) ?? "0, 111, 58");
document.documentElement.style.setProperty("--carrier-right-menu-rgb", lightenRgbString(rightColor, 0.38) ?? "179, 165, 152");
}
class ApiStore { class ApiStore {
isLoading = true; isLoading = true;
@@ -163,10 +115,6 @@ class ApiStore {
getCarrier = async () => { getCarrier = async () => {
this.carrier = await getCarrier(this.route!.carrier_id!); this.carrier = await getCarrier(this.route!.carrier_id!);
applyCarrierColors(this.carrier);
if (this.carrier.main_color) {
colorStore.setMainColor(this.carrier.main_color);
}
}; };
getCity = async () => { getCity = async () => {

View File

@@ -55,7 +55,6 @@ export type GetRouteResponse = {
center_latitude: number; center_latitude: number;
center_longitude: number; center_longitude: number;
governor_appeal: number; governor_appeal: number;
button_text?: string;
id: number; id: number;
path: [number, number][]; path: [number, number][];
rotate: number; rotate: number;
@@ -77,13 +76,8 @@ export type GetCarrierResponse = {
full_name: string; full_name: string;
id: number; id: number;
logo: string; logo: string;
preview_media?: string;
video_preview?: string;
short_name: string; short_name: string;
slogan: string; slogan: string;
main_color?: string;
left_color?: string;
right_color?: string;
}; };
export type GetCityResponse = { export type GetCityResponse = {

View File

@@ -37,14 +37,14 @@ const Fullscreen3DModal = ({ isOpen, onClose, fileUrl }) => {
</div> </div>
</div> </div>
<div className="fullscreen-3d-actions"> <div className="fullscreen-3d-actions">
<button disabled={scale >= 1}> <button title="Увеличить масштаб" disabled={scale >= 1}>
<img src={scale_plus} alt="Увеличить" /> <img src={scale_plus} alt="Увеличить" />
</button> </button>
<button disabled={scale <= 0.1}> <button title="Уменьшить масштаб" disabled={scale <= 0.1}>
<img src={scale_minus} alt="Уменьшить" /> <img src={scale_minus} alt="Уменьшить" />
</button> </button>
<button onPointerUp={onClose}> <button onPointerUp={onClose} title="Закрыть">
<img src={closeIcon} alt="Закрыть" /> <img src={closeIcon} alt="Закрыть" />
</button> </button>
</div> </div>

View File

@@ -95,10 +95,9 @@ const ListOfSights = observer(() => {
// Находим все достопримечательности, начинающиеся с выбранной буквы // Находим все достопримечательности, начинающиеся с выбранной буквы
const sightsForLetter = sightData.filter((sight) => { const sightsForLetter = sightData.filter((sight) => {
const name = sight.name; if (!sight.name) return false;
if (!name) return false;
let sightFirstChar = name.trim().charAt(0); let sightFirstChar = sight.name.trim().charAt(0);
// Для китайского языка используем пиньинь или иероглиф // Для китайского языка используем пиньинь или иероглиф
if (selectedLanguageRight === "zh") { if (selectedLanguageRight === "zh") {
@@ -106,7 +105,7 @@ const ListOfSights = observer(() => {
sightFirstChar = sight.name_pinyin.trim().toUpperCase().charAt(0); sightFirstChar = sight.name_pinyin.trim().toUpperCase().charAt(0);
} else { } else {
// Если пиньинь нет, используем первый иероглиф // Если пиньинь нет, используем первый иероглиф
sightFirstChar = name.trim().charAt(0); sightFirstChar = sight.name.trim().charAt(0);
} }
} else { } else {
sightFirstChar = sightFirstChar.toUpperCase(); sightFirstChar = sightFirstChar.toUpperCase();
@@ -412,13 +411,13 @@ const ListOfSights = observer(() => {
}, [currentSelectedSight]); }, [currentSelectedSight]);
return ( return (
<div className="right-widget" lang={selectedLanguageRight}> <div className="right-widget">
{currentSelectedSight && ( {currentSelectedSight && (
<SightFrame <SightFrame
key={currentSelectedSight.id} key={currentSelectedSight.id}
media={sightFrameMedia} media={sightFrameMedia}
sight_id={currentSelectedSight.id} sight_id={currentSelectedSight.id}
sight_name={currentSelectedSight.name} sight_name={currentSelectedSight.short_name || currentSelectedSight.name}
selectedLanguageRight={selectedLanguageRight} selectedLanguageRight={selectedLanguageRight}
/> />
)} )}
@@ -441,6 +440,11 @@ const ListOfSights = observer(() => {
isLangOpen={isLangMenuOpen} isLangOpen={isLangMenuOpen}
/> />
<TransferWidget
isOpen={isTransferWidgetOpen}
selectedLanguageRight={selectedLanguage}
/>
<LanguageSelector <LanguageSelector
selectedLanguageRight={selectedLanguageRight} selectedLanguageRight={selectedLanguageRight}
onLanguageChange={handleLanguageChange} onLanguageChange={handleLanguageChange}
@@ -472,19 +476,13 @@ const ListOfSights = observer(() => {
isDisabled={isAlphabetDisabled} isDisabled={isAlphabetDisabled}
/> />
</div> </div>
</div>
<TransferWidget <div
isOpen={isTransferWidgetOpen} className="transfer-button-container"
selectedLanguageRight={selectedLanguage} style={{
/> transition: "transform 0.3s ease",
}}
<div >
className="transfer-button-container"
style={{
transition: "transform 0.3s ease",
}}
>
<div <div
style={{ style={{
backgroundColor: "black", backgroundColor: "black",
@@ -514,6 +512,7 @@ const ListOfSights = observer(() => {
</svg> </svg>
</div> </div>
</div> </div>
</div>
</div> </div>
); );
}); });

View File

@@ -14,9 +14,8 @@ const AlphabetNavigator = forwardRef(function AlphabetNavigator(
const letters = new Set(); const letters = new Set();
sightData.forEach((sight) => { sightData.forEach((sight) => {
const name = sight.short_name || sight.name; if (sight.name && sight.name.trim()) {
if (name && name.trim()) { let firstChar = sight.name.trim().charAt(0);
let firstChar = name.trim().charAt(0);
// Для китайского языка используем пиньинь (если доступен) или иероглиф // Для китайского языка используем пиньинь (если доступен) или иероглиф
if (selectedLanguage === "zh") { if (selectedLanguage === "zh") {
@@ -24,7 +23,7 @@ const AlphabetNavigator = forwardRef(function AlphabetNavigator(
firstChar = sight.name_pinyin.trim().toUpperCase().charAt(0); firstChar = sight.name_pinyin.trim().toUpperCase().charAt(0);
} else { } else {
// Если пиньинь нет, используем первый иероглиф // Если пиньинь нет, используем первый иероглиф
firstChar = name.trim().charAt(0); firstChar = sight.name.trim().charAt(0);
} }
} else { } else {
firstChar = firstChar.toUpperCase(); firstChar = firstChar.toUpperCase();

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef } from "react"; import React from "react";
const LANGUAGES = { const LANGUAGES = {
EN: "en", EN: "en",
@@ -18,26 +18,6 @@ const ListHeader = function ListHeader({
isTransferWidgetOpen, isTransferWidgetOpen,
onBackToNearest, onBackToNearest,
}) { }) {
const [isIdle, setIsIdle] = useState(false);
const timerRef = useRef(null);
useEffect(() => {
const resetTimer = () => {
setIsIdle(false);
clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => setIsIdle(true), 15000);
};
const events = ["pointermove", "pointerdown", "touchstart", "keydown"];
events.forEach((e) => window.addEventListener(e, resetTimer));
resetTimer();
return () => {
clearTimeout(timerRef.current);
events.forEach((e) => window.removeEventListener(e, resetTimer));
};
}, []);
const getTitle = () => { const getTitle = () => {
return selectedLanguageRight === LANGUAGES.RU return selectedLanguageRight === LANGUAGES.RU
? "Достопримечательности" ? "Достопримечательности"
@@ -61,11 +41,14 @@ const ListHeader = function ListHeader({
> >
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
width="20" width="21"
height="20" height="12"
viewBox="0 0 21 12" viewBox="0 0 21 12"
fill="none" fill="none"
className={`chevron-svg${isOpen ? " is-open" : ""}${isIdle ? " is-idle" : ""}`} style={{
transform: isOpen ? "rotate(180deg)" : "rotate(0deg)",
transition: "transform 0.15s ease-in-out",
}}
> >
<g clipPath="url(#clip0_658_91932)"> <g clipPath="url(#clip0_658_91932)">
<path <path

View File

@@ -36,7 +36,7 @@ const SightComponent = function SightComponent({
aria-label={`Выбрать достопримечательность ${title}`} aria-label={`Выбрать достопримечательность ${title}`}
> >
<div className="sight-image">{renderThumbnail()}</div> <div className="sight-image">{renderThumbnail()}</div>
<div className="sight-title"> <div className="sight-title" title={title}>
{title} {title}
</div> </div>
</div> </div>

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef, useMemo, useLayoutEffect, useCallback } from "react"; import React, { useState, useEffect, useRef, useMemo } from "react";
import axios from "axios"; import axios from "axios";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { useGeolocationStore } from "../../stores"; import { useGeolocationStore } from "../../stores";
@@ -41,68 +41,10 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
const [isFullscreen3D, setIsFullscreen3D] = useState(false); const [isFullscreen3D, setIsFullscreen3D] = useState(false);
const [fullscreenFileUrl, setFullscreenFileUrl] = useState(""); const [fullscreenFileUrl, setFullscreenFileUrl] = useState("");
const [threeViewResetKey, setThreeViewResetKey] = useState(0); const [threeViewResetKey, setThreeViewResetKey] = useState(0);
const [isSwitching, setIsSwitching] = useState(false);
const threeViewControlRef = useRef(null); const threeViewControlRef = useRef(null);
const mediaCache = useRef({}); const mediaCache = useRef({});
const prevSightIdRef = useRef(null);
const textWrapperRef = useRef(null); const textWrapperRef = useRef(null);
const mediaStackRef = useRef(null);
const titleRef = useRef(null);
const switchTimerRef = useRef(null);
const homeButtonRef = useRef(null);
const menuRef = useRef(null);
const [menuNeedsScroll, setMenuNeedsScroll] = useState(false);
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(false);
const currentSection = articleSections?.[selectedSection] ?? null;
const requestSection = (target) => {
setIsFullscreen3D(false);
if (target === selectedSection) return;
if (switchTimerRef.current) clearTimeout(switchTimerRef.current);
setIsSwitching(true);
switchTimerRef.current = setTimeout(() => {
setSelectedSection(target);
setIsSwitching(false);
switchTimerRef.current = null;
}, 180);
};
const updateScrollState = useCallback(() => {
const menu = menuRef.current;
if (!menu) return;
setCanScrollLeft(menu.scrollLeft > 2);
setCanScrollRight(menu.scrollLeft + menu.clientWidth < menu.scrollWidth - 2);
}, []);
useEffect(() => {
const menu = menuRef.current;
if (!menu || !menuNeedsScroll) {
setCanScrollLeft(false);
setCanScrollRight(false);
return;
}
updateScrollState();
menu.addEventListener('scroll', updateScrollState);
return () => menu.removeEventListener('scroll', updateScrollState);
}, [menuNeedsScroll, updateScrollState]);
useLayoutEffect(() => {
const menu = menuRef.current;
if (!menu) return;
const children = Array.from(menu.querySelectorAll('.sight-frame-menu-point'));
if (children.length < 2) {
setMenuNeedsScroll(false);
return;
}
const style = getComputedStyle(menu);
const availableWidth = menu.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight);
const totalChildrenWidth = children.reduce((sum, el) => sum + el.offsetWidth, 0);
const evenGap = (availableWidth - totalChildrenWidth) / (children.length + 1);
setMenuNeedsScroll(evenGap < 10);
}, [articleSections]);
const { const {
routeSights, routeSights,
@@ -142,16 +84,11 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
const controller = new AbortController(); const controller = new AbortController();
const { signal } = controller; const { signal } = controller;
const isSameSight = prevSightIdRef.current === sight_id; setIsLoadingContent(true);
prevSightIdRef.current = sight_id; setContentError(null);
setModelAspectRatio(null);
if (!isSameSight) { setIsVisible(false);
setIsLoadingContent(true); setMediaData({});
setContentError(null);
setModelAspectRatio(null);
setIsVisible(false);
setMediaData({});
}
if (!sight_id) { if (!sight_id) {
setContentError("Не указан ID статьи."); setContentError("Не указан ID статьи.");
@@ -229,18 +166,12 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
const introSection = { const introSection = {
id: media?.id || "intro-title", id: media?.id || "intro-title",
heading: heading:
sight?.name || sight_name || "Название достопримечательности", sight?.short_name || sight?.name || sight_name || "Название достопримечательности",
body: "", body: "",
}; };
const allSections = [introSection, ...rightArticles]; const allSections = [introSection, ...rightArticles];
setArticleSections(allSections); setArticleSections(allSections);
if (isSameSight) {
setIsLoadingContent(false);
setIsVisible(true);
return;
}
const cacheKey = `${sight_id}_${selectedLanguageRight}`; const cacheKey = `${sight_id}_${selectedLanguageRight}`;
if (mediaCache.current[cacheKey]) { if (mediaCache.current[cacheKey]) {
setMediaData(mediaCache.current[cacheKey]); setMediaData(mediaCache.current[cacheKey]);
@@ -281,43 +212,9 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
useEffect(() => { useEffect(() => {
setSelectedSection(0); setSelectedSection(0);
setIsSwitching(false);
if (switchTimerRef.current) {
clearTimeout(switchTimerRef.current);
switchTimerRef.current = null;
}
}, [sight_id]); }, [sight_id]);
useLayoutEffect(() => { const currentSection = articleSections?.[selectedSection] ?? null;
const scrollable = textWrapperRef.current?.querySelector(".scrollable");
if (scrollable) scrollable.scrollTop = 0;
}, [selectedSection]);
useEffect(() => {
if (!isFullscreen3D) return;
let idleSeconds = 0;
const intervalId = setInterval(() => {
idleSeconds += 1;
if (idleSeconds >= 60) {
setIsFullscreen3D(false);
}
}, 1000);
const resetIdle = () => {
idleSeconds = 0;
};
const events = ["pointerdown", "pointermove", "touchstart", "keydown"];
events.forEach((e) => window.addEventListener(e, resetIdle, { passive: true }));
return () => {
clearInterval(intervalId);
events.forEach((e) => window.removeEventListener(e, resetIdle));
};
}, [isFullscreen3D]);
const renderCurrentMedia = () => { const renderCurrentMedia = () => {
if (!articleSections || Object.keys(mediaData).length === 0) { if (!articleSections || Object.keys(mediaData).length === 0) {
@@ -347,7 +244,9 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
alt="" alt=""
className={className} className={className}
onError={(e) => { onError={(e) => {
console.warn(`Failed to load image: ${currentMediaData.path}`); console.warn(
`Failed to load image: ${currentMediaData.path}`,
);
e.target.style.display = "none"; e.target.style.display = "none";
}} }}
/> />
@@ -362,7 +261,9 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
playsInline playsInline
className={className} className={className}
onError={(e) => { onError={(e) => {
console.warn(`Failed to load video: ${currentMediaData.path}`); console.warn(
`Failed to load video: ${currentMediaData.path}`,
);
e.target.style.display = "none"; e.target.style.display = "none";
}} }}
> >
@@ -412,20 +313,27 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
<button <button
type="button" type="button"
className="three-d-control-btn" className="three-d-control-btn"
onPointerUp={() => threeViewControlRef.current?.zoomOut?.()} title="Уменьшить"
onPointerUp={() =>
threeViewControlRef.current?.zoomOut?.()
}
> >
<MinusIcon /> <MinusIcon />
</button> </button>
<button <button
type="button" type="button"
className="three-d-control-btn" className="three-d-control-btn"
onPointerUp={() => threeViewControlRef.current?.zoomIn?.()} title="Увеличить"
onPointerUp={() =>
threeViewControlRef.current?.zoomIn?.()
}
> >
<PlusIcon /> <PlusIcon />
</button> </button>
<button <button
type="button" type="button"
className="three-d-control-btn" className="three-d-control-btn"
title={isFullscreen3D ? "Свернуть" : "Развернуть"}
onPointerUp={() => { onPointerUp={() => {
if (isFullscreen3D) { if (isFullscreen3D) {
setIsFullscreen3D(false); setIsFullscreen3D(false);
@@ -440,6 +348,11 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
</div> </div>
<button <button
className={"fullscreen-3d-button"} className={"fullscreen-3d-button"}
title={
isFullscreen3D
? "Закрыть полноэкранный режим"
: "Открыть в полноэкранном режиме"
}
aria-hidden="true" aria-hidden="true"
> >
<svg <svg
@@ -455,100 +368,87 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
/> />
</svg> </svg>
</button> </button>
{isFullscreen3D && ( {isFullscreen3D && <div
style={{
position: "absolute",
top: 94,
right: 10,
zIndex: 10,
pointerEvents: "none",
}}
>
<div <div
className="cluster-sights-list"
style={{ style={{
position: "absolute", background: `linear-gradient(to bottom right, rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0) 100%), rgba(0, 111, 58, 0.4)`,
top: 94, backdropFilter: "blur(10px)",
right: 10, borderRadius: "8px",
zIndex: 10, width: 200,
pointerEvents: "none", boxShadow:
"0 0 0 1px rgba(255, 255, 255, 0.3) inset, 4px 4px 12px 0 rgba(255, 255, 255, 0.12) inset",
display: "flex",
flexDirection: "column",
padding: "8px 13px",
}} }}
> >
<div {[
className="cluster-sights-list" {
style={{ label: "Вращать",
background: `linear-gradient(to bottom right, rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0) 100%), rgba(var(--carrier-right-rgb, 0, 111, 58), 0.4)`, icon: <img src={rotate3DIcon} alt="" width="14" height="14" />,
backdropFilter: "blur(10px)", },
borderRadius: "8px", {
width: 200, label: "Приблизить / Отдалить",
boxShadow: icon: <img src={zoom3DIcon} alt="" width="14" height="14" />,
"0 0 0 1px rgba(255, 255, 255, 0.3) inset, 4px 4px 12px 0 rgba(255, 255, 255, 0.12) inset", },
display: "flex", {
flexDirection: "column", label: "Переместить",
padding: "8px 13px", icon: <img src={pan3DIcon} alt="" width="14" height="14" />,
}} },
> ].map((item, index, arr) => (
{[ <div
{ key={index}
label: "Вращать", style={{
icon: ( display: "flex",
<img alignItems: "center",
src={rotate3DIcon} height: "30px",
alt="" userSelect: "none",
width="14" touchAction: "none",
height="14" padding: "0 4px",
/> borderBottom:
), index < arr.length - 1
}, ? "1px solid rgba(255, 255, 255, 0.1)"
{ : "none",
label: "Приблизить / Отдалить", transition: "background-color 0.2s",
icon: ( }}
<img src={zoom3DIcon} alt="" width="14" height="14" /> >
), <span
},
{
label: "Переместить",
icon: (
<img src={pan3DIcon} alt="" width="14" height="14" />
),
},
].map((item, index, arr) => (
<div
key={index}
style={{ style={{
display: "flex", display: "block",
alignItems: "center", marginRight: "8px",
height: "30px", flexShrink: 0,
userSelect: "none", lineHeight: 0,
touchAction: "none",
padding: "0 4px",
borderBottom:
index < arr.length - 1
? "1px solid rgba(255, 255, 255, 0.1)"
: "none",
transition: "background-color 0.2s",
}} }}
> >
<span {item.icon}
style={{ </span>
display: "block", <span
marginRight: "8px", style={{
flexShrink: 0, color: "white",
lineHeight: 0, fontSize: "12px",
}} lineHeight: "1.5",
> fontWeight: "400",
{item.icon} overflow: "hidden",
</span> textOverflow: "ellipsis",
<span whiteSpace: "nowrap",
style={{ flex: 1,
color: "white", }}
fontSize: "12px", >
lineHeight: "1.5", {item.label}
fontWeight: "400", </span>
overflow: "hidden", </div>
textOverflow: "ellipsis", ))}
whiteSpace: "nowrap",
flex: 1,
}}
>
{item.label}
</span>
</div>
))}
</div>
</div> </div>
)} </div>}
</div> </div>
); );
default: default:
@@ -613,6 +513,7 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
const processedSightName = useMemo(() => { const processedSightName = useMemo(() => {
if (!sight_name) return sight_name; if (!sight_name) return sight_name;
// Handle \n line breaks (только в правом виджете)
if (sight_name.includes("\n")) { if (sight_name.includes("\n")) {
return sight_name.split("\n").map((line, i) => ( return sight_name.split("\n").map((line, i) => (
<React.Fragment key={i}> <React.Fragment key={i}>
@@ -657,145 +558,111 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
}, [sight_name]); }, [sight_name]);
return ( return (
<> <div
className={`sight-frame ${isVisible ? "is-visible" : ""} ${
isFullscreen3D && isCurrentMedia3D ? "three-d-fullscreen" : ""
}`}
>
{sightData?.watermark_lu && !isFullscreen3D && (
<Watermark path={ContentAPI.getMediaPath(sightData.watermark_lu)} />
)}
<div <div
className={`sight-frame ${isVisible ? "is-visible" : ""} ${ className={`sight-frame-media-stack ${
isFullscreen3D && isCurrentMedia3D ? "three-d-fullscreen" : "" isCurrentMedia3D ? "three-d-view" : ""
}`} }`}
style={getMediaStackStyle()}
> >
{sightData?.watermark_lu && !isFullscreen3D && ( {contentError ? (
<Watermark path={ContentAPI.getMediaPath(sightData.watermark_lu)} /> <div className="sight-frame-image-placeholder error-message">
{contentError}
</div>
) : isLoadingContent || !articleSections ? (
<div className="sight-frame-image-placeholder">
Загрузка контента...
</div>
) : (
renderCurrentMedia()
)} )}
<div </div>
ref={mediaStackRef} <div className="sight-frame-content">
className={`sight-frame-media-stack ${ {contentError ? (
isCurrentMedia3D ? "three-d-view" : "" <p className="error-message">{contentError}</p>
}`} ) : !currentSection ? (
style={getMediaStackStyle()} <p>Информация отсутствует.</p>
> ) : (
{contentError ? ( <>
<div className="sight-frame-image-placeholder error-message"> {!isFullscreen3D && (
{contentError} <div
</div> className={`sight-frame-title ${
) : isLoadingContent || !articleSections ? ( selectedSection === 0 ? "intro-title" : ""
<div className="sight-frame-image-placeholder"> }`}
Загрузка контента... style={{ lineHeight: titleLineHeight }}
</div>
) : (
renderCurrentMedia()
)}
</div>
<div className={`sight-frame-content ${isSwitching ? "is-switching" : ""}`}>
{contentError ? (
<p className="error-message">{contentError}</p>
) : (
<>
{!isFullscreen3D && (
<div
ref={titleRef}
className={`sight-frame-title ${
selectedSection === 0 ? "intro-title" : ""
}`}
style={{ lineHeight: titleLineHeight }}
>
<p
key={selectedSection}
className="fade-in-text"
style={{
whiteSpace: "normal",
wordBreak: "break-word",
overflowWrap: "break-word",
}}
>
{selectedSection === 0
? processedSightName || sightData?.name
: sightData?.short_name || sightData?.name}
</p>
</div>
)}
<TouchableLayout
key={selectedSection}
className={`sight-frame-text-wrapper ${selectedSection === 0 ? "is-intro" : ""}`}
ref={textWrapperRef}
> >
<div className="sight-frame-text fade-in-text"> <p
{isLoadingContent || !currentSection ? ( style={{
<div className="sight-frame-loading-placeholder" /> whiteSpace: "normal",
) : ( wordBreak: "break-word",
selectedSection !== 0 && ( overflowWrap: "break-word",
<ReactMarkdownComponent value={currentSection.body} /> }}
) >
)} {selectedSection === 0 ? processedSightName : (sightData?.short_name || sight_name)}
</p>
</div>
)}
{selectedSection !== 0 && (
<TouchableLayout
className="sight-frame-text-wrapper"
ref={textWrapperRef}
maxHeight="calc(80vh - 354px)"
>
<div className="sight-frame-text">
<ReactMarkdownComponent value={currentSection.body} />
</div> </div>
</TouchableLayout> </TouchableLayout>
</> )}
)} </>
</div> )}
</div> </div>
<div <div className="sight-frame-menu">
className="sight-frame-menu-wrapper" {selectedSection !== 0 && (
style={{ <div
opacity: isVisible ? 1 : 0, style={{
transition: "opacity 0.9s cubic-bezier(0.16, 1, 0.3, 1) 0.08s", position: "absolute",
pointerEvents: isVisible ? "auto" : "none", left: "10px",
}} marginTop: "-4.5px",
> zIndex: 1,
<div className="sight-frame-menu-fade left" style={{ opacity: canScrollLeft ? 1 : 0 }} /> paddingLeft: "15px",
<div className="sight-frame-menu-fade right" style={{ opacity: canScrollRight ? 1 : 0 }} /> paddingRight: "7.5px",
<div paddingTop: "4.5px",
className="sight-frame-menu" paddingBottom: "4.5px",
ref={menuRef} cursor: "pointer",
style={menuNeedsScroll ? { justifyContent: 'flex-start' } : undefined} }}
> onPointerUp={() => { setSelectedSection(0); setIsFullscreen3D(false); }}
{contentError ? ( >
<p className="error-message">{contentError}</p> <img src={subtractHomeIcon} alt="" width="24" height="21" style={{ display: "block" }} />
) : ( </div>
articleSections && )}
articleSections.length > 1 && {contentError ? (
articleSections.slice(1).map((section, index) => ( <p className="error-message">{contentError}</p>
<div ) : (
onPointerUp={() => requestSection(index + 1)} articleSections &&
key={section.id || section.heading || index} articleSections.length > 1 &&
data-label={section.heading} articleSections.slice(1).map((section, index) => (
className={`sight-frame-menu-point ${ <div
index + 1 === selectedSection ? "active" : "" onPointerUp={() => { setSelectedSection(index + 1); setIsFullscreen3D(false); }}
}`} key={section.id || section.heading || index}
role="button" className={`sight-frame-menu-point ${
tabIndex="0" index + 1 === selectedSection ? "active" : ""
> }`}
{section.heading} role="button"
</div> tabIndex="0"
)) >
)} {section.heading}
</div> </div>
))
)}
</div> </div>
<div </div>
ref={homeButtonRef}
style={{
position: "absolute",
left: "10px",
bottom: "115px",
zIndex: 3,
paddingLeft: "15px",
paddingRight: "7.5px",
paddingTop: "4.5px",
paddingBottom: "4.5px",
cursor: "pointer",
opacity: selectedSection !== 0 ? 1 : 0,
transition: "opacity 0.3s ease",
pointerEvents: selectedSection !== 0 ? "auto" : "none",
}}
onPointerUp={() => requestSection(0)}
>
<img
src={subtractHomeIcon}
alt=""
width="24"
height="21"
style={{ display: "block" }}
/>
</div>
</>
); );
}); });

View File

@@ -96,35 +96,20 @@ const TransferWidget = observer(function TransferWidget({
const getTransferLabel = () => { const getTransferLabel = () => {
if (selectedLanguageRight === "ru") { if (selectedLanguageRight === "ru") {
return stationName ? ( return stationName
<> ? `Пересадки остановки ${stationName}:`
<div>Пересадка на остановке</div> : "Ближайшая остановка не обнаружена";
<div>«{stationName}»:</div>
</>
) : (
"Ближайшая остановка не обнаружена"
);
} }
if (selectedLanguageRight === "en") { if (selectedLanguageRight === "en") {
return stationName ? ( return stationName
<> ? `Available transfers at station ${stationName}`
<div>Transfer at stop</div> : "Nearest station not found";
<div>«{stationName}»:</div>
</>
) : (
"Nearest station not found"
);
} }
return stationName ? ( return stationName
<> ? `在车站可用的换乘:${stationName}`
<div>在站点换乘</div> : "最近的站点未找到";
<div>«{stationName}»:</div>
</>
) : (
"最近的站点未找到"
);
}; };
const getNoTransfersMessage = () => { const getNoTransfersMessage = () => {

View File

@@ -1,7 +1,6 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import "../styles/Loader.css"; import "../styles/Loader.css";
import { getMediaUrl } from "../api/apiConfig";
const loaderVideoPath = new URL("../assets/video/taganai.mp4", import.meta.url) const loaderVideoPath = new URL("../assets/video/taganai.mp4", import.meta.url)
.href; .href;
@@ -10,14 +9,8 @@ const Loader = ({
loadingStatus = null, loadingStatus = null,
loadingProgress = null, loadingProgress = null,
onToggleDebug, onToggleDebug,
videoId = null,
}) => { }) => {
const [showLoadingInfo, setShowLoadingInfo] = useState(false); const [showLoadingInfo, setShowLoadingInfo] = useState(false);
const [isCustomVideoError, setIsCustomVideoError] = useState(false);
useEffect(() => {
setIsCustomVideoError(false);
}, [videoId]);
useEffect(() => { useEffect(() => {
const handleKeyDown = (event) => { const handleKeyDown = (event) => {
@@ -62,12 +55,7 @@ const Loader = ({
loop loop
muted muted
playsInline playsInline
src={ src={loaderVideoPath}
videoId && !isCustomVideoError
? getMediaUrl(videoId)
: loaderVideoPath
}
onError={() => setIsCustomVideoError(true)}
alt="Loading" alt="Loading"
className="error-gif" className="error-gif"
style={{ width: "100%", height: "100%", objectFit: "cover" }} style={{ width: "100%", height: "100%", objectFit: "cover" }}

View File

@@ -71,7 +71,7 @@
} }
.react-markdown-container blockquote { .react-markdown-container blockquote {
border-left: 4px solid var(--carrier-main, #006F3A); border-left: 4px solid #006F3A;
padding-left: 16px; padding-left: 16px;
margin-top: 16px; margin-top: 16px;
margin-bottom: 16px; margin-bottom: 16px;

View File

@@ -17,7 +17,7 @@
114deg, 114deg,
rgba(255, 255, 255, 0.1) 8.71%, rgba(255, 255, 255, 0.1) 8.71%,
rgba(255, 255, 255, 0.05) 69.69% rgba(255, 255, 255, 0.05) 69.69%
), var(--carrier-main, #006F3A); ), #006F3A;
border-radius: 12px; border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1); box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
color: white; color: white;

View File

@@ -12,7 +12,6 @@ interface TouchableLayoutProps {
children?: ReactNode; children?: ReactNode;
className?: string; className?: string;
maxHeight?: string | number; maxHeight?: string | number;
style?: React.CSSProperties;
} }
function useThumbSync(scrollableRef: React.RefObject<HTMLDivElement | null>) { function useThumbSync(scrollableRef: React.RefObject<HTMLDivElement | null>) {
@@ -20,30 +19,20 @@ function useThumbSync(scrollableRef: React.RefObject<HTMLDivElement | null>) {
height: 60, height: 60,
top: 0, top: 0,
hasScroll: false, hasScroll: false,
isAtTop: true,
isAtBottom: false,
}); });
const [visible, setVisible] = useState(false);
const rafRef = useRef<number | null>(null); const rafRef = useRef<number | null>(null);
const update = useCallback(() => { const update = useCallback(() => {
const el = scrollableRef.current; const el = scrollableRef.current;
if (!el) return; if (!el) return;
if (el.closest('[data-height-animating="true"]')) {
return;
}
const sh = el.scrollHeight; const sh = el.scrollHeight;
const ch = el.clientHeight; const ch = el.clientHeight;
const st = el.scrollTop; const st = el.scrollTop;
const th = ch; const th = ch;
const isAtTop = st <= 0; if (sh <= ch) {
const isAtBottom = st + ch >= sh - 1; setState({ height: th, top: 0, hasScroll: false });
if (sh <= ch + 2) {
setState((prev) => ({ ...prev, hasScroll: false, isAtTop: true, isAtBottom: true }));
return; return;
} }
@@ -52,7 +41,7 @@ function useThumbSync(scrollableRef: React.RefObject<HTMLDivElement | null>) {
const scrollRange = sh - ch; const scrollRange = sh - ch;
const top = range <= 0 ? 0 : (st / scrollRange) * range; const top = range <= 0 ? 0 : (st / scrollRange) * range;
setState({ height: thumbHeight, top, hasScroll: true, isAtTop, isAtBottom }); setState({ height: thumbHeight, top, hasScroll: true });
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -68,36 +57,22 @@ function useThumbSync(scrollableRef: React.RefObject<HTMLDivElement | null>) {
}; };
el.addEventListener("scroll", schedule, { passive: true }); el.addEventListener("scroll", schedule, { passive: true });
el.addEventListener("layoutchange", schedule);
const ro = new ResizeObserver(schedule); const ro = new ResizeObserver(schedule);
ro.observe(el); ro.observe(el);
const mo = new MutationObserver(schedule);
mo.observe(el, { childList: true, subtree: true, characterData: true });
schedule(); schedule();
return () => { return () => {
el.removeEventListener("scroll", schedule); el.removeEventListener("scroll", schedule);
el.removeEventListener("layoutchange", schedule);
ro.disconnect(); ro.disconnect();
mo.disconnect();
if (rafRef.current != null) cancelAnimationFrame(rafRef.current); if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
}; };
}, [update]); }, [update]);
useEffect(() => { return state;
if (!state.hasScroll) {
setVisible(false);
return;
}
const t = setTimeout(() => setVisible(true), 250);
return () => clearTimeout(t);
}, [state.hasScroll]);
return { ...state, visible };
} }
export const TouchableLayout = forwardRef<HTMLDivElement, TouchableLayoutProps>( export const TouchableLayout = forwardRef<HTMLDivElement, TouchableLayoutProps>(
({ children, className, maxHeight, style }, ref) => { ({ children, className, maxHeight }, ref) => {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const scrollableRef = useRef<HTMLDivElement>(null); const scrollableRef = useRef<HTMLDivElement>(null);
const trackRef = useRef<HTMLDivElement>(null); const trackRef = useRef<HTMLDivElement>(null);
@@ -259,12 +234,9 @@ export const TouchableLayout = forwardRef<HTMLDivElement, TouchableLayoutProps>(
}; };
}, [thumb.hasScroll]); }, [thumb.hasScroll]);
const containerClassName = [ const containerClassName = className
"scrollable-container", ? `scrollable-container ${className}`
className, : "scrollable-container";
thumb.isAtTop ? "is-at-top" : "",
thumb.isAtBottom ? "is-at-bottom" : "",
].filter(Boolean).join(" ");
const viewportStyle: React.CSSProperties = maxHeight const viewportStyle: React.CSSProperties = maxHeight
? { ? {
@@ -274,27 +246,20 @@ export const TouchableLayout = forwardRef<HTMLDivElement, TouchableLayoutProps>(
: {}; : {};
return ( return (
<div ref={setRefs} className={containerClassName} style={style}> <div ref={setRefs} className={containerClassName}>
<div className="scrollable-viewport" style={viewportStyle}> <div className="scrollable-viewport" style={viewportStyle}>
<div ref={scrollableRef} className="scrollable"> <div ref={scrollableRef} className="scrollable">
{children} {children}
</div> </div>
<div {thumb.hasScroll && (
ref={trackRef} <div ref={trackRef} className="custom-scrollbar-track">
className="custom-scrollbar-track" <div
style={{ opacity: thumb.visible ? 1 : 0 }} ref={thumbRef}
> className="custom-scrollbar-thumb"
<div style={{ height: thumb.height, top: thumb.top }}
ref={thumbRef} />
className="custom-scrollbar-thumb" </div>
style={{ )}
height: thumb.height,
top: thumb.top,
opacity: thumb.visible ? 1 : 0,
pointerEvents: thumb.visible ? "auto" : "none",
}}
/>
</div>
</div> </div>
</div> </div>
); );

View File

@@ -8,7 +8,6 @@ import windIcon from "../assets/weather-icons/wind.png";
import humidityIcon from "../assets/weather-icons/humidity.png"; import humidityIcon from "../assets/weather-icons/humidity.png";
import { useGeolocationStore } from "../stores"; import { useGeolocationStore } from "../stores";
import { translateWeatherStatus } from "../utils/translateWeatherStatus"; import { translateWeatherStatus } from "../utils/translateWeatherStatus";
import { apiStore } from "../api/ApiStore";
function WeatherDataLine({ icon, value, unit }) { function WeatherDataLine({ icon, value, unit }) {
return ( return (
@@ -44,11 +43,7 @@ const WeatherWidget = observer(() => {
const fetchWeather = async () => { const fetchWeather = async () => {
try { try {
const data = await weatherApi.getFormattedWeather({ const data = await weatherApi.getFormattedWeather();
lat: apiStore.context?.currentCoordinates?.latitude,
lng: apiStore.context?.currentCoordinates?.longitude,
cityCode: apiStore.city?.weather_city_code,
});
setWeather(data); setWeather(data);
setError(null); setError(null);
setHasLoadedOnce(true); setHasLoadedOnce(true);

View File

@@ -0,0 +1,349 @@
import { FederatedPointerEvent, FederatedWheelEvent } from "pixi.js";
import { ReactNode, useEffect, useState, useRef } from "react";
import { useTransform } from "./transformContext";
import { BACKGROUND_COLOR, SCALE_FACTOR } from "../../assets/Constants";
import { useApplication } from "@pixi/react";
import { useCameraAnimationStore } from "../../stores";
import { observer } from "mobx-react-lite";
import debounce from "lodash/debounce";
import { apiStore } from "../../api/ApiStore/store";
export const InfiniteCanvas = observer(
({ children }: Readonly<{ children?: ReactNode }>) => {
const { route } = apiStore;
const {
position,
setPosition,
scale,
setScale,
setScreenCenter,
isAutoMode,
setIsAutoMode,
userActivityTimestamp,
updateUserActivity,
setAutoModeStartTimestamp,
} = useTransform();
const [loaded, setLoaded] = useState(false);
const applicationRef = useApplication();
const [isDragging, setIsDragging] = useState(false);
const [startMousePosition, setStartMousePosition] = useState({
x: 0,
y: 0,
});
const [startPosition, setStartPosition] = useState({ x: 0, y: 0 });
const activePointers = useRef(new Map<number, { x: number; y: number }>());
const [isPinching, setIsPinching] = useState(false);
// Add new useRef for storing initial pinch gesture data
const pinchStartData = useRef<{
distance: number;
midpoint: { x: number; y: number };
scale: number;
position: { x: number; y: number };
} | null>(null);
// Keep these for backward compatibility, but we'll use pinchStartData for calculations
const [, setInitialPinchDistance] = useState<number | null>(null);
const [, setInitialPinchMidpoint] = useState<{
x: number;
y: number;
} | null>(null);
const [scaleMin, setScaleMin] = useState(0.1); // Default min scale
const [scaleMax, setScaleMax] = useState(3); // Default max scale
const cameraAnimationStore = useCameraAnimationStore();
// Add debounced version of syncState to reduce jittering
const syncStateDebounced = useRef(
debounce((pos, zoom) => {
cameraAnimationStore.syncState(pos, zoom);
}, 16) // ~60fps
).current;
// Функция для плавного ограничения масштаба
const getSmoothScale = (targetScale: number, currentScale: number) => {
if (isAutoMode) return targetScale; // В авто режиме без ограничений
// Плавное ограничение с затуханием
const damping = 0.3; // Коэффициент затухания (0-1)
if (targetScale < scaleMin) {
// Плавное замедление при приближении к минимальному масштабу
const distance = scaleMin - targetScale;
const dampedDistance = distance * damping;
return Math.max(scaleMin, targetScale + dampedDistance);
}
if (targetScale > scaleMax) {
// Плавное замедление при приближении к максимальному масштабу
const distance = targetScale - scaleMax;
const dampedDistance = distance * damping;
return Math.min(scaleMax, targetScale - dampedDistance);
}
// Плавное возвращение к границам, если текущий масштаб за пределами
if (currentScale < scaleMin) {
// Плавно возвращаемся к минимальному масштабу
const distance = scaleMin - currentScale;
const dampedDistance = distance * damping;
return Math.min(targetScale, currentScale + dampedDistance);
}
if (currentScale > scaleMax) {
// Плавно возвращаемся к максимальному масштабу
const distance = currentScale - scaleMax;
const dampedDistance = distance * damping;
return Math.max(targetScale, currentScale - dampedDistance);
}
return targetScale;
};
const handleUserActivity = () => {
updateUserActivity();
if (isAutoMode) {
setIsAutoMode(false);
}
// При любом действии пользователя останавливаем анимацию
cameraAnimationStore.stopAnimation();
};
// Автоматический режим - таймер для включения и управление масштабом
useEffect(() => {
const interval = setInterval(() => {
const timeSinceActivity = Date.now() - userActivityTimestamp;
if (timeSinceActivity >= 5000 && !isAutoMode) {
// 5 секунд бездействия - включаем авто режим
if (loaded) {
setIsAutoMode(true);
setAutoModeStartTimestamp(Date.now()); // Записываем время включения автопреследования
// Убираем мгновенную установку масштаба - теперь это делает CameraAnimationStore плавно
}
}
}, 1000); // Проверяем каждую секунду
return () => clearInterval(interval);
}, [
userActivityTimestamp,
isAutoMode,
setIsAutoMode,
setScale,
setAutoModeStartTimestamp,
]);
useEffect(() => {
async function fetchRouteData() {
try {
const newScaleMin = route?.scale_min! / SCALE_FACTOR;
const newScaleMax = route?.scale_max! / SCALE_FACTOR;
setScaleMin(newScaleMin);
setScaleMax(newScaleMax);
setLoaded(true);
} catch (error) {
console.error(
"Ошибка загрузки данных маршрута для scaleMin/Max:",
error
);
}
}
fetchRouteData();
}, [route]);
// Убираем жесткое ограничение масштаба - теперь используется плавное ограничение
useEffect(() => {
const canvas = applicationRef?.app.canvas;
if (!canvas) return;
const canvasRect = canvas.getBoundingClientRect();
const canvasLeft = canvasRect.left;
const canvasTop = canvasRect.top;
const centerX = window.innerWidth / 2 - canvasLeft;
const centerY = window.innerHeight / 2 - canvasTop;
setScreenCenter({ x: centerX, y: centerY });
}, [applicationRef?.app.canvas, setScreenCenter]);
const handlePointerDown = (e: FederatedPointerEvent) => {
handleUserActivity();
activePointers.current.set(e.pointerId, { x: e.globalX, y: e.globalY });
if (activePointers.current.size === 1) {
setIsPinching(false);
setInitialPinchDistance(null);
setInitialPinchMidpoint(null);
pinchStartData.current = null;
setIsDragging(true);
setStartPosition({ x: position.x, y: position.y });
setStartMousePosition({ x: e.globalX, y: e.globalY });
} else if (activePointers.current.size === 2) {
setIsDragging(false); // Останавливаем перетаскивание, начинаем пинч
const pointersArray = Array.from(activePointers.current.values());
const p1 = pointersArray[0];
const p2 = pointersArray[1];
// Calculate initial values
const initialDistance = Math.hypot(p2.x - p1.x, p2.y - p1.y);
const initialMidpoint = { x: (p1.x + p2.x) / 2, y: (p1.y + p2.y) / 2 };
// Save initial gesture data in pinchStartData
pinchStartData.current = {
distance: initialDistance,
midpoint: initialMidpoint,
scale: scale,
position: { ...position },
};
// Keep these for backward compatibility
setInitialPinchDistance(initialDistance);
setInitialPinchMidpoint(initialMidpoint);
setIsPinching(true);
}
e.stopPropagation();
};
const handlePointerMove = (e: FederatedPointerEvent) => {
if (!activePointers.current.has(e.pointerId)) return;
updateUserActivity();
activePointers.current.set(e.pointerId, { x: e.globalX, y: e.globalY });
if (
isPinching &&
activePointers.current.size === 2 &&
pinchStartData.current
) {
const pointersArray = Array.from(activePointers.current.values());
const p1 = pointersArray[0];
const p2 = pointersArray[1];
const currentDistance = Math.hypot(p2.x - p1.x, p2.y - p1.y);
const currentMidpoint = {
x: (p1.x + p2.x) / 2,
y: (p1.y + p2.y) / 2,
};
// 1. Calculate zoomFactor relative to the INITIAL distance
const zoomFactor = currentDistance / pinchStartData.current.distance;
// 2. Calculate new scale relative to the INITIAL scale
const targetScale = pinchStartData.current.scale * zoomFactor;
const newScale = getSmoothScale(targetScale, scale);
// 3. Calculate new position relative to the INITIAL position
// This is the standard formula for "zoom to point" (in our case, to the midpoint between fingers)
const newPosition = {
x:
pinchStartData.current.midpoint.x +
(pinchStartData.current.position.x -
pinchStartData.current.midpoint.x) *
(newScale / pinchStartData.current.scale),
y:
pinchStartData.current.midpoint.y +
(pinchStartData.current.position.y -
pinchStartData.current.midpoint.y) *
(newScale / pinchStartData.current.scale),
};
setPosition(newPosition);
setScale(newScale);
syncStateDebounced(newPosition, newScale);
// We do NOT update pinchStartData here - it remains fixed for the entire gesture
// Update these for backward compatibility, but they're not used for calculations
setInitialPinchDistance(currentDistance);
setInitialPinchMidpoint(currentMidpoint);
} else if (activePointers.current.size === 1) {
setIsPinching(false);
setInitialPinchDistance(null);
setInitialPinchMidpoint(null);
pinchStartData.current = null;
if (isDragging) {
const newPosition = {
x: startPosition.x - startMousePosition.x + e.globalX,
y: startPosition.y - startMousePosition.y + e.globalY,
};
setPosition(newPosition);
syncStateDebounced(newPosition, scale);
}
}
e.stopPropagation();
};
const handlePointerUp = (e: FederatedPointerEvent) => {
handleUserActivity();
activePointers.current.delete(e.pointerId);
if (activePointers.current.size < 2) {
setIsPinching(false);
setInitialPinchDistance(null);
setInitialPinchMidpoint(null);
pinchStartData.current = null; // Clear pinch gesture data
}
if (activePointers.current.size === 0) {
setIsDragging(false);
} else if (activePointers.current.size === 1) {
// If one finger remains after pinch, start dragging from the new position
const remainingPointer = Array.from(activePointers.current.values())[0];
setStartPosition({ x: position.x, y: position.y });
setStartMousePosition({ x: remainingPointer.x, y: remainingPointer.y });
setIsDragging(true);
}
e.stopPropagation();
};
const handleWheel = (e: FederatedWheelEvent) => {
e.stopPropagation();
handleUserActivity(); // Используем новую функцию
const mouseX = e.globalX - position.x;
const mouseY = e.globalY - position.y;
const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1;
// Используем плавное ограничение масштаба
const targetScale = scale * zoomFactor;
const newScale = getSmoothScale(targetScale, scale);
const actualZoomFactor = newScale / scale;
if (scale !== newScale && newScale >= scaleMin && newScale <= scaleMax) {
// Убираем из условия && newScale >= scaleMin && newScale <= scaleMax
const newPosition = {
x: position.x + mouseX * (1 - actualZoomFactor),
y: position.y + mouseY * (1 - actualZoomFactor),
};
setPosition(newPosition);
setScale(newScale);
// Используем дебаунсированную функцию для синхронизации стора после зума
syncStateDebounced(newPosition, newScale);
}
};
return (
<>
<pixiGraphics
draw={(g) => {
const canvas = applicationRef.app.canvas;
g.clear();
g.rect(0, 0, canvas?.width ?? 0, canvas?.height ?? 0);
g.fill(BACKGROUND_COLOR);
}}
eventMode="static"
interactive
onPointerDown={handlePointerDown}
onGlobalPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerLeave={handlePointerUp}
onWheel={handleWheel}
/>
<pixiContainer position={position} scale={scale}>
{children}
</pixiContainer>
</>
);
}
);

View File

@@ -5,6 +5,8 @@ import {
useMemo, useMemo,
} from "react"; } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { extend } from "@pixi/react";
import { Container, Graphics, Sprite, Text } from "pixi.js";
import { MapDataProvider, useMapData } from "./MapDataContext"; import { MapDataProvider, useMapData } from "./MapDataContext";
import { TransformProvider, useTransform } from "./transformContext"; import { TransformProvider, useTransform } from "./transformContext";
// @ts-ignore // @ts-ignore
@@ -17,6 +19,8 @@ import { SCALE_FACTOR } from "../../assets/Constants";
import { apiStore } from "../../api/ApiStore/store"; import { apiStore } from "../../api/ApiStore/store";
import WebGLMap from "./WebGLMap"; import WebGLMap from "./WebGLMap";
extend({ Container, Graphics, Text, Sprite });
export function Map() { export function Map() {
return ( return (
<MapDataProvider> <MapDataProvider>

View File

@@ -0,0 +1,393 @@
// SightsLayer.tsx
import { Graphics, Assets, Texture, TextStyle } from "pixi.js";
import { useCallback, useEffect, useState, useMemo } from "react";
import { useTransform } from "./transformContext";
import { SightData } from "./types";
import sightIcon from "../../assets/images/sight.svg";
import { useGeolocationStore } from "../../stores"; // Импортируем useGeolocationStore
const BASE_ICON_SIZE = 30;
const CLUSTER_RADIUS_BASE = 10;
type Cluster = {
id: string;
longitude: number;
latitude: number;
count: number;
sights: SightData[];
};
type PointItem = { type: "point"; id: string; data: SightData };
type ClusterItem = { type: "cluster"; id: string; data: Cluster };
type ClusteredItem = PointItem | ClusterItem;
const getDistance = (
p1: { longitude: number; latitude: number },
p2: { longitude: number; latitude: number }
) => {
return Math.sqrt(
Math.pow(p1.longitude - p2.longitude, 2) +
Math.pow(p1.latitude - p2.latitude, 2)
);
};
const getDistanceFromPointToPath = (
point: { longitude: number; latitude: number },
pathPoints: { x: number; y: number }[]
): number => {
if (!pathPoints || pathPoints.length < 2) {
return Infinity;
}
let minDistance = Infinity;
for (let i = 0; i < pathPoints.length - 1; i++) {
const p1 = pathPoints[i];
const p2 = pathPoints[i + 1];
const lineLengthSq = (p2.x - p1.x) ** 2 + (p2.y - p1.y) ** 2;
if (lineLengthSq === 0) continue;
const t =
((point.longitude - p1.x) * (p2.x - p1.x) +
(point.latitude - p1.y) * (p2.y - p1.y)) /
lineLengthSq;
const projectionT = Math.max(0, Math.min(1, t));
const projectedX = p1.x + projectionT * (p2.x - p1.x);
const projectedY = p1.y + projectionT * (p2.y - p1.y);
const dist = Math.sqrt(
(point.longitude - projectedX) ** 2 + (point.latitude - projectedY) ** 2
);
minDistance = Math.min(minDistance, dist);
}
return minDistance;
};
const useSightClustering = (
sights: SightData[],
distanceThreshold: number,
pathPoints: { x: number; y: number }[]
): ClusteredItem[] => {
return useMemo(() => {
const unclusteredSights: (SightData & { visited?: boolean })[] = sights.map(
(s) => ({ ...s })
);
const clusteredResult: ClusteredItem[] = [];
for (const sight of unclusteredSights) {
if (sight.visited) {
continue;
}
const clusterSights: SightData[] = [];
const queue = [sight];
sight.visited = true;
while (queue.length > 0 && clusterSights.length < 4) {
const current = queue.shift()!;
clusterSights.push(current);
for (const potentialNeighbor of unclusteredSights) {
if (
!potentialNeighbor.visited &&
clusterSights.length < 4 &&
getDistance(current, potentialNeighbor) < distanceThreshold
) {
potentialNeighbor.visited = true;
queue.push(potentialNeighbor);
}
}
}
if (clusterSights.length > 1) {
let furthestSight: SightData | null = null;
let maxDistanceToPath = -1;
for (const s of clusterSights) {
const dist = getDistanceFromPointToPath(s, pathPoints);
if (dist > maxDistanceToPath) {
maxDistanceToPath = dist;
furthestSight = s;
}
}
const count = clusterSights.length;
const longitude = furthestSight
? furthestSight.longitude
: clusterSights.reduce((sum, s) => sum + s.longitude, 0) / count;
const latitude = furthestSight
? furthestSight.latitude
: clusterSights.reduce((sum, s) => sum + s.latitude, 0) / count;
const id = `cluster-${clusterSights[0].id}`;
clusteredResult.push({
type: "cluster",
id,
data: { id, count, longitude, latitude, sights: clusterSights },
});
} else {
const singleSight = clusterSights[0];
clusteredResult.push({
type: "point",
id: String(singleSight.id),
data: singleSight,
});
}
}
return clusteredResult;
}, [sights, distanceThreshold, pathPoints]);
};
// Изменяем пропсы для SingleSight, чтобы он мог передавать ID при тапе
function SingleSight({
sight,
onSightClick,
}: {
readonly sight: SightData;
onSightClick: (sightId: string) => void;
}) {
useTransform();
const [texture, setTexture] = useState<Texture>(Texture.EMPTY);
const store = useGeolocationStore();
const { setIsGovernorWidgetOpen } = store;
useEffect(() => {
Assets.load(sightIcon).then(setTexture).catch(console.error);
}, []);
const handlePointerTap = useCallback(() => {
setIsGovernorWidgetOpen(false);
onSightClick(String(sight.id)); // Передаем ID выбранной достопримечательности
}, [sight.id, onSightClick]);
if (texture === Texture.EMPTY) {
return null;
}
const dynamicSize = BASE_ICON_SIZE;
return (
<pixiSprite
texture={texture}
x={sight.longitude}
y={sight.latitude}
width={dynamicSize}
height={dynamicSize}
anchor={0.5}
eventMode="static"
onPointerTap={handlePointerTap}
/>
);
}
// Добавляем onSightSelectedInCluster в пропсы
function SightCluster({
cluster,
onClusterToggle,
isExpanded,
onSightSelectedInCluster,
}: {
readonly cluster: Cluster;
onClusterToggle: (clusterId: string | null) => void;
isExpanded: boolean;
onSightSelectedInCluster: (sightId: string) => void;
}) {
const store = useGeolocationStore();
const { setIsGovernorWidgetOpen } = store;
useTransform();
const radius = CLUSTER_RADIUS_BASE;
const [texture, setTexture] = useState<Texture>(Texture.EMPTY);
const fontSize = 14;
useEffect(() => {
Assets.load(sightIcon).then(setTexture).catch(console.error);
}, []);
const clusterTextStyle = useMemo(
() =>
new TextStyle({
fill: "white",
fontSize: fontSize,
fontWeight: "bold",
}),
[fontSize]
);
const handleClusterTap = useCallback(() => {
onClusterToggle(isExpanded ? null : cluster.id);
}, [cluster.id, cluster.count, isExpanded, onClusterToggle]);
const handleSightSelect = useCallback(
(sightId: string) => {
setIsGovernorWidgetOpen(false);
onSightSelectedInCluster(sightId); // Передаем выбранный ID наверх
onClusterToggle(null); // Закрываем кластер после выбора достопримечательности
},
[onClusterToggle, onSightSelectedInCluster, setIsGovernorWidgetOpen]
); // Добавляем onSightSelectedInCluster в зависимости
const drawClusterGraphics = useCallback(
(g: Graphics) => {
g.clear();
g.beginFill(0x896f58);
g.drawCircle(0, 0, radius);
g.endFill();
},
[radius]
);
const drawExpandedClusterBackground = useCallback((g: Graphics) => {
g.clear();
const expandedRadius = BASE_ICON_SIZE * 2;
g.beginFill(0x896f58, 0.3);
g.lineStyle(2, 0x896f58, 1);
g.drawCircle(0, 0, expandedRadius);
g.endFill();
}, []);
const handleBackgroundTap = useCallback(() => {
onClusterToggle(null); // Закрываем кластер при клике по фону
}, [cluster.id, onClusterToggle]);
if (texture === Texture.EMPTY) {
return null;
}
const dynamicSize = BASE_ICON_SIZE;
const offsetX = dynamicSize / 2;
const offsetY = -dynamicSize / 2;
const getPositionForSight = (index: number, total: number) => {
const angle = (index / total) * Math.PI * 2;
const distance = BASE_ICON_SIZE * 1.2;
return {
x: distance * Math.cos(angle),
y: distance * Math.sin(angle),
};
};
return (
<pixiContainer x={cluster.longitude} y={cluster.latitude}>
{isExpanded && (
<pixiGraphics
draw={drawExpandedClusterBackground}
eventMode="static"
onPointerTap={handleBackgroundTap}
/>
)}
{!isExpanded ? (
<>
<pixiSprite
texture={texture}
x={0}
y={0}
width={dynamicSize}
height={dynamicSize}
anchor={0.5}
eventMode="static"
onPointerTap={handleClusterTap}
/>
<pixiGraphics draw={drawClusterGraphics} x={offsetX} y={offsetY} />
<pixiText
text={String(cluster.count)}
x={offsetX}
y={offsetY}
anchor={0.5}
style={clusterTextStyle}
resolution={4}
/>
</>
) : (
<>
{cluster.sights.map((sight, index) => {
const pos = getPositionForSight(index, cluster.sights.length);
return (
<pixiSprite
key={sight.id}
texture={texture}
x={pos.x}
y={pos.y}
width={dynamicSize}
height={dynamicSize}
anchor={0.5}
eventMode="static"
onPointerTap={() => handleSightSelect(String(sight.id))}
/>
);
})}
</>
)}
</pixiContainer>
);
}
interface SightsLayerProps {
sights: SightData[];
pathPoints: { x: number; y: number }[];
}
export function SightsLayer({
sights,
pathPoints,
}: Readonly<SightsLayerProps>) {
useTransform();
const distanceThreshold = BASE_ICON_SIZE * 3;
const store = useGeolocationStore(); // Получаем доступ к MobX хранилищу
const {
setSelectedSightId,
setIsManualSelection,
setIsRightWidgetSelectorOpen,
} = store; // Получаем нужные экшены
const items = useSightClustering(sights, distanceThreshold, pathPoints);
const [activeClusterId, setActiveClusterId] = useState<string | null>(null);
const handleClusterToggle = useCallback((clusterId: string | null) => {
setActiveClusterId(clusterId);
}, []);
const handleSightSelected = useCallback(
(sightId: string) => {
setSelectedSightId(sightId);
setIsManualSelection(true);
setIsRightWidgetSelectorOpen(false); // Закрываем селектор правого виджета при клике по достопримечательности
// Закрываем виджет губернатора при выборе достопримечательности
store.closeGovernorModal();
},
[
setSelectedSightId,
setIsManualSelection,
setIsRightWidgetSelectorOpen,
store,
]
);
return (
<>
{items.map((item) => {
if (item.type === "cluster") {
return (
<SightCluster
key={item.id}
cluster={item.data}
onClusterToggle={handleClusterToggle}
isExpanded={activeClusterId === item.id}
onSightSelectedInCluster={handleSightSelected}
/>
);
}
return (
<SingleSight
key={item.id}
sight={item.data}
onSightClick={handleSightSelected}
/>
);
})}
</>
);
}

View File

@@ -0,0 +1,174 @@
import { Graphics } from "pixi.js";
import { useCallback, useMemo } from "react";
import {
BACKGROUND_COLOR,
PATH_COLOR,
STATION_RADIUS,
STATION_OUTLINE_WIDTH,
UNPASSED_STATION_COLOR,
} from "./Constants";
import { StationData } from "./types";
import { useTransform } from "./transformContext";
import { observer } from "mobx-react-lite";
import { useGeolocationStore } from "../../stores/hooks/useGeolocationStore";
import { apiStore } from "../../api/ApiStore/store";
interface StationProps {
station: StationData;
stationEn?: StationData | null;
stationZh?: StationData | null;
isPassed: boolean;
}
const BASE_FONT_SIZE = 16;
const POINT_LABEL_FONT_SIZE = 13;
const DEFAULT_LABEL_OFFSET_X = 25;
const DEFAULT_LABEL_OFFSET_Y = 0;
const getAnchorFromOffset = (
offsetX: number,
offsetY: number
): { x: number; y: number } => {
if (offsetX === 0 && offsetY === 0) {
return { x: 0, y: 0.5 };
}
const length = Math.hypot(offsetX, offsetY);
const nx = offsetX / length;
const ny = offsetY / length;
return { x: (1 - nx) / 2, y: (1 - ny) / 2 };
};
export const Station = observer(
({ station, stationEn, stationZh, isPassed }: Readonly<StationProps>) => {
const { scale } = useTransform();
const { context } = apiStore;
const { selectedLanguage } = useGeolocationStore();
const ZOOM_THRESHOLD_FOR_HIGH_RESOLUTION = 2; // Порог масштаба (в 2 раза)
const HIGH_RESOLUTION_VALUE = 4;
const LOW_RESOLUTION_VALUE = 2;
const dynamicResolution =
scale > ZOOM_THRESHOLD_FOR_HIGH_RESOLUTION
? HIGH_RESOLUTION_VALUE
: LOW_RESOLUTION_VALUE;
const draw = useCallback(
(g: Graphics) => {
g.clear();
// Проверяем, является ли станция начальной или конечной
const isTerminalStation =
context &&
(station.id.toString() === (context as any)?.startStopId ||
station.id.toString() === (context as any)?.endStopId);
// Определяем радиус станции
const stationRadius = isTerminalStation
? STATION_RADIUS * 1.5
: STATION_RADIUS;
// Рисуем основной круг станции
g.circle(station.longitude, station.latitude, stationRadius);
g.fill({ color: isPassed ? PATH_COLOR : UNPASSED_STATION_COLOR });
g.stroke({ color: BACKGROUND_COLOR, width: STATION_OUTLINE_WIDTH });
// Если это терминальная станция, рисуем дополнительный круг по центру
if (isTerminalStation) {
const centerCircleRadius = stationRadius * 0.5; // 50% от радиуса станции
g.circle(station.longitude, station.latitude, centerCircleRadius);
g.fill({ color: BACKGROUND_COLOR });
}
},
[station.latitude, station.longitude, station.id, context, isPassed]
);
const dynamicFontSize = BASE_FONT_SIZE;
const dynamicPointLabelFontSize = POINT_LABEL_FONT_SIZE;
// Определяем фактические смещения. Если station.offset_x и station.offset_y оба равны 0,
// используем дефолтный отступ для обеспечения видимости.
const labelOffsetX =
station.offset_x === 0 && station.offset_y === 0
? DEFAULT_LABEL_OFFSET_X
: station.offset_x / 3;
const labelOffsetY =
station.offset_x === 0 && station.offset_y === 0
? DEFAULT_LABEL_OFFSET_Y
: station.offset_y / 3;
// Вычисляем позицию текстового блока. Это прямые координаты карты.
const textBlockPositionX = station.longitude + labelOffsetX / 3;
const textBlockPositionY = station.latitude + labelOffsetY;
// Используем useMemo для запоминания dynamicAnchor на основе *фактически используемого* отступа.
const dynamicAnchor = useMemo(
() => getAnchorFromOffset(labelOffsetX, labelOffsetY),
[labelOffsetX, labelOffsetY]
);
return (
<pixiContainer>
<pixiGraphics draw={draw} zIndex={0} />
<pixiText
anchor={dynamicAnchor} // Используем динамический якорь
text={station.name}
position={{
x: textBlockPositionX,
y: textBlockPositionY,
}}
style={{
fontSize: dynamicFontSize,
fontFamily: "Roboto",
fontWeight: "bold",
fill: "#fff",
}}
resolution={dynamicResolution}
zIndex={100}
/>
{(selectedLanguage === "en" || selectedLanguage === "ru") && (
<pixiText
anchor={dynamicAnchor} // Используем динамический якорь
text={stationEn?.name}
position={{
x: textBlockPositionX,
y: textBlockPositionY + 14, // Это смещение второй надписи относительно первой
}}
style={{
fontSize: dynamicPointLabelFontSize,
fontFamily: "Roboto",
fontWeight: "normal",
fill: "#CBCBCB",
}}
resolution={dynamicResolution}
zIndex={100}
/>
)}
{selectedLanguage === "zh" && (
<pixiText
anchor={dynamicAnchor} // Используем динамический якорь
text={stationZh?.name}
position={{
x: textBlockPositionX,
y: textBlockPositionY + 14, // Это смещение второй надписи относительно первой
}}
style={{
fontSize: dynamicPointLabelFontSize,
fontFamily: "Roboto",
fontWeight: "normal",
fill: "#CBCBCB",
}}
resolution={dynamicResolution}
zIndex={100}
/>
)}
</pixiContainer>
);
}
);

View File

@@ -0,0 +1,375 @@
import { Texture, Assets } from "pixi.js";
import { useEffect, useState, useMemo, useRef } from "react";
import { useTransform } from "./transformContext";
import { lerp, lerpAngle } from "../../utils/animationUtils";
const basePath = new URL(
"../../assets/tramPosition/Tram Base.svg",
import.meta.url
).href;
const tramPath = new URL("../../assets/tramPosition/Tram.svg", import.meta.url)
.href;
const LERP_SPEED = 0.1; // Скорость интерполяции (10% каждый кадр)
// Функция для проверки расстояния до ближайшей точки маршрута
const getDistanceToPath = (
point: { x: number; y: number },
pathPoints: { x: number; y: number }[]
) => {
if (!pathPoints || pathPoints.length < 2) return Infinity;
let minDistance = Infinity;
for (let i = 0; i < pathPoints.length - 1; i++) {
const p1 = pathPoints[i];
const p2 = pathPoints[i + 1];
const lineLengthSq = (p2.x - p1.x) ** 2 + (p2.y - p1.y) ** 2;
if (lineLengthSq === 0) continue;
const t =
((point.x - p1.x) * (p2.x - p1.x) + (point.y - p1.y) * (p2.y - p1.y)) /
lineLengthSq;
const projectionT = Math.max(0, Math.min(1, t));
const projectedX = p1.x + projectionT * (p2.x - p1.x);
const projectedY = p1.y + projectionT * (p2.y - p1.y);
const dist = Math.sqrt(
(point.x - projectedX) ** 2 + (point.y - projectedY) ** 2
);
if (dist < minDistance) {
minDistance = dist;
}
}
return minDistance;
};
// Функция для проверки расстояния до пройденной части маршрута
const getDistanceToPassedPath = (
point: { x: number; y: number },
pathPoints: { x: number; y: number }[],
passedSegmentIndex: number
) => {
if (!pathPoints || pathPoints.length < 2 || passedSegmentIndex < 0)
return Infinity;
let minDistance = Infinity;
// Проверяем только пройденную часть (до passedSegmentIndex включительно)
for (
let i = 0;
i <= Math.min(passedSegmentIndex, pathPoints.length - 2);
i++
) {
const p1 = pathPoints[i];
const p2 = pathPoints[i + 1];
const lineLengthSq = (p2.x - p1.x) ** 2 + (p2.y - p1.y) ** 2;
if (lineLengthSq === 0) continue;
const t =
((point.x - p1.x) * (p2.x - p1.x) + (point.y - p1.y) * (p2.y - p1.y)) /
lineLengthSq;
const projectionT = Math.max(0, Math.min(1, t));
const projectedX = p1.x + projectionT * (p2.x - p1.x);
const projectedY = p1.y + projectionT * (p2.y - p1.y);
const dist = Math.sqrt(
(point.x - projectedX) ** 2 + (point.y - projectedY) ** 2
);
if (dist < minDistance) {
minDistance = dist;
}
}
return minDistance;
};
// Функция для проверки расстояния до ближайшей станции
const getDistanceToStations = (
point: { x: number; y: number },
stations: {
longitude: number;
latitude: number;
offset_x?: number;
offset_y?: number;
}[],
_debug: boolean = false
) => {
if (!stations || stations.length === 0) {
return Infinity;
}
let minDistance = Infinity;
for (const station of stations) {
// Рассчитываем позицию текста станции с учетом смещений (как в Station.tsx)
const DEFAULT_LABEL_OFFSET_X = 25;
const DEFAULT_LABEL_OFFSET_Y = 0;
const labelOffsetX =
station.offset_x === 0 && station.offset_y === 0
? DEFAULT_LABEL_OFFSET_X
: (station.offset_x || 0) / 3;
const labelOffsetY =
station.offset_x === 0 && station.offset_y === 0
? DEFAULT_LABEL_OFFSET_Y
: (station.offset_y || 0) / 3;
const textBlockPositionX = station.longitude + labelOffsetX;
const textBlockPositionY = station.latitude + labelOffsetY;
// Вычисляем расстояние до позиции текста станции (не до центра станции)
const dist = Math.sqrt(
(point.x - textBlockPositionX) ** 2 + (point.y - textBlockPositionY) ** 2
);
if (dist < minDistance) {
minDistance = dist;
}
}
return minDistance;
};
// Функция для поиска оптимального угла поворота метки относительно трамвая
const findOptimalAngle = (
tramX: number,
tramY: number,
pathPoints: { x: number; y: number }[],
stations: {
longitude: number;
latitude: number;
offset_x?: number;
offset_y?: number;
}[],
passedSegmentIndex: number,
scale: number
) => {
const testRadiusInMapCoords = 100 / scale; // Радиус в координатах карты
const minSafeDistanceToPath = 60; // Минимальное безопасное расстояние до пути в пикселях
const minSafeDistanceToPassedPath = 60; // Минимальное безопасное расстояние до пройденной части в пикселях
const minSafeDistanceToStation = 50; // Минимальное безопасное расстояние до станции в пикселях
let bestAngle = 0;
let bestScore = Infinity; // Ищем минимальный вес
const segmentWeights: { angle: number; weight: number; distances: any }[] =
[];
// Проверяем 12 углов (каждые 30 градусов)
for (let i = 0; i < 12; i++) {
const testAngle = (i * Math.PI * 2) / 12;
const testX = tramX + Math.cos(testAngle) * testRadiusInMapCoords;
const testY = tramY + Math.sin(testAngle) * testRadiusInMapCoords;
const distanceToPath =
getDistanceToPath({ x: testX, y: testY }, pathPoints) * scale; // В пикселях
const distanceToPassedPath =
getDistanceToPassedPath(
{ x: testX, y: testY },
pathPoints,
passedSegmentIndex
) * scale; // В пикселях
const distanceToStation =
getDistanceToStations({ x: testX, y: testY }, stations, false) * scale; // В пикселях с отладкой
// Вычисляем вес для этого угла (чем меньше расстояние, тем больше вес)
let weight = 0;
// Путь - вес 100
if (distanceToPath < minSafeDistanceToPath) {
weight += 100 * (1 - distanceToPath / minSafeDistanceToPath);
}
// Текст прошедшей станции - вес 10
if (distanceToPassedPath < minSafeDistanceToPassedPath) {
weight += 10 * (1 - distanceToPassedPath / minSafeDistanceToPassedPath);
}
// Текст следующей станции - вес 1000
if (distanceToStation < minSafeDistanceToStation) {
weight += 1000 * (1 - distanceToStation / minSafeDistanceToStation);
}
segmentWeights.push({
angle: testAngle,
weight: Math.round(weight * 10) / 10, // Округляем для читаемости
distances: {
path: Math.round(distanceToPath),
passedPath: Math.round(distanceToPassedPath),
station: Math.round(distanceToStation),
},
});
if (weight < bestScore) {
bestScore = weight;
bestAngle = testAngle;
}
}
// Если несколько сегментов имеют одинаковый минимальный вес,
// выбираем тот, который максимально удален от препятствий
const minWeightSegments = segmentWeights.filter(
(s) => s.weight === bestScore
);
if (minWeightSegments.length > 1) {
// Из сегментов с минимальным весом выбираем тот, который максимально удален от препятствий
let bestDistanceSum = -1;
for (const segment of minWeightSegments) {
const distanceSum =
segment.distances.path +
segment.distances.passedPath +
segment.distances.station;
if (distanceSum > bestDistanceSum) {
bestDistanceSum = distanceSum;
bestAngle = segment.angle;
}
}
}
return { bestAngle, segmentWeights };
};
export function TramIcon({
x,
y,
angle,
pathPoints = [],
stations = [],
passedSegmentIndex = -1,
}: {
x: number;
y: number;
angle: number;
pathPoints?: { x: number; y: number }[];
stations?: {
longitude: number;
latitude: number;
offset_x?: number;
offset_y?: number;
}[];
passedSegmentIndex?: number;
}) {
const { scale } = useTransform();
// Находим оптимальный угол поворота метки относительно трамвая
const optimalAngle = useMemo(
() =>
findOptimalAngle(x, y, pathPoints, stations, passedSegmentIndex, scale)
.bestAngle,
[x, y, pathPoints, stations, passedSegmentIndex, scale]
);
// Состояние для плавной анимации позиции и углов (как в HTML файле)
const [smoothPosition, setSmoothPosition] = useState({ x, y });
const [smoothOptimalAngle, setSmoothOptimalAngle] = useState(optimalAngle);
const [smoothTramAngle, setSmoothTramAngle] = useState(angle);
const animationRef = useRef<number | undefined>(undefined);
// Плавная анимация позиции и углов (логика из HTML файла)
useEffect(() => {
const animate = () => {
// Анимируем позицию (как в HTML файле)
setSmoothPosition((prev) => {
const newX = lerp(prev.x, x, LERP_SPEED);
const newY = lerp(prev.y, y, LERP_SPEED);
return {
x: Math.abs(newX - x) < 0.1 ? x : newX,
y: Math.abs(newY - y) < 0.1 ? y : newY,
};
});
// Анимируем оптимальный угол
setSmoothOptimalAngle((prev) => {
const newAngle = lerpAngle(prev, optimalAngle, LERP_SPEED);
return Math.abs(newAngle - optimalAngle) < 0.01
? optimalAngle
: newAngle;
});
// Анимируем угол трамвая
setSmoothTramAngle((prev) => {
const newAngle = lerpAngle(prev, angle, LERP_SPEED);
return Math.abs(newAngle - angle) < 0.01 ? angle : newAngle;
});
// Продолжаем анимацию, если что-то еще не достигло цели
if (
Math.abs(smoothPosition.x - x) > 0.1 ||
Math.abs(smoothPosition.y - y) > 0.1 ||
Math.abs(smoothOptimalAngle - optimalAngle) > 0.01 ||
Math.abs(smoothTramAngle - angle) > 0.01
) {
animationRef.current = requestAnimationFrame(animate);
}
};
animationRef.current = requestAnimationFrame(animate);
return () => {
if (animationRef.current !== undefined) {
cancelAnimationFrame(animationRef.current);
}
};
}, [
x,
y,
optimalAngle,
angle,
smoothPosition,
smoothOptimalAngle,
smoothTramAngle,
]);
// Обычные размеры без увеличения
const backgroundWidth = 111 / scale;
const backgroundHeight = 82 / scale;
const tramWidth = 31 / scale;
const tramHeight = 52 / scale;
const [baseTexture, setBaseTexture] = useState<Texture | null>(null);
const [tramTexture, setTramTexture] = useState<Texture | null>(null);
useEffect(() => {
Assets.load(basePath).then(setBaseTexture).catch(console.error);
Assets.load(tramPath).then(setTramTexture).catch(console.error);
}, []);
if (!baseTexture || !tramTexture) return null;
return (
<pixiContainer x={smoothPosition.x} y={smoothPosition.y}>
{/* вращающийся контейнер с плавным оптимальным углом */}
<pixiContainer rotation={smoothOptimalAngle + Math.PI}>
<pixiSprite
texture={baseTexture}
anchor={{ x: 1, y: 0.5 }}
width={backgroundWidth > 53.7 ? backgroundWidth : 53.7}
height={backgroundHeight > 39.68 ? backgroundHeight : 39.68}
/>
{/* контейнер иконки трамвая с плавным поворотом направления движения */}
<pixiContainer
x={
backgroundWidth > 53.7
? -backgroundWidth / 1.42
: -backgroundWidth / 0.98
}
y={0}
>
<pixiSprite
texture={tramTexture}
anchor={0.5}
rotation={-smoothOptimalAngle - Math.PI}
width={tramWidth}
height={tramHeight}
/>
</pixiContainer>
</pixiContainer>
</pixiContainer>
);
}

View File

@@ -0,0 +1,92 @@
import { Graphics } from "pixi.js";
import { useCallback } from "react";
import { PATH_COLOR, PATH_WIDTH, UNPASSED_STATION_COLOR } from "./Constants";
interface TravelPathProps {
points: { x: number; y: number }[];
busCoordinates?: { x: number; y: number };
}
const PASSED_PATH_COLOR = PATH_COLOR;
const UNPASSED_PATH_COLOR = UNPASSED_STATION_COLOR;
export function TravelPath({
points,
busCoordinates,
}: Readonly<TravelPathProps>) {
const draw = useCallback(
(g: Graphics) => {
g.clear();
if (points.length < 2) {
return;
}
g.moveTo(points[0].x, points[0].y);
for (let i = 1; i < points.length; i++) {
g.lineTo(points[i].x, points[i].y);
}
g.stroke({
color: UNPASSED_PATH_COLOR,
width: PATH_WIDTH,
});
if (!busCoordinates) {
return;
}
let minDistance = Infinity;
let closestSegmentIndex = -1;
let busSegmentStartPoint: { x: number; y: number } | null = null;
for (let i = 0; i < points.length - 1; i++) {
const p1 = points[i];
const p2 = points[i + 1];
const lineLengthSq = (p2.x - p1.x) ** 2 + (p2.y - p1.y) ** 2;
if (lineLengthSq === 0) continue;
const t =
((busCoordinates.x - p1.x) * (p2.x - p1.x) +
(busCoordinates.y - p1.y) * (p2.y - p1.y)) /
lineLengthSq;
const projectionT = Math.max(0, Math.min(1, t));
const projectedX = p1.x + projectionT * (p2.x - p1.x);
const projectedY = p1.y + projectionT * (p2.y - p1.y);
const dist = Math.sqrt(
(busCoordinates.x - projectedX) ** 2 +
(busCoordinates.y - projectedY) ** 2
);
if (dist < minDistance) {
minDistance = dist;
closestSegmentIndex = i;
busSegmentStartPoint = { x: projectedX, y: projectedY };
}
}
if (closestSegmentIndex !== -1 && busSegmentStartPoint) {
g.moveTo(points[0].x, points[0].y);
for (let i = 1; i <= closestSegmentIndex; i++) {
g.lineTo(points[i].x, points[i].y);
}
g.lineTo(busSegmentStartPoint.x, busSegmentStartPoint.y);
g.stroke({
color: PASSED_PATH_COLOR,
width: PATH_WIDTH,
});
}
},
[points, busCoordinates]
);
if (points.length === 0) {
console.error("points is empty");
return null;
}
return <pixiGraphics draw={draw} />;
}

View File

@@ -17,6 +17,7 @@ import {
UNPASSED_STATION_COLOR, UNPASSED_STATION_COLOR,
BUS_COLOR, BUS_COLOR,
BASE_ICON_SIZE, BASE_ICON_SIZE,
CLUSTER_RADIUS_BASE,
} from "./Constants"; } from "./Constants";
import { SCALE_FACTOR } from "../../assets/Constants"; import { SCALE_FACTOR } from "../../assets/Constants";
import { apiStore } from "../../api/ApiStore/store"; import { apiStore } from "../../api/ApiStore/store";
@@ -155,19 +156,6 @@ const useSightClustering = (
continue; continue;
} }
const hasCustomIcon =
sight.is_default_icon === false && !isMediaIdEmpty(sight.icon ?? null);
if (hasCustomIcon) {
sight.visited = true;
clusteredResult.push({
type: "point",
id: String(sight.id),
data: sight,
});
continue;
}
const clusterSights: SightData[] = []; const clusterSights: SightData[] = [];
const queue = [sight]; const queue = [sight];
sight.visited = true; sight.visited = true;
@@ -177,12 +165,8 @@ const useSightClustering = (
clusterSights.push(current); clusterSights.push(current);
for (const potentialNeighbor of unclusteredSights) { for (const potentialNeighbor of unclusteredSights) {
const neighborHasCustomIcon =
potentialNeighbor.is_default_icon === false &&
!isMediaIdEmpty(potentialNeighbor.icon ?? null);
if ( if (
!potentialNeighbor.visited && !potentialNeighbor.visited &&
!neighborHasCustomIcon &&
clusterSights.length < 4 && clusterSights.length < 4 &&
getDistance(current, potentialNeighbor) < distanceThreshold getDistance(current, potentialNeighbor) < distanceThreshold
) { ) {
@@ -192,10 +176,6 @@ const useSightClustering = (
} }
} }
for (const leftover of queue) {
leftover.visited = false;
}
if (clusterSights.length > 1) { if (clusterSights.length > 1) {
let furthestSight: SightData | null = null; let furthestSight: SightData | null = null;
let maxDistanceToPath = -1; let maxDistanceToPath = -1;
@@ -402,14 +382,12 @@ export const WebGLMap = observer(() => {
return livePercent; return livePercent;
} }
if (sight?.is_default_icon === false) { if (
if ( sight != null &&
typeof sight.icon_size === "number" && typeof sight.icon_size === "number" &&
Number.isFinite(sight.icon_size) Number.isFinite(sight.icon_size)
) { ) {
return sight.icon_size; return sight.icon_size;
}
return 100;
} }
if ( if (
@@ -903,36 +881,25 @@ export const WebGLMap = observer(() => {
// Сегментный индекс каждой упорядоченной станции на routePath (canvas-пространство) // Сегментный индекс каждой упорядоченной станции на routePath (canvas-пространство)
const orderedStationSegs = useMemo(() => { const orderedStationSegs = useMemo(() => {
if (!orderedRouteStations || !stationData || routePath.length < 4) if (!orderedRouteStations || !stationData || routePath.length < 4) return [] as number[];
return [] as number[];
return (orderedRouteStations as any[]).map((ordStation) => { return (orderedRouteStations as any[]).map((ordStation) => {
const stIdx = stationData.findIndex( const stIdx = stationData.findIndex((s: any) => String(s.id) === String(ordStation.id));
(s: any) => String(s.id) === String(ordStation.id),
);
if (stIdx < 0) return -1; if (stIdx < 0) return -1;
const sx = stationPoints[stIdx * 2]; const sx = stationPoints[stIdx * 2];
const sy = stationPoints[stIdx * 2 + 1]; const sy = stationPoints[stIdx * 2 + 1];
if (sx === undefined || sy === undefined) return -1; if (sx === undefined || sy === undefined) return -1;
let best = -1, let best = -1, bestD = Infinity;
bestD = Infinity;
for (let i = 0; i < routePath.length - 2; i += 2) { for (let i = 0; i < routePath.length - 2; i += 2) {
const p1x = routePath[i], const p1x = routePath[i], p1y = routePath[i + 1];
p1y = routePath[i + 1]; const p2x = routePath[i + 2], p2y = routePath[i + 3];
const p2x = routePath[i + 2], const dx = p2x - p1x, dy = p2y - p1y;
p2y = routePath[i + 3];
const dx = p2x - p1x,
dy = p2y - p1y;
const len2 = dx * dx + dy * dy; const len2 = dx * dx + dy * dy;
if (!len2) continue; if (!len2) continue;
const t = ((sx - p1x) * dx + (sy - p1y) * dy) / len2; const t = ((sx - p1x) * dx + (sy - p1y) * dy) / len2;
const cl = Math.max(0, Math.min(1, t)); const cl = Math.max(0, Math.min(1, t));
const px = p1x + cl * dx, const px = p1x + cl * dx, py = p1y + cl * dy;
py = p1y + cl * dy;
const d = Math.hypot(sx - px, sy - py); const d = Math.hypot(sx - px, sy - py);
if (d < bestD) { if (d < bestD) { bestD = d; best = i / 2; }
bestD = d;
best = i / 2;
}
} }
return best; return best;
}); });
@@ -1177,9 +1144,7 @@ export const WebGLMap = observer(() => {
const curIdx = apiStore.positionIndex; const curIdx = apiStore.positionIndex;
const prevIdx = prevPositionIndexRef.current; const prevIdx = prevPositionIndexRef.current;
const pathLen = apiStore.route?.path?.length ?? 0; const pathLen = apiStore.route?.path?.length ?? 0;
const isWrap = const isWrap = prevIdx >= 0 && pathLen > 0 &&
prevIdx >= 0 &&
pathLen > 0 &&
Math.abs(curIdx - prevIdx) > pathLen / 4; Math.abs(curIdx - prevIdx) > pathLen / 4;
prevPositionIndexRef.current = curIdx; prevPositionIndexRef.current = curIdx;
@@ -1449,72 +1414,32 @@ export const WebGLMap = observer(() => {
gl.uniform1f(u_pointSize, pointInnerSizePx); gl.uniform1f(u_pointSize, pointInnerSizePx);
if ( if (tramSegIndex >= 0 && orderedRouteStations && stationData && orderedStationSegs.length > 0) {
tramSegIndex >= 0 &&
orderedRouteStations &&
stationData &&
orderedStationSegs.length > 0
) {
const passedPts1: number[] = []; const passedPts1: number[] = [];
const unpassedPts1: number[] = []; const unpassedPts1: number[] = [];
for (let i = 0; i < orderedRouteStations.length; i++) { for (let i = 0; i < orderedRouteStations.length; i++) {
const orderedStation = (orderedRouteStations as any[])[i]; const orderedStation = (orderedRouteStations as any[])[i];
const stationSeg = orderedStationSegs[i] ?? -1; const stationSeg = orderedStationSegs[i] ?? -1;
if (!orderedStation || stationSeg < 0) continue; if (!orderedStation || stationSeg < 0) continue;
const isPassed = const isPassed = simulationDirection === 1 ? stationSeg < tramSegIndex : stationSeg > tramSegIndex;
simulationDirection === 1 const stIdx = stationData.findIndex((s: any) => String(s.id) === String(orderedStation.id));
? stationSeg < tramSegIndex
: stationSeg > tramSegIndex;
const stIdx = stationData.findIndex(
(s: any) => String(s.id) === String(orderedStation.id),
);
if (stIdx < 0) continue; if (stIdx < 0) continue;
const sx = stationPoints[stIdx * 2] as number; const sx = stationPoints[stIdx * 2] as number;
const sy = stationPoints[stIdx * 2 + 1] as number; const sy = stationPoints[stIdx * 2 + 1] as number;
if (isPassed) { if (isPassed) { passedPts1.push(sx, sy); } else { unpassedPts1.push(sx, sy); }
passedPts1.push(sx, sy);
} else {
unpassedPts1.push(sx, sy);
}
} }
if (passedPts1.length > 0) { if (passedPts1.length > 0) {
gl.uniform4f( gl.uniform4f(u_color_pts, ((PATH_COLOR >> 16) & 0xff) / 255, ((PATH_COLOR >> 8) & 0xff) / 255, (PATH_COLOR & 0xff) / 255, 1);
u_color_pts, gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(passedPts1), gl.STATIC_DRAW);
((PATH_COLOR >> 16) & 0xff) / 255,
((PATH_COLOR >> 8) & 0xff) / 255,
(PATH_COLOR & 0xff) / 255,
1,
);
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array(passedPts1),
gl.STATIC_DRAW,
);
gl.drawArrays(gl.POINTS, 0, passedPts1.length / 2); gl.drawArrays(gl.POINTS, 0, passedPts1.length / 2);
} }
if (unpassedPts1.length > 0) { if (unpassedPts1.length > 0) {
gl.uniform4f( gl.uniform4f(u_color_pts, ((UNPASSED_STATION_COLOR >> 16) & 0xff) / 255, ((UNPASSED_STATION_COLOR >> 8) & 0xff) / 255, (UNPASSED_STATION_COLOR & 0xff) / 255, 1);
u_color_pts, gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(unpassedPts1), gl.STATIC_DRAW);
((UNPASSED_STATION_COLOR >> 16) & 0xff) / 255,
((UNPASSED_STATION_COLOR >> 8) & 0xff) / 255,
(UNPASSED_STATION_COLOR & 0xff) / 255,
1,
);
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array(unpassedPts1),
gl.STATIC_DRAW,
);
gl.drawArrays(gl.POINTS, 0, unpassedPts1.length / 2); gl.drawArrays(gl.POINTS, 0, unpassedPts1.length / 2);
} }
} else { } else {
gl.uniform4f( gl.uniform4f(u_color_pts, ((UNPASSED_STATION_COLOR >> 16) & 0xff) / 255, ((UNPASSED_STATION_COLOR >> 8) & 0xff) / 255, (UNPASSED_STATION_COLOR & 0xff) / 255, 1);
u_color_pts,
((UNPASSED_STATION_COLOR >> 16) & 0xff) / 255,
((UNPASSED_STATION_COLOR >> 8) & 0xff) / 255,
(UNPASSED_STATION_COLOR & 0xff) / 255,
1,
);
gl.bufferData(gl.ARRAY_BUFFER, stationPoints, gl.STATIC_DRAW); gl.bufferData(gl.ARRAY_BUFFER, stationPoints, gl.STATIC_DRAW);
gl.drawArrays(gl.POINTS, 0, stationPoints.length / 2); gl.drawArrays(gl.POINTS, 0, stationPoints.length / 2);
} }
@@ -1588,17 +1513,12 @@ export const WebGLMap = observer(() => {
const passedStationIds = new Set<string>(); const passedStationIds = new Set<string>();
const unpassedStationIds = new Set<string>(); const unpassedStationIds = new Set<string>();
if ( if (tramSegIndex >= 0 && orderedRouteStations && orderedStationSegs.length === orderedRouteStations.length) {
tramSegIndex >= 0 &&
orderedRouteStations &&
orderedStationSegs.length === orderedRouteStations.length
) {
for (let i = 0; i < orderedRouteStations.length; i++) { for (let i = 0; i < orderedRouteStations.length; i++) {
const station = (orderedRouteStations as any[])[i]; const station = (orderedRouteStations as any[])[i];
const seg = orderedStationSegs[i] ?? -1; const seg = orderedStationSegs[i] ?? -1;
if (!station || seg < 0) continue; if (!station || seg < 0) continue;
const isPassed = const isPassed = simulationDirection === 1 ? seg < tramSegIndex : seg > tramSegIndex;
simulationDirection === 1 ? seg < tramSegIndex : seg > tramSegIndex;
if (isPassed) passedStationIds.add(String(station.id)); if (isPassed) passedStationIds.add(String(station.id));
else unpassedStationIds.add(String(station.id)); else unpassedStationIds.add(String(station.id));
} }
@@ -1742,26 +1662,11 @@ export const WebGLMap = observer(() => {
const sin = Math.sin(rotationAngle); const sin = Math.sin(rotationAngle);
const startStationData = orderedRouteStations?.[0] const startStationData = orderedRouteStations?.[0]
? stationData.find( ? stationData.find((station: any) => station.id.toString() === String(orderedRouteStations[0].id))
(station: any) => : stationData.find((station: any) => station.id.toString() === apiStore.context?.startStopId);
station.id.toString() === String(orderedRouteStations[0].id),
)
: stationData.find(
(station: any) =>
station.id.toString() === apiStore.context?.startStopId,
);
const endStationData = orderedRouteStations?.length const endStationData = orderedRouteStations?.length
? stationData.find( ? stationData.find((station: any) => station.id.toString() === String(orderedRouteStations[orderedRouteStations.length - 1].id))
(station: any) => : stationData.find((station: any) => station.id.toString() === apiStore.context?.endStopId);
station.id.toString() ===
String(
orderedRouteStations[orderedRouteStations.length - 1].id,
),
)
: stationData.find(
(station: any) =>
station.id.toString() === apiStore.context?.endStopId,
);
const terminalStations: number[] = []; const terminalStations: number[] = [];
@@ -1861,13 +1766,7 @@ export const WebGLMap = observer(() => {
} }
return best; return best;
})(); })();
return ( return tramSegIndex !== -1 && seg !== -1 && (simulationDirection === 1 ? seg < tramSegIndex : seg > tramSegIndex);
tramSegIndex !== -1 &&
seg !== -1 &&
(simulationDirection === 1
? seg < tramSegIndex
: seg > tramSegIndex)
);
})() })()
: false; : false;
@@ -1900,13 +1799,7 @@ export const WebGLMap = observer(() => {
} }
return best; return best;
})(); })();
return ( return tramSegIndex !== -1 && seg !== -1 && (simulationDirection === 1 ? seg < tramSegIndex : seg > tramSegIndex);
tramSegIndex !== -1 &&
seg !== -1 &&
(simulationDirection === 1
? seg < tramSegIndex
: seg > tramSegIndex)
);
})() })()
: false; : false;
@@ -1932,24 +1825,11 @@ export const WebGLMap = observer(() => {
const b_unpassed = (UNPASSED_STATION_COLOR & 0xff) / 255; const b_unpassed = (UNPASSED_STATION_COLOR & 0xff) / 255;
if (startStationData && endStationData) { if (startStationData && endStationData) {
const startIsPassed = const startIsPassed = simulationDirection === 1 ? true : isStartPassed;
simulationDirection === 1 ? true : isStartPassed;
const endIsPassed = simulationDirection === -1 ? true : isEndPassed; const endIsPassed = simulationDirection === -1 ? true : isEndPassed;
gl.uniform4f( gl.uniform4f(u_color_pts, startIsPassed ? r_passed : r_unpassed, startIsPassed ? g_passed : g_unpassed, startIsPassed ? b_passed : b_unpassed, 1.0);
u_color_pts,
startIsPassed ? r_passed : r_unpassed,
startIsPassed ? g_passed : g_unpassed,
startIsPassed ? b_passed : b_unpassed,
1.0,
);
gl.drawArrays(gl.POINTS, 0, 1); gl.drawArrays(gl.POINTS, 0, 1);
gl.uniform4f( gl.uniform4f(u_color_pts, endIsPassed ? r_passed : r_unpassed, endIsPassed ? g_passed : g_unpassed, endIsPassed ? b_passed : b_unpassed, 1.0);
u_color_pts,
endIsPassed ? r_passed : r_unpassed,
endIsPassed ? g_passed : g_unpassed,
endIsPassed ? b_passed : b_unpassed,
1.0,
);
gl.drawArrays(gl.POINTS, 1, 1); gl.drawArrays(gl.POINTS, 1, 1);
} else { } else {
const isStartStation = startStationData !== undefined; const isStartStation = startStationData !== undefined;
@@ -2573,8 +2453,14 @@ export const WebGLMap = observer(() => {
const rx = cluster.longitude; const rx = cluster.longitude;
const ry = cluster.latitude; const ry = cluster.latitude;
const iconSizePercent = resolveSightIconSizePercent();
const iconSize =
SIGHT_ICON_BASE_SIZE * clamp(iconSizePercent / 100, 0.1, 10);
const screenX = (rx * scale + position.x) / dpr; const screenX = (rx * scale + position.x) / dpr;
const screenY = (ry * scale + position.y) / dpr; const screenY = (ry * scale + position.y) / dpr;
const iconLeft = screenX - iconSize / 2;
const iconTop = screenY - iconSize / 2;
const isExpanded = activeClusterId === cluster.id; const isExpanded = activeClusterId === cluster.id;
const selectedSightInCluster = cluster.sights.find( const selectedSightInCluster = cluster.sights.find(
@@ -2586,20 +2472,6 @@ export const WebGLMap = observer(() => {
const selectedIsCustomInCluster = const selectedIsCustomInCluster =
selectedSightInCluster?.is_default_icon === false && selectedSightInCluster?.is_default_icon === false &&
selectedHasIconInCluster; selectedHasIconInCluster;
const iconSizePercent = resolveSightIconSizePercent(
cluster.sights[0],
);
const clusterCustomScaleFactor = selectedIsCustomInCluster
? scale / Math.max(customSightIconBaseScaleRef.current ?? 1, 1e-6)
: 1;
const iconSize =
SIGHT_ICON_BASE_SIZE *
clamp(iconSizePercent / 100, 0.1, 10) *
clusterCustomScaleFactor;
const iconLeft = screenX - iconSize;
const iconTop = screenY - iconSize;
const hasSelectedAltIconInCluster = const hasSelectedAltIconInCluster =
selectedSightInCluster != null && selectedSightInCluster != null &&
selectedIsCustomInCluster && selectedIsCustomInCluster &&
@@ -2657,7 +2529,7 @@ export const WebGLMap = observer(() => {
) )
: false; : false;
const badgeColor = "var(--carrier-main, #006F3A)"; const badgeColor = "#006F3A";
const listPanelWidth = 200; const listPanelWidth = 200;
const listItemHeight = 30; const listItemHeight = 30;
const listMaxHeight = 250; const listMaxHeight = 250;
@@ -2673,10 +2545,13 @@ export const WebGLMap = observer(() => {
onTouchEnd={handleClusterClick} onTouchEnd={handleClusterClick}
style={{ style={{
position: "absolute", position: "absolute",
left: iconLeft, left: iconLeft - CLUSTER_RADIUS_BASE - 10,
top: iconTop, top: iconTop - CLUSTER_RADIUS_BASE - 10,
width: iconSize, width: iconSize + CLUSTER_RADIUS_BASE * 2 + 20,
height: iconSize, height: iconSize + CLUSTER_RADIUS_BASE * 2 + 20,
display: "flex",
alignItems: "center",
justifyContent: "center",
pointerEvents: "auto", pointerEvents: "auto",
cursor: "pointer", cursor: "pointer",
userSelect: "none", userSelect: "none",
@@ -2689,22 +2564,15 @@ export const WebGLMap = observer(() => {
}), }),
}} }}
> >
<div <div style={{ position: "relative" }}>
style={{
position: "absolute",
width: iconSize,
height: iconSize,
flexShrink: 0,
}}
>
<img <img
src={clusterIconUrl} src={clusterIconUrl}
alt="" alt=""
width={iconSize}
height={iconSize}
style={{ style={{
display: "block", display: "block",
pointerEvents: "none", pointerEvents: "none",
width: "100%",
height: "100%",
filter: hasSelectedSight filter: hasSelectedSight
? hasSelectedAltIconInCluster ? hasSelectedAltIconInCluster
? "none" ? "none"
@@ -2717,7 +2585,6 @@ export const WebGLMap = observer(() => {
position: "absolute", position: "absolute",
top: -6, top: -6,
right: -6, right: -6,
zIndex: 1,
width: 15, width: 15,
height: 15, height: 15,
borderRadius: "10px", borderRadius: "10px",
@@ -2740,11 +2607,12 @@ export const WebGLMap = observer(() => {
<div <div
data-expanded-cluster={cluster.id} data-expanded-cluster={cluster.id}
onTouchStart={handleCircleInteraction} onTouchStart={handleCircleInteraction}
onTouchMove={handleCircleInteraction}
onMouseMove={handleCircleInteraction} onMouseMove={handleCircleInteraction}
style={{ style={{
position: "absolute", position: "absolute",
left: screenX - iconSize, left: screenX - iconSize / 2,
top: screenY - iconSize, top: screenY - iconSize / 2,
display: "flex", display: "flex",
alignItems: "flex-start", alignItems: "flex-start",
pointerEvents: "auto", pointerEvents: "auto",
@@ -2805,14 +2673,15 @@ export const WebGLMap = observer(() => {
<div <div
className="cluster-sights-list" className="cluster-sights-list"
style={{ style={{
background: `linear-gradient(to bottom right, rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0) 100%), rgba(var(--carrier-main-rgb, 0, 111, 58), 0.4)`, background: `linear-gradient(to bottom right, rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0) 100%), rgba(0, 111, 58, 0.4)`,
backdropFilter: "blur(10px)", backdropFilter: "blur(10px)",
borderRadius: "8px", borderRadius: "8px",
width: listPanelWidth, width: listPanelWidth,
maxHeight: hasMoreThanTwo ? listMaxHeight : undefined, maxHeight: hasMoreThanTwo ? listMaxHeight : undefined,
boxShadow: boxShadow:
"inset 0 0 0 1px rgba(255, 255, 255, 0.3), inset 4px 4px 12px 0 rgba(255, 255, 255, 0.12)", "0 0 0 1px rgba(255, 255, 255, 0.3) inset, 4px 4px 12px 0 rgba(255, 255, 255, 0.12) inset",
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
}} }}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
@@ -2955,6 +2824,7 @@ export const WebGLMap = observer(() => {
whiteSpace: "nowrap", whiteSpace: "nowrap",
flex: 1, flex: 1,
}} }}
title={sightName}
> >
{sightName} {sightName}
</span> </span>

View File

@@ -1,39 +0,0 @@
import { useRef, useEffect } from "react";
const Collapsible = ({ open, className = "", children }) => {
const ref = useRef(null);
const isFirst = useRef(true);
useEffect(() => {
const el = ref.current;
if (!el) return;
if (isFirst.current) {
isFirst.current = false;
el.style.height = open ? "auto" : "0px";
return;
}
if (open) {
el.style.height = `${el.scrollHeight}px`;
const onEnd = (e) => {
if (e.target !== el || e.propertyName !== "height") return;
el.style.height = "auto";
el.removeEventListener("transitionend", onEnd);
};
el.addEventListener("transitionend", onEnd);
} else {
el.style.height = `${el.scrollHeight}px`;
void el.offsetHeight;
el.style.height = "0px";
}
}, [open]);
return (
<div ref={ref} className={`${className} ${open ? "open" : ""}`}>
{children}
</div>
);
};
export default Collapsible;

View File

@@ -5,8 +5,6 @@ import { useGeolocationStore } from "../../stores";
import "../../styles/LeftWidget.css"; import "../../styles/LeftWidget.css";
import { apiStore } from "../../api/ApiStore/store"; import { apiStore } from "../../api/ApiStore/store";
import { apiBaseURL } from "../../api/apiConfig"; import { apiBaseURL } from "../../api/apiConfig";
import { ReactMarkdownComponent } from "../ReactMarkdown";
import { TouchableLayout } from "../TouchableLayout";
const LeftWidget = observer( const LeftWidget = observer(
({ selectedSightId, onClose, isVisible, sightTop }) => { ({ selectedSightId, onClose, isVisible, sightTop }) => {
@@ -17,7 +15,8 @@ const LeftWidget = observer(
const [isImageLoaded, setIsImageLoaded] = useState(false); const [isImageLoaded, setIsImageLoaded] = useState(false);
const [widgetHeight, setWidgetHeight] = useState(0); const [widgetHeight, setWidgetHeight] = useState(0);
const layoutRef = useRef(null); const textRef = useRef(null);
const activeTouch = useRef(null);
const widgetRef = useRef(null); const widgetRef = useRef(null);
const store = useGeolocationStore(); const store = useGeolocationStore();
@@ -38,10 +37,64 @@ const LeftWidget = observer(
}, [selectedSightData, isImageLoaded, isVisible, isLoading, error]); }, [selectedSightData, isImageLoaded, isVisible, isLoading, error]);
useEffect(() => { useEffect(() => {
if (layoutRef.current) { const scrollContainer = textRef.current;
const scrollable = layoutRef.current.querySelector(".scrollable"); if (!scrollContainer) return;
if (scrollable) scrollable.scrollTop = 0;
} const handleTouchStart = (e) => {
e.stopPropagation();
if (e.touches.length === 1) {
activeTouch.current = {
identifier: e.touches[0].identifier,
lastY: e.touches[0].clientY,
};
}
};
const handleTouchMove = (e) => {
e.preventDefault();
if (activeTouch.current) {
for (const touch of e.changedTouches) {
if (touch.identifier === activeTouch.current.identifier) {
const deltaY = touch.clientY - activeTouch.current.lastY;
scrollContainer.scrollTop -= deltaY;
activeTouch.current.lastY = touch.clientY;
break;
}
}
}
};
const handleTouchEnd = (e) => {
for (const touch of e.changedTouches) {
if (
activeTouch.current &&
touch.identifier === activeTouch.current.identifier
) {
activeTouch.current = null;
break;
}
}
};
scrollContainer.addEventListener("touchstart", handleTouchStart, {
passive: true,
});
scrollContainer.addEventListener("touchmove", handleTouchMove, {
passive: false,
});
scrollContainer.addEventListener("touchend", handleTouchEnd, {
passive: true,
});
scrollContainer.addEventListener("touchcancel", handleTouchEnd, {
passive: true,
});
return () => {
scrollContainer.removeEventListener("touchstart", handleTouchStart);
scrollContainer.removeEventListener("touchmove", handleTouchMove);
scrollContainer.removeEventListener("touchend", handleTouchEnd);
scrollContainer.removeEventListener("touchcancel", handleTouchEnd);
};
}, [selectedSightData]); }, [selectedSightData]);
useEffect(() => { useEffect(() => {
@@ -67,10 +120,8 @@ const LeftWidget = observer(
selectedLanguage === "ru" selectedLanguage === "ru"
? routeSights.find((sight) => sight.id === selectedSightId) ? routeSights.find((sight) => sight.id === selectedSightId)
: selectedLanguage === "en" : selectedLanguage === "en"
? routeSightsEn.find((sight) => sight.id === selectedSightId) ? routeSightsEn.find((sight) => sight.id === selectedSightId)
: routeSightsZh.find((sight) => sight.id === selectedSightId); : routeSightsZh.find((sight) => sight.id === selectedSightId);
if (!sight) return;
const leftArticle = sight.left_article; const leftArticle = sight.left_article;
@@ -78,20 +129,18 @@ const LeftWidget = observer(
selectedLanguage === "ru" selectedLanguage === "ru"
? sightArticles.get(leftArticle + "_" + selectedLanguage) ? sightArticles.get(leftArticle + "_" + selectedLanguage)
: selectedLanguage === "en" : selectedLanguage === "en"
? sightArticlesEn.get(leftArticle + "_" + selectedLanguage) ? sightArticlesEn.get(leftArticle + "_" + selectedLanguage)
: sightArticlesZh.get(leftArticle + "_" + selectedLanguage); : sightArticlesZh.get(leftArticle + "_" + selectedLanguage);
if (!leftArticleData?.media?.length) return;
const media = await ContentAPI.getMediaPreview( const media = await ContentAPI.getMediaPreview(
leftArticleData.media[0].id, leftArticleData.media[0].id,
selectedLanguage, selectedLanguage
); );
const response = { const response = {
mediaPath: media.path, mediaPath: media.path,
mediaType: media.type, mediaType: media.type,
title: leftArticleData.heading, title: sight.short_name || sight.name || leftArticleData.heading,
text: leftArticleData.body, text: leftArticleData.body,
address: sight.address, address: sight.address,
}; };
@@ -129,7 +178,7 @@ const LeftWidget = observer(
setIsImageLoaded(false); setIsImageLoaded(false);
console.error( console.error(
"Ошибка загрузки изображения для достопримечательности:", "Ошибка загрузки изображения для достопримечательности:",
selectedSightId, selectedSightId
); );
if (isVisible) { if (isVisible) {
setTimeout(() => { setTimeout(() => {
@@ -159,7 +208,7 @@ const LeftWidget = observer(
}; };
return ( return (
<div ref={widgetRef} style={widgetTransformStyle} className="left-widget" lang={selectedLanguage}> <div ref={widgetRef} style={widgetTransformStyle} className="left-widget">
{isLoading ? ( {isLoading ? (
<div>Загрузка информации...</div> <div>Загрузка информации...</div>
) : error ? ( ) : error ? (
@@ -185,11 +234,9 @@ const LeftWidget = observer(
<div className="left-widget-address"> <div className="left-widget-address">
{selectedSightData.address} {selectedSightData.address}
</div> </div>
<TouchableLayout ref={layoutRef} className="left-widget-text-scroll"> <div ref={textRef} className="left-widget-text">
<div className="left-widget-text"> {selectedSightData.text}
<ReactMarkdownComponent value={selectedSightData.text} /> </div>
</div>
</TouchableLayout>
</div> </div>
</> </>
) : (isVisible || selectedSightData) && !isLoading ? ( ) : (isVisible || selectedSightData) && !isLoading ? (
@@ -197,13 +244,13 @@ const LeftWidget = observer(
{selectedLanguage === "ru" {selectedLanguage === "ru"
? "Выберите достопримечательность для просмотра деталей." ? "Выберите достопримечательность для просмотра деталей."
: selectedLanguage === "zh" : selectedLanguage === "zh"
? "选择一个地标来查看详细信息。" ? "选择一个地标来查看详细信息。"
: "Select a landmark to view details."} : "Select a landmark to view details."}
</div> </div>
) : null} ) : null}
</div> </div>
); );
}, }
); );
export default LeftWidget; export default LeftWidget;

View File

@@ -2,9 +2,9 @@ import "../../styles/SideMenu.css";
import AppealWidget from "../widgets/AppealWidget"; import AppealWidget from "../widgets/AppealWidget";
import { useEffect, useState, useCallback, useRef } from "react"; import { useEffect, useState, useCallback, useRef } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import gouvermentImage from "../../assets/images/test-image.png";
import sideMenuPhoto from "/side-menu-photo.png"; import sideMenuPhoto from "/side-menu-photo.png";
import RouteWidget from "../widgets/RouteWidget"; import RouteWidget from "../widgets/RouteWidget";
import WeatherWidget from "../WeatherWidget";
import ContentAPI from "../../api/content/content.api"; import ContentAPI from "../../api/content/content.api";
import { useGeolocationStore, useColorStore } from "../../stores"; import { useGeolocationStore, useColorStore } from "../../stores";
import "../../styles/LeftWidget.css"; import "../../styles/LeftWidget.css";
@@ -17,6 +17,7 @@ import { getMediaUrl } from "../../api/apiConfig";
const SideMenu = observer(({ onMenuToggle }) => { const SideMenu = observer(({ onMenuToggle }) => {
const { const {
carrier, carrier,
city,
route, route,
getArticle, getArticle,
sightArticles, sightArticles,
@@ -31,7 +32,6 @@ const SideMenu = observer(({ onMenuToggle }) => {
const [isStationOpen, openStation] = useState(false); const [isStationOpen, openStation] = useState(false);
const [isWidgetOpen, openWidget] = useState(false); const [isWidgetOpen, openWidget] = useState(false);
const [isWeatherVisible, setIsWeatherVisible] = useState(true); const [isWeatherVisible, setIsWeatherVisible] = useState(true);
const [isPreviewMediaError, setIsPreviewMediaError] = useState(false);
const [isLeftWidgetVisible, setIsLeftWidgetVisible] = useState(false); const [isLeftWidgetVisible, setIsLeftWidgetVisible] = useState(false);
const [localSelectedSightId, setLocalSelectedSightId] = useState(null); const [localSelectedSightId, setLocalSelectedSightId] = useState(null);
const [isSightsAnimating, setIsSightsAnimating] = useState(false); const [isSightsAnimating, setIsSightsAnimating] = useState(false);
@@ -85,7 +85,7 @@ const SideMenu = observer(({ onMenuToggle }) => {
let scrollTarget = null; let scrollTarget = null;
const targetElement = document.elementFromPoint( const targetElement = document.elementFromPoint(
touch.clientX, touch.clientX,
touch.clientY, touch.clientY
); );
// Определяем, над каким из скролл-контейнеров находится палец // Определяем, над каким из скролл-контейнеров находится палец
@@ -185,12 +185,27 @@ const SideMenu = observer(({ onMenuToggle }) => {
} }
}, [isLeftWidgetVisible]); }, [isLeftWidgetVisible]);
const [designData, setDesignData] = useState(null);
const [routeData, setRouteData] = useState(null); const [routeData, setRouteData] = useState(null);
const [isLangMenuOpen, setIsLangMenuOpen] = useState(false); const [isLangMenuOpen, setIsLangMenuOpen] = useState(false);
useEffect(() => { useEffect(() => {
setIsPreviewMediaError(false); async function fetchCarrierAndCrest() {
}, [carrier?.preview_media]); try {
const carrierPath = carrier.logo ? getMediaUrl(carrier.logo) : null;
const cityPath = city.arms ? getMediaUrl(city.arms) : null;
const response = {
carrierPath: carrierPath,
creastPath: cityPath,
};
setDesignData(response);
} catch (err) {
console.error("Ошибка в получении данных герба и перевозчика:", err);
}
}
fetchCarrierAndCrest();
}, [contextData?.routeId, isGeolocationLoading, selectedLanguage]);
useEffect(() => { useEffect(() => {
if (isGeolocationLoading || !contextData?.routeId) { if (isGeolocationLoading || !contextData?.routeId) {
@@ -248,19 +263,15 @@ const SideMenu = observer(({ onMenuToggle }) => {
} }
}; };
const handleMenuToggleRef = useRef(handleMenuToggle);
handleMenuToggleRef.current = handleMenuToggle;
const isMenuOpenRef = useRef(isMenuOpen);
isMenuOpenRef.current = isMenuOpen;
useEffect(() => { useEffect(() => {
// Автоматическое закрытие сайд-меню после 45 секунд бездействия
let idleSeconds = 0; let idleSeconds = 0;
const checkIdle = () => { const checkIdle = () => {
idleSeconds += 1; idleSeconds += 1;
if (idleSeconds >= 60 && isMenuOpenRef.current) {
handleMenuToggleRef.current(false); if (idleSeconds >= 45 && isMenuOpen) {
handleMenuToggle(false);
} }
}; };
@@ -289,48 +300,7 @@ const SideMenu = observer(({ onMenuToggle }) => {
window.removeEventListener(event, resetIdle); window.removeEventListener(event, resetIdle);
}); });
}; };
}, []); }, [isMenuOpen, handleMenuToggle]);
// Автоматическое скрытие левого виджета через 60 секунд бездействия
useEffect(() => {
if (!isLeftWidgetVisible) return;
let idleSeconds = 0;
const checkIdle = () => {
idleSeconds += 1;
if (idleSeconds >= 60) {
setIsLeftWidgetVisible(false);
setTimeout(() => {
setIsLeftWidgetOpen(false);
}, 1000);
}
};
const intervalId = setInterval(checkIdle, 1000);
const resetIdle = () => {
idleSeconds = 0;
};
const events = [
"mousedown",
"mousemove",
"keypress",
"scroll",
"touchstart",
"click",
];
events.forEach((event) =>
window.addEventListener(event, resetIdle, { passive: true }),
);
return () => {
clearInterval(intervalId);
events.forEach((event) => window.removeEventListener(event, resetIdle));
};
}, [isLeftWidgetVisible, setIsLeftWidgetOpen]);
// Закрываем и открываем список достопримечательностей при изменении сортировки // Закрываем и открываем список достопримечательностей при изменении сортировки
const prevSortingByRef = useRef(sortingBy); const prevSortingByRef = useRef(sortingBy);
@@ -387,7 +357,7 @@ const SideMenu = observer(({ onMenuToggle }) => {
pointerEvents: "auto", pointerEvents: "auto",
background: background:
isSightsOpen || isStationOpen isSightsOpen || isStationOpen
? `linear-gradient(to bottom right, rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0) 100%), rgba(var(--carrier-left-rgb, 76, 175, 75), 0.4)` ? `linear-gradient(to bottom right, rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0) 100%), rgba(76, 175, 75, 0.4)`
: undefined, : undefined,
backdropFilter: backdropFilter:
isSightsOpen || isStationOpen ? "blur(10px)" : undefined, isSightsOpen || isStationOpen ? "blur(10px)" : undefined,
@@ -399,6 +369,16 @@ const SideMenu = observer(({ onMenuToggle }) => {
"background 0.3s ease, backdrop-filter 0.3s ease, box-shadow 0.3s ease", "background 0.3s ease, backdrop-filter 0.3s ease, box-shadow 0.3s ease",
}} }}
> >
{designData?.creastPath && (
<img
className="side-menu-crest"
src={designData?.creastPath}
alt="Герб"
/>
)}
{carrier?.slogan && (
<div className="side-menu-label">{carrier.slogan}</div>
)}
{shouldRenderSights && ( {shouldRenderSights && (
<SightsList <SightsList
ref={sightsListRef} ref={sightsListRef}
@@ -464,16 +444,14 @@ const SideMenu = observer(({ onMenuToggle }) => {
}} }}
className="appeal-button" className="appeal-button"
> >
{route?.button_text {selectedLanguage == "ru"
? route.button_text ? "Обращение губернатора"
: selectedLanguage == "ru" : selectedLanguage == "zh"
? "Обращение губернатора" ? "州长致辞"
: selectedLanguage == "zh" : "Governor's appeal"}
? "州长致辞"
: "Governor's appeal"}
</div> </div>
)} )}
<div className="side-menu-buttons" style={{ marginTop: route?.governor_appeal > 0 ? '40px' : '260px' }}> <div className="side-menu-buttons">
<div <div
onPointerUp={() => { onPointerUp={() => {
if (!isSightsOpen) { if (!isSightsOpen) {
@@ -522,8 +500,8 @@ const SideMenu = observer(({ onMenuToggle }) => {
{selectedLanguage == "ru" {selectedLanguage == "ru"
? "Достопримечательности" ? "Достопримечательности"
: selectedLanguage == "zh" : selectedLanguage == "zh"
? "景点" ? "景点"
: "Attractions"} : "Attractions"}
</div> </div>
<div <div
onPointerUp={() => { onPointerUp={() => {
@@ -570,8 +548,8 @@ const SideMenu = observer(({ onMenuToggle }) => {
{selectedLanguage == "ru" {selectedLanguage == "ru"
? "Остановки" ? "Остановки"
: selectedLanguage == "zh" : selectedLanguage == "zh"
? "车站" ? "车站"
: "Stations"} : "Stations"}
</div> </div>
</div> </div>
<div className="side-menu-tag"> <div className="side-menu-tag">
@@ -582,62 +560,44 @@ const SideMenu = observer(({ onMenuToggle }) => {
: "#EveryoneOnTheWay"} */} : "#EveryoneOnTheWay"} */}
</div> </div>
<div className="side-menu-bottom-section"> <div className="side-menu-bottom-section">
<img <div className="side-menu-carrier-block">
className="side-menu-bottom-photo" {designData?.carrierPath && (
src={ <img
carrier?.preview_media && !isPreviewMediaError className="carrier-logo"
? getMediaUrl(carrier.preview_media) src={designData?.carrierPath}
: sideMenuPhoto alt="ГЭТ - Электротранспорт Санкт-Петербурга"
} />
onError={() => setIsPreviewMediaError(true)} )}
alt="" {carrier?.full_name && (
/> <div className="side-menu-slogan">{carrier.short_name}</div>
)}
</div>
<img className="side-menu-bottom-photo" src={sideMenuPhoto} alt="" />
</div> </div>
</div> </div>
<div className="side-menu-control-panel"> <div className="side-menu-control-panel">
<RouteWidget /> <RouteWidget />
<div
style={{
transform:
isWeatherVisible && !isGovernorWidgetOpen
? "translateX(0)"
: "translateX(-200%)",
transition: "transform 1s ease",
opacity: isWeatherVisible && !isGovernorWidgetOpen ? 1 : 0,
pointerEvents:
isWeatherVisible && !isGovernorWidgetOpen ? "auto" : "none",
}}
>
<WeatherWidget />
</div>
<AppealWidget <AppealWidget
widgetImgPath={(() => { widgetImgPath={gouvermentImage}
const m = sightArticles.get(route?.governor_appeal + "_ru")?.media;
const mediaId = Array.isArray(m) ? m[0]?.id : m?.id;
return mediaId ? ContentAPI.getMediaPath(mediaId) : undefined;
})()}
isOpen={isWidgetOpen}
style={{ style={{
transform: isWidgetOpen ? "translateX(0)" : "translateX(-200%)", transform: isWidgetOpen ? "translateX(0)" : "translateX(-200%)",
transition: "transform 0.5s ease", transition: "transform 0.5s ease",
zIndex: -1, zIndex: -1,
pointerEvents: isWidgetOpen ? "auto" : "none",
}} }}
widgetLabel={ widgetLabel={
selectedLanguage == "ru" selectedLanguage == "ru"
? sightArticles.get(route?.governor_appeal + "_ru")?.heading ? sightArticles.get(route?.governor_appeal + "_ru")?.heading
: selectedLanguage == "zh" : selectedLanguage == "zh"
? sightArticlesZh.get(route?.governor_appeal + "_zh")?.heading ? sightArticlesZh.get(route?.governor_appeal + "_zh")?.heading
: sightArticlesEn.get(route?.governor_appeal + "_en")?.heading : sightArticlesEn.get(route?.governor_appeal + "_en")?.heading
} }
widgetText={ widgetText={
selectedLanguage == "ru" selectedLanguage == "ru"
? sightArticles.get(route?.governor_appeal + "_ru")?.body ? sightArticles.get(route?.governor_appeal + "_ru")?.body
: selectedLanguage == "zh" : selectedLanguage == "zh"
? sightArticlesZh.get(route?.governor_appeal + "_zh")?.body ? sightArticlesZh.get(route?.governor_appeal + "_zh")?.body
: sightArticlesEn.get(route?.governor_appeal + "_en")?.body : sightArticlesEn.get(route?.governor_appeal + "_en")?.body
} }
/> />

View File

@@ -64,6 +64,7 @@ const StationItem = ({
className="side-menu-sight" className="side-menu-sight"
onPointerDown={(e) => handlePointerDown(e, station.id)} onPointerDown={(e) => handlePointerDown(e, station.id)}
onPointerUp={(e) => handlePointerUp(e, station.id, handleStationClick)} onPointerUp={(e) => handlePointerUp(e, station.id, handleStationClick)}
title={station.name}
> >
<span ref={textRef} className={shouldAnimate ? "marquee-text" : ""}> <span ref={textRef} className={shouldAnimate ? "marquee-text" : ""}>
{station.name} {station.name}

View File

@@ -10,7 +10,6 @@ import { useGeolocationStore } from "../../stores";
import { apiStore } from "../../api/ApiStore/store"; import { apiStore } from "../../api/ApiStore/store";
import { useClickDetection } from "../../hooks/useClickDetection"; import { useClickDetection } from "../../hooks/useClickDetection";
import { TouchableLayout } from "../TouchableLayout"; import { TouchableLayout } from "../TouchableLayout";
import Collapsible from "./Collapsible";
import { getMediaUrl } from "../../api/apiConfig"; import { getMediaUrl } from "../../api/apiConfig";
import stationIcon from "../../assets/transport-icons/station.svg"; import stationIcon from "../../assets/transport-icons/station.svg";
@@ -39,8 +38,25 @@ const SightItem = ({
const [shouldAnimate, setShouldAnimate] = useState(false); const [shouldAnimate, setShouldAnimate] = useState(false);
const [isExpanded, setIsExpanded] = useState(false); const [isExpanded, setIsExpanded] = useState(false);
// Получаем название для отображения
const getSightName = () => { const getSightName = () => {
return sight.short_name || sight.name; // Если есть короткое название, используем его
if (sight.short_name) {
return sight.short_name;
}
if (!sight.left_article) {
return sight.name;
}
const leftArticleData =
selectedLanguage === "ru"
? sightArticles.get(sight.left_article + "_ru")
: selectedLanguage === "en"
? sightArticlesEn.get(sight.left_article + "_en")
: sightArticlesZh.get(sight.left_article + "_zh");
return leftArticleData?.heading || sight.name;
}; };
const sightName = getSightName(); const sightName = getSightName();
@@ -79,48 +95,28 @@ const SightItem = ({
const cacheKey = `${sight.id}_${selectedLanguage}`; const cacheKey = `${sight.id}_${selectedLanguage}`;
const stations = sightStationsCache.get(cacheKey) || []; const stations = sightStationsCache.get(cacheKey) || [];
useEffect(() => {
if (localSelectedSightId !== sight.id) {
setIsExpanded(false);
}
}, [localSelectedSightId, sight.id]);
// Логирование при открытии списка станций для достопримечательности (только один раз)
const wasExpandedRef = useRef(false);
useEffect(() => {
// Выводим логи только когда список открывается (переход из закрытого в открытое состояние)
if (isExpanded && !wasExpandedRef.current && stations.length > 0) {
wasExpandedRef.current = true;
}
// Сбрасываем флаг когда список закрывается
if (!isExpanded) {
wasExpandedRef.current = false;
}
}, [isExpanded, sight.id, selectedLanguage, stations.length]);
return ( return (
<div <div>
className={`side-menu-sight-wrapper ${
localSelectedSightId === sight.id ? "side-menu-sight-selected-wrapper" : ""
}`}
>
<div <div
ref={containerRef} ref={containerRef}
id={`sight-${sight.id}`} id={`sight-${sight.id}`}
onPointerDown={(e) => handlePointerDown(e, sight.id)} onPointerDown={(e) => handlePointerDown(e, sight.id)}
onPointerUp={(e) => handlePointerUp(e, sight.id, handleClick)} onPointerUp={(e) => handlePointerUp(e, sight.id, handleClick)}
className="side-menu-sight pointer" className={`side-menu-sight pointer ${
localSelectedSightId === sight.id ? "selected" : ""
}`}
title={sightName}
> >
<span ref={textRef} className={shouldAnimate ? "marquee-text" : ""}> <span ref={textRef} className={shouldAnimate ? "marquee-text" : ""}>
{sightName} {sightName}
</span> </span>
</div> </div>
<Collapsible className="side-menu-sight-transfer-list" open={isExpanded}> <div
className={`side-menu-sight-transfer-list ${isExpanded ? "open" : ""}`}
>
{stations.length > 0 ? ( {stations.length > 0 ? (
stations.map((station, index) => { stations.map((station, index) => {
let isMediaIdEmptyResult = isMediaIdEmpty(station.icon); const iconSrc = isMediaIdEmpty(station.icon)
const iconSrc = isMediaIdEmptyResult
? stationIcon ? stationIcon
: getMediaUrl(station.icon); : getMediaUrl(station.icon);
@@ -161,7 +157,7 @@ const SightItem = ({
: "No stations"} : "No stations"}
</div> </div>
)} )}
</Collapsible> </div>
</div> </div>
); );
}; };
@@ -237,8 +233,24 @@ const SightsList = observer(
: selectedLanguage === "en" : selectedLanguage === "en"
? routeSightsEn ? routeSightsEn
: routeSightsZh; : routeSightsZh;
// Функция для получения названия для сортировки
const getSightNameForSort = (sight) => { const getSightNameForSort = (sight) => {
return (sight.short_name || sight.name).trim(); if (sight.short_name) {
return sight.short_name.trim();
}
if (!sight.left_article) {
return sight.name.trim();
}
const leftArticleData =
selectedLanguage === "ru"
? sightArticles.get(sight.left_article + "_ru")
: selectedLanguage === "en"
? sightArticlesEn.get(sight.left_article + "_en")
: sightArticlesZh.get(sight.left_article + "_zh");
return (leftArticleData?.heading || sight.name).trim();
}; };
const sortedSights = [...(sights || [])].sort((a, b) => { const sortedSights = [...(sights || [])].sort((a, b) => {
@@ -337,17 +349,24 @@ const SightsList = observer(
}, [localSelectedSightId, sightList, className]); }, [localSelectedSightId, sightList, className]);
const handleSightClick = (sightId) => { const handleSightClick = (sightId) => {
// Если кликнули на уже выбранную достопримечательность — закрываем виджет и сбрасываем цвет
if (isLeftWidgetOpen && localSelectedSightId === sightId) {
setLocalSelectedSightId(null);
setIsLeftWidgetOpen(false);
return;
}
// Открываем левый виджет // Открываем левый виджет
setLocalSelectedSightId(sightId); if (isLeftWidgetOpen && localSelectedSightId !== sightId) {
setIsLeftWidgetOpen(true); setLocalSelectedSightId(sightId);
if (onSightSelected) { setIsLeftWidgetOpen(true);
onSightSelected(sightId); if (onSightSelected) {
onSightSelected(sightId);
}
} else if (!isLeftWidgetOpen) {
setLocalSelectedSightId(sightId);
setIsLeftWidgetOpen(true);
if (onSightSelected) {
onSightSelected(sightId);
}
} else {
// Если виджет уже открыт для этой достопримечательности, просто обновляем список остановок
if (onSightSelected) {
onSightSelected(sightId);
}
} }
}; };

View File

@@ -28,10 +28,10 @@ const SightItem = ({
const textRef = useRef(null); const textRef = useRef(null);
const [shouldAnimate, setShouldAnimate] = useState(false); const [shouldAnimate, setShouldAnimate] = useState(false);
// Получаем название из left_article или используем short_name/name // Получаем название из left_article
const getSightName = () => { const getSightName = () => {
if (!sight.left_article) { if (!sight.left_article) {
return sight.short_name || sight.name; return sight.name;
} }
const leftArticleData = const leftArticleData =
@@ -41,7 +41,7 @@ const SightItem = ({
? sightArticlesEn.get(sight.left_article + "_en") ? sightArticlesEn.get(sight.left_article + "_en")
: sightArticlesZh.get(sight.left_article + "_zh"); : sightArticlesZh.get(sight.left_article + "_zh");
return leftArticleData?.heading || sight.short_name || sight.name; return leftArticleData?.heading || sight.name;
}; };
const sightName = getSightName(); const sightName = getSightName();
@@ -70,6 +70,7 @@ const SightItem = ({
className={`side-menu-sight pointer ${ className={`side-menu-sight pointer ${
localSelectedSightId === sight.id ? "selected" : "" localSelectedSightId === sight.id ? "selected" : ""
}`} }`}
title={sightName}
> >
<span ref={textRef} className={shouldAnimate ? "marquee-text" : ""}> <span ref={textRef} className={shouldAnimate ? "marquee-text" : ""}>
{sightName} {sightName}
@@ -119,9 +120,10 @@ const StationSightsList = observer(
selectedLanguage selectedLanguage
); );
// Функция для получения названия из left_article
const getSightNameForSort = (sight) => { const getSightNameForSort = (sight) => {
if (!sight.left_article) { if (!sight.left_article) {
return (sight.short_name || sight.name).trim(); return sight.name.trim();
} }
const leftArticleData = const leftArticleData =
@@ -131,7 +133,7 @@ const StationSightsList = observer(
? sightArticlesEn.get(sight.left_article + "_en") ? sightArticlesEn.get(sight.left_article + "_en")
: sightArticlesZh.get(sight.left_article + "_zh"); : sightArticlesZh.get(sight.left_article + "_zh");
return (leftArticleData?.heading || sight.short_name || sight.name).trim(); return (leftArticleData?.heading || sight.name).trim();
}; };
const sortedSights = [...(response || [])].sort((a, b) => { const sortedSights = [...(response || [])].sort((a, b) => {
@@ -309,4 +311,3 @@ const StationSightsList = observer(
); );
export default StationSightsList; export default StationSightsList;
nSightsList;

View File

@@ -10,43 +10,6 @@ import { useGeolocationStore } from "../../stores";
import { apiStore } from "../../api/ApiStore/store"; import { apiStore } from "../../api/ApiStore/store";
import { useClickDetection } from "../../hooks/useClickDetection"; import { useClickDetection } from "../../hooks/useClickDetection";
import { TouchableLayout } from "../TouchableLayout"; import { TouchableLayout } from "../TouchableLayout";
import Collapsible from "./Collapsible";
const SightTransferItem = ({ name, style, onPointerUp }) => {
const containerRef = useRef(null);
const textRef = useRef(null);
const [shouldAnimate, setShouldAnimate] = useState(false);
useLayoutEffect(() => {
const checkWidth = () => {
if (containerRef.current && textRef.current) {
const containerWidth = containerRef.current.offsetWidth;
const textWidth = textRef.current.scrollWidth;
const shouldAnimateValue = textWidth > containerWidth;
setShouldAnimate(shouldAnimateValue);
if (shouldAnimateValue) {
containerRef.current.style.setProperty("--container-width", `${containerWidth}px`);
}
}
};
checkWidth();
window.addEventListener("resize", checkWidth);
return () => window.removeEventListener("resize", checkWidth);
}, [name]);
return (
<div
ref={containerRef}
className="side-menu-sight-transfer pointer"
style={style}
onPointerUp={onPointerUp}
>
<span ref={textRef} className={shouldAnimate ? "marquee-text" : ""}>
{name}
</span>
</div>
);
};
const StationItem = ({ const StationItem = ({
station, station,
@@ -93,15 +56,26 @@ const StationItem = ({
const sights = stationSightsCache.get(cacheKey) || []; const sights = stationSightsCache.get(cacheKey) || [];
const getSightName = (sight) => { const getSightName = (sight) => {
return sight.short_name || sight.name; if (sight.short_name) {
return sight.short_name;
}
if (!sight.left_article) {
return sight.name;
}
const leftArticleData =
selectedLanguage === "ru"
? sightArticles.get(sight.left_article + "_ru")
: selectedLanguage === "en"
? sightArticlesEn.get(sight.left_article + "_en")
: sightArticlesZh.get(sight.left_article + "_zh");
return leftArticleData?.heading || sight.name;
}; };
return ( return (
<div <div>
className={`side-menu-sight-wrapper ${
selectedStationId === station.id ? "side-menu-sight-selected-wrapper" : ""
}`}
>
<div <div
ref={containerRef} ref={containerRef}
className="side-menu-sight" className="side-menu-sight"
@@ -114,20 +88,22 @@ const StationItem = ({
); );
} }
}} }}
title={station.name}
> >
<span ref={textRef} className={shouldAnimate ? "marquee-text" : ""}> <span ref={textRef} className={shouldAnimate ? "marquee-text" : ""}>
{station.name} {station.name}
</span> </span>
</div> </div>
<Collapsible <div
className="side-menu-sight-transfer-list" className={`side-menu-sight-transfer-list ${
open={selectedStationId === station.id} selectedStationId === station.id ? "open" : ""
}`}
> >
{sights.length > 0 ? ( {sights.length > 0 ? (
sights.map((sight, index) => ( sights.map((sight, index) => (
<SightTransferItem <div
key={sight.id} key={sight.id}
name={getSightName(sight)} className="side-menu-sight-transfer pointer"
style={{ style={{
borderBottom: borderBottom:
index < sights.length - 1 index < sights.length - 1
@@ -139,11 +115,19 @@ const StationItem = ({
onPointerUp={(e) => { onPointerUp={(e) => {
e.stopPropagation(); e.stopPropagation();
if (onSightClick) { if (onSightClick) {
const elementRect = e.currentTarget.getBoundingClientRect(); // Вычисляем позицию элемента для правильного позиционирования левого виджета
onSightClick(sight.id, elementRect.top); const element = e.currentTarget;
const elementRect = element.getBoundingClientRect();
// Используем позицию элемента относительно viewport (elementRect.top)
// чтобы верхняя граница виджета совпадала с верхней границей элемента
const elementTop = elementRect.top;
onSightClick(sight.id, elementTop);
} }
}} }}
/> >
{getSightName(sight)}
</div>
)) ))
) : ( ) : (
<div className="side-menu-sight-transfer-empty"> <div className="side-menu-sight-transfer-empty">
@@ -154,7 +138,7 @@ const StationItem = ({
: "No sights"} : "No sights"}
</div> </div>
)} )}
</Collapsible> </div>
</div> </div>
); );
}; };

View File

@@ -1,50 +1,13 @@
import { useRef, useEffect } from "react"; import '../../styles/AppealWidget.css'
import "../../styles/AppealWidget.css";
import { TouchableLayout } from "../TouchableLayout";
import { ReactMarkdownComponent } from "../ReactMarkdown";
function AppealWidget({ function AppealWidget({widgetImgPath, widgetLabel, widgetText, style}) {
widgetImgPath, return (
widgetLabel, <div style={style} className='dynamic-widget'>
widgetText, <img className='dynamic-widget-image' src={widgetImgPath} />
style, <div className='dynamic-widget-label'>{widgetLabel}</div>
isOpen, <div className='dynamic-widget-text'>{widgetText}</div>
}) {
const stopProp = (e) => {
e.stopPropagation();
e.preventDefault();
};
const layoutRef = useRef(null);
useEffect(() => {
if (isOpen && layoutRef.current) {
const scrollable = layoutRef.current.querySelector(".scrollable");
if (scrollable) scrollable.scrollTop = 0;
}
}, [isOpen]);
return (
<div
style={style}
className="dynamic-widget"
onPointerDown={stopProp}
onPointerMove={stopProp}
onPointerUp={stopProp}
>
{widgetImgPath && (
<img className="dynamic-widget-image" src={widgetImgPath} />
)}
<div className="dynamic-widget-label">{widgetLabel}</div>
<TouchableLayout
ref={layoutRef}
className="dynamic-widget-text-scroll"
>
<div className="dynamic-widget-text">
<ReactMarkdownComponent value={widgetText} />
</div> </div>
</TouchableLayout> );
</div>
);
} }
export default AppealWidget; export default AppealWidget

View File

@@ -8,7 +8,7 @@ import { apiStore } from "../../api/ApiStore/store";
const RouteWidget = observer(() => { const RouteWidget = observer(() => {
const store = useGeolocationStore(); const store = useGeolocationStore();
const { contextData, isLoading, error, selectedLanguage } = store; const { contextData, isLoading, error, selectedLanguage } = store;
const { routeStations, routeStationsEn, routeStationsZh, context } = apiStore; const { routeStations, routeStationsEn, routeStationsZh, context, route } = apiStore;
const [startStation, setStartStation] = useState(null); const [startStation, setStartStation] = useState(null);
const [startStationEn, setStartStationEn] = useState(null); const [startStationEn, setStartStationEn] = useState(null);
const [endStation, setEndStation] = useState(null); const [endStation, setEndStation] = useState(null);
@@ -84,14 +84,15 @@ const RouteWidget = observer(() => {
const routeZhSubtitle = `${startStationZh?.name} - ${endStationZh?.name}`; const routeZhSubtitle = `${startStationZh?.name} - ${endStationZh?.name}`;
return ( return (
<div className="route-widget"> <div className="route-widget">
<div className={`route-widget-number ${getNumberSizeClass(context?.routeNumber)}`}> <div className={`route-widget-number ${getNumberSizeClass(route?.route_sys_number || context?.routeNumber)}`}>
{context?.routeNumber || "No number"} {route?.route_sys_number || context?.routeNumber || ""}
</div> </div>
<div className="route-widget-content"> <div className="route-widget-content">
<div <div
className={`route-widget-label ${ className={`route-widget-label ${
shouldAnimate(startStation?.name, 18) ? "marquee" : "" shouldAnimate(startStation?.name, 18) ? "marquee" : ""
} ${getLabelSizeClass(startStation?.name)}`} } ${getLabelSizeClass(startStation?.name)}`}
title={startStation?.name}
> >
{startStation?.name} {startStation?.name}
</div> </div>
@@ -99,6 +100,7 @@ const RouteWidget = observer(() => {
className={`route-widget-label ${ className={`route-widget-label ${
shouldAnimate(endStation?.name, 18) ? "marquee" : "" shouldAnimate(endStation?.name, 18) ? "marquee" : ""
} ${getLabelSizeClass(endStation?.name)}`} } ${getLabelSizeClass(endStation?.name)}`}
title={endStation?.name}
> >
{endStation?.name} {endStation?.name}
</div> </div>
@@ -107,6 +109,7 @@ const RouteWidget = observer(() => {
className={`route-widget-subtitle ${ className={`route-widget-subtitle ${
shouldAnimate(routeEnSubtitle, 50) ? "marquee" : "" shouldAnimate(routeEnSubtitle, 50) ? "marquee" : ""
}`} }`}
title={routeEnSubtitle}
> >
{routeEnSubtitle} {routeEnSubtitle}
</div> </div>
@@ -116,6 +119,7 @@ const RouteWidget = observer(() => {
className={`route-widget-subtitle ${ className={`route-widget-subtitle ${
shouldAnimate(routeZhSubtitle, 50) ? "marquee" : "" shouldAnimate(routeZhSubtitle, 50) ? "marquee" : ""
}`} }`}
title={routeZhSubtitle}
> >
{routeZhSubtitle} {routeZhSubtitle}
</div> </div>

View File

@@ -1,6 +1,6 @@
import { Canvas, useThree, useFrame } from "@react-three/fiber"; import { Canvas, useThree } from "@react-three/fiber";
import { OrbitControls, Center, useGLTF } from "@react-three/drei"; import { OrbitControls, Stage, useGLTF } from "@react-three/drei";
import React, { useEffect, useRef, Suspense, useCallback } from "react"; import React, { useEffect, Suspense } from "react";
import { BACKGROUND_COLOR } from "../../assets/Constants"; import { BACKGROUND_COLOR } from "../../assets/Constants";
import * as THREE from "three"; import * as THREE from "three";
import type { OrbitControls as OrbitControlsImpl } from "three-stdlib"; import type { OrbitControls as OrbitControlsImpl } from "three-stdlib";
@@ -23,7 +23,6 @@ interface ThreeViewProps {
const ZOOM_FACTOR = 1.2; const ZOOM_FACTOR = 1.2;
const MIN_DISTANCE = 1; const MIN_DISTANCE = 1;
const MAX_DISTANCE = 100; const MAX_DISTANCE = 100;
const CAMERA_FOV = 40;
const TouchController = () => { const TouchController = () => {
const { camera, controls, gl } = useThree(); const { camera, controls, gl } = useThree();
@@ -198,47 +197,6 @@ const AutoResize = () => {
return null; return null;
}; };
const FitCamera = ({
groupRef,
onReady,
}: {
groupRef: React.RefObject<THREE.Group>;
onReady: () => void;
}) => {
const { camera, controls } = useThree();
const fitted = useRef(false);
useFrame(() => {
if (fitted.current) return;
const group = groupRef.current;
if (!group || group.children.length === 0) return;
const box = new THREE.Box3().setFromObject(group);
const sphere = new THREE.Sphere();
box.getBoundingSphere(sphere);
if (sphere.radius === 0) return;
const fov = THREE.MathUtils.degToRad(CAMERA_FOV);
const dist = sphere.radius / Math.sin(fov / 2);
camera.position.set(0, 0, dist);
camera.lookAt(0, 0, 0);
camera.updateProjectionMatrix();
if (controls) {
const orbit = controls as unknown as OrbitControlsImpl;
orbit.target.set(0, 0, 0);
orbit.update();
}
fitted.current = true;
onReady();
});
return null;
};
const Model = ({ const Model = ({
fileUrl, fileUrl,
onLoad, onLoad,
@@ -275,37 +233,21 @@ export const ThreeView: React.FC<ThreeViewProps> = ({
onError, onError,
controlRef, controlRef,
}) => { }) => {
const [isReady, setIsReady] = React.useState(false);
const groupRef = useRef<THREE.Group>(null!);
const handleReady = useCallback(() => {
setIsReady(true);
onLoad?.();
}, [onLoad]);
return ( return (
<div style={{ width, height, position: "relative", overflow: "hidden" }}> <div style={{ width, height, position: "relative", overflow: "hidden" }}>
{!isReady && (
<div style={{
position: "absolute", inset: 0,
backgroundColor: `#${BACKGROUND_COLOR.toString(16).padStart(6, "0")}`,
zIndex: 1,
}} />
)}
<Canvas <Canvas
gl={{ gl={{
antialias: true, antialias: true,
toneMappingExposure: 1.5, toneMappingExposure: 1.5,
outputColorSpace: THREE.SRGBColorSpace, outputColorSpace: THREE.SRGBColorSpace,
}} }}
camera={{ position: [0, 0, 50], fov: CAMERA_FOV }} camera={{ position: [0, 0, 5], fov: 40 }}
style={{ width: "100%", height: "100%" }} style={{ width: "100%", height: "100%" }}
onError={(e: any) => onError?.(e.message)} onError={(e: any) => onError?.(e.message)}
> >
<AutoResize /> <AutoResize />
<TouchController /> <TouchController />
{controlRef && <ZoomController controlRef={controlRef} />} {controlRef && <ZoomController controlRef={controlRef} />}
<FitCamera groupRef={groupRef} onReady={handleReady} />
<color attach="background" args={[BACKGROUND_COLOR]} /> <color attach="background" args={[BACKGROUND_COLOR]} />
<ambientLight intensity={0.8} /> <ambientLight intensity={0.8} />
<directionalLight position={[30, 30, 30]} intensity={1.2} /> <directionalLight position={[30, 30, 30]} intensity={1.2} />
@@ -323,18 +265,23 @@ export const ThreeView: React.FC<ThreeViewProps> = ({
<pointLight position={[0, 30, 0]} intensity={0.6} /> <pointLight position={[0, 30, 0]} intensity={0.6} />
<Suspense fallback={null}> <Suspense fallback={null}>
<Center precise> <Stage
<group ref={groupRef}> environment={null}
<Model fileUrl={fileUrl} /> intensity={1}
</group> castShadow={false}
</Center> shadows={false}
adjustCamera={true}
center={{ precise: true }}
>
<Model fileUrl={fileUrl} onLoad={onLoad} />
</Stage>
</Suspense> </Suspense>
<OrbitControls <OrbitControls
makeDefault makeDefault
enableZoom={true} enableZoom={true}
enablePan={true} enablePan={true}
target={[0, 0, 0]} target={[50, 50, 50]}
minDistance={1} minDistance={1}
maxDistance={100} maxDistance={100}
enableDamping={true} enableDamping={true}

View File

@@ -14,9 +14,9 @@
.side-menu-sights-block { .side-menu-sights-block {
height: calc(60%); height: calc(60%);
overflow-y: scroll; overflow-y: scroll;
margin-left: 0; margin-left: 20px;
margin-top: 8px; margin-top: 8px;
padding-right: 5px; margin-right: 5px;
touch-action: none; /* Отключаем стандартные действия */ touch-action: none; /* Отключаем стандартные действия */
overscroll-behavior: contain; /* Предотвращаем прокрутку родительских элементов */ overscroll-behavior: contain; /* Предотвращаем прокрутку родительских элементов */
} }

View File

@@ -1,45 +1,21 @@
import { makeAutoObservable, runInAction } from "mobx"; import { makeAutoObservable, runInAction } from "mobx";
const COLOR_WHITE = { h: 151, s: 0, l: 100 };
const COLOR_GREEN = { h: 151, s: 100, l: 22 };
const TRANSITION_DURATION = 60000; const TRANSITION_DURATION = 60000;
const TICK_INTERVAL = 100; const TICK_INTERVAL = 100;
const TICK_STEP = TICK_INTERVAL / TRANSITION_DURATION; const TICK_STEP = TICK_INTERVAL / TRANSITION_DURATION;
function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
const clean = hex.trim().replace(/^#/, "");
const full = clean.length === 3 ? clean.split("").map((c) => c + c).join("") : clean;
if (full.length !== 6) return null;
return {
r: parseInt(full.slice(0, 2), 16),
g: parseInt(full.slice(2, 4), 16),
b: parseInt(full.slice(4, 6), 16),
};
}
function interpolateRgb(
from: { r: number; g: number; b: number },
to: { r: number; g: number; b: number },
t: number
): string {
const r = Math.round(from.r + (to.r - from.r) * t);
const g = Math.round(from.g + (to.g - from.g) * t);
const b = Math.round(from.b + (to.b - from.b) * t);
return `rgb(${r}, ${g}, ${b})`;
}
const WHITE = { r: 255, g: 255, b: 255 };
const DEFAULT_MAIN = { r: 0, g: 111, b: 58 };
interface ColorStore { interface ColorStore {
currentColor: string; currentColor: string;
setCurrentColor: (color: string) => void; setCurrentColor: (color: string) => void;
setMainColor: (hex: string) => void;
startColorAnimation: () => void; startColorAnimation: () => void;
stopColorAnimation: () => void; stopColorAnimation: () => void;
} }
class ColorStore implements ColorStore { class ColorStore implements ColorStore {
currentColor: string = "#fff"; currentColor: string = "#fff";
private mainColor: { r: number; g: number; b: number } = DEFAULT_MAIN;
private progress: number = 0; private progress: number = 0;
private direction: number = 1; private direction: number = 1;
private tickInterval: ReturnType<typeof setInterval> | null = null; private tickInterval: ReturnType<typeof setInterval> | null = null;
@@ -52,12 +28,12 @@ class ColorStore implements ColorStore {
this.currentColor = color; this.currentColor = color;
}; };
setMainColor = (hex: string) => { private interpolateColor(progress: number): string {
const parsed = hexToRgb(hex); const h = Math.round(COLOR_WHITE.h + (COLOR_GREEN.h - COLOR_WHITE.h) * progress);
if (parsed) { const s = Math.round(COLOR_WHITE.s + (COLOR_GREEN.s - COLOR_WHITE.s) * progress);
this.mainColor = parsed; const l = Math.round(COLOR_WHITE.l + (COLOR_GREEN.l - COLOR_WHITE.l) * progress);
} return `hsl(${h}, ${s}%, ${l}%)`;
}; }
startColorAnimation = () => { startColorAnimation = () => {
if (this.tickInterval) return; if (this.tickInterval) return;
@@ -74,7 +50,7 @@ class ColorStore implements ColorStore {
this.direction = 1; this.direction = 1;
} }
this.currentColor = interpolateRgb(WHITE, this.mainColor, this.progress); this.currentColor = this.interpolateColor(this.progress);
}); });
}, TICK_INTERVAL); }, TICK_INTERVAL);
}; };
@@ -92,4 +68,4 @@ class ColorStore implements ColorStore {
} }
export const colorStore = new ColorStore(); export const colorStore = new ColorStore();
export { ColorStore }; export { ColorStore };

View File

@@ -5,124 +5,37 @@
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
width: 420px; width: 420px;
max-height: calc(100vh - 150px - 98px);
border-radius: 10px; border-radius: 10px;
background: background: linear-gradient(
linear-gradient(
114deg, 114deg,
rgba(255, 255, 255, 0) 8.71%, rgba(255, 255, 255, 0) 8.71%,
rgba(255, 255, 255, 0.16) 69.69% rgba(255, 255, 255, 0.16) 69.69%
), ),
var(--carrier-left, #006f3a); #006F3A;
box-sizing: border-box; box-sizing: border-box;
overflow: hidden;
touch-action: none;
} }
.dynamic-widget-image { .dynamic-widget-image {
padding-top: 2px; border-radius-top-left: 10px;
margin-left: 2px; border-radius-top-right: 10px;
margin-right: 2px; padding-top: 4px;
width: 416px; margin-left: 4px;
border-radius: 10px 10px 0 0; margin-right: 4px;
object-fit: cover; width: 412px;
} }
.dynamic-widget-label { .dynamic-widget-label {
width: 100%; width: 380px;
padding: 10px 20px; margin-top: 29px;
box-sizing: border-box;
font-size: 20px; font-size: 20px;
font-weight: 500; font-weight: 500;
color: #fff;
border-bottom: 1px solid var(--Glass-stroke, rgba(255, 255, 255, 0.8));
background:
linear-gradient(
180deg,
rgba(255, 255, 255, 0.2) 0%,
rgba(255, 255, 255, 0) 100%
),
rgba(var(--carrier-right-menu-rgb, 179, 165, 152), 0.4);
} }
.dynamic-widget-text-scroll.scrollable-container {
flex: 1;
min-height: 0;
align-self: stretch;
margin: 15px 20px;
overflow: hidden;
}
.dynamic-widget-text-scroll .scrollable-viewport {
flex: 1;
min-height: 0;
overflow: hidden;
}
.dynamic-widget-text-scroll .scrollable {
flex: 1;
min-height: 0;
}
.dynamic-widget-text { .dynamic-widget-text {
margin-top: 16px;
margin-bottom: 25px;
width: 380px;
font-size: 16px; font-size: 16px;
font-weight: 300; font-weight: 300;
line-height: 135%; line-height: 150%;
padding-right: 5px;
}
.dynamic-widget-text .react-markdown-container {
font-size: 16px;
line-height: 135%;
font-weight: 300;
}
.dynamic-widget-text .react-markdown-container p {
font-size: 16px;
line-height: 135%;
margin-bottom: 8px;
}
.dynamic-widget-text .react-markdown-container p:last-child {
margin-bottom: 0;
}
.dynamic-widget-text .react-markdown-container h1,
.dynamic-widget-text .react-markdown-container h2,
.dynamic-widget-text .react-markdown-container h3,
.dynamic-widget-text .react-markdown-container h4,
.dynamic-widget-text .react-markdown-container h5,
.dynamic-widget-text .react-markdown-container h6 {
font-size: 18px;
margin-top: 10px;
margin-bottom: 4px;
font-weight: 600;
}
.dynamic-widget-text .react-markdown-container ul,
.dynamic-widget-text .react-markdown-container ol {
margin-bottom: 8px;
padding-left: 20px;
}
.dynamic-widget-text .react-markdown-container li {
margin-bottom: 4px;
}
.dynamic-widget-text .react-markdown-container blockquote {
margin-top: 8px;
margin-bottom: 8px;
padding-left: 12px;
border-left: 3px solid rgba(255, 255, 255, 0.4);
}
.dynamic-widget-text .react-markdown-container img {
max-width: 100%;
border-radius: 6px;
}
.dynamic-widget-text .react-markdown-container a {
color: rgba(255, 255, 255, 0.9);
text-decoration: underline;
} }

View File

@@ -14,7 +14,7 @@
rgba(255, 255, 255, 0) 8.71%, rgba(255, 255, 255, 0) 8.71%,
rgba(255, 255, 255, 0.16) 69.69% rgba(255, 255, 255, 0.16) 69.69%
), ),
var(--carrier-left, #006F3A); #006F3A;
will-change: transform, opacity; will-change: transform, opacity;
backface-visibility: hidden; backface-visibility: hidden;
} }
@@ -67,78 +67,17 @@
line-height: 150%; line-height: 150%;
} }
.left-widget-text-scroll.scrollable-container {
margin-top: 15px;
overflow: hidden;
width: 100%;
}
.left-widget-text-scroll .scrollable-viewport {
max-height: 200px;
}
.left-widget-text { .left-widget-text {
margin-top: 15px;
color: #fff; color: #fff;
font-family: "Roboto"; font-family: "Roboto";
font-size: 16px; font-size: 16px;
font-weight: 300; font-weight: 300;
line-height: 135%; line-height: 135%;
padding-right: 3px; max-height: 200px; /* Пример ограничения высоты */
} overflow-y: auto;
touch-action: none;
.left-widget-text .react-markdown-container { overscroll-behavior: contain;
font-size: 16px;
line-height: 135%;
font-weight: 300;
}
.left-widget-text .react-markdown-container p {
font-size: 16px;
line-height: 135%;
margin-bottom: 8px;
}
.left-widget-text .react-markdown-container p:last-child {
margin-bottom: 0;
}
.left-widget-text .react-markdown-container h1,
.left-widget-text .react-markdown-container h2,
.left-widget-text .react-markdown-container h3,
.left-widget-text .react-markdown-container h4,
.left-widget-text .react-markdown-container h5,
.left-widget-text .react-markdown-container h6 {
font-size: 18px;
margin-top: 10px;
margin-bottom: 4px;
font-weight: 600;
}
.left-widget-text .react-markdown-container ul,
.left-widget-text .react-markdown-container ol {
margin-bottom: 8px;
padding-left: 20px;
}
.left-widget-text .react-markdown-container li {
margin-bottom: 4px;
}
.left-widget-text .react-markdown-container blockquote {
margin-top: 8px;
margin-bottom: 8px;
padding-left: 12px;
border-left: 3px solid rgba(255, 255, 255, 0.4);
}
.left-widget-text .react-markdown-container img {
max-width: 100%;
border-radius: 6px;
}
.left-widget-text .react-markdown-container a {
color: rgba(255, 255, 255, 0.9);
text-decoration: underline;
} }
.left-widget-image { .left-widget-image {
@@ -154,27 +93,26 @@
padding-left: 10px; padding-left: 10px;
padding-bottom: 6px; padding-bottom: 6px;
width: 100%; width: 100%;
}
/* Анимация для списка пересадок */
.side-menu-sight-transfer-list.entering,
.side-menu-sight-transfer-list.entered {
max-height: 500px; /* Достаточно большое значение, чтобы вместить все пересадки */
opacity: 1;
transition: max-height 0.3s ease-out, opacity 0.3s ease-out;
overflow: hidden; overflow: hidden;
} }
.side-menu-sight-transfer span {
display: inline-block;
white-space: nowrap;
}
.side-menu-sight-transfer span.marquee-text {
animation: side-menu-marquee 14s linear infinite;
}
.side-menu-sight-transfer-list { .side-menu-sight-transfer-list {
height: 0; max-height: 0;
opacity: 0; opacity: 0;
overflow: hidden; overflow: hidden;
transition: transition: max-height 0.3s ease-out, opacity 0.3s ease-out; /* Анимация при открытии/закрытии */
height 0.3s cubic-bezier(0.16, 1, 0.3, 1),
opacity 0.3s ease;
} }
/* Активное состояние - когда список открыт */
.side-menu-sight-transfer-list.open { .side-menu-sight-transfer-list.open {
max-height: 500px; /* Достаточно большое значение, чтобы вместить все пересадки */
opacity: 1; opacity: 1;
} }

View File

@@ -1,45 +1,3 @@
@property --fade-top {
syntax: "<length>";
inherits: false;
initial-value: 0px;
}
@property --fade-bottom {
syntax: "<length>";
inherits: false;
initial-value: 45px;
}
@keyframes pulse-chevron {
0% {
transform: rotate(var(--r, 0deg)) translateY(0px) scale(1);
}
40% {
transform: rotate(var(--r, 0deg)) translateY(-4px) scale(1.12);
}
60% {
transform: rotate(var(--r, 0deg)) translateY(-5px) scale(1.14);
}
100% {
transform: rotate(var(--r, 0deg)) translateY(0px) scale(1);
}
}
.chevron-svg {
font-size: 20px;
animation: pulse-chevron 1.2s ease-in-out infinite;
animation-play-state: paused;
will-change: transform;
}
.chevron-svg.is-idle {
animation-play-state: running;
}
.chevron-svg.is-open {
--r: 180deg;
}
.right-widget { .right-widget {
position: fixed; position: fixed;
right: 32px; right: 32px;
@@ -48,8 +6,6 @@
} }
.list-of-sights { .list-of-sights {
position: relative;
z-index: 10;
-webkit-box-shadow: 0px -8px 17px 2px rgba(34, 60, 80, 0.2); -webkit-box-shadow: 0px -8px 17px 2px rgba(34, 60, 80, 0.2);
-moz-box-shadow: 0px -8px 17px 2px rgba(34, 60, 80, 0.2); -moz-box-shadow: 0px -8px 17px 2px rgba(34, 60, 80, 0.2);
box-shadow: 0px -8px 17px 2px rgba(34, 60, 80, 0.2); box-shadow: 0px -8px 17px 2px rgba(34, 60, 80, 0.2);
@@ -61,11 +17,11 @@
rgba(255, 255, 255, 0) 8.71%, rgba(255, 255, 255, 0) 8.71%,
rgba(255, 255, 255, 0.16) 69.69% rgba(255, 255, 255, 0.16) 69.69%
), ),
var(--carrier-right, #806c59); #006f3a;
color: white; color: white;
max-height: 68px; max-height: 68px;
transition: max-height 0.35s cubic-bezier(0.16, 1, 0.3, 1); transition: max-height 0.15s ease;
overflow: hidden; overflow: hidden;
} }
@@ -107,7 +63,7 @@
border-radius: 10px; border-radius: 10px;
width: 128px; width: 128px;
background-color: var(--carrier-right-dark, #005a2f); background-color: #0e8953;
} }
.list-of-sights-title { .list-of-sights-title {
@@ -125,7 +81,7 @@
opacity: 0; opacity: 0;
pointer-events: none; pointer-events: none;
transition: opacity 0.25s cubic-bezier(0.16, 1, 0.3, 1) 0.1s; transition: opacity 0.15s ease-in-out 0.15s;
overscroll-behavior: contain; overscroll-behavior: contain;
touch-action: pan-y; touch-action: pan-y;
@@ -134,29 +90,6 @@
backface-visibility: hidden; backface-visibility: hidden;
} }
.list-of-sights-content .scrollable {
--fade-top: 0px;
--fade-bottom: 45px;
mask-image: linear-gradient(
to bottom,
transparent 0px,
black var(--fade-top),
black calc(100% - var(--fade-bottom)),
transparent 100%
);
transition:
--fade-top 0.5s ease,
--fade-bottom 0.5s ease;
}
.list-of-sights-content:not(.is-at-top) .scrollable {
--fade-top: 15px;
}
.list-of-sights-content.is-at-bottom .scrollable {
--fade-bottom: 0px;
}
.list-of-sights-grid { .list-of-sights-grid {
display: grid; display: grid;
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr);
@@ -170,12 +103,6 @@
pointer-events: auto; pointer-events: auto;
} }
.list-of-sights-content .custom-scrollbar-track {
margin-top: 14px;
margin-bottom: 10px;
overflow: hidden;
}
.sight-component { .sight-component {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -248,18 +175,18 @@
height: 1px; height: 1px;
width: 100%; width: 100%;
background-color: rgba(255, 255, 255, 0.3); background-color: rgba(255, 255, 255, 0.3);
margin-bottom: 0; margin-bottom: 14px;
} }
.sight-frame { .sight-frame {
z-index: -1; z-index: -1;
position: absolute; position: absolute;
bottom: 100px; bottom: 66px;
left: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
width: 100%; margin-bottom: 32px;
width: 550px;
border-radius: 10px; border-radius: 10px;
background: background:
linear-gradient( linear-gradient(
@@ -267,14 +194,8 @@
rgba(255, 255, 255, 0) 8.71%, rgba(255, 255, 255, 0) 8.71%,
rgba(255, 255, 255, 0.16) 69.69% rgba(255, 255, 255, 0.16) 69.69%
), ),
var(--carrier-right, #806c59); #006f3a;
max-height: calc(100vh - 128px); max-height: calc(100vh - 128px);
transform: translate3d(0, 0, 0);
overflow: hidden;
}
.sight-frame.three-d-fullscreen {
overflow: visible;
} }
.sight-frame-image { .sight-frame-image {
@@ -291,11 +212,6 @@
flex-direction: column; flex-direction: column;
flex-grow: 1; flex-grow: 1;
width: 100%; width: 100%;
transition: opacity 0.18s ease;
}
.sight-frame-content.is-switching {
opacity: 0;
} }
.sight-frame-get-back-wrapper { .sight-frame-get-back-wrapper {
@@ -321,37 +237,15 @@
rgba(255, 255, 255, 0.22) 0%, rgba(255, 255, 255, 0.22) 0%,
rgba(255, 255, 255, 0.04) 100% rgba(255, 255, 255, 0.04) 100%
), ),
rgba(var(--carrier-right-rgb, 128, 108, 89), 0.72); rgba(0, 111, 58, 0.72);
box-shadow: 4px 4px 12px 0px rgba(255, 255, 255, 0.12) inset; box-shadow: 4px 4px 12px 0px rgba(255, 255, 255, 0.12) inset;
box-sizing: border-box; box-sizing: border-box;
color: white; color: white;
word-wrap: break-word; word-wrap: break-word;
overflow-wrap: break-word;
min-width: 0; min-width: 0;
} }
.fade-in-text {
animation: fadeInText 0.35s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
@keyframes fadeInText {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.sight-frame-title:not(.intro-title) {
background:
linear-gradient(
180deg,
rgba(255, 255, 255, 0.2) 0%,
rgba(255, 255, 255, 0) 100%
),
rgba(var(--carrier-right-menu-rgb, 179, 165, 152), 0.4);
}
.sight-frame-title p { .sight-frame-title p {
word-wrap: break-word; word-wrap: break-word;
overflow-wrap: break-word; overflow-wrap: break-word;
@@ -369,40 +263,18 @@
} }
.sight-frame-text-wrapper { .sight-frame-text-wrapper {
flex-grow: 1;
padding: 16px;
box-sizing: border-box;
max-height: calc(80vh - 354px); max-height: calc(80vh - 354px);
min-height: 0; min-height: 0;
flex-shrink: 0;
padding: 16px 16px 76px 16px;
box-sizing: border-box;
overflow: hidden; overflow: hidden;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
transition: opacity 0.3s ease;
} }
.sight-frame-text-wrapper.scrollable-container { .sight-frame-text-wrapper.scrollable-container {
padding: 16px 16px 76px 16px; padding: 16px;
transition: opacity 0.3s ease;
}
.sight-frame-text-wrapper.is-intro {
height: 0;
padding-top: 0;
padding-bottom: 0;
border: none;
opacity: 0;
pointer-events: none;
overflow: hidden;
}
.sight-frame-loading-placeholder {
flex-shrink: 0;
}
.sight-frame-text-wrapper[data-scrollbar-no-transition="true"] .custom-scrollbar-track,
.sight-frame-text-wrapper[data-scrollbar-no-transition="true"] .custom-scrollbar-thumb {
transition: opacity 0.3s ease !important;
} }
.sight-frame-text-wrapper .scrollable-viewport { .sight-frame-text-wrapper .scrollable-viewport {
flex: 1; flex: 1;
@@ -413,12 +285,11 @@
} }
.sight-frame-text { .sight-frame-text {
display: flow-root; /* BFC prevents margin collapsing for correct scrollHeight measurement */
padding-right: 10px; padding-right: 10px;
text-align: left; text-align: left;
color: #fff; color: #fff;
font-family: "Roboto"; font-family: "Roboto";
font-size: 20px; font-size: 18px;
font-style: normal; font-style: normal;
font-weight: 400; font-weight: 400;
line-height: 150%; line-height: 150%;
@@ -455,57 +326,13 @@
margin-bottom: 0; margin-bottom: 0;
} }
.sight-frame-menu-wrapper {
position: absolute;
bottom: 100px;
left: 0;
width: 100%;
height: 60px;
z-index: 2;
border-radius: 0 0 10px 10px;
overflow: hidden;
flex-shrink: 0;
}
.sight-frame-menu-fade {
position: absolute;
top: 0;
bottom: 0;
width: 120px;
z-index: 100001;
pointer-events: none;
transition: opacity 0.4s ease;
}
.sight-frame-menu-fade.left {
left: 0;
background: linear-gradient(
to right,
rgba(var(--carrier-right-menu-rgb, 179, 165, 152), 0.95),
transparent
);
border-radius: 0 0 0 10px;
}
.sight-frame-menu-fade.right {
right: 0;
background: linear-gradient(
to left,
rgba(var(--carrier-right-menu-rgb, 179, 165, 152), 0.95),
transparent
);
border-radius: 0 0 10px 0;
}
.sight-frame-menu { .sight-frame-menu {
z-index: 100000;
position: relative; position: relative;
padding: 7px 60px; padding: 7px;
width: 100%; width: 100%;
height: 60px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-evenly; justify-content: space-around;
border-radius: 0px 0px 10px 10px; border-radius: 0px 0px 10px 10px;
border-top: 1px solid rgba(255, 255, 255, 0.8); border-top: 1px solid rgba(255, 255, 255, 0.8);
background: background:
@@ -514,22 +341,11 @@
rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0.2) 0%,
rgba(255, 255, 255, 0) 100% rgba(255, 255, 255, 0) 100%
), ),
rgba(var(--carrier-right-menu-rgb, 179, 165, 152), 0.4); rgba(0, 111, 58, 0.4);
box-shadow: 4px 4px 12px 0px rgba(255, 255, 255, 0.12) inset; box-shadow: 4px 4px 12px 0px rgba(255, 255, 255, 0.12) inset;
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
box-sizing: border-box; box-sizing: border-box;
flex-shrink: 0; flex-shrink: 0;
overflow-x: auto;
overflow-y: hidden;
}
.sight-frame-menu::-webkit-scrollbar {
display: none;
}
.sight-frame-menu {
-ms-overflow-style: none;
scrollbar-width: none;
} }
.sight-frame-menu-point { .sight-frame-menu-point {
@@ -540,17 +356,16 @@
font-style: normal; font-style: normal;
font-weight: 400; font-weight: 400;
padding: 8px 12px; padding: 8px 12px;
white-space: nowrap; white-space: nowrap;
flex-shrink: 0;
border-bottom: 2px solid transparent;
transition: transition:
background-color 0.1s ease, background-color 0.1s ease,
color 0.1s ease; color 0.1s ease;
} }
.sight-frame-menu-point.active { .sight-frame-menu-point.active {
text-shadow: 0 0 0.4px #fff, 0 0 0.4px #fff; border-bottom: 2px solid #fff;
border-bottom-color: #fff; font-weight: 600;
} }
.sight-frame-text-wrapper::-webkit-scrollbar-track { .sight-frame-text-wrapper::-webkit-scrollbar-track {
@@ -575,20 +390,30 @@
} }
.sight-frame { .sight-frame {
opacity: 0; opacity: 0;
transition: opacity 0.9s ease-out; transform: translateY(20px);
transition:
opacity 0.2s ease-out,
transform 0.2s ease-out;
} }
.sight-frame.is-visible { .sight-frame.is-visible {
opacity: 1; opacity: 1;
animation: fadeInScale 0.9s cubic-bezier(0.16, 1, 0.3, 1) forwards; transform: translateY(0);
animation: fadeInScale 0.6s ease-out forwards;
} }
@keyframes fadeInScale { @keyframes fadeInScale {
from { 0% {
opacity: 0; opacity: 0;
transform: translateY(20px) scale(0.95);
} }
to { 50% {
opacity: 0.7;
transform: translateY(-5px) scale(1.02);
}
100% {
opacity: 1; opacity: 1;
transform: translateY(0) scale(1);
} }
} }
@@ -640,7 +465,6 @@
font-size: 40px; font-size: 40px;
font-weight: 600; font-weight: 600;
line-height: 150%; line-height: 150%;
padding-bottom: 60px;
} }
.svg-container { .svg-container {
@@ -695,20 +519,17 @@
} }
.alphabet { .alphabet {
width: 40px; width: 100px;
flex-shrink: 0;
margin-right: 10px; margin-right: 10px;
margin-top: 14px; padding-top: 24px;
margin-bottom: 10px;
padding-top: 10px;
padding-bottom: 10px;
display: flex; display: flex;
align-items: center; align-items: center;
flex-direction: column; flex-direction: column;
gap: 20px; gap: 16px;
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
height: 676px; height: 650px;
padding-bottom: 30px;
touch-action: pan-y; touch-action: pan-y;
overscroll-behavior: contain; overscroll-behavior: contain;
scrollbar-width: none; scrollbar-width: none;
@@ -758,9 +579,8 @@
} }
.alphabet-position { .alphabet-position {
display: flex; display: inline-flex;
justify-content: space-between; justify-content: space-between;
width: 100%;
} }
.transfer-button-container { .transfer-button-container {
@@ -787,14 +607,14 @@
position: absolute; position: absolute;
border-radius: 10px; border-radius: 10px;
border: 1px solid var(--carrier-main, #006f3a); border: 1px solid #006f3a;
background: background:
linear-gradient( linear-gradient(
180deg, 180deg,
rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0.2) 0%,
rgba(255, 255, 255, 0) 100% rgba(255, 255, 255, 0) 100%
), ),
rgba(var(--carrier-main-rgb, 0, 111, 58), 0.4); rgba(0, 111, 58, 0.4);
box-shadow: 4px 4px 12px 0px rgba(255, 255, 255, 0.12) inset; box-shadow: 4px 4px 12px 0px rgba(255, 255, 255, 0.12) inset;
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
@@ -927,7 +747,7 @@
border-radius: 32px; border-radius: 32px;
right: 20px; right: 20px;
bottom: 20px; bottom: 20px;
background: var(--carrier-main, #006f3a); background: #006f3a;
z-index: 9999; z-index: 9999;
display: flex; display: flex;
} }

View File

@@ -26,17 +26,15 @@
position: fixed; position: fixed;
display: inline-flex; display: inline-flex;
border-radius: 10px; border-radius: 10px;
box-shadow: box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.3) inset,
0 0 0 1px rgba(255, 255, 255, 0.3) inset,
/* Внутренняя рамка */ 4px 4px 12px 0 rgba(255, 255, 255, 0.12) inset; /* Ваш существующий внутренний shadow */ /* Внутренняя рамка */ 4px 4px 12px 0 rgba(255, 255, 255, 0.12) inset; /* Ваш существующий внутренний shadow */
padding: 1px; /* Чтобы контент не прилипал к рамке */ padding: 1px; /* Чтобы контент не прилипал к рамке */
background: background: linear-gradient(
linear-gradient(
to bottom right, to bottom right,
rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0.2) 0%,
rgba(255, 255, 255, 0) 100% rgba(255, 255, 255, 0) 100%
), ),
rgba(179, 165, 152, 0.4); rgba(0, 111, 58, 0.4);
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
pointer-events: auto; pointer-events: auto;
z-index: 10000001; z-index: 10000001;
@@ -52,7 +50,7 @@
height: 96px; height: 96px;
background-color: #fcd500; background-color: #fcd500;
color: black; color: black;
border-radius: 10px 0 0 10px; border-radius: 10px;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;

View File

@@ -13,7 +13,18 @@
rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0.2) 0%,
rgba(255, 255, 255, 0) 100% rgba(255, 255, 255, 0) 100%
), ),
var(--carrier-left, #006f3a); #006f3a;
}
.side-menu-label {
margin-top: 10px;
text-align: left;
font-size: 15px;
padding: 0 20px;
align-self: flex-start;
font-style: normal;
font-weight: 400;
line-height: 150%;
} }
.appeal-button { .appeal-button {
@@ -24,12 +35,11 @@
font-size: 16px; font-size: 16px;
margin-top: 120px; margin-top: 120px;
font-weight: 500; font-weight: 500;
width: 220px;
text-align: center;
} }
.side-menu-buttons { .side-menu-buttons {
width: 220px; width: 220px;
margin-top: 260px;
} }
.side-menu-button { .side-menu-button {
@@ -41,6 +51,10 @@
border-radius: 10px; border-radius: 10px;
} }
.side-menu-button--sights {
background-color: #fcd500;
}
.side-menu-button--active { .side-menu-button--active {
background-color: #fcd500; background-color: #fcd500;
color: #000; color: #000;
@@ -55,6 +69,24 @@
flex-direction: column; flex-direction: column;
} }
.side-menu-carrier-block {
padding: 0 20px;
}
.carrier-logo {
width: 170px;
}
.side-menu-slogan {
margin-top: 4px;
text-align: left;
font-size: 16px;
font-style: normal;
font-weight: 700;
line-height: 150%;
color: #fff;
}
.side-menu-bottom-photo { .side-menu-bottom-photo {
width: 100%; width: 100%;
margin-top: 32px; margin-top: 32px;
@@ -106,10 +138,10 @@
} }
3.33% { 3.33% {
fill: var(--carrier-left, rgb(76, 175, 75)); fill: rgb(76, 175, 75);
} }
50% { 50% {
fill: var(--carrier-left, rgb(76, 175, 75)); fill: rgb(76, 175, 75);
} }
53.33% { 53.33% {
fill: #ffffff; fill: #ffffff;
@@ -159,7 +191,7 @@
top: -2px; top: -2px;
width: 100px; width: 100px;
height: 7px; height: 7px;
background-color: color-mix(in srgb, var(--carrier-left, #006f3a) 80%, black); background-color: #0e8953;
border-radius: 10px; border-radius: 10px;
} }
@@ -168,8 +200,6 @@
overflow: hidden; overflow: hidden;
top: 250px; top: 250px;
bottom: 0; bottom: 0;
display: flex;
flex-direction: column;
border-radius: 10px 10px 0px 0px; border-radius: 10px 10px 0px 0px;
background: background:
linear-gradient( linear-gradient(
@@ -177,7 +207,7 @@
rgba(255, 255, 255, 0) 8.71%, rgba(255, 255, 255, 0) 8.71%,
rgba(255, 255, 255, 0.16) 69.69% rgba(255, 255, 255, 0.16) 69.69%
), ),
var(--carrier-left, #006f3a); #006f3a;
position: absolute; position: absolute;
width: 288px; width: 288px;
transform: translateY(100%); transform: translateY(100%);
@@ -185,6 +215,8 @@
transition: transition:
transform 0.3s ease-out, transform 0.3s ease-out,
opacity 0.3s ease-out; opacity 0.3s ease-out;
display: flex;
flex-direction: column;
} }
.side-menu-sights.slide-in { .side-menu-sights.slide-in {
@@ -199,10 +231,12 @@
.side-menu-sights-block { .side-menu-sights-block {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
margin-left: 20px;
margin-top: 8px; margin-top: 8px;
touch-action: none; touch-action: none;
overscroll-behavior: contain; overscroll-behavior: contain;
width: 100%; width: auto;
max-width: calc(100% - 20px);
box-sizing: border-box; box-sizing: border-box;
overflow-x: hidden; overflow-x: hidden;
overflow-y: auto; overflow-y: auto;
@@ -210,10 +244,10 @@
.side-menu-sight { .side-menu-sight {
padding-bottom: 2px; padding-bottom: 2px;
margin-right: 20px;
margin-bottom: 6px; margin-bottom: 6px;
margin-top: 6px; margin-top: 6px;
border-bottom: 1px solid border-bottom: 1px solid #0e8953;
color-mix(in srgb, var(--carrier-left, #006f3a) 80%, black);
font-family: "Roboto"; font-family: "Roboto";
font-size: 16px; font-size: 16px;
font-weight: 300; font-weight: 300;
@@ -223,17 +257,6 @@
position: relative; position: relative;
} }
.side-menu-sight-wrapper {
margin-left: -20px;
padding-left: 20px;
background-color: transparent;
transition: background-color 0.3s ease;
}
.side-menu-sight-selected-wrapper {
background-color: rgba(0, 0, 0, 0.2);
}
.side-menu-sight > span { .side-menu-sight > span {
display: inline-block; display: inline-block;
white-space: nowrap; white-space: nowrap;
@@ -255,6 +278,12 @@
} }
} }
.side-menu-crest {
width: 170px;
align-self: flex-start;
margin-left: 20px;
}
.side-menu-control-lang-open { .side-menu-control-lang-open {
gap: 10px; gap: 10px;
display: inline-flex; display: inline-flex;

View File

@@ -43,8 +43,6 @@
position: relative; position: relative;
background: rgba(255, 255, 255, 0.2); background: rgba(255, 255, 255, 0.2);
border-radius: 3px; border-radius: 3px;
overflow: hidden;
transition: opacity 0.5s ease;
} }
.custom-scrollbar-thumb { .custom-scrollbar-thumb {
@@ -57,7 +55,6 @@
pointer-events: auto; pointer-events: auto;
cursor: grab; cursor: grab;
touch-action: none; touch-action: none;
transition: opacity 0.5s ease, height 0.5s ease;
} }
.custom-scrollbar-thumb:active { .custom-scrollbar-thumb:active {
@@ -65,12 +62,11 @@
} }
.side-menu-sights-block .scrollable-viewport { .side-menu-sights-block .scrollable-viewport {
height: calc(98%); height: calc(92%);
} }
.side-menu-sights-block .scrollable { .side-menu-sights-block .scrollable {
height: 100%; height: 100%;
padding-left: 20px;
} }
.list-of-sights-content .scrollable-viewport { .list-of-sights-content .scrollable-viewport {

View File

@@ -19,7 +19,7 @@
rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0.2) 0%,
rgba(255, 255, 255, 0) 100% rgba(255, 255, 255, 0) 100%
), ),
rgba(179, 165, 152, 0.4); rgba(0, 111, 58, 0.4);
} }
.weather-widget-time { .weather-widget-time {

View File

@@ -17,11 +17,6 @@ const isItemVisible = (item: (typeof NAVIGATION_ITEMS.primary)[number]): boolean
); );
} }
// Пользователь с ролью ТО всегда видит раздел устройств
if (item.path === "/devices" && authStore.hasRole("devices_maintenance_rw")) {
return true;
}
const routePermissions = item.path ? ROUTE_REQUIRED_RESOURCES[item.path] ?? [] : []; const routePermissions = item.path ? ROUTE_REQUIRED_RESOURCES[item.path] ?? [] : [];
const canAccessRoute = routePermissions.every((permission) => const canAccessRoute = routePermissions.every((permission) =>
authStore.canAccess(permission), authStore.canAccess(permission),

View File

@@ -1,15 +1,6 @@
@import "tailwindcss"; @import "tailwindcss";
@plugin "@tailwindcss/typography"; @plugin "@tailwindcss/typography";
html,
body,
#root {
margin: 0;
height: 100%;
min-height: 100%;
overflow: hidden;
}
button { button {
cursor: pointer; cursor: pointer;
} }

View File

@@ -1,17 +1,12 @@
import React, { useEffect } from "react"; import React from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { ArrowLeft } from "lucide-react"; import { ArrowLeft } from "lucide-react";
import { LanguageSwitcher } from "@widgets"; import { LanguageSwitcher } from "@widgets";
import { articlesStore, selectedCityStore } from "@shared"; import { articlesStore } from "@shared";
const ArticleCreatePage: React.FC = () => { const ArticleCreatePage: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => {
selectedCityStore.setIsLocked(true);
return () => selectedCityStore.setIsLocked(false);
}, []);
const { articleData } = articlesStore; const { articleData } = articlesStore;
return ( return (

View File

@@ -2,7 +2,7 @@ import React, { useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { ArrowLeft } from "lucide-react"; import { ArrowLeft } from "lucide-react";
import { LanguageSwitcher } from "@widgets"; import { LanguageSwitcher } from "@widgets";
import { articlesStore, languageStore, selectedCityStore } from "@shared"; import { articlesStore, languageStore } from "@shared";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
const ArticleEditPage: React.FC = observer(() => { const ArticleEditPage: React.FC = observer(() => {
@@ -11,11 +11,6 @@ const ArticleEditPage: React.FC = observer(() => {
const { articleData, getArticle } = articlesStore; const { articleData, getArticle } = articlesStore;
useEffect(() => {
selectedCityStore.setIsLocked(true);
return () => selectedCityStore.setIsLocked(false);
}, []);
useEffect(() => { useEffect(() => {
// Устанавливаем русский язык при загрузке страницы // Устанавливаем русский язык при загрузке страницы
languageStore.setLanguage("ru"); languageStore.setLanguage("ru");

View File

@@ -31,7 +31,7 @@ export const ArticleListPage = observer(() => {
setIsLoading(false); setIsLoading(false);
}; };
fetchArticles(); fetchArticles();
}, [language, selectedCityStore.cityVersion]); }, [language]);
const columns: GridColDef[] = [ const columns: GridColDef[] = [
{ {
@@ -55,12 +55,11 @@ export const ArticleListPage = observer(() => {
sortable: false, sortable: false,
renderCell: (params: GridRenderCellParams) => ( renderCell: (params: GridRenderCellParams) => (
<div className="flex h-full gap-7 justify-center items-center"> <div className="flex h-full gap-7 justify-center items-center">
<button title="Просмотр" onClick={() => navigate(`/article/${params.row.id}`)}> <button onClick={() => navigate(`/article/${params.row.id}`)}>
<Eye size={20} className="text-green-500" /> <Eye size={20} className="text-green-500" />
</button> </button>
{canWriteArticles && ( {canWriteArticles && (
<button <button
title="Удалить"
onClick={() => { onClick={() => {
setIsDeleteModalOpen(true); setIsDeleteModalOpen(true);
setRowId(params.row.id); setRowId(params.row.id);
@@ -108,9 +107,7 @@ export const ArticleListPage = observer(() => {
</div> </div>
)} )}
{(rows.length > 0 || searchQuery) && ( <SearchInput value={searchQuery} onChange={setSearchQuery} />
<SearchInput value={searchQuery} onChange={setSearchQuery} />
)}
<div className="w-full"> <div className="w-full">
<DataGrid <DataGrid

View File

@@ -1,5 +1,6 @@
import { import {
Button, Button,
Paper,
TextField, TextField,
Select, Select,
MenuItem, MenuItem,
@@ -19,97 +20,29 @@ import {
languageStore, languageStore,
isMediaIdEmpty, isMediaIdEmpty,
useSelectedCity, useSelectedCity,
selectedCityStore,
SelectMediaDialog, SelectMediaDialog,
UploadMediaDialog, UploadMediaDialog,
PreviewMediaDialog, PreviewMediaDialog,
editSightStore,
} from "@shared"; } from "@shared";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { ImageUploadCard, LanguageSwitcher, VideoPreviewCard } from "@widgets"; import { ImageUploadCard, LanguageSwitcher } from "@widgets";
type ColorFields = { main_color: string; left_color: string; right_color: string };
const colorFields = (data: ColorFields) => ({
main_color: data.main_color,
left_color: data.left_color,
right_color: data.right_color,
});
const ColorPickerField = ({
label,
value,
onChange,
}: {
label: string;
value: string;
onChange: (val: string) => void;
}) => (
<div className="flex items-center gap-3 w-full">
<div
className="w-10 h-10 rounded border border-gray-300 flex-shrink-0 cursor-pointer overflow-hidden relative"
style={{ backgroundColor: value || "#ffffff" }}
>
<input
type="color"
value={value || "#ffffff"}
onChange={(e) => onChange(e.target.value)}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
/>
</div>
<TextField
fullWidth
label={label}
value={value}
placeholder="#000000"
onChange={(e) => onChange(e.target.value)}
InputProps={{
endAdornment: value ? (
<button
type="button"
onClick={() => onChange("")}
className="text-gray-400 hover:text-gray-600 text-xs px-1"
>
✕
</button>
) : undefined,
}}
/>
</div>
);
export const CarrierCreatePage = observer(() => { export const CarrierCreatePage = observer(() => {
const navigate = useNavigate(); const navigate = useNavigate();
const { const { createCarrierData, setCreateCarrierData } = carrierStore;
createCarrierData,
setCreateCarrierData,
setCreateCarrierMedia,
} = carrierStore;
const { language } = languageStore; const { language } = languageStore;
const canReadCities = authStore.canRead("cities"); const canReadCities = authStore.canRead("cities");
const { selectedCityId, selectedCity } = useSelectedCity(); const { selectedCityId, selectedCity } = useSelectedCity();
const [selectedMediaId, setSelectedMediaId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false); const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false); const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false);
const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false); const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false);
const [mediaId, setMediaId] = useState(""); const [mediaId, setMediaId] = useState("");
const [activeMediaField, setActiveMediaField] = useState<
"preview_media" | "video_preview"
>("preview_media");
const [activeMenuType, setActiveMenuType] = useState< const [activeMenuType, setActiveMenuType] = useState<
| "thumbnail" "thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null
| "watermark_lu"
| "watermark_rd"
| "image"
| "video_preview"
| null
>(null); >(null);
useEffect(() => {
selectedCityStore.setIsLocked(true);
return () => selectedCityStore.setIsLocked(false);
}, []);
useEffect(() => { useEffect(() => {
const fetchCities = async () => { const fetchCities = async () => {
if (!authStore.me) { if (!authStore.me) {
@@ -148,16 +81,12 @@ export const CarrierCreatePage = observer(() => {
createCarrierData[language].full_name, createCarrierData[language].full_name,
createCarrierData[language].short_name, createCarrierData[language].short_name,
selectedCityId, selectedCityId,
createCarrierData[language].slogan,
selectedMediaId || "",
language, language,
); );
} }
}, [ }, [selectedCityId, createCarrierData.city_id]);
selectedCityId,
createCarrierData,
createCarrierData.city_id,
language,
setCreateCarrierData,
]);
const handleCreate = async () => { const handleCreate = async () => {
try { try {
@@ -166,7 +95,7 @@ export const CarrierCreatePage = observer(() => {
toast.success("Перевозчик успешно создан"); toast.success("Перевозчик успешно создан");
navigate("/carrier"); navigate("/carrier");
} catch { } catch (error) {
toast.error("Ошибка при создании перевозчика"); toast.error("Ошибка при создании перевозчика");
} finally { } finally {
setIsLoading(false); setIsLoading(false);
@@ -179,22 +108,27 @@ export const CarrierCreatePage = observer(() => {
media_name?: string; media_name?: string;
media_type: number; media_type: number;
}) => { }) => {
setCreateCarrierMedia(activeMediaField, media.id); setSelectedMediaId(media.id);
setCreateCarrierData(
createCarrierData[language].full_name,
createCarrierData[language].short_name,
createCarrierData.city_id,
createCarrierData[language].slogan,
media.id,
language,
);
}; };
const effectivePreviewMediaUrl = isMediaIdEmpty( const selectedMedia =
createCarrierData.preview_media, selectedMediaId && !isMediaIdEmpty(selectedMediaId)
) ? mediaStore.media.find((m) => m.id === selectedMediaId)
: null;
const effectiveLogoUrl = isMediaIdEmpty(selectedMediaId)
? null ? null
: createCarrierData.preview_media; : selectedMedia?.id ?? selectedMediaId ?? null;
const effectiveVideoPreviewId = isMediaIdEmpty(
createCarrierData.video_preview,
)
? null
: createCarrierData.video_preview;
return ( return (
<div className="w-full h-full p-3 flex flex-col gap-10"> <Paper className="w-full h-full p-3 flex flex-col gap-10">
<LanguageSwitcher /> <LanguageSwitcher />
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<button <button
@@ -222,6 +156,8 @@ export const CarrierCreatePage = observer(() => {
createCarrierData[language].full_name, createCarrierData[language].full_name,
createCarrierData[language].short_name, createCarrierData[language].short_name,
e.target.value as number, e.target.value as number,
createCarrierData[language].slogan,
selectedMediaId || "",
language, language,
) )
} }
@@ -244,6 +180,8 @@ export const CarrierCreatePage = observer(() => {
e.target.value, e.target.value,
createCarrierData[language].short_name, createCarrierData[language].short_name,
createCarrierData.city_id, createCarrierData.city_id,
createCarrierData[language].slogan,
selectedMediaId || "",
language, language,
) )
} }
@@ -259,119 +197,59 @@ export const CarrierCreatePage = observer(() => {
createCarrierData[language].full_name, createCarrierData[language].full_name,
e.target.value, e.target.value,
createCarrierData.city_id, createCarrierData.city_id,
createCarrierData[language].slogan,
selectedMediaId || "",
language, language,
) )
} }
/> />
<div className="w-full flex flex-col gap-6"> <TextField
<div className="flex flex-col gap-1"> fullWidth
<ColorPickerField label="Слоган"
label="Основной цвет" value={createCarrierData[language].slogan}
value={createCarrierData.main_color} onChange={(e) =>
onChange={(val) => setCreateCarrierData(
setCreateCarrierData( createCarrierData[language].full_name,
createCarrierData[language].full_name, createCarrierData[language].short_name,
createCarrierData[language].short_name, createCarrierData.city_id,
createCarrierData.city_id, e.target.value,
language, selectedMediaId || "",
{ ...colorFields(createCarrierData), main_color: val } language,
) )
} }
/> />
<p className="text-xs text-gray-500 pl-1">
Используется в: виджет обращений, значки на карте, скопление достопримечательностей на карте, информационный виджет
</p>
</div>
<div className="flex flex-col gap-1">
<ColorPickerField
label="Левый цвет"
value={createCarrierData.left_color}
onChange={(val) =>
setCreateCarrierData(
createCarrierData[language].full_name,
createCarrierData[language].short_name,
createCarrierData.city_id,
language,
{ ...colorFields(createCarrierData), left_color: val }
)
}
/>
<p className="text-xs text-gray-500 pl-1">
Используется в: боковое меню, левый виджет достопримечательности
</p>
</div>
<div className="flex flex-col gap-1">
<ColorPickerField
label="Правый цвет"
value={createCarrierData.right_color}
onChange={(val) =>
setCreateCarrierData(
createCarrierData[language].full_name,
createCarrierData[language].short_name,
createCarrierData.city_id,
language,
{ ...colorFields(createCarrierData), right_color: val }
)
}
/>
<p className="text-xs text-gray-500 pl-1">
Используется в: список достопримечательностей, страница достопримечательности
</p>
</div>
</div>
<div className="w-full flex justify-center items-start gap-10 flex-wrap"> <div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto">
<div className="w-full max-w-[300px]"> <ImageUploadCard
<ImageUploadCard title="Логотип перевозчика"
title="Изображение сайд-меню" imageKey="thumbnail"
imageKey="image" imageUrl={effectiveLogoUrl}
imageUrl={effectivePreviewMediaUrl} onImageClick={() => {
onImageClick={() => { setIsPreviewMediaOpen(true);
setIsPreviewMediaOpen(true); setMediaId(effectiveLogoUrl ?? "");
setMediaId(effectivePreviewMediaUrl ?? ""); }}
}} onDeleteImageClick={() => {
onDeleteImageClick={() => setSelectedMediaId(null);
setCreateCarrierMedia("preview_media", "") setActiveMenuType(null);
} setCreateCarrierData(
onSelectFileClick={() => { createCarrierData[language].full_name,
setActiveMediaField("preview_media"); createCarrierData[language].short_name,
setActiveMenuType("image"); createCarrierData.city_id,
setIsSelectMediaOpen(true); createCarrierData[language].slogan,
}} "",
setUploadMediaOpen={() => { language,
setActiveMediaField("preview_media"); );
setActiveMenuType("image"); }}
setIsUploadMediaOpen(true); onSelectFileClick={() => {
}} setActiveMenuType("image");
/> setIsSelectMediaOpen(true);
</div> }}
<div className="w-full max-w-[300px]"> setUploadMediaOpen={() => {
<VideoPreviewCard setIsUploadMediaOpen(true);
title="Видео загрузки" setActiveMenuType("image");
videoId={effectiveVideoPreviewId} }}
onVideoClick={() => { />
if (effectiveVideoPreviewId) {
setMediaId(effectiveVideoPreviewId);
setIsPreviewMediaOpen(true);
}
}}
onDeleteVideoClick={() =>
setCreateCarrierMedia("video_preview", "")
}
onSelectVideoClick={(file) => {
setActiveMediaField("video_preview");
setActiveMenuType("video_preview");
if (file) {
editSightStore.setFileToUpload(file);
setIsUploadMediaOpen(true);
} else {
setIsSelectMediaOpen(true);
}
}}
className="w-full"
/>
</div>
</div> </div>
<Button <Button
@@ -398,7 +276,7 @@ export const CarrierCreatePage = observer(() => {
open={isSelectMediaOpen} open={isSelectMediaOpen}
onClose={() => setIsSelectMediaOpen(false)} onClose={() => setIsSelectMediaOpen(false)}
onSelectMedia={handleMediaSelect} onSelectMedia={handleMediaSelect}
mediaType={activeMediaField === "video_preview" ? 2 : 1} mediaType={1}
/> />
<UploadMediaDialog <UploadMediaDialog
@@ -415,6 +293,6 @@ export const CarrierCreatePage = observer(() => {
onClose={() => setIsPreviewMediaOpen(false)} onClose={() => setIsPreviewMediaOpen(false)}
mediaId={mediaId} mediaId={mediaId}
/> />
</div> </Paper>
); );
}); });

View File

@@ -1,5 +1,6 @@
import { import {
Button, Button,
Paper,
TextField, TextField,
Select, Select,
MenuItem, MenuItem,
@@ -20,84 +21,19 @@ import {
languageStore, languageStore,
isMediaIdEmpty, isMediaIdEmpty,
LoadingSpinner, LoadingSpinner,
selectedCityStore,
editSightStore,
} from "@shared"; } from "@shared";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { import { ImageUploadCard, LanguageSwitcher, DeleteModal } from "@widgets";
ImageUploadCard,
LanguageSwitcher,
VideoPreviewCard,
} from "@widgets";
import { import {
SelectMediaDialog, SelectMediaDialog,
PreviewMediaDialog, PreviewMediaDialog,
UploadMediaDialog, UploadMediaDialog,
} from "@shared"; } from "@shared";
type ColorFields = {
main_color: string;
left_color: string;
right_color: string;
};
const colorFields = (data: ColorFields) => ({
main_color: data.main_color,
left_color: data.left_color,
right_color: data.right_color,
});
const ColorPickerField = ({
label,
value,
onChange,
}: {
label: string;
value: string;
onChange: (val: string) => void;
}) => (
<div className="flex items-center gap-3 w-full">
<div
className="w-10 h-10 rounded border border-gray-300 flex-shrink-0 cursor-pointer overflow-hidden relative"
style={{ backgroundColor: value || "#ffffff" }}
>
<input
type="color"
value={value || "#ffffff"}
onChange={(e) => onChange(e.target.value)}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
/>
</div>
<TextField
fullWidth
label={label}
value={value}
placeholder="#000000"
onChange={(e) => onChange(e.target.value)}
InputProps={{
endAdornment: value ? (
<button
type="button"
onClick={() => onChange("")}
className="text-gray-400 hover:text-gray-600 text-xs px-1"
>
✕
</button>
) : undefined,
}}
/>
</div>
);
export const CarrierEditPage = observer(() => { export const CarrierEditPage = observer(() => {
const navigate = useNavigate(); const navigate = useNavigate();
const { id } = useParams(); const { id } = useParams();
const { const { getCarrier, setEditCarrierData, editCarrierData } = carrierStore;
getCarrier,
setEditCarrierData,
setEditCarrierMedia,
editCarrierData,
} = carrierStore;
const { language } = languageStore; const { language } = languageStore;
const canReadCities = authStore.canRead("cities"); const canReadCities = authStore.canRead("cities");
@@ -107,24 +43,12 @@ export const CarrierEditPage = observer(() => {
const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false); const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false);
const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false); const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false);
const [mediaId, setMediaId] = useState(""); const [mediaId, setMediaId] = useState("");
const [isDeleteLogoModalOpen, setIsDeleteLogoModalOpen] = useState(false);
const [initialCityName, setInitialCityName] = useState(""); const [initialCityName, setInitialCityName] = useState("");
const [activeMediaField, setActiveMediaField] = useState<
"preview_media" | "video_preview"
>("preview_media");
const [activeMenuType, setActiveMenuType] = useState< const [activeMenuType, setActiveMenuType] = useState<
| "thumbnail" "thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null
| "watermark_lu"
| "watermark_rd"
| "image"
| "video_preview"
| null
>(null); >(null);
useEffect(() => {
selectedCityStore.setIsLocked(true);
return () => selectedCityStore.setIsLocked(false);
}, []);
useEffect(() => { useEffect(() => {
(async () => { (async () => {
if (!id) { if (!id) {
@@ -144,37 +68,29 @@ export const CarrierEditPage = observer(() => {
const carrierData = await getCarrier(Number(id)); const carrierData = await getCarrier(Number(id));
if (carrierData) { if (carrierData) {
const colors = {
main_color: carrierData.ru?.main_color || "",
left_color: carrierData.ru?.left_color || "",
right_color: carrierData.ru?.right_color || "",
};
setEditCarrierData( setEditCarrierData(
carrierData.ru?.full_name || "", carrierData.ru?.full_name || "",
carrierData.ru?.short_name || "", carrierData.ru?.short_name || "",
carrierData.ru?.city_id || 0, carrierData.ru?.city_id || 0,
"ru", carrierData.ru?.slogan || "",
colors, carrierData.ru?.logo || "",
"ru"
); );
setEditCarrierData( setEditCarrierData(
carrierData.en?.full_name || "", carrierData.en?.full_name || "",
carrierData.en?.short_name || "", carrierData.en?.short_name || "",
carrierData.en?.city_id || 0, carrierData.en?.city_id || 0,
"en", carrierData.en?.slogan || "",
carrierData.en?.logo || "",
"en"
); );
setEditCarrierData( setEditCarrierData(
carrierData.zh?.full_name || "", carrierData.zh?.full_name || "",
carrierData.zh?.short_name || "", carrierData.zh?.short_name || "",
carrierData.zh?.city_id || 0, carrierData.zh?.city_id || 0,
"zh", carrierData.zh?.slogan || "",
); carrierData.zh?.logo || "",
setEditCarrierMedia( "zh"
"video_preview",
carrierData.ru?.video_preview || "",
);
setEditCarrierMedia(
"preview_media",
carrierData.ru?.preview_media || "",
); );
setInitialCityName(carrierData.ru?.city || ""); setInitialCityName(carrierData.ru?.city || "");
} }
@@ -186,7 +102,7 @@ export const CarrierEditPage = observer(() => {
})(); })();
languageStore.setLanguage("ru"); languageStore.setLanguage("ru");
}, [id, getCarrier, setEditCarrierData, setEditCarrierMedia]); }, [id]);
const handleEdit = async () => { const handleEdit = async () => {
try { try {
@@ -194,7 +110,7 @@ export const CarrierEditPage = observer(() => {
await carrierStore.editCarrier(Number(id)); await carrierStore.editCarrier(Number(id));
toast.success("Перевозчик успешно обновлен"); toast.success("Перевозчик успешно обновлен");
navigate("/carrier"); navigate("/carrier");
} catch { } catch (error) {
toast.error("Ошибка при обновлении перевозчика"); toast.error("Ошибка при обновлении перевозчика");
} finally { } finally {
setIsLoading(false); setIsLoading(false);
@@ -207,19 +123,23 @@ export const CarrierEditPage = observer(() => {
media_name?: string; media_name?: string;
media_type: number; media_type: number;
}) => { }) => {
setEditCarrierMedia(activeMediaField, media.id); setEditCarrierData(
editCarrierData[language].full_name,
editCarrierData[language].short_name,
editCarrierData.city_id,
editCarrierData[language].slogan,
media.id,
language
);
}; };
const effectivePreviewMediaUrl = isMediaIdEmpty( const selectedMedia =
editCarrierData.preview_media, editCarrierData.logo && !isMediaIdEmpty(editCarrierData.logo)
) ? mediaStore.media.find((m) => m.id === editCarrierData.logo)
: null;
const effectiveLogoUrl = isMediaIdEmpty(editCarrierData.logo)
? null ? null
: editCarrierData.preview_media; : (selectedMedia?.id ?? editCarrierData.logo);
const effectiveVideoPreviewId = isMediaIdEmpty(
editCarrierData.video_preview,
)
? null
: editCarrierData.video_preview;
const baseCities = canReadCities const baseCities = canReadCities
? cityStore.cities["ru"].data ? cityStore.cities["ru"].data
@@ -262,7 +182,7 @@ export const CarrierEditPage = observer(() => {
} }
return ( return (
<div className="w-full h-full p-3 flex flex-col gap-10"> <Paper className="w-full h-full p-3 flex flex-col gap-10">
<LanguageSwitcher /> <LanguageSwitcher />
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<button <button
@@ -289,7 +209,9 @@ export const CarrierEditPage = observer(() => {
editCarrierData[language].full_name, editCarrierData[language].full_name,
editCarrierData[language].short_name, editCarrierData[language].short_name,
Number(e.target.value), Number(e.target.value),
language, editCarrierData[language].slogan,
editCarrierData.logo,
language
) )
} }
> >
@@ -311,7 +233,9 @@ export const CarrierEditPage = observer(() => {
e.target.value, e.target.value,
editCarrierData[language].short_name, editCarrierData[language].short_name,
editCarrierData.city_id, editCarrierData.city_id,
language, editCarrierData[language].slogan,
editCarrierData.logo,
language
) )
} }
/> />
@@ -326,122 +250,50 @@ export const CarrierEditPage = observer(() => {
editCarrierData[language].full_name, editCarrierData[language].full_name,
e.target.value, e.target.value,
editCarrierData.city_id, editCarrierData.city_id,
language, editCarrierData[language].slogan,
editCarrierData.logo,
language
) )
} }
/> />
<div className="w-full flex flex-col gap-6"> <TextField
<div className="flex flex-col gap-1"> fullWidth
<ColorPickerField label="Слоган"
label="Основной цвет" value={editCarrierData[language].slogan}
value={editCarrierData.main_color} onChange={(e) =>
onChange={(val) => setEditCarrierData(
setEditCarrierData( editCarrierData[language].full_name,
editCarrierData[language].full_name, editCarrierData[language].short_name,
editCarrierData[language].short_name, editCarrierData.city_id,
editCarrierData.city_id, e.target.value,
language, editCarrierData.logo,
{ ...colorFields(editCarrierData), main_color: val }, language
) )
} }
/> />
<p className="text-xs text-gray-500 pl-1">
Используется в: значки на карте, скопление достопримечательностей
на карте, информационный виджет
</p>
</div>
<div className="flex flex-col gap-1">
<ColorPickerField
label="Левый цвет"
value={editCarrierData.left_color}
onChange={(val) =>
setEditCarrierData(
editCarrierData[language].full_name,
editCarrierData[language].short_name,
editCarrierData.city_id,
language,
{ ...colorFields(editCarrierData), left_color: val },
)
}
/>
<p className="text-xs text-gray-500 pl-1">
Используется в: виджет обращений, боковое меню (фон, список
остановок), левый виджет достопримечательности
</p>
</div>
<div className="flex flex-col gap-1">
<ColorPickerField
label="Правый цвет"
value={editCarrierData.right_color}
onChange={(val) =>
setEditCarrierData(
editCarrierData[language].full_name,
editCarrierData[language].short_name,
editCarrierData.city_id,
language,
{ ...colorFields(editCarrierData), right_color: val },
)
}
/>
<p className="text-xs text-gray-500 pl-1">
Используется в: список достопримечательностей (фон, карточки),
правый виджет достопримечательности
</p>
</div>
</div>
<div className="w-full flex justify-center items-start gap-10 flex-wrap"> <div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto">
<div className="w-full max-w-[300px]"> <ImageUploadCard
<ImageUploadCard title="Логотип перевозчика"
title="Изображение сайд-меню" imageKey="thumbnail"
imageKey="image" imageUrl={effectiveLogoUrl}
imageUrl={effectivePreviewMediaUrl} onImageClick={() => {
onImageClick={() => { setIsPreviewMediaOpen(true);
setIsPreviewMediaOpen(true); setMediaId(effectiveLogoUrl ?? "");
setMediaId(effectivePreviewMediaUrl ?? ""); }}
}} onDeleteImageClick={() => {
onDeleteImageClick={() => setIsDeleteLogoModalOpen(true);
setEditCarrierMedia("preview_media", "") }}
} onSelectFileClick={() => {
onSelectFileClick={() => { setActiveMenuType("image");
setActiveMediaField("preview_media"); setIsSelectMediaOpen(true);
setActiveMenuType("image"); }}
setIsSelectMediaOpen(true); setUploadMediaOpen={() => {
}} setIsUploadMediaOpen(true);
setUploadMediaOpen={() => { setActiveMenuType("image");
setActiveMediaField("preview_media"); }}
setActiveMenuType("image"); />
setIsUploadMediaOpen(true);
}}
/>
</div>
<div className="w-full max-w-[300px]">
<VideoPreviewCard
title="Видео загрузки"
videoId={effectiveVideoPreviewId}
onVideoClick={() => {
if (effectiveVideoPreviewId) {
setMediaId(effectiveVideoPreviewId);
setIsPreviewMediaOpen(true);
}
}}
onDeleteVideoClick={() =>
setEditCarrierMedia("video_preview", "")
}
onSelectVideoClick={(file) => {
setActiveMediaField("video_preview");
setActiveMenuType("video_preview");
if (file) {
editSightStore.setFileToUpload(file);
setIsUploadMediaOpen(true);
} else {
setIsSelectMediaOpen(true);
}
}}
className="w-full"
/>
</div>
</div> </div>
<Button <Button
@@ -467,7 +319,7 @@ export const CarrierEditPage = observer(() => {
open={isSelectMediaOpen} open={isSelectMediaOpen}
onClose={() => setIsSelectMediaOpen(false)} onClose={() => setIsSelectMediaOpen(false)}
onSelectMedia={handleMediaSelect} onSelectMedia={handleMediaSelect}
mediaType={activeMediaField === "video_preview" ? 2 : 1} mediaType={1}
/> />
<UploadMediaDialog <UploadMediaDialog
@@ -485,6 +337,22 @@ export const CarrierEditPage = observer(() => {
mediaId={mediaId} mediaId={mediaId}
/> />
</div> <DeleteModal
open={isDeleteLogoModalOpen}
onDelete={() => {
setEditCarrierData(
editCarrierData[language].full_name,
editCarrierData[language].short_name,
editCarrierData.city_id,
editCarrierData[language].slogan,
"",
language
);
setIsDeleteLogoModalOpen(false);
}}
onCancel={() => setIsDeleteLogoModalOpen(false)}
edit
/>
</Paper>
); );
}); });

View File

@@ -1,6 +1,6 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { ruRU } from "@mui/x-data-grid/locales"; import { ruRU } from "@mui/x-data-grid/locales";
import { authStore, carrierStore, cityStore, languageStore, selectedCityStore, SearchInput } from "@shared"; import { authStore, carrierStore, cityStore, languageStore, SearchInput } from "@shared";
import { useEffect, useState, useMemo } from "react"; import { useEffect, useState, useMemo } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { Pencil, Trash2, Minus } from "lucide-react"; import { Pencil, Trash2, Minus } from "lucide-react";
@@ -39,7 +39,7 @@ export const CarrierListPage = observer(() => {
setIsLoading(false); setIsLoading(false);
}; };
fetchData(); fetchData();
}, [language, selectedCityStore.cityVersion]); }, [language]);
const columns: GridColDef[] = [ const columns: GridColDef[] = [
{ {
@@ -98,11 +98,10 @@ export const CarrierListPage = observer(() => {
sortable: false, sortable: false,
renderCell: (params: GridRenderCellParams) => ( renderCell: (params: GridRenderCellParams) => (
<div className="flex h-full gap-7 justify-center items-center"> <div className="flex h-full gap-7 justify-center items-center">
<button title="Редактировать" onClick={() => navigate(`/carrier/${params.row.id}/edit`)}> <button onClick={() => navigate(`/carrier/${params.row.id}/edit`)}>
<Pencil size={20} className="text-blue-500" /> <Pencil size={20} className="text-blue-500" />
</button> </button>
<button <button
title="Удалить"
onClick={() => { onClick={() => {
setIsDeleteModalOpen(true); setIsDeleteModalOpen(true);
setRowId(params.row.id); setRowId(params.row.id);
@@ -162,9 +161,7 @@ export const CarrierListPage = observer(() => {
</div> </div>
)} )}
{(rows.length > 0 || searchQuery) && ( <SearchInput value={searchQuery} onChange={setSearchQuery} />
<SearchInput value={searchQuery} onChange={setSearchQuery} />
)}
<DataGrid <DataGrid
rows={rows} rows={rows}
@@ -172,11 +169,6 @@ export const CarrierListPage = observer(() => {
checkboxSelection={canWriteCarriers} checkboxSelection={canWriteCarriers}
disableRowSelectionExcludeModel disableRowSelectionExcludeModel
disableRowSelectionOnClick disableRowSelectionOnClick
onRowDoubleClick={(params) => {
if (canWriteCarriers) {
navigate(`/carrier/${params.id}/edit`);
}
}}
loading={isLoading} loading={isLoading}
paginationModel={paginationModel} paginationModel={paginationModel}
onPaginationModelChange={setPaginationModel} onPaginationModelChange={setPaginationModel}

View File

@@ -17,24 +17,16 @@ import {
countryStore, countryStore,
languageStore, languageStore,
mediaStore, mediaStore,
snapshotStore,
isMediaIdEmpty, isMediaIdEmpty,
SelectMediaDialog, SelectMediaDialog,
UploadMediaDialog, UploadMediaDialog,
PreviewMediaDialog, PreviewMediaDialog,
selectedCityStore,
} from "@shared"; } from "@shared";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { LanguageSwitcher, ImageUploadCard } from "@widgets"; import { LanguageSwitcher, ImageUploadCard } from "@widgets";
export const CityCreatePage = observer(() => { export const CityCreatePage = observer(() => {
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => {
selectedCityStore.setIsLocked(true);
return () => selectedCityStore.setIsLocked(false);
}, []);
const { language } = languageStore; const { language } = languageStore;
const { createCityData, setCreateCityData, setCreateCityWeatherCode } = const { createCityData, setCreateCityData, setCreateCityWeatherCode } =
cityStore; cityStore;
@@ -61,13 +53,7 @@ export const CityCreatePage = observer(() => {
const handleCreate = async () => { const handleCreate = async () => {
try { try {
setIsLoading(true); setIsLoading(true);
const ruCityName = createCityData.ru.name.trim();
await cityStore.createCity(); await cityStore.createCity();
try {
await snapshotStore.createEmptySnapshot(`${ruCityName}_Пустой_Экспорт`);
} catch (e) {
console.warn("Failed to create empty snapshot for city:", e);
}
toast.success("Город успешно создан"); toast.success("Город успешно создан");
navigate("/city"); navigate("/city");
} catch (error) { } catch (error) {

View File

@@ -23,19 +23,12 @@ import {
SelectMediaDialog, SelectMediaDialog,
UploadMediaDialog, UploadMediaDialog,
PreviewMediaDialog, PreviewMediaDialog,
selectedCityStore,
} from "@shared"; } from "@shared";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { LanguageSwitcher, ImageUploadCard } from "@widgets"; import { LanguageSwitcher, ImageUploadCard } from "@widgets";
export const CityEditPage = observer(() => { export const CityEditPage = observer(() => {
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => {
selectedCityStore.setIsLocked(true);
return () => selectedCityStore.setIsLocked(false);
}, []);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isLoadingData, setIsLoadingData] = useState(true); const [isLoadingData, setIsLoadingData] = useState(true);
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false); const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);

View File

@@ -114,11 +114,10 @@ export const CityListPage = observer(() => {
sortable: false, sortable: false,
renderCell: (params: GridRenderCellParams) => ( renderCell: (params: GridRenderCellParams) => (
<div className="flex h-full gap-7 justify-center items-center"> <div className="flex h-full gap-7 justify-center items-center">
<button title="Редактировать" onClick={() => navigate(`/city/${params.row.id}/edit`)}> <button onClick={() => navigate(`/city/${params.row.id}/edit`)}>
<Pencil size={20} className="text-blue-500" /> <Pencil size={20} className="text-blue-500" />
</button> </button>
<button <button
title="Удалить"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
setIsDeleteModalOpen(true); setIsDeleteModalOpen(true);
@@ -140,10 +139,7 @@ export const CityListPage = observer(() => {
<div className="flex justify-between items-center mb-10"> <div className="flex justify-between items-center mb-10">
<h1 className="text-2xl">Города</h1> <h1 className="text-2xl">Города</h1>
{canWriteCities && ( {canWriteCities && (
<CreateButton <CreateButton label="Создать город" path="/city/create" />
label="Создать город"
path="/city/create"
/>
)} )}
</div> </div>
@@ -159,9 +155,7 @@ export const CityListPage = observer(() => {
</div> </div>
)} )}
{(rows.length > 0 || searchQuery) && ( <SearchInput value={searchQuery} onChange={setSearchQuery} />
<SearchInput value={searchQuery} onChange={setSearchQuery} />
)}
<DataGrid <DataGrid
rows={filteredRows} rows={filteredRows}
@@ -169,11 +163,6 @@ export const CityListPage = observer(() => {
checkboxSelection={canWriteCities} checkboxSelection={canWriteCities}
disableRowSelectionExcludeModel disableRowSelectionExcludeModel
disableRowSelectionOnClick disableRowSelectionOnClick
onRowDoubleClick={(params) => {
if (canWriteCities) {
navigate(`/city/${params.id}/edit`);
}
}}
loading={isLoading} loading={isLoading}
paginationModel={paginationModel} paginationModel={paginationModel}
onPaginationModelChange={setPaginationModel} onPaginationModelChange={setPaginationModel}
@@ -206,11 +195,7 @@ export const CityListPage = observer(() => {
slots={{ slots={{
noRowsOverlay: () => ( noRowsOverlay: () => (
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}> <Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
{isLoading ? ( {isLoading ? <CircularProgress size={20} /> : "Нет городов"}
<CircularProgress size={20} />
) : (
"Нет городов"
)}
</Box> </Box>
), ),
}} }}

View File

@@ -15,18 +15,11 @@ import {
RU_COUNTRIES, RU_COUNTRIES,
EN_COUNTRIES, EN_COUNTRIES,
ZH_COUNTRIES, ZH_COUNTRIES,
selectedCityStore,
} from "@shared"; } from "@shared";
import { useState, useEffect } from "react"; import { useState } from "react";
export const CountryAddPage = observer(() => { export const CountryAddPage = observer(() => {
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => {
selectedCityStore.setIsLocked(true);
return () => selectedCityStore.setIsLocked(false);
}, []);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const { createCountryData, setCountryData, createCountry } = countryStore; const { createCountryData, setCountryData, createCountry } = countryStore;

View File

@@ -4,18 +4,12 @@ import { ArrowLeft, Save } from "lucide-react";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { countryStore, languageStore, selectedCityStore } from "@shared"; import { countryStore, languageStore } from "@shared";
import { useState, useEffect } from "react"; import { useState } from "react";
import { LanguageSwitcher } from "@widgets"; import { LanguageSwitcher } from "@widgets";
export const CountryCreatePage = observer(() => { export const CountryCreatePage = observer(() => {
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => {
selectedCityStore.setIsLocked(true);
return () => selectedCityStore.setIsLocked(false);
}, []);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const { language } = languageStore; const { language } = languageStore;
const { createCountryData, setCountryData, createCountry } = countryStore; const { createCountryData, setCountryData, createCountry } = countryStore;

View File

@@ -4,18 +4,12 @@ import { ArrowLeft, Save } from "lucide-react";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { countryStore, languageStore, LoadingSpinner, selectedCityStore } from "@shared"; import { countryStore, languageStore, LoadingSpinner } from "@shared";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { LanguageSwitcher } from "@widgets"; import { LanguageSwitcher } from "@widgets";
export const CountryEditPage = observer(() => { export const CountryEditPage = observer(() => {
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => {
selectedCityStore.setIsLocked(true);
return () => selectedCityStore.setIsLocked(false);
}, []);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isLoadingData, setIsLoadingData] = useState(true); const [isLoadingData, setIsLoadingData] = useState(true);
const { language } = languageStore; const { language } = languageStore;

View File

@@ -92,10 +92,7 @@ export const CountryListPage = observer(() => {
<div className="flex justify-between items-center mb-10"> <div className="flex justify-between items-center mb-10">
<h1 className="text-2xl">Страны</h1> <h1 className="text-2xl">Страны</h1>
{canWriteCountries && ( {canWriteCountries && (
<CreateButton <CreateButton label="Добавить страну" path="/country/add" />
label="Добавить страну"
path="/country/add"
/>
)} )}
</div> </div>
@@ -111,9 +108,7 @@ export const CountryListPage = observer(() => {
</div> </div>
)} )}
{(rows.length > 0 || searchQuery) && ( <SearchInput value={searchQuery} onChange={setSearchQuery} />
<SearchInput value={searchQuery} onChange={setSearchQuery} />
)}
<DataGrid <DataGrid
rows={rows} rows={rows}
@@ -153,11 +148,7 @@ export const CountryListPage = observer(() => {
slots={{ slots={{
noRowsOverlay: () => ( noRowsOverlay: () => (
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}> <Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
{isLoading ? ( {isLoading ? <CircularProgress size={20} /> : "Нет стран"}
<CircularProgress size={20} />
) : (
"Нет стран"
)}
</Box> </Box>
), ),
}} }}

View File

@@ -5,7 +5,6 @@ import {
cityStore, cityStore,
createSightStore, createSightStore,
languageStore, languageStore,
selectedCityStore,
} from "@shared"; } from "@shared";
import { import {
CreateInformationTab, CreateInformationTab,
@@ -15,7 +14,6 @@ import {
} from "@widgets"; } from "@widgets";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { runInAction } from "mobx";
function a11yProps(index: number) { function a11yProps(index: number) {
return { return {
@@ -32,11 +30,6 @@ export const CreateSightPage = observer(() => {
const { getArticles } = articlesStore; const { getArticles } = articlesStore;
const needLeave = createSightStore.needLeaveAgree; const needLeave = createSightStore.needLeaveAgree;
useEffect(() => {
selectedCityStore.setIsLocked(true);
return () => selectedCityStore.setIsLocked(false);
}, []);
const handleChange = (_: React.SyntheticEvent, newValue: number) => { const handleChange = (_: React.SyntheticEvent, newValue: number) => {
setValue(newValue); setValue(newValue);
}; };
@@ -63,14 +56,6 @@ export const CreateSightPage = observer(() => {
await authStore.fetchMeCities().catch(() => undefined); await authStore.fetchMeCities().catch(() => undefined);
} }
await getArticles(languageStore.language); await getArticles(languageStore.language);
const { selectedCityId, selectedCity } = selectedCityStore;
if (selectedCityId && selectedCity && !createSightStore.sight.city_id) {
runInAction(() => {
createSightStore.sight.city_id = selectedCityId;
createSightStore.sight.city = selectedCity.name;
});
}
}; };
fetchData(); fetchData();
}, []); }, []);

View File

@@ -9,7 +9,6 @@ import {
cityStore, cityStore,
editSightStore, editSightStore,
LoadingSpinner, LoadingSpinner,
selectedCityStore,
} from "@shared"; } from "@shared";
import { useBlocker, useParams } from "react-router-dom"; import { useBlocker, useParams } from "react-router-dom";
@@ -26,11 +25,6 @@ export const EditSightPage = observer(() => {
const { sight, getSightInfo, needLeaveAgree, getRightArticles } = editSightStore; const { sight, getSightInfo, needLeaveAgree, getRightArticles } = editSightStore;
const { getArticles } = articlesStore; const { getArticles } = articlesStore;
useEffect(() => {
selectedCityStore.setIsLocked(true);
return () => selectedCityStore.setIsLocked(false);
}, []);
const { id } = useParams(); const { id } = useParams();
const { getCities } = cityStore; const { getCities } = cityStore;

View File

@@ -10,7 +10,7 @@ import {
import { mediaStore, MEDIA_TYPE_LABELS, selectedCityStore } from "@shared"; import { mediaStore, MEDIA_TYPE_LABELS, selectedCityStore } from "@shared";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { ArrowLeft, Loader2, Save } from "lucide-react"; import { ArrowLeft, Loader2, Save } from "lucide-react";
import { useState, useEffect } from "react"; import { useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
@@ -20,11 +20,6 @@ export const MediaCreatePage = observer(() => {
const [type, setType] = useState(""); const [type, setType] = useState("");
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
selectedCityStore.setIsLocked(true);
return () => selectedCityStore.setIsLocked(false);
}, []);
const handleCreate = async () => { const handleCreate = async () => {
try { try {
setIsLoading(true); setIsLoading(true);

View File

@@ -22,7 +22,6 @@ import {
MEDIA_TYPE_LABELS, MEDIA_TYPE_LABELS,
languageStore, languageStore,
LoadingSpinner, LoadingSpinner,
selectedCityStore,
} from "@shared"; } from "@shared";
import { MediaViewer } from "@widgets"; import { MediaViewer } from "@widgets";
@@ -43,11 +42,6 @@ export const MediaEditPage = observer(() => {
const [mediaType, setMediaType] = useState(media?.media_type ?? 1); const [mediaType, setMediaType] = useState(media?.media_type ?? 1);
const [availableMediaTypes, setAvailableMediaTypes] = useState<number[]>([]); const [availableMediaTypes, setAvailableMediaTypes] = useState<number[]>([]);
useEffect(() => {
selectedCityStore.setIsLocked(true);
return () => selectedCityStore.setIsLocked(false);
}, []);
useEffect(() => { useEffect(() => {
if (id) { if (id) {
mediaStore.getOneMedia(id); mediaStore.getOneMedia(id);

View File

@@ -78,12 +78,11 @@ export const MediaListPage = observer(() => {
sortable: false, sortable: false,
renderCell: (params: GridRenderCellParams) => ( renderCell: (params: GridRenderCellParams) => (
<div className="flex h-full gap-7 justify-center items-center"> <div className="flex h-full gap-7 justify-center items-center">
<button title="Просмотр" onClick={() => navigate(`/media/${params.row.id}`)}> <button onClick={() => navigate(`/media/${params.row.id}`)}>
<Eye size={20} className="text-green-500" /> <Eye size={20} className="text-green-500" />
</button> </button>
{canWriteMedia && ( {canWriteMedia && (
<button <button
title="Удалить"
onClick={() => { onClick={() => {
setIsDeleteModalOpen(true); setIsDeleteModalOpen(true);
setRowId(params.row.id); setRowId(params.row.id);
@@ -113,9 +112,7 @@ export const MediaListPage = observer(() => {
return ( return (
<> <>
<div className="w-full"> <div className="w-full">
{(rows.length > 0 || searchQuery) && ( <SearchInput value={searchQuery} onChange={setSearchQuery} />
<SearchInput value={searchQuery} onChange={setSearchQuery} />
)}
{canWriteMedia && ids.length > 0 && ( {canWriteMedia && ids.length > 0 && (
<div className="flex justify-end mb-5 duration-300"> <div className="flex justify-end mb-5 duration-300">

View File

@@ -12,7 +12,11 @@ import {
DialogContent, DialogContent,
DialogActions, DialogActions,
} from "@mui/material"; } from "@mui/material";
import { MediaViewer, VideoPreviewCard, ImageUploadCard } from "@widgets"; import {
MediaViewer,
VideoPreviewCard,
ImageUploadCard,
} from "@widgets";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { ArrowLeft, Loader2, Save, Plus, X } from "lucide-react"; import { ArrowLeft, Loader2, Save, Plus, X } from "lucide-react";
import { useEffect, useState, useMemo } from "react"; import { useEffect, useState, useMemo } from "react";
@@ -35,18 +39,11 @@ import type { Route } from "@shared";
export const RouteCreatePage = observer(() => { export const RouteCreatePage = observer(() => {
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => {
selectedCityStore.setIsLocked(true);
return () => selectedCityStore.setIsLocked(false);
}, []);
const [carrier, setCarrier] = useState<string>(""); const [carrier, setCarrier] = useState<string>("");
const [routeNumber, setRouteNumber] = useState(""); const [routeNumber, setRouteNumber] = useState("");
const [routeCoords, setRouteCoords] = useState(""); const [routeCoords, setRouteCoords] = useState("");
const [govRouteNumber, setGovRouteNumber] = useState(""); const [govRouteNumber, setGovRouteNumber] = useState("");
const [governorAppeal, setGovernorAppeal] = useState<string>(""); const [governorAppeal, setGovernorAppeal] = useState<string>("");
const [buttonText, setButtonText] = useState("");
const [direction, setDirection] = useState("backward"); const [direction, setDirection] = useState("backward");
const [scaleMin, setScaleMin] = useState("10"); const [scaleMin, setScaleMin] = useState("10");
const [scaleMax, setScaleMax] = useState("100"); const [scaleMax, setScaleMax] = useState("100");
@@ -54,7 +51,7 @@ export const RouteCreatePage = observer(() => {
const [turn, setTurn] = useState(""); const [turn, setTurn] = useState("");
const [centerLat, setCenterLat] = useState(""); const [centerLat, setCenterLat] = useState("");
const [centerLng, setCenterLng] = useState(""); const [centerLng, setCenterLng] = useState("");
const [videoTimer, setVideoTimer] = useState(420); const [videoTimer, setVideoTimer] = useState(60);
const [videoPreview, setVideoPreview] = useState<string>(""); const [videoPreview, setVideoPreview] = useState<string>("");
const [icon, setIcon] = useState<string>(""); const [icon, setIcon] = useState<string>("");
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@@ -136,16 +133,6 @@ export const RouteCreatePage = observer(() => {
articlesStore.getArticleList(); articlesStore.getArticleList();
}; };
const handleIconSelect = (media: {
id: string;
filename: string;
media_name?: string;
media_type: number;
}) => {
setIcon(media.id);
setIsSelectIconDialogOpen(false);
};
const handleVideoSelect = (media: { const handleVideoSelect = (media: {
id: string; id: string;
filename: string; filename: string;
@@ -176,6 +163,20 @@ export const RouteCreatePage = observer(() => {
setFileToUpload(null); setFileToUpload(null);
}; };
const handleVideoPreviewClick = () => {
setIsVideoPreviewOpen(true);
};
const handleIconSelect = (media: {
id: string;
filename: string;
media_name?: string;
media_type: number;
}) => {
setIcon(media.id);
setIsSelectIconDialogOpen(false);
};
const selectedIconMedia = const selectedIconMedia =
icon && !isMediaIdEmpty(icon) icon && !isMediaIdEmpty(icon)
? mediaStore.media.find((m) => m.id === icon) ? mediaStore.media.find((m) => m.id === icon)
@@ -285,10 +286,6 @@ export const RouteCreatePage = observer(() => {
newRoute.governor_appeal = governor_appeal; newRoute.governor_appeal = governor_appeal;
} }
if (buttonText.trim()) {
newRoute.button_text = buttonText.trim();
}
const newId = await routeStore.createRoute(newRoute); const newId = await routeStore.createRoute(newRoute);
toast.success("Маршрут успешно создан"); toast.success("Маршрут успешно создан");
navigate(`/route/${newId}/edit`); navigate(`/route/${newId}/edit`);
@@ -404,18 +401,6 @@ export const RouteCreatePage = observer(() => {
onChange={(e) => setGovRouteNumber(e.target.value)} onChange={(e) => setGovRouteNumber(e.target.value)}
/> />
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
Текст кнопки обращения
</Typography>
<TextField
value={buttonText}
onChange={(e) => setButtonText(e.target.value)}
placeholder="Обращение губернатора"
fullWidth
size="small"
helperText="Если пусто, будет использован текст по умолчанию"
/>
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}> <Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
Обращение к пассажирам Обращение к пассажирам
</Typography> </Typography>
@@ -481,7 +466,7 @@ export const RouteCreatePage = observer(() => {
<VideoPreviewCard <VideoPreviewCard
title="Видеозаставка" title="Видеозаставка"
videoId={effectiveVideoId} videoId={effectiveVideoId}
onVideoClick={() => setIsVideoPreviewOpen(true)} onVideoClick={handleVideoPreviewClick}
onDeleteVideoClick={() => setVideoPreview("")} onDeleteVideoClick={() => setVideoPreview("")}
onSelectVideoClick={handleVideoFileSelect} onSelectVideoClick={handleVideoFileSelect}
className="w-full" className="w-full"
@@ -570,7 +555,7 @@ export const RouteCreatePage = observer(() => {
/> />
<TextField <TextField
className="w-full" className="w-full"
label="Таймер показа видео при бездействии (сек)" label="Таймер видео (сек)"
type="number" type="number"
value={videoTimer} value={videoTimer}
onChange={(e) => { onChange={(e) => {
@@ -643,10 +628,11 @@ export const RouteCreatePage = observer(() => {
}} }}
hardcodeType="video_preview" hardcodeType="video_preview"
contextObjectName={routeName || "Маршрут"} contextObjectName={routeName || "Маршрут"}
contextType="route" contextType="sight"
initialFile={fileToUpload || undefined} initialFile={fileToUpload || undefined}
afterUpload={handleVideoUpload} afterUpload={handleVideoUpload}
/> />
<SelectMediaDialog <SelectMediaDialog
open={isSelectIconDialogOpen} open={isSelectIconDialogOpen}
onClose={() => setIsSelectIconDialogOpen(false)} onClose={() => setIsSelectIconDialogOpen(false)}

View File

@@ -36,18 +36,11 @@ import {
UploadMediaDialog, UploadMediaDialog,
PreviewMediaDialog, PreviewMediaDialog,
LoadingSpinner, LoadingSpinner,
selectedCityStore,
} from "@shared"; } from "@shared";
import { LinkedItems } from "../LinekedStations"; import { LinkedItems } from "../LinekedStations";
export const RouteEditPage = observer(() => { export const RouteEditPage = observer(() => {
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => {
selectedCityStore.setIsLocked(true);
return () => selectedCityStore.setIsLocked(false);
}, []);
const { id } = useParams(); const { id } = useParams();
const { editRouteData, copyRouteAction } = routeStore; const { editRouteData, copyRouteAction } = routeStore;
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@@ -244,7 +237,9 @@ export const RouteEditPage = observer(() => {
media_name?: string; media_name?: string;
media_type: number; media_type: number;
}) => { }) => {
routeStore.setEditRouteData({ video_preview: media.id }); routeStore.setEditRouteData({
video_preview: media.id,
});
setIsSelectVideoDialogOpen(false); setIsSelectVideoDialogOpen(false);
}; };
@@ -263,11 +258,19 @@ export const RouteEditPage = observer(() => {
media_name?: string; media_name?: string;
media_type: number; media_type: number;
}) => { }) => {
routeStore.setEditRouteData({ video_preview: media.id }); routeStore.setEditRouteData({
video_preview: media.id,
});
setIsUploadVideoDialogOpen(false); setIsUploadVideoDialogOpen(false);
setFileToUpload(null); setFileToUpload(null);
}; };
const handleVideoPreviewClick = () => {
if (editRouteData.video_preview && editRouteData.video_preview !== "") {
setIsVideoPreviewOpen(true);
}
};
// Получаем название выбранной статьи для отображения // Получаем название выбранной статьи для отображения
const selectedArticle = articlesStore.articleList.ru.data.find( const selectedArticle = articlesStore.articleList.ru.data.find(
(article) => article.id === editRouteData.governor_appeal (article) => article.id === editRouteData.governor_appeal
@@ -545,9 +548,9 @@ export const RouteEditPage = observer(() => {
/> />
<TextField <TextField
className="w-full" className="w-full"
label="Таймер показа видео при бездействии (сек)" label="Таймер видео (сек)"
type="number" type="number"
value={editRouteData.video_timer ?? 420} value={editRouteData.video_timer ?? 60}
onChange={(e) => { onChange={(e) => {
const val = Math.max(1, Math.round(Number(e.target.value))); const val = Math.max(1, Math.round(Number(e.target.value)));
if (Number.isFinite(val)) { if (Number.isFinite(val)) {
@@ -556,22 +559,6 @@ export const RouteEditPage = observer(() => {
}} }}
/> />
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
Текст кнопки обращения
</Typography>
<TextField
value={editRouteData.button_text || ""}
onChange={(e) =>
routeStore.setEditRouteData({
button_text: e.target.value,
})
}
placeholder="Обращение губернатора"
fullWidth
size="small"
helperText="Если пусто, будет использован текст по умолчанию"
/>
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}> <Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
Обращение к пассажирам Обращение к пассажирам
</Typography> </Typography>
@@ -640,10 +627,10 @@ export const RouteEditPage = observer(() => {
<VideoPreviewCard <VideoPreviewCard
title="Видеозаставка" title="Видеозаставка"
videoId={effectiveVideoId} videoId={effectiveVideoId}
onVideoClick={() => setIsVideoPreviewOpen(true)} onVideoClick={handleVideoPreviewClick}
onDeleteVideoClick={() => onDeleteVideoClick={() => {
routeStore.setEditRouteData({ video_preview: "" }) routeStore.setEditRouteData({ video_preview: "" });
} }}
onSelectVideoClick={handleVideoFileSelect} onSelectVideoClick={handleVideoFileSelect}
className="w-full" className="w-full"
/> />
@@ -731,10 +718,11 @@ export const RouteEditPage = observer(() => {
}} }}
hardcodeType="video_preview" hardcodeType="video_preview"
contextObjectName={editRouteData.route_name || "Маршрут"} contextObjectName={editRouteData.route_name || "Маршрут"}
contextType="route" contextType="sight"
initialFile={fileToUpload || undefined} initialFile={fileToUpload || undefined}
afterUpload={handleVideoUpload} afterUpload={handleVideoUpload}
/> />
<SelectMediaDialog <SelectMediaDialog
open={isSelectIconDialogOpen} open={isSelectIconDialogOpen}
onClose={() => setIsSelectIconDialogOpen(false)} onClose={() => setIsSelectIconDialogOpen(false)}

View File

@@ -43,7 +43,7 @@ export const RouteListPage = observer(() => {
loadCounts(routeIds); loadCounts(routeIds);
}; };
fetchData(); fetchData();
}, [language, selectedCityStore.cityVersion]); }, [language]);
const columns: GridColDef[] = [ const columns: GridColDef[] = [
{ {
@@ -139,7 +139,7 @@ export const RouteListPage = observer(() => {
headerAlign: "center" as const, headerAlign: "center" as const,
sortable: true, sortable: true,
renderHeader: (params: any) => ( renderHeader: (params: any) => (
<Tooltip title="Отображает количество привязанных достопримечательностей"> <Tooltip title="Количество привязанных достопримечательностей">
<span>{params.colDef.headerName}</span> <span>{params.colDef.headerName}</span>
</Tooltip> </Tooltip>
), ),
@@ -157,7 +157,7 @@ export const RouteListPage = observer(() => {
headerAlign: "center" as const, headerAlign: "center" as const,
sortable: true, sortable: true,
renderHeader: (params: any) => ( renderHeader: (params: any) => (
<Tooltip title="Отображает количество привязанных остановок"> <Tooltip title="Количество привязанных остановок">
<span>{params.colDef.headerName}</span> <span>{params.colDef.headerName}</span>
</Tooltip> </Tooltip>
), ),
@@ -178,23 +178,22 @@ export const RouteListPage = observer(() => {
return ( return (
<div className="flex h-full gap-7 justify-center items-center"> <div className="flex h-full gap-7 justify-center items-center">
{canWriteRoutes && ( {canWriteRoutes && (
<button title="Редактировать" onClick={() => navigate(`/route/${params.row.id}/edit`)}> <button onClick={() => navigate(`/route/${params.row.id}/edit`)}>
<Pencil size={20} className="text-blue-500" /> <Pencil size={20} className="text-blue-500" />
</button> </button>
)} )}
{canShowRoutePreview && ( {canShowRoutePreview && (
<button title="Предпросмотр на карте" onClick={() => navigate(`/route-preview/${params.row.id}`)}> <button onClick={() => navigate(`/route-preview/${params.row.id}`)}>
<Map size={20} className="text-purple-500" /> <Map size={20} className="text-purple-500" />
</button> </button>
)} )}
{canShowRoutePreview && ( {canShowRoutePreview && (
<button title="Демо" onClick={() => window.open(`/demo/${params.row.id}`, "_blank")}> <button onClick={() => window.open(`/demo/${params.row.id}`, "_blank")}>
<Monitor size={20} className="text-green-500" /> <Monitor size={20} className="text-green-500" />
</button> </button>
)} )}
{canWriteRoutes && ( {canWriteRoutes && (
<button <button
title="Удалить"
onClick={() => { onClick={() => {
setIsDeleteModalOpen(true); setIsDeleteModalOpen(true);
setRowId(params.row.id); setRowId(params.row.id);
@@ -211,9 +210,6 @@ export const RouteListPage = observer(() => {
const rows = useMemo(() => { const rows = useMemo(() => {
const { selectedCityId } = selectedCityStore; const { selectedCityId } = selectedCityStore;
if (!selectedCityId) {
return [];
}
const query = searchQuery.trim().toLowerCase(); const query = searchQuery.trim().toLowerCase();
let filtered = routes.data; let filtered = routes.data;
if (selectedCityId) { if (selectedCityId) {
@@ -251,10 +247,7 @@ export const RouteListPage = observer(() => {
<div className="flex justify-between items-center mb-10"> <div className="flex justify-between items-center mb-10">
<h1 className="text-2xl">Маршруты</h1> <h1 className="text-2xl">Маршруты</h1>
{canWriteRoutes && ( {canWriteRoutes && (
<CreateButton <CreateButton label="Создать маршрут" path="/route/create" />
label="Создать маршрут"
path="/route/create"
/>
)} )}
</div> </div>
@@ -270,9 +263,7 @@ export const RouteListPage = observer(() => {
</div> </div>
)} )}
{(rows.length > 0 || searchQuery) && ( <SearchInput value={searchQuery} onChange={setSearchQuery} />
<SearchInput value={searchQuery} onChange={setSearchQuery} />
)}
<DataGrid <DataGrid
rows={rows} rows={rows}
@@ -313,13 +304,7 @@ export const RouteListPage = observer(() => {
slots={{ slots={{
noRowsOverlay: () => ( noRowsOverlay: () => (
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}> <Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
{isLoading ? ( {isLoading ? <CircularProgress size={20} /> : "Нет маршрутов"}
<CircularProgress size={20} />
) : !selectedCityStore.selectedCityId ? (
"Выберите город"
) : (
"Нет маршрутов"
)}
</Box> </Box>
), ),
}} }}

View File

@@ -0,0 +1,232 @@
import { FederatedMouseEvent, FederatedWheelEvent } from "pixi.js";
import { Component, ReactNode, useEffect, useState, useRef } from "react";
import { useTransform } from "./TransformContext";
import { useMapData } from "./MapDataContext";
import { SCALE_FACTOR } from "./Constants";
import { useApplication } from "@pixi/react";
class ErrorBoundary extends Component<
{ children: ReactNode },
{ hasError: boolean }
> {
state = { hasError: false };
static getDerivedStateFromError() {
return { hasError: true };
}
componentDidCatch(error: Error, info: React.ErrorInfo) {
console.error("Error caught:", error, info);
}
render() {
return this.state.hasError ? <p>Whoopsie Daisy!</p> : this.props.children;
}
}
export function InfiniteCanvas({
children,
}: Readonly<{ children?: ReactNode }>) {
const {
position,
setPosition,
scale,
setScale,
rotation,
setRotation,
setScreenCenter,
screenCenter,
} = useTransform();
const { routeData, originalRouteData, setSelectedSight } = useMapData();
const applicationRef = useApplication();
const [isDragging, setIsDragging] = useState(false);
const [startMousePosition, setStartMousePosition] = useState({ x: 0, y: 0 });
const [startRotation, setStartRotation] = useState(0);
const [startPosition, setStartPosition] = useState({ x: 0, y: 0 });
const [isPointerDown, setIsPointerDown] = useState(false);
const [isUserInteracting, setIsUserInteracting] = useState(false);
const lastOriginalRotation = useRef<number | undefined>(undefined);
useEffect(() => {
if (!applicationRef?.app?.canvas) return;
const canvas = applicationRef.app.canvas;
const canvasRect = canvas.getBoundingClientRect();
const canvasLeft = canvasRect.left;
const canvasTop = canvasRect.top;
const centerX = window.innerWidth / 2 - canvasLeft;
const centerY = window.innerHeight / 2 - canvasTop;
setScreenCenter({ x: centerX, y: centerY });
}, [applicationRef?.app, setScreenCenter]);
const handlePointerDown = (e: FederatedMouseEvent) => {
setIsPointerDown(true);
setIsDragging(false);
setIsUserInteracting(true);
setStartPosition({
x: position.x,
y: position.y,
});
setStartMousePosition({
x: e.globalX,
y: e.globalY,
});
setStartRotation(rotation);
e.stopPropagation();
};
useEffect(() => {
const newRotation = originalRouteData?.rotate ?? 0;
if (!isUserInteracting && lastOriginalRotation.current !== newRotation) {
setRotation((newRotation * Math.PI) / 180);
lastOriginalRotation.current = newRotation;
}
}, [originalRouteData?.rotate, isUserInteracting, setRotation]);
const handlePointerMove = (e: FederatedMouseEvent) => {
if (!isPointerDown) return;
if (!isDragging) {
const dx = e.globalX - startMousePosition.x;
const dy = e.globalY - startMousePosition.y;
if (Math.abs(dx) > 5 || Math.abs(dy) > 5) {
setIsDragging(true);
} else {
return;
}
}
if (e.shiftKey) {
const center = screenCenter ?? { x: 0, y: 0 };
const startAngle = Math.atan2(
startMousePosition.y - center.y,
startMousePosition.x - center.x
);
const currentAngle = Math.atan2(
e.globalY - center.y,
e.globalX - center.x
);
const rotationDiff = currentAngle - startAngle;
setRotation(startRotation + rotationDiff);
const cosDelta = Math.cos(rotationDiff);
const sinDelta = Math.sin(rotationDiff);
setPosition({
x:
center.x * (1 - cosDelta) +
startPosition.x * cosDelta +
(center.y - startPosition.y) * sinDelta,
y:
center.y * (1 - cosDelta) +
startPosition.y * cosDelta +
(startPosition.x - center.x) * sinDelta,
});
} else {
setRotation(startRotation);
setPosition({
x: startPosition.x - startMousePosition.x + e.globalX,
y: startPosition.y - startMousePosition.y + e.globalY,
});
}
e.stopPropagation();
};
const handlePointerUp = (e: FederatedMouseEvent) => {
if (!isDragging) {
setSelectedSight(undefined);
}
setIsPointerDown(false);
setIsDragging(false);
setTimeout(() => {
setIsUserInteracting(false);
}, 100);
e.stopPropagation();
};
const handleWheel = (e: FederatedWheelEvent) => {
e.stopPropagation();
setIsUserInteracting(true);
const mouseX = e.globalX - position.x;
const mouseY = e.globalY - position.y;
const scaleMin = (routeData?.scale_min ?? 10) / SCALE_FACTOR;
const scaleMax = (routeData?.scale_max ?? 20) / SCALE_FACTOR;
const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1;
const newScale = Math.max(scaleMin, Math.min(scaleMax, scale * zoomFactor));
const actualZoomFactor = newScale / scale;
if (scale === newScale) {
setTimeout(() => {
setIsUserInteracting(false);
}, 100);
return;
}
setPosition({
x: position.x + mouseX * (1 - actualZoomFactor),
y: position.y + mouseY * (1 - actualZoomFactor),
});
setScale(newScale);
setTimeout(() => {
setIsUserInteracting(false);
}, 100);
};
useEffect(() => {
applicationRef?.app.render();
}, [position, scale, rotation]);
return (
<ErrorBoundary>
{applicationRef?.app && (
<pixiGraphics
draw={(g) => {
const canvas = applicationRef.app.canvas;
g.clear();
g.rect(0, 0, canvas?.width ?? 0, canvas?.height ?? 0);
g.fill("#111");
}}
eventMode={"static"}
interactive
onPointerDown={handlePointerDown}
onGlobalPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerUpOutside={handlePointerUp}
onWheel={handleWheel}
/>
)}
<pixiContainer
x={position.x}
y={position.y}
scale={scale}
rotation={rotation}
>
{children}
</pixiContainer>
{/* Show center of the screen.
<pixiGraphics
eventMode="none"
draw={(g) => {
g.clear();
const center = screenCenter ?? {x: 0, y: 0};
g.circle(center.x, center.y, 1);
g.fill("#fff");
}}
/> */}
</ErrorBoundary>
);
}

View File

@@ -1,9 +1,10 @@
import { Button } from "@mui/material"; import { Box, Stack, Typography, Button } from "@mui/material";
import { useNavigate, useNavigationType } from "react-router"; import { useNavigate, useNavigationType } from "react-router";
import { MediaViewer } from "@widgets";
import { useMapData } from "./MapDataContext"; import { useMapData } from "./MapDataContext";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { authInstance } from "@shared"; import { authInstance, isMediaIdEmpty } from "@shared";
import ArrowBackIcon from "@mui/icons-material/ArrowBack"; import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import LanguageSelector from "./web-gl/LanguageSelector"; import LanguageSelector from "./web-gl/LanguageSelector";
@@ -14,22 +15,22 @@ type LeftSidebarProps = {
export const LeftSidebar = observer(({ open, onToggle }: LeftSidebarProps) => { export const LeftSidebar = observer(({ open, onToggle }: LeftSidebarProps) => {
const navigate = useNavigate(); const navigate = useNavigate();
const navigationType = useNavigationType(); const navigationType = useNavigationType(); // PUSH, POP, REPLACE
const { routeData } = useMapData(); const { routeData } = useMapData();
const [previewMedia, setPreviewMedia] = useState<string | null>(null); const [carrierThumbnail, setCarrierThumbnail] = useState<string | null>(null);
const [isPreviewMediaError, setIsPreviewMediaError] = useState(false); const [carrierLogo, setCarrierLogo] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
async function fetchCarrierData() { async function fetchCarrierThumbnail() {
if (routeData?.carrier_id) { if (routeData?.carrier_id) {
const carrier = ( const { city_id, logo } = (
await authInstance.get(`/carrier/${routeData.carrier_id}`) await authInstance.get(`/carrier/${routeData.carrier_id}`)
).data; ).data;
setPreviewMedia(carrier.preview_media ?? null); const { arms } = (await authInstance.get(`/city/${city_id}`)).data;
setIsPreviewMediaError(false); setCarrierThumbnail(arms);
setCarrierLogo(logo);
} }
} }
fetchCarrierData(); fetchCarrierThumbnail();
}, [routeData?.carrier_id]); }, [routeData?.carrier_id]);
const handleBack = () => { const handleBack = () => {
@@ -41,128 +42,131 @@ export const LeftSidebar = observer(({ open, onToggle }: LeftSidebarProps) => {
}; };
return ( return (
<div <Box
style={{ sx={{
position: "relative", position: "relative",
width: 288,
height: "100%", height: "100%",
color: "#fff", color: "#fff",
transition: "padding 0.3s ease",
p: open ? 2 : 0,
display: "flex",
flexDirection: "column",
alignItems: "stretch",
justifyContent: "flex-start",
}} }}
> >
{/* Кнопка назад — вне основного меню */} <Stack
<div direction="column"
style={{ height="100%"
padding: "12px 12px 0", width="100%"
opacity: open ? 1 : 0, spacing={4}
pointerEvents: open ? "auto" : "none", alignItems="stretch"
transition: "opacity 0.25s ease", justifyContent="space-between"
}} sx={{
>
<Button
onClick={handleBack}
variant="contained"
sx={{
backgroundColor: "#222",
color: "#fff",
borderRadius: 1.5,
px: 2,
py: 1,
"&:hover": { backgroundColor: "#2d2d2d" },
}}
fullWidth
startIcon={<ArrowBackIcon />}
>
Назад
</Button>
</div>
{/* Основное меню — повторяет .side-menu */}
<div
style={{
boxSizing: "border-box",
paddingTop: 46,
display: "flex",
flexDirection: "column",
alignItems: "center",
height: "calc(100% - 56px)",
position: "relative",
opacity: open ? 1 : 0, opacity: open ? 1 : 0,
transition: "opacity 0.25s ease", transition: "opacity 0.25s ease",
pointerEvents: open ? "auto" : "none", pointerEvents: open ? "auto" : "none",
display: open ? "flex" : "none",
}} }}
> >
{/* Кнопки — .side-menu-buttons */} <div>
<div <Button
style={{ onClick={handleBack}
width: 220, variant="contained"
marginTop: routeData?.governor_appeal || 0 > 0 ? 40 : 260, color="primary"
}} sx={{
> backgroundColor: "#222",
<div color: "#fff",
style={{ borderRadius: 1.5,
backgroundColor: "#fff", px: 2,
color: "#000", py: 1,
textAlign: "center", marginBottom: 10,
padding: "8px 16px", "&:hover": {
marginBottom: 16, backgroundColor: "#2d2d2d",
borderRadius: 10, },
}} }}
fullWidth
startIcon={<ArrowBackIcon />}
> >
Достопримечательности Назад
</div> </Button>
<div
style={{ <Stack
backgroundColor: "#fff", direction="column"
color: "#000", alignItems="center"
textAlign: "center", justifyContent="center"
padding: "8px 16px", spacing={3}
marginBottom: 16,
borderRadius: 10,
}}
> >
Остановки <div
style={{
maxWidth: 150,
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 10,
}}
>
{carrierThumbnail && !isMediaIdEmpty(carrierThumbnail) && (
<MediaViewer
media={{
id: carrierThumbnail,
media_type: 1, // Тип "Фото" для логотипа
filename: "route_thumbnail",
}}
fullWidth
fullHeight
/>
)}
<Typography sx={{ color: "#fff" }} textAlign="center">
При поддержке Правительства
</Typography>
</div>
</Stack>
<div className="flex flex-col items-center justify-center gap-2 mt-10">
<button className="bg-[#fcd500] text-black px-4 py-2 rounded-md w-full font-medium my-10">
Обращение губернатора
</button>
<button className="bg-white text-black px-4 py-2 rounded-md w-full font-medium mx-5">
Достопримечательности
</button>
<button className="bg-white text-black px-4 py-2 rounded-md w-full font-medium mx-5">
Остановки
</button>
</div> </div>
</div> </div>
{/* Нижняя секция — .side-menu-bottom-section */} <Stack
<div direction="column"
style={{ alignItems="center"
position: "absolute", maxHeight={150}
bottom: 0, justifyContent="center"
left: 0, flexGrow={1}
width: "100%",
display: "flex",
flexDirection: "column",
}}
> >
{/* .side-menu-bottom-photo */} {carrierLogo && !isMediaIdEmpty(carrierLogo) && (
<img <MediaViewer
src={ media={{
previewMedia && !isPreviewMediaError id: carrierLogo,
? `${import.meta.env.VITE_KRBL_MEDIA}${previewMedia}/download?token=${localStorage.getItem("token") ?? ""}` media_type: 1, // Тип "Фото" для логотипа
: "/side-menu-photo.png" filename: "route_thumbnail_logo",
} }}
onError={() => setIsPreviewMediaError(true)} fullHeight
alt="" />
style={{ )}
width: "288px", </Stack>
marginTop: 32,
display: "block",
pointerEvents: "none",
}}
/>
</div>
</div>
<div <Typography
className="absolute bottom-[20px] z-10" variant="h6"
style={{ textAlign="center"
right: open ? -520 : -312, sx={{ color: "#fff", marginTop: "auto" }}
transition: "right 0.3s ease", >
}} #ВсемПоПути
> </Typography>
</Stack>
<div className="absolute bottom-[20px] -right-[520px] z-10">
<LanguageSelector onBack={onToggle} isSidebarOpen={open} /> <LanguageSelector onBack={onToggle} isSidebarOpen={open} />
</div> </div>
</div> </Box>
); );
}); });

View File

@@ -0,0 +1,137 @@
import { useEffect, useState } from "react";
import { useTransform } from "./TransformContext";
import { SightData } from "./types";
import { Assets, FederatedMouseEvent, Texture } from "pixi.js";
import { SIGHT_SIZE, UP_SCALE } from "./Constants";
import { coordinatesToLocal, localToCoordinates } from "./utils";
import { useMapData } from "./MapDataContext";
interface SightProps {
sight: SightData;
id: number;
}
export const Sight = ({ sight, id }: Readonly<SightProps>) => {
const { rotation, scale } = useTransform();
const { setSightCoordinates, setSelectedSight } = useMapData();
const [position, setPosition] = useState(
coordinatesToLocal(sight.latitude, sight.longitude)
);
const [isDragging, setIsDragging] = useState(false);
const [isPointerDown, setIsPointerDown] = useState(false);
const [startPosition, setStartPosition] = useState({ x: 0, y: 0 });
const [startMousePosition, setStartMousePosition] = useState({ x: 0, y: 0 });
const handlePointerDown = (e: FederatedMouseEvent) => {
setIsPointerDown(true);
setIsDragging(false);
setStartPosition({
x: position.x,
y: position.y,
});
setStartMousePosition({
x: e.globalX,
y: e.globalY,
});
e.stopPropagation();
};
const handlePointerMove = (e: FederatedMouseEvent) => {
if (!isPointerDown) return;
if (!isDragging) {
const dx = e.globalX - startMousePosition.x;
const dy = e.globalY - startMousePosition.y;
if (Math.abs(dx) > 2 || Math.abs(dy) > 2) {
setIsDragging(true);
} else {
return;
}
}
const dx = (e.globalX - startMousePosition.x) / scale / UP_SCALE;
const dy = (e.globalY - startMousePosition.y) / scale / UP_SCALE;
const cos = Math.cos(rotation);
const sin = Math.sin(rotation);
const newPosition = {
x: startPosition.x + dx * cos + dy * sin,
y: startPosition.y - dx * sin + dy * cos,
};
setPosition(newPosition);
const coordinates = localToCoordinates(newPosition.x, newPosition.y);
setSightCoordinates(sight.id, coordinates.latitude, coordinates.longitude);
e.stopPropagation();
};
const handlePointerUp = (e: FederatedMouseEvent) => {
setIsPointerDown(false);
// Если не было перетаскивания, то это клик
if (!isDragging) {
setSelectedSight(sight);
}
setIsDragging(false);
e.stopPropagation();
};
const [texture, setTexture] = useState(Texture.EMPTY);
useEffect(() => {
Assets.load("/sight_icon.svg").then(setTexture);
}, []);
useEffect(() => {}, [id, sight.latitude, sight.longitude]);
if (!sight) {
console.error("sight is null");
return null;
}
// Компенсируем масштаб, но при зуме текст немного увеличивается
const clampedScale = Math.min(Math.max(scale, 1), 3);
const textScaleFactor = 1 + (clampedScale - 1) * 0.4;
const compensatedSize = SIGHT_SIZE / scale;
const compensatedFontSize = (24 / scale) * textScaleFactor;
return (
<pixiContainer
rotation={-rotation}
eventMode="static"
interactive
onPointerDown={handlePointerDown}
onGlobalPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerUpOutside={handlePointerUp}
x={position.x * UP_SCALE - SIGHT_SIZE / 2}
y={position.y * UP_SCALE - SIGHT_SIZE / 2}
>
<pixiSprite
texture={texture}
width={compensatedSize}
height={compensatedSize}
/>
<pixiGraphics
draw={(g) => {
g.clear();
g.circle(0, 0, 20 / scale);
g.fill({ color: "#000" });
}}
x={compensatedSize}
y={0}
/>
<pixiText
text={`${id + 1}`}
x={compensatedSize + 1 / scale}
y={0}
anchor={0.5}
style={{
fontSize: compensatedFontSize,
fontWeight: "bold",
fill: "#ffffff",
}}
/>
</pixiContainer>
);
};

View File

@@ -0,0 +1,541 @@
import { FederatedMouseEvent, Graphics } from "pixi.js";
import { useCallback, useState, useEffect, useRef, FC, useMemo } from "react";
import { observer } from "mobx-react-lite";
import {
BACKGROUND_COLOR,
PATH_COLOR,
STATION_RADIUS,
STATION_OUTLINE_WIDTH,
UP_SCALE,
} from "./Constants";
import { useTransform } from "./TransformContext";
import { StationData } from "./types";
import { useMapData } from "./MapDataContext";
import { coordinatesToLocal } from "./utils";
import { languageStore } from "@shared";
declare const pixiContainer: any;
declare const pixiGraphics: any;
declare const pixiText: any;
type HorizontalAlign = "left" | "center" | "right";
type VerticalAlign = "top" | "center" | "bottom";
type TextAlign = HorizontalAlign | `${HorizontalAlign} ${VerticalAlign}`;
type LabelAlign = "left" | "center" | "right";
/**
* Преобразует текстовое позиционирование в anchor координаты.
*/
/**
* Получает координату anchor.x из типа выравнивания.
*/
interface StationProps {
station: StationData;
ruLabel: string | null;
anchorPoint?: { x: number; y: number };
/** Anchor для всего блока с текстом. По умолчанию: `"right center"` */
labelBlockAnchor?: TextAlign | { x: number; y: number };
/** Внутреннее выравнивание текста в блоке. По умолчанию: `"left"` */
labelAlign?: LabelAlign;
/** Callback для изменения внутреннего выравнивания */
onLabelAlignChange?: (align: LabelAlign) => void;
/** Callback для отслеживания наведения на текст */
onTextHover?: (isHovered: boolean) => void;
}
interface LabelAlignmentControlProps {
scale: number;
currentAlign: LabelAlign;
onAlignChange: (align: LabelAlign) => void;
onPointerOver: () => void;
onPointerOut: () => void;
onControlPointerEnter: () => void;
onControlPointerLeave: () => void;
}
interface StationLabelProps
extends Omit<StationProps, "ruLabelAnchor" | "nameLabelAnchor"> {}
const getAnchorFromOffset = (
offsetX: number,
offsetY: number
): { x: number; y: number } => {
if (offsetX === 0 && offsetY === 0) {
return { x: 0.5, y: 0.5 };
}
const length = Math.hypot(offsetX, offsetY);
const nx = offsetX / length;
const ny = offsetY / length;
return { x: (1 - nx) / 2, y: (1 - ny) / 2 };
};
const LabelAlignmentControl: FC<LabelAlignmentControlProps> = ({
scale,
currentAlign,
onAlignChange,
onControlPointerEnter,
onControlPointerLeave,
}) => {
const controlHeight = 50 / scale;
const controlWidth = 200 / scale;
const fontSize = 18 / scale;
const borderRadius = 8 / scale;
const compensatedRuFontSize = (26 * 0.75) / scale;
const buttonWidth = controlWidth / 3;
const strokeWidth = 2 / scale;
const drawBg = useCallback(
(g: Graphics) => {
g.clear();
g.roundRect(
-controlWidth / 2,
0,
controlWidth,
controlHeight,
borderRadius
);
g.fill({ color: "#1a1a1a" });
g.roundRect(
-controlWidth / 2,
0,
controlWidth,
controlHeight,
borderRadius
);
g.stroke({ color: "#333333", width: strokeWidth });
for (let i = 1; i < 3; i++) {
const x = -controlWidth / 2 + buttonWidth * i;
g.moveTo(x, strokeWidth);
g.lineTo(x, controlHeight - strokeWidth);
g.stroke({ color: "#333333", width: strokeWidth });
}
},
[controlWidth, controlHeight, borderRadius, buttonWidth, strokeWidth]
);
const drawButtonHighlight = useCallback(
(g: Graphics, index: number, isActive: boolean) => {
g.clear();
if (isActive) {
const x = -controlWidth / 2 + buttonWidth * index;
g.roundRect(
x + strokeWidth,
strokeWidth,
buttonWidth - strokeWidth * 2,
controlHeight - strokeWidth * 2,
borderRadius / 2
);
g.fill({ color: "#0066cc", alpha: 0.8 });
}
},
[controlWidth, controlHeight, buttonWidth, strokeWidth, borderRadius]
);
const getTextStyle = (isActive: boolean) => ({
fontSize,
fontWeight: isActive ? ("bold" as const) : ("normal" as const),
fill: isActive ? "#ffffff" : "#cccccc",
fontFamily: "Arial, sans-serif",
});
const alignOptions = [
{ key: "left" as const, label: "Left" },
{ key: "center" as const, label: "Center" },
{ key: "right" as const, label: "Right" },
];
return (
<pixiContainer
position={{ x: 0, y: compensatedRuFontSize * 1.1 + 15 / scale }}
zIndex={999999999999999999}
eventMode="static"
onPointerOver={(e: FederatedMouseEvent) => {
e.stopPropagation();
onControlPointerEnter();
}}
onPointerOut={(e: FederatedMouseEvent) => {
e.stopPropagation();
onControlPointerLeave();
}}
onPointerDown={(e: FederatedMouseEvent) => {
e.stopPropagation();
}}
>
{/* Основной фон */}
<pixiGraphics draw={drawBg} />
{/* Кнопки с подсветкой */}
{alignOptions.map((option, index) => (
<pixiContainer key={option.key}>
{/* Подсветка активной кнопки */}
<pixiGraphics
draw={(g: Graphics) =>
drawButtonHighlight(g, index, option.key === currentAlign)
}
/>
{/* Текст кнопки */}
<pixiText
text={option.label}
anchor={{ x: 0.5, y: 0.5 }}
position={{
x: -controlWidth / 2 + buttonWidth * (index + 0.5),
y: controlHeight / 2,
}}
style={getTextStyle(option.key === currentAlign)}
eventMode="static"
cursor="pointer"
onClick={(e: FederatedMouseEvent) => {
e.stopPropagation();
onAlignChange(option.key);
}}
onPointerDown={(e: FederatedMouseEvent) => {
e.stopPropagation();
onAlignChange(option.key);
}}
onPointerOver={(e: FederatedMouseEvent) => {
e.stopPropagation();
onControlPointerEnter();
}}
/>
</pixiContainer>
))}
</pixiContainer>
);
};
const StationLabel = observer(
({
station,
ruLabel,
labelAlign: labelAlignProp = "center",
onLabelAlignChange,
onTextHover,
}: Readonly<StationLabelProps>) => {
const { language } = languageStore;
const { rotation, scale } = useTransform();
const { setStationOffset, setStationAlign } = useMapData();
const [position, setPosition] = useState({ x: 0, y: 0 });
const [isDragging, setIsDragging] = useState(false);
const [isPointerDown, setIsPointerDown] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const [isControlHovered, setIsControlHovered] = useState(false);
const [currentLabelAlign, setCurrentLabelAlign] = useState(labelAlignProp);
const [ruLabelWidth, setRuLabelWidth] = useState(0);
const dragStartPos = useRef({ x: 0, y: 0 });
const mouseStartPos = useRef({ x: 0, y: 0 });
const hideTimer = useRef<NodeJS.Timeout | null>(null);
const ruLabelRef = useRef<any>(null);
useEffect(() => {
return () => {
if (hideTimer.current) {
clearTimeout(hideTimer.current);
}
};
}, []);
const handlePointerEnter = () => {
if (hideTimer.current) {
clearTimeout(hideTimer.current);
hideTimer.current = null;
}
setIsHovered(true);
onTextHover?.(true);
};
const handleControlPointerEnter = () => {
if (hideTimer.current) {
clearTimeout(hideTimer.current);
hideTimer.current = null;
}
setIsControlHovered(true);
setIsHovered(true);
onTextHover?.(true);
};
const handleControlPointerLeave = () => {
setIsControlHovered(false);
if (!isHovered) {
hideTimer.current = setTimeout(() => {
setIsHovered(false);
onTextHover?.(false);
}, 0);
}
};
const handlePointerLeave = () => {
hideTimer.current = setTimeout(() => {
setIsHovered(false);
if (!isControlHovered) {
setIsControlHovered(false);
}
onTextHover?.(false);
}, 100);
};
useEffect(() => {
setPosition({ x: station.offset_x ?? 0, y: station.offset_y ?? 0 });
}, [station.offset_x, station.offset_y, station.id]);
const convertNumericAlign = (align: number): LabelAlign => {
switch (align) {
case 0:
return "left";
case 1:
return "center";
case 2:
return "right";
default:
return "center";
}
};
const convertStringAlign = (align: LabelAlign): number => {
switch (align) {
case "left":
return 0;
case "center":
return 1;
case "right":
return 2;
default:
return 1;
}
};
useEffect(() => {
setCurrentLabelAlign(convertNumericAlign(station.align ?? 1));
}, [station.align]);
if (!station) return null;
const coordinates = coordinatesToLocal(station.latitude, station.longitude);
const clampedScale = Math.min(Math.max(scale, 1), 3);
const textScaleFactor = 1 + (clampedScale - 1) * 0.4;
const compensatedRuFontSize = ((26 * 0.75) / scale) * textScaleFactor;
const compensatedNameFontSize = ((16 * 0.75) / scale) * textScaleFactor;
useEffect(() => {
if (ruLabelRef.current && ruLabel) {
setRuLabelWidth(ruLabelRef.current.width);
}
}, [ruLabel, compensatedRuFontSize]);
const handlePointerDown = (e: FederatedMouseEvent) => {
setIsPointerDown(true);
setIsDragging(false);
dragStartPos.current = { ...position };
mouseStartPos.current = { x: e.global.x, y: e.global.y };
e.stopPropagation();
};
const handlePointerMove = (e: FederatedMouseEvent) => {
if (!isPointerDown) return;
if (!isDragging) {
const dx = e.global.x - mouseStartPos.current.x;
const dy = e.global.y - mouseStartPos.current.y;
if (Math.hypot(dx, dy) > 3) setIsDragging(true);
else return;
}
const dx_screen = e.global.x - mouseStartPos.current.x;
const dy_screen = e.global.y - mouseStartPos.current.y;
const newPosition = {
x: dragStartPos.current.x + dx_screen,
y: dragStartPos.current.y + dy_screen,
};
if (
Math.abs(newPosition.x - position.x) > 0.01 ||
Math.abs(newPosition.y - position.y) > 0.01
) {
setPosition(newPosition);
setStationOffset(station.id, newPosition.x, newPosition.y);
}
e.stopPropagation();
};
const handlePointerUp = (e: FederatedMouseEvent) => {
setIsPointerDown(false);
setTimeout(() => setIsDragging(false), 50);
e.stopPropagation();
};
const handleAlignChange = async (align: LabelAlign) => {
setCurrentLabelAlign(align);
onLabelAlignChange?.(align);
const numericAlign = convertStringAlign(align);
setStationAlign(station.id, numericAlign);
};
const dynamicAnchor = useMemo(
() => getAnchorFromOffset(position.x, position.y),
[position.x, position.y]
);
const getSecondLabelPosition = (): number => {
if (!ruLabelWidth) return 0;
switch (currentLabelAlign) {
case "left":
return -ruLabelWidth / 2;
case "center":
return 0;
case "right":
return ruLabelWidth / 2;
default:
return 0;
}
};
const getSecondLabelAnchor = (): number => {
switch (currentLabelAlign) {
case "left":
return 0;
case "center":
return 0.5;
case "right":
return 1;
default:
return 0.5;
}
};
return (
<pixiContainer
x={coordinates.x * UP_SCALE}
y={coordinates.y * UP_SCALE}
rotation={-rotation}
zIndex={isHovered || isControlHovered ? 1000 : 0}
eventMode="static"
interactive
cursor={isDragging ? "grabbing" : "grab"}
onPointerOver={handlePointerEnter}
onPointerOut={handlePointerLeave}
onPointerDown={handlePointerDown}
onPointerUp={handlePointerUp}
onPointerUpOutside={handlePointerUp}
onGlobalPointerMove={handlePointerMove}
>
<pixiContainer
position={{
x:
(position.x + Math.cos(Math.atan2(position.y, position.x))) /
scale,
y:
(position.y + Math.sin(Math.atan2(position.y, position.x))) /
scale,
}}
anchor={dynamicAnchor}
zIndex={isHovered || isControlHovered ? 1000 : 0}
>
{ruLabel && (
<pixiText
ref={ruLabelRef}
text={ruLabel}
position={{ x: 0, y: 0 }}
anchor={{ x: 0.5, y: 0.5 }}
style={{
fontSize: compensatedRuFontSize,
fontWeight: "bold",
fill: "#ffffff",
}}
/>
)}
{station.name && language !== "ru" && ruLabel && (
<pixiText
text={station.name}
position={{
x: getSecondLabelPosition(),
y: compensatedRuFontSize * 1.1,
}}
anchor={{ x: getSecondLabelAnchor(), y: 0.5 }}
style={{
fontSize: compensatedNameFontSize,
fontWeight: "bold",
fill: "#CCCCCC",
}}
/>
)}
{(isHovered || isControlHovered) && !isDragging && (
<LabelAlignmentControl
scale={scale}
currentAlign={currentLabelAlign}
onAlignChange={handleAlignChange}
onPointerOver={handlePointerEnter}
onPointerOut={handlePointerLeave}
onControlPointerEnter={handleControlPointerEnter}
onControlPointerLeave={handleControlPointerLeave}
/>
)}
</pixiContainer>
</pixiContainer>
);
}
);
export const Station = ({
station,
ruLabel,
labelAlign,
onLabelAlignChange,
}: Readonly<StationProps>) => {
const [isTextHovered, setIsTextHovered] = useState(false);
const draw = useCallback(
(g: Graphics) => {
g.clear();
const coordinates = coordinatesToLocal(
station.latitude,
station.longitude
);
const radius = STATION_RADIUS;
const strokeWidth = STATION_OUTLINE_WIDTH;
g.circle(coordinates.x * UP_SCALE, coordinates.y * UP_SCALE, radius);
if (isTextHovered) {
g.fill({ color: 0x00aaff });
g.stroke({ color: 0xffffff, width: strokeWidth + 1 });
} else {
g.fill({ color: PATH_COLOR });
g.stroke({ color: BACKGROUND_COLOR, width: strokeWidth });
}
},
[station.latitude, station.longitude, isTextHovered]
);
return (
<pixiContainer zIndex={isTextHovered ? 1000 : 0}>
<pixiGraphics draw={draw} />
<StationLabel
station={station}
ruLabel={ruLabel}
labelAlign={labelAlign}
onLabelAlignChange={onLabelAlignChange}
onTextHover={setIsTextHovered}
/>
</pixiContainer>
);
};

View File

@@ -0,0 +1,34 @@
import { Graphics } from "pixi.js";
import { useCallback } from "react";
import { PATH_COLOR, PATH_WIDTH } from "./Constants";
import { coordinatesToLocal } from "./utils";
interface TravelPathProps {
points: { x: number; y: number }[];
}
export function TravelPath({ points }: Readonly<TravelPathProps>) {
const draw = useCallback(
(g: Graphics) => {
g.clear();
const coordStart = coordinatesToLocal(points[0].x, points[0].y);
g.moveTo(coordStart.x, coordStart.y);
for (let i = 1; i <= points.length - 1; i++) {
const coordinates = coordinatesToLocal(points[i].x, points[i].y);
g.lineTo(coordinates.x, coordinates.y);
}
g.stroke({
color: PATH_COLOR,
width: PATH_WIDTH,
});
},
[points]
);
if (points.length === 0) {
console.error("points is empty");
return null;
}
return <pixiGraphics draw={draw} />;
}

View File

@@ -1,5 +1,14 @@
import { useRef, useEffect, useState } from "react"; import { useRef, useEffect, useState } from "react";
import { Widgets } from "./Widgets"; import { Widgets } from "./Widgets";
import { extend } from "@pixi/react";
import {
Container,
Graphics,
Sprite,
Texture,
TilingSprite,
Text,
} from "pixi.js";
import { Box, Stack } from "@mui/material"; import { Box, Stack } from "@mui/material";
import { MapDataProvider, useMapData } from "./MapDataContext"; import { MapDataProvider, useMapData } from "./MapDataContext";
import { TransformProvider, useTransform } from "./TransformContext"; import { TransformProvider, useTransform } from "./TransformContext";
@@ -13,6 +22,15 @@ import { UP_SCALE } from "./Constants";
import { WebGLRouteMapPrototype } from "./webgl-prototype/WebGLRouteMapPrototype"; import { WebGLRouteMapPrototype } from "./webgl-prototype/WebGLRouteMapPrototype";
import { CircularProgress } from "@mui/material"; import { CircularProgress } from "@mui/material";
extend({
Container,
Graphics,
Sprite,
Texture,
TilingSprite,
Text,
});
const Loading = () => { const Loading = () => {
const { isRouteLoading, isStationLoading, isSightLoading } = useMapData(); const { isRouteLoading, isStationLoading, isSightLoading } = useMapData();
@@ -36,8 +54,7 @@ export const RoutePreview = () => {
<Box <Box
sx={{ sx={{
position: "relative", position: "relative",
zIndex: 20, width: isLeftSidebarOpen ? 300 : 0,
width: isLeftSidebarOpen ? 288 : 0,
transition: "width 0.3s ease", transition: "width 0.3s ease",
overflow: "visible", overflow: "visible",
height: "100%", height: "100%",
@@ -76,10 +93,9 @@ export const RouteMap = observer(() => {
const parentRef = useRef<HTMLDivElement>(null); const parentRef = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
const previousBodyOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden"; document.body.style.overflow = "hidden";
return () => { return () => {
document.body.style.overflow = previousBodyOverflow; document.body.style.overflow = "auto";
}; };
}, []); }, []);
@@ -129,14 +145,14 @@ export const RouteMap = observer(() => {
) { ) {
const coordinates = coordinatesToLocal( const coordinates = coordinatesToLocal(
originalRouteData?.center_latitude, originalRouteData?.center_latitude,
originalRouteData?.center_longitude, originalRouteData?.center_longitude
); );
setTransform( setTransform(
coordinates.x, coordinates.x,
coordinates.y, coordinates.y,
originalRouteData?.rotate, originalRouteData?.rotate,
originalRouteData?.scale_min, originalRouteData?.scale_min
); );
setIsSetup(true); setIsSetup(true);
} }
@@ -155,6 +171,25 @@ export const RouteMap = observer(() => {
return ( return (
<div style={{ width: "100%", height: "100%" }} ref={parentRef}> <div style={{ width: "100%", height: "100%" }} ref={parentRef}>
{/* <Application resizeTo={parentRef} background="#000" preference="webgl">
<InfiniteCanvas>
<TravelPath points={points} />
{stationData[language].map((obj, index) => (
<Station
station={obj}
key={obj.id}
ruLabel={
language === "ru"
? stationData.ru[index].name
: stationData.ru[index].name
}
/>
))}
{originalSightData?.map((sight: SightData, index: number) => {
return <Sight sight={sight} id={index} key={sight.id} />;
})}
</InfiniteCanvas>
</Application> */}
<WebGLRouteMapPrototype /> <WebGLRouteMapPrototype />
</div> </div>
); );

View File

@@ -6,7 +6,6 @@ export interface RouteData {
icon_size?: number; icon_size?: number;
font_size: number; font_size: number;
governor_appeal: number; governor_appeal: number;
button_text?: string;
id: number; id: number;
path: [number, number][]; path: [number, number][];
rotate: number; rotate: number;

View File

@@ -26,12 +26,10 @@
position: fixed; position: fixed;
display: inline-flex; display: inline-flex;
border-radius: 10px; border-radius: 10px;
box-shadow: box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.3) inset,
0 0 0 1px rgba(255, 255, 255, 0.3) inset,
/* Внутренняя рамка */ 4px 4px 12px 0 rgba(255, 255, 255, 0.12) inset; /* Ваш существующий внутренний shadow */ /* Внутренняя рамка */ 4px 4px 12px 0 rgba(255, 255, 255, 0.12) inset; /* Ваш существующий внутренний shadow */
padding: 1px; /* Чтобы контент не прилипал к рамке */ padding: 1px; /* Чтобы контент не прилипал к рамке */
background: background: linear-gradient(
linear-gradient(
to bottom right, to bottom right,
rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0.2) 0%,
rgba(255, 255, 255, 0) 100% rgba(255, 255, 255, 0) 100%
@@ -52,8 +50,7 @@
height: 96px; height: 96px;
background-color: #fcd500; background-color: #fcd500;
color: black; color: black;
border-top-left-radius: 10px; border-radius: 10px;
border-bottom-left-radius: 10px;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;

View File

@@ -2327,6 +2327,9 @@ export const WebGLRouteMapPrototype = observer(() => {
const stationScreenY = const stationScreenY =
rotatedY * camera.scale + camera.translation.y; rotatedY * camera.scale + camera.translation.y;
const labelX = stationScreenX + offsetX;
const labelY = stationScreenY + offsetY;
const backendAlign = station.align; const backendAlign = station.align;
const anchor = getAnchorFromOffset(backendAlign ?? 2); const anchor = getAnchorFromOffset(backendAlign ?? 2);
@@ -2336,6 +2339,8 @@ export const WebGLRouteMapPrototype = observer(() => {
const dpr = Math.max(1, window.devicePixelRatio || 1); const dpr = Math.max(1, window.devicePixelRatio || 1);
const cssX = labelX / dpr;
const cssY = labelY / dpr;
const rotationCss = `${rotationAngle}rad`; const rotationCss = `${rotationAngle}rad`;
const counterRotationCss = `${-rotationAngle}rad`; const counterRotationCss = `${-rotationAngle}rad`;
@@ -2354,13 +2359,6 @@ export const WebGLRouteMapPrototype = observer(() => {
const scaleFactor = 1 + (zoomClampedScale - 1) * 0.4; const scaleFactor = 1 + (zoomClampedScale - 1) * 0.4;
const primaryFontSize = 16 * fontScale * scaleFactor; const primaryFontSize = 16 * fontScale * scaleFactor;
const mainLabelHeight = primaryFontSize * 1.2;
const labelX = stationScreenX + offsetX;
const labelY = stationScreenY + offsetY + mainLabelHeight / 2;
const cssX = labelX / dpr;
const cssY = labelY / dpr;
const secondaryFontSize = 13 * fontScale * scaleFactor; const secondaryFontSize = 13 * fontScale * scaleFactor;
const secondaryMarginTop = 5 * fontScale * scaleFactor; const secondaryMarginTop = 5 * fontScale * scaleFactor;
@@ -2406,7 +2404,13 @@ export const WebGLRouteMapPrototype = observer(() => {
hoveredStationIconId === station.id || hoveredStationIconId === station.id ||
resizingStationIconId === station.id; resizingStationIconId === station.id;
const secondaryLineHeight = 1.2 * scaleFactor; const secondaryLineHeight = 1.2;
const secondaryHeight = showSecondary
? secondaryFontSize * secondaryLineHeight
: 0;
const menuPaddingTop = showSecondary
? Math.max(0, secondaryHeight - secondaryMarginTop) + 3
: 3;
return ( return (
<div key={station.id}> <div key={station.id}>
@@ -2436,7 +2440,7 @@ export const WebGLRouteMapPrototype = observer(() => {
color: "#fff", color: "#fff",
fontFamily: "Roboto, sans-serif", fontFamily: "Roboto, sans-serif",
textAlign: "left", textAlign: "left",
pointerEvents: "none", pointerEvents: "auto",
cursor: "grab", cursor: "grab",
userSelect: "none", userSelect: "none",
touchAction: "none", touchAction: "none",
@@ -2444,16 +2448,14 @@ export const WebGLRouteMapPrototype = observer(() => {
> >
<div <div
style={{ style={{
display: "inline-block", pointerEvents: "auto",
pointerEvents: "none",
transformOrigin: "left center", transformOrigin: "left center",
transform: `rotate(${rotationCss})`, transform: `rotate(${rotationCss})`,
}} }}
> >
<div <div
style={{ style={{
display: "inline-block", pointerEvents: "auto",
pointerEvents: "none",
transformOrigin: "left center", transformOrigin: "left center",
transform: `rotate(${counterRotationCss})`, transform: `rotate(${counterRotationCss})`,
}} }}
@@ -2547,7 +2549,6 @@ export const WebGLRouteMapPrototype = observer(() => {
) : null} ) : null}
<div <div
style={{ style={{
position: "relative",
fontWeight: 700, fontWeight: 700,
fontSize: primaryFontSize, fontSize: primaryFontSize,
textShadow: "0 0 4px rgba(0,0,0,0.6)", textShadow: "0 0 4px rgba(0,0,0,0.6)",
@@ -2556,26 +2557,26 @@ export const WebGLRouteMapPrototype = observer(() => {
}} }}
> >
{station.name} {station.name}
{showSecondary ? (
<div
style={{
position: "absolute",
top: "100%",
marginTop: -1 * secondaryMarginTop,
fontWeight: 400,
fontSize: secondaryFontSize,
lineHeight: secondaryLineHeight,
color: "#CBCBCB",
textShadow: "0 0 3px rgba(0,0,0,0.4)",
whiteSpace: "nowrap",
...secondaryPositionStyle,
pointerEvents: "none",
}}
>
{secondaryStation?.name}
</div>
) : null}
</div> </div>
{showSecondary ? (
<div
style={{
position: "absolute",
top: "100%",
marginTop: -1 * secondaryMarginTop,
fontWeight: 400,
fontSize: secondaryFontSize,
lineHeight: secondaryLineHeight,
color: "#CBCBCB",
textShadow: "0 0 3px rgba(0,0,0,0.4)",
whiteSpace: "nowrap",
...secondaryPositionStyle,
pointerEvents: "none",
}}
>
{secondaryStation?.name}
</div>
) : null}
</div> </div>
</div> </div>
</div> </div>
@@ -2586,9 +2587,9 @@ export const WebGLRouteMapPrototype = observer(() => {
top: "100%", top: "100%",
left: "50%", left: "50%",
transform: "translateX(-50%)", transform: "translateX(-50%)",
paddingTop: "8px", paddingTop: menuPaddingTop,
pointerEvents: "auto", pointerEvents: "auto",
zIndex: 1000000, zIndex: 10,
cursor: "default", cursor: "default",
}} }}
onPointerDown={(e) => e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()}

View File

@@ -46,7 +46,7 @@ export const SightListPage = observer(() => {
setIsLoading(false); setIsLoading(false);
}; };
fetchSights(); fetchSights();
}, [language, selectedCityStore.cityVersion]); }, [language]);
const columns: GridColDef[] = [ const columns: GridColDef[] = [
{ {
@@ -105,11 +105,10 @@ export const SightListPage = observer(() => {
sortable: false, sortable: false,
renderCell: (params: GridRenderCellParams) => ( renderCell: (params: GridRenderCellParams) => (
<div className="flex h-full gap-7 justify-center items-center"> <div className="flex h-full gap-7 justify-center items-center">
<button title="Редактировать" onClick={() => navigate(`/sight/${params.row.id}/edit`)}> <button onClick={() => navigate(`/sight/${params.row.id}/edit`)}>
<Pencil size={20} className="text-blue-500" /> <Pencil size={20} className="text-blue-500" />
</button> </button>
<button <button
title="Удалить"
onClick={() => { onClick={() => {
setIsDeleteModalOpen(true); setIsDeleteModalOpen(true);
setRowId(params.row.id); setRowId(params.row.id);
@@ -122,11 +121,8 @@ export const SightListPage = observer(() => {
}] : []), }] : []),
]; ];
const { selectedCityId } = selectedCityStore;
const filteredSights = useMemo(() => { const filteredSights = useMemo(() => {
if (!selectedCityId) { const { selectedCityId } = selectedCityStore;
return [];
}
const allowedCityIds = canReadCities const allowedCityIds = canReadCities
? null ? null
: authStore.meCities["ru"].map((c) => c.city_id); : authStore.meCities["ru"].map((c) => c.city_id);
@@ -135,12 +131,12 @@ export const SightListPage = observer(() => {
if (allowedCityIds && !allowedCityIds.includes(sight.city_id)) { if (allowedCityIds && !allowedCityIds.includes(sight.city_id)) {
return false; return false;
} }
if (sight.city_id !== selectedCityId) { if (selectedCityId && sight.city_id !== selectedCityId) {
return false; return false;
} }
return true; return true;
}); });
}, [sights, selectedCityId, canReadCities, authStore.meCities]); }, [sights, selectedCityStore.selectedCityId, canReadCities, authStore.meCities]);
const query = searchQuery.trim().toLowerCase(); const query = searchQuery.trim().toLowerCase();
const rows = filteredSights const rows = filteredSights
@@ -181,9 +177,7 @@ export const SightListPage = observer(() => {
</div> </div>
)} )}
{(rows.length > 0 || searchQuery) && ( <SearchInput value={searchQuery} onChange={setSearchQuery} />
<SearchInput value={searchQuery} onChange={setSearchQuery} />
)}
<DataGrid <DataGrid
rows={rows} rows={rows}
@@ -222,8 +216,6 @@ export const SightListPage = observer(() => {
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}> <Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
{isLoading ? ( {isLoading ? (
<CircularProgress size={20} /> <CircularProgress size={20} />
) : !selectedCityId ? (
"Выберите город"
) : ( ) : (
"Нет достопримечательностей" "Нет достопримечательностей"
)} )}

View File

@@ -1,155 +1,17 @@
import { import { Button, TextField } from "@mui/material";
Button, import { snapshotStore } from "@shared";
TextField,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
} from "@mui/material";
import { snapshotStore, authStore, routeStore, selectedCityStore, cityStore, carrierStore } from "@shared";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { ArrowLeft, Loader2, Save } from "lucide-react"; import { ArrowLeft, Loader2, Save } from "lucide-react";
import { useState, useEffect, useMemo } from "react"; import { useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { runInAction } from "mobx"; import { runInAction } from "mobx";
function escapeRegex(s: string) {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function buildExportNameRegex(cityNames: string[]): RegExp {
if (!cityNames.length) return /.+/;
const pattern = cityNames.map(escapeRegex).join("|");
return new RegExp(`^(${pattern})_.+$`);
}
export const SnapshotCreatePage = observer(() => { export const SnapshotCreatePage = observer(() => {
const { createSnapshot, getSnapshotStatus, getStorageInfo, snapshotStatus } = snapshotStore; const { createSnapshot, getSnapshotStatus, getStorageInfo, snapshotStatus } = snapshotStore;
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => {
selectedCityStore.setIsLocked(true);
return () => selectedCityStore.setIsLocked(false);
}, []);
const [name, setName] = useState(""); const [name, setName] = useState("");
const [nameError, setNameError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [duplicateWarningOpen, setDuplicateWarningOpen] = useState(false);
const [duplicateRouteNumbers, setDuplicateRouteNumbers] = useState<string[]>([]);
const exportNameRegex = useMemo(() => {
const names = cityStore.cities["ru"].data.map((c) => c.name.trim());
return buildExportNameRegex(names);
}, [cityStore.cities["ru"].data.length]);
useEffect(() => {
if (!cityStore.cities["ru"].loaded) {
cityStore.getCities("ru");
}
}, []);
const canReadRoutes = authStore.canRead("routes");
const startExport = async () => {
try {
setIsLoading(true);
const id = await createSnapshot(name);
await getSnapshotStatus(id);
while (snapshotStore.snapshotStatus?.Status != "done") {
await new Promise((resolve) => setTimeout(resolve, 1000));
await getSnapshotStatus(id);
}
if (snapshotStore.snapshotStatus?.Status === "done") {
toast.success("Экспорт медиа успешно создан");
runInAction(() => {
snapshotStore.snapshotStatus = null;
});
await getStorageInfo();
navigate(-1);
}
} catch (error) {
console.error(error);
toast.error("Ошибка при создании экспорта медиа");
} finally {
setIsLoading(false);
}
};
const handleSave = async () => {
if (!canReadRoutes) {
await startExport();
return;
}
try {
runInAction(() => {
routeStore.routes.loaded = false;
});
await routeStore.getRoutes();
await carrierStore.getCarriers("ru");
const routes = routeStore.routes.data;
const carriers = carrierStore.carriers.ru.data;
const carrierCityMap = new Map<number, number>();
for (const c of carriers) {
carrierCityMap.set(c.id, c.city_id);
}
const duplicateMessages: string[] = [];
const directionKey = new Map<string, number>();
for (const route of routes) {
const num = (route.route_sys_number ?? "").trim();
if (!num) continue;
const cityId = carrierCityMap.get(route.carrier_id) ?? 0;
const key = `${num}|${route.route_direction}|${cityId}`;
directionKey.set(key, (directionKey.get(key) ?? 0) + 1);
}
for (const [key, count] of directionKey) {
if (count > 1) {
const [num, dir] = key.split("|");
const dirLabel = dir === "true" ? "прямой" : "обратный";
duplicateMessages.push(
`Дублируется маршрут №${num} (${dirLabel})`
);
}
}
const cityPerNumber = new Map<string, Set<number>>();
for (const route of routes) {
const num = (route.route_sys_number ?? "").trim();
if (!num) continue;
const cityId = carrierCityMap.get(route.carrier_id) ?? 0;
if (!cityPerNumber.has(num)) {
cityPerNumber.set(num, new Set());
}
cityPerNumber.get(num)!.add(cityId);
}
for (const [num, cities] of cityPerNumber) {
if (cities.size > 1) {
duplicateMessages.push(
`Маршрут №${num} присутствует в нескольких городах`
);
}
}
if (duplicateMessages.length > 0) {
setDuplicateRouteNumbers(duplicateMessages);
setDuplicateWarningOpen(true);
} else {
await startExport();
}
} catch {
await startExport();
}
};
return ( return (
<div className="w-full h-[400px] flex justify-center items-center"> <div className="w-full h-[400px] flex justify-center items-center">
@@ -170,19 +32,7 @@ export const SnapshotCreatePage = observer(() => {
label="Название" label="Название"
required required
value={name} value={name}
error={!!nameError} onChange={(e) => setName(e.target.value)}
helperText={nameError ?? " "}
onChange={(e) => {
const val = e.target.value;
setName(val);
const trimmed = val.trim();
const hasFullFormat = trimmed.includes("_") && trimmed.split("_").slice(1).join("_").length > 0;
if (hasFullFormat && !exportNameRegex.test(trimmed)) {
setNameError("Название должно начинаться с названия существующего города");
} else {
setNameError(null);
}
}}
/> />
<Button <Button
@@ -190,8 +40,36 @@ export const SnapshotCreatePage = observer(() => {
color="primary" color="primary"
className="w-min flex gap-2 items-center" className="w-min flex gap-2 items-center"
startIcon={<Save size={20} />} startIcon={<Save size={20} />}
onClick={handleSave} onClick={async () => {
disabled={isLoading || !exportNameRegex.test(name.trim())} try {
setIsLoading(true);
const id = await createSnapshot(name);
await getSnapshotStatus(id);
while (snapshotStore.snapshotStatus?.Status != "done") {
await new Promise((resolve) => setTimeout(resolve, 1000));
await getSnapshotStatus(id);
}
if (snapshotStore.snapshotStatus?.Status === "done") {
toast.success("Экспорт медиа успешно создан");
runInAction(() => {
snapshotStore.snapshotStatus = null;
});
await getStorageInfo();
navigate(-1);
}
} catch (error) {
console.error(error);
toast.error("Ошибка при создании экспорта медиа");
} finally {
setIsLoading(false);
}
}}
disabled={isLoading || !name.trim()}
> >
{isLoading ? ( {isLoading ? (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -209,45 +87,6 @@ export const SnapshotCreatePage = observer(() => {
</Button> </Button>
</div> </div>
</div> </div>
<Dialog
open={duplicateWarningOpen}
onClose={() => !isLoading && setDuplicateWarningOpen(false)}
maxWidth="sm"
fullWidth
>
<DialogTitle>Найдены повторяющиеся маршруты</DialogTitle>
<DialogContent>
<p className="mb-3">
Обнаружены маршруты с одинаковыми номерами трассы. Это может привести к
некорректным данным в экспорте.
</p>
<ul className="list-disc pl-5">
{duplicateRouteNumbers.map((msg, i) => (
<li key={i}>{msg}</li>
))}
</ul>
</DialogContent>
<DialogActions>
<Button
onClick={() => setDuplicateWarningOpen(false)}
disabled={isLoading}
>
Отмена
</Button>
<Button
variant="contained"
sx={{ backgroundColor: "#795548", "&:hover": { backgroundColor: "#5D4037" } }}
disabled={isLoading}
onClick={async () => {
setDuplicateWarningOpen(false);
await startExport();
}}
>
Продолжить экспорт
</Button>
</DialogActions>
</Dialog>
</div> </div>
); );
}); });

View File

@@ -1,24 +1,14 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { ruRU } from "@mui/x-data-grid/locales"; import { ruRU } from "@mui/x-data-grid/locales";
import { authStore, languageStore, snapshotStore, cityStore, vehicleStore, SearchInput } from "@shared"; import { authStore, languageStore, snapshotStore, SearchInput } from "@shared";
import { useEffect, useState, useMemo } from "react"; import { useEffect, useState, useMemo } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { DatabaseBackup, Trash2 } from "lucide-react"; import { DatabaseBackup, Trash2 } from "lucide-react";
import { CreateButton, DeleteModal, SnapshotRestore } from "@widgets"; import { CreateButton, DeleteModal, SnapshotRestore } from "@widgets";
import { Alert, Box, Button, CircularProgress, Dialog, DialogActions, DialogContent, DialogTitle, TextField, Typography } from "@mui/material"; import { Alert, Box, Button, CircularProgress, Dialog, DialogActions, DialogContent, DialogTitle, TextField } from "@mui/material";
const LOW_STORAGE_THRESHOLD_GB = 10; const LOW_STORAGE_THRESHOLD_GB = 10;
function escapeRegex(s: string) {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function buildExportNameRegex(cityNames: string[]): RegExp {
if (!cityNames.length) return /.+/;
const pattern = cityNames.map(escapeRegex).join("|");
return new RegExp(`^(${pattern})_.+$`);
}
const SEGMENT_COLORS = [ const SEGMENT_COLORS = [
"#FF3B30", "#FF3B30",
"#FF9500", "#FF9500",
@@ -43,14 +33,11 @@ export const SnapshotListPage = observer(() => {
createEmptySnapshot, createEmptySnapshot,
} = snapshotStore; } = snapshotStore;
const canWriteDevices = authStore.canWrite("devices"); const canWriteDevices = authStore.canWrite("devices");
const canReadDevices = authStore.canRead("devices");
const canCreateSnapshot = const canCreateSnapshot =
authStore.hasRole("snapshot_create") && canWriteDevices; authStore.hasRole("snapshot_create") && canWriteDevices;
const canManageSnapshots = authStore.canWrite("snapshot") && canWriteDevices; const canManageSnapshots = authStore.canWrite("snapshot") && canWriteDevices;
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isSnapshotOnDeviceWarning, setIsSnapshotOnDeviceWarning] = useState(false);
const [devicesWithSnapshot, setDevicesWithSnapshot] = useState<string[]>([]);
const [rowId, setRowId] = useState<string | null>(null); const [rowId, setRowId] = useState<string | null>(null);
const { language } = languageStore; const { language } = languageStore;
const [isRestoreModalOpen, setIsRestoreModalOpen] = useState(false); const [isRestoreModalOpen, setIsRestoreModalOpen] = useState(false);
@@ -58,12 +45,6 @@ export const SnapshotListPage = observer(() => {
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [isEmptySnapshotModalOpen, setIsEmptySnapshotModalOpen] = useState(false); const [isEmptySnapshotModalOpen, setIsEmptySnapshotModalOpen] = useState(false);
const [emptySnapshotName, setEmptySnapshotName] = useState(""); const [emptySnapshotName, setEmptySnapshotName] = useState("");
const [emptySnapshotNameError, setEmptySnapshotNameError] = useState<string | null>(null);
const exportNameRegex = useMemo(() => {
const names = cityStore.cities["ru"].data.map((c) => c.name.trim());
return buildExportNameRegex(names);
}, [cityStore.cities["ru"].data.length]);
const [isCreatingEmpty, setIsCreatingEmpty] = useState(false); const [isCreatingEmpty, setIsCreatingEmpty] = useState(false);
const [paginationModel, setPaginationModel] = useState({ const [paginationModel, setPaginationModel] = useState({
page: 0, page: 0,
@@ -80,11 +61,7 @@ export const SnapshotListPage = observer(() => {
useEffect(() => { useEffect(() => {
const fetchSnapshots = async () => { const fetchSnapshots = async () => {
setIsLoading(true); setIsLoading(true);
const promises: Promise<void>[] = [getSnapshots(), getStorageInfo()]; await Promise.all([getSnapshots(), getStorageInfo()]);
if (canReadDevices && !vehicleStore.vehicles.loaded) {
promises.push(vehicleStore.getVehicles());
}
await Promise.all(promises);
setIsLoading(false); setIsLoading(false);
}; };
fetchSnapshots(); fetchSnapshots();
@@ -99,26 +76,6 @@ export const SnapshotListPage = observer(() => {
}; };
const columns: GridColDef[] = [ const columns: GridColDef[] = [
{
field: "color",
headerName: "",
width: 28,
sortable: false,
disableColumnMenu: true,
renderCell: (params: GridRenderCellParams) => (
<div className="flex items-center justify-center h-full w-full">
<span
style={{
display: "inline-block",
width: 12,
height: 12,
backgroundColor: params.value,
borderRadius: "50%",
}}
/>
</div>
),
},
{ {
field: "name", field: "name",
headerName: "Название", headerName: "Название",
@@ -131,7 +88,7 @@ export const SnapshotListPage = observer(() => {
}, },
{ {
field: "created_at", field: "created_at",
headerName: "Дата создания (UTC+03:00, MSK)", headerName: "Дата создания",
flex: 1, flex: 1,
renderCell: (params: GridRenderCellParams) => { renderCell: (params: GridRenderCellParams) => {
return <div>{params.value ? params.value : "-"}</div>; return <div>{params.value ? params.value : "-"}</div>;
@@ -160,7 +117,6 @@ export const SnapshotListPage = observer(() => {
renderCell: (params: GridRenderCellParams) => ( renderCell: (params: GridRenderCellParams) => (
<div className="flex h-full gap-7 justify-center items-center"> <div className="flex h-full gap-7 justify-center items-center">
<button <button
title="Восстановить"
onClick={() => { onClick={() => {
setIsRestoreModalOpen(true); setIsRestoreModalOpen(true);
setRowId(params.row.id); setRowId(params.row.id);
@@ -169,22 +125,9 @@ export const SnapshotListPage = observer(() => {
<DatabaseBackup size={20} className="text-blue-500" /> <DatabaseBackup size={20} className="text-blue-500" />
</button> </button>
<button <button
title="Удалить"
onClick={() => { onClick={() => {
const snapshotId = params.row.id;
if (canReadDevices) {
const devicesUsing = vehicleStore.vehicles.data
.filter(v => v.vehicle.current_snapshot_uuid === snapshotId)
.map(v => v.vehicle.tail_number || v.vehicle.uuid || `ID ${v.vehicle.id}`);
if (devicesUsing.length > 0) {
setDevicesWithSnapshot(devicesUsing);
setIsSnapshotOnDeviceWarning(true);
setRowId(snapshotId);
return;
}
}
setIsDeleteModalOpen(true); setIsDeleteModalOpen(true);
setRowId(snapshotId); setRowId(params.row.id);
}} }}
> >
<Trash2 size={20} className="text-red-500" /> <Trash2 size={20} className="text-red-500" />
@@ -207,13 +150,12 @@ export const SnapshotListPage = observer(() => {
.toLowerCase() .toLowerCase()
.includes(query), .includes(query),
) )
.map((snapshot, index) => ({ .map((snapshot) => ({
id: snapshot.ID, id: snapshot.ID,
name: snapshot.Name, name: snapshot.Name,
parent: snapshots.find((s) => s.ID === snapshot.ParentID)?.Name, parent: snapshots.find((s) => s.ID === snapshot.ParentID)?.Name,
created_at: formatCreationTime(snapshot.CreationTime), created_at: formatCreationTime(snapshot.CreationTime),
occupied_disk_space_gb: snapshot.occupied_disk_space_gb, occupied_disk_space_gb: snapshot.occupied_disk_space_gb,
color: SEGMENT_COLORS[index % SEGMENT_COLORS.length],
})); }));
}, [snapshots, searchQuery]); }, [snapshots, searchQuery]);
@@ -236,11 +178,10 @@ export const SnapshotListPage = observer(() => {
disabled={isLowStorage} disabled={isLowStorage}
onClick={() => { onClick={() => {
setEmptySnapshotName(""); setEmptySnapshotName("");
setEmptySnapshotNameError(null);
setIsEmptySnapshotModalOpen(true); setIsEmptySnapshotModalOpen(true);
}} }}
> >
Создать пустой экспорт Создать пустой снапшот
</Button> </Button>
)} )}
{canCreateSnapshot && ( {canCreateSnapshot && (
@@ -262,7 +203,7 @@ export const SnapshotListPage = observer(() => {
</div> </div>
<div className="flex w-full h-3 rounded-lg overflow-hidden bg-gray-100"> <div className="flex w-full h-3 rounded-lg overflow-hidden bg-gray-100">
{rows.map((row) => { {rows.map((row, i) => {
const pct = const pct =
row.occupied_disk_space_gb != null && totalGB > 0 row.occupied_disk_space_gb != null && totalGB > 0
? (row.occupied_disk_space_gb / totalGB) * 100 ? (row.occupied_disk_space_gb / totalGB) * 100
@@ -273,7 +214,8 @@ export const SnapshotListPage = observer(() => {
key={row.id} key={row.id}
style={{ style={{
width: `${pct}%`, width: `${pct}%`,
backgroundColor: row.color, backgroundColor:
SEGMENT_COLORS[i % SEGMENT_COLORS.length],
}} }}
title={`${row.name}: ${row.occupied_disk_space_gb?.toFixed(1)} ГБ`} title={`${row.name}: ${row.occupied_disk_space_gb?.toFixed(1)} ГБ`}
/> />
@@ -291,7 +233,7 @@ export const SnapshotListPage = observer(() => {
</div> </div>
<div className="flex flex-wrap gap-x-5 gap-y-1 mt-3"> <div className="flex flex-wrap gap-x-5 gap-y-1 mt-3">
{rows.map((row) => { {rows.map((row, i) => {
if (row.occupied_disk_space_gb == null || row.occupied_disk_space_gb <= 0) if (row.occupied_disk_space_gb == null || row.occupied_disk_space_gb <= 0)
return null; return null;
return ( return (
@@ -301,7 +243,10 @@ export const SnapshotListPage = observer(() => {
> >
<span <span
className="inline-block w-2.5 h-2.5 rounded-full" className="inline-block w-2.5 h-2.5 rounded-full"
style={{ backgroundColor: row.color }} style={{
backgroundColor:
SEGMENT_COLORS[i % SEGMENT_COLORS.length],
}}
/> />
{row.name} {row.name}
</div> </div>
@@ -335,9 +280,7 @@ export const SnapshotListPage = observer(() => {
</Alert> </Alert>
)} )}
{(rows.length > 0 || searchQuery) && ( <SearchInput value={searchQuery} onChange={setSearchQuery} />
<SearchInput value={searchQuery} onChange={setSearchQuery} />
)}
<DataGrid <DataGrid
rows={rows} rows={rows}
@@ -382,26 +325,14 @@ export const SnapshotListPage = observer(() => {
fullWidth fullWidth
maxWidth="xs" maxWidth="xs"
> >
<DialogTitle>Создать пустой экспорт</DialogTitle> <DialogTitle>Создать пустой снапшот</DialogTitle>
<DialogContent> <DialogContent>
<TextField <TextField
autoFocus autoFocus
fullWidth fullWidth
label="Название" label="Название"
value={emptySnapshotName} value={emptySnapshotName}
error={!!emptySnapshotNameError} onChange={(e) => setEmptySnapshotName(e.target.value)}
helperText={emptySnapshotNameError ?? " "}
onChange={(e) => {
const val = e.target.value;
setEmptySnapshotName(val);
const trimmed = val.trim();
const hasFullFormat = trimmed.includes("_") && trimmed.split("_").slice(1).join("_").length > 0;
if (hasFullFormat && !exportNameRegex.test(trimmed)) {
setEmptySnapshotNameError("Название должно начинаться с названия существующего города");
} else {
setEmptySnapshotNameError(null);
}
}}
margin="normal" margin="normal"
/> />
</DialogContent> </DialogContent>
@@ -411,7 +342,7 @@ export const SnapshotListPage = observer(() => {
</Button> </Button>
<Button <Button
variant="contained" variant="contained"
disabled={!exportNameRegex.test(emptySnapshotName.trim()) || isCreatingEmpty} disabled={!emptySnapshotName.trim() || isCreatingEmpty}
onClick={async () => { onClick={async () => {
setIsCreatingEmpty(true); setIsCreatingEmpty(true);
try { try {
@@ -428,36 +359,6 @@ export const SnapshotListPage = observer(() => {
</DialogActions> </DialogActions>
</Dialog> </Dialog>
<Dialog
open={isSnapshotOnDeviceWarning}
onClose={() => setIsSnapshotOnDeviceWarning(false)}
fullWidth
maxWidth="xs"
>
<DialogTitle>Удаление невозможно</DialogTitle>
<DialogContent>
<Alert severity="warning" sx={{ mt: 1 }}>
Этот экспорт загружен на устройства. Удалите или замените экспорт на
устройствах перед удалением.
</Alert>
<Box sx={{ mt: 2 }}>
<Typography variant="body2" fontWeight={600} gutterBottom>
Устройства:
</Typography>
{devicesWithSnapshot.map((name, i) => (
<Typography key={i} variant="body2">
• {name}
</Typography>
))}
</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => setIsSnapshotOnDeviceWarning(false)}>
Закрыть
</Button>
</DialogActions>
</Dialog>
<SnapshotRestore <SnapshotRestore
open={isRestoreModalOpen} open={isRestoreModalOpen}
loading={isLoading} loading={isLoading}

View File

@@ -19,7 +19,6 @@ import {
mediaStore, mediaStore,
isMediaIdEmpty, isMediaIdEmpty,
useSelectedCity, useSelectedCity,
selectedCityStore,
SelectMediaDialog, SelectMediaDialog,
UploadMediaDialog, UploadMediaDialog,
PreviewMediaDialog, PreviewMediaDialog,
@@ -33,12 +32,6 @@ import {
export const StationCreatePage = observer(() => { export const StationCreatePage = observer(() => {
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => {
selectedCityStore.setIsLocked(true);
return () => selectedCityStore.setIsLocked(false);
}, []);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const { language } = languageStore; const { language } = languageStore;
const { const {

View File

@@ -19,7 +19,6 @@ import {
mediaStore, mediaStore,
isMediaIdEmpty, isMediaIdEmpty,
LoadingSpinner, LoadingSpinner,
selectedCityStore,
SelectMediaDialog, SelectMediaDialog,
PreviewMediaDialog, PreviewMediaDialog,
UploadMediaDialog, UploadMediaDialog,
@@ -35,12 +34,6 @@ import { LinkedSights } from "../LinkedSights";
export const StationEditPage = observer(() => { export const StationEditPage = observer(() => {
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => {
selectedCityStore.setIsLocked(true);
return () => selectedCityStore.setIsLocked(false);
}, []);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isLoadingData, setIsLoadingData] = useState(true); const [isLoadingData, setIsLoadingData] = useState(true);
const { language } = languageStore; const { language } = languageStore;

View File

@@ -49,7 +49,7 @@ export const StationListPage = observer(() => {
loadSightCounts(stationIds); loadSightCounts(stationIds);
}; };
fetchStations(); fetchStations();
}, [language, selectedCityStore.cityVersion]); }, [language]);
const columns: GridColDef[] = [ const columns: GridColDef[] = [
{ {
@@ -86,13 +86,13 @@ export const StationListPage = observer(() => {
}, },
{ {
field: "sightCount", field: "sightCount",
headerName: "Привязки", headerName: "Достопримечательности",
width: 180, width: 180,
align: "center" as const, align: "center" as const,
headerAlign: "center" as const, headerAlign: "center" as const,
sortable: true, sortable: true,
renderHeader: (params) => ( renderHeader: (params) => (
<Tooltip title="Отображает количество привязок"> <Tooltip title="Количество привязанных достопримечательностей">
<span>{params.colDef.headerName}</span> <span>{params.colDef.headerName}</span>
</Tooltip> </Tooltip>
), ),
@@ -114,7 +114,7 @@ export const StationListPage = observer(() => {
headerAlign: "center" as const, headerAlign: "center" as const,
sortable: true, sortable: true,
renderHeader: (params) => ( renderHeader: (params) => (
<Tooltip title="Отображает подтверждение добавленных пересадок"> <Tooltip title="Подтверждение добавленных пересадок">
<span>{params.colDef.headerName}</span> <span>{params.colDef.headerName}</span>
</Tooltip> </Tooltip>
), ),
@@ -141,7 +141,7 @@ export const StationListPage = observer(() => {
return ( return (
<div className="flex h-full gap-7 justify-center items-center"> <div className="flex h-full gap-7 justify-center items-center">
{canWriteStations && ( {canWriteStations && (
<button title="Редактировать" onClick={() => navigate(`/station/${params.row.id}/edit`)}> <button onClick={() => navigate(`/station/${params.row.id}/edit`)}>
<Pencil size={20} className="text-blue-500" /> <Pencil size={20} className="text-blue-500" />
</button> </button>
)} )}
@@ -151,14 +151,13 @@ export const StationListPage = observer(() => {
setSelectedStationId(params.row.id); setSelectedStationId(params.row.id);
setIsTransfersModalOpen(true); setIsTransfersModalOpen(true);
}} }}
title="Управление пересадками" title="Редактировать пересадки"
> >
<Route size={20} className="text-purple-500" /> <Route size={20} className="text-purple-500" />
</button> </button>
)} )}
{canWriteStations && ( {canWriteStations && (
<button <button
title="Удалить"
onClick={() => { onClick={() => {
setIsDeleteModalOpen(true); setIsDeleteModalOpen(true);
setRowId(params.row.id); setRowId(params.row.id);
@@ -175,12 +174,9 @@ export const StationListPage = observer(() => {
const rows = useMemo(() => { const rows = useMemo(() => {
const { selectedCityId } = selectedCityStore; const { selectedCityId } = selectedCityStore;
if (!selectedCityId) {
return [];
}
const query = searchQuery.trim().toLowerCase(); const query = searchQuery.trim().toLowerCase();
return stationLists[language].data return stationLists[language].data
.filter((station: any) => station.city_id === selectedCityId) .filter((station: any) => !selectedCityId || station.city_id === selectedCityId)
.filter( .filter(
(station: any) => (station: any) =>
!query || !query ||
@@ -206,10 +202,7 @@ export const StationListPage = observer(() => {
<div className="flex justify-between items-center mb-10"> <div className="flex justify-between items-center mb-10">
<h1 className="text-2xl">Остановки</h1> <h1 className="text-2xl">Остановки</h1>
{canWriteStations && ( {canWriteStations && (
<CreateButton <CreateButton label="Создать остановку" path="/station/create" />
label="Создать остановку"
path="/station/create"
/>
)} )}
</div> </div>
@@ -225,9 +218,7 @@ export const StationListPage = observer(() => {
</div> </div>
)} )}
{(rows.length > 0 || searchQuery) && ( <SearchInput value={searchQuery} onChange={setSearchQuery} />
<SearchInput value={searchQuery} onChange={setSearchQuery} />
)}
<DataGrid <DataGrid
rows={rows} rows={rows}
@@ -286,13 +277,7 @@ export const StationListPage = observer(() => {
slots={{ slots={{
noRowsOverlay: () => ( noRowsOverlay: () => (
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}> <Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
{isLoading ? ( {isLoading ? <CircularProgress size={20} /> : "Нет остановок"}
<CircularProgress size={20} />
) : !selectedCityStore.selectedCityId ? (
"Выберите город"
) : (
"Нет остановок"
)}
</Box> </Box>
), ),
}} }}

View File

@@ -1,11 +1,4 @@
import { import { Button, Paper, TextField } from "@mui/material";
Button,
Paper,
TextField,
Typography,
Box,
Divider,
} from "@mui/material";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { ArrowLeft, Loader2, Save } from "lucide-react"; import { ArrowLeft, Loader2, Save } from "lucide-react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
@@ -17,10 +10,9 @@ import {
SelectMediaDialog, SelectMediaDialog,
UploadMediaDialog, UploadMediaDialog,
PreviewMediaDialog, PreviewMediaDialog,
selectedCityStore,
} from "@shared"; } from "@shared";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { ImageUploadCard, PermissionsTable, RolesHintTable, ROLE_RESOURCES } from "@widgets"; import { ImageUploadCard } from "@widgets";
export const UserCreatePage = observer(() => { export const UserCreatePage = observer(() => {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -34,38 +26,13 @@ export const UserCreatePage = observer(() => {
"thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null "thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null
>(null); >(null);
const [localRoles, setLocalRoles] = useState<string[]>(
createUserData.roles ?? ["articles_ro", "articles_rw", "media_ro", "media_rw"]
);
useEffect(() => {
selectedCityStore.setIsLocked(true);
return () => selectedCityStore.setIsLocked(false);
}, []);
useEffect(() => { useEffect(() => {
mediaStore.getMedia(); mediaStore.getMedia();
}, []); }, []);
useEffect(() => {
const allRw = ROLE_RESOURCES.every(({ key }) => localRoles.includes(`${key}_rw`));
const isAdmin = allRw && !localRoles.includes("devices_maintenance_rw");
if (isAdmin !== createUserData.is_admin) {
setCreateUserData(
createUserData.name || "",
createUserData.email || "",
createUserData.password || "",
isAdmin,
createUserData.icon
);
}
}, [localRoles]);
const handleCreate = async () => { const handleCreate = async () => {
try { try {
setIsLoading(true); setIsLoading(true);
// Убеждаемся, что роли в сторе обновлены перед созданием
userStore.createUserData.roles = localRoles;
await createUser(); await createUser();
toast.success("Пользователь успешно создан"); toast.success("Пользователь успешно создан");
navigate("/user"); navigate("/user");
@@ -100,15 +67,18 @@ export const UserCreatePage = observer(() => {
: selectedMedia?.id ?? createUserData.icon ?? null; : selectedMedia?.id ?? createUserData.icon ?? null;
return ( return (
<Paper className="w-full p-6 flex flex-col gap-8"> <Paper className="w-full h-full p-3 flex flex-col gap-10">
<button className="flex items-center gap-2 self-start" onClick={() => navigate(-1)}> <div className="flex items-center gap-4">
<ArrowLeft size={20} /> <button
Назад className="flex items-center gap-2"
</button> onClick={() => navigate(-1)}
>
<section className="flex flex-col gap-6"> <ArrowLeft size={20} />
<Typography variant="h6">Основные данные</Typography> Назад
</button>
</div>
<div className="flex flex-col gap-10 w-full items-end">
<TextField <TextField
fullWidth fullWidth
label="Имя" label="Имя"
@@ -146,7 +116,6 @@ export const UserCreatePage = observer(() => {
label="Пароль" label="Пароль"
value={createUserData.password || ""} value={createUserData.password || ""}
required required
type="password"
onChange={(e) => onChange={(e) =>
setCreateUserData( setCreateUserData(
createUserData.name || "", createUserData.name || "",
@@ -158,7 +127,7 @@ export const UserCreatePage = observer(() => {
} }
/> />
<div className="w-full flex flex-col gap-4 max-w-[300px]"> <div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto">
<ImageUploadCard <ImageUploadCard
title="Аватар" title="Аватар"
imageKey="thumbnail" imageKey="thumbnail"
@@ -187,72 +156,23 @@ export const UserCreatePage = observer(() => {
}} }}
/> />
</div> </div>
</section>
<Divider /> <Button
variant="contained"
<section className="flex flex-col gap-4"> className="w-min flex gap-2 items-center"
<Typography variant="h6">Права доступа</Typography> startIcon={<Save size={20} />}
onClick={handleCreate}
<Box sx={{ display: "flex", gap: 1 }}> disabled={
<Button isLoading || !createUserData.name || !createUserData.password
variant="outlined" }
size="small" >
onClick={() => { {isLoading ? (
setCreateUserData( <Loader2 size={20} className="animate-spin" />
createUserData.name || "", ) : (
createUserData.email || "", "Создать"
createUserData.password || "", )}
true, </Button>
createUserData.icon </div>
);
const next: string[] = [];
for (const { key } of ROLE_RESOURCES) {
next.push(`${key}_rw`);
}
next.push("snapshot_create");
setLocalRoles(next);
}}
>
Полный доступ (admin)
</Button>
<Button
variant="outlined"
size="small"
onClick={() => {
setCreateUserData(
createUserData.name || "",
createUserData.email || "",
createUserData.password || "",
false,
createUserData.icon
);
setLocalRoles(["devices_ro", "vehicles_ro", "devices_maintenance_rw"]);
}}
>
Администратор ТО
</Button>
</Box>
<PermissionsTable localRoles={localRoles} setLocalRoles={setLocalRoles} />
<RolesHintTable />
</section>
<Button
variant="contained"
className="self-end w-min flex gap-2 items-center"
startIcon={<Save size={20} />}
onClick={handleCreate}
disabled={
isLoading || !createUserData.name || !createUserData.password || !createUserData.email
}
>
{isLoading ? (
<Loader2 size={20} className="animate-spin" />
) : (
"Создать"
)}
</Button>
<SelectMediaDialog <SelectMediaDialog
open={isSelectMediaOpen} open={isSelectMediaOpen}

View File

@@ -1,9 +1,18 @@
import { import {
Button, Button,
FormControlLabel,
Checkbox,
Paper, Paper,
TextField, TextField,
Box, Box,
Typography, Typography,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
Radio,
RadioGroup,
Divider, Divider,
} from "@mui/material"; } from "@mui/material";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
@@ -22,12 +31,45 @@ import {
authStore, authStore,
cityStore, cityStore,
MultiSelect, MultiSelect,
selectedCityStore,
type User, type User,
type UserCity, type UserCity,
} from "@shared"; } from "@shared";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { ImageUploadCard, DeleteModal, PermissionsTable, RolesHintTable, ROLE_RESOURCES } from "@widgets"; import { ImageUploadCard, DeleteModal } from "@widgets";
const ROLE_RESOURCES = [
{ key: "snapshot", label: "Экспорт" },
{ key: "devices", label: "Устройства" },
{ key: "vehicles", label: "Транспорт" },
{ key: "users", label: "Пользователи" },
{ key: "sights", label: "Достопримечательности" },
{ key: "stations", label: "Остановки" },
{ key: "routes", label: "Маршруты" },
{ key: "countries", label: "Страны" },
{ key: "cities", label: "Города" },
{ key: "carriers", label: "Перевозчики" },
] as const;
type PermissionLevel = "none" | "ro" | "rw";
function getPermissionLevel(roles: string[], resource: string): PermissionLevel {
if (roles.includes(`${resource}_rw`)) return "rw";
if (roles.includes(`${resource}_ro`)) return "ro";
return "none";
}
function applyPermissionChange(
roles: string[],
resource: string,
level: PermissionLevel,
): string[] {
const filtered = roles.filter(
(r) => r !== `${resource}_ro` && r !== `${resource}_rw`,
);
if (level === "ro") return [...filtered, `${resource}_ro`];
if (level === "rw") return [...filtered, `${resource}_rw`];
return filtered;
}
export const UserEditPage = observer(() => { export const UserEditPage = observer(() => {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -51,29 +93,10 @@ export const UserEditPage = observer(() => {
"thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null "thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null
>(null); >(null);
useEffect(() => {
selectedCityStore.setIsLocked(true);
return () => selectedCityStore.setIsLocked(false);
}, []);
useEffect(() => { useEffect(() => {
languageStore.setLanguage("ru"); languageStore.setLanguage("ru");
}, []); }, []);
useEffect(() => {
const allRw = ROLE_RESOURCES.every(({ key }) => localRoles.includes(`${key}_rw`));
const isAdmin = allRw && !localRoles.includes("devices_maintenance_rw");
if (isAdmin !== editUserData.is_admin) {
setEditUserData(
editUserData.name || "",
editUserData.email || "",
editUserData.password || "",
isAdmin,
editUserData.icon || ""
);
}
}, [localRoles]);
useEffect(() => { useEffect(() => {
(async () => { (async () => {
if (id) { if (id) {
@@ -288,36 +311,175 @@ export const UserEditPage = observer(() => {
<section className="flex flex-col gap-4"> <section className="flex flex-col gap-4">
<Typography variant="h6">Права доступа</Typography> <Typography variant="h6">Права доступа</Typography>
<Box sx={{ display: "flex", gap: 1 }}> <FormControlLabel
<Button control={
variant="outlined" <Checkbox
size="small" checked={localRoles.includes("admin")}
onClick={() => { onChange={(e) => {
setEditUserData(editUserData.name || "", editUserData.email || "", editUserData.password || "", true, editUserData.icon || ""); if (e.target.checked) {
const next: string[] = []; setLocalRoles((prev) => {
for (const { key } of ROLE_RESOURCES) { let next = prev.filter((r) => r !== "admin");
next.push(`${key}_rw`); for (const { key } of ROLE_RESOURCES) {
} next = next.filter((r) => r !== `${key}_ro` && r !== `${key}_rw`);
next.push("snapshot_create"); next.push(`${key}_rw`);
setLocalRoles(next); }
}} if (!next.includes("snapshot_create")) {
> next.push("snapshot_create");
Полный доступ (admin) }
</Button> if (!next.includes("devices_maintenance_rw")) {
<Button next.push("devices_maintenance_rw");
variant="outlined" }
size="small" next.push("admin");
onClick={() => { return next;
setEditUserData(editUserData.name || "", editUserData.email || "", editUserData.password || "", false, editUserData.icon || ""); });
setLocalRoles(["devices_ro", "vehicles_ro", "devices_maintenance_rw"]); } else {
}} setLocalRoles((prev) => prev.filter((r) => r !== "admin"));
> }
Администратор ТО }}
</Button> />
</Box> }
label="Полный доступ (admin)"
/>
<PermissionsTable localRoles={localRoles} setLocalRoles={setLocalRoles} /> <Box sx={{ border: "1px solid", borderColor: "divider", borderRadius: 1 }}>
<RolesHintTable /> <Table size="small">
<TableHead>
<TableRow sx={{ bgcolor: "action.hover" }}>
<TableCell sx={{ fontWeight: 600, width: 220 }}>Ресурс</TableCell>
<TableCell align="center" sx={{ fontWeight: 600 }}>Нет доступа</TableCell>
<TableCell align="center" sx={{ fontWeight: 600 }}>Чтение</TableCell>
<TableCell align="center" sx={{ fontWeight: 600 }}>Чтение/Запись</TableCell>
<TableCell align="center" sx={{ fontWeight: 600 }}>
Доп. права
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{ROLE_RESOURCES.map(({ key, label }) => {
const level = getPermissionLevel(localRoles, key);
const isSnapshotResource = key === "snapshot";
const handleChange = (val: string) => {
setLocalRoles((prev) => {
let updated = applyPermissionChange(prev, key, val as PermissionLevel);
if (key === "devices") {
updated = applyPermissionChange(
updated,
"vehicles",
val as PermissionLevel,
);
}
const allRw = ROLE_RESOURCES.every(({ key: k }) =>
updated.includes(`${k}_rw`),
);
if (allRw && !updated.includes("admin")) {
const next = [...updated];
if (!next.includes("snapshot_create")) {
next.push("snapshot_create");
}
next.push("admin");
return next;
}
if (!allRw) {
return updated.filter((r) => r !== "admin");
}
return updated;
});
};
const isDevicesResource = key === "devices";
const handleSnapshotCreateChange = (checked: boolean) => {
if (!isSnapshotResource) {
return;
}
setLocalRoles((prev) => {
const withoutSnapshotCreate = prev.filter(
(role) => role !== "snapshot_create"
);
return checked
? [...withoutSnapshotCreate, "snapshot_create"]
: withoutSnapshotCreate;
});
};
const handleMaintenanceChange = (checked: boolean) => {
setLocalRoles((prev) => {
const without = prev.filter((r) => r !== "devices_maintenance_rw");
return checked ? [...without, "devices_maintenance_rw"] : without;
});
};
return (
<TableRow key={key} hover>
<TableCell>{label}</TableCell>
<TableCell align="center" padding="checkbox">
<RadioGroup
row
value={level}
onChange={(e) => handleChange(e.target.value)}
sx={{ justifyContent: "center", flexWrap: "nowrap" }}
>
<Radio value="none" size="small" />
</RadioGroup>
</TableCell>
<TableCell align="center" padding="checkbox">
{isSnapshotResource ? (
<Typography variant="body2" color="text.secondary">
-
</Typography>
) : (
<RadioGroup
row
value={level}
onChange={(e) => handleChange(e.target.value)}
sx={{ justifyContent: "center", flexWrap: "nowrap" }}
>
<Radio value="ro" size="small" />
</RadioGroup>
)}
</TableCell>
<TableCell align="center" padding="checkbox">
<RadioGroup
row
value={level}
onChange={(e) => handleChange(e.target.value)}
sx={{ justifyContent: "center", flexWrap: "nowrap" }}
>
<Radio value="rw" size="small" />
</RadioGroup>
</TableCell>
<TableCell align="center" padding="checkbox">
{isSnapshotResource ? (
<Checkbox
checked={localRoles.includes("snapshot_create")}
onChange={(e) =>
handleSnapshotCreateChange(e.target.checked)
}
size="small"
title="Разрешает создавать новые снапшоты"
/>
) : isDevicesResource ? (
<Checkbox
checked={localRoles.includes("devices_maintenance_rw")}
onChange={(e) => handleMaintenanceChange(e.target.checked)}
size="small"
title="Разрешает переводить устройства в режим технического обслуживания"
/>
) : (
<Typography variant="body2" color="text.secondary">
-
</Typography>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</Box>
</section> </section>
<Divider /> <Divider />

View File

@@ -92,11 +92,10 @@ export const UserListPage = observer(() => {
sortable: false, sortable: false,
renderCell: (params: GridRenderCellParams) => ( renderCell: (params: GridRenderCellParams) => (
<div className="flex h-full gap-7 justify-center items-center"> <div className="flex h-full gap-7 justify-center items-center">
<button title="Редактировать" onClick={() => navigate(`/user/${params.row.id}/edit`)}> <button onClick={() => navigate(`/user/${params.row.id}/edit`)}>
<Pencil size={20} className="text-blue-500" /> <Pencil size={20} className="text-blue-500" />
</button> </button>
<button <button
title="Удалить"
onClick={() => { onClick={() => {
setIsDeleteModalOpen(true); setIsDeleteModalOpen(true);
setRowId(params.row.id); setRowId(params.row.id);
@@ -147,9 +146,7 @@ export const UserListPage = observer(() => {
</div> </div>
)} )}
{(rows.length > 0 || searchQuery) && ( <SearchInput value={searchQuery} onChange={setSearchQuery} />
<SearchInput value={searchQuery} onChange={setSearchQuery} />
)}
<DataGrid <DataGrid
rows={rows} rows={rows}
@@ -157,11 +154,6 @@ export const UserListPage = observer(() => {
checkboxSelection={canWriteUsers} checkboxSelection={canWriteUsers}
disableRowSelectionExcludeModel disableRowSelectionExcludeModel
disableRowSelectionOnClick disableRowSelectionOnClick
onRowDoubleClick={(params) => {
if (canWriteUsers) {
navigate(`/user/${params.row.id}/edit`);
}
}}
loading={isLoading} loading={isLoading}
paginationModel={paginationModel} paginationModel={paginationModel}
onPaginationModelChange={setPaginationModel} onPaginationModelChange={setPaginationModel}

View File

@@ -12,8 +12,6 @@ import {
VEHICLE_TYPES, VEHICLE_TYPES,
carrierStore, carrierStore,
languageStore, languageStore,
cityStore,
selectedCityStore,
} from "@shared"; } from "@shared";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { ArrowLeft, Save } from "lucide-react"; import { ArrowLeft, Save } from "lucide-react";
@@ -28,18 +26,11 @@ export const VehicleCreatePage = observer(() => {
const [type, setType] = useState(""); const [type, setType] = useState("");
const [carrierId, setCarrierId] = useState<number | null>(null); const [carrierId, setCarrierId] = useState<number | null>(null);
const [model, setModel] = useState(""); const [model, setModel] = useState("");
const [cityId, setCityId] = useState<number | null>(selectedCityStore.selectedCityId);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const { language } = languageStore; const { language } = languageStore;
useEffect(() => {
selectedCityStore.setIsLocked(true);
return () => selectedCityStore.setIsLocked(false);
}, []);
useEffect(() => { useEffect(() => {
carrierStore.getCarriers(language); carrierStore.getCarriers(language);
cityStore.getCities("ru");
}, [language]); }, [language]);
const handleCreate = async () => { const handleCreate = async () => {
@@ -52,7 +43,6 @@ export const VehicleCreatePage = observer(() => {
?.full_name as string, ?.full_name as string,
carrierId!, carrierId!,
model || undefined, model || undefined,
cityId ?? undefined,
); );
toast.success("Транспорт успешно создан"); toast.success("Транспорт успешно создан");
} catch (error) { } catch (error) {
@@ -83,11 +73,12 @@ export const VehicleCreatePage = observer(() => {
onChange={(e) => setTailNumber(e.target.value)} onChange={(e) => setTailNumber(e.target.value)}
/> />
<FormControl fullWidth required> <FormControl fullWidth>
<InputLabel>Тип</InputLabel> <InputLabel>Тип</InputLabel>
<Select <Select
value={type} value={type}
label="Тип" label="Тип"
required
onChange={(e) => setType(e.target.value)} onChange={(e) => setType(e.target.value)}
> >
{VEHICLE_TYPES.map((type) => ( {VEHICLE_TYPES.map((type) => (
@@ -98,11 +89,12 @@ export const VehicleCreatePage = observer(() => {
</Select> </Select>
</FormControl> </FormControl>
<FormControl fullWidth required> <FormControl fullWidth>
<InputLabel>Перевозчик</InputLabel> <InputLabel>Перевозчик</InputLabel>
<Select <Select
value={carrierId || ""} value={carrierId || ""}
label="Перевозчик" label="Перевозчик"
required
onChange={(e) => setCarrierId(e.target.value as number)} onChange={(e) => setCarrierId(e.target.value as number)}
> >
{carrierStore.carriers[language].data?.map((carrier) => ( {carrierStore.carriers[language].data?.map((carrier) => (
@@ -121,27 +113,12 @@ export const VehicleCreatePage = observer(() => {
placeholder="Произвольное название модели" placeholder="Произвольное название модели"
/> />
<FormControl fullWidth required>
<InputLabel>Город</InputLabel>
<Select
value={cityId ?? ""}
label="Город"
onChange={(e) => setCityId(e.target.value ? Number(e.target.value) : null)}
>
{cityStore.cities.ru.data.map((city) => (
<MenuItem key={city.id} value={city.id}>
{city.name}
</MenuItem>
))}
</Select>
</FormControl>
<Button <Button
variant="contained" variant="contained"
className="w-min flex gap-2 items-center" className="w-min flex gap-2 items-center"
startIcon={<Save size={20} />} startIcon={<Save size={20} />}
onClick={handleCreate} onClick={handleCreate}
disabled={isLoading || !tailNumber || !type || !carrierId || !cityId} disabled={isLoading || !tailNumber || !type || !carrierId}
> >
{isLoading ? ( {isLoading ? (
<Loader2 size={20} className="animate-spin" /> <Loader2 size={20} className="animate-spin" />

View File

@@ -20,8 +20,6 @@ import {
VEHICLE_TYPES, VEHICLE_TYPES,
vehicleStore, vehicleStore,
LoadingSpinner, LoadingSpinner,
cityStore,
selectedCityStore,
} from "@shared"; } from "@shared";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
@@ -40,11 +38,6 @@ export const VehicleEditPage = observer(() => {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isLoadingData, setIsLoadingData] = useState(true); const [isLoadingData, setIsLoadingData] = useState(true);
useEffect(() => {
selectedCityStore.setIsLocked(true);
return () => selectedCityStore.setIsLocked(false);
}, []);
useEffect(() => { useEffect(() => {
// Устанавливаем русский язык при загрузке страницы // Устанавливаем русский язык при загрузке страницы
languageStore.setLanguage("ru"); languageStore.setLanguage("ru");
@@ -61,7 +54,6 @@ export const VehicleEditPage = observer(() => {
try { try {
await getVehicle(Number(id)); await getVehicle(Number(id));
await getCarriers(language); await getCarriers(language);
await cityStore.getCities("ru");
setEditVehicleData({ setEditVehicleData({
tail_number: vehicle[Number(id)]?.vehicle.tail_number ?? "", tail_number: vehicle[Number(id)]?.vehicle.tail_number ?? "",
@@ -71,7 +63,6 @@ export const VehicleEditPage = observer(() => {
model: vehicle[Number(id)]?.vehicle.model ?? "", model: vehicle[Number(id)]?.vehicle.model ?? "",
snapshot_update_blocked: snapshot_update_blocked:
vehicle[Number(id)]?.vehicle.snapshot_update_blocked ?? false, vehicle[Number(id)]?.vehicle.snapshot_update_blocked ?? false,
city_id: vehicle[Number(id)]?.vehicle.city_id ?? selectedCityStore.selectedCityId ?? undefined,
}); });
} finally { } finally {
setIsLoadingData(false); setIsLoadingData(false);
@@ -134,11 +125,12 @@ export const VehicleEditPage = observer(() => {
} }
/> />
<FormControl fullWidth required> <FormControl fullWidth>
<InputLabel>Тип</InputLabel> <InputLabel>Тип</InputLabel>
<Select <Select
value={editVehicleData.type} value={editVehicleData.type}
label="Тип" label="Тип"
required
onChange={(e) => onChange={(e) =>
setEditVehicleData({ ...editVehicleData, type: e.target.value }) setEditVehicleData({ ...editVehicleData, type: e.target.value })
} }
@@ -151,11 +143,12 @@ export const VehicleEditPage = observer(() => {
</Select> </Select>
</FormControl> </FormControl>
<FormControl fullWidth required> <FormControl fullWidth>
<InputLabel>Перевозчик</InputLabel> <InputLabel>Перевозчик</InputLabel>
<Select <Select
value={editVehicleData.carrier_id} value={editVehicleData.carrier_id}
label="Перевозчик" label="Перевозчик"
required
onChange={(e) => onChange={(e) =>
setEditVehicleData({ setEditVehicleData({
...editVehicleData, ...editVehicleData,
@@ -184,26 +177,6 @@ export const VehicleEditPage = observer(() => {
placeholder="Произвольное название модели" placeholder="Произвольное название модели"
/> />
<FormControl fullWidth required>
<InputLabel>Город</InputLabel>
<Select
value={editVehicleData.city_id ?? ""}
label="Город"
onChange={(e) =>
setEditVehicleData({
...editVehicleData,
city_id: e.target.value ? Number(e.target.value) : undefined,
})
}
>
{cityStore.cities.ru.data.map((city) => (
<MenuItem key={city.id} value={city.id}>
{city.name}
</MenuItem>
))}
</Select>
</FormControl>
<FormControlLabel <FormControlLabel
control={ control={
<Checkbox <Checkbox
@@ -229,8 +202,7 @@ export const VehicleEditPage = observer(() => {
isLoading || isLoading ||
!editVehicleData.tail_number || !editVehicleData.tail_number ||
!editVehicleData.type || !editVehicleData.type ||
!editVehicleData.carrier_id || !editVehicleData.carrier_id
!editVehicleData.city_id
} }
> >
{isLoading ? ( {isLoading ? (

View File

@@ -1,6 +1,6 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { ruRU } from "@mui/x-data-grid/locales"; import { ruRU } from "@mui/x-data-grid/locales";
import { authStore, carrierStore, languageStore, vehicleStore, SearchInput, selectedCityStore, cityStore } from "@shared"; import { authStore, carrierStore, languageStore, vehicleStore, SearchInput } from "@shared";
import { useEffect, useState, useMemo } from "react"; import { useEffect, useState, useMemo } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { Eye, Pencil, Trash2, Minus } from "lucide-react"; import { Eye, Pencil, Trash2, Minus } from "lucide-react";
@@ -11,7 +11,7 @@ import { Box, CircularProgress } from "@mui/material";
export const VehicleListPage = observer(() => { export const VehicleListPage = observer(() => {
const { vehicles, getVehicles, deleteVehicle } = vehicleStore; const { vehicles, getVehicles, deleteVehicle } = vehicleStore;
const { getCarriers } = carrierStore; const { carriers, getCarriers } = carrierStore;
const navigate = useNavigate(); const navigate = useNavigate();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false); const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
@@ -31,11 +31,10 @@ export const VehicleListPage = observer(() => {
setIsLoading(true); setIsLoading(true);
await getVehicles(); await getVehicles();
await getCarriers(language); await getCarriers(language);
await cityStore.getCities("ru");
setIsLoading(false); setIsLoading(false);
}; };
fetchData(); fetchData();
}, [language, selectedCityStore.cityVersion]); }, [language]);
const columns: GridColDef[] = [ const columns: GridColDef[] = [
{ {
@@ -115,16 +114,15 @@ export const VehicleListPage = observer(() => {
return ( return (
<div className="flex h-full gap-7 justify-center items-center"> <div className="flex h-full gap-7 justify-center items-center">
{canWrite && ( {canWrite && (
<button title="Редактировать" onClick={() => navigate(`/vehicle/${params.row.id}/edit`)}> <button onClick={() => navigate(`/vehicle/${params.row.id}/edit`)}>
<Pencil size={20} className="text-blue-500" /> <Pencil size={20} className="text-blue-500" />
</button> </button>
)} )}
<button title="Просмотр" onClick={() => navigate(`/vehicle/${params.row.id}`)}> <button onClick={() => navigate(`/vehicle/${params.row.id}`)}>
<Eye size={20} className="text-green-500" /> <Eye size={20} className="text-green-500" />
</button> </button>
{canWrite && ( {canWrite && (
<button <button
title="Удалить"
onClick={() => { onClick={() => {
setIsDeleteModalOpen(true); setIsDeleteModalOpen(true);
setRowId(params.row.id); setRowId(params.row.id);
@@ -139,13 +137,9 @@ export const VehicleListPage = observer(() => {
}, },
]; ];
const { selectedCityId } = selectedCityStore;
const rows = useMemo(() => { const rows = useMemo(() => {
if (!selectedCityId) return [];
const query = searchQuery.trim().toLowerCase(); const query = searchQuery.trim().toLowerCase();
return (vehicles.data ?? []) return (vehicles.data ?? [])
.filter((vehicle) => vehicle.vehicle.city_id === selectedCityId)
.filter( .filter(
(vehicle) => (vehicle) =>
!query || !query ||
@@ -157,9 +151,11 @@ export const VehicleListPage = observer(() => {
tail_number: vehicle.vehicle.tail_number, tail_number: vehicle.vehicle.tail_number,
type: vehicle.vehicle.type, type: vehicle.vehicle.type,
carrier: vehicle.vehicle.carrier, carrier: vehicle.vehicle.carrier,
city: cityStore.cities.ru.data.find((c) => c.id === vehicle.vehicle.city_id)?.name, city: carriers[language].data?.find(
(carrier) => carrier.id === vehicle.vehicle.carrier_id
)?.city,
})); }));
}, [vehicles.data, selectedCityId, searchQuery]); }, [vehicles.data, carriers[language].data, searchQuery]);
return ( return (
<> <>
@@ -173,9 +169,7 @@ export const VehicleListPage = observer(() => {
/> />
</div> </div>
{(rows.length > 0 || searchQuery) && ( <SearchInput value={searchQuery} onChange={setSearchQuery} />
<SearchInput value={searchQuery} onChange={setSearchQuery} />
)}
{canWriteVehicles && ids.length > 0 && ( {canWriteVehicles && ids.length > 0 && (
<div className="flex justify-end mb-5 duration-300"> <div className="flex justify-end mb-5 duration-300">
@@ -229,8 +223,6 @@ export const VehicleListPage = observer(() => {
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}> <Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
{isLoading ? ( {isLoading ? (
<CircularProgress size={20} /> <CircularProgress size={20} />
) : !selectedCityId ? (
"Выберите город"
) : ( ) : (
"Нет транспортных средств" "Нет транспортных средств"
)} )}

View File

@@ -1,8 +1,4 @@
import { import { mediaStore, MEDIA_TYPE_LABELS } from "@shared";
mediaStore,
MEDIA_TYPE_LABELS,
PreviewBackgroundToggle,
} from "@shared";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { import {
@@ -33,8 +29,6 @@ export const PreviewMediaDialog = observer(
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false); const [success, setSuccess] = useState(false);
const [isPreviewBackgroundDark, setIsPreviewBackgroundDark] =
useState(false);
const media = mediaId const media = mediaId
? mediaStore.media.find((m) => m.id === mediaId) ? mediaStore.media.find((m) => m.id === mediaId)
: null; : null;
@@ -46,7 +40,6 @@ export const PreviewMediaDialog = observer(
if (media) { if (media) {
setMediaName(media.media_name); setMediaName(media.media_name);
setMediaFilename(media.filename); setMediaFilename(media.filename);
setIsPreviewBackgroundDark(false);
} }
}, [media]); }, [media]);
@@ -148,19 +141,8 @@ export const PreviewMediaDialog = observer(
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
position: "relative",
backgroundColor: isPreviewBackgroundDark ? "#000" : "#fff",
transition: "background-color 150ms ease",
}} }}
> >
{[1, 3, 4, 5].includes(media.media_type) && (
<PreviewBackgroundToggle
dark={isPreviewBackgroundDark}
onToggle={() =>
setIsPreviewBackgroundDark((value) => !value)
}
/>
)}
<MediaViewer <MediaViewer
media={{ media={{
id: mediaId, id: mediaId,

View File

@@ -6,7 +6,6 @@ import {
clearBlobAndGLTFCache, clearBlobAndGLTFCache,
authStore, authStore,
snapshotStore, snapshotStore,
PreviewBackgroundToggle,
} from "@shared"; } from "@shared";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { useEffect, useState, useRef } from "react"; import { useEffect, useState, useRef } from "react";
@@ -91,8 +90,6 @@ export const UploadMediaDialog = observer(
[] []
); );
const [isPreviewLoaded, setIsPreviewLoaded] = useState(false); const [isPreviewLoaded, setIsPreviewLoaded] = useState(false);
const [isPreviewBackgroundDark, setIsPreviewBackgroundDark] =
useState(false);
const previousMediaUrlRef = useRef<string | null>(null); const previousMediaUrlRef = useRef<string | null>(null);
useEffect(() => { useEffect(() => {
@@ -106,26 +103,8 @@ export const UploadMediaDialog = observer(
setMediaFile(initialFile); setMediaFile(initialFile);
setMediaFilename(initialFile.name); setMediaFilename(initialFile.name);
setAvailableMediaTypes([2]);
const extension = initialFile.name.split(".").pop()?.toLowerCase(); setMediaType(2);
if (extension) {
if (["glb", "gltf"].includes(extension)) {
setAvailableMediaTypes([6]);
setMediaType(6);
}
if (
["jpg", "jpeg", "png", "gif", "svg", "webp", "bmp"].includes(
extension
)
) {
setAvailableMediaTypes([1, 3, 4, 5]);
setMediaType(1);
} else if (["mp4", "webm", "mov"].includes(extension)) {
setAvailableMediaTypes([2]);
setMediaType(2);
}
}
const newBlobUrl = URL.createObjectURL(initialFile); const newBlobUrl = URL.createObjectURL(initialFile);
setMediaUrl(newBlobUrl); setMediaUrl(newBlobUrl);
previousMediaUrlRef.current = newBlobUrl; previousMediaUrlRef.current = newBlobUrl;
@@ -350,7 +329,6 @@ export const UploadMediaDialog = observer(
setMediaUrl(null); setMediaUrl(null);
setMediaFile(null); setMediaFile(null);
setIsPreviewLoaded(false); setIsPreviewLoaded(false);
setIsPreviewBackgroundDark(false);
previousMediaUrlRef.current = null; previousMediaUrlRef.current = null;
onClose(); onClose();
}; };
@@ -420,18 +398,8 @@ export const UploadMediaDialog = observer(
justifyContent: "center", justifyContent: "center",
height: "100%", height: "100%",
position: "relative", position: "relative",
backgroundColor: isPreviewBackgroundDark ? "#000" : "#fff",
transition: "background-color 150ms ease",
}} }}
> >
{mediaUrl && mediaType !== 2 && mediaType !== 6 && (
<PreviewBackgroundToggle
dark={isPreviewBackgroundDark}
onToggle={() =>
setIsPreviewBackgroundDark((value) => !value)
}
/>
)}
{!isPreviewLoaded && mediaUrl && ( {!isPreviewLoaded && mediaUrl && (
<Box <Box
sx={{ sx={{

View File

@@ -12,13 +12,13 @@ export type Carrier = {
id: number; id: number;
short_name: string; short_name: string;
full_name: string; full_name: string;
slogan: string;
city: string; city: string;
city_id: number; city_id: number;
preview_media?: string; logo: string;
video_preview?: string; // main_color: string;
main_color: string; // left_color: string;
left_color: string; // right_color: string;
right_color: string;
}; };
type CarrierData = { type CarrierData = {
@@ -111,48 +111,38 @@ class CarrierStore {
createCarrierData = { createCarrierData = {
city_id: 0, city_id: 0,
preview_media: "", logo: "",
video_preview: "",
main_color: "",
left_color: "",
right_color: "",
ru: { ru: {
full_name: "", full_name: "",
short_name: "", short_name: "",
slogan: "",
}, },
en: { en: {
full_name: "", full_name: "",
short_name: "", short_name: "",
slogan: "",
}, },
zh: { zh: {
full_name: "", full_name: "",
short_name: "", short_name: "",
slogan: "",
}, },
}; };
setCreateCarrierMedia = (
field: "preview_media" | "video_preview",
mediaId: string
) => {
this.createCarrierData[field] = mediaId;
};
setCreateCarrierData = ( setCreateCarrierData = (
fullName: string, fullName: string,
shortName: string, shortName: string,
cityId: number, cityId: number,
language: Language, slogan: string,
colors?: { main_color?: string; left_color?: string; right_color?: string } logoId: string,
language: Language
) => { ) => {
this.createCarrierData.city_id = cityId; this.createCarrierData.city_id = cityId;
if (colors) { this.createCarrierData.logo = logoId;
if (colors.main_color !== undefined) this.createCarrierData.main_color = colors.main_color;
if (colors.left_color !== undefined) this.createCarrierData.left_color = colors.left_color;
if (colors.right_color !== undefined) this.createCarrierData.right_color = colors.right_color;
}
this.createCarrierData[language] = { this.createCarrierData[language] = {
full_name: fullName, full_name: fullName,
short_name: shortName, short_name: shortName,
slogan: slogan,
}; };
}; };
@@ -207,15 +197,10 @@ class CarrierStore {
short_name: (this.createCarrierData[language].short_name || "").trim(), short_name: (this.createCarrierData[language].short_name || "").trim(),
city: cityName, city: cityName,
city_id: this.createCarrierData.city_id, city_id: this.createCarrierData.city_id,
...(this.createCarrierData.preview_media slogan: (this.createCarrierData[language].slogan || "").trim(),
? { preview_media: this.createCarrierData.preview_media } ...(this.createCarrierData.logo
? { logo: this.createCarrierData.logo }
: {}), : {}),
...(this.createCarrierData.video_preview
? { video_preview: this.createCarrierData.video_preview }
: {}),
...(this.createCarrierData.main_color ? { main_color: this.createCarrierData.main_color } : {}),
...(this.createCarrierData.left_color ? { left_color: this.createCarrierData.left_color } : {}),
...(this.createCarrierData.right_color ? { right_color: this.createCarrierData.right_color } : {}),
}; };
const response = await languageInstance(language).post("/carrier", payload); const response = await languageInstance(language).post("/carrier", payload);
@@ -226,55 +211,52 @@ class CarrierStore {
this.carriers[language].data.push(response.data); this.carriers[language].data.push(response.data);
}); });
for (const lang of (["ru", "en", "zh"] as const).filter( for (const lang of ["ru", "en", "zh"].filter((l) => l !== language)) {
(item) => item !== language
)) {
const cityNameForLang = this.resolveCityName( const cityNameForLang = this.resolveCityName(
this.createCarrierData.city_id, this.createCarrierData.city_id,
lang lang as Language
); );
const localizedData = this.createCarrierData[lang];
const patchPayload = { const patchPayload = {
full_name: (localizedData.full_name || "").trim(), // @ts-ignore
short_name: (localizedData.short_name || "").trim(), full_name: ((this.createCarrierData[lang as any].full_name as string) || "").trim(),
// @ts-ignore
short_name: ((this.createCarrierData[lang as any].short_name as string) || "").trim(),
city: cityNameForLang || cityName, city: cityNameForLang || cityName,
city_id: this.createCarrierData.city_id, city_id: this.createCarrierData.city_id,
...(this.createCarrierData.preview_media // @ts-ignore
? { preview_media: this.createCarrierData.preview_media } slogan: ((this.createCarrierData[lang as any].slogan as string) || "").trim(),
: {}), ...(this.createCarrierData.logo
...(this.createCarrierData.video_preview ? { logo: this.createCarrierData.logo }
? { video_preview: this.createCarrierData.video_preview }
: {}), : {}),
}; };
const response = await languageInstance(lang).patch( const response = await languageInstance(lang as Language).patch(
`/carrier/${carrierId}`, `/carrier/${carrierId}`,
patchPayload patchPayload
); );
runInAction(() => { runInAction(() => {
this.carriers[lang].data.push(response.data); this.carriers[lang as keyof Carriers].data.push(response.data);
}); });
} }
this.createCarrierData = { this.createCarrierData = {
city_id: 0, city_id: 0,
preview_media: "", logo: "",
video_preview: "",
main_color: "",
left_color: "",
right_color: "",
ru: { ru: {
full_name: "", full_name: "",
short_name: "", short_name: "",
slogan: "",
}, },
en: { en: {
full_name: "", full_name: "",
short_name: "", short_name: "",
slogan: "",
}, },
zh: { zh: {
full_name: "", full_name: "",
short_name: "", short_name: "",
slogan: "",
}, },
}; };
}; };
@@ -283,46 +265,54 @@ class CarrierStore {
ru: { ru: {
full_name: "", full_name: "",
short_name: "", short_name: "",
// main_color: "",
// left_color: "",
// right_color: "",
slogan: "",
}, },
en: { en: {
full_name: "", full_name: "",
short_name: "", short_name: "",
// main_color: "",
// left_color: "",
// right_color: "",
slogan: "",
}, },
city_id: 0,
logo: "",
zh: { zh: {
full_name: "", full_name: "",
short_name: "", short_name: "",
},
city_id: 0,
preview_media: "",
video_preview: "",
main_color: "",
left_color: "",
right_color: "",
};
setEditCarrierMedia = ( // main_color: "",
field: "preview_media" | "video_preview", // left_color: "",
mediaId: string // right_color: "",
) => { slogan: "",
this.editCarrierData[field] = mediaId; },
}; };
setEditCarrierData = ( setEditCarrierData = (
fullName: string, fullName: string,
shortName: string, shortName: string,
cityId: number, cityId: number,
language: Language, // main_color: string,
colors?: { main_color?: string; left_color?: string; right_color?: string } // left_color: string,
// right_color: string,
slogan: string,
logoId: string,
language: Language
) => { ) => {
this.editCarrierData.city_id = cityId; this.editCarrierData.city_id = cityId;
if (colors) { this.editCarrierData.logo = logoId;
if (colors.main_color !== undefined) this.editCarrierData.main_color = colors.main_color;
if (colors.left_color !== undefined) this.editCarrierData.left_color = colors.left_color;
if (colors.right_color !== undefined) this.editCarrierData.right_color = colors.right_color;
}
this.editCarrierData[language] = { this.editCarrierData[language] = {
full_name: fullName, full_name: fullName,
short_name: shortName, short_name: shortName,
// main_color: main_color,
// left_color: left_color,
// right_color: right_color,
slogan: slogan,
}; };
}; };
@@ -333,13 +323,12 @@ class CarrierStore {
...this.editCarrierData[lang], ...this.editCarrierData[lang],
full_name: (this.editCarrierData[lang].full_name || "").trim(), full_name: (this.editCarrierData[lang].full_name || "").trim(),
short_name: (this.editCarrierData[lang].short_name || "").trim(), short_name: (this.editCarrierData[lang].short_name || "").trim(),
slogan: (this.editCarrierData[lang].slogan || "").trim(),
city: cityName, city: cityName,
city_id: this.editCarrierData.city_id, city_id: this.editCarrierData.city_id,
preview_media: this.editCarrierData.preview_media || null, ...(this.editCarrierData.logo
video_preview: this.editCarrierData.video_preview || null, ? { logo: this.editCarrierData.logo }
...(this.editCarrierData.main_color ? { main_color: this.editCarrierData.main_color } : {}), : {}),
...(this.editCarrierData.left_color ? { left_color: this.editCarrierData.left_color } : {}),
...(this.editCarrierData.right_color ? { right_color: this.editCarrierData.right_color } : {}),
}); });
runInAction(() => { runInAction(() => {

View File

@@ -13,7 +13,6 @@ export type Route = {
center_latitude: number; center_latitude: number;
center_longitude: number; center_longitude: number;
governor_appeal: number; governor_appeal: number;
button_text?: string;
id: number; id: number;
icon: string; icon: string;
path: number[][]; path: number[][];
@@ -144,7 +143,6 @@ class RouteStore {
center_latitude: "", center_latitude: "",
center_longitude: "", center_longitude: "",
governor_appeal: 0, governor_appeal: 0,
button_text: "" as string | undefined,
id: 0, id: 0,
icon: "", icon: "",
path: [] as number[][], path: [] as number[][],
@@ -155,7 +153,7 @@ class RouteStore {
scale_max: 0, scale_max: 0,
scale_min: 0, scale_min: 0,
video_preview: "" as string | undefined, video_preview: "" as string | undefined,
video_timer: 420, video_timer: 60,
}; };
setEditRouteData = (data: any) => { setEditRouteData = (data: any) => {

View File

@@ -3,8 +3,6 @@ import { City } from "../CityStore";
class SelectedCityStore { class SelectedCityStore {
selectedCity: City | null = null; selectedCity: City | null = null;
isLocked: boolean = false;
cityVersion: number = 0;
constructor() { constructor() {
makeAutoObservable(this); makeAutoObservable(this);
@@ -26,7 +24,6 @@ class SelectedCityStore {
setSelectedCity = (city: City | null) => { setSelectedCity = (city: City | null) => {
runInAction(() => { runInAction(() => {
this.selectedCity = city; this.selectedCity = city;
this.cityVersion += 1;
if (city) { if (city) {
localStorage.setItem("selectedCity", JSON.stringify(city)); localStorage.setItem("selectedCity", JSON.stringify(city));
} else { } else {
@@ -35,12 +32,6 @@ class SelectedCityStore {
}); });
}; };
setIsLocked = (locked: boolean) => {
runInAction(() => {
this.isLocked = locked;
});
};
clearSelectedCity = () => { clearSelectedCity = () => {
this.setSelectedCity(null); this.setSelectedCity(null);
}; };

View File

@@ -60,6 +60,12 @@ class SnapshotStore {
articlesStore.articleData = null; articlesStore.articleData = null;
articlesStore.articleMedia = null; articlesStore.articleMedia = null;
countryStore.countries = {
ru: { data: [], loaded: false },
en: { data: [], loaded: false },
zh: { data: [], loaded: false },
};
carrierStore.carriers = { carrierStore.carriers = {
ru: { data: [], loaded: false }, ru: { data: [], loaded: false },
en: { data: [], loaded: false }, en: { data: [], loaded: false },

View File

@@ -120,7 +120,6 @@ class VehicleStore {
carrier: string, carrier: string,
carrierId: number, carrierId: number,
model?: string, model?: string,
cityId?: number,
) => { ) => {
const payload: Record<string, unknown> = { const payload: Record<string, unknown> = {
tail_number: tailNumber.trim(), tail_number: tailNumber.trim(),
@@ -130,7 +129,6 @@ class VehicleStore {
}; };
// TODO: когда будет бекенд — добавить model в payload и в ответ // TODO: когда будет бекенд — добавить model в payload и в ответ
if (model != null && model !== "") payload.model = model; if (model != null && model !== "") payload.model = model;
if (cityId != null) payload.city_id = cityId;
const response = await languageInstance("ru").post("/vehicle", payload); const response = await languageInstance("ru").post("/vehicle", payload);
const normalizedVehicle = this.normalizeVehicleItem(response.data); const normalizedVehicle = this.normalizeVehicleItem(response.data);
@@ -149,7 +147,6 @@ class VehicleStore {
carrier_id: number; carrier_id: number;
model: string; model: string;
snapshot_update_blocked: boolean; snapshot_update_blocked: boolean;
city_id?: number;
} = { } = {
tail_number: "", tail_number: "",
type: 0, type: 0,
@@ -157,7 +154,6 @@ class VehicleStore {
carrier_id: 0, carrier_id: 0,
model: "", model: "",
snapshot_update_blocked: false, snapshot_update_blocked: false,
city_id: undefined,
}; };
setEditVehicleData = (data: { setEditVehicleData = (data: {
@@ -167,7 +163,6 @@ class VehicleStore {
carrier_id: number; carrier_id: number;
model?: string; model?: string;
snapshot_update_blocked?: boolean; snapshot_update_blocked?: boolean;
city_id?: number;
}) => { }) => {
this.editVehicleData = { this.editVehicleData = {
...this.editVehicleData, ...this.editVehicleData,
@@ -184,7 +179,6 @@ class VehicleStore {
carrier_id: number; carrier_id: number;
model?: string; model?: string;
snapshot_update_blocked?: boolean; snapshot_update_blocked?: boolean;
city_id?: number;
}, },
) => { ) => {
const payload: Record<string, unknown> = { const payload: Record<string, unknown> = {
@@ -196,7 +190,6 @@ class VehicleStore {
if (data.model != null && data.model !== "") payload.model = data.model; if (data.model != null && data.model !== "") payload.model = data.model;
if (data.snapshot_update_blocked != null) if (data.snapshot_update_blocked != null)
payload.snapshot_update_blocked = data.snapshot_update_blocked; payload.snapshot_update_blocked = data.snapshot_update_blocked;
if (data.city_id != null) payload.city_id = data.city_id;
const response = await languageInstance("ru").patch( const response = await languageInstance("ru").patch(
`/vehicle/${id}`, `/vehicle/${id}`,
payload, payload,

View File

@@ -1,40 +0,0 @@
import { IconButton, Tooltip } from "@mui/material";
import { Contrast } from "lucide-react";
interface PreviewBackgroundToggleProps {
dark: boolean;
onToggle: () => void;
}
export const PreviewBackgroundToggle = ({
dark,
onToggle,
}: PreviewBackgroundToggleProps) => (
<Tooltip title={dark ? "Сделать фон белым" : "Сделать фон чёрным"}>
<IconButton
size="small"
aria-label={dark ? "Сделать фон белым" : "Сделать фон чёрным"}
onClick={(event) => {
event.stopPropagation();
onToggle();
}}
sx={{
position: "absolute",
top: 8,
left: 8,
zIndex: 2,
color: dark ? "#fff" : "#000",
backgroundColor: dark
? "rgba(255, 255, 255, 0.16)"
: "rgba(255, 255, 255, 0.8)",
"&:hover": {
backgroundColor: dark
? "rgba(255, 255, 255, 0.28)"
: "rgba(255, 255, 255, 1)",
},
}}
>
<Contrast size={18} />
</IconButton>
</Tooltip>
);

View File

@@ -6,4 +6,3 @@ export * from "./AnimatedCircleButton";
export * from "./LoadingSpinner"; export * from "./LoadingSpinner";
export * from "./MultiSelect"; export * from "./MultiSelect";
export * from "./SearchInput"; export * from "./SearchInput";
export * from "./PreviewBackgroundToggle";

View File

@@ -6,23 +6,13 @@ import {
SelectChangeEvent, SelectChangeEvent,
Typography, Typography,
Box, Box,
keyframes,
} from "@mui/material"; } from "@mui/material";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { authStore, cityStore, selectedCityStore, snapshotStore, type City } from "@shared"; import { authStore, cityStore, selectedCityStore, type City } from "@shared";
import { MapPin } from "lucide-react"; import { MapPin } from "lucide-react";
const borderSpin = keyframes`
0% {
background-position: 0% 50%;
}
100% {
background-position: 200% 50%;
}
`;
export const CitySelector: React.FC = observer(() => { export const CitySelector: React.FC = observer(() => {
const { selectedCity, setSelectedCity, isLocked } = selectedCityStore; const { selectedCity, setSelectedCity } = selectedCityStore;
const canLoadAllCities = authStore.isAdmin && authStore.canRead("cities"); const canLoadAllCities = authStore.isAdmin && authStore.canRead("cities");
useEffect(() => { useEffect(() => {
@@ -53,93 +43,53 @@ export const CitySelector: React.FC = observer(() => {
})() })()
: baseCities; : baseCities;
const noCitySelected = !selectedCity?.id;
const handleCityChange = (event: SelectChangeEvent<string>) => { const handleCityChange = (event: SelectChangeEvent<string>) => {
const cityId = event.target.value; const cityId = event.target.value;
if (cityId === "") { if (cityId === "") {
snapshotStore.clearStoreCache();
setSelectedCity(null); setSelectedCity(null);
return; return;
} }
const city = currentCities.find((c) => c.id === Number(cityId)); const city = currentCities.find((c) => c.id === Number(cityId));
if (city) { if (city) {
snapshotStore.clearStoreCache();
setSelectedCity(city); setSelectedCity(city);
} }
}; };
const selectElement = (
<Select
value={selectedCity?.id?.toString() || ""}
onChange={handleCityChange}
displayEmpty
disabled={isLocked}
sx={{
height: "40px",
color: "white",
borderRadius: "4px",
...(noCitySelected && !isLocked
? {
backgroundColor: "#48989f",
"& .MuiOutlinedInput-notchedOutline": { border: "none" },
}
: {
"& .MuiOutlinedInput-notchedOutline": {
borderColor: isLocked
? "rgba(255, 255, 255, 0.1)"
: "rgba(255, 255, 255, 0.3)",
},
}),
"&.Mui-disabled": {
color: "rgba(255, 255, 255, 0.5)",
WebkitTextFillColor: "rgba(255, 255, 255, 0.5)",
},
"&:hover .MuiOutlinedInput-notchedOutline": {
borderColor: isLocked
? "rgba(255, 255, 255, 0.1)"
: "rgba(255, 255, 255, 0.5)",
},
"&.Mui-focused .MuiOutlinedInput-notchedOutline": {
borderColor: "white",
},
"& .MuiSvgIcon-root": {
color: isLocked ? "rgba(255, 255, 255, 0.3)" : "white",
},
}}
>
<MenuItem value="">
<Typography variant="body2">Выберите город</Typography>
</MenuItem>
{currentCities.map((city) => (
<MenuItem key={city.id} value={city.id?.toString()}>
<Typography variant="body2">{city.name}</Typography>
</MenuItem>
))}
</Select>
);
return ( return (
<Box className="flex items-center gap-2"> <Box className="flex items-center gap-2">
<MapPin size={16} className={isLocked ? "text-gray-400" : "text-white"} /> <MapPin size={16} className="text-white" />
<FormControl size="medium" sx={{ minWidth: 120 }}> <FormControl size="medium" sx={{ minWidth: 120 }}>
{noCitySelected && !isLocked ? ( <Select
<Box value={selectedCity?.id?.toString() || ""}
sx={{ onChange={handleCityChange}
position: "relative", displayEmpty
borderRadius: "4px", sx={{
padding: "2px", height: "40px",
background: "linear-gradient(90deg, rgba(255,255,255,0.1), rgba(255,255,255,0.7), rgba(255,255,255,0.1), rgba(255,255,255,0.7), rgba(255,255,255,0.1))", color: "white",
backgroundSize: "200% 100%", "& .MuiOutlinedInput-notchedOutline": {
animation: `${borderSpin} 2.5s linear infinite`, borderColor: "rgba(255, 255, 255, 0.3)",
}} },
> "&:hover .MuiOutlinedInput-notchedOutline": {
{selectElement} borderColor: "rgba(255, 255, 255, 0.5)",
</Box> },
) : ( "&.Mui.focused .MuiOutlinedInput-notchedOutline": {
selectElement borderColor: "white",
)} },
"& .MuiSvgIcon-root": {
color: "white",
},
}}
>
<MenuItem value="">
<Typography variant="body2">Выберите город</Typography>
</MenuItem>
{currentCities.map((city) => (
<MenuItem key={city.id} value={city.id?.toString()}>
<Typography variant="body2">{city.name}</Typography>
</MenuItem>
))}
</Select>
</FormControl> </FormControl>
</Box> </Box>
); );

Some files were not shown because too many files have changed in this diff Show More