5 Commits

55 changed files with 5132 additions and 1266 deletions

View File

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

View File

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

View File

@@ -74,7 +74,6 @@ export const CarrierEditPage = observer(() => {
mediaStore.getMedia(); mediaStore.getMedia();
})(); })();
// Устанавливаем русский язык при загрузке страницы
languageStore.setLanguage("ru"); languageStore.setLanguage("ru");
}, [id]); }, [id]);

View File

@@ -44,7 +44,6 @@ export const CityEditPage = observer(() => {
const { getMedia, getOneMedia } = mediaStore; const { getMedia, getOneMedia } = mediaStore;
useEffect(() => { useEffect(() => {
// Устанавливаем русский язык при загрузке страницы
languageStore.setLanguage("ru"); languageStore.setLanguage("ru");
}, []); }, []);
@@ -64,12 +63,11 @@ export const CityEditPage = observer(() => {
(async () => { (async () => {
if (id) { if (id) {
await getCountries("ru"); await getCountries("ru");
// Fetch data for all languages
const ruData = await getCity(id as string, "ru"); const ruData = await getCity(id as string, "ru");
const enData = await getCity(id as string, "en"); const enData = await getCity(id as string, "en");
const zhData = await getCity(id as string, "zh"); const zhData = await getCity(id as string, "zh");
// Set data for each language
setEditCityData(ruData.name, ruData.country_code, ruData.arms, "ru"); setEditCityData(ruData.name, ruData.country_code, ruData.arms, "ru");
setEditCityData(enData.name, enData.country_code, enData.arms, "en"); setEditCityData(enData.name, enData.country_code, enData.arms, "en");
setEditCityData(zhData.name, zhData.country_code, zhData.arms, "zh"); setEditCityData(zhData.name, zhData.country_code, zhData.arms, "zh");
@@ -207,7 +205,7 @@ export const CityEditPage = observer(() => {
open={isSelectMediaOpen} open={isSelectMediaOpen}
onClose={() => setIsSelectMediaOpen(false)} onClose={() => setIsSelectMediaOpen(false)}
onSelectMedia={handleMediaSelect} onSelectMedia={handleMediaSelect}
mediaType={1} // Тип медиа для иконок mediaType={1}
/> />
<UploadMediaDialog <UploadMediaDialog

View File

@@ -17,7 +17,6 @@ export const CountryEditPage = observer(() => {
countryStore; countryStore;
useEffect(() => { useEffect(() => {
// Устанавливаем русский язык при загрузке страницы
languageStore.setLanguage("ru"); languageStore.setLanguage("ru");
}, []); }, []);
@@ -36,12 +35,10 @@ export const CountryEditPage = observer(() => {
useEffect(() => { useEffect(() => {
(async () => { (async () => {
if (id) { if (id) {
// Fetch data for all languages
const ruData = await getCountry(id as string, "ru"); const ruData = await getCountry(id as string, "ru");
const enData = await getCountry(id as string, "en"); const enData = await getCountry(id as string, "en");
const zhData = await getCountry(id as string, "zh"); const zhData = await getCountry(id as string, "zh");
// Set data for each language
setEditCountryData(ruData.name, "ru"); setEditCountryData(ruData.name, "ru");
setEditCountryData(enData.name, "en"); setEditCountryData(enData.name, "en");
setEditCountryData(zhData.name, "zh"); setEditCountryData(zhData.name, "zh");

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -38,8 +38,8 @@ export const RouteCreatePage = observer(() => {
const [govRouteNumber, setGovRouteNumber] = useState(""); const [govRouteNumber, setGovRouteNumber] = useState("");
const [governorAppeal, setGovernorAppeal] = useState<string>(""); const [governorAppeal, setGovernorAppeal] = useState<string>("");
const [direction, setDirection] = useState("backward"); const [direction, setDirection] = useState("backward");
const [scaleMin, setScaleMin] = useState(""); const [scaleMin, setScaleMin] = useState("10");
const [scaleMax, setScaleMax] = useState(""); const [scaleMax, setScaleMax] = useState("100");
const [routeName, setRouteName] = useState(""); const [routeName, setRouteName] = useState("");
const [turn, setTurn] = useState(""); const [turn, setTurn] = useState("");
const [centerLat, setCenterLat] = useState(""); const [centerLat, setCenterLat] = useState("");
@@ -113,7 +113,6 @@ export const RouteCreatePage = observer(() => {
const handleArticleSelect = (articleId: number) => { const handleArticleSelect = (articleId: number) => {
setGovernorAppeal(articleId.toString()); setGovernorAppeal(articleId.toString());
setIsSelectArticleDialogOpen(false); setIsSelectArticleDialogOpen(false);
// Обновляем список статей после создания новой
articlesStore.getArticleList(); articlesStore.getArticleList();
}; };
@@ -154,23 +153,72 @@ export const RouteCreatePage = observer(() => {
const handleCreateRoute = async () => { const handleCreateRoute = async () => {
try { try {
setIsLoading(true); setIsLoading(true);
// Преобразуем значения в нужные типы
if (!routeName.trim()) {
toast.error("Заполните название маршрута");
setIsLoading(false);
return;
}
if (!carrier) {
toast.error("Выберите перевозчика");
setIsLoading(false);
return;
}
if (!routeNumber.trim()) {
toast.error("Заполните номер маршрута");
setIsLoading(false);
return;
}
if (!govRouteNumber.trim()) {
toast.error("Заполните номер маршрута в Говорящем Городе");
setIsLoading(false);
return;
}
if (!governorAppeal) {
toast.error("Выберите статью для обращения к пассажирам");
setIsLoading(false);
return;
}
const validationResult = validateCoordinates(routeCoords);
if (validationResult !== true) {
toast.error(validationResult);
setIsLoading(false);
return;
}
const scale_min = scaleMin ? Number(scaleMin) : null;
const scale_max = scaleMax ? Number(scaleMax) : null;
if (
scale_min === 0 ||
scale_max === 0 ||
scale_min === null ||
scale_max === null
) {
toast.error("Масштабы не могут быть равны 0");
setIsLoading(false);
return;
}
if (
scale_min !== null &&
scale_max !== null &&
scale_max !== undefined &&
scale_min > scale_max
) {
toast.error("Максимальный масштаб не может быть меньше минимального");
setIsLoading(false);
return;
}
const carrier_id = Number(carrier); const carrier_id = Number(carrier);
const governor_appeal = Number(governorAppeal); const governor_appeal = Number(governorAppeal);
const scale_min = scaleMin ? Number(scaleMin) : undefined;
const scale_max = scaleMax ? Number(scaleMax) : undefined;
const rotate = turn ? Number(turn) : undefined; const rotate = turn ? Number(turn) : undefined;
const center_latitude = centerLat ? Number(centerLat) : undefined; const center_latitude = centerLat ? Number(centerLat) : undefined;
const center_longitude = centerLng ? Number(centerLng) : undefined; const center_longitude = centerLng ? Number(centerLng) : undefined;
const route_direction = direction === "forward"; const route_direction = direction === "forward";
const validationResult = validateCoordinates(routeCoords);
if (validationResult !== true) {
toast.error(validationResult);
return;
}
// Координаты маршрута как массив массивов чисел
const path = routeCoords const path = routeCoords
.trim() .trim()
.split("\n") .split("\n")
@@ -182,7 +230,6 @@ export const RouteCreatePage = observer(() => {
return [lat, lon]; return [lat, lon];
}); });
// Собираем объект маршрута
const newRoute: Partial<Route> = { const newRoute: Partial<Route> = {
carrier: carrier:
carrierStore.carriers[ carrierStore.carriers[
@@ -194,8 +241,8 @@ export const RouteCreatePage = observer(() => {
governor_appeal, governor_appeal,
route_name: routeName, route_name: routeName,
route_direction, route_direction,
scale_min, scale_min: scale_min !== null ? scale_min : 0,
scale_max, scale_max: scale_max !== null ? scale_max : 0,
rotate, rotate,
center_latitude, center_latitude,
center_longitude, center_longitude,
@@ -215,7 +262,6 @@ export const RouteCreatePage = observer(() => {
} }
}; };
// Получаем название выбранной статьи для отображения
const selectedArticle = articlesStore.articleList.ru.data.find( const selectedArticle = articlesStore.articleList.ru.data.find(
(article) => article.id === Number(governorAppeal) (article) => article.id === Number(governorAppeal)
); );
@@ -371,14 +417,51 @@ export const RouteCreatePage = observer(() => {
<TextField <TextField
className="w-full" className="w-full"
label="Масштаб (мин)" label="Масштаб (мин)"
type="number"
value={scaleMin} value={scaleMin}
onChange={(e) => setScaleMin(e.target.value)} onChange={(e) => {
let value = e.target.value;
if (Number(value) > 297) {
value = "297";
}
if (Number(value) < 10) {
value = "10";
}
setScaleMin(value);
if (value && scaleMax && Number(value) > Number(scaleMax)) {
setScaleMax(value);
}
}}
error={
scaleMin !== "" &&
scaleMax !== "" &&
Number(scaleMin) > Number(scaleMax)
}
required
helperText={
scaleMin !== "" &&
scaleMax !== "" &&
Number(scaleMin) > Number(scaleMax)
? "Минимальный масштаб не может быть больше максимального"
: ""
}
/> />
<TextField <TextField
className="w-full" className="w-full"
label="Масштаб (макс)" label="Масштаб (макс)"
type="number"
value={scaleMax} value={scaleMax}
onChange={(e) => setScaleMax(e.target.value)} required
onChange={(e) => {
if (Number(e.target.value) > 300) {
e.target.value = "300";
}
const value = e.target.value;
setScaleMax(value);
}}
/> />
<TextField <TextField

View File

@@ -74,10 +74,67 @@ export const RouteEditPage = observer(() => {
}, [editRouteData.path]); }, [editRouteData.path]);
const handleSave = async () => { const handleSave = async () => {
// Валидация обязательных полей
if (!editRouteData.route_name?.trim()) {
toast.error("Заполните название маршрута");
return;
}
if (!editRouteData.carrier_id) {
toast.error("Выберите перевозчика");
return;
}
if (!editRouteData.route_number?.trim()) {
toast.error("Заполните номер маршрута");
return;
}
if (!editRouteData.route_sys_number?.trim()) {
toast.error("Заполните номер маршрута в Говорящем Городе");
return;
}
if (!editRouteData.governor_appeal) {
toast.error("Выберите статью для обращения к пассажирам");
return;
}
const validationResult = validateCoordinates(coordinates);
if (validationResult !== true) {
toast.error(validationResult);
return;
}
// Валидация масштабов
if (
editRouteData.scale_min !== null &&
editRouteData.scale_min !== undefined &&
editRouteData.scale_max !== null &&
editRouteData.scale_max !== undefined &&
editRouteData.scale_min > editRouteData.scale_max
) {
toast.error("Максимальный масштаб не может быть меньше минимального");
return;
}
if (
editRouteData.scale_min === 0 ||
editRouteData.scale_max === 0 ||
editRouteData.scale_min === null ||
editRouteData.scale_max === null
) {
toast.error("Масштабы не могут быть равны 0");
setIsLoading(false);
return;
}
setIsLoading(true); setIsLoading(true);
try {
await routeStore.editRoute(Number(id)); await routeStore.editRoute(Number(id));
toast.success("Маршрут успешно сохранен"); toast.success("Маршрут успешно сохранен");
} catch (error) {
console.error(error);
toast.error("Произошла ошибка при сохранении маршрута");
} finally {
setIsLoading(false); setIsLoading(false);
}
}; };
const validateCoordinates = (value: string) => { const validateCoordinates = (value: string) => {
@@ -333,23 +390,69 @@ export const RouteEditPage = observer(() => {
<TextField <TextField
className="w-full" className="w-full"
label="Масштаб (мин)" label="Масштаб (мин)"
type="number"
value={editRouteData.scale_min ?? ""} value={editRouteData.scale_min ?? ""}
onChange={(e) => onChange={(e) => {
routeStore.setEditRouteData({ let value = e.target.value === "" ? null : e.target.value;
scale_min:
e.target.value === "" ? null : parseFloat(e.target.value), if (value && Number(value) > 297) {
}) value = "297";
} }
if (value && Number(value) < 10) {
value = "10";
}
routeStore.setEditRouteData({
scale_min: value ? Number(value) : null,
});
// Если максимальный масштаб стал меньше минимального, обновляем его
if (
value !== null &&
editRouteData.scale_max !== null &&
editRouteData.scale_max !== undefined &&
value &&
Number(value) > (editRouteData.scale_max ?? 0)
) {
routeStore.setEditRouteData({
scale_max: value ? Number(value) : null,
});
}
}}
required
/> />
<TextField <TextField
className="w-full" className="w-full"
required
label="Масштаб (макс)" label="Масштаб (макс)"
type="number"
value={editRouteData.scale_max ?? ""} value={editRouteData.scale_max ?? ""}
onChange={(e) => onChange={(e) => {
let value = e.target.value;
if (Number(value) > 300) {
value = "300";
}
routeStore.setEditRouteData({ routeStore.setEditRouteData({
scale_max: scale_max: value === "" ? null : parseFloat(value),
e.target.value === "" ? null : parseFloat(e.target.value), });
}) }}
error={
editRouteData.scale_min !== null &&
editRouteData.scale_min !== undefined &&
editRouteData.scale_max !== null &&
editRouteData.scale_max !== undefined &&
editRouteData.scale_max < editRouteData.scale_min
}
helperText={
editRouteData.scale_min !== null &&
editRouteData.scale_min !== undefined &&
editRouteData.scale_max !== null &&
editRouteData.scale_max !== undefined &&
editRouteData.scale_max < editRouteData.scale_min
? "Максимальный масштаб не может быть меньше минимального"
: ""
} }
/> />
<TextField <TextField

View File

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

View File

@@ -1,12 +1,19 @@
import { Stack, Typography, Button } from "@mui/material"; import { Box, Stack, Typography, Button } from "@mui/material";
import { useNavigate, useNavigationType } from "react-router"; import { useNavigate, useNavigationType } from "react-router";
import { MediaViewer } from "@widgets"; import { MediaViewer } from "@widgets";
import { useMapData } from "./MapDataContext"; import { useMapData } from "./MapDataContext";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { authInstance } from "@shared"; import { authInstance } from "@shared";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import LanguageSelector from "./web-gl/LanguageSelector";
export const LeftSidebar = observer(() => { type LeftSidebarProps = {
open: boolean;
onToggle: () => void;
};
export const LeftSidebar = observer(({ open, onToggle }: LeftSidebarProps) => {
const navigate = useNavigate(); const navigate = useNavigate();
const navigationType = useNavigationType(); // PUSH, POP, REPLACE const navigationType = useNavigationType(); // PUSH, POP, REPLACE
const { routeData } = useMapData(); const { routeData } = useMapData();
@@ -35,32 +42,57 @@ export const LeftSidebar = observer(() => {
}; };
return ( return (
<Stack direction="column" width="300px" p={2} bgcolor="primary.main"> <Box
<button sx={{
onClick={handleBack} position: "relative",
type="button" height: "100%",
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
gap: 10,
color: "#fff", color: "#fff",
backgroundColor: "#222", transition: "padding 0.3s ease",
borderRadius: 10, p: open ? 2 : 0,
height: 40, display: "flex",
width: "100%", flexDirection: "column",
border: "none", alignItems: "stretch",
cursor: "pointer", justifyContent: "flex-start",
}} }}
> >
<p>Назад</p> <Stack
</button> direction="column"
height="100%"
width="100%"
spacing={4}
alignItems="stretch"
sx={{
opacity: open ? 1 : 0,
transition: "opacity 0.25s ease",
pointerEvents: open ? "auto" : "none",
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 <Stack
direction="column" direction="column"
alignItems="center" alignItems="center"
justifyContent="center" justifyContent="center"
my={10} spacing={3}
> >
<div <div
style={{ style={{
@@ -82,9 +114,9 @@ export const LeftSidebar = observer(() => {
fullHeight fullHeight
/> />
)} )}
<Typography sx={{ mb: 2, color: "#fff" }} textAlign="center"> <Typography sx={{ color: "#fff" }} textAlign="center">
При поддержке Правительства При поддержке Правительства
</Typography>{" "} </Typography>
</div> </div>
</Stack> </Stack>
@@ -92,7 +124,6 @@ export const LeftSidebar = observer(() => {
direction="column" direction="column"
alignItems="center" alignItems="center"
justifyContent="center" justifyContent="center"
my={10}
spacing={2} spacing={2}
> >
<Button variant="outlined" color="warning" fullWidth> <Button variant="outlined" color="warning" fullWidth>
@@ -108,7 +139,7 @@ export const LeftSidebar = observer(() => {
alignItems="center" alignItems="center"
maxHeight={150} maxHeight={150}
justifyContent="center" justifyContent="center"
my={10} flexGrow={1}
> >
{carrierLogo && ( {carrierLogo && (
<MediaViewer <MediaViewer
@@ -122,14 +153,35 @@ export const LeftSidebar = observer(() => {
)} )}
</Stack> </Stack>
<Typography <Typography variant="h6" textAlign="center" sx={{ color: "#fff" }}>
variant="h6"
textAlign="center"
mt="auto"
sx={{ color: "#fff" }}
>
#ВсемПоПути #ВсемПоПути
</Typography> </Typography>
</Stack> </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",
}}
>
#ВсемПоПути
</Typography>
)}
<div className="absolute bottom-[20px] -right-[520px] z-10">
<LanguageSelector onBack={onToggle} isSidebarOpen={open} />
</div>
</Box>
); );
}); });

View File

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

View File

@@ -2,7 +2,6 @@ import { Button, Stack, TextField, Typography, Slider } from "@mui/material";
import { useMapData } from "./MapDataContext"; import { useMapData } from "./MapDataContext";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTransform } from "./TransformContext"; import { useTransform } from "./TransformContext";
import { coordinatesToLocal, localToCoordinates } from "./utils";
import { SCALE_FACTOR } from "./Constants"; import { SCALE_FACTOR } from "./Constants";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
@@ -13,18 +12,10 @@ export function RightSidebar() {
saveChanges, saveChanges,
originalRouteData, originalRouteData,
setMapRotation, setMapRotation,
setMapCenter, setIconSize: updateIconSize,
setFontSize: updateFontSize,
} = useMapData(); } = useMapData();
const { const { rotation, rotateToAngle, scale, setScaleAtCenter } = useTransform();
rotation,
position,
screenToLocal,
screenCenter,
rotateToAngle,
setTransform,
scale,
setScaleAtCenter,
} = useTransform();
const [minScale, setMinScale] = useState<number>(1); const [minScale, setMinScale] = useState<number>(1);
const [maxScale, setMaxScale] = useState<number>(5); const [maxScale, setMaxScale] = useState<number>(5);
@@ -34,14 +25,14 @@ export function RightSidebar() {
}); });
const [rotationDegrees, setRotationDegrees] = useState<number>(0); const [rotationDegrees, setRotationDegrees] = useState<number>(0);
const [isUserEditing, setIsUserEditing] = useState<boolean>(false); const [isUserEditing, setIsUserEditing] = useState<boolean>(false);
const [iconSize, setIconSize] = useState<number>(100);
const [fontSize, setFontSize] = useState<number>(100);
useEffect(() => { useEffect(() => {
if (originalRouteData) { if (originalRouteData) {
// Проверяем и сбрасываем минимальный масштаб если нужно
const originalMinScale = originalRouteData.scale_min ?? 1; const originalMinScale = originalRouteData.scale_min ?? 1;
const resetMinScale = originalMinScale < 1 ? 1 : originalMinScale; const resetMinScale = originalMinScale < 1 ? 1 : originalMinScale;
// Проверяем и сбрасываем максимальный масштаб если нужно
const originalMaxScale = originalRouteData.scale_max ?? 5; const originalMaxScale = originalRouteData.scale_max ?? 5;
const resetMaxScale = originalMaxScale < 3 ? 3 : originalMaxScale; const resetMaxScale = originalMaxScale < 3 ? 3 : originalMaxScale;
@@ -52,6 +43,8 @@ export function RightSidebar() {
x: originalRouteData.center_latitude ?? 0, x: originalRouteData.center_latitude ?? 0,
y: originalRouteData.center_longitude ?? 0, y: originalRouteData.center_longitude ?? 0,
}); });
setIconSize(originalRouteData.icon_size ?? 100);
setFontSize(originalRouteData.font_size ?? 100);
} }
}, [originalRouteData]); }, [originalRouteData]);
@@ -72,33 +65,55 @@ export function RightSidebar() {
}, [rotationDegrees]); }, [rotationDegrees]);
useEffect(() => { useEffect(() => {
if (!isUserEditing) { if (isUserEditing) {
const center = screenCenter ?? { x: 0, y: 0 }; return;
const localCenter = screenToLocal(center.x, center.y);
const coordinates = localToCoordinates(localCenter.x, localCenter.y);
setLocalCenter({ x: coordinates.latitude, y: coordinates.longitude });
} }
}, [
position,
screenCenter,
screenToLocal,
localToCoordinates,
setLocalCenter,
isUserEditing,
]);
useEffect(() => { const latitude = routeData?.center_latitude ?? 0;
setMapCenter(localCenter.x, localCenter.y); const longitude = routeData?.center_longitude ?? 0;
}, [localCenter]);
setLocalCenter((prev) => {
if (
Math.abs(prev.x - latitude) < 1e-6 &&
Math.abs(prev.y - longitude) < 1e-6
) {
return prev;
}
return { x: latitude, y: longitude };
});
}, [isUserEditing, routeData?.center_latitude, routeData?.center_longitude]);
function setRotationFromDegrees(degrees: number) { function setRotationFromDegrees(degrees: number) {
rotateToAngle((degrees * Math.PI) / 180); rotateToAngle((degrees * Math.PI) / 180);
} }
function pan({ x, y }: { x: number; y: number }) { const handleIconSizeChange = (value: number) => {
const coordinates = coordinatesToLocal(x, y); if (!Number.isFinite(value)) {
setTransform(coordinates.x, coordinates.y); return;
} }
const clamped = Math.max(50, Math.min(300, Math.round(value)));
setIconSize(clamped);
updateIconSize(clamped);
};
const handleFontSizeChange = (value: number) => {
if (!Number.isFinite(value)) {
return;
}
const clamped = Math.max(50, Math.min(300, Math.round(value)));
setFontSize(clamped);
updateFontSize(clamped);
};
useEffect(() => {
const next = routeData?.icon_size ?? originalRouteData?.icon_size ?? 100;
setIconSize((prev) => (Math.abs(prev - next) > 0.5 ? next : prev));
}, [routeData?.icon_size, originalRouteData?.icon_size]);
useEffect(() => {
const next = routeData?.font_size ?? originalRouteData?.font_size ?? 100;
setFontSize((prev) => (Math.abs(prev - next) > 0.5 ? next : prev));
}, [routeData?.font_size, originalRouteData?.font_size]);
if (!routeData) { if (!routeData) {
return null; return null;
@@ -118,7 +133,7 @@ export function RightSidebar() {
borderRadius={2} borderRadius={2}
> >
<Typography variant="h6" sx={{ mb: 2, color: "#fff" }} textAlign="center"> <Typography variant="h6" sx={{ mb: 2, color: "#fff" }} textAlign="center">
Детали о достопримечательностях Настройка маршрута
</Typography> </Typography>
<Stack spacing={2} direction="row" alignItems="center"> <Stack spacing={2} direction="row" alignItems="center">
@@ -130,19 +145,18 @@ export function RightSidebar() {
onChange={(e) => { onChange={(e) => {
let newMinScale = Number(e.target.value); let newMinScale = Number(e.target.value);
// Сбрасываем к 1 если меньше if (newMinScale < 10) {
if (newMinScale < 1) { newMinScale = 10;
newMinScale = 1;
} }
setMinScale(newMinScale); setMinScale(newMinScale);
if (maxScale - newMinScale < 2) { if (maxScale - newMinScale < 2) {
let newMaxScale = newMinScale + 2; let newMaxScale = newMinScale + 2;
// Сбрасываем максимальный к 3 если меньше минимального
if (newMaxScale < 3) { if (newMaxScale < 3) {
newMaxScale = 3; newMaxScale = 3;
setMinScale(1); // Сбрасываем минимальный к 1 setMinScale(1);
} }
setMaxScale(newMaxScale); setMaxScale(newMaxScale);
} }
@@ -175,19 +189,22 @@ export function RightSidebar() {
onChange={(e) => { onChange={(e) => {
let newMaxScale = Number(e.target.value); let newMaxScale = Number(e.target.value);
// Сбрасываем к 3 если меньше минимального if (newMaxScale < 13) {
if (newMaxScale < 3) { newMaxScale = 13;
newMaxScale = 3; }
if (newMaxScale > 300) {
newMaxScale = 300;
} }
setMaxScale(newMaxScale); setMaxScale(newMaxScale);
if (newMaxScale - minScale < 2) { if (newMaxScale - minScale < 2) {
let newMinScale = newMaxScale - 2; let newMinScale = newMaxScale - 2;
// Сбрасываем минимальный к 1 если меньше
if (newMinScale < 1) { if (newMinScale < 1) {
newMinScale = 1; newMinScale = 1;
setMaxScale(3); // Сбрасываем максимальный к минимальному значению setMaxScale(3);
} }
setMinScale(newMinScale); setMinScale(newMinScale);
} }
@@ -208,7 +225,7 @@ export function RightSidebar() {
slotProps={{ slotProps={{
input: { input: {
min: 3, min: 3,
max: 10, max: 300,
}, },
}} }}
/> />
@@ -272,6 +289,62 @@ export function RightSidebar() {
}} }}
/> />
<Typography variant="body2" sx={{ color: "#fff", textAlign: "center" }}>
Размер иконок: {iconSize}%
</Typography>
<Slider
value={iconSize}
onChange={(_, value) => {
if (typeof value === "number") {
handleIconSizeChange(value);
}
}}
min={50}
max={300}
step={1}
sx={{
color: "#fff",
"& .MuiSlider-thumb": {
backgroundColor: "#fff",
},
"& .MuiSlider-track": {
backgroundColor: "#fff",
},
"& .MuiSlider-rail": {
backgroundColor: "#666",
},
}}
/>
<Typography variant="body2" sx={{ color: "#fff", textAlign: "center" }}>
Размер шрифта: {fontSize}%
</Typography>
<Slider
value={fontSize}
onChange={(_, value) => {
if (typeof value === "number") {
handleFontSizeChange(value);
}
}}
min={50}
max={300}
step={1}
sx={{
color: "#fff",
"& .MuiSlider-thumb": {
backgroundColor: "#fff",
},
"& .MuiSlider-track": {
backgroundColor: "#fff",
},
"& .MuiSlider-rail": {
backgroundColor: "#666",
},
}}
/>
<TextField <TextField
type="number" type="number"
label="Поворот (в градусах)" label="Поворот (в градусах)"
@@ -314,9 +387,10 @@ export function RightSidebar() {
onChange={(e) => { onChange={(e) => {
setIsUserEditing(true); setIsUserEditing(true);
setLocalCenter((prev) => ({ ...prev, x: Number(e.target.value) })); setLocalCenter((prev) => ({ ...prev, x: Number(e.target.value) }));
pan({ x: Number(e.target.value), y: localCenter.y });
}} }}
onBlur={() => setIsUserEditing(false)} onBlur={() => {
setIsUserEditing(false);
}}
style={{ backgroundColor: "#222", borderRadius: 4 }} style={{ backgroundColor: "#222", borderRadius: 4 }}
sx={{ sx={{
"& .MuiInputLabel-root": { "& .MuiInputLabel-root": {
@@ -338,9 +412,10 @@ export function RightSidebar() {
onChange={(e) => { onChange={(e) => {
setIsUserEditing(true); setIsUserEditing(true);
setLocalCenter((prev) => ({ ...prev, y: Number(e.target.value) })); setLocalCenter((prev) => ({ ...prev, y: Number(e.target.value) }));
pan({ x: localCenter.x, y: Number(e.target.value) });
}} }}
onBlur={() => setIsUserEditing(false)} onBlur={() => {
setIsUserEditing(false);
}}
style={{ backgroundColor: "#222", borderRadius: 4 }} style={{ backgroundColor: "#222", borderRadius: 4 }}
sx={{ sx={{
"& .MuiInputLabel-root": { "& .MuiInputLabel-root": {

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

@@ -32,7 +32,7 @@ export const StationEditPage = observer(() => {
} = stationsStore; } = stationsStore;
const { cities, getCities } = cityStore; const { cities, getCities } = cityStore;
const [coordinates, setCoordinates] = useState<string>(""); const [coordinates, setCoordinates] = useState<string>("");
// НОВОЕ СОСТОЯНИЕ ДЛЯ ПРЕДУПРЕЖДАЮЩЕГО ОКНА
const [isSaveWarningOpen, setIsSaveWarningOpen] = useState(false); const [isSaveWarningOpen, setIsSaveWarningOpen] = useState(false);
useEffect(() => { useEffect(() => {
@@ -50,7 +50,6 @@ export const StationEditPage = observer(() => {
} }
}, [editStationData.common.latitude, editStationData.common.longitude]); }, [editStationData.common.latitude, editStationData.common.longitude]);
// НОВАЯ ФУНКЦИЯ: Фактическое редактирование (вызывается после подтверждения)
const executeEdit = async () => { const executeEdit = async () => {
try { try {
setIsLoading(true); setIsLoading(true);
@@ -64,10 +63,9 @@ export const StationEditPage = observer(() => {
} }
}; };
// ОБНОВЛЕННАЯ ФУНКЦИЯ: Проверка и вызов окна или редактирования
const handleEdit = async () => { const handleEdit = async () => {
const isCityMissing = !editStationData.common.city_id; const isCityMissing = !editStationData.common.city_id;
// Проверяем названия на всех языках
const isNameMissing = const isNameMissing =
!editStationData.ru.name || !editStationData.ru.name ||
!editStationData.en.name || !editStationData.en.name ||
@@ -81,13 +79,11 @@ export const StationEditPage = observer(() => {
await executeEdit(); await executeEdit();
}; };
// Обработчик "Да" в предупреждающем окне
const handleConfirmEdit = async () => { const handleConfirmEdit = async () => {
setIsSaveWarningOpen(false); setIsSaveWarningOpen(false);
await executeEdit(); await executeEdit();
}; };
// Обработчик "Нет" в предупреждающем окне
const handleCancelEdit = () => { const handleCancelEdit = () => {
setIsSaveWarningOpen(false); setIsSaveWarningOpen(false);
}; };
@@ -243,7 +239,7 @@ export const StationEditPage = observer(() => {
className="w-min flex gap-2 items-center" className="w-min flex gap-2 items-center"
startIcon={<Save size={20} />} startIcon={<Save size={20} />}
onClick={handleEdit} onClick={handleEdit}
disabled={isLoading} // Убрал проверку name/city_id отсюда, чтобы ее обрабатывал handleEdit disabled={isLoading}
> >
{isLoading ? ( {isLoading ? (
<Loader2 size={20} className="animate-spin" /> <Loader2 size={20} className="animate-spin" />

View File

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

View File

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

View File

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

View File

@@ -523,7 +523,6 @@ export const ArticleSelectOrCreateDialog = observer(
article?.service_name?.toLowerCase().includes(searchQuery.toLowerCase()) article?.service_name?.toLowerCase().includes(searchQuery.toLowerCase())
); );
// Preview-by-click logic with request serialization to avoid concurrent requests
const [isPreviewLoading, setIsPreviewLoading] = useState(false); const [isPreviewLoading, setIsPreviewLoading] = useState(false);
const [queuedPreviewId, setQueuedPreviewId] = useState<number | null>(null); const [queuedPreviewId, setQueuedPreviewId] = useState<number | null>(null);
const clickTimerRef = (typeof window !== "undefined" const clickTimerRef = (typeof window !== "undefined"
@@ -551,7 +550,7 @@ export const ArticleSelectOrCreateDialog = observer(
if (queuedPreviewId && queuedPreviewId !== articleId) { if (queuedPreviewId && queuedPreviewId !== articleId) {
const nextId = queuedPreviewId; const nextId = queuedPreviewId;
setQueuedPreviewId(null); setQueuedPreviewId(null);
// Run the next queued preview
runPreviewFetch(nextId); runPreviewFetch(nextId);
} else { } else {
setQueuedPreviewId(null); setQueuedPreviewId(null);
@@ -560,7 +559,6 @@ export const ArticleSelectOrCreateDialog = observer(
}; };
const handleListItemClick = (articleId: number) => { const handleListItemClick = (articleId: number) => {
// Delay to allow double-click to cancel preview
if (clickTimerRef.current) clearTimeout(clickTimerRef.current); if (clickTimerRef.current) clearTimeout(clickTimerRef.current);
clickTimerRef.current = setTimeout(() => { clickTimerRef.current = setTimeout(() => {
if (tabValue === 0 && !selectedArticleId && !tempArticleId) { if (tabValue === 0 && !selectedArticleId && !tempArticleId) {
@@ -570,7 +568,6 @@ export const ArticleSelectOrCreateDialog = observer(
}; };
const handleListItemDoubleClick = (articleId: number) => { const handleListItemDoubleClick = (articleId: number) => {
// Cancel pending single-click preview and proceed to select
if (clickTimerRef.current) { if (clickTimerRef.current) {
clearTimeout(clickTimerRef.current); clearTimeout(clickTimerRef.current);
(clickTimerRef as any).current = null; (clickTimerRef as any).current = null;

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,3 @@
// @shared/stores/editSightStore.ts
import { import {
articlesStore, articlesStore,
authInstance, authInstance,
@@ -96,13 +95,11 @@ class EditSightStore {
} }
runInAction(() => { runInAction(() => {
// Обновляем языковую часть
this.sight[language] = { this.sight[language] = {
...this.sight[language], ...this.sight[language],
...data, ...data,
}; };
// Только при первом запросе обновляем общую часть
if (!this.hasLoadedCommon) { if (!this.hasLoadedCommon) {
this.sight.common = { this.sight.common = {
...this.sight.common, ...this.sight.common,
@@ -123,7 +120,6 @@ class EditSightStore {
let responseEn = await languageInstance("en").get(`/sight/${id}/article`); let responseEn = await languageInstance("en").get(`/sight/${id}/article`);
let responseZh = await languageInstance("zh").get(`/sight/${id}/article`); let responseZh = await languageInstance("zh").get(`/sight/${id}/article`);
// Create a map of article IDs to their media
const mediaMap = new Map(); const mediaMap = new Map();
for (const article of responseRu.data) { for (const article of responseRu.data) {
const responseMedia = await authInstance.get( const responseMedia = await authInstance.get(
@@ -132,7 +128,6 @@ class EditSightStore {
mediaMap.set(article.id, responseMedia.data); mediaMap.set(article.id, responseMedia.data);
} }
// Function to add media to articles
const addMediaToArticles = (articles: any[]) => { const addMediaToArticles = (articles: any[]) => {
return articles.map((article) => ({ return articles.map((article) => ({
...article, ...article,
@@ -327,28 +322,6 @@ class EditSightStore {
articles: articleIdsInObject, articles: articleIdsInObject,
}); });
// await languageInstance("ru").patch(
// `/sight/${this.sight.common.left_article}/article`,
// {
// heading: this.sight.ru.left.heading,
// body: this.sight.ru.left.body,
// }
// );
// await languageInstance("en").patch(
// `/sight/${this.sight.common.left_article}/article`,
// {
// heading: this.sight.en.left.heading,
// body: this.sight.en.left.body,
// }
// );
// await languageInstance("zh").patch(
// `/sight/${this.sight.common.left_article}/article`,
// {
// heading: this.sight.zh.left.heading,
// body: this.sight.zh.left.body,
// }
// );
this.needLeaveAgree = false; this.needLeaveAgree = false;
}; };
@@ -589,7 +562,7 @@ class EditSightStore {
}); });
}); });
return article_id; // Return the linked article ID return article_id;
}; };
deleteRightArticleMedia = async (article_id: number, media_id: string) => { deleteRightArticleMedia = async (article_id: number, media_id: string) => {
@@ -695,7 +668,7 @@ class EditSightStore {
}); });
}); });
return id; // Return the ID of the newly created article return id;
}; };
createLinkWithRightArticle = async ( createLinkWithRightArticle = async (
@@ -770,7 +743,7 @@ class EditSightStore {
this.sight.ru.right = sortArticles(this.sight.ru.right); this.sight.ru.right = sortArticles(this.sight.ru.right);
this.sight.en.right = sortArticles(this.sight.en.right); this.sight.en.right = sortArticles(this.sight.en.right);
this.sight.zh.right = sortArticles(this.sight.zh.right); // теперь zh тоже сортируется одинаково this.sight.zh.right = sortArticles(this.sight.zh.right);
this.needLeaveAgree = true; this.needLeaveAgree = true;
}; };

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,6 @@
import { authInstance } from "@shared"; import { authInstance } from "@shared";
import { makeAutoObservable, runInAction } from "mobx"; import { makeAutoObservable, runInAction } from "mobx";
// Импорт функции сброса кешей карты
// import { clearMapCaches } from "../../pages/MapPage";
import { import {
articlesStore, articlesStore,
cityStore, cityStore,
@@ -35,9 +33,7 @@ class SnapshotStore {
makeAutoObservable(this); makeAutoObservable(this);
} }
// Функция для сброса всех кешей в приложении
private clearAllCaches = () => { private clearAllCaches = () => {
// Сброс кешей статей
articlesStore.articleList = { articlesStore.articleList = {
ru: { data: [], loaded: false }, ru: { data: [], loaded: false },
en: { data: [], loaded: false }, en: { data: [], loaded: false },
@@ -47,7 +43,6 @@ class SnapshotStore {
articlesStore.articleData = null; articlesStore.articleData = null;
articlesStore.articleMedia = null; articlesStore.articleMedia = null;
// Сброс кешей городов
cityStore.cities = { cityStore.cities = {
ru: { data: [], loaded: false }, ru: { data: [], loaded: false },
en: { data: [], loaded: false }, en: { data: [], loaded: false },
@@ -56,21 +51,18 @@ class SnapshotStore {
cityStore.ruCities = { data: [], loaded: false }; cityStore.ruCities = { data: [], loaded: false };
cityStore.city = {}; cityStore.city = {};
// Сброс кешей стран
countryStore.countries = { countryStore.countries = {
ru: { data: [], loaded: false }, ru: { data: [], loaded: false },
en: { data: [], loaded: false }, en: { data: [], loaded: false },
zh: { data: [], loaded: false }, zh: { data: [], loaded: false },
}; };
// Сброс кешей перевозчиков
carrierStore.carriers = { carrierStore.carriers = {
ru: { data: [], loaded: false }, ru: { data: [], loaded: false },
en: { data: [], loaded: false }, en: { data: [], loaded: false },
zh: { data: [], loaded: false }, zh: { data: [], loaded: false },
}; };
// Сброс кешей станций
stationsStore.stationLists = { stationsStore.stationLists = {
ru: { data: [], loaded: false }, ru: { data: [], loaded: false },
en: { data: [], loaded: false }, en: { data: [], loaded: false },
@@ -78,24 +70,18 @@ class SnapshotStore {
}; };
stationsStore.stationPreview = {}; stationsStore.stationPreview = {};
// Сброс кешей достопримечательностей
sightsStore.sights = []; sightsStore.sights = [];
sightsStore.sight = null; sightsStore.sight = null;
// Сброс кешей маршрутов
routeStore.routes = { data: [], loaded: false }; routeStore.routes = { data: [], loaded: false };
// Сброс кешей транспорта
vehicleStore.vehicles = { data: [], loaded: false }; vehicleStore.vehicles = { data: [], loaded: false };
// Сброс кешей пользователей
userStore.users = { data: [], loaded: false }; userStore.users = { data: [], loaded: false };
// Сброс кешей медиа
mediaStore.media = []; mediaStore.media = [];
mediaStore.oneMedia = null; mediaStore.oneMedia = null;
// Сброс кешей создания и редактирования достопримечательностей
createSightStore.sight = JSON.parse( createSightStore.sight = JSON.parse(
JSON.stringify({ JSON.stringify({
city_id: 0, city_id: 0,
@@ -173,26 +159,21 @@ class SnapshotStore {
editSightStore.fileToUpload = null; editSightStore.fileToUpload = null;
editSightStore.needLeaveAgree = false; editSightStore.needLeaveAgree = false;
// Сброс кешей устройств
devicesStore.devices = []; devicesStore.devices = [];
devicesStore.uuid = null; devicesStore.uuid = null;
devicesStore.sendSnapshotModalOpen = false; devicesStore.sendSnapshotModalOpen = false;
// Сброс кешей авторизации (кроме токена)
authStore.payload = null; authStore.payload = null;
authStore.error = null; authStore.error = null;
authStore.isLoading = false; authStore.isLoading = false;
// Сброс кешей карты (если они загружены)
try { try {
// Сбрасываем кеши mapStore если он доступен
if (typeof window !== "undefined" && (window as any).mapStore) { if (typeof window !== "undefined" && (window as any).mapStore) {
(window as any).mapStore.routes = []; (window as any).mapStore.routes = [];
(window as any).mapStore.stations = []; (window as any).mapStore.stations = [];
(window as any).mapStore.sights = []; (window as any).mapStore.sights = [];
} }
// Сбрасываем кеши MapService если он доступен
if (typeof window !== "undefined" && (window as any).mapServiceInstance) { if (typeof window !== "undefined" && (window as any).mapServiceInstance) {
(window as any).mapServiceInstance.clearCaches(); (window as any).mapServiceInstance.clearCaches();
} }
@@ -200,7 +181,6 @@ class SnapshotStore {
console.warn("Не удалось сбросить кеши карты:", error); console.warn("Не удалось сбросить кеши карты:", error);
} }
// Сброс localStorage кешей (кроме токена авторизации)
const token = localStorage.getItem("token"); const token = localStorage.getItem("token");
const rememberedEmail = localStorage.getItem("rememberedEmail"); const rememberedEmail = localStorage.getItem("rememberedEmail");
const rememberedPassword = localStorage.getItem("rememberedPassword"); const rememberedPassword = localStorage.getItem("rememberedPassword");
@@ -208,14 +188,12 @@ class SnapshotStore {
localStorage.clear(); localStorage.clear();
sessionStorage.clear(); sessionStorage.clear();
// Восстанавливаем важные данные
if (token) localStorage.setItem("token", token); if (token) localStorage.setItem("token", token);
if (rememberedEmail) if (rememberedEmail)
localStorage.setItem("rememberedEmail", rememberedEmail); localStorage.setItem("rememberedEmail", rememberedEmail);
if (rememberedPassword) if (rememberedPassword)
localStorage.setItem("rememberedPassword", rememberedPassword); localStorage.setItem("rememberedPassword", rememberedPassword);
// Сброс кешей карты (если они есть)
const mapPositionKey = "mapPosition"; const mapPositionKey = "mapPosition";
const activeSectionKey = "mapActiveSection"; const activeSectionKey = "mapActiveSection";
if (localStorage.getItem(mapPositionKey)) { if (localStorage.getItem(mapPositionKey)) {
@@ -225,7 +203,6 @@ class SnapshotStore {
localStorage.removeItem(activeSectionKey); localStorage.removeItem(activeSectionKey);
} }
// Попытка очистить кеш браузера (если поддерживается)
if ("caches" in window) { if ("caches" in window) {
try { try {
caches.keys().then((cacheNames) => { caches.keys().then((cacheNames) => {
@@ -240,7 +217,6 @@ class SnapshotStore {
} }
} }
// Попытка очистить IndexedDB (если поддерживается)
if ("indexedDB" in window) { if ("indexedDB" in window) {
try { try {
indexedDB.databases().then((databases) => { indexedDB.databases().then((databases) => {
@@ -284,10 +260,8 @@ class SnapshotStore {
}; };
restoreSnapshot = async (id: string) => { restoreSnapshot = async (id: string) => {
// Сначала сбрасываем все кеши
this.clearAllCaches(); this.clearAllCaches();
// Затем восстанавливаем снапшот
await authInstance.post(`/snapshots/${id}/restore`); await authInstance.post(`/snapshots/${id}/restore`);
}; };

View File

@@ -7,7 +7,7 @@ type StationLanguageData = {
name: string; name: string;
system_name: string; system_name: string;
address: string; address: string;
loaded: boolean; // Indicates if this language's data has been loaded/modified loaded: boolean;
}; };
type StationCommonData = { type StationCommonData = {
@@ -92,7 +92,6 @@ class StationsStore {
}, },
}; };
// This will store the full station data, keyed by ID and then by language
stationPreview: Record< stationPreview: Record<
string, string,
Record<string, { loaded: boolean; data: Station }> Record<string, { loaded: boolean; data: Station }>
@@ -264,7 +263,6 @@ class StationsStore {
}; };
}; };
// Sets language-specific station data
setLanguageEditStationData = ( setLanguageEditStationData = (
language: Language, language: Language,
data: Partial<StationLanguageData> data: Partial<StationLanguageData>
@@ -295,7 +293,7 @@ class StationsStore {
`/station/${id}`, `/station/${id}`,
{ {
name: name || "", name: name || "",
system_name: name || "", // system_name is often derived from name system_name: name || "",
description: description || "", description: description || "",
address: address || "", address: address || "",
...commonDataPayload, ...commonDataPayload,
@@ -303,7 +301,6 @@ class StationsStore {
); );
runInAction(() => { runInAction(() => {
// Update the cached preview data and station lists after successful patch
if (this.stationPreview[id]) { if (this.stationPreview[id]) {
this.stationPreview[id][language] = { this.stationPreview[id][language] = {
loaded: true, loaded: true,
@@ -343,11 +340,11 @@ class StationsStore {
runInAction(() => { runInAction(() => {
this.stations = this.stations.filter((station) => station.id !== id); this.stations = this.stations.filter((station) => station.id !== id);
// Also clear from stationPreview cache
if (this.stationPreview[id]) { if (this.stationPreview[id]) {
delete this.stationPreview[id]; delete this.stationPreview[id];
} }
// Clear from stationLists as well for all languages
for (const lang of ["ru", "en", "zh"] as const) { for (const lang of ["ru", "en", "zh"] as const) {
if (this.stationLists[lang].data) { if (this.stationLists[lang].data) {
this.stationLists[lang].data = this.stationLists[lang].data.filter( this.stationLists[lang].data = this.stationLists[lang].data.filter(
@@ -421,12 +418,11 @@ class StationsStore {
delete commonDataPayload.icon; delete commonDataPayload.icon;
} }
// First create station in Russian
const { name, address } = this.createStationData[language]; const { name, address } = this.createStationData[language];
const description = this.createStationData.common.description; const description = this.createStationData.common.description;
const response = await languageInstance(language).post("/station", { const response = await languageInstance(language).post("/station", {
name: name || "", name: name || "",
system_name: name || "", // system_name is often derived from name system_name: name || "",
description: description || "", description: description || "",
address: address || "", address: address || "",
...commonDataPayload, ...commonDataPayload,
@@ -438,7 +434,6 @@ class StationsStore {
const stationId = response.data.id; const stationId = response.data.id;
// Then update for other languages
for (const lang of ["ru", "en", "zh"].filter( for (const lang of ["ru", "en", "zh"].filter(
(lang) => lang !== language (lang) => lang !== language
) as Language[]) { ) as Language[]) {
@@ -448,7 +443,7 @@ class StationsStore {
`/station/${stationId}`, `/station/${stationId}`,
{ {
name: name || "", name: name || "",
system_name: name || "", // system_name is often derived from name system_name: name || "",
description: description || "", description: description || "",
address: address || "", address: address || "",
...commonDataPayload, ...commonDataPayload,
@@ -507,7 +502,6 @@ class StationsStore {
return response.data; return response.data;
}; };
// Reset editStationData when navigating away or after saving
resetEditStationData = () => { resetEditStationData = () => {
this.editStationData = { this.editStationData = {
ru: { ru: {

View File

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

View File

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

View File

@@ -11,8 +11,8 @@ import {
devicesStore, devicesStore,
Modal, Modal,
snapshotStore, snapshotStore,
vehicleStore, // Not directly used in this component's rendering logic anymore vehicleStore,
} from "@shared"; // Assuming @shared exports these } from "@shared";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { Button, Checkbox, Typography } from "@mui/material"; import { Button, Checkbox, Typography } from "@mui/material";
@@ -23,12 +23,10 @@ import { useNavigate } from "react-router-dom";
export type ConnectedDevice = string; export type ConnectedDevice = string;
interface Snapshot { interface Snapshot {
ID: string; // Assuming ID is string based on usage ID: string;
Name: string; Name: string;
// Add other snapshot properties if needed
} }
// --- HELPER FUNCTIONS ---
const formatDate = (dateString: string | null) => { const formatDate = (dateString: string | null) => {
if (!dateString) return "Нет данных"; if (!dateString) return "Нет данных";
try { try {
@@ -76,12 +74,7 @@ function createData(
}; };
} }
// This function transforms the raw device data (which includes vehicle and device_status) const transformDevicesToRows = (vehicles: Vehicle[]): TableRowData[] => {
// into the format expected by the table. It now filters for devices that have a UUID.
const transformDevicesToRows = (
vehicles: Vehicle[]
// devices: ConnectedDevice[]
): TableRowData[] => {
return vehicles.map((vehicle) => { return vehicles.map((vehicle) => {
const uuid = vehicle.vehicle.uuid; const uuid = vehicle.vehicle.uuid;
if (!uuid) if (!uuid)
@@ -115,26 +108,21 @@ export const DevicesTable = observer(() => {
} = devicesStore; } = devicesStore;
const { snapshots, getSnapshots } = snapshotStore; const { snapshots, getSnapshots } = snapshotStore;
const { getVehicles, vehicles } = vehicleStore; // Removed as devicesStore.devices should be the source of truth const { getVehicles, vehicles } = vehicleStore;
const { devices } = devicesStore; const { devices } = devicesStore;
const navigate = useNavigate(); const navigate = useNavigate();
const [selectedDeviceUuids, setSelectedDeviceUuids] = useState<string[]>([]); const [selectedDeviceUuids, setSelectedDeviceUuids] = useState<string[]>([]);
// Transform the raw devices data into rows suitable for the table const currentTableRows = transformDevicesToRows(vehicles.data as Vehicle[]);
// This will also filter out devices without a UUID, as those cannot be acted upon.
const currentTableRows = transformDevicesToRows(
vehicles.data as Vehicle[]
// devices as ConnectedDevice[]
);
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
await getVehicles(); // Not strictly needed if devicesStore.devices is populated correctly by getDevices await getVehicles();
await getDevices(); // This should fetch the combined vehicle/device_status data await getDevices();
await getSnapshots(); await getSnapshots();
}; };
fetchData(); fetchData();
}, [getDevices, getSnapshots]); // Added dependencies }, [getDevices, getSnapshots]);
const isAllSelected = const isAllSelected =
currentTableRows.length > 0 && currentTableRows.length > 0 &&
@@ -144,7 +132,6 @@ export const DevicesTable = observer(() => {
if (isAllSelected) { if (isAllSelected) {
setSelectedDeviceUuids([]); setSelectedDeviceUuids([]);
} else { } else {
// Select all device UUIDs from the *currently visible and selectable* rows
setSelectedDeviceUuids( setSelectedDeviceUuids(
currentTableRows.map((row) => row.device_uuid ?? "") currentTableRows.map((row) => row.device_uuid ?? "")
); );
@@ -171,14 +158,13 @@ export const DevicesTable = observer(() => {
}; };
const handleReloadStatus = async (uuid: string) => { const handleReloadStatus = async (uuid: string) => {
setSelectedDevice(uuid); // Sets the device in the store, useful for context elsewhere setSelectedDevice(uuid);
try { try {
await authInstance.post(`/devices/${uuid}/request-status`); await authInstance.post(`/devices/${uuid}/request-status`);
await getVehicles(); await getVehicles();
await getDevices(); // Refresh devices to show updated status await getDevices();
} catch (error) { } catch (error) {
console.error(`Error requesting status for device ${uuid}:`, error); console.error(`Error requesting status for device ${uuid}:`, error);
// Optionally: show a user-facing error message
} }
}; };
@@ -200,22 +186,16 @@ export const DevicesTable = observer(() => {
} }
}; };
try { try {
// Create an array of promises for all snapshot requests
const snapshotPromises = selectedDeviceUuids.map((deviceUuid) => { const snapshotPromises = selectedDeviceUuids.map((deviceUuid) => {
return send(deviceUuid); return send(deviceUuid);
}); });
// Wait for all promises to settle (either resolve or reject)
await Promise.allSettled(snapshotPromises); await Promise.allSettled(snapshotPromises);
// After all requests are attempted await getDevices();
await getDevices(); // Refresh the device list setSelectedDeviceUuids([]);
setSelectedDeviceUuids([]); // Clear the selection toggleSendSnapshotModal();
toggleSendSnapshotModal(); // Close the modal
} catch (error) { } catch (error) {
// This catch block might not be hit if Promise.allSettled is used,
// as it doesn't reject on individual promise failures.
// Individual errors should be handled if needed within the .map or by checking results.
console.error("Error in snapshot sending process:", error); console.error("Error in snapshot sending process:", error);
} }
}; };
@@ -235,7 +215,7 @@ export const DevicesTable = observer(() => {
</div> </div>
<div className="flex justify-end p-3 gap-2"> <div className="flex justify-end p-3 gap-2">
<Button <Button
variant="outlined" // Changed to outlined for distinction variant="outlined"
onClick={handleSelectAllDevices} onClick={handleSelectAllDevices}
size="small" size="small"
> >
@@ -286,7 +266,6 @@ export const DevicesTable = observer(() => {
)} )}
selected={selectedDeviceUuids.includes(row.device_uuid ?? "")} selected={selectedDeviceUuids.includes(row.device_uuid ?? "")}
onClick={(event) => { onClick={(event) => {
// Allow clicking row to toggle checkbox, if not clicking on button
if ( if (
(event.target as HTMLElement).closest("button") === null && (event.target as HTMLElement).closest("button") === null &&
(event.target as HTMLElement).closest( (event.target as HTMLElement).closest(
@@ -308,7 +287,6 @@ export const DevicesTable = observer(() => {
} }
} }
// Only toggle checkbox if Shift key is not pressed
if (!event.shiftKey) { if (!event.shiftKey) {
handleSelectDevice( handleSelectDevice(
{ {
@@ -317,7 +295,7 @@ export const DevicesTable = observer(() => {
row.device_uuid ?? "" row.device_uuid ?? ""
), ),
}, },
} as React.ChangeEvent<HTMLInputElement>, // Simulate event } as React.ChangeEvent<HTMLInputElement>,
row.device_uuid ?? "" row.device_uuid ?? ""
); );
} }
@@ -445,7 +423,7 @@ export const DevicesTable = observer(() => {
</strong> </strong>
</Typography> </Typography>
<div className="mt-2 flex flex-col gap-2 max-h-[300px] overflow-y-auto pr-2"> <div className="mt-2 flex flex-col gap-2 max-h-[300px] overflow-y-auto pr-2">
{snapshots && (snapshots as Snapshot[]).length > 0 ? ( // Cast snapshots {snapshots && (snapshots as Snapshot[]).length > 0 ? (
(snapshots as Snapshot[]).map((snapshot) => ( (snapshots as Snapshot[]).map((snapshot) => (
<Button <Button
variant="outlined" variant="outlined"

View File

@@ -1,6 +1,6 @@
import React, { useRef, DragEvent } from "react"; import React, { useRef, DragEvent } from "react";
import { Paper, Box, Typography, Button, Tooltip } from "@mui/material"; import { Paper, Box, Typography, Button, Tooltip } from "@mui/material";
import { X, Info, Plus } from "lucide-react"; // Assuming lucide-react for icons import { X, Info, Plus } from "lucide-react";
import { editSightStore } from "@shared"; import { editSightStore } from "@shared";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
interface ImageUploadCardProps { interface ImageUploadCardProps {
@@ -50,14 +50,14 @@ export const ImageUploadCard: React.FC<ImageUploadCardProps> = ({
toast.error("Пожалуйста, выберите изображение"); toast.error("Пожалуйста, выберите изображение");
} }
} }
// Reset the input value so selecting the same file again triggers change
event.target.value = ""; event.target.value = "";
}; };
const token = localStorage.getItem("token"); const token = localStorage.getItem("token");
// --- Drag and Drop Handlers ---
const handleDragOver = (event: DragEvent<HTMLDivElement>) => { const handleDragOver = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault(); // Crucial to allow a drop event.preventDefault();
event.stopPropagation(); event.stopPropagation();
}; };
@@ -67,7 +67,7 @@ export const ImageUploadCard: React.FC<ImageUploadCardProps> = ({
}; };
const handleDrop = async (event: DragEvent<HTMLDivElement>) => { const handleDrop = async (event: DragEvent<HTMLDivElement>) => {
event.preventDefault(); // Crucial to allow a drop event.preventDefault();
event.stopPropagation(); event.stopPropagation();
const files = event.dataTransfer.files; const files = event.dataTransfer.files;
@@ -120,7 +120,6 @@ export const ImageUploadCard: React.FC<ImageUploadCardProps> = ({
cursor: imageUrl ? "pointer" : "default", cursor: imageUrl ? "pointer" : "default",
}} }}
onClick={onImageClick} onClick={onImageClick}
// Removed onClick on the main Box to avoid conflicts
> >
{imageUrl && ( {imageUrl && (
<button <button
@@ -153,7 +152,7 @@ export const ImageUploadCard: React.FC<ImageUploadCardProps> = ({
borderRadius: 1, borderRadius: 1,
cursor: "pointer", cursor: "pointer",
}} }}
onClick={handleZoneClick} // Click handler for the zone onClick={handleZoneClick}
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}
onDrop={handleDrop} onDrop={handleDrop}
@@ -167,8 +166,8 @@ export const ImageUploadCard: React.FC<ImageUploadCardProps> = ({
color="primary" color="primary"
startIcon={<Plus color="white" size={18} />} startIcon={<Plus color="white" size={18} />}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); // Prevent `handleZoneClick` from firing e.stopPropagation();
onSelectFileClick(); // This button might trigger a different modal onSelectFileClick();
}} }}
> >
Выбрать файл Выбрать файл
@@ -179,7 +178,7 @@ export const ImageUploadCard: React.FC<ImageUploadCardProps> = ({
ref={fileInputRef} ref={fileInputRef}
onChange={handleFileInputChange} onChange={handleFileInputChange}
style={{ display: "none" }} style={{ display: "none" }}
accept="image/*" // Accept only image files accept="image/*"
/> />
</div> </div>
)} )}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -28,9 +28,8 @@ export const VideoPreviewCard: React.FC<VideoPreviewCardProps> = ({
const token = localStorage.getItem("token"); const token = localStorage.getItem("token");
useEffect(() => {}, [isDragOver]); useEffect(() => {}, [isDragOver]);
// --- Click to select file ---
const handleZoneClick = () => { const handleZoneClick = () => {
// Trigger the hidden file input click
fileInputRef.current?.click(); fileInputRef.current?.click();
}; };
@@ -40,19 +39,17 @@ export const VideoPreviewCard: React.FC<VideoPreviewCardProps> = ({
const file = event.target.files?.[0]; const file = event.target.files?.[0];
if (file) { if (file) {
if (file.type.startsWith("video/")) { if (file.type.startsWith("video/")) {
// Открываем диалог загрузки медиа с файлом видео
onSelectVideoClick(file); onSelectVideoClick(file);
} else { } else {
toast.error("Пожалуйста, выберите видео файл"); toast.error("Пожалуйста, выберите видео файл");
} }
} }
// Reset the input value so selecting the same file again triggers change
event.target.value = ""; event.target.value = "";
}; };
// --- Drag and Drop Handlers ---
const handleDragOver = (event: DragEvent<HTMLDivElement>) => { const handleDragOver = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault(); // Crucial to allow a drop event.preventDefault();
event.stopPropagation(); event.stopPropagation();
setIsDragOver(true); setIsDragOver(true);
}; };
@@ -64,7 +61,7 @@ export const VideoPreviewCard: React.FC<VideoPreviewCardProps> = ({
}; };
const handleDrop = async (event: DragEvent<HTMLDivElement>) => { const handleDrop = async (event: DragEvent<HTMLDivElement>) => {
event.preventDefault(); // Crucial to allow a drop event.preventDefault();
event.stopPropagation(); event.stopPropagation();
setIsDragOver(false); setIsDragOver(false);
@@ -72,7 +69,6 @@ export const VideoPreviewCard: React.FC<VideoPreviewCardProps> = ({
if (files && files.length > 0) { if (files && files.length > 0) {
const file = files[0]; const file = files[0];
if (file.type.startsWith("video/")) { if (file.type.startsWith("video/")) {
// Открываем диалог загрузки медиа с файлом видео
onSelectVideoClick(file); onSelectVideoClick(file);
} else { } else {
toast.error("Пожалуйста, выберите видео файл"); toast.error("Пожалуйста, выберите видео файл");
@@ -175,7 +171,7 @@ export const VideoPreviewCard: React.FC<VideoPreviewCardProps> = ({
borderRadius: 1, borderRadius: 1,
cursor: "pointer", cursor: "pointer",
}} }}
onClick={handleZoneClick} // Click handler for the zone onClick={handleZoneClick}
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}
onDrop={handleDrop} onDrop={handleDrop}
@@ -189,8 +185,8 @@ export const VideoPreviewCard: React.FC<VideoPreviewCardProps> = ({
color="primary" color="primary"
startIcon={<Plus color="white" size={18} />} startIcon={<Plus color="white" size={18} />}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); // Prevent `handleZoneClick` from firing e.stopPropagation();
onSelectVideoClick(); // This button triggers the media selection dialog onSelectVideoClick();
}} }}
> >
Выбрать файл Выбрать файл
@@ -201,7 +197,7 @@ export const VideoPreviewCard: React.FC<VideoPreviewCardProps> = ({
ref={fileInputRef} ref={fileInputRef}
onChange={handleFileInputChange} onChange={handleFileInputChange}
style={{ display: "none" }} style={{ display: "none" }}
accept="video/*" // Accept only video files accept="video/*"
/> />
</div> </div>
)} )}

File diff suppressed because one or more lines are too long

474
yarn.lock
View File

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