Compare commits

...

20 Commits

Author SHA1 Message Date
50ad374cf5 feat: Add selected city functional with some debugging 2025-10-22 03:04:58 +03:00
9e47ab667f feat: Update admin panel 2025-10-22 02:55:04 +03:00
1b8fc3d215 fix: fix upload bug 3d 2025-10-20 20:00:28 +03:00
f5142ec95d fix: Env 2025-10-13 14:34:56 +03:00
cdb96dfb8b fix: Fix errors 2025-10-10 08:40:39 +03:00
c50ccb3a0c fix: Add more filters by city for station list and sight list 2025-10-06 10:35:15 +03:00
4bcc2e2cca fix: Fix webp + delete ctrl z + filter by city 2025-10-06 10:03:41 +03:00
26e4d70b95 fix: Fix 3d models 2025-10-02 22:20:37 +03:00
a357994025 feat: Update pop-up logic 2025-10-02 04:45:43 +03:00
7382a85082 feat: Update city logic in map page with scrollbar 2025-10-02 04:38:15 +03:00
db64beb3ee feat: Select city in top of the page for next usage in create/edit pages 2025-09-28 10:41:13 +03:00
Микаэл Оганесян
1abd6b30a4 build fix 2025-09-27 22:31:14 -07:00
Микаэл Оганесян
b25df42960 hotfix admin panel 2025-09-27 22:29:13 -07:00
34ba3c1db0 Add Dockerfile and Makefile for containerization and build automation
- Created a Dockerfile with a multi-stage build process to containerize the application.
- Added Makefile for managing build, export, and cleanup tasks.
2025-07-29 17:39:21 +03:00
4f038551a2 fix: Fix problems and bugs 2025-07-28 08:18:21 +03:00
470a58a3fa fix: Fix panorama + route scale data 2025-07-26 11:48:41 +03:00
89d7fc2748 feat: Add scale on group click, add cache for map entities, fix map preview loading 2025-07-15 05:29:27 +03:00
97f95fc394 feat: Group map entities + delete useless logs 2025-07-13 20:56:25 +03:00
bf117ef048 feat: Add preview_video for sights 2025-07-13 20:26:45 +03:00
ced3067915 fix: Fix name on map and fix city name in sight list 2025-07-13 14:36:57 +03:00
82 changed files with 4720 additions and 1294 deletions

1
.env
View File

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

32
Dockerfile Normal file
View File

@@ -0,0 +1,32 @@
# Stage 1: Build the application
FROM node:20-alpine AS build
# Set working directory
WORKDIR /app
# Copy package.json and yarn.lock
COPY package.json yarn.lock ./
# Install dependencies
RUN yarn install --frozen-lockfile
# Copy the rest of the application code
COPY . .
# Build the application
RUN yarn build
# Stage 2: Serve the application with Nginx
FROM nginx:alpine
# Copy the built application from the build stage
COPY --from=build /app/dist /usr/share/nginx/html
# Copy nginx configuration (optional, can be added later if needed)
# COPY nginx.conf /etc/nginx/conf.d/default.conf
# Expose port 80
EXPOSE 80
# Start Nginx server
CMD ["nginx", "-g", "daemon off;"]

38
Makefile Normal file
View File

@@ -0,0 +1,38 @@
# Variables
IMAGE_NAME = white-nights-admin-panel
IMAGE_TAG = latest
FULL_IMAGE_NAME = $(IMAGE_NAME):$(IMAGE_TAG)
ARCHIVE_NAME = white-nights-admin-panel-image.zip
# Default target
.PHONY: help
help:
@echo "Available commands:"
@echo " make build-image - Build Docker image"
@echo " make export-image - Build Docker image and export it to a zip archive"
@echo " make clean - Remove Docker image and zip archive"
@echo " make help - Show this help message"
# Build Docker image
.PHONY: build-image
build-image:
@echo "Building Docker image: $(FULL_IMAGE_NAME)"
docker build -t $(FULL_IMAGE_NAME) .
# Export Docker image to zip archive
.PHONY: export-image
export-image: build-image
@echo "Exporting Docker image to $(ARCHIVE_NAME)"
docker save $(FULL_IMAGE_NAME) | gzip > $(ARCHIVE_NAME)
@echo "Image exported successfully to $(ARCHIVE_NAME)"
# Clean up
.PHONY: clean
clean:
@echo "Removing Docker image and zip archive"
-docker rmi $(FULL_IMAGE_NAME) 2>/dev/null || true
-rm -f $(ARCHIVE_NAME) 2>/dev/null || true
@echo "Clean up completed"
# Default target when no arguments provided
.DEFAULT_GOAL := help

14
package-lock.json generated
View File

@@ -6592,20 +6592,6 @@
"node": ">=18"
}
},
"node_modules/yaml": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.0.tgz",
"integrity": "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==",
"license": "ISC",
"optional": true,
"peer": true,
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
}
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",

View File

@@ -0,0 +1,158 @@
import React, { Component, ReactNode } from "react";
import { Box, Button, Typography, Paper, Container } from "@mui/material";
import { RefreshCw, Home, AlertTriangle } from "lucide-react";
interface Props {
children: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
export class GlobalErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
hasError: false,
error: null,
};
}
static getDerivedStateFromError(error: Error): State {
return {
hasError: true,
error,
};
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error("❌ GlobalErrorBoundary: Критическая ошибка приложения", {
error: error.message,
stack: error.stack,
componentStack: errorInfo.componentStack,
});
}
handleReset = () => {
this.setState({
hasError: false,
error: null,
});
window.location.reload();
};
handleGoHome = () => {
this.setState({
hasError: false,
error: null,
});
window.location.href = "/";
};
render() {
if (this.state.hasError) {
return (
<Box
sx={{
minHeight: "100vh",
display: "flex",
justifyContent: "center",
alignItems: "center",
backgroundColor: "background.default",
p: 2,
}}
>
<Container maxWidth="sm">
<Paper
elevation={3}
sx={{
p: 4,
textAlign: "center",
}}
>
<Box
sx={{
display: "flex",
justifyContent: "center",
mb: 3,
}}
>
<AlertTriangle size={64} color="#f44336" />
</Box>
<Typography variant="h4" component="h1" gutterBottom>
Упс! Что-то пошло не так
</Typography>
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
Приложение столкнулось с неожиданной ошибкой. Попробуйте
перезагрузить страницу или вернуться на главную.
</Typography>
{this.state.error?.message && (
<Paper
variant="outlined"
sx={{
p: 2,
mb: 3,
backgroundColor: "error.light",
color: "error.contrastText",
textAlign: "left",
}}
>
<Typography
variant="caption"
sx={{ fontWeight: "bold", display: "block", mb: 1 }}
>
Информация об ошибке:
</Typography>
<Typography
variant="caption"
sx={{
fontFamily: "monospace",
fontSize: "0.75rem",
wordBreak: "break-word",
display: "block",
}}
>
{this.state.error.message}
</Typography>
</Paper>
)}
<Box
sx={{
display: "flex",
gap: 2,
justifyContent: "center",
flexWrap: "wrap",
}}
>
<Button
variant="outlined"
startIcon={<Home size={16} />}
onClick={this.handleGoHome}
size="large"
>
На главную
</Button>
<Button
variant="contained"
startIcon={<RefreshCw size={16} />}
onClick={this.handleReset}
size="large"
>
Перезагрузить
</Button>
</Box>
</Paper>
</Container>
</Box>
);
}
return this.props.children;
}
}

View File

@@ -4,10 +4,13 @@ import { Router } from "./router";
import { CustomTheme } from "@shared";
import { ThemeProvider } from "@mui/material/styles";
import { ToastContainer } from "react-toastify";
import { GlobalErrorBoundary } from "./GlobalErrorBoundary";
export const App: React.FC = () => (
<GlobalErrorBoundary>
<ThemeProvider theme={CustomTheme.Light}>
<ToastContainer />
<Router />
</ThemeProvider>
</GlobalErrorBoundary>
);

View File

@@ -5,6 +5,7 @@ export interface NavigationItem {
label: string;
icon: LucideIcon;
path?: string;
for_admin?: boolean;
onClick?: () => void;
nestedItems?: NavigationItem[];
}

View File

@@ -10,6 +10,7 @@ import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import type { NavigationItem } from "../model";
import { useNavigate, useLocation } from "react-router-dom";
import { Plus } from "lucide-react";
import { authStore } from "@shared";
interface NavigationItemProps {
item: NavigationItem;
@@ -30,9 +31,21 @@ export const NavigationItemComponent: React.FC<NavigationItemProps> = ({
const navigate = useNavigate();
const location = useLocation();
const [isExpanded, setIsExpanded] = React.useState(false);
const { payload } = authStore;
// @ts-ignore
const isAdmin = payload?.is_admin || false;
const isActive = item.path ? location.pathname.startsWith(item.path) : false;
const filteredNestedItems = item.nestedItems?.filter((nestedItem) => {
if (nestedItem.for_admin) {
return isAdmin;
}
return true;
});
const handleClick = () => {
if (item.id === "all" && !open) {
onDrawerOpen?.();
@@ -108,15 +121,16 @@ export const NavigationItemComponent: React.FC<NavigationItemProps> = ({
},
]}
/>
{item.nestedItems &&
{filteredNestedItems &&
filteredNestedItems.length > 0 &&
open &&
(isExpanded ? <ExpandLessIcon /> : <ExpandMoreIcon />)}
</ListItemButton>
</ListItem>
{item.nestedItems && (
{filteredNestedItems && filteredNestedItems.length > 0 && (
<Collapse in={isExpanded} timeout="auto" unmountOnExit>
<List component="div" disablePadding>
{item.nestedItems.map((nestedItem) => (
{filteredNestedItems.map((nestedItem) => (
<NavigationItemComponent
key={nestedItem.id}
item={nestedItem}

View File

@@ -1,16 +1,36 @@
import List from "@mui/material/List";
import Divider from "@mui/material/Divider";
import { NAVIGATION_ITEMS } from "@shared";
import { authStore, NAVIGATION_ITEMS } from "@shared";
import { NavigationItem, NavigationItemComponent } from "@entities";
import { observer } from "mobx-react-lite";
interface NavigationListProps {
open: boolean;
onDrawerOpen?: () => void;
}
export const NavigationList = ({ open, onDrawerOpen }: NavigationListProps) => {
const primaryItems = NAVIGATION_ITEMS.primary;
const secondaryItems = NAVIGATION_ITEMS.secondary;
export const NavigationList = observer(
({ open, onDrawerOpen }: NavigationListProps) => {
const { payload } = authStore;
// @ts-ignore
const isAdmin = Boolean(payload?.is_admin) || false;
const primaryItems = NAVIGATION_ITEMS.primary.filter((item) => {
if (item.for_admin) {
return isAdmin;
}
if (item.nestedItems && item.nestedItems.length > 0) {
return item.nestedItems.some((nestedItem) => {
if (nestedItem.for_admin) {
return isAdmin;
}
return true;
});
}
return true;
});
return (
<>
@@ -26,7 +46,7 @@ export const NavigationList = ({ open, onDrawerOpen }: NavigationListProps) => {
</List>
<Divider />
<List>
{secondaryItems.map((item) => (
{NAVIGATION_ITEMS.secondary.map((item) => (
<NavigationItemComponent
key={item.id}
item={item as NavigationItem}
@@ -38,4 +58,5 @@ export const NavigationList = ({ open, onDrawerOpen }: NavigationListProps) => {
</List>
</>
);
};
}
);

View File

@@ -46,6 +46,7 @@ export const ArticleListPage = observer(() => {
{
field: "actions",
headerName: "Действия",
sortable: false,
renderCell: (params: GridRenderCellParams) => {
return (

View File

@@ -12,7 +12,13 @@ import { ArrowLeft, Save } from "lucide-react";
import { Loader2 } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify";
import { carrierStore, cityStore, mediaStore, languageStore } from "@shared";
import {
carrierStore,
cityStore,
mediaStore,
languageStore,
useSelectedCity,
} from "@shared";
import { useState, useEffect } from "react";
import { ImageUploadCard, LanguageSwitcher } from "@widgets";
import {
@@ -25,6 +31,7 @@ export const CarrierCreatePage = observer(() => {
const navigate = useNavigate();
const { createCarrierData, setCreateCarrierData } = carrierStore;
const { language } = languageStore;
const { selectedCityId } = useSelectedCity();
const [selectedMediaId, setSelectedMediaId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
@@ -41,6 +48,20 @@ export const CarrierCreatePage = observer(() => {
languageStore.setLanguage("ru");
}, []);
// Автоматически устанавливаем выбранный город при загрузке страницы
useEffect(() => {
if (selectedCityId && !createCarrierData.city_id) {
setCreateCarrierData(
createCarrierData[language].full_name,
createCarrierData[language].short_name,
selectedCityId,
createCarrierData[language].slogan,
selectedMediaId || "",
language
);
}
}, [selectedCityId, createCarrierData.city_id]);
const handleCreate = async () => {
try {
setIsLoading(true);

View File

@@ -88,6 +88,7 @@ export const CarrierListPage = observer(() => {
headerName: "Действия",
headerAlign: "center",
width: 200,
sortable: false,
renderCell: (params: GridRenderCellParams) => {
return (

View File

@@ -50,7 +50,6 @@ export const CityListPage = observer(() => {
}
setRows(newRows2 || []);
console.log(newRows2);
}, [cities, countryStore.countries, language, isLoading]);
const columns: GridColDef[] = [
@@ -94,6 +93,7 @@ export const CityListPage = observer(() => {
align: "center",
headerAlign: "center",
width: 200,
sortable: false,
renderCell: (params: GridRenderCellParams) => {
return (
<div className="flex h-full gap-7 justify-center items-center">

View File

@@ -50,6 +50,7 @@ export const CountryListPage = observer(() => {
align: "center",
headerAlign: "center",
width: 200,
sortable: false,
renderCell: (params: GridRenderCellParams) => {
return (
<div className="flex h-full gap-7 justify-center items-center">

View File

@@ -52,7 +52,12 @@ export const LoginPage = () => {
}
navigate("/map");
try {
await getUsers();
} catch (err) {
console.error(err);
}
toast.success("Вход в систему выполнен успешно");
} catch (err) {
setError(

File diff suppressed because it is too large Load Diff

View File

@@ -45,10 +45,10 @@ export const MediaEditPage = observer(() => {
if (id) {
mediaStore.getOneMedia(id);
}
console.log(newFile);
console.log(uploadDialogOpen);
}, [id]);
useEffect(() => {}, [newFile, uploadDialogOpen]);
useEffect(() => {
if (media) {
setMediaName(media.media_name);
@@ -60,7 +60,11 @@ export const MediaEditPage = observer(() => {
if (extension) {
if (["glb", "gltf"].includes(extension)) {
setAvailableMediaTypes([6]); // 3D model
} else if (["jpg", "jpeg", "png", "gif"].includes(extension)) {
} else if (
["jpg", "jpeg", "png", "gif", "svg", "webp", "bmp"].includes(
extension
)
) {
setAvailableMediaTypes([1, 3, 4, 5]); // Photo, Icon, Watermark, Panorama
} else if (["mp4", "webm", "mov"].includes(extension)) {
setAvailableMediaTypes([2]); // Video
@@ -109,7 +113,11 @@ export const MediaEditPage = observer(() => {
if (["glb", "gltf"].includes(extension)) {
setAvailableMediaTypes([6]); // 3D model
setMediaType(6);
} else if (["jpg", "jpeg", "png", "gif"].includes(extension)) {
} else if (
["jpg", "jpeg", "png", "gif", "svg", "webp", "bmp"].includes(
extension
)
) {
setAvailableMediaTypes([1, 3, 4, 5]); // Photo, Icon, Watermark, Panorama
setMediaType(1); // Default to Photo
} else if (["mp4", "webm", "mov"].includes(extension)) {

View File

@@ -69,6 +69,7 @@ export const MediaListPage = observer(() => {
width: 200,
align: "center",
headerAlign: "center",
sortable: false,
renderCell: (params: GridRenderCellParams) => {
return (

View File

@@ -24,6 +24,7 @@ import {
Tab,
Box,
} from "@mui/material";
import { observer } from "mobx-react-lite";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import DragIndicatorIcon from "@mui/icons-material/DragIndicator";
import {
@@ -33,7 +34,12 @@ import {
DropResult,
} from "@hello-pangea/dnd";
import { authInstance, languageStore, routeStore } from "@shared";
import {
authInstance,
languageStore,
routeStore,
selectedCityStore,
} from "@shared";
import { EditStationModal } from "../../widgets/modals/EditStationModal";
// Helper function to insert an item at a specific position (1-based index)
@@ -73,7 +79,6 @@ type LinkedItemsProps<T> = {
disableCreation?: boolean;
updatedLinkedItems?: T[];
refresh?: number;
cityId?: number;
routeDirection?: boolean;
};
@@ -112,7 +117,7 @@ export const LinkedItems = <
);
};
export const LinkedItemsContents = <
const LinkedItemsContentsInner = <
T extends { id: number; name: string; [key: string]: any }
>({
parentId,
@@ -124,7 +129,6 @@ export const LinkedItemsContents = <
disableCreation = false,
updatedLinkedItems,
refresh,
cityId,
routeDirection,
}: LinkedItemsProps<T>) => {
const { language } = languageStore;
@@ -140,9 +144,7 @@ export const LinkedItemsContents = <
const [activeTab, setActiveTab] = useState(0);
const [searchQuery, setSearchQuery] = useState("");
useEffect(() => {
console.log(error);
}, [error]);
useEffect(() => {}, [error]);
const parentResource = "route";
const childResource = "station";
@@ -155,17 +157,20 @@ export const LinkedItemsContents = <
// Фильтруем станции по направлению маршрута
return item.direction === routeDirection;
})
.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 (!cityId || item.city_id == cityId) {
if (!searchQuery.trim()) return true;
return String(item.name)
.toLowerCase()
.includes(searchQuery.toLowerCase());
}
return false;
return String(item.name).toLowerCase().includes(searchQuery.toLowerCase());
});
useEffect(() => {
@@ -227,7 +232,7 @@ export const LinkedItemsContents = <
if (type === "edit") {
setError(null);
authInstance
.get(`/${childResource}/`)
.get(`/${childResource}`)
.then((response) => {
setAllItems(response?.data || []);
})
@@ -462,9 +467,7 @@ export const LinkedItemsContents = <
onChange={(_, newValue) =>
setSelectedItemId(newValue?.id || null)
}
options={availableItems.filter(
(item) => !cityId || item.city_id == cityId
)}
options={availableItems}
getOptionLabel={(item) => String(item.name)}
renderInput={(params) => (
<TextField
@@ -599,3 +602,7 @@ export const LinkedItemsContents = <
</>
);
};
export const LinkedItemsContents = observer(
LinkedItemsContentsInner
) as typeof LinkedItemsContentsInner;

View File

@@ -16,13 +16,18 @@ import {
import { MediaViewer } from "@widgets";
import { observer } from "mobx-react-lite";
import { ArrowLeft, Loader2, Save, Plus } from "lucide-react";
import { useEffect, useState } from "react";
import { useEffect, useState, useMemo } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify";
import { carrierStore } from "../../../shared/store/CarrierStore";
import { articlesStore } from "../../../shared/store/ArticlesStore";
import { Route, routeStore } from "../../../shared/store/RouteStore";
import { languageStore, SelectArticleModal, SelectMediaDialog } from "@shared";
import {
languageStore,
SelectArticleModal,
SelectMediaDialog,
selectedCityStore,
} from "@shared";
export const RouteCreatePage = observer(() => {
const navigate = useNavigate();
@@ -50,6 +55,21 @@ export const RouteCreatePage = observer(() => {
articlesStore.getArticleList();
}, [language]);
// Фильтруем перевозчиков только из выбранного города
const filteredCarriers = useMemo(() => {
const carriers =
carrierStore.carriers[language as keyof typeof carrierStore.carriers]
.data || [];
if (!selectedCityStore.selectedCityId) {
return carriers;
}
return carriers.filter(
(carrier: any) => carrier.city_id === selectedCityStore.selectedCityId
);
}, [carrierStore.carriers, language, selectedCityStore.selectedCityId]);
const validateCoordinates = (value: string) => {
try {
const lines = value.trim().split("\n");
@@ -194,16 +214,10 @@ export const RouteCreatePage = observer(() => {
value={carrier}
label="Выберите перевозчика"
onChange={(e) => setCarrier(e.target.value as string)}
disabled={
carrierStore.carriers[
language as keyof typeof carrierStore.carriers
].data?.length === 0
}
disabled={filteredCarriers.length === 0}
>
<MenuItem value="">Не выбрано</MenuItem>
{carrierStore.carriers[
language as keyof typeof carrierStore.carriers
].data?.map((carrier) => (
{filteredCarriers.map((carrier: any) => (
<MenuItem key={carrier.id} value={carrier.id}>
{carrier.full_name}
</MenuItem>
@@ -301,10 +315,10 @@ export const RouteCreatePage = observer(() => {
</Box>
</Box>
{/* Селектор видео превью */}
{/* Селектор видеозаставки */}
<Box className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">
Видео превью
Видеозаставка
</label>
<Box className="flex gap-2">
<Box

View File

@@ -307,10 +307,10 @@ export const RouteEditPage = observer(() => {
</Box>
</Box>
{/* Селектор видео превью */}
{/* Селектор видеозаставки */}
<Box className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">
Видео превью
Видеозаставка
</label>
<Box className="flex gap-2">
<Box

View File

@@ -90,6 +90,7 @@ export const RouteListPage = observer(() => {
width: 250,
align: "center",
headerAlign: "center",
sortable: false,
renderCell: (params: GridRenderCellParams) => {
return (
<div className="flex h-full gap-7 justify-center items-center">

View File

@@ -1,7 +1,7 @@
export const UP_SCALE = 30000;
export const PATH_WIDTH = 15;
export const STATION_RADIUS = 20;
export const STATION_OUTLINE_WIDTH = 10;
export const UP_SCALE = 10000;
export const PATH_WIDTH = 5;
export const STATION_RADIUS = 8;
export const STATION_OUTLINE_WIDTH = 4;
export const SIGHT_SIZE = 40;
export const SCALE_FACTOR = 50;

View File

@@ -54,16 +54,16 @@ export function InfiniteCanvas({
const lastOriginalRotation = useRef<number | undefined>(undefined);
useEffect(() => {
const canvas = applicationRef?.app?.canvas;
if (!canvas) return;
if (!applicationRef?.app?.canvas) return;
const canvas = applicationRef.app.canvas;
const canvasRect = canvas.getBoundingClientRect();
const canvasLeft = canvasRect.left;
const canvasTop = canvasRect.top;
const centerX = window.innerWidth / 2 - canvasLeft;
const centerY = window.innerHeight / 2 - canvasTop;
setScreenCenter({ x: centerX, y: centerY });
}, [applicationRef?.app?.canvas, setScreenCenter]);
}, [applicationRef?.app, setScreenCenter]);
const handlePointerDown = (e: FederatedMouseEvent) => {
setIsPointerDown(true);

View File

@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
import { useTransform } from "./TransformContext";
import { coordinatesToLocal, localToCoordinates } from "./utils";
import { SCALE_FACTOR } from "./Constants";
import { toast } from "react-toastify";
export function RightSidebar() {
const {
@@ -100,7 +101,6 @@ export function RightSidebar() {
}
if (!routeData) {
console.error("routeData is null");
return null;
}
@@ -360,8 +360,14 @@ export function RightSidebar() {
variant="contained"
color="secondary"
sx={{ mt: 2 }}
onClick={() => {
saveChanges();
onClick={async () => {
try {
await saveChanges();
toast.success("Изменения сохранены");
} catch (error) {
console.error(error);
toast.error("Ошибка при сохранении изменений");
}
}}
>
Сохранить изменения

View File

@@ -82,11 +82,7 @@ export const Sight = ({ sight, id }: Readonly<SightProps>) => {
Assets.load("/SightIcon.png").then(setTexture);
}, []);
useEffect(() => {
console.log(
`Rendering Sight ${id + 1} at [${sight.latitude}, ${sight.longitude}]`
);
}, [id, sight.latitude, sight.longitude]);
useEffect(() => {}, [id, sight.latitude, sight.longitude]);
if (!sight) {
console.error("sight is null");

View File

@@ -57,8 +57,8 @@ export function Widgets() {
mb: 1,
}}
>
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
<Landmark size={16} />
<Box sx={{ display: "flex", gap: 0.5 }}>
<Landmark size={16} className="shrink-0" />
<Typography
variant="subtitle2"
sx={{ color: "#fff", fontWeight: "bold" }}

View File

@@ -26,6 +26,7 @@ import { Sight } from "./Sight";
import { SightData } from "./types";
import { Station } from "./Station";
import { UP_SCALE } from "./Constants";
import CircularProgress from "@mui/material/CircularProgress";
extend({
Container,
@@ -36,13 +37,27 @@ extend({
Text,
});
const Loading = () => {
const { isRouteLoading, isStationLoading, isSightLoading } = useMapData();
if (isRouteLoading || isStationLoading || isSightLoading) {
return (
<div className="fixed flex z-1 items-center justify-center h-screen w-screen bg-[#111]">
<CircularProgress />
</div>
);
}
return null;
};
export const RoutePreview = () => {
const { routeData, stationData, sightData } = useMapData();
return (
<MapDataProvider>
<TransformProvider>
<Stack direction="row" height="100vh" width="100vw" overflow="hidden">
<LanguageSwitcher />
{routeData && stationData && sightData ? <LanguageSwitcher /> : null}
<Loading />
<LeftSidebar />
<Stack direction="row" flex={1} position="relative" height="100%">
<RouteMap />
@@ -145,13 +160,13 @@ export const RouteMap = observer(() => {
]);
if (!routeData || !stationData || !sightData) {
console.error("routeData, stationData or sightData is null");
return <div>Loading...</div>;
return null;
}
return (
<div style={{ width: "100%", height: "100%" }} ref={parentRef}>
<Application resizeTo={parentRef} background="#fff">
<LanguageSwitcher />
<Application resizeTo={parentRef} background="#fff" preference="webgl">
<InfiniteCanvas>
<TravelPath points={points} />
{stationData[language].map((obj, index) => (

View File

@@ -1,7 +1,12 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { ruRU } from "@mui/x-data-grid/locales";
import { languageStore, sightsStore } from "@shared";
import { useEffect, useState } from "react";
import {
cityStore,
languageStore,
sightsStore,
selectedCityStore,
} from "@shared";
import { useEffect, useState, useMemo } from "react";
import { observer } from "mobx-react-lite";
import { Pencil, Trash2, Minus } from "lucide-react";
import { useNavigate } from "react-router-dom";
@@ -10,6 +15,7 @@ import { Box, CircularProgress } from "@mui/material";
export const SightListPage = observer(() => {
const { sights, getSights, deleteListSight } = sightsStore;
const { cities, getCities } = cityStore;
const navigate = useNavigate();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
@@ -21,7 +27,9 @@ export const SightListPage = observer(() => {
useEffect(() => {
const fetchSights = async () => {
setIsLoading(true);
await getCities(language);
await getSights();
setIsLoading(false);
};
fetchSights();
@@ -45,14 +53,14 @@ export const SightListPage = observer(() => {
},
},
{
field: "city",
field: "city_id",
headerName: "Город",
flex: 1,
renderCell: (params: GridRenderCellParams) => {
return (
<div className="w-full h-full flex items-center">
{params.value ? (
params.value
cities[language].data.find((el) => el.id == params.value)?.name
) : (
<Minus size={20} className="text-red-500" />
)}
@@ -65,6 +73,7 @@ export const SightListPage = observer(() => {
headerName: "Действия",
align: "center",
headerAlign: "center",
sortable: false,
renderCell: (params: GridRenderCellParams) => {
return (
@@ -89,10 +98,19 @@ export const SightListPage = observer(() => {
},
];
const rows = sights.map((sight) => ({
// Фильтрация достопримечательностей по выбранному городу
const filteredSights = useMemo(() => {
const { selectedCityId } = selectedCityStore;
if (!selectedCityId) {
return sights;
}
return sights.filter((sight: any) => sight.city_id === selectedCityId);
}, [sights, selectedCityStore.selectedCityId]);
const rows = filteredSights.map((sight) => ({
id: sight.id,
name: sight.name,
city: sight.city,
city_id: sight.city_id,
}));
return (
@@ -129,7 +147,6 @@ export const SightListPage = observer(() => {
loading={isLoading}
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
onRowSelectionModelChange={(newSelection) => {
console.log(newSelection);
setIds(Array.from(newSelection.ids as unknown as number[]));
}}
slots={{

View File

@@ -1,26 +1,20 @@
import { Button, Paper, TextField } from "@mui/material";
import { Button, TextField } from "@mui/material";
import { snapshotStore } from "@shared";
import { observer } from "mobx-react-lite";
import { ArrowLeft, Loader2, Save } from "lucide-react";
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify";
export const SnapshotCreatePage = observer(() => {
const { id } = useParams();
const { getSnapshot, createSnapshot } = snapshotStore;
const { createSnapshot } = snapshotStore;
const navigate = useNavigate();
const [name, setName] = useState("");
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
(async () => {
await getSnapshot(id as string);
})();
}, [id]);
return (
<Paper className="w-full h-full p-3 flex flex-col gap-10">
<div className="w-full h-[400px] flex justify-center items-center">
<div className="w-full h-full p-3 flex flex-col gap-10">
<div className="flex justify-between items-center">
<button
className="flex items-center gap-2"
@@ -51,11 +45,15 @@ export const SnapshotCreatePage = observer(() => {
await createSnapshot(name);
setIsLoading(false);
toast.success("Снапшот успешно создан");
navigate(-1);
} catch (error) {
console.error(error);
toast.error("Ошибка при создании снапшота");
} finally {
setIsLoading(false);
}
}}
disabled={isLoading}
disabled={isLoading || !name.trim()}
>
{isLoading ? (
<Loader2 size={20} className="animate-spin" />
@@ -64,6 +62,7 @@ export const SnapshotCreatePage = observer(() => {
)}
</Button>
</div>
</Paper>
</div>
</div>
);
});

View File

@@ -43,6 +43,7 @@ export const SnapshotListPage = observer(() => {
headerName: "Действия",
width: 300,
headerAlign: "center",
sortable: false,
renderCell: (params: GridRenderCellParams) => {
return (
@@ -117,12 +118,15 @@ export const SnapshotListPage = observer(() => {
<SnapshotRestore
open={isRestoreModalOpen}
loading={isLoading}
onDelete={async () => {
setIsLoading(true);
if (rowId) {
await restoreSnapshot(rowId);
}
setIsRestoreModalOpen(false);
setRowId(null);
setIsLoading(false);
}}
onCancel={() => {
setIsRestoreModalOpen(false);

View File

@@ -1,3 +1,2 @@
export * from "./SnapshotListPage";
export * from "./SnapshotCreatePage";

View File

@@ -17,9 +17,10 @@ import {
Paper,
TableBody,
} from "@mui/material";
import { observer } from "mobx-react-lite";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import { authInstance, languageStore } from "@shared";
import { authInstance, languageStore, selectedCityStore } from "@shared";
type Field<T> = {
label: string;
@@ -73,7 +74,7 @@ export const LinkedSights = <
);
};
export const LinkedSightsContents = <
const LinkedSightsContentsInner = <
T extends { id: number; name: string; [key: string]: any }
>({
parentId,
@@ -93,15 +94,21 @@ export const LinkedSightsContents = <
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
console.log(error);
}, [error]);
useEffect(() => {}, [error]);
const parentResource = "station";
const childResource = "sight";
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));
useEffect(() => {
@@ -178,7 +185,7 @@ export const LinkedSightsContents = <
if (type === "edit") {
setError(null);
authInstance
.get(`/${childResource}/`)
.get(`/${childResource}`)
.then((response) => {
setAllItems(response?.data || []);
})
@@ -315,3 +322,7 @@ export const LinkedSightsContents = <
</>
);
};
export const LinkedSightsContents = observer(
LinkedSightsContentsInner
) as typeof LinkedSightsContentsInner;

View File

@@ -12,9 +12,15 @@ import { ArrowLeft, Save } from "lucide-react";
import { Loader2 } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify";
import { stationsStore, languageStore, cityStore } from "@shared";
import {
stationsStore,
languageStore,
cityStore,
useSelectedCity,
} from "@shared";
import { useEffect, useState } from "react";
import { LanguageSwitcher } from "@widgets";
import { SaveWithoutCityAgree } from "@widgets";
export const StationCreatePage = observer(() => {
const navigate = useNavigate();
@@ -27,7 +33,10 @@ export const StationCreatePage = observer(() => {
setLanguageCreateStationData,
} = stationsStore;
const { cities, getCities } = cityStore;
const { selectedCityId, selectedCity } = useSelectedCity();
const [coordinates, setCoordinates] = useState<string>("");
// НОВОЕ СОСТОЯНИЕ ДЛЯ ПРЕДУПРЕЖДАЮЩЕГО ОКНА
const [isSaveWarningOpen, setIsSaveWarningOpen] = useState(false);
useEffect(() => {
if (
@@ -40,7 +49,8 @@ export const StationCreatePage = observer(() => {
}
}, [createStationData.common.latitude, createStationData.common.longitude]);
const handleCreate = async () => {
// НОВАЯ ФУНКЦИЯ: Фактическое создание (вызывается после подтверждения)
const executeCreate = async () => {
try {
setIsLoading(true);
await createStation();
@@ -54,6 +64,31 @@ export const StationCreatePage = observer(() => {
}
};
// ОБНОВЛЕННАЯ ФУНКЦИЯ: Проверка и вызов окна или создания
const handleCreate = async () => {
const isCityMissing = !createStationData.common.city_id;
// Проверяем названия на всех языках
const isNameMissing = !createStationData.ru.name || !createStationData.en.name || !createStationData.zh.name;
if (isCityMissing || isNameMissing) {
setIsSaveWarningOpen(true);
return;
}
await executeCreate();
};
// Обработчик "Да" в предупреждающем окне
const handleConfirmCreate = async () => {
setIsSaveWarningOpen(false);
await executeCreate();
};
// Обработчик "Нет" в предупреждающем окне
const handleCancelCreate = () => {
setIsSaveWarningOpen(false);
};
useEffect(() => {
const fetchCities = async () => {
await getCities("ru");
@@ -64,6 +99,16 @@ export const StationCreatePage = observer(() => {
fetchCities();
}, []);
// Автоматически устанавливаем выбранный город при загрузке страницы
useEffect(() => {
if (selectedCityId && selectedCity && !createStationData.common.city_id) {
setCreateCommonData({
city_id: selectedCityId,
city: selectedCity.name,
});
}
}, [selectedCityId, selectedCity, createStationData.common.city_id]);
return (
<Paper className="w-full h-full p-3 flex flex-col gap-10">
<LanguageSwitcher />
@@ -192,7 +237,7 @@ export const StationCreatePage = observer(() => {
className="w-min flex gap-2 items-center"
startIcon={<Save size={20} />}
onClick={handleCreate}
disabled={isLoading || !createStationData[language]?.name}
disabled={isLoading} // Убрал проверку name/city_id отсюда, чтобы ее обрабатывал handleCreate
>
{isLoading ? (
<Loader2 size={20} className="animate-spin" />
@@ -201,6 +246,16 @@ export const StationCreatePage = observer(() => {
)}
</Button>
</div>
{/* ИНТЕГРИРОВАННОЕ ПРЕДУПРЕЖДАЮЩЕЕ ОКНО */}
{isSaveWarningOpen && (
<SaveWithoutCityAgree
blocker={{
proceed: handleConfirmCreate,
reset: handleCancelCreate,
}}
/>
)}
</Paper>
);
});

View File

@@ -16,6 +16,7 @@ import { stationsStore, languageStore, cityStore } from "@shared";
import { useEffect, useState } from "react";
import { LanguageSwitcher } from "@widgets";
import { LinkedSights } from "../LinkedSights";
import { SaveWithoutCityAgree } from "@widgets";
export const StationEditPage = observer(() => {
const navigate = useNavigate();
@@ -31,9 +32,10 @@ export const StationEditPage = observer(() => {
} = stationsStore;
const { cities, getCities } = cityStore;
const [coordinates, setCoordinates] = useState<string>("");
// НОВОЕ СОСТОЯНИЕ ДЛЯ ПРЕДУПРЕЖДАЮЩЕГО ОКНА
const [isSaveWarningOpen, setIsSaveWarningOpen] = useState(false);
useEffect(() => {
// Устанавливаем русский язык при загрузке страницы
languageStore.setLanguage("ru");
}, []);
@@ -48,7 +50,8 @@ export const StationEditPage = observer(() => {
}
}, [editStationData.common.latitude, editStationData.common.longitude]);
const handleEdit = async () => {
// НОВАЯ ФУНКЦИЯ: Фактическое редактирование (вызывается после подтверждения)
const executeEdit = async () => {
try {
setIsLoading(true);
await editStation(Number(id));
@@ -61,6 +64,34 @@ export const StationEditPage = observer(() => {
}
};
// ОБНОВЛЕННАЯ ФУНКЦИЯ: Проверка и вызов окна или редактирования
const handleEdit = async () => {
const isCityMissing = !editStationData.common.city_id;
// Проверяем названия на всех языках
const isNameMissing =
!editStationData.ru.name ||
!editStationData.en.name ||
!editStationData.zh.name;
if (isCityMissing || isNameMissing) {
setIsSaveWarningOpen(true);
return;
}
await executeEdit();
};
// Обработчик "Да" в предупреждающем окне
const handleConfirmEdit = async () => {
setIsSaveWarningOpen(false);
await executeEdit();
};
// Обработчик "Нет" в предупреждающем окне
const handleCancelEdit = () => {
setIsSaveWarningOpen(false);
};
useEffect(() => {
const fetchAndSetStationData = async () => {
if (!id) return;
@@ -78,6 +109,7 @@ export const StationEditPage = observer(() => {
return (
<Paper className="w-full h-full p-3 flex flex-col gap-10">
<LanguageSwitcher />
<div className="flex items-center gap-4">
<button
className="flex items-center gap-2"
@@ -211,7 +243,7 @@ export const StationEditPage = observer(() => {
className="w-min flex gap-2 items-center"
startIcon={<Save size={20} />}
onClick={handleEdit}
disabled={isLoading || !editStationData[language]?.name}
disabled={isLoading} // Убрал проверку name/city_id отсюда, чтобы ее обрабатывал handleEdit
>
{isLoading ? (
<Loader2 size={20} className="animate-spin" />
@@ -220,6 +252,16 @@ export const StationEditPage = observer(() => {
)}
</Button>
</div>
{/* ИНТЕГРИРОВАННОЕ ПРЕДУПРЕЖДАЮЩЕЕ ОКНО */}
{isSaveWarningOpen && (
<SaveWithoutCityAgree
blocker={{
proceed: handleConfirmEdit,
reset: handleCancelEdit,
}}
/>
)}
</Paper>
);
});

View File

@@ -1,6 +1,11 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { ruRU } from "@mui/x-data-grid/locales";
import { languageStore, stationsStore } from "@shared";
import {
languageStore,
stationsStore,
selectedCityStore,
cityStore,
} from "@shared";
import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite";
import { Eye, Pencil, Trash2, Minus } from "lucide-react";
@@ -21,6 +26,7 @@ export const StationListPage = observer(() => {
useEffect(() => {
const fetchStations = async () => {
setIsLoading(true);
await cityStore.getCities(language);
await getStationList();
setIsLoading(false);
};
@@ -85,6 +91,7 @@ export const StationListPage = observer(() => {
width: 140,
align: "center",
headerAlign: "center",
sortable: false,
renderCell: (params: GridRenderCellParams) => {
return (
@@ -109,7 +116,18 @@ export const StationListPage = observer(() => {
},
];
const rows = stationLists[language].data.map((station: any) => ({
// Фильтрация станций по выбранному городу
const filteredStations = () => {
const { selectedCityId } = selectedCityStore;
if (!selectedCityId) {
return stationLists[language].data;
}
return stationLists[language].data.filter(
(station: any) => station.city_id === selectedCityId
);
};
const rows = filteredStations().map((station: any) => ({
id: station.id,
name: station.name,
system_name: station.system_name,

View File

@@ -83,6 +83,7 @@ export const UserListPage = observer(() => {
flex: 1,
align: "center",
headerAlign: "center",
sortable: false,
renderCell: (params: GridRenderCellParams) => {
return (

View File

@@ -102,6 +102,7 @@ export const VehicleListPage = observer(() => {
width: 200,
align: "center",
headerAlign: "center",
sortable: false,
renderCell: (params: GridRenderCellParams) => {
return (

View File

@@ -2,7 +2,7 @@ import { languageStore, Language } from "@shared";
import axios, { AxiosError, InternalAxiosRequestConfig } from "axios";
const authInstance = axios.create({
baseURL: "https://wn.krbl.ru",
baseURL: import.meta.env.VITE_API_URL,
});
authInstance.interceptors.request.use((config: InternalAxiosRequestConfig) => {
@@ -24,7 +24,7 @@ authInstance.interceptors.response.use(
const languageInstance = (language: Language) => {
const instance = axios.create({
baseURL: "https://wn.krbl.ru",
baseURL: import.meta.env.VITE_API_URL,
});
instance.interceptors.request.use((config) => {
config.headers.Authorization = `Bearer ${localStorage.getItem("token")}`;

View File

@@ -25,6 +25,7 @@ interface NavigationItem {
label: string;
icon?: LucideIcon | React.ReactNode;
path?: string;
for_admin?: boolean;
onClick?: () => void;
nestedItems?: NavigationItem[];
isActive?: boolean;
@@ -40,6 +41,7 @@ export const NAVIGATION_ITEMS: {
label: "Снапшоты",
icon: GitBranch,
path: "/snapshot",
for_admin: true,
},
{
id: "map",
@@ -52,6 +54,7 @@ export const NAVIGATION_ITEMS: {
label: "Устройства",
icon: Cpu,
path: "/devices",
for_admin: true,
},
// {
// id: "vehicles",
@@ -64,6 +67,7 @@ export const NAVIGATION_ITEMS: {
label: "Пользователи",
icon: Users,
path: "/user",
for_admin: true,
},
{
id: "all",
@@ -106,12 +110,14 @@ export const NAVIGATION_ITEMS: {
label: "Страны",
icon: Earth,
path: "/country",
for_admin: true,
},
{
id: "cities",
label: "Города",
icon: Building2,
path: "/city",
for_admin: true,
},
{
id: "carriers",
@@ -119,6 +125,7 @@ export const NAVIGATION_ITEMS: {
// @ts-ignore
icon: CarrierSvg,
path: "/carrier",
for_admin: true,
},
],
},

View File

@@ -1,4 +1,4 @@
export const API_URL = "https://wn.krbl.ru";
export const API_URL = import.meta.env.VITE_API_URL;
export const MEDIA_TYPE_LABELS = {
1: "Фото",
2: "Видео",
@@ -8,6 +8,8 @@ export const MEDIA_TYPE_LABELS = {
6: "3Д-модель",
};
export * from "./mediaTypes";
export const MEDIA_TYPE_VALUES = {
image: 1,
video: 2,
@@ -17,6 +19,7 @@ export const MEDIA_TYPE_VALUES = {
watermark_rd: 4,
panorama: 5,
model: 6,
video_preview: 2,
};
export const RU_COUNTRIES = [

View File

@@ -0,0 +1,85 @@
// Допустимые типы и расширения файлов для медиа
export const ALLOWED_MEDIA_TYPES = {
image: {
extensions: [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg"],
mimeTypes: [
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
"image/bmp",
"image/svg+xml",
],
accept: "image/*",
},
video: {
extensions: [".mp4", ".webm", ".ogg", ".mov", ".avi"],
mimeTypes: [
"video/mp4",
"video/webm",
"video/ogg",
"video/quicktime",
"video/x-msvideo",
],
accept: "video/*",
},
model3d: {
extensions: [".glb", ".gltf"],
mimeTypes: ["model/gltf-binary", "model/gltf+json"],
accept: ".glb,.gltf",
},
panorama: {
extensions: [".jpg", ".jpeg", ".png"],
mimeTypes: ["image/jpeg", "image/png"],
accept: "image/*",
},
} as const;
export const getAllAllowedExtensions = (): string[] => {
return [
...ALLOWED_MEDIA_TYPES.image.extensions,
...ALLOWED_MEDIA_TYPES.video.extensions,
...ALLOWED_MEDIA_TYPES.model3d.extensions,
];
};
export const getAllAcceptString = (): string => {
return `${ALLOWED_MEDIA_TYPES.image.accept},${ALLOWED_MEDIA_TYPES.video.accept},${ALLOWED_MEDIA_TYPES.model3d.accept}`;
};
export const validateFileExtension = (
file: File
): { valid: boolean; error?: string } => {
const fileName = file.name.toLowerCase();
const extension = fileName.substring(fileName.lastIndexOf("."));
const allowedExtensions = getAllAllowedExtensions();
if (!allowedExtensions.includes(extension)) {
return {
valid: false,
error: `Неподдерживаемый формат файла "${extension}". Допустимые форматы: ${allowedExtensions.join(
", "
)}`,
};
}
return { valid: true };
};
export const filterValidFiles = (
files: File[]
): { validFiles: File[]; errors: string[] } => {
const validFiles: File[] = [];
const errors: string[] = [];
files.forEach((file) => {
const validation = validateFileExtension(file);
if (validation.valid) {
validFiles.push(file);
} else {
errors.push(`${file.name}: ${validation.error}`);
}
});
return { validFiles, errors };
};

View File

@@ -0,0 +1 @@
export * from "./useSelectedCity";

View File

@@ -0,0 +1,12 @@
import { selectedCityStore } from "@shared";
export const useSelectedCity = () => {
const { selectedCity, selectedCityId, selectedCityName } = selectedCityStore;
return {
selectedCity,
selectedCityId,
selectedCityName,
hasSelectedCity: !!selectedCity,
};
};

View File

@@ -5,3 +5,4 @@ export * from "./store";
export * from "./const";
export * from "./api";
export * from "./modals";
export * from "./hooks";

View File

@@ -0,0 +1,82 @@
/**
* Утилита для управления кешем GLTF и blob URL
*/
// Динамический импорт useGLTF для избежания проблем с SSR
let useGLTF: any = null;
const initializeUseGLTF = async () => {
if (!useGLTF) {
try {
const drei = await import("@react-three/drei");
useGLTF = drei.useGLTF;
} catch (error) {
console.warn(
"⚠️ GLTFCacheManager: Не удалось импортировать useGLTF",
error
);
}
}
return useGLTF;
};
/**
* Очищает кеш GLTF для конкретного URL
*/
export const clearGLTFCacheForUrl = async (url: string) => {
try {
const gltf = await initializeUseGLTF();
if (gltf && gltf.clear) {
gltf.clear(url);
}
} catch (error) {}
};
/**
* Очищает весь кеш GLTF
*/
export const clearAllGLTFCache = async () => {
try {
const gltf = await initializeUseGLTF();
if (gltf && gltf.clear) {
gltf.clear();
}
} catch (error) {}
};
/**
* Очищает blob URL из памяти браузера
*/
export const revokeBlobURL = (url: string) => {
if (url && url.startsWith("blob:")) {
try {
URL.revokeObjectURL(url);
} catch (error) {}
}
};
/**
* Комплексная очистка: blob URL + кеш GLTF
*/
export const clearBlobAndGLTFCache = async (url: string) => {
// Сначала отзываем blob URL
revokeBlobURL(url);
// Затем очищаем кеш GLTF
await clearGLTFCacheForUrl(url);
};
/**
* Очистка при смене медиа (для предотвращения конфликтов)
*/
export const clearMediaTransitionCache = async (
previousMediaId: string | number | null,
newMediaId: string | number | null,
newMediaType?: number
) => {
console.log(newMediaId, newMediaType);
// Если переключаемся с/на 3D модель, очищаем весь кеш
if (newMediaType === 6 || previousMediaId) {
await clearAllGLTFCache();
}
};

View File

@@ -1,5 +1,6 @@
export * from "./mui/theme";
export * from "./DecodeJWT";
export * from "./gltfCacheManager";
/**
* Генерирует название медиа по умолчанию в разных форматах

View File

@@ -3,9 +3,10 @@ import {
MEDIA_TYPE_VALUES,
editSightStore,
generateDefaultMediaName,
clearBlobAndGLTFCache,
} from "@shared";
import { observer } from "mobx-react-lite";
import { useEffect, useState } from "react";
import { useEffect, useState, useRef } from "react";
import {
Dialog,
DialogTitle,
@@ -36,7 +37,13 @@ interface UploadMediaDialogProps {
media_type: number;
}) => void;
afterUploadSight?: (id: string) => void;
hardcodeType?: "thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null;
hardcodeType?:
| "thumbnail"
| "watermark_lu"
| "watermark_rd"
| "image"
| "video_preview"
| null;
contextObjectName?: string;
contextType?:
| "sight"
@@ -47,6 +54,7 @@ interface UploadMediaDialogProps {
| "station";
isArticle?: boolean;
articleName?: string;
initialFile?: File; // <--- добавлено
}
export const UploadMediaDialog = observer(
@@ -60,6 +68,7 @@ export const UploadMediaDialog = observer(
isArticle,
articleName,
initialFile, // <--- добавлено
}: UploadMediaDialogProps) => {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -73,6 +82,41 @@ export const UploadMediaDialog = observer(
const [availableMediaTypes, setAvailableMediaTypes] = useState<number[]>(
[]
);
const [isPreviewLoaded, setIsPreviewLoaded] = useState(false);
const previousMediaUrlRef = useRef<string | null>(null);
useEffect(() => {
if (initialFile) {
// Очищаем предыдущий blob URL если он существует
if (
previousMediaUrlRef.current &&
previousMediaUrlRef.current.startsWith("blob:")
) {
clearBlobAndGLTFCache(previousMediaUrlRef.current);
}
setMediaFile(initialFile);
setMediaFilename(initialFile.name);
setAvailableMediaTypes([2]);
setMediaType(2);
const newBlobUrl = URL.createObjectURL(initialFile);
setMediaUrl(newBlobUrl);
previousMediaUrlRef.current = newBlobUrl;
setMediaName(initialFile.name.replace(/\.[^/.]+$/, ""));
}
}, [initialFile]);
// Очистка blob URL при размонтировании компонента
useEffect(() => {
return () => {
if (
previousMediaUrlRef.current &&
previousMediaUrlRef.current.startsWith("blob:")
) {
clearBlobAndGLTFCache(previousMediaUrlRef.current);
}
};
}, []); // Пустой массив зависимостей - выполняется только при размонтировании
useEffect(() => {
if (fileToUpload) {
@@ -85,7 +129,11 @@ export const UploadMediaDialog = observer(
setAvailableMediaTypes([6]);
setMediaType(6);
}
if (["jpg", "jpeg", "png", "gif", "svg"].includes(extension)) {
if (
["jpg", "jpeg", "png", "gif", "svg", "webp", "bmp"].includes(
extension
)
) {
// Для изображений доступны все типы кроме видео
setAvailableMediaTypes([1, 3, 4, 5]); // Фото, Иконка, Водяной знак, Панорама, 3Д-модель
setMediaType(1); // По умолчанию Фото
@@ -187,9 +235,20 @@ export const UploadMediaDialog = observer(
useEffect(() => {
if (mediaFile) {
setMediaUrl(URL.createObjectURL(mediaFile as Blob));
// Очищаем предыдущий blob URL и кеш GLTF если он существует
if (
previousMediaUrlRef.current &&
previousMediaUrlRef.current.startsWith("blob:")
) {
clearBlobAndGLTFCache(previousMediaUrlRef.current);
}
}, [mediaFile]);
const newBlobUrl = URL.createObjectURL(mediaFile as Blob);
setMediaUrl(newBlobUrl);
previousMediaUrlRef.current = newBlobUrl; // Сохраняем новый URL в ref
setIsPreviewLoaded(false); // Сбрасываем состояние загрузки при смене файла
}
}, [mediaFile]); // Убираем mediaUrl из зависимостей чтобы избежать зацикливания
// const fileFormat = useEffect(() => {
// const handleKeyPress = (event: KeyboardEvent) => {
@@ -226,6 +285,10 @@ export const UploadMediaDialog = observer(
}
}
setSuccess(true);
// Закрываем модальное окно после успешного сохранения
setTimeout(() => {
handleClose();
}, 1000); // Небольшая задержка, чтобы пользователь увидел сообщение об успехе
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to save media");
} finally {
@@ -234,8 +297,20 @@ export const UploadMediaDialog = observer(
};
const handleClose = () => {
// Очищаем blob URL и кеш GLTF при закрытии диалога
if (
previousMediaUrlRef.current &&
previousMediaUrlRef.current.startsWith("blob:")
) {
clearBlobAndGLTFCache(previousMediaUrlRef.current);
}
setError(null);
setSuccess(false);
setMediaUrl(null);
setMediaFile(null);
setIsPreviewLoaded(false);
previousMediaUrlRef.current = null; // Очищаем ref
onClose();
};
@@ -303,8 +378,22 @@ export const UploadMediaDialog = observer(
alignItems: "center",
justifyContent: "center",
height: "100%",
position: "relative",
}}
>
{!isPreviewLoaded && mediaUrl && (
<Box
sx={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
zIndex: 1,
}}
>
<CircularProgress />
</Box>
)}
{mediaType == 2 && mediaUrl && (
<video
src={mediaUrl}
@@ -313,10 +402,16 @@ export const UploadMediaDialog = observer(
loop
controls
style={{ maxWidth: "100%", maxHeight: "100%" }}
onLoadedData={() => setIsPreviewLoaded(true)}
onError={() => setIsPreviewLoaded(true)}
/>
)}
{mediaType === 6 && mediaUrl && (
<ModelViewer3D fileUrl={mediaUrl} height="100%" />
<ModelViewer3D
fileUrl={mediaUrl}
height="100%"
onLoad={() => setIsPreviewLoaded(true)}
/>
)}
{mediaType !== 6 && mediaType !== 2 && mediaUrl && (
<img
@@ -326,6 +421,8 @@ export const UploadMediaDialog = observer(
height: "100%",
objectFit: "contain",
}}
onLoad={() => setIsPreviewLoaded(true)}
onError={() => setIsPreviewLoaded(true)}
/>
)}
</Paper>
@@ -333,18 +430,31 @@ export const UploadMediaDialog = observer(
<Box className="flex flex-col gap-2 self-end">
<Button
variant="contained"
color="success"
sx={{
backgroundColor: isLoading ? "#9e9e9e" : "#4caf50",
"&:hover": {
backgroundColor: isLoading ? "#9e9e9e" : "#45a049",
},
}}
startIcon={
isLoading ? (
<CircularProgress size={16} />
<CircularProgress size={16} color="inherit" />
) : (
<Save size={16} />
)
}
onClick={handleSave}
disabled={isLoading || (!mediaName && !mediaFilename)}
disabled={
isLoading ||
(!mediaName && !mediaFilename) ||
!isPreviewLoaded
}
>
Сохранить
{isLoading
? "Сохранение..."
: !isPreviewLoaded
? "Загрузка превью..."
: "Сохранить"}
</Button>
</Box>
</Box>

View File

@@ -551,7 +551,6 @@ class CreateSightStore {
});
}
console.log("Sight created with ID:", newSightId);
// Optionally: this.clearCreateSight(); // To reset form after successful creation
this.needLeaveAgree = false;
return newSightId;

View File

@@ -12,6 +12,7 @@ class DevicesStore {
getDevices = async () => {
const response = await authInstance.get(`${API_URL}/devices/connected`);
runInAction(() => {
this.devices = response.data;
});

View File

@@ -497,9 +497,7 @@ class EditSightStore {
media_name: media_name,
media_type: type,
};
} catch (error) {
console.log(error);
}
} catch (error) {}
};
createLinkWithArticle = async (media: {

View File

@@ -0,0 +1,15 @@
import { makeAutoObservable } from "mobx";
class MenuStore {
isOpen: boolean = true;
constructor() {
makeAutoObservable(this);
}
setIsMenuOpen = (isOpen: boolean) => {
this.isOpen = isOpen;
};
}
export const menuStore = new MenuStore();

View File

@@ -0,0 +1,101 @@
import { makeAutoObservable } from "mobx";
export interface ModelLoadingState {
isLoading: boolean;
progress: number;
modelId: string | null;
error?: string;
startTime?: number;
}
class ModelLoadingStore {
private loadingStates: Map<string, ModelLoadingState> = new Map();
constructor() {
makeAutoObservable(this);
}
// Начать отслеживание загрузки модели
startLoading(modelId: string) {
this.loadingStates.set(modelId, {
isLoading: true,
progress: 0,
modelId,
startTime: Date.now(),
});
}
// Обновить прогресс загрузки
updateProgress(modelId: string, progress: number) {
const state = this.loadingStates.get(modelId);
if (state) {
state.progress = Math.min(100, Math.max(0, progress));
}
}
// Завершить загрузку модели
finishLoading(modelId: string) {
const state = this.loadingStates.get(modelId);
if (state) {
state.isLoading = false;
state.progress = 100;
}
}
// Остановить загрузку (в случае ошибки)
stopLoading(modelId: string) {
this.loadingStates.delete(modelId);
}
// Обработать ошибку загрузки
handleError(modelId: string, error?: string) {
const state = this.loadingStates.get(modelId);
if (state) {
state.isLoading = false;
state.error = error || "Ошибка загрузки модели";
}
}
// Получить состояние загрузки для конкретной модели
getLoadingState(modelId: string): ModelLoadingState | undefined {
return this.loadingStates.get(modelId);
}
// Проверить, загружается ли какая-либо модель
get isAnyModelLoading(): boolean {
return Array.from(this.loadingStates.values()).some(
(state) => state.isLoading
);
}
// Получить все загружающиеся модели
get loadingModels(): ModelLoadingState[] {
return Array.from(this.loadingStates.values()).filter(
(state) => state.isLoading
);
}
// Получить общий прогресс всех загружающихся моделей
get overallProgress(): number {
const loadingModels = this.loadingModels;
if (loadingModels.length === 0) return 100;
const totalProgress = loadingModels.reduce(
(sum, model) => sum + model.progress,
0
);
return Math.round(totalProgress / loadingModels.length);
}
// Проверить, заблокировано ли сохранение (есть ли загружающиеся модели)
get isSaveBlocked(): boolean {
return this.isAnyModelLoading;
}
// Очистить все состояния загрузки
clearAll() {
this.loadingStates.clear();
}
}
export const modelLoadingStore = new ModelLoadingStore();

View File

@@ -82,11 +82,6 @@ class RouteStore {
};
setRouteStations = (routeId: number, stationId: number, data: any) => {
console.log(
this.routeStations[routeId],
stationId,
this.routeStations[routeId].find((station) => station.id === stationId)
);
this.routeStations[routeId] = this.routeStations[routeId]?.map((station) =>
station.id === stationId ? { ...station, ...data } : station
);
@@ -139,7 +134,6 @@ class RouteStore {
copyRouteAction = async (id: number) => {
const response = await authInstance.post(`/route/${id}/copy`);
console.log(response);
runInAction(() => {
this.routes.data = [...this.routes.data, response.data];

View File

@@ -0,0 +1,48 @@
import { makeAutoObservable, runInAction } from "mobx";
import { City } from "../CityStore";
class SelectedCityStore {
selectedCity: City | null = null;
constructor() {
makeAutoObservable(this);
this.initialize();
}
private initialize() {
const storedCity = localStorage.getItem("selectedCity");
if (storedCity) {
try {
this.selectedCity = JSON.parse(storedCity);
} catch (error) {
console.error("Error parsing stored city:", error);
localStorage.removeItem("selectedCity");
}
}
}
setSelectedCity = (city: City | null) => {
runInAction(() => {
this.selectedCity = city;
if (city) {
localStorage.setItem("selectedCity", JSON.stringify(city));
} else {
localStorage.removeItem("selectedCity");
}
});
};
clearSelectedCity = () => {
this.setSelectedCity(null);
};
get selectedCityId() {
return this.selectedCity?.id || null;
}
get selectedCityName() {
return this.selectedCity?.name || null;
}
}
export const selectedCityStore = new SelectedCityStore();

View File

@@ -97,15 +97,19 @@ class SightsStore {
city: number,
coordinates: { latitude: number; longitude: number }
) => {
const id = (
await authInstance.post("/sight", {
const response = await authInstance.post("/sight", {
name: this.createSight[languageStore.language].name,
address: this.createSight[languageStore.language].address,
city_id: city,
latitude: coordinates.latitude,
longitude: coordinates.longitude,
})
).data.id;
});
runInAction(() => {
this.sights.push(response.data);
});
const id = response.data.id;
const anotherLanguages = ["ru", "en", "zh"].filter(
(language) => language !== languageStore.language

View File

@@ -1,6 +1,24 @@
import { authInstance } from "@shared";
import { makeAutoObservable, runInAction } from "mobx";
// Импорт функции сброса кешей карты
// import { clearMapCaches } from "../../pages/MapPage";
import {
articlesStore,
cityStore,
countryStore,
carrierStore,
stationsStore,
sightsStore,
routeStore,
vehicleStore,
userStore,
mediaStore,
createSightStore,
editSightStore,
devicesStore,
authStore,
} from "@shared";
type Snapshot = {
ID: string;
@@ -17,6 +35,230 @@ class SnapshotStore {
makeAutoObservable(this);
}
// Функция для сброса всех кешей в приложении
private clearAllCaches = () => {
// Сброс кешей статей
articlesStore.articleList = {
ru: { data: [], loaded: false },
en: { data: [], loaded: false },
zh: { data: [], loaded: false },
};
articlesStore.articlePreview = {};
articlesStore.articleData = null;
articlesStore.articleMedia = null;
// Сброс кешей городов
cityStore.cities = {
ru: { data: [], loaded: false },
en: { data: [], loaded: false },
zh: { data: [], loaded: false },
};
cityStore.ruCities = { data: [], loaded: false };
cityStore.city = {};
// Сброс кешей стран
countryStore.countries = {
ru: { data: [], loaded: false },
en: { data: [], loaded: false },
zh: { data: [], loaded: false },
};
// Сброс кешей перевозчиков
carrierStore.carriers = {
ru: { data: [], loaded: false },
en: { data: [], loaded: false },
zh: { data: [], loaded: false },
};
// Сброс кешей станций
stationsStore.stationLists = {
ru: { data: [], loaded: false },
en: { data: [], loaded: false },
zh: { data: [], loaded: false },
};
stationsStore.stationPreview = {};
// Сброс кешей достопримечательностей
sightsStore.sights = [];
sightsStore.sight = null;
// Сброс кешей маршрутов
routeStore.routes = { data: [], loaded: false };
// Сброс кешей транспорта
vehicleStore.vehicles = { data: [], loaded: false };
// Сброс кешей пользователей
userStore.users = { data: [], loaded: false };
// Сброс кешей медиа
mediaStore.media = [];
mediaStore.oneMedia = null;
// Сброс кешей создания и редактирования достопримечательностей
createSightStore.sight = JSON.parse(
JSON.stringify({
city_id: 0,
city: "",
latitude: 0,
longitude: 0,
thumbnail: null,
watermark_lu: null,
watermark_rd: null,
left_article: 0,
preview_media: null,
video_preview: null,
ru: {
name: "",
address: "",
left: { heading: "", body: "", media: [] },
right: [],
},
en: {
name: "",
address: "",
left: { heading: "", body: "", media: [] },
right: [],
},
zh: {
name: "",
address: "",
left: { heading: "", body: "", media: [] },
right: [],
},
})
);
createSightStore.uploadMediaOpen = false;
createSightStore.fileToUpload = null;
createSightStore.needLeaveAgree = false;
editSightStore.sight = {
common: {
id: 0,
city_id: 0,
city: "",
latitude: 0,
longitude: 0,
thumbnail: null,
watermark_lu: null,
watermark_rd: null,
left_article: 0,
preview_media: null,
video_preview: null,
},
ru: {
id: 0,
name: "",
address: "",
left: { heading: "", body: "", media: [] },
right: [],
},
en: {
id: 0,
name: "",
address: "",
left: { heading: "", body: "", media: [] },
right: [],
},
zh: {
id: 0,
name: "",
address: "",
left: { heading: "", body: "", media: [] },
right: [],
},
};
editSightStore.hasLoadedCommon = false;
editSightStore.uploadMediaOpen = false;
editSightStore.fileToUpload = null;
editSightStore.needLeaveAgree = false;
// Сброс кешей устройств
devicesStore.devices = [];
devicesStore.uuid = null;
devicesStore.sendSnapshotModalOpen = false;
// Сброс кешей авторизации (кроме токена)
authStore.payload = null;
authStore.error = null;
authStore.isLoading = false;
// Сброс кешей карты (если они загружены)
try {
// Сбрасываем кеши mapStore если он доступен
if (typeof window !== "undefined" && (window as any).mapStore) {
(window as any).mapStore.routes = [];
(window as any).mapStore.stations = [];
(window as any).mapStore.sights = [];
}
// Сбрасываем кеши MapService если он доступен
if (typeof window !== "undefined" && (window as any).mapServiceInstance) {
(window as any).mapServiceInstance.clearCaches();
}
} catch (error) {
console.warn("Не удалось сбросить кеши карты:", error);
}
// Сброс localStorage кешей (кроме токена авторизации)
const token = localStorage.getItem("token");
const rememberedEmail = localStorage.getItem("rememberedEmail");
const rememberedPassword = localStorage.getItem("rememberedPassword");
localStorage.clear();
sessionStorage.clear();
// Восстанавливаем важные данные
if (token) localStorage.setItem("token", token);
if (rememberedEmail)
localStorage.setItem("rememberedEmail", rememberedEmail);
if (rememberedPassword)
localStorage.setItem("rememberedPassword", rememberedPassword);
// Сброс кешей карты (если они есть)
const mapPositionKey = "mapPosition";
const activeSectionKey = "mapActiveSection";
if (localStorage.getItem(mapPositionKey)) {
localStorage.removeItem(mapPositionKey);
}
if (localStorage.getItem(activeSectionKey)) {
localStorage.removeItem(activeSectionKey);
}
// Попытка очистить кеш браузера (если поддерживается)
if ("caches" in window) {
try {
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames.map((cacheName) => {
return caches.delete(cacheName);
})
);
});
} catch (error) {
console.warn("Кеш браузера не поддерживается:", error);
}
}
// Попытка очистить IndexedDB (если поддерживается)
if ("indexedDB" in window) {
try {
indexedDB.databases().then((databases) => {
return Promise.all(
databases.map((db) => {
if (db.name) {
return indexedDB.deleteDatabase(db.name);
}
return Promise.resolve();
})
);
});
} catch (error) {
console.warn("IndexedDB не поддерживается:", error);
}
}
};
getSnapshots = async () => {
const response = await authInstance.get(`/snapshots`);
@@ -42,6 +284,10 @@ class SnapshotStore {
};
restoreSnapshot = async (id: string) => {
// Сначала сбрасываем все кеши
this.clearAllCaches();
// Затем восстанавливаем снапшот
await authInstance.post(`/snapshots/${id}/restore`);
};

View File

@@ -14,3 +14,5 @@ export * from "./RouteStore";
export * from "./UserStore";
export * from "./CarrierStore";
export * from "./StationsStore";
export * from "./MenuStore";
export * from "./SelectedCityStore";

View File

@@ -0,0 +1,143 @@
import React from "react";
import {
Box,
CircularProgress,
Typography,
LinearProgress,
} from "@mui/material";
interface LoadingSpinnerProps {
progress?: number;
message?: string;
size?: number;
color?: "primary" | "secondary" | "error" | "info" | "success" | "warning";
variant?: "circular" | "linear";
showPercentage?: boolean;
thickness?: number;
className?: string;
}
export const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
progress,
message = "Загрузка...",
size = 40,
color = "primary",
variant = "circular",
showPercentage = true,
thickness = 4,
className,
}) => {
if (variant === "linear") {
return (
<Box
className={className}
sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 2,
padding: 3,
width: "100%",
}}
>
<Box sx={{ width: "100%", maxWidth: 300 }}>
<LinearProgress
variant={progress !== undefined ? "determinate" : "indeterminate"}
value={progress}
color={color}
sx={{
height: 8,
borderRadius: 4,
backgroundColor: "rgba(0, 0, 0, 0.1)",
"& .MuiLinearProgress-bar": {
borderRadius: 4,
},
}}
/>
{showPercentage && progress !== undefined && (
<Typography
variant="caption"
color="text.secondary"
sx={{
display: "block",
textAlign: "center",
mt: 1,
fontSize: "0.875rem",
fontWeight: 500,
}}
>
{`${Math.round(progress)}%`}
</Typography>
)}
</Box>
{message && (
<Typography variant="body2" color="text.secondary" textAlign="center">
{message}
</Typography>
)}
</Box>
);
}
return (
<Box
className={className}
sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 2,
padding: 3,
}}
>
<Box sx={{ position: "relative", display: "inline-flex" }}>
<CircularProgress
size={size}
color={color}
variant={progress !== undefined ? "determinate" : "indeterminate"}
value={progress}
thickness={thickness}
sx={{
"& .MuiCircularProgress-circle": {
strokeLinecap: "round",
},
}}
/>
{showPercentage && progress !== undefined && (
<Box
sx={{
top: 0,
left: 0,
bottom: 0,
right: 0,
position: "absolute",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Typography
variant="caption"
component="div"
color="text.secondary"
sx={{
fontSize: size * 0.25,
fontWeight: 600,
lineHeight: 1,
}}
>
{`${Math.round(progress)}%`}
</Typography>
</Box>
)}
</Box>
{message && (
<Typography variant="body2" color="text.secondary" textAlign="center">
{message}
</Typography>
)}
</Box>
);
};

View File

@@ -0,0 +1,196 @@
import React from "react";
import {
Box,
Typography,
LinearProgress,
CircularProgress,
} from "@mui/material";
interface ModelLoadingIndicatorProps {
progress?: number;
message?: string;
isVisible?: boolean;
variant?: "overlay" | "inline";
size?: "small" | "medium" | "large";
showDetails?: boolean;
}
export const ModelLoadingIndicator: React.FC<ModelLoadingIndicatorProps> = ({
progress = 0,
message = "Загрузка 3D модели...",
isVisible = true,
variant = "overlay",
size = "medium",
showDetails = true,
}) => {
const sizeConfig = {
small: {
spinnerSize: 32,
fontSize: "0.75rem",
progressBarWidth: 150,
padding: 2,
},
medium: {
spinnerSize: 48,
fontSize: "0.875rem",
progressBarWidth: 200,
padding: 3,
},
large: {
spinnerSize: 64,
fontSize: "1rem",
progressBarWidth: 250,
padding: 4,
},
};
const currentSize = sizeConfig[size];
if (!isVisible) return null;
const getProgressStage = (progress: number): string => {
if (progress < 20) return "Инициализация...";
if (progress < 40) return "Загрузка геометрии...";
if (progress < 60) return "Обработка материалов...";
if (progress < 80) return "Загрузка текстур...";
if (progress < 95) return "Финализация...";
if (progress === 100) return "Готово!";
return "Загрузка...";
};
const content = (
<Box
sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 2,
padding: currentSize.padding,
textAlign: "center",
width: "100%",
}}
>
{/* Крутяшка с процентами */}
<Box sx={{ position: "relative", display: "inline-flex" }}>
<CircularProgress
size={currentSize.spinnerSize}
variant="determinate"
value={progress}
thickness={4}
sx={{
color: "primary.main",
}}
/>
<Box
sx={{
top: 0,
left: 0,
bottom: 0,
right: 0,
position: "absolute",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Typography
variant="caption"
component="div"
color="text.secondary"
sx={{
fontSize: currentSize.spinnerSize * 0.25,
fontWeight: 600,
lineHeight: 1,
}}
>
{`${Math.round(progress)}%`}
</Typography>
</Box>
</Box>
{/* Линейный прогресс бар */}
<Box sx={{ width: "100%", maxWidth: currentSize.progressBarWidth }}>
<LinearProgress
variant="determinate"
value={progress}
color="primary"
sx={{
height: 8,
borderRadius: 4,
backgroundColor: "rgba(0, 0, 0, 0.1)",
}}
/>
</Box>
{/* Основное сообщение */}
<Typography
variant="body2"
color="text.secondary"
sx={{
fontSize: currentSize.fontSize,
fontWeight: 500,
maxWidth: 280,
lineHeight: 1.4,
}}
>
{message}
</Typography>
{/* Детальная информация о прогрессе */}
{showDetails && progress > 0 && (
<Typography
variant="caption"
color="text.disabled"
sx={{
fontSize: "0.75rem",
opacity: 0.8,
fontWeight: 400,
}}
>
{getProgressStage(progress)}
</Typography>
)}
</Box>
);
if (variant === "overlay") {
return (
<Box
sx={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "rgba(255, 255, 255, 0.95)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 1000,
borderRadius: 1,
border: "1px solid rgba(0, 0, 0, 0.05)",
}}
>
{content}
</Box>
);
}
return (
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "center",
minHeight: 200,
backgroundColor: "rgba(0, 0, 0, 0.02)",
borderRadius: 2,
border: "1px dashed",
borderColor: "divider",
}}
>
{content}
</Box>
);
};

View File

@@ -0,0 +1,77 @@
import React, { useEffect } from "react";
import {
FormControl,
Select,
MenuItem,
SelectChangeEvent,
Typography,
Box,
} from "@mui/material";
import { observer } from "mobx-react-lite";
import { cityStore, selectedCityStore } from "@shared";
import { MapPin } from "lucide-react";
export const CitySelector: React.FC = observer(() => {
const { getCities, cities } = cityStore;
const { selectedCity, setSelectedCity } = selectedCityStore;
useEffect(() => {
getCities("ru");
}, []);
const handleCityChange = (event: SelectChangeEvent<string>) => {
const cityId = event.target.value;
if (cityId === "") {
setSelectedCity(null);
return;
}
const city = cities["ru"].data.find((c) => c.id === Number(cityId));
if (city) {
setSelectedCity(city);
}
};
const currentCities = cities["ru"].data;
return (
<Box className="flex items-center gap-2">
<MapPin size={16} className="text-white" />
<FormControl size="medium" sx={{ minWidth: 120 }}>
<Select
value={selectedCity?.id?.toString() || ""}
onChange={handleCityChange}
displayEmpty
sx={{
height: "40px",
color: "white",
"& .MuiOutlinedInput-notchedOutline": {
borderColor: "rgba(255, 255, 255, 0.3)",
},
"&:hover .MuiOutlinedInput-notchedOutline": {
borderColor: "rgba(255, 255, 255, 0.5)",
},
"&.Mui-focused .MuiOutlinedInput-notchedOutline": {
borderColor: "white",
},
"& .MuiSvgIcon-root": {
color: "white",
},
}}
MenuProps={{
PaperProps: {},
}}
>
<MenuItem value="">
<Typography variant="body2">Выберите город</Typography>
</MenuItem>
{currentCities.map((city) => (
<MenuItem key={city.id} value={city.id?.toString()}>
<Typography variant="body2">{city.name}</Typography>
</MenuItem>
))}
</Select>
</FormControl>
</Box>
);
});

View File

@@ -174,6 +174,7 @@ export const DevicesTable = observer(() => {
setSelectedDevice(uuid); // Sets the device in the store, useful for context elsewhere
try {
await authInstance.post(`/devices/${uuid}/request-status`);
await getVehicles();
await getDevices(); // Refresh devices to show updated status
} catch (error) {
console.error(`Error requesting status for device ${uuid}:`, error);
@@ -201,7 +202,6 @@ export const DevicesTable = observer(() => {
try {
// Create an array of promises for all snapshot requests
const snapshotPromises = selectedDeviceUuids.map((deviceUuid) => {
console.log(`Sending snapshot ${snapshotId} to device ${deviceUuid}`);
return send(deviceUuid);
});
@@ -398,7 +398,6 @@ export const DevicesTable = observer(() => {
devices.find((device) => device === row.device_uuid)
) {
await handleReloadStatus(row.device_uuid);
await getDevices();
toast.success("Статус устройства обновлен");
} else {
toast.error("Нет связи с устройством");

View File

@@ -35,6 +35,7 @@ export const ImageUploadCard: React.FC<ImageUploadCardProps> = ({
console.log("isDragOver");
}
}, [isDragOver]);
// --- Click to select file ---
const handleZoneClick = () => {
// Trigger the hidden file input click

View File

@@ -51,7 +51,7 @@ export const LanguageSwitcher = observer(() => {
key={lang}
onClick={() => handleLanguageChange(lang)}
variant={"contained"} // Highlight the active language
color={language === lang ? "primary" : "secondary"}
color={language === lang ? "primary" : "inherit"}
sx={{ minWidth: "60px" }} // Give buttons a consistent width
>
{getLanguageLabel(lang)}

View File

@@ -8,10 +8,11 @@ import { AppBar } from "./ui/AppBar";
import { Drawer } from "./ui/Drawer";
import { DrawerHeader } from "./ui/DrawerHeader";
import { NavigationList } from "@features";
import { authStore, userStore } from "@shared";
import { authStore, userStore, menuStore } from "@shared";
import { observer } from "mobx-react-lite";
import { useEffect } from "react";
import { Typography } from "@mui/material";
import { CitySelector } from "@widgets";
interface LayoutProps {
children: React.ReactNode;
@@ -20,6 +21,12 @@ interface LayoutProps {
export const Layout: React.FC<LayoutProps> = observer(({ children }) => {
const theme = useTheme();
const [open, setOpen] = React.useState(true);
const { setIsMenuOpen } = menuStore;
React.useEffect(() => {
setIsMenuOpen(open);
}, [open]);
const { getUsers, users } = userStore;
useEffect(() => {
@@ -55,11 +62,10 @@ export const Layout: React.FC<LayoutProps> = observer(({ children }) => {
>
<Menu />
</IconButton>
<div></div>
<CitySelector />
<div className="flex gap-2 items-center">
<div className="flex flex-col gap-1">
{(() => {
console.log(authStore.payload);
return (
<>
<p className=" text-white">

View File

@@ -1,9 +1,14 @@
import { Box, Button } from "@mui/material";
import { MediaViewer } from "@widgets";
import { PreviewMediaDialog } from "@shared";
import {
PreviewMediaDialog,
filterValidFiles,
getAllAcceptString,
} from "@shared";
import { X, Upload } from "lucide-react";
import { observer } from "mobx-react-lite";
import { useState, DragEvent, useRef } from "react";
import { toast } from "react-toastify";
export const MediaArea = observer(
({
@@ -36,7 +41,15 @@ export const MediaArea = observer(
const files = Array.from(e.dataTransfer.files);
if (files.length && onFilesDrop) {
onFilesDrop(files);
const { validFiles, errors } = filterValidFiles(files);
if (errors.length > 0) {
errors.forEach((error) => toast.error(error));
}
if (validFiles.length > 0) {
onFilesDrop(validFiles);
}
}
};
@@ -56,7 +69,15 @@ export const MediaArea = observer(
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files || []);
if (files.length && onFilesDrop) {
onFilesDrop(files);
const { validFiles, errors } = filterValidFiles(files);
if (errors.length > 0) {
errors.forEach((error) => toast.error(error));
}
if (validFiles.length > 0) {
onFilesDrop(validFiles);
}
}
// Сбрасываем значение input, чтобы можно было выбрать тот же файл снова
event.target.value = "";
@@ -68,7 +89,7 @@ export const MediaArea = observer(
type="file"
ref={fileInputRef}
onChange={handleFileSelect}
accept="image/*,video/*,.glb,.gltf"
accept={getAllAcceptString()}
multiple
style={{ display: "none" }}
/>
@@ -109,6 +130,7 @@ export const MediaArea = observer(
media_type: m.media_type,
filename: m.filename,
}}
height="40px"
/>
<button
className="absolute top-2 right-2"

View File

@@ -1,8 +1,15 @@
import { Box, Button } from "@mui/material";
import { editSightStore, SelectMediaDialog, UploadMediaDialog } from "@shared";
import {
editSightStore,
SelectMediaDialog,
UploadMediaDialog,
filterValidFiles,
getAllAcceptString,
} from "@shared";
import { Upload } from "lucide-react";
import { observer } from "mobx-react-lite";
import { useState, DragEvent, useRef } from "react";
import { toast } from "react-toastify";
export const MediaAreaForSight = observer(
({
@@ -38,11 +45,18 @@ export const MediaAreaForSight = observer(
setIsDragging(false);
const files = Array.from(e.dataTransfer.files);
if (files.length && onFilesDrop) {
setFileToUpload(files[0]);
if (files.length) {
const { validFiles, errors } = filterValidFiles(files);
if (errors.length > 0) {
errors.forEach((error: string) => toast.error(error));
}
if (validFiles.length > 0 && onFilesDrop) {
setFileToUpload(validFiles[0]);
setUploadMediaDialogOpen(true);
}
}
};
const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
@@ -60,11 +74,19 @@ export const MediaAreaForSight = observer(
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files || []);
if (files.length && onFilesDrop) {
setFileToUpload(files[0]);
onFilesDrop(files);
if (files.length) {
const { validFiles, errors } = filterValidFiles(files);
if (errors.length > 0) {
errors.forEach((error: string) => toast.error(error));
}
if (validFiles.length > 0 && onFilesDrop) {
setFileToUpload(validFiles[0]);
onFilesDrop(validFiles);
setUploadMediaDialogOpen(true);
}
}
// Сбрасываем значение input, чтобы можно было выбрать тот же файл снова
event.target.value = "";
@@ -76,7 +98,7 @@ export const MediaAreaForSight = observer(
type="file"
ref={fileInputRef}
onChange={handleFileSelect}
accept="image/*,video/*,.glb,.gltf"
accept={getAllAcceptString()}
multiple
style={{ display: "none" }}
/>

View File

@@ -1,5 +1,60 @@
import { Canvas } from "@react-three/fiber";
import { OrbitControls, Stage, useGLTF } from "@react-three/drei";
import { useEffect, Suspense } from "react";
import { Box, CircularProgress, Typography } from "@mui/material";
// Утилита для очистки кеша GLTF
const clearGLTFCache = (url?: string) => {
try {
if (url) {
// Если это blob URL, очищаем его из кеша
if (url.startsWith("blob:")) {
useGLTF.clear(url);
} else {
useGLTF.clear(url);
}
}
} catch (error) {
console.warn("⚠️ clearGLTFCache: Ошибка при очистке кеша", error);
}
};
// Утилита для проверки типа файла
const isValid3DFile = (url: string): boolean => {
try {
const urlObj = new URL(url);
const pathname = urlObj.pathname.toLowerCase();
const searchParams = urlObj.searchParams;
// Проверяем расширение файла в пути
const validExtensions = [".glb", ".gltf"];
const hasValidExtension = validExtensions.some((ext) =>
pathname.endsWith(ext)
);
// Проверяем параметры запроса на наличие типа файла
const fileType = searchParams.get("type") || searchParams.get("format");
const hasValidType =
fileType && ["glb", "gltf"].includes(fileType.toLowerCase());
// Если это blob URL, считаем его валидным (пользователь выбрал файл)
const isBlobUrl = url.startsWith("blob:");
// Если это URL с токеном и нет явного расширения, считаем валидным
// (предполагаем что сервер вернет правильный файл)
const hasToken = searchParams.has("token");
const isServerUrl = hasToken && !hasValidExtension;
const isValid =
hasValidExtension || hasValidType || isBlobUrl || isServerUrl;
return isValid;
} catch (error) {
console.warn("⚠️ isValid3DFile: Ошибка при проверке URL", error);
// В случае ошибки парсинга URL, считаем валидным (пусть useGLTF сам разберется)
return true;
}
};
type ModelViewerProps = {
width?: string;
@@ -7,21 +62,93 @@ type ModelViewerProps = {
height?: string;
};
const Model = ({ fileUrl }: { fileUrl: string }) => {
// Очищаем кеш перед загрузкой новой модели
useEffect(() => {
// Очищаем кеш для текущего URL
clearGLTFCache(fileUrl);
}, [fileUrl]);
// Проверяем валидность файла перед загрузкой (только для blob URL)
if (fileUrl.startsWith("blob:") && !isValid3DFile(fileUrl)) {
console.warn("⚠️ Model: Попытка загрузить невалидный 3D файл", { fileUrl });
throw new Error(`Файл не является корректной 3D моделью: ${fileUrl}`);
}
const { scene } = useGLTF(fileUrl);
return <primitive object={scene} />;
};
const LoadingFallback = () => {
return (
<Box
sx={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 2,
zIndex: 1000,
backgroundColor: "background.paper",
p: 3,
borderRadius: 2,
}}
>
<CircularProgress size={48} />
<Typography
variant="body2"
color="text.secondary"
style={{ whiteSpace: "nowrap" }}
>
Загрузка 3D модели...
</Typography>
</Box>
);
};
export const ThreeView = ({
fileUrl,
height = "100%",
width = "100%",
}: ModelViewerProps) => {
const { scene } = useGLTF(fileUrl);
// Проверяем валидность файла (только для blob URL)
useEffect(() => {
if (fileUrl.startsWith("blob:") && !isValid3DFile(fileUrl)) {
console.warn("⚠️ ThreeView: Невалидный 3D файл", { fileUrl });
}
}, [fileUrl]);
// Очищаем кеш при размонтировании и при смене URL
useEffect(() => {
// Очищаем кеш сразу при монтировании компонента
clearGLTFCache(fileUrl);
return () => {
clearGLTFCache(fileUrl);
};
}, [fileUrl]);
return (
<Canvas style={{ height: height, width: width }}>
<Box sx={{ position: "relative", width, height }}>
<Suspense fallback={<LoadingFallback />}>
<Canvas
style={{ height: height, width: width }}
camera={{
position: [1, 1, 1],
fov: 30,
}}
>
<ambientLight />
<directionalLight />
<Stage environment="city" intensity={0.6}>
<primitive object={scene} />
<Stage environment="city" intensity={0.6} adjustCamera={false}>
<Model fileUrl={fileUrl} />
</Stage>
<OrbitControls />
</Canvas>
</Suspense>
</Box>
);
};

View File

@@ -0,0 +1,240 @@
import React, { Component, ReactNode } from "react";
import { Box, Button, Typography, Paper } from "@mui/material";
import { RefreshCw, AlertTriangle } from "lucide-react";
interface Props {
children: ReactNode;
onReset?: () => void;
resetKey?: number | string;
}
interface State {
hasError: boolean;
error: Error | null;
lastResetKey?: number | string;
}
export class ThreeViewErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
hasError: false,
error: null,
lastResetKey: props.resetKey,
};
}
static getDerivedStateFromError(error: Error): Partial<State> {
return {
hasError: true,
error,
};
}
static getDerivedStateFromProps(
props: Props,
state: State
): Partial<State> | null {
// Сбрасываем ошибку ТОЛЬКО при смене медиа (когда меняется ID в resetKey)
if (
props.resetKey !== state.lastResetKey &&
state.lastResetKey !== undefined
) {
const oldMediaId = String(state.lastResetKey).split("-")[0];
const newMediaId = String(props.resetKey).split("-")[0];
// Сбрасываем ошибку только если изменился ID медиа (пользователь выбрал другую модель)
if (oldMediaId !== newMediaId) {
return {
hasError: false,
error: null,
lastResetKey: props.resetKey,
};
}
// Если изменился только счетчик (нажата кнопка "Попробовать снова"), обновляем lastResetKey
// но не сбрасываем ошибку автоматически - ждем результата загрузки
return {
lastResetKey: props.resetKey,
};
}
if (state.lastResetKey === undefined && props.resetKey !== undefined) {
return {
lastResetKey: props.resetKey,
};
}
return null;
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error("❌ ThreeViewErrorBoundary: Ошибка загрузки 3D модели", {
error: error.message,
stack: error.stack,
componentStack: errorInfo.componentStack,
});
}
getErrorMessage = () => {
const errorMessage = this.state.error?.message || "";
if (
errorMessage.includes("not valid JSON") ||
errorMessage.includes("Unexpected token")
) {
return "Неверный формат файла. Убедитесь, что файл является корректной 3D моделью в формате GLB/GLTF.";
}
if (errorMessage.includes("Could not load")) {
return "Не удалось загрузить файл 3D модели. Проверьте, что файл существует и доступен.";
}
if (errorMessage.includes("404") || errorMessage.includes("Not Found")) {
return "Файл 3D модели не найден на сервере.";
}
if (errorMessage.includes("Network") || errorMessage.includes("fetch")) {
return "Ошибка сети при загрузке 3D модели. Проверьте интернет-соединение.";
}
return (
errorMessage || "Произошла неизвестная ошибка при загрузке 3D модели"
);
};
getErrorReasons = () => {
const errorMessage = this.state.error?.message || "";
if (
errorMessage.includes("not valid JSON") ||
errorMessage.includes("Unexpected token")
) {
return [
"Файл не является 3D моделью",
"Загружен файл неподдерживаемого формата",
"Файл поврежден или не полностью загружен",
"Используйте только GLB или GLTF форматы",
];
}
return [
"Поврежденный файл модели",
"Неподдерживаемый формат",
"Проблемы с загрузкой файла",
];
};
handleReset = () => {
// Сначала сбрасываем состояние ошибки
this.setState(
{
hasError: false,
error: null,
},
() => {
// После того как состояние обновилось, вызываем callback для изменения resetKey
// Это приведет к пересозданию компонента и новой попытке загрузки
this.props.onReset?.();
}
);
};
handleClose = () => {
this.setState({
hasError: false,
error: null,
});
};
render() {
if (this.state.hasError) {
return (
<Box
sx={{
width: "100%",
height: "100%",
display: "flex",
justifyContent: "center",
alignItems: "center",
p: 2,
}}
>
<Paper
elevation={3}
sx={{
p: 3,
maxWidth: 500,
width: "100%",
position: "relative",
backgroundColor: "error.light",
color: "error.contrastText",
}}
>
<Box sx={{ display: "flex", alignItems: "center", mb: 2 }}>
<AlertTriangle size={32} style={{ marginRight: 12 }} />
<Typography variant="h6" component="h2">
Ошибка загрузки 3D модели
</Typography>
</Box>
<Typography variant="body2" sx={{ mb: 2 }}>
{this.getErrorMessage()}
</Typography>
<Typography variant="caption" sx={{ mb: 2, display: "block" }}>
Возможные причины:
<ul style={{ margin: "8px 0", paddingLeft: "20px" }}>
{this.getErrorReasons().map((reason, index) => (
<li key={index}>{reason}</li>
))}
</ul>
</Typography>
{this.state.error?.message && (
<Typography
variant="caption"
sx={{
mb: 2,
display: "block",
fontFamily: "monospace",
backgroundColor: "rgba(0, 0, 0, 0.1)",
p: 1,
borderRadius: 1,
fontSize: "0.7rem",
wordBreak: "break-word",
maxHeight: "100px",
overflow: "auto",
}}
>
{this.state.error.message}
</Typography>
)}
<Box sx={{ display: "flex", gap: 1 }}>
<Button
variant="contained"
startIcon={<RefreshCw size={16} />}
onClick={() => {
this.handleReset();
}}
sx={{
backgroundColor: "error.contrastText",
color: "error.main",
"&:hover": {
backgroundColor: "rgba(255, 255, 255, 0.9)",
},
}}
>
Попробовать снова
</Button>
</Box>
</Paper>
</Box>
);
}
return this.props.children;
}
}

View File

@@ -1,7 +1,10 @@
import { Box } from "@mui/material";
import { useState, useEffect } from "react";
import { ReactPhotoSphereViewer } from "react-photo-sphere-viewer";
import { ThreeView } from "./ThreeView";
import { ThreeViewErrorBoundary } from "./ThreeViewErrorBoundary";
import { clearMediaTransitionCache } from "../../shared/lib/gltfCacheManager";
export interface MediaData {
id: string | number;
@@ -12,15 +15,46 @@ export interface MediaData {
export function MediaViewer({
media,
className,
height,
width,
fullWidth,
fullHeight,
}: Readonly<{
media?: MediaData;
className?: string;
height?: string;
width?: string;
fullWidth?: boolean;
fullHeight?: boolean;
}>) {
const token = localStorage.getItem("token");
const [resetKey, setResetKey] = useState(0);
const [previousMediaId, setPreviousMediaId] = useState<
string | number | null
>(null);
useEffect(() => {
if (media?.id !== previousMediaId) {
// Используем новый cache manager для очистки кеша
clearMediaTransitionCache(
previousMediaId,
media?.id || null,
media?.media_type
);
setResetKey(0);
setPreviousMediaId(media?.id || null);
}
}, [media?.id, media?.media_type, previousMediaId]);
const handleReset = () => {
setResetKey((prev) => {
const newKey = prev + 1;
return newKey;
});
};
return (
<Box
className={className}
@@ -42,13 +76,8 @@ export function MediaViewer({
}/download?token=${token}`}
alt={media?.filename}
style={{
height: fullHeight ? "100%" : "auto",
width: fullWidth ? "100%" : "auto",
...(media?.filename?.toLowerCase().endsWith(".webp") && {
maxWidth: "300px",
maxHeight: "300px",
objectFit: "contain",
}),
height: fullHeight ? "100%" : height ? height : "auto",
width: fullWidth ? "100%" : width ? width : "auto",
}}
/>
)}
@@ -59,8 +88,8 @@ export function MediaViewer({
media?.id
}/download?token=${token}`}
style={{
width: "100%",
height: "100%",
width: width ? width : "100%",
height: height ? height : "100%",
objectFit: "cover",
borderRadius: 8,
}}
@@ -76,8 +105,8 @@ export function MediaViewer({
}/download?token=${token}`}
alt={media?.filename}
style={{
height: fullHeight ? "100%" : "auto",
width: fullWidth ? "100%" : "auto",
height: fullHeight ? "100%" : height ? height : "auto",
width: fullWidth ? "100%" : width ? width : "auto",
}}
/>
)}
@@ -98,19 +127,25 @@ export function MediaViewer({
src={`${import.meta.env.VITE_KRBL_MEDIA}${
media?.id
}/download?token=${token}`}
width={"100%"}
height={"100%"}
width={width ? width : "500px"}
height={height ? height : "300px"}
/>
)}
{media?.media_type === 6 && (
<ThreeViewErrorBoundary
onReset={handleReset}
resetKey={`${media?.id}-${resetKey}`}
>
<ThreeView
key={`3d-model-${media?.id}-${resetKey}`}
fileUrl={`${import.meta.env.VITE_KRBL_MEDIA}${
media?.id
}/download?token=${token}`}
height="500px"
width="500px"
height={height ? height : "500px"}
width={width ? width : "500px"}
/>
</ThreeViewErrorBoundary>
)}
</Box>
);

View File

@@ -1,3 +1,4 @@
import React from "react";
import { Stage, useGLTF } from "@react-three/drei";
import { Canvas } from "@react-three/fiber";
import { OrbitControls } from "@react-three/drei";
@@ -5,12 +6,21 @@ import { OrbitControls } from "@react-three/drei";
export const ModelViewer3D = ({
fileUrl,
height = "100%",
onLoad,
}: {
fileUrl: string;
height: string;
onLoad?: () => void;
}) => {
const { scene } = useGLTF(fileUrl);
// Вызываем onLoad когда модель загружена
React.useEffect(() => {
if (onLoad) {
onLoad();
}
}, [scene, onLoad]);
return (
<Canvas style={{ width: "100%", height: height }}>
<ambientLight />

View File

@@ -0,0 +1,24 @@
import { Button } from "@mui/material";
export const SaveWithoutCityAgree = ({ blocker }: { blocker: any }) => {
return (
<div className="fixed top-0 left-0 w-screen h-screen flex justify-center items-center z-10000 bg-black/30">
<div className="bg-white p-4 w-140 rounded-lg flex flex-col gap-4 items-center">
<p className="text-black w-140 text-center">
Вы не указали город и/или не заполнили названия на всех языках
(русский, английский, китайский).
<br />
Сохранить без этой информации?
</p>
<div className="flex gap-4 justify-center">
<Button variant="contained" onClick={() => blocker.proceed()}>
Да
</Button>
<Button variant="outlined" onClick={() => blocker.reset()}>
Нет
</Button>
</div>
</div>
</div>
);
};

View File

@@ -5,6 +5,10 @@ import {
Autocomplete,
MenuItem,
Menu as MuiMenu,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
} from "@mui/material";
import {
BackButton,
@@ -20,14 +24,18 @@ import {
UploadMediaDialog,
MEDIA_TYPE_VALUES,
} from "@shared";
import { ImageUploadCard, LanguageSwitcher } from "@widgets";
import {
ImageUploadCard,
LanguageSwitcher,
VideoPreviewCard,
MediaViewer,
} from "@widgets";
import { Save } from "lucide-react";
import { observer } from "mobx-react-lite";
import { useEffect, useState } from "react";
import { toast } from "react-toastify";
// Мокап для всплывающей подсказки
import { SaveWithoutCityAgree } from "@widgets";
export const CreateInformationTab = observer(
({ value, index }: { value: number; index: number }) => {
@@ -42,30 +50,25 @@ export const CreateInformationTab = observer(
const [, setCity] = useState<number>(sight.city_id ?? 0);
const [coordinates, setCoordinates] = useState<string>(`0, 0`);
// Menu state for each media button
const [menuAnchorEl, setMenuAnchorEl] = useState<null | HTMLElement>(null);
const [activeMenuType, setActiveMenuType] = useState<
"thumbnail" | "watermark_lu" | "watermark_rd" | null
"thumbnail" | "watermark_lu" | "watermark_rd" | "video_preview" | null
>(null);
const [isAddMediaOpen, setIsAddMediaOpen] = useState(false);
const [isVideoPreviewOpen, setIsVideoPreviewOpen] = useState(false);
const [hardcodeType, setHardcodeType] = useState<
"thumbnail" | "watermark_lu" | "watermark_rd" | null
"thumbnail" | "watermark_lu" | "watermark_rd" | "video_preview" | null
>(null);
// const handleMenuOpen = (
// event: React.MouseEvent<HTMLElement>,
// type: "thumbnail" | "watermark_lu" | "watermark_rd"
// ) => {
// setMenuAnchorEl(event.currentTarget);
// setActiveMenuType(type);
// };
// НОВОЕ СОСТОЯНИЕ ДЛЯ ПРЕДУПРЕЖДАЮЩЕГО ОКНА
const [isSaveWarningOpen, setIsSaveWarningOpen] = useState(false);
useEffect(() => {}, [hardcodeType]);
useEffect(() => {
// Показывать только при инициализации (не менять при ошибках пользователя)
if (sight.latitude !== 0 || sight.longitude !== 0) {
setCoordinates(`${sight.latitude}, ${sight.longitude}`);
}
// если координаты обнулились — оставить поле как есть
}, [sight.latitude, sight.longitude]);
const handleMenuClose = () => {
@@ -100,7 +103,7 @@ export const CreateInformationTab = observer(
media_name?: string;
media_type: number;
},
type: "thumbnail" | "watermark_lu" | "watermark_rd"
type: "thumbnail" | "watermark_lu" | "watermark_rd" | "video_preview"
) => {
handleChange({
[type]: media.id,
@@ -108,6 +111,36 @@ export const CreateInformationTab = observer(
setActiveMenuType(null);
};
const handleVideoPreviewClick = () => {
if (sight.video_preview && sight.video_preview !== "") {
setIsVideoPreviewOpen(true);
}
};
const handleSave = async () => {
const isCityMissing = !sight.city_id;
// Проверяем названия на всех языках
const isNameMissing = !sight.ru.name || !sight.en.name || !sight.zh.name;
if (isCityMissing || isNameMissing) {
setIsSaveWarningOpen(true);
return;
}
await createSight(language);
toast.success("Достопримечательность создана");
};
const handleConfirmSave = async () => {
setIsSaveWarningOpen(false);
await createSight(language);
toast.success("Достопримечательность создана");
};
const handleCancelSave = () => {
setIsSaveWarningOpen(false);
};
return (
<>
<TabPanel value={value} index={index}>
@@ -117,7 +150,7 @@ export const CreateInformationTab = observer(
flexDirection: "column",
gap: 3,
position: "relative",
paddingBottom: "70px" /* Space for save button */,
paddingBottom: "70px",
}}
>
<div className="flex gap-10 items-center mb-5 max-w-[80%]">
@@ -129,12 +162,11 @@ export const CreateInformationTab = observer(
sx={{
display: "flex",
gap: 4, // Added gap between the two main columns
gap: 4,
width: "100%",
flexDirection: "column",
}}
>
{/* Left column with main fields */}
<Box
sx={{
flexGrow: 1,
@@ -198,14 +230,13 @@ export const CreateInformationTab = observer(
value={coordinates}
onChange={(e) => {
const input = e.target.value;
setCoordinates(input); // показываем как есть
setCoordinates(input);
const [latStr, lonStr] = input.split(/\s+/); // учитываем любые пробелы
const [latStr, lonStr] = input.split(/\s+/);
const lat = parseFloat(latStr);
const lon = parseFloat(lonStr);
// Проверка, что обе координаты валидные числа
const isValidLat = !isNaN(lat);
const isValidLon = !isNaN(lon);
@@ -243,7 +274,7 @@ export const CreateInformationTab = observer(
justifyContent: "space-around",
width: "80%",
gap: 2,
flexDirection: { xs: "column", sm: "row" }, // Stack on extra small, side-by-side on small and up
flexDirection: { xs: "column", sm: "row" },
}}
>
<ImageUploadCard
@@ -329,35 +360,50 @@ export const CreateInformationTab = observer(
setHardcodeType("watermark_rd");
}}
/>
</Box>
</Box>
</Box>
{/* LanguageSwitcher positioned at the top right */}
<VideoPreviewCard
title="Видеозаставка"
videoId={sight.video_preview}
onVideoClick={handleVideoPreviewClick}
onDeleteVideoClick={() => {
handleChange({
video_preview: null,
});
}}
onSelectVideoClick={(file) => {
if (file) {
createSightStore.setFileToUpload(file);
setActiveMenuType("video_preview");
setIsUploadMediaOpen(true);
} else {
setActiveMenuType("video_preview");
setIsAddMediaOpen(true);
}
}}
/>
</Box>
</Box>
</Box>
<LanguageSwitcher />
{/* Save Button fixed at the bottom right */}
<Box
sx={{
position: "absolute",
bottom: 0,
right: 0,
padding: 2,
backgroundColor: "background.paper", // To ensure it stands out over content
width: "100%", // Take full width to cover content below it
backgroundColor: "background.paper",
width: "100%",
display: "flex",
justifyContent: "flex-end", // Align to the right
justifyContent: "flex-end",
}}
>
<Button
variant="contained"
color="success"
startIcon={<Save color="white" size={18} />}
onClick={async () => {
await createSight(language);
toast.success("Достопримечательность создана");
}}
onClick={handleSave}
>
Сохранить
</Button>
@@ -365,7 +411,6 @@ export const CreateInformationTab = observer(
</Box>
</TabPanel>
{/* Media Menu */}
<MuiMenu
anchorEl={menuAnchorEl}
open={Boolean(menuAnchorEl)}
@@ -390,7 +435,9 @@ export const CreateInformationTab = observer(
setActiveMenuType(null);
}}
onSelectMedia={(media) => {
handleMediaSelect(media, activeMenuType ?? "thumbnail");
if (activeMenuType) {
handleMediaSelect(media, activeMenuType);
}
}}
mediaType={
activeMenuType
@@ -413,14 +460,58 @@ export const CreateInformationTab = observer(
contextObjectName={sight[language].name}
contextType="sight"
afterUpload={(media) => {
if (activeMenuType === "video_preview") {
handleChange({
video_preview: media.id,
});
} else {
handleChange({
[activeMenuType ?? "thumbnail"]: media.id,
});
}
setActiveMenuType(null);
setIsUploadMediaOpen(false);
}}
hardcodeType={hardcodeType}
hardcodeType={activeMenuType}
initialFile={createSightStore.fileToUpload || undefined}
/>
{sight.video_preview && sight.video_preview !== "" && (
<Dialog
open={isVideoPreviewOpen}
onClose={() => setIsVideoPreviewOpen(false)}
maxWidth="md"
fullWidth
>
<DialogTitle>Предпросмотр видео</DialogTitle>
<DialogContent>
<Box className="flex justify-center items-center p-4">
<MediaViewer
media={{
id: sight.video_preview,
media_type: 2,
filename: "video_preview",
}}
/>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => setIsVideoPreviewOpen(false)}>
Закрыть
</Button>
</DialogActions>
</Dialog>
)}
{/* ИНТЕГРИРОВАННОЕ ПРЕДУПРЕЖДАЮЩЕЕ ОКНО */}
{isSaveWarningOpen && (
<SaveWithoutCityAgree
blocker={{
proceed: handleConfirmSave,
reset: handleCancelSave,
}}
/>
)}
</>
);
}

View File

@@ -5,6 +5,10 @@ import {
Autocomplete,
MenuItem,
Menu as MuiMenu,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
} from "@mui/material";
import {
BackButton,
@@ -20,7 +24,12 @@ import {
UploadMediaDialog,
MEDIA_TYPE_VALUES,
} from "@shared";
import { ImageUploadCard, LanguageSwitcher } from "@widgets";
import {
ImageUploadCard,
LanguageSwitcher,
VideoPreviewCard,
MediaViewer,
} from "@widgets";
import { Save } from "lucide-react";
import { observer } from "mobx-react-lite";
@@ -28,7 +37,8 @@ import { useEffect, useState } from "react";
import { toast } from "react-toastify";
// Мокап для всплывающей подсказки
// Компонент предупреждающего окна (перенесен сюда)
import { SaveWithoutCityAgree } from "@widgets";
export const InformationTab = observer(
({ value, index }: { value: number; index: number }) => {
@@ -42,22 +52,25 @@ export const InformationTab = observer(
const [, setCity] = useState<number>(sight.common.city_id ?? 0);
const [coordinates, setCoordinates] = useState<string>(`0, 0`);
// Menu state for each media button
const [menuAnchorEl, setMenuAnchorEl] = useState<null | HTMLElement>(null);
const [activeMenuType, setActiveMenuType] = useState<
"thumbnail" | "watermark_lu" | "watermark_rd" | null
"thumbnail" | "watermark_lu" | "watermark_rd" | "video_preview" | null
>(null);
const [isAddMediaOpen, setIsAddMediaOpen] = useState(false);
const [isVideoPreviewOpen, setIsVideoPreviewOpen] = useState(false);
const [hardcodeType, setHardcodeType] = useState<
"thumbnail" | "watermark_lu" | "watermark_rd" | null
"thumbnail" | "watermark_lu" | "watermark_rd" | "video_preview" | null
>(null);
const { cities } = cityStore;
// НОВОЕ СОСТОЯНИЕ ДЛЯ ПРЕДУПРЕЖДАЮЩЕГО ОКНА
const [isSaveWarningOpen, setIsSaveWarningOpen] = useState(false);
useEffect(() => {}, [hardcodeType]);
useEffect(() => {
// Показывать только при инициализации (не менять при ошибках пользователя)
if (sight.common.latitude !== 0 || sight.common.longitude !== 0) {
setCoordinates(`${sight.common.latitude}, ${sight.common.longitude}`);
}
// если координаты обнулились — оставить поле как есть
}, [sight.common.latitude, sight.common.longitude]);
const handleMenuClose = () => {
@@ -74,22 +87,28 @@ export const InformationTab = observer(
handleMenuClose();
};
const handleMediaSelect = (media: {
const handleMediaSelect = (
media: {
id: string;
filename: string;
media_name?: string;
media_type: number;
}) => {
if (!activeMenuType) return;
},
type: "thumbnail" | "watermark_lu" | "watermark_rd" | "video_preview"
) => {
handleChange(
language as Language,
{
[activeMenuType ?? "thumbnail"]: media.id,
[type]: media.id,
},
true
);
};
setIsUploadMediaOpen(false);
const handleVideoPreviewClick = () => {
if (sight.common.video_preview && sight.common.video_preview !== "") {
setIsVideoPreviewOpen(true);
}
};
const handleChange = (
@@ -100,6 +119,37 @@ export const InformationTab = observer(
updateSightInfo(language, content, common);
};
// НОВАЯ ФУНКЦИЯ: Фактическое сохранение (вызывается после подтверждения)
const executeSave = async () => {
await updateSight();
toast.success("Достопримечательность сохранена");
};
// ОБНОВЛЕННАЯ ФУНКЦИЯ: Проверка и вызов окна или сохранения
const handleSave = async () => {
const isCityMissing = !sight.common.city_id;
// Проверяем названия на всех языках
const isNameMissing = !sight.ru.name || !sight.en.name || !sight.zh.name;
if (isCityMissing || isNameMissing) {
setIsSaveWarningOpen(true);
return;
}
await executeSave();
};
// Обработчик "Да" в предупреждающем окне
const handleConfirmSave = async () => {
setIsSaveWarningOpen(false);
await executeSave();
};
// Обработчик "Нет" в предупреждающем окне
const handleCancelSave = () => {
setIsSaveWarningOpen(false);
};
return (
<>
<TabPanel value={value} index={index}>
@@ -109,7 +159,7 @@ export const InformationTab = observer(
flexDirection: "column",
gap: 3,
position: "relative",
paddingBottom: "70px" /* Space for save button */,
paddingBottom: "70px",
}}
>
<div className="flex gap-10 items-center mb-5 max-w-[80%]">
@@ -122,12 +172,11 @@ export const InformationTab = observer(
sx={{
display: "flex",
gap: 4, // Added gap between the two main columns
gap: 4,
width: "100%",
flexDirection: "column",
}}
>
{/* Left column with main fields */}
<Box
sx={{
flexGrow: 1,
@@ -189,16 +238,14 @@ export const InformationTab = observer(
value={coordinates}
onChange={(e) => {
const newValue = e.target.value;
setCoordinates(newValue); // сохраняем ввод пользователя как есть
setCoordinates(newValue);
// Обрабатываем значение для сохранения
const input = newValue.replace(/,/g, " ").trim();
const [latStr, lonStr] = input.split(/\s+/);
const lat = parseFloat(latStr);
const lon = parseFloat(lonStr);
// Проверка, что обе координаты валидные числа
const isValidLat = !isNaN(lat);
const isValidLon = !isNaN(lon);
@@ -241,7 +288,7 @@ export const InformationTab = observer(
justifyContent: "space-around",
width: "80%",
gap: 2,
flexDirection: { xs: "column", sm: "row" }, // Stack on extra small, side-by-side on small and up
flexDirection: { xs: "column", sm: "row" },
}}
>
<ImageUploadCard
@@ -337,35 +384,54 @@ export const InformationTab = observer(
setHardcodeType("watermark_rd");
}}
/>
</Box>
</Box>
</Box>
{/* LanguageSwitcher positioned at the top right */}
<VideoPreviewCard
title="Видеозаставка"
videoId={sight.common.video_preview}
onVideoClick={handleVideoPreviewClick}
onDeleteVideoClick={() => {
handleChange(
language as Language,
{
video_preview: null,
},
true
);
}}
onSelectVideoClick={(file) => {
if (file) {
editSightStore.setFileToUpload(file);
setActiveMenuType("video_preview");
setIsUploadMediaOpen(true);
} else {
setActiveMenuType("video_preview");
setIsAddMediaOpen(true);
}
}}
/>
</Box>
</Box>
</Box>
<LanguageSwitcher />
{/* Save Button fixed at the bottom right */}
<Box
sx={{
position: "absolute",
bottom: 0,
right: 0,
padding: 2,
backgroundColor: "background.paper", // To ensure it stands out over content
width: "100%", // Take full width to cover content below it
backgroundColor: "background.paper",
width: "100%",
display: "flex",
justifyContent: "flex-end", // Align to the right
justifyContent: "flex-end",
}}
>
<Button
variant="contained"
color="success"
startIcon={<Save color="white" size={18} />}
onClick={async () => {
await updateSight();
toast.success("Достопримечательность сохранена");
}}
onClick={handleSave} // Используем новую функцию-обработчик
>
Сохранить
</Button>
@@ -373,7 +439,6 @@ export const InformationTab = observer(
</Box>
</TabPanel>
{/* Media Menu */}
<MuiMenu
anchorEl={menuAnchorEl}
open={Boolean(menuAnchorEl)}
@@ -395,8 +460,13 @@ export const InformationTab = observer(
open={isAddMediaOpen}
onClose={() => {
setIsAddMediaOpen(false);
setActiveMenuType(null);
}}
onSelectMedia={(media) => {
if (activeMenuType) {
handleMediaSelect(media, activeMenuType);
}
}}
onSelectMedia={handleMediaSelect}
mediaType={
activeMenuType
? MEDIA_TYPE_VALUES[
@@ -412,6 +482,15 @@ export const InformationTab = observer(
contextObjectName={sight[language].name}
contextType="sight"
afterUpload={(media) => {
if (activeMenuType === "video_preview") {
handleChange(
language as Language,
{
video_preview: media.id,
},
true
);
} else {
handleChange(
language as Language,
{
@@ -419,16 +498,55 @@ export const InformationTab = observer(
},
true
);
}
setActiveMenuType(null);
setIsUploadMediaOpen(false);
}}
hardcodeType={hardcodeType}
hardcodeType={activeMenuType}
initialFile={editSightStore.fileToUpload || undefined}
/>
<PreviewMediaDialog
open={isPreviewMediaOpen}
onClose={() => setIsPreviewMediaOpen(false)}
mediaId={mediaId}
/>
{sight.common.video_preview && sight.common.video_preview !== "" && (
<Dialog
open={isVideoPreviewOpen}
onClose={() => setIsVideoPreviewOpen(false)}
maxWidth="md"
fullWidth
>
<DialogTitle>Предпросмотр видео</DialogTitle>
<DialogContent>
<Box className="flex justify-center items-center p-4">
<MediaViewer
media={{
id: sight.common.video_preview,
media_type: 2,
filename: "video_preview",
}}
/>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => setIsVideoPreviewOpen(false)}>
Закрыть
</Button>
</DialogActions>
</Dialog>
)}
{/* ИНТЕГРИРОВАННОЕ ПРЕДУПРЕЖДАЮЩЕЕ ОКНО */}
{isSaveWarningOpen && (
<SaveWithoutCityAgree
blocker={{
proceed: handleConfirmSave,
reset: handleCancelSave,
}}
/>
)}
</>
);
}

View File

@@ -316,6 +316,7 @@ export const LeftWidgetTab = observer(
}}
fullWidth
/>
{sight.common.watermark_lu && (
<img
src={`${import.meta.env.VITE_KRBL_MEDIA}${
sight.common.watermark_lu
@@ -328,7 +329,9 @@ export const LeftWidgetTab = observer(
objectFit: "contain",
}}
/>
)}
{sight.common.watermark_rd && (
<img
src={`${import.meta.env.VITE_KRBL_MEDIA}${
sight.common.watermark_rd
@@ -341,6 +344,7 @@ export const LeftWidgetTab = observer(
objectFit: "contain",
}}
/>
)}
</>
) : (
<ImagePlus size={48} color="white" />

View File

@@ -87,7 +87,6 @@ export const RightWidgetTab = observer(
}
};
fetchData();
console.log(sight[language].right);
}, [sight.common.id]);
const [activeArticleIndex, setActiveArticleIndex] = useState<number | null>(
@@ -175,10 +174,6 @@ export const RightWidgetTab = observer(
toast.success("Достопримечательность сохранена");
};
useEffect(() => {
console.log(sight[language].right);
}, [sight[language].right]);
const handleDragEnd = (result: DropResult) => {
const { source, destination } = result;

View File

@@ -1,13 +1,15 @@
import { Button } from "@mui/material";
import { Button, CircularProgress } from "@mui/material";
export const SnapshotRestore = ({
onDelete,
onCancel,
open,
loading = false,
}: {
onDelete: () => void;
onCancel: () => void;
open: boolean;
loading?: boolean;
}) => {
return (
<div
@@ -23,10 +25,22 @@ export const SnapshotRestore = ({
Это действие нельзя будет отменить.
</p>
<div className="flex gap-4 justify-center">
<Button variant="contained" color="primary" onClick={onDelete}>
Да
<Button
variant="contained"
color="primary"
onClick={onDelete}
disabled={loading}
startIcon={
loading ? (
<CircularProgress size={16} color="inherit" />
) : undefined
}
>
{loading ? "Восстановление..." : "Да"}
</Button>
<Button onClick={onCancel} disabled={loading}>
Нет
</Button>
<Button onClick={onCancel}>Нет</Button>
</div>
</div>
</div>

View File

@@ -0,0 +1,203 @@
import React, { useRef, useState, DragEvent, useEffect } from "react";
import { Paper, Box, Typography, Button, Tooltip } from "@mui/material";
import { X, Info, Plus, Play } from "lucide-react";
import { toast } from "react-toastify";
interface VideoPreviewCardProps {
title: string;
videoId: string | null | undefined;
onVideoClick: () => void;
onDeleteVideoClick: () => void;
onSelectVideoClick: (file?: File) => void;
tooltipText?: string;
}
export const VideoPreviewCard: React.FC<VideoPreviewCardProps> = ({
title,
videoId,
onVideoClick,
onDeleteVideoClick,
onSelectVideoClick,
tooltipText,
}) => {
const fileInputRef = useRef<HTMLInputElement>(null);
const [isDragOver, setIsDragOver] = useState(false);
const token = localStorage.getItem("token");
useEffect(() => {}, [isDragOver]);
// --- Click to select file ---
const handleZoneClick = () => {
// Trigger the hidden file input click
fileInputRef.current?.click();
};
const handleFileInputChange = async (
event: React.ChangeEvent<HTMLInputElement>
) => {
const file = event.target.files?.[0];
if (file) {
if (file.type.startsWith("video/")) {
// Открываем диалог загрузки медиа с файлом видео
onSelectVideoClick(file);
} else {
toast.error("Пожалуйста, выберите видео файл");
}
}
// Reset the input value so selecting the same file again triggers change
event.target.value = "";
};
// --- Drag and Drop Handlers ---
const handleDragOver = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault(); // Crucial to allow a drop
event.stopPropagation();
setIsDragOver(true);
};
const handleDragLeave = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
setIsDragOver(false);
};
const handleDrop = async (event: DragEvent<HTMLDivElement>) => {
event.preventDefault(); // Crucial to allow a drop
event.stopPropagation();
setIsDragOver(false);
const files = event.dataTransfer.files;
if (files && files.length > 0) {
const file = files[0];
if (file.type.startsWith("video/")) {
// Открываем диалог загрузки медиа с файлом видео
onSelectVideoClick(file);
} else {
toast.error("Пожалуйста, выберите видео файл");
}
}
};
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>
{tooltipText && (
<Tooltip title={tooltipText}>
<Info size={16} color="gray" style={{ cursor: "pointer" }} />
</Tooltip>
)}
</Box>
<Box
sx={{
position: "relative",
width: "200px",
height: "200px",
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 1,
mb: 1,
cursor: videoId ? "pointer" : "default",
}}
onClick={onVideoClick}
>
{videoId && (
<button
className="absolute top-2 right-2 z-10"
onClick={(e) => {
e.stopPropagation();
onDeleteVideoClick();
}}
>
<X color="red" />
</button>
)}
{videoId ? (
<Box sx={{ position: "relative", width: "100%", height: "100%" }}>
<video
src={`${
import.meta.env.VITE_KRBL_MEDIA
}${videoId}/download?token=${token}`}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
borderRadius: 4,
}}
muted
/>
<Box
sx={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
backgroundColor: "rgba(0, 0, 0, 0.5)",
borderRadius: "50%",
width: 40,
height: 40,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Play size={20} color="white" />
</Box>
</Box>
) : (
<div className="w-full flex flex-col items-center justify-center gap-3">
<div
className="flex flex-col p-5 items-center justify-center gap-3"
style={{
border: "2px dashed #ccc",
borderRadius: 1,
cursor: "pointer",
}}
onClick={handleZoneClick} // Click handler for the zone
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<p className="text-center">Перетащите файл</p>
</div>
<p>или</p>
<Button
variant="contained"
color="primary"
startIcon={<Plus color="white" size={18} />}
onClick={(e) => {
e.stopPropagation(); // Prevent `handleZoneClick` from firing
onSelectVideoClick(); // This button triggers the media selection dialog
}}
>
Выбрать файл
</Button>
{/* Hidden file input */}
<input
type="file"
ref={fileInputRef}
onChange={handleFileInputChange}
style={{ display: "none" }}
accept="video/*" // Accept only video files
/>
</div>
)}
</Box>
</Paper>
);
};

View File

@@ -12,8 +12,11 @@ export * from "./MediaArea";
export * from "./ModelViewer3D";
export * from "./MediaAreaForSight";
export * from "./ImageUploadCard";
export * from "./VideoPreviewCard";
export * from "./LeaveAgree";
export * from "./DeleteModal";
export * from "./SnapshotRestore";
export * from "./CreateButton";
export * from "./SaveWithoutCityAgree";
export * from "./CitySelector";
export * from "./modals";

View File

@@ -43,8 +43,6 @@ export const EditStationModal = observer(
} = routeStore;
const handleSave = async () => {
console.log(routeId, selectedStationId);
await saveRouteStations(Number(routeId), selectedStationId);
toast.success("Успешно сохранено");
onClose();

File diff suppressed because one or more lines are too long

446
yarn.lock
View File

@@ -24,7 +24,7 @@
resolved "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.27.3.tgz"
integrity sha512-V42wFfx1ymFte+ecf6iXghnnP8kWTO+ZLXIyZq+1LAXHHvTZdVxicn4yiVYdYMGaCO3tmqub11AorKkv+iodqw==
"@babel/core@^7.0.0", "@babel/core@^7.0.0-0", "@babel/core@^7.26.10":
"@babel/core@^7.26.10":
version "7.27.3"
resolved "https://registry.npmjs.org/@babel/core/-/core-7.27.3.tgz"
integrity sha512-hyrN8ivxfvJ4i0fIJuV4EOlV0WDMz5Ui4StRTgVaAvWeiRCilXgwVvxJKtFQ3TKtHgJscB2YiXKGNJuVwhQMtA==
@@ -173,6 +173,28 @@
resolved "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz"
integrity sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==
"@emnapi/core@^1.4.3":
version "1.6.0"
resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.6.0.tgz#517f65d1c8270d5d5aa1aad660d5acb897430dca"
integrity sha512-zq/ay+9fNIJJtJiZxdTnXS20PllcYMX3OE23ESc4HK/bdYu3cOWYVhsOhVnXALfU/uqJIxn5NBPd9z4v+SfoSg==
dependencies:
"@emnapi/wasi-threads" "1.1.0"
tslib "^2.4.0"
"@emnapi/runtime@^1.4.3":
version "1.6.0"
resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.6.0.tgz#8fe297e0090f6e89a57a1f31f1c440bdbc3c01d8"
integrity sha512-obtUmAHTMjll499P+D9A3axeJFlhdjOWdKUNs/U6QIGT7V5RjcUW1xToAzjvmgTSQhDbYn/NwfTRoJcQ2rNBxA==
dependencies:
tslib "^2.4.0"
"@emnapi/wasi-threads@1.1.0", "@emnapi/wasi-threads@^1.0.2":
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":
version "11.13.5"
resolved "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz"
@@ -218,7 +240,7 @@
resolved "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz"
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"
resolved "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz"
integrity sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==
@@ -248,7 +270,7 @@
resolved "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz"
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.0"
resolved "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.0.tgz"
integrity sha512-XxfOnXFffatap2IyCeJyNov3kiDQWoR08gPUQxvbL7fxKryGBKUZUkG6Hz48DZwVrJSVh9sJboyV1Ds4OW6SgA==
@@ -280,11 +302,131 @@
resolved "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz"
integrity sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==
"@esbuild/aix-ppc64@0.25.5":
version "0.25.5"
resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.5.tgz#4e0f91776c2b340e75558f60552195f6fad09f18"
integrity sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA==
"@esbuild/android-arm64@0.25.5":
version "0.25.5"
resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.25.5.tgz#bc766407f1718923f6b8079c8c61bf86ac3a6a4f"
integrity sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg==
"@esbuild/android-arm@0.25.5":
version "0.25.5"
resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.25.5.tgz#4290d6d3407bae3883ad2cded1081a234473ce26"
integrity sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA==
"@esbuild/android-x64@0.25.5":
version "0.25.5"
resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.25.5.tgz#40c11d9cbca4f2406548c8a9895d321bc3b35eff"
integrity sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw==
"@esbuild/darwin-arm64@0.25.5":
version "0.25.5"
resolved "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.5.tgz"
integrity sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ==
"@esbuild/darwin-x64@0.25.5":
version "0.25.5"
resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.25.5.tgz#e27a5d92a14886ef1d492fd50fc61a2d4d87e418"
integrity sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ==
"@esbuild/freebsd-arm64@0.25.5":
version "0.25.5"
resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.5.tgz#97cede59d638840ca104e605cdb9f1b118ba0b1c"
integrity sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw==
"@esbuild/freebsd-x64@0.25.5":
version "0.25.5"
resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.5.tgz#71c77812042a1a8190c3d581e140d15b876b9c6f"
integrity sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw==
"@esbuild/linux-arm64@0.25.5":
version "0.25.5"
resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.25.5.tgz#f7b7c8f97eff8ffd2e47f6c67eb5c9765f2181b8"
integrity sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg==
"@esbuild/linux-arm@0.25.5":
version "0.25.5"
resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.25.5.tgz#2a0be71b6cd8201fa559aea45598dffabc05d911"
integrity sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw==
"@esbuild/linux-ia32@0.25.5":
version "0.25.5"
resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.25.5.tgz#763414463cd9ea6fa1f96555d2762f9f84c61783"
integrity sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA==
"@esbuild/linux-loong64@0.25.5":
version "0.25.5"
resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.25.5.tgz#428cf2213ff786a502a52c96cf29d1fcf1eb8506"
integrity sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg==
"@esbuild/linux-mips64el@0.25.5":
version "0.25.5"
resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.5.tgz#5cbcc7fd841b4cd53358afd33527cd394e325d96"
integrity sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg==
"@esbuild/linux-ppc64@0.25.5":
version "0.25.5"
resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.5.tgz#0d954ab39ce4f5e50f00c4f8c4fd38f976c13ad9"
integrity sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ==
"@esbuild/linux-riscv64@0.25.5":
version "0.25.5"
resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.5.tgz#0e7dd30730505abd8088321e8497e94b547bfb1e"
integrity sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA==
"@esbuild/linux-s390x@0.25.5":
version "0.25.5"
resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.25.5.tgz#5669af81327a398a336d7e40e320b5bbd6e6e72d"
integrity sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ==
"@esbuild/linux-x64@0.25.5":
version "0.25.5"
resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.25.5.tgz#b2357dd153aa49038967ddc1ffd90c68a9d2a0d4"
integrity sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw==
"@esbuild/netbsd-arm64@0.25.5":
version "0.25.5"
resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.5.tgz#53b4dfb8fe1cee93777c9e366893bd3daa6ba63d"
integrity sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw==
"@esbuild/netbsd-x64@0.25.5":
version "0.25.5"
resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.5.tgz#a0206f6314ce7dc8713b7732703d0f58de1d1e79"
integrity sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ==
"@esbuild/openbsd-arm64@0.25.5":
version "0.25.5"
resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.5.tgz#2a796c87c44e8de78001d808c77d948a21ec22fd"
integrity sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw==
"@esbuild/openbsd-x64@0.25.5":
version "0.25.5"
resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.5.tgz#28d0cd8909b7fa3953af998f2b2ed34f576728f0"
integrity sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg==
"@esbuild/sunos-x64@0.25.5":
version "0.25.5"
resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.25.5.tgz#a28164f5b997e8247d407e36c90d3fd5ddbe0dc5"
integrity sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA==
"@esbuild/win32-arm64@0.25.5":
version "0.25.5"
resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.25.5.tgz#6eadbead38e8bd12f633a5190e45eff80e24007e"
integrity sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw==
"@esbuild/win32-ia32@0.25.5":
version "0.25.5"
resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.25.5.tgz#bab6288005482f9ed2adb9ded7e88eba9a62cc0d"
integrity sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ==
"@esbuild/win32-x64@0.25.5":
version "0.25.5"
resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.25.5.tgz#7fc114af5f6563f19f73324b5d5ff36ece0803d1"
integrity sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g==
"@eslint-community/eslint-utils@^4.2.0", "@eslint-community/eslint-utils@^4.7.0":
version "4.7.0"
resolved "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz"
@@ -333,7 +475,7 @@
minimatch "^3.1.2"
strip-json-comments "^3.1.1"
"@eslint/js@^9.25.0", "@eslint/js@9.27.0":
"@eslint/js@9.27.0", "@eslint/js@^9.25.0":
version "9.27.0"
resolved "https://registry.npmjs.org/@eslint/js/-/js-9.27.0.tgz"
integrity sha512-G5JD9Tu5HJEu4z2Uo4aHY2sLV64B7CDMXxFzqzjl3NKd6RVzSXNoE80jk7Y0lJkTTkjiIhBAqmlYwjuBY3tvpA==
@@ -453,7 +595,7 @@
dependencies:
"@babel/runtime" "^7.27.1"
"@mui/material@^5.15.14 || ^6.0.0 || ^7.0.0", "@mui/material@^7.1.0", "@mui/material@^7.1.1":
"@mui/material@^7.1.0":
version "7.1.1"
resolved "https://registry.npmjs.org/@mui/material/-/material-7.1.1.tgz"
integrity sha512-mTpdmdZCaHCGOH3SrYM41+XKvNL0iQfM9KlYgpSjgadXx/fEKhhvOktxm8++Xw6FFeOHoOiV+lzOI8X1rsv71A==
@@ -492,7 +634,7 @@
csstype "^3.1.3"
prop-types "^15.8.1"
"@mui/system@^5.15.14 || ^6.0.0 || ^7.0.0", "@mui/system@^7.1.1":
"@mui/system@^7.1.1":
version "7.1.1"
resolved "https://registry.npmjs.org/@mui/system/-/system-7.1.1.tgz"
integrity sha512-Kj1uhiqnj4Zo7PDjAOghtXJtNABunWvhcRU0O7RQJ7WOxeynoH6wXPcilphV8QTFtkKaip8EiNJRiCD+B3eROA==
@@ -546,6 +688,15 @@
"@babel/runtime" "^7.27.4"
"@mui/utils" "^7.1.1"
"@napi-rs/wasm-runtime@^0.2.10":
version "0.2.12"
resolved "https://registry.yarnpkg.com/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz#3e78a8b96e6c33a6c517e1894efbd5385a7cb6f2"
integrity sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==
dependencies:
"@emnapi/core" "^1.4.3"
"@emnapi/runtime" "^1.4.3"
"@tybys/wasm-util" "^0.10.0"
"@nodelib/fs.scandir@2.1.5":
version "2.1.5"
resolved "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz"
@@ -554,7 +705,7 @@
"@nodelib/fs.stat" "2.0.5"
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"
resolved "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz"
integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==
@@ -572,7 +723,7 @@
resolved "https://registry.npmjs.org/@petamoriken/float16/-/float16-3.9.2.tgz"
integrity sha512-VgffxawQde93xKxT3qap3OH+meZf7VaSB5Sqd4Rqc+FP5alWbpOyan/7tRbOAvynjpG3GpdtAuGU/NdhQpmrog==
"@photo-sphere-viewer/core@^5.13.2", "@photo-sphere-viewer/core@>=5.13.1":
"@photo-sphere-viewer/core@^5.13.2":
version "5.13.2"
resolved "https://registry.npmjs.org/@photo-sphere-viewer/core/-/core-5.13.2.tgz"
integrity sha512-rL4Ey39Prx4Iyxt1f2tAqlXvqu4/ovXfUvIpLt540OpZJiFjWccs6qLywof9vuhBJ7PXHudHWCjRPce0W8kx8w==
@@ -624,7 +775,7 @@
utility-types "^3.11.0"
zustand "^5.0.1"
"@react-three/fiber@^9.0.0", "@react-three/fiber@^9.1.2":
"@react-three/fiber@^9.1.2":
version "9.1.2"
resolved "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.1.2.tgz"
integrity sha512-k8FR9yVHV9kIF3iuOD0ds5hVymXYXfgdKklqziBVod9ZEJ8uk05Zjw29J/omU3IKeUfLNAIHfxneN3TUYM4I2w==
@@ -647,11 +798,106 @@
resolved "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.9.tgz"
integrity sha512-e9MeMtVWo186sgvFFJOPGy7/d2j2mZhLJIdVW0C/xDluuOvymEATqz6zKsP0ZmXGzQtqlyjz5sC1sYQUoJG98w==
"@rollup/rollup-android-arm-eabi@4.41.1":
version "4.41.1"
resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.41.1.tgz#f39f09f60d4a562de727c960d7b202a2cf797424"
integrity sha512-NELNvyEWZ6R9QMkiytB4/L4zSEaBC03KIXEghptLGLZWJ6VPrL63ooZQCOnlx36aQPGhzuOMwDerC1Eb2VmrLw==
"@rollup/rollup-android-arm64@4.41.1":
version "4.41.1"
resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.41.1.tgz#d19af7e23760717f1d879d4ca3d2cd247742dff2"
integrity sha512-DXdQe1BJ6TK47ukAoZLehRHhfKnKg9BjnQYUu9gzhI8Mwa1d2fzxA1aw2JixHVl403bwp1+/o/NhhHtxWJBgEA==
"@rollup/rollup-darwin-arm64@4.41.1":
version "4.41.1"
resolved "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.41.1.tgz"
integrity sha512-5afxvwszzdulsU2w8JKWwY8/sJOLPzf0e1bFuvcW5h9zsEg+RQAojdW0ux2zyYAz7R8HvvzKCjLNJhVq965U7w==
"@rollup/rollup-darwin-x64@4.41.1":
version "4.41.1"
resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.41.1.tgz#aa66d2ba1a25e609500e13bef06dc0e71cc0c0d4"
integrity sha512-egpJACny8QOdHNNMZKf8xY0Is6gIMz+tuqXlusxquWu3F833DcMwmGM7WlvCO9sB3OsPjdC4U0wHw5FabzCGZg==
"@rollup/rollup-freebsd-arm64@4.41.1":
version "4.41.1"
resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.41.1.tgz#df10a7b6316a0ef1028c6ca71a081124c537e30d"
integrity sha512-DBVMZH5vbjgRk3r0OzgjS38z+atlupJ7xfKIDJdZZL6sM6wjfDNo64aowcLPKIx7LMQi8vybB56uh1Ftck/Atg==
"@rollup/rollup-freebsd-x64@4.41.1":
version "4.41.1"
resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.41.1.tgz#a3fdce8a05e95b068cbcb46e4df5185e407d0c35"
integrity sha512-3FkydeohozEskBxNWEIbPfOE0aqQgB6ttTkJ159uWOFn42VLyfAiyD9UK5mhu+ItWzft60DycIN1Xdgiy8o/SA==
"@rollup/rollup-linux-arm-gnueabihf@4.41.1":
version "4.41.1"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.41.1.tgz#49f766c55383bd0498014a9d76924348c2f3890c"
integrity sha512-wC53ZNDgt0pqx5xCAgNunkTzFE8GTgdZ9EwYGVcg+jEjJdZGtq9xPjDnFgfFozQI/Xm1mh+D9YlYtl+ueswNEg==
"@rollup/rollup-linux-arm-musleabihf@4.41.1":
version "4.41.1"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.41.1.tgz#1d4d7d32fc557e17d52e1857817381ea365e2959"
integrity sha512-jwKCca1gbZkZLhLRtsrka5N8sFAaxrGz/7wRJ8Wwvq3jug7toO21vWlViihG85ei7uJTpzbXZRcORotE+xyrLA==
"@rollup/rollup-linux-arm64-gnu@4.41.1":
version "4.41.1"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.41.1.tgz#f4fc317268441e9589edad3be8f62b6c03009bc1"
integrity sha512-g0UBcNknsmmNQ8V2d/zD2P7WWfJKU0F1nu0k5pW4rvdb+BIqMm8ToluW/eeRmxCared5dD76lS04uL4UaNgpNA==
"@rollup/rollup-linux-arm64-musl@4.41.1":
version "4.41.1"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.41.1.tgz#63a1f1b0671cb17822dabae827fef0e443aebeb7"
integrity sha512-XZpeGB5TKEZWzIrj7sXr+BEaSgo/ma/kCgrZgL0oo5qdB1JlTzIYQKel/RmhT6vMAvOdM2teYlAaOGJpJ9lahg==
"@rollup/rollup-linux-loongarch64-gnu@4.41.1":
version "4.41.1"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.41.1.tgz#c659b01cc6c0730b547571fc3973e1e955369f98"
integrity sha512-bkCfDJ4qzWfFRCNt5RVV4DOw6KEgFTUZi2r2RuYhGWC8WhCA8lCAJhDeAmrM/fdiAH54m0mA0Vk2FGRPyzI+tw==
"@rollup/rollup-linux-powerpc64le-gnu@4.41.1":
version "4.41.1"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.41.1.tgz#612e746f9ad7e58480f964d65e0d6c3f4aae69a8"
integrity sha512-3mr3Xm+gvMX+/8EKogIZSIEF0WUu0HL9di+YWlJpO8CQBnoLAEL/roTCxuLncEdgcfJcvA4UMOf+2dnjl4Ut1A==
"@rollup/rollup-linux-riscv64-gnu@4.41.1":
version "4.41.1"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.41.1.tgz#4610dbd1dcfbbae32fbc10c20ae7387acb31110c"
integrity sha512-3rwCIh6MQ1LGrvKJitQjZFuQnT2wxfU+ivhNBzmxXTXPllewOF7JR1s2vMX/tWtUYFgphygxjqMl76q4aMotGw==
"@rollup/rollup-linux-riscv64-musl@4.41.1":
version "4.41.1"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.41.1.tgz#054911fab40dc83fafc21e470193c058108f19d8"
integrity sha512-LdIUOb3gvfmpkgFZuccNa2uYiqtgZAz3PTzjuM5bH3nvuy9ty6RGc/Q0+HDFrHrizJGVpjnTZ1yS5TNNjFlklw==
"@rollup/rollup-linux-s390x-gnu@4.41.1":
version "4.41.1"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.41.1.tgz#98896eca8012547c7f04bd07eaa6896825f9e1a5"
integrity sha512-oIE6M8WC9ma6xYqjvPhzZYk6NbobIURvP/lEbh7FWplcMO6gn7MM2yHKA1eC/GvYwzNKK/1LYgqzdkZ8YFxR8g==
"@rollup/rollup-linux-x64-gnu@4.41.1":
version "4.41.1"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.41.1.tgz#01cf56844a1e636ee80dfb364e72c2b7142ad896"
integrity sha512-cWBOvayNvA+SyeQMp79BHPK8ws6sHSsYnK5zDcsC3Hsxr1dgTABKjMnMslPq1DvZIp6uO7kIWhiGwaTdR4Og9A==
"@rollup/rollup-linux-x64-musl@4.41.1":
version "4.41.1"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.41.1.tgz#e67c7531df6dff0b4c241101d4096617fbca87c3"
integrity sha512-y5CbN44M+pUCdGDlZFzGGBSKCA4A/J2ZH4edTYSSxFg7ce1Xt3GtydbVKWLlzL+INfFIZAEg1ZV6hh9+QQf9YQ==
"@rollup/rollup-win32-arm64-msvc@4.41.1":
version "4.41.1"
resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.41.1.tgz#7eeada98444e580674de6989284e4baacd48ea65"
integrity sha512-lZkCxIrjlJlMt1dLO/FbpZbzt6J/A8p4DnqzSa4PWqPEUUUnzXLeki/iyPLfV0BmHItlYgHUqJe+3KiyydmiNQ==
"@rollup/rollup-win32-ia32-msvc@4.41.1":
version "4.41.1"
resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.41.1.tgz#516c4b54f80587b4a390aaf4940b40870271d35d"
integrity sha512-+psFT9+pIh2iuGsxFYYa/LhS5MFKmuivRsx9iPJWNSGbh2XVEjk90fmpUEjCnILPEPJnikAU6SFDiEUyOv90Pg==
"@rollup/rollup-win32-x64-msvc@4.41.1":
version "4.41.1"
resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.41.1.tgz#848f99b0d9936d92221bb6070baeff4db6947a30"
integrity sha512-Wq2zpapRYLfi4aKxf2Xff0tN+7slj2d4R87WEzqw7ZLsVvO5zwYCIuEGSZYiK41+GlwUo1HiR+GdkLEJnCKTCw==
"@tailwindcss/node@4.1.8":
version "4.1.8"
resolved "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.8.tgz"
@@ -665,11 +911,73 @@
source-map-js "^1.2.1"
tailwindcss "4.1.8"
"@tailwindcss/oxide-android-arm64@4.1.8":
version "4.1.8"
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.8.tgz#4cb4b464636fc7e3154a1bb7df38a828291b3e9a"
integrity sha512-Fbz7qni62uKYceWYvUjRqhGfZKwhZDQhlrJKGtnZfuNtHFqa8wmr+Wn74CTWERiW2hn3mN5gTpOoxWKk0jRxjg==
"@tailwindcss/oxide-darwin-arm64@4.1.8":
version "4.1.8"
resolved "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.8.tgz"
integrity sha512-RdRvedGsT0vwVVDztvyXhKpsU2ark/BjgG0huo4+2BluxdXo8NDgzl77qh0T1nUxmM11eXwR8jA39ibvSTbi7A==
"@tailwindcss/oxide-darwin-x64@4.1.8":
version "4.1.8"
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.8.tgz#d0f3fa4c3bde21a772e29e31c9739d91db79de12"
integrity sha512-t6PgxjEMLp5Ovf7uMb2OFmb3kqzVTPPakWpBIFzppk4JE4ix0yEtbtSjPbU8+PZETpaYMtXvss2Sdkx8Vs4XRw==
"@tailwindcss/oxide-freebsd-x64@4.1.8":
version "4.1.8"
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.8.tgz#545c94c941007ed1aa2e449465501b70d59cb3da"
integrity sha512-g8C8eGEyhHTqwPStSwZNSrOlyx0bhK/V/+zX0Y+n7DoRUzyS8eMbVshVOLJTDDC+Qn9IJnilYbIKzpB9n4aBsg==
"@tailwindcss/oxide-linux-arm-gnueabihf@4.1.8":
version "4.1.8"
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.8.tgz#e1bdbf63a179081669b8cd1c9523889774760eb9"
integrity sha512-Jmzr3FA4S2tHhaC6yCjac3rGf7hG9R6Gf2z9i9JFcuyy0u79HfQsh/thifbYTF2ic82KJovKKkIB6Z9TdNhCXQ==
"@tailwindcss/oxide-linux-arm64-gnu@4.1.8":
version "4.1.8"
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.8.tgz#8d28093bbd43bdae771a2dcca720e926baa57093"
integrity sha512-qq7jXtO1+UEtCmCeBBIRDrPFIVI4ilEQ97qgBGdwXAARrUqSn/L9fUrkb1XP/mvVtoVeR2bt/0L77xx53bPZ/Q==
"@tailwindcss/oxide-linux-arm64-musl@4.1.8":
version "4.1.8"
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.8.tgz#cc6cece814d813885ead9cd8b9d55aeb3db56c97"
integrity sha512-O6b8QesPbJCRshsNApsOIpzKt3ztG35gfX9tEf4arD7mwNinsoCKxkj8TgEE0YRjmjtO3r9FlJnT/ENd9EVefQ==
"@tailwindcss/oxide-linux-x64-gnu@4.1.8":
version "4.1.8"
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.8.tgz#4cac14fa71382574773fb7986d9f0681ad89e3de"
integrity sha512-32iEXX/pXwikshNOGnERAFwFSfiltmijMIAbUhnNyjFr3tmWmMJWQKU2vNcFX0DACSXJ3ZWcSkzNbaKTdngH6g==
"@tailwindcss/oxide-linux-x64-musl@4.1.8":
version "4.1.8"
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.8.tgz#e085f1ccbc8f97625773a6a3afc2a6f88edf59da"
integrity sha512-s+VSSD+TfZeMEsCaFaHTaY5YNj3Dri8rST09gMvYQKwPphacRG7wbuQ5ZJMIJXN/puxPcg/nU+ucvWguPpvBDg==
"@tailwindcss/oxide-wasm32-wasi@4.1.8":
version "4.1.8"
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.8.tgz#c5e19fffe67f25cabf12a357bba4e87128151ea0"
integrity sha512-CXBPVFkpDjM67sS1psWohZ6g/2/cd+cq56vPxK4JeawelxwK4YECgl9Y9TjkE2qfF+9/s1tHHJqrC4SS6cVvSg==
dependencies:
"@emnapi/core" "^1.4.3"
"@emnapi/runtime" "^1.4.3"
"@emnapi/wasi-threads" "^1.0.2"
"@napi-rs/wasm-runtime" "^0.2.10"
"@tybys/wasm-util" "^0.9.0"
tslib "^2.8.0"
"@tailwindcss/oxide-win32-arm64-msvc@4.1.8":
version "4.1.8"
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.8.tgz#77521f23f91604c587736927fd2cb526667b7344"
integrity sha512-7GmYk1n28teDHUjPlIx4Z6Z4hHEgvP5ZW2QS9ygnDAdI/myh3HTHjDqtSqgu1BpRoI4OiLx+fThAyA1JePoENA==
"@tailwindcss/oxide-win32-x64-msvc@4.1.8":
version "4.1.8"
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.8.tgz#55c876ab35f8779d1dceec61483cd9834d7365ac"
integrity sha512-fou+U20j+Jl0EHwK92spoWISON2OBnCazIc038Xj2TdweYV33ZRkS9nwqiUi2d/Wba5xg5UoHfvynnb/UB49cQ==
"@tailwindcss/oxide@4.1.8":
version "4.1.8"
resolved "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.8.tgz"
@@ -715,6 +1023,20 @@
resolved "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz"
integrity sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==
"@tybys/wasm-util@^0.10.0":
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"
"@tybys/wasm-util@^0.9.0":
version "0.9.0"
resolved "https://registry.yarnpkg.com/@tybys/wasm-util/-/wasm-util-0.9.0.tgz#3e75eb00604c8d6db470bf18c37b7d984a0e3355"
integrity sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==
dependencies:
tslib "^2.4.0"
"@types/babel__core@^7.20.5":
version "7.20.5"
resolved "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz"
@@ -784,7 +1106,7 @@
dependencies:
"@types/estree" "*"
"@types/estree@*", "@types/estree@^1.0.0", "@types/estree@^1.0.6", "@types/estree@1.0.7":
"@types/estree@*", "@types/estree@1.0.7", "@types/estree@^1.0.0", "@types/estree@^1.0.6":
version "1.0.7"
resolved "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz"
integrity sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==
@@ -818,7 +1140,7 @@
resolved "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz"
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.15.24"
resolved "https://registry.npmjs.org/@types/node/-/node-22.15.24.tgz"
integrity sha512-w9CZGm9RDjzTh/D+hFwlBJ3ziUaVw7oufKA3vOFSOZlzmW9AkZnfjPb+DLnrV6qtgL/LNmP0/2zBNCFHL3F0ng==
@@ -860,7 +1182,7 @@
resolved "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz"
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.0.0", "@types/react@^19.1.2", "@types/react@>=16.8", "@types/react@>=18", "@types/react@>=18.0.0":
"@types/react@^19.1.2":
version "19.1.6"
resolved "https://registry.npmjs.org/@types/react/-/react-19.1.6.tgz"
integrity sha512-JeG0rEWak0N6Itr6QUx+X60uQmN+5t3j9r/OVDtWzFXKaj6kD1BwJzOksD0FF6iWxZlbE1kB0q9vtnU2ekqa1Q==
@@ -879,7 +1201,7 @@
dependencies:
"@types/estree" "*"
"@types/three@*", "@types/three@>=0.134.0":
"@types/three@*":
version "0.177.0"
resolved "https://registry.npmjs.org/@types/three/-/three-0.177.0.tgz"
integrity sha512-/ZAkn4OLUijKQySNci47lFO+4JLE1TihEjsGWPUT+4jWqxtwOPPEwJV1C3k5MEx0mcBPCdkFjzRzDOnHEI1R+A==
@@ -927,7 +1249,7 @@
natural-compare "^1.4.0"
ts-api-utils "^2.1.0"
"@typescript-eslint/parser@^8.33.0", "@typescript-eslint/parser@8.33.0":
"@typescript-eslint/parser@8.33.0":
version "8.33.0"
resolved "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.33.0.tgz"
integrity sha512-JaehZvf6m0yqYp34+RVnihBAChkqeH+tqqhS0GuX1qgPpwLvmTPheKEs6OeCK6hVJgXZHJ2vbjnC9j119auStQ==
@@ -955,7 +1277,7 @@
"@typescript-eslint/types" "8.33.0"
"@typescript-eslint/visitor-keys" "8.33.0"
"@typescript-eslint/tsconfig-utils@^8.33.0", "@typescript-eslint/tsconfig-utils@8.33.0":
"@typescript-eslint/tsconfig-utils@8.33.0", "@typescript-eslint/tsconfig-utils@^8.33.0":
version "8.33.0"
resolved "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.33.0.tgz"
integrity sha512-sTkETlbqhEoiFmGr1gsdq5HyVbSOF0145SYDJ/EQmXHtKViCaGvnyLqWFFHtEXoS0J1yU8Wyou2UGmgW88fEug==
@@ -970,7 +1292,7 @@
debug "^4.3.4"
ts-api-utils "^2.1.0"
"@typescript-eslint/types@^8.33.0", "@typescript-eslint/types@8.33.0":
"@typescript-eslint/types@8.33.0", "@typescript-eslint/types@^8.33.0":
version "8.33.0"
resolved "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.33.0.tgz"
integrity sha512-DKuXOKpM5IDT1FA2g9x9x1Ug81YuKrzf4mYX8FAVSNu5Wo/LELHWQyM1pQaDkI42bX15PWl0vNPt1uGiIFUOpg==
@@ -1053,7 +1375,7 @@ acorn-jsx@^5.3.2:
resolved "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz"
integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==
"acorn@^6.0.0 || ^7.0.0 || ^8.0.0", acorn@^8.14.0:
acorn@^8.14.0:
version "8.14.1"
resolved "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz"
integrity sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==
@@ -1152,7 +1474,7 @@ braces@^3.0.3:
dependencies:
fill-range "^7.1.1"
browserslist@^4.24.0, "browserslist@>= 4.21.0":
browserslist@^4.24.0:
version "4.24.5"
resolved "https://registry.npmjs.org/browserslist/-/browserslist-4.24.5.tgz"
integrity sha512-FDToo4Wo82hIdgc1CQ+NQD0hEhmpPjrZ3hiUgwgOG6IuTdlpr8jdjyG24P6cNP1yJpTLzS5OcGgSw0xmDU1/Tw==
@@ -1411,7 +1733,7 @@ earcut@^3.0.0, earcut@^3.0.1:
resolved "https://registry.npmjs.org/earcut/-/earcut-3.0.1.tgz"
integrity sha512-0l1/0gOjESMeQyYaK5IDiPNvFeu93Z/cO0TjZh9eZ1vyCtZnA7KMZ8rQggpsJHIbGSdrqYq9OhuveadOVHCshw==
easymde@^2.20.0, "easymde@>= 2.0.0 < 3.0.0":
easymde@^2.20.0:
version "2.20.0"
resolved "https://registry.npmjs.org/easymde/-/easymde-2.20.0.tgz"
integrity sha512-V1Z5f92TfR42Na852OWnIZMbM7zotWQYTddNaLYZFVKj7APBbyZ3FYJ27gBw2grMW3R6Qdv9J8n5Ij7XRSIgXQ==
@@ -1543,7 +1865,7 @@ eslint-visitor-keys@^4.2.0:
resolved "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz"
integrity sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==
"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.27.0"
resolved "https://registry.npmjs.org/eslint/-/eslint-9.27.0.tgz"
integrity sha512-ixRawFQuMB9DZ7fjU3iGGganFDp3+45bPOdaRurcFHSXO1e/sYwUX/FtQZpLZJR6SjMoJH8hR2pPEAfDyCoU2Q==
@@ -2111,7 +2433,7 @@ its-fine@^2.0.0:
dependencies:
"@types/react-reconciler" "^0.28.9"
jiti@*, jiti@^2.4.2, jiti@>=1.21.0:
jiti@^2.4.2:
version "2.4.2"
resolved "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz"
integrity sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==
@@ -2195,7 +2517,52 @@ lightningcss-darwin-arm64@1.30.1:
resolved "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.1.tgz"
integrity sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==
lightningcss@^1.21.0, lightningcss@1.30.1:
lightningcss-darwin-x64@1.30.1:
version "1.30.1"
resolved "https://registry.yarnpkg.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.1.tgz#e81105d3fd6330860c15fe860f64d39cff5fbd22"
integrity sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA==
lightningcss-freebsd-x64@1.30.1:
version "1.30.1"
resolved "https://registry.yarnpkg.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.1.tgz#a0e732031083ff9d625c5db021d09eb085af8be4"
integrity sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig==
lightningcss-linux-arm-gnueabihf@1.30.1:
version "1.30.1"
resolved "https://registry.yarnpkg.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.1.tgz#1f5ecca6095528ddb649f9304ba2560c72474908"
integrity sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q==
lightningcss-linux-arm64-gnu@1.30.1:
version "1.30.1"
resolved "https://registry.yarnpkg.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.1.tgz#eee7799726103bffff1e88993df726f6911ec009"
integrity sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw==
lightningcss-linux-arm64-musl@1.30.1:
version "1.30.1"
resolved "https://registry.yarnpkg.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.1.tgz#f2e4b53f42892feeef8f620cbb889f7c064a7dfe"
integrity sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==
lightningcss-linux-x64-gnu@1.30.1:
version "1.30.1"
resolved "https://registry.yarnpkg.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.1.tgz#2fc7096224bc000ebb97eea94aea248c5b0eb157"
integrity sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==
lightningcss-linux-x64-musl@1.30.1:
version "1.30.1"
resolved "https://registry.yarnpkg.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.1.tgz#66dca2b159fd819ea832c44895d07e5b31d75f26"
integrity sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==
lightningcss-win32-arm64-msvc@1.30.1:
version "1.30.1"
resolved "https://registry.yarnpkg.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.1.tgz#7d8110a19d7c2d22bfdf2f2bb8be68e7d1b69039"
integrity sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==
lightningcss-win32-x64-msvc@1.30.1:
version "1.30.1"
resolved "https://registry.yarnpkg.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.1.tgz#fd7dd008ea98494b85d24b4bea016793f2e0e352"
integrity sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg==
lightningcss@1.30.1:
version "1.30.1"
resolved "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz"
integrity sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==
@@ -2658,7 +3025,7 @@ mobx-react-lite@^4.1.0:
dependencies:
use-sync-external-store "^1.4.0"
mobx@^6.13.7, mobx@^6.9.0:
mobx@^6.13.7:
version "6.13.7"
resolved "https://registry.npmjs.org/mobx/-/mobx-6.13.7.tgz"
integrity sha512-aChaVU/DO5aRPmk1GX8L+whocagUUpBQqoPtJk+cm7UOXUk87J4PeWCh6nNmTTIfEhiR9DI/+FnA8dln/hTK7g==
@@ -2822,12 +3189,12 @@ picomatch@^2.3.1:
resolved "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz"
integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==
"picomatch@^3 || ^4", picomatch@^4.0.2:
picomatch@^4.0.2:
version "4.0.2"
resolved "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz"
integrity sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==
pixi.js@^8.10.1, pixi.js@^8.2.6:
pixi.js@^8.10.1:
version "8.11.0"
resolved "https://registry.npmjs.org/pixi.js/-/pixi.js-8.11.0.tgz"
integrity sha512-dyuThzncsgEgJZnvd/A/5x6IkUERbK+phXqUQrI+0C6WE+8xqGH5VChRTLecemhgZF0kQ+gZOM3tJTX9937xpg==
@@ -2883,7 +3250,7 @@ promise-worker-transferable@^1.0.4:
is-promise "^2.1.0"
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"
resolved "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz"
integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==
@@ -2949,7 +3316,7 @@ react-colorful@^5.6.1:
resolved "https://registry.npmjs.org/react-colorful/-/react-colorful-5.6.1.tgz"
integrity sha512-1exovf0uGTGyq5mXQT0zgQ80uvj2PCwvF8zY1RN9/vbJVSjSo3fsB/4L3ObbF7u70NduSiK4xu4Y6q1MHoUGEw==
"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.0, react-dom@>=16.8.2, react-dom@>=18:
react-dom@^19.1.0:
version "19.1.0"
resolved "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz"
integrity sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==
@@ -2999,7 +3366,7 @@ react-photo-sphere-viewer@^6.2.3:
dependencies:
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"
resolved "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.31.0.tgz"
integrity sha512-7Ob7Z+URmesIsIVRjnLoDGwBEG/tVitidU0nMsqX/eeJaLY89RISO/10ERe0MqmzuKUUB1rmY+h1itMbUHg9BQ==
@@ -3063,12 +3430,12 @@ react-use-measure@^2.1.7:
resolved "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz"
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@>= 16.8 || 18.0.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.1.0"
resolved "https://registry.npmjs.org/react/-/react-19.1.0.tgz"
integrity sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==
redux@^5.0.0, redux@^5.0.1:
redux@^5.0.1:
version "5.0.1"
resolved "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz"
integrity sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==
@@ -3289,7 +3656,7 @@ suspend-react@^0.1.3:
resolved "https://registry.npmjs.org/suspend-react/-/suspend-react-0.1.3.tgz"
integrity sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ==
tailwindcss@^4.1.8, "tailwindcss@>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1", tailwindcss@4.1.8:
tailwindcss@4.1.8, tailwindcss@^4.1.8:
version "4.1.8"
resolved "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.8.tgz"
integrity sha512-kjeW8gjdxasbmFKpVGrGd5T4i40mV5J2Rasw48QARfYeQ8YS9x02ON9SFWax3Qf616rt4Cp3nVNIj6Hd1mP3og==
@@ -3338,7 +3705,7 @@ three@^0.175.0:
resolved "https://registry.npmjs.org/three/-/three-0.175.0.tgz"
integrity sha512-nNE3pnTHxXN/Phw768u0Grr7W4+rumGg/H6PgeseNJojkJtmeHJfZWi41Gp2mpXl1pg1pf1zjwR4McM1jTqkpg==
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"
resolved "https://registry.npmjs.org/three/-/three-0.177.0.tgz"
integrity sha512-EiXv5/qWAaGI+Vz2A+JfavwYCMdGjxVsrn3oBwllUoqYeaBO75J63ZfyaQKoiLrqNHoTlUc6PFgMXnS0kI45zg==
@@ -3398,9 +3765,9 @@ ts-api-utils@^2.1.0:
resolved "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz"
integrity sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==
tslib@^2.7.0:
tslib@^2.4.0, tslib@^2.7.0, tslib@^2.8.0:
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==
tunnel-rat@^0.1.2:
@@ -3426,7 +3793,7 @@ typescript-eslint@^8.30.1:
"@typescript-eslint/parser" "8.33.0"
"@typescript-eslint/utils" "8.33.0"
typescript@>=4.8.4, "typescript@>=4.8.4 <5.9.0", typescript@~5.8.3:
typescript@~5.8.3:
version "5.8.3"
resolved "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz"
integrity sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==
@@ -3507,7 +3874,7 @@ uri-js@^4.2.2:
dependencies:
punycode "^2.1.0"
use-sync-external-store@^1.2.2, use-sync-external-store@^1.4.0, use-sync-external-store@^1.5.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.5.0:
version "1.5.0"
resolved "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz"
integrity sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==
@@ -3553,7 +3920,7 @@ vfile@^6.0.0:
"@types/unist" "^3.0.0"
vfile-message "^4.0.0"
"vite@^4.2.0 || ^5.0.0 || ^6.0.0", "vite@^5.2.0 || ^6", vite@^6.3.5:
vite@^6.3.5:
version "6.3.5"
resolved "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz"
integrity sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==
@@ -3619,11 +3986,6 @@ yaml@^1.10.0:
resolved "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz"
integrity sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==
yaml@^2.4.2:
version "2.8.0"
resolved "https://registry.npmjs.org/yaml/-/yaml-2.8.0.tgz"
integrity sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==
yocto-queue@^0.1.0:
version "0.1.0"
resolved "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz"