Compare commits
24 Commits
0a6192c7da
...
feature/we
| Author | SHA1 | Date | |
|---|---|---|---|
| 73070fe233 | |||
| 7cf188a55c | |||
| 2a9449ba58 | |||
| 1c097a4ca2 | |||
| 048848faa0 | |||
| 8fe6505249 | |||
| 58abe15ec4 | |||
| 144e7cb00c | |||
| d557664b25 | |||
| bbab6fc46a | |||
| 25155a66bc | |||
| a3d574a79c | |||
| 39e11ad5ca | |||
| 7e068e49f5 | |||
| 79539d0583 | |||
| c5c5f835bc | |||
| 5481d264e0 | |||
| d6772b1e3a | |||
| 11133b6839 | |||
| aaeaed3fa5 | |||
| 95fe297aae | |||
| 04a9ac452e | |||
| 85c71563c1 | |||
| 6f32c6e671 |
7
.env
7
.env
@@ -1,3 +1,4 @@
|
||||
VITE_API_URL='https://wn.krbl.ru'
|
||||
VITE_REACT_APP ='https://wn.krbl.ru/'
|
||||
VITE_KRBL_MEDIA='https://wn.krbl.ru/media/'
|
||||
VITE_API_URL='https://wn.st.unprism.ru'
|
||||
VITE_REACT_APP ='https://wn.st.unprism.ru/'
|
||||
VITE_KRBL_MEDIA='https://wn.st.unprism.ru/media/'
|
||||
VITE_NEED_AUTH='true'
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "white-nights",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"version": "1.0.3",
|
||||
"type": "module",
|
||||
"license": "UNLICENSED",
|
||||
"scripts": {
|
||||
@@ -41,7 +41,8 @@
|
||||
"react-toastify": "^11.0.5",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"tailwindcss": "^4.1.8",
|
||||
"three": "^0.177.0"
|
||||
"three": "^0.177.0",
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.25.0",
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
CityCreatePage,
|
||||
CarrierCreatePage,
|
||||
VehicleCreatePage,
|
||||
VehicleEditPage,
|
||||
CountryEditPage,
|
||||
CityEditPage,
|
||||
UserCreatePage,
|
||||
@@ -51,7 +52,9 @@ import {
|
||||
|
||||
const PublicRoute = ({ children }: { children: React.ReactNode }) => {
|
||||
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 <>{children}</>;
|
||||
@@ -59,13 +62,18 @@ const PublicRoute = ({ children }: { children: React.ReactNode }) => {
|
||||
|
||||
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
||||
const { isAuthenticated } = authStore;
|
||||
const need_auth = import.meta.env.VITE_NEED_AUTH == "true";
|
||||
|
||||
const location = useLocation();
|
||||
if (!isAuthenticated) {
|
||||
|
||||
if (!isAuthenticated && need_auth) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
if (location.pathname === "/") {
|
||||
return <Navigate to="/map" replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
@@ -146,6 +154,7 @@ const router = createBrowserRouter([
|
||||
{ path: "station/:id", element: <StationPreviewPage /> },
|
||||
{ path: "station/:id/edit", element: <StationEditPage /> },
|
||||
{ path: "vehicle/create", element: <VehicleCreatePage /> },
|
||||
{ path: "vehicle/:id/edit", element: <VehicleEditPage /> },
|
||||
{ path: "article", element: <ArticleListPage /> },
|
||||
{ path: "article/:id", element: <ArticlePreviewPage /> },
|
||||
],
|
||||
|
||||
@@ -33,8 +33,10 @@ export const NavigationItemComponent: React.FC<NavigationItemProps> = ({
|
||||
const [isExpanded, setIsExpanded] = React.useState(false);
|
||||
const { payload } = authStore;
|
||||
|
||||
const need_auth = import.meta.env.VITE_NEED_AUTH == "true";
|
||||
|
||||
// @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;
|
||||
|
||||
|
||||
@@ -17,6 +17,10 @@ export const ArticleListPage = observer(() => {
|
||||
const { language } = languageStore;
|
||||
const [ids, setIds] = useState<number[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [paginationModel, setPaginationModel] = useState({
|
||||
page: 0,
|
||||
pageSize: 50,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const fetchArticles = async () => {
|
||||
@@ -83,31 +87,41 @@ export const ArticleListPage = observer(() => {
|
||||
<h1 className="text-2xl">Статьи</h1>
|
||||
</div>
|
||||
|
||||
<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"
|
||||
onClick={() => setIsBulkDeleteModalOpen(true)}
|
||||
>
|
||||
<Trash2 size={20} className="text-white" /> Удалить выбранные (
|
||||
{ids.length})
|
||||
</button>
|
||||
</div>
|
||||
{ids.length > 0 && (
|
||||
<div className="flex justify-end mb-5 duration-300">
|
||||
<button
|
||||
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})
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="w-full">
|
||||
<DataGrid
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
hideFooterPagination
|
||||
checkboxSelection
|
||||
disableRowSelectionExcludeModel
|
||||
loading={isLoading}
|
||||
paginationModel={paginationModel}
|
||||
onPaginationModelChange={setPaginationModel}
|
||||
pageSizeOptions={[50]}
|
||||
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
|
||||
onRowSelectionModelChange={(newSelection) => {
|
||||
setIds(Array.from(newSelection.ids) as number[]);
|
||||
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
|
||||
slots={{
|
||||
noRowsOverlay: () => (
|
||||
<Box
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Box } from "@mui/material";
|
||||
import { PreviewLeftWidget } from "./PreviewLeftWidget";
|
||||
import { PreviewRightWidget } from "./PreviewRightWidget";
|
||||
import { articlesStore, languageStore } from "@shared";
|
||||
import { articlesStore, languageStore, LoadingSpinner } from "@shared";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
|
||||
export const ArticlePreviewPage = () => {
|
||||
@@ -11,18 +11,41 @@ export const ArticlePreviewPage = () => {
|
||||
const { id } = useParams();
|
||||
const { getArticle, getArticleMedia, getArticlePreview } = articlesStore;
|
||||
const { language } = languageStore;
|
||||
const [isLoadingData, setIsLoadingData] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
if (id) {
|
||||
await getArticle(Number(id), language);
|
||||
await getArticleMedia(Number(id));
|
||||
await getArticlePreview(Number(id));
|
||||
setIsLoadingData(true);
|
||||
try {
|
||||
await getArticle(Number(id), language);
|
||||
await getArticleMedia(Number(id));
|
||||
await getArticlePreview(Number(id));
|
||||
} finally {
|
||||
setIsLoadingData(false);
|
||||
}
|
||||
} else {
|
||||
setIsLoadingData(false);
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
}, [id, language]);
|
||||
|
||||
if (isLoadingData) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
minHeight: "60vh",
|
||||
}}
|
||||
>
|
||||
<LoadingSpinner message="Загрузка данных статьи..." />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-4 mb-10">
|
||||
|
||||
@@ -8,8 +8,8 @@ import {
|
||||
InputLabel,
|
||||
} from "@mui/material";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { ArrowLeft, Save } from "lucide-react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { ArrowLeft, Loader2, Save } from "lucide-react";
|
||||
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "react-toastify";
|
||||
import {
|
||||
@@ -17,15 +17,14 @@ import {
|
||||
cityStore,
|
||||
mediaStore,
|
||||
languageStore,
|
||||
isMediaIdEmpty,
|
||||
useSelectedCity,
|
||||
} from "@shared";
|
||||
import { useState, useEffect } from "react";
|
||||
import { ImageUploadCard, LanguageSwitcher } from "@widgets";
|
||||
import {
|
||||
SelectMediaDialog,
|
||||
UploadMediaDialog,
|
||||
PreviewMediaDialog,
|
||||
} from "@shared";
|
||||
import { useState, useEffect } from "react";
|
||||
import { ImageUploadCard, LanguageSwitcher } from "@widgets";
|
||||
|
||||
export const CarrierCreatePage = observer(() => {
|
||||
const navigate = useNavigate();
|
||||
@@ -56,7 +55,7 @@ export const CarrierCreatePage = observer(() => {
|
||||
selectedCityId,
|
||||
createCarrierData[language].slogan,
|
||||
selectedMediaId || "",
|
||||
language
|
||||
language,
|
||||
);
|
||||
}
|
||||
}, [selectedCityId, createCarrierData.city_id]);
|
||||
@@ -88,13 +87,17 @@ export const CarrierCreatePage = observer(() => {
|
||||
createCarrierData.city_id,
|
||||
createCarrierData[language].slogan,
|
||||
media.id,
|
||||
language
|
||||
language,
|
||||
);
|
||||
};
|
||||
|
||||
const selectedMedia = selectedMediaId
|
||||
? mediaStore.media.find((m) => m.id === selectedMediaId)
|
||||
: null;
|
||||
const selectedMedia =
|
||||
selectedMediaId && !isMediaIdEmpty(selectedMediaId)
|
||||
? mediaStore.media.find((m) => m.id === selectedMediaId)
|
||||
: null;
|
||||
const effectiveLogoUrl = isMediaIdEmpty(selectedMediaId)
|
||||
? null
|
||||
: selectedMedia?.id ?? selectedMediaId ?? null;
|
||||
|
||||
return (
|
||||
<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,
|
||||
createCarrierData[language].slogan,
|
||||
selectedMediaId || "",
|
||||
language
|
||||
language,
|
||||
)
|
||||
}
|
||||
>
|
||||
@@ -151,7 +154,7 @@ export const CarrierCreatePage = observer(() => {
|
||||
createCarrierData.city_id,
|
||||
createCarrierData[language].slogan,
|
||||
selectedMediaId || "",
|
||||
language
|
||||
language,
|
||||
)
|
||||
}
|
||||
/>
|
||||
@@ -168,7 +171,7 @@ export const CarrierCreatePage = observer(() => {
|
||||
createCarrierData.city_id,
|
||||
createCarrierData[language].slogan,
|
||||
selectedMediaId || "",
|
||||
language
|
||||
language,
|
||||
)
|
||||
}
|
||||
/>
|
||||
@@ -184,7 +187,7 @@ export const CarrierCreatePage = observer(() => {
|
||||
createCarrierData.city_id,
|
||||
e.target.value,
|
||||
selectedMediaId || "",
|
||||
language
|
||||
language,
|
||||
)
|
||||
}
|
||||
/>
|
||||
@@ -193,10 +196,10 @@ export const CarrierCreatePage = observer(() => {
|
||||
<ImageUploadCard
|
||||
title="Логотип перевозчика"
|
||||
imageKey="thumbnail"
|
||||
imageUrl={selectedMedia?.id}
|
||||
imageUrl={effectiveLogoUrl}
|
||||
onImageClick={() => {
|
||||
setIsPreviewMediaOpen(true);
|
||||
setMediaId(selectedMedia?.id ?? "");
|
||||
setMediaId(effectiveLogoUrl ?? "");
|
||||
}}
|
||||
onDeleteImageClick={() => {
|
||||
setSelectedMediaId(null);
|
||||
@@ -207,7 +210,7 @@ export const CarrierCreatePage = observer(() => {
|
||||
createCarrierData.city_id,
|
||||
createCarrierData[language].slogan,
|
||||
"",
|
||||
language
|
||||
language,
|
||||
);
|
||||
}}
|
||||
onSelectFileClick={() => {
|
||||
|
||||
@@ -6,13 +6,21 @@ import {
|
||||
MenuItem,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Box,
|
||||
} from "@mui/material";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { ArrowLeft, Save } from "lucide-react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
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 { ImageUploadCard, LanguageSwitcher, DeleteModal } from "@widgets";
|
||||
import {
|
||||
@@ -28,6 +36,7 @@ export const CarrierEditPage = observer(() => {
|
||||
const { language } = languageStore;
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isLoadingData, setIsLoadingData] = useState(true);
|
||||
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
|
||||
const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false);
|
||||
const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false);
|
||||
@@ -39,39 +48,48 @@ export const CarrierEditPage = observer(() => {
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
await cityStore.getCities("ru");
|
||||
await cityStore.getCities("en");
|
||||
await cityStore.getCities("zh");
|
||||
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"
|
||||
);
|
||||
if (!id) {
|
||||
setIsLoadingData(false);
|
||||
return;
|
||||
}
|
||||
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");
|
||||
@@ -106,9 +124,28 @@ export const CarrierEditPage = observer(() => {
|
||||
);
|
||||
};
|
||||
|
||||
const selectedMedia = editCarrierData.logo
|
||||
? mediaStore.media.find((m) => m.id === editCarrierData.logo)
|
||||
: null;
|
||||
const selectedMedia =
|
||||
editCarrierData.logo && !isMediaIdEmpty(editCarrierData.logo)
|
||||
? 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 (
|
||||
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
||||
@@ -206,10 +243,10 @@ export const CarrierEditPage = observer(() => {
|
||||
<ImageUploadCard
|
||||
title="Логотип перевозчика"
|
||||
imageKey="thumbnail"
|
||||
imageUrl={selectedMedia?.id}
|
||||
imageUrl={effectiveLogoUrl}
|
||||
onImageClick={() => {
|
||||
setIsPreviewMediaOpen(true);
|
||||
setMediaId(selectedMedia?.id ?? "");
|
||||
setMediaId(effectiveLogoUrl ?? "");
|
||||
}}
|
||||
onDeleteImageClick={() => {
|
||||
setIsDeleteLogoModalOpen(true);
|
||||
|
||||
@@ -17,6 +17,10 @@ export const CarrierListPage = observer(() => {
|
||||
const [rowId, setRowId] = useState<number | null>(null);
|
||||
const [ids, setIds] = useState<number[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [paginationModel, setPaginationModel] = useState({
|
||||
page: 0,
|
||||
pageSize: 50,
|
||||
});
|
||||
const { language } = languageStore;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -129,28 +133,39 @@ export const CarrierListPage = observer(() => {
|
||||
<CreateButton label="Создать перевозчика" path="/carrier/create" />
|
||||
</div>
|
||||
|
||||
<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"
|
||||
onClick={() => setIsBulkDeleteModalOpen(true)}
|
||||
>
|
||||
<Trash2 size={20} className="text-white" /> Удалить выбранные (
|
||||
{ids.length})
|
||||
</button>
|
||||
</div>
|
||||
{ids.length > 0 && (
|
||||
<div className="flex justify-end mb-5 duration-300">
|
||||
<button
|
||||
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})
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DataGrid
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
hideFooter
|
||||
checkboxSelection
|
||||
disableRowSelectionExcludeModel
|
||||
loading={isLoading}
|
||||
paginationModel={paginationModel}
|
||||
onPaginationModelChange={setPaginationModel}
|
||||
pageSizeOptions={[50]}
|
||||
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
|
||||
onRowSelectionModelChange={(newSelection) => {
|
||||
setIds(Array.from(newSelection.ids as unknown as number[]));
|
||||
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([]);
|
||||
}
|
||||
}}
|
||||
slots={{
|
||||
noRowsOverlay: () => (
|
||||
|
||||
@@ -12,14 +12,18 @@ import { ArrowLeft, Save } from "lucide-react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "react-toastify";
|
||||
import { cityStore, countryStore, languageStore, mediaStore } from "@shared";
|
||||
import { useState, useEffect } from "react";
|
||||
import { LanguageSwitcher, ImageUploadCard } from "@widgets";
|
||||
import {
|
||||
cityStore,
|
||||
countryStore,
|
||||
languageStore,
|
||||
mediaStore,
|
||||
isMediaIdEmpty,
|
||||
SelectMediaDialog,
|
||||
UploadMediaDialog,
|
||||
PreviewMediaDialog,
|
||||
} from "@shared";
|
||||
import { useState, useEffect } from "react";
|
||||
import { LanguageSwitcher, ImageUploadCard } from "@widgets";
|
||||
|
||||
export const CityCreatePage = observer(() => {
|
||||
const navigate = useNavigate();
|
||||
@@ -72,9 +76,13 @@ export const CityCreatePage = observer(() => {
|
||||
);
|
||||
};
|
||||
|
||||
const selectedMedia = createCityData.arms
|
||||
? mediaStore.media.find((m) => m.id === createCityData.arms)
|
||||
: null;
|
||||
const selectedMedia =
|
||||
createCityData.arms && !isMediaIdEmpty(createCityData.arms)
|
||||
? mediaStore.media.find((m) => m.id === createCityData.arms)
|
||||
: null;
|
||||
const effectiveArmsUrl = isMediaIdEmpty(createCityData.arms)
|
||||
? null
|
||||
: (selectedMedia?.id ?? createCityData.arms);
|
||||
|
||||
return (
|
||||
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
||||
@@ -135,10 +143,10 @@ export const CityCreatePage = observer(() => {
|
||||
<ImageUploadCard
|
||||
title="Герб города"
|
||||
imageKey="image"
|
||||
imageUrl={selectedMedia?.id}
|
||||
imageUrl={effectiveArmsUrl}
|
||||
onImageClick={() => {
|
||||
setIsPreviewMediaOpen(true);
|
||||
setMediaId(selectedMedia?.id ?? "");
|
||||
setMediaId(effectiveArmsUrl ?? "");
|
||||
}}
|
||||
onDeleteImageClick={() => {
|
||||
setCreateCityData(
|
||||
|
||||
@@ -6,10 +6,10 @@ import {
|
||||
MenuItem,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Box,
|
||||
} from "@mui/material";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { ArrowLeft, Save } from "lucide-react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { ArrowLeft, Loader2, Save } from "lucide-react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { toast } from "react-toastify";
|
||||
import {
|
||||
@@ -17,19 +17,20 @@ import {
|
||||
countryStore,
|
||||
languageStore,
|
||||
mediaStore,
|
||||
isMediaIdEmpty,
|
||||
CashedCities,
|
||||
} from "@shared";
|
||||
import { useEffect, useState } from "react";
|
||||
import { LanguageSwitcher, ImageUploadCard } from "@widgets";
|
||||
import {
|
||||
LoadingSpinner,
|
||||
SelectMediaDialog,
|
||||
UploadMediaDialog,
|
||||
PreviewMediaDialog,
|
||||
} from "@shared";
|
||||
import { useEffect, useState } from "react";
|
||||
import { LanguageSwitcher, ImageUploadCard } from "@widgets";
|
||||
|
||||
export const CityEditPage = observer(() => {
|
||||
const navigate = useNavigate();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isLoadingData, setIsLoadingData] = useState(true);
|
||||
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
|
||||
const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false);
|
||||
const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false);
|
||||
@@ -62,19 +63,26 @@ export const CityEditPage = observer(() => {
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (id) {
|
||||
await getCountries("ru");
|
||||
setIsLoadingData(true);
|
||||
try {
|
||||
await getCountries("ru");
|
||||
|
||||
const ruData = await getCity(id as string, "ru");
|
||||
const enData = await getCity(id as string, "en");
|
||||
const zhData = await getCity(id as string, "zh");
|
||||
const ruData = await getCity(id as string, "ru");
|
||||
const enData = await getCity(id as string, "en");
|
||||
const zhData = await getCity(id as string, "zh");
|
||||
|
||||
setEditCityData(ruData.name, ruData.country_code, ruData.arms, "ru");
|
||||
setEditCityData(enData.name, enData.country_code, enData.arms, "en");
|
||||
setEditCityData(zhData.name, zhData.country_code, zhData.arms, "zh");
|
||||
setEditCityData(ruData.name, ruData.country_code, ruData.arms, "ru");
|
||||
setEditCityData(enData.name, enData.country_code, enData.arms, "en");
|
||||
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]);
|
||||
@@ -89,13 +97,32 @@ export const CityEditPage = observer(() => {
|
||||
editCityData[language].name,
|
||||
editCityData.country_code,
|
||||
media.id,
|
||||
language
|
||||
language,
|
||||
);
|
||||
};
|
||||
|
||||
const selectedMedia = editCityData.arms
|
||||
? mediaStore.media.find((m) => m.id === editCityData.arms)
|
||||
: null;
|
||||
const selectedMedia =
|
||||
editCityData.arms && !isMediaIdEmpty(editCityData.arms)
|
||||
? 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 (
|
||||
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
||||
@@ -124,7 +151,7 @@ export const CityEditPage = observer(() => {
|
||||
e.target.value,
|
||||
editCityData.country_code,
|
||||
editCityData.arms,
|
||||
language
|
||||
language,
|
||||
)
|
||||
}
|
||||
/>
|
||||
@@ -140,7 +167,7 @@ export const CityEditPage = observer(() => {
|
||||
editCityData[language].name,
|
||||
e.target.value,
|
||||
editCityData.arms,
|
||||
language
|
||||
language,
|
||||
);
|
||||
}}
|
||||
>
|
||||
@@ -156,17 +183,17 @@ export const CityEditPage = observer(() => {
|
||||
<ImageUploadCard
|
||||
title="Герб города"
|
||||
imageKey="image"
|
||||
imageUrl={selectedMedia?.id}
|
||||
imageUrl={effectiveArmsUrl}
|
||||
onImageClick={() => {
|
||||
setIsPreviewMediaOpen(true);
|
||||
setMediaId(selectedMedia?.id ?? "");
|
||||
setMediaId(effectiveArmsUrl ?? "");
|
||||
}}
|
||||
onDeleteImageClick={() => {
|
||||
setEditCityData(
|
||||
editCityData[language].name,
|
||||
editCityData.country_code,
|
||||
"",
|
||||
language
|
||||
language,
|
||||
);
|
||||
setActiveMenuType(null);
|
||||
}}
|
||||
|
||||
@@ -18,6 +18,10 @@ export const CityListPage = observer(() => {
|
||||
const [ids, setIds] = useState<number[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [rows, setRows] = useState<any[]>([]);
|
||||
const [paginationModel, setPaginationModel] = useState({
|
||||
page: 0,
|
||||
pageSize: 50,
|
||||
});
|
||||
const { language } = languageStore;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -128,28 +132,39 @@ export const CityListPage = observer(() => {
|
||||
<CreateButton label="Создать город" path="/city/create" />
|
||||
</div>
|
||||
|
||||
<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"
|
||||
onClick={() => setIsBulkDeleteModalOpen(true)}
|
||||
>
|
||||
<Trash2 size={20} className="text-white" /> Удалить выбранные (
|
||||
{ids.length})
|
||||
</button>
|
||||
</div>
|
||||
{ids.length > 0 && (
|
||||
<div className="flex justify-end mb-5 duration-300">
|
||||
<button
|
||||
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})
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DataGrid
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
hideFooter
|
||||
checkboxSelection
|
||||
disableRowSelectionExcludeModel
|
||||
loading={isLoading}
|
||||
paginationModel={paginationModel}
|
||||
onPaginationModelChange={setPaginationModel}
|
||||
pageSizeOptions={[50]}
|
||||
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
|
||||
onRowSelectionModelChange={(newSelection) => {
|
||||
setIds(Array.from(newSelection.ids as unknown as number[]));
|
||||
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([]);
|
||||
}
|
||||
}}
|
||||
slots={{
|
||||
noRowsOverlay: () => (
|
||||
|
||||
@@ -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 { ArrowLeft, Save } from "lucide-react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { toast } from "react-toastify";
|
||||
import { countryStore, languageStore } from "@shared";
|
||||
import { countryStore, languageStore, LoadingSpinner } from "@shared";
|
||||
import { useEffect, useState } from "react";
|
||||
import { LanguageSwitcher } from "@widgets";
|
||||
|
||||
export const CountryEditPage = observer(() => {
|
||||
const navigate = useNavigate();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isLoadingData, setIsLoadingData] = useState(true);
|
||||
const { language } = languageStore;
|
||||
const { id } = useParams();
|
||||
const { editCountryData, editCountry, getCountry, setEditCountryData } =
|
||||
@@ -35,17 +36,39 @@ export const CountryEditPage = observer(() => {
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (id) {
|
||||
const ruData = await getCountry(id as string, "ru");
|
||||
const enData = await getCountry(id as string, "en");
|
||||
const zhData = await getCountry(id as string, "zh");
|
||||
setIsLoadingData(true);
|
||||
try {
|
||||
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(enData.name, "en");
|
||||
setEditCountryData(zhData.name, "zh");
|
||||
setEditCountryData(ruData.name, "ru");
|
||||
setEditCountryData(enData.name, "en");
|
||||
setEditCountryData(zhData.name, "zh");
|
||||
} finally {
|
||||
setIsLoadingData(false);
|
||||
}
|
||||
} else {
|
||||
setIsLoadingData(false);
|
||||
}
|
||||
})();
|
||||
}, [id]);
|
||||
|
||||
if (isLoadingData) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
minHeight: "60vh",
|
||||
}}
|
||||
>
|
||||
<LoadingSpinner message="Загрузка данных страны..." />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
||||
<LanguageSwitcher />
|
||||
|
||||
@@ -16,6 +16,10 @@ export const CountryListPage = observer(() => {
|
||||
const [rowId, setRowId] = useState<string | null>(null);
|
||||
const [ids, setIds] = useState<number[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [paginationModel, setPaginationModel] = useState({
|
||||
page: 0,
|
||||
pageSize: 50,
|
||||
});
|
||||
const { language } = languageStore;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -93,28 +97,39 @@ export const CountryListPage = observer(() => {
|
||||
<CreateButton label="Добавить страну" path="/country/add" />
|
||||
</div>
|
||||
|
||||
<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"
|
||||
onClick={() => setIsBulkDeleteModalOpen(true)}
|
||||
>
|
||||
<Trash2 size={20} className="text-white" /> Удалить выбранные (
|
||||
{ids.length})
|
||||
</button>
|
||||
</div>
|
||||
{ids.length > 0 && (
|
||||
<div className="flex justify-end mb-5 duration-300">
|
||||
<button
|
||||
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})
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DataGrid
|
||||
rows={rows || []}
|
||||
columns={columns}
|
||||
hideFooter
|
||||
checkboxSelection
|
||||
disableRowSelectionExcludeModel
|
||||
loading={isLoading}
|
||||
paginationModel={paginationModel}
|
||||
onPaginationModelChange={setPaginationModel}
|
||||
pageSizeOptions={[50]}
|
||||
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
|
||||
onRowSelectionModelChange={(newSelection) => {
|
||||
setIds(Array.from(newSelection.ids as unknown as number[]));
|
||||
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([]);
|
||||
}
|
||||
}}
|
||||
slots={{
|
||||
noRowsOverlay: () => (
|
||||
|
||||
@@ -3,7 +3,12 @@ import { InformationTab, LeaveAgree, RightWidgetTab } from "@widgets";
|
||||
import { LeftWidgetTab } from "@widgets";
|
||||
import { useEffect, useState } from "react";
|
||||
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";
|
||||
|
||||
function a11yProps(index: number) {
|
||||
@@ -15,7 +20,8 @@ function a11yProps(index: number) {
|
||||
|
||||
export const EditSightPage = observer(() => {
|
||||
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 { id } = useParams();
|
||||
@@ -33,13 +39,22 @@ export const EditSightPage = observer(() => {
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
if (id) {
|
||||
await getCities("ru");
|
||||
await getSightInfo(+id, "ru");
|
||||
await getSightInfo(+id, "en");
|
||||
await getSightInfo(+id, "zh");
|
||||
await getArticles("ru");
|
||||
await getArticles("en");
|
||||
await getArticles("zh");
|
||||
setIsLoadingData(true);
|
||||
try {
|
||||
await getCities("ru");
|
||||
await getSightInfo(+id, "ru");
|
||||
await getSightInfo(+id, "en");
|
||||
await getSightInfo(+id, "zh");
|
||||
await getArticles("ru");
|
||||
await getArticles("en");
|
||||
await getArticles("zh");
|
||||
// Загружаем данные правого виджета перед завершением загрузки
|
||||
await getRightArticles(+id);
|
||||
} finally {
|
||||
setIsLoadingData(false);
|
||||
}
|
||||
} else {
|
||||
setIsLoadingData(false);
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
@@ -79,12 +94,25 @@ export const EditSightPage = observer(() => {
|
||||
</Tabs>
|
||||
</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>
|
||||
{isLoadingData ? (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
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}
|
||||
|
||||
@@ -186,7 +186,7 @@ const saveHiddenRoutes = (hiddenRoutes: Set<number>): void => {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
HIDDEN_ROUTES_KEY,
|
||||
JSON.stringify(Array.from(hiddenRoutes))
|
||||
JSON.stringify(Array.from(hiddenRoutes)),
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn("Failed to save hidden routes:", error);
|
||||
@@ -221,7 +221,7 @@ class MapStore {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
HIDE_SIGHTS_BY_HIDDEN_ROUTES_KEY,
|
||||
JSON.stringify(!!val)
|
||||
JSON.stringify(!!val),
|
||||
);
|
||||
} catch (e) {}
|
||||
}
|
||||
@@ -239,7 +239,7 @@ class MapStore {
|
||||
|
||||
private sortFeatures<T extends ApiStation | ApiSight>(
|
||||
features: T[],
|
||||
sortType: SortType
|
||||
sortType: SortType,
|
||||
): T[] {
|
||||
const sorted = [...features];
|
||||
switch (sortType) {
|
||||
@@ -324,7 +324,7 @@ class MapStore {
|
||||
return this.sortedStations;
|
||||
}
|
||||
return this.sortedStations.filter(
|
||||
(station) => station.city_id === selectedCityId
|
||||
(station) => station.city_id === selectedCityId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -365,7 +365,7 @@ class MapStore {
|
||||
const response = await languageInstance("ru").get("/route");
|
||||
const routesIds = response.data.map((route: any) => route.id);
|
||||
const routePromises = routesIds.map((id: number) =>
|
||||
languageInstance("ru").get(`/route/${id}`)
|
||||
languageInstance("ru").get(`/route/${id}`),
|
||||
);
|
||||
const routeResponses = await Promise.all(routePromises);
|
||||
this.routes = routeResponses.map((res) => ({
|
||||
@@ -379,7 +379,7 @@ class MapStore {
|
||||
}));
|
||||
|
||||
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);
|
||||
@@ -391,14 +391,14 @@ class MapStore {
|
||||
const stationPromises = routesIds.map(async (routeId) => {
|
||||
try {
|
||||
const stationsResponse = await languageInstance("ru").get(
|
||||
`/route/${routeId}/station`
|
||||
`/route/${routeId}/station`,
|
||||
);
|
||||
const stationIds = stationsResponse.data.map((s: any) => s.id);
|
||||
this.routeStationsCache.set(routeId, stationIds);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to preload stations for route ${routeId}:`,
|
||||
error
|
||||
error,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -409,7 +409,7 @@ class MapStore {
|
||||
const sightPromises = routesIds.map(async (routeId) => {
|
||||
try {
|
||||
const sightsResponse = await languageInstance("ru").get(
|
||||
`/route/${routeId}/sight`
|
||||
`/route/${routeId}/sight`,
|
||||
);
|
||||
const sightIds = sightsResponse.data.map((s: any) => s.id);
|
||||
this.routeSightsCache.set(routeId, sightIds);
|
||||
@@ -488,22 +488,12 @@ class MapStore {
|
||||
const route_number = properties.name || "Маршрут 1";
|
||||
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 = "";
|
||||
|
||||
if (selectedCityStore.selectedCityId) {
|
||||
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) {
|
||||
@@ -515,8 +505,8 @@ class MapStore {
|
||||
const routeData = {
|
||||
route_number,
|
||||
path,
|
||||
center_latitude,
|
||||
center_longitude,
|
||||
center_latitude: path[0][0],
|
||||
center_longitude: path[0][1],
|
||||
carrier,
|
||||
carrier_id,
|
||||
governor_appeal: 0,
|
||||
@@ -531,7 +521,7 @@ class MapStore {
|
||||
|
||||
if (!carrier_id && selectedCityStore.selectedCityId) {
|
||||
toast.error(
|
||||
"В выбранном городе нет доступных перевозчиков, маршрут отображается в общем списке"
|
||||
"В выбранном городе нет доступных перевозчиков, маршрут отображается в общем списке",
|
||||
);
|
||||
}
|
||||
createdItem = routeStore.routes.data[routeStore.routes.data.length - 1];
|
||||
@@ -583,7 +573,7 @@ class MapStore {
|
||||
const centerCoords = getCenter(lineGeom.getExtent());
|
||||
const [center_longitude, center_latitude] = toLonLat(
|
||||
centerCoords,
|
||||
"EPSG:3857"
|
||||
"EPSG:3857",
|
||||
);
|
||||
data = {
|
||||
route_number: properties.name,
|
||||
@@ -616,7 +606,7 @@ class MapStore {
|
||||
return;
|
||||
}
|
||||
throw new Error(
|
||||
`Could not find old data for ${featureType} with id ${numericId}`
|
||||
`Could not find old data for ${featureType} with id ${numericId}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -636,7 +626,7 @@ class MapStore {
|
||||
|
||||
const response = await languageInstance("ru").patch(
|
||||
`/${featureType}/${numericId}`,
|
||||
requestBody
|
||||
requestBody,
|
||||
);
|
||||
|
||||
const updateStore = (store: any[], updatedItem: any) => {
|
||||
@@ -755,7 +745,7 @@ class MapService {
|
||||
private selectInteraction: Select;
|
||||
private hoveredFeatureId: string | number | null;
|
||||
private boundHandlePointerMove: (
|
||||
event: MapBrowserEvent<PointerEvent>
|
||||
event: MapBrowserEvent<PointerEvent>,
|
||||
) => void;
|
||||
private boundHandlePointerLeave: () => void;
|
||||
private boundHandleContextMenu: (event: MouseEvent) => void;
|
||||
@@ -794,7 +784,7 @@ class MapService {
|
||||
onFeaturesChange: (features: Feature<Geometry>[]) => void,
|
||||
onFeatureSelect: (feature: Feature<Geometry> | null) => void,
|
||||
tooltipElement: HTMLElement,
|
||||
onSelectionChange?: (ids: Set<string | number>) => void
|
||||
onSelectionChange?: (ids: Set<string | number>) => void,
|
||||
) {
|
||||
this.map = null;
|
||||
this.tooltipElement = tooltipElement;
|
||||
@@ -943,7 +933,7 @@ class MapService {
|
||||
style: (featureLike: FeatureLike) => {
|
||||
const clusterFeature = featureLike as Feature<Point>;
|
||||
const featuresInCluster = clusterFeature.get(
|
||||
"features"
|
||||
"features",
|
||||
) as Feature<Point>[];
|
||||
const size = featuresInCluster.length;
|
||||
|
||||
@@ -1001,18 +991,18 @@ class MapService {
|
||||
|
||||
this.pointSource.on(
|
||||
"addfeature",
|
||||
this.handleFeatureEvent.bind(this) as any
|
||||
this.handleFeatureEvent.bind(this) as any,
|
||||
);
|
||||
this.pointSource.on("removefeature", () => this.updateFeaturesInReact());
|
||||
this.pointSource.on(
|
||||
"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("removefeature", () => this.updateFeaturesInReact());
|
||||
this.lineSource.on(
|
||||
"changefeature",
|
||||
this.handleFeatureChange.bind(this) as any
|
||||
this.handleFeatureChange.bind(this) as any,
|
||||
);
|
||||
|
||||
let renderCompleteHandled = false;
|
||||
@@ -1066,7 +1056,7 @@ class MapService {
|
||||
if (center && zoom !== undefined && this.map) {
|
||||
const [lon, lat] = toLonLat(
|
||||
center,
|
||||
this.map.getView().getProjection()
|
||||
this.map.getView().getProjection(),
|
||||
);
|
||||
saveMapPosition({ center: [lon, lat], zoom });
|
||||
}
|
||||
@@ -1078,7 +1068,7 @@ class MapService {
|
||||
if (center && zoom !== undefined && this.map) {
|
||||
const [lon, lat] = toLonLat(
|
||||
center,
|
||||
this.map.getView().getProjection()
|
||||
this.map.getView().getProjection(),
|
||||
);
|
||||
saveMapPosition({ center: [lon, lat], zoom });
|
||||
}
|
||||
@@ -1199,7 +1189,7 @@ class MapService {
|
||||
const feature = this.map?.forEachFeatureAtPixel(
|
||||
event.pixel,
|
||||
(f: FeatureLike) => f as Feature<Geometry>,
|
||||
{ layerFilter, hitTolerance: 5 }
|
||||
{ layerFilter, hitTolerance: 5 },
|
||||
);
|
||||
|
||||
if (!feature) return;
|
||||
@@ -1237,7 +1227,7 @@ class MapService {
|
||||
}
|
||||
|
||||
const newCoordinates = coordinates.filter(
|
||||
(_, index) => index !== closestIndex
|
||||
(_, index) => index !== closestIndex,
|
||||
);
|
||||
lineString.setCoordinates(newCoordinates);
|
||||
this.saveModifiedFeature(feature);
|
||||
@@ -1280,7 +1270,7 @@ class MapService {
|
||||
selected.add(f.getId()!);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
this.setSelectedIds(selected);
|
||||
@@ -1427,7 +1417,7 @@ class MapService {
|
||||
public loadFeaturesFromApi(
|
||||
_apiStations: typeof mapStore.stations,
|
||||
_apiRoutes: typeof mapStore.routes,
|
||||
_apiSights: typeof mapStore.sights
|
||||
_apiSights: typeof mapStore.sights,
|
||||
): void {
|
||||
if (!this.map) return;
|
||||
|
||||
@@ -1460,8 +1450,8 @@ class MapService {
|
||||
transform(
|
||||
[station.longitude, station.latitude],
|
||||
"EPSG:4326",
|
||||
projection
|
||||
)
|
||||
projection,
|
||||
),
|
||||
);
|
||||
const feature = new Feature({ geometry: point, name: station.name });
|
||||
feature.setId(`station-${station.id}`);
|
||||
@@ -1472,7 +1462,7 @@ class MapService {
|
||||
filteredSights.forEach((sight) => {
|
||||
if (sight.longitude == null || sight.latitude == null) return;
|
||||
const point = new Point(
|
||||
transform([sight.longitude, sight.latitude], "EPSG:4326", projection)
|
||||
transform([sight.longitude, sight.latitude], "EPSG:4326", projection),
|
||||
);
|
||||
const feature = new Feature({
|
||||
geometry: point,
|
||||
@@ -1492,7 +1482,7 @@ class MapService {
|
||||
const coordinates = route.path
|
||||
.filter((c) => c && c[0] != null && c[1] != null)
|
||||
.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;
|
||||
@@ -1578,7 +1568,7 @@ class MapService {
|
||||
|
||||
public startDrawing(
|
||||
type: "Point" | "LineString",
|
||||
featureType: FeatureType
|
||||
featureType: FeatureType,
|
||||
): void {
|
||||
if (!this.map) return;
|
||||
|
||||
@@ -1742,7 +1732,7 @@ class MapService {
|
||||
this.map.forEachFeatureAtPixel(
|
||||
event.pixel,
|
||||
(f: FeatureLike) => f as Feature<Geometry>,
|
||||
{ layerFilter, hitTolerance: 5 }
|
||||
{ layerFilter, hitTolerance: 5 },
|
||||
);
|
||||
|
||||
let finalFeature: Feature<Geometry> | null = null;
|
||||
@@ -1817,7 +1807,7 @@ class MapService {
|
||||
|
||||
public deleteFeature(
|
||||
featureId: string | number | undefined,
|
||||
recourse: string
|
||||
recourse: string,
|
||||
): void {
|
||||
if (featureId === undefined) return;
|
||||
|
||||
@@ -1873,7 +1863,7 @@ class MapService {
|
||||
const lineFeature = this.lineSource.getFeatureById(id);
|
||||
if (lineFeature)
|
||||
this.lineSource.removeFeature(
|
||||
lineFeature as Feature<LineString>
|
||||
lineFeature as Feature<LineString>,
|
||||
);
|
||||
const pointFeature = this.pointSource.getFeatureById(id);
|
||||
if (pointFeature)
|
||||
@@ -1900,11 +1890,11 @@ class MapService {
|
||||
if (targetEl instanceof HTMLElement) {
|
||||
targetEl.removeEventListener(
|
||||
"contextmenu",
|
||||
this.boundHandleContextMenu
|
||||
this.boundHandleContextMenu,
|
||||
);
|
||||
targetEl.removeEventListener(
|
||||
"pointerleave",
|
||||
this.boundHandlePointerLeave
|
||||
this.boundHandlePointerLeave,
|
||||
);
|
||||
}
|
||||
this.map.un("pointermove", this.boundHandlePointerMove as any);
|
||||
@@ -1917,7 +1907,7 @@ class MapService {
|
||||
}
|
||||
|
||||
private handleFeatureEvent(
|
||||
event: VectorSourceEvent<Feature<Geometry>>
|
||||
event: VectorSourceEvent<Feature<Geometry>>,
|
||||
): void {
|
||||
if (!event.feature) return;
|
||||
const feature = event.feature;
|
||||
@@ -1928,7 +1918,7 @@ class MapService {
|
||||
}
|
||||
|
||||
private handleFeatureChange(
|
||||
event: VectorSourceEvent<Feature<Geometry>>
|
||||
event: VectorSourceEvent<Feature<Geometry>>,
|
||||
): void {
|
||||
if (!event.feature) return;
|
||||
this.updateFeaturesInReact();
|
||||
@@ -1966,7 +1956,7 @@ class MapService {
|
||||
});
|
||||
|
||||
this.modifyInteraction.setActive(
|
||||
this.selectInteraction.getFeatures().getLength() > 0
|
||||
this.selectInteraction.getFeatures().getLength() > 0,
|
||||
);
|
||||
this.clusterLayer.changed();
|
||||
this.routeLayer.changed();
|
||||
@@ -2036,7 +2026,7 @@ class MapService {
|
||||
if (typeof featureId === "number" || !String(featureId).includes("-")) {
|
||||
console.warn(
|
||||
"Skipping save for feature with non-standard ID:",
|
||||
featureId
|
||||
featureId,
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -2084,7 +2074,7 @@ class MapService {
|
||||
try {
|
||||
const createdFeatureData = await mapStore.createFeature(
|
||||
featureType,
|
||||
featureGeoJSON
|
||||
featureGeoJSON,
|
||||
);
|
||||
|
||||
const newFeatureId = `${featureType}-${createdFeatureData.id}`;
|
||||
@@ -2103,8 +2093,8 @@ class MapService {
|
||||
|
||||
const lineGeom = new LineString(
|
||||
routeData.path.map((c) =>
|
||||
transform([c[1], c[0]], "EPSG:4326", projection)
|
||||
)
|
||||
transform([c[1], c[0]], "EPSG:4326", projection),
|
||||
),
|
||||
);
|
||||
feature.setGeometry(lineGeom);
|
||||
} else {
|
||||
@@ -2206,8 +2196,8 @@ const MapControls: React.FC<MapControlsProps> = ({
|
||||
isDisabled
|
||||
? "bg-gray-200 text-gray-400 cursor-not-allowed"
|
||||
: isActive
|
||||
? "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-blue-600 text-white shadow-md hover:bg-blue-700"
|
||||
: "bg-gray-100 text-gray-700 hover:bg-blue-100 hover:text-blue-700"
|
||||
}`;
|
||||
return (
|
||||
<button
|
||||
@@ -2257,7 +2247,7 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
|
||||
|
||||
const actualFeatures = useMemo(
|
||||
() => mapFeatures.filter((f) => !f.get("isProxy")),
|
||||
[mapFeatures]
|
||||
[mapFeatures],
|
||||
);
|
||||
|
||||
const allFeatures = useMemo(() => {
|
||||
@@ -2267,8 +2257,8 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
|
||||
transform(
|
||||
[station.longitude, station.latitude],
|
||||
"EPSG:4326",
|
||||
"EPSG:3857"
|
||||
)
|
||||
"EPSG:3857",
|
||||
),
|
||||
),
|
||||
name: station.name,
|
||||
description: station.description || "",
|
||||
@@ -2285,8 +2275,8 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
|
||||
transform(
|
||||
[sight.longitude, sight.latitude],
|
||||
"EPSG:4326",
|
||||
"EPSG:3857"
|
||||
)
|
||||
"EPSG:3857",
|
||||
),
|
||||
),
|
||||
name: sight.name,
|
||||
description: sight.description,
|
||||
@@ -2330,7 +2320,7 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
|
||||
(f.get("routeNumber") as string) || "",
|
||||
];
|
||||
return candidates.some((value) =>
|
||||
value.toLowerCase().includes(normalizedQuery)
|
||||
value.toLowerCase().includes(normalizedQuery),
|
||||
);
|
||||
});
|
||||
}, [allFeatures, searchQuery]);
|
||||
@@ -2353,7 +2343,7 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
|
||||
mapService.selectFeature(id);
|
||||
}
|
||||
},
|
||||
[mapService, selectedIds, setSelectedIds]
|
||||
[mapService, selectedIds, setSelectedIds],
|
||||
);
|
||||
|
||||
const handleDeleteFeature = useCallback(
|
||||
@@ -2363,7 +2353,7 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
|
||||
mapService.deleteFeature(id, resource);
|
||||
}
|
||||
},
|
||||
[mapService]
|
||||
[mapService],
|
||||
);
|
||||
|
||||
const handleCheckboxChange = useCallback(
|
||||
@@ -2375,14 +2365,14 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
|
||||
setSelectedIds(newSet);
|
||||
mapService.setSelectedIds(newSet);
|
||||
},
|
||||
[mapService, selectedIds, setSelectedIds]
|
||||
[mapService, selectedIds, setSelectedIds],
|
||||
);
|
||||
|
||||
const handleBulkDelete = useCallback(() => {
|
||||
if (!mapService || selectedIds.size === 0) return;
|
||||
if (
|
||||
window.confirm(
|
||||
`Вы уверены, что хотите удалить ${selectedIds.size} объект(ов)?`
|
||||
`Вы уверены, что хотите удалить ${selectedIds.size} объект(ов)?`,
|
||||
)
|
||||
) {
|
||||
mapService.deleteMultipleFeatures(Array.from(selectedIds));
|
||||
@@ -2396,7 +2386,7 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
|
||||
if (!featureType || !numericId) return;
|
||||
navigate(`/${featureType}/${numericId}/edit`);
|
||||
},
|
||||
[navigate]
|
||||
[navigate],
|
||||
);
|
||||
|
||||
const handleHideRoute = useCallback(
|
||||
@@ -2423,7 +2413,7 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
|
||||
const coordinates = route.path
|
||||
.filter((c) => c && c[0] != null && c[1] != null)
|
||||
.map((c: [number, number]) =>
|
||||
transform([c[1], c[0]], "EPSG:4326", projection)
|
||||
transform([c[1], c[0]], "EPSG:4326", projection),
|
||||
);
|
||||
|
||||
if (coordinates.length > 0) {
|
||||
@@ -2445,7 +2435,7 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
|
||||
|
||||
const visibleRouteIds = allRouteIds.filter(
|
||||
(id: number) =>
|
||||
id !== numericRouteId && !mapStore.hiddenRoutes.has(id)
|
||||
id !== numericRouteId && !mapStore.hiddenRoutes.has(id),
|
||||
);
|
||||
|
||||
const stationsInVisibleRoutes = new Set<number>();
|
||||
@@ -2453,12 +2443,12 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
|
||||
const stationIds =
|
||||
mapStore.routeStationsCache.get(otherRouteId) || [];
|
||||
stationIds.forEach((id: number) =>
|
||||
stationsInVisibleRoutes.add(id)
|
||||
stationsInVisibleRoutes.add(id),
|
||||
);
|
||||
});
|
||||
|
||||
const stationsToShow = routeStationIds.filter(
|
||||
(id: number) => !stationsInVisibleRoutes.has(id)
|
||||
(id: number) => !stationsInVisibleRoutes.has(id),
|
||||
);
|
||||
|
||||
for (const stationId of stationsToShow) {
|
||||
@@ -2469,8 +2459,8 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
|
||||
transform(
|
||||
[station.longitude, station.latitude],
|
||||
"EPSG:4326",
|
||||
projection
|
||||
)
|
||||
projection,
|
||||
),
|
||||
);
|
||||
const feature = new Feature({
|
||||
geometry: point,
|
||||
@@ -2480,7 +2470,7 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
|
||||
feature.set("featureType", "station");
|
||||
|
||||
const existingFeature = mapService.pointSource.getFeatureById(
|
||||
`station-${station.id}`
|
||||
`station-${station.id}`,
|
||||
);
|
||||
if (!existingFeature) {
|
||||
mapService.pointSource.addFeature(feature);
|
||||
@@ -2497,7 +2487,7 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
|
||||
|
||||
const visibleRouteIds = allRouteIds.filter(
|
||||
(id: number) =>
|
||||
id !== numericRouteId && !mapStore.hiddenRoutes.has(id)
|
||||
id !== numericRouteId && !mapStore.hiddenRoutes.has(id),
|
||||
);
|
||||
|
||||
const stationsInVisibleRoutes = new Set<number>();
|
||||
@@ -2505,21 +2495,21 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
|
||||
const stationIds =
|
||||
mapStore.routeStationsCache.get(otherRouteId) || [];
|
||||
stationIds.forEach((id: number) =>
|
||||
stationsInVisibleRoutes.add(id)
|
||||
stationsInVisibleRoutes.add(id),
|
||||
);
|
||||
});
|
||||
|
||||
const stationsToHide = routeStationIds.filter(
|
||||
(id: number) => !stationsInVisibleRoutes.has(id)
|
||||
(id: number) => !stationsInVisibleRoutes.has(id),
|
||||
);
|
||||
|
||||
stationsToHide.forEach((stationId: number) => {
|
||||
const pointFeature = mapService.pointSource.getFeatureById(
|
||||
`station-${stationId}`
|
||||
`station-${stationId}`,
|
||||
);
|
||||
if (pointFeature) {
|
||||
mapService.pointSource.removeFeature(
|
||||
pointFeature as Feature<Point>
|
||||
pointFeature as Feature<Point>,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -2527,7 +2517,7 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
|
||||
const lineFeature = mapService.lineSource.getFeatureById(routeId);
|
||||
if (lineFeature) {
|
||||
mapService.lineSource.removeFeature(
|
||||
lineFeature as Feature<LineString>
|
||||
lineFeature as Feature<LineString>,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2539,31 +2529,31 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[handleHideRoute] Error toggling route visibility:",
|
||||
error
|
||||
error,
|
||||
);
|
||||
toast.error("Ошибка при изменении видимости маршрута");
|
||||
}
|
||||
},
|
||||
[mapService]
|
||||
[mapService],
|
||||
);
|
||||
|
||||
const sortFeaturesByType = <T extends Feature<Geometry>>(
|
||||
features: T[],
|
||||
sortType: SortType
|
||||
sortType: SortType,
|
||||
): T[] => {
|
||||
const sorted = [...features];
|
||||
switch (sortType) {
|
||||
case "name_asc":
|
||||
return sorted.sort((a, b) =>
|
||||
((a.get("name") as string) || "").localeCompare(
|
||||
(b.get("name") as string) || ""
|
||||
)
|
||||
(b.get("name") as string) || "",
|
||||
),
|
||||
);
|
||||
case "name_desc":
|
||||
return sorted.sort((a, b) =>
|
||||
((b.get("name") as string) || "").localeCompare(
|
||||
(a.get("name") as string) || ""
|
||||
)
|
||||
(a.get("name") as string) || "",
|
||||
),
|
||||
);
|
||||
case "created_asc":
|
||||
return sorted.sort((a, b) => {
|
||||
@@ -2590,13 +2580,13 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
|
||||
const aDate = a.get("updated_at")
|
||||
? new Date(a.get("updated_at"))
|
||||
: a.get("created_at")
|
||||
? new Date(a.get("created_at"))
|
||||
: new Date(0);
|
||||
? new Date(a.get("created_at"))
|
||||
: new Date(0);
|
||||
const bDate = b.get("updated_at")
|
||||
? new Date(b.get("updated_at"))
|
||||
: b.get("created_at")
|
||||
? new Date(b.get("created_at"))
|
||||
: new Date(0);
|
||||
? new Date(b.get("created_at"))
|
||||
: new Date(0);
|
||||
return aDate.getTime() - bDate.getTime();
|
||||
});
|
||||
case "updated_desc":
|
||||
@@ -2604,13 +2594,13 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
|
||||
const aDate = a.get("updated_at")
|
||||
? new Date(a.get("updated_at"))
|
||||
: a.get("created_at")
|
||||
? new Date(a.get("created_at"))
|
||||
: new Date(0);
|
||||
? new Date(a.get("created_at"))
|
||||
: new Date(0);
|
||||
const bDate = b.get("updated_at")
|
||||
? new Date(b.get("updated_at"))
|
||||
: b.get("created_at")
|
||||
? new Date(b.get("created_at"))
|
||||
: new Date(0);
|
||||
? new Date(b.get("created_at"))
|
||||
: new Date(0);
|
||||
return bDate.getTime() - aDate.getTime();
|
||||
});
|
||||
default:
|
||||
@@ -2619,13 +2609,13 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
|
||||
};
|
||||
|
||||
const stations = filteredFeatures.filter(
|
||||
(f) => f.get("featureType") === "station"
|
||||
(f) => f.get("featureType") === "station",
|
||||
);
|
||||
const lines = filteredFeatures.filter(
|
||||
(f) => f.get("featureType") === "route"
|
||||
(f) => f.get("featureType") === "route",
|
||||
);
|
||||
const sights = filteredFeatures.filter(
|
||||
(f) => f.get("featureType") === "sight"
|
||||
(f) => f.get("featureType") === "sight",
|
||||
);
|
||||
|
||||
const sortedStations = sortFeaturesByType(stations, stationSort);
|
||||
@@ -2634,7 +2624,7 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
|
||||
const renderFeatureList = (
|
||||
features: Feature<Geometry>[],
|
||||
featureType: "station" | "route" | "sight",
|
||||
IconComponent: React.ElementType
|
||||
IconComponent: React.ElementType,
|
||||
) => (
|
||||
<div className="space-y-1 pr-1">
|
||||
{features.length > 0 ? (
|
||||
@@ -2662,18 +2652,16 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
|
||||
description.trim() !== "";
|
||||
const routeName =
|
||||
featureType === "route"
|
||||
? ((feature.get("routeName") as string) || "")
|
||||
? (feature.get("routeName") as string) || ""
|
||||
: "";
|
||||
const routeNumber =
|
||||
featureType === "route"
|
||||
? ((feature.get("routeNumber") as string) || fName)
|
||||
? (feature.get("routeNumber") as string) || fName
|
||||
: "";
|
||||
const routeNumberTrimmed = routeNumber.trim();
|
||||
const routeNameTrimmed = routeName.trim();
|
||||
const displayName =
|
||||
featureType === "route"
|
||||
? routeNumberTrimmed || fName
|
||||
: fName;
|
||||
featureType === "route" ? routeNumberTrimmed || fName : fName;
|
||||
const showRouteName =
|
||||
featureType === "route" &&
|
||||
routeNameTrimmed !== "" &&
|
||||
@@ -2909,7 +2897,7 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
|
||||
}`}
|
||||
onClick={() =>
|
||||
mapStore.setHideSightsByHiddenRoutes(
|
||||
!mapStore.hideSightsByHiddenRoutes
|
||||
!mapStore.hideSightsByHiddenRoutes,
|
||||
)
|
||||
}
|
||||
>
|
||||
@@ -3004,7 +2992,7 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
),
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
@@ -3021,7 +3009,7 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const MapPage: React.FC = observer(() => {
|
||||
@@ -3037,7 +3025,7 @@ export const MapPage: React.FC = observer(() => {
|
||||
const [selectedFeatureForSidebar, setSelectedFeatureForSidebar] =
|
||||
useState<Feature<Geometry> | null>(null);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string | number>>(
|
||||
new Set()
|
||||
new Set(),
|
||||
);
|
||||
const [isLassoActive, setIsLassoActive] = useState<boolean>(false);
|
||||
const [showHelp, setShowHelp] = useState<boolean>(false);
|
||||
@@ -3049,7 +3037,7 @@ export const MapPage: React.FC = observer(() => {
|
||||
|
||||
const handleFeaturesChange = useCallback(
|
||||
(feats: Feature<Geometry>[]) => setMapFeatures([...feats]),
|
||||
[]
|
||||
[],
|
||||
);
|
||||
|
||||
const handleFeatureSelectForSidebar = useCallback(
|
||||
@@ -3061,8 +3049,8 @@ export const MapPage: React.FC = observer(() => {
|
||||
featureType === "sight"
|
||||
? "sights"
|
||||
: featureType === "route"
|
||||
? "lines"
|
||||
: "layers";
|
||||
? "lines"
|
||||
: "layers";
|
||||
setActiveSectionFromParent(sectionId);
|
||||
setTimeout(() => {
|
||||
document
|
||||
@@ -3071,7 +3059,7 @@ export const MapPage: React.FC = observer(() => {
|
||||
}, 100);
|
||||
}
|
||||
},
|
||||
[]
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -3092,7 +3080,7 @@ export const MapPage: React.FC = observer(() => {
|
||||
mapService.loadFeaturesFromApi(
|
||||
mapStore.stations,
|
||||
mapStore.routes,
|
||||
mapStore.sights
|
||||
mapStore.sights,
|
||||
);
|
||||
} catch (e) {
|
||||
console.error("Failed to load initial map data:", e);
|
||||
@@ -3111,7 +3099,7 @@ export const MapPage: React.FC = observer(() => {
|
||||
handleFeaturesChange,
|
||||
handleFeatureSelectForSidebar,
|
||||
tooltipRef.current,
|
||||
setSelectedIds
|
||||
setSelectedIds,
|
||||
);
|
||||
setMapServiceInstance(service);
|
||||
|
||||
@@ -3122,7 +3110,7 @@ export const MapPage: React.FC = observer(() => {
|
||||
loadInitialData(service);
|
||||
} catch (e: any) {
|
||||
setError(
|
||||
`Ошибка инициализации карты: ${e.message || "Неизвестная ошибка"}.`
|
||||
`Ошибка инициализации карты: ${e.message || "Неизвестная ошибка"}.`,
|
||||
);
|
||||
setIsMapLoading(false);
|
||||
setIsDataLoading(false);
|
||||
@@ -3215,7 +3203,7 @@ export const MapPage: React.FC = observer(() => {
|
||||
mapServiceInstance.loadFeaturesFromApi(
|
||||
mapStore.stations,
|
||||
mapStore.routes,
|
||||
mapStore.sights
|
||||
mapStore.sights,
|
||||
);
|
||||
}
|
||||
}, [selectedCityId, mapServiceInstance, isDataLoading]);
|
||||
@@ -3228,7 +3216,7 @@ export const MapPage: React.FC = observer(() => {
|
||||
mapServiceInstance.loadFeaturesFromApi(
|
||||
mapStore.stations,
|
||||
mapStore.routes,
|
||||
mapStore.sights
|
||||
mapStore.sights,
|
||||
);
|
||||
}
|
||||
}, [
|
||||
@@ -3244,7 +3232,7 @@ export const MapPage: React.FC = observer(() => {
|
||||
selectedFeatureForSidebar !== null || selectedIds.size > 0;
|
||||
|
||||
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
|
||||
ref={mapRef}
|
||||
@@ -3291,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 && (
|
||||
<div className="absolute bottom-16 right-4 z-20 p-4 bg-white rounded-lg shadow-xl max-w-xs">
|
||||
<h4 className="font-bold mb-2">Горячие клавиши:</h4>
|
||||
<ul className="text-sm space-y-2">
|
||||
<li>
|
||||
<span className="font-mono bg-gray-100 px-1 rounded">
|
||||
Shift
|
||||
</span>{" "}
|
||||
- Режим выделения (лассо)
|
||||
</li>
|
||||
<li>
|
||||
<span className="font-mono bg-gray-100 px-1 rounded">
|
||||
Ctrl + клик
|
||||
</span>{" "}
|
||||
- Добавить/убрать из выделения
|
||||
</li>
|
||||
<li>
|
||||
<span className="font-mono bg-gray-100 px-1 rounded">Esc</span>{" "}
|
||||
- Отменить выделение
|
||||
</li>
|
||||
</ul>
|
||||
<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>
|
||||
<div className="text-sm space-y-3">
|
||||
<div>
|
||||
<p className="font-semibold mb-1">Перемещение и масштаб:</p>
|
||||
<ul className="space-y-1">
|
||||
<li>Колесо мыши — приблизить / отдалить.</li>
|
||||
<li>
|
||||
Средняя кнопка мыши (колесо зажато) — перетаскивание карты.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="font-semibold mb-1">Выделение объектов:</p>
|
||||
<ul className="space-y-1">
|
||||
<li>Одинарный клик по объекту — выделить и центрировать.</li>
|
||||
<li>
|
||||
<span className="font-mono bg-gray-100 px-1 rounded">
|
||||
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
|
||||
onClick={() => setShowHelp(false)}
|
||||
className="mt-3 text-xs text-blue-600 hover:text-blue-800"
|
||||
@@ -3328,6 +3368,14 @@ export const MapPage: React.FC = observer(() => {
|
||||
</button>
|
||||
</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>
|
||||
{showContent && (
|
||||
<MapSightbar
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
mediaStore,
|
||||
MEDIA_TYPE_LABELS,
|
||||
languageStore,
|
||||
LoadingSpinner,
|
||||
} from "@shared";
|
||||
import { MediaViewer } from "@widgets";
|
||||
|
||||
@@ -138,8 +139,15 @@ export const MediaEditPage = observer(() => {
|
||||
|
||||
if (!media && id) {
|
||||
return (
|
||||
<Box className="flex justify-center items-center h-screen">
|
||||
<CircularProgress />
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
minHeight: "60vh",
|
||||
}}
|
||||
>
|
||||
<LoadingSpinner message="Загрузка данных медиа..." />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,10 @@ export const MediaListPage = observer(() => {
|
||||
const [rowId, setRowId] = useState<string | null>(null);
|
||||
const [ids, setIds] = useState<string[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [paginationModel, setPaginationModel] = useState({
|
||||
page: 0,
|
||||
pageSize: 50,
|
||||
});
|
||||
const { language } = languageStore;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -100,29 +104,39 @@ export const MediaListPage = observer(() => {
|
||||
return (
|
||||
<>
|
||||
<div className="w-full">
|
||||
<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"
|
||||
onClick={() => setIsBulkDeleteModalOpen(true)}
|
||||
>
|
||||
<Trash2 size={20} className="text-white" /> Удалить выбранные (
|
||||
{ids.length})
|
||||
</button>
|
||||
</div>
|
||||
{ids.length > 0 && (
|
||||
<div className="flex justify-end mb-5 duration-300">
|
||||
<button
|
||||
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})
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DataGrid
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
hideFooterPagination
|
||||
checkboxSelection
|
||||
disableRowSelectionExcludeModel
|
||||
loading={isLoading}
|
||||
onRowSelectionModelChange={(newSelection) => {
|
||||
setIds(Array.from(newSelection.ids) as string[]);
|
||||
paginationModel={paginationModel}
|
||||
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}
|
||||
slots={{
|
||||
noRowsOverlay: () => (
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
languageStore,
|
||||
routeStore,
|
||||
selectedCityStore,
|
||||
stationsStore,
|
||||
} from "@shared";
|
||||
import { EditStationModal } from "../../widgets/modals/EditStationModal";
|
||||
|
||||
@@ -77,7 +78,6 @@ type LinkedItemsProps<T> = {
|
||||
disableCreation?: boolean;
|
||||
updatedLinkedItems?: T[];
|
||||
refresh?: number;
|
||||
routeDirection?: boolean;
|
||||
};
|
||||
|
||||
export const LinkedItems = <
|
||||
@@ -127,7 +127,6 @@ const LinkedItemsContentsInner = <
|
||||
disableCreation = false,
|
||||
updatedLinkedItems,
|
||||
refresh,
|
||||
routeDirection,
|
||||
}: LinkedItemsProps<T>) => {
|
||||
const { language } = languageStore;
|
||||
|
||||
@@ -152,11 +151,6 @@ const LinkedItemsContentsInner = <
|
||||
|
||||
const availableItems = allItems
|
||||
.filter((item) => !linkedItems.some((linked) => linked.id === item.id))
|
||||
.filter((item) => {
|
||||
if (routeDirection === undefined) return true;
|
||||
|
||||
return item.direction === routeDirection;
|
||||
})
|
||||
.filter((item) => {
|
||||
const selectedCityId = selectedCityStore.selectedCityId;
|
||||
if (selectedCityId && "city_id" in item) {
|
||||
@@ -168,7 +162,10 @@ const LinkedItemsContentsInner = <
|
||||
|
||||
const filteredAvailableItems = availableItems.filter((item) => {
|
||||
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(() => {
|
||||
@@ -185,6 +182,19 @@ const LinkedItemsContentsInner = <
|
||||
setPosition(linkedItems.length + 1);
|
||||
}, [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) => {
|
||||
if (!result.destination) return;
|
||||
|
||||
@@ -198,7 +208,13 @@ const LinkedItemsContentsInner = <
|
||||
|
||||
authInstance
|
||||
.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) => {
|
||||
console.error("Error updating station order:", error);
|
||||
@@ -245,11 +261,28 @@ const LinkedItemsContentsInner = <
|
||||
const linkItem = () => {
|
||||
if (selectedItemId !== null) {
|
||||
setError(null);
|
||||
const selectedItem = allItems.find((item) => item.id === selectedItemId);
|
||||
const requestData = {
|
||||
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,
|
||||
{ id: selectedItemId }
|
||||
(() => {
|
||||
if (!selectedItem) return { id: selectedItemId };
|
||||
const transfers = getStationTransfers(
|
||||
selectedItemId,
|
||||
selectedItem.transfers
|
||||
);
|
||||
return {
|
||||
...selectedItem,
|
||||
transfers: transfers || selectedItem.transfers,
|
||||
};
|
||||
})()
|
||||
),
|
||||
};
|
||||
|
||||
@@ -331,10 +364,24 @@ const LinkedItemsContentsInner = <
|
||||
|
||||
setError(null);
|
||||
setIsLinkingBulk(true);
|
||||
const selectedStations = Array.from(selectedItems).map((id) => ({ id }));
|
||||
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 = {
|
||||
stations: [
|
||||
...linkedItems.map((item) => ({ id: item.id })),
|
||||
...linkedItems.map((item) => {
|
||||
const transfers = getStationTransfers(item.id, item.transfers);
|
||||
return {
|
||||
...item,
|
||||
transfers: transfers || item.transfers,
|
||||
};
|
||||
}),
|
||||
...selectedStations,
|
||||
],
|
||||
};
|
||||
@@ -459,12 +506,6 @@ const LinkedItemsContentsInner = <
|
||||
{type === "edit" && !disableCreation && (
|
||||
<Stack gap={2} mt={2}>
|
||||
<Typography variant="subtitle1">Добавить остановки</Typography>
|
||||
{routeDirection !== undefined && (
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
Показываются только остановки для{" "}
|
||||
{routeDirection ? "прямого" : "обратного"} направления
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
@@ -493,6 +534,7 @@ const LinkedItemsContentsInner = <
|
||||
<TextField
|
||||
{...params}
|
||||
label="Выберите остановку"
|
||||
placeholder="Введите название или описание остановки..."
|
||||
fullWidth
|
||||
/>
|
||||
)}
|
||||
@@ -500,16 +542,15 @@ const LinkedItemsContentsInner = <
|
||||
option.id === value?.id
|
||||
}
|
||||
filterOptions={(options, { inputValue }) => {
|
||||
const searchWords = inputValue
|
||||
.toLowerCase()
|
||||
.split(" ")
|
||||
.filter(Boolean);
|
||||
if (!inputValue.trim()) return options;
|
||||
const query = inputValue.toLowerCase();
|
||||
return options.filter((option) => {
|
||||
const optionWords = String(option.name)
|
||||
.toLowerCase()
|
||||
.split(" ");
|
||||
return searchWords.every((searchWord) =>
|
||||
optionWords.some((word) => word.startsWith(searchWord))
|
||||
const name = String(option.name || "").toLowerCase();
|
||||
const description = String(
|
||||
option.description || ""
|
||||
).toLowerCase();
|
||||
return (
|
||||
name.includes(query) || description.includes(query)
|
||||
);
|
||||
});
|
||||
}}
|
||||
|
||||
@@ -13,22 +13,30 @@ import {
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
} from "@mui/material";
|
||||
import { MediaViewer, VideoPreviewCard } from "@widgets";
|
||||
import {
|
||||
MediaViewer,
|
||||
VideoPreviewCard,
|
||||
ImageUploadCard,
|
||||
} from "@widgets";
|
||||
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 { useNavigate } from "react-router-dom";
|
||||
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 {
|
||||
carrierStore,
|
||||
articlesStore,
|
||||
routeStore,
|
||||
languageStore,
|
||||
mediaStore,
|
||||
isMediaIdEmpty,
|
||||
ArticleSelectOrCreateDialog,
|
||||
SelectMediaDialog,
|
||||
selectedCityStore,
|
||||
UploadMediaDialog,
|
||||
PreviewMediaDialog,
|
||||
} from "@shared";
|
||||
import type { Route } from "@shared";
|
||||
|
||||
export const RouteCreatePage = observer(() => {
|
||||
const navigate = useNavigate();
|
||||
@@ -45,18 +53,27 @@ export const RouteCreatePage = observer(() => {
|
||||
const [centerLat, setCenterLat] = useState("");
|
||||
const [centerLng, setCenterLng] = useState("");
|
||||
const [videoPreview, setVideoPreview] = useState<string>("");
|
||||
const [icon, setIcon] = useState<string>("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSelectArticleDialogOpen, setIsSelectArticleDialogOpen] =
|
||||
useState(false);
|
||||
const [isSelectVideoDialogOpen, setIsSelectVideoDialogOpen] = useState(false);
|
||||
const [isVideoPreviewOpen, setIsVideoPreviewOpen] = 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 { language } = languageStore;
|
||||
|
||||
useEffect(() => {
|
||||
carrierStore.getCarriers(language);
|
||||
articlesStore.getArticleList();
|
||||
mediaStore.getMedia();
|
||||
}, [language]);
|
||||
|
||||
const filteredCarriers = useMemo(() => {
|
||||
@@ -150,6 +167,23 @@ export const RouteCreatePage = observer(() => {
|
||||
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 () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
@@ -174,11 +208,6 @@ export const RouteCreatePage = observer(() => {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
if (!governorAppeal) {
|
||||
toast.error("Выберите статью для обращения к пассажирам");
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const validationResult = validateCoordinates(routeCoords);
|
||||
if (validationResult !== true) {
|
||||
@@ -213,7 +242,9 @@ export const RouteCreatePage = observer(() => {
|
||||
}
|
||||
|
||||
const carrier_id = Number(carrier);
|
||||
const governor_appeal = Number(governorAppeal);
|
||||
const governor_appeal = governorAppeal
|
||||
? Number(governorAppeal)
|
||||
: undefined;
|
||||
const rotate = turn ? Number(turn) : undefined;
|
||||
const center_latitude = centerLat ? Number(centerLat) : undefined;
|
||||
const center_longitude = centerLng ? Number(centerLng) : undefined;
|
||||
@@ -238,7 +269,6 @@ export const RouteCreatePage = observer(() => {
|
||||
carrier_id,
|
||||
route_number: routeNumber,
|
||||
route_sys_number: govRouteNumber,
|
||||
governor_appeal,
|
||||
route_name: routeName,
|
||||
route_direction,
|
||||
scale_min: scale_min !== null ? scale_min : 0,
|
||||
@@ -247,10 +277,14 @@ export const RouteCreatePage = observer(() => {
|
||||
center_latitude,
|
||||
center_longitude,
|
||||
path,
|
||||
video_preview:
|
||||
videoPreview && videoPreview !== "" ? videoPreview : undefined,
|
||||
video_preview: !isMediaIdEmpty(videoPreview) ? videoPreview : undefined,
|
||||
icon: !isMediaIdEmpty(icon) ? icon : undefined,
|
||||
};
|
||||
|
||||
if (governor_appeal !== undefined) {
|
||||
newRoute.governor_appeal = governor_appeal;
|
||||
}
|
||||
|
||||
await routeStore.createRoute(newRoute);
|
||||
toast.success("Маршрут успешно создан");
|
||||
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
|
||||
variant="outlined"
|
||||
onClick={() => setIsSelectArticleDialogOpen(true)}
|
||||
@@ -392,16 +437,41 @@ export const RouteCreatePage = observer(() => {
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<VideoPreviewCard
|
||||
title="Видеозаставка"
|
||||
videoId={videoPreview}
|
||||
onVideoClick={handleVideoPreviewClick}
|
||||
onDeleteVideoClick={() => {
|
||||
setVideoPreview("");
|
||||
}}
|
||||
onSelectVideoClick={handleVideoFileSelect}
|
||||
className="w-full"
|
||||
/>
|
||||
<Box className="w-full flex justify-center gap-4 flex-wrap">
|
||||
<div className="flex flex-col gap-4 max-w-[300px]">
|
||||
<ImageUploadCard
|
||||
title="Иконка маршрута"
|
||||
imageKey="thumbnail"
|
||||
imageUrl={effectiveIconUrl}
|
||||
onImageClick={() => {
|
||||
setIsPreviewIconOpen(true);
|
||||
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>
|
||||
<InputLabel>Прямой/обратный маршрут</InputLabel>
|
||||
@@ -511,7 +581,7 @@ export const RouteCreatePage = observer(() => {
|
||||
onSelectMedia={handleVideoSelect}
|
||||
mediaType={2}
|
||||
/>
|
||||
{videoPreview && videoPreview !== "" && (
|
||||
{effectiveVideoId && (
|
||||
<Dialog
|
||||
open={isVideoPreviewOpen}
|
||||
onClose={() => setIsVideoPreviewOpen(false)}
|
||||
@@ -523,7 +593,7 @@ export const RouteCreatePage = observer(() => {
|
||||
<Box className="flex justify-center items-center p-4">
|
||||
<MediaViewer
|
||||
media={{
|
||||
id: videoPreview,
|
||||
id: effectiveVideoId,
|
||||
media_type: 2,
|
||||
filename: "video_preview",
|
||||
}}
|
||||
@@ -549,6 +619,28 @@ export const RouteCreatePage = observer(() => {
|
||||
initialFile={fileToUpload || undefined}
|
||||
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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -13,23 +13,31 @@ import {
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
} from "@mui/material";
|
||||
import { MediaViewer, VideoPreviewCard } from "@widgets";
|
||||
import {
|
||||
MediaViewer,
|
||||
VideoPreviewCard,
|
||||
ImageUploadCard,
|
||||
DeleteModal,
|
||||
} from "@widgets";
|
||||
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 { useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
import { carrierStore } from "../../../shared/store/CarrierStore";
|
||||
import { articlesStore } from "../../../shared/store/ArticlesStore";
|
||||
import { toast } from "react-toastify";
|
||||
import {
|
||||
carrierStore,
|
||||
articlesStore,
|
||||
routeStore,
|
||||
languageStore,
|
||||
mediaStore,
|
||||
isMediaIdEmpty,
|
||||
stationsStore,
|
||||
ArticleSelectOrCreateDialog,
|
||||
SelectMediaDialog,
|
||||
UploadMediaDialog,
|
||||
PreviewMediaDialog,
|
||||
LoadingSpinner,
|
||||
} from "@shared";
|
||||
import { toast } from "react-toastify";
|
||||
import { stationsStore } from "@shared";
|
||||
import { LinkedItems } from "../LinekedStations";
|
||||
|
||||
export const RouteEditPage = observer(() => {
|
||||
@@ -37,33 +45,73 @@ export const RouteEditPage = observer(() => {
|
||||
const { id } = useParams();
|
||||
const { editRouteData, copyRouteAction } = routeStore;
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isLoadingData, setIsLoadingData] = useState(true);
|
||||
const [isSelectArticleDialogOpen, setIsSelectArticleDialogOpen] =
|
||||
useState(false);
|
||||
const [isSelectVideoDialogOpen, setIsSelectVideoDialogOpen] = useState(false);
|
||||
const [isVideoPreviewOpen, setIsVideoPreviewOpen] = 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 { language } = languageStore;
|
||||
const [coordinates, setCoordinates] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
const response = await routeStore.getRoute(Number(id));
|
||||
routeStore.setEditRouteData(response);
|
||||
languageStore.setLanguage("ru");
|
||||
if (!id) {
|
||||
setIsLoadingData(false);
|
||||
return;
|
||||
}
|
||||
setIsLoadingData(true);
|
||||
try {
|
||||
const response = await routeStore.getRoute(Number(id));
|
||||
routeStore.setEditRouteData(response);
|
||||
languageStore.setLanguage("ru");
|
||||
} finally {
|
||||
setIsLoadingData(false);
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
}, []);
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
carrierStore.getCarriers(language);
|
||||
stationsStore.getStations();
|
||||
articlesStore.getArticleList();
|
||||
await carrierStore.getCarriers(language);
|
||||
await stationsStore.getStations();
|
||||
await articlesStore.getArticleList();
|
||||
await mediaStore.getMedia();
|
||||
};
|
||||
fetchData();
|
||||
}, [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(() => {
|
||||
if (editRouteData.path && editRouteData.path.length > 0) {
|
||||
const formattedPath = editRouteData.path
|
||||
@@ -91,10 +139,6 @@ export const RouteEditPage = observer(() => {
|
||||
toast.error("Заполните номер маршрута в Говорящем Городе");
|
||||
return;
|
||||
}
|
||||
if (!editRouteData.governor_appeal) {
|
||||
toast.error("Выберите статью для обращения к пассажирам");
|
||||
return;
|
||||
}
|
||||
|
||||
const validationResult = validateCoordinates(coordinates);
|
||||
if (validationResult !== true) {
|
||||
@@ -233,6 +277,21 @@ export const RouteEditPage = observer(() => {
|
||||
(article) => article.id === editRouteData.governor_appeal
|
||||
);
|
||||
|
||||
if (isLoadingData) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
minHeight: "60vh",
|
||||
}}
|
||||
>
|
||||
<LoadingSpinner message="Загрузка данных маршрута..." />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
||||
<div className="flex items-center gap-4">
|
||||
@@ -505,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
|
||||
variant="outlined"
|
||||
onClick={() => setIsSelectArticleDialogOpen(true)}
|
||||
@@ -515,16 +589,42 @@ export const RouteEditPage = observer(() => {
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<VideoPreviewCard
|
||||
title="Видеозаставка"
|
||||
videoId={editRouteData.video_preview}
|
||||
onVideoClick={handleVideoPreviewClick}
|
||||
onDeleteVideoClick={() => {
|
||||
routeStore.setEditRouteData({ video_preview: "" });
|
||||
}}
|
||||
onSelectVideoClick={handleVideoFileSelect}
|
||||
className="w-full"
|
||||
/>
|
||||
<Box className="w-full flex justify-center gap-4 flex-wrap">
|
||||
<div className="flex flex-col gap-4 max-w-[300px]">
|
||||
<ImageUploadCard
|
||||
title="Иконка маршрута"
|
||||
imageKey="thumbnail"
|
||||
imageUrl={effectiveIconUrl}
|
||||
onImageClick={() => {
|
||||
setIsPreviewIconOpen(true);
|
||||
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>
|
||||
|
||||
<LinkedItems
|
||||
@@ -538,7 +638,6 @@ export const RouteEditPage = observer(() => {
|
||||
onUpdate={() => {
|
||||
routeStore.getRoute(Number(id));
|
||||
}}
|
||||
routeDirection={editRouteData.route_direction}
|
||||
/>
|
||||
|
||||
<div className="flex w-full justify-between">
|
||||
@@ -585,10 +684,10 @@ export const RouteEditPage = observer(() => {
|
||||
<DialogTitle>Предпросмотр видео</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box className="flex justify-center items-center p-4">
|
||||
{editRouteData.video_preview && (
|
||||
{effectiveVideoId && (
|
||||
<MediaViewer
|
||||
media={{
|
||||
id: editRouteData.video_preview,
|
||||
id: effectiveVideoId,
|
||||
media_type: 2,
|
||||
filename: "video_preview",
|
||||
}}
|
||||
@@ -612,6 +711,38 @@ export const RouteEditPage = observer(() => {
|
||||
initialFile={fileToUpload || undefined}
|
||||
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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -17,6 +17,10 @@ export const RouteListPage = observer(() => {
|
||||
const [rowId, setRowId] = useState<number | null>(null);
|
||||
const [ids, setIds] = useState<number[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [paginationModel, setPaginationModel] = useState({
|
||||
page: 0,
|
||||
pageSize: 50,
|
||||
});
|
||||
const { language } = languageStore;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -149,28 +153,39 @@ export const RouteListPage = observer(() => {
|
||||
<CreateButton label="Создать маршрут" path="/route/create" />
|
||||
</div>
|
||||
|
||||
<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"
|
||||
onClick={() => setIsBulkDeleteModalOpen(true)}
|
||||
>
|
||||
<Trash2 size={20} className="text-white" /> Удалить выбранные (
|
||||
{ids.length})
|
||||
</button>
|
||||
</div>
|
||||
{ids.length > 0 && (
|
||||
<div className="flex justify-end mb-5 duration-300">
|
||||
<button
|
||||
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})
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DataGrid
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
hideFooter
|
||||
checkboxSelection
|
||||
disableRowSelectionExcludeModel
|
||||
loading={isLoading}
|
||||
paginationModel={paginationModel}
|
||||
onPaginationModelChange={setPaginationModel}
|
||||
pageSizeOptions={[50]}
|
||||
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
|
||||
onRowSelectionModelChange={(newSelection) => {
|
||||
setIds(Array.from(newSelection.ids as unknown as number[]));
|
||||
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([]);
|
||||
}
|
||||
}}
|
||||
slots={{
|
||||
noRowsOverlay: () => (
|
||||
|
||||
@@ -5,5 +5,5 @@ export const STATION_OUTLINE_WIDTH = 4;
|
||||
export const SIGHT_SIZE = 40;
|
||||
export const SCALE_FACTOR = 50;
|
||||
|
||||
export const BACKGROUND_COLOR = 0x111111;
|
||||
export const BACKGROUND_COLOR = 0x000;
|
||||
export const PATH_COLOR = 0xff4d4d;
|
||||
|
||||
@@ -4,7 +4,7 @@ import { MediaViewer } from "@widgets";
|
||||
import { useMapData } from "./MapDataContext";
|
||||
import { observer } from "mobx-react-lite";
|
||||
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";
|
||||
|
||||
@@ -61,6 +61,7 @@ export const LeftSidebar = observer(({ open, onToggle }: LeftSidebarProps) => {
|
||||
width="100%"
|
||||
spacing={4}
|
||||
alignItems="stretch"
|
||||
justifyContent="space-between"
|
||||
sx={{
|
||||
opacity: open ? 1 : 0,
|
||||
transition: "opacity 0.25s ease",
|
||||
@@ -68,71 +69,72 @@ export const LeftSidebar = observer(({ open, onToggle }: LeftSidebarProps) => {
|
||||
display: open ? "flex" : "none",
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
onClick={handleBack}
|
||||
variant="contained"
|
||||
color="primary"
|
||||
sx={{
|
||||
backgroundColor: "#222",
|
||||
color: "#fff",
|
||||
borderRadius: 1.5,
|
||||
px: 2,
|
||||
py: 1,
|
||||
"&:hover": {
|
||||
backgroundColor: "#2d2d2d",
|
||||
},
|
||||
}}
|
||||
fullWidth
|
||||
startIcon={<ArrowBackIcon />}
|
||||
>
|
||||
Назад
|
||||
</Button>
|
||||
|
||||
<Stack
|
||||
direction="column"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
spacing={3}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: 200,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
<div>
|
||||
<Button
|
||||
onClick={handleBack}
|
||||
variant="contained"
|
||||
color="primary"
|
||||
sx={{
|
||||
backgroundColor: "#222",
|
||||
color: "#fff",
|
||||
borderRadius: 1.5,
|
||||
px: 2,
|
||||
py: 1,
|
||||
marginBottom: 10,
|
||||
"&:hover": {
|
||||
backgroundColor: "#2d2d2d",
|
||||
},
|
||||
}}
|
||||
fullWidth
|
||||
startIcon={<ArrowBackIcon />}
|
||||
>
|
||||
{carrierThumbnail && (
|
||||
<MediaViewer
|
||||
media={{
|
||||
id: carrierThumbnail,
|
||||
media_type: 1, // Тип "Фото" для логотипа
|
||||
filename: "route_thumbnail",
|
||||
}}
|
||||
fullWidth
|
||||
fullHeight
|
||||
/>
|
||||
)}
|
||||
<Typography sx={{ color: "#fff" }} textAlign="center">
|
||||
При поддержке Правительства
|
||||
</Typography>
|
||||
</div>
|
||||
</Stack>
|
||||
Назад
|
||||
</Button>
|
||||
|
||||
<Stack
|
||||
direction="column"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
spacing={2}
|
||||
>
|
||||
<Button variant="outlined" color="warning" fullWidth>
|
||||
Достопримечательности
|
||||
</Button>
|
||||
<Button variant="outlined" color="warning" fullWidth>
|
||||
Остановки
|
||||
</Button>
|
||||
</Stack>
|
||||
<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"
|
||||
@@ -141,7 +143,7 @@ export const LeftSidebar = observer(({ open, onToggle }: LeftSidebarProps) => {
|
||||
justifyContent="center"
|
||||
flexGrow={1}
|
||||
>
|
||||
{carrierLogo && (
|
||||
{carrierLogo && !isMediaIdEmpty(carrierLogo) && (
|
||||
<MediaViewer
|
||||
media={{
|
||||
id: carrierLogo,
|
||||
@@ -153,31 +155,14 @@ export const LeftSidebar = observer(({ open, onToggle }: LeftSidebarProps) => {
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Typography variant="h6" textAlign="center" sx={{ color: "#fff" }}>
|
||||
#ВсемПоПути
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
{!open && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: "rgba(255,255,255,0.6)",
|
||||
writingMode: "vertical-rl",
|
||||
transform: "rotate(180deg)",
|
||||
letterSpacing: 4,
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
transformOrigin: "center",
|
||||
translate: "-50% -50%",
|
||||
opacity: 0.6,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
variant="h6"
|
||||
textAlign="center"
|
||||
sx={{ color: "#fff", marginTop: "auto" }}
|
||||
>
|
||||
#ВсемПоПути
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<div className="absolute bottom-[20px] -right-[520px] z-10">
|
||||
<LanguageSelector onBack={onToggle} isSidebarOpen={open} />
|
||||
|
||||
@@ -169,7 +169,7 @@ export const MapDataProvider = observer(
|
||||
}
|
||||
|
||||
function setIconSize(size: number) {
|
||||
const clamped = Math.max(50, Math.min(300, size));
|
||||
const clamped = Math.max(1, Math.min(300, size));
|
||||
setRouteChanges((prev) => {
|
||||
if (prev.icon_size === clamped) {
|
||||
return prev;
|
||||
@@ -179,7 +179,7 @@ export const MapDataProvider = observer(
|
||||
}
|
||||
|
||||
function setFontSize(size: number) {
|
||||
const clamped = Math.max(50, Math.min(300, size));
|
||||
const clamped = Math.max(1, Math.min(300, size));
|
||||
setRouteChanges((prev) => {
|
||||
if (prev.font_size === clamped) {
|
||||
return prev;
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
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 { useEffect, useState } from "react";
|
||||
import { useTransform } from "./TransformContext";
|
||||
@@ -12,6 +20,7 @@ export function RightSidebar() {
|
||||
saveChanges,
|
||||
originalRouteData,
|
||||
setMapRotation,
|
||||
setMapCenter,
|
||||
setIconSize: updateIconSize,
|
||||
setFontSize: updateFontSize,
|
||||
} = useMapData();
|
||||
@@ -27,6 +36,7 @@ export function RightSidebar() {
|
||||
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(() => {
|
||||
if (originalRouteData) {
|
||||
@@ -91,7 +101,7 @@ export function RightSidebar() {
|
||||
if (!Number.isFinite(value)) {
|
||||
return;
|
||||
}
|
||||
const clamped = Math.max(50, Math.min(300, Math.round(value)));
|
||||
const clamped = Math.max(1, Math.min(300, Math.round(value)));
|
||||
setIconSize(clamped);
|
||||
updateIconSize(clamped);
|
||||
};
|
||||
@@ -100,7 +110,7 @@ export function RightSidebar() {
|
||||
if (!Number.isFinite(value)) {
|
||||
return;
|
||||
}
|
||||
const clamped = Math.max(50, Math.min(300, Math.round(value)));
|
||||
const clamped = Math.max(1, Math.min(300, Math.round(value)));
|
||||
setFontSize(clamped);
|
||||
updateFontSize(clamped);
|
||||
};
|
||||
@@ -149,11 +159,19 @@ export function RightSidebar() {
|
||||
newMinScale = 10;
|
||||
}
|
||||
|
||||
if (newMinScale > 300) {
|
||||
newMinScale = 297;
|
||||
}
|
||||
|
||||
setMinScale(newMinScale);
|
||||
|
||||
if (maxScale - newMinScale < 2) {
|
||||
let newMaxScale = newMinScale + 2;
|
||||
|
||||
if (newMaxScale > 300) {
|
||||
newMaxScale = 300;
|
||||
}
|
||||
|
||||
if (newMaxScale < 3) {
|
||||
newMaxScale = 3;
|
||||
setMinScale(1);
|
||||
@@ -289,60 +307,58 @@ export function RightSidebar() {
|
||||
}}
|
||||
/>
|
||||
|
||||
<Typography variant="body2" sx={{ color: "#fff", textAlign: "center" }}>
|
||||
Размер иконок: {iconSize}%
|
||||
</Typography>
|
||||
|
||||
<Slider
|
||||
<TextField
|
||||
type="number"
|
||||
label="Размер иконок (%)"
|
||||
variant="filled"
|
||||
value={iconSize}
|
||||
onChange={(_, value) => {
|
||||
if (typeof value === "number") {
|
||||
onChange={(e) => {
|
||||
const value = Number(e.target.value);
|
||||
if (!isNaN(value)) {
|
||||
handleIconSizeChange(value);
|
||||
}
|
||||
}}
|
||||
min={50}
|
||||
max={300}
|
||||
step={1}
|
||||
style={{ backgroundColor: "#222", borderRadius: 4 }}
|
||||
sx={{
|
||||
color: "#fff",
|
||||
"& .MuiSlider-thumb": {
|
||||
backgroundColor: "#fff",
|
||||
"& .MuiInputLabel-root": {
|
||||
color: "#fff",
|
||||
},
|
||||
"& .MuiSlider-track": {
|
||||
backgroundColor: "#fff",
|
||||
},
|
||||
"& .MuiSlider-rail": {
|
||||
backgroundColor: "#666",
|
||||
"& .MuiInputBase-input": {
|
||||
color: "#fff",
|
||||
},
|
||||
}}
|
||||
inputProps={{
|
||||
min: 1,
|
||||
max: 300,
|
||||
step: 1,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Typography variant="body2" sx={{ color: "#fff", textAlign: "center" }}>
|
||||
Размер шрифта: {fontSize}%
|
||||
</Typography>
|
||||
|
||||
<Slider
|
||||
<TextField
|
||||
type="number"
|
||||
label="Размер шрифта (%)"
|
||||
variant="filled"
|
||||
value={fontSize}
|
||||
onChange={(_, value) => {
|
||||
if (typeof value === "number") {
|
||||
onChange={(e) => {
|
||||
const value = Number(e.target.value);
|
||||
if (!isNaN(value)) {
|
||||
handleFontSizeChange(value);
|
||||
}
|
||||
}}
|
||||
min={50}
|
||||
max={300}
|
||||
step={1}
|
||||
style={{ backgroundColor: "#222", borderRadius: 4 }}
|
||||
sx={{
|
||||
color: "#fff",
|
||||
"& .MuiSlider-thumb": {
|
||||
backgroundColor: "#fff",
|
||||
"& .MuiInputLabel-root": {
|
||||
color: "#fff",
|
||||
},
|
||||
"& .MuiSlider-track": {
|
||||
backgroundColor: "#fff",
|
||||
},
|
||||
"& .MuiSlider-rail": {
|
||||
backgroundColor: "#666",
|
||||
"& .MuiInputBase-input": {
|
||||
color: "#fff",
|
||||
},
|
||||
}}
|
||||
inputProps={{
|
||||
min: 1,
|
||||
max: 300,
|
||||
step: 1,
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
@@ -386,7 +402,11 @@ export function RightSidebar() {
|
||||
value={Math.round(localCenter.x * 1000) / 1000}
|
||||
onChange={(e) => {
|
||||
setIsUserEditing(true);
|
||||
setLocalCenter((prev) => ({ ...prev, x: Number(e.target.value) }));
|
||||
const newValue = Number(e.target.value);
|
||||
setLocalCenter((prev) => ({ ...prev, x: newValue }));
|
||||
if (!isNaN(newValue) && localCenter.y !== undefined) {
|
||||
setMapCenter(newValue, localCenter.y);
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
setIsUserEditing(false);
|
||||
@@ -406,12 +426,16 @@ export function RightSidebar() {
|
||||
/>
|
||||
<TextField
|
||||
type="number"
|
||||
label="Центр карты, высота"
|
||||
label="Центр карты, долгота"
|
||||
variant="filled"
|
||||
value={Math.round(localCenter.y * 1000) / 1000}
|
||||
onChange={(e) => {
|
||||
setIsUserEditing(true);
|
||||
setLocalCenter((prev) => ({ ...prev, y: Number(e.target.value) }));
|
||||
const newValue = Number(e.target.value);
|
||||
setLocalCenter((prev) => ({ ...prev, y: newValue }));
|
||||
if (!isNaN(newValue) && localCenter.x !== undefined) {
|
||||
setMapCenter(localCenter.x, newValue);
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
setIsUserEditing(false);
|
||||
@@ -434,19 +458,51 @@ export function RightSidebar() {
|
||||
<Button
|
||||
variant="contained"
|
||||
color="secondary"
|
||||
sx={{ mt: 2 }}
|
||||
sx={{ mt: 2, position: "relative" }}
|
||||
disabled={isSaving}
|
||||
onClick={async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await saveChanges();
|
||||
toast.success("Изменения сохранены");
|
||||
} catch (error) {
|
||||
console.error(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>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ export interface StationData {
|
||||
address: string;
|
||||
city_id?: number;
|
||||
description: string;
|
||||
icon?: string;
|
||||
id: number;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
|
||||
@@ -1,21 +1,28 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { PointerEvent as ReactPointerEvent } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import type { PointerEvent as ReactPointerEvent, CSSProperties } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
|
||||
import { useMapData } from "../MapDataContext";
|
||||
import { AlignLeft, AlignCenter, AlignRight } from "lucide-react";
|
||||
import { useTransform } from "../TransformContext";
|
||||
import { coordinatesToLocal, localToCoordinates } from "../utils";
|
||||
import {
|
||||
BACKGROUND_COLOR,
|
||||
PATH_COLOR,
|
||||
SCALE_FACTOR,
|
||||
UP_SCALE,
|
||||
} from "../Constants";
|
||||
import { BACKGROUND_COLOR, SCALE_FACTOR, UP_SCALE } from "../Constants";
|
||||
import { languageStore } from "@shared";
|
||||
import { SightData } from "../types";
|
||||
import { isMediaIdEmpty } from "../../../../shared/lib/index";
|
||||
|
||||
const SIGHT_ICON_URL = "/sight_icon.svg";
|
||||
|
||||
const buttons = [
|
||||
{ label: <AlignLeft size={16} />, value: 1, align: "left" },
|
||||
{
|
||||
label: <AlignCenter size={16} />,
|
||||
value: 2,
|
||||
align: "center",
|
||||
},
|
||||
{ label: <AlignRight size={16} />, value: 3, align: "right" },
|
||||
];
|
||||
|
||||
type Vec2 = { x: number; y: number };
|
||||
|
||||
type Transform = {
|
||||
@@ -358,8 +365,24 @@ const computeViewTransform = (
|
||||
return { scale, translation };
|
||||
};
|
||||
|
||||
const getAnchorFromOffset = (align: number): { x: number; y: number } => {
|
||||
let anchorX: number;
|
||||
if (align === 1) {
|
||||
anchorX = 0;
|
||||
} else if (align === 3) {
|
||||
anchorX = 1;
|
||||
} else {
|
||||
anchorX = 0.5;
|
||||
}
|
||||
|
||||
const anchorY = 0.5;
|
||||
|
||||
return { x: anchorX, y: anchorY };
|
||||
};
|
||||
|
||||
const backgroundColor = toColor(BACKGROUND_COLOR);
|
||||
const pathColor = toColor(PATH_COLOR);
|
||||
|
||||
const pathColor = toColor(0xed1c24);
|
||||
|
||||
export const WebGLRouteMapPrototype = observer(() => {
|
||||
const {
|
||||
@@ -369,6 +392,7 @@ export const WebGLRouteMapPrototype = observer(() => {
|
||||
sightData,
|
||||
setSelectedSight,
|
||||
setStationOffset,
|
||||
setStationAlign,
|
||||
setSightCoordinates,
|
||||
setMapCenter,
|
||||
} = useMapData();
|
||||
@@ -387,6 +411,7 @@ export const WebGLRouteMapPrototype = observer(() => {
|
||||
const transformRef = useRef<Transform | null>(null);
|
||||
const lastTransformRef = useRef<Transform | null>(null);
|
||||
const [transformState, setTransformState] = useState<Transform | null>(null);
|
||||
|
||||
const clampTransformScale = useCallback((transform: Transform): Transform => {
|
||||
const { min, max } = scaleLimitsRef.current;
|
||||
const clampedScale = clamp(transform.scale, min, max);
|
||||
@@ -414,6 +439,7 @@ export const WebGLRouteMapPrototype = observer(() => {
|
||||
y: centerY - worldCenterY * clampedScale,
|
||||
},
|
||||
};
|
||||
|
||||
lastTransformRef.current = adjusted;
|
||||
return adjusted;
|
||||
}, []);
|
||||
@@ -439,6 +465,11 @@ export const WebGLRouteMapPrototype = observer(() => {
|
||||
const [liveSightPositions, setLiveSightPositions] = useState<
|
||||
Map<number, SightLivePosition>
|
||||
>(new Map());
|
||||
type StationAlignment = "left" | "center" | "right";
|
||||
const [stationAlignments, setStationAlignments] = useState<
|
||||
Map<number, StationAlignment>
|
||||
>(new Map());
|
||||
const [hoveredStationId, setHoveredStationId] = useState<number | null>(null);
|
||||
const lastCenterRef = useRef<{
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
@@ -531,9 +562,12 @@ export const WebGLRouteMapPrototype = observer(() => {
|
||||
return;
|
||||
}
|
||||
|
||||
const roundedLat = Math.round(latitude * 1e6) / 1e6;
|
||||
const roundedLon = Math.round(longitude * 1e6) / 1e6;
|
||||
|
||||
lastCenterRef.current = {
|
||||
latitude: Math.round(latitude * 1e6) / 1e6,
|
||||
longitude: Math.round(longitude * 1e6) / 1e6,
|
||||
latitude: roundedLat,
|
||||
longitude: roundedLon,
|
||||
};
|
||||
},
|
||||
[rotationAngle]
|
||||
@@ -585,6 +619,45 @@ export const WebGLRouteMapPrototype = observer(() => {
|
||||
}, 120);
|
||||
}, [cancelScheduledCenterCommit, commitCenter]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hoveredStationId == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
const key = event.key;
|
||||
let nextAlignment: StationAlignment | null = null;
|
||||
let alignNumber: number | null = null;
|
||||
|
||||
if (key === "1") {
|
||||
nextAlignment = "left";
|
||||
alignNumber = 1;
|
||||
} else if (key === "2") {
|
||||
nextAlignment = "center";
|
||||
alignNumber = 2;
|
||||
} else if (key === "3") {
|
||||
nextAlignment = "right";
|
||||
alignNumber = 3;
|
||||
}
|
||||
|
||||
if (!nextAlignment || alignNumber === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
setStationAlignments((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(hoveredStationId, nextAlignment as StationAlignment);
|
||||
return next;
|
||||
});
|
||||
setStationAlign(hoveredStationId, alignNumber);
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [hoveredStationId, setStationAlignments, setStationAlign]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cancelScheduledCenterCommit();
|
||||
@@ -592,11 +665,22 @@ export const WebGLRouteMapPrototype = observer(() => {
|
||||
}, [cancelScheduledCenterCommit]);
|
||||
|
||||
const updateTransform = useCallback(
|
||||
(next: Transform) => {
|
||||
const adjusted = clampTransformScale(next);
|
||||
(
|
||||
next: Transform,
|
||||
options?: { immediate?: boolean; skipClamp?: boolean }
|
||||
) => {
|
||||
const adjusted = options?.skipClamp ? next : clampTransformScale(next);
|
||||
|
||||
transformRef.current = adjusted;
|
||||
setTransformState(adjusted);
|
||||
setSharedScale(adjusted.scale);
|
||||
if (options?.immediate) {
|
||||
flushSync(() => {
|
||||
setTransformState(adjusted);
|
||||
setSharedScale(adjusted.scale);
|
||||
});
|
||||
} else {
|
||||
setTransformState(adjusted);
|
||||
setSharedScale(adjusted.scale);
|
||||
}
|
||||
computeCenterCoordinates(adjusted);
|
||||
},
|
||||
[clampTransformScale, setSharedScale, computeCenterCoordinates]
|
||||
@@ -636,18 +720,21 @@ export const WebGLRouteMapPrototype = observer(() => {
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
const world = getWorldPosition(
|
||||
event.clientX,
|
||||
event.clientY,
|
||||
state.camera
|
||||
);
|
||||
if (!world) return;
|
||||
const stationScreenX =
|
||||
state.rotatedBase.x * state.camera.scale + state.camera.translation.x;
|
||||
const stationScreenY =
|
||||
state.rotatedBase.y * state.camera.scale + state.camera.translation.y;
|
||||
|
||||
const adjustedWorldX = world.x - state.pointerDelta.x;
|
||||
const adjustedWorldY = world.y - state.pointerDelta.y;
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const scaleX = canvas.width / Math.max(rect.width, 1);
|
||||
const scaleY = canvas.height / Math.max(rect.height, 1);
|
||||
const pointerScreenX = (event.clientX - rect.left) * scaleX;
|
||||
const pointerScreenY = (event.clientY - rect.top) * scaleY;
|
||||
|
||||
const newOffsetX = adjustedWorldX - state.rotatedBase.x;
|
||||
const newOffsetY = adjustedWorldY - state.rotatedBase.y;
|
||||
const newOffsetX = pointerScreenX - stationScreenX - state.pointerDelta.x;
|
||||
const newOffsetY = pointerScreenY - stationScreenY - state.pointerDelta.y;
|
||||
|
||||
state.lastOffset = { x: newOffsetX, y: newOffsetY };
|
||||
setLiveStationOffsets((prev) => {
|
||||
@@ -714,19 +801,25 @@ export const WebGLRouteMapPrototype = observer(() => {
|
||||
|
||||
suppressAutoFitRef.current = true;
|
||||
|
||||
const pointerWorld = getWorldPosition(
|
||||
event.clientX,
|
||||
event.clientY,
|
||||
camera
|
||||
);
|
||||
const labelWorldX = rotatedBase.x + currentOffset.x;
|
||||
const labelWorldY = rotatedBase.y + currentOffset.y;
|
||||
const pointerDelta = pointerWorld
|
||||
? {
|
||||
x: pointerWorld.x - labelWorldX,
|
||||
y: pointerWorld.y - labelWorldY,
|
||||
}
|
||||
: { x: 0, y: 0 };
|
||||
const stationScreenX =
|
||||
rotatedBase.x * camera.scale + camera.translation.x;
|
||||
const stationScreenY =
|
||||
rotatedBase.y * camera.scale + camera.translation.y;
|
||||
const labelScreenX = stationScreenX + currentOffset.x;
|
||||
const labelScreenY = stationScreenY + currentOffset.y;
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const scaleX = canvas.width / Math.max(rect.width, 1);
|
||||
const scaleY = canvas.height / Math.max(rect.height, 1);
|
||||
const pointerScreenX = (event.clientX - rect.left) * scaleX;
|
||||
const pointerScreenY = (event.clientY - rect.top) * scaleY;
|
||||
|
||||
const pointerDelta = {
|
||||
x: pointerScreenX - labelScreenX,
|
||||
y: pointerScreenY - labelScreenY,
|
||||
};
|
||||
|
||||
const captureTarget = event.currentTarget;
|
||||
if (captureTarget.setPointerCapture) {
|
||||
@@ -954,7 +1047,6 @@ export const WebGLRouteMapPrototype = observer(() => {
|
||||
canvas.getContext("webgl") || canvas.getContext("experimental-webgl");
|
||||
|
||||
if (!(gl instanceof WebGLRenderingContext)) {
|
||||
console.error("WebGL is not supported in this browser");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -978,7 +1070,7 @@ export const WebGLRouteMapPrototype = observer(() => {
|
||||
lineBufferRef.current = gl.createBuffer();
|
||||
pointBufferRef.current = gl.createBuffer();
|
||||
} catch (error) {
|
||||
console.error("Failed to initialize WebGL", error);
|
||||
// console.error("Failed to initialize WebGL", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -1073,6 +1165,10 @@ export const WebGLRouteMapPrototype = observer(() => {
|
||||
|
||||
let transform = transformRef.current;
|
||||
if (!transform || !Number.isFinite(transform.scale)) {
|
||||
if (canvasSize.width === 0 || canvasSize.height === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
transform = computeViewTransform(
|
||||
fallbackVertices,
|
||||
canvas.width,
|
||||
@@ -1117,9 +1213,25 @@ export const WebGLRouteMapPrototype = observer(() => {
|
||||
latitude: centerLat as number,
|
||||
longitude: centerLon as number,
|
||||
};
|
||||
const clamped = clampTransformScale(transform);
|
||||
if (clamped.scale !== transform.scale) {
|
||||
const clampedScale = clamped.scale;
|
||||
transform = {
|
||||
scale: clampedScale,
|
||||
translation: {
|
||||
x: canvas.width / 2 - rotatedX * clampedScale,
|
||||
y: canvas.height / 2 - rotatedY * clampedScale,
|
||||
},
|
||||
};
|
||||
|
||||
updateTransform(transform, { skipClamp: true });
|
||||
} else {
|
||||
updateTransform(clamped, { skipClamp: true });
|
||||
}
|
||||
} else {
|
||||
transform = clampTransformScale(transform);
|
||||
updateTransform(transform);
|
||||
}
|
||||
transform = clampTransformScale(transform);
|
||||
updateTransform(transform);
|
||||
} else {
|
||||
const clamped = clampTransformScale(transform);
|
||||
if (clamped !== transform) {
|
||||
@@ -1129,13 +1241,16 @@ export const WebGLRouteMapPrototype = observer(() => {
|
||||
}
|
||||
|
||||
const { scale, translation } = transform;
|
||||
const pointOuterSizePx = clamp(scale * 13.3333, 6, 120);
|
||||
|
||||
const desiredRouteWidthCss = 7;
|
||||
const desiredStationDiameterCss = 12;
|
||||
const pointOuterSizePx = desiredStationDiameterCss * dpr;
|
||||
const pointInnerSizePx = pointOuterSizePx * 0.8;
|
||||
|
||||
if (rotatedRouteVertices.length >= 4) {
|
||||
gl.useProgram(lineProgram.program);
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, lineBuffer);
|
||||
const lineWidth = pointInnerSizePx / scale;
|
||||
const lineWidth = (desiredRouteWidthCss * dpr) / scale;
|
||||
const thickVertices = generateThickLineGeometry(
|
||||
rotatedRouteVertices,
|
||||
lineWidth
|
||||
@@ -1330,13 +1445,78 @@ export const WebGLRouteMapPrototype = observer(() => {
|
||||
skipNextAutoFitRef.current = false;
|
||||
return;
|
||||
}
|
||||
resetTransform();
|
||||
|
||||
const currentTransform = transformRef.current ?? lastTransformRef.current;
|
||||
if (!currentTransform) {
|
||||
resetTransform();
|
||||
return;
|
||||
}
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas || canvas.width === 0 || canvas.height === 0) {
|
||||
resetTransform();
|
||||
return;
|
||||
}
|
||||
|
||||
const preservedScale = currentTransform.scale;
|
||||
|
||||
const centerX = canvas.width / 2;
|
||||
const centerY = canvas.height / 2;
|
||||
|
||||
const worldCenterX =
|
||||
(centerX - currentTransform.translation.x) / preservedScale;
|
||||
const worldCenterY =
|
||||
(centerY - currentTransform.translation.y) / preservedScale;
|
||||
|
||||
const centerLat =
|
||||
routeData?.center_latitude ?? originalRouteData?.center_latitude;
|
||||
const centerLon =
|
||||
routeData?.center_longitude ?? originalRouteData?.center_longitude;
|
||||
|
||||
if (Number.isFinite(centerLat) && Number.isFinite(centerLon)) {
|
||||
const local = coordinatesToLocal(
|
||||
centerLat as number,
|
||||
centerLon as number
|
||||
);
|
||||
const baseX = local.x * UP_SCALE;
|
||||
const baseY = local.y * UP_SCALE;
|
||||
const cos = Math.cos(rotationAngle);
|
||||
const sin = Math.sin(rotationAngle);
|
||||
const rotatedX = baseX * cos - baseY * sin;
|
||||
const rotatedY = baseX * sin + baseY * cos;
|
||||
|
||||
const updatedTransform: Transform = {
|
||||
scale: preservedScale,
|
||||
translation: {
|
||||
x: centerX - rotatedX * preservedScale,
|
||||
y: centerY - rotatedY * preservedScale,
|
||||
},
|
||||
};
|
||||
|
||||
transformRef.current = updatedTransform;
|
||||
lastTransformRef.current = updatedTransform;
|
||||
setTransformState(updatedTransform);
|
||||
drawSceneRef.current();
|
||||
} else {
|
||||
const updatedTransform: Transform = {
|
||||
scale: preservedScale,
|
||||
translation: {
|
||||
x: centerX - worldCenterX * preservedScale,
|
||||
y: centerY - worldCenterY * preservedScale,
|
||||
},
|
||||
};
|
||||
|
||||
transformRef.current = updatedTransform;
|
||||
lastTransformRef.current = updatedTransform;
|
||||
setTransformState(updatedTransform);
|
||||
drawSceneRef.current();
|
||||
}
|
||||
}, [
|
||||
routeVertices,
|
||||
stationVertices,
|
||||
canvasSize.width,
|
||||
canvasSize.height,
|
||||
rotationAngle,
|
||||
routeData?.center_latitude,
|
||||
routeData?.center_longitude,
|
||||
originalRouteData?.center_latitude,
|
||||
originalRouteData?.center_longitude,
|
||||
resetTransform,
|
||||
]);
|
||||
|
||||
@@ -1494,7 +1674,7 @@ export const WebGLRouteMapPrototype = observer(() => {
|
||||
y: transform.translation.y + dy,
|
||||
},
|
||||
};
|
||||
updateTransform(next);
|
||||
updateTransform(next, { immediate: true });
|
||||
drawSceneRef.current();
|
||||
};
|
||||
|
||||
@@ -1570,13 +1750,16 @@ export const WebGLRouteMapPrototype = observer(() => {
|
||||
scaleLimitsRef.current.max
|
||||
);
|
||||
|
||||
updateTransform({
|
||||
scale: clampedScale,
|
||||
translation: {
|
||||
x: midpoint.x - worldMidpoint.x * clampedScale,
|
||||
y: midpoint.y - worldMidpoint.y * clampedScale,
|
||||
updateTransform(
|
||||
{
|
||||
scale: clampedScale,
|
||||
translation: {
|
||||
x: midpoint.x - worldMidpoint.x * clampedScale,
|
||||
y: midpoint.y - worldMidpoint.y * clampedScale,
|
||||
},
|
||||
},
|
||||
});
|
||||
{ immediate: true }
|
||||
);
|
||||
drawSceneRef.current();
|
||||
}
|
||||
};
|
||||
@@ -1627,13 +1810,16 @@ export const WebGLRouteMapPrototype = observer(() => {
|
||||
y: (position.y - transform.translation.y) / transform.scale,
|
||||
};
|
||||
|
||||
updateTransform({
|
||||
scale: clampedScale,
|
||||
translation: {
|
||||
x: position.x - worldPoint.x * clampedScale,
|
||||
y: position.y - worldPoint.y * clampedScale,
|
||||
updateTransform(
|
||||
{
|
||||
scale: clampedScale,
|
||||
translation: {
|
||||
x: position.x - worldPoint.x * clampedScale,
|
||||
y: position.y - worldPoint.y * clampedScale,
|
||||
},
|
||||
},
|
||||
});
|
||||
{ immediate: true }
|
||||
);
|
||||
drawSceneRef.current();
|
||||
scheduleCenterCommit();
|
||||
};
|
||||
@@ -1722,12 +1908,23 @@ export const WebGLRouteMapPrototype = observer(() => {
|
||||
? liveStationOffset.y
|
||||
: baseOffsetY;
|
||||
|
||||
const labelX =
|
||||
(rotatedX + offsetX) * camera.scale + camera.translation.x;
|
||||
const labelY =
|
||||
(rotatedY + offsetY) * camera.scale + camera.translation.y;
|
||||
const stationScreenX =
|
||||
rotatedX * camera.scale + camera.translation.x;
|
||||
const stationScreenY =
|
||||
rotatedY * camera.scale + camera.translation.y;
|
||||
|
||||
const labelX = stationScreenX + offsetX;
|
||||
const labelY = stationScreenY + offsetY;
|
||||
|
||||
const backendAlign = station.align;
|
||||
|
||||
const anchor = getAnchorFromOffset(backendAlign ?? 2);
|
||||
const transformCss = `translate(${-anchor.x * 100}%, ${
|
||||
-anchor.y * 100
|
||||
}%)`;
|
||||
|
||||
const dpr = Math.max(1, window.devicePixelRatio || 1);
|
||||
|
||||
const cssX = labelX / dpr;
|
||||
const cssY = labelY / dpr;
|
||||
const rotationCss = `${rotationAngle}rad`;
|
||||
@@ -1742,77 +1939,207 @@ export const WebGLRouteMapPrototype = observer(() => {
|
||||
const fontSizePercent =
|
||||
routeData?.font_size ?? originalRouteData?.font_size ?? 100;
|
||||
const fontScale = fontSizePercent / 100;
|
||||
|
||||
const primaryFontSize = 16 * fontScale;
|
||||
const secondaryFontSize = 13 * fontScale;
|
||||
|
||||
const secondaryMarginTop = 5 * fontScale;
|
||||
|
||||
const alignmentFromData: StationAlignment =
|
||||
backendAlign === 1
|
||||
? "left"
|
||||
: backendAlign === 3
|
||||
? "right"
|
||||
: "center";
|
||||
const alignment: StationAlignment =
|
||||
stationAlignments.get(station.id) ?? alignmentFromData;
|
||||
|
||||
const secondaryPositionStyle: CSSProperties =
|
||||
alignment === "left"
|
||||
? { left: 0, transform: "none" }
|
||||
: alignment === "right"
|
||||
? { right: 0, transform: "none" }
|
||||
: { left: "50%", transform: "translateX(-50%)" };
|
||||
|
||||
let isMediaIdEmptyResult = isMediaIdEmpty(station.icon);
|
||||
const iconUrl = isMediaIdEmptyResult
|
||||
? null
|
||||
: `${import.meta.env.VITE_KRBL_MEDIA}${
|
||||
station.icon
|
||||
}/download?token=${localStorage.getItem("token") ?? ""}`;
|
||||
|
||||
const iconSizePx = Math.round(primaryFontSize * 1.2);
|
||||
|
||||
const secondaryLineHeight = 1.2;
|
||||
const secondaryHeight = showSecondary
|
||||
? secondaryFontSize * secondaryLineHeight
|
||||
: 0;
|
||||
const menuPaddingTop = showSecondary
|
||||
? Math.max(0, secondaryHeight - secondaryMarginTop) + 3
|
||||
: 3;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={station.id}
|
||||
onPointerDown={(event) =>
|
||||
handleStationPointerDown(
|
||||
event,
|
||||
station.id,
|
||||
{
|
||||
x: rotatedX,
|
||||
y: rotatedY,
|
||||
},
|
||||
{ x: offsetX, y: offsetY }
|
||||
)
|
||||
}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: cssX,
|
||||
top: cssY,
|
||||
transform: "translate(0, -50%)",
|
||||
color: "#fff",
|
||||
fontFamily: "Roboto, sans-serif",
|
||||
textAlign: "left",
|
||||
pointerEvents: "auto",
|
||||
cursor: "grab",
|
||||
userSelect: "none",
|
||||
touchAction: "none",
|
||||
}}
|
||||
>
|
||||
<div key={station.id}>
|
||||
<div
|
||||
onMouseEnter={() => setHoveredStationId(station.id)}
|
||||
onMouseLeave={() =>
|
||||
setHoveredStationId((prev) =>
|
||||
prev === station.id ? null : prev
|
||||
)
|
||||
}
|
||||
onPointerDown={(event) =>
|
||||
handleStationPointerDown(
|
||||
event,
|
||||
station.id,
|
||||
{
|
||||
x: rotatedX,
|
||||
y: rotatedY,
|
||||
},
|
||||
{ x: offsetX, y: offsetY }
|
||||
)
|
||||
}
|
||||
style={{
|
||||
pointerEvents: "none",
|
||||
transformOrigin: "left center",
|
||||
transform: `rotate(${rotationCss})`,
|
||||
position: "absolute",
|
||||
left: cssX,
|
||||
top: cssY,
|
||||
transform: transformCss,
|
||||
color: "#fff",
|
||||
fontFamily: "Roboto, sans-serif",
|
||||
textAlign: "left",
|
||||
pointerEvents: "auto",
|
||||
cursor: "grab",
|
||||
userSelect: "none",
|
||||
touchAction: "none",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
pointerEvents: "none",
|
||||
transformOrigin: "left center",
|
||||
transform: `rotate(${counterRotationCss})`,
|
||||
transform: `rotate(${rotationCss})`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: 700,
|
||||
fontSize: primaryFontSize,
|
||||
textShadow: "0 0 4px rgba(0,0,0,0.6)",
|
||||
pointerEvents: "none",
|
||||
transformOrigin: "left center",
|
||||
transform: `rotate(${counterRotationCss})`,
|
||||
}}
|
||||
>
|
||||
{station.name}
|
||||
</div>
|
||||
{showSecondary ? (
|
||||
<div
|
||||
style={{
|
||||
fontWeight: 400,
|
||||
marginTop: -1 * secondaryMarginTop,
|
||||
fontSize: secondaryFontSize,
|
||||
color: "#CBCBCB",
|
||||
textShadow: "0 0 3px rgba(0,0,0,0.4)",
|
||||
position: "relative",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: iconUrl ? 6 : 0,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
{translatedStation?.name}
|
||||
{iconUrl ? (
|
||||
<img
|
||||
src={iconUrl}
|
||||
alt=""
|
||||
style={{
|
||||
width: iconSizePx,
|
||||
height: iconSizePx,
|
||||
flexShrink: 0,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<div
|
||||
style={{
|
||||
fontWeight: 700,
|
||||
fontSize: primaryFontSize,
|
||||
textShadow: "0 0 4px rgba(0,0,0,0.6)",
|
||||
pointerEvents: "none",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{station.name}
|
||||
</div>
|
||||
{showSecondary ? (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "100%",
|
||||
marginTop: -1 * secondaryMarginTop,
|
||||
fontWeight: 400,
|
||||
fontSize: secondaryFontSize,
|
||||
lineHeight: secondaryLineHeight,
|
||||
color: "#CBCBCB",
|
||||
textShadow: "0 0 3px rgba(0,0,0,0.4)",
|
||||
whiteSpace: "nowrap",
|
||||
...secondaryPositionStyle,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
{translatedStation?.name}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{hoveredStationId === station.id && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "100%",
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
paddingTop: menuPaddingTop,
|
||||
pointerEvents: "auto",
|
||||
zIndex: 10,
|
||||
cursor: "default",
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 4,
|
||||
padding: 4,
|
||||
borderRadius: 4,
|
||||
backgroundColor: "white",
|
||||
boxShadow: "0 2px 8px rgba(0,0,0,0.15)",
|
||||
}}
|
||||
>
|
||||
{buttons.map((btn) => (
|
||||
<div
|
||||
key={btn.value}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setStationAlignments((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(
|
||||
station.id,
|
||||
btn.align as StationAlignment
|
||||
);
|
||||
return next;
|
||||
});
|
||||
setStationAlign(station.id, btn.value);
|
||||
}}
|
||||
style={{
|
||||
padding: "4px 8px",
|
||||
fontSize: 12,
|
||||
cursor: "pointer",
|
||||
backgroundColor:
|
||||
alignment === btn.align
|
||||
? "#e0e0e0"
|
||||
: "transparent",
|
||||
borderRadius: 4,
|
||||
whiteSpace: "nowrap",
|
||||
color: "black",
|
||||
fontWeight: 500,
|
||||
userSelect: "none",
|
||||
}}
|
||||
>
|
||||
{btn.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -118,6 +118,10 @@ const LinkedStationsContentsInner = <
|
||||
const parentResource = "sight";
|
||||
const childResource = "station";
|
||||
|
||||
const buildPayload = (ids: number[]) => ({
|
||||
[`${childResource}_ids`]: ids,
|
||||
});
|
||||
|
||||
const availableItems = allItems
|
||||
.filter((item) => !linkedItems.some((linked) => linked.id === item.id))
|
||||
.filter((item) => {
|
||||
@@ -131,7 +135,10 @@ const LinkedStationsContentsInner = <
|
||||
|
||||
const filteredAvailableItems = availableItems.filter((item) => {
|
||||
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(() => {
|
||||
@@ -159,9 +166,7 @@ const LinkedStationsContentsInner = <
|
||||
const linkItem = () => {
|
||||
if (selectedItemId !== null) {
|
||||
setError(null);
|
||||
const requestData = {
|
||||
station_id: selectedItemId,
|
||||
};
|
||||
const requestData = buildPayload([selectedItemId]);
|
||||
|
||||
setIsLinkingSingle(true);
|
||||
authInstance
|
||||
@@ -193,7 +198,7 @@ const LinkedStationsContentsInner = <
|
||||
});
|
||||
authInstance
|
||||
.delete(`/${parentResource}/${parentId}/${childResource}`, {
|
||||
data: { [`${childResource}_id`]: itemId },
|
||||
data: buildPayload([itemId]),
|
||||
})
|
||||
.then(() => {
|
||||
setLinkedItems(linkedItems.filter((item) => item.id !== itemId));
|
||||
@@ -228,46 +233,28 @@ const LinkedStationsContentsInner = <
|
||||
|
||||
setIsLinkingBulk(true);
|
||||
const idsToLink = Array.from(selectedItems);
|
||||
const linkedIds: number[] = [];
|
||||
const failedIds: number[] = [];
|
||||
|
||||
for (const id of idsToLink) {
|
||||
try {
|
||||
await authInstance.post(`/${parentResource}/${parentId}/${childResource}`, {
|
||||
station_id: id,
|
||||
});
|
||||
linkedIds.push(id);
|
||||
} catch (error) {
|
||||
console.error("Error linking station:", error);
|
||||
failedIds.push(id);
|
||||
}
|
||||
}
|
||||
try {
|
||||
await authInstance.post(
|
||||
`/${parentResource}/${parentId}/${childResource}`,
|
||||
buildPayload(idsToLink)
|
||||
);
|
||||
|
||||
if (linkedIds.length > 0) {
|
||||
const newItems = allItems.filter((item) => linkedIds.includes(item.id));
|
||||
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?.();
|
||||
}
|
||||
|
||||
setSelectedItems((prev) => {
|
||||
if (linkedIds.length === 0) {
|
||||
return prev;
|
||||
}
|
||||
const remaining = new Set(prev);
|
||||
linkedIds.forEach((id) => remaining.delete(id));
|
||||
return failedIds.length > 0 ? remaining : new Set();
|
||||
});
|
||||
|
||||
if (failedIds.length > 0) {
|
||||
setError(
|
||||
failedIds.length === idsToLink.length
|
||||
? "Failed to link stations"
|
||||
: "Some stations failed to link"
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error linking stations:", error);
|
||||
setError("Failed to link stations");
|
||||
}
|
||||
|
||||
setIsLinkingBulk(false);
|
||||
@@ -303,39 +290,26 @@ const LinkedStationsContentsInner = <
|
||||
return next;
|
||||
});
|
||||
|
||||
const detachedIds: number[] = [];
|
||||
const failedIds: number[] = [];
|
||||
try {
|
||||
await authInstance.delete(
|
||||
`/${parentResource}/${parentId}/${childResource}`,
|
||||
{
|
||||
data: buildPayload(idsToDetach),
|
||||
}
|
||||
);
|
||||
|
||||
for (const itemId of idsToDetach) {
|
||||
try {
|
||||
await authInstance.delete(`/${parentResource}/${parentId}/${childResource}`, {
|
||||
data: { [`${childResource}_id`]: itemId },
|
||||
});
|
||||
detachedIds.push(itemId);
|
||||
} catch (error) {
|
||||
console.error("Error deleting station:", error);
|
||||
failedIds.push(itemId);
|
||||
}
|
||||
}
|
||||
|
||||
if (detachedIds.length > 0) {
|
||||
setLinkedItems((prev) =>
|
||||
prev.filter((item) => !detachedIds.includes(item.id))
|
||||
prev.filter((item) => !idsToDetach.includes(item.id))
|
||||
);
|
||||
setSelectedToDetach((prev) => {
|
||||
const remaining = new Set(prev);
|
||||
detachedIds.forEach((id) => remaining.delete(id));
|
||||
return failedIds.length > 0 ? remaining : new Set();
|
||||
idsToDetach.forEach((id) => remaining.delete(id));
|
||||
return remaining;
|
||||
});
|
||||
onUpdate?.();
|
||||
}
|
||||
|
||||
if (failedIds.length > 0) {
|
||||
setError(
|
||||
failedIds.length === idsToDetach.length
|
||||
? "Failed to delete stations"
|
||||
: "Some stations failed to delete"
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error deleting stations:", error);
|
||||
setError("Failed to delete stations");
|
||||
}
|
||||
|
||||
setDetachingIds((prev) => {
|
||||
@@ -499,8 +473,9 @@ const LinkedStationsContentsInner = <
|
||||
<Autocomplete
|
||||
fullWidth
|
||||
value={
|
||||
availableItems?.find((item) => item.id === selectedItemId) ||
|
||||
null
|
||||
availableItems?.find(
|
||||
(item) => item.id === selectedItemId
|
||||
) || null
|
||||
}
|
||||
onChange={(_, newValue) =>
|
||||
setSelectedItemId(newValue?.id || null)
|
||||
@@ -508,28 +483,37 @@ const LinkedStationsContentsInner = <
|
||||
options={availableItems}
|
||||
getOptionLabel={(item) => String(item.name)}
|
||||
renderInput={(params) => (
|
||||
<TextField {...params} label="Выберите остановку" fullWidth />
|
||||
<TextField
|
||||
{...params}
|
||||
label="Выберите остановку"
|
||||
placeholder="Введите название или описание остановки..."
|
||||
fullWidth
|
||||
/>
|
||||
)}
|
||||
isOptionEqualToValue={(option, value) =>
|
||||
option.id === value?.id
|
||||
}
|
||||
filterOptions={(options, { inputValue }) => {
|
||||
const searchWords = inputValue
|
||||
.toLowerCase()
|
||||
.split(" ")
|
||||
.filter(Boolean);
|
||||
if (!inputValue.trim()) return options;
|
||||
const query = inputValue.toLowerCase();
|
||||
return options.filter((option) => {
|
||||
const optionWords = String(option.name)
|
||||
.toLowerCase()
|
||||
.split(" ");
|
||||
return searchWords.every((searchWord) =>
|
||||
optionWords.some((word) => word.startsWith(searchWord))
|
||||
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}>
|
||||
{String(option.name)}
|
||||
<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>
|
||||
)}
|
||||
/>
|
||||
@@ -553,7 +537,7 @@ const LinkedStationsContentsInner = <
|
||||
label="Поиск остановок"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Введите название остановки..."
|
||||
placeholder="Введите название или описание остановки..."
|
||||
size="small"
|
||||
/>
|
||||
|
||||
@@ -569,11 +553,19 @@ const LinkedStationsContentsInner = <
|
||||
size="small"
|
||||
/>
|
||||
}
|
||||
label={String(item.name)}
|
||||
label={
|
||||
<div className="flex justify-between items-center w-full gap-10">
|
||||
<p>{String(item.name)}</p>
|
||||
<p className="text-xs text-gray-500 max-w-[300px] truncate text-ellipsis">
|
||||
{String(item.description)}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
sx={{
|
||||
margin: 0,
|
||||
"& .MuiFormControlLabel-label": {
|
||||
fontSize: "0.9rem",
|
||||
width: "100%",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -22,6 +22,10 @@ export const SightListPage = observer(() => {
|
||||
const [rowId, setRowId] = useState<string | null>(null);
|
||||
const [ids, setIds] = useState<number[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [paginationModel, setPaginationModel] = useState({
|
||||
page: 0,
|
||||
pageSize: 50,
|
||||
});
|
||||
const { language } = languageStore;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -98,7 +102,6 @@ export const SightListPage = observer(() => {
|
||||
},
|
||||
];
|
||||
|
||||
// Фильтрация достопримечательностей по выбранному городу
|
||||
const filteredSights = useMemo(() => {
|
||||
const { selectedCityId } = selectedCityStore;
|
||||
if (!selectedCityId) {
|
||||
@@ -126,28 +129,39 @@ export const SightListPage = observer(() => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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"
|
||||
onClick={() => setIsBulkDeleteModalOpen(true)}
|
||||
>
|
||||
<Trash2 size={20} className="text-white" /> Удалить выбранные (
|
||||
{ids.length})
|
||||
</button>
|
||||
</div>
|
||||
{ids.length > 0 && (
|
||||
<div className="flex justify-end mb-5 duration-300">
|
||||
<button
|
||||
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})
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DataGrid
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
hideFooter
|
||||
checkboxSelection
|
||||
disableRowSelectionExcludeModel
|
||||
loading={isLoading}
|
||||
paginationModel={paginationModel}
|
||||
onPaginationModelChange={setPaginationModel}
|
||||
pageSizeOptions={[50]}
|
||||
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
|
||||
onRowSelectionModelChange={(newSelection) => {
|
||||
setIds(Array.from(newSelection.ids as unknown as number[]));
|
||||
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([]);
|
||||
}
|
||||
}}
|
||||
slots={{
|
||||
noRowsOverlay: () => (
|
||||
|
||||
@@ -5,9 +5,10 @@ import { ArrowLeft, Loader2, Save } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "react-toastify";
|
||||
import { runInAction } from "mobx";
|
||||
|
||||
export const SnapshotCreatePage = observer(() => {
|
||||
const { createSnapshot } = snapshotStore;
|
||||
const { createSnapshot, getSnapshotStatus, snapshotStatus } = snapshotStore;
|
||||
const navigate = useNavigate();
|
||||
const [name, setName] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -24,7 +25,7 @@ export const SnapshotCreatePage = observer(() => {
|
||||
Назад
|
||||
</button>
|
||||
</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">
|
||||
<TextField
|
||||
className="w-full"
|
||||
@@ -42,13 +43,27 @@ export const SnapshotCreatePage = observer(() => {
|
||||
onClick={async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await createSnapshot(name);
|
||||
setIsLoading(false);
|
||||
toast.success("Снапшот успешно создан");
|
||||
navigate(-1);
|
||||
const id = await createSnapshot(name);
|
||||
|
||||
await getSnapshotStatus(id);
|
||||
|
||||
while (snapshotStore.snapshotStatus?.Status != "done") {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
await getSnapshotStatus(id);
|
||||
}
|
||||
|
||||
if (snapshotStore.snapshotStatus?.Status === "done") {
|
||||
toast.success("Экспорт медиа успешно создан");
|
||||
|
||||
runInAction(() => {
|
||||
snapshotStore.snapshotStatus = null;
|
||||
});
|
||||
|
||||
navigate(-1);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error("Ошибка при создании снапшота");
|
||||
toast.error("Ошибка при создании экспорта медиа");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -56,7 +71,15 @@ export const SnapshotCreatePage = observer(() => {
|
||||
disabled={isLoading || !name.trim()}
|
||||
>
|
||||
{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>
|
||||
) : (
|
||||
"Сохранить"
|
||||
)}
|
||||
|
||||
@@ -12,10 +12,14 @@ export const SnapshotListPage = observer(() => {
|
||||
snapshotStore;
|
||||
|
||||
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 [isRestoreModalOpen, setIsRestoreModalOpen] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [paginationModel, setPaginationModel] = useState({
|
||||
page: 0,
|
||||
pageSize: 50,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSnapshots = async () => {
|
||||
@@ -26,6 +30,14 @@ export const SnapshotListPage = observer(() => {
|
||||
fetchSnapshots();
|
||||
}, [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[] = [
|
||||
{
|
||||
field: "name",
|
||||
@@ -37,7 +49,14 @@ export const SnapshotListPage = observer(() => {
|
||||
headerName: "Родитель",
|
||||
flex: 1,
|
||||
},
|
||||
|
||||
{
|
||||
field: "created_at",
|
||||
headerName: "Дата создания",
|
||||
flex: 1,
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return <div>{params.value ? params.value : "-"}</div>;
|
||||
},
|
||||
},
|
||||
{
|
||||
field: "actions",
|
||||
headerName: "Действия",
|
||||
@@ -75,26 +94,32 @@ export const SnapshotListPage = observer(() => {
|
||||
id: snapshot.ID,
|
||||
name: snapshot.Name,
|
||||
parent: snapshots.find((s) => s.ID === snapshot.ParentID)?.Name,
|
||||
created_at: formatCreationTime(snapshot.CreationTime),
|
||||
}));
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ width: "100%" }}>
|
||||
<div className="flex justify-between items-center mb-10">
|
||||
<h1 className="text-2xl ">Снапшоты</h1>
|
||||
<CreateButton label="Создать снапшот" path="/snapshot/create" />
|
||||
<h1 className="text-2xl ">Экспорт Медиа</h1>
|
||||
<CreateButton label="Создать экспорт медиа" path="/snapshot/create" />
|
||||
</div>
|
||||
<DataGrid
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
hideFooterPagination
|
||||
hideFooter
|
||||
loading={isLoading}
|
||||
paginationModel={paginationModel}
|
||||
onPaginationModelChange={setPaginationModel}
|
||||
pageSizeOptions={[50]}
|
||||
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
|
||||
slots={{
|
||||
noRowsOverlay: () => (
|
||||
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
|
||||
{isLoading ? <CircularProgress size={20} /> : "Нет снапшотов"}
|
||||
{isLoading ? (
|
||||
<CircularProgress size={20} />
|
||||
) : (
|
||||
"Нет экспортов медиа"
|
||||
)}
|
||||
</Box>
|
||||
),
|
||||
}}
|
||||
|
||||
@@ -118,6 +118,10 @@ const LinkedSightsContentsInner = <
|
||||
const parentResource = "station";
|
||||
const childResource = "sight";
|
||||
|
||||
const buildPayload = (ids: number[]) => ({
|
||||
[`${childResource}_ids`]: ids,
|
||||
});
|
||||
|
||||
const availableItems = allItems
|
||||
.filter((item) => !linkedItems.some((linked) => linked.id === item.id))
|
||||
.filter((item) => {
|
||||
@@ -160,9 +164,7 @@ const LinkedSightsContentsInner = <
|
||||
const linkItem = () => {
|
||||
if (selectedItemId !== null) {
|
||||
setError(null);
|
||||
const requestData = {
|
||||
sight_id: selectedItemId,
|
||||
};
|
||||
const requestData = buildPayload([selectedItemId]);
|
||||
|
||||
setIsLinkingSingle(true);
|
||||
authInstance
|
||||
@@ -194,7 +196,7 @@ const LinkedSightsContentsInner = <
|
||||
});
|
||||
authInstance
|
||||
.delete(`/${parentResource}/${parentId}/${childResource}`, {
|
||||
data: { [`${childResource}_id`]: itemId },
|
||||
data: buildPayload([itemId]),
|
||||
})
|
||||
.then(() => {
|
||||
setLinkedItems(linkedItems.filter((item) => item.id !== itemId));
|
||||
@@ -229,49 +231,28 @@ const LinkedSightsContentsInner = <
|
||||
|
||||
setIsLinkingBulk(true);
|
||||
const idsToLink = Array.from(selectedItems);
|
||||
const linkedIds: number[] = [];
|
||||
const failedIds: number[] = [];
|
||||
|
||||
for (const id of idsToLink) {
|
||||
try {
|
||||
await authInstance.post(
|
||||
`/${parentResource}/${parentId}/${childResource}`,
|
||||
{
|
||||
sight_id: id,
|
||||
}
|
||||
);
|
||||
linkedIds.push(id);
|
||||
} catch (error) {
|
||||
console.error("Error linking sight:", error);
|
||||
failedIds.push(id);
|
||||
}
|
||||
}
|
||||
try {
|
||||
await authInstance.post(
|
||||
`/${parentResource}/${parentId}/${childResource}`,
|
||||
buildPayload(idsToLink)
|
||||
);
|
||||
|
||||
if (linkedIds.length > 0) {
|
||||
const newItems = allItems.filter((item) => linkedIds.includes(item.id));
|
||||
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?.();
|
||||
}
|
||||
|
||||
setSelectedItems((prev) => {
|
||||
if (linkedIds.length === 0) {
|
||||
return prev;
|
||||
}
|
||||
const remaining = new Set(prev);
|
||||
linkedIds.forEach((id) => remaining.delete(id));
|
||||
return failedIds.length > 0 ? remaining : new Set();
|
||||
});
|
||||
|
||||
if (failedIds.length > 0) {
|
||||
setError(
|
||||
failedIds.length === idsToLink.length
|
||||
? "Failed to link sights"
|
||||
: "Some sights failed to link"
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error linking sights:", error);
|
||||
setError("Failed to link sights");
|
||||
}
|
||||
|
||||
setIsLinkingBulk(false);
|
||||
@@ -307,42 +288,26 @@ const LinkedSightsContentsInner = <
|
||||
return next;
|
||||
});
|
||||
|
||||
const detachedIds: number[] = [];
|
||||
const failedIds: number[] = [];
|
||||
try {
|
||||
await authInstance.delete(
|
||||
`/${parentResource}/${parentId}/${childResource}`,
|
||||
{
|
||||
data: buildPayload(idsToDetach),
|
||||
}
|
||||
);
|
||||
|
||||
for (const itemId of idsToDetach) {
|
||||
try {
|
||||
await authInstance.delete(
|
||||
`/${parentResource}/${parentId}/${childResource}`,
|
||||
{
|
||||
data: { [`${childResource}_id`]: itemId },
|
||||
}
|
||||
);
|
||||
detachedIds.push(itemId);
|
||||
} catch (error) {
|
||||
console.error("Error deleting sight:", error);
|
||||
failedIds.push(itemId);
|
||||
}
|
||||
}
|
||||
|
||||
if (detachedIds.length > 0) {
|
||||
setLinkedItems((prev) =>
|
||||
prev.filter((item) => !detachedIds.includes(item.id))
|
||||
prev.filter((item) => !idsToDetach.includes(item.id))
|
||||
);
|
||||
setSelectedToDetach((prev) => {
|
||||
const remaining = new Set(prev);
|
||||
detachedIds.forEach((id) => remaining.delete(id));
|
||||
return failedIds.length > 0 ? remaining : new Set();
|
||||
idsToDetach.forEach((id) => remaining.delete(id));
|
||||
return remaining;
|
||||
});
|
||||
onUpdate?.();
|
||||
}
|
||||
|
||||
if (failedIds.length > 0) {
|
||||
setError(
|
||||
failedIds.length === idsToDetach.length
|
||||
? "Failed to delete sights"
|
||||
: "Some sights failed to delete"
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error deleting sights:", error);
|
||||
setError("Failed to delete sights");
|
||||
}
|
||||
|
||||
setDetachingIds((prev) => {
|
||||
|
||||
@@ -1,26 +1,33 @@
|
||||
import {
|
||||
Button,
|
||||
Paper,
|
||||
TextField,
|
||||
Select,
|
||||
MenuItem,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Box,
|
||||
} from "@mui/material";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { ArrowLeft, Save } from "lucide-react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { ArrowLeft, Loader2, Save } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "react-toastify";
|
||||
import {
|
||||
stationsStore,
|
||||
languageStore,
|
||||
cityStore,
|
||||
mediaStore,
|
||||
isMediaIdEmpty,
|
||||
useSelectedCity,
|
||||
SelectMediaDialog,
|
||||
UploadMediaDialog,
|
||||
PreviewMediaDialog,
|
||||
} from "@shared";
|
||||
import { useEffect, useState } from "react";
|
||||
import { LanguageSwitcher } from "@widgets";
|
||||
import { SaveWithoutCityAgree } from "@widgets";
|
||||
import {
|
||||
ImageUploadCard,
|
||||
LanguageSwitcher,
|
||||
SaveWithoutCityAgree,
|
||||
} from "@widgets";
|
||||
|
||||
export const StationCreatePage = observer(() => {
|
||||
const navigate = useNavigate();
|
||||
@@ -35,6 +42,13 @@ export const StationCreatePage = observer(() => {
|
||||
const { cities, getCities } = cityStore;
|
||||
const { selectedCityId, selectedCity } = useSelectedCity();
|
||||
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);
|
||||
|
||||
@@ -96,8 +110,27 @@ export const StationCreatePage = observer(() => {
|
||||
};
|
||||
|
||||
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(() => {
|
||||
if (selectedCityId && selectedCity && !createStationData.common.city_id) {
|
||||
setCreateCommonData({
|
||||
@@ -108,7 +141,7 @@ export const StationCreatePage = observer(() => {
|
||||
}, [selectedCityId, selectedCity, createStationData.common.city_id]);
|
||||
|
||||
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 />
|
||||
<div className="flex items-center gap-4">
|
||||
<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
|
||||
fullWidth
|
||||
label="Описание"
|
||||
@@ -230,6 +246,30 @@ export const StationCreatePage = observer(() => {
|
||||
</Select>
|
||||
</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
|
||||
variant="contained"
|
||||
className="w-min flex gap-2 items-center"
|
||||
@@ -246,6 +286,28 @@ export const StationCreatePage = observer(() => {
|
||||
</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 && (
|
||||
<SaveWithoutCityAgree
|
||||
blocker={{
|
||||
@@ -254,6 +316,6 @@ export const StationCreatePage = observer(() => {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,26 +1,40 @@
|
||||
import {
|
||||
Button,
|
||||
Paper,
|
||||
TextField,
|
||||
Select,
|
||||
MenuItem,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Box,
|
||||
} from "@mui/material";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { ArrowLeft, Save } from "lucide-react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { ArrowLeft, Loader2, Save } from "lucide-react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
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 { LanguageSwitcher } from "@widgets";
|
||||
import {
|
||||
ImageUploadCard,
|
||||
LanguageSwitcher,
|
||||
SaveWithoutCityAgree,
|
||||
DeleteModal,
|
||||
} from "@widgets";
|
||||
import { LinkedSights } from "../LinkedSights";
|
||||
import { SaveWithoutCityAgree } from "@widgets";
|
||||
|
||||
export const StationEditPage = observer(() => {
|
||||
const navigate = useNavigate();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isLoadingData, setIsLoadingData] = useState(true);
|
||||
const { language } = languageStore;
|
||||
const { id } = useParams();
|
||||
const {
|
||||
@@ -32,6 +46,14 @@ export const StationEditPage = observer(() => {
|
||||
} = stationsStore;
|
||||
const { cities, getCities } = cityStore;
|
||||
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);
|
||||
|
||||
@@ -88,22 +110,63 @@ export const StationEditPage = observer(() => {
|
||||
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(() => {
|
||||
const fetchAndSetStationData = async () => {
|
||||
if (!id) return;
|
||||
if (!id) {
|
||||
setIsLoadingData(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const stationId = Number(id);
|
||||
await getEditStation(stationId);
|
||||
await getCities("ru");
|
||||
await getCities("en");
|
||||
await getCities("zh");
|
||||
setIsLoadingData(true);
|
||||
try {
|
||||
const stationId = Number(id);
|
||||
await getEditStation(stationId);
|
||||
await getCities("ru");
|
||||
await getCities("en");
|
||||
await getCities("zh");
|
||||
await mediaStore.getMedia();
|
||||
} finally {
|
||||
setIsLoadingData(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchAndSetStationData();
|
||||
}, [id]);
|
||||
|
||||
if (isLoadingData) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
minHeight: "60vh",
|
||||
}}
|
||||
>
|
||||
<LoadingSpinner message="Загрузка данных станции..." />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
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 />
|
||||
|
||||
<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
|
||||
fullWidth
|
||||
label="Описание"
|
||||
@@ -226,6 +272,29 @@ export const StationEditPage = observer(() => {
|
||||
</Select>
|
||||
</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 && (
|
||||
<LinkedSights
|
||||
parentId={Number(id)}
|
||||
@@ -249,6 +318,38 @@ export const StationEditPage = observer(() => {
|
||||
</Button>
|
||||
</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 && (
|
||||
<SaveWithoutCityAgree
|
||||
@@ -258,6 +359,6 @@ export const StationEditPage = observer(() => {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -8,9 +8,14 @@ import {
|
||||
} from "@shared";
|
||||
import { useEffect, useState } from "react";
|
||||
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 { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets";
|
||||
import {
|
||||
CreateButton,
|
||||
DeleteModal,
|
||||
LanguageSwitcher,
|
||||
EditStationTransfersModal,
|
||||
} from "@widgets";
|
||||
import { Box, CircularProgress } from "@mui/material";
|
||||
|
||||
export const StationListPage = observer(() => {
|
||||
@@ -18,9 +23,17 @@ export const StationListPage = observer(() => {
|
||||
const navigate = useNavigate();
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
|
||||
const [isTransfersModalOpen, setIsTransfersModalOpen] = useState(false);
|
||||
const [rowId, setRowId] = useState<number | null>(null);
|
||||
const [selectedStationId, setSelectedStationId] = useState<number | null>(
|
||||
null
|
||||
);
|
||||
const [ids, setIds] = useState<number[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [paginationModel, setPaginationModel] = useState({
|
||||
page: 0,
|
||||
pageSize: 50,
|
||||
});
|
||||
const { language } = languageStore;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -51,8 +64,8 @@ export const StationListPage = observer(() => {
|
||||
},
|
||||
},
|
||||
{
|
||||
field: "system_name",
|
||||
headerName: "Системное название",
|
||||
field: "description",
|
||||
headerName: "Описание",
|
||||
flex: 1,
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
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",
|
||||
headerName: "Действия",
|
||||
width: 140,
|
||||
width: 200,
|
||||
align: "center",
|
||||
headerAlign: "center",
|
||||
sortable: false,
|
||||
@@ -102,6 +96,15 @@ export const StationListPage = observer(() => {
|
||||
<button onClick={() => navigate(`/station/${params.row.id}`)}>
|
||||
<Eye size={20} className="text-green-500" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedStationId(params.row.id);
|
||||
setIsTransfersModalOpen(true);
|
||||
}}
|
||||
title="Редактировать пересадки"
|
||||
>
|
||||
<Route size={20} className="text-purple-500" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsDeleteModalOpen(true);
|
||||
@@ -116,7 +119,6 @@ export const StationListPage = observer(() => {
|
||||
},
|
||||
];
|
||||
|
||||
// Фильтрация станций по выбранному городу
|
||||
const filteredStations = () => {
|
||||
const { selectedCityId } = selectedCityStore;
|
||||
if (!selectedCityId) {
|
||||
@@ -130,8 +132,7 @@ export const StationListPage = observer(() => {
|
||||
const rows = filteredStations().map((station: any) => ({
|
||||
id: station.id,
|
||||
name: station.name,
|
||||
system_name: station.system_name,
|
||||
direction: station.direction,
|
||||
description: station.description,
|
||||
}));
|
||||
|
||||
return (
|
||||
@@ -144,29 +145,75 @@ export const StationListPage = observer(() => {
|
||||
<CreateButton label="Создать остановки" path="/station/create" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex justify-end mb-5 duration-300"
|
||||
style={{ opacity: ids.length > 0 ? 1 : 0 }}
|
||||
>
|
||||
<div className="flex justify-end mb-5 duration-300">
|
||||
<button
|
||||
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
|
||||
onClick={() => setIsBulkDeleteModalOpen(true)}
|
||||
className={`px-4 py-2 rounded flex gap-2 items-center transition-all ${
|
||||
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" /> Удалить выбранные (
|
||||
{ids.length})
|
||||
<Trash2
|
||||
size={20}
|
||||
className={ids.length > 0 ? "text-white" : "text-gray-500"}
|
||||
/>
|
||||
Удалить выбранные ({ids.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<DataGrid
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
hideFooterPagination
|
||||
checkboxSelection
|
||||
disableRowSelectionExcludeModel
|
||||
loading={isLoading}
|
||||
onRowSelectionModelChange={(newSelection) => {
|
||||
setIds(Array.from(newSelection.ids) as number[]);
|
||||
paginationModel={paginationModel}
|
||||
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}
|
||||
slots={{
|
||||
noRowsOverlay: () => (
|
||||
@@ -205,6 +252,15 @@ export const StationListPage = observer(() => {
|
||||
setIsBulkDeleteModalOpen(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
<EditStationTransfersModal
|
||||
open={isTransfersModalOpen}
|
||||
onClose={() => {
|
||||
setIsTransfersModalOpen(false);
|
||||
setSelectedStationId(null);
|
||||
}}
|
||||
stationId={selectedStationId}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Paper } from "@mui/material";
|
||||
import { languageStore, stationsStore } from "@shared";
|
||||
import { Paper, Box } from "@mui/material";
|
||||
import { languageStore, stationsStore, LoadingSpinner } from "@shared";
|
||||
import { LanguageSwitcher } from "@widgets";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { LinkedSights } from "../LinkedSights";
|
||||
|
||||
@@ -12,15 +12,38 @@ export const StationPreviewPage = observer(() => {
|
||||
const { stationPreview, getStationPreview } = stationsStore;
|
||||
const navigate = useNavigate();
|
||||
const { language } = languageStore;
|
||||
const [isLoadingData, setIsLoadingData] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (id) {
|
||||
await getStationPreview(Number(id));
|
||||
setIsLoadingData(true);
|
||||
try {
|
||||
await getStationPreview(Number(id));
|
||||
} finally {
|
||||
setIsLoadingData(false);
|
||||
}
|
||||
} else {
|
||||
setIsLoadingData(false);
|
||||
}
|
||||
})();
|
||||
}, [id, language]);
|
||||
|
||||
if (isLoadingData) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
minHeight: "60vh",
|
||||
}}
|
||||
>
|
||||
<LoadingSpinner message="Загрузка данных станции..." />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper className="w-full p-3 py-5 flex flex-col gap-10">
|
||||
<LanguageSwitcher />
|
||||
@@ -44,21 +67,6 @@ export const StationPreviewPage = observer(() => {
|
||||
<p>{stationPreview[id!]?.[language]?.data.system_name}</p>
|
||||
</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 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-lg font-bold">Адрес</h1>
|
||||
|
||||
@@ -6,17 +6,35 @@ import {
|
||||
FormControlLabel,
|
||||
} from "@mui/material";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { ArrowLeft, Save } from "lucide-react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { ArrowLeft, Loader2, Save } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "react-toastify";
|
||||
import { userStore } from "@shared";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
userStore,
|
||||
mediaStore,
|
||||
isMediaIdEmpty,
|
||||
SelectMediaDialog,
|
||||
UploadMediaDialog,
|
||||
PreviewMediaDialog,
|
||||
} from "@shared";
|
||||
import { useState, useEffect } from "react";
|
||||
import { ImageUploadCard } from "@widgets";
|
||||
|
||||
export const UserCreatePage = observer(() => {
|
||||
const navigate = useNavigate();
|
||||
const { createUserData, setCreateUserData, createUser } = userStore;
|
||||
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 () => {
|
||||
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 (
|
||||
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
||||
<div className="flex items-center gap-4">
|
||||
@@ -54,7 +95,8 @@ export const UserCreatePage = observer(() => {
|
||||
e.target.value,
|
||||
createUserData.email || "",
|
||||
createUserData.password || "",
|
||||
createUserData.is_admin || false
|
||||
createUserData.is_admin || false,
|
||||
createUserData.icon
|
||||
)
|
||||
}
|
||||
/>
|
||||
@@ -69,7 +111,8 @@ export const UserCreatePage = observer(() => {
|
||||
createUserData.name || "",
|
||||
e.target.value,
|
||||
createUserData.password || "",
|
||||
createUserData.is_admin || false
|
||||
createUserData.is_admin || false,
|
||||
createUserData.icon
|
||||
)
|
||||
}
|
||||
/>
|
||||
@@ -84,7 +127,8 @@ export const UserCreatePage = observer(() => {
|
||||
createUserData.name || "",
|
||||
createUserData.email || "",
|
||||
e.target.value,
|
||||
createUserData.is_admin || false
|
||||
createUserData.is_admin || false,
|
||||
createUserData.icon
|
||||
)
|
||||
}
|
||||
/>
|
||||
@@ -99,7 +143,8 @@ export const UserCreatePage = observer(() => {
|
||||
createUserData.name || "",
|
||||
createUserData.email || "",
|
||||
createUserData.password || "",
|
||||
e.target.checked
|
||||
e.target.checked,
|
||||
createUserData.icon
|
||||
);
|
||||
}}
|
||||
/>
|
||||
@@ -108,6 +153,36 @@ export const UserCreatePage = observer(() => {
|
||||
/>
|
||||
</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
|
||||
variant="contained"
|
||||
className="w-min flex gap-2 items-center"
|
||||
@@ -124,6 +199,28 @@ export const UserCreatePage = observer(() => {
|
||||
)}
|
||||
</Button>
|
||||
</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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -4,24 +4,43 @@ import {
|
||||
Checkbox,
|
||||
Paper,
|
||||
TextField,
|
||||
Box,
|
||||
} from "@mui/material";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { ArrowLeft, Save } from "lucide-react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { ArrowLeft, Loader2, Save } from "lucide-react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
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 { ImageUploadCard, DeleteModal } from "@widgets";
|
||||
|
||||
export const UserEditPage = observer(() => {
|
||||
const navigate = useNavigate();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isLoadingData, setIsLoadingData] = useState(true);
|
||||
|
||||
const { id } = useParams();
|
||||
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(() => {
|
||||
// Устанавливаем русский язык при загрузке страницы
|
||||
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(() => {
|
||||
(async () => {
|
||||
if (id) {
|
||||
const data = await getUser(Number(id));
|
||||
setIsLoadingData(true);
|
||||
try {
|
||||
await mediaStore.getMedia();
|
||||
const data = await getUser(Number(id));
|
||||
|
||||
setEditUserData(
|
||||
data?.name || "",
|
||||
data?.email || "",
|
||||
data?.password || "",
|
||||
data?.is_admin || false
|
||||
);
|
||||
if (data) {
|
||||
setEditUserData(
|
||||
data.name || "",
|
||||
data.email || "",
|
||||
data.password || "",
|
||||
data.is_admin || false,
|
||||
data.icon || ""
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setIsLoadingData(false);
|
||||
}
|
||||
} else {
|
||||
setIsLoadingData(false);
|
||||
}
|
||||
})();
|
||||
}, [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 (
|
||||
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
||||
<div className="flex items-center gap-4">
|
||||
@@ -76,7 +144,8 @@ export const UserEditPage = observer(() => {
|
||||
e.target.value,
|
||||
editUserData.email || "",
|
||||
editUserData.password || "",
|
||||
editUserData.is_admin || false
|
||||
editUserData.is_admin || false,
|
||||
editUserData.icon
|
||||
)
|
||||
}
|
||||
/>
|
||||
@@ -90,7 +159,8 @@ export const UserEditPage = observer(() => {
|
||||
editUserData.name || "",
|
||||
e.target.value,
|
||||
editUserData.password || "",
|
||||
editUserData.is_admin || false
|
||||
editUserData.is_admin || false,
|
||||
editUserData.icon
|
||||
)
|
||||
}
|
||||
/>
|
||||
@@ -98,14 +168,15 @@ export const UserEditPage = observer(() => {
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Пароль"
|
||||
placeholder="Оставить пустым, чтобы не менять"
|
||||
value={editUserData.password || ""}
|
||||
required
|
||||
onChange={(e) =>
|
||||
setEditUserData(
|
||||
editUserData.name || "",
|
||||
editUserData.email || "",
|
||||
e.target.value,
|
||||
editUserData.is_admin || false
|
||||
editUserData.is_admin || false,
|
||||
editUserData.icon
|
||||
)
|
||||
}
|
||||
/>
|
||||
@@ -118,7 +189,8 @@ export const UserEditPage = observer(() => {
|
||||
editUserData.name || "",
|
||||
editUserData.email || "",
|
||||
editUserData.password || "",
|
||||
e.target.checked
|
||||
e.target.checked,
|
||||
editUserData.icon
|
||||
)
|
||||
}
|
||||
/>
|
||||
@@ -126,6 +198,27 @@ export const UserEditPage = observer(() => {
|
||||
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
|
||||
variant="contained"
|
||||
className="w-min flex gap-2 items-center self-end"
|
||||
@@ -140,6 +233,44 @@ export const UserEditPage = observer(() => {
|
||||
)}
|
||||
</Button>
|
||||
</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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -16,6 +16,10 @@ export const UserListPage = observer(() => {
|
||||
const [rowId, setRowId] = useState<number | null>(null);
|
||||
const [ids, setIds] = useState<number[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [paginationModel, setPaginationModel] = useState({
|
||||
page: 0,
|
||||
pageSize: 50,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUsers = async () => {
|
||||
@@ -126,29 +130,39 @@ export const UserListPage = observer(() => {
|
||||
<CreateButton label="Создать пользователя" path="/user/create" />
|
||||
</div>
|
||||
|
||||
<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"
|
||||
onClick={() => setIsBulkDeleteModalOpen(true)}
|
||||
>
|
||||
<Trash2 size={20} className="text-white" /> Удалить выбранные (
|
||||
{ids.length})
|
||||
</button>
|
||||
</div>
|
||||
{ids.length > 0 && (
|
||||
<div className="flex justify-end mb-5 duration-300">
|
||||
<button
|
||||
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})
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DataGrid
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
hideFooterPagination
|
||||
checkboxSelection
|
||||
disableRowSelectionExcludeModel
|
||||
loading={isLoading}
|
||||
onRowSelectionModelChange={(newSelection) => {
|
||||
setIds(Array.from(newSelection.ids) as number[]);
|
||||
paginationModel={paginationModel}
|
||||
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}
|
||||
slots={{
|
||||
noRowsOverlay: () => (
|
||||
|
||||
@@ -25,6 +25,7 @@ export const VehicleCreatePage = observer(() => {
|
||||
const [tailNumber, setTailNumber] = useState("");
|
||||
const [type, setType] = useState("");
|
||||
const [carrierId, setCarrierId] = useState<number | null>(null);
|
||||
const [model, setModel] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { language } = languageStore;
|
||||
|
||||
@@ -36,11 +37,12 @@ export const VehicleCreatePage = observer(() => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await vehicleStore.createVehicle(
|
||||
Number(tailNumber),
|
||||
tailNumber,
|
||||
Number(type),
|
||||
carrierStore.carriers[language].data?.find((c) => c.id === carrierId)
|
||||
?.full_name as string,
|
||||
carrierId!
|
||||
carrierId!,
|
||||
model || undefined,
|
||||
);
|
||||
toast.success("Транспорт успешно создан");
|
||||
} catch (error) {
|
||||
@@ -103,6 +105,14 @@ export const VehicleCreatePage = observer(() => {
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Модель ТС"
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
placeholder="Произвольное название модели"
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
className="w-min flex gap-2 items-center"
|
||||
|
||||
@@ -6,6 +6,9 @@ import {
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Button,
|
||||
Box,
|
||||
FormControlLabel,
|
||||
Checkbox,
|
||||
} from "@mui/material";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { ArrowLeft, Loader2, Save } from "lucide-react";
|
||||
@@ -16,6 +19,7 @@ import {
|
||||
languageStore,
|
||||
VEHICLE_TYPES,
|
||||
vehicleStore,
|
||||
LoadingSpinner,
|
||||
} from "@shared";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
@@ -31,6 +35,8 @@ export const VehicleEditPage = observer(() => {
|
||||
} = vehicleStore;
|
||||
const { getCarriers } = carrierStore;
|
||||
const { language } = languageStore;
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isLoadingData, setIsLoadingData] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// Устанавливаем русский язык при загрузке страницы
|
||||
@@ -38,31 +44,61 @@ export const VehicleEditPage = observer(() => {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
await getVehicle(Number(id));
|
||||
await getCarriers(language);
|
||||
const fetchAndSetVehicleData = async () => {
|
||||
if (!id) {
|
||||
setIsLoadingData(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setEditVehicleData({
|
||||
tail_number: vehicle[Number(id)]?.vehicle.tail_number,
|
||||
type: vehicle[Number(id)]?.vehicle.type,
|
||||
carrier: vehicle[Number(id)]?.vehicle.carrier,
|
||||
carrier_id: vehicle[Number(id)]?.vehicle.carrier_id,
|
||||
});
|
||||
})();
|
||||
setIsLoadingData(true);
|
||||
try {
|
||||
await getVehicle(Number(id));
|
||||
await getCarriers(language);
|
||||
|
||||
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]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const handleEdit = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await editVehicle(Number(id), editVehicleData);
|
||||
toast.success("Транспортное средство успешно обновлено");
|
||||
navigate("/vehicle");
|
||||
navigate("/devices");
|
||||
} catch (error) {
|
||||
toast.error("Ошибка при обновлении транспортного средства");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoadingData) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
minHeight: "60vh",
|
||||
}}
|
||||
>
|
||||
<LoadingSpinner message="Загрузка данных транспортного средства..." />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
||||
<div className="flex items-center gap-4">
|
||||
@@ -84,7 +120,7 @@ export const VehicleEditPage = observer(() => {
|
||||
onChange={(e) =>
|
||||
setEditVehicleData({
|
||||
...editVehicleData,
|
||||
tail_number: Number(e.target.value),
|
||||
tail_number: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
@@ -128,6 +164,35 @@ export const VehicleEditPage = observer(() => {
|
||||
</Select>
|
||||
</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
|
||||
variant="contained"
|
||||
className="w-min flex gap-2 items-center"
|
||||
|
||||
@@ -18,6 +18,10 @@ export const VehicleListPage = observer(() => {
|
||||
const [rowId, setRowId] = useState<number | null>(null);
|
||||
const [ids, setIds] = useState<number[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [paginationModel, setPaginationModel] = useState({
|
||||
page: 0,
|
||||
pageSize: 50,
|
||||
});
|
||||
const { language } = languageStore;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -148,29 +152,39 @@ export const VehicleListPage = observer(() => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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"
|
||||
onClick={() => setIsBulkDeleteModalOpen(true)}
|
||||
>
|
||||
<Trash2 size={20} className="text-white" /> Удалить выбранные (
|
||||
{ids.length})
|
||||
</button>
|
||||
</div>
|
||||
{ids.length > 0 && (
|
||||
<div className="flex justify-end mb-5 duration-300">
|
||||
<button
|
||||
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})
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DataGrid
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
hideFooterPagination
|
||||
checkboxSelection
|
||||
disableRowSelectionExcludeModel
|
||||
loading={isLoading}
|
||||
onRowSelectionModelChange={(newSelection) => {
|
||||
setIds(Array.from(newSelection.ids) as number[]);
|
||||
paginationModel={paginationModel}
|
||||
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}
|
||||
slots={{
|
||||
noRowsOverlay: () => (
|
||||
|
||||
@@ -36,7 +36,7 @@ export const NAVIGATION_ITEMS: {
|
||||
primary: [
|
||||
{
|
||||
id: "snapshots",
|
||||
label: "Снапшоты",
|
||||
label: "Экспорт",
|
||||
icon: GitBranch,
|
||||
path: "/snapshot",
|
||||
for_admin: true,
|
||||
@@ -124,6 +124,16 @@ export const NAVIGATION_ITEMS: {
|
||||
};
|
||||
|
||||
export const VEHICLE_TYPES = [
|
||||
{ label: "Трамвай", value: 1 },
|
||||
{ label: "Автобус", value: 3 },
|
||||
{ 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;
|
||||
|
||||
@@ -33,3 +33,12 @@ export const generateDefaultMediaName = (
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
@@ -51,7 +51,9 @@ interface UploadMediaDialogProps {
|
||||
| "carrier"
|
||||
| "country"
|
||||
| "vehicle"
|
||||
| "station";
|
||||
| "station"
|
||||
| "route"
|
||||
| "user";
|
||||
isArticle?: boolean;
|
||||
articleName?: string;
|
||||
initialFile?: File;
|
||||
|
||||
@@ -86,28 +86,35 @@ class EditSightStore {
|
||||
}
|
||||
|
||||
hasLoadedCommon = false;
|
||||
isLoading = false;
|
||||
|
||||
getSightInfo = async (id: number, language: Language) => {
|
||||
const response = await languageInstance(language).get(`/sight/${id}`);
|
||||
const data = response.data;
|
||||
this.isLoading = true;
|
||||
try {
|
||||
const response = await languageInstance(language).get(`/sight/${id}`);
|
||||
const data = response.data;
|
||||
|
||||
if (data.left_article != 0 && data.left_article != null) {
|
||||
await this.getLeftArticle(data.left_article);
|
||||
}
|
||||
if (data.left_article != 0 && data.left_article != null) {
|
||||
await this.getLeftArticle(data.left_article);
|
||||
}
|
||||
|
||||
runInAction(() => {
|
||||
this.sight[language] = {
|
||||
...this.sight[language],
|
||||
...data,
|
||||
};
|
||||
|
||||
if (!this.hasLoadedCommon) {
|
||||
this.sight.common = {
|
||||
...this.sight.common,
|
||||
runInAction(() => {
|
||||
this.sight[language] = {
|
||||
...this.sight[language],
|
||||
...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) => {
|
||||
@@ -168,6 +175,8 @@ class EditSightStore {
|
||||
|
||||
clearSightInfo = () => {
|
||||
this.needLeaveAgree = false;
|
||||
this.hasLoadedCommon = false;
|
||||
this.isLoading = false;
|
||||
this.sight = {
|
||||
common: {
|
||||
id: 0,
|
||||
@@ -479,18 +488,19 @@ class EditSightStore {
|
||||
formData.append("media_name", media_name);
|
||||
}
|
||||
formData.append("type", type.toString());
|
||||
try {
|
||||
const response = await authInstance.post(`/media`, formData);
|
||||
this.fileToUpload = null;
|
||||
this.uploadMediaOpen = false;
|
||||
mediaStore.getMedia();
|
||||
return {
|
||||
id: response.data.id,
|
||||
filename: filename,
|
||||
media_name: media_name,
|
||||
media_type: type,
|
||||
};
|
||||
} catch (error) {}
|
||||
|
||||
const response = await authInstance.post(`/media`, formData);
|
||||
this.fileToUpload = null;
|
||||
this.uploadMediaOpen = false;
|
||||
|
||||
mediaStore.getMedia();
|
||||
|
||||
return {
|
||||
id: response.data.id,
|
||||
filename: filename,
|
||||
media_name: media_name,
|
||||
media_type: type,
|
||||
};
|
||||
};
|
||||
|
||||
createLinkWithArticle = async (media: {
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { makeAutoObservable, runInAction } from "mobx";
|
||||
import { authInstance } from "@shared";
|
||||
import {
|
||||
authInstance,
|
||||
languageInstance,
|
||||
languageStore,
|
||||
isMediaIdEmpty,
|
||||
} from "@shared";
|
||||
|
||||
export type Route = {
|
||||
route_name: string;
|
||||
@@ -9,6 +14,7 @@ export type Route = {
|
||||
center_longitude: number;
|
||||
governor_appeal: number;
|
||||
id: number;
|
||||
icon: string;
|
||||
path: number[][];
|
||||
rotate: number;
|
||||
route_direction: boolean;
|
||||
@@ -89,11 +95,43 @@ class RouteStore {
|
||||
};
|
||||
|
||||
saveRouteStations = async (routeId: number, stationId: number) => {
|
||||
await authInstance.patch(`/route/${routeId}/station`, {
|
||||
...this.routeStations[routeId]?.find(
|
||||
(station) => station.id === stationId
|
||||
),
|
||||
const { language } = languageStore;
|
||||
|
||||
// Получаем актуальные данные станции с сервера
|
||||
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,
|
||||
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: "",
|
||||
governor_appeal: 0,
|
||||
id: 0,
|
||||
icon: "",
|
||||
path: [] as number[][],
|
||||
rotate: 0,
|
||||
route_direction: false,
|
||||
@@ -120,14 +159,27 @@ class RouteStore {
|
||||
};
|
||||
|
||||
editRoute = async (id: number) => {
|
||||
if (!this.editRouteData.video_preview) {
|
||||
if (
|
||||
!this.editRouteData.video_preview ||
|
||||
isMediaIdEmpty(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,
|
||||
center_latitude: parseFloat(this.editRouteData.center_latitude),
|
||||
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(() => {
|
||||
this.route[id] = response.data;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { authInstance } from "@shared";
|
||||
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { makeAutoObservable, runInAction } from "mobx";
|
||||
import {
|
||||
articlesStore,
|
||||
@@ -25,9 +25,18 @@ type Snapshot = {
|
||||
CreationTime: string;
|
||||
};
|
||||
|
||||
type SnapshotStatus = {
|
||||
ID: string;
|
||||
Status: string;
|
||||
Progress: number;
|
||||
Error: string;
|
||||
};
|
||||
|
||||
class SnapshotStore {
|
||||
snapshots: Snapshot[] = [];
|
||||
snapshot: Snapshot | null = null;
|
||||
lastRequestId: string | null = null;
|
||||
snapshotStatus: SnapshotStatus | null = null;
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(this);
|
||||
@@ -266,7 +275,23 @@ class SnapshotStore {
|
||||
};
|
||||
|
||||
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;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { authInstance, languageInstance, languageStore } from "@shared";
|
||||
import { makeAutoObservable, runInAction } from "mobx";
|
||||
import { routeStore } from "../RouteStore";
|
||||
|
||||
type Language = "ru" | "en" | "zh";
|
||||
|
||||
@@ -12,7 +13,6 @@ type StationLanguageData = {
|
||||
|
||||
type StationCommonData = {
|
||||
city_id: number;
|
||||
direction: boolean;
|
||||
description: string;
|
||||
icon: string;
|
||||
latitude: number;
|
||||
@@ -43,7 +43,6 @@ type Station = {
|
||||
city: string;
|
||||
city_id: number;
|
||||
description: string;
|
||||
direction: boolean;
|
||||
icon: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
@@ -122,7 +121,6 @@ class StationsStore {
|
||||
common: {
|
||||
city: "",
|
||||
city_id: 0,
|
||||
direction: false,
|
||||
description: "",
|
||||
icon: "",
|
||||
latitude: 0,
|
||||
@@ -168,7 +166,6 @@ class StationsStore {
|
||||
common: {
|
||||
city: "",
|
||||
city_id: 0,
|
||||
direction: false,
|
||||
description: "",
|
||||
icon: "",
|
||||
latitude: 0,
|
||||
@@ -251,7 +248,6 @@ class StationsStore {
|
||||
common: {
|
||||
city: ruResponse.data.city,
|
||||
city_id: ruResponse.data.city_id,
|
||||
direction: ruResponse.data.direction,
|
||||
description: ruResponse.data.description,
|
||||
icon: ruResponse.data.icon,
|
||||
latitude: ruResponse.data.latitude,
|
||||
@@ -276,7 +272,6 @@ class StationsStore {
|
||||
editStation = async (id: number) => {
|
||||
const commonDataPayload = {
|
||||
city_id: this.editStationData.common.city_id,
|
||||
direction: this.editStationData.common.direction,
|
||||
icon: this.editStationData.common.icon,
|
||||
latitude: this.editStationData.common.latitude,
|
||||
longitude: this.editStationData.common.longitude,
|
||||
@@ -404,7 +399,6 @@ class StationsStore {
|
||||
const { language } = languageStore;
|
||||
let commonDataPayload: Partial<StationCommonData> = {
|
||||
city_id: this.createStationData.common.city_id,
|
||||
direction: this.createStationData.common.direction,
|
||||
icon: this.createStationData.common.icon,
|
||||
latitude: this.createStationData.common.latitude,
|
||||
longitude: this.createStationData.common.longitude,
|
||||
@@ -478,7 +472,6 @@ class StationsStore {
|
||||
common: {
|
||||
city: "",
|
||||
city_id: 0,
|
||||
direction: false,
|
||||
icon: "",
|
||||
latitude: 0,
|
||||
description: "",
|
||||
@@ -525,7 +518,6 @@ class StationsStore {
|
||||
common: {
|
||||
city: "",
|
||||
city_id: 0,
|
||||
direction: false,
|
||||
description: "",
|
||||
icon: "",
|
||||
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();
|
||||
|
||||
@@ -7,6 +7,7 @@ export type User = {
|
||||
is_admin: boolean;
|
||||
name: string;
|
||||
password?: string;
|
||||
icon?: string;
|
||||
};
|
||||
|
||||
class UserStore {
|
||||
@@ -57,15 +58,23 @@ class UserStore {
|
||||
email: "",
|
||||
password: "",
|
||||
is_admin: false,
|
||||
icon: "",
|
||||
};
|
||||
|
||||
setCreateUserData = (
|
||||
name: string,
|
||||
email: 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 () => {
|
||||
@@ -73,7 +82,9 @@ class UserStore {
|
||||
if (this.users.data.length > 0) {
|
||||
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(() => {
|
||||
this.users.data.push({
|
||||
@@ -88,19 +99,30 @@ class UserStore {
|
||||
email: "",
|
||||
password: "",
|
||||
is_admin: false,
|
||||
icon: "",
|
||||
};
|
||||
|
||||
setEditUserData = (
|
||||
name: string,
|
||||
email: 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) => {
|
||||
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(() => {
|
||||
this.users.data = this.users.data.map((user) =>
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import { languageInstance } from "@shared";
|
||||
import { authInstance, languageInstance } from "@shared";
|
||||
import { makeAutoObservable, runInAction } from "mobx";
|
||||
|
||||
export type Vehicle = {
|
||||
vehicle: {
|
||||
id: number;
|
||||
tail_number: number;
|
||||
tail_number: string;
|
||||
type: number;
|
||||
carrier_id: number;
|
||||
carrier: 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_uuid: string;
|
||||
@@ -34,11 +40,75 @@ class VehicleStore {
|
||||
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 () => {
|
||||
const response = await languageInstance("ru").get(`/vehicle`);
|
||||
const vehiclesList = Array.isArray(response.data)
|
||||
? response.data
|
||||
: Array.isArray(response.data?.vehicles)
|
||||
? response.data.vehicles
|
||||
: [];
|
||||
|
||||
runInAction(() => {
|
||||
this.vehicles.data = response.data;
|
||||
this.vehicles.data = vehiclesList.map(this.normalizeVehicleItem);
|
||||
this.vehicles.loaded = true;
|
||||
});
|
||||
};
|
||||
@@ -55,56 +125,62 @@ class VehicleStore {
|
||||
|
||||
getVehicle = async (id: number) => {
|
||||
const response = await languageInstance("ru").get(`/vehicle/${id}`);
|
||||
const normalizedVehicle = this.normalizeVehicleItem(response.data);
|
||||
|
||||
runInAction(() => {
|
||||
this.vehicle[id] = response.data;
|
||||
this.vehicle[id] = normalizedVehicle;
|
||||
});
|
||||
};
|
||||
|
||||
createVehicle = async (
|
||||
tailNumber: number,
|
||||
tailNumber: string,
|
||||
type: number,
|
||||
carrier: string,
|
||||
carrierId: number
|
||||
carrierId: number,
|
||||
model?: string
|
||||
) => {
|
||||
const response = await languageInstance("ru").post("/vehicle", {
|
||||
const payload: Record<string, unknown> = {
|
||||
tail_number: tailNumber,
|
||||
type,
|
||||
carrier,
|
||||
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(() => {
|
||||
this.vehicles.data.push({
|
||||
vehicle: {
|
||||
id: response.data.id,
|
||||
tail_number: response.data.tail_number,
|
||||
type: response.data.type,
|
||||
carrier_id: response.data.carrier_id,
|
||||
carrier: response.data.carrier,
|
||||
uuid: response.data.uuid,
|
||||
},
|
||||
});
|
||||
this.vehicles.data.push(normalizedVehicle);
|
||||
if (normalizedVehicle.vehicle?.id != null) {
|
||||
this.vehicle[normalizedVehicle.vehicle.id] = normalizedVehicle;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
editVehicleData: {
|
||||
tail_number: number;
|
||||
tail_number: string;
|
||||
type: number;
|
||||
carrier: string;
|
||||
carrier_id: number;
|
||||
model: string;
|
||||
snapshot_update_blocked: boolean;
|
||||
} = {
|
||||
tail_number: 0,
|
||||
tail_number: "",
|
||||
type: 0,
|
||||
carrier: "",
|
||||
carrier_id: 0,
|
||||
model: "",
|
||||
snapshot_update_blocked: false,
|
||||
};
|
||||
|
||||
setEditVehicleData = (data: {
|
||||
tail_number: number;
|
||||
tail_number: string;
|
||||
type: number;
|
||||
carrier: string;
|
||||
carrier_id: number;
|
||||
model?: string;
|
||||
snapshot_update_blocked?: boolean;
|
||||
}) => {
|
||||
this.editVehicleData = {
|
||||
...this.editVehicleData,
|
||||
@@ -115,34 +191,72 @@ class VehicleStore {
|
||||
editVehicle = async (
|
||||
id: number,
|
||||
data: {
|
||||
tail_number: number;
|
||||
tail_number: string;
|
||||
type: number;
|
||||
carrier: string;
|
||||
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,
|
||||
type: data.type,
|
||||
carrier: data.carrier,
|
||||
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(() => {
|
||||
this.vehicle[id] = {
|
||||
vehicle: {
|
||||
...this.vehicle[id].vehicle,
|
||||
...response.data,
|
||||
},
|
||||
};
|
||||
this.vehicles.data = this.vehicles.data.map((vehicle) =>
|
||||
vehicle.vehicle.id === id
|
||||
? {
|
||||
...vehicle,
|
||||
...response.data,
|
||||
}
|
||||
: vehicle
|
||||
);
|
||||
this.mergeVehicleInCaches({
|
||||
...updatedVehiclePayload,
|
||||
id,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
setMaintenanceMode = async (uuid: string, enabled: boolean) => {
|
||||
const response = await authInstance.post(`/devices/${uuid}/maintenance-mode`, {
|
||||
enabled,
|
||||
});
|
||||
const normalizedVehicle = this.normalizeVehicleItem(response.data);
|
||||
|
||||
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,
|
||||
});
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
title?: string;
|
||||
sx?: SxProps<Theme>;
|
||||
}
|
||||
|
||||
const style = {
|
||||
@@ -19,7 +20,7 @@ const style = {
|
||||
borderRadius: 2,
|
||||
};
|
||||
|
||||
export const Modal = ({ open, onClose, children, title }: ModalProps) => {
|
||||
export const Modal = ({ open, onClose, children, title, sx }: ModalProps) => {
|
||||
return (
|
||||
<MuiModal
|
||||
open={open}
|
||||
@@ -27,7 +28,7 @@ export const Modal = ({ open, onClose, children, title }: ModalProps) => {
|
||||
aria-labelledby="modal-modal-title"
|
||||
aria-describedby="modal-modal-description"
|
||||
>
|
||||
<Box sx={style}>
|
||||
<Box sx={{ ...style, ...sx }}>
|
||||
{title && (
|
||||
<Typography
|
||||
id="modal-modal-title"
|
||||
|
||||
@@ -3,3 +3,4 @@ export * from "./BackButton";
|
||||
export * from "./Modal";
|
||||
export * from "./CoordinatesInput";
|
||||
export * from "./AnimatedCircleButton";
|
||||
export * from "./LoadingSpinner";
|
||||
|
||||
1
src/vite-env.d.ts
vendored
1
src/vite-env.d.ts
vendored
@@ -1 +1,2 @@
|
||||
/// <reference types="vite/client" />
|
||||
declare const __APP_VERSION__: string;
|
||||
|
||||
338
src/widgets/DevicesTable/DeviceLogsModal.tsx
Normal file
338
src/widgets/DevicesTable/DeviceLogsModal.tsx
Normal 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
@@ -8,7 +8,7 @@ import { AppBar } from "./ui/AppBar";
|
||||
import { Drawer } from "./ui/Drawer";
|
||||
import { DrawerHeader } from "./ui/DrawerHeader";
|
||||
import { NavigationList } from "@features";
|
||||
import { authStore, userStore, menuStore } from "@shared";
|
||||
import { authStore, userStore, menuStore, isMediaIdEmpty } from "@shared";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useEffect } from "react";
|
||||
import { Typography } from "@mui/material";
|
||||
@@ -67,18 +67,18 @@ export const Layout: React.FC<LayoutProps> = observer(({ children }) => {
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="flex flex-col gap-1">
|
||||
{(() => {
|
||||
return (
|
||||
<>
|
||||
<p className=" text-white">
|
||||
{
|
||||
users?.data?.find(
|
||||
// @ts-ignore
|
||||
(user) => user.id === authStore.payload?.user_id
|
||||
)?.name
|
||||
}
|
||||
</p>
|
||||
{(() => {
|
||||
const currentUser = users?.data?.find(
|
||||
(user) => user.id === (authStore.payload as { user_id?: number })?.user_id,
|
||||
);
|
||||
const hasAvatar =
|
||||
currentUser?.icon && !isMediaIdEmpty(currentUser.icon);
|
||||
const token = localStorage.getItem("token");
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-white">{currentUser?.name}</p>
|
||||
<div
|
||||
className="text-center text-xs"
|
||||
style={{
|
||||
@@ -88,18 +88,27 @@ export const Layout: React.FC<LayoutProps> = observer(({ children }) => {
|
||||
padding: "2px 10px",
|
||||
}}
|
||||
>
|
||||
{/* @ts-ignore */}
|
||||
{authStore.payload?.is_admin
|
||||
{(authStore.payload as { is_admin?: boolean })?.is_admin
|
||||
? "Администратор"
|
||||
: "Режим пользователя"}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
<div className="w-10 h-10 bg-gray-600 rounded-full flex items-center justify-center">
|
||||
<User />
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-10 h-10 rounded-full flex items-center justify-center overflow-hidden bg-gray-600 shrink-0">
|
||||
{hasAvatar ? (
|
||||
<img
|
||||
src={`${
|
||||
import.meta.env.VITE_KRBL_MEDIA
|
||||
}${currentUser!.icon}/download?token=${token}`}
|
||||
alt="Аватар"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<User className="text-white" size={20} />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
@@ -138,6 +147,9 @@ export const Layout: React.FC<LayoutProps> = observer(({ children }) => {
|
||||
)}
|
||||
</DrawerHeader>
|
||||
<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>
|
||||
<Box
|
||||
component="main"
|
||||
|
||||
@@ -10,23 +10,25 @@ import { observer } from "mobx-react-lite";
|
||||
import { useState, DragEvent, useRef } from "react";
|
||||
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(
|
||||
({
|
||||
articleId,
|
||||
mediaIds,
|
||||
deleteMedia,
|
||||
onFilesDrop, // 👈 Проп для обработки загруженных файлов
|
||||
onFilesDrop,
|
||||
setSelectMediaDialogOpen,
|
||||
}: {
|
||||
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;
|
||||
}) => {
|
||||
}: MediaAreaProps) => {
|
||||
const [mediaModal, setMediaModal] = useState<boolean>(false);
|
||||
const [mediaId, setMediaId] = useState<string>("");
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isDragging, setIsDragging] = useState<boolean>(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleMediaModal = (mediaId: string) => {
|
||||
@@ -34,23 +36,29 @@ export const MediaArea = observer(
|
||||
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>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
if (files.length && onFilesDrop) {
|
||||
const { validFiles, errors } = filterValidFiles(files);
|
||||
|
||||
if (errors.length > 0) {
|
||||
errors.forEach((error) => toast.error(error));
|
||||
}
|
||||
|
||||
if (validFiles.length > 0) {
|
||||
onFilesDrop(validFiles);
|
||||
}
|
||||
}
|
||||
processFiles(files);
|
||||
};
|
||||
|
||||
const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
|
||||
@@ -68,19 +76,11 @@ export const MediaArea = observer(
|
||||
|
||||
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(event.target.files || []);
|
||||
if (files.length && onFilesDrop) {
|
||||
const { validFiles, errors } = filterValidFiles(files);
|
||||
processFiles(files);
|
||||
|
||||
if (errors.length > 0) {
|
||||
errors.forEach((error) => toast.error(error));
|
||||
}
|
||||
|
||||
if (validFiles.length > 0) {
|
||||
onFilesDrop(validFiles);
|
||||
}
|
||||
if (event.target) {
|
||||
event.target.value = "";
|
||||
}
|
||||
// Сбрасываем значение input, чтобы можно было выбрать тот же файл снова
|
||||
event.target.value = "";
|
||||
};
|
||||
|
||||
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">
|
||||
<div className="w-full flex flex-col items-center justify-center">
|
||||
<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" : ""
|
||||
}`}
|
||||
onDrop={handleDrop}
|
||||
@@ -105,9 +105,11 @@ export const MediaArea = observer(
|
||||
onClick={handleClick}
|
||||
>
|
||||
<Upload size={32} className="mb-2" />
|
||||
Перетащите медиа файлы сюда или нажмите для выбора
|
||||
<span className="text-center">
|
||||
Перетащите медиа файлы сюда или нажмите для выбора
|
||||
</span>
|
||||
</div>
|
||||
<div>или</div>
|
||||
<div className="my-2">или</div>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
@@ -117,33 +119,38 @@ export const MediaArea = observer(
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="w-full flex flex-start flex-wrap gap-2 mt-4 py-10">
|
||||
{mediaIds.map((m) => (
|
||||
<button
|
||||
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"
|
||||
/>
|
||||
{mediaIds.length > 0 && (
|
||||
<div className="w-full flex flex-start flex-wrap gap-2 mt-4 py-10">
|
||||
{mediaIds.map((m) => (
|
||||
<button
|
||||
className="absolute top-2 right-2"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteMedia(articleId, m.id);
|
||||
}}
|
||||
className="relative w-[100px] h-[80px]"
|
||||
key={m.id}
|
||||
onClick={() => handleMediaModal(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>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<PreviewMediaDialog
|
||||
|
||||
@@ -11,52 +11,72 @@ import { observer } from "mobx-react-lite";
|
||||
import { useState, DragEvent, useRef } from "react";
|
||||
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(
|
||||
({
|
||||
onFilesDrop, // 👈 Проп для обработки загруженных файлов
|
||||
onFilesDrop,
|
||||
onFinishUpload,
|
||||
contextObjectName,
|
||||
contextType,
|
||||
isArticle,
|
||||
articleName,
|
||||
}: {
|
||||
onFilesDrop?: (files: File[]) => void;
|
||||
onFinishUpload?: (mediaId: string) => void;
|
||||
contextObjectName?: string;
|
||||
contextType?:
|
||||
| "sight"
|
||||
| "city"
|
||||
| "carrier"
|
||||
| "country"
|
||||
| "vehicle"
|
||||
| "station";
|
||||
isArticle?: boolean;
|
||||
articleName?: string;
|
||||
}) => {
|
||||
const [selectMediaDialogOpen, setSelectMediaDialogOpen] = useState(false);
|
||||
const [uploadMediaDialogOpen, setUploadMediaDialogOpen] = useState(false);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
}: MediaAreaForSightProps) => {
|
||||
const [selectMediaDialogOpen, setSelectMediaDialogOpen] =
|
||||
useState<boolean>(false);
|
||||
const [uploadMediaDialogOpen, setUploadMediaDialogOpen] =
|
||||
useState<boolean>(false);
|
||||
const [isDragging, setIsDragging] = useState<boolean>(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const { 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>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
if (files.length) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
processFiles(files);
|
||||
};
|
||||
|
||||
const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
|
||||
@@ -74,22 +94,12 @@ export const MediaAreaForSight = observer(
|
||||
|
||||
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(event.target.files || []);
|
||||
if (files.length) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
processFiles(files);
|
||||
|
||||
// Сбрасываем значение input, чтобы можно было выбрать тот же файл снова
|
||||
event.target.value = "";
|
||||
if (event.target) {
|
||||
event.target.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
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">
|
||||
<div className="w-full flex flex-col items-center justify-center">
|
||||
<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" : ""
|
||||
}`}
|
||||
onDrop={handleDrop}
|
||||
@@ -114,9 +124,11 @@ export const MediaAreaForSight = observer(
|
||||
onClick={handleClick}
|
||||
>
|
||||
<Upload size={32} className="mb-2" />
|
||||
Перетащите медиа файлы сюда или нажмите для выбора
|
||||
<span className="text-center">
|
||||
Перетащите медиа файлы сюда или нажмите для выбора
|
||||
</span>
|
||||
</div>
|
||||
<div>или</div>
|
||||
<div className="my-2">или</div>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
|
||||
@@ -129,7 +129,7 @@ export const ThreeView = ({
|
||||
>
|
||||
<ambientLight />
|
||||
<directionalLight />
|
||||
<Stage environment="city" intensity={0.6} adjustCamera={false}>
|
||||
<Stage environment={null} intensity={0.6} adjustCamera={false}>
|
||||
<Model fileUrl={fileUrl} />
|
||||
</Stage>
|
||||
<OrbitControls />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Box } from "@mui/material";
|
||||
import { Box, Typography } from "@mui/material";
|
||||
import { useState, useEffect } from "react";
|
||||
import { Cuboid } from "lucide-react";
|
||||
|
||||
import { ReactPhotoSphereViewer } from "react-photo-sphere-viewer";
|
||||
import { ThreeView } from "./ThreeView";
|
||||
@@ -19,6 +20,7 @@ export function MediaViewer({
|
||||
width,
|
||||
fullWidth,
|
||||
fullHeight,
|
||||
compact,
|
||||
}: Readonly<{
|
||||
media?: MediaData;
|
||||
className?: string;
|
||||
@@ -26,6 +28,8 @@ export function MediaViewer({
|
||||
width?: string;
|
||||
fullWidth?: boolean;
|
||||
fullHeight?: boolean;
|
||||
/** В компактном режиме (миниатюры) 3D модели не рендерятся — показывается placeholder */
|
||||
compact?: boolean;
|
||||
}>) {
|
||||
const token = localStorage.getItem("token");
|
||||
const [resetKey, setResetKey] = useState(0);
|
||||
@@ -76,8 +80,9 @@ export function MediaViewer({
|
||||
}/download?token=${token}`}
|
||||
alt={media?.filename}
|
||||
style={{
|
||||
height: fullHeight ? "100%" : height ? height : "auto",
|
||||
width: fullWidth ? "100%" : width ? width : "auto",
|
||||
height: compact ? "80px" : fullHeight ? "100%" : height ? height : "auto",
|
||||
width: compact ? "100px" : fullWidth ? "100%" : width ? width : "auto",
|
||||
objectFit: "cover",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -88,8 +93,8 @@ export function MediaViewer({
|
||||
media?.id
|
||||
}/download?token=${token}`}
|
||||
style={{
|
||||
width: width ? width : "100%",
|
||||
height: height ? height : "100%",
|
||||
width: compact ? "100px" : width ? width : "100%",
|
||||
height: compact ? "80px" : height ? height : "100%",
|
||||
objectFit: "cover",
|
||||
borderRadius: 8,
|
||||
}}
|
||||
@@ -105,8 +110,9 @@ export function MediaViewer({
|
||||
}/download?token=${token}`}
|
||||
alt={media?.filename}
|
||||
style={{
|
||||
height: fullHeight ? "100%" : height ? height : "auto",
|
||||
width: fullWidth ? "100%" : width ? width : "auto",
|
||||
height: compact ? "80px" : fullHeight ? "100%" : height ? height : "auto",
|
||||
width: compact ? "100px" : fullWidth ? "100%" : width ? width : "auto",
|
||||
objectFit: "cover",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -117,6 +123,8 @@ export function MediaViewer({
|
||||
}/download?token=${token}`}
|
||||
alt={media?.filename}
|
||||
style={{
|
||||
width: compact ? "100px" : "100%",
|
||||
height: compact ? "80px" : undefined,
|
||||
objectFit: "cover",
|
||||
}}
|
||||
/>
|
||||
@@ -127,26 +135,46 @@ export function MediaViewer({
|
||||
src={`${import.meta.env.VITE_KRBL_MEDIA}${
|
||||
media?.id
|
||||
}/download?token=${token}`}
|
||||
width={width ? width : "500px"}
|
||||
height={height ? height : "300px"}
|
||||
width={compact ? "100px" : fullWidth ? "100%" : width ? width : "500px"}
|
||||
height={compact ? "80px" : fullHeight ? "100%" : height ? height : "300px"}
|
||||
/>
|
||||
)}
|
||||
|
||||
{media?.media_type === 6 && (
|
||||
<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>
|
||||
)}
|
||||
{media?.media_type === 6 &&
|
||||
(compact ? (
|
||||
<Box
|
||||
sx={{
|
||||
width: "100px",
|
||||
height: "80px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: "action.hover",
|
||||
borderRadius: 5,
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
languageStore,
|
||||
Language,
|
||||
cityStore,
|
||||
isMediaIdEmpty,
|
||||
SelectMediaDialog,
|
||||
PreviewMediaDialog,
|
||||
SightLanguageInfo,
|
||||
@@ -308,7 +309,7 @@ export const CreateInformationTab = observer(
|
||||
<ImageUploadCard
|
||||
title="Водяной знак (левый верхний)"
|
||||
imageKey="watermark_lu"
|
||||
imageUrl={sight.watermark_lu}
|
||||
imageUrl={isMediaIdEmpty(sight.watermark_lu) ? null : sight.watermark_lu}
|
||||
onImageClick={() => {
|
||||
setIsPreviewMediaOpen(true);
|
||||
setMediaId(sight.watermark_lu ?? "");
|
||||
@@ -363,7 +364,7 @@ export const CreateInformationTab = observer(
|
||||
|
||||
<VideoPreviewCard
|
||||
title="Видеозаставка"
|
||||
videoId={sight.video_preview}
|
||||
videoId={isMediaIdEmpty(sight.video_preview) ? null : sight.video_preview}
|
||||
onVideoClick={handleVideoPreviewClick}
|
||||
onDeleteVideoClick={() => {
|
||||
handleChange({
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import {
|
||||
BackButton,
|
||||
createSightStore,
|
||||
editSightStore,
|
||||
languageStore,
|
||||
SelectArticleModal,
|
||||
TabPanel,
|
||||
@@ -51,9 +52,6 @@ export const CreateRightTab = observer(
|
||||
unlinkPreviewMedia,
|
||||
createLinkWithRightArticle,
|
||||
deleteRightArticleMedia,
|
||||
setFileToUpload,
|
||||
setUploadMediaOpen,
|
||||
uploadMediaOpen,
|
||||
unlinkRightAritcle,
|
||||
deleteRightArticle,
|
||||
linkExistingRightArticle,
|
||||
@@ -62,6 +60,8 @@ export const CreateRightTab = observer(
|
||||
updateRightArticles,
|
||||
} = createSightStore;
|
||||
const { language } = languageStore;
|
||||
const { setFileToUpload, setUploadMediaOpen, uploadMediaOpen } =
|
||||
editSightStore;
|
||||
|
||||
const [selectArticleDialogOpen, setSelectArticleDialogOpen] =
|
||||
useState(false);
|
||||
@@ -434,49 +434,61 @@ export const CreateRightTab = observer(
|
||||
</Box>
|
||||
) : type === "media" ? (
|
||||
<Box className="w-[80%] border border-gray-300 rounded-2xl relative flex items-center justify-center">
|
||||
{sight.preview_media && (
|
||||
<>
|
||||
{type === "media" && (
|
||||
<Box className="w-[80%] h-full rounded-2xl relative flex items-center justify-center">
|
||||
{previewMedia && (
|
||||
<>
|
||||
<Box className="absolute top-4 right-4 z-10">
|
||||
<button
|
||||
className="w-10 h-10 flex items-center justify-center z-10"
|
||||
onClick={handleUnlinkPreviewMedia}
|
||||
>
|
||||
<X size={20} color="red" />
|
||||
</button>
|
||||
</Box>
|
||||
<>
|
||||
{type === "media" && (
|
||||
<Box className="w-[80%] h-full rounded-2xl relative flex items-center justify-center">
|
||||
{previewMedia && (
|
||||
<>
|
||||
<Box className="absolute top-4 right-4 z-10">
|
||||
<button
|
||||
className="w-10 h-10 flex items-center justify-center z-10"
|
||||
onClick={handleUnlinkPreviewMedia}
|
||||
>
|
||||
<X size={20} color="red" />
|
||||
</button>
|
||||
</Box>
|
||||
|
||||
<Box className="w-1/2 h-1/2">
|
||||
<MediaViewer
|
||||
media={{
|
||||
id: previewMedia.id || "",
|
||||
media_type: previewMedia.media_type,
|
||||
filename: previewMedia.filename || "",
|
||||
}}
|
||||
fullWidth
|
||||
fullHeight
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{!previewMedia && (
|
||||
<MediaAreaForSight
|
||||
onFinishUpload={(mediaId) => {
|
||||
linkPreviewMedia(mediaId);
|
||||
}}
|
||||
onFilesDrop={() => {}}
|
||||
contextObjectName={sight[language].name}
|
||||
contextType="sight"
|
||||
isArticle={false}
|
||||
/>
|
||||
)}
|
||||
<Box className="w-1/2 h-1/2">
|
||||
<MediaViewer
|
||||
media={{
|
||||
id: previewMedia.id || "",
|
||||
media_type: previewMedia.media_type,
|
||||
filename: previewMedia.filename || "",
|
||||
}}
|
||||
fullWidth
|
||||
fullHeight
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!previewMedia && (
|
||||
<Box className="w-full h-full flex justify-center items-center">
|
||||
<Box
|
||||
sx={{
|
||||
maxWidth: "500px",
|
||||
maxHeight: "100%",
|
||||
display: "flex",
|
||||
flexGrow: 1,
|
||||
margin: "0 auto",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<MediaAreaForSight
|
||||
onFinishUpload={(mediaId) => {
|
||||
linkPreviewMedia(mediaId);
|
||||
}}
|
||||
onFilesDrop={() => {}}
|
||||
contextObjectName={sight[language].name}
|
||||
contextType="sight"
|
||||
isArticle={false}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
</Box>
|
||||
) : (
|
||||
<Box className="w-[80%] h-[70vh] border border-gray-300 rounded-2xl p-3 flex justify-center items-center">
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
Language,
|
||||
cityStore,
|
||||
editSightStore,
|
||||
isMediaIdEmpty,
|
||||
SelectMediaDialog,
|
||||
PreviewMediaDialog,
|
||||
SightLanguageInfo,
|
||||
@@ -275,7 +276,10 @@ export const InformationTab = observer(
|
||||
{sight.common.id !== 0 && (
|
||||
<LinkedStations
|
||||
parentId={sight.common.id}
|
||||
fields={[{ label: "Название", data: "name" }]}
|
||||
fields={[
|
||||
{ label: "Название", data: "name" },
|
||||
{ label: "Описание", data: "description" },
|
||||
]}
|
||||
type="edit"
|
||||
/>
|
||||
)}
|
||||
@@ -331,7 +335,7 @@ export const InformationTab = observer(
|
||||
<ImageUploadCard
|
||||
title="Водяной знак (левый верхний)"
|
||||
imageKey="watermark_lu"
|
||||
imageUrl={sight.common.watermark_lu}
|
||||
imageUrl={isMediaIdEmpty(sight.common.watermark_lu) ? null : sight.common.watermark_lu}
|
||||
onImageClick={() => {
|
||||
setIsPreviewMediaOpen(true);
|
||||
setMediaId(sight.common.watermark_lu ?? "");
|
||||
@@ -393,7 +397,7 @@ export const InformationTab = observer(
|
||||
|
||||
<VideoPreviewCard
|
||||
title="Видеозаставка"
|
||||
videoId={sight.common.video_preview}
|
||||
videoId={isMediaIdEmpty(sight.common.video_preview) ? null : sight.common.video_preview}
|
||||
onVideoClick={handleVideoPreviewClick}
|
||||
onDeleteVideoClick={() => {
|
||||
handleChange(
|
||||
|
||||
@@ -415,21 +415,12 @@ export const RightWidgetTab = observer(
|
||||
media_type: previewMedia.media_type,
|
||||
filename: previewMedia.filename || "",
|
||||
}}
|
||||
fullWidth
|
||||
fullHeight
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
{!previewMedia && (
|
||||
<MediaAreaForSight
|
||||
onFinishUpload={(mediaId) => {
|
||||
linkPreviewMedia(mediaId);
|
||||
}}
|
||||
onFilesDrop={() => {}}
|
||||
contextObjectName={sight[language].name}
|
||||
contextType="sight"
|
||||
isArticle={false}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -19,7 +19,7 @@ export const SnapshotRestore = ({
|
||||
>
|
||||
<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>
|
||||
<p className="text-black w-100 text-center">
|
||||
Это действие нельзя будет отменить.
|
||||
|
||||
@@ -20,18 +20,6 @@ interface EditStationModalProps {
|
||||
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(
|
||||
({ open, onClose }: EditStationModalProps) => {
|
||||
const { id: routeId } = useParams<{ id: string }>();
|
||||
@@ -95,37 +83,6 @@ export const EditStationModal = observer(
|
||||
defaultValue={station?.offset_y}
|
||||
/>
|
||||
</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>
|
||||
<DialogActions sx={{ p: 3, justifyContent: "flex-end" }}>
|
||||
<Button onClick={handleSave} variant="contained" color="primary">
|
||||
|
||||
168
src/widgets/modals/EditStationTransfersModal/index.tsx
Normal file
168
src/widgets/modals/EditStationTransfersModal/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from "./SelectArticleDialog";
|
||||
export * from "./EditStationModal";
|
||||
export * from "./EditStationTransfersModal";
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -2,12 +2,10 @@ import { defineConfig, type UserConfigExport } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import path from "path";
|
||||
import pkg from "./package.json";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
tailwindcss(),
|
||||
],
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@shared": path.resolve(__dirname, "src/shared"),
|
||||
@@ -18,9 +16,11 @@ export default defineConfig({
|
||||
"@app": path.resolve(__dirname, "src/app"),
|
||||
},
|
||||
},
|
||||
define: {
|
||||
__APP_VERSION__: JSON.stringify(pkg.version),
|
||||
},
|
||||
|
||||
build: {
|
||||
chunkSizeWarningLimit: 5000,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -4107,6 +4107,11 @@ utility-types@^3.11.0:
|
||||
resolved "https://registry.npmjs.org/utility-types/-/utility-types-3.11.0.tgz"
|
||||
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:
|
||||
version "5.0.3"
|
||||
resolved "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz"
|
||||
|
||||
Reference in New Issue
Block a user