diff --git a/src/app/GlobalErrorBoundary.tsx b/src/app/GlobalErrorBoundary.tsx new file mode 100644 index 0000000..52467e4 --- /dev/null +++ b/src/app/GlobalErrorBoundary.tsx @@ -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 { + 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 ( + + + + + + + + + Упс! Что-то пошло не так + + + + Приложение столкнулось с неожиданной ошибкой. Попробуйте + перезагрузить страницу или вернуться на главную. + + + {this.state.error?.message && ( + + + Информация об ошибке: + + + {this.state.error.message} + + + )} + + + + + + + + + ); + } + + return this.props.children; + } +} diff --git a/src/app/index.tsx b/src/app/index.tsx index 5c274f7..89e157d 100644 --- a/src/app/index.tsx +++ b/src/app/index.tsx @@ -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 = () => ( - - - - + + + + + + ); diff --git a/src/shared/const/index.ts b/src/shared/const/index.ts index df008b1..9507d14 100644 --- a/src/shared/const/index.ts +++ b/src/shared/const/index.ts @@ -8,6 +8,8 @@ export const MEDIA_TYPE_LABELS = { 6: "3Д-модель", }; +export * from "./mediaTypes"; + export const MEDIA_TYPE_VALUES = { image: 1, video: 2, diff --git a/src/shared/const/mediaTypes.ts b/src/shared/const/mediaTypes.ts new file mode 100644 index 0000000..77ef30a --- /dev/null +++ b/src/shared/const/mediaTypes.ts @@ -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 }; +}; diff --git a/src/widgets/MediaArea/index.tsx b/src/widgets/MediaArea/index.tsx index 702f512..f0696bb 100644 --- a/src/widgets/MediaArea/index.tsx +++ b/src/widgets/MediaArea/index.tsx @@ -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) => { 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" }} /> diff --git a/src/widgets/MediaAreaForSight/index.tsx b/src/widgets/MediaAreaForSight/index.tsx index 0c60376..bb1dd20 100644 --- a/src/widgets/MediaAreaForSight/index.tsx +++ b/src/widgets/MediaAreaForSight/index.tsx @@ -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); - setUploadMediaDialogOpen(true); + 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) => { @@ -60,10 +74,18 @@ export const MediaAreaForSight = observer( const handleFileSelect = (event: React.ChangeEvent) => { const files = Array.from(event.target.files || []); - if (files.length && onFilesDrop) { - setFileToUpload(files[0]); - onFilesDrop(files); - setUploadMediaDialogOpen(true); + 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, чтобы можно было выбрать тот же файл снова @@ -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" }} /> diff --git a/src/widgets/MediaViewer/ThreeView.tsx b/src/widgets/MediaViewer/ThreeView.tsx index 023a563..39d03aa 100644 --- a/src/widgets/MediaViewer/ThreeView.tsx +++ b/src/widgets/MediaViewer/ThreeView.tsx @@ -1,5 +1,7 @@ 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"; type ModelViewerProps = { width?: string; @@ -7,21 +9,72 @@ type ModelViewerProps = { height?: string; }; +const Model = ({ fileUrl }: { fileUrl: string }) => { + const { scene } = useGLTF(fileUrl); + return ; +}; + +const LoadingFallback = () => { + return ( + + + + Загрузка 3D модели... + + + ); +}; + export const ThreeView = ({ fileUrl, height = "100%", width = "100%", }: ModelViewerProps) => { - const { scene } = useGLTF(fileUrl); + // Очищаем кеш при размонтировании + useEffect(() => { + return () => { + useGLTF.clear(fileUrl); + console.log("🧹 ThreeView: Очистка кеша модели", { fileUrl }); + }; + }, [fileUrl]); return ( - - - - - - - - + + }> + + + + + + + + + + ); }; diff --git a/src/widgets/MediaViewer/ThreeViewErrorBoundary.tsx b/src/widgets/MediaViewer/ThreeViewErrorBoundary.tsx new file mode 100644 index 0000000..2fb50e0 --- /dev/null +++ b/src/widgets/MediaViewer/ThreeViewErrorBoundary.tsx @@ -0,0 +1,263 @@ +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 { + constructor(props: Props) { + super(props); + this.state = { + hasError: false, + error: null, + lastResetKey: props.resetKey, + }; + } + + static getDerivedStateFromError(error: Error): Partial { + return { + hasError: true, + error, + }; + } + + static getDerivedStateFromProps( + props: Props, + state: State + ): Partial | 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) { + console.log( + "🔄 ThreeViewErrorBoundary: Сброс ошибки при смене модели", + { + oldKey: state.lastResetKey, + newKey: props.resetKey, + oldMediaId, + newMediaId, + } + ); + return { + hasError: false, + error: null, + lastResetKey: props.resetKey, + }; + } + + // Если изменился только счетчик (нажата кнопка "Попробовать снова"), обновляем lastResetKey + // но не сбрасываем ошибку автоматически - ждем результата загрузки + console.log( + "🔄 ThreeViewErrorBoundary: Обновление lastResetKey без сброса ошибки", + { + oldKey: state.lastResetKey, + newKey: props.resetKey, + } + ); + 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 = () => { + console.log( + "🔄 ThreeViewErrorBoundary: Перезагрузка компонента и перезапрос модели" + ); + + // Сначала сбрасываем состояние ошибки + this.setState( + { + hasError: false, + error: null, + }, + () => { + // После того как состояние обновилось, вызываем callback для изменения resetKey + // Это приведет к пересозданию компонента и новой попытке загрузки + this.props.onReset?.(); + } + ); + }; + + handleClose = () => { + console.log("❌ ThreeViewErrorBoundary: Закрытие ошибки"); + this.setState({ + hasError: false, + error: null, + }); + }; + + render() { + if (this.state.hasError) { + return ( + + + + + + Ошибка загрузки 3D модели + + + + + {this.getErrorMessage()} + + + + Возможные причины: +
    + {this.getErrorReasons().map((reason, index) => ( +
  • {reason}
  • + ))} +
+
+ + {this.state.error?.message && ( + + {this.state.error.message} + + )} + + + + +
+
+ ); + } + + return this.props.children; + } +} diff --git a/src/widgets/MediaViewer/index.tsx b/src/widgets/MediaViewer/index.tsx index 3c8a3b2..51e8ee3 100644 --- a/src/widgets/MediaViewer/index.tsx +++ b/src/widgets/MediaViewer/index.tsx @@ -1,7 +1,9 @@ import { Box } from "@mui/material"; +import { useState } from "react"; import { ReactPhotoSphereViewer } from "react-photo-sphere-viewer"; import { ThreeView } from "./ThreeView"; +import { ThreeViewErrorBoundary } from "./ThreeViewErrorBoundary"; export interface MediaData { id: string | number; @@ -25,6 +27,23 @@ export function MediaViewer({ fullHeight?: boolean; }>) { const token = localStorage.getItem("token"); + const [resetKey, setResetKey] = useState(0); + + const handleReset = () => { + console.log("🔄 MediaViewer: handleReset вызван", { + currentResetKey: resetKey, + mediaId: media?.id, + }); + setResetKey((prev) => { + const newKey = prev + 1; + console.log("🔄 MediaViewer: resetKey обновлен", { + oldKey: prev, + newKey, + }); + return newKey; + }); + }; + return ( + + + )} );