12 Commits

42 changed files with 1784 additions and 745 deletions

3
.env
View File

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

View File

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

View File

@@ -51,7 +51,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 +61,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}</>;
};

View File

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

View File

@@ -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) {
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">

View File

@@ -6,13 +6,20 @@ 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,
LoadingSpinner,
} from "@shared";
import { useState, useEffect } from "react";
import { ImageUploadCard, LanguageSwitcher, DeleteModal } from "@widgets";
import {
@@ -28,6 +35,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,6 +47,12 @@ export const CarrierEditPage = observer(() => {
useEffect(() => {
(async () => {
if (!id) {
setIsLoadingData(false);
return;
}
setIsLoadingData(true);
try {
await cityStore.getCities("ru");
await cityStore.getCities("en");
await cityStore.getCities("zh");
@@ -71,7 +85,10 @@ export const CarrierEditPage = observer(() => {
);
}
mediaStore.getMedia();
await mediaStore.getMedia();
} finally {
setIsLoadingData(false);
}
})();
languageStore.setLanguage("ru");
@@ -110,6 +127,21 @@ export const CarrierEditPage = observer(() => {
? mediaStore.media.find((m) => m.id === editCarrierData.logo)
: 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">
<LanguageSwitcher />

View File

@@ -6,6 +6,7 @@ import {
MenuItem,
FormControl,
InputLabel,
Box,
} from "@mui/material";
import { observer } from "mobx-react-lite";
import { ArrowLeft, Save } from "lucide-react";
@@ -18,6 +19,7 @@ import {
languageStore,
mediaStore,
CashedCities,
LoadingSpinner,
} from "@shared";
import { useEffect, useState } from "react";
import { LanguageSwitcher, ImageUploadCard } from "@widgets";
@@ -30,6 +32,7 @@ import {
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,6 +65,8 @@ export const CityEditPage = observer(() => {
useEffect(() => {
(async () => {
if (id) {
setIsLoadingData(true);
try {
await getCountries("ru");
const ruData = await getCity(id as string, "ru");
@@ -75,6 +80,11 @@ export const CityEditPage = observer(() => {
await getOneMedia(ruData.arms as string);
await getMedia();
} finally {
setIsLoadingData(false);
}
} else {
setIsLoadingData(false);
}
})();
}, [id]);
@@ -97,6 +107,21 @@ export const CityEditPage = observer(() => {
? mediaStore.media.find((m) => m.id === editCityData.arms)
: 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">
<LanguageSwitcher />

View File

@@ -1,16 +1,17 @@
import { Button, Paper, TextField } from "@mui/material";
import { Button, Paper, TextField, Box } from "@mui/material";
import { observer } from "mobx-react-lite";
import { 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,6 +36,8 @@ export const CountryEditPage = observer(() => {
useEffect(() => {
(async () => {
if (id) {
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");
@@ -42,10 +45,30 @@ export const CountryEditPage = observer(() => {
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 />

View File

@@ -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,6 +39,8 @@ export const EditSightPage = observer(() => {
useEffect(() => {
const fetchData = async () => {
if (id) {
setIsLoadingData(true);
try {
await getCities("ru");
await getSightInfo(+id, "ru");
await getSightInfo(+id, "en");
@@ -40,6 +48,13 @@ export const EditSightPage = observer(() => {
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 && (
{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}

View File

@@ -488,16 +488,6 @@ 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 = "";
@@ -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,
@@ -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 !== "" &&

View File

@@ -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>
);
}

View File

@@ -39,6 +39,7 @@ import {
languageStore,
routeStore,
selectedCityStore,
stationsStore,
} from "@shared";
import { EditStationModal } from "../../widgets/modals/EditStationModal";
@@ -185,6 +186,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 +212,14 @@ const LinkedItemsContentsInner = <
authInstance
.post(`/${parentResource}/${parentId}/${childResource}`, {
stations: reorderedItems.map((item) => ({ id: item.id })),
stations: reorderedItems.map((item) => {
const stationData: any = { id: item.id };
const transfers = getStationTransfers(item.id, item.transfers);
if (transfers) {
stationData.transfers = transfers;
}
return stationData;
}),
})
.catch((error) => {
console.error("Error updating station order:", error);
@@ -245,11 +266,29 @@ 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 stationData: any = { id: item.id };
const transfers = getStationTransfers(item.id, item.transfers);
if (transfers) {
stationData.transfers = transfers;
}
return stationData;
}),
position,
{ id: selectedItemId }
(() => {
const newStationData: any = { id: selectedItemId };
const transfers = getStationTransfers(
selectedItemId,
selectedItem?.transfers
);
if (transfers) {
newStationData.transfers = transfers;
}
return newStationData;
})()
),
};
@@ -331,10 +370,25 @@ 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);
const stationData: any = { id };
const transfers = getStationTransfers(id, item?.transfers);
if (transfers) {
stationData.transfers = transfers;
}
return stationData;
});
const requestData = {
stations: [
...linkedItems.map((item) => ({ id: item.id })),
...linkedItems.map((item) => {
const stationData: any = { id: item.id };
const transfers = getStationTransfers(item.id, item.transfers);
if (transfers) {
stationData.transfers = transfers;
}
return stationData;
}),
...selectedStations,
],
};

View File

@@ -15,7 +15,7 @@ import {
} from "@mui/material";
import { MediaViewer, VideoPreviewCard } 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";
@@ -174,11 +174,6 @@ export const RouteCreatePage = observer(() => {
setIsLoading(false);
return;
}
if (!governorAppeal) {
toast.error("Выберите статью для обращения к пассажирам");
setIsLoading(false);
return;
}
const validationResult = validateCoordinates(routeCoords);
if (validationResult !== true) {
@@ -213,7 +208,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 +235,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,
@@ -251,6 +247,10 @@ export const RouteCreatePage = observer(() => {
videoPreview && videoPreview !== "" ? videoPreview : undefined,
};
if (governor_appeal !== undefined) {
newRoute.governor_appeal = governor_appeal;
}
await routeStore.createRoute(newRoute);
toast.success("Маршрут успешно создан");
navigate(-1);
@@ -382,6 +382,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)}

View File

@@ -15,7 +15,7 @@ import {
} from "@mui/material";
import { MediaViewer, VideoPreviewCard } 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";
@@ -27,6 +27,7 @@ import {
ArticleSelectOrCreateDialog,
SelectMediaDialog,
UploadMediaDialog,
LoadingSpinner,
} from "@shared";
import { toast } from "react-toastify";
import { stationsStore } from "@shared";
@@ -37,6 +38,7 @@ 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);
@@ -48,18 +50,27 @@ export const RouteEditPage = observer(() => {
useEffect(() => {
const fetchData = async () => {
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();
};
fetchData();
}, [id, language]);
@@ -91,10 +102,6 @@ export const RouteEditPage = observer(() => {
toast.error("Заполните номер маршрута в Говорящем Городе");
return;
}
if (!editRouteData.governor_appeal) {
toast.error("Выберите статью для обращения к пассажирам");
return;
}
const validationResult = validateCoordinates(coordinates);
if (validationResult !== true) {
@@ -233,6 +240,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 +527,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)}

View File

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

View File

@@ -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,6 +69,7 @@ export const LeftSidebar = observer(({ open, onToggle }: LeftSidebarProps) => {
display: open ? "flex" : "none",
}}
>
<div>
<Button
onClick={handleBack}
variant="contained"
@@ -78,6 +80,7 @@ export const LeftSidebar = observer(({ open, onToggle }: LeftSidebarProps) => {
borderRadius: 1.5,
px: 2,
py: 1,
marginBottom: 10,
"&:hover": {
backgroundColor: "#2d2d2d",
},
@@ -96,7 +99,7 @@ export const LeftSidebar = observer(({ open, onToggle }: LeftSidebarProps) => {
>
<div
style={{
maxWidth: 200,
maxWidth: 150,
display: "flex",
flexDirection: "column",
alignItems: "center",
@@ -120,19 +123,18 @@ export const LeftSidebar = observer(({ open, onToggle }: LeftSidebarProps) => {
</div>
</Stack>
<Stack
direction="column"
alignItems="center"
justifyContent="center"
spacing={2}
>
<Button variant="outlined" color="warning" fullWidth>
<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 variant="outlined" color="warning" fullWidth>
</button>
<button className="bg-white text-black px-4 py-2 rounded-md w-full font-medium mx-5">
Остановки
</Button>
</Stack>
</button>
</div>
</div>
<Stack
direction="column"
@@ -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} />

View File

@@ -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) {
@@ -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);
@@ -386,7 +404,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 +428,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 +460,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>
);
}

View File

@@ -1,21 +1,27 @@
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";
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 +364,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 +391,7 @@ export const WebGLRouteMapPrototype = observer(() => {
sightData,
setSelectedSight,
setStationOffset,
setStationAlign,
setSightCoordinates,
setMapCenter,
} = useMapData();
@@ -387,6 +410,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 +438,7 @@ export const WebGLRouteMapPrototype = observer(() => {
y: centerY - worldCenterY * clampedScale,
},
};
lastTransformRef.current = adjusted;
return adjusted;
}, []);
@@ -439,6 +464,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 +561,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 +618,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 +664,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;
if (options?.immediate) {
flushSync(() => {
setTransformState(adjusted);
setSharedScale(adjusted.scale);
});
} else {
setTransformState(adjusted);
setSharedScale(adjusted.scale);
}
computeCenterCoordinates(adjusted);
},
[clampTransformScale, setSharedScale, computeCenterCoordinates]
@@ -636,18 +719,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 +800,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 +1046,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 +1069,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 +1164,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 +1212,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);
}
} else {
const clamped = clampTransformScale(transform);
if (clamped !== transform) {
@@ -1129,13 +1240,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 +1444,78 @@ export const WebGLRouteMapPrototype = observer(() => {
skipNextAutoFitRef.current = false;
return;
}
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 +1673,7 @@ export const WebGLRouteMapPrototype = observer(() => {
y: transform.translation.y + dy,
},
};
updateTransform(next);
updateTransform(next, { immediate: true });
drawSceneRef.current();
};
@@ -1570,13 +1749,16 @@ export const WebGLRouteMapPrototype = observer(() => {
scaleLimitsRef.current.max
);
updateTransform({
updateTransform(
{
scale: clampedScale,
translation: {
x: midpoint.x - worldMidpoint.x * clampedScale,
y: midpoint.y - worldMidpoint.y * clampedScale,
},
});
},
{ immediate: true }
);
drawSceneRef.current();
}
};
@@ -1627,13 +1809,16 @@ export const WebGLRouteMapPrototype = observer(() => {
y: (position.y - transform.translation.y) / transform.scale,
};
updateTransform({
updateTransform(
{
scale: clampedScale,
translation: {
x: position.x - worldPoint.x * clampedScale,
y: position.y - worldPoint.y * clampedScale,
},
});
},
{ immediate: true }
);
drawSceneRef.current();
scheduleCenterCommit();
};
@@ -1722,12 +1907,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,13 +1938,45 @@ 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%)" };
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}>
<div
key={station.id}
onMouseEnter={() => setHoveredStationId(station.id)}
onMouseLeave={() =>
setHoveredStationId((prev) =>
prev === station.id ? null : prev
)
}
onPointerDown={(event) =>
handleStationPointerDown(
event,
@@ -1764,7 +1992,7 @@ export const WebGLRouteMapPrototype = observer(() => {
position: "absolute",
left: cssX,
top: cssY,
transform: "translate(0, -50%)",
transform: transformCss,
color: "#fff",
fontFamily: "Roboto, sans-serif",
textAlign: "left",
@@ -1787,6 +2015,13 @@ export const WebGLRouteMapPrototype = observer(() => {
transformOrigin: "left center",
transform: `rotate(${counterRotationCss})`,
}}
>
<div
style={{
position: "relative",
display: "inline-block",
pointerEvents: "none",
}}
>
<div
style={{
@@ -1794,6 +2029,7 @@ export const WebGLRouteMapPrototype = observer(() => {
fontSize: primaryFontSize,
textShadow: "0 0 4px rgba(0,0,0,0.6)",
pointerEvents: "none",
whiteSpace: "nowrap",
}}
>
{station.name}
@@ -1801,11 +2037,16 @@ export const WebGLRouteMapPrototype = observer(() => {
{showSecondary ? (
<div
style={{
fontWeight: 400,
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",
}}
>
@@ -1815,6 +2056,68 @@ export const WebGLRouteMapPrototype = observer(() => {
</div>
</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>
);
})}
</div>

View File

@@ -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);
}
}
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];
});
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();
idsToLink.forEach((id) => remaining.delete(id));
return remaining;
});
if (failedIds.length > 0) {
setError(
failedIds.length === idsToLink.length
? "Failed to link stations"
: "Some stations failed to link"
);
onUpdate?.();
} 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[] = [];
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);
}
await authInstance.delete(
`/${parentResource}/${parentId}/${childResource}`,
{
data: buildPayload(idsToDetach),
}
);
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%",
},
}}
/>

View File

@@ -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);
@@ -42,10 +43,24 @@ export const SnapshotCreatePage = observer(() => {
onClick={async () => {
try {
setIsLoading(true);
await createSnapshot(name);
setIsLoading(false);
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("Ошибка при создании снапшота");
@@ -56,7 +71,15 @@ export const SnapshotCreatePage = observer(() => {
disabled={isLoading || !name.trim()}
>
{isLoading ? (
<div className="flex items-center gap-2">
<Loader2 size={20} className="animate-spin" />
<span>
{snapshotStatus?.Progress
? (snapshotStatus.Progress * 100).toFixed(2)
: 0}
%
</span>
</div>
) : (
"Сохранить"
)}

View File

@@ -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,
}
buildPayload(idsToLink)
);
linkedIds.push(id);
} catch (error) {
console.error("Error linking sight:", error);
failedIds.push(id);
}
}
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];
});
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();
idsToLink.forEach((id) => remaining.delete(id));
return remaining;
});
if (failedIds.length > 0) {
setError(
failedIds.length === idsToLink.length
? "Failed to link sights"
: "Some sights failed to link"
);
onUpdate?.();
} 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[] = [];
for (const itemId of idsToDetach) {
try {
await authInstance.delete(
`/${parentResource}/${parentId}/${childResource}`,
{
data: { [`${childResource}_id`]: itemId },
data: buildPayload(idsToDetach),
}
);
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) => {

View File

@@ -6,13 +6,19 @@ 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 { stationsStore, languageStore, cityStore } from "@shared";
import {
stationsStore,
languageStore,
cityStore,
LoadingSpinner,
} from "@shared";
import { useEffect, useState } from "react";
import { LanguageSwitcher } from "@widgets";
import { LinkedSights } from "../LinkedSights";
@@ -21,6 +27,7 @@ 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 {
@@ -90,18 +97,41 @@ export const StationEditPage = observer(() => {
useEffect(() => {
const fetchAndSetStationData = async () => {
if (!id) return;
if (!id) {
setIsLoadingData(false);
return;
}
setIsLoadingData(true);
try {
const stationId = Number(id);
await getEditStation(stationId);
await getCities("ru");
await getCities("en");
await getCities("zh");
} 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">
<LanguageSwitcher />

View File

@@ -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,7 +23,11 @@ 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 { language } = languageStore;
@@ -51,8 +60,8 @@ export const StationListPage = observer(() => {
},
},
{
field: "system_name",
headerName: "Системное название",
field: "description",
headerName: "Описание",
flex: 1,
renderCell: (params: GridRenderCellParams) => {
return (
@@ -88,7 +97,7 @@ export const StationListPage = observer(() => {
{
field: "actions",
headerName: "Действия",
width: 140,
width: 200,
align: "center",
headerAlign: "center",
sortable: false,
@@ -102,6 +111,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);
@@ -130,7 +148,7 @@ export const StationListPage = observer(() => {
const rows = filteredStations().map((station: any) => ({
id: station.id,
name: station.name,
system_name: station.system_name,
description: station.description,
direction: station.direction,
}));
@@ -205,6 +223,15 @@ export const StationListPage = observer(() => {
setIsBulkDeleteModalOpen(false);
}}
/>
<EditStationTransfersModal
open={isTransfersModalOpen}
onClose={() => {
setIsTransfersModalOpen(false);
setSelectedStationId(null);
}}
stationId={selectedStationId}
/>
</>
);
});

View File

@@ -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) {
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 />

View File

@@ -4,18 +4,20 @@ 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 { useNavigate, useParams } from "react-router-dom";
import { toast } from "react-toastify";
import { userStore, languageStore } from "@shared";
import { userStore, languageStore, LoadingSpinner } from "@shared";
import { useEffect, useState } from "react";
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;
@@ -41,6 +43,8 @@ export const UserEditPage = observer(() => {
useEffect(() => {
(async () => {
if (id) {
setIsLoadingData(true);
try {
const data = await getUser(Number(id));
setEditUserData(
@@ -49,10 +53,30 @@ export const UserEditPage = observer(() => {
data?.password || "",
data?.is_admin || false
);
} 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">
<div className="flex items-center gap-4">

View File

@@ -86,7 +86,11 @@ class EditSightStore {
}
hasLoadedCommon = false;
isLoading = false;
getSightInfo = async (id: number, language: Language) => {
this.isLoading = true;
try {
const response = await languageInstance(language).get(`/sight/${id}`);
const data = response.data;
@@ -108,6 +112,9 @@ class EditSightStore {
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) {}
};
createLinkWithArticle = async (media: {

View File

@@ -1,5 +1,5 @@
import { makeAutoObservable, runInAction } from "mobx";
import { authInstance } from "@shared";
import { authInstance, languageInstance, languageStore } from "@shared";
export type Route = {
route_name: string;
@@ -89,11 +89,43 @@ class RouteStore {
};
saveRouteStations = async (routeId: number, stationId: number) => {
await authInstance.patch(`/route/${routeId}/station`, {
...this.routeStations[routeId]?.find(
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
);
}
});
};
@@ -123,11 +155,18 @@ class RouteStore {
if (!this.editRouteData.video_preview) {
delete this.editRouteData.video_preview;
}
const response = await authInstance.patch(`/route/${id}`, {
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;

View File

@@ -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;
});
};
}

View File

@@ -1,5 +1,6 @@
import { authInstance, languageInstance, languageStore } from "@shared";
import { makeAutoObservable, runInAction } from "mobx";
import { routeStore } from "../RouteStore";
type Language = "ru" | "en" | "zh";
@@ -546,6 +547,99 @@ class StationsStore {
},
};
};
updateStationTransfers = async (
id: number,
transfers: {
bus: string;
metro_blue: string;
metro_green: string;
metro_orange: string;
metro_purple: string;
metro_red: string;
train: string;
tram: string;
trolleybus: string;
}
) => {
const { language } = languageStore;
// Получаем данные станции для текущего языка
const response = await languageInstance(language).get(`/station/${id}`);
const stationData = response.data as Station;
if (!stationData) {
throw new Error("Station not found");
}
// Формируем commonDataPayload как в editStation, с обновленными transfers
const commonDataPayload = {
city_id: stationData.city_id,
direction: stationData.direction,
latitude: stationData.latitude,
longitude: stationData.longitude,
offset_x: stationData.offset_x,
offset_y: stationData.offset_y,
transfers: transfers,
city: stationData.city || "",
};
// Отправляем один PATCH запрос, так как пересадки общие для всех языков
const patchResponse = await languageInstance(language).patch(
`/station/${id}`,
{
name: stationData.name || "",
system_name: stationData.system_name || "",
description: stationData.description || "",
address: stationData.address || "",
...commonDataPayload,
}
);
// Обновляем данные для всех языков в локальном состоянии
runInAction(() => {
const updatedTransfers = patchResponse.data.transfers;
for (const lang of ["ru", "en", "zh"] as const) {
if (this.stationPreview[id]) {
this.stationPreview[id][lang] = {
...this.stationPreview[id][lang],
data: {
...this.stationPreview[id][lang].data,
transfers: updatedTransfers,
},
};
}
if (this.stationLists[lang].data) {
this.stationLists[lang].data = this.stationLists[lang].data.map(
(station: Station) =>
station.id === id
? {
...station,
transfers: updatedTransfers,
}
: station
);
}
}
// Обновляем пересадки в RouteStore.routeStations для всех маршрутов
if (routeStore?.routeStations) {
for (const routeId in routeStore.routeStations) {
routeStore.routeStations[routeId] = routeStore.routeStations[
routeId
].map((station: any) =>
station.id === id
? {
...station,
transfers: updatedTransfers,
}
: station
);
}
}
});
};
}
export const stationsStore = new StationsStore();

View File

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

View File

@@ -5,7 +5,7 @@ import TableContainer from "@mui/material/TableContainer";
import TableHead from "@mui/material/TableHead";
import TableRow from "@mui/material/TableRow";
import Paper from "@mui/material/Paper";
import { Check, Copy, RotateCcw, X } from "lucide-react";
import { Check, Copy, RotateCcw, Trash2, X } from "lucide-react";
import {
authInstance,
devicesStore,
@@ -19,6 +19,7 @@ import { Button, Checkbox, Typography } from "@mui/material";
import { Vehicle } from "@shared";
import { toast } from "react-toastify";
import { useNavigate } from "react-router-dom";
import { DeleteModal } from "@widgets";
export type ConnectedDevice = string;
@@ -108,10 +109,11 @@ export const DevicesTable = observer(() => {
} = devicesStore;
const { snapshots, getSnapshots } = snapshotStore;
const { getVehicles, vehicles } = vehicleStore;
const { getVehicles, vehicles, deleteVehicle } = vehicleStore;
const { devices } = devicesStore;
const navigate = useNavigate();
const [selectedDeviceUuids, setSelectedDeviceUuids] = useState<string[]>([]);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const currentTableRows = transformDevicesToRows(vehicles.data as Vehicle[]);
@@ -200,6 +202,30 @@ export const DevicesTable = observer(() => {
}
};
const getVehicleIdsByUuids = (uuids: string[]): number[] => {
return vehicles.data
.filter((vehicle) => uuids.includes(vehicle.vehicle.uuid ?? ""))
.map((vehicle) => vehicle.vehicle.id);
};
const handleDeleteVehicles = async () => {
if (selectedDeviceUuids.length === 0) return;
const vehicleIds = getVehicleIdsByUuids(selectedDeviceUuids);
try {
await Promise.all(vehicleIds.map((id) => deleteVehicle(id)));
await getVehicles();
await getDevices();
setSelectedDeviceUuids([]);
setIsDeleteModalOpen(false);
toast.success(`Удалено устройств: ${vehicleIds.length}`);
} catch (error) {
console.error("Error deleting vehicles:", error);
toast.error("Ошибка при удалении устройств");
}
};
return (
<>
<TableContainer component={Paper} sx={{ mt: 2 }}>
@@ -213,7 +239,7 @@ export const DevicesTable = observer(() => {
Добавить устройство
</Button>
</div>
<div className="flex justify-end p-3 gap-2">
<div className="flex justify-end p-3 gap-2 items-center">
<Button
variant="outlined"
onClick={handleSelectAllDevices}
@@ -221,6 +247,17 @@ export const DevicesTable = observer(() => {
>
{isAllSelected ? "Снять выбор" : "Выбрать все"}
</Button>
{selectedDeviceUuids.length > 0 && (
<Button
variant="contained"
color="error"
onClick={() => setIsDeleteModalOpen(true)}
size="small"
startIcon={<Trash2 size={16} />}
>
Удалить ({selectedDeviceUuids.length})
</Button>
)}
<Button
variant="contained"
color="primary"
@@ -451,6 +488,12 @@ export const DevicesTable = observer(() => {
Отмена
</Button>
</Modal>
<DeleteModal
open={isDeleteModalOpen}
onDelete={handleDeleteVehicles}
onCancel={() => setIsDeleteModalOpen(false)}
/>
</>
);
});

View File

@@ -10,23 +10,25 @@ import { observer } from "mobx-react-lite";
import { useState, DragEvent, useRef } from "react";
import { toast } from "react-toastify";
export const MediaArea = observer(
({
articleId,
mediaIds,
deleteMedia,
onFilesDrop, // 👈 Проп для обработки загруженных файлов
setSelectMediaDialogOpen,
}: {
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,
setSelectMediaDialogOpen,
}: 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,13 +36,11 @@ export const MediaArea = observer(
setMediaId(mediaId);
};
const handleDrop = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
const processFiles = (files: File[]) => {
if (!files.length || !onFilesDrop) {
return;
}
const files = Array.from(e.dataTransfer.files);
if (files.length && onFilesDrop) {
const { validFiles, errors } = filterValidFiles(files);
if (errors.length > 0) {
@@ -50,7 +50,15 @@ export const MediaArea = observer(
if (validFiles.length > 0) {
onFilesDrop(validFiles);
}
}
};
const handleDrop = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
const files = Array.from(e.dataTransfer.files);
processFiles(files);
};
const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
@@ -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);
}
}
// Сбрасываем значение input, чтобы можно было выбрать тот же файл снова
if (event.target) {
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,12 +119,14 @@ export const MediaArea = observer(
</Button>
</div>
{mediaIds.length > 0 && (
<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)}
type="button"
>
<MediaViewer
media={{
@@ -133,17 +137,20 @@ export const MediaArea = observer(
height="40px"
/>
<button
className="absolute top-2 right-2"
className="absolute top-2 right-2 bg-white rounded-full p-1 shadow-md hover:shadow-lg transition-shadow"
onClick={(e) => {
e.stopPropagation();
deleteMedia(articleId, m.id);
}}
type="button"
aria-label="Удалить медиа"
>
<X size={16} color="red" />
</button>
</button>
))}
</div>
)}
</Box>
<PreviewMediaDialog

View File

@@ -11,52 +11,72 @@ import { observer } from "mobx-react-lite";
import { useState, DragEvent, useRef } from "react";
import { toast } from "react-toastify";
export const MediaAreaForSight = observer(
({
onFilesDrop, // 👈 Проп для обработки загруженных файлов
onFinishUpload,
contextObjectName,
contextType,
isArticle,
articleName,
}: {
onFilesDrop?: (files: File[]) => void;
onFinishUpload?: (mediaId: string) => void;
contextObjectName?: string;
contextType?:
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;
}) => {
const [selectMediaDialogOpen, setSelectMediaDialogOpen] = useState(false);
const [uploadMediaDialogOpen, setUploadMediaDialogOpen] = useState(false);
const [isDragging, setIsDragging] = useState(false);
}
export const MediaAreaForSight = observer(
({
onFilesDrop,
onFinishUpload,
contextObjectName,
contextType,
isArticle,
articleName,
}: MediaAreaForSightProps) => {
const [selectMediaDialogOpen, setSelectMediaDialogOpen] =
useState<boolean>(false);
const [uploadMediaDialogOpen, setUploadMediaDialogOpen] =
useState<boolean>(false);
const [isDragging, setIsDragging] = useState<boolean>(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const { 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, чтобы можно было выбрать тот же файл снова
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"

View File

@@ -117,6 +117,7 @@ export function MediaViewer({
}/download?token=${token}`}
alt={media?.filename}
style={{
width: "100%",
objectFit: "cover",
}}
/>
@@ -127,8 +128,8 @@ export function MediaViewer({
src={`${import.meta.env.VITE_KRBL_MEDIA}${
media?.id
}/download?token=${token}`}
width={width ? width : "500px"}
height={height ? height : "300px"}
width={fullWidth ? "100%" : width ? width : "500px"}
height={fullHeight ? "100%" : height ? height : "300px"}
/>
)}

View File

@@ -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,7 +434,6 @@ 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">
@@ -462,11 +461,19 @@ export const CreateRightTab = observer(
</Box>
</>
)}
</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);
@@ -476,8 +483,13 @@ export const CreateRightTab = observer(
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">
<Typography variant="h6" color="text.secondary">

View File

@@ -275,7 +275,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"
/>
)}

View File

@@ -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>
)}
</>

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

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