26 Commits

Author SHA1 Message Date
73070fe233 v1.0.3 2026-02-28 20:00:21 +03:00
7cf188a55c fix: do only after ot 2026-02-22 19:45:03 +03:00
2a9449ba58 feat: add txt export 2026-02-22 19:37:02 +03:00
1c097a4ca2 v1.0.2 2026-02-22 19:36:53 +03:00
048848faa0 v1.0.1 2026-02-20 19:04:04 +03:00
8fe6505249 fix: fix bug with stations in the route 2026-02-04 20:29:09 +03:00
58abe15ec4 fix: fix null password 2026-02-02 15:58:27 +03:00
144e7cb00c fix: update 3d models 2026-02-02 11:06:33 +03:00
d557664b25 feat: big major update 2026-02-02 04:00:37 +03:00
bbab6fc46a v1.0.0 2026-01-28 14:41:00 +03:00
25155a66bc feat: update tail_number to string 2026-01-07 18:16:59 +03:00
a3d574a79c feat: pagination for tables with deleted station directions 2025-12-24 15:43:25 +03:00
39e11ad5ca feat: add deleting vehicles in device page 2025-12-10 15:20:53 +03:00
7e068e49f5 feat: update transfers 2025-12-07 19:36:49 +03:00
79539d0583 fix: add anchor for station 2025-11-28 02:36:03 +03:00
c5c5f835bc feat: update map anchor for station and desciprtion search 2025-11-27 20:17:23 +03:00
5481d264e0 feat: update center route-preview and align buttons 2025-11-27 10:58:51 +03:00
d6772b1e3a feat: add loader for saving route and description for stations 2025-11-26 21:45:59 +03:00
11133b6839 fix: update preview-page bug fix sights 2025-11-26 19:37:26 +03:00
aaeaed3fa5 fix: delete try catch in try catch... 2025-11-26 13:33:55 +03:00
95fe297aae feat: update route center calculating 2025-11-26 04:29:19 +03:00
04a9ac452e feat: snapshot realization update for polling usage 2025-11-24 16:16:57 +03:00
85c71563c1 feat: Improving page loading 2025-11-20 20:17:52 +03:00
6f32c6e671 feat: Update left sidebar and fetch queries 2025-11-12 03:14:03 +03:00
0a6192c7da feat: Корректировки 07.11.25 2025-11-11 09:13:58 +03:00
b1ba3b4cd5 feat: Update 2025-11-11 03:33:26 +03:00
79 changed files with 8133 additions and 3397 deletions

7
.env
View File

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

View File

@@ -1,7 +1,7 @@
{ {
"name": "white-nights", "name": "white-nights",
"private": true, "private": true,
"version": "0.0.0", "version": "1.0.3",
"type": "module", "type": "module",
"license": "UNLICENSED", "license": "UNLICENSED",
"scripts": { "scripts": {
@@ -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

@@ -22,6 +22,7 @@ import {
CityCreatePage, CityCreatePage,
CarrierCreatePage, CarrierCreatePage,
VehicleCreatePage, VehicleCreatePage,
VehicleEditPage,
CountryEditPage, CountryEditPage,
CityEditPage, CityEditPage,
UserCreatePage, UserCreatePage,
@@ -51,7 +52,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}</>;
@@ -59,13 +62,18 @@ 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}</>;
}; };
@@ -146,6 +154,7 @@ const router = createBrowserRouter([
{ path: "station/:id", element: <StationPreviewPage /> }, { path: "station/:id", element: <StationPreviewPage /> },
{ path: "station/:id/edit", element: <StationEditPage /> }, { path: "station/:id/edit", element: <StationEditPage /> },
{ path: "vehicle/create", element: <VehicleCreatePage /> }, { path: "vehicle/create", element: <VehicleCreatePage /> },
{ path: "vehicle/:id/edit", element: <VehicleEditPage /> },
{ path: "article", element: <ArticleListPage /> }, { path: "article", element: <ArticleListPage /> },
{ path: "article/:id", element: <ArticlePreviewPage /> }, { path: "article/:id", element: <ArticlePreviewPage /> },
], ],

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

@@ -17,6 +17,10 @@ export const ArticleListPage = observer(() => {
const { language } = languageStore; const { language } = languageStore;
const [ids, setIds] = useState<number[]>([]); const [ids, setIds] = useState<number[]>([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [paginationModel, setPaginationModel] = useState({
page: 0,
pageSize: 50,
});
useEffect(() => { useEffect(() => {
const fetchArticles = async () => { const fetchArticles = async () => {
@@ -83,31 +87,41 @@ export const ArticleListPage = observer(() => {
<h1 className="text-2xl">Статьи</h1> <h1 className="text-2xl">Статьи</h1>
</div> </div>
<div {ids.length > 0 && (
className="flex justify-end mb-5 duration-300" <div className="flex justify-end mb-5 duration-300">
style={{ opacity: ids.length > 0 ? 1 : 0 }} <button
> className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
<button onClick={() => setIsBulkDeleteModalOpen(true)}
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center" >
onClick={() => setIsBulkDeleteModalOpen(true)} <Trash2 size={20} className="text-white" /> Удалить выбранные (
> {ids.length})
<Trash2 size={20} className="text-white" /> Удалить выбранные ( </button>
{ids.length}) </div>
</button> )}
</div>
<div className="w-full"> <div className="w-full">
<DataGrid <DataGrid
rows={rows} rows={rows}
columns={columns} columns={columns}
hideFooterPagination
checkboxSelection checkboxSelection
disableRowSelectionExcludeModel
loading={isLoading} loading={isLoading}
paginationModel={paginationModel}
onPaginationModelChange={setPaginationModel}
pageSizeOptions={[50]}
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText} localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
onRowSelectionModelChange={(newSelection) => { onRowSelectionModelChange={(newSelection: any) => {
setIds(Array.from(newSelection.ids) as number[]); if (Array.isArray(newSelection)) {
const selectedIds = newSelection.map((id: string | number) => Number(id));
setIds(selectedIds);
} else if (newSelection && typeof newSelection === 'object' && 'ids' in newSelection) {
const idsSet = newSelection.ids as Set<string | number>;
const selectedIds = Array.from(idsSet).map((id: string | number) => Number(id));
setIds(selectedIds);
} else {
setIds([]);
}
}} }}
hideFooter
slots={{ slots={{
noRowsOverlay: () => ( noRowsOverlay: () => (
<Box <Box

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) {
await getArticle(Number(id), language); setIsLoadingData(true);
await getArticleMedia(Number(id)); try {
await getArticlePreview(Number(id)); await getArticle(Number(id), language);
await getArticleMedia(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

@@ -8,8 +8,8 @@ import {
InputLabel, InputLabel,
} 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, Loader2, Save } from "lucide-react";
import { Loader2 } from "lucide-react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { import {
@@ -17,15 +17,14 @@ import {
cityStore, cityStore,
mediaStore, mediaStore,
languageStore, languageStore,
isMediaIdEmpty,
useSelectedCity, useSelectedCity,
} from "@shared";
import { useState, useEffect } from "react";
import { ImageUploadCard, LanguageSwitcher } from "@widgets";
import {
SelectMediaDialog, SelectMediaDialog,
UploadMediaDialog, UploadMediaDialog,
PreviewMediaDialog, PreviewMediaDialog,
} from "@shared"; } from "@shared";
import { useState, useEffect } from "react";
import { ImageUploadCard, LanguageSwitcher } from "@widgets";
export const CarrierCreatePage = observer(() => { export const CarrierCreatePage = observer(() => {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -56,7 +55,7 @@ export const CarrierCreatePage = observer(() => {
selectedCityId, selectedCityId,
createCarrierData[language].slogan, createCarrierData[language].slogan,
selectedMediaId || "", selectedMediaId || "",
language language,
); );
} }
}, [selectedCityId, createCarrierData.city_id]); }, [selectedCityId, createCarrierData.city_id]);
@@ -88,13 +87,17 @@ export const CarrierCreatePage = observer(() => {
createCarrierData.city_id, createCarrierData.city_id,
createCarrierData[language].slogan, createCarrierData[language].slogan,
media.id, media.id,
language language,
); );
}; };
const selectedMedia = selectedMediaId const selectedMedia =
? mediaStore.media.find((m) => m.id === selectedMediaId) selectedMediaId && !isMediaIdEmpty(selectedMediaId)
: null; ? mediaStore.media.find((m) => m.id === selectedMediaId)
: null;
const effectiveLogoUrl = isMediaIdEmpty(selectedMediaId)
? null
: selectedMedia?.id ?? selectedMediaId ?? null;
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">
@@ -127,7 +130,7 @@ export const CarrierCreatePage = observer(() => {
e.target.value as number, e.target.value as number,
createCarrierData[language].slogan, createCarrierData[language].slogan,
selectedMediaId || "", selectedMediaId || "",
language language,
) )
} }
> >
@@ -151,7 +154,7 @@ export const CarrierCreatePage = observer(() => {
createCarrierData.city_id, createCarrierData.city_id,
createCarrierData[language].slogan, createCarrierData[language].slogan,
selectedMediaId || "", selectedMediaId || "",
language language,
) )
} }
/> />
@@ -168,7 +171,7 @@ export const CarrierCreatePage = observer(() => {
createCarrierData.city_id, createCarrierData.city_id,
createCarrierData[language].slogan, createCarrierData[language].slogan,
selectedMediaId || "", selectedMediaId || "",
language language,
) )
} }
/> />
@@ -184,7 +187,7 @@ export const CarrierCreatePage = observer(() => {
createCarrierData.city_id, createCarrierData.city_id,
e.target.value, e.target.value,
selectedMediaId || "", selectedMediaId || "",
language language,
) )
} }
/> />
@@ -193,10 +196,10 @@ export const CarrierCreatePage = observer(() => {
<ImageUploadCard <ImageUploadCard
title="Логотип перевозчика" title="Логотип перевозчика"
imageKey="thumbnail" imageKey="thumbnail"
imageUrl={selectedMedia?.id} imageUrl={effectiveLogoUrl}
onImageClick={() => { onImageClick={() => {
setIsPreviewMediaOpen(true); setIsPreviewMediaOpen(true);
setMediaId(selectedMedia?.id ?? ""); setMediaId(effectiveLogoUrl ?? "");
}} }}
onDeleteImageClick={() => { onDeleteImageClick={() => {
setSelectedMediaId(null); setSelectedMediaId(null);
@@ -207,7 +210,7 @@ export const CarrierCreatePage = observer(() => {
createCarrierData.city_id, createCarrierData.city_id,
createCarrierData[language].slogan, createCarrierData[language].slogan,
"", "",
language language,
); );
}} }}
onSelectFileClick={() => { onSelectFileClick={() => {

View File

@@ -6,13 +6,21 @@ 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,
isMediaIdEmpty,
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 +36,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,39 +48,48 @@ export const CarrierEditPage = observer(() => {
useEffect(() => { useEffect(() => {
(async () => { (async () => {
await cityStore.getCities("ru"); if (!id) {
await cityStore.getCities("en"); setIsLoadingData(false);
await cityStore.getCities("zh"); return;
const carrierData = await getCarrier(Number(id));
if (carrierData) {
setEditCarrierData(
carrierData.ru?.full_name || "",
carrierData.ru?.short_name || "",
carrierData.ru?.city_id || 0,
carrierData.ru?.slogan || "",
carrierData.ru?.logo || "",
"ru"
);
setEditCarrierData(
carrierData.en?.full_name || "",
carrierData.en?.short_name || "",
carrierData.en?.city_id || 0,
carrierData.en?.slogan || "",
carrierData.en?.logo || "",
"en"
);
setEditCarrierData(
carrierData.zh?.full_name || "",
carrierData.zh?.short_name || "",
carrierData.zh?.city_id || 0,
carrierData.zh?.slogan || "",
carrierData.zh?.logo || "",
"zh"
);
} }
setIsLoadingData(true);
try {
await cityStore.getCities("ru");
await cityStore.getCities("en");
await cityStore.getCities("zh");
const carrierData = await getCarrier(Number(id));
mediaStore.getMedia(); if (carrierData) {
setEditCarrierData(
carrierData.ru?.full_name || "",
carrierData.ru?.short_name || "",
carrierData.ru?.city_id || 0,
carrierData.ru?.slogan || "",
carrierData.ru?.logo || "",
"ru"
);
setEditCarrierData(
carrierData.en?.full_name || "",
carrierData.en?.short_name || "",
carrierData.en?.city_id || 0,
carrierData.en?.slogan || "",
carrierData.en?.logo || "",
"en"
);
setEditCarrierData(
carrierData.zh?.full_name || "",
carrierData.zh?.short_name || "",
carrierData.zh?.city_id || 0,
carrierData.zh?.slogan || "",
carrierData.zh?.logo || "",
"zh"
);
}
await mediaStore.getMedia();
} finally {
setIsLoadingData(false);
}
})(); })();
languageStore.setLanguage("ru"); languageStore.setLanguage("ru");
@@ -106,9 +124,28 @@ export const CarrierEditPage = observer(() => {
); );
}; };
const selectedMedia = editCarrierData.logo const selectedMedia =
? mediaStore.media.find((m) => m.id === editCarrierData.logo) editCarrierData.logo && !isMediaIdEmpty(editCarrierData.logo)
: null; ? mediaStore.media.find((m) => m.id === editCarrierData.logo)
: null;
const effectiveLogoUrl = isMediaIdEmpty(editCarrierData.logo)
? null
: (selectedMedia?.id ?? editCarrierData.logo);
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">
@@ -206,10 +243,10 @@ export const CarrierEditPage = observer(() => {
<ImageUploadCard <ImageUploadCard
title="Логотип перевозчика" title="Логотип перевозчика"
imageKey="thumbnail" imageKey="thumbnail"
imageUrl={selectedMedia?.id} imageUrl={effectiveLogoUrl}
onImageClick={() => { onImageClick={() => {
setIsPreviewMediaOpen(true); setIsPreviewMediaOpen(true);
setMediaId(selectedMedia?.id ?? ""); setMediaId(effectiveLogoUrl ?? "");
}} }}
onDeleteImageClick={() => { onDeleteImageClick={() => {
setIsDeleteLogoModalOpen(true); setIsDeleteLogoModalOpen(true);

View File

@@ -17,6 +17,10 @@ export const CarrierListPage = observer(() => {
const [rowId, setRowId] = useState<number | null>(null); const [rowId, setRowId] = 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 [paginationModel, setPaginationModel] = useState({
page: 0,
pageSize: 50,
});
const { language } = languageStore; const { language } = languageStore;
useEffect(() => { useEffect(() => {
@@ -129,28 +133,39 @@ export const CarrierListPage = observer(() => {
<CreateButton label="Создать перевозчика" path="/carrier/create" /> <CreateButton label="Создать перевозчика" path="/carrier/create" />
</div> </div>
<div {ids.length > 0 && (
className="flex justify-end mb-5 duration-300" <div className="flex justify-end mb-5 duration-300">
style={{ opacity: ids.length > 0 ? 1 : 0 }} <button
> className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
<button onClick={() => setIsBulkDeleteModalOpen(true)}
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center" >
onClick={() => setIsBulkDeleteModalOpen(true)} <Trash2 size={20} className="text-white" /> Удалить выбранные (
> {ids.length})
<Trash2 size={20} className="text-white" /> Удалить выбранные ( </button>
{ids.length}) </div>
</button> )}
</div>
<DataGrid <DataGrid
rows={rows} rows={rows}
columns={columns} columns={columns}
hideFooter
checkboxSelection checkboxSelection
disableRowSelectionExcludeModel
loading={isLoading} loading={isLoading}
paginationModel={paginationModel}
onPaginationModelChange={setPaginationModel}
pageSizeOptions={[50]}
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText} localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
onRowSelectionModelChange={(newSelection) => { onRowSelectionModelChange={(newSelection: any) => {
setIds(Array.from(newSelection.ids as unknown as number[])); if (Array.isArray(newSelection)) {
const selectedIds = newSelection.map((id: string | number) => Number(id));
setIds(selectedIds);
} else if (newSelection && typeof newSelection === 'object' && 'ids' in newSelection) {
const idsSet = newSelection.ids as Set<string | number>;
const selectedIds = Array.from(idsSet).map((id: string | number) => Number(id));
setIds(selectedIds);
} else {
setIds([]);
}
}} }}
slots={{ slots={{
noRowsOverlay: () => ( noRowsOverlay: () => (

View File

@@ -12,14 +12,18 @@ import { ArrowLeft, Save } from "lucide-react";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { cityStore, countryStore, languageStore, mediaStore } from "@shared";
import { useState, useEffect } from "react";
import { LanguageSwitcher, ImageUploadCard } from "@widgets";
import { import {
cityStore,
countryStore,
languageStore,
mediaStore,
isMediaIdEmpty,
SelectMediaDialog, SelectMediaDialog,
UploadMediaDialog, UploadMediaDialog,
PreviewMediaDialog, PreviewMediaDialog,
} from "@shared"; } from "@shared";
import { useState, useEffect } from "react";
import { LanguageSwitcher, ImageUploadCard } from "@widgets";
export const CityCreatePage = observer(() => { export const CityCreatePage = observer(() => {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -72,9 +76,13 @@ export const CityCreatePage = observer(() => {
); );
}; };
const selectedMedia = createCityData.arms const selectedMedia =
? mediaStore.media.find((m) => m.id === createCityData.arms) createCityData.arms && !isMediaIdEmpty(createCityData.arms)
: null; ? mediaStore.media.find((m) => m.id === createCityData.arms)
: null;
const effectiveArmsUrl = isMediaIdEmpty(createCityData.arms)
? null
: (selectedMedia?.id ?? createCityData.arms);
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">
@@ -135,10 +143,10 @@ export const CityCreatePage = observer(() => {
<ImageUploadCard <ImageUploadCard
title="Герб города" title="Герб города"
imageKey="image" imageKey="image"
imageUrl={selectedMedia?.id} imageUrl={effectiveArmsUrl}
onImageClick={() => { onImageClick={() => {
setIsPreviewMediaOpen(true); setIsPreviewMediaOpen(true);
setMediaId(selectedMedia?.id ?? ""); setMediaId(effectiveArmsUrl ?? "");
}} }}
onDeleteImageClick={() => { onDeleteImageClick={() => {
setCreateCityData( setCreateCityData(

View File

@@ -6,10 +6,10 @@ 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, Loader2, Save } 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 { import {
@@ -17,19 +17,20 @@ import {
countryStore, countryStore,
languageStore, languageStore,
mediaStore, mediaStore,
isMediaIdEmpty,
CashedCities, CashedCities,
} from "@shared"; LoadingSpinner,
import { useEffect, useState } from "react";
import { LanguageSwitcher, ImageUploadCard } from "@widgets";
import {
SelectMediaDialog, SelectMediaDialog,
UploadMediaDialog, UploadMediaDialog,
PreviewMediaDialog, PreviewMediaDialog,
} from "@shared"; } from "@shared";
import { useEffect, useState } from "react";
import { LanguageSwitcher, ImageUploadCard } from "@widgets";
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);
@@ -62,19 +63,26 @@ export const CityEditPage = observer(() => {
useEffect(() => { useEffect(() => {
(async () => { (async () => {
if (id) { if (id) {
await getCountries("ru"); setIsLoadingData(true);
try {
await getCountries("ru");
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");
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");
await getOneMedia(ruData.arms as string); await getOneMedia(ruData.arms as string);
await getMedia(); await getMedia();
} finally {
setIsLoadingData(false);
}
} else {
setIsLoadingData(false);
} }
})(); })();
}, [id]); }, [id]);
@@ -89,13 +97,32 @@ export const CityEditPage = observer(() => {
editCityData[language].name, editCityData[language].name,
editCityData.country_code, editCityData.country_code,
media.id, media.id,
language language,
); );
}; };
const selectedMedia = editCityData.arms const selectedMedia =
? mediaStore.media.find((m) => m.id === editCityData.arms) editCityData.arms && !isMediaIdEmpty(editCityData.arms)
: null; ? mediaStore.media.find((m) => m.id === editCityData.arms)
: null;
const effectiveArmsUrl = isMediaIdEmpty(editCityData.arms)
? null
: selectedMedia?.id ?? editCityData.arms;
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">
@@ -124,7 +151,7 @@ export const CityEditPage = observer(() => {
e.target.value, e.target.value,
editCityData.country_code, editCityData.country_code,
editCityData.arms, editCityData.arms,
language language,
) )
} }
/> />
@@ -140,7 +167,7 @@ export const CityEditPage = observer(() => {
editCityData[language].name, editCityData[language].name,
e.target.value, e.target.value,
editCityData.arms, editCityData.arms,
language language,
); );
}} }}
> >
@@ -156,17 +183,17 @@ export const CityEditPage = observer(() => {
<ImageUploadCard <ImageUploadCard
title="Герб города" title="Герб города"
imageKey="image" imageKey="image"
imageUrl={selectedMedia?.id} imageUrl={effectiveArmsUrl}
onImageClick={() => { onImageClick={() => {
setIsPreviewMediaOpen(true); setIsPreviewMediaOpen(true);
setMediaId(selectedMedia?.id ?? ""); setMediaId(effectiveArmsUrl ?? "");
}} }}
onDeleteImageClick={() => { onDeleteImageClick={() => {
setEditCityData( setEditCityData(
editCityData[language].name, editCityData[language].name,
editCityData.country_code, editCityData.country_code,
"", "",
language language,
); );
setActiveMenuType(null); setActiveMenuType(null);
}} }}

View File

@@ -18,6 +18,10 @@ export const CityListPage = observer(() => {
const [ids, setIds] = useState<number[]>([]); const [ids, setIds] = useState<number[]>([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [rows, setRows] = useState<any[]>([]); const [rows, setRows] = useState<any[]>([]);
const [paginationModel, setPaginationModel] = useState({
page: 0,
pageSize: 50,
});
const { language } = languageStore; const { language } = languageStore;
useEffect(() => { useEffect(() => {
@@ -128,28 +132,39 @@ export const CityListPage = observer(() => {
<CreateButton label="Создать город" path="/city/create" /> <CreateButton label="Создать город" path="/city/create" />
</div> </div>
<div {ids.length > 0 && (
className="flex justify-end mb-5 duration-300" <div className="flex justify-end mb-5 duration-300">
style={{ opacity: ids.length > 0 ? 1 : 0 }} <button
> className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
<button onClick={() => setIsBulkDeleteModalOpen(true)}
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center" >
onClick={() => setIsBulkDeleteModalOpen(true)} <Trash2 size={20} className="text-white" /> Удалить выбранные (
> {ids.length})
<Trash2 size={20} className="text-white" /> Удалить выбранные ( </button>
{ids.length}) </div>
</button> )}
</div>
<DataGrid <DataGrid
rows={rows} rows={rows}
columns={columns} columns={columns}
hideFooter
checkboxSelection checkboxSelection
disableRowSelectionExcludeModel
loading={isLoading} loading={isLoading}
paginationModel={paginationModel}
onPaginationModelChange={setPaginationModel}
pageSizeOptions={[50]}
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText} localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
onRowSelectionModelChange={(newSelection) => { onRowSelectionModelChange={(newSelection: any) => {
setIds(Array.from(newSelection.ids as unknown as number[])); if (Array.isArray(newSelection)) {
const selectedIds = newSelection.map((id: string | number) => Number(id));
setIds(selectedIds);
} else if (newSelection && typeof newSelection === 'object' && 'ids' in newSelection) {
const idsSet = newSelection.ids as Set<string | number>;
const selectedIds = Array.from(idsSet).map((id: string | number) => Number(id));
setIds(selectedIds);
} else {
setIds([]);
}
}} }}
slots={{ slots={{
noRowsOverlay: () => ( noRowsOverlay: () => (

View File

@@ -1,16 +1,17 @@
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 } =
@@ -35,17 +36,39 @@ export const CountryEditPage = observer(() => {
useEffect(() => { useEffect(() => {
(async () => { (async () => {
if (id) { if (id) {
const ruData = await getCountry(id as string, "ru"); setIsLoadingData(true);
const enData = await getCountry(id as string, "en"); try {
const zhData = await getCountry(id as string, "zh"); const ruData = await getCountry(id as string, "ru");
const enData = await getCountry(id as string, "en");
const zhData = await getCountry(id as string, "zh");
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

@@ -16,6 +16,10 @@ export const CountryListPage = observer(() => {
const [rowId, setRowId] = useState<string | null>(null); const [rowId, setRowId] = useState<string | null>(null);
const [ids, setIds] = useState<number[]>([]); const [ids, setIds] = useState<number[]>([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [paginationModel, setPaginationModel] = useState({
page: 0,
pageSize: 50,
});
const { language } = languageStore; const { language } = languageStore;
useEffect(() => { useEffect(() => {
@@ -93,28 +97,39 @@ export const CountryListPage = observer(() => {
<CreateButton label="Добавить страну" path="/country/add" /> <CreateButton label="Добавить страну" path="/country/add" />
</div> </div>
<div {ids.length > 0 && (
className="flex justify-end mb-5 duration-300" <div className="flex justify-end mb-5 duration-300">
style={{ opacity: ids.length > 0 ? 1 : 0 }} <button
> className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
<button onClick={() => setIsBulkDeleteModalOpen(true)}
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center" >
onClick={() => setIsBulkDeleteModalOpen(true)} <Trash2 size={20} className="text-white" /> Удалить выбранные (
> {ids.length})
<Trash2 size={20} className="text-white" /> Удалить выбранные ( </button>
{ids.length}) </div>
</button> )}
</div>
<DataGrid <DataGrid
rows={rows || []} rows={rows || []}
columns={columns} columns={columns}
hideFooter
checkboxSelection checkboxSelection
disableRowSelectionExcludeModel
loading={isLoading} loading={isLoading}
paginationModel={paginationModel}
onPaginationModelChange={setPaginationModel}
pageSizeOptions={[50]}
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText} localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
onRowSelectionModelChange={(newSelection) => { onRowSelectionModelChange={(newSelection: any) => {
setIds(Array.from(newSelection.ids as unknown as number[])); if (Array.isArray(newSelection)) {
const selectedIds = newSelection.map((id: string | number) => Number(id));
setIds(selectedIds);
} else if (newSelection && typeof newSelection === 'object' && 'ids' in newSelection) {
const idsSet = newSelection.ids as Set<string | number>;
const selectedIds = Array.from(idsSet).map((id: string | number) => Number(id));
setIds(selectedIds);
} else {
setIds([]);
}
}} }}
slots={{ slots={{
noRowsOverlay: () => ( noRowsOverlay: () => (

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,13 +39,22 @@ export const EditSightPage = observer(() => {
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
if (id) { if (id) {
await getCities("ru"); setIsLoadingData(true);
await getSightInfo(+id, "ru"); try {
await getSightInfo(+id, "en"); await getCities("ru");
await getSightInfo(+id, "zh"); await getSightInfo(+id, "ru");
await getArticles("ru"); await getSightInfo(+id, "en");
await getArticles("en"); await getSightInfo(+id, "zh");
await getArticles("zh"); await getArticles("ru");
await getArticles("en");
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 ? (
<div className="flex-1"> <Box
<InformationTab value={value} index={0} /> sx={{
<LeftWidgetTab value={value} index={1} /> display: "flex",
<RightWidgetTab value={value} index={2} /> justifyContent: "center",
</div> alignItems: "center",
minHeight: "60vh",
}}
>
<LoadingSpinner message="Загрузка данных достопримечательности..." />
</Box>
) : (
sight.common.id !== 0 && (
<div className="flex-1">
<InformationTab value={value} index={0} />
<LeftWidgetTab value={value} index={1} />
<RightWidgetTab value={value} index={2} />
</div>
)
)} )}
{blocker.state === "blocked" ? <LeaveAgree blocker={blocker} /> : null} {blocker.state === "blocked" ? <LeaveAgree blocker={blocker} /> : null}

View File

@@ -124,6 +124,7 @@ export const clearMapCaches = () => {
interface ApiRoute { interface ApiRoute {
id: number; id: number;
route_number: string; route_number: string;
route_name: string;
path: [number, number][]; path: [number, number][];
center_latitude: number; center_latitude: number;
center_longitude: number; center_longitude: number;
@@ -185,7 +186,7 @@ const saveHiddenRoutes = (hiddenRoutes: Set<number>): void => {
try { try {
localStorage.setItem( localStorage.setItem(
HIDDEN_ROUTES_KEY, HIDDEN_ROUTES_KEY,
JSON.stringify(Array.from(hiddenRoutes)) JSON.stringify(Array.from(hiddenRoutes)),
); );
} catch (error) { } catch (error) {
console.warn("Failed to save hidden routes:", error); console.warn("Failed to save hidden routes:", error);
@@ -220,7 +221,7 @@ class MapStore {
try { try {
localStorage.setItem( localStorage.setItem(
HIDE_SIGHTS_BY_HIDDEN_ROUTES_KEY, HIDE_SIGHTS_BY_HIDDEN_ROUTES_KEY,
JSON.stringify(!!val) JSON.stringify(!!val),
); );
} catch (e) {} } catch (e) {}
} }
@@ -238,7 +239,7 @@ class MapStore {
private sortFeatures<T extends ApiStation | ApiSight>( private sortFeatures<T extends ApiStation | ApiSight>(
features: T[], features: T[],
sortType: SortType sortType: SortType,
): T[] { ): T[] {
const sorted = [...features]; const sorted = [...features];
switch (sortType) { switch (sortType) {
@@ -323,7 +324,7 @@ class MapStore {
return this.sortedStations; return this.sortedStations;
} }
return this.sortedStations.filter( return this.sortedStations.filter(
(station) => station.city_id === selectedCityId (station) => station.city_id === selectedCityId,
); );
} }
@@ -364,12 +365,13 @@ class MapStore {
const response = await languageInstance("ru").get("/route"); const response = await languageInstance("ru").get("/route");
const routesIds = response.data.map((route: any) => route.id); const routesIds = response.data.map((route: any) => route.id);
const routePromises = routesIds.map((id: number) => const routePromises = routesIds.map((id: number) =>
languageInstance("ru").get(`/route/${id}`) languageInstance("ru").get(`/route/${id}`),
); );
const routeResponses = await Promise.all(routePromises); const routeResponses = await Promise.all(routePromises);
this.routes = routeResponses.map((res) => ({ this.routes = routeResponses.map((res) => ({
id: res.data.id, id: res.data.id,
route_number: res.data.route_number, route_number: res.data.route_number,
route_name: res.data.route_name || "",
path: res.data.path, path: res.data.path,
center_latitude: res.data.center_latitude, center_latitude: res.data.center_latitude,
center_longitude: res.data.center_longitude, center_longitude: res.data.center_longitude,
@@ -377,7 +379,7 @@ class MapStore {
})); }));
this.routes = this.routes.sort((a, b) => this.routes = this.routes.sort((a, b) =>
a.route_number.localeCompare(b.route_number) a.route_number.localeCompare(b.route_number),
); );
await this.preloadRouteStations(routesIds); await this.preloadRouteStations(routesIds);
@@ -389,14 +391,14 @@ class MapStore {
const stationPromises = routesIds.map(async (routeId) => { const stationPromises = routesIds.map(async (routeId) => {
try { try {
const stationsResponse = await languageInstance("ru").get( const stationsResponse = await languageInstance("ru").get(
`/route/${routeId}/station` `/route/${routeId}/station`,
); );
const stationIds = stationsResponse.data.map((s: any) => s.id); const stationIds = stationsResponse.data.map((s: any) => s.id);
this.routeStationsCache.set(routeId, stationIds); this.routeStationsCache.set(routeId, stationIds);
} catch (error) { } catch (error) {
console.error( console.error(
`Failed to preload stations for route ${routeId}:`, `Failed to preload stations for route ${routeId}:`,
error error,
); );
} }
}); });
@@ -407,7 +409,7 @@ class MapStore {
const sightPromises = routesIds.map(async (routeId) => { const sightPromises = routesIds.map(async (routeId) => {
try { try {
const sightsResponse = await languageInstance("ru").get( const sightsResponse = await languageInstance("ru").get(
`/route/${routeId}/sight` `/route/${routeId}/sight`,
); );
const sightIds = sightsResponse.data.map((s: any) => s.id); const sightIds = sightsResponse.data.map((s: any) => s.id);
this.routeSightsCache.set(routeId, sightIds); this.routeSightsCache.set(routeId, sightIds);
@@ -486,22 +488,12 @@ class MapStore {
const route_number = properties.name || "Маршрут 1"; const route_number = properties.name || "Маршрут 1";
const path = geometry.coordinates.map((c: any) => [c[1], c[0]]); const path = geometry.coordinates.map((c: any) => [c[1], c[0]]);
const lineGeom = new GeoJSON().readGeometry(geometry, {
dataProjection: "EPSG:4326",
featureProjection: "EPSG:3857",
});
const centerCoords = getCenter(lineGeom.getExtent());
const [center_longitude, center_latitude] = toLonLat(
centerCoords,
"EPSG:3857"
);
let carrier_id = 0; let carrier_id = 0;
let carrier = ""; let carrier = "";
if (selectedCityStore.selectedCityId) { if (selectedCityStore.selectedCityId) {
const carriersInCity = carrierStore.carriers.ru.data.filter( const carriersInCity = carrierStore.carriers.ru.data.filter(
(c: any) => c.city_id === selectedCityStore.selectedCityId (c: any) => c.city_id === selectedCityStore.selectedCityId,
); );
if (carriersInCity.length > 0) { if (carriersInCity.length > 0) {
@@ -513,8 +505,8 @@ class MapStore {
const routeData = { const routeData = {
route_number, route_number,
path, path,
center_latitude, center_latitude: path[0][0],
center_longitude, center_longitude: path[0][1],
carrier, carrier,
carrier_id, carrier_id,
governor_appeal: 0, governor_appeal: 0,
@@ -529,7 +521,7 @@ class MapStore {
if (!carrier_id && selectedCityStore.selectedCityId) { if (!carrier_id && selectedCityStore.selectedCityId) {
toast.error( toast.error(
"В выбранном городе нет доступных перевозчиков, маршрут отображается в общем списке" "В выбранном городе нет доступных перевозчиков, маршрут отображается в общем списке",
); );
} }
createdItem = routeStore.routes.data[routeStore.routes.data.length - 1]; createdItem = routeStore.routes.data[routeStore.routes.data.length - 1];
@@ -581,7 +573,7 @@ class MapStore {
const centerCoords = getCenter(lineGeom.getExtent()); const centerCoords = getCenter(lineGeom.getExtent());
const [center_longitude, center_latitude] = toLonLat( const [center_longitude, center_latitude] = toLonLat(
centerCoords, centerCoords,
"EPSG:3857" "EPSG:3857",
); );
data = { data = {
route_number: properties.name, route_number: properties.name,
@@ -614,7 +606,7 @@ class MapStore {
return; return;
} }
throw new Error( throw new Error(
`Could not find old data for ${featureType} with id ${numericId}` `Could not find old data for ${featureType} with id ${numericId}`,
); );
} }
@@ -634,7 +626,7 @@ class MapStore {
const response = await languageInstance("ru").patch( const response = await languageInstance("ru").patch(
`/${featureType}/${numericId}`, `/${featureType}/${numericId}`,
requestBody requestBody,
); );
const updateStore = (store: any[], updatedItem: any) => { const updateStore = (store: any[], updatedItem: any) => {
@@ -753,7 +745,7 @@ class MapService {
private selectInteraction: Select; private selectInteraction: Select;
private hoveredFeatureId: string | number | null; private hoveredFeatureId: string | number | null;
private boundHandlePointerMove: ( private boundHandlePointerMove: (
event: MapBrowserEvent<PointerEvent> event: MapBrowserEvent<PointerEvent>,
) => void; ) => void;
private boundHandlePointerLeave: () => void; private boundHandlePointerLeave: () => void;
private boundHandleContextMenu: (event: MouseEvent) => void; private boundHandleContextMenu: (event: MouseEvent) => void;
@@ -792,7 +784,7 @@ class MapService {
onFeaturesChange: (features: Feature<Geometry>[]) => void, onFeaturesChange: (features: Feature<Geometry>[]) => void,
onFeatureSelect: (feature: Feature<Geometry> | null) => void, onFeatureSelect: (feature: Feature<Geometry> | null) => void,
tooltipElement: HTMLElement, tooltipElement: HTMLElement,
onSelectionChange?: (ids: Set<string | number>) => void onSelectionChange?: (ids: Set<string | number>) => void,
) { ) {
this.map = null; this.map = null;
this.tooltipElement = tooltipElement; this.tooltipElement = tooltipElement;
@@ -941,7 +933,7 @@ class MapService {
style: (featureLike: FeatureLike) => { style: (featureLike: FeatureLike) => {
const clusterFeature = featureLike as Feature<Point>; const clusterFeature = featureLike as Feature<Point>;
const featuresInCluster = clusterFeature.get( const featuresInCluster = clusterFeature.get(
"features" "features",
) as Feature<Point>[]; ) as Feature<Point>[];
const size = featuresInCluster.length; const size = featuresInCluster.length;
@@ -999,18 +991,18 @@ class MapService {
this.pointSource.on( this.pointSource.on(
"addfeature", "addfeature",
this.handleFeatureEvent.bind(this) as any this.handleFeatureEvent.bind(this) as any,
); );
this.pointSource.on("removefeature", () => this.updateFeaturesInReact()); this.pointSource.on("removefeature", () => this.updateFeaturesInReact());
this.pointSource.on( this.pointSource.on(
"changefeature", "changefeature",
this.handleFeatureChange.bind(this) as any this.handleFeatureChange.bind(this) as any,
); );
this.lineSource.on("addfeature", this.handleFeatureEvent.bind(this) as any); this.lineSource.on("addfeature", this.handleFeatureEvent.bind(this) as any);
this.lineSource.on("removefeature", () => this.updateFeaturesInReact()); this.lineSource.on("removefeature", () => this.updateFeaturesInReact());
this.lineSource.on( this.lineSource.on(
"changefeature", "changefeature",
this.handleFeatureChange.bind(this) as any this.handleFeatureChange.bind(this) as any,
); );
let renderCompleteHandled = false; let renderCompleteHandled = false;
@@ -1064,7 +1056,7 @@ class MapService {
if (center && zoom !== undefined && this.map) { if (center && zoom !== undefined && this.map) {
const [lon, lat] = toLonLat( const [lon, lat] = toLonLat(
center, center,
this.map.getView().getProjection() this.map.getView().getProjection(),
); );
saveMapPosition({ center: [lon, lat], zoom }); saveMapPosition({ center: [lon, lat], zoom });
} }
@@ -1076,7 +1068,7 @@ class MapService {
if (center && zoom !== undefined && this.map) { if (center && zoom !== undefined && this.map) {
const [lon, lat] = toLonLat( const [lon, lat] = toLonLat(
center, center,
this.map.getView().getProjection() this.map.getView().getProjection(),
); );
saveMapPosition({ center: [lon, lat], zoom }); saveMapPosition({ center: [lon, lat], zoom });
} }
@@ -1197,7 +1189,7 @@ class MapService {
const feature = this.map?.forEachFeatureAtPixel( const feature = this.map?.forEachFeatureAtPixel(
event.pixel, event.pixel,
(f: FeatureLike) => f as Feature<Geometry>, (f: FeatureLike) => f as Feature<Geometry>,
{ layerFilter, hitTolerance: 5 } { layerFilter, hitTolerance: 5 },
); );
if (!feature) return; if (!feature) return;
@@ -1235,7 +1227,7 @@ class MapService {
} }
const newCoordinates = coordinates.filter( const newCoordinates = coordinates.filter(
(_, index) => index !== closestIndex (_, index) => index !== closestIndex,
); );
lineString.setCoordinates(newCoordinates); lineString.setCoordinates(newCoordinates);
this.saveModifiedFeature(feature); this.saveModifiedFeature(feature);
@@ -1278,7 +1270,7 @@ class MapService {
selected.add(f.getId()!); selected.add(f.getId()!);
} }
} }
} },
); );
this.setSelectedIds(selected); this.setSelectedIds(selected);
@@ -1425,7 +1417,7 @@ class MapService {
public loadFeaturesFromApi( public loadFeaturesFromApi(
_apiStations: typeof mapStore.stations, _apiStations: typeof mapStore.stations,
_apiRoutes: typeof mapStore.routes, _apiRoutes: typeof mapStore.routes,
_apiSights: typeof mapStore.sights _apiSights: typeof mapStore.sights,
): void { ): void {
if (!this.map) return; if (!this.map) return;
@@ -1458,8 +1450,8 @@ class MapService {
transform( transform(
[station.longitude, station.latitude], [station.longitude, station.latitude],
"EPSG:4326", "EPSG:4326",
projection projection,
) ),
); );
const feature = new Feature({ geometry: point, name: station.name }); const feature = new Feature({ geometry: point, name: station.name });
feature.setId(`station-${station.id}`); feature.setId(`station-${station.id}`);
@@ -1470,7 +1462,7 @@ class MapService {
filteredSights.forEach((sight) => { filteredSights.forEach((sight) => {
if (sight.longitude == null || sight.latitude == null) return; if (sight.longitude == null || sight.latitude == null) return;
const point = new Point( const point = new Point(
transform([sight.longitude, sight.latitude], "EPSG:4326", projection) transform([sight.longitude, sight.latitude], "EPSG:4326", projection),
); );
const feature = new Feature({ const feature = new Feature({
geometry: point, geometry: point,
@@ -1490,7 +1482,7 @@ class MapService {
const coordinates = route.path const coordinates = route.path
.filter((c) => c && c[0] != null && c[1] != null) .filter((c) => c && c[0] != null && c[1] != null)
.map((c: [number, number]) => .map((c: [number, number]) =>
transform([c[1], c[0]], "EPSG:4326", projection) transform([c[1], c[0]], "EPSG:4326", projection),
); );
if (coordinates.length === 0) return; if (coordinates.length === 0) return;
@@ -1576,7 +1568,7 @@ class MapService {
public startDrawing( public startDrawing(
type: "Point" | "LineString", type: "Point" | "LineString",
featureType: FeatureType featureType: FeatureType,
): void { ): void {
if (!this.map) return; if (!this.map) return;
@@ -1740,7 +1732,7 @@ class MapService {
this.map.forEachFeatureAtPixel( this.map.forEachFeatureAtPixel(
event.pixel, event.pixel,
(f: FeatureLike) => f as Feature<Geometry>, (f: FeatureLike) => f as Feature<Geometry>,
{ layerFilter, hitTolerance: 5 } { layerFilter, hitTolerance: 5 },
); );
let finalFeature: Feature<Geometry> | null = null; let finalFeature: Feature<Geometry> | null = null;
@@ -1815,7 +1807,7 @@ class MapService {
public deleteFeature( public deleteFeature(
featureId: string | number | undefined, featureId: string | number | undefined,
recourse: string recourse: string,
): void { ): void {
if (featureId === undefined) return; if (featureId === undefined) return;
@@ -1871,7 +1863,7 @@ class MapService {
const lineFeature = this.lineSource.getFeatureById(id); const lineFeature = this.lineSource.getFeatureById(id);
if (lineFeature) if (lineFeature)
this.lineSource.removeFeature( this.lineSource.removeFeature(
lineFeature as Feature<LineString> lineFeature as Feature<LineString>,
); );
const pointFeature = this.pointSource.getFeatureById(id); const pointFeature = this.pointSource.getFeatureById(id);
if (pointFeature) if (pointFeature)
@@ -1898,11 +1890,11 @@ class MapService {
if (targetEl instanceof HTMLElement) { if (targetEl instanceof HTMLElement) {
targetEl.removeEventListener( targetEl.removeEventListener(
"contextmenu", "contextmenu",
this.boundHandleContextMenu this.boundHandleContextMenu,
); );
targetEl.removeEventListener( targetEl.removeEventListener(
"pointerleave", "pointerleave",
this.boundHandlePointerLeave this.boundHandlePointerLeave,
); );
} }
this.map.un("pointermove", this.boundHandlePointerMove as any); this.map.un("pointermove", this.boundHandlePointerMove as any);
@@ -1915,7 +1907,7 @@ class MapService {
} }
private handleFeatureEvent( private handleFeatureEvent(
event: VectorSourceEvent<Feature<Geometry>> event: VectorSourceEvent<Feature<Geometry>>,
): void { ): void {
if (!event.feature) return; if (!event.feature) return;
const feature = event.feature; const feature = event.feature;
@@ -1926,7 +1918,7 @@ class MapService {
} }
private handleFeatureChange( private handleFeatureChange(
event: VectorSourceEvent<Feature<Geometry>> event: VectorSourceEvent<Feature<Geometry>>,
): void { ): void {
if (!event.feature) return; if (!event.feature) return;
this.updateFeaturesInReact(); this.updateFeaturesInReact();
@@ -1964,7 +1956,7 @@ class MapService {
}); });
this.modifyInteraction.setActive( this.modifyInteraction.setActive(
this.selectInteraction.getFeatures().getLength() > 0 this.selectInteraction.getFeatures().getLength() > 0,
); );
this.clusterLayer.changed(); this.clusterLayer.changed();
this.routeLayer.changed(); this.routeLayer.changed();
@@ -2034,7 +2026,7 @@ class MapService {
if (typeof featureId === "number" || !String(featureId).includes("-")) { if (typeof featureId === "number" || !String(featureId).includes("-")) {
console.warn( console.warn(
"Skipping save for feature with non-standard ID:", "Skipping save for feature with non-standard ID:",
featureId featureId,
); );
return; return;
} }
@@ -2082,7 +2074,7 @@ class MapService {
try { try {
const createdFeatureData = await mapStore.createFeature( const createdFeatureData = await mapStore.createFeature(
featureType, featureType,
featureGeoJSON featureGeoJSON,
); );
const newFeatureId = `${featureType}-${createdFeatureData.id}`; const newFeatureId = `${featureType}-${createdFeatureData.id}`;
@@ -2101,8 +2093,8 @@ class MapService {
const lineGeom = new LineString( const lineGeom = new LineString(
routeData.path.map((c) => routeData.path.map((c) =>
transform([c[1], c[0]], "EPSG:4326", projection) transform([c[1], c[0]], "EPSG:4326", projection),
) ),
); );
feature.setGeometry(lineGeom); feature.setGeometry(lineGeom);
} else { } else {
@@ -2204,8 +2196,8 @@ const MapControls: React.FC<MapControlsProps> = ({
isDisabled isDisabled
? "bg-gray-200 text-gray-400 cursor-not-allowed" ? "bg-gray-200 text-gray-400 cursor-not-allowed"
: isActive : isActive
? "bg-blue-600 text-white shadow-md hover:bg-blue-700" ? "bg-blue-600 text-white shadow-md hover:bg-blue-700"
: "bg-gray-100 text-gray-700 hover:bg-blue-100 hover:text-blue-700" : "bg-gray-100 text-gray-700 hover:bg-blue-100 hover:text-blue-700"
}`; }`;
return ( return (
<button <button
@@ -2255,7 +2247,7 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
const actualFeatures = useMemo( const actualFeatures = useMemo(
() => mapFeatures.filter((f) => !f.get("isProxy")), () => mapFeatures.filter((f) => !f.get("isProxy")),
[mapFeatures] [mapFeatures],
); );
const allFeatures = useMemo(() => { const allFeatures = useMemo(() => {
@@ -2265,8 +2257,8 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
transform( transform(
[station.longitude, station.latitude], [station.longitude, station.latitude],
"EPSG:4326", "EPSG:4326",
"EPSG:3857" "EPSG:3857",
) ),
), ),
name: station.name, name: station.name,
description: station.description || "", description: station.description || "",
@@ -2283,8 +2275,8 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
transform( transform(
[sight.longitude, sight.latitude], [sight.longitude, sight.latitude],
"EPSG:4326", "EPSG:4326",
"EPSG:3857" "EPSG:3857",
) ),
), ),
name: sight.name, name: sight.name,
description: sight.description, description: sight.description,
@@ -2302,6 +2294,8 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
}); });
feature.setId(`route-${route.id}`); feature.setId(`route-${route.id}`);
feature.set("featureType", "route"); feature.set("featureType", "route");
feature.set("routeName", route.route_name);
feature.set("routeNumber", route.route_number);
return feature; return feature;
}); });
@@ -2317,11 +2311,18 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
const filteredFeatures = useMemo(() => { const filteredFeatures = useMemo(() => {
if (!searchQuery.trim()) return allFeatures; if (!searchQuery.trim()) return allFeatures;
return allFeatures.filter((f) => const normalizedQuery = searchQuery.toLowerCase();
((f.get("name") as string) || "") return allFeatures.filter((f) => {
.toLowerCase() const candidates = [
.includes(searchQuery.toLowerCase()) (f.get("name") as string) || "",
); (f.get("description") as string) || "",
(f.get("routeName") as string) || "",
(f.get("routeNumber") as string) || "",
];
return candidates.some((value) =>
value.toLowerCase().includes(normalizedQuery),
);
});
}, [allFeatures, searchQuery]); }, [allFeatures, searchQuery]);
const handleFeatureClick = useCallback( const handleFeatureClick = useCallback(
@@ -2342,7 +2343,7 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
mapService.selectFeature(id); mapService.selectFeature(id);
} }
}, },
[mapService, selectedIds, setSelectedIds] [mapService, selectedIds, setSelectedIds],
); );
const handleDeleteFeature = useCallback( const handleDeleteFeature = useCallback(
@@ -2352,7 +2353,7 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
mapService.deleteFeature(id, resource); mapService.deleteFeature(id, resource);
} }
}, },
[mapService] [mapService],
); );
const handleCheckboxChange = useCallback( const handleCheckboxChange = useCallback(
@@ -2364,14 +2365,14 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
setSelectedIds(newSet); setSelectedIds(newSet);
mapService.setSelectedIds(newSet); mapService.setSelectedIds(newSet);
}, },
[mapService, selectedIds, setSelectedIds] [mapService, selectedIds, setSelectedIds],
); );
const handleBulkDelete = useCallback(() => { const handleBulkDelete = useCallback(() => {
if (!mapService || selectedIds.size === 0) return; if (!mapService || selectedIds.size === 0) return;
if ( if (
window.confirm( window.confirm(
`Вы уверены, что хотите удалить ${selectedIds.size} объект(ов)?` `Вы уверены, что хотите удалить ${selectedIds.size} объект(ов)?`,
) )
) { ) {
mapService.deleteMultipleFeatures(Array.from(selectedIds)); mapService.deleteMultipleFeatures(Array.from(selectedIds));
@@ -2385,7 +2386,7 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
if (!featureType || !numericId) return; if (!featureType || !numericId) return;
navigate(`/${featureType}/${numericId}/edit`); navigate(`/${featureType}/${numericId}/edit`);
}, },
[navigate] [navigate],
); );
const handleHideRoute = useCallback( const handleHideRoute = useCallback(
@@ -2412,7 +2413,7 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
const coordinates = route.path const coordinates = route.path
.filter((c) => c && c[0] != null && c[1] != null) .filter((c) => c && c[0] != null && c[1] != null)
.map((c: [number, number]) => .map((c: [number, number]) =>
transform([c[1], c[0]], "EPSG:4326", projection) transform([c[1], c[0]], "EPSG:4326", projection),
); );
if (coordinates.length > 0) { if (coordinates.length > 0) {
@@ -2434,7 +2435,7 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
const visibleRouteIds = allRouteIds.filter( const visibleRouteIds = allRouteIds.filter(
(id: number) => (id: number) =>
id !== numericRouteId && !mapStore.hiddenRoutes.has(id) id !== numericRouteId && !mapStore.hiddenRoutes.has(id),
); );
const stationsInVisibleRoutes = new Set<number>(); const stationsInVisibleRoutes = new Set<number>();
@@ -2442,12 +2443,12 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
const stationIds = const stationIds =
mapStore.routeStationsCache.get(otherRouteId) || []; mapStore.routeStationsCache.get(otherRouteId) || [];
stationIds.forEach((id: number) => stationIds.forEach((id: number) =>
stationsInVisibleRoutes.add(id) stationsInVisibleRoutes.add(id),
); );
}); });
const stationsToShow = routeStationIds.filter( const stationsToShow = routeStationIds.filter(
(id: number) => !stationsInVisibleRoutes.has(id) (id: number) => !stationsInVisibleRoutes.has(id),
); );
for (const stationId of stationsToShow) { for (const stationId of stationsToShow) {
@@ -2458,8 +2459,8 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
transform( transform(
[station.longitude, station.latitude], [station.longitude, station.latitude],
"EPSG:4326", "EPSG:4326",
projection projection,
) ),
); );
const feature = new Feature({ const feature = new Feature({
geometry: point, geometry: point,
@@ -2469,7 +2470,7 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
feature.set("featureType", "station"); feature.set("featureType", "station");
const existingFeature = mapService.pointSource.getFeatureById( const existingFeature = mapService.pointSource.getFeatureById(
`station-${station.id}` `station-${station.id}`,
); );
if (!existingFeature) { if (!existingFeature) {
mapService.pointSource.addFeature(feature); mapService.pointSource.addFeature(feature);
@@ -2486,7 +2487,7 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
const visibleRouteIds = allRouteIds.filter( const visibleRouteIds = allRouteIds.filter(
(id: number) => (id: number) =>
id !== numericRouteId && !mapStore.hiddenRoutes.has(id) id !== numericRouteId && !mapStore.hiddenRoutes.has(id),
); );
const stationsInVisibleRoutes = new Set<number>(); const stationsInVisibleRoutes = new Set<number>();
@@ -2494,21 +2495,21 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
const stationIds = const stationIds =
mapStore.routeStationsCache.get(otherRouteId) || []; mapStore.routeStationsCache.get(otherRouteId) || [];
stationIds.forEach((id: number) => stationIds.forEach((id: number) =>
stationsInVisibleRoutes.add(id) stationsInVisibleRoutes.add(id),
); );
}); });
const stationsToHide = routeStationIds.filter( const stationsToHide = routeStationIds.filter(
(id: number) => !stationsInVisibleRoutes.has(id) (id: number) => !stationsInVisibleRoutes.has(id),
); );
stationsToHide.forEach((stationId: number) => { stationsToHide.forEach((stationId: number) => {
const pointFeature = mapService.pointSource.getFeatureById( const pointFeature = mapService.pointSource.getFeatureById(
`station-${stationId}` `station-${stationId}`,
); );
if (pointFeature) { if (pointFeature) {
mapService.pointSource.removeFeature( mapService.pointSource.removeFeature(
pointFeature as Feature<Point> pointFeature as Feature<Point>,
); );
} }
}); });
@@ -2516,7 +2517,7 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
const lineFeature = mapService.lineSource.getFeatureById(routeId); const lineFeature = mapService.lineSource.getFeatureById(routeId);
if (lineFeature) { if (lineFeature) {
mapService.lineSource.removeFeature( mapService.lineSource.removeFeature(
lineFeature as Feature<LineString> lineFeature as Feature<LineString>,
); );
} }
@@ -2528,31 +2529,31 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
} catch (error) { } catch (error) {
console.error( console.error(
"[handleHideRoute] Error toggling route visibility:", "[handleHideRoute] Error toggling route visibility:",
error error,
); );
toast.error("Ошибка при изменении видимости маршрута"); toast.error("Ошибка при изменении видимости маршрута");
} }
}, },
[mapService] [mapService],
); );
const sortFeaturesByType = <T extends Feature<Geometry>>( const sortFeaturesByType = <T extends Feature<Geometry>>(
features: T[], features: T[],
sortType: SortType sortType: SortType,
): T[] => { ): T[] => {
const sorted = [...features]; const sorted = [...features];
switch (sortType) { switch (sortType) {
case "name_asc": case "name_asc":
return sorted.sort((a, b) => return sorted.sort((a, b) =>
((a.get("name") as string) || "").localeCompare( ((a.get("name") as string) || "").localeCompare(
(b.get("name") as string) || "" (b.get("name") as string) || "",
) ),
); );
case "name_desc": case "name_desc":
return sorted.sort((a, b) => return sorted.sort((a, b) =>
((b.get("name") as string) || "").localeCompare( ((b.get("name") as string) || "").localeCompare(
(a.get("name") as string) || "" (a.get("name") as string) || "",
) ),
); );
case "created_asc": case "created_asc":
return sorted.sort((a, b) => { return sorted.sort((a, b) => {
@@ -2579,13 +2580,13 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
const aDate = a.get("updated_at") const aDate = a.get("updated_at")
? new Date(a.get("updated_at")) ? new Date(a.get("updated_at"))
: a.get("created_at") : a.get("created_at")
? new Date(a.get("created_at")) ? new Date(a.get("created_at"))
: new Date(0); : new Date(0);
const bDate = b.get("updated_at") const bDate = b.get("updated_at")
? new Date(b.get("updated_at")) ? new Date(b.get("updated_at"))
: b.get("created_at") : b.get("created_at")
? new Date(b.get("created_at")) ? new Date(b.get("created_at"))
: new Date(0); : new Date(0);
return aDate.getTime() - bDate.getTime(); return aDate.getTime() - bDate.getTime();
}); });
case "updated_desc": case "updated_desc":
@@ -2593,13 +2594,13 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
const aDate = a.get("updated_at") const aDate = a.get("updated_at")
? new Date(a.get("updated_at")) ? new Date(a.get("updated_at"))
: a.get("created_at") : a.get("created_at")
? new Date(a.get("created_at")) ? new Date(a.get("created_at"))
: new Date(0); : new Date(0);
const bDate = b.get("updated_at") const bDate = b.get("updated_at")
? new Date(b.get("updated_at")) ? new Date(b.get("updated_at"))
: b.get("created_at") : b.get("created_at")
? new Date(b.get("created_at")) ? new Date(b.get("created_at"))
: new Date(0); : new Date(0);
return bDate.getTime() - aDate.getTime(); return bDate.getTime() - aDate.getTime();
}); });
default: default:
@@ -2608,13 +2609,13 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
}; };
const stations = filteredFeatures.filter( const stations = filteredFeatures.filter(
(f) => f.get("featureType") === "station" (f) => f.get("featureType") === "station",
); );
const lines = filteredFeatures.filter( const lines = filteredFeatures.filter(
(f) => f.get("featureType") === "route" (f) => f.get("featureType") === "route",
); );
const sights = filteredFeatures.filter( const sights = filteredFeatures.filter(
(f) => f.get("featureType") === "sight" (f) => f.get("featureType") === "sight",
); );
const sortedStations = sortFeaturesByType(stations, stationSort); const sortedStations = sortFeaturesByType(stations, stationSort);
@@ -2623,7 +2624,7 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
const renderFeatureList = ( const renderFeatureList = (
features: Feature<Geometry>[], features: Feature<Geometry>[],
featureType: "station" | "route" | "sight", featureType: "station" | "route" | "sight",
IconComponent: React.ElementType IconComponent: React.ElementType,
) => ( ) => (
<div className="space-y-1 pr-1"> <div className="space-y-1 pr-1">
{features.length > 0 ? ( {features.length > 0 ? (
@@ -2649,6 +2650,38 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
featureType === "station" && featureType === "station" &&
description && description &&
description.trim() !== ""; description.trim() !== "";
const routeName =
featureType === "route"
? (feature.get("routeName") as string) || ""
: "";
const routeNumber =
featureType === "route"
? (feature.get("routeNumber") as string) || fName
: "";
const routeNumberTrimmed = routeNumber.trim();
const routeNameTrimmed = routeName.trim();
const displayName =
featureType === "route" ? routeNumberTrimmed || fName : fName;
const showRouteName =
featureType === "route" &&
routeNameTrimmed !== "" &&
routeNameTrimmed !== displayName;
const titleParts: string[] = [];
if (featureType === "route") {
if (routeNumberTrimmed) {
titleParts.push(routeNumberTrimmed);
}
if (routeNameTrimmed) {
titleParts.push(routeNameTrimmed);
}
}
const titleText =
featureType === "route"
? titleParts.join(" • ") ||
routeNumberTrimmed ||
routeNameTrimmed ||
fName
: fName;
return ( return (
<div <div
@@ -2667,7 +2700,7 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
checked={isChecked} checked={isChecked}
onChange={() => handleCheckboxChange(fId)} onChange={() => handleCheckboxChange(fId)}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
aria-label={`Выбрать ${fName}`} aria-label={`Выбрать ${titleText}`}
/> />
</div> </div>
<div <div
@@ -2692,9 +2725,9 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
<span <span
className={`font-medium whitespace-nowrap overflow-x-auto block className={`font-medium whitespace-nowrap overflow-x-auto block
scrollbar-visible`} scrollbar-visible`}
title={fName} title={titleText}
> >
{fName} {displayName}
</span> </span>
</div> </div>
{showDescription && ( {showDescription && (
@@ -2702,6 +2735,11 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
{description} {description}
</div> </div>
)} )}
{showRouteName && (
<div className="mt-1 text-xs text-gray-600">
{routeNameTrimmed}
</div>
)}
</div> </div>
<div className="flex-shrink-0 flex items-center space-x-1 opacity-60 group-hover:opacity-100"> <div className="flex-shrink-0 flex items-center space-x-1 opacity-60 group-hover:opacity-100">
<button <button
@@ -2859,7 +2897,7 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
}`} }`}
onClick={() => onClick={() =>
mapStore.setHideSightsByHiddenRoutes( mapStore.setHideSightsByHiddenRoutes(
!mapStore.hideSightsByHiddenRoutes !mapStore.hideSightsByHiddenRoutes,
) )
} }
> >
@@ -2954,7 +2992,7 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
</> </>
)} )}
</div> </div>
) ),
) )
)} )}
</div> </div>
@@ -2971,7 +3009,7 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
)} )}
</div> </div>
); );
} },
); );
export const MapPage: React.FC = observer(() => { export const MapPage: React.FC = observer(() => {
@@ -2987,7 +3025,7 @@ export const MapPage: React.FC = observer(() => {
const [selectedFeatureForSidebar, setSelectedFeatureForSidebar] = const [selectedFeatureForSidebar, setSelectedFeatureForSidebar] =
useState<Feature<Geometry> | null>(null); useState<Feature<Geometry> | null>(null);
const [selectedIds, setSelectedIds] = useState<Set<string | number>>( const [selectedIds, setSelectedIds] = useState<Set<string | number>>(
new Set() new Set(),
); );
const [isLassoActive, setIsLassoActive] = useState<boolean>(false); const [isLassoActive, setIsLassoActive] = useState<boolean>(false);
const [showHelp, setShowHelp] = useState<boolean>(false); const [showHelp, setShowHelp] = useState<boolean>(false);
@@ -2999,7 +3037,7 @@ export const MapPage: React.FC = observer(() => {
const handleFeaturesChange = useCallback( const handleFeaturesChange = useCallback(
(feats: Feature<Geometry>[]) => setMapFeatures([...feats]), (feats: Feature<Geometry>[]) => setMapFeatures([...feats]),
[] [],
); );
const handleFeatureSelectForSidebar = useCallback( const handleFeatureSelectForSidebar = useCallback(
@@ -3011,8 +3049,8 @@ export const MapPage: React.FC = observer(() => {
featureType === "sight" featureType === "sight"
? "sights" ? "sights"
: featureType === "route" : featureType === "route"
? "lines" ? "lines"
: "layers"; : "layers";
setActiveSectionFromParent(sectionId); setActiveSectionFromParent(sectionId);
setTimeout(() => { setTimeout(() => {
document document
@@ -3021,7 +3059,7 @@ export const MapPage: React.FC = observer(() => {
}, 100); }, 100);
} }
}, },
[] [],
); );
useEffect(() => { useEffect(() => {
@@ -3042,7 +3080,7 @@ export const MapPage: React.FC = observer(() => {
mapService.loadFeaturesFromApi( mapService.loadFeaturesFromApi(
mapStore.stations, mapStore.stations,
mapStore.routes, mapStore.routes,
mapStore.sights mapStore.sights,
); );
} catch (e) { } catch (e) {
console.error("Failed to load initial map data:", e); console.error("Failed to load initial map data:", e);
@@ -3061,7 +3099,7 @@ export const MapPage: React.FC = observer(() => {
handleFeaturesChange, handleFeaturesChange,
handleFeatureSelectForSidebar, handleFeatureSelectForSidebar,
tooltipRef.current, tooltipRef.current,
setSelectedIds setSelectedIds,
); );
setMapServiceInstance(service); setMapServiceInstance(service);
@@ -3072,7 +3110,7 @@ export const MapPage: React.FC = observer(() => {
loadInitialData(service); loadInitialData(service);
} catch (e: any) { } catch (e: any) {
setError( setError(
`Ошибка инициализации карты: ${e.message || "Неизвестная ошибка"}.` `Ошибка инициализации карты: ${e.message || "Неизвестная ошибка"}.`,
); );
setIsMapLoading(false); setIsMapLoading(false);
setIsDataLoading(false); setIsDataLoading(false);
@@ -3165,7 +3203,7 @@ export const MapPage: React.FC = observer(() => {
mapServiceInstance.loadFeaturesFromApi( mapServiceInstance.loadFeaturesFromApi(
mapStore.stations, mapStore.stations,
mapStore.routes, mapStore.routes,
mapStore.sights mapStore.sights,
); );
} }
}, [selectedCityId, mapServiceInstance, isDataLoading]); }, [selectedCityId, mapServiceInstance, isDataLoading]);
@@ -3178,7 +3216,7 @@ export const MapPage: React.FC = observer(() => {
mapServiceInstance.loadFeaturesFromApi( mapServiceInstance.loadFeaturesFromApi(
mapStore.stations, mapStore.stations,
mapStore.routes, mapStore.routes,
mapStore.sights mapStore.sights,
); );
} }
}, [ }, [
@@ -3194,7 +3232,7 @@ export const MapPage: React.FC = observer(() => {
selectedFeatureForSidebar !== null || selectedIds.size > 0; selectedFeatureForSidebar !== null || selectedIds.size > 0;
return ( return (
<div className="flex flex-col md:flex-row font-sans bg-gray-100 h-[90vh] overflow-hidden"> <div className="-mb-4 flex flex-col md:flex-row font-sans bg-gray-100 h-[90vh] overflow-hidden">
<div className="relative flex-grow flex"> <div className="relative flex-grow flex">
<div <div
ref={mapRef} ref={mapRef}
@@ -3241,35 +3279,87 @@ export const MapPage: React.FC = observer(() => {
/> />
)} )}
<button
onClick={() => setShowHelp(!showHelp)}
className="absolute bottom-4 right-4 z-20 p-2 bg-white rounded-full shadow-md hover:bg-gray-100"
title="Помощь по клавишам"
>
<InfoIcon size={20} />
</button>
{showHelp && ( {showHelp && (
<div className="absolute bottom-16 right-4 z-20 p-4 bg-white rounded-lg shadow-xl max-w-xs"> <div className="absolute bottom-16 right-4 z-20 p-4 bg-white rounded-lg shadow-xl max-w-md max-h-[30vh] overflow-y-auto scrollbar-visible">
<h4 className="font-bold mb-2">Горячие клавиши:</h4> <h4 className="font-bold mb-2">Управление картой</h4>
<ul className="text-sm space-y-2"> <div className="text-sm space-y-3">
<li> <div>
<span className="font-mono bg-gray-100 px-1 rounded"> <p className="font-semibold mb-1">Перемещение и масштаб:</p>
Shift <ul className="space-y-1">
</span>{" "} <li>Колесо мыши приблизить / отдалить.</li>
- Режим выделения (лассо) <li>
</li> Средняя кнопка мыши (колесо зажато) перетаскивание карты.
<li> </li>
<span className="font-mono bg-gray-100 px-1 rounded"> </ul>
Ctrl + клик </div>
</span>{" "}
- Добавить/убрать из выделения <div>
</li> <p className="font-semibold mb-1">Выделение объектов:</p>
<li> <ul className="space-y-1">
<span className="font-mono bg-gray-100 px-1 rounded">Esc</span>{" "} <li>Одинарный клик по объекту выделить и центрировать.</li>
- Отменить выделение <li>
</li> <span className="font-mono bg-gray-100 px-1 rounded">
</ul> Ctrl
</span>{" "}
+ клик добавить / убрать объект из выделения.
</li>
<li>
<span className="font-mono bg-gray-100 px-1 rounded">
Shift
</span>{" "}
временно включить режим лассо (выделение области).
</li>
<li>Клик по пустому месту карты снять выделение.</li>
<li>
<span className="font-mono bg-gray-100 px-1 rounded">
Esc
</span>{" "}
снять выделение всех объектов.
</li>
</ul>
</div>
<div>
<p className="font-semibold mb-1">
Рисование и редактирование:
</p>
<ul className="space-y-1">
<li>
Кнопки в верхней панели выбор режима: редактирование,
добавление остановки, достопримечательности или маршрута.
</li>
<li>
При рисовании маршрута: правый клик завершить линию.
</li>
<li>
В режиме редактирования: перетаскивайте точки маршрута для
изменения траектории.
</li>
<li>
Двойной клик по внутренней точке маршрута удалить эту
точку (при наличии не менее 2 точек).
</li>
</ul>
</div>
<div>
<p className="font-semibold mb-1">Боковая панель:</p>
<ul className="space-y-1">
<li>Клик по строке в списке перейти к объекту на карте.</li>
<li>
Иконка карандаша открыть объект в режиме редактирования.
</li>
<li>
Иконка карты у маршрутов открыть предпросмотр маршрута.
</li>
<li>
Иконка глаза у маршрутов скрыть / показать маршрут и
связанные остановки.
</li>
<li>Иконка корзины удалить объект.</li>
</ul>
</div>
</div>
<button <button
onClick={() => setShowHelp(false)} onClick={() => setShowHelp(false)}
className="mt-3 text-xs text-blue-600 hover:text-blue-800" className="mt-3 text-xs text-blue-600 hover:text-blue-800"
@@ -3278,6 +3368,14 @@ export const MapPage: React.FC = observer(() => {
</button> </button>
</div> </div>
)} )}
<button
onClick={() => setShowHelp(!showHelp)}
className="absolute bottom-4 right-4 z-20 p-2 bg-white rounded-full shadow-md hover:bg-gray-100"
title="Помощь по управлению картой"
>
<InfoIcon size={20} />
</button>
</div> </div>
{showContent && ( {showContent && (
<MapSightbar <MapSightbar

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";
@@ -138,8 +139,15 @@ export const MediaEditPage = observer(() => {
if (!media && id) { if (!media && id) {
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

@@ -16,6 +16,10 @@ export const MediaListPage = observer(() => {
const [rowId, setRowId] = useState<string | null>(null); const [rowId, setRowId] = useState<string | null>(null);
const [ids, setIds] = useState<string[]>([]); const [ids, setIds] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [paginationModel, setPaginationModel] = useState({
page: 0,
pageSize: 50,
});
const { language } = languageStore; const { language } = languageStore;
useEffect(() => { useEffect(() => {
@@ -100,29 +104,39 @@ export const MediaListPage = observer(() => {
return ( return (
<> <>
<div className="w-full"> <div className="w-full">
<div {ids.length > 0 && (
className="flex justify-end mb-5 duration-300" <div className="flex justify-end mb-5 duration-300">
style={{ opacity: ids.length > 0 ? 1 : 0 }} <button
> className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
<button onClick={() => setIsBulkDeleteModalOpen(true)}
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center" >
onClick={() => setIsBulkDeleteModalOpen(true)} <Trash2 size={20} className="text-white" /> Удалить выбранные (
> {ids.length})
<Trash2 size={20} className="text-white" /> Удалить выбранные ( </button>
{ids.length}) </div>
</button> )}
</div>
<DataGrid <DataGrid
rows={rows} rows={rows}
columns={columns} columns={columns}
hideFooterPagination
checkboxSelection checkboxSelection
disableRowSelectionExcludeModel
loading={isLoading} loading={isLoading}
onRowSelectionModelChange={(newSelection) => { paginationModel={paginationModel}
setIds(Array.from(newSelection.ids) as string[]); onPaginationModelChange={setPaginationModel}
pageSizeOptions={[50]}
onRowSelectionModelChange={(newSelection: any) => {
if (Array.isArray(newSelection)) {
const selectedIds = newSelection.map((id: string | number) => String(id));
setIds(selectedIds);
} else if (newSelection && typeof newSelection === 'object' && 'ids' in newSelection) {
const idsSet = newSelection.ids as Set<string | number>;
const selectedIds = Array.from(idsSet).map((id: string | number) => String(id));
setIds(selectedIds);
} else {
setIds([]);
}
}} }}
hideFooter
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText} localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
slots={{ slots={{
noRowsOverlay: () => ( noRowsOverlay: () => (

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,10 +34,12 @@ 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";
@@ -77,7 +78,6 @@ type LinkedItemsProps<T> = {
disableCreation?: boolean; disableCreation?: boolean;
updatedLinkedItems?: T[]; updatedLinkedItems?: T[];
refresh?: number; refresh?: number;
routeDirection?: boolean;
}; };
export const LinkedItems = < export const LinkedItems = <
@@ -127,7 +127,6 @@ const LinkedItemsContentsInner = <
disableCreation = false, disableCreation = false,
updatedLinkedItems, updatedLinkedItems,
refresh, refresh,
routeDirection,
}: LinkedItemsProps<T>) => { }: LinkedItemsProps<T>) => {
const { language } = languageStore; const { language } = languageStore;
@@ -141,6 +140,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]);
@@ -149,11 +151,6 @@ 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) => {
if (routeDirection === undefined) return true;
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) {
@@ -165,7 +162,10 @@ const LinkedItemsContentsInner = <
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()); const query = searchQuery.toLowerCase();
const name = String(item.name || "").toLowerCase();
const description = String(item.description || "").toLowerCase();
return name.includes(query) || description.includes(query);
}); });
useEffect(() => { useEffect(() => {
@@ -182,6 +182,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;
@@ -195,7 +208,13 @@ const LinkedItemsContentsInner = <
authInstance authInstance
.post(`/${parentResource}/${parentId}/${childResource}`, { .post(`/${parentResource}/${parentId}/${childResource}`, {
stations: reorderedItems.map((item) => ({ id: item.id })), stations: reorderedItems.map((item) => {
const transfers = getStationTransfers(item.id, item.transfers);
return {
...item,
transfers: transfers || item.transfers,
};
}),
}) })
.catch((error) => { .catch((error) => {
console.error("Error updating station order:", error); console.error("Error updating station order:", error);
@@ -242,14 +261,32 @@ 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 transfers = getStationTransfers(item.id, item.transfers);
return {
...item,
transfers: transfers || item.transfers,
};
}),
position, position,
{ id: selectedItemId } (() => {
if (!selectedItem) return { id: selectedItemId };
const transfers = getStationTransfers(
selectedItemId,
selectedItem.transfers
);
return {
...selectedItem,
transfers: transfers || selectedItem.transfers,
};
})()
), ),
}; };
setIsLinkingSingle(true);
authInstance authInstance
.post(`/${parentResource}/${parentId}/${childResource}`, requestData) .post(`/${parentResource}/${parentId}/${childResource}`, requestData)
.then(() => { .then(() => {
@@ -268,12 +305,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 },
@@ -285,6 +330,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;
});
}); });
}; };
@@ -311,10 +363,25 @@ 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);
if (!item) return { id };
const transfers = getStationTransfers(id, item.transfers);
return {
...item,
transfers: transfers || item.transfers,
};
});
const requestData = { const requestData = {
stations: [ stations: [
...linkedItems.map((item) => ({ id: item.id })), ...linkedItems.map((item) => {
const transfers = getStationTransfers(item.id, item.transfers);
return {
...item,
transfers: transfers || item.transfers,
};
}),
...selectedStations, ...selectedStations,
], ],
}; };
@@ -330,6 +397,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);
}); });
}; };
@@ -399,7 +469,7 @@ const LinkedItemsContentsInner = <
))} ))}
{type === "edit" && ( {type === "edit" && (
<TableCell> <TableCell>
<Button <AnimatedCircleButton
variant="outlined" variant="outlined"
color="error" color="error"
size="small" size="small"
@@ -407,9 +477,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>
@@ -434,12 +506,6 @@ const LinkedItemsContentsInner = <
{type === "edit" && !disableCreation && ( {type === "edit" && !disableCreation && (
<Stack gap={2} mt={2}> <Stack gap={2} mt={2}>
<Typography variant="subtitle1">Добавить остановки</Typography> <Typography variant="subtitle1">Добавить остановки</Typography>
{routeDirection !== undefined && (
<Typography variant="body2" color="textSecondary">
Показываются только остановки для{" "}
{routeDirection ? "прямого" : "обратного"} направления
</Typography>
)}
<Tabs <Tabs
value={activeTab} value={activeTab}
@@ -468,6 +534,7 @@ const LinkedItemsContentsInner = <
<TextField <TextField
{...params} {...params}
label="Выберите остановку" label="Выберите остановку"
placeholder="Введите название или описание остановки..."
fullWidth fullWidth
/> />
)} )}
@@ -475,16 +542,15 @@ const LinkedItemsContentsInner = <
option.id === value?.id option.id === value?.id
} }
filterOptions={(options, { inputValue }) => { filterOptions={(options, { inputValue }) => {
const searchWords = inputValue if (!inputValue.trim()) return options;
.toLowerCase() const query = inputValue.toLowerCase();
.split(" ")
.filter(Boolean);
return options.filter((option) => { return options.filter((option) => {
const optionWords = String(option.name) const name = String(option.name || "").toLowerCase();
.toLowerCase() const description = String(
.split(" "); option.description || ""
return searchWords.every((searchWord) => ).toLowerCase();
optionWords.some((word) => word.startsWith(searchWord)) return (
name.includes(query) || description.includes(query)
); );
}); });
}} }}
@@ -520,14 +586,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>
)} )}
@@ -587,14 +654,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,22 +13,30 @@ import {
DialogContent, DialogContent,
DialogActions, DialogActions,
} from "@mui/material"; } from "@mui/material";
import { MediaViewer, VideoPreviewCard } from "@widgets"; import {
MediaViewer,
VideoPreviewCard,
ImageUploadCard,
} from "@widgets";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { ArrowLeft, Loader2, Save, Plus } 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";
import { carrierStore } from "../../../shared/store/CarrierStore";
import { articlesStore } from "../../../shared/store/ArticlesStore";
import { Route, routeStore } from "../../../shared/store/RouteStore";
import { import {
carrierStore,
articlesStore,
routeStore,
languageStore, languageStore,
mediaStore,
isMediaIdEmpty,
ArticleSelectOrCreateDialog, ArticleSelectOrCreateDialog,
SelectMediaDialog, SelectMediaDialog,
selectedCityStore, selectedCityStore,
UploadMediaDialog, UploadMediaDialog,
PreviewMediaDialog,
} from "@shared"; } from "@shared";
import type { Route } from "@shared";
export const RouteCreatePage = observer(() => { export const RouteCreatePage = observer(() => {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -45,18 +53,27 @@ export const RouteCreatePage = observer(() => {
const [centerLat, setCenterLat] = useState(""); const [centerLat, setCenterLat] = useState("");
const [centerLng, setCenterLng] = useState(""); const [centerLng, setCenterLng] = useState("");
const [videoPreview, setVideoPreview] = useState<string>(""); const [videoPreview, setVideoPreview] = useState<string>("");
const [icon, setIcon] = useState<string>("");
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
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 [isUploadVideoDialogOpen, setIsUploadVideoDialogOpen] = useState(false);
const [isSelectIconDialogOpen, setIsSelectIconDialogOpen] = useState(false);
const [isUploadIconDialogOpen, setIsUploadIconDialogOpen] = useState(false);
const [isPreviewIconOpen, setIsPreviewIconOpen] = useState(false);
const [previewIconId, setPreviewIconId] = useState("");
const [activeIconMenuType, setActiveIconMenuType] = useState<
"thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null
>(null);
const [fileToUpload, setFileToUpload] = useState<File | null>(null); const [fileToUpload, setFileToUpload] = useState<File | null>(null);
const { language } = languageStore; const { language } = languageStore;
useEffect(() => { useEffect(() => {
carrierStore.getCarriers(language); carrierStore.getCarriers(language);
articlesStore.getArticleList(); articlesStore.getArticleList();
mediaStore.getMedia();
}, [language]); }, [language]);
const filteredCarriers = useMemo(() => { const filteredCarriers = useMemo(() => {
@@ -150,6 +167,23 @@ export const RouteCreatePage = observer(() => {
setIsVideoPreviewOpen(true); setIsVideoPreviewOpen(true);
}; };
const handleIconSelect = (media: {
id: string;
filename: string;
media_name?: string;
media_type: number;
}) => {
setIcon(media.id);
setIsSelectIconDialogOpen(false);
};
const selectedIconMedia =
icon && !isMediaIdEmpty(icon)
? mediaStore.media.find((m) => m.id === icon)
: null;
const effectiveIconUrl = isMediaIdEmpty(icon) ? null : selectedIconMedia?.id ?? icon;
const effectiveVideoId = isMediaIdEmpty(videoPreview) ? null : videoPreview;
const handleCreateRoute = async () => { const handleCreateRoute = async () => {
try { try {
setIsLoading(true); setIsLoading(true);
@@ -174,11 +208,6 @@ export const RouteCreatePage = observer(() => {
setIsLoading(false); setIsLoading(false);
return; return;
} }
if (!governorAppeal) {
toast.error("Выберите статью для обращения к пассажирам");
setIsLoading(false);
return;
}
const validationResult = validateCoordinates(routeCoords); const validationResult = validateCoordinates(routeCoords);
if (validationResult !== true) { if (validationResult !== true) {
@@ -213,7 +242,9 @@ export const RouteCreatePage = observer(() => {
} }
const carrier_id = Number(carrier); const carrier_id = Number(carrier);
const governor_appeal = Number(governorAppeal); const governor_appeal = governorAppeal
? Number(governorAppeal)
: 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;
@@ -238,7 +269,6 @@ 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_name: routeName,
route_direction, route_direction,
scale_min: scale_min !== null ? scale_min : 0, scale_min: scale_min !== null ? scale_min : 0,
@@ -247,10 +277,14 @@ export const RouteCreatePage = observer(() => {
center_latitude, center_latitude,
center_longitude, center_longitude,
path, path,
video_preview: video_preview: !isMediaIdEmpty(videoPreview) ? videoPreview : undefined,
videoPreview && videoPreview !== "" ? videoPreview : undefined, icon: !isMediaIdEmpty(icon) ? icon : 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);
@@ -382,6 +416,17 @@ export const RouteCreatePage = observer(() => {
}, },
}} }}
/> />
{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)}
@@ -392,16 +437,41 @@ export const RouteCreatePage = observer(() => {
</Button> </Button>
</Box> </Box>
<VideoPreviewCard <Box className="w-full flex justify-center gap-4 flex-wrap">
title="Видеозаставка" <div className="flex flex-col gap-4 max-w-[300px]">
videoId={videoPreview} <ImageUploadCard
onVideoClick={handleVideoPreviewClick} title="Иконка маршрута"
onDeleteVideoClick={() => { imageKey="thumbnail"
setVideoPreview(""); imageUrl={effectiveIconUrl}
}} onImageClick={() => {
onSelectVideoClick={handleVideoFileSelect} setIsPreviewIconOpen(true);
className="w-full" setPreviewIconId(selectedIconMedia?.id ?? icon ?? "");
/> }}
onDeleteImageClick={() => {
setIcon("");
setActiveIconMenuType(null);
}}
onSelectFileClick={() => {
setActiveIconMenuType("image");
setIsSelectIconDialogOpen(true);
}}
setUploadMediaOpen={() => {
setIsUploadIconDialogOpen(true);
setActiveIconMenuType("image");
}}
/>
</div>
<div className="flex flex-col gap-4 max-w-[300px]">
<VideoPreviewCard
title="Видеозаставка"
videoId={effectiveVideoId}
onVideoClick={handleVideoPreviewClick}
onDeleteVideoClick={() => setVideoPreview("")}
onSelectVideoClick={handleVideoFileSelect}
className="w-full"
/>
</div>
</Box>
<FormControl fullWidth required> <FormControl fullWidth required>
<InputLabel>Прямой/обратный маршрут</InputLabel> <InputLabel>Прямой/обратный маршрут</InputLabel>
@@ -420,7 +490,15 @@ export const RouteCreatePage = observer(() => {
type="number" type="number"
value={scaleMin} value={scaleMin}
onChange={(e) => { onChange={(e) => {
const value = e.target.value; let value = e.target.value;
if (Number(value) > 297) {
value = "297";
}
if (Number(value) < 10) {
value = "10";
}
setScaleMin(value); setScaleMin(value);
if (value && scaleMax && Number(value) > Number(scaleMax)) { if (value && scaleMax && Number(value) > Number(scaleMax)) {
setScaleMax(value); setScaleMax(value);
@@ -447,6 +525,10 @@ export const RouteCreatePage = observer(() => {
value={scaleMax} value={scaleMax}
required required
onChange={(e) => { onChange={(e) => {
if (Number(e.target.value) > 300) {
e.target.value = "300";
}
const value = e.target.value; const value = e.target.value;
setScaleMax(value); setScaleMax(value);
}} }}
@@ -499,7 +581,7 @@ export const RouteCreatePage = observer(() => {
onSelectMedia={handleVideoSelect} onSelectMedia={handleVideoSelect}
mediaType={2} mediaType={2}
/> />
{videoPreview && videoPreview !== "" && ( {effectiveVideoId && (
<Dialog <Dialog
open={isVideoPreviewOpen} open={isVideoPreviewOpen}
onClose={() => setIsVideoPreviewOpen(false)} onClose={() => setIsVideoPreviewOpen(false)}
@@ -511,7 +593,7 @@ export const RouteCreatePage = observer(() => {
<Box className="flex justify-center items-center p-4"> <Box className="flex justify-center items-center p-4">
<MediaViewer <MediaViewer
media={{ media={{
id: videoPreview, id: effectiveVideoId,
media_type: 2, media_type: 2,
filename: "video_preview", filename: "video_preview",
}} }}
@@ -537,6 +619,28 @@ export const RouteCreatePage = observer(() => {
initialFile={fileToUpload || undefined} initialFile={fileToUpload || undefined}
afterUpload={handleVideoUpload} afterUpload={handleVideoUpload}
/> />
<SelectMediaDialog
open={isSelectIconDialogOpen}
onClose={() => setIsSelectIconDialogOpen(false)}
onSelectMedia={handleIconSelect}
mediaType={1}
/>
<UploadMediaDialog
open={isUploadIconDialogOpen}
onClose={() => setIsUploadIconDialogOpen(false)}
contextObjectName={routeName || "Маршрут"}
contextType="route"
afterUpload={handleIconSelect}
hardcodeType={activeIconMenuType}
/>
<PreviewMediaDialog
open={isPreviewIconOpen}
onClose={() => setIsPreviewIconOpen(false)}
mediaId={previewIconId}
/>
</Paper> </Paper>
); );
}); });

View File

@@ -13,23 +13,31 @@ import {
DialogContent, DialogContent,
DialogActions, DialogActions,
} from "@mui/material"; } from "@mui/material";
import { MediaViewer, VideoPreviewCard } from "@widgets"; import {
MediaViewer,
VideoPreviewCard,
ImageUploadCard,
DeleteModal,
} 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";
import { toast } from "react-toastify";
import { carrierStore } from "../../../shared/store/CarrierStore";
import { articlesStore } from "../../../shared/store/ArticlesStore";
import { import {
carrierStore,
articlesStore,
routeStore, routeStore,
languageStore, languageStore,
mediaStore,
isMediaIdEmpty,
stationsStore,
ArticleSelectOrCreateDialog, ArticleSelectOrCreateDialog,
SelectMediaDialog, SelectMediaDialog,
UploadMediaDialog, UploadMediaDialog,
PreviewMediaDialog,
LoadingSpinner,
} from "@shared"; } from "@shared";
import { toast } from "react-toastify";
import { stationsStore } from "@shared";
import { LinkedItems } from "../LinekedStations"; import { LinkedItems } from "../LinekedStations";
export const RouteEditPage = observer(() => { export const RouteEditPage = observer(() => {
@@ -37,33 +45,73 @@ 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 [isUploadVideoDialogOpen, setIsUploadVideoDialogOpen] = useState(false);
const [isSelectIconDialogOpen, setIsSelectIconDialogOpen] = useState(false);
const [isUploadIconDialogOpen, setIsUploadIconDialogOpen] = useState(false);
const [isPreviewIconOpen, setIsPreviewIconOpen] = useState(false);
const [isDeleteIconModalOpen, setIsDeleteIconModalOpen] = useState(false);
const [previewIconId, setPreviewIconId] = useState("");
const [activeIconMenuType, setActiveIconMenuType] = useState<
"thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null
>(null);
const [fileToUpload, setFileToUpload] = useState<File | null>(null); 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 () => {
const response = await routeStore.getRoute(Number(id)); if (!id) {
routeStore.setEditRouteData(response); setIsLoadingData(false);
languageStore.setLanguage("ru"); return;
}
setIsLoadingData(true);
try {
const response = await routeStore.getRoute(Number(id));
routeStore.setEditRouteData(response);
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();
await mediaStore.getMedia();
}; };
fetchData(); fetchData();
}, [id, language]); }, [id, language]);
const handleIconSelect = (media: {
id: string;
filename: string;
media_name?: string;
media_type: number;
}) => {
routeStore.setEditRouteData({ icon: media.id });
setIsSelectIconDialogOpen(false);
};
const selectedIconMedia =
editRouteData.icon && !isMediaIdEmpty(editRouteData.icon)
? mediaStore.media.find((m) => m.id === editRouteData.icon)
: null;
const effectiveIconUrl = isMediaIdEmpty(editRouteData.icon)
? null
: (selectedIconMedia?.id ?? editRouteData.icon);
const effectiveVideoId = isMediaIdEmpty(editRouteData.video_preview)
? null
: editRouteData.video_preview;
useEffect(() => { useEffect(() => {
if (editRouteData.path && editRouteData.path.length > 0) { if (editRouteData.path && editRouteData.path.length > 0) {
const formattedPath = editRouteData.path const formattedPath = editRouteData.path
@@ -91,10 +139,6 @@ export const RouteEditPage = observer(() => {
toast.error("Заполните номер маршрута в Говорящем Городе"); toast.error("Заполните номер маршрута в Говорящем Городе");
return; return;
} }
if (!editRouteData.governor_appeal) {
toast.error("Выберите статью для обращения к пассажирам");
return;
}
const validationResult = validateCoordinates(coordinates); const validationResult = validateCoordinates(coordinates);
if (validationResult !== true) { if (validationResult !== true) {
@@ -233,6 +277,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">
@@ -393,20 +452,29 @@ export const RouteEditPage = observer(() => {
type="number" type="number"
value={editRouteData.scale_min ?? ""} value={editRouteData.scale_min ?? ""}
onChange={(e) => { onChange={(e) => {
const value = let value = e.target.value === "" ? null : e.target.value;
e.target.value === "" ? null : parseFloat(e.target.value);
if (value && Number(value) > 297) {
value = "297";
}
if (value && Number(value) < 10) {
value = "10";
}
routeStore.setEditRouteData({ routeStore.setEditRouteData({
scale_min: value, scale_min: value ? Number(value) : null,
}); });
// Если максимальный масштаб стал меньше минимального, обновляем его // Если максимальный масштаб стал меньше минимального, обновляем его
if ( if (
value !== null && value !== null &&
editRouteData.scale_max !== null && editRouteData.scale_max !== null &&
editRouteData.scale_max !== undefined && editRouteData.scale_max !== undefined &&
value > editRouteData.scale_max value &&
Number(value) > (editRouteData.scale_max ?? 0)
) { ) {
routeStore.setEditRouteData({ routeStore.setEditRouteData({
scale_max: value, scale_max: value ? Number(value) : null,
}); });
} }
}} }}
@@ -418,12 +486,17 @@ export const RouteEditPage = observer(() => {
label="Масштаб (макс)" label="Масштаб (макс)"
type="number" 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={ error={
editRouteData.scale_min !== null && editRouteData.scale_min !== null &&
editRouteData.scale_min !== undefined && editRouteData.scale_min !== undefined &&
@@ -491,6 +564,21 @@ export const RouteEditPage = observer(() => {
}, },
}} }}
/> />
{selectedArticle && (
<Button
variant="outlined"
color="error"
onClick={() =>
routeStore.setEditRouteData({
governor_appeal: 0,
})
}
startIcon={<X size={16} />}
sx={{ minWidth: "auto", px: 2 }}
>
Сбросить
</Button>
)}
<Button <Button
variant="outlined" variant="outlined"
onClick={() => setIsSelectArticleDialogOpen(true)} onClick={() => setIsSelectArticleDialogOpen(true)}
@@ -501,16 +589,42 @@ export const RouteEditPage = observer(() => {
</Button> </Button>
</Box> </Box>
<VideoPreviewCard <Box className="w-full flex justify-center gap-4 flex-wrap">
title="Видеозаставка" <div className="flex flex-col gap-4 max-w-[300px]">
videoId={editRouteData.video_preview} <ImageUploadCard
onVideoClick={handleVideoPreviewClick} title="Иконка маршрута"
onDeleteVideoClick={() => { imageKey="thumbnail"
routeStore.setEditRouteData({ video_preview: "" }); imageUrl={effectiveIconUrl}
}} onImageClick={() => {
onSelectVideoClick={handleVideoFileSelect} setIsPreviewIconOpen(true);
className="w-full" setPreviewIconId(
/> selectedIconMedia?.id ?? editRouteData.icon ?? ""
);
}}
onDeleteImageClick={() => setIsDeleteIconModalOpen(true)}
onSelectFileClick={() => {
setActiveIconMenuType("image");
setIsSelectIconDialogOpen(true);
}}
setUploadMediaOpen={() => {
setIsUploadIconDialogOpen(true);
setActiveIconMenuType("image");
}}
/>
</div>
<div className="flex flex-col gap-4 max-w-[300px]">
<VideoPreviewCard
title="Видеозаставка"
videoId={effectiveVideoId}
onVideoClick={handleVideoPreviewClick}
onDeleteVideoClick={() => {
routeStore.setEditRouteData({ video_preview: "" });
}}
onSelectVideoClick={handleVideoFileSelect}
className="w-full"
/>
</div>
</Box>
</Box> </Box>
<LinkedItems <LinkedItems
@@ -524,7 +638,6 @@ export const RouteEditPage = observer(() => {
onUpdate={() => { onUpdate={() => {
routeStore.getRoute(Number(id)); routeStore.getRoute(Number(id));
}} }}
routeDirection={editRouteData.route_direction}
/> />
<div className="flex w-full justify-between"> <div className="flex w-full justify-between">
@@ -571,10 +684,10 @@ 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 && ( {effectiveVideoId && (
<MediaViewer <MediaViewer
media={{ media={{
id: editRouteData.video_preview, id: effectiveVideoId,
media_type: 2, media_type: 2,
filename: "video_preview", filename: "video_preview",
}} }}
@@ -598,6 +711,38 @@ export const RouteEditPage = observer(() => {
initialFile={fileToUpload || undefined} initialFile={fileToUpload || undefined}
afterUpload={handleVideoUpload} afterUpload={handleVideoUpload}
/> />
<SelectMediaDialog
open={isSelectIconDialogOpen}
onClose={() => setIsSelectIconDialogOpen(false)}
onSelectMedia={handleIconSelect}
mediaType={1}
/>
<UploadMediaDialog
open={isUploadIconDialogOpen}
onClose={() => setIsUploadIconDialogOpen(false)}
contextObjectName={editRouteData.route_name || "Маршрут"}
contextType="route"
afterUpload={handleIconSelect}
hardcodeType={activeIconMenuType}
/>
<PreviewMediaDialog
open={isPreviewIconOpen}
onClose={() => setIsPreviewIconOpen(false)}
mediaId={previewIconId}
/>
<DeleteModal
open={isDeleteIconModalOpen}
onDelete={() => {
routeStore.setEditRouteData({ icon: "" });
setIsDeleteIconModalOpen(false);
}}
onCancel={() => setIsDeleteIconModalOpen(false)}
edit
/>
</Paper> </Paper>
); );
}); });

View File

@@ -17,6 +17,10 @@ export const RouteListPage = observer(() => {
const [rowId, setRowId] = useState<number | null>(null); const [rowId, setRowId] = 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 [paginationModel, setPaginationModel] = useState({
page: 0,
pageSize: 50,
});
const { language } = languageStore; const { language } = languageStore;
useEffect(() => { useEffect(() => {
@@ -149,28 +153,39 @@ export const RouteListPage = observer(() => {
<CreateButton label="Создать маршрут" path="/route/create" /> <CreateButton label="Создать маршрут" path="/route/create" />
</div> </div>
<div {ids.length > 0 && (
className="flex justify-end mb-5 duration-300" <div className="flex justify-end mb-5 duration-300">
style={{ opacity: ids.length > 0 ? 1 : 0 }} <button
> className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
<button onClick={() => setIsBulkDeleteModalOpen(true)}
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center" >
onClick={() => setIsBulkDeleteModalOpen(true)} <Trash2 size={20} className="text-white" /> Удалить выбранные (
> {ids.length})
<Trash2 size={20} className="text-white" /> Удалить выбранные ( </button>
{ids.length}) </div>
</button> )}
</div>
<DataGrid <DataGrid
rows={rows} rows={rows}
columns={columns} columns={columns}
hideFooter
checkboxSelection checkboxSelection
disableRowSelectionExcludeModel
loading={isLoading} loading={isLoading}
paginationModel={paginationModel}
onPaginationModelChange={setPaginationModel}
pageSizeOptions={[50]}
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText} localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
onRowSelectionModelChange={(newSelection) => { onRowSelectionModelChange={(newSelection: any) => {
setIds(Array.from(newSelection.ids as unknown as number[])); if (Array.isArray(newSelection)) {
const selectedIds = newSelection.map((id: string | number) => Number(id));
setIds(selectedIds);
} else if (newSelection && typeof newSelection === 'object' && 'ids' in newSelection) {
const idsSet = newSelection.ids as Set<string | number>;
const selectedIds = Array.from(idsSet).map((id: string | number) => Number(id));
setIds(selectedIds);
} else {
setIds([]);
}
}} }}
slots={{ slots={{
noRowsOverlay: () => ( noRowsOverlay: () => (

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

@@ -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, isMediaIdEmpty } 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,101 +42,131 @@ 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={{ color: "#fff",
display: "flex", transition: "padding 0.3s ease",
justifyContent: "center", p: open ? 2 : 0,
alignItems: "center", display: "flex",
gap: 10, flexDirection: "column",
color: "#fff", alignItems: "stretch",
backgroundColor: "#222", justifyContent: "flex-start",
borderRadius: 10, }}
height: 40, >
width: "100%",
border: "none",
cursor: "pointer",
}}
>
<p>Назад</p>
</button>
<Stack <Stack
direction="column" direction="column"
alignItems="center" height="100%"
justifyContent="center" width="100%"
my={10} 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 <div>
style={{ <Button
maxWidth: 200, onClick={handleBack}
display: "flex", variant="contained"
flexDirection: "column", color="primary"
alignItems: "center", sx={{
gap: 10, backgroundColor: "#222",
}} color: "#fff",
borderRadius: 1.5,
px: 2,
py: 1,
marginBottom: 10,
"&:hover": {
backgroundColor: "#2d2d2d",
},
}}
fullWidth
startIcon={<ArrowBackIcon />}
>
Назад
</Button>
<Stack
direction="column"
alignItems="center"
justifyContent="center"
spacing={3}
>
<div
style={{
maxWidth: 150,
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 10,
}}
>
{carrierThumbnail && !isMediaIdEmpty(carrierThumbnail) && (
<MediaViewer
media={{
id: carrierThumbnail,
media_type: 1, // Тип "Фото" для логотипа
filename: "route_thumbnail",
}}
fullWidth
fullHeight
/>
)}
<Typography sx={{ color: "#fff" }} textAlign="center">
При поддержке Правительства
</Typography>
</div>
</Stack>
<div className="flex flex-col items-center justify-center gap-2 mt-10">
<button className="bg-[#fcd500] text-black px-4 py-2 rounded-md w-full font-medium my-10">
Обращение губернатора
</button>
<button className="bg-white text-black px-4 py-2 rounded-md w-full font-medium mx-5">
Достопримечательности
</button>
<button className="bg-white text-black px-4 py-2 rounded-md w-full font-medium mx-5">
Остановки
</button>
</div>
</div>
<Stack
direction="column"
alignItems="center"
maxHeight={150}
justifyContent="center"
flexGrow={1}
> >
{carrierThumbnail && ( {carrierLogo && !isMediaIdEmpty(carrierLogo) && (
<MediaViewer <MediaViewer
media={{ media={{
id: carrierThumbnail, id: carrierLogo,
media_type: 1, // Тип "Фото" для логотипа media_type: 1, // Тип "Фото" для логотипа
filename: "route_thumbnail", filename: "route_thumbnail_logo",
}} }}
fullWidth
fullHeight fullHeight
/> />
)} )}
<Typography sx={{ mb: 2, color: "#fff" }} textAlign="center"> </Stack>
При поддержке Правительства
</Typography>{" "} <Typography
</div> variant="h6"
textAlign="center"
sx={{ color: "#fff", marginTop: "auto" }}
>
#ВсемПоПути
</Typography>
</Stack> </Stack>
<Stack <div className="absolute bottom-[20px] -right-[520px] z-10">
direction="column" <LanguageSelector onBack={onToggle} isSidebarOpen={open} />
alignItems="center" </div>
justifyContent="center" </Box>
my={10}
spacing={2}
>
<Button variant="outlined" color="warning" fullWidth>
Достопримечательности
</Button>
<Button variant="outlined" color="warning" fullWidth>
Остановки
</Button>
</Stack>
<Stack
direction="column"
alignItems="center"
maxHeight={150}
justifyContent="center"
my={10}
>
{carrierLogo && (
<MediaViewer
media={{
id: carrierLogo,
media_type: 1, // Тип "Фото" для логотипа
filename: "route_thumbnail_logo",
}}
fullHeight
/>
)}
</Stack>
<Typography
variant="h6"
textAlign="center"
mt="auto"
sx={{ color: "#fff" }}
>
#ВсемПоПути
</Typography>
</Stack>
); );
}); });

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: () => {},
}); });
@@ -164,9 +168,57 @@ export const MapDataProvider = observer(
}); });
} }
function setMapCenter(x: number, y: number) { function setIconSize(size: number) {
const clamped = Math.max(1, 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(1, 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,
};
}); });
} }
@@ -179,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
);
} }
} }
@@ -320,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
@@ -375,6 +465,8 @@ export const MapDataProvider = observer(
setStationOffset, setStationOffset,
setStationAlign, setStationAlign,
setSightCoordinates, setSightCoordinates,
setIconSize,
setFontSize,
}), }),
[ [
originalRouteData, originalRouteData,
@@ -387,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,6 +34,9 @@ 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) {
@@ -50,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]);
@@ -70,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(1, Math.min(300, Math.round(value)));
setIconSize(clamped);
updateIconSize(clamped);
};
const handleFontSizeChange = (value: number) => {
if (!Number.isFinite(value)) {
return;
}
const clamped = Math.max(1, 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;
@@ -128,8 +155,12 @@ export function RightSidebar() {
onChange={(e) => { onChange={(e) => {
let newMinScale = Number(e.target.value); let newMinScale = Number(e.target.value);
if (newMinScale < 1) { if (newMinScale < 10) {
newMinScale = 1; newMinScale = 10;
}
if (newMinScale > 300) {
newMinScale = 297;
} }
setMinScale(newMinScale); setMinScale(newMinScale);
@@ -137,6 +168,10 @@ export function RightSidebar() {
if (maxScale - newMinScale < 2) { if (maxScale - newMinScale < 2) {
let newMaxScale = newMinScale + 2; let newMaxScale = newMinScale + 2;
if (newMaxScale > 300) {
newMaxScale = 300;
}
if (newMaxScale < 3) { if (newMaxScale < 3) {
newMaxScale = 3; newMaxScale = 3;
setMinScale(1); setMinScale(1);
@@ -172,8 +207,12 @@ export function RightSidebar() {
onChange={(e) => { onChange={(e) => {
let newMaxScale = Number(e.target.value); let newMaxScale = Number(e.target.value);
if (newMaxScale < 3) { if (newMaxScale < 13) {
newMaxScale = 3; newMaxScale = 13;
}
if (newMaxScale > 300) {
newMaxScale = 300;
} }
setMaxScale(newMaxScale); setMaxScale(newMaxScale);
@@ -204,7 +243,7 @@ export function RightSidebar() {
slotProps={{ slotProps={{
input: { input: {
min: 3, min: 3,
max: 10, max: 300,
}, },
}} }}
/> />
@@ -268,6 +307,60 @@ export function RightSidebar() {
}} }}
/> />
<TextField
type="number"
label="Размер иконок (%)"
variant="filled"
value={iconSize}
onChange={(e) => {
const value = Number(e.target.value);
if (!isNaN(value)) {
handleIconSizeChange(value);
}
}}
style={{ backgroundColor: "#222", borderRadius: 4 }}
sx={{
"& .MuiInputLabel-root": {
color: "#fff",
},
"& .MuiInputBase-input": {
color: "#fff",
},
}}
inputProps={{
min: 1,
max: 300,
step: 1,
}}
/>
<TextField
type="number"
label="Размер шрифта (%)"
variant="filled"
value={fontSize}
onChange={(e) => {
const value = Number(e.target.value);
if (!isNaN(value)) {
handleFontSizeChange(value);
}
}}
style={{ backgroundColor: "#222", borderRadius: 4 }}
sx={{
"& .MuiInputLabel-root": {
color: "#fff",
},
"& .MuiInputBase-input": {
color: "#fff",
},
}}
inputProps={{
min: 1,
max: 300,
step: 1,
}}
/>
<TextField <TextField
type="number" type="number"
label="Поворот (в градусах)" label="Поворот (в градусах)"
@@ -309,10 +402,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": {
@@ -328,15 +426,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": {
@@ -355,19 +458,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

@@ -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][];
@@ -31,6 +33,7 @@ export interface StationData {
address: string; address: string;
city_id?: number; city_id?: number;
description: string; description: string;
icon?: string;
id: number; id: number;
latitude: number; latitude: number;
longitude: number; longitude: 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

File diff suppressed because it is too large Load Diff

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 LinkedStationsContentsInner = <
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 = "sight"; const parentResource = "sight";
const childResource = "station"; const childResource = "station";
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) => {
@@ -110,6 +133,14 @@ const LinkedStationsContentsInner = <
}) })
.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;
const query = searchQuery.toLowerCase();
const name = String(item.name || "").toLowerCase();
const description = String(item.description || "").toLowerCase();
return name.includes(query) || description.includes(query);
});
useEffect(() => { useEffect(() => {
if (updatedLinkedItems) { if (updatedLinkedItems) {
setLinkedItems(updatedLinkedItems); setLinkedItems(updatedLinkedItems);
@@ -120,13 +151,24 @@ const LinkedStationsContentsInner = <
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]);
station_id: selectedItemId,
};
setIsLinkingSingle(true);
authInstance authInstance
.post(`/${parentResource}/${parentId}/${childResource}`, requestData) .post(`/${parentResource}/${parentId}/${childResource}`, requestData)
.then(() => { .then(() => {
@@ -140,15 +182,23 @@ const LinkedStationsContentsInner = <
.catch((error) => { .catch((error) => {
console.error("Error linking station:", error); console.error("Error linking station:", 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: buildPayload([itemId]),
}) })
.then(() => { .then(() => {
setLinkedItems(linkedItems.filter((item) => item.id !== itemId)); setLinkedItems(linkedItems.filter((item) => item.id !== itemId));
@@ -157,9 +207,125 @@ const LinkedStationsContentsInner = <
.catch((error) => { .catch((error) => {
console.error("Error deleting station:", error); console.error("Error deleting station:", error);
setError("Failed to delete station"); 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(() => { useEffect(() => {
if (parentId) { if (parentId) {
setIsLoading(true); setIsLoading(true);
@@ -203,6 +369,16 @@ const LinkedStationsContentsInner = <
<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>
@@ -218,6 +394,15 @@ const LinkedStationsContentsInner = <
<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)}>
@@ -228,7 +413,7 @@ const LinkedStationsContentsInner = <
))} ))}
{type === "edit" && ( {type === "edit" && (
<TableCell> <TableCell>
<Button <AnimatedCircleButton
variant="outlined" variant="outlined"
color="error" color="error"
size="small" size="small"
@@ -236,9 +421,11 @@ const LinkedStationsContentsInner = <
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>
@@ -248,6 +435,20 @@ const LinkedStationsContentsInner = <
</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}>
Остановки не найдены Остановки не найдены
@@ -256,48 +457,145 @@ const LinkedStationsContentsInner = <
{type === "edit" && !disableCreation && ( {type === "edit" && !disableCreation && (
<Stack gap={2} mt={2}> <Stack gap={2} mt={2}>
<Typography variant="subtitle1">Добавить остановку</Typography> <Typography variant="subtitle1">Добавить остановки</Typography>
<Autocomplete <Tabs
fullWidth value={activeTab}
value={ onChange={(_, value) => setActiveTab(value)}
availableItems?.find((item) => item.id === selectedItemId) || null variant="fullWidth"
}
onChange={(_, newValue) => setSelectedItemId(newValue?.id || null)}
options={availableItems}
getOptionLabel={(item) => String(item.name)}
renderInput={(params) => (
<TextField {...params} label="Выберите остановку" fullWidth />
)}
isOptionEqualToValue={(option, value) => option.id === value?.id}
filterOptions={(options, { inputValue }) => {
const searchWords = inputValue
.toLowerCase()
.split(" ")
.filter(Boolean);
return options.filter((option) => {
const optionWords = String(option.name)
.toLowerCase()
.split(" ");
return searchWords.every((searchWord) =>
optionWords.some((word) => word.startsWith(searchWord))
);
});
}}
renderOption={(props, option) => (
<li {...props} key={option.id}>
{String(option.name)}
</li>
)}
/>
<Button
variant="contained"
onClick={linkItem}
disabled={!selectedItemId}
sx={{ alignSelf: "flex-start" }}
> >
Добавить <Tab label="По одной" />
</Button> <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> </Stack>
)} )}

View File

@@ -22,6 +22,10 @@ export const SightListPage = observer(() => {
const [rowId, setRowId] = useState<string | null>(null); const [rowId, setRowId] = useState<string | null>(null);
const [ids, setIds] = useState<number[]>([]); const [ids, setIds] = useState<number[]>([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [paginationModel, setPaginationModel] = useState({
page: 0,
pageSize: 50,
});
const { language } = languageStore; const { language } = languageStore;
useEffect(() => { useEffect(() => {
@@ -98,7 +102,6 @@ export const SightListPage = observer(() => {
}, },
]; ];
// Фильтрация достопримечательностей по выбранному городу
const filteredSights = useMemo(() => { const filteredSights = useMemo(() => {
const { selectedCityId } = selectedCityStore; const { selectedCityId } = selectedCityStore;
if (!selectedCityId) { if (!selectedCityId) {
@@ -126,28 +129,39 @@ export const SightListPage = observer(() => {
/> />
</div> </div>
<div {ids.length > 0 && (
className="flex justify-end mb-5 duration-300" <div className="flex justify-end mb-5 duration-300">
style={{ opacity: ids.length > 0 ? 1 : 0 }} <button
> className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
<button onClick={() => setIsBulkDeleteModalOpen(true)}
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center" >
onClick={() => setIsBulkDeleteModalOpen(true)} <Trash2 size={20} className="text-white" /> Удалить выбранные (
> {ids.length})
<Trash2 size={20} className="text-white" /> Удалить выбранные ( </button>
{ids.length}) </div>
</button> )}
</div>
<DataGrid <DataGrid
rows={rows} rows={rows}
columns={columns} columns={columns}
hideFooter
checkboxSelection checkboxSelection
disableRowSelectionExcludeModel
loading={isLoading} loading={isLoading}
paginationModel={paginationModel}
onPaginationModelChange={setPaginationModel}
pageSizeOptions={[50]}
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText} localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
onRowSelectionModelChange={(newSelection) => { onRowSelectionModelChange={(newSelection: any) => {
setIds(Array.from(newSelection.ids as unknown as number[])); if (Array.isArray(newSelection)) {
const selectedIds = newSelection.map((id: string | number) => Number(id));
setIds(selectedIds);
} else if (newSelection && typeof newSelection === 'object' && 'ids' in newSelection) {
const idsSet = newSelection.ids as Set<string | number>;
const selectedIds = Array.from(idsSet).map((id: string | number) => Number(id));
setIds(selectedIds);
} else {
setIds([]);
}
}} }}
slots={{ slots={{
noRowsOverlay: () => ( noRowsOverlay: () => (

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);
@@ -24,7 +25,7 @@ export const SnapshotCreatePage = observer(() => {
Назад Назад
</button> </button>
</div> </div>
<h1 className="text-2xl font-bold">Создание снапшота</h1> <h1 className="text-2xl font-bold">Создание экспорта медиа</h1>
<div className="flex flex-col gap-10 w-full items-end"> <div className="flex flex-col gap-10 w-full items-end">
<TextField <TextField
className="w-full" className="w-full"
@@ -42,13 +43,27 @@ export const SnapshotCreatePage = observer(() => {
onClick={async () => { onClick={async () => {
try { try {
setIsLoading(true); setIsLoading(true);
await createSnapshot(name); const id = await createSnapshot(name);
setIsLoading(false);
toast.success("Снапшот успешно создан"); await getSnapshotStatus(id);
navigate(-1);
while (snapshotStore.snapshotStatus?.Status != "done") {
await new Promise((resolve) => setTimeout(resolve, 1000));
await getSnapshotStatus(id);
}
if (snapshotStore.snapshotStatus?.Status === "done") {
toast.success("Экспорт медиа успешно создан");
runInAction(() => {
snapshotStore.snapshotStatus = null;
});
navigate(-1);
}
} catch (error) { } catch (error) {
console.error(error); console.error(error);
toast.error("Ошибка при создании снапшота"); toast.error("Ошибка при создании экспорта медиа");
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
@@ -56,7 +71,15 @@ export const SnapshotCreatePage = observer(() => {
disabled={isLoading || !name.trim()} disabled={isLoading || !name.trim()}
> >
{isLoading ? ( {isLoading ? (
<Loader2 size={20} className="animate-spin" /> <div className="flex items-center gap-2">
<Loader2 size={20} className="animate-spin" />
<span>
{snapshotStatus?.Progress
? (snapshotStatus.Progress * 100).toFixed(2)
: 0}
%
</span>
</div>
) : ( ) : (
"Сохранить" "Сохранить"
)} )}

View File

@@ -12,10 +12,14 @@ export const SnapshotListPage = observer(() => {
snapshotStore; snapshotStore;
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [rowId, setRowId] = useState<string | null>(null); // Lifted state const [rowId, setRowId] = useState<string | null>(null);
const { language } = languageStore; const { language } = languageStore;
const [isRestoreModalOpen, setIsRestoreModalOpen] = useState(false); const [isRestoreModalOpen, setIsRestoreModalOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [paginationModel, setPaginationModel] = useState({
page: 0,
pageSize: 50,
});
useEffect(() => { useEffect(() => {
const fetchSnapshots = async () => { const fetchSnapshots = async () => {
@@ -26,6 +30,14 @@ export const SnapshotListPage = observer(() => {
fetchSnapshots(); fetchSnapshots();
}, [language]); }, [language]);
const formatCreationTime = (isoString: string | undefined) => {
if (!isoString) return "";
const [datePart, timePartWithMs] = isoString.split("T");
if (!datePart || !timePartWithMs) return isoString;
const timePart = timePartWithMs.split(".")[0];
return `${datePart} - ${timePart}`;
};
const columns: GridColDef[] = [ const columns: GridColDef[] = [
{ {
field: "name", field: "name",
@@ -37,7 +49,14 @@ export const SnapshotListPage = observer(() => {
headerName: "Родитель", headerName: "Родитель",
flex: 1, flex: 1,
}, },
{
field: "created_at",
headerName: "Дата создания",
flex: 1,
renderCell: (params: GridRenderCellParams) => {
return <div>{params.value ? params.value : "-"}</div>;
},
},
{ {
field: "actions", field: "actions",
headerName: "Действия", headerName: "Действия",
@@ -75,26 +94,32 @@ export const SnapshotListPage = observer(() => {
id: snapshot.ID, id: snapshot.ID,
name: snapshot.Name, name: snapshot.Name,
parent: snapshots.find((s) => s.ID === snapshot.ParentID)?.Name, parent: snapshots.find((s) => s.ID === snapshot.ParentID)?.Name,
created_at: formatCreationTime(snapshot.CreationTime),
})); }));
return ( return (
<> <>
<div style={{ width: "100%" }}> <div style={{ width: "100%" }}>
<div className="flex justify-between items-center mb-10"> <div className="flex justify-between items-center mb-10">
<h1 className="text-2xl ">Снапшоты</h1> <h1 className="text-2xl ">Экспорт Медиа</h1>
<CreateButton label="Создать снапшот" path="/snapshot/create" /> <CreateButton label="Создать экспорт медиа" path="/snapshot/create" />
</div> </div>
<DataGrid <DataGrid
rows={rows} rows={rows}
columns={columns} columns={columns}
hideFooterPagination
hideFooter
loading={isLoading} loading={isLoading}
paginationModel={paginationModel}
onPaginationModelChange={setPaginationModel}
pageSizeOptions={[50]}
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText} localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
slots={{ slots={{
noRowsOverlay: () => ( noRowsOverlay: () => (
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}> <Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
{isLoading ? <CircularProgress size={20} /> : "Нет снапшотов"} {isLoading ? (
<CircularProgress size={20} />
) : (
"Нет экспортов медиа"
)}
</Box> </Box>
), ),
}} }}

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,53 +456,133 @@ 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>
<Autocomplete <Tabs
fullWidth value={activeTab}
value={ onChange={(_, value) => setActiveTab(value)}
availableItems?.find((item) => item.id === selectedItemId) || null variant="fullWidth"
}
onChange={(_, newValue) => setSelectedItemId(newValue?.id || null)}
options={availableItems}
getOptionLabel={(item) => String(item.name)}
renderInput={(params) => (
<TextField
{...params}
label="Выберите достопримечательность"
fullWidth
/>
)}
isOptionEqualToValue={(option, value) => option.id === value?.id}
filterOptions={(options, { inputValue }) => {
const searchWords = inputValue
.toLowerCase()
.split(" ")
.filter(Boolean);
return options.filter((option) => {
const optionWords = String(option.name)
.toLowerCase()
.split(" ");
return searchWords.every((searchWord) =>
optionWords.some((word) => word.startsWith(searchWord))
);
});
}}
renderOption={(props, option) => (
<li {...props} key={option.id}>
{String(option.name)}
</li>
)}
/>
<Button
variant="contained"
onClick={linkItem}
disabled={!selectedItemId}
sx={{ alignSelf: "flex-start" }}
> >
Добавить <Tab label="По одной" />
</Button> <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="Выберите достопримечательность"
fullWidth
/>
)}
isOptionEqualToValue={(option, value) =>
option.id === value?.id
}
filterOptions={(options, { inputValue }) => {
const searchWords = inputValue
.toLowerCase()
.split(" ")
.filter(Boolean);
return options.filter((option) => {
const optionWords = String(option.name)
.toLowerCase()
.split(" ");
return searchWords.every((searchWord) =>
optionWords.some((word) => word.startsWith(searchWord))
);
});
}}
renderOption={(props, option) => (
<li {...props} key={option.id}>
{String(option.name)}
</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={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

@@ -1,26 +1,33 @@
import { import {
Button, Button,
Paper,
TextField, TextField,
Select, Select,
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, Loader2, Save } from "lucide-react";
import { Loader2 } from "lucide-react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { import {
stationsStore, stationsStore,
languageStore, languageStore,
cityStore, cityStore,
mediaStore,
isMediaIdEmpty,
useSelectedCity, useSelectedCity,
SelectMediaDialog,
UploadMediaDialog,
PreviewMediaDialog,
} from "@shared"; } from "@shared";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { LanguageSwitcher } from "@widgets"; import {
import { SaveWithoutCityAgree } from "@widgets"; ImageUploadCard,
LanguageSwitcher,
SaveWithoutCityAgree,
} from "@widgets";
export const StationCreatePage = observer(() => { export const StationCreatePage = observer(() => {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -35,6 +42,13 @@ 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 [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false);
const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false);
const [mediaId, setMediaId] = useState("");
const [activeMenuType, setActiveMenuType] = useState<
"thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null
>(null);
const [isSaveWarningOpen, setIsSaveWarningOpen] = useState(false); const [isSaveWarningOpen, setIsSaveWarningOpen] = useState(false);
@@ -96,8 +110,27 @@ export const StationCreatePage = observer(() => {
}; };
fetchCities(); fetchCities();
mediaStore.getMedia();
}, []); }, []);
const handleMediaSelect = (media: {
id: string;
filename: string;
media_name?: string;
media_type: number;
}) => {
setCreateCommonData({ icon: media.id });
};
const selectedMedia =
createStationData.common.icon &&
!isMediaIdEmpty(createStationData.common.icon)
? mediaStore.media.find((m) => m.id === createStationData.common.icon)
: null;
const effectiveIconUrl = isMediaIdEmpty(createStationData.common.icon)
? null
: selectedMedia?.id ?? createStationData.common.icon;
useEffect(() => { useEffect(() => {
if (selectedCityId && selectedCity && !createStationData.common.city_id) { if (selectedCityId && selectedCity && !createStationData.common.city_id) {
setCreateCommonData({ setCreateCommonData({
@@ -108,7 +141,7 @@ export const StationCreatePage = observer(() => {
}, [selectedCityId, selectedCity, createStationData.common.city_id]); }, [selectedCityId, selectedCity, createStationData.common.city_id]);
return ( return (
<Paper className="w-full h-full p-3 flex flex-col gap-10"> <Box className="w-full h-full p-3 flex flex-col gap-10">
<LanguageSwitcher /> <LanguageSwitcher />
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<button <button
@@ -136,23 +169,6 @@ export const StationCreatePage = observer(() => {
} }
/> />
<FormControl fullWidth>
<InputLabel id="direction-label">Прямой/обратный маршрут</InputLabel>
<Select
labelId="direction-label"
value={createStationData.common.direction ? "Прямой" : "Обратный"}
label="Прямой/обратный маршрут"
onChange={(e) =>
setCreateCommonData({
direction: e.target.value === "Прямой",
})
}
>
<MenuItem value="Прямой">Прямой</MenuItem>
<MenuItem value="Обратный">Обратный</MenuItem>
</Select>
</FormControl>
<TextField <TextField
fullWidth fullWidth
label="Описание" label="Описание"
@@ -230,6 +246,30 @@ export const StationCreatePage = observer(() => {
</Select> </Select>
</FormControl> </FormControl>
<div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto">
<ImageUploadCard
title="Иконка остановки"
imageKey="thumbnail"
imageUrl={effectiveIconUrl}
onImageClick={() => {
setIsPreviewMediaOpen(true);
setMediaId(effectiveIconUrl ?? "");
}}
onDeleteImageClick={() => {
setCreateCommonData({ icon: "" });
setActiveMenuType(null);
}}
onSelectFileClick={() => {
setActiveMenuType("image");
setIsSelectMediaOpen(true);
}}
setUploadMediaOpen={() => {
setIsUploadMediaOpen(true);
setActiveMenuType("image");
}}
/>
</div>
<Button <Button
variant="contained" variant="contained"
className="w-min flex gap-2 items-center" className="w-min flex gap-2 items-center"
@@ -246,6 +286,28 @@ export const StationCreatePage = observer(() => {
</div> </div>
{/* ИНТЕГРИРОВАННОЕ ПРЕДУПРЕЖДАЮЩЕЕ ОКНО */} {/* ИНТЕГРИРОВАННОЕ ПРЕДУПРЕЖДАЮЩЕЕ ОКНО */}
<SelectMediaDialog
open={isSelectMediaOpen}
onClose={() => setIsSelectMediaOpen(false)}
onSelectMedia={handleMediaSelect}
mediaType={1}
/>
<UploadMediaDialog
open={isUploadMediaOpen}
onClose={() => setIsUploadMediaOpen(false)}
contextObjectName={createStationData[language].name || "Остановка"}
contextType="station"
afterUpload={handleMediaSelect}
hardcodeType={activeMenuType}
/>
<PreviewMediaDialog
open={isPreviewMediaOpen}
onClose={() => setIsPreviewMediaOpen(false)}
mediaId={mediaId}
/>
{isSaveWarningOpen && ( {isSaveWarningOpen && (
<SaveWithoutCityAgree <SaveWithoutCityAgree
blocker={{ blocker={{
@@ -254,6 +316,6 @@ export const StationCreatePage = observer(() => {
}} }}
/> />
)} )}
</Paper> </Box>
); );
}); });

View File

@@ -1,26 +1,40 @@
import { import {
Button, Button,
Paper,
TextField, TextField,
Select, Select,
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, Loader2, Save } 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,
mediaStore,
isMediaIdEmpty,
LoadingSpinner,
SelectMediaDialog,
PreviewMediaDialog,
UploadMediaDialog,
} from "@shared";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { LanguageSwitcher } from "@widgets"; import {
ImageUploadCard,
LanguageSwitcher,
SaveWithoutCityAgree,
DeleteModal,
} from "@widgets";
import { LinkedSights } from "../LinkedSights"; import { LinkedSights } from "../LinkedSights";
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,6 +46,14 @@ 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 [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false);
const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false);
const [mediaId, setMediaId] = useState("");
const [isDeleteIconModalOpen, setIsDeleteIconModalOpen] = useState(false);
const [activeMenuType, setActiveMenuType] = useState<
"thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null
>(null);
const [isSaveWarningOpen, setIsSaveWarningOpen] = useState(false); const [isSaveWarningOpen, setIsSaveWarningOpen] = useState(false);
@@ -88,22 +110,63 @@ export const StationEditPage = observer(() => {
setIsSaveWarningOpen(false); setIsSaveWarningOpen(false);
}; };
const handleMediaSelect = (media: {
id: string;
filename: string;
media_name?: string;
media_type: number;
}) => {
setEditCommonData({ icon: media.id });
};
const selectedMedia =
editStationData.common.icon && !isMediaIdEmpty(editStationData.common.icon)
? mediaStore.media.find((m) => m.id === editStationData.common.icon)
: null;
const effectiveIconUrl = isMediaIdEmpty(editStationData.common.icon)
? null
: selectedMedia?.id ?? editStationData.common.icon;
useEffect(() => { useEffect(() => {
const fetchAndSetStationData = async () => { const fetchAndSetStationData = async () => {
if (!id) return; if (!id) {
setIsLoadingData(false);
return;
}
const stationId = Number(id); setIsLoadingData(true);
await getEditStation(stationId); try {
await getCities("ru"); const stationId = Number(id);
await getCities("en"); await getEditStation(stationId);
await getCities("zh"); await getCities("ru");
await getCities("en");
await getCities("zh");
await mediaStore.getMedia();
} 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"> <Box className="w-full h-full p-3 flex flex-col gap-10">
<LanguageSwitcher /> <LanguageSwitcher />
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
@@ -132,23 +195,6 @@ export const StationEditPage = observer(() => {
} }
/> />
<FormControl fullWidth>
<InputLabel id="direction-label">Прямой/обратный маршрут</InputLabel>
<Select
labelId="direction-label"
value={editStationData.common.direction ? "Прямой" : "Обратный"}
label="Прямой/обратный маршрут"
onChange={(e) =>
setEditCommonData({
direction: e.target.value === "Прямой",
})
}
>
<MenuItem value="Прямой">Прямой</MenuItem>
<MenuItem value="Обратный">Обратный</MenuItem>
</Select>
</FormControl>
<TextField <TextField
fullWidth fullWidth
label="Описание" label="Описание"
@@ -226,6 +272,29 @@ export const StationEditPage = observer(() => {
</Select> </Select>
</FormControl> </FormControl>
<div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto">
<ImageUploadCard
title="Иконка остановки"
imageKey="thumbnail"
imageUrl={effectiveIconUrl}
onImageClick={() => {
setIsPreviewMediaOpen(true);
setMediaId(effectiveIconUrl ?? "");
}}
onDeleteImageClick={() => {
setIsDeleteIconModalOpen(true);
}}
onSelectFileClick={() => {
setActiveMenuType("image");
setIsSelectMediaOpen(true);
}}
setUploadMediaOpen={() => {
setIsUploadMediaOpen(true);
setActiveMenuType("image");
}}
/>
</div>
{id && ( {id && (
<LinkedSights <LinkedSights
parentId={Number(id)} parentId={Number(id)}
@@ -249,6 +318,38 @@ export const StationEditPage = observer(() => {
</Button> </Button>
</div> </div>
<SelectMediaDialog
open={isSelectMediaOpen}
onClose={() => setIsSelectMediaOpen(false)}
onSelectMedia={handleMediaSelect}
mediaType={1}
/>
<UploadMediaDialog
open={isUploadMediaOpen}
onClose={() => setIsUploadMediaOpen(false)}
contextObjectName={editStationData[language].name || "Остановка"}
contextType="station"
afterUpload={handleMediaSelect}
hardcodeType={activeMenuType}
/>
<PreviewMediaDialog
open={isPreviewMediaOpen}
onClose={() => setIsPreviewMediaOpen(false)}
mediaId={mediaId}
/>
<DeleteModal
open={isDeleteIconModalOpen}
onDelete={() => {
setEditCommonData({ icon: "" });
setIsDeleteIconModalOpen(false);
}}
onCancel={() => setIsDeleteIconModalOpen(false)}
edit
/>
{/* ИНТЕГРИРОВАННОЕ ПРЕДУПРЕЖДАЮЩЕЕ ОКНО */} {/* ИНТЕГРИРОВАННОЕ ПРЕДУПРЕЖДАЮЩЕЕ ОКНО */}
{isSaveWarningOpen && ( {isSaveWarningOpen && (
<SaveWithoutCityAgree <SaveWithoutCityAgree
@@ -258,6 +359,6 @@ export const StationEditPage = observer(() => {
}} }}
/> />
)} )}
</Paper> </Box>
); );
}); });

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,9 +23,17 @@ 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 [paginationModel, setPaginationModel] = useState({
page: 0,
pageSize: 50,
});
const { language } = languageStore; const { language } = languageStore;
useEffect(() => { useEffect(() => {
@@ -51,8 +64,8 @@ export const StationListPage = observer(() => {
}, },
}, },
{ {
field: "system_name", field: "description",
headerName: "Системное название", headerName: "Описание",
flex: 1, flex: 1,
renderCell: (params: GridRenderCellParams) => { renderCell: (params: GridRenderCellParams) => {
return ( return (
@@ -66,29 +79,10 @@ export const StationListPage = observer(() => {
); );
}, },
}, },
{
field: "direction",
headerName: "Направление",
width: 200,
align: "center",
headerAlign: "center",
renderCell: (params: GridRenderCellParams) => {
return (
<p
className={
params.row.direction === true ? "text-green-500" : "text-red-500"
}
>
{params.row.direction ? "Прямой" : "Обратный"}
</p>
);
},
},
{ {
field: "actions", field: "actions",
headerName: "Действия", headerName: "Действия",
width: 140, width: 200,
align: "center", align: "center",
headerAlign: "center", headerAlign: "center",
sortable: false, sortable: false,
@@ -102,6 +96,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);
@@ -116,7 +119,6 @@ export const StationListPage = observer(() => {
}, },
]; ];
// Фильтрация станций по выбранному городу
const filteredStations = () => { const filteredStations = () => {
const { selectedCityId } = selectedCityStore; const { selectedCityId } = selectedCityStore;
if (!selectedCityId) { if (!selectedCityId) {
@@ -130,8 +132,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,
})); }));
return ( return (
@@ -144,29 +145,75 @@ export const StationListPage = observer(() => {
<CreateButton label="Создать остановки" path="/station/create" /> <CreateButton label="Создать остановки" path="/station/create" />
</div> </div>
<div <div className="flex justify-end mb-5 duration-300">
className="flex justify-end mb-5 duration-300"
style={{ opacity: ids.length > 0 ? 1 : 0 }}
>
<button <button
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center" className={`px-4 py-2 rounded flex gap-2 items-center transition-all ${
onClick={() => setIsBulkDeleteModalOpen(true)} ids.length > 0
? "bg-red-500 text-white cursor-pointer opacity-100"
: "bg-gray-300 text-gray-500 cursor-not-allowed opacity-0 pointer-events-none"
}`}
onClick={() => {
if (ids.length > 0) {
setIsBulkDeleteModalOpen(true);
}
}}
disabled={ids.length === 0}
> >
<Trash2 size={20} className="text-white" /> Удалить выбранные ( <Trash2
{ids.length}) size={20}
className={ids.length > 0 ? "text-white" : "text-gray-500"}
/>
Удалить выбранные ({ids.length})
</button> </button>
</div> </div>
<DataGrid <DataGrid
rows={rows} rows={rows}
columns={columns} columns={columns}
hideFooterPagination
checkboxSelection checkboxSelection
disableRowSelectionExcludeModel
loading={isLoading} loading={isLoading}
onRowSelectionModelChange={(newSelection) => { paginationModel={paginationModel}
setIds(Array.from(newSelection.ids) as number[]); onPaginationModelChange={setPaginationModel}
pageSizeOptions={[50]}
onRowSelectionModelChange={(newSelection: any) => {
if (Array.isArray(newSelection)) {
const selectedIds = newSelection
.map((id: string | number) => {
const numId =
typeof id === "string"
? Number.parseInt(id, 10)
: Number(id);
return numId;
})
.filter(
(id: number) =>
!Number.isNaN(id) && id !== null && id !== undefined
);
setIds(selectedIds);
} else if (
newSelection &&
typeof newSelection === "object" &&
"ids" in newSelection
) {
const idsSet = newSelection.ids as Set<string | number>;
const selectedIds = Array.from(idsSet)
.map((id: string | number) => {
const numId =
typeof id === "string"
? Number.parseInt(id, 10)
: Number(id);
return numId;
})
.filter(
(id: number) =>
!Number.isNaN(id) && id !== null && id !== undefined
);
setIds(selectedIds);
} else {
setIds([]);
}
}} }}
hideFooter
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText} localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
slots={{ slots={{
noRowsOverlay: () => ( noRowsOverlay: () => (
@@ -205,6 +252,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) {
await getStationPreview(Number(id)); setIsLoadingData(true);
try {
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 />
@@ -44,21 +67,6 @@ export const StationPreviewPage = observer(() => {
<p>{stationPreview[id!]?.[language]?.data.system_name}</p> <p>{stationPreview[id!]?.[language]?.data.system_name}</p>
</div> </div>
<div className="flex flex-col gap-2">
<h1 className="text-lg font-bold">Направление</h1>
<p
className={`${
stationPreview[id!]?.[language]?.data.direction
? "text-green-500"
: "text-red-500"
}`}
>
{stationPreview[id!]?.[language]?.data.direction
? "Прямой"
: "Обратный"}
</p>
</div>
{stationPreview[id!]?.[language]?.data.address && ( {stationPreview[id!]?.[language]?.data.address && (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<h1 className="text-lg font-bold">Адрес</h1> <h1 className="text-lg font-bold">Адрес</h1>

View File

@@ -6,17 +6,35 @@ import {
FormControlLabel, FormControlLabel,
} 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, Loader2, Save } from "lucide-react";
import { Loader2 } from "lucide-react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { userStore } from "@shared"; import {
import { useState } from "react"; userStore,
mediaStore,
isMediaIdEmpty,
SelectMediaDialog,
UploadMediaDialog,
PreviewMediaDialog,
} from "@shared";
import { useState, useEffect } from "react";
import { ImageUploadCard } from "@widgets";
export const UserCreatePage = observer(() => { export const UserCreatePage = observer(() => {
const navigate = useNavigate(); const navigate = useNavigate();
const { createUserData, setCreateUserData, createUser } = userStore; const { createUserData, setCreateUserData, createUser } = userStore;
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false);
const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false);
const [mediaId, setMediaId] = useState("");
const [activeMenuType, setActiveMenuType] = useState<
"thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null
>(null);
useEffect(() => {
mediaStore.getMedia();
}, []);
const handleCreate = async () => { const handleCreate = async () => {
try { try {
@@ -31,6 +49,29 @@ export const UserCreatePage = observer(() => {
} }
}; };
const handleMediaSelect = (media: {
id: string;
filename: string;
media_name?: string;
media_type: number;
}) => {
setCreateUserData(
createUserData.name || "",
createUserData.email || "",
createUserData.password || "",
createUserData.is_admin || false,
media.id
);
};
const selectedMedia =
createUserData.icon && !isMediaIdEmpty(createUserData.icon)
? mediaStore.media.find((m) => m.id === createUserData.icon)
: null;
const effectiveIconUrl = isMediaIdEmpty(createUserData.icon)
? null
: selectedMedia?.id ?? createUserData.icon ?? null;
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">
@@ -54,7 +95,8 @@ export const UserCreatePage = observer(() => {
e.target.value, e.target.value,
createUserData.email || "", createUserData.email || "",
createUserData.password || "", createUserData.password || "",
createUserData.is_admin || false createUserData.is_admin || false,
createUserData.icon
) )
} }
/> />
@@ -69,7 +111,8 @@ export const UserCreatePage = observer(() => {
createUserData.name || "", createUserData.name || "",
e.target.value, e.target.value,
createUserData.password || "", createUserData.password || "",
createUserData.is_admin || false createUserData.is_admin || false,
createUserData.icon
) )
} }
/> />
@@ -84,7 +127,8 @@ export const UserCreatePage = observer(() => {
createUserData.name || "", createUserData.name || "",
createUserData.email || "", createUserData.email || "",
e.target.value, e.target.value,
createUserData.is_admin || false createUserData.is_admin || false,
createUserData.icon
) )
} }
/> />
@@ -99,7 +143,8 @@ export const UserCreatePage = observer(() => {
createUserData.name || "", createUserData.name || "",
createUserData.email || "", createUserData.email || "",
createUserData.password || "", createUserData.password || "",
e.target.checked e.target.checked,
createUserData.icon
); );
}} }}
/> />
@@ -108,6 +153,36 @@ export const UserCreatePage = observer(() => {
/> />
</div> </div>
<div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto">
<ImageUploadCard
title="Аватар"
imageKey="thumbnail"
imageUrl={effectiveIconUrl}
onImageClick={() => {
setIsPreviewMediaOpen(true);
setMediaId(effectiveIconUrl ?? "");
}}
onDeleteImageClick={() => {
setCreateUserData(
createUserData.name || "",
createUserData.email || "",
createUserData.password || "",
createUserData.is_admin || false,
""
);
setActiveMenuType(null);
}}
onSelectFileClick={() => {
setActiveMenuType("image");
setIsSelectMediaOpen(true);
}}
setUploadMediaOpen={() => {
setIsUploadMediaOpen(true);
setActiveMenuType("image");
}}
/>
</div>
<Button <Button
variant="contained" variant="contained"
className="w-min flex gap-2 items-center" className="w-min flex gap-2 items-center"
@@ -124,6 +199,28 @@ export const UserCreatePage = observer(() => {
)} )}
</Button> </Button>
</div> </div>
<SelectMediaDialog
open={isSelectMediaOpen}
onClose={() => setIsSelectMediaOpen(false)}
onSelectMedia={handleMediaSelect}
mediaType={1}
/>
<UploadMediaDialog
open={isUploadMediaOpen}
onClose={() => setIsUploadMediaOpen(false)}
contextObjectName={createUserData.name || "Пользователь"}
contextType="user"
afterUpload={handleMediaSelect}
hardcodeType={activeMenuType}
/>
<PreviewMediaDialog
open={isPreviewMediaOpen}
onClose={() => setIsPreviewMediaOpen(false)}
mediaId={mediaId}
/>
</Paper> </Paper>
); );
}); });

View File

@@ -4,24 +4,43 @@ 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, Loader2, Save } 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,
mediaStore,
isMediaIdEmpty,
SelectMediaDialog,
UploadMediaDialog,
PreviewMediaDialog,
} from "@shared";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { ImageUploadCard, DeleteModal } from "@widgets";
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;
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false);
const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false);
const [mediaId, setMediaId] = useState("");
const [isDeleteIconModalOpen, setIsDeleteIconModalOpen] = useState(false);
const [activeMenuType, setActiveMenuType] = useState<
"thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null
>(null);
useEffect(() => { useEffect(() => {
// Устанавливаем русский язык при загрузке страницы
languageStore.setLanguage("ru"); languageStore.setLanguage("ru");
}, []); }, []);
@@ -38,21 +57,70 @@ export const UserEditPage = observer(() => {
} }
}; };
const handleMediaSelect = (media: {
id: string;
filename: string;
media_name?: string;
media_type: number;
}) => {
setEditUserData(
editUserData.name || "",
editUserData.email || "",
editUserData.password || "",
editUserData.is_admin || false,
media.id
);
};
useEffect(() => { useEffect(() => {
(async () => { (async () => {
if (id) { if (id) {
const data = await getUser(Number(id)); setIsLoadingData(true);
try {
await mediaStore.getMedia();
const data = await getUser(Number(id));
setEditUserData( if (data) {
data?.name || "", setEditUserData(
data?.email || "", data.name || "",
data?.password || "", data.email || "",
data?.is_admin || false data.password || "",
); data.is_admin || false,
data.icon || ""
);
}
} finally {
setIsLoadingData(false);
}
} else {
setIsLoadingData(false);
} }
})(); })();
}, [id]); }, [id]);
const selectedMedia =
editUserData.icon && !isMediaIdEmpty(editUserData.icon)
? mediaStore.media.find((m) => m.id === editUserData.icon)
: null;
const effectiveIconUrl = isMediaIdEmpty(editUserData.icon)
? null
: selectedMedia?.id ?? editUserData.icon ?? 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">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
@@ -76,7 +144,8 @@ export const UserEditPage = observer(() => {
e.target.value, e.target.value,
editUserData.email || "", editUserData.email || "",
editUserData.password || "", editUserData.password || "",
editUserData.is_admin || false editUserData.is_admin || false,
editUserData.icon
) )
} }
/> />
@@ -90,7 +159,8 @@ export const UserEditPage = observer(() => {
editUserData.name || "", editUserData.name || "",
e.target.value, e.target.value,
editUserData.password || "", editUserData.password || "",
editUserData.is_admin || false editUserData.is_admin || false,
editUserData.icon
) )
} }
/> />
@@ -98,14 +168,15 @@ export const UserEditPage = observer(() => {
<TextField <TextField
fullWidth fullWidth
label="Пароль" label="Пароль"
placeholder="Оставить пустым, чтобы не менять"
value={editUserData.password || ""} value={editUserData.password || ""}
required
onChange={(e) => onChange={(e) =>
setEditUserData( setEditUserData(
editUserData.name || "", editUserData.name || "",
editUserData.email || "", editUserData.email || "",
e.target.value, e.target.value,
editUserData.is_admin || false editUserData.is_admin || false,
editUserData.icon
) )
} }
/> />
@@ -118,7 +189,8 @@ export const UserEditPage = observer(() => {
editUserData.name || "", editUserData.name || "",
editUserData.email || "", editUserData.email || "",
editUserData.password || "", editUserData.password || "",
e.target.checked e.target.checked,
editUserData.icon
) )
} }
/> />
@@ -126,6 +198,27 @@ export const UserEditPage = observer(() => {
label="Администратор" label="Администратор"
/> />
<div className="w-full flex flex-col gap-4 max-w-[300px]">
<ImageUploadCard
title="Аватар"
imageKey="thumbnail"
imageUrl={effectiveIconUrl}
onImageClick={() => {
setIsPreviewMediaOpen(true);
setMediaId(effectiveIconUrl ?? "");
}}
onDeleteImageClick={() => setIsDeleteIconModalOpen(true)}
onSelectFileClick={() => {
setActiveMenuType("image");
setIsSelectMediaOpen(true);
}}
setUploadMediaOpen={() => {
setIsUploadMediaOpen(true);
setActiveMenuType("image");
}}
/>
</div>
<Button <Button
variant="contained" variant="contained"
className="w-min flex gap-2 items-center self-end" className="w-min flex gap-2 items-center self-end"
@@ -140,6 +233,44 @@ export const UserEditPage = observer(() => {
)} )}
</Button> </Button>
</div> </div>
<SelectMediaDialog
open={isSelectMediaOpen}
onClose={() => setIsSelectMediaOpen(false)}
onSelectMedia={handleMediaSelect}
mediaType={1}
/>
<UploadMediaDialog
open={isUploadMediaOpen}
onClose={() => setIsUploadMediaOpen(false)}
contextObjectName={editUserData.name || "Пользователь"}
contextType="user"
afterUpload={handleMediaSelect}
hardcodeType={activeMenuType}
/>
<PreviewMediaDialog
open={isPreviewMediaOpen}
onClose={() => setIsPreviewMediaOpen(false)}
mediaId={mediaId}
/>
<DeleteModal
open={isDeleteIconModalOpen}
onDelete={() => {
setEditUserData(
editUserData.name || "",
editUserData.email || "",
editUserData.password || "",
editUserData.is_admin || false,
""
);
setIsDeleteIconModalOpen(false);
}}
onCancel={() => setIsDeleteIconModalOpen(false)}
edit
/>
</Paper> </Paper>
); );
}); });

View File

@@ -16,6 +16,10 @@ export const UserListPage = observer(() => {
const [rowId, setRowId] = useState<number | null>(null); const [rowId, setRowId] = 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 [paginationModel, setPaginationModel] = useState({
page: 0,
pageSize: 50,
});
useEffect(() => { useEffect(() => {
const fetchUsers = async () => { const fetchUsers = async () => {
@@ -126,29 +130,39 @@ export const UserListPage = observer(() => {
<CreateButton label="Создать пользователя" path="/user/create" /> <CreateButton label="Создать пользователя" path="/user/create" />
</div> </div>
<div {ids.length > 0 && (
className="flex justify-end mb-5 duration-300" <div className="flex justify-end mb-5 duration-300">
style={{ opacity: ids.length > 0 ? 1 : 0 }} <button
> className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
<button onClick={() => setIsBulkDeleteModalOpen(true)}
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center" >
onClick={() => setIsBulkDeleteModalOpen(true)} <Trash2 size={20} className="text-white" /> Удалить выбранные (
> {ids.length})
<Trash2 size={20} className="text-white" /> Удалить выбранные ( </button>
{ids.length}) </div>
</button> )}
</div>
<DataGrid <DataGrid
rows={rows} rows={rows}
columns={columns} columns={columns}
hideFooterPagination
checkboxSelection checkboxSelection
disableRowSelectionExcludeModel
loading={isLoading} loading={isLoading}
onRowSelectionModelChange={(newSelection) => { paginationModel={paginationModel}
setIds(Array.from(newSelection.ids) as number[]); onPaginationModelChange={setPaginationModel}
pageSizeOptions={[50]}
onRowSelectionModelChange={(newSelection: any) => {
if (Array.isArray(newSelection)) {
const selectedIds = newSelection.map((id: string | number) => Number(id));
setIds(selectedIds);
} else if (newSelection && typeof newSelection === 'object' && 'ids' in newSelection) {
const idsSet = newSelection.ids as Set<string | number>;
const selectedIds = Array.from(idsSet).map((id: string | number) => Number(id));
setIds(selectedIds);
} else {
setIds([]);
}
}} }}
hideFooter
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText} localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
slots={{ slots={{
noRowsOverlay: () => ( noRowsOverlay: () => (

View File

@@ -25,6 +25,7 @@ export const VehicleCreatePage = observer(() => {
const [tailNumber, setTailNumber] = useState(""); const [tailNumber, setTailNumber] = useState("");
const [type, setType] = useState(""); const [type, setType] = useState("");
const [carrierId, setCarrierId] = useState<number | null>(null); const [carrierId, setCarrierId] = useState<number | null>(null);
const [model, setModel] = useState("");
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const { language } = languageStore; const { language } = languageStore;
@@ -36,11 +37,12 @@ export const VehicleCreatePage = observer(() => {
try { try {
setIsLoading(true); setIsLoading(true);
await vehicleStore.createVehicle( await vehicleStore.createVehicle(
Number(tailNumber), tailNumber,
Number(type), Number(type),
carrierStore.carriers[language].data?.find((c) => c.id === carrierId) carrierStore.carriers[language].data?.find((c) => c.id === carrierId)
?.full_name as string, ?.full_name as string,
carrierId! carrierId!,
model || undefined,
); );
toast.success("Транспорт успешно создан"); toast.success("Транспорт успешно создан");
} catch (error) { } catch (error) {
@@ -103,6 +105,14 @@ export const VehicleCreatePage = observer(() => {
</Select> </Select>
</FormControl> </FormControl>
<TextField
fullWidth
label="Модель ТС"
value={model}
onChange={(e) => setModel(e.target.value)}
placeholder="Произвольное название модели"
/>
<Button <Button
variant="contained" variant="contained"
className="w-min flex gap-2 items-center" className="w-min flex gap-2 items-center"

View File

@@ -6,6 +6,9 @@ import {
FormControl, FormControl,
InputLabel, InputLabel,
Button, Button,
Box,
FormControlLabel,
Checkbox,
} from "@mui/material"; } from "@mui/material";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { ArrowLeft, Loader2, Save } from "lucide-react"; import { ArrowLeft, Loader2, Save } from "lucide-react";
@@ -16,6 +19,7 @@ import {
languageStore, languageStore,
VEHICLE_TYPES, VEHICLE_TYPES,
vehicleStore, vehicleStore,
LoadingSpinner,
} from "@shared"; } from "@shared";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
@@ -31,6 +35,8 @@ export const VehicleEditPage = observer(() => {
} = vehicleStore; } = vehicleStore;
const { getCarriers } = carrierStore; const { getCarriers } = carrierStore;
const { language } = languageStore; const { language } = languageStore;
const [isLoading, setIsLoading] = useState(false);
const [isLoadingData, setIsLoadingData] = useState(true);
useEffect(() => { useEffect(() => {
// Устанавливаем русский язык при загрузке страницы // Устанавливаем русский язык при загрузке страницы
@@ -38,31 +44,61 @@ export const VehicleEditPage = observer(() => {
}, []); }, []);
useEffect(() => { useEffect(() => {
(async () => { const fetchAndSetVehicleData = async () => {
await getVehicle(Number(id)); if (!id) {
await getCarriers(language); setIsLoadingData(false);
return;
}
setEditVehicleData({ setIsLoadingData(true);
tail_number: vehicle[Number(id)]?.vehicle.tail_number, try {
type: vehicle[Number(id)]?.vehicle.type, await getVehicle(Number(id));
carrier: vehicle[Number(id)]?.vehicle.carrier, await getCarriers(language);
carrier_id: vehicle[Number(id)]?.vehicle.carrier_id,
}); setEditVehicleData({
})(); tail_number: vehicle[Number(id)]?.vehicle.tail_number ?? "",
type: vehicle[Number(id)]?.vehicle.type ?? 0,
carrier: vehicle[Number(id)]?.vehicle.carrier ?? "",
carrier_id: vehicle[Number(id)]?.vehicle.carrier_id ?? 0,
model: vehicle[Number(id)]?.vehicle.model ?? "",
snapshot_update_blocked:
vehicle[Number(id)]?.vehicle.snapshot_update_blocked ?? false,
});
} finally {
setIsLoadingData(false);
}
};
fetchAndSetVehicleData();
}, [id, language]); }, [id, language]);
const [isLoading, setIsLoading] = useState(false);
const handleEdit = async () => { const handleEdit = async () => {
try { try {
setIsLoading(true); setIsLoading(true);
await editVehicle(Number(id), editVehicleData); await editVehicle(Number(id), editVehicleData);
toast.success("Транспортное средство успешно обновлено"); toast.success("Транспортное средство успешно обновлено");
navigate("/vehicle"); navigate("/devices");
} catch (error) { } catch (error) {
toast.error("Ошибка при обновлении транспортного средства"); toast.error("Ошибка при обновлении транспортного средства");
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}; };
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">
@@ -84,7 +120,7 @@ export const VehicleEditPage = observer(() => {
onChange={(e) => onChange={(e) =>
setEditVehicleData({ setEditVehicleData({
...editVehicleData, ...editVehicleData,
tail_number: Number(e.target.value), tail_number: e.target.value,
}) })
} }
/> />
@@ -128,6 +164,35 @@ export const VehicleEditPage = observer(() => {
</Select> </Select>
</FormControl> </FormControl>
<TextField
fullWidth
label="Модель ТС"
value={editVehicleData.model}
onChange={(e) =>
setEditVehicleData({
...editVehicleData,
model: e.target.value,
})
}
placeholder="Произвольное название модели"
/>
<FormControlLabel
control={
<Checkbox
checked={editVehicleData.snapshot_update_blocked}
onChange={(e) =>
setEditVehicleData({
...editVehicleData,
snapshot_update_blocked: e.target.checked,
})
}
color="primary"
/>
}
label="Блокировка обновления ПО"
/>
<Button <Button
variant="contained" variant="contained"
className="w-min flex gap-2 items-center" className="w-min flex gap-2 items-center"

View File

@@ -18,6 +18,10 @@ export const VehicleListPage = observer(() => {
const [rowId, setRowId] = useState<number | null>(null); const [rowId, setRowId] = 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 [paginationModel, setPaginationModel] = useState({
page: 0,
pageSize: 50,
});
const { language } = languageStore; const { language } = languageStore;
useEffect(() => { useEffect(() => {
@@ -148,29 +152,39 @@ export const VehicleListPage = observer(() => {
/> />
</div> </div>
<div {ids.length > 0 && (
className="flex justify-end mb-5 duration-300" <div className="flex justify-end mb-5 duration-300">
style={{ opacity: ids.length > 0 ? 1 : 0 }} <button
> className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
<button onClick={() => setIsBulkDeleteModalOpen(true)}
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center" >
onClick={() => setIsBulkDeleteModalOpen(true)} <Trash2 size={20} className="text-white" /> Удалить выбранные (
> {ids.length})
<Trash2 size={20} className="text-white" /> Удалить выбранные ( </button>
{ids.length}) </div>
</button> )}
</div>
<DataGrid <DataGrid
rows={rows} rows={rows}
columns={columns} columns={columns}
hideFooterPagination
checkboxSelection checkboxSelection
disableRowSelectionExcludeModel
loading={isLoading} loading={isLoading}
onRowSelectionModelChange={(newSelection) => { paginationModel={paginationModel}
setIds(Array.from(newSelection.ids) as number[]); onPaginationModelChange={setPaginationModel}
pageSizeOptions={[50]}
onRowSelectionModelChange={(newSelection: any) => {
if (Array.isArray(newSelection)) {
const selectedIds = newSelection.map((id: string | number) => Number(id));
setIds(selectedIds);
} else if (newSelection && typeof newSelection === 'object' && 'ids' in newSelection) {
const idsSet = newSelection.ids as Set<string | number>;
const selectedIds = Array.from(idsSet).map((id: string | number) => Number(id));
setIds(selectedIds);
} else {
setIds([]);
}
}} }}
hideFooter
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText} localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
slots={{ slots={{
noRowsOverlay: () => ( noRowsOverlay: () => (

View File

@@ -36,7 +36,7 @@ export const NAVIGATION_ITEMS: {
primary: [ primary: [
{ {
id: "snapshots", id: "snapshots",
label: "Снапшоты", label: "Экспорт",
icon: GitBranch, icon: GitBranch,
path: "/snapshot", path: "/snapshot",
for_admin: true, for_admin: true,
@@ -124,6 +124,16 @@ export const NAVIGATION_ITEMS: {
}; };
export const VEHICLE_TYPES = [ export const VEHICLE_TYPES = [
{ label: "Трамвай", value: 1 }, { label: "Автобус", value: 3 },
{ label: "Троллейбус", value: 2 }, { label: "Троллейбус", value: 2 },
{ label: "Трамвай", value: 1 },
{ label: "Электробус", value: 4 },
{ label: "Электричка", value: 5 },
{ label: "Вагон метро", value: 6 },
{ label: "Вагон ЖД", value: 7 },
]; ];
export const VEHICLE_MODELS = [
{ label: "71-431P «Довлатов»", value: "71-431P «Довлатов»" },
{ label: "71-638M-02 «Альтаир»", value: "71-638M-02 «Альтаир»" },
] as const;

View File

@@ -33,3 +33,12 @@ export const generateDefaultMediaName = (
return `${objectName || "Название"}_${fileNameWithoutExtension}_Медиа`; return `${objectName || "Название"}_${fileNameWithoutExtension}_Медиа`;
}; };
/** Медиа-id считается пустым, если строка пустая или состоит только из нулей (с дефисами или без). */
export const isMediaIdEmpty = (
id: string | null | undefined
): boolean => {
if (id == null || id === "") return true;
const digits = id.replace(/-/g, "");
return digits === "" || /^0+$/.test(digits);
};

View File

@@ -51,7 +51,9 @@ interface UploadMediaDialogProps {
| "carrier" | "carrier"
| "country" | "country"
| "vehicle" | "vehicle"
| "station"; | "station"
| "route"
| "user";
isArticle?: boolean; isArticle?: boolean;
articleName?: string; articleName?: string;
initialFile?: File; initialFile?: File;

View File

@@ -86,28 +86,35 @@ class EditSightStore {
} }
hasLoadedCommon = false; hasLoadedCommon = false;
isLoading = false;
getSightInfo = async (id: number, language: Language) => { getSightInfo = async (id: number, language: Language) => {
const response = await languageInstance(language).get(`/sight/${id}`); this.isLoading = true;
const data = response.data; try {
const response = await languageInstance(language).get(`/sight/${id}`);
const data = response.data;
if (data.left_article != 0 && data.left_article != null) { if (data.left_article != 0 && data.left_article != null) {
await this.getLeftArticle(data.left_article); await this.getLeftArticle(data.left_article);
} }
runInAction(() => { runInAction(() => {
this.sight[language] = { this.sight[language] = {
...this.sight[language], ...this.sight[language],
...data,
};
if (!this.hasLoadedCommon) {
this.sight.common = {
...this.sight.common,
...data, ...data,
}; };
this.hasLoadedCommon = true;
} if (!this.hasLoadedCommon) {
}); this.sight.common = {
...this.sight.common,
...data,
};
this.hasLoadedCommon = true;
}
});
} finally {
this.isLoading = false;
}
}; };
updateLeftInfo = (language: Language, heading: string, body: string) => { updateLeftInfo = (language: Language, heading: string, body: string) => {
@@ -168,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,
@@ -479,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();
return { mediaStore.getMedia();
id: response.data.id,
filename: filename, return {
media_name: media_name, id: response.data.id,
media_type: type, filename: filename,
}; media_name: media_name,
} catch (error) {} media_type: type,
};
}; };
createLinkWithArticle = async (media: { createLinkWithArticle = async (media: {

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

@@ -1,5 +1,10 @@
import { makeAutoObservable, runInAction } from "mobx"; import { makeAutoObservable, runInAction } from "mobx";
import { authInstance } from "@shared"; import {
authInstance,
languageInstance,
languageStore,
isMediaIdEmpty,
} from "@shared";
export type Route = { export type Route = {
route_name: string; route_name: string;
@@ -9,6 +14,7 @@ export type Route = {
center_longitude: number; center_longitude: number;
governor_appeal: number; governor_appeal: number;
id: number; id: number;
icon: string;
path: number[][]; path: number[][];
rotate: number; rotate: number;
route_direction: boolean; route_direction: boolean;
@@ -89,11 +95,43 @@ 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(
(station) => station.id === stationId // Получаем актуальные данные станции с сервера
), const stationResponse = await languageInstance(language).get(
`/station/${stationId}`
);
const fullStationData = stationResponse.data;
// Получаем отредактированные данные из локального кеша
const editedStationData = this.routeStations[routeId]?.find(
(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
);
}
}); });
}; };
@@ -105,6 +143,7 @@ class RouteStore {
center_longitude: "", center_longitude: "",
governor_appeal: 0, governor_appeal: 0,
id: 0, id: 0,
icon: "",
path: [] as number[][], path: [] as number[][],
rotate: 0, rotate: 0,
route_direction: false, route_direction: false,
@@ -120,14 +159,27 @@ class RouteStore {
}; };
editRoute = async (id: number) => { editRoute = async (id: number) => {
if (!this.editRouteData.video_preview) { if (
!this.editRouteData.video_preview ||
isMediaIdEmpty(this.editRouteData.video_preview)
) {
delete this.editRouteData.video_preview; delete this.editRouteData.video_preview;
} }
const response = await authInstance.patch(`/route/${id}`, { if (!this.editRouteData.icon || isMediaIdEmpty(this.editRouteData.icon)) {
delete (this.editRouteData as any).icon;
}
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

@@ -132,12 +132,16 @@ class SightsStore {
common: boolean common: boolean
) => { ) => {
if (common) { if (common) {
// @ts-ignore
this.sight!.common = { this.sight!.common = {
// @ts-ignore
...this.sight!.common, ...this.sight!.common,
...content, ...content,
}; };
} else { } else {
// @ts-ignore
this.sight![language] = { this.sight![language] = {
// @ts-ignore
...this.sight![language], ...this.sight![language],
...content, ...content,
}; };

View File

@@ -1,5 +1,5 @@
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 { import {
articlesStore, articlesStore,
@@ -25,9 +25,18 @@ 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);
@@ -266,7 +275,23 @@ class SnapshotStore {
}; };
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";
@@ -12,7 +13,6 @@ type StationLanguageData = {
type StationCommonData = { type StationCommonData = {
city_id: number; city_id: number;
direction: boolean;
description: string; description: string;
icon: string; icon: string;
latitude: number; latitude: number;
@@ -43,7 +43,6 @@ type Station = {
city: string; city: string;
city_id: number; city_id: number;
description: string; description: string;
direction: boolean;
icon: string; icon: string;
latitude: number; latitude: number;
longitude: number; longitude: number;
@@ -122,7 +121,6 @@ class StationsStore {
common: { common: {
city: "", city: "",
city_id: 0, city_id: 0,
direction: false,
description: "", description: "",
icon: "", icon: "",
latitude: 0, latitude: 0,
@@ -168,7 +166,6 @@ class StationsStore {
common: { common: {
city: "", city: "",
city_id: 0, city_id: 0,
direction: false,
description: "", description: "",
icon: "", icon: "",
latitude: 0, latitude: 0,
@@ -251,7 +248,6 @@ class StationsStore {
common: { common: {
city: ruResponse.data.city, city: ruResponse.data.city,
city_id: ruResponse.data.city_id, city_id: ruResponse.data.city_id,
direction: ruResponse.data.direction,
description: ruResponse.data.description, description: ruResponse.data.description,
icon: ruResponse.data.icon, icon: ruResponse.data.icon,
latitude: ruResponse.data.latitude, latitude: ruResponse.data.latitude,
@@ -276,7 +272,6 @@ class StationsStore {
editStation = async (id: number) => { editStation = async (id: number) => {
const commonDataPayload = { const commonDataPayload = {
city_id: this.editStationData.common.city_id, city_id: this.editStationData.common.city_id,
direction: this.editStationData.common.direction,
icon: this.editStationData.common.icon, icon: this.editStationData.common.icon,
latitude: this.editStationData.common.latitude, latitude: this.editStationData.common.latitude,
longitude: this.editStationData.common.longitude, longitude: this.editStationData.common.longitude,
@@ -404,7 +399,6 @@ class StationsStore {
const { language } = languageStore; const { language } = languageStore;
let commonDataPayload: Partial<StationCommonData> = { let commonDataPayload: Partial<StationCommonData> = {
city_id: this.createStationData.common.city_id, city_id: this.createStationData.common.city_id,
direction: this.createStationData.common.direction,
icon: this.createStationData.common.icon, icon: this.createStationData.common.icon,
latitude: this.createStationData.common.latitude, latitude: this.createStationData.common.latitude,
longitude: this.createStationData.common.longitude, longitude: this.createStationData.common.longitude,
@@ -478,7 +472,6 @@ class StationsStore {
common: { common: {
city: "", city: "",
city_id: 0, city_id: 0,
direction: false,
icon: "", icon: "",
latitude: 0, latitude: 0,
description: "", description: "",
@@ -525,7 +518,6 @@ class StationsStore {
common: { common: {
city: "", city: "",
city_id: 0, city_id: 0,
direction: false,
description: "", description: "",
icon: "", icon: "",
latitude: 0, latitude: 0,
@@ -546,6 +538,98 @@ 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,
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

@@ -7,6 +7,7 @@ export type User = {
is_admin: boolean; is_admin: boolean;
name: string; name: string;
password?: string; password?: string;
icon?: string;
}; };
class UserStore { class UserStore {
@@ -57,15 +58,23 @@ class UserStore {
email: "", email: "",
password: "", password: "",
is_admin: false, is_admin: false,
icon: "",
}; };
setCreateUserData = ( setCreateUserData = (
name: string, name: string,
email: string, email: string,
password: string, password: string,
is_admin: boolean is_admin: boolean,
icon?: string
) => { ) => {
this.createUserData = { name, email, password, is_admin }; this.createUserData = {
name,
email,
password,
is_admin,
icon: icon ?? "",
};
}; };
createUser = async () => { createUser = async () => {
@@ -73,7 +82,9 @@ class UserStore {
if (this.users.data.length > 0) { if (this.users.data.length > 0) {
id = this.users.data[this.users.data.length - 1].id + 1; id = this.users.data[this.users.data.length - 1].id + 1;
} }
const response = await authInstance.post("/user", this.createUserData); const payload = { ...this.createUserData };
if (!payload.icon) delete payload.icon;
const response = await authInstance.post("/user", payload);
runInAction(() => { runInAction(() => {
this.users.data.push({ this.users.data.push({
@@ -88,19 +99,30 @@ class UserStore {
email: "", email: "",
password: "", password: "",
is_admin: false, is_admin: false,
icon: "",
}; };
setEditUserData = ( setEditUserData = (
name: string, name: string,
email: string, email: string,
password: string, password: string,
is_admin: boolean is_admin: boolean,
icon?: string
) => { ) => {
this.editUserData = { name, email, password, is_admin }; this.editUserData = {
name,
email,
password,
is_admin,
icon: icon ?? "",
};
}; };
editUser = async (id: number) => { editUser = async (id: number) => {
const response = await authInstance.patch(`/user/${id}`, this.editUserData); const payload = { ...this.editUserData };
if (!payload.icon) delete payload.icon;
if (!payload.password?.trim()) delete payload.password;
const response = await authInstance.patch(`/user/${id}`, payload);
runInAction(() => { runInAction(() => {
this.users.data = this.users.data.map((user) => this.users.data = this.users.data.map((user) =>

View File

@@ -1,14 +1,20 @@
import { languageInstance } from "@shared"; import { authInstance, languageInstance } from "@shared";
import { makeAutoObservable, runInAction } from "mobx"; import { makeAutoObservable, runInAction } from "mobx";
export type Vehicle = { export type Vehicle = {
vehicle: { vehicle: {
id: number; id: number;
tail_number: number; tail_number: string;
type: number; type: number;
carrier_id: number; carrier_id: number;
carrier: string; carrier: string;
uuid?: string; uuid?: string;
model?: string;
current_snapshot_uuid?: string;
snapshot_update_blocked?: boolean;
demo_mode_enabled?: boolean;
maintenance_mode_on?: boolean;
city_id?: number;
}; };
device_status?: { device_status?: {
device_uuid: string; device_uuid: string;
@@ -34,11 +40,75 @@ class VehicleStore {
makeAutoObservable(this); makeAutoObservable(this);
} }
private normalizeVehicleItem = (item: any): Vehicle => {
if (item && typeof item === "object" && "vehicle" in item) {
return {
vehicle: item.vehicle ?? {},
device_status: item.device_status,
} as Vehicle;
}
return {
vehicle: item ?? {},
} as Vehicle;
};
private mergeVehicleInCaches = (updatedVehicle: any) => {
if (!updatedVehicle) return;
const updatedId = updatedVehicle.id;
const updatedUuid = updatedVehicle.uuid;
const mergeItem = (item: Vehicle): Vehicle => ({
...item,
vehicle: {
...item.vehicle,
...updatedVehicle,
},
});
this.vehicles.data = this.vehicles.data.map((item) => {
const sameId = updatedId != null && item.vehicle.id === updatedId;
const sameUuid =
updatedUuid != null &&
item.vehicle.uuid != null &&
item.vehicle.uuid === updatedUuid;
if (!sameId && !sameUuid) return item;
return mergeItem(item);
});
if (updatedId != null) {
const existing = this.vehicle[updatedId];
this.vehicle[updatedId] = existing
? mergeItem(existing)
: ({ vehicle: updatedVehicle } as Vehicle);
return;
}
if (updatedUuid != null) {
const entry = Object.entries(this.vehicle).find(
([, item]) => item.vehicle.uuid === updatedUuid
);
if (entry) {
const [key, item] = entry;
this.vehicle[key] = mergeItem(item);
}
}
};
getVehicles = async () => { getVehicles = async () => {
const response = await languageInstance("ru").get(`/vehicle`); const response = await languageInstance("ru").get(`/vehicle`);
const vehiclesList = Array.isArray(response.data)
? response.data
: Array.isArray(response.data?.vehicles)
? response.data.vehicles
: [];
runInAction(() => { runInAction(() => {
this.vehicles.data = response.data; this.vehicles.data = vehiclesList.map(this.normalizeVehicleItem);
this.vehicles.loaded = true; this.vehicles.loaded = true;
}); });
}; };
@@ -55,56 +125,62 @@ class VehicleStore {
getVehicle = async (id: number) => { getVehicle = async (id: number) => {
const response = await languageInstance("ru").get(`/vehicle/${id}`); const response = await languageInstance("ru").get(`/vehicle/${id}`);
const normalizedVehicle = this.normalizeVehicleItem(response.data);
runInAction(() => { runInAction(() => {
this.vehicle[id] = response.data; this.vehicle[id] = normalizedVehicle;
}); });
}; };
createVehicle = async ( createVehicle = async (
tailNumber: number, tailNumber: string,
type: number, type: number,
carrier: string, carrier: string,
carrierId: number carrierId: number,
model?: string
) => { ) => {
const response = await languageInstance("ru").post("/vehicle", { const payload: Record<string, unknown> = {
tail_number: tailNumber, tail_number: tailNumber,
type, type,
carrier, carrier,
carrier_id: carrierId, carrier_id: carrierId,
}); };
// TODO: когда будет бекенд — добавить model в payload и в ответ
if (model != null && model !== "") payload.model = model;
const response = await languageInstance("ru").post("/vehicle", payload);
const normalizedVehicle = this.normalizeVehicleItem(response.data);
runInAction(() => { runInAction(() => {
this.vehicles.data.push({ this.vehicles.data.push(normalizedVehicle);
vehicle: { if (normalizedVehicle.vehicle?.id != null) {
id: response.data.id, this.vehicle[normalizedVehicle.vehicle.id] = normalizedVehicle;
tail_number: response.data.tail_number, }
type: response.data.type,
carrier_id: response.data.carrier_id,
carrier: response.data.carrier,
uuid: response.data.uuid,
},
});
}); });
}; };
editVehicleData: { editVehicleData: {
tail_number: number; tail_number: string;
type: number; type: number;
carrier: string; carrier: string;
carrier_id: number; carrier_id: number;
model: string;
snapshot_update_blocked: boolean;
} = { } = {
tail_number: 0, tail_number: "",
type: 0, type: 0,
carrier: "", carrier: "",
carrier_id: 0, carrier_id: 0,
model: "",
snapshot_update_blocked: false,
}; };
setEditVehicleData = (data: { setEditVehicleData = (data: {
tail_number: number; tail_number: string;
type: number; type: number;
carrier: string; carrier: string;
carrier_id: number; carrier_id: number;
model?: string;
snapshot_update_blocked?: boolean;
}) => { }) => {
this.editVehicleData = { this.editVehicleData = {
...this.editVehicleData, ...this.editVehicleData,
@@ -115,34 +191,72 @@ class VehicleStore {
editVehicle = async ( editVehicle = async (
id: number, id: number,
data: { data: {
tail_number: number; tail_number: string;
type: number; type: number;
carrier: string; carrier: string;
carrier_id: number; carrier_id: number;
model?: string;
snapshot_update_blocked?: boolean;
} }
) => { ) => {
const response = await languageInstance("ru").patch(`/vehicle/${id}`, { const payload: Record<string, unknown> = {
tail_number: data.tail_number, tail_number: data.tail_number,
type: data.type, type: data.type,
carrier: data.carrier, carrier: data.carrier,
carrier_id: data.carrier_id, carrier_id: data.carrier_id,
}); };
if (data.model != null && data.model !== "") payload.model = data.model;
if (data.snapshot_update_blocked != null)
payload.snapshot_update_blocked = data.snapshot_update_blocked;
const response = await languageInstance("ru").patch(
`/vehicle/${id}`,
payload
);
const normalizedVehicle = this.normalizeVehicleItem(response.data);
const updatedVehiclePayload = {
...normalizedVehicle.vehicle,
model: normalizedVehicle.vehicle.model ?? data.model,
snapshot_update_blocked:
normalizedVehicle.vehicle.snapshot_update_blocked ??
data.snapshot_update_blocked,
};
runInAction(() => { runInAction(() => {
this.vehicle[id] = { this.mergeVehicleInCaches({
vehicle: { ...updatedVehiclePayload,
...this.vehicle[id].vehicle, id,
...response.data, });
}, });
}; };
this.vehicles.data = this.vehicles.data.map((vehicle) =>
vehicle.vehicle.id === id setMaintenanceMode = async (uuid: string, enabled: boolean) => {
? { const response = await authInstance.post(`/devices/${uuid}/maintenance-mode`, {
...vehicle, enabled,
...response.data, });
} const normalizedVehicle = this.normalizeVehicleItem(response.data);
: vehicle
); runInAction(() => {
this.mergeVehicleInCaches({
...normalizedVehicle.vehicle,
uuid,
maintenance_mode_on:
normalizedVehicle.vehicle.maintenance_mode_on ?? enabled,
});
});
};
setDemoMode = async (uuid: string, enabled: boolean) => {
const response = await authInstance.post(`/devices/${uuid}/demo-mode`, {
enabled,
});
const normalizedVehicle = this.normalizeVehicleItem(response.data);
runInAction(() => {
this.mergeVehicleInCaches({
...normalizedVehicle.vehicle,
uuid,
demo_mode_enabled: normalizedVehicle.vehicle.demo_mode_enabled ?? enabled,
});
}); });
}; };
} }

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

@@ -1,10 +1,11 @@
import { Modal as MuiModal, Typography, Box } from "@mui/material"; import { Modal as MuiModal, Typography, Box, SxProps, Theme } from "@mui/material";
interface ModalProps { interface ModalProps {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
children: React.ReactNode; children: React.ReactNode;
title?: string; title?: string;
sx?: SxProps<Theme>;
} }
const style = { const style = {
@@ -19,7 +20,7 @@ const style = {
borderRadius: 2, borderRadius: 2,
}; };
export const Modal = ({ open, onClose, children, title }: ModalProps) => { export const Modal = ({ open, onClose, children, title, sx }: ModalProps) => {
return ( return (
<MuiModal <MuiModal
open={open} open={open}
@@ -27,7 +28,7 @@ export const Modal = ({ open, onClose, children, title }: ModalProps) => {
aria-labelledby="modal-modal-title" aria-labelledby="modal-modal-title"
aria-describedby="modal-modal-description" aria-describedby="modal-modal-description"
> >
<Box sx={style}> <Box sx={{ ...style, ...sx }}>
{title && ( {title && (
<Typography <Typography
id="modal-modal-title" id="modal-modal-title"

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";

1
src/vite-env.d.ts vendored
View File

@@ -1 +1,2 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
declare const __APP_VERSION__: string;

View File

@@ -0,0 +1,338 @@
import { API_URL, authInstance, Modal } from "@shared";
import { Button, CircularProgress, TextField } from "@mui/material";
import { useEffect, useState, useMemo } from "react";
import { toast } from "react-toastify";
interface DeviceLogChunk {
date?: string;
lines?: string[];
}
interface DeviceLogsModalProps {
open: boolean;
deviceUuid: string | null;
onClose: () => void;
}
const toYYYYMMDD = (d: Date) => d.toISOString().slice(0, 10);
const shiftYYYYMMDD = (value: string, days: number) => {
const d = new Date(`${value}T00:00:00Z`);
if (Number.isNaN(d.getTime())) return value;
d.setUTCDate(d.getUTCDate() + days);
return toYYYYMMDD(d);
};
type LogLevel = "info" | "warn" | "error" | "debug" | "fatal" | "unknown";
const LOG_LEVEL_STYLES: Record<LogLevel, { badge: string; text: string }> = {
info: {
badge: "bg-blue-100 text-blue-700",
text: "text-[#000000BF]",
},
debug: {
badge: "bg-gray-100 text-gray-600",
text: "text-gray-600",
},
warn: {
badge: "bg-amber-100 text-amber-700",
text: "text-amber-800",
},
error: {
badge: "bg-red-100 text-red-700",
text: "text-red-700",
},
fatal: {
badge: "bg-red-200 text-red-900",
text: "text-red-900 font-semibold",
},
unknown: {
badge: "bg-gray-100 text-gray-500",
text: "text-[#000000BF]",
},
};
const formatTs = (raw: string): string => {
try {
const d = new Date(raw);
if (Number.isNaN(d.getTime())) return raw;
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
} catch {
return raw;
}
};
const parseJsonLogLine = (line: string) => {
try {
const obj = JSON.parse(line);
if (obj && typeof obj === "object") {
const level: LogLevel =
obj.level && obj.level in LOG_LEVEL_STYLES
? (obj.level as LogLevel)
: "unknown";
const ts: string = obj.ts ? formatTs(obj.ts) : "";
const msg: string = obj.msg ?? "";
const extra: Record<string, unknown> = { ...obj };
delete extra.level;
delete extra.ts;
delete extra.msg;
delete extra.caller;
const extraStr = Object.keys(extra).length
? " " +
Object.entries(extra)
.map(([k, v]) => `${k}=${typeof v === "string" ? v : JSON.stringify(v)}`)
.join(" ")
: "";
return { ts, level, msg, extraStr };
}
} catch {
return null;
}
return null;
};
const parseLogLine = (line: string, index: number) => {
const json = parseJsonLogLine(line);
if (json) {
return {
id: index,
time: json.ts,
text: json.msg + json.extraStr,
level: json.level,
sortKey: json.ts,
};
}
const bracketMatch = line.match(/^(\[[^\]]+\])\s*(.*)$/);
if (bracketMatch) {
const rawTime = bracketMatch[1].replace(/^\[|\]$/g, "").trim();
return {
id: index,
time: formatTs(rawTime),
text: bracketMatch[2].trim() || line,
level: "unknown" as LogLevel,
sortKey: rawTime,
};
}
return {
id: index,
time: "",
text: line,
level: "unknown" as LogLevel,
sortKey: "",
};
};
export const DeviceLogsModal = ({
open,
deviceUuid,
onClose,
}: DeviceLogsModalProps) => {
const [chunks, setChunks] = useState<DeviceLogChunk[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const today = new Date();
const yesterday = new Date(today.getTime() - 24 * 60 * 60 * 1000);
const [dateFrom, setDateFrom] = useState(toYYYYMMDD(yesterday));
const [dateTo, setDateTo] = useState(toYYYYMMDD(today));
const dateToMin = shiftYYYYMMDD(dateFrom, 1);
const dateFromMax = shiftYYYYMMDD(dateTo, -1);
const handleDateFromChange = (value: string) => {
setDateFrom(value);
if (!dateTo || dateTo <= value) {
setDateTo(shiftYYYYMMDD(value, 1));
}
};
const handleDateToChange = (value: string) => {
if (value <= dateFrom) {
toast.info("Дата 'До' должна быть позже даты 'От'");
setDateTo(shiftYYYYMMDD(dateFrom, 1));
return;
}
setDateTo(value);
};
useEffect(() => {
if (!open || !deviceUuid) return;
const fetchLogs = async () => {
setIsLoading(true);
setError(null);
try {
const { data } = await authInstance.get<DeviceLogChunk[]>(
`${API_URL}/devices/${deviceUuid}/logs`,
{
params: {
from: dateFrom,
to: toYYYYMMDD(new Date(new Date(dateTo).getTime() + 86400000)),
},
}
);
setChunks(Array.isArray(data) ? data : []);
} catch (err: unknown) {
const msg =
err && typeof err === "object" && "message" in err
? String((err as { message?: string }).message)
: "Ошибка загрузки логов";
setError(msg);
toast.error(msg);
} finally {
setIsLoading(false);
}
};
fetchLogs();
}, [open, deviceUuid, dateFrom, dateTo]);
const logs = useMemo(() => {
const parsed = chunks.flatMap((chunk, chunkIdx) =>
(chunk.lines ?? []).map((line, i) =>
parseLogLine(line, chunkIdx * 10000 + i)
)
);
parsed.sort((a, b) => {
if (!a.sortKey && !b.sortKey) return 0;
if (!a.sortKey) return 1;
if (!b.sortKey) return -1;
return b.sortKey.localeCompare(a.sortKey);
});
return parsed;
}, [chunks]);
const logsText = useMemo(
() =>
logs
.map((log) => {
const level = log.level === "unknown" ? "LOG" : log.level.toUpperCase();
const time = log.time ? `[${log.time}] ` : "";
return `${time}${level}: ${log.text}`;
})
.join("\n"),
[logs]
);
const handleDownloadLogs = () => {
if (!logsText) {
toast.info("Нет логов для сохранения");
return;
}
try {
const safeDeviceUuid = (deviceUuid ?? "device").replace(
/[^a-zA-Z0-9_-]/g,
"_"
);
const fileName = `logs_${safeDeviceUuid}_${dateFrom}_${dateTo}.txt`;
const blob = new Blob([`\uFEFF${logsText}`], {
type: "text/plain;charset=utf-8",
});
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
toast.success("Логи сохранены в .txt");
} catch {
toast.error("Не удалось сохранить логи");
}
};
return (
<Modal open={open} onClose={onClose} sx={{ width: "80vw", p: 3 }}>
<div className="flex flex-col gap-6 h-[85vh]">
<div className="flex gap-4 items-center justify-between w-full flex-wrap">
<h2 className="text-2xl font-semibold text-[#000000BF]">Логи</h2>
<div className="flex gap-4 items-center">
<TextField
type="date"
label="От"
size="small"
value={dateFrom}
onChange={(e) => handleDateFromChange(e.target.value)}
slotProps={{
inputLabel: { shrink: true },
htmlInput: { max: dateFromMax },
}}
/>
<TextField
type="date"
label="До"
size="small"
value={dateTo}
onChange={(e) => handleDateToChange(e.target.value)}
slotProps={{
inputLabel: { shrink: true },
htmlInput: { min: dateToMin },
}}
/>
<Button
variant="outlined"
size="small"
onClick={handleDownloadLogs}
disabled={isLoading || Boolean(error) || logs.length === 0}
>
Скачать .txt
</Button>
</div>
</div>
<div className="flex-1 flex flex-col min-h-0 w-full">
{isLoading && (
<div className="w-full h-full flex items-center justify-center">
<CircularProgress />
</div>
)}
{!isLoading && error && (
<div className="w-full h-full flex items-center justify-center text-red-500">
{error}
</div>
)}
{!isLoading && !error && (
<div className="w-full h-full overflow-y-auto rounded-xl">
<div className="flex flex-col gap-0.5 font-mono text-[13px]">
{logs.length > 0 ? (
logs.map((log) => {
const style = LOG_LEVEL_STYLES[log.level];
return (
<div
key={log.id}
className={`flex gap-3 items-start px-2 py-1 rounded ${style.text}`}
>
<span
className={`shrink-0 px-1.5 py-0.5 rounded text-[11px] font-semibold uppercase ${style.badge}`}
>
{log.level === "unknown" ? "LOG" : log.level}
</span>
<span className="text-gray-400 shrink-0 whitespace-nowrap">
{log.time || null}
</span>
<span className="break-all">{log.text}</span>
</div>
);
})
) : (
<div className="h-full flex items-center justify-center text-gray-500 py-10">
Логи отсутствуют.
</div>
)}
</div>
</div>
)}
</div>
</div>
</Modal>
);
};

File diff suppressed because it is too large Load Diff

View File

@@ -8,7 +8,7 @@ import { AppBar } from "./ui/AppBar";
import { Drawer } from "./ui/Drawer"; import { Drawer } from "./ui/Drawer";
import { DrawerHeader } from "./ui/DrawerHeader"; import { DrawerHeader } from "./ui/DrawerHeader";
import { NavigationList } from "@features"; import { NavigationList } from "@features";
import { authStore, userStore, menuStore } from "@shared"; import { authStore, userStore, menuStore, isMediaIdEmpty } from "@shared";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { useEffect } from "react"; import { useEffect } from "react";
import { Typography } from "@mui/material"; import { Typography } from "@mui/material";
@@ -67,18 +67,18 @@ export const Layout: React.FC<LayoutProps> = observer(({ children }) => {
</div> </div>
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
<div className="flex flex-col gap-1"> {(() => {
{(() => { const currentUser = users?.data?.find(
return ( (user) => user.id === (authStore.payload as { user_id?: number })?.user_id,
<> );
<p className=" text-white"> const hasAvatar =
{ currentUser?.icon && !isMediaIdEmpty(currentUser.icon);
users?.data?.find( const token = localStorage.getItem("token");
// @ts-ignore
(user) => user.id === authStore.payload?.user_id return (
)?.name <>
} <div className="flex flex-col gap-1">
</p> <p className="text-white">{currentUser?.name}</p>
<div <div
className="text-center text-xs" className="text-center text-xs"
style={{ style={{
@@ -88,18 +88,27 @@ export const Layout: React.FC<LayoutProps> = observer(({ children }) => {
padding: "2px 10px", padding: "2px 10px",
}} }}
> >
{/* @ts-ignore */} {(authStore.payload as { is_admin?: boolean })?.is_admin
{authStore.payload?.is_admin
? "Администратор" ? "Администратор"
: "Режим пользователя"} : "Режим пользователя"}
</div> </div>
</> </div>
); <div className="w-10 h-10 rounded-full flex items-center justify-center overflow-hidden bg-gray-600 shrink-0">
})()} {hasAvatar ? (
</div> <img
<div className="w-10 h-10 bg-gray-600 rounded-full flex items-center justify-center"> src={`${
<User /> import.meta.env.VITE_KRBL_MEDIA
</div> }${currentUser!.icon}/download?token=${token}`}
alt="Аватар"
className="w-full h-full object-cover"
/>
) : (
<User className="text-white" size={20} />
)}
</div>
</>
);
})()}
</div> </div>
</Toolbar> </Toolbar>
</AppBar> </AppBar>
@@ -138,6 +147,9 @@ export const Layout: React.FC<LayoutProps> = observer(({ children }) => {
)} )}
</DrawerHeader> </DrawerHeader>
<NavigationList open={open} onDrawerOpen={handleDrawerOpen} /> <NavigationList open={open} onDrawerOpen={handleDrawerOpen} />
<div className="mt-auto flex justify-center items-center pb-5 text-sm text-gray-300">
v.{__APP_VERSION__}
</div>
</Drawer> </Drawer>
<Box <Box
component="main" component="main"

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";
interface MediaAreaProps {
articleId: number;
mediaIds: { id: string; media_type: number; filename: string }[];
deleteMedia: (id: number, media_id: string) => void;
onFilesDrop?: (files: File[]) => void;
setSelectMediaDialogOpen: (open: boolean) => void;
}
export const MediaArea = observer( export const MediaArea = observer(
({ ({
articleId, articleId,
mediaIds, mediaIds,
deleteMedia, deleteMedia,
onFilesDrop, // 👈 Проп для обработки загруженных файлов onFilesDrop,
setSelectMediaDialogOpen, setSelectMediaDialogOpen,
}: { }: MediaAreaProps) => {
articleId: number;
mediaIds: { id: string; media_type: number; filename: string }[];
deleteMedia: (id: number, media_id: string) => void;
onFilesDrop?: (files: File[]) => void;
setSelectMediaDialogOpen: (open: boolean) => void;
}) => {
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,23 +36,29 @@ export const MediaArea = observer(
setMediaId(mediaId); setMediaId(mediaId);
}; };
const processFiles = (files: File[]) => {
if (!files.length || !onFilesDrop) {
return;
}
const { validFiles, errors } = filterValidFiles(files);
if (errors.length > 0) {
errors.forEach((error) => toast.error(error));
}
if (validFiles.length > 0) {
onFilesDrop(validFiles);
}
};
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 && onFilesDrop) { processFiles(files);
const { validFiles, errors } = filterValidFiles(files);
if (errors.length > 0) {
errors.forEach((error) => toast.error(error));
}
if (validFiles.length > 0) {
onFilesDrop(validFiles);
}
}
}; };
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)); event.target.value = "";
}
if (validFiles.length > 0) {
onFilesDrop(validFiles);
}
} }
// Сбрасываем значение input, чтобы можно было выбрать тот же файл снова
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,33 +119,38 @@ export const MediaArea = observer(
</Button> </Button>
</div> </div>
<div className="w-full flex flex-start flex-wrap gap-2 mt-4 py-10"> {mediaIds.length > 0 && (
{mediaIds.map((m) => ( <div className="w-full flex flex-start flex-wrap gap-2 mt-4 py-10">
<button {mediaIds.map((m) => (
className="relative w-20 h-20"
key={m.id}
onClick={() => handleMediaModal(m.id)}
>
<MediaViewer
media={{
id: m.id,
media_type: m.media_type,
filename: m.filename,
}}
height="40px"
/>
<button <button
className="absolute top-2 right-2" className="relative w-[100px] h-[80px]"
onClick={(e) => { key={m.id}
e.stopPropagation(); onClick={() => handleMediaModal(m.id)}
deleteMedia(articleId, m.id); type="button"
}}
> >
<X size={16} color="red" /> <MediaViewer
media={{
id: m.id,
media_type: m.media_type,
filename: m.filename,
}}
compact
/>
<button
className="absolute top-2 right-2 bg-white rounded-full p-1 shadow-md hover:shadow-lg transition-shadow"
onClick={(e) => {
e.stopPropagation();
deleteMedia(articleId, m.id);
}}
type="button"
aria-label="Удалить медиа"
>
<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";
type ContextType =
| "sight"
| "city"
| "carrier"
| "country"
| "vehicle"
| "station";
interface MediaAreaForSightProps {
onFilesDrop?: (files: File[]) => void;
onFinishUpload?: (mediaId: string) => void;
contextObjectName?: string;
contextType?: ContextType;
isArticle?: boolean;
articleName?: string;
}
export const MediaAreaForSight = observer( export const MediaAreaForSight = observer(
({ ({
onFilesDrop, // 👈 Проп для обработки загруженных файлов onFilesDrop,
onFinishUpload, onFinishUpload,
contextObjectName, contextObjectName,
contextType, contextType,
isArticle, isArticle,
articleName, articleName,
}: { }: MediaAreaForSightProps) => {
onFilesDrop?: (files: File[]) => void; const [selectMediaDialogOpen, setSelectMediaDialogOpen] =
onFinishUpload?: (mediaId: string) => void; useState<boolean>(false);
contextObjectName?: string; const [uploadMediaDialogOpen, setUploadMediaDialogOpen] =
contextType?: useState<boolean>(false);
| "sight" const [isDragging, setIsDragging] = useState<boolean>(false);
| "city"
| "carrier"
| "country"
| "vehicle"
| "station";
isArticle?: boolean;
articleName?: string;
}) => {
const [selectMediaDialogOpen, setSelectMediaDialogOpen] = useState(false);
const [uploadMediaDialogOpen, setUploadMediaDialogOpen] = useState(false);
const [isDragging, setIsDragging] = useState(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, чтобы можно было выбрать тот же файл снова
event.target.value = ""; if (event.target) {
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

@@ -129,7 +129,7 @@ export const ThreeView = ({
> >
<ambientLight /> <ambientLight />
<directionalLight /> <directionalLight />
<Stage environment="city" intensity={0.6} adjustCamera={false}> <Stage environment={null} intensity={0.6} adjustCamera={false}>
<Model fileUrl={fileUrl} /> <Model fileUrl={fileUrl} />
</Stage> </Stage>
<OrbitControls /> <OrbitControls />

View File

@@ -1,5 +1,6 @@
import { Box } from "@mui/material"; import { Box, Typography } from "@mui/material";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { Cuboid } from "lucide-react";
import { ReactPhotoSphereViewer } from "react-photo-sphere-viewer"; import { ReactPhotoSphereViewer } from "react-photo-sphere-viewer";
import { ThreeView } from "./ThreeView"; import { ThreeView } from "./ThreeView";
@@ -19,6 +20,7 @@ export function MediaViewer({
width, width,
fullWidth, fullWidth,
fullHeight, fullHeight,
compact,
}: Readonly<{ }: Readonly<{
media?: MediaData; media?: MediaData;
className?: string; className?: string;
@@ -26,6 +28,8 @@ export function MediaViewer({
width?: string; width?: string;
fullWidth?: boolean; fullWidth?: boolean;
fullHeight?: boolean; fullHeight?: boolean;
/** В компактном режиме (миниатюры) 3D модели не рендерятся — показывается placeholder */
compact?: boolean;
}>) { }>) {
const token = localStorage.getItem("token"); const token = localStorage.getItem("token");
const [resetKey, setResetKey] = useState(0); const [resetKey, setResetKey] = useState(0);
@@ -76,8 +80,9 @@ export function MediaViewer({
}/download?token=${token}`} }/download?token=${token}`}
alt={media?.filename} alt={media?.filename}
style={{ style={{
height: fullHeight ? "100%" : height ? height : "auto", height: compact ? "80px" : fullHeight ? "100%" : height ? height : "auto",
width: fullWidth ? "100%" : width ? width : "auto", width: compact ? "100px" : fullWidth ? "100%" : width ? width : "auto",
objectFit: "cover",
}} }}
/> />
)} )}
@@ -88,8 +93,8 @@ export function MediaViewer({
media?.id media?.id
}/download?token=${token}`} }/download?token=${token}`}
style={{ style={{
width: width ? width : "100%", width: compact ? "100px" : width ? width : "100%",
height: height ? height : "100%", height: compact ? "80px" : height ? height : "100%",
objectFit: "cover", objectFit: "cover",
borderRadius: 8, borderRadius: 8,
}} }}
@@ -105,8 +110,9 @@ export function MediaViewer({
}/download?token=${token}`} }/download?token=${token}`}
alt={media?.filename} alt={media?.filename}
style={{ style={{
height: fullHeight ? "100%" : height ? height : "auto", height: compact ? "80px" : fullHeight ? "100%" : height ? height : "auto",
width: fullWidth ? "100%" : width ? width : "auto", width: compact ? "100px" : fullWidth ? "100%" : width ? width : "auto",
objectFit: "cover",
}} }}
/> />
)} )}
@@ -117,6 +123,8 @@ export function MediaViewer({
}/download?token=${token}`} }/download?token=${token}`}
alt={media?.filename} alt={media?.filename}
style={{ style={{
width: compact ? "100px" : "100%",
height: compact ? "80px" : undefined,
objectFit: "cover", objectFit: "cover",
}} }}
/> />
@@ -127,26 +135,46 @@ 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={compact ? "100px" : fullWidth ? "100%" : width ? width : "500px"}
height={height ? height : "300px"} height={compact ? "80px" : fullHeight ? "100%" : height ? height : "300px"}
/> />
)} )}
{media?.media_type === 6 && ( {media?.media_type === 6 &&
<ThreeViewErrorBoundary (compact ? (
onReset={handleReset} <Box
resetKey={`${media?.id}-${resetKey}`} sx={{
> width: "100px",
<ThreeView height: "80px",
key={`3d-model-${media?.id}-${resetKey}`} display: "flex",
fileUrl={`${import.meta.env.VITE_KRBL_MEDIA}${ flexDirection: "column",
media?.id alignItems: "center",
}/download?token=${token}`} justifyContent: "center",
height={height ? height : "500px"} backgroundColor: "action.hover",
width={width ? width : "500px"} borderRadius: 5,
/> color: "text.secondary",
</ThreeViewErrorBoundary> }}
)} >
<Cuboid size={24} />
<Typography variant="caption" sx={{ mt: 0.5 }}>
3D
</Typography>
</Box>
) : (
<ThreeViewErrorBoundary
onReset={handleReset}
resetKey={`${media?.id}-${resetKey}`}
>
<ThreeView
key={`3d-model-${media?.id}-${resetKey}`}
fileUrl={`${import.meta.env.VITE_KRBL_MEDIA}${
media?.id
}/download?token=${token}`}
height={height ? height : "500px"}
width={width ? width : "500px"}
/>
</ThreeViewErrorBoundary>
))}
</Box> </Box>
); );
} }

View File

@@ -16,6 +16,7 @@ import {
languageStore, languageStore,
Language, Language,
cityStore, cityStore,
isMediaIdEmpty,
SelectMediaDialog, SelectMediaDialog,
PreviewMediaDialog, PreviewMediaDialog,
SightLanguageInfo, SightLanguageInfo,
@@ -308,7 +309,7 @@ export const CreateInformationTab = observer(
<ImageUploadCard <ImageUploadCard
title="Водяной знак (левый верхний)" title="Водяной знак (левый верхний)"
imageKey="watermark_lu" imageKey="watermark_lu"
imageUrl={sight.watermark_lu} imageUrl={isMediaIdEmpty(sight.watermark_lu) ? null : sight.watermark_lu}
onImageClick={() => { onImageClick={() => {
setIsPreviewMediaOpen(true); setIsPreviewMediaOpen(true);
setMediaId(sight.watermark_lu ?? ""); setMediaId(sight.watermark_lu ?? "");
@@ -363,7 +364,7 @@ export const CreateInformationTab = observer(
<VideoPreviewCard <VideoPreviewCard
title="Видеозаставка" title="Видеозаставка"
videoId={sight.video_preview} videoId={isMediaIdEmpty(sight.video_preview) ? null : sight.video_preview}
onVideoClick={handleVideoPreviewClick} onVideoClick={handleVideoPreviewClick}
onDeleteVideoClick={() => { onDeleteVideoClick={() => {
handleChange({ handleChange({

View File

@@ -10,6 +10,7 @@ import {
import { import {
BackButton, BackButton,
createSightStore, createSightStore,
editSightStore,
languageStore, languageStore,
SelectArticleModal, SelectArticleModal,
TabPanel, TabPanel,
@@ -51,9 +52,6 @@ export const CreateRightTab = observer(
unlinkPreviewMedia, unlinkPreviewMedia,
createLinkWithRightArticle, createLinkWithRightArticle,
deleteRightArticleMedia, deleteRightArticleMedia,
setFileToUpload,
setUploadMediaOpen,
uploadMediaOpen,
unlinkRightAritcle, unlinkRightAritcle,
deleteRightArticle, deleteRightArticle,
linkExistingRightArticle, linkExistingRightArticle,
@@ -62,6 +60,8 @@ export const CreateRightTab = observer(
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);
@@ -434,49 +434,61 @@ 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"> {previewMedia && (
{previewMedia && ( <>
<> <Box className="absolute top-4 right-4 z-10">
<Box className="absolute top-4 right-4 z-10"> <button
<button className="w-10 h-10 flex items-center justify-center z-10"
className="w-10 h-10 flex items-center justify-center z-10" onClick={handleUnlinkPreviewMedia}
onClick={handleUnlinkPreviewMedia} >
> <X size={20} color="red" />
<X size={20} color="red" /> </button>
</button> </Box>
</Box>
<Box className="w-1/2 h-1/2"> <Box className="w-1/2 h-1/2">
<MediaViewer <MediaViewer
media={{ media={{
id: previewMedia.id || "", id: previewMedia.id || "",
media_type: previewMedia.media_type, media_type: previewMedia.media_type,
filename: previewMedia.filename || "", filename: previewMedia.filename || "",
}} }}
fullWidth fullWidth
fullHeight fullHeight
/> />
</Box> </Box>
</> </>
)} )}
</Box>
)} {!previewMedia && (
</> <Box className="w-full h-full flex justify-center items-center">
)} <Box
{!previewMedia && ( sx={{
<MediaAreaForSight maxWidth: "500px",
onFinishUpload={(mediaId) => { maxHeight: "100%",
linkPreviewMedia(mediaId); display: "flex",
}} flexGrow: 1,
onFilesDrop={() => {}} margin: "0 auto",
contextObjectName={sight[language].name} justifyContent: "center",
contextType="sight" }}
isArticle={false} >
/> <MediaAreaForSight
)} onFinishUpload={(mediaId) => {
linkPreviewMedia(mediaId);
}}
onFilesDrop={() => {}}
contextObjectName={sight[language].name}
contextType="sight"
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">

View File

@@ -17,6 +17,7 @@ import {
Language, Language,
cityStore, cityStore,
editSightStore, editSightStore,
isMediaIdEmpty,
SelectMediaDialog, SelectMediaDialog,
PreviewMediaDialog, PreviewMediaDialog,
SightLanguageInfo, SightLanguageInfo,
@@ -275,7 +276,10 @@ export const InformationTab = observer(
{sight.common.id !== 0 && ( {sight.common.id !== 0 && (
<LinkedStations <LinkedStations
parentId={sight.common.id} parentId={sight.common.id}
fields={[{ label: "Название", data: "name" }]} fields={[
{ label: "Название", data: "name" },
{ label: "Описание", data: "description" },
]}
type="edit" type="edit"
/> />
)} )}
@@ -331,7 +335,7 @@ export const InformationTab = observer(
<ImageUploadCard <ImageUploadCard
title="Водяной знак (левый верхний)" title="Водяной знак (левый верхний)"
imageKey="watermark_lu" imageKey="watermark_lu"
imageUrl={sight.common.watermark_lu} imageUrl={isMediaIdEmpty(sight.common.watermark_lu) ? null : sight.common.watermark_lu}
onImageClick={() => { onImageClick={() => {
setIsPreviewMediaOpen(true); setIsPreviewMediaOpen(true);
setMediaId(sight.common.watermark_lu ?? ""); setMediaId(sight.common.watermark_lu ?? "");
@@ -393,7 +397,7 @@ export const InformationTab = observer(
<VideoPreviewCard <VideoPreviewCard
title="Видеозаставка" title="Видеозаставка"
videoId={sight.common.video_preview} videoId={isMediaIdEmpty(sight.common.video_preview) ? null : sight.common.video_preview}
onVideoClick={handleVideoPreviewClick} onVideoClick={handleVideoPreviewClick}
onDeleteVideoClick={() => { onDeleteVideoClick={() => {
handleChange( handleChange(

View File

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

@@ -19,7 +19,7 @@ export const SnapshotRestore = ({
> >
<div className="bg-white p-4 w-140 rounded-lg flex flex-col gap-4 items-center"> <div className="bg-white p-4 w-140 rounded-lg flex flex-col gap-4 items-center">
<p className="text-black w-110 text-center"> <p className="text-black w-110 text-center">
Вы уверены, что хотите восстановить этот снапшот? Вы уверены, что хотите восстановить этот экспорт медиа?
</p> </p>
<p className="text-black w-100 text-center"> <p className="text-black w-100 text-center">
Это действие нельзя будет отменить. Это действие нельзя будет отменить.

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

@@ -2,12 +2,10 @@ import { defineConfig, type UserConfigExport } from "vite";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite"; import tailwindcss from "@tailwindcss/vite";
import path from "path"; import path from "path";
import pkg from "./package.json";
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [react(), tailwindcss()],
react(),
tailwindcss(),
],
resolve: { resolve: {
alias: { alias: {
"@shared": path.resolve(__dirname, "src/shared"), "@shared": path.resolve(__dirname, "src/shared"),
@@ -18,9 +16,11 @@ export default defineConfig({
"@app": path.resolve(__dirname, "src/app"), "@app": path.resolve(__dirname, "src/app"),
}, },
}, },
define: {
__APP_VERSION__: JSON.stringify(pkg.version),
},
build: { build: {
chunkSizeWarningLimit: 5000, chunkSizeWarningLimit: 5000,
}, },
}); });

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