69 Commits

Author SHA1 Message Date
1bb3f43979 feat: add scrollable menu with fade hints and home button transition 2026-05-20 12:48:00 +03:00
7e539f550b feat: hide search input when list is empty and add animated city selector highlight
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-19 14:14:04 +03:00
fbf6b0dc9d feat: add annotation for action buttons and double click for user list page 2026-05-19 13:47:36 +03:00
a997cdb198 feat: delete title atribute from the client page 2026-05-19 13:16:17 +03:00
bf45dcdbfc feat: update appeal widget and check double routes for create snapshot 2026-05-19 13:16:02 +03:00
83ccdef790 feat: update media in edit article and remove big hit boxes from clusters 2026-05-13 11:09:12 +03:00
51d1870198 feat: appeal widget and right widget update 2026-05-12 00:04:02 +03:00
193f53c029 feat: update map, admin to and cache 2026-05-08 13:33:41 +03:00
4bda233b63 feat: update route widget and leftsidebar in route-preview 2026-05-08 01:12:28 +03:00
d758dbffa6 feat: big update 07.05.26 2026-05-07 13:08:33 +03:00
6af95bb449 feat: update color carrier 2026-05-05 15:07:18 +03:00
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
85 changed files with 4210 additions and 2134 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.7",
"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,19 +1,13 @@
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(() => {
document.documentElement.lang = languageStore.language;
}, [languageStore.language]);
return (
<GlobalErrorBoundary> <GlobalErrorBoundary>
<ThemeProvider theme={CustomTheme.Light}> <ThemeProvider theme={CustomTheme.Light}>
<TestingModeBanner /> <TestingModeBanner />
@@ -21,5 +15,4 @@ export const App: React.FC = observer(() => {
<Router /> <Router />
</ThemeProvider> </ThemeProvider>
</GlobalErrorBoundary> </GlobalErrorBoundary>
); );
});

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

@@ -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,8 +76,6 @@ 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; main_color?: string;

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,7 +411,7 @@ 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}
@@ -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,12 +476,6 @@ const ListOfSights = observer(() => {
isDisabled={isAlphabetDisabled} isDisabled={isAlphabetDisabled}
/> />
</div> </div>
</div>
<TransferWidget
isOpen={isTransferWidgetOpen}
selectedLanguageRight={selectedLanguage}
/>
<div <div
className="transfer-button-container" className="transfer-button-container"
@@ -515,6 +513,7 @@ const ListOfSights = observer(() => {
</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

@@ -41,35 +41,16 @@ 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 idleTimerRef = 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 menuRef = useRef(null);
const [menuNeedsScroll, setMenuNeedsScroll] = useState(false); const [menuNeedsScroll, setMenuNeedsScroll] = useState(false);
const [canScrollLeft, setCanScrollLeft] = useState(false); const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = 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 updateScrollState = useCallback(() => {
const menu = menuRef.current; const menu = menuRef.current;
if (!menu) return; if (!menu) return;
@@ -102,7 +83,55 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
const totalChildrenWidth = children.reduce((sum, el) => sum + el.offsetWidth, 0); const totalChildrenWidth = children.reduce((sum, el) => sum + el.offsetWidth, 0);
const evenGap = (availableWidth - totalChildrenWidth) / (children.length + 1); const evenGap = (availableWidth - totalChildrenWidth) / (children.length + 1);
setMenuNeedsScroll(evenGap < 10); setMenuNeedsScroll(evenGap < 10);
}, [articleSections]); }, [articleSections, selectedSection]);
// Автозакрытие fullscreen 3D при бездействии (60 сек)
useEffect(() => {
if (!isFullscreen3D) {
if (idleTimerRef.current) {
clearInterval(idleTimerRef.current);
idleTimerRef.current = null;
}
return;
}
let idleSeconds = 0;
const checkIdle = () => {
idleSeconds += 1;
if (idleSeconds >= 60) {
setIsFullscreen3D(false);
}
};
idleTimerRef.current = 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 () => {
if (idleTimerRef.current) {
clearInterval(idleTimerRef.current);
idleTimerRef.current = null;
}
events.forEach((event) => {
window.removeEventListener(event, resetIdle);
});
};
}, [isFullscreen3D]);
const { const {
routeSights, routeSights,
@@ -142,16 +171,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;
prevSightIdRef.current = sight_id;
if (!isSameSight) {
setIsLoadingContent(true); setIsLoadingContent(true);
setContentError(null); setContentError(null);
setModelAspectRatio(null); setModelAspectRatio(null);
setIsVisible(false); setIsVisible(false);
setMediaData({}); setMediaData({});
}
if (!sight_id) { if (!sight_id) {
setContentError("Не указан ID статьи."); setContentError("Не указан ID статьи.");
@@ -229,18 +253,15 @@ 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 +302,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) {
@@ -613,6 +600,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,7 +645,6 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
}, [sight_name]); }, [sight_name]);
return ( return (
<>
<div <div
className={`sight-frame ${isVisible ? "is-visible" : ""} ${ className={`sight-frame ${isVisible ? "is-visible" : ""} ${
isFullscreen3D && isCurrentMedia3D ? "three-d-fullscreen" : "" isFullscreen3D && isCurrentMedia3D ? "three-d-fullscreen" : ""
@@ -667,7 +654,6 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
<Watermark path={ContentAPI.getMediaPath(sightData.watermark_lu)} /> <Watermark path={ContentAPI.getMediaPath(sightData.watermark_lu)} />
)} )}
<div <div
ref={mediaStackRef}
className={`sight-frame-media-stack ${ className={`sight-frame-media-stack ${
isCurrentMedia3D ? "three-d-view" : "" isCurrentMedia3D ? "three-d-view" : ""
}`} }`}
@@ -685,22 +671,21 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
renderCurrentMedia() renderCurrentMedia()
)} )}
</div> </div>
<div className={`sight-frame-content ${isSwitching ? "is-switching" : ""}`}> <div className="sight-frame-content">
{contentError ? ( {contentError ? (
<p className="error-message">{contentError}</p> <p className="error-message">{contentError}</p>
) : !currentSection ? (
<p>Информация отсутствует.</p>
) : ( ) : (
<> <>
{!isFullscreen3D && ( {!isFullscreen3D && (
<div <div
ref={titleRef}
className={`sight-frame-title ${ className={`sight-frame-title ${
selectedSection === 0 ? "intro-title" : "" selectedSection === 0 ? "intro-title" : ""
}`} }`}
style={{ lineHeight: titleLineHeight }} style={{ lineHeight: titleLineHeight }}
> >
<p <p
key={selectedSection}
className="fade-in-text"
style={{ style={{
whiteSpace: "normal", whiteSpace: "normal",
wordBreak: "break-word", wordBreak: "break-word",
@@ -708,45 +693,62 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
}} }}
> >
{selectedSection === 0 {selectedSection === 0
? processedSightName || sightData?.name ? processedSightName
: sightData?.short_name || sightData?.name} : sightData?.short_name || sight_name}
</p> </p>
</div> </div>
)} )}
{selectedSection !== 0 && (
<TouchableLayout <TouchableLayout
key={selectedSection} className="sight-frame-text-wrapper"
className={`sight-frame-text-wrapper ${selectedSection === 0 ? "is-intro" : ""}`}
ref={textWrapperRef} ref={textWrapperRef}
maxHeight="calc(80vh - 354px)"
> >
<div className="sight-frame-text fade-in-text"> <div className="sight-frame-text">
{isLoadingContent || !currentSection ? (
<div className="sight-frame-loading-placeholder" />
) : (
selectedSection !== 0 && (
<ReactMarkdownComponent value={currentSection.body} /> <ReactMarkdownComponent value={currentSection.body} />
)
)}
</div> </div>
</TouchableLayout> </TouchableLayout>
)}
</> </>
)} )}
</div> </div>
</div> <div className="sight-frame-menu-wrapper">
<div
className="sight-frame-menu-wrapper"
style={{
opacity: isVisible ? 1 : 0,
transition: "opacity 0.9s cubic-bezier(0.16, 1, 0.3, 1) 0.08s",
pointerEvents: isVisible ? "auto" : "none",
}}
>
<div className="sight-frame-menu-fade left" style={{ opacity: canScrollLeft ? 1 : 0 }} /> <div className="sight-frame-menu-fade left" style={{ opacity: canScrollLeft ? 1 : 0 }} />
<div className="sight-frame-menu-fade right" style={{ opacity: canScrollRight ? 1 : 0 }} /> <div className="sight-frame-menu-fade right" style={{ opacity: canScrollRight ? 1 : 0 }} />
<div <div
className="sight-frame-menu" className="sight-frame-menu"
ref={menuRef} ref={menuRef}
style={menuNeedsScroll ? { justifyContent: 'flex-start' } : undefined} style={menuNeedsScroll ? { justifyContent: 'space-between' } : undefined}
> >
<div
style={{
position: "absolute",
left: "10px",
marginTop: "-4.5px",
zIndex: 1,
paddingLeft: "15px",
paddingRight: "7.5px",
paddingTop: "4.5px",
paddingBottom: "4.5px",
cursor: "pointer",
opacity: selectedSection !== 0 ? 1 : 0,
transform: selectedSection !== 0 ? "scale(1)" : "scale(0.5)",
transition: "opacity 0.3s ease, transform 0.3s ease",
pointerEvents: selectedSection !== 0 ? "auto" : "none",
}}
onPointerUp={() => {
setSelectedSection(0);
setIsFullscreen3D(false);
}}
>
<img
src={subtractHomeIcon}
alt=""
width="24"
height="21"
style={{ display: "block" }}
/>
</div>
{contentError ? ( {contentError ? (
<p className="error-message">{contentError}</p> <p className="error-message">{contentError}</p>
) : ( ) : (
@@ -754,9 +756,11 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
articleSections.length > 1 && articleSections.length > 1 &&
articleSections.slice(1).map((section, index) => ( articleSections.slice(1).map((section, index) => (
<div <div
onPointerUp={() => requestSection(index + 1)} onPointerUp={() => {
setSelectedSection(index + 1);
setIsFullscreen3D(false);
}}
key={section.id || section.heading || index} key={section.id || section.heading || index}
data-label={section.heading}
className={`sight-frame-menu-point ${ className={`sight-frame-menu-point ${
index + 1 === selectedSection ? "active" : "" index + 1 === selectedSection ? "active" : ""
}`} }`}
@@ -769,33 +773,7 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
)} )}
</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> </div>
</>
); );
}); });

View File

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

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

@@ -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"
style={{ opacity: thumb.visible ? 1 : 0 }}
>
<div <div
ref={thumbRef} ref={thumbRef}
className="custom-scrollbar-thumb" className="custom-scrollbar-thumb"
style={{ style={{ height: thumb.height, top: thumb.top }}
height: thumb.height,
top: thumb.top,
opacity: thumb.visible ? 1 : 0,
pointerEvents: thumb.visible ? "auto" : "none",
}}
/> />
</div> </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

@@ -155,19 +155,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 +164,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 +175,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,15 +381,13 @@ 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 (
typeof routeData?.icon_size === "number" && typeof routeData?.icon_size === "number" &&

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(() => {
@@ -159,7 +212,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 +238,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 ? (

View File

@@ -4,7 +4,6 @@ import { useEffect, useState, useCallback, useRef } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
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";
@@ -13,10 +12,12 @@ import StationsList from "./StationsList";
import LeftWidget from "./LeftWidget"; import LeftWidget from "./LeftWidget";
import { apiStore } from "../../api/ApiStore/store"; import { apiStore } from "../../api/ApiStore/store";
import { getMediaUrl } from "../../api/apiConfig"; import { getMediaUrl } from "../../api/apiConfig";
import defaultCrest from "../../assets/images/Герб.png";
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,17 +263,18 @@ const SideMenu = observer(({ onMenuToggle }) => {
} }
}; };
const handleMenuToggleRef = useRef(handleMenuToggle);
handleMenuToggleRef.current = handleMenuToggle;
const isMenuOpenRef = useRef(isMenuOpen); const isMenuOpenRef = useRef(isMenuOpen);
isMenuOpenRef.current = isMenuOpen; const handleMenuToggleRef = useRef(handleMenuToggle);
useEffect(() => { isMenuOpenRef.current = isMenuOpen; }, [isMenuOpen]);
useEffect(() => { handleMenuToggleRef.current = handleMenuToggle; });
useEffect(() => { useEffect(() => {
// Автоматическое закрытие сайд-меню после 60 секунд бездействия
let idleSeconds = 0; let idleSeconds = 0;
const checkIdle = () => { const checkIdle = () => {
idleSeconds += 1; idleSeconds += 1;
if (idleSeconds >= 60 && isMenuOpenRef.current) { if (idleSeconds >= 60 && isMenuOpenRef.current) {
handleMenuToggleRef.current(false); handleMenuToggleRef.current(false);
} }
@@ -291,47 +307,6 @@ const SideMenu = observer(({ onMenuToggle }) => {
}; };
}, []); }, []);
// Автоматическое скрытие левого виджета через 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);
const isFirstRenderRef = useRef(true); const isFirstRenderRef = useRef(true);
@@ -399,6 +374,14 @@ 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",
}} }}
> >
<img
className="side-menu-crest"
src={designData?.creastPath || defaultCrest}
alt="Герб"
/>
{carrier?.slogan && (
<div className="side-menu-label">{carrier.slogan}</div>
)}
{shouldRenderSights && ( {shouldRenderSights && (
<SightsList <SightsList
ref={sightsListRef} ref={sightsListRef}
@@ -464,9 +447,7 @@ const SideMenu = observer(({ onMenuToggle }) => {
}} }}
className="appeal-button" className="appeal-button"
> >
{route?.button_text {selectedLanguage == "ru"
? route.button_text
: selectedLanguage == "ru"
? "Обращение губернатора" ? "Обращение губернатора"
: selectedLanguage == "zh" : selectedLanguage == "zh"
? "州长致辞" ? "州长致辞"
@@ -515,7 +496,7 @@ const SideMenu = observer(({ onMenuToggle }) => {
}, 300); }, 300);
} }
}} }}
className={`side-menu-button side-menu-button--sights ${ className={`side-menu-button ${
isSightsOpen ? "side-menu-button--active" : "" isSightsOpen ? "side-menu-button--active" : ""
}`} }`}
> >
@@ -524,7 +505,7 @@ const SideMenu = observer(({ onMenuToggle }) => {
: selectedLanguage == "zh" : selectedLanguage == "zh"
? "景点" ? "景点"
: "Attractions"} : "Attractions"}
</div> </div>
<div <div
onPointerUp={() => { onPointerUp={() => {
if (!isStationOpen) { if (!isStationOpen) {
@@ -572,7 +553,7 @@ const SideMenu = observer(({ onMenuToggle }) => {
: selectedLanguage == "zh" : selectedLanguage == "zh"
? "车站" ? "车站"
: "Stations"} : "Stations"}
</div> </div>
</div> </div>
<div className="side-menu-tag"> <div className="side-menu-tag">
{/* {selectedLanguage == "ru" {/* {selectedLanguage == "ru"
@@ -582,41 +563,29 @@ const SideMenu = observer(({ onMenuToggle }) => {
: "#EveryoneOnTheWay"} */} : "#EveryoneOnTheWay"} */}
</div> </div>
<div className="side-menu-bottom-section"> <div className="side-menu-bottom-section">
<div className="side-menu-carrier-block">
{designData?.carrierPath && (
<img <img
className="side-menu-bottom-photo" className="carrier-logo"
src={ src={designData?.carrierPath}
carrier?.preview_media && !isPreviewMediaError alt="ГЭТ - Электротранспорт Санкт-Петербурга"
? getMediaUrl(carrier.preview_media)
: sideMenuPhoto
}
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={(() => {
const m = sightArticles.get(route?.governor_appeal + "_ru")?.media; const m = sightArticles.get(route?.governor_appeal + "_ru")?.media;
const mediaId = Array.isArray(m) ? m[0]?.id : m?.id; const mediaId = Array.isArray(m) ? m[0]?.id : m?.id;
return mediaId ? ContentAPI.getMediaPath(mediaId) : undefined; return mediaId ? getMediaUrl(mediaId) : undefined;
})()} })()}
isOpen={isWidgetOpen} isOpen={isWidgetOpen}
style={{ style={{

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,27 @@ 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" : ""
}`}
> >
<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 +156,7 @@ const SightItem = ({
: "No stations"} : "No stations"}
</div> </div>
)} )}
</Collapsible> </div>
</div> </div>
); );
}; };
@@ -237,8 +232,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,18 +348,25 @@ const SightsList = observer(
}, [localSelectedSightId, sightList, className]); }, [localSelectedSightId, sightList, className]);
const handleSightClick = (sightId) => { const handleSightClick = (sightId) => {
// Если кликнули на уже выбранную достопримечательность — закрываем виджет и сбрасываем цвет
if (isLeftWidgetOpen && localSelectedSightId === sightId) {
setLocalSelectedSightId(null);
setIsLeftWidgetOpen(false);
return;
}
// Открываем левый виджет // Открываем левый виджет
if (isLeftWidgetOpen && localSelectedSightId !== sightId) {
setLocalSelectedSightId(sightId); setLocalSelectedSightId(sightId);
setIsLeftWidgetOpen(true); setIsLeftWidgetOpen(true);
if (onSightSelected) { if (onSightSelected) {
onSightSelected(sightId); onSightSelected(sightId);
} }
} else if (!isLeftWidgetOpen) {
setLocalSelectedSightId(sightId);
setIsLeftWidgetOpen(true);
if (onSightSelected) {
onSightSelected(sightId);
}
} else {
// Если виджет уже открыт для этой достопримечательности, просто обновляем список остановок
if (onSightSelected) {
onSightSelected(sightId);
}
}
}; };
const handleClose = () => { const handleClose = () => {

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();
@@ -119,9 +119,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 +132,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 +310,3 @@ const StationSightsList = observer(
); );
export default StationSightsList; export default StationSightsList;
nSightsList;

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";
const SightTransferItem = ({ name, style, onPointerUp }) => { const SightTransferItem = ({ name, style, onPointerUp }) => {
const containerRef = useRef(null); const containerRef = useRef(null);
@@ -93,15 +92,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"
@@ -119,9 +129,10 @@ const StationItem = ({
{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) => (
@@ -154,7 +165,7 @@ const StationItem = ({
: "No sights"} : "No sights"}
</div> </div>
)} )}
</Collapsible> </div>
</div> </div>
); );
}; };

View File

@@ -1,7 +1,6 @@
import { useRef, useEffect } from "react"; import { useRef, useEffect } from "react";
import "../../styles/AppealWidget.css"; import "../../styles/AppealWidget.css";
import { TouchableLayout } from "../TouchableLayout"; import { TouchableLayout } from "../TouchableLayout";
import { ReactMarkdownComponent } from "../ReactMarkdown";
function AppealWidget({ function AppealWidget({
widgetImgPath, widgetImgPath,
@@ -39,9 +38,7 @@ function AppealWidget({
ref={layoutRef} ref={layoutRef}
className="dynamic-widget-text-scroll" className="dynamic-widget-text-scroll"
> >
<div className="dynamic-widget-text"> <div className="dynamic-widget-text">{widgetText}</div>
<ReactMarkdownComponent value={widgetText} />
</div>
</TouchableLayout> </TouchableLayout>
</div> </div>
); );

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,22 +84,22 @@ 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)}`}
> >
{startStation?.name} {startStation?.name}
</div> </div>
<div <div
className={`route-widget-label ${ className={`route-widget-label ${
shouldAnimate(endStation?.name, 18) ? "marquee" : "" shouldAnimate(endStation?.name, 18) ? "marquee" : ""
} ${getLabelSizeClass(endStation?.name)}`} } ${getLabelSizeClass(endStation?.name)}`}
> >
{endStation?.name} {endStation?.name}
</div> </div>
{(selectedLanguage === "en" || selectedLanguage === "ru") && ( {(selectedLanguage === "en" || selectedLanguage === "ru") && (
@@ -107,7 +107,7 @@ const RouteWidget = observer(() => {
className={`route-widget-subtitle ${ className={`route-widget-subtitle ${
shouldAnimate(routeEnSubtitle, 50) ? "marquee" : "" shouldAnimate(routeEnSubtitle, 50) ? "marquee" : ""
}`} }`}
> >
{routeEnSubtitle} {routeEnSubtitle}
</div> </div>
)} )}
@@ -116,7 +116,7 @@ const RouteWidget = observer(() => {
className={`route-widget-subtitle ${ className={`route-widget-subtitle ${
shouldAnimate(routeZhSubtitle, 50) ? "marquee" : "" shouldAnimate(routeZhSubtitle, 50) ? "marquee" : ""
}`} }`}
> >
{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

@@ -66,63 +66,8 @@
.dynamic-widget-text { .dynamic-widget-text {
font-size: 16px; font-size: 14px;
font-weight: 300; font-weight: 400;
line-height: 135%; line-height: 190%;
padding-right: 5px; 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

@@ -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 {
@@ -166,15 +105,24 @@
animation: side-menu-marquee 14s linear infinite; animation: side-menu-marquee 14s linear infinite;
} }
.side-menu-sight-transfer-list { /* Анимация для списка пересадок */
height: 0; .side-menu-sight-transfer-list.entering,
opacity: 0; .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;
transition:
height 0.3s cubic-bezier(0.16, 1, 0.3, 1),
opacity 0.3s ease;
} }
.side-menu-sight-transfer-list {
max-height: 0;
opacity: 0;
overflow: hidden;
transition: max-height 0.3s ease-out, opacity 0.3s ease-out; /* Анимация при открытии/закрытии */
}
/* Активное состояние - когда список открыт */
.side-menu-sight-transfer-list.open { .side-menu-sight-transfer-list.open {
max-height: 500px; /* Достаточно большое значение, чтобы вместить все пересадки */
opacity: 1; opacity: 1;
} }

View File

@@ -1,28 +1,8 @@
@property --fade-top {
syntax: "<length>";
inherits: false;
initial-value: 0px;
}
@property --fade-bottom {
syntax: "<length>";
inherits: false;
initial-value: 45px;
}
@keyframes pulse-chevron { @keyframes pulse-chevron {
0% { 0% { transform: rotate(var(--r, 0deg)) translateY(0px) scale(1); }
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); }
40% { 100% { transform: rotate(var(--r, 0deg)) translateY(0px) scale(1); }
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 { .chevron-svg {
@@ -48,8 +28,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 +39,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); var(--carrier-right, #806C59);
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 +85,11 @@
border-radius: 10px; border-radius: 10px;
width: 128px; width: 128px;
background-color: var(--carrier-right-dark, #005a2f); background-color: color-mix(
in srgb,
var(--carrier-right, #806C59) 80%,
black
);
} }
.list-of-sights-title { .list-of-sights-title {
@@ -125,7 +107,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 +116,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 +129,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 +201,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 +220,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); var(--carrier-right, #806C59);
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 +238,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 {
@@ -326,22 +268,10 @@
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) { .sight-frame-title:not(.intro-title) {
background: background:
linear-gradient( linear-gradient(
@@ -369,40 +299,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 +321,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%;
@@ -433,7 +340,7 @@
background: linear-gradient( background: linear-gradient(
to right, to right,
transparent 35%, transparent 35%,
#0e8953 50%, color-mix(in srgb, var(--carrier-right, #806C59) 80%, black) 50%,
transparent 65% transparent 65%
); );
border-radius: 3px; border-radius: 3px;
@@ -456,14 +363,8 @@
} }
.sight-frame-menu-wrapper { .sight-frame-menu-wrapper {
position: absolute; position: relative;
bottom: 100px;
left: 0;
width: 100%; width: 100%;
height: 60px;
z-index: 2;
border-radius: 0 0 10px 10px;
overflow: hidden;
flex-shrink: 0; flex-shrink: 0;
} }
@@ -472,37 +373,27 @@
top: 0; top: 0;
bottom: 0; bottom: 0;
width: 120px; width: 120px;
z-index: 100001; z-index: 3;
pointer-events: none; pointer-events: none;
transition: opacity 0.4s ease; transition: opacity 0.4s ease;
} }
.sight-frame-menu-fade.left { .sight-frame-menu-fade.left {
left: 0; left: 0;
background: linear-gradient( background: linear-gradient(to right, rgba(var(--carrier-right-menu-rgb, 179, 165, 152), 0.95), transparent);
to right,
rgba(var(--carrier-right-menu-rgb, 179, 165, 152), 0.95),
transparent
);
border-radius: 0 0 0 10px; border-radius: 0 0 0 10px;
} }
.sight-frame-menu-fade.right { .sight-frame-menu-fade.right {
right: 0; right: 0;
background: linear-gradient( background: linear-gradient(to left, rgba(var(--carrier-right-menu-rgb, 179, 165, 152), 0.95), transparent);
to left,
rgba(var(--carrier-right-menu-rgb, 179, 165, 152), 0.95),
transparent
);
border-radius: 0 0 10px 0; border-radius: 0 0 10px 0;
} }
.sight-frame-menu { .sight-frame-menu {
z-index: 100000;
position: relative; position: relative;
padding: 7px 60px; padding: 7px 60px;
width: 100%; width: 100%;
height: 60px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-evenly; justify-content: space-evenly;
@@ -542,15 +433,14 @@
padding: 8px 12px; padding: 8px 12px;
white-space: nowrap; white-space: nowrap;
flex-shrink: 0; 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; font-weight: 600;
border-bottom-color: #fff; border-bottom: 2px solid #fff;
} }
.sight-frame-text-wrapper::-webkit-scrollbar-track { .sight-frame-text-wrapper::-webkit-scrollbar-track {
@@ -575,20 +465,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 +540,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 +594,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 +654,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 {
@@ -927,7 +822,7 @@
border-radius: 32px; border-radius: 32px;
right: 20px; right: 20px;
bottom: 20px; bottom: 20px;
background: var(--carrier-main, #006f3a); background: var(--carrier-right, #806C59);
z-index: 9999; z-index: 9999;
display: flex; display: flex;
} }

View File

@@ -52,7 +52,8 @@
height: 96px; height: 96px;
background-color: #fcd500; background-color: #fcd500;
color: black; color: black;
border-radius: 10px 0 0 10px; border-top-left-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

@@ -16,6 +16,17 @@
var(--carrier-left, #006f3a); var(--carrier-left, #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 {
color: black; color: black;
padding: 8px 16px; padding: 8px 16px;
@@ -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: 40px;
} }
.side-menu-button { .side-menu-button {
@@ -55,6 +65,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;
@@ -168,8 +196,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(
@@ -185,6 +211,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 +227,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,6 +240,7 @@
.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
@@ -223,17 +254,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 +275,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

@@ -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

@@ -108,7 +108,7 @@ export const ArticleListPage = observer(() => {
</div> </div>
)} )}
{(rows.length > 0 || searchQuery) && ( {rows.length > 0 && (
<SearchInput value={searchQuery} onChange={setSearchQuery} /> <SearchInput value={searchQuery} onChange={setSearchQuery} />
)} )}

View File

@@ -1,5 +1,6 @@
import { import {
Button, Button,
Paper,
TextField, TextField,
Select, Select,
MenuItem, MenuItem,
@@ -23,10 +24,9 @@ import {
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 }; type ColorFields = { main_color: string; left_color: string; right_color: string };
@@ -80,29 +80,18 @@ const ColorPickerField = ({
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(() => { useEffect(() => {
@@ -148,16 +137,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 +151,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 +164,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 +212,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 +236,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,6 +253,24 @@ 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,
)
}
/>
<TextField
fullWidth
label="Слоган"
value={createCarrierData[language].slogan}
onChange={(e) =>
setCreateCarrierData(
createCarrierData[language].full_name,
createCarrierData[language].short_name,
createCarrierData.city_id,
e.target.value,
selectedMediaId || "",
language, language,
) )
} }
@@ -274,6 +286,8 @@ export const CarrierCreatePage = observer(() => {
createCarrierData[language].full_name, createCarrierData[language].full_name,
createCarrierData[language].short_name, createCarrierData[language].short_name,
createCarrierData.city_id, createCarrierData.city_id,
createCarrierData[language].slogan,
selectedMediaId || "",
language, language,
{ ...colorFields(createCarrierData), main_color: val } { ...colorFields(createCarrierData), main_color: val }
) )
@@ -292,6 +306,8 @@ export const CarrierCreatePage = observer(() => {
createCarrierData[language].full_name, createCarrierData[language].full_name,
createCarrierData[language].short_name, createCarrierData[language].short_name,
createCarrierData.city_id, createCarrierData.city_id,
createCarrierData[language].slogan,
selectedMediaId || "",
language, language,
{ ...colorFields(createCarrierData), left_color: val } { ...colorFields(createCarrierData), left_color: val }
) )
@@ -310,6 +326,8 @@ export const CarrierCreatePage = observer(() => {
createCarrierData[language].full_name, createCarrierData[language].full_name,
createCarrierData[language].short_name, createCarrierData[language].short_name,
createCarrierData.city_id, createCarrierData.city_id,
createCarrierData[language].slogan,
selectedMediaId || "",
language, language,
{ ...colorFields(createCarrierData), right_color: val } { ...colorFields(createCarrierData), right_color: val }
) )
@@ -321,58 +339,37 @@ export const CarrierCreatePage = observer(() => {
</div> </div>
</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="image" imageKey="thumbnail"
imageUrl={effectivePreviewMediaUrl} imageUrl={effectiveLogoUrl}
onImageClick={() => { onImageClick={() => {
setIsPreviewMediaOpen(true); setIsPreviewMediaOpen(true);
setMediaId(effectivePreviewMediaUrl ?? ""); setMediaId(effectiveLogoUrl ?? "");
}}
onDeleteImageClick={() => {
setSelectedMediaId(null);
setActiveMenuType(null);
setCreateCarrierData(
createCarrierData[language].full_name,
createCarrierData[language].short_name,
createCarrierData.city_id,
createCarrierData[language].slogan,
"",
language,
);
}} }}
onDeleteImageClick={() =>
setCreateCarrierMedia("preview_media", "")
}
onSelectFileClick={() => { onSelectFileClick={() => {
setActiveMediaField("preview_media");
setActiveMenuType("image"); setActiveMenuType("image");
setIsSelectMediaOpen(true); setIsSelectMediaOpen(true);
}} }}
setUploadMediaOpen={() => { setUploadMediaOpen={() => {
setActiveMediaField("preview_media"); setIsUploadMediaOpen(true);
setActiveMenuType("image"); setActiveMenuType("image");
setIsUploadMediaOpen(true);
}} }}
/> />
</div> </div>
<div className="w-full max-w-[300px]">
<VideoPreviewCard
title="Видео загрузки"
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>
<Button <Button
variant="contained" variant="contained"
@@ -398,7 +395,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 +412,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,
@@ -21,14 +22,9 @@ import {
isMediaIdEmpty, isMediaIdEmpty,
LoadingSpinner, LoadingSpinner,
selectedCityStore, 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,
@@ -92,12 +88,7 @@ const ColorPickerField = ({
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,17 +98,10 @@ 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(() => { useEffect(() => {
@@ -153,6 +137,8 @@ export const CarrierEditPage = observer(() => {
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,
carrierData.ru?.slogan || "",
carrierData.ru?.logo || "",
"ru", "ru",
colors, colors,
); );
@@ -160,22 +146,18 @@ export const CarrierEditPage = observer(() => {
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,
carrierData.en?.slogan || "",
carrierData.en?.logo || "",
"en", "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,
carrierData.zh?.slogan || "",
carrierData.zh?.logo || "",
"zh", "zh",
); );
setEditCarrierMedia(
"video_preview",
carrierData.ru?.video_preview || "",
);
setEditCarrierMedia(
"preview_media",
carrierData.ru?.preview_media || "",
);
setInitialCityName(carrierData.ru?.city || ""); setInitialCityName(carrierData.ru?.city || "");
} }
@@ -186,7 +168,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 +176,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 +189,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 +248,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,6 +275,8 @@ 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),
editCarrierData[language].slogan,
editCarrierData.logo,
language, language,
) )
} }
@@ -311,6 +299,8 @@ export const CarrierEditPage = observer(() => {
e.target.value, e.target.value,
editCarrierData[language].short_name, editCarrierData[language].short_name,
editCarrierData.city_id, editCarrierData.city_id,
editCarrierData[language].slogan,
editCarrierData.logo,
language, language,
) )
} }
@@ -326,6 +316,24 @@ export const CarrierEditPage = observer(() => {
editCarrierData[language].full_name, editCarrierData[language].full_name,
e.target.value, e.target.value,
editCarrierData.city_id, editCarrierData.city_id,
editCarrierData[language].slogan,
editCarrierData.logo,
language,
)
}
/>
<TextField
fullWidth
label="Слоган"
value={editCarrierData[language].slogan}
onChange={(e) =>
setEditCarrierData(
editCarrierData[language].full_name,
editCarrierData[language].short_name,
editCarrierData.city_id,
e.target.value,
editCarrierData.logo,
language, language,
) )
} }
@@ -341,6 +349,8 @@ export const CarrierEditPage = observer(() => {
editCarrierData[language].full_name, editCarrierData[language].full_name,
editCarrierData[language].short_name, editCarrierData[language].short_name,
editCarrierData.city_id, editCarrierData.city_id,
editCarrierData[language].slogan,
editCarrierData.logo,
language, language,
{ ...colorFields(editCarrierData), main_color: val }, { ...colorFields(editCarrierData), main_color: val },
) )
@@ -360,6 +370,8 @@ export const CarrierEditPage = observer(() => {
editCarrierData[language].full_name, editCarrierData[language].full_name,
editCarrierData[language].short_name, editCarrierData[language].short_name,
editCarrierData.city_id, editCarrierData.city_id,
editCarrierData[language].slogan,
editCarrierData.logo,
language, language,
{ ...colorFields(editCarrierData), left_color: val }, { ...colorFields(editCarrierData), left_color: val },
) )
@@ -379,6 +391,8 @@ export const CarrierEditPage = observer(() => {
editCarrierData[language].full_name, editCarrierData[language].full_name,
editCarrierData[language].short_name, editCarrierData[language].short_name,
editCarrierData.city_id, editCarrierData.city_id,
editCarrierData[language].slogan,
editCarrierData.logo,
language, language,
{ ...colorFields(editCarrierData), right_color: val }, { ...colorFields(editCarrierData), right_color: val },
) )
@@ -391,58 +405,28 @@ export const CarrierEditPage = observer(() => {
</div> </div>
</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="image" imageKey="thumbnail"
imageUrl={effectivePreviewMediaUrl} imageUrl={effectiveLogoUrl}
onImageClick={() => { onImageClick={() => {
setIsPreviewMediaOpen(true); setIsPreviewMediaOpen(true);
setMediaId(effectivePreviewMediaUrl ?? ""); setMediaId(effectiveLogoUrl ?? "");
}}
onDeleteImageClick={() => {
setIsDeleteLogoModalOpen(true);
}} }}
onDeleteImageClick={() =>
setEditCarrierMedia("preview_media", "")
}
onSelectFileClick={() => { onSelectFileClick={() => {
setActiveMediaField("preview_media");
setActiveMenuType("image"); setActiveMenuType("image");
setIsSelectMediaOpen(true); setIsSelectMediaOpen(true);
}} }}
setUploadMediaOpen={() => { setUploadMediaOpen={() => {
setActiveMediaField("preview_media"); setIsUploadMediaOpen(true);
setActiveMenuType("image"); setActiveMenuType("image");
setIsUploadMediaOpen(true);
}} }}
/> />
</div> </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>
<Button <Button
variant="contained" variant="contained"
@@ -467,7 +451,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 +469,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

@@ -162,7 +162,7 @@ export const CarrierListPage = observer(() => {
</div> </div>
)} )}
{(rows.length > 0 || searchQuery) && ( {rows.length > 0 && (
<SearchInput value={searchQuery} onChange={setSearchQuery} /> <SearchInput value={searchQuery} onChange={setSearchQuery} />
)} )}

View File

@@ -17,7 +17,6 @@ import {
countryStore, countryStore,
languageStore, languageStore,
mediaStore, mediaStore,
snapshotStore,
isMediaIdEmpty, isMediaIdEmpty,
SelectMediaDialog, SelectMediaDialog,
UploadMediaDialog, UploadMediaDialog,
@@ -61,13 +60,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

@@ -159,7 +159,7 @@ export const CityListPage = observer(() => {
</div> </div>
)} )}
{(rows.length > 0 || searchQuery) && ( {rows.length > 0 && (
<SearchInput value={searchQuery} onChange={setSearchQuery} /> <SearchInput value={searchQuery} onChange={setSearchQuery} />
)} )}

View File

@@ -111,7 +111,7 @@ export const CountryListPage = observer(() => {
</div> </div>
)} )}
{(rows.length > 0 || searchQuery) && ( {rows.length > 0 && (
<SearchInput value={searchQuery} onChange={setSearchQuery} /> <SearchInput value={searchQuery} onChange={setSearchQuery} />
)} )}

View File

@@ -113,7 +113,7 @@ export const MediaListPage = observer(() => {
return ( return (
<> <>
<div className="w-full"> <div className="w-full">
{(rows.length > 0 || searchQuery) && ( {rows.length > 0 && (
<SearchInput value={searchQuery} onChange={setSearchQuery} /> <SearchInput value={searchQuery} onChange={setSearchQuery} />
)} )}

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";
@@ -46,7 +50,6 @@ export const RouteCreatePage = observer(() => {
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");
@@ -136,16 +139,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 +169,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 +292,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 +407,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 +472,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 +561,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 +634,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

@@ -244,7 +244,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 +265,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,7 +555,7 @@ 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 ?? 420}
onChange={(e) => { onChange={(e) => {
@@ -556,22 +566,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 +634,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 +725,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

@@ -270,7 +270,7 @@ export const RouteListPage = observer(() => {
</div> </div>
)} )}
{(rows.length > 0 || searchQuery) && ( {rows.length > 0 && (
<SearchInput value={searchQuery} onChange={setSearchQuery} /> <SearchInput value={searchQuery} onChange={setSearchQuery} />
)} )}

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 { 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";
@@ -16,8 +17,9 @@ export const LeftSidebar = observer(({ open, onToggle }: LeftSidebarProps) => {
const navigate = useNavigate(); const navigate = useNavigate();
const navigationType = useNavigationType(); const navigationType = useNavigationType();
const { routeData } = useMapData(); const { routeData } = useMapData();
const [previewMedia, setPreviewMedia] = useState<string | null>(null); const [carrierLogo, setCarrierLogo] = useState<string | null>(null);
const [isPreviewMediaError, setIsPreviewMediaError] = useState(false); const [carrierSlogan, setCarrierSlogan] = useState<string | null>(null);
const [carrierShortName, setCarrierShortName] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
async function fetchCarrierData() { async function fetchCarrierData() {
@@ -25,8 +27,9 @@ export const LeftSidebar = observer(({ open, onToggle }: LeftSidebarProps) => {
const carrier = ( const carrier = (
await authInstance.get(`/carrier/${routeData.carrier_id}`) await authInstance.get(`/carrier/${routeData.carrier_id}`)
).data; ).data;
setPreviewMedia(carrier.preview_media ?? null); setCarrierLogo(carrier.logo);
setIsPreviewMediaError(false); setCarrierSlogan(carrier.slogan ?? null);
setCarrierShortName(carrier.short_name ?? null);
} }
} }
fetchCarrierData(); fetchCarrierData();
@@ -50,14 +53,7 @@ export const LeftSidebar = observer(({ open, onToggle }: LeftSidebarProps) => {
}} }}
> >
{/* Кнопка назад — вне основного меню */} {/* Кнопка назад — вне основного меню */}
<div <div style={{ padding: "12px 12px 0" }}>
style={{
padding: "12px 12px 0",
opacity: open ? 1 : 0,
pointerEvents: open ? "auto" : "none",
transition: "opacity 0.25s ease",
}}
>
<Button <Button
onClick={handleBack} onClick={handleBack}
variant="contained" variant="contained"
@@ -91,6 +87,43 @@ export const LeftSidebar = observer(({ open, onToggle }: LeftSidebarProps) => {
pointerEvents: open ? "auto" : "none", pointerEvents: open ? "auto" : "none",
}} }}
> >
{/* Герб — .side-menu-crest */}
<div
style={{
width: 170,
height: 170,
alignSelf: "flex-start",
marginLeft: 20,
backgroundColor: "rgba(255,255,255,0.15)",
borderRadius: 8,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "rgba(255,255,255,0.5)",
fontSize: 14,
fontWeight: 500,
}}
>
Герб
</div>
{/* Слоган — .side-menu-label */}
{carrierSlogan && (
<div
style={{
marginTop: 10,
textAlign: "left",
fontSize: 15,
padding: "0 20px",
alignSelf: "flex-start",
fontWeight: 400,
lineHeight: "150%",
}}
>
{carrierSlogan}
</div>
)}
{/* Кнопки — .side-menu-buttons */} {/* Кнопки — .side-menu-buttons */}
<div <div
style={{ style={{
@@ -135,14 +168,39 @@ export const LeftSidebar = observer(({ open, onToggle }: LeftSidebarProps) => {
flexDirection: "column", flexDirection: "column",
}} }}
> >
{/* .side-menu-carrier-block */}
<div style={{ padding: "0 20px" }}>
{carrierLogo && !isMediaIdEmpty(carrierLogo) && (
<div style={{ width: 170 }}>
<MediaViewer
media={{
id: carrierLogo,
media_type: 1,
filename: "carrier_logo",
}}
fullWidth
/>
</div>
)}
{carrierShortName && (
<div
style={{
marginTop: 4,
textAlign: "left",
fontSize: 16,
fontWeight: 700,
lineHeight: "150%",
color: "#fff",
}}
>
{carrierShortName}
</div>
)}
</div>
{/* .side-menu-bottom-photo */} {/* .side-menu-bottom-photo */}
<img <img
src={ src="/side-menu-photo.png"
previewMedia && !isPreviewMediaError
? `${import.meta.env.VITE_KRBL_MEDIA}${previewMedia}/download?token=${localStorage.getItem("token") ?? ""}`
: "/side-menu-photo.png"
}
onError={() => setIsPreviewMediaError(true)}
alt="" alt=""
style={{ style={{
width: "288px", width: "288px",
@@ -154,13 +212,7 @@ export const LeftSidebar = observer(({ open, onToggle }: LeftSidebarProps) => {
</div> </div>
</div> </div>
<div <div className="absolute bottom-[20px] -right-[520px] z-10">
className="absolute bottom-[20px] z-10"
style={{
right: open ? -520 : -312,
transition: "right 0.3s ease",
}}
>
<LanguageSelector onBack={onToggle} isSidebarOpen={open} /> <LanguageSelector onBack={onToggle} isSidebarOpen={open} />
</div> </div>
</div> </div>

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,557 @@
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}
>
{ruLabelWidth > 0 && (
<pixiGraphics
draw={(g: Graphics) => {
g.clear();
const hasSecondLabel = !!(station.name && language !== "ru" && ruLabel);
const pad = 10 / scale;
const w = ruLabelWidth + pad * 2;
const top = -compensatedRuFontSize / 2 - pad;
const bottom = hasSecondLabel
? compensatedRuFontSize * 1.1 + compensatedNameFontSize / 2 + pad
: compensatedRuFontSize / 2 + pad;
g.rect(-w / 2, top, w, bottom - top);
g.fill({ color: 0x000000, alpha: 0.001 });
}}
/>
)}
{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,7 +54,6 @@ export const RoutePreview = () => {
<Box <Box
sx={{ sx={{
position: "relative", position: "relative",
zIndex: 20,
width: isLeftSidebarOpen ? 288 : 0, width: isLeftSidebarOpen ? 288 : 0,
transition: "width 0.3s ease", transition: "width 0.3s ease",
overflow: "visible", overflow: "visible",
@@ -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";
}; };
}, []); }, []);
@@ -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

@@ -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,7 @@ 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;
return ( return (
<div key={station.id}> <div key={station.id}>
@@ -2440,6 +2438,7 @@ export const WebGLRouteMapPrototype = observer(() => {
cursor: "grab", cursor: "grab",
userSelect: "none", userSelect: "none",
touchAction: "none", touchAction: "none",
lineHeight: 1,
}} }}
> >
<div <div
@@ -2550,6 +2549,7 @@ export const WebGLRouteMapPrototype = observer(() => {
position: "relative", position: "relative",
fontWeight: 700, fontWeight: 700,
fontSize: primaryFontSize, fontSize: primaryFontSize,
lineHeight: 1,
textShadow: "0 0 4px rgba(0,0,0,0.6)", textShadow: "0 0 4px rgba(0,0,0,0.6)",
pointerEvents: "none", pointerEvents: "none",
whiteSpace: "nowrap", whiteSpace: "nowrap",

View File

@@ -181,7 +181,7 @@ export const SightListPage = observer(() => {
</div> </div>
)} )}
{(rows.length > 0 || searchQuery) && ( {rows.length > 0 && (
<SearchInput value={searchQuery} onChange={setSearchQuery} /> <SearchInput value={searchQuery} onChange={setSearchQuery} />
)} )}

View File

@@ -6,24 +6,14 @@ import {
DialogContent, DialogContent,
DialogActions, DialogActions,
} from "@mui/material"; } from "@mui/material";
import { snapshotStore, authStore, routeStore, selectedCityStore, cityStore, carrierStore } from "@shared"; import { snapshotStore, authStore, routeStore, 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, useMemo } from "react"; import { useState, useEffect } 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();
@@ -34,22 +24,10 @@ export const SnapshotCreatePage = observer(() => {
}, []); }, []);
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 [duplicateWarningOpen, setDuplicateWarningOpen] = useState(false);
const [duplicateRouteNumbers, setDuplicateRouteNumbers] = useState<string[]>([]); 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 canReadRoutes = authStore.canRead("routes");
const startExport = async () => { const startExport = async () => {
@@ -93,55 +71,22 @@ export const SnapshotCreatePage = observer(() => {
routeStore.routes.loaded = false; routeStore.routes.loaded = false;
}); });
await routeStore.getRoutes(); await routeStore.getRoutes();
await carrierStore.getCarriers("ru");
const routes = routeStore.routes.data; const routes = routeStore.routes.data;
const carriers = carrierStore.carriers.ru.data; const numberCount = new Map<string, number>();
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) { for (const route of routes) {
const num = (route.route_sys_number ?? "").trim(); const num = (route.route_number ?? "").trim();
if (!num) continue; if (num) {
const cityId = carrierCityMap.get(route.carrier_id) ?? 0; numberCount.set(num, (numberCount.get(num) ?? 0) + 1);
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>>(); const duplicates = Array.from(numberCount.entries())
for (const route of routes) { .filter(([, count]) => count > 1)
const num = (route.route_sys_number ?? "").trim(); .map(([num]) => num);
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) { if (duplicates.length > 0) {
setDuplicateRouteNumbers(duplicateMessages); setDuplicateRouteNumbers(duplicates);
setDuplicateWarningOpen(true); setDuplicateWarningOpen(true);
} else { } else {
await startExport(); await startExport();
@@ -170,19 +115,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
@@ -191,7 +124,7 @@ export const SnapshotCreatePage = observer(() => {
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={handleSave}
disabled={isLoading || !exportNameRegex.test(name.trim())} disabled={isLoading || !name.trim()}
> >
{isLoading ? ( {isLoading ? (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -219,12 +152,14 @@ export const SnapshotCreatePage = observer(() => {
<DialogTitle>Найдены повторяющиеся маршруты</DialogTitle> <DialogTitle>Найдены повторяющиеся маршруты</DialogTitle>
<DialogContent> <DialogContent>
<p className="mb-3"> <p className="mb-3">
Обнаружены маршруты с одинаковыми номерами трассы. Это может привести к Обнаружены маршруты с одинаковыми номерами. Это может привести к
некорректным данным в экспорте. некорректным данным в экспорте.
</p> </p>
<ul className="list-disc pl-5"> <ul className="list-disc pl-5">
{duplicateRouteNumbers.map((msg, i) => ( {duplicateRouteNumbers.map((num) => (
<li key={i}>{msg}</li> <li key={num}>
Найдены повторяющиеся маршруты под номером №{num}
</li>
))} ))}
</ul> </ul>
</DialogContent> </DialogContent>

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();
@@ -131,7 +108,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>;
@@ -171,20 +148,8 @@ export const SnapshotListPage = observer(() => {
<button <button
title="Удалить" 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" />
@@ -236,7 +201,6 @@ export const SnapshotListPage = observer(() => {
disabled={isLowStorage} disabled={isLowStorage}
onClick={() => { onClick={() => {
setEmptySnapshotName(""); setEmptySnapshotName("");
setEmptySnapshotNameError(null);
setIsEmptySnapshotModalOpen(true); setIsEmptySnapshotModalOpen(true);
}} }}
> >
@@ -335,7 +299,7 @@ export const SnapshotListPage = observer(() => {
</Alert> </Alert>
)} )}
{(rows.length > 0 || searchQuery) && ( {rows.length > 0 && (
<SearchInput value={searchQuery} onChange={setSearchQuery} /> <SearchInput value={searchQuery} onChange={setSearchQuery} />
)} )}
@@ -389,19 +353,7 @@ export const SnapshotListPage = observer(() => {
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 +363,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 +380,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

@@ -225,7 +225,7 @@ export const StationListPage = observer(() => {
</div> </div>
)} )}
{(rows.length > 0 || searchQuery) && ( {rows.length > 0 && (
<SearchInput value={searchQuery} onChange={setSearchQuery} /> <SearchInput value={searchQuery} onChange={setSearchQuery} />
)} )}

View File

@@ -2,8 +2,16 @@ import {
Button, Button,
Paper, Paper,
TextField, TextField,
Checkbox,
Typography, Typography,
Box, Box,
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";
@@ -20,7 +28,41 @@ import {
selectedCityStore, 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";
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 UserCreatePage = observer(() => { export const UserCreatePage = observer(() => {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -234,8 +276,133 @@ export const UserCreatePage = observer(() => {
</Button> </Button>
</Box> </Box>
<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,
);
}
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 ? (
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "center" }}>
<Checkbox
checked={localRoles.includes("devices_maintenance_rw")}
onChange={(e) => handleMaintenanceChange(e.target.checked)}
size="small"
title="Техническое обслуживание (ТО)"
/>
</Box>
) : (
<Typography variant="body2" color="text.secondary">
-
</Typography>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</Box>
</section> </section>
<Button <Button

View File

@@ -1,9 +1,17 @@
import { import {
Button, Button,
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";
@@ -27,7 +35,41 @@ import {
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();
@@ -316,8 +358,133 @@ export const UserEditPage = observer(() => {
</Button> </Button>
</Box> </Box>
<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,
);
}
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 ? (
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "center" }}>
<Checkbox
checked={localRoles.includes("devices_maintenance_rw")}
onChange={(e) => handleMaintenanceChange(e.target.checked)}
size="small"
title="Техническое обслуживание (ТО)"
/>
</Box>
) : (
<Typography variant="body2" color="text.secondary">
-
</Typography>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</Box>
</section> </section>
<Divider /> <Divider />

View File

@@ -147,7 +147,7 @@ export const UserListPage = observer(() => {
</div> </div>
)} )}
{(rows.length > 0 || searchQuery) && ( {rows.length > 0 && (
<SearchInput value={searchQuery} onChange={setSearchQuery} /> <SearchInput value={searchQuery} onChange={setSearchQuery} />
)} )}

View File

@@ -173,7 +173,7 @@ export const VehicleListPage = observer(() => {
/> />
</div> </div>
{(rows.length > 0 || searchQuery) && ( {rows.length > 0 && (
<SearchInput value={searchQuery} onChange={setSearchQuery} /> <SearchInput value={searchQuery} onChange={setSearchQuery} />
)} )}

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(() => {
@@ -350,7 +347,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 +416,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,10 +12,10 @@ 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;
@@ -111,40 +111,38 @@ class CarrierStore {
createCarrierData = { createCarrierData = {
city_id: 0, city_id: 0,
preview_media: "", logo: "",
video_preview: "",
main_color: "", main_color: "",
left_color: "", left_color: "",
right_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,
slogan: string,
logoId: string,
language: Language, language: Language,
colors?: { main_color?: string; left_color?: string; right_color?: string } colors?: { main_color?: string; left_color?: string; right_color?: string }
) => { ) => {
this.createCarrierData.city_id = cityId; this.createCarrierData.city_id = cityId;
this.createCarrierData.logo = logoId;
if (colors) { if (colors) {
if (colors.main_color !== undefined) this.createCarrierData.main_color = colors.main_color; 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.left_color !== undefined) this.createCarrierData.left_color = colors.left_color;
@@ -153,6 +151,7 @@ class CarrierStore {
this.createCarrierData[language] = { this.createCarrierData[language] = {
full_name: fullName, full_name: fullName,
short_name: shortName, short_name: shortName,
slogan: slogan,
}; };
}; };
@@ -207,12 +206,8 @@ 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.main_color ? { main_color: this.createCarrierData.main_color } : {}),
...(this.createCarrierData.left_color ? { left_color: this.createCarrierData.left_color } : {}), ...(this.createCarrierData.left_color ? { left_color: this.createCarrierData.left_color } : {}),
...(this.createCarrierData.right_color ? { right_color: this.createCarrierData.right_color } : {}), ...(this.createCarrierData.right_color ? { right_color: this.createCarrierData.right_color } : {}),
@@ -226,55 +221,55 @@ 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: "", main_color: "",
left_color: "", left_color: "",
right_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,38 +278,36 @@ class CarrierStore {
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: "",
}, },
city_id: 0, city_id: 0,
preview_media: "", logo: "",
video_preview: "",
main_color: "", main_color: "",
left_color: "", left_color: "",
right_color: "", right_color: "",
}; };
setEditCarrierMedia = (
field: "preview_media" | "video_preview",
mediaId: string
) => {
this.editCarrierData[field] = mediaId;
};
setEditCarrierData = ( setEditCarrierData = (
fullName: string, fullName: string,
shortName: string, shortName: string,
cityId: number, cityId: number,
slogan: string,
logoId: string,
language: Language, language: Language,
colors?: { main_color?: string; left_color?: string; right_color?: string } colors?: { main_color?: string; left_color?: string; right_color?: string }
) => { ) => {
this.editCarrierData.city_id = cityId; this.editCarrierData.city_id = cityId;
this.editCarrierData.logo = logoId;
if (colors) { if (colors) {
if (colors.main_color !== undefined) this.editCarrierData.main_color = colors.main_color; 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.left_color !== undefined) this.editCarrierData.left_color = colors.left_color;
@@ -323,6 +316,7 @@ class CarrierStore {
this.editCarrierData[language] = { this.editCarrierData[language] = {
full_name: fullName, full_name: fullName,
short_name: shortName, short_name: shortName,
slogan: slogan,
}; };
}; };
@@ -333,10 +327,10 @@ 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 ? { logo: this.editCarrierData.logo } : {}),
video_preview: this.editCarrierData.video_preview || null,
...(this.editCarrierData.main_color ? { main_color: this.editCarrierData.main_color } : {}), ...(this.editCarrierData.main_color ? { main_color: this.editCarrierData.main_color } : {}),
...(this.editCarrierData.left_color ? { left_color: this.editCarrierData.left_color } : {}), ...(this.editCarrierData.left_color ? { left_color: this.editCarrierData.left_color } : {}),
...(this.editCarrierData.right_color ? { right_color: this.editCarrierData.right_color } : {}), ...(this.editCarrierData.right_color ? { right_color: this.editCarrierData.right_color } : {}),

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[][],

View File

@@ -1,7 +1,7 @@
import { authInstance, languageInstance, mobxFetch } from "@shared"; import { authInstance, languageInstance, mobxFetch } from "@shared";
import { makeAutoObservable, runInAction } from "mobx"; import { makeAutoObservable, runInAction } from "mobx";
import { getVehicleSessionsApi } from "./api"; import { getVehicleSessionsApi } from "./api";
import { Vehicle, VehicleMaintenanceSession, SetMaintenanceModeResponse } from "./types"; import { Vehicle, VehicleMaintenanceSession } from "./types";
export type { Vehicle, VehicleMaintenanceSession } from "./types"; export type { Vehicle, VehicleMaintenanceSession } from "./types";
@@ -218,32 +218,23 @@ class VehicleStore {
}); });
}; };
setMaintenanceMode = async ( setMaintenanceMode = async (uuid: string, enabled: boolean) => {
uuid: string, const response = await authInstance.post(
enabled: boolean,
durationSeconds?: number,
): Promise<SetMaintenanceModeResponse> => {
const response = await authInstance.post<SetMaintenanceModeResponse>(
`/devices/${uuid}/maintenance-mode`, `/devices/${uuid}/maintenance-mode`,
{ {
enabled, enabled,
...(enabled && durationSeconds !== undefined
? { duration_seconds: durationSeconds }
: {}),
}, },
); );
if (!response.data.saved) { const normalizedVehicle = this.normalizeVehicleItem(response.data);
throw new Error("Maintenance mode was not saved");
}
runInAction(() => { runInAction(() => {
this.mergeVehicleInCaches({ this.mergeVehicleInCaches({
...normalizedVehicle.vehicle,
uuid, uuid,
maintenance_mode_on: response.data.enabled, maintenance_mode_on:
maintenance_mode_duration_seconds: response.data.duration_seconds, normalizedVehicle.vehicle.maintenance_mode_on ?? enabled,
}); });
}); });
return response.data;
}; };
setDemoMode = async (uuid: string, enabled: boolean) => { setDemoMode = async (uuid: string, enabled: boolean) => {

View File

@@ -11,7 +11,6 @@ export type Vehicle = {
snapshot_update_blocked?: boolean; snapshot_update_blocked?: boolean;
demo_mode_enabled?: boolean; demo_mode_enabled?: boolean;
maintenance_mode_on?: boolean; maintenance_mode_on?: boolean;
maintenance_mode_duration_seconds?: number;
city_id?: number; city_id?: number;
}; };
device_status?: { device_status?: {
@@ -22,24 +21,13 @@ export type Vehicle = {
last_update: string; last_update: string;
is_connected: boolean; is_connected: boolean;
current_route_id?: number; current_route_id?: number;
maintenance_mode_on?: boolean;
maintenance_mode_duration_seconds?: number;
}; };
}; };
export type VehicleMaintenanceSession = { export type VehicleMaintenanceSession = {
duration_seconds?: number | null; duration_seconds: number;
ended_at?: string | null; ended_at: string;
id: number; id: number;
started_at: string; started_at: string;
vehicle_id: number; vehicle_id: number;
}; };
export type SetMaintenanceModeResponse = {
message: string;
enabled: boolean;
duration_seconds: number;
saved: boolean;
sent: boolean;
warning?: string;
};

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

@@ -24,51 +24,30 @@ const shiftYYYYMMDD = (value: string, days: number) => {
type LogLevel = "info" | "warn" | "error" | "debug" | "fatal" | "unknown"; type LogLevel = "info" | "warn" | "error" | "debug" | "fatal" | "unknown";
const LOG_LEVEL_STYLES: Record< const LOG_LEVEL_STYLES: Record<LogLevel, { badge: string; text: string }> = {
LogLevel,
{ badge: string; text: string; bg: string; color: string; borderColor: string }
> = {
info: { info: {
badge: "bg-blue-100 text-blue-700", badge: "bg-blue-100 text-blue-700",
text: "text-[#000000BF]", text: "text-[#000000BF]",
bg: "#DBEAFE",
color: "#1D4ED8",
borderColor: "#93C5FD",
}, },
debug: { debug: {
badge: "bg-gray-100 text-gray-600", badge: "bg-gray-100 text-gray-600",
text: "text-gray-600", text: "text-gray-600",
bg: "#F3F4F6",
color: "#4B5563",
borderColor: "#D1D5DB",
}, },
warn: { warn: {
badge: "bg-amber-100 text-amber-700", badge: "bg-amber-100 text-amber-700",
text: "text-amber-800", text: "text-amber-800",
bg: "#FEF3C7",
color: "#B45309",
borderColor: "#FCD34D",
}, },
error: { error: {
badge: "bg-red-100 text-red-700", badge: "bg-red-100 text-red-700",
text: "text-red-700", text: "text-red-700",
bg: "#FEE2E2",
color: "#B91C1C",
borderColor: "#FCA5A5",
}, },
fatal: { fatal: {
badge: "bg-red-200 text-red-900", badge: "bg-red-200 text-red-900",
text: "text-red-900 font-semibold", text: "text-red-900 font-semibold",
bg: "#FECACA",
color: "#7F1D1D",
borderColor: "#F87171",
}, },
unknown: { unknown: {
badge: "bg-gray-100 text-gray-500", badge: "bg-gray-100 text-gray-500",
text: "text-[#000000BF]", text: "text-[#000000BF]",
bg: "#F3F4F6",
color: "#6B7280",
borderColor: "#D1D5DB",
}, },
}; };
@@ -160,23 +139,6 @@ export const DeviceLogsModal = ({
const yesterday = new Date(today.getTime() - 24 * 60 * 60 * 1000); const yesterday = new Date(today.getTime() - 24 * 60 * 60 * 1000);
const [dateFrom, setDateFrom] = useState(toYYYYMMDD(yesterday)); const [dateFrom, setDateFrom] = useState(toYYYYMMDD(yesterday));
const [dateTo, setDateTo] = useState(toYYYYMMDD(today)); const [dateTo, setDateTo] = useState(toYYYYMMDD(today));
const ALL_LEVELS: LogLevel[] = ["debug", "info", "warn", "error", "fatal"];
const [activeLevels, setActiveLevels] = useState<Set<LogLevel>>(
new Set(ALL_LEVELS)
);
const toggleLevel = (level: LogLevel) => {
setActiveLevels((prev) => {
const next = new Set(prev);
if (next.has(level)) {
next.delete(level);
} else {
next.add(level);
}
return next;
});
};
const dateToMin = shiftYYYYMMDD(dateFrom, 1); const dateToMin = shiftYYYYMMDD(dateFrom, 1);
const dateFromMax = shiftYYYYMMDD(dateTo, -1); const dateFromMax = shiftYYYYMMDD(dateTo, -1);
@@ -243,21 +205,16 @@ export const DeviceLogsModal = ({
return parsed; return parsed;
}, [chunks]); }, [chunks]);
const filteredLogs = useMemo(
() => logs.filter((log) => activeLevels.has(log.level)),
[logs, activeLevels]
);
const logsText = useMemo( const logsText = useMemo(
() => () =>
filteredLogs logs
.map((log) => { .map((log) => {
const level = log.level === "unknown" ? "LOG" : log.level.toUpperCase(); const level = log.level === "unknown" ? "LOG" : log.level.toUpperCase();
const time = log.time ? `[${log.time}] ` : ""; const time = log.time ? `[${log.time}] ` : "";
return `${time}${level}: ${log.text}`; return `${time}${level}: ${log.text}`;
}) })
.join("\n"), .join("\n"),
[filteredLogs] [logs]
); );
const handleDownloadLogs = () => { const handleDownloadLogs = () => {
@@ -296,28 +253,6 @@ export const DeviceLogsModal = ({
<div className="flex flex-col gap-6 h-[85vh]"> <div className="flex flex-col gap-6 h-[85vh]">
<div className="flex gap-4 items-center justify-between w-full flex-wrap"> <div className="flex gap-4 items-center justify-between w-full flex-wrap">
<h2 className="text-2xl font-semibold text-[#000000BF]">Логи</h2> <h2 className="text-2xl font-semibold text-[#000000BF]">Логи</h2>
<div className="flex gap-1.5 items-center">
{ALL_LEVELS.map((level) => {
const active = activeLevels.has(level);
const s = LOG_LEVEL_STYLES[level];
return (
<button
key={level}
type="button"
onClick={() => toggleLevel(level)}
className="cursor-pointer select-none rounded-md px-2.5 py-1 text-[11px] font-semibold uppercase tracking-wide transition-all duration-150"
style={{
backgroundColor: active ? s.bg : "transparent",
color: active ? s.color : "#9CA3AF",
border: `1.5px solid ${active ? s.borderColor : "#E5E7EB"}`,
opacity: active ? 1 : 0.55,
}}
>
{level}
</button>
);
})}
</div>
<div className="flex gap-4 items-center"> <div className="flex gap-4 items-center">
<TextField <TextField
type="date" type="date"
@@ -345,7 +280,7 @@ export const DeviceLogsModal = ({
variant="outlined" variant="outlined"
size="small" size="small"
onClick={handleDownloadLogs} onClick={handleDownloadLogs}
disabled={isLoading || Boolean(error) || filteredLogs.length === 0} disabled={isLoading || Boolean(error) || logs.length === 0}
> >
Скачать .txt Скачать .txt
</Button> </Button>
@@ -368,8 +303,8 @@ export const DeviceLogsModal = ({
{!isLoading && !error && ( {!isLoading && !error && (
<div className="w-full h-full overflow-y-auto rounded-xl"> <div className="w-full h-full overflow-y-auto rounded-xl">
<div className="flex flex-col gap-0.5 font-mono text-[13px]"> <div className="flex flex-col gap-0.5 font-mono text-[13px]">
{filteredLogs.length > 0 ? ( {logs.length > 0 ? (
filteredLogs.map((log) => { logs.map((log) => {
const style = LOG_LEVEL_STYLES[log.level]; const style = LOG_LEVEL_STYLES[log.level];
return ( return (
<div <div

View File

@@ -21,7 +21,7 @@ interface VehicleSessionsModalProps {
onClose: () => void; onClose: () => void;
} }
const formatDateTime = (value?: string | null) => { const formatDateTime = (value: string) => {
if (!value) return "-"; if (!value) return "-";
const date = new Date(value); const date = new Date(value);
@@ -38,8 +38,8 @@ const formatDateTime = (value?: string | null) => {
}).format(date); }).format(date);
}; };
const formatDuration = (durationSeconds?: number | null) => { const formatDuration = (durationSeconds: number) => {
if (durationSeconds == null || !Number.isFinite(durationSeconds) || durationSeconds < 0) { if (!Number.isFinite(durationSeconds) || durationSeconds < 0) {
return "-"; return "-";
} }
@@ -152,10 +152,10 @@ export const VehicleSessionsModal = observer(
{formatDateTime(session.started_at)} {formatDateTime(session.started_at)}
</TableCell> </TableCell>
<TableCell> <TableCell>
{session.ended_at ? formatDateTime(session.ended_at) : "В процессе"} {formatDateTime(session.ended_at)}
</TableCell> </TableCell>
<TableCell align="right"> <TableCell align="right">
{session.ended_at ? formatDuration(session.duration_seconds) : "—"} {formatDuration(session.duration_seconds)}
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ))}

View File

@@ -34,26 +34,12 @@ import {
Typography, Typography,
Checkbox, Checkbox,
Tooltip, Tooltip,
TextField,
MenuItem,
} from "@mui/material"; } from "@mui/material";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { DeleteModal } from "@widgets"; import { DeleteModal } from "@widgets";
import { DeviceLogsModal } from "./DeviceLogsModal"; import { DeviceLogsModal } from "./DeviceLogsModal";
import { VehicleSessionsModal } from "./VehicleSessionsModal"; import { VehicleSessionsModal } from "./VehicleSessionsModal";
import { isAxiosError } from "axios";
const maintenanceDurations = [0, 900, 1800, 3600, 7200];
const MAX_MAINTENANCE_DURATION_SECONDS = 2_147_483_647;
const formatMaintenanceDuration = (seconds: number) => {
if (seconds === 0) return "Без автоматического отключения";
return [
Math.floor(seconds / 3600) ? `${Math.floor(seconds / 3600)} ч` : "",
Math.floor((seconds % 3600) / 60) ? `${Math.floor((seconds % 3600) / 60)} мин` : "",
seconds % 60 ? `${seconds % 60} с` : "",
].filter(Boolean).join(" ");
};
export type ConnectedDevice = string; export type ConnectedDevice = string;
@@ -95,7 +81,6 @@ type RowData = {
current_snapshot_uuid: string | null; current_snapshot_uuid: string | null;
snapshot_update_blocked: boolean; snapshot_update_blocked: boolean;
maintenance_mode_on: boolean; maintenance_mode_on: boolean;
maintenance_mode_duration_seconds: number;
demo_mode_enabled: boolean; demo_mode_enabled: boolean;
current_route_id: number | null; current_route_id: number | null;
}; };
@@ -135,7 +120,6 @@ const transformToRows = (vehicles: Vehicle[]): RowData[] => {
current_snapshot_uuid: vehicle.vehicle.current_snapshot_uuid ?? null, current_snapshot_uuid: vehicle.vehicle.current_snapshot_uuid ?? null,
snapshot_update_blocked: vehicle.vehicle.snapshot_update_blocked ?? false, snapshot_update_blocked: vehicle.vehicle.snapshot_update_blocked ?? false,
maintenance_mode_on: vehicle.vehicle.maintenance_mode_on ?? false, maintenance_mode_on: vehicle.vehicle.maintenance_mode_on ?? false,
maintenance_mode_duration_seconds: vehicle.vehicle.maintenance_mode_duration_seconds ?? 0,
demo_mode_enabled: vehicle.vehicle.demo_mode_enabled ?? false, demo_mode_enabled: vehicle.vehicle.demo_mode_enabled ?? false,
current_route_id: vehicle.device_status?.current_route_id ?? null, current_route_id: vehicle.device_status?.current_route_id ?? null,
}; };
@@ -185,11 +169,6 @@ export const DevicesTable = observer(() => {
); );
const [maintenanceConfirm, setMaintenanceConfirm] = const [maintenanceConfirm, setMaintenanceConfirm] =
useState<PendingModeToggle | null>(null); useState<PendingModeToggle | null>(null);
const [maintenanceDuration, setMaintenanceDuration] = useState(0);
const [customMaintenanceDuration, setCustomMaintenanceDuration] = useState(false);
const [customMaintenanceHours, setCustomMaintenanceHours] = useState(0);
const [customMaintenanceMinutes, setCustomMaintenanceMinutes] = useState(0);
const [savedMaintenanceDuration, setSavedMaintenanceDuration] = useState(0);
const [demoConfirm, setDemoConfirm] = useState<PendingModeToggle | null>( const [demoConfirm, setDemoConfirm] = useState<PendingModeToggle | null>(
null, null,
); );
@@ -259,7 +238,6 @@ export const DevicesTable = observer(() => {
}, [rows, selectedIds]); }, [rows, selectedIds]);
const applyMaintenanceMode = async (toggle: PendingModeToggle) => { const applyMaintenanceMode = async (toggle: PendingModeToggle) => {
if (!canMaintenanceDevices) return false;
setMaintenanceLoadingUuids((prev) => { setMaintenanceLoadingUuids((prev) => {
const next = new Set(prev); const next = new Set(prev);
next.add(toggle.deviceUuid); next.add(toggle.deviceUuid);
@@ -267,42 +245,20 @@ export const DevicesTable = observer(() => {
}); });
try { try {
const result = await setMaintenanceMode( await setMaintenanceMode(toggle.deviceUuid, toggle.nextEnabled);
toggle.deviceUuid, await getVehicles();
toggle.nextEnabled, await getDevices();
toggle.nextEnabled ? maintenanceDuration : undefined, toast.success(
toggle.nextEnabled
? "Устройство отправлено на ТО"
: "Режим ТО отключен",
); );
if (!result.sent) {
toast.warning(
result.enabled
? "Настройка сохранена. Режим ТО включится после подключения устройства."
: "Настройка сохранена. Режим ТО отключится после подключения устройства.",
);
} else {
toast.success(result.enabled
? `Команда включения ТО отправлена. Длительность: ${formatMaintenanceDuration(result.duration_seconds)}`
: "Команда отключения ТО отправлена");
}
const refresh = await Promise.allSettled([getVehicles(), getDevices()]);
if (refresh.some((result) => result.status === "rejected")) {
toast.warning("Настройка ТО сохранена, но обновить таблицу не удалось. Обновите страницу.");
}
return true;
} catch (error) { } catch (error) {
console.error( console.error(
`Error toggling maintenance mode for ${toggle.deviceUuid}:`, `Error toggling maintenance mode for ${toggle.deviceUuid}:`,
error, error,
); );
const status = isAxiosError(error) ? error.response?.status : undefined; toast.error("Не удалось изменить режим ТО");
const messages: Record<number, string> = {
400: "Некорректные параметры режима ТО",
401: "Войдите в систему, чтобы изменить режим ТО",
403: "Нет права на управление режимом ТО",
404: "Устройство не найдено",
500: "Сервер не смог сохранить настройку ТО",
};
toast.error((status && messages[status]) || "Не удалось изменить режим ТО");
return false;
} finally { } finally {
setMaintenanceLoadingUuids((prev) => { setMaintenanceLoadingUuids((prev) => {
const next = new Set(prev); const next = new Set(prev);
@@ -342,12 +298,7 @@ export const DevicesTable = observer(() => {
}; };
const openMaintenanceConfirm = (row: RowData) => { const openMaintenanceConfirm = (row: RowData) => {
if (!row.device_uuid || !canMaintenanceDevices || maintenanceConfirmSubmitting) return; if (!row.device_uuid) return;
setMaintenanceDuration(row.maintenance_mode_duration_seconds);
setCustomMaintenanceDuration(false);
setCustomMaintenanceHours(Math.floor(row.maintenance_mode_duration_seconds / 3600));
setCustomMaintenanceMinutes(Math.floor((row.maintenance_mode_duration_seconds % 3600) / 60));
setSavedMaintenanceDuration(row.maintenance_mode_duration_seconds);
setMaintenanceConfirm({ setMaintenanceConfirm({
deviceUuid: row.device_uuid, deviceUuid: row.device_uuid,
nextEnabled: !row.maintenance_mode_on, nextEnabled: !row.maintenance_mode_on,
@@ -365,13 +316,12 @@ export const DevicesTable = observer(() => {
}; };
const handleConfirmMaintenanceToggle = async () => { const handleConfirmMaintenanceToggle = async () => {
if (!maintenanceConfirm || !canMaintenanceDevices || maintenanceConfirmSubmitting) return; if (!maintenanceConfirm) return;
setMaintenanceConfirmSubmitting(true); setMaintenanceConfirmSubmitting(true);
try { try {
if (await applyMaintenanceMode(maintenanceConfirm)) { await applyMaintenanceMode(maintenanceConfirm);
setMaintenanceConfirm(null); setMaintenanceConfirm(null);
}
} finally { } finally {
setMaintenanceConfirmSubmitting(false); setMaintenanceConfirmSubmitting(false);
} }
@@ -491,13 +441,9 @@ export const DevicesTable = observer(() => {
sx={{ display: "flex", justifyContent: "center", width: "100%" }} sx={{ display: "flex", justifyContent: "center", width: "100%" }}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<Tooltip title={`Сохранённая длительность ТО: ${formatMaintenanceDuration(rowData.maintenance_mode_duration_seconds)}`}>
<span>
<Checkbox <Checkbox
checked={rowData.maintenance_mode_on} checked={rowData.maintenance_mode_on}
inputProps={{ "aria-label": `Режим ТО: ${rowData.tail_number}` }}
disabled={ disabled={
!canMaintenanceDevices ||
!rowData.device_uuid || !rowData.device_uuid ||
isMaintenanceLoading || isMaintenanceLoading ||
maintenanceConfirmSubmitting maintenanceConfirmSubmitting
@@ -506,8 +452,6 @@ export const DevicesTable = observer(() => {
sx={{ p: 0 }} sx={{ p: 0 }}
onChange={() => openMaintenanceConfirm(rowData)} onChange={() => openMaintenanceConfirm(rowData)}
/> />
</span>
</Tooltip>
</Box> </Box>
); );
}, },
@@ -750,7 +694,6 @@ export const DevicesTable = observer(() => {
demoConfirmSubmitting, demoConfirmSubmitting,
routes, routes,
canWriteDevices, canWriteDevices,
canMaintenanceDevices,
isMaintenanceOnly, isMaintenanceOnly,
], ],
); );
@@ -765,7 +708,7 @@ export const DevicesTable = observer(() => {
} }
if (!canWriteDevices) { if (!canWriteDevices) {
return columns.filter( return columns.filter(
(c) => c.field !== "demo_mode_enabled", (c) => c.field !== "maintenance_mode_on" && c.field !== "demo_mode_enabled",
); );
} }
return columns; return columns;
@@ -789,29 +732,6 @@ export const DevicesTable = observer(() => {
fetchData(); fetchData();
}, [getDevices, getSnapshots, getVehicles, getRoutes, isMaintenanceOnly, selectedCityStore.cityVersion]); }, [getDevices, getSnapshots, getVehicles, getRoutes, isMaintenanceOnly, selectedCityStore.cityVersion]);
useEffect(() => {
let refreshing = false;
const refreshMaintenance = async () => {
if (document.visibilityState !== "visible" || refreshing || maintenanceConfirmSubmitting) return;
refreshing = true;
try {
const results = await Promise.allSettled([getVehicles(), getDevices()]);
for (const result of results) {
if (result.status === "rejected") {
console.error("Failed to refresh maintenance state:", result.reason);
}
}
} finally {
refreshing = false;
}
};
const timer = window.setInterval(refreshMaintenance, 15_000);
document.addEventListener("visibilitychange", refreshMaintenance);
return () => {
window.clearInterval(timer);
document.removeEventListener("visibilitychange", refreshMaintenance);
};
}, [getVehicles, getDevices, maintenanceConfirmSubmitting]);
const handleOpenSendSnapshotModal = () => { const handleOpenSendSnapshotModal = () => {
if (!canWriteDevices) { if (!canWriteDevices) {
@@ -1107,83 +1027,13 @@ export const DevicesTable = observer(() => {
sx={{ width: "min(640px, 92vw)", p: 3 }} sx={{ width: "min(640px, 92vw)", p: 3 }}
> >
<Box component="h2" sx={{ mb: 1, typography: "h6" }}> <Box component="h2" sx={{ mb: 1, typography: "h6" }}>
{maintenanceConfirm?.nextEnabled ? "Включить режим ТО" : "Отключить режим ТО"} Подтверждение режима ТО
</Box> </Box>
<Box sx={{ mb: 3 }}> <Box sx={{ mb: 3 }}>
{maintenanceConfirm?.nextEnabled {maintenanceConfirm?.nextEnabled
? `Включить режим ТО для устройства ${maintenanceConfirm.tailNumber}?` ? `Включить режим ТО для устройства ${maintenanceConfirm.tailNumber}?`
: `Отключить режим ТО для устройства ${maintenanceConfirm?.tailNumber}?`} : `Отключить режим ТО для устройства ${maintenanceConfirm?.tailNumber}?`}
</Box> </Box>
{maintenanceConfirm?.nextEnabled && (
<Box sx={{ mb: 3 }}>
<TextField
select
fullWidth
label="Длительность"
value={customMaintenanceDuration ? -1 : maintenanceDuration}
disabled={maintenanceConfirmSubmitting}
onChange={(event) => {
const seconds = Number(event.target.value);
if (seconds === -1) {
setCustomMaintenanceDuration(true);
return;
}
setCustomMaintenanceDuration(false);
setMaintenanceDuration(seconds);
}}
helperText="Автоотключение выполняется на устройстве после применения команды."
>
{maintenanceDurations.map((seconds) => (
<MenuItem key={seconds} value={seconds}>
{formatMaintenanceDuration(seconds)}
</MenuItem>
))}
{!maintenanceDurations.includes(savedMaintenanceDuration) && (
<MenuItem value={savedMaintenanceDuration}>
{formatMaintenanceDuration(savedMaintenanceDuration)} (сохранено)
</MenuItem>
)}
<MenuItem value={-1}>Своё время</MenuItem>
</TextField>
{customMaintenanceDuration && (
<Box sx={{ display: "flex", gap: 1.5, mt: 2 }}>
<TextField
type="number"
fullWidth
label="Часы"
value={customMaintenanceHours}
disabled={maintenanceConfirmSubmitting}
inputProps={{ min: 0, max: Math.floor(MAX_MAINTENANCE_DURATION_SECONDS / 3600), step: 1 }}
onChange={(event) => {
const hours = Math.max(0, Math.floor(Number(event.target.value) || 0));
setCustomMaintenanceHours(hours);
setMaintenanceDuration(hours * 3600 + customMaintenanceMinutes * 60);
}}
/>
<TextField
type="number"
fullWidth
label="Минуты"
value={customMaintenanceMinutes}
disabled={maintenanceConfirmSubmitting}
inputProps={{ min: 0, max: 59, step: 1 }}
onChange={(event) => {
const minutes = Math.min(59, Math.max(0, Math.floor(Number(event.target.value) || 0)));
setCustomMaintenanceMinutes(minutes);
setMaintenanceDuration(customMaintenanceHours * 3600 + minutes * 60);
}}
/>
</Box>
)}
{customMaintenanceDuration && (maintenanceDuration < 60 || maintenanceDuration > MAX_MAINTENANCE_DURATION_SECONDS) && (
<Typography color="error" variant="caption" sx={{ display: "block", mt: 1 }}>
{maintenanceDuration < 60
? "Укажите длительность не меньше одной минуты."
: "Указанная длительность превышает допустимый максимум."}
</Typography>
)}
</Box>
)}
<Box sx={{ display: "flex", gap: 1.5 }}> <Box sx={{ display: "flex", gap: 1.5 }}>
<Button <Button
fullWidth fullWidth
@@ -1197,15 +1047,10 @@ export const DevicesTable = observer(() => {
<Button <Button
fullWidth fullWidth
variant="contained" variant="contained"
disabled={ disabled={maintenanceConfirmSubmitting}
maintenanceConfirmSubmitting ||
(Boolean(maintenanceConfirm?.nextEnabled) &&
customMaintenanceDuration &&
(maintenanceDuration < 60 || maintenanceDuration > MAX_MAINTENANCE_DURATION_SECONDS))
}
onClick={handleConfirmMaintenanceToggle} onClick={handleConfirmMaintenanceToggle}
> >
{maintenanceConfirmSubmitting ? "Сохранение…" : maintenanceConfirm?.nextEnabled ? "Включить" : "Отключить"} Подтвердить
</Button> </Button>
</Box> </Box>
</Modal> </Modal>

View File

@@ -1,7 +1,7 @@
import React, { useRef, useState, DragEvent } from "react"; import React, { useRef, DragEvent } from "react";
import { Paper, Box, Typography, Button, Tooltip } from "@mui/material"; import { Paper, Box, Typography, Button, Tooltip } from "@mui/material";
import { X, Info, Plus } from "lucide-react"; import { X, Info, Plus } from "lucide-react";
import { editSightStore, PreviewBackgroundToggle } from "@shared"; import { editSightStore } from "@shared";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
interface ImageUploadCardProps { interface ImageUploadCardProps {
title: string; title: string;
@@ -33,7 +33,6 @@ export const ImageUploadCard: React.FC<ImageUploadCardProps> = ({
tooltipText, tooltipText,
}) => { }) => {
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const [isPreviewBackgroundDark, setIsPreviewBackgroundDark] = useState(false);
const { setFileToUpload } = editSightStore; const { setFileToUpload } = editSightStore;
const handleZoneClick = () => { const handleZoneClick = () => {
@@ -125,21 +124,9 @@ export const ImageUploadCard: React.FC<ImageUploadCardProps> = ({
borderRadius: 1, borderRadius: 1,
mb: 1, mb: 1,
cursor: imageUrl ? "pointer" : "default", cursor: imageUrl ? "pointer" : "default",
backgroundColor: imageUrl
? isPreviewBackgroundDark
? "#000"
: "#fff"
: "transparent",
transition: "background-color 150ms ease",
}} }}
onClick={onImageClick} onClick={onImageClick}
> >
{imageUrl && (
<PreviewBackgroundToggle
dark={isPreviewBackgroundDark}
onToggle={() => setIsPreviewBackgroundDark((value) => !value)}
/>
)}
{imageUrl && ( {imageUrl && (
<button <button
className="absolute top-2 right-2" className="absolute top-2 right-2"

View File

@@ -42,14 +42,7 @@ export const Layout: React.FC<LayoutProps> = observer(({ children }) => {
}; };
return ( return (
<Box <Box sx={{ display: "flex" }}>
sx={{
display: "flex",
height: "100vh",
minHeight: 0,
overflow: "hidden",
}}
>
<AppBar position="fixed" open={open}> <AppBar position="fixed" open={open}>
<Toolbar className="flex justify-between"> <Toolbar className="flex justify-between">
<div className="flex items-center"> <div className="flex items-center">
@@ -156,14 +149,9 @@ export const Layout: React.FC<LayoutProps> = observer(({ children }) => {
sx={{ sx={{
width: "100%", width: "100%",
flexGrow: 1, flexGrow: 1,
minWidth: 0,
minHeight: 0,
height: "100vh",
p: 3, p: 3,
overflowY: "auto", overflow: "auto",
overflowX: "hidden",
maxWidth: "100vw", maxWidth: "100vw",
boxSizing: "border-box",
}} }}
> >
<div className="mt-16"></div> <div className="mt-16"></div>

View File

@@ -1,128 +0,0 @@
import {
Checkbox,
Typography,
Box,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
Radio,
RadioGroup,
} from "@mui/material";
import { ROLE_RESOURCES, getPermissionLevel, applyPermissionChange, type PermissionLevel } from "./constants";
interface PermissionsTableProps {
localRoles: string[];
setLocalRoles: React.Dispatch<React.SetStateAction<string[]>>;
}
export function PermissionsTable({ localRoles, setLocalRoles }: PermissionsTableProps) {
return (
<Box sx={{ border: "1px solid", borderColor: "divider", borderRadius: 1 }}>
<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 isDevicesResource = key === "devices";
const handleChange = (val: string) => {
setLocalRoles((prev) => {
let updated = applyPermissionChange(prev, key, val as PermissionLevel);
if (key === "devices") {
updated = applyPermissionChange(updated, "vehicles", val as PermissionLevel);
}
return updated;
});
};
const handleSnapshotCreateChange = (checked: boolean) => {
setLocalRoles((prev) => {
const without = prev.filter((role) => role !== "snapshot_create");
return checked ? [...without, "snapshot_create"] : without;
});
};
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 ? (
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "center" }}>
<Checkbox
checked={localRoles.includes("devices_maintenance_rw")}
onChange={(e) => handleMaintenanceChange(e.target.checked)}
size="small"
title="Техническое обслуживание (ТО)"
/>
</Box>
) : (
<Typography variant="body2" color="text.secondary">-</Typography>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</Box>
);
}

View File

@@ -1,49 +0,0 @@
import {
Typography,
Box,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
} from "@mui/material";
const ROLE_HINTS = [
{ tab: "Экспорт", roles: "Экспорт (Ч/З)" },
{ tab: "Создание экспорта", roles: "Экспорт (доп. права) + Устройства (Ч/З)" },
{ tab: "Устройства", roles: "Устройства + Транспорт + Маршруты + Перевозчики + Экспорт (Ч/З)" },
{ tab: "Карта", roles: "Маршруты (Ч/З) или Остановки (Ч/З) или Достопримечательности (Ч/З)" },
{ tab: "Пользователи", roles: "Пользователи" },
{ tab: "Достопримечательности", roles: "Достопримечательности" },
{ tab: "Остановки", roles: "Остановки" },
{ tab: "Маршруты", roles: "Маршруты + Перевозчики" },
{ tab: "Страны", roles: "Страны" },
{ tab: "Города", roles: "Города + Страны" },
{ tab: "Перевозчики", roles: "Перевозчики" },
];
export function RolesHintTable() {
return (
<Box sx={{ mt: 2, p: 2, bgcolor: "grey.50", borderRadius: 1, border: "1px solid", borderColor: "divider" }}>
<Typography variant="subtitle2" gutterBottom>
Какие роли нужны для вкладок
</Typography>
<Table size="small">
<TableHead>
<TableRow>
<TableCell sx={{ fontWeight: 600, py: 0.5 }}>Вкладка</TableCell>
<TableCell sx={{ fontWeight: 600, py: 0.5 }}>Необходимые роли</TableCell>
</TableRow>
</TableHead>
<TableBody>
{ROLE_HINTS.map(({ tab, roles }) => (
<TableRow key={tab}>
<TableCell sx={{ py: 0.5 }}>{tab}</TableCell>
<TableCell sx={{ py: 0.5 }}>{roles}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Box>
);
}

View File

@@ -1,33 +0,0 @@
export 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;
export type PermissionLevel = "none" | "ro" | "rw";
export function getPermissionLevel(roles: string[], resource: string): PermissionLevel {
if (roles.includes(`${resource}_rw`)) return "rw";
if (roles.includes(`${resource}_ro`)) return "ro";
return "none";
}
export 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;
}

View File

@@ -1,3 +0,0 @@
export { PermissionsTable } from "./PermissionsTable";
export { RolesHintTable } from "./RolesHintTable";
export { ROLE_RESOURCES, type PermissionLevel, getPermissionLevel, applyPermissionChange } from "./constants";

View File

@@ -164,7 +164,6 @@
position: relative; position: relative;
padding: 7px; padding: 7px;
width: 100%; width: 100%;
height: 60px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-around; justify-content: space-around;
@@ -268,3 +267,4 @@
.sfp-sight-frame-media-stack.three-d-view { .sfp-sight-frame-media-stack.three-d-view {
background-color: #111 !important; background-color: #111 !important;
} }

View File

@@ -21,4 +21,3 @@ export * from "./SaveWithoutCityAgree";
export * from "./CitySelector"; export * from "./CitySelector";
export * from "./modals"; export * from "./modals";
export * from "./TestingModeBanner"; export * from "./TestingModeBanner";
export * from "./PermissionsTable";

1
tsconfig.tsbuildinfo Normal file

File diff suppressed because one or more lines are too long

474
yarn.lock
View File

@@ -16,7 +16,7 @@
resolved "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz" resolved "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz"
integrity sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA== integrity sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==
"@babel/core@^7.0.0", "@babel/core@^7.0.0-0", "@babel/core@^7.21.3", "@babel/core@^7.28.0": "@babel/core@^7.21.3", "@babel/core@^7.28.0":
version "7.28.5" version "7.28.5"
resolved "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz" resolved "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz"
integrity sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw== integrity sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==
@@ -170,6 +170,28 @@
resolved "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz" resolved "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz"
integrity sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow== integrity sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==
"@emnapi/core@^1.5.0":
version "1.10.0"
resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.10.0.tgz#380ccc8f2412ea22d1d972df7f8ee23a3b9c7467"
integrity sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==
dependencies:
"@emnapi/wasi-threads" "1.2.1"
tslib "^2.4.0"
"@emnapi/runtime@^1.5.0":
version "1.10.0"
resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.10.0.tgz#4b260c0d3534204e98c6110b8db1a987d26ec87c"
integrity sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==
dependencies:
tslib "^2.4.0"
"@emnapi/wasi-threads@1.2.1", "@emnapi/wasi-threads@^1.1.0":
version "1.2.1"
resolved "https://registry.yarnpkg.com/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz#28fed21a1ba1ce797c44a070abc94d42f3ae8548"
integrity sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==
dependencies:
tslib "^2.4.0"
"@emotion/babel-plugin@^11.13.5": "@emotion/babel-plugin@^11.13.5":
version "11.13.5" version "11.13.5"
resolved "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz" resolved "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz"
@@ -215,7 +237,7 @@
resolved "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz" resolved "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz"
integrity sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ== integrity sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==
"@emotion/react@^11.0.0-rc.0", "@emotion/react@^11.14.0", "@emotion/react@^11.4.1", "@emotion/react@^11.5.0", "@emotion/react@^11.9.0": "@emotion/react@^11.14.0":
version "11.14.0" version "11.14.0"
resolved "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz" resolved "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz"
integrity sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA== integrity sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==
@@ -245,7 +267,7 @@
resolved "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz" resolved "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz"
integrity sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg== integrity sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==
"@emotion/styled@^11.14.0", "@emotion/styled@^11.3.0", "@emotion/styled@^11.8.1": "@emotion/styled@^11.14.0":
version "11.14.1" version "11.14.1"
resolved "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz" resolved "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz"
integrity sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw== integrity sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==
@@ -277,11 +299,136 @@
resolved "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz" resolved "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz"
integrity sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg== integrity sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==
"@esbuild/aix-ppc64@0.25.11":
version "0.25.11"
resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.11.tgz#2ae33300598132cc4cf580dbbb28d30fed3c5c49"
integrity sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg==
"@esbuild/android-arm64@0.25.11":
version "0.25.11"
resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.25.11.tgz#927708b3db5d739d6cb7709136924cc81bec9b03"
integrity sha512-9slpyFBc4FPPz48+f6jyiXOx/Y4v34TUeDDXJpZqAWQn/08lKGeD8aDp9TMn9jDz2CiEuHwfhRmGBvpnd/PWIQ==
"@esbuild/android-arm@0.25.11":
version "0.25.11"
resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.25.11.tgz#571f94e7f4068957ec4c2cfb907deae3d01b55ae"
integrity sha512-uoa7dU+Dt3HYsethkJ1k6Z9YdcHjTrSb5NUy66ZfZaSV8hEYGD5ZHbEMXnqLFlbBflLsl89Zke7CAdDJ4JI+Gg==
"@esbuild/android-x64@0.25.11":
version "0.25.11"
resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.25.11.tgz#8a3bf5cae6c560c7ececa3150b2bde76e0fb81e6"
integrity sha512-Sgiab4xBjPU1QoPEIqS3Xx+R2lezu0LKIEcYe6pftr56PqPygbB7+szVnzoShbx64MUupqoE0KyRlN7gezbl8g==
"@esbuild/darwin-arm64@0.25.11": "@esbuild/darwin-arm64@0.25.11":
version "0.25.11" version "0.25.11"
resolved "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.11.tgz" resolved "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.11.tgz"
integrity sha512-VekY0PBCukppoQrycFxUqkCojnTQhdec0vevUL/EDOCnXd9LKWqD/bHwMPzigIJXPhC59Vd1WFIL57SKs2mg4w== integrity sha512-VekY0PBCukppoQrycFxUqkCojnTQhdec0vevUL/EDOCnXd9LKWqD/bHwMPzigIJXPhC59Vd1WFIL57SKs2mg4w==
"@esbuild/darwin-x64@0.25.11":
version "0.25.11"
resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.25.11.tgz#70f5e925a30c8309f1294d407a5e5e002e0315fe"
integrity sha512-+hfp3yfBalNEpTGp9loYgbknjR695HkqtY3d3/JjSRUyPg/xd6q+mQqIb5qdywnDxRZykIHs3axEqU6l1+oWEQ==
"@esbuild/freebsd-arm64@0.25.11":
version "0.25.11"
resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.11.tgz#4ec1db687c5b2b78b44148025da9632397553e8a"
integrity sha512-CmKjrnayyTJF2eVuO//uSjl/K3KsMIeYeyN7FyDBjsR3lnSJHaXlVoAK8DZa7lXWChbuOk7NjAc7ygAwrnPBhA==
"@esbuild/freebsd-x64@0.25.11":
version "0.25.11"
resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.11.tgz#4c81abd1b142f1e9acfef8c5153d438ca53f44bb"
integrity sha512-Dyq+5oscTJvMaYPvW3x3FLpi2+gSZTCE/1ffdwuM6G1ARang/mb3jvjxs0mw6n3Lsw84ocfo9CrNMqc5lTfGOw==
"@esbuild/linux-arm64@0.25.11":
version "0.25.11"
resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.25.11.tgz#69517a111acfc2b93aa0fb5eaeb834c0202ccda5"
integrity sha512-Qr8AzcplUhGvdyUF08A1kHU3Vr2O88xxP0Tm8GcdVOUm25XYcMPp2YqSVHbLuXzYQMf9Bh/iKx7YPqECs6ffLA==
"@esbuild/linux-arm@0.25.11":
version "0.25.11"
resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.25.11.tgz#58dac26eae2dba0fac5405052b9002dac088d38f"
integrity sha512-TBMv6B4kCfrGJ8cUPo7vd6NECZH/8hPpBHHlYI3qzoYFvWu2AdTvZNuU/7hsbKWqu/COU7NIK12dHAAqBLLXgw==
"@esbuild/linux-ia32@0.25.11":
version "0.25.11"
resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.25.11.tgz#b89d4efe9bdad46ba944f0f3b8ddd40834268c2b"
integrity sha512-TmnJg8BMGPehs5JKrCLqyWTVAvielc615jbkOirATQvWWB1NMXY77oLMzsUjRLa0+ngecEmDGqt5jiDC6bfvOw==
"@esbuild/linux-loong64@0.25.11":
version "0.25.11"
resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.25.11.tgz#11f603cb60ad14392c3f5c94d64b3cc8b630fbeb"
integrity sha512-DIGXL2+gvDaXlaq8xruNXUJdT5tF+SBbJQKbWy/0J7OhU8gOHOzKmGIlfTTl6nHaCOoipxQbuJi7O++ldrxgMw==
"@esbuild/linux-mips64el@0.25.11":
version "0.25.11"
resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.11.tgz#b7d447ff0676b8ab247d69dac40a5cf08e5eeaf5"
integrity sha512-Osx1nALUJu4pU43o9OyjSCXokFkFbyzjXb6VhGIJZQ5JZi8ylCQ9/LFagolPsHtgw6himDSyb5ETSfmp4rpiKQ==
"@esbuild/linux-ppc64@0.25.11":
version "0.25.11"
resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.11.tgz#b3a28ed7cc252a61b07ff7c8fd8a984ffd3a2f74"
integrity sha512-nbLFgsQQEsBa8XSgSTSlrnBSrpoWh7ioFDUmwo158gIm5NNP+17IYmNWzaIzWmgCxq56vfr34xGkOcZ7jX6CPw==
"@esbuild/linux-riscv64@0.25.11":
version "0.25.11"
resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.11.tgz#ce75b08f7d871a75edcf4d2125f50b21dc9dc273"
integrity sha512-HfyAmqZi9uBAbgKYP1yGuI7tSREXwIb438q0nqvlpxAOs3XnZ8RsisRfmVsgV486NdjD7Mw2UrFSw51lzUk1ww==
"@esbuild/linux-s390x@0.25.11":
version "0.25.11"
resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.25.11.tgz#cd08f6c73b6b6ff9ccdaabbd3ff6ad3dca99c263"
integrity sha512-HjLqVgSSYnVXRisyfmzsH6mXqyvj0SA7pG5g+9W7ESgwA70AXYNpfKBqh1KbTxmQVaYxpzA/SvlB9oclGPbApw==
"@esbuild/linux-x64@0.25.11":
version "0.25.11"
resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.25.11.tgz#3c3718af31a95d8946ebd3c32bb1e699bdf74910"
integrity sha512-HSFAT4+WYjIhrHxKBwGmOOSpphjYkcswF449j6EjsjbinTZbp8PJtjsVK1XFJStdzXdy/jaddAep2FGY+wyFAQ==
"@esbuild/netbsd-arm64@0.25.11":
version "0.25.11"
resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.11.tgz#b4c767082401e3a4e8595fe53c47cd7f097c8077"
integrity sha512-hr9Oxj1Fa4r04dNpWr3P8QKVVsjQhqrMSUzZzf+LZcYjZNqhA3IAfPQdEh1FLVUJSiu6sgAwp3OmwBfbFgG2Xg==
"@esbuild/netbsd-x64@0.25.11":
version "0.25.11"
resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.11.tgz#f2a930458ed2941d1f11ebc34b9c7d61f7a4d034"
integrity sha512-u7tKA+qbzBydyj0vgpu+5h5AeudxOAGncb8N6C9Kh1N4n7wU1Xw1JDApsRjpShRpXRQlJLb9wY28ELpwdPcZ7A==
"@esbuild/openbsd-arm64@0.25.11":
version "0.25.11"
resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.11.tgz#b4ae93c75aec48bc1e8a0154957a05f0641f2dad"
integrity sha512-Qq6YHhayieor3DxFOoYM1q0q1uMFYb7cSpLD2qzDSvK1NAvqFi8Xgivv0cFC6J+hWVw2teCYltyy9/m/14ryHg==
"@esbuild/openbsd-x64@0.25.11":
version "0.25.11"
resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.11.tgz#b42863959c8dcf9b01581522e40012d2c70045e2"
integrity sha512-CN+7c++kkbrckTOz5hrehxWN7uIhFFlmS/hqziSFVWpAzpWrQoAG4chH+nN3Be+Kzv/uuo7zhX716x3Sn2Jduw==
"@esbuild/openharmony-arm64@0.25.11":
version "0.25.11"
resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.11.tgz#b2e717141c8fdf6bddd4010f0912e6b39e1640f1"
integrity sha512-rOREuNIQgaiR+9QuNkbkxubbp8MSO9rONmwP5nKncnWJ9v5jQ4JxFnLu4zDSRPf3x4u+2VN4pM4RdyIzDty/wQ==
"@esbuild/sunos-x64@0.25.11":
version "0.25.11"
resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.25.11.tgz#9fbea1febe8778927804828883ec0f6dd80eb244"
integrity sha512-nq2xdYaWxyg9DcIyXkZhcYulC6pQ2FuCgem3LI92IwMgIZ69KHeY8T4Y88pcwoLIjbed8n36CyKoYRDygNSGhA==
"@esbuild/win32-arm64@0.25.11":
version "0.25.11"
resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.25.11.tgz#501539cedb24468336073383989a7323005a8935"
integrity sha512-3XxECOWJq1qMZ3MN8srCJ/QfoLpL+VaxD/WfNRm1O3B4+AZ/BnLVgFbUV3eiRYDMXetciH16dwPbbHqwe1uU0Q==
"@esbuild/win32-ia32@0.25.11":
version "0.25.11"
resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.25.11.tgz#8ac7229aa82cef8f16ffb58f1176a973a7a15343"
integrity sha512-3ukss6gb9XZ8TlRyJlgLn17ecsK4NSQTmdIXRASVsiS2sQ6zPPZklNJT5GR5tE/MUarymmy8kCEf5xPCNCqVOA==
"@esbuild/win32-x64@0.25.11":
version "0.25.11"
resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.25.11.tgz#5ecda6f3fe138b7e456f4e429edde33c823f392f"
integrity sha512-D7Hpz6A2L4hzsRpPaCYkQnGOotdUpDzSGRIv9I+1ITdHROSFUWW95ZPZWQmGka1Fg7W3zFJowyn9WGwMJ0+KPA==
"@eslint-community/eslint-utils@^4.7.0", "@eslint-community/eslint-utils@^4.8.0": "@eslint-community/eslint-utils@^4.7.0", "@eslint-community/eslint-utils@^4.8.0":
version "4.9.0" version "4.9.0"
resolved "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz" resolved "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz"
@@ -332,7 +479,7 @@
minimatch "^3.1.2" minimatch "^3.1.2"
strip-json-comments "^3.1.1" strip-json-comments "^3.1.1"
"@eslint/js@^9.25.0", "@eslint/js@9.38.0": "@eslint/js@9.38.0", "@eslint/js@^9.25.0":
version "9.38.0" version "9.38.0"
resolved "https://registry.npmjs.org/@eslint/js/-/js-9.38.0.tgz" resolved "https://registry.npmjs.org/@eslint/js/-/js-9.38.0.tgz"
integrity sha512-UZ1VpFvXf9J06YG9xQBdnzU+kthors6KjhMAl6f4gH4usHyh31rUf2DLGInT8RFYIReYXNSydgPY0V2LuWgl7A== integrity sha512-UZ1VpFvXf9J06YG9xQBdnzU+kthors6KjhMAl6f4gH4usHyh31rUf2DLGInT8RFYIReYXNSydgPY0V2LuWgl7A==
@@ -442,7 +589,7 @@
dependencies: dependencies:
"@babel/runtime" "^7.28.4" "@babel/runtime" "^7.28.4"
"@mui/material@^5.15.14 || ^6.0.0 || ^7.0.0", "@mui/material@^7.1.0", "@mui/material@^7.3.4": "@mui/material@^7.1.0":
version "7.3.4" version "7.3.4"
resolved "https://registry.npmjs.org/@mui/material/-/material-7.3.4.tgz" resolved "https://registry.npmjs.org/@mui/material/-/material-7.3.4.tgz"
integrity sha512-gEQL9pbJZZHT7lYJBKQCS723v1MGys2IFc94COXbUIyCTWa+qC77a7hUax4Yjd5ggEm35dk4AyYABpKKWC4MLw== integrity sha512-gEQL9pbJZZHT7lYJBKQCS723v1MGys2IFc94COXbUIyCTWa+qC77a7hUax4Yjd5ggEm35dk4AyYABpKKWC4MLw==
@@ -481,7 +628,7 @@
csstype "^3.1.3" csstype "^3.1.3"
prop-types "^15.8.1" prop-types "^15.8.1"
"@mui/system@^5.15.14 || ^6.0.0 || ^7.0.0", "@mui/system@^7.3.3": "@mui/system@^7.3.3":
version "7.3.3" version "7.3.3"
resolved "https://registry.npmjs.org/@mui/system/-/system-7.3.3.tgz" resolved "https://registry.npmjs.org/@mui/system/-/system-7.3.3.tgz"
integrity sha512-Lqq3emZr5IzRLKaHPuMaLBDVaGvxoh6z7HMWd1RPKawBM5uMRaQ4ImsmmgXWtwJdfZux5eugfDhXJUo2mliS8Q== integrity sha512-Lqq3emZr5IzRLKaHPuMaLBDVaGvxoh6z7HMWd1RPKawBM5uMRaQ4ImsmmgXWtwJdfZux5eugfDhXJUo2mliS8Q==
@@ -546,6 +693,13 @@
"@mui/utils" "^7.3.3" "@mui/utils" "^7.3.3"
"@mui/x-internals" "8.14.0" "@mui/x-internals" "8.14.0"
"@napi-rs/wasm-runtime@^1.0.7":
version "1.1.4"
resolved "https://registry.yarnpkg.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz#a46bbfedc29751b7170c5d23bc1d8ee8c7e3c1e1"
integrity sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==
dependencies:
"@tybys/wasm-util" "^0.10.1"
"@nodelib/fs.scandir@2.1.5": "@nodelib/fs.scandir@2.1.5":
version "2.1.5" version "2.1.5"
resolved "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz" resolved "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz"
@@ -554,7 +708,7 @@
"@nodelib/fs.stat" "2.0.5" "@nodelib/fs.stat" "2.0.5"
run-parallel "^1.1.9" run-parallel "^1.1.9"
"@nodelib/fs.stat@^2.0.2", "@nodelib/fs.stat@2.0.5": "@nodelib/fs.stat@2.0.5", "@nodelib/fs.stat@^2.0.2":
version "2.0.5" version "2.0.5"
resolved "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz" resolved "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz"
integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A== integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==
@@ -572,7 +726,7 @@
resolved "https://registry.npmjs.org/@petamoriken/float16/-/float16-3.9.3.tgz" resolved "https://registry.npmjs.org/@petamoriken/float16/-/float16-3.9.3.tgz"
integrity sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g== integrity sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g==
"@photo-sphere-viewer/core@^5.13.2", "@photo-sphere-viewer/core@>=5.13.1": "@photo-sphere-viewer/core@^5.13.2":
version "5.14.0" version "5.14.0"
resolved "https://registry.npmjs.org/@photo-sphere-viewer/core/-/core-5.14.0.tgz" resolved "https://registry.npmjs.org/@photo-sphere-viewer/core/-/core-5.14.0.tgz"
integrity sha512-V0JeDSB1D2Q60Zqn7+0FPjq8gqbKEwuxMzNdTLydefkQugVztLvdZykO+4k5XTpweZ2QAWPH/QOI1xZbsdvR9A== integrity sha512-V0JeDSB1D2Q60Zqn7+0FPjq8gqbKEwuxMzNdTLydefkQugVztLvdZykO+4k5XTpweZ2QAWPH/QOI1xZbsdvR9A==
@@ -624,7 +778,7 @@
utility-types "^3.11.0" utility-types "^3.11.0"
zustand "^5.0.1" zustand "^5.0.1"
"@react-three/fiber@^9.0.0", "@react-three/fiber@^9.1.2": "@react-three/fiber@^9.1.2":
version "9.4.0" version "9.4.0"
resolved "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.4.0.tgz" resolved "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.4.0.tgz"
integrity sha512-k4iu1R6e5D54918V4sqmISUkI5OgTw3v7/sDRKEC632Wd5g2WBtUS5gyG63X0GJO/HZUj1tsjSXfyzwrUHZl1g== integrity sha512-k4iu1R6e5D54918V4sqmISUkI5OgTw3v7/sDRKEC632Wd5g2WBtUS5gyG63X0GJO/HZUj1tsjSXfyzwrUHZl1g==
@@ -656,11 +810,116 @@
estree-walker "^2.0.2" estree-walker "^2.0.2"
picomatch "^4.0.2" picomatch "^4.0.2"
"@rollup/rollup-android-arm-eabi@4.52.5":
version "4.52.5"
resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.5.tgz#0f44a2f8668ed87b040b6fe659358ac9239da4db"
integrity sha512-8c1vW4ocv3UOMp9K+gToY5zL2XiiVw3k7f1ksf4yO1FlDFQ1C2u72iACFnSOceJFsWskc2WZNqeRhFRPzv+wtQ==
"@rollup/rollup-android-arm64@4.52.5":
version "4.52.5"
resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.5.tgz#25b9a01deef6518a948431564c987bcb205274f5"
integrity sha512-mQGfsIEFcu21mvqkEKKu2dYmtuSZOBMmAl5CFlPGLY94Vlcm+zWApK7F/eocsNzp8tKmbeBP8yXyAbx0XHsFNA==
"@rollup/rollup-darwin-arm64@4.52.5": "@rollup/rollup-darwin-arm64@4.52.5":
version "4.52.5" version "4.52.5"
resolved "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.5.tgz" resolved "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.5.tgz"
integrity sha512-takF3CR71mCAGA+v794QUZ0b6ZSrgJkArC+gUiG6LB6TQty9T0Mqh3m2ImRBOxS2IeYBo4lKWIieSvnEk2OQWA== integrity sha512-takF3CR71mCAGA+v794QUZ0b6ZSrgJkArC+gUiG6LB6TQty9T0Mqh3m2ImRBOxS2IeYBo4lKWIieSvnEk2OQWA==
"@rollup/rollup-darwin-x64@4.52.5":
version "4.52.5"
resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.5.tgz#8e526417cd6f54daf1d0c04cf361160216581956"
integrity sha512-W901Pla8Ya95WpxDn//VF9K9u2JbocwV/v75TE0YIHNTbhqUTv9w4VuQ9MaWlNOkkEfFwkdNhXgcLqPSmHy0fA==
"@rollup/rollup-freebsd-arm64@4.52.5":
version "4.52.5"
resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.5.tgz#0e7027054493f3409b1f219a3eac5efd128ef899"
integrity sha512-QofO7i7JycsYOWxe0GFqhLmF6l1TqBswJMvICnRUjqCx8b47MTo46W8AoeQwiokAx3zVryVnxtBMcGcnX12LvA==
"@rollup/rollup-freebsd-x64@4.52.5":
version "4.52.5"
resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.5.tgz#72b204a920139e9ec3d331bd9cfd9a0c248ccb10"
integrity sha512-jr21b/99ew8ujZubPo9skbrItHEIE50WdV86cdSoRkKtmWa+DDr6fu2c/xyRT0F/WazZpam6kk7IHBerSL7LDQ==
"@rollup/rollup-linux-arm-gnueabihf@4.52.5":
version "4.52.5"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.5.tgz#ab1b522ebe5b7e06c99504cc38f6cd8b808ba41c"
integrity sha512-PsNAbcyv9CcecAUagQefwX8fQn9LQ4nZkpDboBOttmyffnInRy8R8dSg6hxxl2Re5QhHBf6FYIDhIj5v982ATQ==
"@rollup/rollup-linux-arm-musleabihf@4.52.5":
version "4.52.5"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.5.tgz#f8cc30b638f1ee7e3d18eac24af47ea29d9beb00"
integrity sha512-Fw4tysRutyQc/wwkmcyoqFtJhh0u31K+Q6jYjeicsGJJ7bbEq8LwPWV/w0cnzOqR2m694/Af6hpFayLJZkG2VQ==
"@rollup/rollup-linux-arm64-gnu@4.52.5":
version "4.52.5"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.5.tgz#7af37a9e85f25db59dc8214172907b7e146c12cc"
integrity sha512-a+3wVnAYdQClOTlyapKmyI6BLPAFYs0JM8HRpgYZQO02rMR09ZcV9LbQB+NL6sljzG38869YqThrRnfPMCDtZg==
"@rollup/rollup-linux-arm64-musl@4.52.5":
version "4.52.5"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.5.tgz#a623eb0d3617c03b7a73716eb85c6e37b776f7e0"
integrity sha512-AvttBOMwO9Pcuuf7m9PkC1PUIKsfaAJ4AYhy944qeTJgQOqJYJ9oVl2nYgY7Rk0mkbsuOpCAYSs6wLYB2Xiw0Q==
"@rollup/rollup-linux-loong64-gnu@4.52.5":
version "4.52.5"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.5.tgz#76ea038b549c5c6c5f0d062942627c4066642ee2"
integrity sha512-DkDk8pmXQV2wVrF6oq5tONK6UHLz/XcEVow4JTTerdeV1uqPeHxwcg7aFsfnSm9L+OO8WJsWotKM2JJPMWrQtA==
"@rollup/rollup-linux-ppc64-gnu@4.52.5":
version "4.52.5"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.5.tgz#d9a4c3f0a3492bc78f6fdfe8131ac61c7359ccd5"
integrity sha512-W/b9ZN/U9+hPQVvlGwjzi+Wy4xdoH2I8EjaCkMvzpI7wJUs8sWJ03Rq96jRnHkSrcHTpQe8h5Tg3ZzUPGauvAw==
"@rollup/rollup-linux-riscv64-gnu@4.52.5":
version "4.52.5"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.5.tgz#87ab033eebd1a9a1dd7b60509f6333ec1f82d994"
integrity sha512-sjQLr9BW7R/ZiXnQiWPkErNfLMkkWIoCz7YMn27HldKsADEKa5WYdobaa1hmN6slu9oWQbB6/jFpJ+P2IkVrmw==
"@rollup/rollup-linux-riscv64-musl@4.52.5":
version "4.52.5"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.5.tgz#bda3eb67e1c993c1ba12bc9c2f694e7703958d9f"
integrity sha512-hq3jU/kGyjXWTvAh2awn8oHroCbrPm8JqM7RUpKjalIRWWXE01CQOf/tUNWNHjmbMHg/hmNCwc/Pz3k1T/j/Lg==
"@rollup/rollup-linux-s390x-gnu@4.52.5":
version "4.52.5"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.5.tgz#f7bc10fbe096ab44694233dc42a2291ed5453d4b"
integrity sha512-gn8kHOrku8D4NGHMK1Y7NA7INQTRdVOntt1OCYypZPRt6skGbddska44K8iocdpxHTMMNui5oH4elPH4QOLrFQ==
"@rollup/rollup-linux-x64-gnu@4.52.5":
version "4.52.5"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.5.tgz#a151cb1234cc9b2cf5e8cfc02aa91436b8f9e278"
integrity sha512-hXGLYpdhiNElzN770+H2nlx+jRog8TyynpTVzdlc6bndktjKWyZyiCsuDAlpd+j+W+WNqfcyAWz9HxxIGfZm1Q==
"@rollup/rollup-linux-x64-musl@4.52.5":
version "4.52.5"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.5.tgz#7859e196501cc3b3062d45d2776cfb4d2f3a9350"
integrity sha512-arCGIcuNKjBoKAXD+y7XomR9gY6Mw7HnFBv5Rw7wQRvwYLR7gBAgV7Mb2QTyjXfTveBNFAtPt46/36vV9STLNg==
"@rollup/rollup-openharmony-arm64@4.52.5":
version "4.52.5"
resolved "https://registry.yarnpkg.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.5.tgz#85d0df7233734df31e547c1e647d2a5300b3bf30"
integrity sha512-QoFqB6+/9Rly/RiPjaomPLmR/13cgkIGfA40LHly9zcH1S0bN2HVFYk3a1eAyHQyjs3ZJYlXvIGtcCs5tko9Cw==
"@rollup/rollup-win32-arm64-msvc@4.52.5":
version "4.52.5"
resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.5.tgz#e62357d00458db17277b88adbf690bb855cac937"
integrity sha512-w0cDWVR6MlTstla1cIfOGyl8+qb93FlAVutcor14Gf5Md5ap5ySfQ7R9S/NjNaMLSFdUnKGEasmVnu3lCMqB7w==
"@rollup/rollup-win32-ia32-msvc@4.52.5":
version "4.52.5"
resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.5.tgz#fc7cd40f44834a703c1f1c3fe8bcc27ce476cd50"
integrity sha512-Aufdpzp7DpOTULJCuvzqcItSGDH73pF3ko/f+ckJhxQyHtp67rHw3HMNxoIdDMUITJESNE6a8uh4Lo4SLouOUg==
"@rollup/rollup-win32-x64-gnu@4.52.5":
version "4.52.5"
resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.5.tgz#1a22acfc93c64a64a48c42672e857ee51774d0d3"
integrity sha512-UGBUGPFp1vkj6p8wCRraqNhqwX/4kNQPS57BCFc8wYh0g94iVIW33wJtQAx3G7vrjjNtRaxiMUylM0ktp/TRSQ==
"@rollup/rollup-win32-x64-msvc@4.52.5":
version "4.52.5"
resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.5.tgz#1657f56326bbe0ac80eedc9f9c18fc1ddd24e107"
integrity sha512-TAcgQh2sSkykPRWLrdyy2AiceMckNf5loITqXxFI5VuQjS5tSuw3WlwdN8qv8vzjLAUTvYaH/mVjSFpbkFbpTg==
"@svgr/babel-plugin-add-jsx-attribute@8.0.0": "@svgr/babel-plugin-add-jsx-attribute@8.0.0":
version "8.0.0" version "8.0.0"
resolved "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-8.0.0.tgz" resolved "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-8.0.0.tgz"
@@ -715,7 +974,7 @@
"@svgr/babel-plugin-transform-react-native-svg" "8.1.0" "@svgr/babel-plugin-transform-react-native-svg" "8.1.0"
"@svgr/babel-plugin-transform-svg-component" "8.0.0" "@svgr/babel-plugin-transform-svg-component" "8.0.0"
"@svgr/core@*", "@svgr/core@^8.1.0": "@svgr/core@^8.1.0":
version "8.1.0" version "8.1.0"
resolved "https://registry.npmjs.org/@svgr/core/-/core-8.1.0.tgz" resolved "https://registry.npmjs.org/@svgr/core/-/core-8.1.0.tgz"
integrity sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA== integrity sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==
@@ -757,11 +1016,73 @@
source-map-js "^1.2.1" source-map-js "^1.2.1"
tailwindcss "4.1.16" tailwindcss "4.1.16"
"@tailwindcss/oxide-android-arm64@4.1.16":
version "4.1.16"
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.16.tgz#9bd16c0a08db20d7c93907a9bd1564e0255307eb"
integrity sha512-8+ctzkjHgwDJ5caq9IqRSgsP70xhdhJvm+oueS/yhD5ixLhqTw9fSL1OurzMUhBwE5zK26FXLCz2f/RtkISqHA==
"@tailwindcss/oxide-darwin-arm64@4.1.16": "@tailwindcss/oxide-darwin-arm64@4.1.16":
version "4.1.16" version "4.1.16"
resolved "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.16.tgz" resolved "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.16.tgz"
integrity sha512-C3oZy5042v2FOALBZtY0JTDnGNdS6w7DxL/odvSny17ORUnaRKhyTse8xYi3yKGyfnTUOdavRCdmc8QqJYwFKA== integrity sha512-C3oZy5042v2FOALBZtY0JTDnGNdS6w7DxL/odvSny17ORUnaRKhyTse8xYi3yKGyfnTUOdavRCdmc8QqJYwFKA==
"@tailwindcss/oxide-darwin-x64@4.1.16":
version "4.1.16"
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.16.tgz#6193bafbb1a885795702f12bbef9cc5eb4cc550b"
integrity sha512-vjrl/1Ub9+JwU6BP0emgipGjowzYZMjbWCDqwA2Z4vCa+HBSpP4v6U2ddejcHsolsYxwL5r4bPNoamlV0xDdLg==
"@tailwindcss/oxide-freebsd-x64@4.1.16":
version "4.1.16"
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.16.tgz#0e2b064d71ba87a9001ac963be2752a8ddb64349"
integrity sha512-TSMpPYpQLm+aR1wW5rKuUuEruc/oOX3C7H0BTnPDn7W/eMw8W+MRMpiypKMkXZfwH8wqPIRKppuZoedTtNj2tg==
"@tailwindcss/oxide-linux-arm-gnueabihf@4.1.16":
version "4.1.16"
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.16.tgz#8e80c959eeda81a08ed955e23eb6d228287b9672"
integrity sha512-p0GGfRg/w0sdsFKBjMYvvKIiKy/LNWLWgV/plR4lUgrsxFAoQBFrXkZ4C0w8IOXfslB9vHK/JGASWD2IefIpvw==
"@tailwindcss/oxide-linux-arm64-gnu@4.1.16":
version "4.1.16"
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.16.tgz#d5f54910920fc5808122515f5208c5ecc1a40545"
integrity sha512-DoixyMmTNO19rwRPdqviTrG1rYzpxgyYJl8RgQvdAQUzxC1ToLRqtNJpU/ATURSKgIg6uerPw2feW0aS8SNr/w==
"@tailwindcss/oxide-linux-arm64-musl@4.1.16":
version "4.1.16"
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.16.tgz#67cdb932230ac47bf3bf5415ccc92417b27020ee"
integrity sha512-H81UXMa9hJhWhaAUca6bU2wm5RRFpuHImrwXBUvPbYb+3jo32I9VIwpOX6hms0fPmA6f2pGVlybO6qU8pF4fzQ==
"@tailwindcss/oxide-linux-x64-gnu@4.1.16":
version "4.1.16"
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.16.tgz#80ae0cfd8ebc970f239060ecdfdd07f6f6b14dce"
integrity sha512-ZGHQxDtFC2/ruo7t99Qo2TTIvOERULPl5l0K1g0oK6b5PGqjYMga+FcY1wIUnrUxY56h28FxybtDEla+ICOyew==
"@tailwindcss/oxide-linux-x64-musl@4.1.16":
version "4.1.16"
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.16.tgz#524e5b87e8e79a712de3d9bbb94d2fc2fa44391c"
integrity sha512-Oi1tAaa0rcKf1Og9MzKeINZzMLPbhxvm7rno5/zuP1WYmpiG0bEHq4AcRUiG2165/WUzvxkW4XDYCscZWbTLZw==
"@tailwindcss/oxide-wasm32-wasi@4.1.16":
version "4.1.16"
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.16.tgz#dc31d6bc1f6c1e8119a335ae3f28deb4d7c560f2"
integrity sha512-B01u/b8LteGRwucIBmCQ07FVXLzImWESAIMcUU6nvFt/tYsQ6IHz8DmZ5KtvmwxD+iTYBtM1xwoGXswnlu9v0Q==
dependencies:
"@emnapi/core" "^1.5.0"
"@emnapi/runtime" "^1.5.0"
"@emnapi/wasi-threads" "^1.1.0"
"@napi-rs/wasm-runtime" "^1.0.7"
"@tybys/wasm-util" "^0.10.1"
tslib "^2.4.0"
"@tailwindcss/oxide-win32-arm64-msvc@4.1.16":
version "4.1.16"
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.16.tgz#f1f810cdb49dae8071d5edf0db5cc0da2ec6a7e8"
integrity sha512-zX+Q8sSkGj6HKRTMJXuPvOcP8XfYON24zJBRPlszcH1Np7xuHXhWn8qfFjIujVzvH3BHU+16jBXwgpl20i+v9A==
"@tailwindcss/oxide-win32-x64-msvc@4.1.16":
version "4.1.16"
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.16.tgz#76dcda613578f06569c0a6015f39f12746a24dce"
integrity sha512-m5dDFJUEejbFqP+UXVstd4W/wnxA4F61q8SoL+mqTypId2T2ZpuxosNSgowiCnLp2+Z+rivdU0AqpfgiD7yCBg==
"@tailwindcss/oxide@4.1.16": "@tailwindcss/oxide@4.1.16":
version "4.1.16" version "4.1.16"
resolved "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.16.tgz" resolved "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.16.tgz"
@@ -801,6 +1122,13 @@
resolved "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz" resolved "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz"
integrity sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA== integrity sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==
"@tybys/wasm-util@^0.10.1":
version "0.10.1"
resolved "https://registry.yarnpkg.com/@tybys/wasm-util/-/wasm-util-0.10.1.tgz#ecddd3205cf1e2d5274649ff0eedd2991ed7f414"
integrity sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==
dependencies:
tslib "^2.4.0"
"@types/babel__core@^7.20.5": "@types/babel__core@^7.20.5":
version "7.20.5" version "7.20.5"
resolved "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz" resolved "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz"
@@ -870,7 +1198,7 @@
dependencies: dependencies:
"@types/estree" "*" "@types/estree" "*"
"@types/estree@*", "@types/estree@^1.0.0", "@types/estree@^1.0.6", "@types/estree@1.0.8": "@types/estree@*", "@types/estree@1.0.8", "@types/estree@^1.0.0", "@types/estree@^1.0.6":
version "1.0.8" version "1.0.8"
resolved "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz" resolved "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz"
integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w== integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==
@@ -909,7 +1237,7 @@
resolved "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz" resolved "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz"
integrity sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA== integrity sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==
"@types/node@^18.0.0 || ^20.0.0 || >=22.0.0", "@types/node@^22.15.24": "@types/node@^22.15.24":
version "22.18.13" version "22.18.13"
resolved "https://registry.npmjs.org/@types/node/-/node-22.18.13.tgz" resolved "https://registry.npmjs.org/@types/node/-/node-22.18.13.tgz"
integrity sha512-Bo45YKIjnmFtv6I1TuC8AaHBbqXtIo+Om5fE4QiU1Tj8QR/qt+8O3BAtOimG5IFmwaWiPmB3Mv3jtYzBA4Us2A== integrity sha512-Bo45YKIjnmFtv6I1TuC8AaHBbqXtIo+Om5fE4QiU1Tj8QR/qt+8O3BAtOimG5IFmwaWiPmB3Mv3jtYzBA4Us2A==
@@ -956,7 +1284,7 @@
resolved "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz" resolved "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz"
integrity sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w== integrity sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==
"@types/react@*", "@types/react@^17.0.0 || ^18.0.0 || ^19.0.0", "@types/react@^18.2.25 || ^19", "@types/react@^19.1.2", "@types/react@^19.2.0", "@types/react@>=16.8", "@types/react@>=18", "@types/react@>=18.0.0": "@types/react@^19.1.2":
version "19.2.2" version "19.2.2"
resolved "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz" resolved "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz"
integrity sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA== integrity sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==
@@ -975,7 +1303,7 @@
dependencies: dependencies:
"@types/estree" "*" "@types/estree" "*"
"@types/three@*", "@types/three@>=0.134.0": "@types/three@*":
version "0.180.0" version "0.180.0"
resolved "https://registry.npmjs.org/@types/three/-/three-0.180.0.tgz" resolved "https://registry.npmjs.org/@types/three/-/three-0.180.0.tgz"
integrity sha512-ykFtgCqNnY0IPvDro7h+9ZeLY+qjgUWv+qEvUt84grhenO60Hqd4hScHE7VTB9nOQ/3QM8lkbNE+4vKjEpUxKg== integrity sha512-ykFtgCqNnY0IPvDro7h+9ZeLY+qjgUWv+qEvUt84grhenO60Hqd4hScHE7VTB9nOQ/3QM8lkbNE+4vKjEpUxKg==
@@ -1023,7 +1351,7 @@
natural-compare "^1.4.0" natural-compare "^1.4.0"
ts-api-utils "^2.1.0" ts-api-utils "^2.1.0"
"@typescript-eslint/parser@^8.46.2", "@typescript-eslint/parser@8.46.2": "@typescript-eslint/parser@8.46.2":
version "8.46.2" version "8.46.2"
resolved "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.2.tgz" resolved "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.2.tgz"
integrity sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g== integrity sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==
@@ -1051,7 +1379,7 @@
"@typescript-eslint/types" "8.46.2" "@typescript-eslint/types" "8.46.2"
"@typescript-eslint/visitor-keys" "8.46.2" "@typescript-eslint/visitor-keys" "8.46.2"
"@typescript-eslint/tsconfig-utils@^8.46.2", "@typescript-eslint/tsconfig-utils@8.46.2": "@typescript-eslint/tsconfig-utils@8.46.2", "@typescript-eslint/tsconfig-utils@^8.46.2":
version "8.46.2" version "8.46.2"
resolved "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.2.tgz" resolved "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.2.tgz"
integrity sha512-a7QH6fw4S57+F5y2FIxxSDyi5M4UfGF+Jl1bCGd7+L4KsaUY80GsiF/t0UoRFDHAguKlBaACWJRmdrc6Xfkkag== integrity sha512-a7QH6fw4S57+F5y2FIxxSDyi5M4UfGF+Jl1bCGd7+L4KsaUY80GsiF/t0UoRFDHAguKlBaACWJRmdrc6Xfkkag==
@@ -1067,7 +1395,7 @@
debug "^4.3.4" debug "^4.3.4"
ts-api-utils "^2.1.0" ts-api-utils "^2.1.0"
"@typescript-eslint/types@^8.46.2", "@typescript-eslint/types@8.46.2": "@typescript-eslint/types@8.46.2", "@typescript-eslint/types@^8.46.2":
version "8.46.2" version "8.46.2"
resolved "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.2.tgz" resolved "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.2.tgz"
integrity sha512-lNCWCbq7rpg7qDsQrd3D6NyWYu+gkTENkG5IKYhUIcxSb59SQC/hEQ+MrG4sTgBVghTonNWq42bA/d4yYumldQ== integrity sha512-lNCWCbq7rpg7qDsQrd3D6NyWYu+gkTENkG5IKYhUIcxSb59SQC/hEQ+MrG4sTgBVghTonNWq42bA/d4yYumldQ==
@@ -1150,7 +1478,7 @@ acorn-jsx@^5.3.2:
resolved "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz" resolved "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz"
integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==
"acorn@^6.0.0 || ^7.0.0 || ^8.0.0", acorn@^8.15.0: acorn@^8.15.0:
version "8.15.0" version "8.15.0"
resolved "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz" resolved "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz"
integrity sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg== integrity sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==
@@ -1254,7 +1582,7 @@ braces@^3.0.3:
dependencies: dependencies:
fill-range "^7.1.1" fill-range "^7.1.1"
browserslist@^4.24.0, "browserslist@>= 4.21.0": browserslist@^4.24.0:
version "4.27.0" version "4.27.0"
resolved "https://registry.npmjs.org/browserslist/-/browserslist-4.27.0.tgz" resolved "https://registry.npmjs.org/browserslist/-/browserslist-4.27.0.tgz"
integrity sha512-AXVQwdhot1eqLihwasPElhX2tAZiBjWdJ9i/Zcj2S6QYIjkx62OKSfnobkriB81C3l4w0rVy3Nt4jaTBltYEpw== integrity sha512-AXVQwdhot1eqLihwasPElhX2tAZiBjWdJ9i/Zcj2S6QYIjkx62OKSfnobkriB81C3l4w0rVy3Nt4jaTBltYEpw==
@@ -1551,7 +1879,7 @@ earcut@^3.0.0, earcut@^3.0.2:
resolved "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz" resolved "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz"
integrity sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ== integrity sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==
easymde@^2.20.0, "easymde@>= 2.0.0 < 3.0.0": easymde@^2.20.0:
version "2.20.0" version "2.20.0"
resolved "https://registry.npmjs.org/easymde/-/easymde-2.20.0.tgz" resolved "https://registry.npmjs.org/easymde/-/easymde-2.20.0.tgz"
integrity sha512-V1Z5f92TfR42Na852OWnIZMbM7zotWQYTddNaLYZFVKj7APBbyZ3FYJ27gBw2grMW3R6Qdv9J8n5Ij7XRSIgXQ== integrity sha512-V1Z5f92TfR42Na852OWnIZMbM7zotWQYTddNaLYZFVKj7APBbyZ3FYJ27gBw2grMW3R6Qdv9J8n5Ij7XRSIgXQ==
@@ -1694,7 +2022,7 @@ eslint-visitor-keys@^4.2.1:
resolved "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz" resolved "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz"
integrity sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ== integrity sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==
"eslint@^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0", "eslint@^6.0.0 || ^7.0.0 || >=8.0.0", "eslint@^8.57.0 || ^9.0.0", eslint@^9.25.0, eslint@>=8.40: eslint@^9.25.0:
version "9.38.0" version "9.38.0"
resolved "https://registry.npmjs.org/eslint/-/eslint-9.38.0.tgz" resolved "https://registry.npmjs.org/eslint/-/eslint-9.38.0.tgz"
integrity sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw== integrity sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==
@@ -1820,12 +2148,7 @@ fastq@^1.6.0:
dependencies: dependencies:
reusify "^1.0.4" reusify "^1.0.4"
fdir@^6.4.4: fdir@^6.4.4, fdir@^6.5.0:
version "6.5.0"
resolved "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz"
integrity sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==
fdir@^6.5.0:
version "6.5.0" version "6.5.0"
resolved "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz" resolved "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz"
integrity sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg== integrity sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==
@@ -2289,7 +2612,7 @@ its-fine@^2.0.0:
dependencies: dependencies:
"@types/react-reconciler" "^0.28.9" "@types/react-reconciler" "^0.28.9"
jiti@*, jiti@^2.6.1, jiti@>=1.21.0: jiti@^2.6.1:
version "2.6.1" version "2.6.1"
resolved "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz" resolved "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz"
integrity sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ== integrity sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==
@@ -2368,12 +2691,62 @@ lie@^3.0.2:
dependencies: dependencies:
immediate "~3.0.5" immediate "~3.0.5"
lightningcss-android-arm64@1.30.2:
version "1.30.2"
resolved "https://registry.yarnpkg.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz#6966b7024d39c94994008b548b71ab360eb3a307"
integrity sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==
lightningcss-darwin-arm64@1.30.2: lightningcss-darwin-arm64@1.30.2:
version "1.30.2" version "1.30.2"
resolved "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz" resolved "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz"
integrity sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA== integrity sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==
lightningcss@^1.21.0, lightningcss@1.30.2: lightningcss-darwin-x64@1.30.2:
version "1.30.2"
resolved "https://registry.yarnpkg.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz#5ce87e9cd7c4f2dcc1b713f5e8ee185c88d9b7cd"
integrity sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==
lightningcss-freebsd-x64@1.30.2:
version "1.30.2"
resolved "https://registry.yarnpkg.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz#6ae1d5e773c97961df5cff57b851807ef33692a5"
integrity sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==
lightningcss-linux-arm-gnueabihf@1.30.2:
version "1.30.2"
resolved "https://registry.yarnpkg.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz#62c489610c0424151a6121fa99d77731536cdaeb"
integrity sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==
lightningcss-linux-arm64-gnu@1.30.2:
version "1.30.2"
resolved "https://registry.yarnpkg.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz#2a3661b56fe95a0cafae90be026fe0590d089298"
integrity sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==
lightningcss-linux-arm64-musl@1.30.2:
version "1.30.2"
resolved "https://registry.yarnpkg.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz#d7ddd6b26959245e026bc1ad9eb6aa983aa90e6b"
integrity sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==
lightningcss-linux-x64-gnu@1.30.2:
version "1.30.2"
resolved "https://registry.yarnpkg.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz#5a89814c8e63213a5965c3d166dff83c36152b1a"
integrity sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==
lightningcss-linux-x64-musl@1.30.2:
version "1.30.2"
resolved "https://registry.yarnpkg.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz#808c2e91ce0bf5d0af0e867c6152e5378c049728"
integrity sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==
lightningcss-win32-arm64-msvc@1.30.2:
version "1.30.2"
resolved "https://registry.yarnpkg.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz#ab4a8a8a2e6a82a4531e8bbb6bf0ff161ee6625a"
integrity sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==
lightningcss-win32-x64-msvc@1.30.2:
version "1.30.2"
resolved "https://registry.yarnpkg.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz#f01f382c8e0a27e1c018b0bee316d210eac43b6e"
integrity sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==
lightningcss@1.30.2:
version "1.30.2" version "1.30.2"
resolved "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz" resolved "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz"
integrity sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ== integrity sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==
@@ -2822,7 +3195,7 @@ mobx-react-lite@^4.1.0:
dependencies: dependencies:
use-sync-external-store "^1.4.0" use-sync-external-store "^1.4.0"
mobx@^6.13.7, mobx@^6.9.0: mobx@^6.13.7:
version "6.15.0" version "6.15.0"
resolved "https://registry.npmjs.org/mobx/-/mobx-6.15.0.tgz" resolved "https://registry.npmjs.org/mobx/-/mobx-6.15.0.tgz"
integrity sha512-UczzB+0nnwGotYSgllfARAqWCJ5e/skuV2K/l+Zyck/H6pJIhLXuBnz+6vn2i211o7DtbE78HQtsYEKICHGI+g== integrity sha512-UczzB+0nnwGotYSgllfARAqWCJ5e/skuV2K/l+Zyck/H6pJIhLXuBnz+6vn2i211o7DtbE78HQtsYEKICHGI+g==
@@ -2897,7 +3270,7 @@ overlayscrollbars-react@^0.5.6:
resolved "https://registry.npmjs.org/overlayscrollbars-react/-/overlayscrollbars-react-0.5.6.tgz" resolved "https://registry.npmjs.org/overlayscrollbars-react/-/overlayscrollbars-react-0.5.6.tgz"
integrity sha512-E5To04bL5brn9GVCZ36SnfGanxa2I2MDkWoa4Cjo5wol7l+diAgi4DBc983V7l2nOk/OLJ6Feg4kySspQEGDBw== integrity sha512-E5To04bL5brn9GVCZ36SnfGanxa2I2MDkWoa4Cjo5wol7l+diAgi4DBc983V7l2nOk/OLJ6Feg4kySspQEGDBw==
overlayscrollbars@^2.0.0, overlayscrollbars@^2.15.1: overlayscrollbars@^2.15.1:
version "2.15.1" version "2.15.1"
resolved "https://registry.npmjs.org/overlayscrollbars/-/overlayscrollbars-2.15.1.tgz" resolved "https://registry.npmjs.org/overlayscrollbars/-/overlayscrollbars-2.15.1.tgz"
integrity sha512-glX26JwjL+Tkzv0JNOWdW4VozP5dGXO+Wx8+TPrdTEJTSYT/8eJS8yXM+fewjU0nFq/JeCa+X+BqABNjC4YZSA== integrity sha512-glX26JwjL+Tkzv0JNOWdW4VozP5dGXO+Wx8+TPrdTEJTSYT/8eJS8yXM+fewjU0nFq/JeCa+X+BqABNjC4YZSA==
@@ -3013,12 +3386,12 @@ picomatch@^2.3.1:
resolved "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz" resolved "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz"
integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==
"picomatch@^3 || ^4", picomatch@^4.0.2, picomatch@^4.0.3: picomatch@^4.0.2, picomatch@^4.0.3:
version "4.0.3" version "4.0.3"
resolved "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz" resolved "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz"
integrity sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q== integrity sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==
pixi.js@^8.10.1, pixi.js@^8.2.6: pixi.js@^8.10.1:
version "8.14.0" version "8.14.0"
resolved "https://registry.npmjs.org/pixi.js/-/pixi.js-8.14.0.tgz" resolved "https://registry.npmjs.org/pixi.js/-/pixi.js-8.14.0.tgz"
integrity sha512-ituDiEBb1Oqx56RYwTtC6MjPUhPfF/i15fpUv5oEqmzC/ce3SaSumulJcOjKG7+y0J0Ekl9Rl4XTxaUw+MVFZw== integrity sha512-ituDiEBb1Oqx56RYwTtC6MjPUhPfF/i15fpUv5oEqmzC/ce3SaSumulJcOjKG7+y0J0Ekl9Rl4XTxaUw+MVFZw==
@@ -3075,7 +3448,7 @@ promise-worker-transferable@^1.0.4:
is-promise "^2.1.0" is-promise "^2.1.0"
lie "^3.0.2" lie "^3.0.2"
prop-types@^15.5.4, prop-types@^15.6.2, prop-types@^15.8.1: prop-types@^15.6.2, prop-types@^15.8.1:
version "15.8.1" version "15.8.1"
resolved "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz" resolved "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz"
integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg== integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==
@@ -3136,19 +3509,14 @@ rbush@^4.0.0:
dependencies: dependencies:
quickselect "^3.0.0" quickselect "^3.0.0"
"react-dom@^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom@^18 || ^19", "react-dom@^18.0.0 || ^19.0.0", react-dom@^19, react-dom@^19.0.0, react-dom@^19.1.0, react-dom@>=16.0.0, react-dom@>=16.13, react-dom@>=16.6.0, react-dom@>=16.8.2, react-dom@>=18: react-dom@^19.1.0:
version "19.2.0" version "19.2.0"
resolved "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz" resolved "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz"
integrity sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ== integrity sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==
dependencies: dependencies:
scheduler "^0.27.0" scheduler "^0.27.0"
react-is@^16.13.1: react-is@^16.13.1, react-is@^16.7.0:
version "16.13.1"
resolved "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz"
integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==
react-is@^16.7.0:
version "16.13.1" version "16.13.1"
resolved "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz" resolved "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz"
integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==
@@ -3182,7 +3550,7 @@ react-photo-sphere-viewer@^6.2.3:
dependencies: dependencies:
eventemitter3 "^5.0.1" eventemitter3 "^5.0.1"
react-reconciler@^0.31.0, react-reconciler@0.31.0: react-reconciler@0.31.0, react-reconciler@^0.31.0:
version "0.31.0" version "0.31.0"
resolved "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.31.0.tgz" resolved "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.31.0.tgz"
integrity sha512-7Ob7Z+URmesIsIVRjnLoDGwBEG/tVitidU0nMsqX/eeJaLY89RISO/10ERe0MqmzuKUUB1rmY+h1itMbUHg9BQ== integrity sha512-7Ob7Z+URmesIsIVRjnLoDGwBEG/tVitidU0nMsqX/eeJaLY89RISO/10ERe0MqmzuKUUB1rmY+h1itMbUHg9BQ==
@@ -3209,7 +3577,7 @@ react-router-dom@^7.6.1:
dependencies: dependencies:
react-router "7.9.4" react-router "7.9.4"
react-router@^7.9.4, react-router@7.9.4: react-router@7.9.4, react-router@^7.9.4:
version "7.9.4" version "7.9.4"
resolved "https://registry.npmjs.org/react-router/-/react-router-7.9.4.tgz" resolved "https://registry.npmjs.org/react-router/-/react-router-7.9.4.tgz"
integrity sha512-SD3G8HKviFHg9xj7dNODUKDFgpG4xqD5nhyd0mYoB5iISepuZAvzSr8ywxgxKJ52yRzf/HWtVHc9AWwoTbljvA== integrity sha512-SD3G8HKviFHg9xj7dNODUKDFgpG4xqD5nhyd0mYoB5iISepuZAvzSr8ywxgxKJ52yRzf/HWtVHc9AWwoTbljvA==
@@ -3246,12 +3614,12 @@ react-use-measure@^2.1.7:
resolved "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz" resolved "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz"
integrity sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg== integrity sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==
"react@^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react@^16.8.0 || ^17 || ^18 || ^19", "react@^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react@^17.0.0 || ^18.0.0 || ^19.0.0", "react@^18 || ^19", "react@^18.0 || ^19", "react@^18.0.0 || ^19.0.0", react@^19, react@^19.0.0, react@^19.1.0, react@^19.2.0, "react@>= 16.8.0", react@>=16.0.0, react@>=16.13, react@>=16.6.0, react@>=16.8, react@>=16.8.0, react@>=16.8.2, react@>=17.0, react@>=18, react@>=18.0.0, react@>=19.0.0: react@^19.1.0:
version "19.2.0" version "19.2.0"
resolved "https://registry.npmjs.org/react/-/react-19.2.0.tgz" resolved "https://registry.npmjs.org/react/-/react-19.2.0.tgz"
integrity sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ== integrity sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==
redux@^5.0.0, redux@^5.0.1: redux@^5.0.1:
version "5.0.1" version "5.0.1"
resolved "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz" resolved "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz"
integrity sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w== integrity sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==
@@ -3337,7 +3705,7 @@ rollup-plugin-visualizer@^6.0.5:
source-map "^0.7.4" source-map "^0.7.4"
yargs "^17.5.1" yargs "^17.5.1"
rollup@^1.20.0||^2.0.0||^3.0.0||^4.0.0, rollup@^4.34.9, "rollup@2.x || 3.x || 4.x": rollup@^4.34.9:
version "4.52.5" version "4.52.5"
resolved "https://registry.npmjs.org/rollup/-/rollup-4.52.5.tgz" resolved "https://registry.npmjs.org/rollup/-/rollup-4.52.5.tgz"
integrity sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw== integrity sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw==
@@ -3523,7 +3891,7 @@ svg-parser@^2.0.4:
resolved "https://registry.npmjs.org/svg-parser/-/svg-parser-2.0.4.tgz" resolved "https://registry.npmjs.org/svg-parser/-/svg-parser-2.0.4.tgz"
integrity sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ== integrity sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==
tailwindcss@^4.1.8, "tailwindcss@>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1", tailwindcss@4.1.16: tailwindcss@4.1.16, tailwindcss@^4.1.8:
version "4.1.16" version "4.1.16"
resolved "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.16.tgz" resolved "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.16.tgz"
integrity sha512-pONL5awpaQX4LN5eiv7moSiSPd/DLDzKVRJz8Q9PgzmAdd1R4307GQS2ZpfiN7ZmekdQrfhZZiSE5jkLR4WNaA== integrity sha512-pONL5awpaQX4LN5eiv7moSiSPd/DLDzKVRJz8Q9PgzmAdd1R4307GQS2ZpfiN7ZmekdQrfhZZiSE5jkLR4WNaA==
@@ -3555,7 +3923,7 @@ three@^0.170.0:
resolved "https://registry.npmjs.org/three/-/three-0.170.0.tgz" resolved "https://registry.npmjs.org/three/-/three-0.170.0.tgz"
integrity sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ== integrity sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ==
three@^0.177.0, "three@>= 0.159.0", three@>=0.125.0, three@>=0.126.1, three@>=0.128.0, three@>=0.134.0, three@>=0.137, three@>=0.156, three@>=0.159: three@^0.177.0:
version "0.177.0" version "0.177.0"
resolved "https://registry.npmjs.org/three/-/three-0.177.0.tgz" resolved "https://registry.npmjs.org/three/-/three-0.177.0.tgz"
integrity sha512-EiXv5/qWAaGI+Vz2A+JfavwYCMdGjxVsrn3oBwllUoqYeaBO75J63ZfyaQKoiLrqNHoTlUc6PFgMXnS0kI45zg== integrity sha512-EiXv5/qWAaGI+Vz2A+JfavwYCMdGjxVsrn3oBwllUoqYeaBO75J63ZfyaQKoiLrqNHoTlUc6PFgMXnS0kI45zg==
@@ -3625,9 +3993,9 @@ ts-api-utils@^2.1.0:
resolved "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz" resolved "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz"
integrity sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ== integrity sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==
tslib@^2.0.3: tslib@^2.0.3, tslib@^2.4.0:
version "2.8.1" version "2.8.1"
resolved "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f"
integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==
tunnel-rat@^0.1.2: tunnel-rat@^0.1.2:
@@ -3654,7 +4022,7 @@ typescript-eslint@^8.30.1:
"@typescript-eslint/typescript-estree" "8.46.2" "@typescript-eslint/typescript-estree" "8.46.2"
"@typescript-eslint/utils" "8.46.2" "@typescript-eslint/utils" "8.46.2"
typescript@>=4.8.4, "typescript@>=4.8.4 <6.0.0", typescript@>=4.9.5, typescript@~5.8.3: typescript@~5.8.3:
version "5.8.3" version "5.8.3"
resolved "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz" resolved "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz"
integrity sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ== integrity sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==
@@ -3735,7 +4103,7 @@ uri-js@^4.2.2:
dependencies: dependencies:
punycode "^2.1.0" punycode "^2.1.0"
use-sync-external-store@^1.2.2, use-sync-external-store@^1.4.0, use-sync-external-store@^1.6.0, use-sync-external-store@>=1.2.0: use-sync-external-store@^1.2.2, use-sync-external-store@^1.4.0, use-sync-external-store@^1.6.0:
version "1.6.0" version "1.6.0"
resolved "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz" resolved "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz"
integrity sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w== integrity sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==
@@ -3795,7 +4163,7 @@ vite-plugin-svgr@^4.5.0:
"@svgr/core" "^8.1.0" "@svgr/core" "^8.1.0"
"@svgr/plugin-jsx" "^8.1.0" "@svgr/plugin-jsx" "^8.1.0"
"vite@^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0", "vite@^5.2.0 || ^6 || ^7", vite@^6.3.5, vite@>=2.6.0: vite@^6.3.5:
version "6.4.1" version "6.4.1"
resolved "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz" resolved "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz"
integrity sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g== integrity sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==