19 Commits

Author SHA1 Message Date
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
75 changed files with 7899 additions and 1980 deletions

3
.env
View File

@@ -1,3 +1,4 @@
VITE_API_URL='https://wn.krbl.ru' VITE_API_URL='https://wn.krbl.ru'
VITE_REACT_APP ='https://wn.krbl.ru/' VITE_REACT_APP ='https://wn.krbl.ru'
VITE_KRBL_MEDIA='https://wn.krbl.ru/media/' VITE_KRBL_MEDIA='https://wn.krbl.ru/media/'
VITE_NEED_AUTH='true'

View File

@@ -41,7 +41,8 @@
"react-toastify": "^11.0.5", "react-toastify": "^11.0.5",
"rehype-raw": "^7.0.0", "rehype-raw": "^7.0.0",
"tailwindcss": "^4.1.8", "tailwindcss": "^4.1.8",
"three": "^0.177.0" "three": "^0.177.0",
"uuid": "^13.0.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.25.0", "@eslint/js": "^9.25.0",

View File

@@ -16,12 +16,7 @@ import {
SnapshotListPage, SnapshotListPage,
CarrierListPage, CarrierListPage,
StationListPage, StationListPage,
// VehicleListPage,
ArticleListPage, ArticleListPage,
// CountryPreviewPage,
// VehiclePreviewPage,
// CarrierPreviewPage,
SnapshotCreatePage, SnapshotCreatePage,
CountryCreatePage, CountryCreatePage,
CityCreatePage, CityCreatePage,
@@ -31,7 +26,6 @@ import {
CityEditPage, CityEditPage,
UserCreatePage, UserCreatePage,
UserEditPage, UserEditPage,
// VehicleEditPage,
CarrierEditPage, CarrierEditPage,
StationCreatePage, StationCreatePage,
StationPreviewPage, StationPreviewPage,
@@ -57,7 +51,9 @@ import {
const PublicRoute = ({ children }: { children: React.ReactNode }) => { const PublicRoute = ({ children }: { children: React.ReactNode }) => {
const { isAuthenticated } = authStore; const { isAuthenticated } = authStore;
if (isAuthenticated) { const need_auth = import.meta.env.VITE_NEED_AUTH == "true";
if (isAuthenticated || !need_auth) {
return <Navigate to="/map" replace />; return <Navigate to="/map" replace />;
} }
return <>{children}</>; return <>{children}</>;
@@ -65,17 +61,21 @@ const PublicRoute = ({ children }: { children: React.ReactNode }) => {
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => { const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
const { isAuthenticated } = authStore; const { isAuthenticated } = authStore;
const need_auth = import.meta.env.VITE_NEED_AUTH == "true";
const location = useLocation(); const location = useLocation();
if (!isAuthenticated) {
if (!isAuthenticated && need_auth) {
return <Navigate to="/login" replace />; return <Navigate to="/login" replace />;
} }
if (location.pathname === "/") { if (location.pathname === "/") {
return <Navigate to="/map" replace />; return <Navigate to="/map" replace />;
} }
return <>{children}</>; return <>{children}</>;
}; };
// Чтобы очистка сторов происходила при смене локации
const ClearStoresWrapper: React.FC<{ children: React.ReactNode }> = ({ const ClearStoresWrapper: React.FC<{ children: React.ReactNode }> = ({
children, children,
}) => { }) => {
@@ -116,65 +116,45 @@ const router = createBrowserRouter([
children: [ children: [
{ index: true, element: <MainPage /> }, { index: true, element: <MainPage /> },
// Sight
{ path: "sight", element: <SightListPage /> }, { path: "sight", element: <SightListPage /> },
{ path: "sight/create", element: <CreateSightPage /> }, { path: "sight/create", element: <CreateSightPage /> },
{ path: "sight/:id/edit", element: <EditSightPage /> }, { path: "sight/:id/edit", element: <EditSightPage /> },
// Device
{ path: "devices", element: <DevicesPage /> }, { path: "devices", element: <DevicesPage /> },
// Map
{ path: "map", element: <MapPage /> }, { path: "map", element: <MapPage /> },
// Media
{ path: "media", element: <MediaListPage /> }, { path: "media", element: <MediaListPage /> },
{ path: "media/:id", element: <MediaPreviewPage /> }, { path: "media/:id", element: <MediaPreviewPage /> },
{ path: "media/:id/edit", element: <MediaEditPage /> }, { path: "media/:id/edit", element: <MediaEditPage /> },
// Country
{ path: "country", element: <CountryListPage /> }, { path: "country", element: <CountryListPage /> },
{ path: "country/create", element: <CountryCreatePage /> }, { path: "country/create", element: <CountryCreatePage /> },
{ path: "country/add", element: <CountryAddPage /> }, { path: "country/add", element: <CountryAddPage /> },
// { path: "country/:id", element: <CountryPreviewPage /> },
{ path: "country/:id/edit", element: <CountryEditPage /> }, { path: "country/:id/edit", element: <CountryEditPage /> },
// City
{ path: "city", element: <CityListPage /> }, { path: "city", element: <CityListPage /> },
{ path: "city/create", element: <CityCreatePage /> }, { path: "city/create", element: <CityCreatePage /> },
// { path: "city/:id", element: <CityPreviewPage /> },
{ path: "city/:id/edit", element: <CityEditPage /> }, { path: "city/:id/edit", element: <CityEditPage /> },
// Route
{ path: "route", element: <RouteListPage /> }, { path: "route", element: <RouteListPage /> },
{ path: "route/create", element: <RouteCreatePage /> }, { path: "route/create", element: <RouteCreatePage /> },
{ path: "route/:id/edit", element: <RouteEditPage /> }, { path: "route/:id/edit", element: <RouteEditPage /> },
// User
{ path: "user", element: <UserListPage /> }, { path: "user", element: <UserListPage /> },
{ path: "user/create", element: <UserCreatePage /> }, { path: "user/create", element: <UserCreatePage /> },
{ path: "user/:id/edit", element: <UserEditPage /> }, { path: "user/:id/edit", element: <UserEditPage /> },
// Snapshot
{ path: "snapshot", element: <SnapshotListPage /> }, { path: "snapshot", element: <SnapshotListPage /> },
{ path: "snapshot/create", element: <SnapshotCreatePage /> }, { path: "snapshot/create", element: <SnapshotCreatePage /> },
// Carrier
{ path: "carrier", element: <CarrierListPage /> }, { path: "carrier", element: <CarrierListPage /> },
{ path: "carrier/create", element: <CarrierCreatePage /> }, { path: "carrier/create", element: <CarrierCreatePage /> },
// { path: "carrier/:id", element: <CarrierPreviewPage /> },
{ path: "carrier/:id/edit", element: <CarrierEditPage /> }, { path: "carrier/:id/edit", element: <CarrierEditPage /> },
// Station
{ path: "station", element: <StationListPage /> }, { path: "station", element: <StationListPage /> },
{ path: "station/create", element: <StationCreatePage /> }, { path: "station/create", element: <StationCreatePage /> },
{ path: "station/:id", element: <StationPreviewPage /> }, { path: "station/:id", element: <StationPreviewPage /> },
{ path: "station/:id/edit", element: <StationEditPage /> }, { path: "station/:id/edit", element: <StationEditPage /> },
// Vehicle
// { path: "vehicle", element: <VehicleListPage /> },
{ path: "vehicle/create", element: <VehicleCreatePage /> }, { path: "vehicle/create", element: <VehicleCreatePage /> },
// { path: "vehicle/:id", element: <VehiclePreviewPage /> },
// { path: "vehicle/:id/edit", element: <VehicleEditPage /> },
// Article
{ path: "article", element: <ArticleListPage /> }, { path: "article", element: <ArticleListPage /> },
{ path: "article/:id", element: <ArticlePreviewPage /> }, { path: "article/:id", element: <ArticlePreviewPage /> },
// { path: "media/create", element: <CreateMediaPage /> },
], ],
}, },
]); ]);

View File

@@ -33,8 +33,10 @@ export const NavigationItemComponent: React.FC<NavigationItemProps> = ({
const [isExpanded, setIsExpanded] = React.useState(false); const [isExpanded, setIsExpanded] = React.useState(false);
const { payload } = authStore; const { payload } = authStore;
const need_auth = import.meta.env.VITE_NEED_AUTH == "true";
// @ts-ignore // @ts-ignore
const isAdmin = payload?.is_admin || false; const isAdmin = payload?.is_admin || false || !need_auth;
const isActive = item.path ? location.pathname.startsWith(item.path) : false; const isActive = item.path ? location.pathname.startsWith(item.path) : false;

View File

@@ -1,9 +1,9 @@
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { useEffect } from "react"; import { useEffect, useState } from "react";
import { Box } from "@mui/material"; import { Box } from "@mui/material";
import { PreviewLeftWidget } from "./PreviewLeftWidget"; import { PreviewLeftWidget } from "./PreviewLeftWidget";
import { PreviewRightWidget } from "./PreviewRightWidget"; import { PreviewRightWidget } from "./PreviewRightWidget";
import { articlesStore, languageStore } from "@shared"; import { articlesStore, languageStore, LoadingSpinner } from "@shared";
import { ArrowLeft } from "lucide-react"; import { ArrowLeft } from "lucide-react";
export const ArticlePreviewPage = () => { export const ArticlePreviewPage = () => {
@@ -11,18 +11,41 @@ export const ArticlePreviewPage = () => {
const { id } = useParams(); const { id } = useParams();
const { getArticle, getArticleMedia, getArticlePreview } = articlesStore; const { getArticle, getArticleMedia, getArticlePreview } = articlesStore;
const { language } = languageStore; const { language } = languageStore;
const [isLoadingData, setIsLoadingData] = useState(true);
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
if (id) { if (id) {
setIsLoadingData(true);
try {
await getArticle(Number(id), language); await getArticle(Number(id), language);
await getArticleMedia(Number(id)); await getArticleMedia(Number(id));
await getArticlePreview(Number(id)); await getArticlePreview(Number(id));
} finally {
setIsLoadingData(false);
}
} else {
setIsLoadingData(false);
} }
}; };
fetchData(); fetchData();
}, [id, language]); }, [id, language]);
if (isLoadingData) {
return (
<Box
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
minHeight: "60vh",
}}
>
<LoadingSpinner message="Загрузка данных статьи..." />
</Box>
);
}
return ( return (
<> <>
<div className="flex items-center gap-4 mb-10"> <div className="flex items-center gap-4 mb-10">

View File

@@ -48,7 +48,6 @@ export const CarrierCreatePage = observer(() => {
languageStore.setLanguage("ru"); languageStore.setLanguage("ru");
}, []); }, []);
// Автоматически устанавливаем выбранный город при загрузке страницы
useEffect(() => { useEffect(() => {
if (selectedCityId && !createCarrierData.city_id) { if (selectedCityId && !createCarrierData.city_id) {
setCreateCarrierData( setCreateCarrierData(

View File

@@ -6,13 +6,20 @@ import {
MenuItem, MenuItem,
FormControl, FormControl,
InputLabel, InputLabel,
Box,
} from "@mui/material"; } from "@mui/material";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { ArrowLeft, Save } from "lucide-react"; import { ArrowLeft, Save } from "lucide-react";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { carrierStore, cityStore, mediaStore, languageStore } from "@shared"; import {
carrierStore,
cityStore,
mediaStore,
languageStore,
LoadingSpinner,
} from "@shared";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { ImageUploadCard, LanguageSwitcher, DeleteModal } from "@widgets"; import { ImageUploadCard, LanguageSwitcher, DeleteModal } from "@widgets";
import { import {
@@ -28,6 +35,7 @@ export const CarrierEditPage = observer(() => {
const { language } = languageStore; const { language } = languageStore;
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isLoadingData, setIsLoadingData] = useState(true);
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);
@@ -39,6 +47,12 @@ export const CarrierEditPage = observer(() => {
useEffect(() => { useEffect(() => {
(async () => { (async () => {
if (!id) {
setIsLoadingData(false);
return;
}
setIsLoadingData(true);
try {
await cityStore.getCities("ru"); await cityStore.getCities("ru");
await cityStore.getCities("en"); await cityStore.getCities("en");
await cityStore.getCities("zh"); await cityStore.getCities("zh");
@@ -71,10 +85,12 @@ export const CarrierEditPage = observer(() => {
); );
} }
mediaStore.getMedia(); await mediaStore.getMedia();
} finally {
setIsLoadingData(false);
}
})(); })();
// Устанавливаем русский язык при загрузке страницы
languageStore.setLanguage("ru"); languageStore.setLanguage("ru");
}, [id]); }, [id]);
@@ -111,6 +127,21 @@ export const CarrierEditPage = observer(() => {
? mediaStore.media.find((m) => m.id === editCarrierData.logo) ? mediaStore.media.find((m) => m.id === editCarrierData.logo)
: null; : null;
if (isLoadingData) {
return (
<Box
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
minHeight: "60vh",
}}
>
<LoadingSpinner message="Загрузка данных перевозчика..." />
</Box>
);
}
return ( return (
<Paper 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 />

View File

@@ -6,6 +6,7 @@ import {
MenuItem, MenuItem,
FormControl, FormControl,
InputLabel, InputLabel,
Box,
} from "@mui/material"; } from "@mui/material";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { ArrowLeft, Save } from "lucide-react"; import { ArrowLeft, Save } from "lucide-react";
@@ -18,6 +19,7 @@ import {
languageStore, languageStore,
mediaStore, mediaStore,
CashedCities, CashedCities,
LoadingSpinner,
} from "@shared"; } from "@shared";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { LanguageSwitcher, ImageUploadCard } from "@widgets"; import { LanguageSwitcher, ImageUploadCard } from "@widgets";
@@ -30,6 +32,7 @@ import {
export const CityEditPage = observer(() => { export const CityEditPage = observer(() => {
const navigate = useNavigate(); const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isLoadingData, setIsLoadingData] = useState(true);
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);
@@ -44,7 +47,6 @@ export const CityEditPage = observer(() => {
const { getMedia, getOneMedia } = mediaStore; const { getMedia, getOneMedia } = mediaStore;
useEffect(() => { useEffect(() => {
// Устанавливаем русский язык при загрузке страницы
languageStore.setLanguage("ru"); languageStore.setLanguage("ru");
}, []); }, []);
@@ -63,13 +65,14 @@ export const CityEditPage = observer(() => {
useEffect(() => { useEffect(() => {
(async () => { (async () => {
if (id) { if (id) {
setIsLoadingData(true);
try {
await getCountries("ru"); await getCountries("ru");
// Fetch data for all languages
const ruData = await getCity(id as string, "ru"); const ruData = await getCity(id as string, "ru");
const enData = await getCity(id as string, "en"); const enData = await getCity(id as string, "en");
const zhData = await getCity(id as string, "zh"); const zhData = await getCity(id as string, "zh");
// Set data for each language
setEditCityData(ruData.name, ruData.country_code, ruData.arms, "ru"); setEditCityData(ruData.name, ruData.country_code, ruData.arms, "ru");
setEditCityData(enData.name, enData.country_code, enData.arms, "en"); setEditCityData(enData.name, enData.country_code, enData.arms, "en");
setEditCityData(zhData.name, zhData.country_code, zhData.arms, "zh"); setEditCityData(zhData.name, zhData.country_code, zhData.arms, "zh");
@@ -77,6 +80,11 @@ export const CityEditPage = observer(() => {
await getOneMedia(ruData.arms as string); await getOneMedia(ruData.arms as string);
await getMedia(); await getMedia();
} finally {
setIsLoadingData(false);
}
} else {
setIsLoadingData(false);
} }
})(); })();
}, [id]); }, [id]);
@@ -99,6 +107,21 @@ export const CityEditPage = observer(() => {
? mediaStore.media.find((m) => m.id === editCityData.arms) ? mediaStore.media.find((m) => m.id === editCityData.arms)
: null; : null;
if (isLoadingData) {
return (
<Box
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
minHeight: "60vh",
}}
>
<LoadingSpinner message="Загрузка данных города..." />
</Box>
);
}
return ( return (
<Paper 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 />
@@ -207,7 +230,7 @@ export const CityEditPage = observer(() => {
open={isSelectMediaOpen} open={isSelectMediaOpen}
onClose={() => setIsSelectMediaOpen(false)} onClose={() => setIsSelectMediaOpen(false)}
onSelectMedia={handleMediaSelect} onSelectMedia={handleMediaSelect}
mediaType={1} // Тип медиа для иконок mediaType={1}
/> />
<UploadMediaDialog <UploadMediaDialog

View File

@@ -1,23 +1,23 @@
import { Button, Paper, TextField } from "@mui/material"; import { Button, Paper, TextField, Box } from "@mui/material";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { ArrowLeft, Save } from "lucide-react"; import { ArrowLeft, Save } from "lucide-react";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { countryStore, languageStore } from "@shared"; import { countryStore, languageStore, LoadingSpinner } from "@shared";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { LanguageSwitcher } from "@widgets"; import { LanguageSwitcher } from "@widgets";
export const CountryEditPage = observer(() => { export const CountryEditPage = observer(() => {
const navigate = useNavigate(); const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isLoadingData, setIsLoadingData] = useState(true);
const { language } = languageStore; const { language } = languageStore;
const { id } = useParams(); const { id } = useParams();
const { editCountryData, editCountry, getCountry, setEditCountryData } = const { editCountryData, editCountry, getCountry, setEditCountryData } =
countryStore; countryStore;
useEffect(() => { useEffect(() => {
// Устанавливаем русский язык при загрузке страницы
languageStore.setLanguage("ru"); languageStore.setLanguage("ru");
}, []); }, []);
@@ -36,19 +36,39 @@ export const CountryEditPage = observer(() => {
useEffect(() => { useEffect(() => {
(async () => { (async () => {
if (id) { if (id) {
// Fetch data for all languages setIsLoadingData(true);
try {
const ruData = await getCountry(id as string, "ru"); const ruData = await getCountry(id as string, "ru");
const enData = await getCountry(id as string, "en"); const enData = await getCountry(id as string, "en");
const zhData = await getCountry(id as string, "zh"); const zhData = await getCountry(id as string, "zh");
// Set data for each language
setEditCountryData(ruData.name, "ru"); setEditCountryData(ruData.name, "ru");
setEditCountryData(enData.name, "en"); setEditCountryData(enData.name, "en");
setEditCountryData(zhData.name, "zh"); setEditCountryData(zhData.name, "zh");
} finally {
setIsLoadingData(false);
}
} else {
setIsLoadingData(false);
} }
})(); })();
}, [id]); }, [id]);
if (isLoadingData) {
return (
<Box
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
minHeight: "60vh",
}}
>
<LoadingSpinner message="Загрузка данных страны..." />
</Box>
);
}
return ( return (
<Paper 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 />

View File

@@ -3,7 +3,12 @@ import { InformationTab, LeaveAgree, RightWidgetTab } from "@widgets";
import { LeftWidgetTab } from "@widgets"; import { LeftWidgetTab } from "@widgets";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { articlesStore, cityStore, editSightStore } from "@shared"; import {
articlesStore,
cityStore,
editSightStore,
LoadingSpinner,
} from "@shared";
import { useBlocker, useParams } from "react-router-dom"; import { useBlocker, useParams } from "react-router-dom";
function a11yProps(index: number) { function a11yProps(index: number) {
@@ -15,7 +20,8 @@ function a11yProps(index: number) {
export const EditSightPage = observer(() => { export const EditSightPage = observer(() => {
const [value, setValue] = useState(0); const [value, setValue] = useState(0);
const { sight, getSightInfo, needLeaveAgree } = editSightStore; const [isLoadingData, setIsLoadingData] = useState(true);
const { sight, getSightInfo, needLeaveAgree, getRightArticles } = editSightStore;
const { getArticles } = articlesStore; const { getArticles } = articlesStore;
const { id } = useParams(); const { id } = useParams();
@@ -33,6 +39,8 @@ export const EditSightPage = observer(() => {
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
if (id) { if (id) {
setIsLoadingData(true);
try {
await getCities("ru"); await getCities("ru");
await getSightInfo(+id, "ru"); await getSightInfo(+id, "ru");
await getSightInfo(+id, "en"); await getSightInfo(+id, "en");
@@ -40,6 +48,13 @@ export const EditSightPage = observer(() => {
await getArticles("ru"); await getArticles("ru");
await getArticles("en"); await getArticles("en");
await getArticles("zh"); await getArticles("zh");
// Загружаем данные правого виджета перед завершением загрузки
await getRightArticles(+id);
} finally {
setIsLoadingData(false);
}
} else {
setIsLoadingData(false);
} }
}; };
fetchData(); fetchData();
@@ -79,12 +94,25 @@ export const EditSightPage = observer(() => {
</Tabs> </Tabs>
</Box> </Box>
{sight.common.id !== 0 && ( {isLoadingData ? (
<Box
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
minHeight: "60vh",
}}
>
<LoadingSpinner message="Загрузка данных достопримечательности..." />
</Box>
) : (
sight.common.id !== 0 && (
<div className="flex-1"> <div className="flex-1">
<InformationTab value={value} index={0} /> <InformationTab value={value} index={0} />
<LeftWidgetTab value={value} index={1} /> <LeftWidgetTab value={value} index={1} />
<RightWidgetTab value={value} index={2} /> <RightWidgetTab value={value} index={2} />
</div> </div>
)
)} )}
{blocker.state === "blocked" ? <LeaveAgree blocker={blocker} /> : null} {blocker.state === "blocked" ? <LeaveAgree blocker={blocker} /> : null}

View File

@@ -24,7 +24,6 @@ export const LoginPage = () => {
const { login } = authStore; const { login } = authStore;
const { getUsers } = userStore; const { getUsers } = userStore;
useEffect(() => { useEffect(() => {
// Load saved credentials if they exist
const savedEmail = localStorage.getItem("rememberedEmail"); const savedEmail = localStorage.getItem("rememberedEmail");
const savedPassword = localStorage.getItem("rememberedPassword"); const savedPassword = localStorage.getItem("rememberedPassword");
if (savedEmail && savedPassword) { if (savedEmail && savedPassword) {
@@ -42,7 +41,6 @@ export const LoginPage = () => {
try { try {
await login(email, password); await login(email, password);
// Save or clear credentials based on remember me checkbox
if (rememberMe) { if (rememberMe) {
localStorage.setItem("rememberedEmail", email); localStorage.setItem("rememberedEmail", email);
localStorage.setItem("rememberedPassword", password); localStorage.setItem("rememberedPassword", password);

File diff suppressed because it is too large Load Diff

View File

@@ -22,10 +22,8 @@ interface ApiSight {
longitude: number; longitude: number;
} }
// Допуск для сравнения координат, чтобы избежать ошибок с точностью чисел.
const COORDINATE_PRECISION_TOLERANCE = 1e-9; const COORDINATE_PRECISION_TOLERANCE = 1e-9;
// Вспомогательная функция, обновленная для сравнения с допуском.
const arePathsEqual = ( const arePathsEqual = (
path1: [number, number][], path1: [number, number][],
path2: [number, number][] path2: [number, number][]
@@ -136,7 +134,6 @@ class MapStore {
longitude: geometry.coordinates[0], longitude: geometry.coordinates[0],
}; };
// ИЗМЕНЕНИЕ: Сравнение координат с допуском
if ( if (
originalStation.name !== currentStation.name || originalStation.name !== currentStation.name ||
Math.abs(originalStation.latitude - currentStation.latitude) > Math.abs(originalStation.latitude - currentStation.latitude) >
@@ -155,7 +152,6 @@ class MapStore {
path: geometry.coordinates, path: geometry.coordinates,
}; };
// ИЗМЕНЕНИЕ: Используется новая функция arePathsEqual с допуском
if ( if (
originalRoute.route_number !== currentRoute.route_number || originalRoute.route_number !== currentRoute.route_number ||
!arePathsEqual(originalRoute.path, currentRoute.path) !arePathsEqual(originalRoute.path, currentRoute.path)
@@ -173,7 +169,6 @@ class MapStore {
longitude: geometry.coordinates[0], longitude: geometry.coordinates[0],
}; };
// ИЗМЕНЕНИЕ: Сравнение координат с допуском
if ( if (
originalSight.name !== currentSight.name || originalSight.name !== currentSight.name ||
originalSight.description !== currentSight.description || originalSight.description !== currentSight.description ||

View File

@@ -21,6 +21,7 @@ import {
mediaStore, mediaStore,
MEDIA_TYPE_LABELS, MEDIA_TYPE_LABELS,
languageStore, languageStore,
LoadingSpinner,
} from "@shared"; } from "@shared";
import { MediaViewer } from "@widgets"; import { MediaViewer } from "@widgets";
@@ -33,7 +34,7 @@ export const MediaEditPage = observer(() => {
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const [newFile, setNewFile] = useState<File | null>(null); const [newFile, setNewFile] = useState<File | null>(null);
const [uploadDialogOpen, setUploadDialogOpen] = useState(false); // State for the upload dialog const [uploadDialogOpen, setUploadDialogOpen] = useState(false);
const media = id ? mediaStore.media.find((m) => m.id === id) : null; const media = id ? mediaStore.media.find((m) => m.id === id) : null;
const [mediaName, setMediaName] = useState(media?.media_name ?? ""); const [mediaName, setMediaName] = useState(media?.media_name ?? "");
@@ -55,51 +56,27 @@ export const MediaEditPage = observer(() => {
setMediaFilename(media.filename); setMediaFilename(media.filename);
setMediaType(media.media_type); setMediaType(media.media_type);
// Set available media types based on current file extension
const extension = media.filename.split(".").pop()?.toLowerCase(); const extension = media.filename.split(".").pop()?.toLowerCase();
if (extension) { if (extension) {
if (["glb", "gltf"].includes(extension)) { if (["glb", "gltf"].includes(extension)) {
setAvailableMediaTypes([6]); // 3D model setAvailableMediaTypes([6]);
} else if ( } else if (
["jpg", "jpeg", "png", "gif", "svg", "webp", "bmp"].includes( ["jpg", "jpeg", "png", "gif", "svg", "webp", "bmp"].includes(
extension extension
) )
) { ) {
setAvailableMediaTypes([1, 3, 4, 5]); // Photo, Icon, Watermark, Panorama setAvailableMediaTypes([1, 3, 4, 5]);
} else if (["mp4", "webm", "mov"].includes(extension)) { } else if (["mp4", "webm", "mov"].includes(extension)) {
setAvailableMediaTypes([2]); // Video setAvailableMediaTypes([2]);
} }
} }
} }
}, [media]); }, [media]);
useEffect(() => { useEffect(() => {
// Устанавливаем русский язык при загрузке страницы
languageStore.setLanguage("ru"); languageStore.setLanguage("ru");
}, []); }, []);
// const handleDrop = (e: DragEvent<HTMLDivElement>) => {
// e.preventDefault();
// e.stopPropagation();
// setIsDragging(false);
// const files = Array.from(e.dataTransfer.files);
// if (files.length > 0) {
// setNewFile(files[0]);
// setMediaFilename(files[0].name);
// setUploadDialogOpen(true); // Open dialog on file drop
// }
// };
// const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
// e.preventDefault();
// setIsDragging(true);
// };
// const handleDragLeave = () => {
// setIsDragging(false);
// };
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => { const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files; const files = e.target.files;
if (files && files.length > 0) { if (files && files.length > 0) {
@@ -107,26 +84,25 @@ export const MediaEditPage = observer(() => {
setNewFile(file); setNewFile(file);
setMediaFilename(file.name); setMediaFilename(file.name);
// Determine media type based on file extension
const extension = file.name.split(".").pop()?.toLowerCase(); const extension = file.name.split(".").pop()?.toLowerCase();
if (extension) { if (extension) {
if (["glb", "gltf"].includes(extension)) { if (["glb", "gltf"].includes(extension)) {
setAvailableMediaTypes([6]); // 3D model setAvailableMediaTypes([6]);
setMediaType(6); setMediaType(6);
} else if ( } else if (
["jpg", "jpeg", "png", "gif", "svg", "webp", "bmp"].includes( ["jpg", "jpeg", "png", "gif", "svg", "webp", "bmp"].includes(
extension extension
) )
) { ) {
setAvailableMediaTypes([1, 3, 4, 5]); // Photo, Icon, Watermark, Panorama setAvailableMediaTypes([1, 3, 4, 5]);
setMediaType(1); // Default to Photo setMediaType(1);
} else if (["mp4", "webm", "mov"].includes(extension)) { } else if (["mp4", "webm", "mov"].includes(extension)) {
setAvailableMediaTypes([2]); // Video setAvailableMediaTypes([2]);
setMediaType(2); setMediaType(2);
} }
} }
setUploadDialogOpen(true); // Open dialog on file selection setUploadDialogOpen(true);
} }
}; };
@@ -143,11 +119,6 @@ export const MediaEditPage = observer(() => {
type: mediaType, type: mediaType,
}); });
// If a new file was selected, the actual file upload will happen
// via the UploadMediaDialog. We just need to make sure the metadata
// is updated correctly before or after.
// Since the dialog handles the actual upload, we don't call updateMediaFile here.
setSuccess(true); setSuccess(true);
handleUploadSuccess(); handleUploadSuccess();
} catch (err) { } catch (err) {
@@ -158,20 +129,25 @@ export const MediaEditPage = observer(() => {
}; };
const handleUploadSuccess = () => { const handleUploadSuccess = () => {
// After successful upload in the dialog, refresh media data if needed
if (id) { if (id) {
mediaStore.getOneMedia(id); mediaStore.getOneMedia(id);
} }
setNewFile(null); // Clear the new file state after successful upload setNewFile(null);
setUploadDialogOpen(false); setUploadDialogOpen(false);
setSuccess(true); setSuccess(true);
}; };
if (!media && id) { if (!media && id) {
// Only show loading if an ID is present and media is not yet loaded
return ( return (
<Box className="flex justify-center items-center h-screen"> <Box
<CircularProgress /> sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
minHeight: "60vh",
}}
>
<LoadingSpinner message="Загрузка данных медиа..." />
</Box> </Box>
); );
} }

View File

@@ -2,7 +2,6 @@ import { useState, useEffect } from "react";
import { import {
Stack, Stack,
Typography, Typography,
Button,
FormControl, FormControl,
Accordion, Accordion,
AccordionSummary, AccordionSummary,
@@ -35,14 +34,15 @@ import {
} from "@hello-pangea/dnd"; } from "@hello-pangea/dnd";
import { import {
AnimatedCircleButton,
authInstance, authInstance,
languageStore, languageStore,
routeStore, routeStore,
selectedCityStore, selectedCityStore,
stationsStore,
} from "@shared"; } from "@shared";
import { EditStationModal } from "../../widgets/modals/EditStationModal"; import { EditStationModal } from "../../widgets/modals/EditStationModal";
// Helper function to insert an item at a specific position (1-based index)
function insertAtPosition<T>(arr: T[], pos: number, value: T): T[] { function insertAtPosition<T>(arr: T[], pos: number, value: T): T[] {
const index = pos - 1; const index = pos - 1;
const result = [...arr]; const result = [...arr];
@@ -54,7 +54,6 @@ function insertAtPosition<T>(arr: T[], pos: number, value: T): T[] {
return result; return result;
} }
// Helper function to reorder items after drag and drop
const reorder = <T,>(list: T[], startIndex: number, endIndex: number): T[] => { const reorder = <T,>(list: T[], startIndex: number, endIndex: number): T[] => {
const result = Array.from(list); const result = Array.from(list);
const [removed] = result.splice(startIndex, 1); const [removed] = result.splice(startIndex, 1);
@@ -143,6 +142,9 @@ const LinkedItemsContentsInner = <
const [selectedItems, setSelectedItems] = useState<Set<number>>(new Set()); const [selectedItems, setSelectedItems] = useState<Set<number>>(new Set());
const [activeTab, setActiveTab] = useState(0); const [activeTab, setActiveTab] = useState(0);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [isLinkingSingle, setIsLinkingSingle] = useState(false);
const [isLinkingBulk, setIsLinkingBulk] = useState(false);
const [detachingIds, setDetachingIds] = useState<Set<number>>(new Set());
useEffect(() => {}, [error]); useEffect(() => {}, [error]);
@@ -152,13 +154,11 @@ const LinkedItemsContentsInner = <
const availableItems = allItems const availableItems = allItems
.filter((item) => !linkedItems.some((linked) => linked.id === item.id)) .filter((item) => !linkedItems.some((linked) => linked.id === item.id))
.filter((item) => { .filter((item) => {
// Если направление маршрута не указано, показываем все станции
if (routeDirection === undefined) return true; if (routeDirection === undefined) return true;
// Фильтруем станции по направлению маршрута
return item.direction === routeDirection; return item.direction === routeDirection;
}) })
.filter((item) => { .filter((item) => {
// Фильтруем по городу из навбара
const selectedCityId = selectedCityStore.selectedCityId; const selectedCityId = selectedCityStore.selectedCityId;
if (selectedCityId && "city_id" in item) { if (selectedCityId && "city_id" in item) {
return item.city_id === selectedCityId; return item.city_id === selectedCityId;
@@ -167,7 +167,6 @@ const LinkedItemsContentsInner = <
}) })
.sort((a, b) => a.name.localeCompare(b.name)); .sort((a, b) => a.name.localeCompare(b.name));
// Фильтрация по поиску для массового режима
const filteredAvailableItems = availableItems.filter((item) => { const filteredAvailableItems = availableItems.filter((item) => {
if (!searchQuery.trim()) return true; if (!searchQuery.trim()) return true;
return String(item.name).toLowerCase().includes(searchQuery.toLowerCase()); return String(item.name).toLowerCase().includes(searchQuery.toLowerCase());
@@ -187,6 +186,19 @@ const LinkedItemsContentsInner = <
setPosition(linkedItems.length + 1); setPosition(linkedItems.length + 1);
}, [linkedItems.length]); }, [linkedItems.length]);
const getStationTransfers = (stationId: number, fallbackTransfers?: any) => {
const { stationLists } = stationsStore;
for (const lang of ["ru", "en", "zh"] as const) {
const station = stationLists[lang].data.find(
(s: any) => s.id === stationId
);
if (station?.transfers) {
return station.transfers;
}
}
return fallbackTransfers;
};
const onDragEnd = (result: DropResult) => { const onDragEnd = (result: DropResult) => {
if (!result.destination) return; if (!result.destination) return;
@@ -200,7 +212,14 @@ const LinkedItemsContentsInner = <
authInstance authInstance
.post(`/${parentResource}/${parentId}/${childResource}`, { .post(`/${parentResource}/${parentId}/${childResource}`, {
stations: reorderedItems.map((item) => ({ id: item.id })), stations: reorderedItems.map((item) => {
const stationData: any = { id: item.id };
const transfers = getStationTransfers(item.id, item.transfers);
if (transfers) {
stationData.transfers = transfers;
}
return stationData;
}),
}) })
.catch((error) => { .catch((error) => {
console.error("Error updating station order:", error); console.error("Error updating station order:", error);
@@ -247,14 +266,33 @@ const LinkedItemsContentsInner = <
const linkItem = () => { const linkItem = () => {
if (selectedItemId !== null) { if (selectedItemId !== null) {
setError(null); setError(null);
const selectedItem = allItems.find((item) => item.id === selectedItemId);
const requestData = { const requestData = {
stations: insertAtPosition( stations: insertAtPosition(
linkedItems.map((item) => ({ id: item.id })), linkedItems.map((item) => {
const stationData: any = { id: item.id };
const transfers = getStationTransfers(item.id, item.transfers);
if (transfers) {
stationData.transfers = transfers;
}
return stationData;
}),
position, position,
{ id: selectedItemId } (() => {
const newStationData: any = { id: selectedItemId };
const transfers = getStationTransfers(
selectedItemId,
selectedItem?.transfers
);
if (transfers) {
newStationData.transfers = transfers;
}
return newStationData;
})()
), ),
}; };
setIsLinkingSingle(true);
authInstance authInstance
.post(`/${parentResource}/${parentId}/${childResource}`, requestData) .post(`/${parentResource}/${parentId}/${childResource}`, requestData)
.then(() => { .then(() => {
@@ -273,12 +311,20 @@ const LinkedItemsContentsInner = <
.catch((error) => { .catch((error) => {
console.error("Error linking item:", error); console.error("Error linking item:", error);
setError("Failed to link station"); setError("Failed to link station");
})
.finally(() => {
setIsLinkingSingle(false);
}); });
} }
}; };
const deleteItem = (itemId: number) => { const deleteItem = (itemId: number) => {
setError(null); setError(null);
setDetachingIds((prev) => {
const next = new Set(prev);
next.add(itemId);
return next;
});
authInstance authInstance
.delete(`/${parentResource}/${parentId}/${childResource}`, { .delete(`/${parentResource}/${parentId}/${childResource}`, {
data: { [`${childResource}_id`]: itemId }, data: { [`${childResource}_id`]: itemId },
@@ -290,6 +336,13 @@ const LinkedItemsContentsInner = <
.catch((error) => { .catch((error) => {
console.error("Error deleting item:", error); console.error("Error deleting item:", error);
setError("Failed to delete station"); setError("Failed to delete station");
})
.finally(() => {
setDetachingIds((prev) => {
const next = new Set(prev);
next.delete(itemId);
return next;
});
}); });
}; };
@@ -316,10 +369,26 @@ const LinkedItemsContentsInner = <
if (selectedItems.size === 0) return; if (selectedItems.size === 0) return;
setError(null); setError(null);
const selectedStations = Array.from(selectedItems).map((id) => ({ id })); setIsLinkingBulk(true);
const selectedStations = Array.from(selectedItems).map((id) => {
const item = allItems.find((item) => item.id === id);
const stationData: any = { id };
const transfers = getStationTransfers(id, item?.transfers);
if (transfers) {
stationData.transfers = transfers;
}
return stationData;
});
const requestData = { const requestData = {
stations: [ stations: [
...linkedItems.map((item) => ({ id: item.id })), ...linkedItems.map((item) => {
const stationData: any = { id: item.id };
const transfers = getStationTransfers(item.id, item.transfers);
if (transfers) {
stationData.transfers = transfers;
}
return stationData;
}),
...selectedStations, ...selectedStations,
], ],
}; };
@@ -335,6 +404,9 @@ const LinkedItemsContentsInner = <
.catch((error) => { .catch((error) => {
console.error("Error linking items:", error); console.error("Error linking items:", error);
setError("Failed to link stations"); setError("Failed to link stations");
})
.finally(() => {
setIsLinkingBulk(false);
}); });
}; };
@@ -404,7 +476,7 @@ const LinkedItemsContentsInner = <
))} ))}
{type === "edit" && ( {type === "edit" && (
<TableCell> <TableCell>
<Button <AnimatedCircleButton
variant="outlined" variant="outlined"
color="error" color="error"
size="small" size="small"
@@ -412,9 +484,11 @@ const LinkedItemsContentsInner = <
e.stopPropagation(); e.stopPropagation();
deleteItem(item.id); deleteItem(item.id);
}} }}
disabled={detachingIds.has(item.id)}
loading={detachingIds.has(item.id)}
> >
Отвязать Отвязать
</Button> </AnimatedCircleButton>
</TableCell> </TableCell>
)} )}
</TableRow> </TableRow>
@@ -525,14 +599,15 @@ const LinkedItemsContentsInner = <
/> />
</FormControl> </FormControl>
<Button <AnimatedCircleButton
variant="contained" variant="contained"
onClick={linkItem} onClick={linkItem}
disabled={!selectedItemId} disabled={!selectedItemId || isLinkingSingle}
loading={isLinkingSingle}
sx={{ alignSelf: "flex-start" }} sx={{ alignSelf: "flex-start" }}
> >
Добавить Добавить
</Button> </AnimatedCircleButton>
</Stack> </Stack>
)} )}
@@ -562,7 +637,14 @@ const LinkedItemsContentsInner = <
size="small" size="small"
/> />
} }
label={String(item.name)} label={
<div className="flex justify-between items-center w-full gap-10">
<p>{String(item.name)}</p>
<p className="text-xs text-gray-500 max-w-[300px] truncate text-ellipsis">
{String(item.description)}
</p>
</div>
}
sx={{ sx={{
margin: 0, margin: 0,
"& .MuiFormControlLabel-label": { "& .MuiFormControlLabel-label": {
@@ -585,14 +667,15 @@ const LinkedItemsContentsInner = <
</Stack> </Stack>
</Paper> </Paper>
<Button <AnimatedCircleButton
variant="contained" variant="contained"
onClick={handleBulkLink} onClick={handleBulkLink}
disabled={selectedItems.size === 0} disabled={selectedItems.size === 0 || isLinkingBulk}
loading={isLinkingBulk}
sx={{ alignSelf: "flex-start" }} sx={{ alignSelf: "flex-start" }}
> >
Добавить выбранные ({selectedItems.size}) Добавить выбранные ({selectedItems.size})
</Button> </AnimatedCircleButton>
</Stack> </Stack>
)} )}
</Box> </Box>

View File

@@ -13,9 +13,9 @@ import {
DialogContent, DialogContent,
DialogActions, DialogActions,
} from "@mui/material"; } from "@mui/material";
import { MediaViewer } from "@widgets"; import { MediaViewer, VideoPreviewCard } from "@widgets";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { ArrowLeft, Loader2, Save, Plus } from "lucide-react"; import { ArrowLeft, Loader2, Save, Plus, X } from "lucide-react";
import { useEffect, useState, useMemo } from "react"; import { useEffect, useState, useMemo } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
@@ -24,9 +24,10 @@ import { articlesStore } from "../../../shared/store/ArticlesStore";
import { Route, routeStore } from "../../../shared/store/RouteStore"; import { Route, routeStore } from "../../../shared/store/RouteStore";
import { import {
languageStore, languageStore,
SelectArticleModal, ArticleSelectOrCreateDialog,
SelectMediaDialog, SelectMediaDialog,
selectedCityStore, selectedCityStore,
UploadMediaDialog,
} from "@shared"; } from "@shared";
export const RouteCreatePage = observer(() => { export const RouteCreatePage = observer(() => {
@@ -37,8 +38,9 @@ export const RouteCreatePage = observer(() => {
const [govRouteNumber, setGovRouteNumber] = useState(""); const [govRouteNumber, setGovRouteNumber] = useState("");
const [governorAppeal, setGovernorAppeal] = useState<string>(""); const [governorAppeal, setGovernorAppeal] = useState<string>("");
const [direction, setDirection] = useState("backward"); const [direction, setDirection] = useState("backward");
const [scaleMin, setScaleMin] = useState(""); const [scaleMin, setScaleMin] = useState("10");
const [scaleMax, setScaleMax] = useState(""); const [scaleMax, setScaleMax] = useState("100");
const [routeName, setRouteName] = useState("");
const [turn, setTurn] = useState(""); const [turn, setTurn] = useState("");
const [centerLat, setCenterLat] = useState(""); const [centerLat, setCenterLat] = useState("");
const [centerLng, setCenterLng] = useState(""); const [centerLng, setCenterLng] = useState("");
@@ -48,6 +50,8 @@ export const RouteCreatePage = observer(() => {
useState(false); useState(false);
const [isSelectVideoDialogOpen, setIsSelectVideoDialogOpen] = useState(false); const [isSelectVideoDialogOpen, setIsSelectVideoDialogOpen] = useState(false);
const [isVideoPreviewOpen, setIsVideoPreviewOpen] = useState(false); const [isVideoPreviewOpen, setIsVideoPreviewOpen] = useState(false);
const [isUploadVideoDialogOpen, setIsUploadVideoDialogOpen] = useState(false);
const [fileToUpload, setFileToUpload] = useState<File | null>(null);
const { language } = languageStore; const { language } = languageStore;
useEffect(() => { useEffect(() => {
@@ -55,7 +59,6 @@ export const RouteCreatePage = observer(() => {
articlesStore.getArticleList(); articlesStore.getArticleList();
}, [language]); }, [language]);
// Фильтруем перевозчиков только из выбранного города
const filteredCarriers = useMemo(() => { const filteredCarriers = useMemo(() => {
const carriers = const carriers =
carrierStore.carriers[language as keyof typeof carrierStore.carriers] carrierStore.carriers[language as keyof typeof carrierStore.carriers]
@@ -110,6 +113,7 @@ export const RouteCreatePage = observer(() => {
const handleArticleSelect = (articleId: number) => { const handleArticleSelect = (articleId: number) => {
setGovernorAppeal(articleId.toString()); setGovernorAppeal(articleId.toString());
setIsSelectArticleDialogOpen(false); setIsSelectArticleDialogOpen(false);
articlesStore.getArticleList();
}; };
const handleVideoSelect = (media: { const handleVideoSelect = (media: {
@@ -122,6 +126,26 @@ export const RouteCreatePage = observer(() => {
setIsSelectVideoDialogOpen(false); setIsSelectVideoDialogOpen(false);
}; };
const handleVideoFileSelect = (file?: File) => {
if (file) {
setFileToUpload(file);
setIsUploadVideoDialogOpen(true);
} else {
setIsSelectVideoDialogOpen(true);
}
};
const handleVideoUpload = (media: {
id: string;
filename: string;
media_name?: string;
media_type: number;
}) => {
setVideoPreview(media.id);
setIsUploadVideoDialogOpen(false);
setFileToUpload(null);
};
const handleVideoPreviewClick = () => { const handleVideoPreviewClick = () => {
setIsVideoPreviewOpen(true); setIsVideoPreviewOpen(true);
}; };
@@ -129,23 +153,69 @@ export const RouteCreatePage = observer(() => {
const handleCreateRoute = async () => { const handleCreateRoute = async () => {
try { try {
setIsLoading(true); setIsLoading(true);
// Преобразуем значения в нужные типы
if (!routeName.trim()) {
toast.error("Заполните название маршрута");
setIsLoading(false);
return;
}
if (!carrier) {
toast.error("Выберите перевозчика");
setIsLoading(false);
return;
}
if (!routeNumber.trim()) {
toast.error("Заполните номер маршрута");
setIsLoading(false);
return;
}
if (!govRouteNumber.trim()) {
toast.error("Заполните номер маршрута в Говорящем Городе");
setIsLoading(false);
return;
}
const validationResult = validateCoordinates(routeCoords);
if (validationResult !== true) {
toast.error(validationResult);
setIsLoading(false);
return;
}
const scale_min = scaleMin ? Number(scaleMin) : null;
const scale_max = scaleMax ? Number(scaleMax) : null;
if (
scale_min === 0 ||
scale_max === 0 ||
scale_min === null ||
scale_max === null
) {
toast.error("Масштабы не могут быть равны 0");
setIsLoading(false);
return;
}
if (
scale_min !== null &&
scale_max !== null &&
scale_max !== undefined &&
scale_min > scale_max
) {
toast.error("Максимальный масштаб не может быть меньше минимального");
setIsLoading(false);
return;
}
const carrier_id = Number(carrier); const carrier_id = Number(carrier);
const governor_appeal = Number(governorAppeal); const governor_appeal = governorAppeal
const scale_min = scaleMin ? Number(scaleMin) : undefined; ? Number(governorAppeal)
const scale_max = scaleMax ? Number(scaleMax) : undefined; : undefined;
const rotate = turn ? Number(turn) : undefined; const rotate = turn ? Number(turn) : undefined;
const center_latitude = centerLat ? Number(centerLat) : undefined; const center_latitude = centerLat ? Number(centerLat) : undefined;
const center_longitude = centerLng ? Number(centerLng) : undefined; const center_longitude = centerLng ? Number(centerLng) : undefined;
const route_direction = direction === "forward"; const route_direction = direction === "forward";
const validationResult = validateCoordinates(routeCoords);
if (validationResult !== true) {
toast.error(validationResult);
return;
}
// Координаты маршрута как массив массивов чисел
const path = routeCoords const path = routeCoords
.trim() .trim()
.split("\n") .split("\n")
@@ -157,7 +227,6 @@ export const RouteCreatePage = observer(() => {
return [lat, lon]; return [lat, lon];
}); });
// Собираем объект маршрута
const newRoute: Partial<Route> = { const newRoute: Partial<Route> = {
carrier: carrier:
carrierStore.carriers[ carrierStore.carriers[
@@ -166,10 +235,10 @@ export const RouteCreatePage = observer(() => {
carrier_id, carrier_id,
route_number: routeNumber, route_number: routeNumber,
route_sys_number: govRouteNumber, route_sys_number: govRouteNumber,
governor_appeal, route_name: routeName,
route_direction, route_direction,
scale_min, scale_min: scale_min !== null ? scale_min : 0,
scale_max, scale_max: scale_max !== null ? scale_max : 0,
rotate, rotate,
center_latitude, center_latitude,
center_longitude, center_longitude,
@@ -178,6 +247,10 @@ export const RouteCreatePage = observer(() => {
videoPreview && videoPreview !== "" ? videoPreview : undefined, videoPreview && videoPreview !== "" ? videoPreview : undefined,
}; };
if (governor_appeal !== undefined) {
newRoute.governor_appeal = governor_appeal;
}
await routeStore.createRoute(newRoute); await routeStore.createRoute(newRoute);
toast.success("Маршрут успешно создан"); toast.success("Маршрут успешно создан");
navigate(-1); navigate(-1);
@@ -189,7 +262,6 @@ export const RouteCreatePage = observer(() => {
} }
}; };
// Получаем название выбранной статьи для отображения
const selectedArticle = articlesStore.articleList.ru.data.find( const selectedArticle = articlesStore.articleList.ru.data.find(
(article) => article.id === Number(governorAppeal) (article) => article.id === Number(governorAppeal)
); );
@@ -208,6 +280,13 @@ export const RouteCreatePage = observer(() => {
<div className="flex flex-col gap-10 w-full items-end"> <div className="flex flex-col gap-10 w-full items-end">
<Box className="flex flex-col gap-6 w-full"> <Box className="flex flex-col gap-6 w-full">
<TextField
className="w-full"
label="Название маршрута"
required
value={routeName}
onChange={(e) => setRouteName(e.target.value)}
/>
<FormControl fullWidth required> <FormControl fullWidth required>
<InputLabel>Выберите перевозчика</InputLabel> <InputLabel>Выберите перевозчика</InputLabel>
<Select <Select
@@ -247,7 +326,6 @@ export const RouteCreatePage = observer(() => {
const lines = routeCoords.split("\n"); const lines = routeCoords.split("\n");
const lastLine = lines[lines.length - 1]; const lastLine = lines[lines.length - 1];
// Если мы на последней строке и она не пустая
if (lastLine && lastLine.trim()) { if (lastLine && lastLine.trim()) {
e.preventDefault(); e.preventDefault();
const newValue = routeCoords + "\n"; const newValue = routeCoords + "\n";
@@ -279,6 +357,7 @@ export const RouteCreatePage = observer(() => {
}, },
}} }}
/> />
<TextField <TextField
className="w-full" className="w-full"
label="Номер маршрута в Говорящем Городе" label="Номер маршрута в Говорящем Городе"
@@ -287,23 +366,33 @@ export const RouteCreatePage = observer(() => {
onChange={(e) => setGovRouteNumber(e.target.value)} onChange={(e) => setGovRouteNumber(e.target.value)}
/> />
{/* Заменяем Select на кнопку для выбора статьи */} <Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
<Box className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">
Обращение к пассажирам Обращение к пассажирам
</label> </Typography>
<Box className="flex gap-2"> <Box className="flex gap-2">
<TextField <TextField
className="flex-1" className="flex-1"
value={selectedArticle?.heading || "Статья не выбрана"} value={selectedArticle?.heading || "Статья не выбрана"}
placeholder="Выберите статью" placeholder="Выберите статью"
disabled disabled
fullWidth
sx={{ sx={{
"& .MuiInputBase-input": { "& .MuiInputBase-input": {
color: selectedArticle ? "inherit" : "#999", color: selectedArticle ? "inherit" : "#999",
}, },
}} }}
/> />
{selectedArticle && (
<Button
variant="outlined"
color="error"
onClick={() => setGovernorAppeal("")}
startIcon={<X size={16} />}
sx={{ minWidth: "auto", px: 2 }}
>
Сбросить
</Button>
)}
<Button <Button
variant="outlined" variant="outlined"
onClick={() => setIsSelectArticleDialogOpen(true)} onClick={() => setIsSelectArticleDialogOpen(true)}
@@ -313,73 +402,17 @@ export const RouteCreatePage = observer(() => {
Выбрать Выбрать
</Button> </Button>
</Box> </Box>
</Box>
{/* Селектор видеозаставки */} <VideoPreviewCard
<Box className="flex flex-col gap-2"> title="Видеозаставка"
<label className="text-sm font-medium text-gray-700"> videoId={videoPreview}
Видеозаставка onVideoClick={handleVideoPreviewClick}
</label> onDeleteVideoClick={() => {
<Box className="flex gap-2">
<Box
className="flex-1"
onClick={handleVideoPreviewClick}
sx={{
cursor:
videoPreview && videoPreview !== "" ? "pointer" : "default",
}}
>
<Box
className="w-full h-[50px] border border-gray-400 rounded-sm flex items-center justify-between px-4"
sx={{
"& .MuiInputBase-input": {
color:
videoPreview && videoPreview !== ""
? "inherit"
: "#999",
cursor:
videoPreview && videoPreview !== ""
? "pointer"
: "default",
},
}}
>
<Typography variant="body1" className="text-sm">
{videoPreview && videoPreview !== ""
? "Видео выбрано"
: "Видео не выбрано"}
</Typography>
{videoPreview && videoPreview !== "" && (
<Box
onClick={(e) => {
e.stopPropagation();
setVideoPreview(""); setVideoPreview("");
}} }}
sx={{ onSelectVideoClick={handleVideoFileSelect}
cursor: "pointer", className="w-full"
color: "#999", />
"&:hover": {
color: "#666",
},
}}
>
<Typography variant="body1" className="text-lg font-bold">
×
</Typography>
</Box>
)}
</Box>
</Box>
<Button
variant="outlined"
onClick={() => setIsSelectVideoDialogOpen(true)}
startIcon={<Plus size={16} />}
sx={{ minWidth: "auto", px: 2 }}
>
Выбрать
</Button>
</Box>
</Box>
<FormControl fullWidth required> <FormControl fullWidth required>
<InputLabel>Прямой/обратный маршрут</InputLabel> <InputLabel>Прямой/обратный маршрут</InputLabel>
@@ -395,15 +428,53 @@ export const RouteCreatePage = observer(() => {
<TextField <TextField
className="w-full" className="w-full"
label="Масштаб (мин)" label="Масштаб (мин)"
type="number"
value={scaleMin} value={scaleMin}
onChange={(e) => setScaleMin(e.target.value)} onChange={(e) => {
let value = e.target.value;
if (Number(value) > 297) {
value = "297";
}
if (Number(value) < 10) {
value = "10";
}
setScaleMin(value);
if (value && scaleMax && Number(value) > Number(scaleMax)) {
setScaleMax(value);
}
}}
error={
scaleMin !== "" &&
scaleMax !== "" &&
Number(scaleMin) > Number(scaleMax)
}
required
helperText={
scaleMin !== "" &&
scaleMax !== "" &&
Number(scaleMin) > Number(scaleMax)
? "Минимальный масштаб не может быть больше максимального"
: ""
}
/> />
<TextField <TextField
className="w-full" className="w-full"
label="Масштаб (макс)" label="Масштаб (макс)"
type="number"
value={scaleMax} value={scaleMax}
onChange={(e) => setScaleMax(e.target.value)} required
onChange={(e) => {
if (Number(e.target.value) > 300) {
e.target.value = "300";
}
const value = e.target.value;
setScaleMax(value);
}}
/> />
<TextField <TextField
className="w-full" className="w-full"
label="Поворот" label="Поворот"
@@ -440,23 +511,17 @@ export const RouteCreatePage = observer(() => {
</Button> </Button>
</div> </div>
</div> </div>
<ArticleSelectOrCreateDialog
{/* Модальное окно выбора статьи */}
<SelectArticleModal
open={isSelectArticleDialogOpen} open={isSelectArticleDialogOpen}
onClose={() => setIsSelectArticleDialogOpen(false)} onClose={() => setIsSelectArticleDialogOpen(false)}
onSelectArticle={handleArticleSelect} onSelectArticle={handleArticleSelect}
/> />
{/* Модальное окно выбора видео */}
<SelectMediaDialog <SelectMediaDialog
open={isSelectVideoDialogOpen} open={isSelectVideoDialogOpen}
onClose={() => setIsSelectVideoDialogOpen(false)} onClose={() => setIsSelectVideoDialogOpen(false)}
onSelectMedia={handleVideoSelect} onSelectMedia={handleVideoSelect}
mediaType={2} mediaType={2}
/> />
{/* Модальное окно предпросмотра видео */}
{videoPreview && videoPreview !== "" && ( {videoPreview && videoPreview !== "" && (
<Dialog <Dialog
open={isVideoPreviewOpen} open={isVideoPreviewOpen}
@@ -483,6 +548,18 @@ export const RouteCreatePage = observer(() => {
</DialogActions> </DialogActions>
</Dialog> </Dialog>
)} )}
<UploadMediaDialog
open={isUploadVideoDialogOpen}
onClose={() => {
setIsUploadVideoDialogOpen(false);
setFileToUpload(null);
}}
hardcodeType="video_preview"
contextObjectName={routeName || "Маршрут"}
contextType="sight"
initialFile={fileToUpload || undefined}
afterUpload={handleVideoUpload}
/>
</Paper> </Paper>
); );
}); });

View File

@@ -13,9 +13,9 @@ import {
DialogContent, DialogContent,
DialogActions, DialogActions,
} from "@mui/material"; } from "@mui/material";
import { MediaViewer } from "@widgets"; import { MediaViewer, VideoPreviewCard } from "@widgets";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { ArrowLeft, Copy, Save, Plus } from "lucide-react"; import { ArrowLeft, Copy, Save, Plus, X } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
@@ -24,8 +24,10 @@ import { articlesStore } from "../../../shared/store/ArticlesStore";
import { import {
routeStore, routeStore,
languageStore, languageStore,
SelectArticleModal, ArticleSelectOrCreateDialog,
SelectMediaDialog, SelectMediaDialog,
UploadMediaDialog,
LoadingSpinner,
} from "@shared"; } from "@shared";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { stationsStore } from "@shared"; import { stationsStore } from "@shared";
@@ -36,28 +38,39 @@ export const RouteEditPage = observer(() => {
const { id } = useParams(); const { id } = useParams();
const { editRouteData, copyRouteAction } = routeStore; const { editRouteData, copyRouteAction } = routeStore;
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isLoadingData, setIsLoadingData] = useState(true);
const [isSelectArticleDialogOpen, setIsSelectArticleDialogOpen] = const [isSelectArticleDialogOpen, setIsSelectArticleDialogOpen] =
useState(false); useState(false);
const [isSelectVideoDialogOpen, setIsSelectVideoDialogOpen] = useState(false); const [isSelectVideoDialogOpen, setIsSelectVideoDialogOpen] = useState(false);
const [isVideoPreviewOpen, setIsVideoPreviewOpen] = useState(false); const [isVideoPreviewOpen, setIsVideoPreviewOpen] = useState(false);
const [isUploadVideoDialogOpen, setIsUploadVideoDialogOpen] = useState(false);
const [fileToUpload, setFileToUpload] = useState<File | null>(null);
const { language } = languageStore; const { language } = languageStore;
const [coordinates, setCoordinates] = useState<string>(""); const [coordinates, setCoordinates] = useState<string>("");
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
// Устанавливаем русский язык при загрузке страницы if (!id) {
setIsLoadingData(false);
return;
}
setIsLoadingData(true);
try {
const response = await routeStore.getRoute(Number(id)); const response = await routeStore.getRoute(Number(id));
routeStore.setEditRouteData(response); routeStore.setEditRouteData(response);
languageStore.setLanguage("ru"); languageStore.setLanguage("ru");
} finally {
setIsLoadingData(false);
}
}; };
fetchData(); fetchData();
}, []); }, [id]);
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
carrierStore.getCarriers(language); await carrierStore.getCarriers(language);
stationsStore.getStations(); await stationsStore.getStations();
articlesStore.getArticleList(); await articlesStore.getArticleList();
}; };
fetchData(); fetchData();
}, [id, language]); }, [id, language]);
@@ -72,10 +85,63 @@ export const RouteEditPage = observer(() => {
}, [editRouteData.path]); }, [editRouteData.path]);
const handleSave = async () => { const handleSave = async () => {
// Валидация обязательных полей
if (!editRouteData.route_name?.trim()) {
toast.error("Заполните название маршрута");
return;
}
if (!editRouteData.carrier_id) {
toast.error("Выберите перевозчика");
return;
}
if (!editRouteData.route_number?.trim()) {
toast.error("Заполните номер маршрута");
return;
}
if (!editRouteData.route_sys_number?.trim()) {
toast.error("Заполните номер маршрута в Говорящем Городе");
return;
}
const validationResult = validateCoordinates(coordinates);
if (validationResult !== true) {
toast.error(validationResult);
return;
}
// Валидация масштабов
if (
editRouteData.scale_min !== null &&
editRouteData.scale_min !== undefined &&
editRouteData.scale_max !== null &&
editRouteData.scale_max !== undefined &&
editRouteData.scale_min > editRouteData.scale_max
) {
toast.error("Максимальный масштаб не может быть меньше минимального");
return;
}
if (
editRouteData.scale_min === 0 ||
editRouteData.scale_max === 0 ||
editRouteData.scale_min === null ||
editRouteData.scale_max === null
) {
toast.error("Масштабы не могут быть равны 0");
setIsLoading(false);
return;
}
setIsLoading(true); setIsLoading(true);
try {
await routeStore.editRoute(Number(id)); await routeStore.editRoute(Number(id));
toast.success("Маршрут успешно сохранен"); toast.success("Маршрут успешно сохранен");
} catch (error) {
console.error(error);
toast.error("Произошла ошибка при сохранении маршрута");
} finally {
setIsLoading(false); setIsLoading(false);
}
}; };
const validateCoordinates = (value: string) => { const validateCoordinates = (value: string) => {
@@ -125,6 +191,8 @@ export const RouteEditPage = observer(() => {
governor_appeal: articleId, governor_appeal: articleId,
}); });
setIsSelectArticleDialogOpen(false); setIsSelectArticleDialogOpen(false);
// Обновляем список статей после создания новой
articlesStore.getArticleList();
}; };
const handleVideoSelect = (media: { const handleVideoSelect = (media: {
@@ -139,6 +207,28 @@ export const RouteEditPage = observer(() => {
setIsSelectVideoDialogOpen(false); setIsSelectVideoDialogOpen(false);
}; };
const handleVideoFileSelect = (file?: File) => {
if (file) {
setFileToUpload(file);
setIsUploadVideoDialogOpen(true);
} else {
setIsSelectVideoDialogOpen(true);
}
};
const handleVideoUpload = (media: {
id: string;
filename: string;
media_name?: string;
media_type: number;
}) => {
routeStore.setEditRouteData({
video_preview: media.id,
});
setIsUploadVideoDialogOpen(false);
setFileToUpload(null);
};
const handleVideoPreviewClick = () => { const handleVideoPreviewClick = () => {
if (editRouteData.video_preview && editRouteData.video_preview !== "") { if (editRouteData.video_preview && editRouteData.video_preview !== "") {
setIsVideoPreviewOpen(true); setIsVideoPreviewOpen(true);
@@ -150,6 +240,21 @@ export const RouteEditPage = observer(() => {
(article) => article.id === editRouteData.governor_appeal (article) => article.id === editRouteData.governor_appeal
); );
if (isLoadingData) {
return (
<Box
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
minHeight: "60vh",
}}
>
<LoadingSpinner message="Загрузка данных маршрута..." />
</Box>
);
}
return ( return (
<Paper 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">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
@@ -164,6 +269,17 @@ export const RouteEditPage = observer(() => {
<div className="flex flex-col gap-10 w-full items-end"> <div className="flex flex-col gap-10 w-full items-end">
<Box className="flex flex-col gap-6 w-full"> <Box className="flex flex-col gap-6 w-full">
<TextField
className="w-full"
label="Название маршрута"
required
value={editRouteData.route_name || ""}
onChange={(e) =>
routeStore.setEditRouteData({
route_name: e.target.value,
})
}
/>
<FormControl fullWidth required> <FormControl fullWidth required>
<InputLabel>Выберите перевозчика</InputLabel> <InputLabel>Выберите перевозчика</InputLabel>
<Select <Select
@@ -235,7 +351,6 @@ export const RouteEditPage = observer(() => {
const lines = coordinates.split("\n"); const lines = coordinates.split("\n");
const lastLine = lines[lines.length - 1]; const lastLine = lines[lines.length - 1];
// Если мы на последней строке и она не пустая
if (lastLine && lastLine.trim()) { if (lastLine && lastLine.trim()) {
e.preventDefault(); e.preventDefault();
const newValue = coordinates + "\n"; const newValue = coordinates + "\n";
@@ -279,110 +394,6 @@ export const RouteEditPage = observer(() => {
} }
/> />
{/* Заменяем Select на кнопку для выбора статьи */}
<Box className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">
Обращение к пассажирам
</label>
<Box className="flex gap-2">
<TextField
className="flex-1"
value={selectedArticle?.heading || "Статья не выбрана"}
placeholder="Выберите статью"
disabled
sx={{
"& .MuiInputBase-input": {
color: selectedArticle ? "inherit" : "#999",
},
}}
/>
<Button
variant="outlined"
onClick={() => setIsSelectArticleDialogOpen(true)}
startIcon={<Plus size={16} />}
sx={{ minWidth: "auto", px: 2 }}
>
Выбрать
</Button>
</Box>
</Box>
{/* Селектор видеозаставки */}
<Box className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">
Видеозаставка
</label>
<Box className="flex gap-2">
<Box
className="flex-1"
onClick={handleVideoPreviewClick}
sx={{
cursor:
editRouteData.video_preview &&
editRouteData.video_preview !== ""
? "pointer"
: "default",
}}
>
<Box
className="w-full h-[50px] border border-gray-400 rounded-sm flex items-center justify-between px-4"
sx={{
"& .MuiInputBase-input": {
color:
editRouteData.video_preview &&
editRouteData.video_preview !== ""
? "inherit"
: "#999",
cursor:
editRouteData.video_preview &&
editRouteData.video_preview !== ""
? "pointer"
: "default",
},
}}
>
<Typography variant="body1" className="text-sm">
{editRouteData.video_preview &&
editRouteData.video_preview !== ""
? "Видео выбрано"
: "Видео не выбрано"}
</Typography>
{editRouteData.video_preview &&
editRouteData.video_preview !== "" && (
<Box
onClick={(e) => {
e.stopPropagation();
routeStore.setEditRouteData({ video_preview: "" });
}}
sx={{
cursor: "pointer",
color: "#999",
"&:hover": {
color: "#666",
},
}}
>
<Typography
variant="body1"
className="text-lg font-bold"
>
×
</Typography>
</Box>
)}
</Box>
</Box>
<Button
variant="outlined"
onClick={() => setIsSelectVideoDialogOpen(true)}
startIcon={<Plus size={16} />}
sx={{ minWidth: "auto", px: 2 }}
>
Выбрать
</Button>
</Box>
</Box>
<FormControl fullWidth required> <FormControl fullWidth required>
<InputLabel>Прямой/обратный маршрут</InputLabel> <InputLabel>Прямой/обратный маршрут</InputLabel>
<Select <Select
@@ -401,23 +412,69 @@ export const RouteEditPage = observer(() => {
<TextField <TextField
className="w-full" className="w-full"
label="Масштаб (мин)" label="Масштаб (мин)"
type="number"
value={editRouteData.scale_min ?? ""} value={editRouteData.scale_min ?? ""}
onChange={(e) => onChange={(e) => {
routeStore.setEditRouteData({ let value = e.target.value === "" ? null : e.target.value;
scale_min:
e.target.value === "" ? null : parseFloat(e.target.value), if (value && Number(value) > 297) {
}) value = "297";
} }
if (value && Number(value) < 10) {
value = "10";
}
routeStore.setEditRouteData({
scale_min: value ? Number(value) : null,
});
// Если максимальный масштаб стал меньше минимального, обновляем его
if (
value !== null &&
editRouteData.scale_max !== null &&
editRouteData.scale_max !== undefined &&
value &&
Number(value) > (editRouteData.scale_max ?? 0)
) {
routeStore.setEditRouteData({
scale_max: value ? Number(value) : null,
});
}
}}
required
/> />
<TextField <TextField
className="w-full" className="w-full"
required
label="Масштаб (макс)" label="Масштаб (макс)"
type="number"
value={editRouteData.scale_max ?? ""} value={editRouteData.scale_max ?? ""}
onChange={(e) => onChange={(e) => {
let value = e.target.value;
if (Number(value) > 300) {
value = "300";
}
routeStore.setEditRouteData({ routeStore.setEditRouteData({
scale_max: scale_max: value === "" ? null : parseFloat(value),
e.target.value === "" ? null : parseFloat(e.target.value), });
}) }}
error={
editRouteData.scale_min !== null &&
editRouteData.scale_min !== undefined &&
editRouteData.scale_max !== null &&
editRouteData.scale_max !== undefined &&
editRouteData.scale_max < editRouteData.scale_min
}
helperText={
editRouteData.scale_min !== null &&
editRouteData.scale_min !== undefined &&
editRouteData.scale_max !== null &&
editRouteData.scale_max !== undefined &&
editRouteData.scale_max < editRouteData.scale_min
? "Максимальный масштаб не может быть меньше минимального"
: ""
} }
/> />
<TextField <TextField
@@ -453,6 +510,58 @@ export const RouteEditPage = observer(() => {
}) })
} }
/> />
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
Обращение к пассажирам
</Typography>
<Box className="flex gap-2">
<TextField
className="flex-1"
value={selectedArticle?.heading || "Статья не выбрана"}
placeholder="Выберите статью"
disabled
fullWidth
sx={{
"& .MuiInputBase-input": {
color: selectedArticle ? "inherit" : "#999",
},
}}
/>
{selectedArticle && (
<Button
variant="outlined"
color="error"
onClick={() =>
routeStore.setEditRouteData({
governor_appeal: 0,
})
}
startIcon={<X size={16} />}
sx={{ minWidth: "auto", px: 2 }}
>
Сбросить
</Button>
)}
<Button
variant="outlined"
onClick={() => setIsSelectArticleDialogOpen(true)}
startIcon={<Plus size={16} />}
sx={{ minWidth: "auto", px: 2 }}
>
Выбрать
</Button>
</Box>
<VideoPreviewCard
title="Видеозаставка"
videoId={editRouteData.video_preview}
onVideoClick={handleVideoPreviewClick}
onDeleteVideoClick={() => {
routeStore.setEditRouteData({ video_preview: "" });
}}
onSelectVideoClick={handleVideoFileSelect}
className="w-full"
/>
</Box> </Box>
<LinkedItems <LinkedItems
@@ -493,23 +602,17 @@ export const RouteEditPage = observer(() => {
</Button> </Button>
</div> </div>
</div> </div>
<ArticleSelectOrCreateDialog
{/* Модальное окно выбора статьи */}
<SelectArticleModal
open={isSelectArticleDialogOpen} open={isSelectArticleDialogOpen}
onClose={() => setIsSelectArticleDialogOpen(false)} onClose={() => setIsSelectArticleDialogOpen(false)}
onSelectArticle={handleArticleSelect} onSelectArticle={handleArticleSelect}
/> />
{/* Модальное окно выбора видео */}
<SelectMediaDialog <SelectMediaDialog
open={isSelectVideoDialogOpen} open={isSelectVideoDialogOpen}
onClose={() => setIsSelectVideoDialogOpen(false)} onClose={() => setIsSelectVideoDialogOpen(false)}
onSelectMedia={handleVideoSelect} onSelectMedia={handleVideoSelect}
mediaType={2} mediaType={2}
/> />
{/* Модальное окно предпросмотра видео */}
<Dialog <Dialog
open={isVideoPreviewOpen} open={isVideoPreviewOpen}
onClose={() => setIsVideoPreviewOpen(false)} onClose={() => setIsVideoPreviewOpen(false)}
@@ -519,6 +622,7 @@ export const RouteEditPage = observer(() => {
<DialogTitle>Предпросмотр видео</DialogTitle> <DialogTitle>Предпросмотр видео</DialogTitle>
<DialogContent> <DialogContent>
<Box className="flex justify-center items-center p-4"> <Box className="flex justify-center items-center p-4">
{editRouteData.video_preview && (
<MediaViewer <MediaViewer
media={{ media={{
id: editRouteData.video_preview, id: editRouteData.video_preview,
@@ -526,12 +630,25 @@ export const RouteEditPage = observer(() => {
filename: "video_preview", filename: "video_preview",
}} }}
/> />
)}
</Box> </Box>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={() => setIsVideoPreviewOpen(false)}>Закрыть</Button> <Button onClick={() => setIsVideoPreviewOpen(false)}>Закрыть</Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>
<UploadMediaDialog
open={isUploadVideoDialogOpen}
onClose={() => {
setIsUploadVideoDialogOpen(false);
setFileToUpload(null);
}}
hardcodeType="video_preview"
contextObjectName={editRouteData.route_name || "Маршрут"}
contextType="sight"
initialFile={fileToUpload || undefined}
afterUpload={handleVideoUpload}
/>
</Paper> </Paper>
); );
}); });

View File

@@ -50,6 +50,22 @@ export const RouteListPage = observer(() => {
); );
}, },
}, },
{
field: "route_name",
headerName: "Название маршрута",
flex: 1,
renderCell: (params: GridRenderCellParams) => {
return (
<div className="w-full h-full flex items-center">
{params.value ? (
params.value
) : (
<Minus size={20} className="text-red-500" />
)}
</div>
);
},
},
{ {
field: "route_number", field: "route_number",
headerName: "Номер маршрута", headerName: "Номер маршрута",
@@ -100,9 +116,7 @@ export const RouteListPage = observer(() => {
<button onClick={() => navigate(`/route-preview/${params.row.id}`)}> <button onClick={() => navigate(`/route-preview/${params.row.id}`)}>
<Map size={20} className="text-purple-500" /> <Map size={20} className="text-purple-500" />
</button> </button>
{/* <button onClick={() => navigate(`/route/${params.row.id}`)}>
<Eye size={20} className="text-green-500" />
</button> */}
<button <button
onClick={() => { onClick={() => {
setIsDeleteModalOpen(true); setIsDeleteModalOpen(true);
@@ -122,6 +136,7 @@ export const RouteListPage = observer(() => {
carrier_id: route.carrier_id, carrier_id: route.carrier_id,
route_number: route.route_number, route_number: route.route_number,
route_direction: route.route_direction ? "Прямой" : "Обратный", route_direction: route.route_direction ? "Прямой" : "Обратный",
route_name: route.route_name,
})); }));
return ( return (

View File

@@ -5,5 +5,5 @@ export const STATION_OUTLINE_WIDTH = 4;
export const SIGHT_SIZE = 40; export const SIGHT_SIZE = 40;
export const SCALE_FACTOR = 50; export const SCALE_FACTOR = 50;
export const BACKGROUND_COLOR = 0x111111; export const BACKGROUND_COLOR = 0x000;
export const PATH_COLOR = 0xff4d4d; export const PATH_COLOR = 0xff4d4d;

View File

@@ -47,10 +47,8 @@ export function InfiniteCanvas({
const [startPosition, setStartPosition] = useState({ x: 0, y: 0 }); const [startPosition, setStartPosition] = useState({ x: 0, y: 0 });
const [isPointerDown, setIsPointerDown] = useState(false); const [isPointerDown, setIsPointerDown] = useState(false);
// Флаг для предотвращения конфликта между пользовательским вводом и данными маршрута
const [isUserInteracting, setIsUserInteracting] = useState(false); const [isUserInteracting, setIsUserInteracting] = useState(false);
// Реф для отслеживания последнего значения originalRouteData?.rotate
const lastOriginalRotation = useRef<number | undefined>(undefined); const lastOriginalRotation = useRef<number | undefined>(undefined);
useEffect(() => { useEffect(() => {
@@ -68,7 +66,7 @@ export function InfiniteCanvas({
const handlePointerDown = (e: FederatedMouseEvent) => { const handlePointerDown = (e: FederatedMouseEvent) => {
setIsPointerDown(true); setIsPointerDown(true);
setIsDragging(false); setIsDragging(false);
setIsUserInteracting(true); // Устанавливаем флаг взаимодействия пользователя setIsUserInteracting(true);
setStartPosition({ setStartPosition({
x: position.x, x: position.x,
y: position.y, y: position.y,
@@ -81,13 +79,9 @@ export function InfiniteCanvas({
e.stopPropagation(); e.stopPropagation();
}; };
// Устанавливаем rotation только при изменении originalRouteData и отсутствии взаимодействия пользователя
useEffect(() => { useEffect(() => {
const newRotation = originalRouteData?.rotate ?? 0; const newRotation = originalRouteData?.rotate ?? 0;
// Обновляем rotation только если:
// 1. Пользователь не взаимодействует с канвасом
// 2. Значение действительно изменилось
if (!isUserInteracting && lastOriginalRotation.current !== newRotation) { if (!isUserInteracting && lastOriginalRotation.current !== newRotation) {
setRotation((newRotation * Math.PI) / 180); setRotation((newRotation * Math.PI) / 180);
lastOriginalRotation.current = newRotation; lastOriginalRotation.current = newRotation;
@@ -97,7 +91,6 @@ export function InfiniteCanvas({
const handlePointerMove = (e: FederatedMouseEvent) => { const handlePointerMove = (e: FederatedMouseEvent) => {
if (!isPointerDown) return; if (!isPointerDown) return;
// Проверяем, началось ли перетаскивание
if (!isDragging) { if (!isDragging) {
const dx = e.globalX - startMousePosition.x; const dx = e.globalX - startMousePosition.x;
const dy = e.globalY - startMousePosition.y; const dy = e.globalY - startMousePosition.y;
@@ -119,10 +112,8 @@ export function InfiniteCanvas({
e.globalX - center.x e.globalX - center.x
); );
// Calculate rotation difference in radians
const rotationDiff = currentAngle - startAngle; const rotationDiff = currentAngle - startAngle;
// Update rotation
setRotation(startRotation + rotationDiff); setRotation(startRotation + rotationDiff);
const cosDelta = Math.cos(rotationDiff); const cosDelta = Math.cos(rotationDiff);
@@ -149,15 +140,13 @@ export function InfiniteCanvas({
}; };
const handlePointerUp = (e: FederatedMouseEvent) => { const handlePointerUp = (e: FederatedMouseEvent) => {
// Если не было перетаскивания, то это простой клик - закрываем виджет
if (!isDragging) { if (!isDragging) {
setSelectedSight(undefined); setSelectedSight(undefined);
} }
setIsPointerDown(false); setIsPointerDown(false);
setIsDragging(false); setIsDragging(false);
// Сбрасываем флаг взаимодействия через небольшую задержку
// чтобы избежать немедленного срабатывания useEffect
setTimeout(() => { setTimeout(() => {
setIsUserInteracting(false); setIsUserInteracting(false);
}, 100); }, 100);
@@ -166,29 +155,25 @@ export function InfiniteCanvas({
const handleWheel = (e: FederatedWheelEvent) => { const handleWheel = (e: FederatedWheelEvent) => {
e.stopPropagation(); e.stopPropagation();
setIsUserInteracting(true); // Устанавливаем флаг при зуме setIsUserInteracting(true);
// Get mouse position relative to canvas
const mouseX = e.globalX - position.x; const mouseX = e.globalX - position.x;
const mouseY = e.globalY - position.y; const mouseY = e.globalY - position.y;
// Calculate new scale
const scaleMin = (routeData?.scale_min ?? 10) / SCALE_FACTOR; const scaleMin = (routeData?.scale_min ?? 10) / SCALE_FACTOR;
const scaleMax = (routeData?.scale_max ?? 20) / SCALE_FACTOR; const scaleMax = (routeData?.scale_max ?? 20) / SCALE_FACTOR;
const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1; // Zoom out/in const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1;
const newScale = Math.max(scaleMin, Math.min(scaleMax, scale * zoomFactor)); const newScale = Math.max(scaleMin, Math.min(scaleMax, scale * zoomFactor));
const actualZoomFactor = newScale / scale; const actualZoomFactor = newScale / scale;
if (scale === newScale) { if (scale === newScale) {
// Сбрасываем флаг, если зум не изменился
setTimeout(() => { setTimeout(() => {
setIsUserInteracting(false); setIsUserInteracting(false);
}, 100); }, 100);
return; return;
} }
// Update position to zoom towards mouse cursor
setPosition({ setPosition({
x: position.x + mouseX * (1 - actualZoomFactor), x: position.x + mouseX * (1 - actualZoomFactor),
y: position.y + mouseY * (1 - actualZoomFactor), y: position.y + mouseY * (1 - actualZoomFactor),
@@ -196,7 +181,6 @@ export function InfiniteCanvas({
setScale(newScale); setScale(newScale);
// Сбрасываем флаг взаимодействия через задержку
setTimeout(() => { setTimeout(() => {
setIsUserInteracting(false); setIsUserInteracting(false);
}, 100); }, 100);

View File

@@ -1,12 +1,19 @@
import { Stack, Typography, Button } from "@mui/material"; import { Box, Stack, Typography, Button } from "@mui/material";
import { useNavigate, useNavigationType } from "react-router"; import { useNavigate, useNavigationType } from "react-router";
import { MediaViewer } from "@widgets"; import { 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 } from "@shared";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import LanguageSelector from "./web-gl/LanguageSelector";
export const LeftSidebar = observer(() => { type LeftSidebarProps = {
open: boolean;
onToggle: () => void;
};
export const LeftSidebar = observer(({ open, onToggle }: LeftSidebarProps) => {
const navigate = useNavigate(); const navigate = useNavigate();
const navigationType = useNavigationType(); // PUSH, POP, REPLACE const navigationType = useNavigationType(); // PUSH, POP, REPLACE
const { routeData } = useMapData(); const { routeData } = useMapData();
@@ -35,36 +42,64 @@ export const LeftSidebar = observer(() => {
}; };
return ( return (
<Stack direction="column" width="300px" p={2} bgcolor="primary.main"> <Box
<button sx={{
onClick={handleBack} position: "relative",
type="button" height: "100%",
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
gap: 10,
color: "#fff", color: "#fff",
backgroundColor: "#222", transition: "padding 0.3s ease",
borderRadius: 10, p: open ? 2 : 0,
height: 40, display: "flex",
width: "100%", flexDirection: "column",
border: "none", alignItems: "stretch",
cursor: "pointer", justifyContent: "flex-start",
}} }}
> >
<p>Назад</p> <Stack
</button> direction="column"
height="100%"
width="100%"
spacing={4}
alignItems="stretch"
justifyContent="space-between"
sx={{
opacity: open ? 1 : 0,
transition: "opacity 0.25s ease",
pointerEvents: open ? "auto" : "none",
display: open ? "flex" : "none",
}}
>
<div>
<Button
onClick={handleBack}
variant="contained"
color="primary"
sx={{
backgroundColor: "#222",
color: "#fff",
borderRadius: 1.5,
px: 2,
py: 1,
marginBottom: 10,
"&:hover": {
backgroundColor: "#2d2d2d",
},
}}
fullWidth
startIcon={<ArrowBackIcon />}
>
Назад
</Button>
<Stack <Stack
direction="column" direction="column"
alignItems="center" alignItems="center"
justifyContent="center" justifyContent="center"
my={10} spacing={3}
> >
<div <div
style={{ style={{
maxWidth: 200, maxWidth: 150,
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
alignItems: "center", alignItems: "center",
@@ -82,33 +117,31 @@ export const LeftSidebar = observer(() => {
fullHeight fullHeight
/> />
)} )}
<Typography sx={{ mb: 2, color: "#fff" }} textAlign="center"> <Typography sx={{ color: "#fff" }} textAlign="center">
При поддержке Правительства При поддержке Правительства
</Typography>{" "} </Typography>
</div> </div>
</Stack> </Stack>
<Stack <div className="flex flex-col items-center justify-center gap-2 mt-10">
direction="column" <button className="bg-[#fcd500] text-black px-4 py-2 rounded-md w-full font-medium my-10">
alignItems="center" Обращение губернатора
justifyContent="center" </button>
my={10} <button className="bg-white text-black px-4 py-2 rounded-md w-full font-medium mx-5">
spacing={2}
>
<Button variant="outlined" color="warning" fullWidth>
Достопримечательности Достопримечательности
</Button> </button>
<Button variant="outlined" color="warning" fullWidth> <button className="bg-white text-black px-4 py-2 rounded-md w-full font-medium mx-5">
Остановки Остановки
</Button> </button>
</Stack> </div>
</div>
<Stack <Stack
direction="column" direction="column"
alignItems="center" alignItems="center"
maxHeight={150} maxHeight={150}
justifyContent="center" justifyContent="center"
my={10} flexGrow={1}
> >
{carrierLogo && ( {carrierLogo && (
<MediaViewer <MediaViewer
@@ -125,11 +158,15 @@ export const LeftSidebar = observer(() => {
<Typography <Typography
variant="h6" variant="h6"
textAlign="center" textAlign="center"
mt="auto" sx={{ color: "#fff", marginTop: "auto" }}
sx={{ color: "#fff" }}
> >
#ВсемПоПути #ВсемПоПути
</Typography> </Typography>
</Stack> </Stack>
<div className="absolute bottom-[20px] -right-[520px] z-10">
<LanguageSelector onBack={onToggle} isSidebarOpen={open} />
</div>
</Box>
); );
}); });

View File

@@ -41,6 +41,8 @@ const MapDataContext = createContext<{
latitude: number, latitude: number,
longitude: number longitude: number
) => void; ) => void;
setIconSize: (size: number) => void;
setFontSize: (size: number) => void;
saveChanges: () => void; saveChanges: () => void;
}>({ }>({
originalRouteData: undefined, originalRouteData: undefined,
@@ -61,6 +63,8 @@ const MapDataContext = createContext<{
setStationOffset: () => {}, setStationOffset: () => {},
setStationAlign: () => {}, setStationAlign: () => {},
setSightCoordinates: () => {}, setSightCoordinates: () => {},
setIconSize: () => {},
setFontSize: () => {},
saveChanges: () => {}, saveChanges: () => {},
}); });
@@ -141,7 +145,6 @@ export const MapDataProvider = observer(
}, [routeId]); }, [routeId]);
useEffect(() => { useEffect(() => {
// combine changes with original data
if (originalRouteData) if (originalRouteData)
setRouteData({ ...originalRouteData, ...routeChanges }); setRouteData({ ...originalRouteData, ...routeChanges });
if (originalSightData) setSightData(originalSightData); if (originalSightData) setSightData(originalSightData);
@@ -165,9 +168,57 @@ export const MapDataProvider = observer(
}); });
} }
function setMapCenter(x: number, y: number) { function setIconSize(size: number) {
const clamped = Math.max(50, Math.min(300, size));
setRouteChanges((prev) => { setRouteChanges((prev) => {
return { ...prev, center_latitude: x, center_longitude: y }; if (prev.icon_size === clamped) {
return prev;
}
return { ...prev, icon_size: clamped };
});
}
function setFontSize(size: number) {
const clamped = Math.max(50, Math.min(300, size));
setRouteChanges((prev) => {
if (prev.font_size === clamped) {
return prev;
}
return { ...prev, font_size: clamped };
});
}
function setMapCenter(latitude: number, longitude: number) {
const epsilon = 1e-6;
setRouteChanges((prev) => {
const prevLat = prev.center_latitude;
const prevLon = prev.center_longitude;
if (
prevLat !== undefined &&
prevLon !== undefined &&
Math.abs(prevLat - latitude) < epsilon &&
Math.abs(prevLon - longitude) < epsilon
) {
return prev;
}
return {
...prev,
center_latitude: latitude,
center_longitude: longitude,
};
});
setRouteData((routePrev) => {
if (!routePrev) return routePrev;
return {
...routePrev,
center_latitude: latitude,
center_longitude: longitude,
};
}); });
} }
@@ -180,12 +231,42 @@ export const MapDataProvider = observer(
async function saveStationChanges() { async function saveStationChanges() {
for (const station of stationChanges) { for (const station of stationChanges) {
await authInstance.patch(`/route/${routeId}/station`, station); await authInstance.patch(`/route/${routeId}/station`, station);
setStationData((prev) => {
const updated = { ...prev };
Object.keys(updated).forEach((lang) => {
updated[lang] = updated[lang].map((s) =>
s.id === station.station_id
? {
...s,
offset_x: station.offset_x,
offset_y: station.offset_y,
}
: s
);
});
return updated;
});
} }
} }
async function saveSightChanges() { async function saveSightChanges() {
for (const sight of sightChanges) { for (const sight of sightChanges) {
await authInstance.patch(`/route/${routeId}/sight`, sight); await authInstance.patch(`/route/${routeId}/sight`, sight);
setSightData((prev) =>
prev
? prev.map((s) =>
s.id === sight.sight_id
? {
...s,
latitude: sight.latitude,
longitude: sight.longitude,
}
: s
)
: prev
);
} }
} }
@@ -321,6 +402,14 @@ export const MapDataProvider = observer(
latitude: number, latitude: number,
longitude: number longitude: number
) { ) {
setSightData((prev) =>
prev
? prev.map((sight) =>
sight.id === sightId ? { ...sight, latitude, longitude } : sight
)
: prev
);
setSightChanges((prev) => { setSightChanges((prev) => {
const existingIndex = prev.findIndex( const existingIndex = prev.findIndex(
(sight) => sight.sight_id === sightId (sight) => sight.sight_id === sightId
@@ -376,6 +465,8 @@ export const MapDataProvider = observer(
setStationOffset, setStationOffset,
setStationAlign, setStationAlign,
setSightCoordinates, setSightCoordinates,
setIconSize,
setFontSize,
}), }),
[ [
originalRouteData, originalRouteData,
@@ -388,6 +479,8 @@ export const MapDataProvider = observer(
isStationLoading, isStationLoading,
isSightLoading, isSightLoading,
selectedSight, selectedSight,
setIconSize,
setFontSize,
] ]
); );

View File

@@ -1,8 +1,15 @@
import { Button, Stack, TextField, Typography, Slider } from "@mui/material"; import {
Button,
Stack,
TextField,
Typography,
Slider,
CircularProgress,
Box,
} from "@mui/material";
import { useMapData } from "./MapDataContext"; import { useMapData } from "./MapDataContext";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTransform } from "./TransformContext"; import { useTransform } from "./TransformContext";
import { coordinatesToLocal, localToCoordinates } from "./utils";
import { SCALE_FACTOR } from "./Constants"; import { SCALE_FACTOR } from "./Constants";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
@@ -14,17 +21,10 @@ export function RightSidebar() {
originalRouteData, originalRouteData,
setMapRotation, setMapRotation,
setMapCenter, setMapCenter,
setIconSize: updateIconSize,
setFontSize: updateFontSize,
} = useMapData(); } = useMapData();
const { const { rotation, rotateToAngle, scale, setScaleAtCenter } = useTransform();
rotation,
position,
screenToLocal,
screenCenter,
rotateToAngle,
setTransform,
scale,
setScaleAtCenter,
} = useTransform();
const [minScale, setMinScale] = useState<number>(1); const [minScale, setMinScale] = useState<number>(1);
const [maxScale, setMaxScale] = useState<number>(5); const [maxScale, setMaxScale] = useState<number>(5);
@@ -34,14 +34,15 @@ export function RightSidebar() {
}); });
const [rotationDegrees, setRotationDegrees] = useState<number>(0); const [rotationDegrees, setRotationDegrees] = useState<number>(0);
const [isUserEditing, setIsUserEditing] = useState<boolean>(false); const [isUserEditing, setIsUserEditing] = useState<boolean>(false);
const [iconSize, setIconSize] = useState<number>(100);
const [fontSize, setFontSize] = useState<number>(100);
const [isSaving, setIsSaving] = useState<boolean>(false);
useEffect(() => { useEffect(() => {
if (originalRouteData) { if (originalRouteData) {
// Проверяем и сбрасываем минимальный масштаб если нужно
const originalMinScale = originalRouteData.scale_min ?? 1; const originalMinScale = originalRouteData.scale_min ?? 1;
const resetMinScale = originalMinScale < 1 ? 1 : originalMinScale; const resetMinScale = originalMinScale < 1 ? 1 : originalMinScale;
// Проверяем и сбрасываем максимальный масштаб если нужно
const originalMaxScale = originalRouteData.scale_max ?? 5; const originalMaxScale = originalRouteData.scale_max ?? 5;
const resetMaxScale = originalMaxScale < 3 ? 3 : originalMaxScale; const resetMaxScale = originalMaxScale < 3 ? 3 : originalMaxScale;
@@ -52,6 +53,8 @@ export function RightSidebar() {
x: originalRouteData.center_latitude ?? 0, x: originalRouteData.center_latitude ?? 0,
y: originalRouteData.center_longitude ?? 0, y: originalRouteData.center_longitude ?? 0,
}); });
setIconSize(originalRouteData.icon_size ?? 100);
setFontSize(originalRouteData.font_size ?? 100);
} }
}, [originalRouteData]); }, [originalRouteData]);
@@ -72,33 +75,55 @@ export function RightSidebar() {
}, [rotationDegrees]); }, [rotationDegrees]);
useEffect(() => { useEffect(() => {
if (!isUserEditing) { if (isUserEditing) {
const center = screenCenter ?? { x: 0, y: 0 }; return;
const localCenter = screenToLocal(center.x, center.y);
const coordinates = localToCoordinates(localCenter.x, localCenter.y);
setLocalCenter({ x: coordinates.latitude, y: coordinates.longitude });
} }
}, [
position,
screenCenter,
screenToLocal,
localToCoordinates,
setLocalCenter,
isUserEditing,
]);
useEffect(() => { const latitude = routeData?.center_latitude ?? 0;
setMapCenter(localCenter.x, localCenter.y); const longitude = routeData?.center_longitude ?? 0;
}, [localCenter]);
setLocalCenter((prev) => {
if (
Math.abs(prev.x - latitude) < 1e-6 &&
Math.abs(prev.y - longitude) < 1e-6
) {
return prev;
}
return { x: latitude, y: longitude };
});
}, [isUserEditing, routeData?.center_latitude, routeData?.center_longitude]);
function setRotationFromDegrees(degrees: number) { function setRotationFromDegrees(degrees: number) {
rotateToAngle((degrees * Math.PI) / 180); rotateToAngle((degrees * Math.PI) / 180);
} }
function pan({ x, y }: { x: number; y: number }) { const handleIconSizeChange = (value: number) => {
const coordinates = coordinatesToLocal(x, y); if (!Number.isFinite(value)) {
setTransform(coordinates.x, coordinates.y); return;
} }
const clamped = Math.max(50, Math.min(300, Math.round(value)));
setIconSize(clamped);
updateIconSize(clamped);
};
const handleFontSizeChange = (value: number) => {
if (!Number.isFinite(value)) {
return;
}
const clamped = Math.max(50, Math.min(300, Math.round(value)));
setFontSize(clamped);
updateFontSize(clamped);
};
useEffect(() => {
const next = routeData?.icon_size ?? originalRouteData?.icon_size ?? 100;
setIconSize((prev) => (Math.abs(prev - next) > 0.5 ? next : prev));
}, [routeData?.icon_size, originalRouteData?.icon_size]);
useEffect(() => {
const next = routeData?.font_size ?? originalRouteData?.font_size ?? 100;
setFontSize((prev) => (Math.abs(prev - next) > 0.5 ? next : prev));
}, [routeData?.font_size, originalRouteData?.font_size]);
if (!routeData) { if (!routeData) {
return null; return null;
@@ -118,7 +143,7 @@ export function RightSidebar() {
borderRadius={2} borderRadius={2}
> >
<Typography variant="h6" sx={{ mb: 2, color: "#fff" }} textAlign="center"> <Typography variant="h6" sx={{ mb: 2, color: "#fff" }} textAlign="center">
Детали о достопримечательностях Настройка маршрута
</Typography> </Typography>
<Stack spacing={2} direction="row" alignItems="center"> <Stack spacing={2} direction="row" alignItems="center">
@@ -130,19 +155,26 @@ export function RightSidebar() {
onChange={(e) => { onChange={(e) => {
let newMinScale = Number(e.target.value); let newMinScale = Number(e.target.value);
// Сбрасываем к 1 если меньше if (newMinScale < 10) {
if (newMinScale < 1) { newMinScale = 10;
newMinScale = 1; }
if (newMinScale > 300) {
newMinScale = 297;
} }
setMinScale(newMinScale); setMinScale(newMinScale);
if (maxScale - newMinScale < 2) { if (maxScale - newMinScale < 2) {
let newMaxScale = newMinScale + 2; let newMaxScale = newMinScale + 2;
// Сбрасываем максимальный к 3 если меньше минимального
if (newMaxScale > 300) {
newMaxScale = 300;
}
if (newMaxScale < 3) { if (newMaxScale < 3) {
newMaxScale = 3; newMaxScale = 3;
setMinScale(1); // Сбрасываем минимальный к 1 setMinScale(1);
} }
setMaxScale(newMaxScale); setMaxScale(newMaxScale);
} }
@@ -175,19 +207,22 @@ export function RightSidebar() {
onChange={(e) => { onChange={(e) => {
let newMaxScale = Number(e.target.value); let newMaxScale = Number(e.target.value);
// Сбрасываем к 3 если меньше минимального if (newMaxScale < 13) {
if (newMaxScale < 3) { newMaxScale = 13;
newMaxScale = 3; }
if (newMaxScale > 300) {
newMaxScale = 300;
} }
setMaxScale(newMaxScale); setMaxScale(newMaxScale);
if (newMaxScale - minScale < 2) { if (newMaxScale - minScale < 2) {
let newMinScale = newMaxScale - 2; let newMinScale = newMaxScale - 2;
// Сбрасываем минимальный к 1 если меньше
if (newMinScale < 1) { if (newMinScale < 1) {
newMinScale = 1; newMinScale = 1;
setMaxScale(3); // Сбрасываем максимальный к минимальному значению setMaxScale(3);
} }
setMinScale(newMinScale); setMinScale(newMinScale);
} }
@@ -208,7 +243,7 @@ export function RightSidebar() {
slotProps={{ slotProps={{
input: { input: {
min: 3, min: 3,
max: 10, max: 300,
}, },
}} }}
/> />
@@ -272,6 +307,62 @@ export function RightSidebar() {
}} }}
/> />
<Typography variant="body2" sx={{ color: "#fff", textAlign: "center" }}>
Размер иконок: {iconSize}%
</Typography>
<Slider
value={iconSize}
onChange={(_, value) => {
if (typeof value === "number") {
handleIconSizeChange(value);
}
}}
min={50}
max={300}
step={1}
sx={{
color: "#fff",
"& .MuiSlider-thumb": {
backgroundColor: "#fff",
},
"& .MuiSlider-track": {
backgroundColor: "#fff",
},
"& .MuiSlider-rail": {
backgroundColor: "#666",
},
}}
/>
<Typography variant="body2" sx={{ color: "#fff", textAlign: "center" }}>
Размер шрифта: {fontSize}%
</Typography>
<Slider
value={fontSize}
onChange={(_, value) => {
if (typeof value === "number") {
handleFontSizeChange(value);
}
}}
min={50}
max={300}
step={1}
sx={{
color: "#fff",
"& .MuiSlider-thumb": {
backgroundColor: "#fff",
},
"& .MuiSlider-track": {
backgroundColor: "#fff",
},
"& .MuiSlider-rail": {
backgroundColor: "#666",
},
}}
/>
<TextField <TextField
type="number" type="number"
label="Поворот (в градусах)" label="Поворот (в градусах)"
@@ -313,10 +404,15 @@ export function RightSidebar() {
value={Math.round(localCenter.x * 1000) / 1000} value={Math.round(localCenter.x * 1000) / 1000}
onChange={(e) => { onChange={(e) => {
setIsUserEditing(true); setIsUserEditing(true);
setLocalCenter((prev) => ({ ...prev, x: Number(e.target.value) })); const newValue = Number(e.target.value);
pan({ x: Number(e.target.value), y: localCenter.y }); setLocalCenter((prev) => ({ ...prev, x: newValue }));
if (!isNaN(newValue) && localCenter.y !== undefined) {
setMapCenter(newValue, localCenter.y);
}
}}
onBlur={() => {
setIsUserEditing(false);
}} }}
onBlur={() => setIsUserEditing(false)}
style={{ backgroundColor: "#222", borderRadius: 4 }} style={{ backgroundColor: "#222", borderRadius: 4 }}
sx={{ sx={{
"& .MuiInputLabel-root": { "& .MuiInputLabel-root": {
@@ -332,15 +428,20 @@ export function RightSidebar() {
/> />
<TextField <TextField
type="number" type="number"
label="Центр карты, высота" label="Центр карты, долгота"
variant="filled" variant="filled"
value={Math.round(localCenter.y * 1000) / 1000} value={Math.round(localCenter.y * 1000) / 1000}
onChange={(e) => { onChange={(e) => {
setIsUserEditing(true); setIsUserEditing(true);
setLocalCenter((prev) => ({ ...prev, y: Number(e.target.value) })); const newValue = Number(e.target.value);
pan({ x: localCenter.x, y: Number(e.target.value) }); setLocalCenter((prev) => ({ ...prev, y: newValue }));
if (!isNaN(newValue) && localCenter.x !== undefined) {
setMapCenter(localCenter.x, newValue);
}
}}
onBlur={() => {
setIsUserEditing(false);
}} }}
onBlur={() => setIsUserEditing(false)}
style={{ backgroundColor: "#222", borderRadius: 4 }} style={{ backgroundColor: "#222", borderRadius: 4 }}
sx={{ sx={{
"& .MuiInputLabel-root": { "& .MuiInputLabel-root": {
@@ -359,19 +460,51 @@ export function RightSidebar() {
<Button <Button
variant="contained" variant="contained"
color="secondary" color="secondary"
sx={{ mt: 2 }} sx={{ mt: 2, position: "relative" }}
disabled={isSaving}
onClick={async () => { onClick={async () => {
setIsSaving(true);
try { try {
await saveChanges(); await saveChanges();
toast.success("Изменения сохранены"); toast.success("Изменения сохранены");
} catch (error) { } catch (error) {
console.error(error); console.error(error);
toast.error("Ошибка при сохранении изменений"); toast.error("Ошибка при сохранении изменений");
} finally {
setIsSaving(false);
} }
}} }}
> >
Сохранить изменения {isSaving ? (
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 1,
}}
>
<CircularProgress size={20} sx={{ color: "inherit" }} />
<span>Сохранение...</span>
</Box>
) : (
"Сохранить изменения"
)}
</Button> </Button>
<svg
xmlns="http://www.w3.org/2000/svg"
width="48"
height="48"
viewBox="0 0 48 48"
fill="none"
style={{ cursor: "pointer" }}
className="absolute bottom-5 left-[-68px] z-100"
>
<path
d="M24.0013 0C23.3513 0 22.7013 0.03 22.0413 0.08C10.4213 1 1.01127 10.41 0.0812683 22.03C-0.428732 28.39 1.55127 34.28 5.14127 38.83C5.60127 39.42 5.76127 40.21 5.46127 40.89C4.76127 42.43 3.63127 43.64 3.05127 44.23C2.50127 44.78 1.97127 45.27 1.38127 45.7C0.791268 46.13 0.841268 47.03 1.45127 47.42C2.08127 47.82 3.01127 47.99 4.12127 47.99C6.84127 47.99 10.5813 46.99 13.3013 46.06C13.5013 45.99 13.7013 45.96 13.9113 45.96C14.1813 45.96 14.4613 46.02 14.7113 46.13C17.5713 47.33 20.7113 48 24.0013 48C24.6513 48 25.3213 47.97 25.9813 47.92C37.6313 46.98 47.0613 37.51 47.9313 25.85C48.9913 11.76 37.8713 0 24.0013 0ZM29.5113 37.71C29.4813 37.82 29.3413 37.94 29.2313 37.98C27.7413 38.48 26.2713 39.12 24.7313 39.42C22.9513 39.77 21.1413 39.68 19.5513 38.58C18.2213 37.66 17.7313 36.36 17.8113 34.8C17.9013 32.91 18.5113 31.13 19.0013 29.33C19.5213 27.42 20.1113 25.53 20.4613 23.59C20.9413 20.94 19.7813 20.48 17.3913 20.74C16.8013 20.8 16.2313 21.04 15.5813 21.22C15.7213 20.62 15.8313 20.08 15.9913 19.55C16.0213 19.45 19.6313 17.94 21.4413 17.78C23.3513 17.61 25.2013 17.8 26.6013 19.32C27.3913 20.17 27.6113 21.21 27.5913 22.33C27.5413 24.8 26.5813 27.07 25.9813 29.42C25.6113 30.86 25.2513 32.3 24.9313 33.75C24.8413 34.15 24.8413 34.59 24.8813 35C24.9613 35.97 25.4413 36.39 26.4313 36.57C27.6213 36.78 28.7213 36.45 29.9313 36.04C29.7813 36.66 29.6613 37.19 29.5213 37.7L29.5113 37.71ZM26.8513 15.21C26.6513 15.23 26.4613 15.23 26.2013 15.25C24.4013 15.27 22.7313 14.15 22.2013 12.52C21.5913 10.65 22.5613 8.71 24.5313 7.86C26.7913 6.88 29.5813 8.07 30.2713 10.33C31.0513 12.87 29.0613 14.95 26.8613 15.21H26.8513Z"
fill="white"
/>
</svg>
</Stack> </Stack>
); );
} }

View File

@@ -2,7 +2,6 @@ import { FederatedMouseEvent, Graphics } from "pixi.js";
import { useCallback, useState, useEffect, useRef, FC, useMemo } from "react"; import { useCallback, useState, useEffect, useRef, FC, useMemo } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
// --- Заглушки для зависимостей (замените на ваши реальные импорты) ---
import { import {
BACKGROUND_COLOR, BACKGROUND_COLOR,
PATH_COLOR, PATH_COLOR,
@@ -15,22 +14,16 @@ import { StationData } from "./types";
import { useMapData } from "./MapDataContext"; import { useMapData } from "./MapDataContext";
import { coordinatesToLocal } from "./utils"; import { coordinatesToLocal } from "./utils";
import { languageStore } from "@shared"; import { languageStore } from "@shared";
// --- Конец заглушек ---
// --- Декларации для react-pixi ---
// (В реальном проекте типы должны быть предоставлены библиотекой @pixi/react-pixi)
declare const pixiContainer: any; declare const pixiContainer: any;
declare const pixiGraphics: any; declare const pixiGraphics: any;
declare const pixiText: any; declare const pixiText: any;
// --- Типы ---
type HorizontalAlign = "left" | "center" | "right"; type HorizontalAlign = "left" | "center" | "right";
type VerticalAlign = "top" | "center" | "bottom"; type VerticalAlign = "top" | "center" | "bottom";
type TextAlign = HorizontalAlign | `${HorizontalAlign} ${VerticalAlign}`; type TextAlign = HorizontalAlign | `${HorizontalAlign} ${VerticalAlign}`;
type LabelAlign = "left" | "center" | "right"; type LabelAlign = "left" | "center" | "right";
// --- Утилиты ---
/** /**
* Преобразует текстовое позиционирование в anchor координаты. * Преобразует текстовое позиционирование в anchor координаты.
*/ */
@@ -39,8 +32,6 @@ type LabelAlign = "left" | "center" | "right";
* Получает координату anchor.x из типа выравнивания. * Получает координату anchor.x из типа выравнивания.
*/ */
// --- Интерфейсы пропсов ---
interface StationProps { interface StationProps {
station: StationData; station: StationData;
ruLabel: string | null; ruLabel: string | null;
@@ -83,10 +74,6 @@ const getAnchorFromOffset = (
return { x: (1 - nx) / 2, y: (1 - ny) / 2 }; return { x: (1 - nx) / 2, y: (1 - ny) / 2 };
}; };
// =========================================================================
// Компонент: Панель управления выравниванием в стиле УрФУ
// =========================================================================
const LabelAlignmentControl: FC<LabelAlignmentControlProps> = ({ const LabelAlignmentControl: FC<LabelAlignmentControlProps> = ({
scale, scale,
currentAlign, currentAlign,
@@ -107,7 +94,6 @@ const LabelAlignmentControl: FC<LabelAlignmentControlProps> = ({
(g: Graphics) => { (g: Graphics) => {
g.clear(); g.clear();
// Основной фон с градиентом
g.roundRect( g.roundRect(
-controlWidth / 2, -controlWidth / 2,
0, 0,
@@ -115,9 +101,8 @@ const LabelAlignmentControl: FC<LabelAlignmentControlProps> = ({
controlHeight, controlHeight,
borderRadius borderRadius
); );
g.fill({ color: "#1a1a1a" }); // Темный фон как у УрФУ g.fill({ color: "#1a1a1a" });
// Тонкая рамка
g.roundRect( g.roundRect(
-controlWidth / 2, -controlWidth / 2,
0, 0,
@@ -127,7 +112,6 @@ const LabelAlignmentControl: FC<LabelAlignmentControlProps> = ({
); );
g.stroke({ color: "#333333", width: strokeWidth }); g.stroke({ color: "#333333", width: strokeWidth });
// Разделители между кнопками
for (let i = 1; i < 3; i++) { for (let i = 1; i < 3; i++) {
const x = -controlWidth / 2 + buttonWidth * i; const x = -controlWidth / 2 + buttonWidth * i;
g.moveTo(x, strokeWidth); g.moveTo(x, strokeWidth);
@@ -151,7 +135,7 @@ const LabelAlignmentControl: FC<LabelAlignmentControlProps> = ({
controlHeight - strokeWidth * 2, controlHeight - strokeWidth * 2,
borderRadius / 2 borderRadius / 2
); );
g.fill({ color: "#0066cc", alpha: 0.8 }); // Синий акцент УрФУ g.fill({ color: "#0066cc", alpha: 0.8 });
} }
}, },
[controlWidth, controlHeight, buttonWidth, strokeWidth, borderRadius] [controlWidth, controlHeight, buttonWidth, strokeWidth, borderRadius]
@@ -230,10 +214,6 @@ const LabelAlignmentControl: FC<LabelAlignmentControlProps> = ({
); );
}; };
// =========================================================================
// Компонент: Метка Станции (с логикой)
// =========================================================================
const StationLabel = observer( const StationLabel = observer(
({ ({
station, station,
@@ -274,48 +254,45 @@ const StationLabel = observer(
hideTimer.current = null; hideTimer.current = null;
} }
setIsHovered(true); setIsHovered(true);
onTextHover?.(true); // Call the callback to indicate text is hovered onTextHover?.(true);
}; };
const handleControlPointerEnter = () => { const handleControlPointerEnter = () => {
// Дополнительная обработка для панели управления
if (hideTimer.current) { if (hideTimer.current) {
clearTimeout(hideTimer.current); clearTimeout(hideTimer.current);
hideTimer.current = null; hideTimer.current = null;
} }
setIsControlHovered(true); setIsControlHovered(true);
setIsHovered(true); setIsHovered(true);
onTextHover?.(true); // Call the callback to indicate text/control is hovered onTextHover?.(true);
}; };
const handleControlPointerLeave = () => { const handleControlPointerLeave = () => {
setIsControlHovered(false); setIsControlHovered(false);
// Если курсор не над основным контейнером, скрываем панель через некоторое время
if (!isHovered) { if (!isHovered) {
hideTimer.current = setTimeout(() => { hideTimer.current = setTimeout(() => {
setIsHovered(false); setIsHovered(false);
onTextHover?.(false); // Call the callback to indicate neither text nor control is hovered onTextHover?.(false);
}, 0); }, 0);
} }
}; };
const handlePointerLeave = () => { const handlePointerLeave = () => {
// Увеличиваем время до скрытия панели и добавляем проверку
hideTimer.current = setTimeout(() => { hideTimer.current = setTimeout(() => {
setIsHovered(false); setIsHovered(false);
// Если курсор не над панелью управления, скрываем и её
if (!isControlHovered) { if (!isControlHovered) {
setIsControlHovered(false); setIsControlHovered(false);
} }
onTextHover?.(false); // Call the callback to indicate text is no longer hovered onTextHover?.(false);
}, 100); // Увеличиваем время до скрытия панели }, 100);
}; };
useEffect(() => { useEffect(() => {
setPosition({ x: station.offset_x ?? 0, y: station.offset_y ?? 0 }); setPosition({ x: station.offset_x ?? 0, y: station.offset_y ?? 0 });
}, [station.offset_x, station.offset_y, station.id]); }, [station.offset_x, station.offset_y, station.id]);
// Функция для конвертации числового align в строковый
const convertNumericAlign = (align: number): LabelAlign => { const convertNumericAlign = (align: number): LabelAlign => {
switch (align) { switch (align) {
case 0: case 0:
@@ -329,7 +306,6 @@ const StationLabel = observer(
} }
}; };
// Функция для конвертации строкового align в числовой
const convertStringAlign = (align: LabelAlign): number => { const convertStringAlign = (align: LabelAlign): number => {
switch (align) { switch (align) {
case "left": case "left":
@@ -353,7 +329,6 @@ const StationLabel = observer(
const compensatedRuFontSize = (26 * 0.75) / scale; const compensatedRuFontSize = (26 * 0.75) / scale;
const compensatedNameFontSize = (16 * 0.75) / scale; const compensatedNameFontSize = (16 * 0.75) / scale;
// Измеряем ширину верхнего лейбла
useEffect(() => { useEffect(() => {
if (ruLabelRef.current && ruLabel) { if (ruLabelRef.current && ruLabel) {
setRuLabelWidth(ruLabelRef.current.width); setRuLabelWidth(ruLabelRef.current.width);
@@ -386,7 +361,6 @@ const StationLabel = observer(
y: dragStartPos.current.y + dy_screen, y: dragStartPos.current.y + dy_screen,
}; };
// Проверяем, изменилась ли позиция
if ( if (
Math.abs(newPosition.x - position.x) > 0.01 || Math.abs(newPosition.x - position.x) > 0.01 ||
Math.abs(newPosition.y - position.y) > 0.01 Math.abs(newPosition.y - position.y) > 0.01
@@ -406,7 +380,7 @@ const StationLabel = observer(
const handleAlignChange = async (align: LabelAlign) => { const handleAlignChange = async (align: LabelAlign) => {
setCurrentLabelAlign(align); setCurrentLabelAlign(align);
onLabelAlignChange?.(align); onLabelAlignChange?.(align);
// Сохраняем в стор
const numericAlign = convertStringAlign(align); const numericAlign = convertStringAlign(align);
setStationAlign(station.id, numericAlign); setStationAlign(station.id, numericAlign);
}; };
@@ -416,34 +390,29 @@ const StationLabel = observer(
[position.x, position.y] [position.x, position.y]
); );
// Функция для расчета позиции нижнего лейбла относительно ширины верхнего
const getSecondLabelPosition = (): number => { const getSecondLabelPosition = (): number => {
if (!ruLabelWidth) return 0; if (!ruLabelWidth) return 0;
switch (currentLabelAlign) { switch (currentLabelAlign) {
case "left": case "left":
// Позиционируем относительно левого края верхнего текста
return -ruLabelWidth / 2; return -ruLabelWidth / 2;
case "center": case "center":
// Центрируем относительно центра верхнего текста
return 0; return 0;
case "right": case "right":
// Позиционируем относительно правого края верхнего текста
return ruLabelWidth / 2; return ruLabelWidth / 2;
default: default:
return 0; return 0;
} }
}; };
// Функция для расчета anchor нижнего лейбла
const getSecondLabelAnchor = (): number => { const getSecondLabelAnchor = (): number => {
switch (currentLabelAlign) { switch (currentLabelAlign) {
case "left": case "left":
return 0; // anchor.x = 0 (левый край) return 0;
case "center": case "center":
return 0.5; // anchor.x = 0.5 (центр) return 0.5;
case "right": case "right":
return 1; // anchor.x = 1 (правый край) return 1;
default: default:
return 0.5; return 0.5;
} }
@@ -522,10 +491,6 @@ const StationLabel = observer(
} }
); );
// =========================================================================
// Главный экспортируемый компонент: Станция
// =========================================================================
export const Station = ({ export const Station = ({
station, station,
ruLabel, ruLabel,
@@ -548,10 +513,9 @@ export const Station = ({
g.circle(coordinates.x * UP_SCALE, coordinates.y * UP_SCALE, radius); g.circle(coordinates.x * UP_SCALE, coordinates.y * UP_SCALE, radius);
// Change fill color when text is hovered
if (isTextHovered) { if (isTextHovered) {
g.fill({ color: 0x00aaff }); // Highlight color when hovered g.fill({ color: 0x00aaff });
g.stroke({ color: 0xffffff, width: strokeWidth + 1 }); // Brighter outline when hovered g.stroke({ color: 0xffffff, width: strokeWidth + 1 });
} else { } else {
g.fill({ color: PATH_COLOR }); g.fill({ color: PATH_COLOR });
g.stroke({ color: BACKGROUND_COLOR, width: strokeWidth }); g.stroke({ color: BACKGROUND_COLOR, width: strokeWidth });

View File

@@ -50,7 +50,6 @@ const TransformContext = createContext<{
setScaleAtCenter: () => {}, setScaleAtCenter: () => {},
}); });
// Provider component
export const TransformProvider = ({ children }: { children: ReactNode }) => { export const TransformProvider = ({ children }: { children: ReactNode }) => {
const [position, setPosition] = useState({ x: 0, y: 0 }); const [position, setPosition] = useState({ x: 0, y: 0 });
const [scale, setScale] = useState(1); const [scale, setScale] = useState(1);
@@ -59,12 +58,10 @@ export const TransformProvider = ({ children }: { children: ReactNode }) => {
const screenToLocal = useCallback( const screenToLocal = useCallback(
(screenX: number, screenY: number) => { (screenX: number, screenY: number) => {
// Translate point relative to current pan position
const translatedX = (screenX - position.x) / scale; const translatedX = (screenX - position.x) / scale;
const translatedY = (screenY - position.y) / scale; const translatedY = (screenY - position.y) / scale;
// Rotate point around center const cosRotation = Math.cos(-rotation);
const cosRotation = Math.cos(-rotation); // Negative rotation to reverse transform
const sinRotation = Math.sin(-rotation); const sinRotation = Math.sin(-rotation);
const rotatedX = translatedX * cosRotation - translatedY * sinRotation; const rotatedX = translatedX * cosRotation - translatedY * sinRotation;
const rotatedY = translatedX * sinRotation + translatedY * cosRotation; const rotatedY = translatedX * sinRotation + translatedY * cosRotation;
@@ -77,7 +74,6 @@ export const TransformProvider = ({ children }: { children: ReactNode }) => {
[position.x, position.y, scale, rotation] [position.x, position.y, scale, rotation]
); );
// Inverse of screenToLocal
const localToScreen = useCallback( const localToScreen = useCallback(
(localX: number, localY: number) => { (localX: number, localY: number) => {
const upscaledX = localX * UP_SCALE; const upscaledX = localX * UP_SCALE;
@@ -120,7 +116,6 @@ export const TransformProvider = ({ children }: { children: ReactNode }) => {
(currentFromPosition.x - center.x) * sinDelta, (currentFromPosition.x - center.x) * sinDelta,
}; };
// Update both rotation and position in a single batch to avoid stale closure
setRotation(to); setRotation(to);
setPosition(newPosition); setPosition(newPosition);
}, },
@@ -150,13 +145,11 @@ export const TransformProvider = ({ children }: { children: ReactNode }) => {
const cosRot = Math.cos(selectedRotation); const cosRot = Math.cos(selectedRotation);
const sinRot = Math.sin(selectedRotation); const sinRot = Math.sin(selectedRotation);
// Translate point relative to center, rotate, then translate back
const dx = newPosition.x; const dx = newPosition.x;
const dy = newPosition.y; const dy = newPosition.y;
newPosition.x = dx * cosRot - dy * sinRot + center.x; newPosition.x = dx * cosRot - dy * sinRot + center.x;
newPosition.y = dx * sinRot + dy * cosRot + center.y; newPosition.y = dx * sinRot + dy * cosRot + center.y;
// Batch state updates to avoid intermediate renders
setPosition(newPosition); setPosition(newPosition);
setRotation(selectedRotation); setRotation(selectedRotation);
setScale(selectedScale); setScale(selectedScale);
@@ -184,7 +177,6 @@ export const TransformProvider = ({ children }: { children: ReactNode }) => {
); );
const setScaleOnly = useCallback((newScale: number) => { const setScaleOnly = useCallback((newScale: number) => {
// Изменяем только масштаб, не трогая позицию и поворот
setScale(newScale); setScale(newScale);
}, []); }, []);
@@ -237,7 +229,6 @@ export const TransformProvider = ({ children }: { children: ReactNode }) => {
); );
}; };
// Custom hook for easy access to transform values
export const useTransform = () => { export const useTransform = () => {
const context = useContext(TransformContext); const context = useContext(TransformContext);
if (!context) { if (!context) {

View File

@@ -1,6 +1,6 @@
import { useRef, useEffect, useState } from "react"; import { useRef, useEffect, useState } from "react";
import { Widgets } from "./Widgets"; import { Widgets } from "./Widgets";
import { Application, extend } from "@pixi/react"; import { extend } from "@pixi/react";
import { import {
Container, Container,
Graphics, Graphics,
@@ -9,24 +9,18 @@ import {
TilingSprite, TilingSprite,
Text, Text,
} from "pixi.js"; } from "pixi.js";
import { 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";
import { InfiniteCanvas } from "./InfiniteCanvas";
import { TravelPath } from "./TravelPath";
import { LeftSidebar } from "./LeftSidebar"; import { LeftSidebar } from "./LeftSidebar";
import { RightSidebar } from "./RightSidebar"; import { RightSidebar } from "./RightSidebar";
import { coordinatesToLocal } from "./utils"; import { coordinatesToLocal } from "./utils";
import { LanguageSwitcher } from "@widgets";
import { languageStore } from "@shared";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { Sight } from "./Sight";
import { SightData } from "./types";
import { Station } from "./Station";
import { UP_SCALE } from "./Constants"; import { UP_SCALE } from "./Constants";
import CircularProgress from "@mui/material/CircularProgress"; import { WebGLRouteMapPrototype } from "./webgl-prototype/WebGLRouteMapPrototype";
import { CircularProgress } from "@mui/material";
extend({ extend({
Container, Container,
@@ -42,7 +36,7 @@ const Loading = () => {
if (isRouteLoading || isStationLoading || isSightLoading) { if (isRouteLoading || isStationLoading || isSightLoading) {
return ( return (
<div className="fixed flex z-1 items-center justify-center h-screen w-screen bg-[#111]"> <div className="fixed flex z-1000000000 items-center justify-center h-screen w-screen bg-[#111]">
<CircularProgress /> <CircularProgress />
</div> </div>
); );
@@ -51,14 +45,33 @@ const Loading = () => {
return null; return null;
}; };
export const RoutePreview = () => { export const RoutePreview = () => {
const { routeData, stationData, sightData } = useMapData(); const [isLeftSidebarOpen, setIsLeftSidebarOpen] = useState(true);
return ( return (
<MapDataProvider> <MapDataProvider>
<TransformProvider> <TransformProvider>
<Stack direction="row" height="100vh" width="100vw" overflow="hidden"> <Stack direction="row" height="100vh" width="100vw" overflow="hidden">
{routeData && stationData && sightData ? <LanguageSwitcher /> : null}
<Loading /> <Loading />
<LeftSidebar /> <Box
sx={{
position: "relative",
width: isLeftSidebarOpen ? 300 : 0,
transition: "width 0.3s ease",
overflow: "visible",
height: "100%",
bgcolor: "primary.main",
borderRight: isLeftSidebarOpen
? "1px solid rgba(255,255,255,0.08)"
: "none",
display: "flex",
justifyContent: "flex-start",
flexShrink: 0,
}}
>
<LeftSidebar
open={isLeftSidebarOpen}
onToggle={() => setIsLeftSidebarOpen((prev) => !prev)}
/>
</Box>
<Stack direction="row" flex={1} position="relative" height="100%"> <Stack direction="row" flex={1} position="relative" height="100%">
<RouteMap /> <RouteMap />
<Widgets /> <Widgets />
@@ -71,15 +84,8 @@ export const RoutePreview = () => {
}; };
export const RouteMap = observer(() => { export const RouteMap = observer(() => {
const { language } = languageStore;
const { setPosition, setTransform, screenCenter } = useTransform(); const { setPosition, setTransform, screenCenter } = useTransform();
const { const { routeData, stationData, sightData, originalRouteData } = useMapData();
routeData,
stationData,
sightData,
originalRouteData,
originalSightData,
} = useMapData();
const [points, setPoints] = useState<{ x: number; y: number }[]>([]); const [points, setPoints] = useState<{ x: number; y: number }[]>([]);
const [isSetup, setIsSetup] = useState(false); const [isSetup, setIsSetup] = useState(false);
@@ -165,8 +171,7 @@ export const RouteMap = observer(() => {
return ( return (
<div style={{ width: "100%", height: "100%" }} ref={parentRef}> <div style={{ width: "100%", height: "100%" }} ref={parentRef}>
<LanguageSwitcher /> {/* <Application resizeTo={parentRef} background="#000" preference="webgl">
<Application resizeTo={parentRef} background="#fff" preference="webgl">
<InfiniteCanvas> <InfiniteCanvas>
<TravelPath points={points} /> <TravelPath points={points} />
{stationData[language].map((obj, index) => ( {stationData[language].map((obj, index) => (
@@ -184,7 +189,8 @@ export const RouteMap = observer(() => {
return <Sight sight={sight} id={index} key={sight.id} />; return <Sight sight={sight} id={index} key={sight.id} />;
})} })}
</InfiniteCanvas> </InfiniteCanvas>
</Application> </Application> */}
<WebGLRouteMapPrototype />
</div> </div>
); );
}); });

View File

@@ -3,6 +3,8 @@ export interface RouteData {
carrier_id: number; carrier_id: number;
center_latitude: number; center_latitude: number;
center_longitude: number; center_longitude: number;
icon_size: number;
font_size: number;
governor_appeal: number; governor_appeal: number;
id: number; id: number;
path: [number, number][]; path: [number, number][];

View File

@@ -0,0 +1,203 @@
import { useEffect, useRef, useState, type ReactElement } from "react";
import { observer } from "mobx-react-lite";
import { languageStore } from "@shared";
const LANGUAGES = ["ru", "zh", "en"] as const;
type Language = (typeof LANGUAGES)[number];
type LanguageSelectorProps = {
onBack?: () => void;
isSidebarOpen?: boolean;
};
const renderLanguageIcon = (lang: Language): ReactElement => {
switch (lang) {
case "ru":
return (
<svg
className="h-12 w-12 cursor-pointer"
viewBox="0 0 48 48"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect x="3" y="3" width="42" height="42" rx="21" fill="black" />
<path
d="M24 0C10.75 0 0 10.75 0 24C0 37.25 10.75 48 24 48C37.25 48 48 37.25 48 24C48 10.75 37.25 0 24 0ZM24.2 33.55H19.92L16.29 26.46H13.11V33.55H9.12V14.18H16.32C18.61 14.18 20.37 14.69 21.62 15.71C22.87 16.73 23.48 18.17 23.48 20.03C23.48 21.35 23.19 22.45 22.62 23.34C22.05 24.22 21.18 24.93 20.02 25.45L24.21 33.37V33.56L24.2 33.55ZM40.3 26.94C40.3 29.06 39.64 30.74 38.31 31.97C36.98 33.2 35.17 33.82 32.87 33.82C30.57 33.82 28.81 33.22 27.48 32.02C26.15 30.82 25.47 29.18 25.44 27.08V14.18H29.43V26.97C29.43 28.24 29.73 29.16 30.34 29.74C30.95 30.32 31.79 30.61 32.86 30.61C35.1 30.61 36.24 29.43 36.28 27.07V14.18H40.28V26.94H40.3Z"
fill="white"
/>
<path
d="M16.3086 17.4099H13.0986V23.2199H16.3186C17.3186 23.2199 18.0986 22.9599 18.6486 22.4499C19.1986 21.9399 19.4686 21.2399 19.4686 20.3399C19.4686 19.4399 19.2086 18.7099 18.6886 18.1799C18.1686 17.6499 17.3686 17.3899 16.2986 17.3899L16.3086 17.4099Z"
fill="white"
/>
</svg>
);
case "zh":
return (
<svg
className="h-12 w-12 cursor-pointer"
viewBox="0 0 48 48"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect x="3" y="3" width="42" height="42" rx="21" fill="black" />
<path d="M10.287 20.382H6.291V24.147H10.287V20.382Z" fill={"white"} />
<path
d="M13.704 24.147H17.721V20.382H13.704V24.147Z"
fill={"white"}
/>
<path
d="M36.1254 20.046H29.8575C30.6606 21.9406 31.7187 23.6442 33.0513 25.1217C34.3105 23.6927 35.3315 22.0126 36.1254 20.046Z"
fill={"white"}
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M24 48C37.2548 48 48 37.2548 48 24C48 10.7452 37.2548 0 24 0C10.7452 0 0 10.7452 0 24C0 37.2548 10.7452 48 24 48ZM10.287 13.5H13.704V17.1541H21.117V28.446H17.721V27.375H13.704V33.969H10.287V27.375H6.291V28.5511H3V17.1541H10.287V13.5ZM31.35 13.5H34.704V16.8181H43.083V20.046H39.8887C38.804 22.9506 37.3746 25.3834 35.581 27.4065C37.6488 28.9237 40.1651 30.0542 43.1682 30.7291L43.8469 30.8817L43.3465 31.3649C42.7753 31.9162 41.9777 33.0771 41.5886 33.7939L41.4484 34.0521L41.1642 33.9778C37.8385 33.1088 35.1249 31.7521 32.8974 29.9253C30.6296 31.6954 27.9389 33.0335 24.802 34.015L24.4889 34.1129L24.3502 33.8156C24.0724 33.2203 23.2933 32.029 22.8051 31.439L22.4307 30.9868L22.9986 30.8373C25.936 30.0648 28.4025 28.9702 30.4373 27.4935C28.6775 25.4061 27.319 22.9142 26.2412 20.046H23.097V16.8181H31.35V13.5Z"
fill={"white"}
/>
</svg>
);
case "en":
default:
return (
<svg
className="h-12 w-12 cursor-pointer"
viewBox="0 0 48 48"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect x="3" y="3" width="42" height="42" rx="21" fill="black" />
<path
d="M24 0C10.75 0 0 10.75 0 24C0 37.25 10.75 48 24 48C37.25 48 48 37.25 48 24C48 10.75 37.25 0 24 0ZM21.57 33.79H8.41V14.15H21.55V17.43H12.45V22.11H20.22V25.28H12.45V30.54H21.57V33.79ZM39.54 33.79H35.49L27.61 20.87V33.79H23.56V14.15H27.61L35.5 27.1V14.15H39.53V33.79H39.54Z"
fill="white"
/>
</svg>
);
}
};
const CollapsedIcon = () => (
<svg
className="h-12 w-12 cursor-pointer"
viewBox="0 0 48 48"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect x="4" y="3" width="39" height="42" rx="19.5" fill="black" />
<path
fillRule="evenodd"
clipRule="evenodd"
d="M0 24C0 10.75 10.75 0 24 0C37.25 0 48 10.75 48 24C48 37.25 37.25 48 24 48C10.75 48 0 37.25 0 24ZM36.05 19.41L38.27 25.47L40.47 19.39L39.17 19.77C39.16 19.75 39.16 19.35 39.16 19.32C38.58 12.41 32.99 7.15 25.94 7.15C25.42 7.15 24.99 7.58 24.99 8.1C24.99 8.62 25.42 9.05 25.94 9.05C31.92 9.05 36.68 13.47 37.26 19.31C37.26 19.325 37.2625 19.435 37.265 19.545C37.2675 19.655 37.27 19.765 37.27 19.78L36.05 19.41ZM8.90375 27.5369C11.3535 26.9568 13.6332 25.8438 15.5701 24.2838L15.5709 24.2846C17.2124 25.8526 19.2497 26.9784 21.4812 27.5521C21.5875 27.5649 21.6945 27.5649 21.8007 27.5521C22.3194 27.6074 22.8298 27.3911 23.1385 26.9848C23.448 26.5786 23.5086 26.0441 23.2986 25.5826C23.0879 25.1211 22.6389 24.803 22.1202 24.7477C20.4804 24.3182 18.9808 23.4937 17.7634 22.3495C20.2489 20.0131 22.1036 17.1245 23.1659 13.9363C23.2729 13.5077 23.165 13.0566 22.8754 12.716C22.6016 12.3627 22.1709 12.1552 21.7136 12.1552H16.6307V10.402C16.6307 9.90121 16.3535 9.43889 15.9045 9.1881C15.4556 8.9373 14.9012 8.9373 14.4522 9.1881C14.0033 9.43809 13.7261 9.90121 13.7261 10.402V12.1824H8.64317C8.12367 12.1824 7.64484 12.45 7.38509 12.8835C7.12534 13.317 7.12534 13.8522 7.38509 14.2857C7.64484 14.7192 8.1245 14.9868 8.64317 14.9868H19.6655C18.6804 16.9971 17.3443 18.828 15.7153 20.3993C14.8373 19.3977 14.0688 18.3128 13.4207 17.1598C13.2747 16.7896 12.9734 16.4972 12.5909 16.3529C12.2083 16.2087 11.7809 16.2279 11.4141 16.405C11.0473 16.5821 10.7751 16.901 10.6647 17.2824C10.5544 17.6638 10.6166 18.0724 10.8357 18.4074C11.6058 19.7423 12.5153 20.997 13.551 22.1516C12.0207 23.3728 10.2307 24.2533 8.30873 24.7317C7.92284 24.7709 7.56932 24.956 7.32617 25.2469C7.08219 25.5369 6.96767 25.9095 7.00833 26.2813C7.04899 26.6539 7.24069 26.9952 7.54193 27.23C7.84235 27.4656 8.22824 27.5761 8.61329 27.5369C8.70956 27.5513 8.80748 27.5513 8.90375 27.5369ZM34.9002 38.8803C35.2512 39.0301 35.6487 39.0397 36.0072 38.9067C36.3815 38.7865 36.6886 38.5237 36.8587 38.18C37.0288 37.8362 37.0462 37.4404 36.9077 37.0839L30.3434 20.7767C30.238 20.5131 30.0529 20.2863 29.8115 20.1261C29.57 19.9658 29.2845 19.8793 28.9924 19.8785C28.7019 19.8785 28.4173 19.9626 28.1766 20.1196C27.9359 20.2775 27.7501 20.501 27.6422 20.7623L21.136 36.523C20.9443 36.9885 21.024 37.5181 21.346 37.9116C21.668 38.305 22.1825 38.5029 22.6962 38.4308C23.2107 38.3579 23.6455 38.0269 23.8372 37.5606L25.4057 33.6489H32.3185L34.1342 38.1079C34.2737 38.4524 34.5492 38.7304 34.9002 38.8803ZM28.9052 25.1652L31.1857 30.8445H31.1849H26.5667L28.9052 25.1652Z"
fill="#FFFFFF"
/>
</svg>
);
const ArrowIcon = ({ rotation }: { rotation: number }) => (
<svg
style={{
transform: `rotate(${rotation}deg)`,
transition: "transform 0.15s ease",
}}
className="h-12 w-12"
viewBox="0 0 48 48"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect x="8" y="7" width="31" height="33" fill="black" />
<path
d="M24.0001 0C10.7501 0 0.00012207 10.75 0.00012207 24C0.00012207 37.25 10.7501 48 24.0001 48C37.2501 48 48.0001 37.25 48.0001 24C48.0001 10.75 37.2501 0 24.0001 0ZM37.5401 25.84C37.5401 26.4 37.0901 26.85 36.5301 26.85H20.5901C20.1401 26.85 19.9201 27.39 20.2301 27.71L27.6801 35.16C28.0801 35.56 28.0801 36.2 27.6801 36.59L25.0801 39.19C24.6801 39.59 24.0401 39.59 23.6501 39.19L12.4901 28.03L9.17012 24.71C8.77012 24.31 8.77012 23.67 9.17012 23.28L12.4901 19.96L23.6501 8.8C24.0501 8.4 24.6901 8.4 25.0801 8.8L27.6801 11.4C28.0801 11.8 28.0801 12.44 27.6801 12.83L20.2301 20.28C19.9101 20.6 20.1401 21.14 20.5901 21.14H36.5301C37.0901 21.14 37.5401 21.59 37.5401 22.15V25.82V25.84Z"
fill="white"
/>
</svg>
);
const LanguageSelector = observer(
({ onBack, isSidebarOpen = true }: LanguageSelectorProps) => {
const { setLanguage } = languageStore;
const [isOpen, setIsOpen] = useState(false);
const containerRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!isOpen) {
return;
}
const handleOutside = (event: PointerEvent) => {
if (
containerRef.current &&
!containerRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
};
document.addEventListener("pointerdown", handleOutside);
return () => {
document.removeEventListener("pointerdown", handleOutside);
};
}, [isOpen]);
const handleSelect = (code: Language) => {
setLanguage(code);
setIsOpen(false);
};
const toggle = () => setIsOpen((prev) => !prev);
return (
<div
ref={containerRef}
className="pointer-events-auto"
style={{
width: "500px",
transition: "width 0.25s ease",
flex: "0 0 auto",
}}
>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2 ">
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onBack?.();
}}
className="flex h-12 w-12 items-center justify-center"
aria-label={
isOpen ? "Скрыть выбор языка" : "Показать выбор языка"
}
>
<ArrowIcon rotation={isSidebarOpen ? 0 : 180} />
</button>
{isOpen ? (
LANGUAGES.map((lang) => (
<button
key={lang}
type="button"
onClick={(event) => {
event.stopPropagation();
handleSelect(lang);
}}
className="flex h-12 w-12 items-center justify-center"
aria-label={`Переключить язык на ${lang.toUpperCase()}`}
>
{renderLanguageIcon(lang)}
</button>
))
) : (
<div
className="flex h-12 w-12 items-center justify-center"
onClick={toggle}
>
<CollapsedIcon />
</div>
)}
</div>
</div>
</div>
);
}
);
export default LanguageSelector;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,619 @@
import { useState, useEffect } from "react";
import {
Stack,
Typography,
Accordion,
AccordionSummary,
AccordionDetails,
useTheme,
TextField,
Autocomplete,
TableCell,
TableContainer,
Table,
TableHead,
TableRow,
Paper,
TableBody,
Checkbox,
FormControlLabel,
Tabs,
Tab,
Box,
} from "@mui/material";
import { observer } from "mobx-react-lite";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import {
AnimatedCircleButton,
authInstance,
languageStore,
selectedCityStore,
} from "@shared";
type Field<T> = {
label: string;
data: keyof T;
render?: (value: any) => React.ReactNode;
};
type LinkedStationsProps<T> = {
parentId: string | number;
fields: Field<T>[];
setItemsParent?: (items: T[]) => void;
type: "show" | "edit";
onUpdate?: () => void;
disableCreation?: boolean;
updatedLinkedItems?: T[];
refresh?: number;
};
export const LinkedStations = <
T extends { id: number; name: string; [key: string]: any }
>(
props: LinkedStationsProps<T>
) => {
const theme = useTheme();
return (
<>
<Accordion sx={{ width: "100%" }}>
<AccordionSummary
expandIcon={<ExpandMoreIcon />}
sx={{
background: theme.palette.background.paper,
borderBottom: `1px solid ${theme.palette.divider}`,
width: "100%",
}}
>
<Typography variant="subtitle1" fontWeight="bold">
Привязанные остановки
</Typography>
</AccordionSummary>
<AccordionDetails
sx={{ background: theme.palette.background.paper, width: "100%" }}
>
<Stack gap={2} width="100%">
<LinkedStationsContents {...props} />
</Stack>
</AccordionDetails>
</Accordion>
</>
);
};
const LinkedStationsContentsInner = <
T extends { id: number; name: string; [key: string]: any }
>({
parentId,
setItemsParent,
fields,
type,
onUpdate,
disableCreation = false,
updatedLinkedItems,
refresh,
}: LinkedStationsProps<T>) => {
const { language } = languageStore;
const [allItems, setAllItems] = useState<T[]>([]);
const [linkedItems, setLinkedItems] = useState<T[]>([]);
const [selectedItemId, setSelectedItemId] = useState<number | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState(0);
const [selectedItems, setSelectedItems] = useState<Set<number>>(new Set());
const [searchQuery, setSearchQuery] = useState("");
const [selectedToDetach, setSelectedToDetach] = useState<Set<number>>(
new Set()
);
const [isLinkingSingle, setIsLinkingSingle] = useState(false);
const [isLinkingBulk, setIsLinkingBulk] = useState(false);
const [isBulkDetaching, setIsBulkDetaching] = useState(false);
const [detachingIds, setDetachingIds] = useState<Set<number>>(new Set());
useEffect(() => {}, [error]);
const parentResource = "sight";
const childResource = "station";
const buildPayload = (ids: number[]) => ({
[`${childResource}_ids`]: ids,
});
const availableItems = allItems
.filter((item) => !linkedItems.some((linked) => linked.id === item.id))
.filter((item) => {
const selectedCityId = selectedCityStore.selectedCityId;
if (selectedCityId && "city_id" in item) {
return item.city_id === selectedCityId;
}
return true;
})
.sort((a, b) => a.name.localeCompare(b.name));
const filteredAvailableItems = availableItems.filter((item) => {
if (!searchQuery.trim()) return true;
const query = searchQuery.toLowerCase();
const name = String(item.name || "").toLowerCase();
const description = String(item.description || "").toLowerCase();
return name.includes(query) || description.includes(query);
});
useEffect(() => {
if (updatedLinkedItems) {
setLinkedItems(updatedLinkedItems);
}
}, [updatedLinkedItems]);
useEffect(() => {
setItemsParent?.(linkedItems);
}, [linkedItems, setItemsParent]);
useEffect(() => {
setSelectedToDetach((prev) => {
const updated = new Set<number>();
linkedItems.forEach((item) => {
if (prev.has(item.id)) {
updated.add(item.id);
}
});
return updated;
});
}, [linkedItems]);
const linkItem = () => {
if (selectedItemId !== null) {
setError(null);
const requestData = buildPayload([selectedItemId]);
setIsLinkingSingle(true);
authInstance
.post(`/${parentResource}/${parentId}/${childResource}`, requestData)
.then(() => {
const newItem = allItems.find((item) => item.id === selectedItemId);
if (newItem) {
setLinkedItems([...linkedItems, newItem]);
}
setSelectedItemId(null);
onUpdate?.();
})
.catch((error) => {
console.error("Error linking station:", error);
setError("Failed to link station");
})
.finally(() => {
setIsLinkingSingle(false);
});
}
};
const deleteItem = (itemId: number) => {
setError(null);
setDetachingIds((prev) => {
const next = new Set(prev);
next.add(itemId);
return next;
});
authInstance
.delete(`/${parentResource}/${parentId}/${childResource}`, {
data: buildPayload([itemId]),
})
.then(() => {
setLinkedItems(linkedItems.filter((item) => item.id !== itemId));
onUpdate?.();
})
.catch((error) => {
console.error("Error deleting station:", error);
setError("Failed to delete station");
})
.finally(() => {
setDetachingIds((prev) => {
const next = new Set(prev);
next.delete(itemId);
return next;
});
});
};
const handleCheckboxChange = (itemId: number) => {
const updated = new Set(selectedItems);
if (updated.has(itemId)) {
updated.delete(itemId);
} else {
updated.add(itemId);
}
setSelectedItems(updated);
};
const handleBulkLink = async () => {
if (selectedItems.size === 0) return;
setError(null);
setIsLinkingBulk(true);
const idsToLink = Array.from(selectedItems);
try {
await authInstance.post(
`/${parentResource}/${parentId}/${childResource}`,
buildPayload(idsToLink)
);
const newItems = allItems.filter((item) => idsToLink.includes(item.id));
setLinkedItems((prev) => {
const existingIds = new Set(prev.map((item) => item.id));
const additions = newItems.filter((item) => !existingIds.has(item.id));
return [...prev, ...additions];
});
setSelectedItems((prev) => {
const remaining = new Set(prev);
idsToLink.forEach((id) => remaining.delete(id));
return remaining;
});
onUpdate?.();
} catch (error) {
console.error("Error linking stations:", error);
setError("Failed to link stations");
}
setIsLinkingBulk(false);
};
const toggleDetachSelection = (itemId: number) => {
const updated = new Set(selectedToDetach);
if (updated.has(itemId)) {
updated.delete(itemId);
} else {
updated.add(itemId);
}
setSelectedToDetach(updated);
};
const handleToggleAllDetach = (checked: boolean) => {
if (!checked) {
setSelectedToDetach(new Set());
return;
}
setSelectedToDetach(new Set(linkedItems.map((item) => item.id)));
};
const handleBulkDetach = async () => {
const idsToDetach = Array.from(selectedToDetach);
if (idsToDetach.length === 0) return;
setError(null);
setIsBulkDetaching(true);
setDetachingIds((prev) => {
const next = new Set(prev);
idsToDetach.forEach((id) => next.add(id));
return next;
});
try {
await authInstance.delete(
`/${parentResource}/${parentId}/${childResource}`,
{
data: buildPayload(idsToDetach),
}
);
setLinkedItems((prev) =>
prev.filter((item) => !idsToDetach.includes(item.id))
);
setSelectedToDetach((prev) => {
const remaining = new Set(prev);
idsToDetach.forEach((id) => remaining.delete(id));
return remaining;
});
onUpdate?.();
} catch (error) {
console.error("Error deleting stations:", error);
setError("Failed to delete stations");
}
setDetachingIds((prev) => {
const next = new Set(prev);
idsToDetach.forEach((id) => next.delete(id));
return next;
});
setIsBulkDetaching(false);
};
const allSelectedForDetach =
linkedItems.length > 0 &&
linkedItems.every((item) => selectedToDetach.has(item.id));
const isIndeterminateDetach =
selectedToDetach.size > 0 && !allSelectedForDetach;
useEffect(() => {
if (parentId) {
setIsLoading(true);
setError(null);
authInstance
.get(`/${parentResource}/${parentId}/${childResource}`)
.then((response) => {
setLinkedItems(response?.data || []);
})
.catch((error) => {
console.error("Error fetching linked stations:", error);
setError("Failed to load linked stations");
setLinkedItems([]);
})
.finally(() => {
setIsLoading(false);
});
}
}, [parentId, language, refresh]);
useEffect(() => {
if (type === "edit") {
setError(null);
authInstance
.get(`/${childResource}`)
.then((response) => {
setAllItems(response?.data || []);
})
.catch((error) => {
console.error("Error fetching all stations:", error);
setError("Failed to load available stations");
setAllItems([]);
});
}
}, [type]);
return (
<>
{linkedItems?.length > 0 && (
<TableContainer component={Paper} sx={{ width: "100%" }}>
<Table sx={{ width: "100%" }}>
<TableHead>
<TableRow>
{type === "edit" && (
<TableCell width="50px">
<Checkbox
size="small"
checked={allSelectedForDetach}
indeterminate={isIndeterminateDetach}
onChange={(e) => handleToggleAllDetach(e.target.checked)}
/>
</TableCell>
)}
<TableCell key="id" width="60px">
</TableCell>
{fields.map((field) => (
<TableCell key={String(field.data)}>{field.label}</TableCell>
))}
{type === "edit" && (
<TableCell width="120px">Действие</TableCell>
)}
</TableRow>
</TableHead>
<TableBody>
{linkedItems.map((item, index) => (
<TableRow key={item.id} hover>
{type === "edit" && (
<TableCell>
<Checkbox
size="small"
checked={selectedToDetach.has(item.id)}
onChange={() => toggleDetachSelection(item.id)}
/>
</TableCell>
)}
<TableCell>{index + 1}</TableCell>
{fields.map((field, idx) => (
<TableCell key={String(field.data) + String(idx)}>
{field.render
? field.render(item[field.data])
: item[field.data]}
</TableCell>
))}
{type === "edit" && (
<TableCell>
<AnimatedCircleButton
variant="outlined"
color="error"
size="small"
onClick={(e) => {
e.stopPropagation();
deleteItem(item.id);
}}
disabled={detachingIds.has(item.id)}
loading={detachingIds.has(item.id)}
>
Отвязать
</AnimatedCircleButton>
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
{type === "edit" && linkedItems.length > 0 && (
<Stack direction="row" gap={2} mt={2}>
<AnimatedCircleButton
variant="outlined"
color="error"
onClick={handleBulkDetach}
disabled={selectedToDetach.size === 0 || isBulkDetaching}
loading={isBulkDetaching}
>
Отвязать выбранные ({selectedToDetach.size})
</AnimatedCircleButton>
</Stack>
)}
{linkedItems.length === 0 && !isLoading && (
<Typography color="textSecondary" textAlign="center" py={2}>
Остановки не найдены
</Typography>
)}
{type === "edit" && !disableCreation && (
<Stack gap={2} mt={2}>
<Typography variant="subtitle1">Добавить остановки</Typography>
<Tabs
value={activeTab}
onChange={(_, value) => setActiveTab(value)}
variant="fullWidth"
>
<Tab label="По одной" />
<Tab label="Массово" />
</Tabs>
<Box sx={{ mt: 1 }}>
{activeTab === 0 && (
<Stack gap={2}>
<Autocomplete
fullWidth
value={
availableItems?.find(
(item) => item.id === selectedItemId
) || null
}
onChange={(_, newValue) =>
setSelectedItemId(newValue?.id || null)
}
options={availableItems}
getOptionLabel={(item) => String(item.name)}
renderInput={(params) => (
<TextField
{...params}
label="Выберите остановку"
placeholder="Введите название или описание остановки..."
fullWidth
/>
)}
isOptionEqualToValue={(option, value) =>
option.id === value?.id
}
filterOptions={(options, { inputValue }) => {
if (!inputValue.trim()) return options;
const query = inputValue.toLowerCase();
return options.filter((option) => {
const name = String(option.name || "").toLowerCase();
const description = String(
option.description || ""
).toLowerCase();
return (
name.includes(query) || description.includes(query)
);
});
}}
renderOption={(props, option) => (
<li {...props} key={option.id}>
<div className="flex justify-between items-center w-full">
<p>{String(option.name)}</p>
<p className="text-xs text-gray-500 max-w-[300px] mr-4 truncate">
{String(option.description)}
</p>
</div>
</li>
)}
/>
<AnimatedCircleButton
variant="contained"
onClick={linkItem}
disabled={!selectedItemId || isLinkingSingle}
loading={isLinkingSingle}
sx={{ alignSelf: "flex-start" }}
>
Добавить
</AnimatedCircleButton>
</Stack>
)}
{activeTab === 1 && (
<Stack gap={2}>
<TextField
fullWidth
label="Поиск остановок"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Введите название или описание остановки..."
size="small"
/>
<Paper sx={{ maxHeight: 300, overflow: "auto", p: 2 }}>
<Stack gap={1}>
{filteredAvailableItems.map((item) => (
<FormControlLabel
key={item.id}
control={
<Checkbox
checked={selectedItems.has(item.id)}
onChange={() => handleCheckboxChange(item.id)}
size="small"
/>
}
label={
<div className="flex justify-between items-center w-full gap-10">
<p>{String(item.name)}</p>
<p className="text-xs text-gray-500 max-w-[300px] truncate text-ellipsis">
{String(item.description)}
</p>
</div>
}
sx={{
margin: 0,
"& .MuiFormControlLabel-label": {
fontSize: "0.9rem",
width: "100%",
},
}}
/>
))}
{filteredAvailableItems.length === 0 && (
<Typography
color="textSecondary"
textAlign="center"
py={1}
>
{searchQuery.trim()
? "Остановки не найдены"
: "Нет доступных остановок"}
</Typography>
)}
</Stack>
</Paper>
<AnimatedCircleButton
variant="contained"
onClick={handleBulkLink}
disabled={selectedItems.size === 0 || isLinkingBulk}
loading={isLinkingBulk}
sx={{ alignSelf: "flex-start" }}
>
Добавить выбранные ({selectedItems.size})
</AnimatedCircleButton>
</Stack>
)}
</Box>
</Stack>
)}
{isLoading && (
<Typography color="textSecondary" textAlign="center" py={2}>
Загрузка...
</Typography>
)}
{error && (
<Typography color="error" textAlign="center" py={2}>
{error}
</Typography>
)}
</>
);
};
export const LinkedStationsContents = observer(
LinkedStationsContentsInner
) as typeof LinkedStationsContentsInner;

View File

@@ -1 +1,2 @@
export * from "./SightListPage"; export * from "./SightListPage";
export { LinkedStations } from "./LinkedStations";

View File

@@ -5,9 +5,10 @@ import { ArrowLeft, Loader2, Save } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { runInAction } from "mobx";
export const SnapshotCreatePage = observer(() => { export const SnapshotCreatePage = observer(() => {
const { createSnapshot } = snapshotStore; const { createSnapshot, getSnapshotStatus, snapshotStatus } = snapshotStore;
const navigate = useNavigate(); const navigate = useNavigate();
const [name, setName] = useState(""); const [name, setName] = useState("");
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@@ -42,10 +43,24 @@ export const SnapshotCreatePage = observer(() => {
onClick={async () => { onClick={async () => {
try { try {
setIsLoading(true); setIsLoading(true);
await createSnapshot(name); const id = await createSnapshot(name);
setIsLoading(false);
await getSnapshotStatus(id);
while (snapshotStore.snapshotStatus?.Status != "done") {
await new Promise((resolve) => setTimeout(resolve, 1000));
await getSnapshotStatus(id);
}
if (snapshotStore.snapshotStatus?.Status === "done") {
toast.success("Снапшот успешно создан"); toast.success("Снапшот успешно создан");
runInAction(() => {
snapshotStore.snapshotStatus = null;
});
navigate(-1); navigate(-1);
}
} catch (error) { } catch (error) {
console.error(error); console.error(error);
toast.error("Ошибка при создании снапшота"); toast.error("Ошибка при создании снапшота");
@@ -56,7 +71,15 @@ export const SnapshotCreatePage = observer(() => {
disabled={isLoading || !name.trim()} disabled={isLoading || !name.trim()}
> >
{isLoading ? ( {isLoading ? (
<div className="flex items-center gap-2">
<Loader2 size={20} className="animate-spin" /> <Loader2 size={20} className="animate-spin" />
<span>
{snapshotStatus?.Progress
? (snapshotStatus.Progress * 100).toFixed(2)
: 0}
%
</span>
</div>
) : ( ) : (
"Сохранить" "Сохранить"
)} )}

View File

@@ -2,7 +2,6 @@ import { useState, useEffect } from "react";
import { import {
Stack, Stack,
Typography, Typography,
Button,
Accordion, Accordion,
AccordionSummary, AccordionSummary,
AccordionDetails, AccordionDetails,
@@ -16,11 +15,21 @@ import {
TableRow, TableRow,
Paper, Paper,
TableBody, TableBody,
Checkbox,
FormControlLabel,
Tabs,
Tab,
Box,
} from "@mui/material"; } from "@mui/material";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import { authInstance, languageStore, selectedCityStore } from "@shared"; import {
AnimatedCircleButton,
authInstance,
languageStore,
selectedCityStore,
} from "@shared";
type Field<T> = { type Field<T> = {
label: string; label: string;
@@ -93,12 +102,26 @@ const LinkedSightsContentsInner = <
const [selectedItemId, setSelectedItemId] = useState<number | null>(null); const [selectedItemId, setSelectedItemId] = useState<number | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(true); const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState(0);
const [selectedItems, setSelectedItems] = useState<Set<number>>(new Set());
const [searchQuery, setSearchQuery] = useState("");
const [selectedToDetach, setSelectedToDetach] = useState<Set<number>>(
new Set()
);
const [isLinkingSingle, setIsLinkingSingle] = useState(false);
const [isLinkingBulk, setIsLinkingBulk] = useState(false);
const [isBulkDetaching, setIsBulkDetaching] = useState(false);
const [detachingIds, setDetachingIds] = useState<Set<number>>(new Set());
useEffect(() => {}, [error]); useEffect(() => {}, [error]);
const parentResource = "station"; const parentResource = "station";
const childResource = "sight"; const childResource = "sight";
const buildPayload = (ids: number[]) => ({
[`${childResource}_ids`]: ids,
});
const availableItems = allItems const availableItems = allItems
.filter((item) => !linkedItems.some((linked) => linked.id === item.id)) .filter((item) => !linkedItems.some((linked) => linked.id === item.id))
.filter((item) => { .filter((item) => {
@@ -111,6 +134,11 @@ const LinkedSightsContentsInner = <
}) })
.sort((a, b) => a.name.localeCompare(b.name)); .sort((a, b) => a.name.localeCompare(b.name));
const filteredAvailableItems = availableItems.filter((item) => {
if (!searchQuery.trim()) return true;
return String(item.name).toLowerCase().includes(searchQuery.toLowerCase());
});
useEffect(() => { useEffect(() => {
if (updatedLinkedItems) { if (updatedLinkedItems) {
setLinkedItems(updatedLinkedItems); setLinkedItems(updatedLinkedItems);
@@ -121,13 +149,24 @@ const LinkedSightsContentsInner = <
setItemsParent?.(linkedItems); setItemsParent?.(linkedItems);
}, [linkedItems, setItemsParent]); }, [linkedItems, setItemsParent]);
useEffect(() => {
setSelectedToDetach((prev) => {
const updated = new Set<number>();
linkedItems.forEach((item) => {
if (prev.has(item.id)) {
updated.add(item.id);
}
});
return updated;
});
}, [linkedItems]);
const linkItem = () => { const linkItem = () => {
if (selectedItemId !== null) { if (selectedItemId !== null) {
setError(null); setError(null);
const requestData = { const requestData = buildPayload([selectedItemId]);
sight_id: selectedItemId,
};
setIsLinkingSingle(true);
authInstance authInstance
.post(`/${parentResource}/${parentId}/${childResource}`, requestData) .post(`/${parentResource}/${parentId}/${childResource}`, requestData)
.then(() => { .then(() => {
@@ -141,15 +180,23 @@ const LinkedSightsContentsInner = <
.catch((error) => { .catch((error) => {
console.error("Error linking sight:", error); console.error("Error linking sight:", error);
setError("Failed to link sight"); setError("Failed to link sight");
})
.finally(() => {
setIsLinkingSingle(false);
}); });
} }
}; };
const deleteItem = (itemId: number) => { const deleteItem = (itemId: number) => {
setError(null); setError(null);
setDetachingIds((prev) => {
const next = new Set(prev);
next.add(itemId);
return next;
});
authInstance authInstance
.delete(`/${parentResource}/${parentId}/${childResource}`, { .delete(`/${parentResource}/${parentId}/${childResource}`, {
data: { [`${childResource}_id`]: itemId }, data: buildPayload([itemId]),
}) })
.then(() => { .then(() => {
setLinkedItems(linkedItems.filter((item) => item.id !== itemId)); setLinkedItems(linkedItems.filter((item) => item.id !== itemId));
@@ -158,9 +205,125 @@ const LinkedSightsContentsInner = <
.catch((error) => { .catch((error) => {
console.error("Error deleting sight:", error); console.error("Error deleting sight:", error);
setError("Failed to delete sight"); setError("Failed to delete sight");
})
.finally(() => {
setDetachingIds((prev) => {
const next = new Set(prev);
next.delete(itemId);
return next;
});
}); });
}; };
const handleCheckboxChange = (itemId: number) => {
const updated = new Set(selectedItems);
if (updated.has(itemId)) {
updated.delete(itemId);
} else {
updated.add(itemId);
}
setSelectedItems(updated);
};
const handleBulkLink = async () => {
if (selectedItems.size === 0) return;
setError(null);
setIsLinkingBulk(true);
const idsToLink = Array.from(selectedItems);
try {
await authInstance.post(
`/${parentResource}/${parentId}/${childResource}`,
buildPayload(idsToLink)
);
const newItems = allItems.filter((item) => idsToLink.includes(item.id));
setLinkedItems((prev) => {
const existingIds = new Set(prev.map((item) => item.id));
const additions = newItems.filter((item) => !existingIds.has(item.id));
return [...prev, ...additions];
});
setSelectedItems((prev) => {
const remaining = new Set(prev);
idsToLink.forEach((id) => remaining.delete(id));
return remaining;
});
onUpdate?.();
} catch (error) {
console.error("Error linking sights:", error);
setError("Failed to link sights");
}
setIsLinkingBulk(false);
};
const toggleDetachSelection = (itemId: number) => {
const updated = new Set(selectedToDetach);
if (updated.has(itemId)) {
updated.delete(itemId);
} else {
updated.add(itemId);
}
setSelectedToDetach(updated);
};
const handleToggleAllDetach = (checked: boolean) => {
if (!checked) {
setSelectedToDetach(new Set());
return;
}
setSelectedToDetach(new Set(linkedItems.map((item) => item.id)));
};
const handleBulkDetach = async () => {
const idsToDetach = Array.from(selectedToDetach);
if (idsToDetach.length === 0) return;
setError(null);
setIsBulkDetaching(true);
setDetachingIds((prev) => {
const next = new Set(prev);
idsToDetach.forEach((id) => next.add(id));
return next;
});
try {
await authInstance.delete(
`/${parentResource}/${parentId}/${childResource}`,
{
data: buildPayload(idsToDetach),
}
);
setLinkedItems((prev) =>
prev.filter((item) => !idsToDetach.includes(item.id))
);
setSelectedToDetach((prev) => {
const remaining = new Set(prev);
idsToDetach.forEach((id) => remaining.delete(id));
return remaining;
});
onUpdate?.();
} catch (error) {
console.error("Error deleting sights:", error);
setError("Failed to delete sights");
}
setDetachingIds((prev) => {
const next = new Set(prev);
idsToDetach.forEach((id) => next.delete(id));
return next;
});
setIsBulkDetaching(false);
};
const allSelectedForDetach =
linkedItems.length > 0 &&
linkedItems.every((item) => selectedToDetach.has(item.id));
const isIndeterminateDetach =
selectedToDetach.size > 0 && !allSelectedForDetach;
useEffect(() => { useEffect(() => {
if (parentId) { if (parentId) {
setIsLoading(true); setIsLoading(true);
@@ -204,6 +367,16 @@ const LinkedSightsContentsInner = <
<Table sx={{ width: "100%" }}> <Table sx={{ width: "100%" }}>
<TableHead> <TableHead>
<TableRow> <TableRow>
{type === "edit" && (
<TableCell width="50px">
<Checkbox
size="small"
checked={allSelectedForDetach}
indeterminate={isIndeterminateDetach}
onChange={(e) => handleToggleAllDetach(e.target.checked)}
/>
</TableCell>
)}
<TableCell key="id" width="60px"> <TableCell key="id" width="60px">
</TableCell> </TableCell>
@@ -219,6 +392,15 @@ const LinkedSightsContentsInner = <
<TableBody> <TableBody>
{linkedItems.map((item, index) => ( {linkedItems.map((item, index) => (
<TableRow key={item.id} hover> <TableRow key={item.id} hover>
{type === "edit" && (
<TableCell>
<Checkbox
size="small"
checked={selectedToDetach.has(item.id)}
onChange={() => toggleDetachSelection(item.id)}
/>
</TableCell>
)}
<TableCell>{index + 1}</TableCell> <TableCell>{index + 1}</TableCell>
{fields.map((field, idx) => ( {fields.map((field, idx) => (
<TableCell key={String(field.data) + String(idx)}> <TableCell key={String(field.data) + String(idx)}>
@@ -229,7 +411,7 @@ const LinkedSightsContentsInner = <
))} ))}
{type === "edit" && ( {type === "edit" && (
<TableCell> <TableCell>
<Button <AnimatedCircleButton
variant="outlined" variant="outlined"
color="error" color="error"
size="small" size="small"
@@ -237,9 +419,11 @@ const LinkedSightsContentsInner = <
e.stopPropagation(); e.stopPropagation();
deleteItem(item.id); deleteItem(item.id);
}} }}
disabled={detachingIds.has(item.id)}
loading={detachingIds.has(item.id)}
> >
Отвязать Отвязать
</Button> </AnimatedCircleButton>
</TableCell> </TableCell>
)} )}
</TableRow> </TableRow>
@@ -249,6 +433,20 @@ const LinkedSightsContentsInner = <
</TableContainer> </TableContainer>
)} )}
{type === "edit" && linkedItems.length > 0 && (
<Stack direction="row" gap={2} mt={2}>
<AnimatedCircleButton
variant="outlined"
color="error"
onClick={handleBulkDetach}
disabled={selectedToDetach.size === 0 || isBulkDetaching}
loading={isBulkDetaching}
>
Отвязать выбранные ({selectedToDetach.size})
</AnimatedCircleButton>
</Stack>
)}
{linkedItems.length === 0 && !isLoading && ( {linkedItems.length === 0 && !isLoading && (
<Typography color="textSecondary" textAlign="center" py={2}> <Typography color="textSecondary" textAlign="center" py={2}>
Достопримечательности не найдены Достопримечательности не найдены
@@ -258,14 +456,30 @@ const LinkedSightsContentsInner = <
{type === "edit" && !disableCreation && ( {type === "edit" && !disableCreation && (
<Stack gap={2} mt={2}> <Stack gap={2} mt={2}>
<Typography variant="subtitle1"> <Typography variant="subtitle1">
Добавить достопримечательность Добавить достопримечательности
</Typography> </Typography>
<Tabs
value={activeTab}
onChange={(_, value) => setActiveTab(value)}
variant="fullWidth"
>
<Tab label="По одной" />
<Tab label="Массово" />
</Tabs>
<Box sx={{ mt: 1 }}>
{activeTab === 0 && (
<Stack gap={2}>
<Autocomplete <Autocomplete
fullWidth fullWidth
value={ value={
availableItems?.find((item) => item.id === selectedItemId) || null availableItems?.find(
(item) => item.id === selectedItemId
) || null
}
onChange={(_, newValue) =>
setSelectedItemId(newValue?.id || null)
} }
onChange={(_, newValue) => setSelectedItemId(newValue?.id || null)}
options={availableItems} options={availableItems}
getOptionLabel={(item) => String(item.name)} getOptionLabel={(item) => String(item.name)}
renderInput={(params) => ( renderInput={(params) => (
@@ -275,7 +489,9 @@ const LinkedSightsContentsInner = <
fullWidth fullWidth
/> />
)} )}
isOptionEqualToValue={(option, value) => option.id === value?.id} isOptionEqualToValue={(option, value) =>
option.id === value?.id
}
filterOptions={(options, { inputValue }) => { filterOptions={(options, { inputValue }) => {
const searchWords = inputValue const searchWords = inputValue
.toLowerCase() .toLowerCase()
@@ -297,14 +513,76 @@ const LinkedSightsContentsInner = <
)} )}
/> />
<Button <AnimatedCircleButton
variant="contained" variant="contained"
onClick={linkItem} onClick={linkItem}
disabled={!selectedItemId} disabled={!selectedItemId || isLinkingSingle}
loading={isLinkingSingle}
sx={{ alignSelf: "flex-start" }} sx={{ alignSelf: "flex-start" }}
> >
Добавить Добавить
</Button> </AnimatedCircleButton>
</Stack>
)}
{activeTab === 1 && (
<Stack gap={2}>
<TextField
fullWidth
label="Поиск достопримечательностей"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Введите название..."
size="small"
/>
<Paper sx={{ maxHeight: 300, overflow: "auto", p: 2 }}>
<Stack gap={1}>
{filteredAvailableItems.map((item) => (
<FormControlLabel
key={item.id}
control={
<Checkbox
checked={selectedItems.has(item.id)}
onChange={() => handleCheckboxChange(item.id)}
size="small"
/>
}
label={String(item.name)}
sx={{
margin: 0,
"& .MuiFormControlLabel-label": {
fontSize: "0.9rem",
},
}}
/>
))}
{filteredAvailableItems.length === 0 && (
<Typography
color="textSecondary"
textAlign="center"
py={1}
>
{searchQuery.trim()
? "Достопримечательности не найдены"
: "Нет доступных достопримечательностей"}
</Typography>
)}
</Stack>
</Paper>
<AnimatedCircleButton
variant="contained"
onClick={handleBulkLink}
disabled={selectedItems.size === 0 || isLinkingBulk}
loading={isLinkingBulk}
sx={{ alignSelf: "flex-start" }}
>
Добавить выбранные ({selectedItems.size})
</AnimatedCircleButton>
</Stack>
)}
</Box>
</Stack> </Stack>
)} )}

View File

@@ -35,7 +35,7 @@ export const StationCreatePage = observer(() => {
const { cities, getCities } = cityStore; const { cities, getCities } = cityStore;
const { selectedCityId, selectedCity } = useSelectedCity(); const { selectedCityId, selectedCity } = useSelectedCity();
const [coordinates, setCoordinates] = useState<string>(""); const [coordinates, setCoordinates] = useState<string>("");
// НОВОЕ СОСТОЯНИЕ ДЛЯ ПРЕДУПРЕЖДАЮЩЕГО ОКНА
const [isSaveWarningOpen, setIsSaveWarningOpen] = useState(false); const [isSaveWarningOpen, setIsSaveWarningOpen] = useState(false);
useEffect(() => { useEffect(() => {
@@ -49,7 +49,6 @@ export const StationCreatePage = observer(() => {
} }
}, [createStationData.common.latitude, createStationData.common.longitude]); }, [createStationData.common.latitude, createStationData.common.longitude]);
// НОВАЯ ФУНКЦИЯ: Фактическое создание (вызывается после подтверждения)
const executeCreate = async () => { const executeCreate = async () => {
try { try {
setIsLoading(true); setIsLoading(true);
@@ -64,11 +63,13 @@ export const StationCreatePage = observer(() => {
} }
}; };
// ОБНОВЛЕННАЯ ФУНКЦИЯ: Проверка и вызов окна или создания
const handleCreate = async () => { const handleCreate = async () => {
const isCityMissing = !createStationData.common.city_id; const isCityMissing = !createStationData.common.city_id;
// Проверяем названия на всех языках
const isNameMissing = !createStationData.ru.name || !createStationData.en.name || !createStationData.zh.name; const isNameMissing =
!createStationData.ru.name ||
!createStationData.en.name ||
!createStationData.zh.name;
if (isCityMissing || isNameMissing) { if (isCityMissing || isNameMissing) {
setIsSaveWarningOpen(true); setIsSaveWarningOpen(true);
@@ -78,13 +79,11 @@ export const StationCreatePage = observer(() => {
await executeCreate(); await executeCreate();
}; };
// Обработчик "Да" в предупреждающем окне
const handleConfirmCreate = async () => { const handleConfirmCreate = async () => {
setIsSaveWarningOpen(false); setIsSaveWarningOpen(false);
await executeCreate(); await executeCreate();
}; };
// Обработчик "Нет" в предупреждающем окне
const handleCancelCreate = () => { const handleCancelCreate = () => {
setIsSaveWarningOpen(false); setIsSaveWarningOpen(false);
}; };
@@ -99,7 +98,6 @@ export const StationCreatePage = observer(() => {
fetchCities(); fetchCities();
}, []); }, []);
// Автоматически устанавливаем выбранный город при загрузке страницы
useEffect(() => { useEffect(() => {
if (selectedCityId && selectedCity && !createStationData.common.city_id) { if (selectedCityId && selectedCity && !createStationData.common.city_id) {
setCreateCommonData({ setCreateCommonData({
@@ -237,7 +235,7 @@ export const StationCreatePage = 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={handleCreate} onClick={handleCreate}
disabled={isLoading} // Убрал проверку name/city_id отсюда, чтобы ее обрабатывал handleCreate disabled={isLoading}
> >
{isLoading ? ( {isLoading ? (
<Loader2 size={20} className="animate-spin" /> <Loader2 size={20} className="animate-spin" />

View File

@@ -6,13 +6,19 @@ import {
MenuItem, MenuItem,
FormControl, FormControl,
InputLabel, InputLabel,
Box,
} from "@mui/material"; } from "@mui/material";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { ArrowLeft, Save } from "lucide-react"; import { ArrowLeft, Save } from "lucide-react";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { stationsStore, languageStore, cityStore } from "@shared"; import {
stationsStore,
languageStore,
cityStore,
LoadingSpinner,
} from "@shared";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { LanguageSwitcher } from "@widgets"; import { LanguageSwitcher } from "@widgets";
import { LinkedSights } from "../LinkedSights"; import { LinkedSights } from "../LinkedSights";
@@ -21,6 +27,7 @@ import { SaveWithoutCityAgree } from "@widgets";
export const StationEditPage = observer(() => { export const StationEditPage = observer(() => {
const navigate = useNavigate(); const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isLoadingData, setIsLoadingData] = useState(true);
const { language } = languageStore; const { language } = languageStore;
const { id } = useParams(); const { id } = useParams();
const { const {
@@ -32,7 +39,7 @@ export const StationEditPage = observer(() => {
} = stationsStore; } = stationsStore;
const { cities, getCities } = cityStore; const { cities, getCities } = cityStore;
const [coordinates, setCoordinates] = useState<string>(""); const [coordinates, setCoordinates] = useState<string>("");
// НОВОЕ СОСТОЯНИЕ ДЛЯ ПРЕДУПРЕЖДАЮЩЕГО ОКНА
const [isSaveWarningOpen, setIsSaveWarningOpen] = useState(false); const [isSaveWarningOpen, setIsSaveWarningOpen] = useState(false);
useEffect(() => { useEffect(() => {
@@ -50,7 +57,6 @@ export const StationEditPage = observer(() => {
} }
}, [editStationData.common.latitude, editStationData.common.longitude]); }, [editStationData.common.latitude, editStationData.common.longitude]);
// НОВАЯ ФУНКЦИЯ: Фактическое редактирование (вызывается после подтверждения)
const executeEdit = async () => { const executeEdit = async () => {
try { try {
setIsLoading(true); setIsLoading(true);
@@ -64,10 +70,9 @@ export const StationEditPage = observer(() => {
} }
}; };
// ОБНОВЛЕННАЯ ФУНКЦИЯ: Проверка и вызов окна или редактирования
const handleEdit = async () => { const handleEdit = async () => {
const isCityMissing = !editStationData.common.city_id; const isCityMissing = !editStationData.common.city_id;
// Проверяем названия на всех языках
const isNameMissing = const isNameMissing =
!editStationData.ru.name || !editStationData.ru.name ||
!editStationData.en.name || !editStationData.en.name ||
@@ -81,31 +86,52 @@ export const StationEditPage = observer(() => {
await executeEdit(); await executeEdit();
}; };
// Обработчик "Да" в предупреждающем окне
const handleConfirmEdit = async () => { const handleConfirmEdit = async () => {
setIsSaveWarningOpen(false); setIsSaveWarningOpen(false);
await executeEdit(); await executeEdit();
}; };
// Обработчик "Нет" в предупреждающем окне
const handleCancelEdit = () => { const handleCancelEdit = () => {
setIsSaveWarningOpen(false); setIsSaveWarningOpen(false);
}; };
useEffect(() => { useEffect(() => {
const fetchAndSetStationData = async () => { const fetchAndSetStationData = async () => {
if (!id) return; if (!id) {
setIsLoadingData(false);
return;
}
setIsLoadingData(true);
try {
const stationId = Number(id); const stationId = Number(id);
await getEditStation(stationId); await getEditStation(stationId);
await getCities("ru"); await getCities("ru");
await getCities("en"); await getCities("en");
await getCities("zh"); await getCities("zh");
} finally {
setIsLoadingData(false);
}
}; };
fetchAndSetStationData(); fetchAndSetStationData();
}, [id]); }, [id]);
if (isLoadingData) {
return (
<Box
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
minHeight: "60vh",
}}
>
<LoadingSpinner message="Загрузка данных станции..." />
</Box>
);
}
return ( return (
<Paper 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 />
@@ -243,7 +269,7 @@ export const StationEditPage = 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={handleEdit} onClick={handleEdit}
disabled={isLoading} // Убрал проверку name/city_id отсюда, чтобы ее обрабатывал handleEdit disabled={isLoading}
> >
{isLoading ? ( {isLoading ? (
<Loader2 size={20} className="animate-spin" /> <Loader2 size={20} className="animate-spin" />

View File

@@ -8,9 +8,14 @@ import {
} from "@shared"; } from "@shared";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { Eye, Pencil, Trash2, Minus } from "lucide-react"; import { Eye, Pencil, Trash2, Minus, Route } from "lucide-react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets"; import {
CreateButton,
DeleteModal,
LanguageSwitcher,
EditStationTransfersModal,
} from "@widgets";
import { Box, CircularProgress } from "@mui/material"; import { Box, CircularProgress } from "@mui/material";
export const StationListPage = observer(() => { export const StationListPage = observer(() => {
@@ -18,7 +23,11 @@ export const StationListPage = observer(() => {
const navigate = useNavigate(); const navigate = useNavigate();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false); const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
const [isTransfersModalOpen, setIsTransfersModalOpen] = useState(false);
const [rowId, setRowId] = useState<number | null>(null); const [rowId, setRowId] = useState<number | null>(null);
const [selectedStationId, setSelectedStationId] = useState<number | null>(
null
);
const [ids, setIds] = useState<number[]>([]); const [ids, setIds] = useState<number[]>([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const { language } = languageStore; const { language } = languageStore;
@@ -51,8 +60,8 @@ export const StationListPage = observer(() => {
}, },
}, },
{ {
field: "system_name", field: "description",
headerName: "Системное название", headerName: "Описание",
flex: 1, flex: 1,
renderCell: (params: GridRenderCellParams) => { renderCell: (params: GridRenderCellParams) => {
return ( return (
@@ -88,7 +97,7 @@ export const StationListPage = observer(() => {
{ {
field: "actions", field: "actions",
headerName: "Действия", headerName: "Действия",
width: 140, width: 200,
align: "center", align: "center",
headerAlign: "center", headerAlign: "center",
sortable: false, sortable: false,
@@ -102,6 +111,15 @@ export const StationListPage = observer(() => {
<button onClick={() => navigate(`/station/${params.row.id}`)}> <button onClick={() => navigate(`/station/${params.row.id}`)}>
<Eye size={20} className="text-green-500" /> <Eye size={20} className="text-green-500" />
</button> </button>
<button
onClick={() => {
setSelectedStationId(params.row.id);
setIsTransfersModalOpen(true);
}}
title="Редактировать пересадки"
>
<Route size={20} className="text-purple-500" />
</button>
<button <button
onClick={() => { onClick={() => {
setIsDeleteModalOpen(true); setIsDeleteModalOpen(true);
@@ -130,7 +148,7 @@ export const StationListPage = observer(() => {
const rows = filteredStations().map((station: any) => ({ const rows = filteredStations().map((station: any) => ({
id: station.id, id: station.id,
name: station.name, name: station.name,
system_name: station.system_name, description: station.description,
direction: station.direction, direction: station.direction,
})); }));
@@ -205,6 +223,15 @@ export const StationListPage = observer(() => {
setIsBulkDeleteModalOpen(false); setIsBulkDeleteModalOpen(false);
}} }}
/> />
<EditStationTransfersModal
open={isTransfersModalOpen}
onClose={() => {
setIsTransfersModalOpen(false);
setSelectedStationId(null);
}}
stationId={selectedStationId}
/>
</> </>
); );
}); });

View File

@@ -1,9 +1,9 @@
import { Paper } from "@mui/material"; import { Paper, Box } from "@mui/material";
import { languageStore, stationsStore } from "@shared"; import { languageStore, stationsStore, LoadingSpinner } from "@shared";
import { LanguageSwitcher } from "@widgets"; import { LanguageSwitcher } from "@widgets";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { ArrowLeft } from "lucide-react"; import { ArrowLeft } from "lucide-react";
import { useEffect } from "react"; import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { LinkedSights } from "../LinkedSights"; import { LinkedSights } from "../LinkedSights";
@@ -12,15 +12,38 @@ export const StationPreviewPage = observer(() => {
const { stationPreview, getStationPreview } = stationsStore; const { stationPreview, getStationPreview } = stationsStore;
const navigate = useNavigate(); const navigate = useNavigate();
const { language } = languageStore; const { language } = languageStore;
const [isLoadingData, setIsLoadingData] = useState(true);
useEffect(() => { useEffect(() => {
(async () => { (async () => {
if (id) { if (id) {
setIsLoadingData(true);
try {
await getStationPreview(Number(id)); await getStationPreview(Number(id));
} finally {
setIsLoadingData(false);
}
} else {
setIsLoadingData(false);
} }
})(); })();
}, [id, language]); }, [id, language]);
if (isLoadingData) {
return (
<Box
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
minHeight: "60vh",
}}
>
<LoadingSpinner message="Загрузка данных станции..." />
</Box>
);
}
return ( return (
<Paper className="w-full p-3 py-5 flex flex-col gap-10"> <Paper className="w-full p-3 py-5 flex flex-col gap-10">
<LanguageSwitcher /> <LanguageSwitcher />

View File

@@ -4,18 +4,20 @@ import {
Checkbox, Checkbox,
Paper, Paper,
TextField, TextField,
Box,
} from "@mui/material"; } from "@mui/material";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { ArrowLeft, Save } from "lucide-react"; import { ArrowLeft, Save } from "lucide-react";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { userStore, languageStore } from "@shared"; import { userStore, languageStore, LoadingSpinner } from "@shared";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
export const UserEditPage = observer(() => { export const UserEditPage = observer(() => {
const navigate = useNavigate(); const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isLoadingData, setIsLoadingData] = useState(true);
const { id } = useParams(); const { id } = useParams();
const { editUserData, editUser, getUser, setEditUserData } = userStore; const { editUserData, editUser, getUser, setEditUserData } = userStore;
@@ -41,6 +43,8 @@ export const UserEditPage = observer(() => {
useEffect(() => { useEffect(() => {
(async () => { (async () => {
if (id) { if (id) {
setIsLoadingData(true);
try {
const data = await getUser(Number(id)); const data = await getUser(Number(id));
setEditUserData( setEditUserData(
@@ -49,10 +53,30 @@ export const UserEditPage = observer(() => {
data?.password || "", data?.password || "",
data?.is_admin || false data?.is_admin || false
); );
} finally {
setIsLoadingData(false);
}
} else {
setIsLoadingData(false);
} }
})(); })();
}, [id]); }, [id]);
if (isLoadingData) {
return (
<Box
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
minHeight: "60vh",
}}
>
<LoadingSpinner message="Загрузка данных пользователя..." />
</Box>
);
}
return ( return (
<Paper 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">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">

View File

@@ -8,13 +8,10 @@ import {
Earth, Earth,
Landmark, Landmark,
GitBranch, GitBranch,
// Car,
Table, Table,
Split, Split,
// Newspaper,
PersonStanding, PersonStanding,
Cpu, Cpu,
// BookImage,
} from "lucide-react"; } from "lucide-react";
import carrierIcon from "./carrier.svg"; import carrierIcon from "./carrier.svg";
@@ -57,12 +54,6 @@ export const NAVIGATION_ITEMS: {
path: "/devices", path: "/devices",
for_admin: true, for_admin: true,
}, },
// {
// id: "vehicles",
// label: "Транспорт",
// icon: Car,
// path: "/vehicle",
// },
{ {
id: "users", id: "users",
label: "Пользователи", label: "Пользователи",
@@ -75,18 +66,6 @@ export const NAVIGATION_ITEMS: {
label: "Справочник", label: "Справочник",
icon: Table, icon: Table,
nestedItems: [ nestedItems: [
// {
// id: "media",
// label: "Медиа",
// icon: BookImage,
// path: "/media",
// },
// {
// id: "articles",
// label: "Статьи",
// icon: Newspaper,
// path: "/article",
// },
{ {
id: "attractions", id: "attractions",
label: "Достопримечательности", label: "Достопримечательности",
@@ -124,7 +103,7 @@ export const NAVIGATION_ITEMS: {
id: "carriers", id: "carriers",
label: "Перевозчики", label: "Перевозчики",
// @ts-ignore // @ts-ignore
icon: () => <img src={carrierIcon} alt="Перевозчики"/>, icon: () => <img src={carrierIcon} alt="Перевозчики" />,
path: "/carrier", path: "/carrier",
for_admin: true, for_admin: true,
}, },

View File

@@ -1,8 +1,3 @@
/**
* Утилита для управления кешем GLTF и blob URL
*/
// Динамический импорт useGLTF для избежания проблем с SSR
let useGLTF: any = null; let useGLTF: any = null;
const initializeUseGLTF = async () => { const initializeUseGLTF = async () => {
@@ -20,9 +15,6 @@ const initializeUseGLTF = async () => {
return useGLTF; return useGLTF;
}; };
/**
* Очищает кеш GLTF для конкретного URL
*/
export const clearGLTFCacheForUrl = async (url: string) => { export const clearGLTFCacheForUrl = async (url: string) => {
try { try {
const gltf = await initializeUseGLTF(); const gltf = await initializeUseGLTF();
@@ -32,9 +24,6 @@ export const clearGLTFCacheForUrl = async (url: string) => {
} catch (error) {} } catch (error) {}
}; };
/**
* Очищает весь кеш GLTF
*/
export const clearAllGLTFCache = async () => { export const clearAllGLTFCache = async () => {
try { try {
const gltf = await initializeUseGLTF(); const gltf = await initializeUseGLTF();
@@ -44,9 +33,6 @@ export const clearAllGLTFCache = async () => {
} catch (error) {} } catch (error) {}
}; };
/**
* Очищает blob URL из памяти браузера
*/
export const revokeBlobURL = (url: string) => { export const revokeBlobURL = (url: string) => {
if (url && url.startsWith("blob:")) { if (url && url.startsWith("blob:")) {
try { try {
@@ -55,27 +41,16 @@ export const revokeBlobURL = (url: string) => {
} }
}; };
/**
* Комплексная очистка: blob URL + кеш GLTF
*/
export const clearBlobAndGLTFCache = async (url: string) => { export const clearBlobAndGLTFCache = async (url: string) => {
// Сначала отзываем blob URL
revokeBlobURL(url); revokeBlobURL(url);
// Затем очищаем кеш GLTF
await clearGLTFCacheForUrl(url); await clearGLTFCacheForUrl(url);
}; };
/**
* Очистка при смене медиа (для предотвращения конфликтов)
*/
export const clearMediaTransitionCache = async ( export const clearMediaTransitionCache = async (
previousMediaId: string | number | null, previousMediaId: string | number | null,
newMediaId: string | number | null,
newMediaType?: number newMediaType?: number
) => { ) => {
console.log(newMediaId, newMediaType);
// Если переключаемся с/на 3D модель, очищаем весь кеш
if (newMediaType === 6 || previousMediaId) { if (newMediaType === 6 || previousMediaId) {
await clearAllGLTFCache(); await clearAllGLTFCache();
} }

View File

@@ -2,34 +2,17 @@ export * from "./mui/theme";
export * from "./DecodeJWT"; export * from "./DecodeJWT";
export * from "./gltfCacheManager"; export * from "./gltfCacheManager";
/**
* Генерирует название медиа по умолчанию в разных форматах
*
* Примеры использования:
* - Для достопримечательности с названием: "Михайловский замок_mikhail-zamok_Фото"
* - Для достопримечательности без названия: "Название_mikhail-zamok_Фото"
* - Для статьи: "Михайловский замок_mikhail-zamok_Факты" (где "Факты" - название статьи)
*
* @param objectName - Название объекта (достопримечательности, города и т.д.)
* @param fileName - Название файла
* @param mediaType - Тип медиа (число) или название статьи
* @param isArticle - Флаг, указывающий что медиа добавляется к статье
* @returns Строка в нужном формате
*/
export const generateDefaultMediaName = ( export const generateDefaultMediaName = (
objectName: string, objectName: string,
fileName: string, fileName: string,
mediaType: number | string, mediaType: number | string,
isArticle: boolean = false isArticle: boolean = false
): string => { ): string => {
// Убираем расширение из названия файла
const fileNameWithoutExtension = fileName.split(".").slice(0, -1).join("."); const fileNameWithoutExtension = fileName.split(".").slice(0, -1).join(".");
if (isArticle && typeof mediaType === "string") { if (isArticle && typeof mediaType === "string") {
// Для статей: "Название достопримечательности_название файлаазвание статьи"
return `${objectName}_${fileNameWithoutExtension}_${mediaType}`; return `${objectName}_${fileNameWithoutExtension}_${mediaType}`;
} else if (typeof mediaType === "number") { } else if (typeof mediaType === "number") {
// Получаем название типа медиа
const mediaTypeLabels: Record<number, string> = { const mediaTypeLabels: Record<number, string> = {
1: "Фото", 1: "Фото",
2: "Видео", 2: "Видео",
@@ -42,14 +25,11 @@ export const generateDefaultMediaName = (
const mediaTypeLabel = mediaTypeLabels[mediaType] || "Медиа"; const mediaTypeLabel = mediaTypeLabels[mediaType] || "Медиа";
if (objectName && objectName.trim() !== "") { if (objectName && objectName.trim() !== "") {
// Если есть название объекта: "Название объектаазвание файла_тип медиа"
return `${objectName}_${fileNameWithoutExtension}_${mediaTypeLabel}`; return `${objectName}_${fileNameWithoutExtension}_${mediaTypeLabel}`;
} else { } else {
// Если нет названия объекта: "Названиеазвание файла_тип медиа"
return `Название_${fileNameWithoutExtension}_${mediaTypeLabel}`; return `Название_${fileNameWithoutExtension}_${mediaTypeLabel}`;
} }
} }
// Fallback
return `${objectName || "Название"}_${fileNameWithoutExtension}_Медиа`; return `${objectName || "Название"}_${fileNameWithoutExtension}_Медиа`;
}; };

File diff suppressed because it is too large Load Diff

View File

@@ -54,7 +54,7 @@ interface UploadMediaDialogProps {
| "station"; | "station";
isArticle?: boolean; isArticle?: boolean;
articleName?: string; articleName?: string;
initialFile?: File; // <--- добавлено initialFile?: File;
} }
export const UploadMediaDialog = observer( export const UploadMediaDialog = observer(
@@ -68,7 +68,7 @@ export const UploadMediaDialog = observer(
isArticle, isArticle,
articleName, articleName,
initialFile, // <--- добавлено initialFile,
}: UploadMediaDialogProps) => { }: UploadMediaDialogProps) => {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -87,7 +87,6 @@ export const UploadMediaDialog = observer(
useEffect(() => { useEffect(() => {
if (initialFile) { if (initialFile) {
// Очищаем предыдущий blob URL если он существует
if ( if (
previousMediaUrlRef.current && previousMediaUrlRef.current &&
previousMediaUrlRef.current.startsWith("blob:") previousMediaUrlRef.current.startsWith("blob:")
@@ -106,7 +105,6 @@ export const UploadMediaDialog = observer(
} }
}, [initialFile]); }, [initialFile]);
// Очистка blob URL при размонтировании компонента
useEffect(() => { useEffect(() => {
return () => { return () => {
if ( if (
@@ -116,13 +114,13 @@ export const UploadMediaDialog = observer(
clearBlobAndGLTFCache(previousMediaUrlRef.current); clearBlobAndGLTFCache(previousMediaUrlRef.current);
} }
}; };
}, []); // Пустой массив зависимостей - выполняется только при размонтировании }, []);
useEffect(() => { useEffect(() => {
if (fileToUpload) { if (fileToUpload) {
setMediaFile(fileToUpload); setMediaFile(fileToUpload);
setMediaFilename(fileToUpload.name); setMediaFilename(fileToUpload.name);
// Try to determine media type from file extension
const extension = fileToUpload.name.split(".").pop()?.toLowerCase(); const extension = fileToUpload.name.split(".").pop()?.toLowerCase();
if (extension) { if (extension) {
if (["glb", "gltf"].includes(extension)) { if (["glb", "gltf"].includes(extension)) {
@@ -134,22 +132,18 @@ export const UploadMediaDialog = observer(
extension extension
) )
) { ) {
// Для изображений доступны все типы кроме видео setAvailableMediaTypes([1, 3, 4, 5]);
setAvailableMediaTypes([1, 3, 4, 5]); // Фото, Иконка, Водяной знак, Панорама, 3Д-модель setMediaType(1);
setMediaType(1); // По умолчанию Фото
} else if (["mp4", "webm", "mov"].includes(extension)) { } else if (["mp4", "webm", "mov"].includes(extension)) {
// Для видео только тип Видео
setAvailableMediaTypes([2]); setAvailableMediaTypes([2]);
setMediaType(2); setMediaType(2);
} }
} }
// Генерируем название по умолчанию если есть контекст
if (fileToUpload.name) { if (fileToUpload.name) {
let defaultName = ""; let defaultName = "";
if (isArticle && articleName && contextObjectName) { if (isArticle && articleName && contextObjectName) {
// Для статей: "Название достопримечательности_название файлаазвание статьи"
defaultName = generateDefaultMediaName( defaultName = generateDefaultMediaName(
contextObjectName, contextObjectName,
fileToUpload.name, fileToUpload.name,
@@ -157,10 +151,9 @@ export const UploadMediaDialog = observer(
true true
); );
} else if (contextObjectName && contextObjectName.trim() !== "") { } else if (contextObjectName && contextObjectName.trim() !== "") {
// Для обычных медиа с названием объекта
const currentMediaType = hardcodeType const currentMediaType = hardcodeType
? MEDIA_TYPE_VALUES[hardcodeType] ? MEDIA_TYPE_VALUES[hardcodeType]
: 1; // По умолчанию фото : 1;
defaultName = generateDefaultMediaName( defaultName = generateDefaultMediaName(
contextObjectName, contextObjectName,
fileToUpload.name, fileToUpload.name,
@@ -168,10 +161,9 @@ export const UploadMediaDialog = observer(
false false
); );
} else { } else {
// Для медиа без названия объекта
const currentMediaType = hardcodeType const currentMediaType = hardcodeType
? MEDIA_TYPE_VALUES[hardcodeType] ? MEDIA_TYPE_VALUES[hardcodeType]
: 1; // По умолчанию фото : 1;
defaultName = generateDefaultMediaName( defaultName = generateDefaultMediaName(
"", "",
fileToUpload.name, fileToUpload.name,
@@ -185,13 +177,11 @@ export const UploadMediaDialog = observer(
} }
}, [fileToUpload, contextObjectName, hardcodeType, isArticle, articleName]); }, [fileToUpload, contextObjectName, hardcodeType, isArticle, articleName]);
// Обновляем название при изменении типа медиа
useEffect(() => { useEffect(() => {
if (mediaFilename && mediaType > 0) { if (mediaFilename && mediaType > 0) {
let defaultName = ""; let defaultName = "";
if (isArticle && articleName && contextObjectName) { if (isArticle && articleName && contextObjectName) {
// Для статей: "Название достопримечательности_название файлаазвание статьи"
defaultName = generateDefaultMediaName( defaultName = generateDefaultMediaName(
contextObjectName, contextObjectName,
mediaFilename, mediaFilename,
@@ -199,7 +189,6 @@ export const UploadMediaDialog = observer(
true true
); );
} else if (contextObjectName && contextObjectName.trim() !== "") { } else if (contextObjectName && contextObjectName.trim() !== "") {
// Для обычных медиа с названием объекта
const currentMediaType = hardcodeType const currentMediaType = hardcodeType
? MEDIA_TYPE_VALUES[hardcodeType] ? MEDIA_TYPE_VALUES[hardcodeType]
: mediaType; : mediaType;
@@ -210,7 +199,6 @@ export const UploadMediaDialog = observer(
false false
); );
} else { } else {
// Для медиа без названия объекта
const currentMediaType = hardcodeType const currentMediaType = hardcodeType
? MEDIA_TYPE_VALUES[hardcodeType] ? MEDIA_TYPE_VALUES[hardcodeType]
: mediaType; : mediaType;
@@ -235,7 +223,6 @@ export const UploadMediaDialog = observer(
useEffect(() => { useEffect(() => {
if (mediaFile) { if (mediaFile) {
// Очищаем предыдущий blob URL и кеш GLTF если он существует
if ( if (
previousMediaUrlRef.current && previousMediaUrlRef.current &&
previousMediaUrlRef.current.startsWith("blob:") previousMediaUrlRef.current.startsWith("blob:")
@@ -245,22 +232,10 @@ export const UploadMediaDialog = observer(
const newBlobUrl = URL.createObjectURL(mediaFile as Blob); const newBlobUrl = URL.createObjectURL(mediaFile as Blob);
setMediaUrl(newBlobUrl); setMediaUrl(newBlobUrl);
previousMediaUrlRef.current = newBlobUrl; // Сохраняем новый URL в ref previousMediaUrlRef.current = newBlobUrl;
setIsPreviewLoaded(false); // Сбрасываем состояние загрузки при смене файла setIsPreviewLoaded(false);
} }
}, [mediaFile]); // Убираем mediaUrl из зависимостей чтобы избежать зацикливания }, [mediaFile]);
// const fileFormat = useEffect(() => {
// const handleKeyPress = (event: KeyboardEvent) => {
// if (event.key.toLowerCase() === "enter" && !event.ctrlKey) {
// event.preventDefault();
// onClose();
// }
// };
// window.addEventListener("keydown", handleKeyPress);
// return () => window.removeEventListener("keydown", handleKeyPress);
// }, [onClose]);
const handleSave = async () => { const handleSave = async () => {
if (!mediaFile) return; if (!mediaFile) return;
@@ -285,10 +260,10 @@ export const UploadMediaDialog = observer(
} }
} }
setSuccess(true); setSuccess(true);
// Закрываем модальное окно после успешного сохранения
setTimeout(() => { setTimeout(() => {
handleClose(); handleClose();
}, 1000); // Небольшая задержка, чтобы пользователь увидел сообщение об успехе }, 1000);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Failed to save media"); setError(err instanceof Error ? err.message : "Failed to save media");
} finally { } finally {
@@ -297,7 +272,6 @@ export const UploadMediaDialog = observer(
}; };
const handleClose = () => { const handleClose = () => {
// Очищаем blob URL и кеш GLTF при закрытии диалога
if ( if (
previousMediaUrlRef.current && previousMediaUrlRef.current &&
previousMediaUrlRef.current.startsWith("blob:") previousMediaUrlRef.current.startsWith("blob:")
@@ -310,7 +284,7 @@ export const UploadMediaDialog = observer(
setMediaUrl(null); setMediaUrl(null);
setMediaFile(null); setMediaFile(null);
setIsPreviewLoaded(false); setIsPreviewLoaded(false);
previousMediaUrlRef.current = null; // Очищаем ref previousMediaUrlRef.current = null;
onClose(); onClose();
}; };

View File

@@ -2,3 +2,4 @@ export * from "./SelectArticleDialog";
export * from "./SelectMediaDialog"; export * from "./SelectMediaDialog";
export * from "./PreviewMediaDialog"; export * from "./PreviewMediaDialog";
export * from "./UploadMediaDialog"; export * from "./UploadMediaDialog";
export * from "./ArticleSelectOrCreateDialog";

View File

@@ -171,7 +171,6 @@ class CarrierStore {
this.carriers[language].data.push(response.data); this.carriers[language].data.push(response.data);
}); });
// Create translations for other languages
for (const lang of ["ru", "en", "zh"].filter((l) => l !== language)) { for (const lang of ["ru", "en", "zh"].filter((l) => l !== language)) {
const patchPayload = { const patchPayload = {
// @ts-ignore // @ts-ignore

View File

@@ -1,4 +1,3 @@
// @shared/stores/createSightStore.ts
import { import {
articlesStore, articlesStore,
Language, Language,
@@ -27,7 +26,6 @@ type SightLanguageInfo = {
}; };
type SightCommonInfo = { type SightCommonInfo = {
// id: number; // ID is 0 until created
city_id: number; city_id: number;
city: string; city: string;
latitude: number; latitude: number;
@@ -35,13 +33,11 @@ type SightCommonInfo = {
thumbnail: string | null; thumbnail: string | null;
watermark_lu: string | null; watermark_lu: string | null;
watermark_rd: string | null; watermark_rd: string | null;
left_article: number; // Can be 0 or a real ID, or placeholder like 10000000 left_article: number;
preview_media: string | null; preview_media: string | null;
video_preview: string | null; video_preview: string | null;
}; };
// SightBaseInfo combines common info with language-specific info
// The 'id' for the sight itself will be assigned upon creation by the backend.
type SightBaseInfo = SightCommonInfo & { type SightBaseInfo = SightCommonInfo & {
[key in Language]: SightLanguageInfo; [key in Language]: SightLanguageInfo;
}; };
@@ -78,7 +74,7 @@ const initialSightState: SightBaseInfo = {
}; };
class CreateSightStore { class CreateSightStore {
sight: SightBaseInfo = JSON.parse(JSON.stringify(initialSightState)); // Deep copy for reset sight: SightBaseInfo = JSON.parse(JSON.stringify(initialSightState));
uploadMediaOpen = false; uploadMediaOpen = false;
setUploadMediaOpen = (open: boolean) => { setUploadMediaOpen = (open: boolean) => {
@@ -93,9 +89,7 @@ class CreateSightStore {
makeAutoObservable(this); makeAutoObservable(this);
} }
// --- Right Article Management ---
createNewRightArticle = async () => { createNewRightArticle = async () => {
// Create article in DB for all languages
const articleRuData = { const articleRuData = {
heading: "Новый заголовок (RU)", heading: "Новый заголовок (RU)",
body: "Новый текст (RU)", body: "Новый текст (RU)",
@@ -125,7 +119,7 @@ class CreateSightStore {
}, },
}, },
}); });
const { id } = articleRes.data; // New article's ID const { id } = articleRes.data;
runInAction(() => { runInAction(() => {
const newArticleEntry = { id, media: [] }; const newArticleEntry = { id, media: [] };
@@ -133,7 +127,7 @@ class CreateSightStore {
this.sight.en.right.push({ ...newArticleEntry, ...articleEnData }); this.sight.en.right.push({ ...newArticleEntry, ...articleEnData });
this.sight.zh.right.push({ ...newArticleEntry, ...articleZhData }); this.sight.zh.right.push({ ...newArticleEntry, ...articleZhData });
}); });
return id; // Return ID for potential immediate use return id;
} catch (error) { } catch (error) {
console.error("Error creating new right article:", error); console.error("Error creating new right article:", error);
throw error; throw error;
@@ -169,7 +163,7 @@ class CreateSightStore {
}); });
}); });
return articleId; // Return the linked article ID return articleId;
} catch (error) { } catch (error) {
console.error("Error linking existing right article:", error); console.error("Error linking existing right article:", error);
throw error; throw error;
@@ -188,9 +182,7 @@ class CreateSightStore {
} }
}; };
// "Unlink" in create mode means just removing from the list to be created with the sight
unlinkRightAritcle = (articleId: number) => { unlinkRightAritcle = (articleId: number) => {
// Changed from 'unlinkRightAritcle' spelling
runInAction(() => { runInAction(() => {
this.sight.ru.right = this.sight.ru.right.filter( this.sight.ru.right = this.sight.ru.right.filter(
(article) => article.id !== articleId (article) => article.id !== articleId
@@ -202,16 +194,12 @@ class CreateSightStore {
(article) => article.id !== articleId (article) => article.id !== articleId
); );
}); });
// Note: If this article was created via createNewRightArticle, it still exists in the DB.
// Consider if an orphaned article should be deleted here or managed separately.
// For now, it just removes it from the list associated with *this specific sight creation process*.
}; };
deleteRightArticle = async (articleId: number) => { deleteRightArticle = async (articleId: number) => {
try { try {
await authInstance.delete(`/article/${articleId}`); // Delete from backend await authInstance.delete(`/article/${articleId}`);
runInAction(() => { runInAction(() => {
// Remove from local store for all languages
this.sight.ru.right = this.sight.ru.right.filter( this.sight.ru.right = this.sight.ru.right.filter(
(article) => article.id !== articleId (article) => article.id !== articleId
); );
@@ -228,12 +216,11 @@ class CreateSightStore {
} }
}; };
// --- Right Article Media Management ---
createLinkWithRightArticle = async (media: MediaItem, articleId: number) => { createLinkWithRightArticle = async (media: MediaItem, articleId: number) => {
try { try {
await authInstance.post(`/article/${articleId}/media`, { await authInstance.post(`/article/${articleId}/media`, {
media_id: media.id, media_id: media.id,
media_order: 1, // Or calculate based on existing media.length + 1 media_order: 1,
}); });
runInAction(() => { runInAction(() => {
(["ru", "en", "zh"] as Language[]).forEach((lang) => { (["ru", "en", "zh"] as Language[]).forEach((lang) => {
@@ -242,7 +229,7 @@ class CreateSightStore {
); );
if (article) { if (article) {
if (!article.media) article.media = []; if (!article.media) article.media = [];
article.media.unshift(media); // Add to the beginning article.media.unshift(media);
} }
}); });
}); });
@@ -273,7 +260,6 @@ class CreateSightStore {
} }
}; };
// --- Left Article Management (largely unchanged from your provided store) ---
updateLeftInfo = (language: Language, heading: string, body: string) => { updateLeftInfo = (language: Language, heading: string, body: string) => {
this.sight[language].left.heading = heading; this.sight[language].left.heading = heading;
this.sight[language].left.body = body; this.sight[language].left.body = body;
@@ -323,7 +309,7 @@ class CreateSightStore {
deleteLeftArticle = async (articleId: number) => { deleteLeftArticle = async (articleId: number) => {
/* ... your existing logic ... */ /* ... your existing logic ... */
await authInstance.delete(`/article/${articleId}`); await authInstance.delete(`/article/${articleId}`);
// articlesStore.getArticles(languageStore.language); // If still neede
runInAction(() => { runInAction(() => {
articlesStore.articles.ru = articlesStore.articles.ru.filter( articlesStore.articles.ru = articlesStore.articles.ru.filter(
(article) => article.id !== articleId (article) => article.id !== articleId
@@ -340,63 +326,69 @@ class CreateSightStore {
createLeftArticle = async () => { createLeftArticle = async () => {
/* ... your existing logic to create a new left article (placeholder or DB) ... */ /* ... your existing logic to create a new left article (placeholder or DB) ... */
const ruName = (this.sight.ru.name || "").trim();
const enName = (this.sight.en.name || "").trim();
const zhName = (this.sight.zh.name || "").trim();
const hasAnyName = !!(ruName || enName || zhName);
const response = await languageInstance("ru").post("/article", { const response = await languageInstance("ru").post("/article", {
heading: "Новая левая статья", heading: hasAnyName ? ruName : "",
body: "Заполните контентом", body: "",
}); });
const newLeftArticleId = response.data.id; const newLeftArticleId = response.data.id;
await languageInstance("en").patch(`/article/${newLeftArticleId}`, { await languageInstance("en").patch(`/article/${newLeftArticleId}`, {
heading: "New Left Article", heading: hasAnyName ? enName : "",
body: "Fill with content", body: "",
}); });
await languageInstance("zh").patch(`/article/${newLeftArticleId}`, { await languageInstance("zh").patch(`/article/${newLeftArticleId}`, {
heading: "新的左侧文章", heading: hasAnyName ? zhName : "",
body: "填写内容", body: "",
}); });
runInAction(() => { runInAction(() => {
this.sight.left_article = newLeftArticleId; // Store the actual ID this.sight.left_article = newLeftArticleId;
this.sight.ru.left = { this.sight.ru.left = {
heading: "Новая левая статья", heading: hasAnyName ? ruName : "",
body: "Заполните контентом", body: "",
media: [], media: [],
}; };
this.sight.en.left = { this.sight.en.left = {
heading: "New Left Article", heading: hasAnyName ? enName : "",
body: "Fill with content", body: "",
media: [], media: [],
}; };
this.sight.zh.left = { this.sight.zh.left = {
heading: "新的左侧文章", heading: hasAnyName ? zhName : "",
body: "填写内容", body: "",
media: [], media: [],
}; };
articlesStore.articles.ru.push({ articlesStore.articles.ru.push({
id: newLeftArticleId, id: newLeftArticleId,
heading: "Новая левая статья", heading: hasAnyName ? ruName : "",
body: "Заполните контентом", body: "",
service_name: "Новая левая статья", service_name: hasAnyName ? ruName : "",
}); });
articlesStore.articles.en.push({ articlesStore.articles.en.push({
id: newLeftArticleId, id: newLeftArticleId,
heading: "New Left Article", heading: hasAnyName ? enName : "",
body: "Fill with content", body: "",
service_name: "New Left Article", service_name: hasAnyName ? enName : "",
}); });
articlesStore.articles.zh.push({ articlesStore.articles.zh.push({
id: newLeftArticleId, id: newLeftArticleId,
heading: "新的左侧文章", heading: hasAnyName ? zhName : "",
body: "填写内容", body: "",
service_name: "新的左侧文章", service_name: hasAnyName ? zhName : "",
}); });
}); });
return newLeftArticleId; return newLeftArticleId;
}; };
// Placeholder for a "new" unsaved left article
setNewLeftArticlePlaceholder = () => { setNewLeftArticlePlaceholder = () => {
this.sight.left_article = 10000000; // Special placeholder ID this.sight.left_article = 10000000;
this.sight.ru.left = { this.sight.ru.left = {
heading: "Новая левая статья", heading: "Новая левая статья",
body: "Заполните контентом", body: "Заполните контентом",
@@ -414,7 +406,6 @@ class CreateSightStore {
}; };
}; };
// --- Sight Preview Media ---
linkPreviewMedia = (mediaId: string) => { linkPreviewMedia = (mediaId: string) => {
this.sight.preview_media = mediaId; this.sight.preview_media = mediaId;
}; };
@@ -423,32 +414,27 @@ class CreateSightStore {
this.sight.preview_media = null; this.sight.preview_media = null;
}; };
// --- General Store Methods ---
clearCreateSight = () => { clearCreateSight = () => {
this.needLeaveAgree = false; this.needLeaveAgree = false;
this.sight = JSON.parse(JSON.stringify(initialSightState)); // Reset to initial this.sight = JSON.parse(JSON.stringify(initialSightState));
}; };
updateSightInfo = ( updateSightInfo = (
content: Partial<SightLanguageInfo | SightCommonInfo>, // Corrected types content: Partial<SightLanguageInfo | SightCommonInfo>,
language?: Language language?: Language
) => { ) => {
this.needLeaveAgree = true; this.needLeaveAgree = true;
if (language) { if (language) {
this.sight[language] = { ...this.sight[language], ...content }; this.sight[language] = { ...this.sight[language], ...content };
} else { } else {
// Assuming content here is for SightCommonInfo
this.sight = { ...this.sight, ...(content as Partial<SightCommonInfo>) }; this.sight = { ...this.sight, ...(content as Partial<SightCommonInfo>) };
} }
}; };
// --- Main Sight Creation Logic ---
createSight = async (primaryLanguage: Language) => { createSight = async (primaryLanguage: Language) => {
let finalLeftArticleId = this.sight.left_article; let finalLeftArticleId = this.sight.left_article;
// 1. Handle Left Article (Create if new, or use existing ID)
if (this.sight.left_article === 10000000) { if (this.sight.left_article === 10000000) {
// Placeholder for new
const res = await languageInstance("ru").post("/article", { const res = await languageInstance("ru").post("/article", {
heading: this.sight.ru.left.heading, heading: this.sight.ru.left.heading,
body: this.sight.ru.left.body, body: this.sight.ru.left.body,
@@ -466,7 +452,6 @@ class CreateSightStore {
this.sight.left_article !== 0 && this.sight.left_article !== 0 &&
this.sight.left_article !== null this.sight.left_article !== null
) { ) {
// Existing, ensure it's up-to-date
await languageInstance("ru").patch( await languageInstance("ru").patch(
`/article/${this.sight.left_article}`, `/article/${this.sight.left_article}`,
{ heading: this.sight.ru.left.heading, body: this.sight.ru.left.body } { heading: this.sight.ru.left.heading, body: this.sight.ru.left.body }
@@ -480,10 +465,7 @@ class CreateSightStore {
{ heading: this.sight.zh.left.heading, body: this.sight.zh.left.body } { heading: this.sight.zh.left.heading, body: this.sight.zh.left.body }
); );
} }
// else: left_article is 0, so no left article
// 2. Right articles are already created in DB and their IDs are in this.sight[lang].right.
// We just need to update their content if changed before saving the sight.
for (const lang of ["ru", "en", "zh"] as Language[]) { for (const lang of ["ru", "en", "zh"] as Language[]) {
for (const article of this.sight[lang].right) { for (const article of this.sight[lang].right) {
if (article.id == 0 || article.id == null) { if (article.id == 0 || article.id == null) {
@@ -493,14 +475,12 @@ class CreateSightStore {
heading: article.heading, heading: article.heading,
body: article.body, body: article.body,
}); });
// Media for these articles are already linked via createLinkWithRightArticle
} }
} }
const rightArticleIdsForLink = this.sight[primaryLanguage].right.map( const rightArticleIdsForLink = this.sight[primaryLanguage].right.map(
(a) => a.id (a) => a.id
); );
// 3. Create Sight object in DB
const sightPayload = { const sightPayload = {
city_id: this.sight.city_id, city_id: this.sight.city_id,
city: this.sight.city, city: this.sight.city,
@@ -520,9 +500,8 @@ class CreateSightStore {
"/sight", "/sight",
sightPayload sightPayload
); );
const newSightId = response.data.id; // ID of the newly created sight const newSightId = response.data.id;
// 4. Update other languages for the sight
const otherLanguages = (["ru", "en", "zh"] as Language[]).filter( const otherLanguages = (["ru", "en", "zh"] as Language[]).filter(
(l) => l !== primaryLanguage (l) => l !== primaryLanguage
); );
@@ -543,20 +522,17 @@ class CreateSightStore {
}); });
} }
// 5. Link Right Articles to the new Sight
for (let i = 0; i < rightArticleIdsForLink.length; i++) { for (let i = 0; i < rightArticleIdsForLink.length; i++) {
await authInstance.post(`/sight/${newSightId}/article`, { await authInstance.post(`/sight/${newSightId}/article`, {
article_id: rightArticleIdsForLink[i], article_id: rightArticleIdsForLink[i],
page_num: i + 1, // Or other logic for page_num page_num: i + 1,
}); });
} }
// Optionally: this.clearCreateSight(); // To reset form after successful creation
this.needLeaveAgree = false; this.needLeaveAgree = false;
return newSightId; return newSightId;
}; };
// --- Media Upload (Generic, used by dialogs) ---
uploadMedia = async ( uploadMedia = async (
filename: string, filename: string,
type: number, type: number,
@@ -575,12 +551,12 @@ class CreateSightStore {
this.fileToUpload = null; this.fileToUpload = null;
this.uploadMediaOpen = false; this.uploadMediaOpen = false;
}); });
mediaStore.getMedia(); // Refresh global media list mediaStore.getMedia();
return { return {
id: response.data.id, id: response.data.id,
filename: filename, // Or response.data.filename if backend returns it filename: filename,
media_name: media_name, // Or response.data.media_name media_name: media_name,
media_type: type, // Or response.data.type media_type: type,
}; };
} catch (error) { } catch (error) {
console.error("Error uploading media:", error); console.error("Error uploading media:", error);
@@ -588,15 +564,12 @@ class CreateSightStore {
} }
}; };
// For Left Article Media
createLinkWithLeftArticle = async (media: MediaItem) => { createLinkWithLeftArticle = async (media: MediaItem) => {
if (!this.sight.left_article || this.sight.left_article === 10000000) { if (!this.sight.left_article || this.sight.left_article === 10000000) {
console.warn( console.warn(
"Left article not selected or is a placeholder. Cannot link media yet." "Left article not selected or is a placeholder. Cannot link media yet."
); );
// If it's a placeholder, we could store the media temporarily and link it after the article is created.
// For simplicity, we'll assume the article must exist.
// A more robust solution might involve creating the article first if it's a placeholder.
return; return;
} }
try { try {
@@ -655,7 +628,7 @@ class CreateSightStore {
this.sight.ru.right = sortArticles(this.sight.ru.right); this.sight.ru.right = sortArticles(this.sight.ru.right);
this.sight.en.right = sortArticles(this.sight.en.right); this.sight.en.right = sortArticles(this.sight.en.right);
this.sight.zh.right = sortArticles(this.sight.zh.right); // теперь zh тоже сортируется одинаково this.sight.zh.right = sortArticles(this.sight.zh.right);
this.needLeaveAgree = true; this.needLeaveAgree = true;
}; };

View File

@@ -1,4 +1,3 @@
// @shared/stores/editSightStore.ts
import { import {
articlesStore, articlesStore,
authInstance, authInstance,
@@ -87,7 +86,11 @@ class EditSightStore {
} }
hasLoadedCommon = false; hasLoadedCommon = false;
isLoading = false;
getSightInfo = async (id: number, language: Language) => { getSightInfo = async (id: number, language: Language) => {
this.isLoading = true;
try {
const response = await languageInstance(language).get(`/sight/${id}`); const response = await languageInstance(language).get(`/sight/${id}`);
const data = response.data; const data = response.data;
@@ -96,13 +99,11 @@ class EditSightStore {
} }
runInAction(() => { runInAction(() => {
// Обновляем языковую часть
this.sight[language] = { this.sight[language] = {
...this.sight[language], ...this.sight[language],
...data, ...data,
}; };
// Только при первом запросе обновляем общую часть
if (!this.hasLoadedCommon) { if (!this.hasLoadedCommon) {
this.sight.common = { this.sight.common = {
...this.sight.common, ...this.sight.common,
@@ -111,6 +112,9 @@ class EditSightStore {
this.hasLoadedCommon = true; this.hasLoadedCommon = true;
} }
}); });
} finally {
this.isLoading = false;
}
}; };
updateLeftInfo = (language: Language, heading: string, body: string) => { updateLeftInfo = (language: Language, heading: string, body: string) => {
@@ -123,7 +127,6 @@ class EditSightStore {
let responseEn = await languageInstance("en").get(`/sight/${id}/article`); let responseEn = await languageInstance("en").get(`/sight/${id}/article`);
let responseZh = await languageInstance("zh").get(`/sight/${id}/article`); let responseZh = await languageInstance("zh").get(`/sight/${id}/article`);
// Create a map of article IDs to their media
const mediaMap = new Map(); const mediaMap = new Map();
for (const article of responseRu.data) { for (const article of responseRu.data) {
const responseMedia = await authInstance.get( const responseMedia = await authInstance.get(
@@ -132,7 +135,6 @@ class EditSightStore {
mediaMap.set(article.id, responseMedia.data); mediaMap.set(article.id, responseMedia.data);
} }
// Function to add media to articles
const addMediaToArticles = (articles: any[]) => { const addMediaToArticles = (articles: any[]) => {
return articles.map((article) => ({ return articles.map((article) => ({
...article, ...article,
@@ -173,6 +175,8 @@ class EditSightStore {
clearSightInfo = () => { clearSightInfo = () => {
this.needLeaveAgree = false; this.needLeaveAgree = false;
this.hasLoadedCommon = false;
this.isLoading = false;
this.sight = { this.sight = {
common: { common: {
id: 0, id: 0,
@@ -327,28 +331,6 @@ class EditSightStore {
articles: articleIdsInObject, articles: articleIdsInObject,
}); });
// await languageInstance("ru").patch(
// `/sight/${this.sight.common.left_article}/article`,
// {
// heading: this.sight.ru.left.heading,
// body: this.sight.ru.left.body,
// }
// );
// await languageInstance("en").patch(
// `/sight/${this.sight.common.left_article}/article`,
// {
// heading: this.sight.en.left.heading,
// body: this.sight.en.left.body,
// }
// );
// await languageInstance("zh").patch(
// `/sight/${this.sight.common.left_article}/article`,
// {
// heading: this.sight.zh.left.heading,
// body: this.sight.zh.left.body,
// }
// );
this.needLeaveAgree = false; this.needLeaveAgree = false;
}; };
@@ -400,16 +382,36 @@ class EditSightStore {
}; };
createLeftArticle = async () => { createLeftArticle = async () => {
const ruName = (this.sight.ru.name || "").trim();
const enName = (this.sight.en.name || "").trim();
const zhName = (this.sight.zh.name || "").trim();
const hasAnyName = !!(ruName || enName || zhName);
const response = await languageInstance("ru").post(`/article`, { const response = await languageInstance("ru").post(`/article`, {
heading: "", heading: hasAnyName ? ruName : "",
body: "", body: "",
}); });
this.sight.common.left_article = response.data.id; this.sight.common.left_article = response.data.id;
this.sight.ru.left.heading = ""; await languageInstance("en").patch(
this.sight.en.left.heading = ""; `/article/${this.sight.common.left_article}`,
this.sight.zh.left.heading = ""; {
heading: hasAnyName ? enName : "",
body: "",
}
);
await languageInstance("zh").patch(
`/article/${this.sight.common.left_article}`,
{
heading: hasAnyName ? zhName : "",
body: "",
}
);
this.sight.ru.left.heading = hasAnyName ? ruName : "";
this.sight.en.left.heading = hasAnyName ? enName : "";
this.sight.zh.left.heading = hasAnyName ? zhName : "";
this.sight.ru.left.body = ""; this.sight.ru.left.body = "";
this.sight.en.left.body = ""; this.sight.en.left.body = "";
this.sight.zh.left.body = ""; this.sight.zh.left.body = "";
@@ -486,18 +488,19 @@ class EditSightStore {
formData.append("media_name", media_name); formData.append("media_name", media_name);
} }
formData.append("type", type.toString()); formData.append("type", type.toString());
try {
const response = await authInstance.post(`/media`, formData); const response = await authInstance.post(`/media`, formData);
this.fileToUpload = null; this.fileToUpload = null;
this.uploadMediaOpen = false; this.uploadMediaOpen = false;
mediaStore.getMedia(); mediaStore.getMedia();
return { return {
id: response.data.id, id: response.data.id,
filename: filename, filename: filename,
media_name: media_name, media_name: media_name,
media_type: type, media_type: type,
}; };
} catch (error) {}
}; };
createLinkWithArticle = async (media: { createLinkWithArticle = async (media: {
@@ -569,7 +572,7 @@ class EditSightStore {
}); });
}); });
return article_id; // Return the linked article ID return article_id;
}; };
deleteRightArticleMedia = async (article_id: number, media_id: string) => { deleteRightArticleMedia = async (article_id: number, media_id: string) => {
@@ -675,7 +678,7 @@ class EditSightStore {
}); });
}); });
return id; // Return the ID of the newly created article return id;
}; };
createLinkWithRightArticle = async ( createLinkWithRightArticle = async (
@@ -750,7 +753,7 @@ class EditSightStore {
this.sight.ru.right = sortArticles(this.sight.ru.right); this.sight.ru.right = sortArticles(this.sight.ru.right);
this.sight.en.right = sortArticles(this.sight.en.right); this.sight.en.right = sortArticles(this.sight.en.right);
this.sight.zh.right = sortArticles(this.sight.zh.right); // теперь zh тоже сортируется одинаково this.sight.zh.right = sortArticles(this.sight.zh.right);
this.needLeaveAgree = true; this.needLeaveAgree = true;
}; };

View File

@@ -6,10 +6,24 @@ class LanguageStore {
constructor() { constructor() {
makeAutoObservable(this); makeAutoObservable(this);
if (typeof window !== "undefined") {
const storedLanguage = window.localStorage.getItem("appLanguage");
if (
storedLanguage &&
["ru", "en", "zh"].includes(storedLanguage.toLowerCase())
) {
this.language = storedLanguage.toLowerCase() as Language;
}
}
} }
setLanguage = (language: Language) => { setLanguage = (language: Language) => {
this.language = language; this.language = language;
if (typeof window !== "undefined") {
window.localStorage.setItem("appLanguage", language);
}
}; };
} }

View File

@@ -39,12 +39,11 @@ class MediaStore {
updateMedia = async (id: string, data: Partial<Media>) => { updateMedia = async (id: string, data: Partial<Media>) => {
const response = await authInstance.patch(`/media/${id}`, data); const response = await authInstance.patch(`/media/${id}`, data);
runInAction(() => { runInAction(() => {
// Update in media array
const index = this.media.findIndex((m) => m.id === id); const index = this.media.findIndex((m) => m.id === id);
if (index !== -1) { if (index !== -1) {
this.media[index] = { ...this.media[index], ...response.data }; this.media[index] = { ...this.media[index], ...response.data };
} }
// Update oneMedia if it's the current media being viewed
if (this.oneMedia?.id === id) { if (this.oneMedia?.id === id) {
this.oneMedia = { ...this.oneMedia, ...response.data }; this.oneMedia = { ...this.oneMedia, ...response.data };
} }
@@ -64,12 +63,11 @@ class MediaStore {
}); });
runInAction(() => { runInAction(() => {
// Update in media array
const index = this.media.findIndex((m) => m.id === id); const index = this.media.findIndex((m) => m.id === id);
if (index !== -1) { if (index !== -1) {
this.media[index] = { ...this.media[index], ...response.data }; this.media[index] = { ...this.media[index], ...response.data };
} }
// Update oneMedia if it's the current media being viewed
if (this.oneMedia?.id === id) { if (this.oneMedia?.id === id) {
this.oneMedia = { ...this.oneMedia, ...response.data }; this.oneMedia = { ...this.oneMedia, ...response.data };
} }

View File

@@ -15,7 +15,6 @@ class ModelLoadingStore {
makeAutoObservable(this); makeAutoObservable(this);
} }
// Начать отслеживание загрузки модели
startLoading(modelId: string) { startLoading(modelId: string) {
this.loadingStates.set(modelId, { this.loadingStates.set(modelId, {
isLoading: true, isLoading: true,
@@ -25,7 +24,6 @@ class ModelLoadingStore {
}); });
} }
// Обновить прогресс загрузки
updateProgress(modelId: string, progress: number) { updateProgress(modelId: string, progress: number) {
const state = this.loadingStates.get(modelId); const state = this.loadingStates.get(modelId);
if (state) { if (state) {
@@ -33,7 +31,6 @@ class ModelLoadingStore {
} }
} }
// Завершить загрузку модели
finishLoading(modelId: string) { finishLoading(modelId: string) {
const state = this.loadingStates.get(modelId); const state = this.loadingStates.get(modelId);
if (state) { if (state) {
@@ -42,12 +39,10 @@ class ModelLoadingStore {
} }
} }
// Остановить загрузку (в случае ошибки)
stopLoading(modelId: string) { stopLoading(modelId: string) {
this.loadingStates.delete(modelId); this.loadingStates.delete(modelId);
} }
// Обработать ошибку загрузки
handleError(modelId: string, error?: string) { handleError(modelId: string, error?: string) {
const state = this.loadingStates.get(modelId); const state = this.loadingStates.get(modelId);
if (state) { if (state) {
@@ -56,26 +51,22 @@ class ModelLoadingStore {
} }
} }
// Получить состояние загрузки для конкретной модели
getLoadingState(modelId: string): ModelLoadingState | undefined { getLoadingState(modelId: string): ModelLoadingState | undefined {
return this.loadingStates.get(modelId); return this.loadingStates.get(modelId);
} }
// Проверить, загружается ли какая-либо модель
get isAnyModelLoading(): boolean { get isAnyModelLoading(): boolean {
return Array.from(this.loadingStates.values()).some( return Array.from(this.loadingStates.values()).some(
(state) => state.isLoading (state) => state.isLoading
); );
} }
// Получить все загружающиеся модели
get loadingModels(): ModelLoadingState[] { get loadingModels(): ModelLoadingState[] {
return Array.from(this.loadingStates.values()).filter( return Array.from(this.loadingStates.values()).filter(
(state) => state.isLoading (state) => state.isLoading
); );
} }
// Получить общий прогресс всех загружающихся моделей
get overallProgress(): number { get overallProgress(): number {
const loadingModels = this.loadingModels; const loadingModels = this.loadingModels;
if (loadingModels.length === 0) return 100; if (loadingModels.length === 0) return 100;
@@ -87,12 +78,10 @@ class ModelLoadingStore {
return Math.round(totalProgress / loadingModels.length); return Math.round(totalProgress / loadingModels.length);
} }
// Проверить, заблокировано ли сохранение (есть ли загружающиеся модели)
get isSaveBlocked(): boolean { get isSaveBlocked(): boolean {
return this.isAnyModelLoading; return this.isAnyModelLoading;
} }
// Очистить все состояния загрузки
clearAll() { clearAll() {
this.loadingStates.clear(); this.loadingStates.clear();
} }

View File

@@ -1,7 +1,8 @@
import { makeAutoObservable, runInAction } from "mobx"; import { makeAutoObservable, runInAction } from "mobx";
import { authInstance } from "@shared"; import { authInstance, languageInstance, languageStore } from "@shared";
export type Route = { export type Route = {
route_name: string;
carrier: string; carrier: string;
carrier_id: number; carrier_id: number;
center_latitude: number; center_latitude: number;
@@ -88,15 +89,48 @@ class RouteStore {
}; };
saveRouteStations = async (routeId: number, stationId: number) => { saveRouteStations = async (routeId: number, stationId: number) => {
await authInstance.patch(`/route/${routeId}/station`, { const { language } = languageStore;
...this.routeStations[routeId]?.find(
// Получаем актуальные данные станции с сервера
const stationResponse = await languageInstance(language).get(
`/station/${stationId}`
);
const fullStationData = stationResponse.data;
// Получаем отредактированные данные из локального кеша
const editedStationData = this.routeStations[routeId]?.find(
(station) => station.id === stationId (station) => station.id === stationId
), );
// Формируем данные для отправки: все поля станции + отредактированные offset
const dataToSend: any = {
station_id: stationId, station_id: stationId,
offset_x: editedStationData?.offset_x ?? fullStationData.offset_x ?? 0,
offset_y: editedStationData?.offset_y ?? fullStationData.offset_y ?? 0,
align: editedStationData?.align ?? fullStationData.align ?? 0,
transfers: fullStationData.transfers || {},
};
await authInstance.patch(`/route/${routeId}/station`, dataToSend);
// Обновляем локальный кеш после успешного сохранения
runInAction(() => {
if (this.routeStations[routeId]) {
this.routeStations[routeId] = this.routeStations[routeId].map(
(station) =>
station.id === stationId
? {
...station,
...dataToSend,
}
: station
);
}
}); });
}; };
editRouteData = { editRouteData = {
route_name: "",
carrier: "", carrier: "",
carrier_id: 0, carrier_id: 0,
center_latitude: "", center_latitude: "",
@@ -110,7 +144,7 @@ class RouteStore {
route_sys_number: "", route_sys_number: "",
scale_max: 0, scale_max: 0,
scale_min: 0, scale_min: 0,
video_preview: "", video_preview: "" as string | undefined,
}; };
setEditRouteData = (data: any) => { setEditRouteData = (data: any) => {
@@ -118,11 +152,21 @@ class RouteStore {
}; };
editRoute = async (id: number) => { editRoute = async (id: number) => {
const response = await authInstance.patch(`/route/${id}`, { if (!this.editRouteData.video_preview) {
delete this.editRouteData.video_preview;
}
const dataToSend: any = {
...this.editRouteData, ...this.editRouteData,
center_latitude: parseFloat(this.editRouteData.center_latitude), center_latitude: parseFloat(this.editRouteData.center_latitude),
center_longitude: parseFloat(this.editRouteData.center_longitude), center_longitude: parseFloat(this.editRouteData.center_longitude),
}); };
if (
this.editRouteData.governor_appeal === 0 ||
!this.editRouteData.governor_appeal
) {
dataToSend.governor_appeal = null;
}
const response = await authInstance.patch(`/route/${id}`, dataToSend);
runInAction(() => { runInAction(() => {
this.route[id] = response.data; this.route[id] = response.data;

View File

@@ -58,41 +58,6 @@ class SightsStore {
}); });
}; };
// getSight = async (id: number) => {
// const response = await authInstance.get(`/sight/${id}`);
// runInAction(() => {
// this.sight = response.data;
// editSightStore.sightInfo = {
// ...editSightStore.sightInfo,
// id: response.data.id,
// city_id: response.data.city_id,
// city: response.data.city,
// latitude: response.data.latitude,
// longitude: response.data.longitude,
// thumbnail: response.data.thumbnail,
// watermark_lu: response.data.watermark_lu,
// watermark_rd: response.data.watermark_rd,
// left_article: response.data.left_article,
// preview_media: response.data.preview_media,
// video_preview: response.data.video_preview,
// [languageStore.language]: {
// info: {
// name: response.data.name,
// address: response.data.address,
// },
// left: {
// heading: articlesStore.articles[languageStore.language].find(
// (article) => article.id === response.data.left_article
// )?.heading,
// body: articlesStore.articles[languageStore.language].find(
// },
// },
// };
// });
// };
createSightAction = async ( createSightAction = async (
city: number, city: number,
coordinates: { latitude: number; longitude: number } coordinates: { latitude: number; longitude: number }

View File

@@ -1,8 +1,6 @@
import { authInstance } from "@shared"; import { authInstance } from "@shared";
import { v4 as uuidv4 } from "uuid";
import { makeAutoObservable, runInAction } from "mobx"; import { makeAutoObservable, runInAction } from "mobx";
// Импорт функции сброса кешей карты
// import { clearMapCaches } from "../../pages/MapPage";
import { import {
articlesStore, articlesStore,
cityStore, cityStore,
@@ -27,17 +25,24 @@ type Snapshot = {
CreationTime: string; CreationTime: string;
}; };
type SnapshotStatus = {
ID: string;
Status: string;
Progress: number;
Error: string;
};
class SnapshotStore { class SnapshotStore {
snapshots: Snapshot[] = []; snapshots: Snapshot[] = [];
snapshot: Snapshot | null = null; snapshot: Snapshot | null = null;
lastRequestId: string | null = null;
snapshotStatus: SnapshotStatus | null = null;
constructor() { constructor() {
makeAutoObservable(this); makeAutoObservable(this);
} }
// Функция для сброса всех кешей в приложении
private clearAllCaches = () => { private clearAllCaches = () => {
// Сброс кешей статей
articlesStore.articleList = { articlesStore.articleList = {
ru: { data: [], loaded: false }, ru: { data: [], loaded: false },
en: { data: [], loaded: false }, en: { data: [], loaded: false },
@@ -47,7 +52,6 @@ class SnapshotStore {
articlesStore.articleData = null; articlesStore.articleData = null;
articlesStore.articleMedia = null; articlesStore.articleMedia = null;
// Сброс кешей городов
cityStore.cities = { cityStore.cities = {
ru: { data: [], loaded: false }, ru: { data: [], loaded: false },
en: { data: [], loaded: false }, en: { data: [], loaded: false },
@@ -56,21 +60,18 @@ class SnapshotStore {
cityStore.ruCities = { data: [], loaded: false }; cityStore.ruCities = { data: [], loaded: false };
cityStore.city = {}; cityStore.city = {};
// Сброс кешей стран
countryStore.countries = { countryStore.countries = {
ru: { data: [], loaded: false }, ru: { data: [], loaded: false },
en: { data: [], loaded: false }, en: { data: [], loaded: false },
zh: { data: [], loaded: false }, zh: { data: [], loaded: false },
}; };
// Сброс кешей перевозчиков
carrierStore.carriers = { carrierStore.carriers = {
ru: { data: [], loaded: false }, ru: { data: [], loaded: false },
en: { data: [], loaded: false }, en: { data: [], loaded: false },
zh: { data: [], loaded: false }, zh: { data: [], loaded: false },
}; };
// Сброс кешей станций
stationsStore.stationLists = { stationsStore.stationLists = {
ru: { data: [], loaded: false }, ru: { data: [], loaded: false },
en: { data: [], loaded: false }, en: { data: [], loaded: false },
@@ -78,24 +79,18 @@ class SnapshotStore {
}; };
stationsStore.stationPreview = {}; stationsStore.stationPreview = {};
// Сброс кешей достопримечательностей
sightsStore.sights = []; sightsStore.sights = [];
sightsStore.sight = null; sightsStore.sight = null;
// Сброс кешей маршрутов
routeStore.routes = { data: [], loaded: false }; routeStore.routes = { data: [], loaded: false };
// Сброс кешей транспорта
vehicleStore.vehicles = { data: [], loaded: false }; vehicleStore.vehicles = { data: [], loaded: false };
// Сброс кешей пользователей
userStore.users = { data: [], loaded: false }; userStore.users = { data: [], loaded: false };
// Сброс кешей медиа
mediaStore.media = []; mediaStore.media = [];
mediaStore.oneMedia = null; mediaStore.oneMedia = null;
// Сброс кешей создания и редактирования достопримечательностей
createSightStore.sight = JSON.parse( createSightStore.sight = JSON.parse(
JSON.stringify({ JSON.stringify({
city_id: 0, city_id: 0,
@@ -173,26 +168,21 @@ class SnapshotStore {
editSightStore.fileToUpload = null; editSightStore.fileToUpload = null;
editSightStore.needLeaveAgree = false; editSightStore.needLeaveAgree = false;
// Сброс кешей устройств
devicesStore.devices = []; devicesStore.devices = [];
devicesStore.uuid = null; devicesStore.uuid = null;
devicesStore.sendSnapshotModalOpen = false; devicesStore.sendSnapshotModalOpen = false;
// Сброс кешей авторизации (кроме токена)
authStore.payload = null; authStore.payload = null;
authStore.error = null; authStore.error = null;
authStore.isLoading = false; authStore.isLoading = false;
// Сброс кешей карты (если они загружены)
try { try {
// Сбрасываем кеши mapStore если он доступен
if (typeof window !== "undefined" && (window as any).mapStore) { if (typeof window !== "undefined" && (window as any).mapStore) {
(window as any).mapStore.routes = []; (window as any).mapStore.routes = [];
(window as any).mapStore.stations = []; (window as any).mapStore.stations = [];
(window as any).mapStore.sights = []; (window as any).mapStore.sights = [];
} }
// Сбрасываем кеши MapService если он доступен
if (typeof window !== "undefined" && (window as any).mapServiceInstance) { if (typeof window !== "undefined" && (window as any).mapServiceInstance) {
(window as any).mapServiceInstance.clearCaches(); (window as any).mapServiceInstance.clearCaches();
} }
@@ -200,7 +190,6 @@ class SnapshotStore {
console.warn("Не удалось сбросить кеши карты:", error); console.warn("Не удалось сбросить кеши карты:", error);
} }
// Сброс localStorage кешей (кроме токена авторизации)
const token = localStorage.getItem("token"); const token = localStorage.getItem("token");
const rememberedEmail = localStorage.getItem("rememberedEmail"); const rememberedEmail = localStorage.getItem("rememberedEmail");
const rememberedPassword = localStorage.getItem("rememberedPassword"); const rememberedPassword = localStorage.getItem("rememberedPassword");
@@ -208,14 +197,12 @@ class SnapshotStore {
localStorage.clear(); localStorage.clear();
sessionStorage.clear(); sessionStorage.clear();
// Восстанавливаем важные данные
if (token) localStorage.setItem("token", token); if (token) localStorage.setItem("token", token);
if (rememberedEmail) if (rememberedEmail)
localStorage.setItem("rememberedEmail", rememberedEmail); localStorage.setItem("rememberedEmail", rememberedEmail);
if (rememberedPassword) if (rememberedPassword)
localStorage.setItem("rememberedPassword", rememberedPassword); localStorage.setItem("rememberedPassword", rememberedPassword);
// Сброс кешей карты (если они есть)
const mapPositionKey = "mapPosition"; const mapPositionKey = "mapPosition";
const activeSectionKey = "mapActiveSection"; const activeSectionKey = "mapActiveSection";
if (localStorage.getItem(mapPositionKey)) { if (localStorage.getItem(mapPositionKey)) {
@@ -225,7 +212,6 @@ class SnapshotStore {
localStorage.removeItem(activeSectionKey); localStorage.removeItem(activeSectionKey);
} }
// Попытка очистить кеш браузера (если поддерживается)
if ("caches" in window) { if ("caches" in window) {
try { try {
caches.keys().then((cacheNames) => { caches.keys().then((cacheNames) => {
@@ -240,7 +226,6 @@ class SnapshotStore {
} }
} }
// Попытка очистить IndexedDB (если поддерживается)
if ("indexedDB" in window) { if ("indexedDB" in window) {
try { try {
indexedDB.databases().then((databases) => { indexedDB.databases().then((databases) => {
@@ -284,15 +269,29 @@ class SnapshotStore {
}; };
restoreSnapshot = async (id: string) => { restoreSnapshot = async (id: string) => {
// Сначала сбрасываем все кеши
this.clearAllCaches(); this.clearAllCaches();
// Затем восстанавливаем снапшот
await authInstance.post(`/snapshots/${id}/restore`); await authInstance.post(`/snapshots/${id}/restore`);
}; };
createSnapshot = async (name: string) => { createSnapshot = async (name: string) => {
await authInstance.post(`/snapshots`, { name }); this.lastRequestId = uuidv4();
const response = await authInstance.post(
`/snapshots`,
{ name },
{ headers: { "X-Request-ID": this.lastRequestId } }
);
return response.data.ID;
};
getSnapshotStatus = async (id: string) => {
const response = await authInstance.get(`/snapshots/status/${id}`);
runInAction(() => {
this.snapshotStatus = response.data;
});
}; };
} }

View File

@@ -1,5 +1,6 @@
import { authInstance, languageInstance, languageStore } from "@shared"; import { authInstance, languageInstance, languageStore } from "@shared";
import { makeAutoObservable, runInAction } from "mobx"; import { makeAutoObservable, runInAction } from "mobx";
import { routeStore } from "../RouteStore";
type Language = "ru" | "en" | "zh"; type Language = "ru" | "en" | "zh";
@@ -7,7 +8,7 @@ type StationLanguageData = {
name: string; name: string;
system_name: string; system_name: string;
address: string; address: string;
loaded: boolean; // Indicates if this language's data has been loaded/modified loaded: boolean;
}; };
type StationCommonData = { type StationCommonData = {
@@ -92,7 +93,6 @@ class StationsStore {
}, },
}; };
// This will store the full station data, keyed by ID and then by language
stationPreview: Record< stationPreview: Record<
string, string,
Record<string, { loaded: boolean; data: Station }> Record<string, { loaded: boolean; data: Station }>
@@ -264,7 +264,6 @@ class StationsStore {
}; };
}; };
// Sets language-specific station data
setLanguageEditStationData = ( setLanguageEditStationData = (
language: Language, language: Language,
data: Partial<StationLanguageData> data: Partial<StationLanguageData>
@@ -295,7 +294,7 @@ class StationsStore {
`/station/${id}`, `/station/${id}`,
{ {
name: name || "", name: name || "",
system_name: name || "", // system_name is often derived from name system_name: name || "",
description: description || "", description: description || "",
address: address || "", address: address || "",
...commonDataPayload, ...commonDataPayload,
@@ -303,7 +302,6 @@ class StationsStore {
); );
runInAction(() => { runInAction(() => {
// Update the cached preview data and station lists after successful patch
if (this.stationPreview[id]) { if (this.stationPreview[id]) {
this.stationPreview[id][language] = { this.stationPreview[id][language] = {
loaded: true, loaded: true,
@@ -343,11 +341,11 @@ class StationsStore {
runInAction(() => { runInAction(() => {
this.stations = this.stations.filter((station) => station.id !== id); this.stations = this.stations.filter((station) => station.id !== id);
// Also clear from stationPreview cache
if (this.stationPreview[id]) { if (this.stationPreview[id]) {
delete this.stationPreview[id]; delete this.stationPreview[id];
} }
// Clear from stationLists as well for all languages
for (const lang of ["ru", "en", "zh"] as const) { for (const lang of ["ru", "en", "zh"] as const) {
if (this.stationLists[lang].data) { if (this.stationLists[lang].data) {
this.stationLists[lang].data = this.stationLists[lang].data.filter( this.stationLists[lang].data = this.stationLists[lang].data.filter(
@@ -421,12 +419,11 @@ class StationsStore {
delete commonDataPayload.icon; delete commonDataPayload.icon;
} }
// First create station in Russian
const { name, address } = this.createStationData[language]; const { name, address } = this.createStationData[language];
const description = this.createStationData.common.description; const description = this.createStationData.common.description;
const response = await languageInstance(language).post("/station", { const response = await languageInstance(language).post("/station", {
name: name || "", name: name || "",
system_name: name || "", // system_name is often derived from name system_name: name || "",
description: description || "", description: description || "",
address: address || "", address: address || "",
...commonDataPayload, ...commonDataPayload,
@@ -438,7 +435,6 @@ class StationsStore {
const stationId = response.data.id; const stationId = response.data.id;
// Then update for other languages
for (const lang of ["ru", "en", "zh"].filter( for (const lang of ["ru", "en", "zh"].filter(
(lang) => lang !== language (lang) => lang !== language
) as Language[]) { ) as Language[]) {
@@ -448,7 +444,7 @@ class StationsStore {
`/station/${stationId}`, `/station/${stationId}`,
{ {
name: name || "", name: name || "",
system_name: name || "", // system_name is often derived from name system_name: name || "",
description: description || "", description: description || "",
address: address || "", address: address || "",
...commonDataPayload, ...commonDataPayload,
@@ -507,7 +503,6 @@ class StationsStore {
return response.data; return response.data;
}; };
// Reset editStationData when navigating away or after saving
resetEditStationData = () => { resetEditStationData = () => {
this.editStationData = { this.editStationData = {
ru: { ru: {
@@ -552,6 +547,99 @@ class StationsStore {
}, },
}; };
}; };
updateStationTransfers = async (
id: number,
transfers: {
bus: string;
metro_blue: string;
metro_green: string;
metro_orange: string;
metro_purple: string;
metro_red: string;
train: string;
tram: string;
trolleybus: string;
}
) => {
const { language } = languageStore;
// Получаем данные станции для текущего языка
const response = await languageInstance(language).get(`/station/${id}`);
const stationData = response.data as Station;
if (!stationData) {
throw new Error("Station not found");
}
// Формируем commonDataPayload как в editStation, с обновленными transfers
const commonDataPayload = {
city_id: stationData.city_id,
direction: stationData.direction,
latitude: stationData.latitude,
longitude: stationData.longitude,
offset_x: stationData.offset_x,
offset_y: stationData.offset_y,
transfers: transfers,
city: stationData.city || "",
};
// Отправляем один PATCH запрос, так как пересадки общие для всех языков
const patchResponse = await languageInstance(language).patch(
`/station/${id}`,
{
name: stationData.name || "",
system_name: stationData.system_name || "",
description: stationData.description || "",
address: stationData.address || "",
...commonDataPayload,
}
);
// Обновляем данные для всех языков в локальном состоянии
runInAction(() => {
const updatedTransfers = patchResponse.data.transfers;
for (const lang of ["ru", "en", "zh"] as const) {
if (this.stationPreview[id]) {
this.stationPreview[id][lang] = {
...this.stationPreview[id][lang],
data: {
...this.stationPreview[id][lang].data,
transfers: updatedTransfers,
},
};
}
if (this.stationLists[lang].data) {
this.stationLists[lang].data = this.stationLists[lang].data.map(
(station: Station) =>
station.id === id
? {
...station,
transfers: updatedTransfers,
}
: station
);
}
}
// Обновляем пересадки в RouteStore.routeStations для всех маршрутов
if (routeStore?.routeStations) {
for (const routeId in routeStore.routeStations) {
routeStore.routeStations[routeId] = routeStore.routeStations[
routeId
].map((station: any) =>
station.id === id
? {
...station,
transfers: updatedTransfers,
}
: station
);
}
}
});
};
} }
export const stationsStore = new StationsStore(); export const stationsStore = new StationsStore();

View File

@@ -0,0 +1,171 @@
import { forwardRef } from "react";
import { Button, ButtonProps, CircularProgress } from "@mui/material";
import { alpha, keyframes, styled } from "@mui/material/styles";
import type { Theme } from "@mui/material/styles";
type AnimatedCircleButtonProps = ButtonProps & {
disableAnimation?: boolean;
loading?: boolean;
};
type StyledButtonProps = AnimatedCircleButtonProps & { theme: Theme };
const loadingPulse = keyframes`
0% {
transform: translate(-50%, -50%) scale(0.6);
opacity: 0.35;
}
50% {
transform: translate(-50%, -50%) scale(1.45);
opacity: 0.15;
}
100% {
transform: translate(-50%, -50%) scale(0.6);
opacity: 0;
}
`;
const StyledButton = styled(Button, {
shouldForwardProp: (prop) =>
prop !== "disableAnimation" && prop !== "loading",
})<AnimatedCircleButtonProps>((props: StyledButtonProps) => {
const {
theme,
disableAnimation = false,
color,
variant = "text",
disabled = false,
loading = false,
} = props;
const shouldAnimate = !disableAnimation && (!disabled || loading);
const pointerBlocked = loading;
const paletteMainMap: Record<string, string> = {
primary: theme.palette.primary.main,
secondary: theme.palette.secondary.main,
error: theme.palette.error.main,
warning: theme.palette.warning.main,
info: theme.palette.info.main,
success: theme.palette.success.main,
inherit: theme.palette.primary.main,
};
const paletteMain =
(color && paletteMainMap[String(color)]) ?? theme.palette.primary.main;
const pulseColor =
variant === "outlined" || variant === "text"
? alpha(paletteMain, 0.18)
: alpha(paletteMain, 0.3);
return {
position: "relative",
overflow: "hidden",
borderRadius: 5,
zIndex: 0,
transition: "transform 0.2s ease, box-shadow 0.2s ease",
pointerEvents: pointerBlocked ? "none" : undefined,
"&::after": shouldAnimate
? {
content: '""',
position: "absolute",
width: "12px",
height: "12px",
backgroundColor: pulseColor,
borderRadius: "50%",
top: "50%",
left: "50%",
pointerEvents: "none",
zIndex: 0,
...(loading
? {
opacity: 0.35,
transform: "translate(-50%, -50%) scale(0.6)",
animation: `${loadingPulse} 1.2s ease-in-out infinite`,
}
: {
opacity: 0,
transform: "translate(-50%, -50%) scale(0)",
transition: "transform 0.45s ease, opacity 0.45s ease",
}),
}
: {},
...(loading
? {}
: {
"&:hover": {
transform: "translateY(-1px)",
boxShadow: theme.shadows[4],
},
"&:hover::after": shouldAnimate
? {
transform: "translate(-50%, -50%) scale(15)",
opacity: 1,
}
: {},
"&:active": {
transform: "translateY(0)",
boxShadow: theme.shadows[2],
},
"&:active::after": shouldAnimate
? {
transform: "translate(-50%, -50%) scale(18)",
opacity: 0.4,
}
: {},
}),
"&.Mui-disabled": {
boxShadow: "none",
transform: "none",
...(loading && shouldAnimate
? {}
: {
"&::after": {
opacity: 0,
},
}),
},
...(disabled && {
boxShadow: "none",
transform: "none",
}),
"& > *": {
position: "relative",
zIndex: 1,
},
};
});
export const AnimatedCircleButton = forwardRef<
HTMLButtonElement,
AnimatedCircleButtonProps
>((props, ref) => {
const {
loading = false,
disabled,
children,
startIcon,
endIcon,
...rest
} = props;
const effectiveStartIcon = loading ? (
<CircularProgress size={16} color="inherit" />
) : (
startIcon
);
return (
<StyledButton
ref={ref}
loading={loading}
disabled={loading ? true : disabled}
startIcon={effectiveStartIcon}
endIcon={loading ? undefined : endIcon}
{...rest}
>
{children}
</StyledButton>
);
});

View File

@@ -2,3 +2,5 @@ export * from "./TabPanel";
export * from "./BackButton"; export * from "./BackButton";
export * from "./Modal"; export * from "./Modal";
export * from "./CoordinatesInput"; export * from "./CoordinatesInput";
export * from "./AnimatedCircleButton";
export * from "./LoadingSpinner";

View File

@@ -5,30 +5,29 @@ import TableContainer from "@mui/material/TableContainer";
import TableHead from "@mui/material/TableHead"; import TableHead from "@mui/material/TableHead";
import TableRow from "@mui/material/TableRow"; import TableRow from "@mui/material/TableRow";
import Paper from "@mui/material/Paper"; import Paper from "@mui/material/Paper";
import { Check, Copy, RotateCcw, X } from "lucide-react"; import { Check, Copy, RotateCcw, Trash2, X } from "lucide-react";
import { import {
authInstance, authInstance,
devicesStore, devicesStore,
Modal, Modal,
snapshotStore, snapshotStore,
vehicleStore, // Not directly used in this component's rendering logic anymore vehicleStore,
} from "@shared"; // Assuming @shared exports these } from "@shared";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { Button, Checkbox, Typography } from "@mui/material"; import { Button, Checkbox, Typography } from "@mui/material";
import { Vehicle } from "@shared"; import { Vehicle } from "@shared";
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";
export type ConnectedDevice = string; export type ConnectedDevice = string;
interface Snapshot { interface Snapshot {
ID: string; // Assuming ID is string based on usage ID: string;
Name: string; Name: string;
// Add other snapshot properties if needed
} }
// --- HELPER FUNCTIONS ---
const formatDate = (dateString: string | null) => { const formatDate = (dateString: string | null) => {
if (!dateString) return "Нет данных"; if (!dateString) return "Нет данных";
try { try {
@@ -76,12 +75,7 @@ function createData(
}; };
} }
// This function transforms the raw device data (which includes vehicle and device_status) const transformDevicesToRows = (vehicles: Vehicle[]): TableRowData[] => {
// into the format expected by the table. It now filters for devices that have a UUID.
const transformDevicesToRows = (
vehicles: Vehicle[]
// devices: ConnectedDevice[]
): TableRowData[] => {
return vehicles.map((vehicle) => { return vehicles.map((vehicle) => {
const uuid = vehicle.vehicle.uuid; const uuid = vehicle.vehicle.uuid;
if (!uuid) if (!uuid)
@@ -115,26 +109,22 @@ export const DevicesTable = observer(() => {
} = devicesStore; } = devicesStore;
const { snapshots, getSnapshots } = snapshotStore; const { snapshots, getSnapshots } = snapshotStore;
const { getVehicles, vehicles } = vehicleStore; // Removed as devicesStore.devices should be the source of truth const { getVehicles, vehicles, deleteVehicle } = vehicleStore;
const { devices } = devicesStore; const { devices } = devicesStore;
const navigate = useNavigate(); const navigate = useNavigate();
const [selectedDeviceUuids, setSelectedDeviceUuids] = useState<string[]>([]); const [selectedDeviceUuids, setSelectedDeviceUuids] = useState<string[]>([]);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
// Transform the raw devices data into rows suitable for the table const currentTableRows = transformDevicesToRows(vehicles.data as Vehicle[]);
// This will also filter out devices without a UUID, as those cannot be acted upon.
const currentTableRows = transformDevicesToRows(
vehicles.data as Vehicle[]
// devices as ConnectedDevice[]
);
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
await getVehicles(); // Not strictly needed if devicesStore.devices is populated correctly by getDevices await getVehicles();
await getDevices(); // This should fetch the combined vehicle/device_status data await getDevices();
await getSnapshots(); await getSnapshots();
}; };
fetchData(); fetchData();
}, [getDevices, getSnapshots]); // Added dependencies }, [getDevices, getSnapshots]);
const isAllSelected = const isAllSelected =
currentTableRows.length > 0 && currentTableRows.length > 0 &&
@@ -144,7 +134,6 @@ export const DevicesTable = observer(() => {
if (isAllSelected) { if (isAllSelected) {
setSelectedDeviceUuids([]); setSelectedDeviceUuids([]);
} else { } else {
// Select all device UUIDs from the *currently visible and selectable* rows
setSelectedDeviceUuids( setSelectedDeviceUuids(
currentTableRows.map((row) => row.device_uuid ?? "") currentTableRows.map((row) => row.device_uuid ?? "")
); );
@@ -171,14 +160,13 @@ export const DevicesTable = observer(() => {
}; };
const handleReloadStatus = async (uuid: string) => { const handleReloadStatus = async (uuid: string) => {
setSelectedDevice(uuid); // Sets the device in the store, useful for context elsewhere setSelectedDevice(uuid);
try { try {
await authInstance.post(`/devices/${uuid}/request-status`); await authInstance.post(`/devices/${uuid}/request-status`);
await getVehicles(); await getVehicles();
await getDevices(); // Refresh devices to show updated status await getDevices();
} catch (error) { } catch (error) {
console.error(`Error requesting status for device ${uuid}:`, error); console.error(`Error requesting status for device ${uuid}:`, error);
// Optionally: show a user-facing error message
} }
}; };
@@ -200,26 +188,44 @@ export const DevicesTable = observer(() => {
} }
}; };
try { try {
// Create an array of promises for all snapshot requests
const snapshotPromises = selectedDeviceUuids.map((deviceUuid) => { const snapshotPromises = selectedDeviceUuids.map((deviceUuid) => {
return send(deviceUuid); return send(deviceUuid);
}); });
// Wait for all promises to settle (either resolve or reject)
await Promise.allSettled(snapshotPromises); await Promise.allSettled(snapshotPromises);
// After all requests are attempted await getDevices();
await getDevices(); // Refresh the device list setSelectedDeviceUuids([]);
setSelectedDeviceUuids([]); // Clear the selection toggleSendSnapshotModal();
toggleSendSnapshotModal(); // Close the modal
} catch (error) { } catch (error) {
// This catch block might not be hit if Promise.allSettled is used,
// as it doesn't reject on individual promise failures.
// Individual errors should be handled if needed within the .map or by checking results.
console.error("Error in snapshot sending process:", error); console.error("Error in snapshot sending process:", error);
} }
}; };
const getVehicleIdsByUuids = (uuids: string[]): number[] => {
return vehicles.data
.filter((vehicle) => uuids.includes(vehicle.vehicle.uuid ?? ""))
.map((vehicle) => vehicle.vehicle.id);
};
const handleDeleteVehicles = async () => {
if (selectedDeviceUuids.length === 0) return;
const vehicleIds = getVehicleIdsByUuids(selectedDeviceUuids);
try {
await Promise.all(vehicleIds.map((id) => deleteVehicle(id)));
await getVehicles();
await getDevices();
setSelectedDeviceUuids([]);
setIsDeleteModalOpen(false);
toast.success(`Удалено устройств: ${vehicleIds.length}`);
} catch (error) {
console.error("Error deleting vehicles:", error);
toast.error("Ошибка при удалении устройств");
}
};
return ( return (
<> <>
<TableContainer component={Paper} sx={{ mt: 2 }}> <TableContainer component={Paper} sx={{ mt: 2 }}>
@@ -233,14 +239,25 @@ export const DevicesTable = observer(() => {
Добавить устройство Добавить устройство
</Button> </Button>
</div> </div>
<div className="flex justify-end p-3 gap-2"> <div className="flex justify-end p-3 gap-2 items-center">
<Button <Button
variant="outlined" // Changed to outlined for distinction variant="outlined"
onClick={handleSelectAllDevices} onClick={handleSelectAllDevices}
size="small" size="small"
> >
{isAllSelected ? "Снять выбор" : "Выбрать все"} {isAllSelected ? "Снять выбор" : "Выбрать все"}
</Button> </Button>
{selectedDeviceUuids.length > 0 && (
<Button
variant="contained"
color="error"
onClick={() => setIsDeleteModalOpen(true)}
size="small"
startIcon={<Trash2 size={16} />}
>
Удалить ({selectedDeviceUuids.length})
</Button>
)}
<Button <Button
variant="contained" variant="contained"
color="primary" color="primary"
@@ -286,7 +303,6 @@ export const DevicesTable = observer(() => {
)} )}
selected={selectedDeviceUuids.includes(row.device_uuid ?? "")} selected={selectedDeviceUuids.includes(row.device_uuid ?? "")}
onClick={(event) => { onClick={(event) => {
// Allow clicking row to toggle checkbox, if not clicking on button
if ( if (
(event.target as HTMLElement).closest("button") === null && (event.target as HTMLElement).closest("button") === null &&
(event.target as HTMLElement).closest( (event.target as HTMLElement).closest(
@@ -308,7 +324,6 @@ export const DevicesTable = observer(() => {
} }
} }
// Only toggle checkbox if Shift key is not pressed
if (!event.shiftKey) { if (!event.shiftKey) {
handleSelectDevice( handleSelectDevice(
{ {
@@ -317,7 +332,7 @@ export const DevicesTable = observer(() => {
row.device_uuid ?? "" row.device_uuid ?? ""
), ),
}, },
} as React.ChangeEvent<HTMLInputElement>, // Simulate event } as React.ChangeEvent<HTMLInputElement>,
row.device_uuid ?? "" row.device_uuid ?? ""
); );
} }
@@ -445,7 +460,7 @@ export const DevicesTable = observer(() => {
</strong> </strong>
</Typography> </Typography>
<div className="mt-2 flex flex-col gap-2 max-h-[300px] overflow-y-auto pr-2"> <div className="mt-2 flex flex-col gap-2 max-h-[300px] overflow-y-auto pr-2">
{snapshots && (snapshots as Snapshot[]).length > 0 ? ( // Cast snapshots {snapshots && (snapshots as Snapshot[]).length > 0 ? (
(snapshots as Snapshot[]).map((snapshot) => ( (snapshots as Snapshot[]).map((snapshot) => (
<Button <Button
variant="outlined" variant="outlined"
@@ -473,6 +488,12 @@ export const DevicesTable = observer(() => {
Отмена Отмена
</Button> </Button>
</Modal> </Modal>
<DeleteModal
open={isDeleteModalOpen}
onDelete={handleDeleteVehicles}
onCancel={() => setIsDeleteModalOpen(false)}
/>
</> </>
); );
}); });

View File

@@ -1,6 +1,6 @@
import React, { useRef, useState, DragEvent, useEffect } 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"; // Assuming lucide-react for icons import { X, Info, Plus } from "lucide-react";
import { editSightStore } from "@shared"; import { editSightStore } from "@shared";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
interface ImageUploadCardProps { interface ImageUploadCardProps {
@@ -27,18 +27,9 @@ export const ImageUploadCard: React.FC<ImageUploadCardProps> = ({
tooltipText, tooltipText,
}) => { }) => {
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const [isDragOver, setIsDragOver] = useState(false);
const { setFileToUpload } = editSightStore; const { setFileToUpload } = editSightStore;
useEffect(() => {
if (isDragOver) {
console.log("isDragOver");
}
}, [isDragOver]);
// --- Click to select file ---
const handleZoneClick = () => { const handleZoneClick = () => {
// Trigger the hidden file input click
fileInputRef.current?.click(); fileInputRef.current?.click();
}; };
@@ -59,28 +50,25 @@ export const ImageUploadCard: React.FC<ImageUploadCardProps> = ({
toast.error("Пожалуйста, выберите изображение"); toast.error("Пожалуйста, выберите изображение");
} }
} }
// Reset the input value so selecting the same file again triggers change
event.target.value = ""; event.target.value = "";
}; };
const token = localStorage.getItem("token"); const token = localStorage.getItem("token");
// --- Drag and Drop Handlers ---
const handleDragOver = (event: DragEvent<HTMLDivElement>) => { const handleDragOver = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault(); // Crucial to allow a drop event.preventDefault();
event.stopPropagation(); event.stopPropagation();
setIsDragOver(true);
}; };
const handleDragLeave = (event: DragEvent<HTMLDivElement>) => { const handleDragLeave = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
setIsDragOver(false);
}; };
const handleDrop = async (event: DragEvent<HTMLDivElement>) => { const handleDrop = async (event: DragEvent<HTMLDivElement>) => {
event.preventDefault(); // Crucial to allow a drop event.preventDefault();
event.stopPropagation(); event.stopPropagation();
setIsDragOver(false);
const files = event.dataTransfer.files; const files = event.dataTransfer.files;
if (files && files.length > 0) { if (files && files.length > 0) {
@@ -132,7 +120,6 @@ export const ImageUploadCard: React.FC<ImageUploadCardProps> = ({
cursor: imageUrl ? "pointer" : "default", cursor: imageUrl ? "pointer" : "default",
}} }}
onClick={onImageClick} onClick={onImageClick}
// Removed onClick on the main Box to avoid conflicts
> >
{imageUrl && ( {imageUrl && (
<button <button
@@ -165,7 +152,7 @@ export const ImageUploadCard: React.FC<ImageUploadCardProps> = ({
borderRadius: 1, borderRadius: 1,
cursor: "pointer", cursor: "pointer",
}} }}
onClick={handleZoneClick} // Click handler for the zone onClick={handleZoneClick}
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}
onDrop={handleDrop} onDrop={handleDrop}
@@ -179,8 +166,8 @@ export const ImageUploadCard: React.FC<ImageUploadCardProps> = ({
color="primary" color="primary"
startIcon={<Plus color="white" size={18} />} startIcon={<Plus color="white" size={18} />}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); // Prevent `handleZoneClick` from firing e.stopPropagation();
onSelectFileClick(); // This button might trigger a different modal onSelectFileClick();
}} }}
> >
Выбрать файл Выбрать файл
@@ -191,7 +178,7 @@ export const ImageUploadCard: React.FC<ImageUploadCardProps> = ({
ref={fileInputRef} ref={fileInputRef}
onChange={handleFileInputChange} onChange={handleFileInputChange}
style={{ display: "none" }} style={{ display: "none" }}
accept="image/*" // Accept only image files accept="image/*"
/> />
</div> </div>
)} )}

View File

@@ -10,23 +10,25 @@ import { observer } from "mobx-react-lite";
import { useState, DragEvent, useRef } from "react"; import { useState, DragEvent, useRef } from "react";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
export const MediaArea = observer( interface MediaAreaProps {
({
articleId,
mediaIds,
deleteMedia,
onFilesDrop, // 👈 Проп для обработки загруженных файлов
setSelectMediaDialogOpen,
}: {
articleId: number; articleId: number;
mediaIds: { id: string; media_type: number; filename: string }[]; mediaIds: { id: string; media_type: number; filename: string }[];
deleteMedia: (id: number, media_id: string) => void; deleteMedia: (id: number, media_id: string) => void;
onFilesDrop?: (files: File[]) => void; onFilesDrop?: (files: File[]) => void;
setSelectMediaDialogOpen: (open: boolean) => void; setSelectMediaDialogOpen: (open: boolean) => void;
}) => { }
export const MediaArea = observer(
({
articleId,
mediaIds,
deleteMedia,
onFilesDrop,
setSelectMediaDialogOpen,
}: MediaAreaProps) => {
const [mediaModal, setMediaModal] = useState<boolean>(false); const [mediaModal, setMediaModal] = useState<boolean>(false);
const [mediaId, setMediaId] = useState<string>(""); const [mediaId, setMediaId] = useState<string>("");
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState<boolean>(false);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const handleMediaModal = (mediaId: string) => { const handleMediaModal = (mediaId: string) => {
@@ -34,13 +36,11 @@ export const MediaArea = observer(
setMediaId(mediaId); setMediaId(mediaId);
}; };
const handleDrop = (e: DragEvent<HTMLDivElement>) => { const processFiles = (files: File[]) => {
e.preventDefault(); if (!files.length || !onFilesDrop) {
e.stopPropagation(); return;
setIsDragging(false); }
const files = Array.from(e.dataTransfer.files);
if (files.length && onFilesDrop) {
const { validFiles, errors } = filterValidFiles(files); const { validFiles, errors } = filterValidFiles(files);
if (errors.length > 0) { if (errors.length > 0) {
@@ -50,7 +50,15 @@ export const MediaArea = observer(
if (validFiles.length > 0) { if (validFiles.length > 0) {
onFilesDrop(validFiles); onFilesDrop(validFiles);
} }
} };
const handleDrop = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
const files = Array.from(e.dataTransfer.files);
processFiles(files);
}; };
const handleDragOver = (e: DragEvent<HTMLDivElement>) => { const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
@@ -68,19 +76,11 @@ export const MediaArea = observer(
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => { const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files || []); const files = Array.from(event.target.files || []);
if (files.length && onFilesDrop) { processFiles(files);
const { validFiles, errors } = filterValidFiles(files);
if (errors.length > 0) { if (event.target) {
errors.forEach((error) => toast.error(error));
}
if (validFiles.length > 0) {
onFilesDrop(validFiles);
}
}
// Сбрасываем значение input, чтобы можно было выбрать тот же файл снова
event.target.value = ""; event.target.value = "";
}
}; };
return ( return (
@@ -96,7 +96,7 @@ export const MediaArea = observer(
<Box className="w-full flex flex-col items-center justify-center border rounded-md p-4"> <Box className="w-full flex flex-col items-center justify-center border rounded-md p-4">
<div className="w-full flex flex-col items-center justify-center"> <div className="w-full flex flex-col items-center justify-center">
<div <div
className={`w-full h-40 flex flex-col justify-center items-center text-gray-400 border-dashed border-2 rounded-md border-gray-400 p-4 cursor-pointer hover:bg-gray-50 ${ className={`w-full h-40 flex flex-col justify-center items-center text-gray-400 border-dashed border-2 rounded-md border-gray-400 p-4 cursor-pointer hover:bg-gray-50 transition-colors ${
isDragging ? "bg-blue-100 border-blue-400" : "" isDragging ? "bg-blue-100 border-blue-400" : ""
}`} }`}
onDrop={handleDrop} onDrop={handleDrop}
@@ -105,9 +105,11 @@ export const MediaArea = observer(
onClick={handleClick} onClick={handleClick}
> >
<Upload size={32} className="mb-2" /> <Upload size={32} className="mb-2" />
<span className="text-center">
Перетащите медиа файлы сюда или нажмите для выбора Перетащите медиа файлы сюда или нажмите для выбора
</span>
</div> </div>
<div>или</div> <div className="my-2">или</div>
<Button <Button
variant="contained" variant="contained"
color="primary" color="primary"
@@ -117,12 +119,14 @@ export const MediaArea = observer(
</Button> </Button>
</div> </div>
{mediaIds.length > 0 && (
<div className="w-full flex flex-start flex-wrap gap-2 mt-4 py-10"> <div className="w-full flex flex-start flex-wrap gap-2 mt-4 py-10">
{mediaIds.map((m) => ( {mediaIds.map((m) => (
<button <button
className="relative w-20 h-20" className="relative w-20 h-20"
key={m.id} key={m.id}
onClick={() => handleMediaModal(m.id)} onClick={() => handleMediaModal(m.id)}
type="button"
> >
<MediaViewer <MediaViewer
media={{ media={{
@@ -133,17 +137,20 @@ export const MediaArea = observer(
height="40px" height="40px"
/> />
<button <button
className="absolute top-2 right-2" className="absolute top-2 right-2 bg-white rounded-full p-1 shadow-md hover:shadow-lg transition-shadow"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
deleteMedia(articleId, m.id); deleteMedia(articleId, m.id);
}} }}
type="button"
aria-label="Удалить медиа"
> >
<X size={16} color="red" /> <X size={16} color="red" />
</button> </button>
</button> </button>
))} ))}
</div> </div>
)}
</Box> </Box>
<PreviewMediaDialog <PreviewMediaDialog

View File

@@ -11,52 +11,72 @@ import { observer } from "mobx-react-lite";
import { useState, DragEvent, useRef } from "react"; import { useState, DragEvent, useRef } from "react";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
export const MediaAreaForSight = observer( type ContextType =
({
onFilesDrop, // 👈 Проп для обработки загруженных файлов
onFinishUpload,
contextObjectName,
contextType,
isArticle,
articleName,
}: {
onFilesDrop?: (files: File[]) => void;
onFinishUpload?: (mediaId: string) => void;
contextObjectName?: string;
contextType?:
| "sight" | "sight"
| "city" | "city"
| "carrier" | "carrier"
| "country" | "country"
| "vehicle" | "vehicle"
| "station"; | "station";
interface MediaAreaForSightProps {
onFilesDrop?: (files: File[]) => void;
onFinishUpload?: (mediaId: string) => void;
contextObjectName?: string;
contextType?: ContextType;
isArticle?: boolean; isArticle?: boolean;
articleName?: string; articleName?: string;
}) => { }
const [selectMediaDialogOpen, setSelectMediaDialogOpen] = useState(false);
const [uploadMediaDialogOpen, setUploadMediaDialogOpen] = useState(false); export const MediaAreaForSight = observer(
const [isDragging, setIsDragging] = useState(false); ({
onFilesDrop,
onFinishUpload,
contextObjectName,
contextType,
isArticle,
articleName,
}: MediaAreaForSightProps) => {
const [selectMediaDialogOpen, setSelectMediaDialogOpen] =
useState<boolean>(false);
const [uploadMediaDialogOpen, setUploadMediaDialogOpen] =
useState<boolean>(false);
const [isDragging, setIsDragging] = useState<boolean>(false);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const { setFileToUpload } = editSightStore; const { setFileToUpload } = editSightStore;
const processFiles = (files: File[]) => {
if (!files.length) {
return;
}
const { validFiles, errors } = filterValidFiles(files);
if (errors.length > 0) {
errors.forEach((error: string) => toast.error(error));
}
if (validFiles.length > 0) {
// Сохраняем первый файл для загрузки
setFileToUpload(validFiles[0]);
// Вызываем колбэк, если он передан
if (onFilesDrop) {
onFilesDrop(validFiles);
}
// Открываем диалог загрузки
setUploadMediaDialogOpen(true);
}
};
const handleDrop = (e: DragEvent<HTMLDivElement>) => { const handleDrop = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
setIsDragging(false); setIsDragging(false);
const files = Array.from(e.dataTransfer.files); const files = Array.from(e.dataTransfer.files);
if (files.length) { processFiles(files);
const { validFiles, errors } = filterValidFiles(files);
if (errors.length > 0) {
errors.forEach((error: string) => toast.error(error));
}
if (validFiles.length > 0 && onFilesDrop) {
setFileToUpload(validFiles[0]);
setUploadMediaDialogOpen(true);
}
}
}; };
const handleDragOver = (e: DragEvent<HTMLDivElement>) => { const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
@@ -74,22 +94,12 @@ export const MediaAreaForSight = observer(
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => { const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files || []); const files = Array.from(event.target.files || []);
if (files.length) { processFiles(files);
const { validFiles, errors } = filterValidFiles(files);
if (errors.length > 0) {
errors.forEach((error: string) => toast.error(error));
}
if (validFiles.length > 0 && onFilesDrop) {
setFileToUpload(validFiles[0]);
onFilesDrop(validFiles);
setUploadMediaDialogOpen(true);
}
}
// Сбрасываем значение input, чтобы можно было выбрать тот же файл снова // Сбрасываем значение input, чтобы можно было выбрать тот же файл снова
if (event.target) {
event.target.value = ""; event.target.value = "";
}
}; };
return ( return (
@@ -105,7 +115,7 @@ export const MediaAreaForSight = observer(
<Box className="w-full flex flex-col items-center justify-center border rounded-md p-4"> <Box className="w-full flex flex-col items-center justify-center border rounded-md p-4">
<div className="w-full flex flex-col items-center justify-center"> <div className="w-full flex flex-col items-center justify-center">
<div <div
className={`w-full h-40 flex text-center flex-col justify-center items-center text-gray-400 border-dashed border-2 rounded-md border-gray-400 p-4 cursor-pointer hover:bg-gray-50 ${ className={`w-full h-40 flex flex-col justify-center items-center text-gray-400 border-dashed border-2 rounded-md border-gray-400 p-4 cursor-pointer hover:bg-gray-50 transition-colors ${
isDragging ? "bg-blue-100 border-blue-400" : "" isDragging ? "bg-blue-100 border-blue-400" : ""
}`} }`}
onDrop={handleDrop} onDrop={handleDrop}
@@ -114,9 +124,11 @@ export const MediaAreaForSight = observer(
onClick={handleClick} onClick={handleClick}
> >
<Upload size={32} className="mb-2" /> <Upload size={32} className="mb-2" />
<span className="text-center">
Перетащите медиа файлы сюда или нажмите для выбора Перетащите медиа файлы сюда или нажмите для выбора
</span>
</div> </div>
<div>или</div> <div className="my-2">или</div>
<Button <Button
variant="contained" variant="contained"
color="primary" color="primary"

View File

@@ -3,11 +3,9 @@ import { OrbitControls, Stage, useGLTF } from "@react-three/drei";
import { useEffect, Suspense } from "react"; import { useEffect, Suspense } from "react";
import { Box, CircularProgress, Typography } from "@mui/material"; import { Box, CircularProgress, Typography } from "@mui/material";
// Утилита для очистки кеша GLTF
const clearGLTFCache = (url?: string) => { const clearGLTFCache = (url?: string) => {
try { try {
if (url) { if (url) {
// Если это blob URL, очищаем его из кеша
if (url.startsWith("blob:")) { if (url.startsWith("blob:")) {
useGLTF.clear(url); useGLTF.clear(url);
} else { } else {
@@ -19,29 +17,23 @@ const clearGLTFCache = (url?: string) => {
} }
}; };
// Утилита для проверки типа файла
const isValid3DFile = (url: string): boolean => { const isValid3DFile = (url: string): boolean => {
try { try {
const urlObj = new URL(url); const urlObj = new URL(url);
const pathname = urlObj.pathname.toLowerCase(); const pathname = urlObj.pathname.toLowerCase();
const searchParams = urlObj.searchParams; const searchParams = urlObj.searchParams;
// Проверяем расширение файла в пути
const validExtensions = [".glb", ".gltf"]; const validExtensions = [".glb", ".gltf"];
const hasValidExtension = validExtensions.some((ext) => const hasValidExtension = validExtensions.some((ext) =>
pathname.endsWith(ext) pathname.endsWith(ext)
); );
// Проверяем параметры запроса на наличие типа файла
const fileType = searchParams.get("type") || searchParams.get("format"); const fileType = searchParams.get("type") || searchParams.get("format");
const hasValidType = const hasValidType =
fileType && ["glb", "gltf"].includes(fileType.toLowerCase()); fileType && ["glb", "gltf"].includes(fileType.toLowerCase());
// Если это blob URL, считаем его валидным (пользователь выбрал файл)
const isBlobUrl = url.startsWith("blob:"); const isBlobUrl = url.startsWith("blob:");
// Если это URL с токеном и нет явного расширения, считаем валидным
// (предполагаем что сервер вернет правильный файл)
const hasToken = searchParams.has("token"); const hasToken = searchParams.has("token");
const isServerUrl = hasToken && !hasValidExtension; const isServerUrl = hasToken && !hasValidExtension;
@@ -51,7 +43,7 @@ const isValid3DFile = (url: string): boolean => {
return isValid; return isValid;
} catch (error) { } catch (error) {
console.warn("⚠️ isValid3DFile: Ошибка при проверке URL", error); console.warn("⚠️ isValid3DFile: Ошибка при проверке URL", error);
// В случае ошибки парсинга URL, считаем валидным (пусть useGLTF сам разберется)
return true; return true;
} }
}; };
@@ -63,13 +55,10 @@ type ModelViewerProps = {
}; };
const Model = ({ fileUrl }: { fileUrl: string }) => { const Model = ({ fileUrl }: { fileUrl: string }) => {
// Очищаем кеш перед загрузкой новой модели
useEffect(() => { useEffect(() => {
// Очищаем кеш для текущего URL
clearGLTFCache(fileUrl); clearGLTFCache(fileUrl);
}, [fileUrl]); }, [fileUrl]);
// Проверяем валидность файла перед загрузкой (только для blob URL)
if (fileUrl.startsWith("blob:") && !isValid3DFile(fileUrl)) { if (fileUrl.startsWith("blob:") && !isValid3DFile(fileUrl)) {
console.warn("⚠️ Model: Попытка загрузить невалидный 3D файл", { fileUrl }); console.warn("⚠️ Model: Попытка загрузить невалидный 3D файл", { fileUrl });
throw new Error(`Файл не является корректной 3D моделью: ${fileUrl}`); throw new Error(`Файл не является корректной 3D моделью: ${fileUrl}`);
@@ -114,16 +103,13 @@ export const ThreeView = ({
height = "100%", height = "100%",
width = "100%", width = "100%",
}: ModelViewerProps) => { }: ModelViewerProps) => {
// Проверяем валидность файла (только для blob URL)
useEffect(() => { useEffect(() => {
if (fileUrl.startsWith("blob:") && !isValid3DFile(fileUrl)) { if (fileUrl.startsWith("blob:") && !isValid3DFile(fileUrl)) {
console.warn("⚠️ ThreeView: Невалидный 3D файл", { fileUrl }); console.warn("⚠️ ThreeView: Невалидный 3D файл", { fileUrl });
} }
}, [fileUrl]); }, [fileUrl]);
// Очищаем кеш при размонтировании и при смене URL
useEffect(() => { useEffect(() => {
// Очищаем кеш сразу при монтировании компонента
clearGLTFCache(fileUrl); clearGLTFCache(fileUrl);
return () => { return () => {

View File

@@ -35,7 +35,6 @@ export class ThreeViewErrorBoundary extends Component<Props, State> {
props: Props, props: Props,
state: State state: State
): Partial<State> | null { ): Partial<State> | null {
// Сбрасываем ошибку ТОЛЬКО при смене медиа (когда меняется ID в resetKey)
if ( if (
props.resetKey !== state.lastResetKey && props.resetKey !== state.lastResetKey &&
state.lastResetKey !== undefined state.lastResetKey !== undefined
@@ -43,7 +42,6 @@ export class ThreeViewErrorBoundary extends Component<Props, State> {
const oldMediaId = String(state.lastResetKey).split("-")[0]; const oldMediaId = String(state.lastResetKey).split("-")[0];
const newMediaId = String(props.resetKey).split("-")[0]; const newMediaId = String(props.resetKey).split("-")[0];
// Сбрасываем ошибку только если изменился ID медиа (пользователь выбрал другую модель)
if (oldMediaId !== newMediaId) { if (oldMediaId !== newMediaId) {
return { return {
hasError: false, hasError: false,
@@ -52,9 +50,6 @@ export class ThreeViewErrorBoundary extends Component<Props, State> {
}; };
} }
// Если изменился только счетчик (нажата кнопка "Попробовать снова"), обновляем lastResetKey
// но не сбрасываем ошибку автоматически - ждем результата загрузки
return { return {
lastResetKey: props.resetKey, lastResetKey: props.resetKey,
}; };
@@ -127,15 +122,12 @@ export class ThreeViewErrorBoundary extends Component<Props, State> {
}; };
handleReset = () => { handleReset = () => {
// Сначала сбрасываем состояние ошибки
this.setState( this.setState(
{ {
hasError: false, hasError: false,
error: null, error: null,
}, },
() => { () => {
// После того как состояние обновилось, вызываем callback для изменения resetKey
// Это приведет к пересозданию компонента и новой попытке загрузки
this.props.onReset?.(); this.props.onReset?.();
} }
); );

View File

@@ -38,7 +38,7 @@ export function MediaViewer({
// Используем новый cache manager для очистки кеша // Используем новый cache manager для очистки кеша
clearMediaTransitionCache( clearMediaTransitionCache(
previousMediaId, previousMediaId,
media?.id || null,
media?.media_type media?.media_type
); );
@@ -117,6 +117,7 @@ export function MediaViewer({
}/download?token=${token}`} }/download?token=${token}`}
alt={media?.filename} alt={media?.filename}
style={{ style={{
width: "100%",
objectFit: "cover", objectFit: "cover",
}} }}
/> />
@@ -127,8 +128,8 @@ export function MediaViewer({
src={`${import.meta.env.VITE_KRBL_MEDIA}${ src={`${import.meta.env.VITE_KRBL_MEDIA}${
media?.id media?.id
}/download?token=${token}`} }/download?token=${token}`}
width={width ? width : "500px"} width={fullWidth ? "100%" : width ? width : "500px"}
height={height ? height : "300px"} height={fullHeight ? "100%" : height ? height : "300px"}
/> />
)} )}

View File

@@ -1,158 +0,0 @@
// import { Box, Button, Paper, Typography } from "@mui/material";
// import { X, Upload } from "lucide-react";
// import { useCallback, useState } from "react";
// import { useDropzone } from "react-dropzone";
// import { UploadMediaDialog } from "@shared";
// import { createSightStore } from "@shared";
// interface MediaUploadBoxProps {
// title: string;
// tooltip?: string;
// mediaId: string | null;
// onMediaSelect: (mediaId: string) => void;
// onMediaRemove: () => void;
// onPreviewClick: (mediaId: string) => void;
// token: string;
// type: "thumbnail" | "watermark_lu" | "watermark_rd";
// }
// export const MediaUploadBox = ({
// title,
// tooltip,
// mediaId,
// onMediaSelect,
// onMediaRemove,
// onPreviewClick,
// token,
// type,
// }: MediaUploadBoxProps) => {
// const [uploadMediaOpen, setUploadMediaOpen] = useState(false);
// const [fileToUpload, setFileToUpload] = useState<File | null>(null);
// const onDrop = useCallback((acceptedFiles: File[]) => {
// if (acceptedFiles.length > 0) {
// setFileToUpload(acceptedFiles[0]);
// setUploadMediaOpen(true);
// }
// }, []);
// const { getRootProps, getInputProps, isDragActive } = useDropzone({
// onDrop,
// accept: {
// "image/*": [".png", ".jpg", ".jpeg", ".gif"],
// },
// multiple: false,
// });
// const handleUploadComplete = async (media: {
// id: string;
// filename: string;
// media_name?: string;
// media_type: number;
// }) => {
// onMediaSelect(media.id);
// };
// return (
// <>
// <Paper
// elevation={2}
// sx={{
// padding: 2,
// display: "flex",
// flexDirection: "column",
// alignItems: "center",
// gap: 1,
// flex: 1,
// minWidth: 150,
// }}
// >
// <Box sx={{ display: "flex", alignItems: "center" }}>
// <Typography variant="subtitle2" gutterBottom sx={{ mb: 0, mr: 0.5 }}>
// {title}
// </Typography>
// </Box>
// <Box
// {...getRootProps()}
// sx={{
// position: "relative",
// width: "200px",
// height: "200px",
// display: "flex",
// alignItems: "center",
// justifyContent: "center",
// borderRadius: 1,
// mb: 1,
// cursor: mediaId ? "pointer" : "default",
// border: isDragActive ? "2px dashed #1976d2" : "none",
// backgroundColor: isDragActive
// ? "rgba(25, 118, 210, 0.04)"
// : "transparent",
// transition: "all 0.2s ease",
// }}
// >
// <input {...getInputProps()} />
// {mediaId && (
// <button
// className="absolute top-2 right-2 z-10"
// onClick={(e) => {
// e.stopPropagation();
// onMediaRemove();
// }}
// >
// <X color="red" />
// </button>
// )}
// {mediaId ? (
// <img
// src={`${
// import.meta.env.VITE_KRBL_MEDIA
// }${mediaId}/download?token=${token}`}
// alt={title}
// style={{ maxWidth: "100%", maxHeight: "100%" }}
// onClick={(e) => {
// e.stopPropagation();
// onPreviewClick(mediaId);
// }}
// />
// ) : (
// <div className="w-full flex flex-col items-center justify-center gap-3">
// <div
// className={`w-full h-20 border rounded-md flex flex-col items-center transition-all duration-300 justify-center border-dashed ${
// isDragActive
// ? "border-blue-500 bg-blue-50"
// : "border-gray-300"
// } cursor-pointer hover:bg-gray-100`}
// >
// <Upload size={24} className="mb-2" />
// <p>
// {isDragActive ? "Отпустите файл здесь" : "Перетащите файл"}
// </p>
// </div>
// <p>или</p>
// <Button
// variant="contained"
// color="primary"
// onClick={(e) => {
// e.stopPropagation();
// onMediaSelect("");
// }}
// >
// Выбрать файл
// </Button>
// </div>
// )}
// </Box>
// </Paper>
// <UploadMediaDialog
// open={uploadMediaOpen}
// onClose={() => {
// setUploadMediaOpen(false);
// setFileToUpload(null);
// }}
// afterUpload={handleUploadComplete}
// />
// </>
// );
// };

View File

@@ -1,4 +1,3 @@
// @widgets/LeftWidgetTab.tsx
import { Box, Button, TextField, Paper, Typography } from "@mui/material"; import { Box, Button, TextField, Paper, Typography } from "@mui/material";
import { import {
BackButton, BackButton,
@@ -50,17 +49,6 @@ export const CreateLeftTab = observer(
const [isSelectMediaDialogOpen, setIsSelectMediaDialogOpen] = const [isSelectMediaDialogOpen, setIsSelectMediaDialogOpen] =
useState(false); useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
// const handleMediaSelected = useCallback(() => {
// // При выборе медиа, обновляем данные для ТЕКУЩЕГО ЯЗЫКА
// // сохраняя текущие heading и body.
// updateSightInfo(language, {
// left: {
// heading: data.left.heading,
// body: data.left.body,
// },
// });
// setIsSelectMediaDialogOpen(false);
// }, [language, data.left.heading, data.left.body]);
const handleCloseArticleDialog = useCallback(() => { const handleCloseArticleDialog = useCallback(() => {
setIsSelectArticleDialogOpen(false); setIsSelectArticleDialogOpen(false);

View File

@@ -10,31 +10,31 @@ import {
import { import {
BackButton, BackButton,
createSightStore, createSightStore,
editSightStore,
languageStore, languageStore,
SelectArticleModal, SelectArticleModal,
TabPanel, TabPanel,
SelectMediaDialog, // Import SelectMediaDialog,
UploadMediaDialog, UploadMediaDialog,
Media, // Import Media,
} from "@shared"; } from "@shared";
import { import {
LanguageSwitcher, LanguageSwitcher,
MediaArea, // Import MediaArea,
MediaAreaForSight, // Import MediaAreaForSight,
ReactMarkdownComponent, ReactMarkdownComponent,
ReactMarkdownEditor, ReactMarkdownEditor,
DeleteModal, DeleteModal,
} from "@widgets"; } from "@widgets";
import { ImagePlus, Plus, Save, Trash2, Unlink, X } from "lucide-react"; // Import X import { ImagePlus, Plus, Save, Trash2, Unlink, X } from "lucide-react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { useState, useEffect } from "react"; // Added useEffect import { useState, useEffect } from "react";
import { MediaViewer } from "../../MediaViewer/index"; import { MediaViewer } from "../../MediaViewer/index";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { authInstance } from "@shared"; import { authInstance } from "@shared";
import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd"; import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd";
type MediaItemShared = { type MediaItemShared = {
// Define if not already available from @shared
id: string; id: string;
filename: string; filename: string;
media_name?: string; media_name?: string;
@@ -52,17 +52,16 @@ export const CreateRightTab = observer(
unlinkPreviewMedia, unlinkPreviewMedia,
createLinkWithRightArticle, createLinkWithRightArticle,
deleteRightArticleMedia, deleteRightArticleMedia,
setFileToUpload, // From store unlinkRightAritcle,
setUploadMediaOpen, // From store
uploadMediaOpen, // From store
unlinkRightAritcle, // Corrected spelling
deleteRightArticle, deleteRightArticle,
linkExistingRightArticle, linkExistingRightArticle,
createSight, createSight,
clearCreateSight, // For resetting form clearCreateSight,
updateRightArticles, updateRightArticles,
} = createSightStore; } = createSightStore;
const { language } = languageStore; const { language } = languageStore;
const { setFileToUpload, setUploadMediaOpen, uploadMediaOpen } =
editSightStore;
const [selectArticleDialogOpen, setSelectArticleDialogOpen] = const [selectArticleDialogOpen, setSelectArticleDialogOpen] =
useState(false); useState(false);
@@ -78,7 +77,7 @@ export const CreateRightTab = observer(
>(null); >(null);
const [previewMedia, setPreviewMedia] = useState<Media | null>(null); const [previewMedia, setPreviewMedia] = useState<Media | null>(null);
// Reset activeArticleIndex if language changes and index is out of bounds
useEffect(() => { useEffect(() => {
if (sight.preview_media) { if (sight.preview_media) {
const fetchMedia = async () => { const fetchMedia = async () => {
@@ -97,7 +96,7 @@ export const CreateRightTab = observer(
activeArticleIndex >= sight[language].right.length activeArticleIndex >= sight[language].right.length
) { ) {
setActiveArticleIndex(null); setActiveArticleIndex(null);
setType("media"); // Default back to media preview if selected article disappears setType("media");
} }
}, [language, sight[language].right, activeArticleIndex]); }, [language, sight[language].right, activeArticleIndex]);
@@ -113,10 +112,9 @@ export const CreateRightTab = observer(
try { try {
await createSight(language); await createSight(language);
toast.success("Достопримечательность успешно создана!"); toast.success("Достопримечательность успешно создана!");
clearCreateSight(); // Reset form clearCreateSight();
setActiveArticleIndex(null); setActiveArticleIndex(null);
setType("media"); setType("media");
// Potentially navigate away: history.push('/sights-list');
} catch (error) { } catch (error) {
console.error("Failed to save sight:", error); console.error("Failed to save sight:", error);
toast.error("Ошибка при создании достопримечательности."); toast.error("Ошибка при создании достопримечательности.");
@@ -132,7 +130,7 @@ export const CreateRightTab = observer(
handleCloseMenu(); handleCloseMenu();
try { try {
const newArticleId = await createNewRightArticle(); const newArticleId = await createNewRightArticle();
// Automatically select the new article if ID is returned
const newIndex = sight[language].right.findIndex( const newIndex = sight[language].right.findIndex(
(a) => a.id === newArticleId (a) => a.id === newArticleId
); );
@@ -140,7 +138,6 @@ export const CreateRightTab = observer(
setActiveArticleIndex(newIndex); setActiveArticleIndex(newIndex);
setType("article"); setType("article");
} else { } else {
// Fallback if findIndex fails (should not happen if store updates correctly)
setActiveArticleIndex(sight[language].right.length - 1); setActiveArticleIndex(sight[language].right.length - 1);
setType("article"); setType("article");
} }
@@ -156,7 +153,7 @@ export const CreateRightTab = observer(
const linkedArticleId = await linkExistingRightArticle( const linkedArticleId = await linkExistingRightArticle(
selectedArticleId selectedArticleId
); );
setSelectArticleDialogOpen(false); // Close dialog setSelectArticleDialogOpen(false);
const newIndex = sight[language].right.findIndex( const newIndex = sight[language].right.findIndex(
(a) => a.id === linkedArticleId (a) => a.id === linkedArticleId
); );
@@ -174,7 +171,6 @@ export const CreateRightTab = observer(
? sight[language].right[activeArticleIndex] ? sight[language].right[activeArticleIndex]
: null; : null;
// Media Handling for Dialogs
const handleOpenUploadMedia = () => { const handleOpenUploadMedia = () => {
setUploadMediaOpen(true); setUploadMediaOpen(true);
}; };
@@ -203,7 +199,6 @@ export const CreateRightTab = observer(
}; };
const handleMediaUploaded = async (media: MediaItemShared) => { const handleMediaUploaded = async (media: MediaItemShared) => {
// After UploadMediaDialog finishes
setUploadMediaOpen(false); setUploadMediaOpen(false);
setFileToUpload(null); setFileToUpload(null);
if (mediaTarget === "sightPreview") { if (mediaTarget === "sightPreview") {
@@ -211,36 +206,25 @@ export const CreateRightTab = observer(
} else if (mediaTarget === "rightArticle" && currentRightArticle) { } else if (mediaTarget === "rightArticle" && currentRightArticle) {
await createLinkWithRightArticle(media, currentRightArticle.id); await createLinkWithRightArticle(media, currentRightArticle.id);
} }
setMediaTarget(null); // Reset target setMediaTarget(null);
}; };
const handleDragEnd = (result: any) => { const handleDragEnd = (result: any) => {
const { source, destination } = result; const { source, destination } = result;
// 1. Guard clause: If dropped outside any droppable area, do nothing.
if (!destination) return; if (!destination) return;
// Extract source and destination indices
const sourceIndex = source.index; const sourceIndex = source.index;
const destinationIndex = destination.index; const destinationIndex = destination.index;
// 2. Guard clause: If dropped in the same position, do nothing.
if (sourceIndex === destinationIndex) return; if (sourceIndex === destinationIndex) return;
// 3. Create a new array with reordered articles:
// - Create a shallow copy of the current articles array.
// This is important for immutability and triggering re-renders.
const newRightArticles = [...sight[language].right]; const newRightArticles = [...sight[language].right];
// - Remove the dragged article from its original position.
// `splice` returns an array of removed items, so we destructure the first (and only) one.
const [movedArticle] = newRightArticles.splice(sourceIndex, 1); const [movedArticle] = newRightArticles.splice(sourceIndex, 1);
// - Insert the moved article into its new position.
newRightArticles.splice(destinationIndex, 0, movedArticle); newRightArticles.splice(destinationIndex, 0, movedArticle);
// 4. Update the store with the new order:
// This will typically trigger a re-render of the component with the updated list.
updateRightArticles(newRightArticles); updateRightArticles(newRightArticles);
}; };
@@ -254,7 +238,7 @@ export const CreateRightTab = observer(
height: "100%", height: "100%",
minHeight: "calc(100vh - 200px)", minHeight: "calc(100vh - 200px)",
gap: 2, gap: 2,
paddingBottom: "70px", // Space for the save button paddingBottom: "70px",
position: "relative", position: "relative",
}} }}
> >
@@ -264,7 +248,6 @@ export const CreateRightTab = observer(
</div> </div>
<Box sx={{ display: "flex", flexGrow: 1, gap: 2.5 }}> <Box sx={{ display: "flex", flexGrow: 1, gap: 2.5 }}>
{/* Left Column: Navigation & Article List */}
<Box className="flex flex-col w-[75%] gap-2"> <Box className="flex flex-col w-[75%] gap-2">
<Box className="w-full flex gap-2 "> <Box className="w-full flex gap-2 ">
<Box className="relative w-[20%] h-[70vh] flex flex-col rounded-2xl overflow-y-auto gap-3 border border-gray-300 p-3"> <Box className="relative w-[20%] h-[70vh] flex flex-col rounded-2xl overflow-y-auto gap-3 border border-gray-300 p-3">
@@ -272,7 +255,6 @@ export const CreateRightTab = observer(
<Box <Box
onClick={() => { onClick={() => {
setType("media"); setType("media");
// setActiveArticleIndex(null); // Optional: deselect article when switching to general media view
}} }}
className={`w-full p-4 rounded-2xl cursor-pointer text-sm hover:bg-gray-300 transition-all duration-300 ${ className={`w-full p-4 rounded-2xl cursor-pointer text-sm hover:bg-gray-300 transition-all duration-300 ${
type === "media" type === "media"
@@ -364,7 +346,6 @@ export const CreateRightTab = observer(
</Menu> </Menu>
</Box> </Box>
{/* Main content area: Article Editor or Sight Media Preview */}
{type === "article" && currentRightArticle ? ( {type === "article" && currentRightArticle ? (
<Box className="w-[80%] border border-gray-300 p-3 flex flex-col gap-2 overflow-hidden"> <Box className="w-[80%] border border-gray-300 p-3 flex flex-col gap-2 overflow-hidden">
<Box className="flex justify-end gap-2 mb-1 flex-shrink-0"> <Box className="flex justify-end gap-2 mb-1 flex-shrink-0">
@@ -375,7 +356,7 @@ export const CreateRightTab = observer(
startIcon={<Unlink color="white" size={18} />} startIcon={<Unlink color="white" size={18} />}
onClick={() => { onClick={() => {
if (currentRightArticle) { if (currentRightArticle) {
unlinkRightAritcle(currentRightArticle.id); // Corrected function name unlinkRightAritcle(currentRightArticle.id);
setActiveArticleIndex(null); setActiveArticleIndex(null);
setType("media"); setType("media");
} }
@@ -435,7 +416,7 @@ export const CreateRightTab = observer(
/> />
</Box> </Box>
<MediaArea <MediaArea
articleId={currentRightArticle.id} // Needs a real ID articleId={currentRightArticle.id}
mediaIds={currentRightArticle.media || []} mediaIds={currentRightArticle.media || []}
onFilesDrop={(files) => { onFilesDrop={(files) => {
if (files.length > 0) { if (files.length > 0) {
@@ -453,7 +434,6 @@ export const CreateRightTab = observer(
</Box> </Box>
) : type === "media" ? ( ) : type === "media" ? (
<Box className="w-[80%] border border-gray-300 rounded-2xl relative flex items-center justify-center"> <Box className="w-[80%] border border-gray-300 rounded-2xl relative flex items-center justify-center">
{sight.preview_media && (
<> <>
{type === "media" && ( {type === "media" && (
<Box className="w-[80%] h-full rounded-2xl relative flex items-center justify-center"> <Box className="w-[80%] h-full rounded-2xl relative flex items-center justify-center">
@@ -481,11 +461,19 @@ export const CreateRightTab = observer(
</Box> </Box>
</> </>
)} )}
</Box>
)}
</>
)}
{!previewMedia && ( {!previewMedia && (
<Box className="w-full h-full flex justify-center items-center">
<Box
sx={{
maxWidth: "500px",
maxHeight: "100%",
display: "flex",
flexGrow: 1,
margin: "0 auto",
justifyContent: "center",
}}
>
<MediaAreaForSight <MediaAreaForSight
onFinishUpload={(mediaId) => { onFinishUpload={(mediaId) => {
linkPreviewMedia(mediaId); linkPreviewMedia(mediaId);
@@ -495,8 +483,13 @@ export const CreateRightTab = observer(
contextType="sight" contextType="sight"
isArticle={false} isArticle={false}
/> />
</Box>
</Box>
)} )}
</Box> </Box>
)}
</>
</Box>
) : ( ) : (
<Box className="w-[80%] h-[70vh] border border-gray-300 rounded-2xl p-3 flex justify-center items-center"> <Box className="w-[80%] h-[70vh] border border-gray-300 rounded-2xl p-3 flex justify-center items-center">
<Typography variant="h6" color="text.secondary"> <Typography variant="h6" color="text.secondary">
@@ -507,7 +500,6 @@ export const CreateRightTab = observer(
</Box> </Box>
</Box> </Box>
{/* Right Column: Live Preview */}
<Box className="w-[25%] mr-10"> <Box className="w-[25%] mr-10">
{type === "article" && activeArticleIndex !== null && ( {type === "article" && activeArticleIndex !== null && (
<Paper <Paper
@@ -662,12 +654,11 @@ export const CreateRightTab = observer(
</Box> </Box>
</Box> </Box>
{/* Sticky Save Button Footer */}
<Box <Box
sx={{ sx={{
position: "absolute", position: "absolute",
bottom: "-20px", bottom: "-20px",
left: 0, // ensure it spans from left left: 0,
right: 0, right: 0,
padding: 2, padding: 2,
backgroundColor: "background.paper", backgroundColor: "background.paper",
@@ -689,19 +680,17 @@ export const CreateRightTab = observer(
</Box> </Box>
</Box> </Box>
{/* Modals */}
<SelectArticleModal <SelectArticleModal
open={selectArticleDialogOpen} open={selectArticleDialogOpen}
onClose={() => setSelectArticleDialogOpen(false)} onClose={() => setSelectArticleDialogOpen(false)}
onSelectArticle={handleSelectExistingArticleAndLink} onSelectArticle={handleSelectExistingArticleAndLink}
// Pass IDs of already linked/added right articles to exclude them from selection
linkedArticleIds={sight[language].right.map((article) => article.id)} linkedArticleIds={sight[language].right.map((article) => article.id)}
/> />
<UploadMediaDialog <UploadMediaDialog
open={uploadMediaOpen} // From store open={uploadMediaOpen}
onClose={() => { onClose={() => {
setUploadMediaOpen(false); setUploadMediaOpen(false);
setFileToUpload(null); // Clear file if dialog is closed without upload setFileToUpload(null);
setMediaTarget(null); setMediaTarget(null);
}} }}
contextObjectName={sight[language].name} contextObjectName={sight[language].name}
@@ -712,7 +701,7 @@ export const CreateRightTab = observer(
? sight[language].right[activeArticleIndex].heading ? sight[language].right[activeArticleIndex].heading
: undefined : undefined
} }
afterUpload={handleMediaUploaded} // This will use the mediaTarget afterUpload={handleMediaUploaded}
/> />
<SelectMediaDialog <SelectMediaDialog
open={isSelectMediaDialogOpen} open={isSelectMediaDialogOpen}

View File

@@ -37,8 +37,8 @@ import { useEffect, useState } from "react";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
// Компонент предупреждающего окна (перенесен сюда)
import { SaveWithoutCityAgree } from "@widgets"; import { SaveWithoutCityAgree } from "@widgets";
import { LinkedStations } from "@pages";
export const InformationTab = observer( export const InformationTab = observer(
({ value, index }: { value: number; index: number }) => { ({ value, index }: { value: number; index: number }) => {
@@ -62,7 +62,7 @@ export const InformationTab = observer(
"thumbnail" | "watermark_lu" | "watermark_rd" | "video_preview" | null "thumbnail" | "watermark_lu" | "watermark_rd" | "video_preview" | null
>(null); >(null);
const { cities } = cityStore; const { cities } = cityStore;
// НОВОЕ СОСТОЯНИЕ ДЛЯ ПРЕДУПРЕЖДАЮЩЕГО ОКНА
const [isSaveWarningOpen, setIsSaveWarningOpen] = useState(false); const [isSaveWarningOpen, setIsSaveWarningOpen] = useState(false);
useEffect(() => {}, [hardcodeType]); useEffect(() => {}, [hardcodeType]);
@@ -119,16 +119,14 @@ export const InformationTab = observer(
updateSightInfo(language, content, common); updateSightInfo(language, content, common);
}; };
// НОВАЯ ФУНКЦИЯ: Фактическое сохранение (вызывается после подтверждения)
const executeSave = async () => { const executeSave = async () => {
await updateSight(); await updateSight();
toast.success("Достопримечательность сохранена"); toast.success("Достопримечательность сохранена");
}; };
// ОБНОВЛЕННАЯ ФУНКЦИЯ: Проверка и вызов окна или сохранения
const handleSave = async () => { const handleSave = async () => {
const isCityMissing = !sight.common.city_id; const isCityMissing = !sight.common.city_id;
// Проверяем названия на всех языках
const isNameMissing = !sight.ru.name || !sight.en.name || !sight.zh.name; const isNameMissing = !sight.ru.name || !sight.en.name || !sight.zh.name;
if (isCityMissing || isNameMissing) { if (isCityMissing || isNameMissing) {
@@ -139,13 +137,11 @@ export const InformationTab = observer(
await executeSave(); await executeSave();
}; };
// Обработчик "Да" в предупреждающем окне
const handleConfirmSave = async () => { const handleConfirmSave = async () => {
setIsSaveWarningOpen(false); setIsSaveWarningOpen(false);
await executeSave(); await executeSave();
}; };
// Обработчик "Нет" в предупреждающем окне
const handleCancelSave = () => { const handleCancelSave = () => {
setIsSaveWarningOpen(false); setIsSaveWarningOpen(false);
}; };
@@ -275,6 +271,19 @@ export const InformationTab = observer(
/> />
</Box> </Box>
<Box sx={{ width: "80%" }}>
{sight.common.id !== 0 && (
<LinkedStations
parentId={sight.common.id}
fields={[
{ label: "Название", data: "name" },
{ label: "Описание", data: "description" },
]}
type="edit"
/>
)}
</Box>
<Box <Box
sx={{ sx={{
display: "flex", display: "flex",
@@ -431,7 +440,7 @@ export const InformationTab = observer(
variant="contained" variant="contained"
color="success" color="success"
startIcon={<Save color="white" size={18} />} startIcon={<Save color="white" size={18} />}
onClick={handleSave} // Используем новую функцию-обработчик onClick={handleSave}
> >
Сохранить Сохранить
</Button> </Button>
@@ -538,7 +547,6 @@ export const InformationTab = observer(
</Dialog> </Dialog>
)} )}
{/* ИНТЕГРИРОВАННОЕ ПРЕДУПРЕЖДАЮЩЕЕ ОКНО */}
{isSaveWarningOpen && ( {isSaveWarningOpen && (
<SaveWithoutCityAgree <SaveWithoutCityAgree
blocker={{ blocker={{

View File

@@ -118,7 +118,7 @@ export const RightWidgetTab = observer(
try { try {
const newArticleId = await createNewRightArticle(); const newArticleId = await createNewRightArticle();
handleClose(); handleClose();
// Automatically select the newly created article
const newIndex = sight[language].right.findIndex( const newIndex = sight[language].right.findIndex(
(article) => article.id === newArticleId (article) => article.id === newArticleId
); );
@@ -144,7 +144,7 @@ export const RightWidgetTab = observer(
try { try {
const linkedArticleId = await linkArticle(id); const linkedArticleId = await linkArticle(id);
handleCloseSelectModal(); handleCloseSelectModal();
// Automatically select the newly linked article
const newIndex = sight[language].right.findIndex( const newIndex = sight[language].right.findIndex(
(article) => article.id === linkedArticleId (article) => article.id === linkedArticleId
); );
@@ -177,30 +177,19 @@ export const RightWidgetTab = observer(
const handleDragEnd = (result: DropResult) => { const handleDragEnd = (result: DropResult) => {
const { source, destination } = result; const { source, destination } = result;
// 1. Guard clause: If dropped outside any droppable area, do nothing.
if (!destination) return; if (!destination) return;
// Extract source and destination indices
const sourceIndex = source.index; const sourceIndex = source.index;
const destinationIndex = destination.index; const destinationIndex = destination.index;
// 2. Guard clause: If dropped in the same position, do nothing.
if (sourceIndex === destinationIndex) return; if (sourceIndex === destinationIndex) return;
// 3. Create a new array with reordered articles:
// - Create a shallow copy of the current articles array.
// This is important for immutability and triggering re-renders.
const newRightArticles = [...sight[language].right]; const newRightArticles = [...sight[language].right];
// - Remove the dragged article from its original position.
// `splice` returns an array of removed items, so we destructure the first (and only) one.
const [movedArticle] = newRightArticles.splice(sourceIndex, 1); const [movedArticle] = newRightArticles.splice(sourceIndex, 1);
// - Insert the moved article into its new position.
newRightArticles.splice(destinationIndex, 0, movedArticle); newRightArticles.splice(destinationIndex, 0, movedArticle);
// 4. Update the store with the new order:
// This will typically trigger a re-render of the component with the updated list.
updateRightArticles(newRightArticles); updateRightArticles(newRightArticles);
}; };
@@ -426,21 +415,12 @@ export const RightWidgetTab = observer(
media_type: previewMedia.media_type, media_type: previewMedia.media_type,
filename: previewMedia.filename || "", filename: previewMedia.filename || "",
}} }}
fullWidth
fullHeight
/> />
</Box> </Box>
</> </>
)} )}
{!previewMedia && (
<MediaAreaForSight
onFinishUpload={(mediaId) => {
linkPreviewMedia(mediaId);
}}
onFilesDrop={() => {}}
contextObjectName={sight[language].name}
contextType="sight"
isArticle={false}
/>
)}
</Box> </Box>
)} )}
</> </>

View File

@@ -11,6 +11,7 @@ interface VideoPreviewCardProps {
onDeleteVideoClick: () => void; onDeleteVideoClick: () => void;
onSelectVideoClick: (file?: File) => void; onSelectVideoClick: (file?: File) => void;
tooltipText?: string; tooltipText?: string;
className?: string;
} }
export const VideoPreviewCard: React.FC<VideoPreviewCardProps> = ({ export const VideoPreviewCard: React.FC<VideoPreviewCardProps> = ({
@@ -20,15 +21,15 @@ export const VideoPreviewCard: React.FC<VideoPreviewCardProps> = ({
onDeleteVideoClick, onDeleteVideoClick,
onSelectVideoClick, onSelectVideoClick,
tooltipText, tooltipText,
className,
}) => { }) => {
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const [isDragOver, setIsDragOver] = useState(false); const [isDragOver, setIsDragOver] = useState(false);
const token = localStorage.getItem("token"); const token = localStorage.getItem("token");
useEffect(() => {}, [isDragOver]); useEffect(() => {}, [isDragOver]);
// --- Click to select file ---
const handleZoneClick = () => { const handleZoneClick = () => {
// Trigger the hidden file input click
fileInputRef.current?.click(); fileInputRef.current?.click();
}; };
@@ -38,19 +39,17 @@ export const VideoPreviewCard: React.FC<VideoPreviewCardProps> = ({
const file = event.target.files?.[0]; const file = event.target.files?.[0];
if (file) { if (file) {
if (file.type.startsWith("video/")) { if (file.type.startsWith("video/")) {
// Открываем диалог загрузки медиа с файлом видео
onSelectVideoClick(file); onSelectVideoClick(file);
} else { } else {
toast.error("Пожалуйста, выберите видео файл"); toast.error("Пожалуйста, выберите видео файл");
} }
} }
// Reset the input value so selecting the same file again triggers change
event.target.value = ""; event.target.value = "";
}; };
// --- Drag and Drop Handlers ---
const handleDragOver = (event: DragEvent<HTMLDivElement>) => { const handleDragOver = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault(); // Crucial to allow a drop event.preventDefault();
event.stopPropagation(); event.stopPropagation();
setIsDragOver(true); setIsDragOver(true);
}; };
@@ -62,7 +61,7 @@ export const VideoPreviewCard: React.FC<VideoPreviewCardProps> = ({
}; };
const handleDrop = async (event: DragEvent<HTMLDivElement>) => { const handleDrop = async (event: DragEvent<HTMLDivElement>) => {
event.preventDefault(); // Crucial to allow a drop event.preventDefault();
event.stopPropagation(); event.stopPropagation();
setIsDragOver(false); setIsDragOver(false);
@@ -70,7 +69,6 @@ export const VideoPreviewCard: React.FC<VideoPreviewCardProps> = ({
if (files && files.length > 0) { if (files && files.length > 0) {
const file = files[0]; const file = files[0];
if (file.type.startsWith("video/")) { if (file.type.startsWith("video/")) {
// Открываем диалог загрузки медиа с файлом видео
onSelectVideoClick(file); onSelectVideoClick(file);
} else { } else {
toast.error("Пожалуйста, выберите видео файл"); toast.error("Пожалуйста, выберите видео файл");
@@ -89,7 +87,10 @@ export const VideoPreviewCard: React.FC<VideoPreviewCardProps> = ({
gap: 1, gap: 1,
flex: 1, flex: 1,
minWidth: 150, minWidth: 150,
width: "min-content",
mx: "auto",
}} }}
className={className}
> >
<Box sx={{ display: "flex", alignItems: "center" }}> <Box sx={{ display: "flex", alignItems: "center" }}>
<Typography variant="subtitle2" gutterBottom sx={{ mb: 0, mr: 0.5 }}> <Typography variant="subtitle2" gutterBottom sx={{ mb: 0, mr: 0.5 }}>
@@ -127,7 +128,10 @@ export const VideoPreviewCard: React.FC<VideoPreviewCardProps> = ({
</button> </button>
)} )}
{videoId ? ( {videoId ? (
<Box sx={{ position: "relative", width: "100%", height: "100%" }}> <Box
sx={{ position: "relative", width: "100%", height: "100%" }}
className={className}
>
<video <video
src={`${ src={`${
import.meta.env.VITE_KRBL_MEDIA import.meta.env.VITE_KRBL_MEDIA
@@ -167,7 +171,7 @@ export const VideoPreviewCard: React.FC<VideoPreviewCardProps> = ({
borderRadius: 1, borderRadius: 1,
cursor: "pointer", cursor: "pointer",
}} }}
onClick={handleZoneClick} // Click handler for the zone onClick={handleZoneClick}
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}
onDrop={handleDrop} onDrop={handleDrop}
@@ -181,8 +185,8 @@ export const VideoPreviewCard: React.FC<VideoPreviewCardProps> = ({
color="primary" color="primary"
startIcon={<Plus color="white" size={18} />} startIcon={<Plus color="white" size={18} />}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); // Prevent `handleZoneClick` from firing e.stopPropagation();
onSelectVideoClick(); // This button triggers the media selection dialog onSelectVideoClick();
}} }}
> >
Выбрать файл Выбрать файл
@@ -193,7 +197,7 @@ export const VideoPreviewCard: React.FC<VideoPreviewCardProps> = ({
ref={fileInputRef} ref={fileInputRef}
onChange={handleFileInputChange} onChange={handleFileInputChange}
style={{ display: "none" }} style={{ display: "none" }}
accept="video/*" // Accept only video files accept="video/*"
/> />
</div> </div>
)} )}

View File

@@ -20,18 +20,6 @@ interface EditStationModalProps {
onClose: () => void; onClose: () => void;
} }
const transferFields = [
{ key: "bus", label: "Автобус" },
{ key: "metro_blue", label: "Метро (синяя)" },
{ key: "metro_green", label: "Метро (зеленая)" },
{ key: "metro_orange", label: "Метро (оранжевая)" },
{ key: "metro_purple", label: "Метро (фиолетовая)" },
{ key: "metro_red", label: "Метро (красная)" },
{ key: "train", label: "Электричка" },
{ key: "tram", label: "Трамвай" },
{ key: "trolleybus", label: "Троллейбус" },
];
export const EditStationModal = observer( export const EditStationModal = observer(
({ open, onClose }: EditStationModalProps) => { ({ open, onClose }: EditStationModalProps) => {
const { id: routeId } = useParams<{ id: string }>(); const { id: routeId } = useParams<{ id: string }>();
@@ -95,37 +83,6 @@ export const EditStationModal = observer(
defaultValue={station?.offset_y} defaultValue={station?.offset_y}
/> />
</Box> </Box>
<Box sx={{ mt: 4 }}>
<Typography variant="h6" gutterBottom>
Пересадки
</Typography>
<Box
sx={{
display: "grid",
gridTemplateColumns: "repeat(3, 1fr)",
gap: 2,
}}
>
{transferFields.map(({ key, label }) => (
<TextField
key={key}
label={label}
name={key}
fullWidth
defaultValue={station?.transfers?.[key]}
onChange={(e) => {
setRouteStations(Number(routeId), selectedStationId, {
...station,
transfers: {
...station?.transfers,
[key]: e.target.value,
},
});
}}
/>
))}
</Box>
</Box>
</DialogContent> </DialogContent>
<DialogActions sx={{ p: 3, justifyContent: "flex-end" }}> <DialogActions sx={{ p: 3, justifyContent: "flex-end" }}>
<Button onClick={handleSave} variant="contained" color="primary"> <Button onClick={handleSave} variant="contained" color="primary">

View File

@@ -0,0 +1,168 @@
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
TextField,
Typography,
IconButton,
Box,
} from "@mui/material";
import { stationsStore, languageStore } from "@shared";
import { observer } from "mobx-react-lite";
import { ArrowLeft } from "lucide-react";
import { toast } from "react-toastify";
import { useState, useEffect } from "react";
interface EditStationTransfersModalProps {
open: boolean;
onClose: () => void;
stationId: number | null;
}
const transferFields = [
{ key: "bus", label: "Автобус" },
{ key: "metro_blue", label: "Метро (синяя)" },
{ key: "metro_green", label: "Метро (зеленая)" },
{ key: "metro_orange", label: "Метро (оранжевая)" },
{ key: "metro_purple", label: "Метро (фиолетовая)" },
{ key: "metro_red", label: "Метро (красная)" },
{ key: "train", label: "Электричка" },
{ key: "tram", label: "Трамвай" },
{ key: "trolleybus", label: "Троллейбус" },
];
export const EditStationTransfersModal = observer(
({ open, onClose, stationId }: EditStationTransfersModalProps) => {
const { stationLists, updateStationTransfers } = stationsStore;
const { language } = languageStore;
const [transfers, setTransfers] = useState<{
bus: string;
metro_blue: string;
metro_green: string;
metro_orange: string;
metro_purple: string;
metro_red: string;
train: string;
tram: string;
trolleybus: string;
}>({
bus: "",
metro_blue: "",
metro_green: "",
metro_orange: "",
metro_purple: "",
metro_red: "",
train: "",
tram: "",
trolleybus: "",
});
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
if (open && stationId) {
let station = stationLists[language].data.find(
(s: any) => s.id === stationId
);
if (!station?.transfers) {
for (const lang of ["ru", "en", "zh"] as const) {
const foundStation = stationLists[lang].data.find(
(s: any) => s.id === stationId
);
if (foundStation?.transfers) {
station = foundStation;
break;
}
}
}
if (station?.transfers) {
setTransfers(station.transfers);
}
}
}, [open, stationId, stationLists]);
const handleSave = async () => {
if (!stationId) return;
try {
setIsLoading(true);
await updateStationTransfers(stationId, transfers);
toast.success("Пересадки успешно обновлены");
const { getStationList } = stationsStore;
await getStationList();
onClose();
} catch (error) {
console.error("Error updating transfers:", error);
toast.error("Ошибка при обновлении пересадок");
} finally {
setIsLoading(false);
}
};
const handleTransferChange = (key: string, value: string) => {
setTransfers((prev) => ({
...prev,
[key]: value,
}));
};
return (
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
<DialogTitle>
<Box display="flex" alignItems="center" gap={2}>
<IconButton onClick={onClose}>
<ArrowLeft />
</IconButton>
<Box>
<Typography variant="caption" color="text.secondary">
Станции / Редактировать пересадки
</Typography>
<Typography variant="h6">Редактирование пересадок</Typography>
</Box>
</Box>
</DialogTitle>
<DialogContent>
<Box sx={{ mt: 2 }}>
<Typography variant="h6" gutterBottom>
Пересадки
</Typography>
<Box
sx={{
display: "grid",
gridTemplateColumns: "repeat(3, 1fr)",
gap: 2,
mt: 2,
}}
>
{transferFields.map(({ key, label }) => (
<TextField
key={key}
label={label}
name={key}
fullWidth
value={transfers[key as keyof typeof transfers] || ""}
onChange={(e) => handleTransferChange(key, e.target.value)}
/>
))}
</Box>
</Box>
</DialogContent>
<DialogActions sx={{ p: 3, justifyContent: "flex-end" }}>
<Button onClick={onClose} variant="outlined">
Отмена
</Button>
<Button
onClick={handleSave}
variant="contained"
color="primary"
disabled={isLoading}
>
{isLoading ? "Сохранение..." : "Сохранить"}
</Button>
</DialogActions>
</Dialog>
);
}
);

View File

@@ -1,2 +1,3 @@
export * from "./SelectArticleDialog"; export * from "./SelectArticleDialog";
export * from "./EditStationModal"; export * from "./EditStationModal";
export * from "./EditStationTransfersModal";

File diff suppressed because one or more lines are too long

View File

@@ -3,56 +3,6 @@ import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite"; import tailwindcss from "@tailwindcss/vite";
import path from "path"; import path from "path";
type ManualChunksFn = (id: string, api: { getModuleIds: () => Iterable<string> }) => string | undefined;
const manualChunks: ManualChunksFn = (id) => {
if (id.includes('node_modules')) {
if (
id.includes('three.') ||
id.includes('@react-three') ||
id.includes('ol/') ||
id.includes('mapbox-gl') ||
id.includes('@babel/runtime')
) {
return 'vendor-3d-maps';
}
if (id.includes('codemirror') || id.includes('react-codemirror2')) {
return 'vendor-codemirror';
}
if (id.includes('hls.js')) {
return 'vendor-hls';
}
if (id.includes('pixi.js')) {
return 'vendor-pixijs';
}
if (id.includes('@mui/material') || id.includes('@mui/icons-material') || id.includes('@mui/x-data-grid')) {
return 'vendor-mui-core';
}
if (id.includes('/react/') || id.includes('/react-dom/')) {
return 'vendor-react-core';
}
if (id.includes('react-router') || id.includes('history')) {
return 'vendor-router';
}
return 'vendor-common-remainder';
}
if (id.includes('src/pages/')) {
const pathParts = id.split('src/pages/');
if (pathParts.length > 1) {
return 'page-' + pathParts[1].split('/')[0].toLowerCase();
}
}
};
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [
react(), react(),
@@ -70,16 +20,7 @@ export default defineConfig({
}, },
build: { build: {
chunkSizeWarningLimit: 2000, chunkSizeWarningLimit: 5000,
},
});
rollupOptions: {
output: {
manualChunks,
entryFileNames: `assets/[name]-[hash].js`,
chunkFileNames: `assets/[name]-[hash].js`,
assetFileNames: `assets/[name]-[hash].[ext]`,
}
}
}
}) as UserConfigExport;

479
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.7.0"
resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.7.0.tgz#135de4e8858763989112281bdf38ca02439db7c3"
integrity sha512-pJdKGq/1iquWYtv1RRSljZklxHCOCAJFJrImO5ZLKPJVJlVUcs8yFwNQlqS0Lo8xT1VAXXTCZocF9n26FWEKsw==
dependencies:
"@emnapi/wasi-threads" "1.1.0"
tslib "^2.4.0"
"@emnapi/runtime@^1.5.0":
version "1.7.0"
resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.7.0.tgz#d7ef3832df8564fe5903bf0567aedbd19538ecbe"
integrity sha512-oAYoQnCYaQZKVS53Fq23ceWMRxq5EhQsE0x0RdQ55jT7wagMu5k+fS39v1fiSLrtrLQlXwVINenqhLMtTrV/1Q==
dependencies:
tslib "^2.4.0"
"@emnapi/wasi-threads@1.1.0", "@emnapi/wasi-threads@^1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz#60b2102fddc9ccb78607e4a3cf8403ea69be41bf"
integrity sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==
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,15 @@
"@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.0.7"
resolved "https://registry.yarnpkg.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.0.7.tgz#dcfea99a75f06209a235f3d941e3460a51e9b14c"
integrity sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw==
dependencies:
"@emnapi/core" "^1.5.0"
"@emnapi/runtime" "^1.5.0"
"@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 +710,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 +728,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 +780,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 +812,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 +976,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 +1018,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 +1124,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 +1200,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==
@@ -904,7 +1234,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==
@@ -951,7 +1281,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==
@@ -970,7 +1300,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==
@@ -1018,7 +1348,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==
@@ -1046,7 +1376,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==
@@ -1062,7 +1392,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==
@@ -1145,7 +1475,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==
@@ -1249,7 +1579,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==
@@ -1546,7 +1876,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==
@@ -1689,7 +2019,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==
@@ -1815,12 +2145,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==
@@ -2284,7 +2609,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==
@@ -2363,12 +2688,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==
@@ -2812,7 +3187,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==
@@ -2993,12 +3368,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==
@@ -3055,7 +3430,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==
@@ -3116,19 +3491,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==
@@ -3162,7 +3532,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==
@@ -3189,7 +3559,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==
@@ -3226,12 +3596,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==
@@ -3317,7 +3687,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==
@@ -3503,7 +3873,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==
@@ -3535,7 +3905,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==
@@ -3605,9 +3975,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:
@@ -3634,7 +4004,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==
@@ -3715,7 +4085,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==
@@ -3737,6 +4107,11 @@ utility-types@^3.11.0:
resolved "https://registry.npmjs.org/utility-types/-/utility-types-3.11.0.tgz" resolved "https://registry.npmjs.org/utility-types/-/utility-types-3.11.0.tgz"
integrity sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw== integrity sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==
uuid@^13.0.0:
version "13.0.0"
resolved "https://registry.yarnpkg.com/uuid/-/uuid-13.0.0.tgz#263dc341b19b4d755eb8fe36b78d95a6b65707e8"
integrity sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==
vfile-location@^5.0.0: vfile-location@^5.0.0:
version "5.0.3" version "5.0.3"
resolved "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz" resolved "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz"
@@ -3770,7 +4145,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==