Compare commits

...

5 Commits

Author SHA1 Message Date
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
27 changed files with 1156 additions and 444 deletions

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

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

View File

@ -140,9 +140,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";
@ -227,7 +225,7 @@ export const LinkedItemsContents = <
if (type === "edit") {
setError(null);
authInstance
.get(`/${childResource}/`)
.get(`/${childResource}`)
.then((response) => {
setAllItems(response?.data || []);
})

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

@ -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 {
@ -360,8 +361,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,12 +160,12 @@ 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}>
<LanguageSwitcher />
<Application resizeTo={parentRef} background="#fff">
<InfiniteCanvas>
<TravelPath points={points} />

View File

@ -132,7 +132,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,4 +1,4 @@
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";
@ -20,50 +20,56 @@ export const SnapshotCreatePage = observer(() => {
}, [id]);
return (
<Paper 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"
onClick={() => navigate(-1)}
>
<ArrowLeft size={20} />
Назад
</button>
</div>
<h1 className="text-2xl font-bold">Создание снапшота</h1>
<div className="flex flex-col gap-10 w-full items-end">
<TextField
className="w-full"
label="Название"
required
value={name}
onChange={(e) => setName(e.target.value)}
/>
<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"
onClick={() => navigate(-1)}
>
<ArrowLeft size={20} />
Назад
</button>
</div>
<h1 className="text-2xl font-bold">Создание снапшота</h1>
<div className="flex flex-col gap-10 w-full items-end">
<TextField
className="w-full"
label="Название"
required
value={name}
onChange={(e) => setName(e.target.value)}
/>
<Button
variant="contained"
color="primary"
className="w-min flex gap-2 items-center"
startIcon={<Save size={20} />}
onClick={async () => {
try {
setIsLoading(true);
await createSnapshot(name);
setIsLoading(false);
toast.success("Снапшот успешно создан");
} catch (error) {
console.error(error);
}
}}
disabled={isLoading}
>
{isLoading ? (
<Loader2 size={20} className="animate-spin" />
) : (
"Сохранить"
)}
</Button>
<Button
variant="contained"
color="primary"
className="w-min flex gap-2 items-center"
startIcon={<Save size={20} />}
onClick={async () => {
try {
setIsLoading(true);
await createSnapshot(name);
setIsLoading(false);
toast.success("Снапшот успешно создан");
navigate(-1);
} catch (error) {
console.error(error);
toast.error("Ошибка при создании снапшота");
} finally {
setIsLoading(false);
}
}}
disabled={isLoading || !name.trim()}
>
{isLoading ? (
<Loader2 size={20} className="animate-spin" />
) : (
"Сохранить"
)}
</Button>
</div>
</div>
</Paper>
</div>
);
});

View File

@ -117,12 +117,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

@ -93,9 +93,7 @@ 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";
@ -178,7 +176,7 @@ export const LinkedSightsContents = <
if (type === "edit") {
setError(null);
authInstance
.get(`/${childResource}/`)
.get(`/${childResource}`)
.then((response) => {
setAllItems(response?.data || []);
})

View File

@ -81,6 +81,7 @@ export const UploadMediaDialog = observer(
const [availableMediaTypes, setAvailableMediaTypes] = useState<number[]>(
[]
);
const [isPreviewLoaded, setIsPreviewLoaded] = useState(false);
useEffect(() => {
if (initialFile) {
@ -207,6 +208,7 @@ export const UploadMediaDialog = observer(
useEffect(() => {
if (mediaFile) {
setMediaUrl(URL.createObjectURL(mediaFile as Blob));
setIsPreviewLoaded(false); // Сбрасываем состояние загрузки при смене файла
}
}, [mediaFile]);
@ -326,8 +328,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}
@ -336,10 +352,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
@ -349,6 +371,8 @@ export const UploadMediaDialog = observer(
height: "100%",
objectFit: "contain",
}}
onLoad={() => setIsPreviewLoaded(true)}
onError={() => setIsPreviewLoaded(true)}
/>
)}
</Paper>
@ -370,9 +394,17 @@ export const UploadMediaDialog = observer(
)
}
onClick={handleSave}
disabled={isLoading || (!mediaName && !mediaFilename)}
disabled={
isLoading ||
(!mediaName && !mediaFilename) ||
!isPreviewLoaded
}
>
{isLoading ? "Сохранение..." : "Сохранить"}
{isLoading
? "Сохранение..."
: !isPreviewLoaded
? "Загрузка превью..."
: "Сохранить"}
</Button>
</Box>
</Box>

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

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

View File

@ -97,15 +97,19 @@ class SightsStore {
city: number,
coordinates: { latitude: number; longitude: number }
) => {
const id = (
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;
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,
});
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,248 @@ 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);
})
);
})
.then(() => {
console.log("Кеш браузера очищен");
})
.catch((error) => {
console.warn("Не удалось очистить кеш браузера:", error);
});
} 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();
})
);
})
.then(() => {
console.log("IndexedDB очищен");
})
.catch((error) => {
console.warn("Не удалось очистить IndexedDB:", error);
});
} catch (error) {
console.warn("IndexedDB не поддерживается:", error);
}
}
console.log("Все кеши приложения сброшены");
};
getSnapshots = async () => {
const response = await authInstance.get(`/snapshots`);
@ -42,6 +302,10 @@ class SnapshotStore {
};
restoreSnapshot = async (id: string) => {
// Сначала сбрасываем все кеши
this.clearAllCaches();
// Затем восстанавливаем снапшот
await authInstance.post(`/snapshots/${id}/restore`);
};

View File

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

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

@ -109,6 +109,7 @@ export const MediaArea = observer(
media_type: m.media_type,
filename: m.filename,
}}
height="40px"
/>
<button
className="absolute top-2 right-2"

View File

@ -12,11 +12,15 @@ 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;
}>) {
@ -42,8 +46,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",
...(media?.filename?.toLowerCase().endsWith(".webp") && {
maxWidth: "300px",
maxHeight: "300px",
@ -59,8 +63,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 +80,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,8 +102,8 @@ 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"}
/>
)}
@ -108,8 +112,8 @@ export function MediaViewer({
fileUrl={`${import.meta.env.VITE_KRBL_MEDIA}${
media?.id
}/download?token=${token}`}
height="500px"
width="500px"
height={height ? height : "500px"}
width={width ? width : "500px"}
/>
)}
</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

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

File diff suppressed because one or more lines are too long