493 lines
16 KiB
TypeScript
493 lines
16 KiB
TypeScript
import {
|
||
MEDIA_TYPE_LABELS,
|
||
MEDIA_TYPE_VALUES,
|
||
editSightStore,
|
||
generateDefaultMediaName,
|
||
clearBlobAndGLTFCache,
|
||
} from "@shared";
|
||
import { observer } from "mobx-react-lite";
|
||
import { useEffect, useState, useRef } from "react";
|
||
import {
|
||
Dialog,
|
||
DialogTitle,
|
||
DialogContent,
|
||
DialogActions,
|
||
Button,
|
||
TextField,
|
||
Paper,
|
||
Box,
|
||
CircularProgress,
|
||
Alert,
|
||
Snackbar,
|
||
Select,
|
||
MenuItem,
|
||
FormControl,
|
||
InputLabel,
|
||
} from "@mui/material";
|
||
import { Save } from "lucide-react";
|
||
import { ModelViewer3D } from "@widgets";
|
||
|
||
interface UploadMediaDialogProps {
|
||
open: boolean;
|
||
onClose: () => void;
|
||
afterUpload?: (media: {
|
||
id: string;
|
||
filename: string;
|
||
media_name?: string;
|
||
media_type: number;
|
||
}) => void;
|
||
afterUploadSight?: (id: string) => void;
|
||
hardcodeType?:
|
||
| "thumbnail"
|
||
| "watermark_lu"
|
||
| "watermark_rd"
|
||
| "image"
|
||
| "video_preview"
|
||
| null;
|
||
contextObjectName?: string;
|
||
contextType?:
|
||
| "sight"
|
||
| "city"
|
||
| "carrier"
|
||
| "country"
|
||
| "vehicle"
|
||
| "station";
|
||
isArticle?: boolean;
|
||
articleName?: string;
|
||
initialFile?: File; // <--- добавлено
|
||
}
|
||
|
||
export const UploadMediaDialog = observer(
|
||
({
|
||
open,
|
||
onClose,
|
||
afterUpload,
|
||
afterUploadSight,
|
||
hardcodeType,
|
||
contextObjectName,
|
||
|
||
isArticle,
|
||
articleName,
|
||
initialFile, // <--- добавлено
|
||
}: UploadMediaDialogProps) => {
|
||
const [isLoading, setIsLoading] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [success, setSuccess] = useState(false);
|
||
const [mediaName, setMediaName] = useState("");
|
||
const [mediaFilename, setMediaFilename] = useState("");
|
||
const [mediaType, setMediaType] = useState(0);
|
||
const [mediaFile, setMediaFile] = useState<File | null>(null);
|
||
const { fileToUpload, uploadMedia } = editSightStore;
|
||
const [mediaUrl, setMediaUrl] = useState<string | null>(null);
|
||
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) {
|
||
setMediaFile(fileToUpload);
|
||
setMediaFilename(fileToUpload.name);
|
||
// Try to determine media type from file extension
|
||
const extension = fileToUpload.name.split(".").pop()?.toLowerCase();
|
||
if (extension) {
|
||
if (["glb", "gltf"].includes(extension)) {
|
||
setAvailableMediaTypes([6]);
|
||
setMediaType(6);
|
||
}
|
||
if (
|
||
["jpg", "jpeg", "png", "gif", "svg", "webp", "bmp"].includes(
|
||
extension
|
||
)
|
||
) {
|
||
// Для изображений доступны все типы кроме видео
|
||
setAvailableMediaTypes([1, 3, 4, 5]); // Фото, Иконка, Водяной знак, Панорама, 3Д-модель
|
||
setMediaType(1); // По умолчанию Фото
|
||
} else if (["mp4", "webm", "mov"].includes(extension)) {
|
||
// Для видео только тип Видео
|
||
setAvailableMediaTypes([2]);
|
||
setMediaType(2);
|
||
}
|
||
}
|
||
|
||
// Генерируем название по умолчанию если есть контекст
|
||
if (fileToUpload.name) {
|
||
let defaultName = "";
|
||
|
||
if (isArticle && articleName && contextObjectName) {
|
||
// Для статей: "Название достопримечательности_название файла_название статьи"
|
||
defaultName = generateDefaultMediaName(
|
||
contextObjectName,
|
||
fileToUpload.name,
|
||
articleName,
|
||
true
|
||
);
|
||
} else if (contextObjectName && contextObjectName.trim() !== "") {
|
||
// Для обычных медиа с названием объекта
|
||
const currentMediaType = hardcodeType
|
||
? MEDIA_TYPE_VALUES[hardcodeType]
|
||
: 1; // По умолчанию фото
|
||
defaultName = generateDefaultMediaName(
|
||
contextObjectName,
|
||
fileToUpload.name,
|
||
currentMediaType,
|
||
false
|
||
);
|
||
} else {
|
||
// Для медиа без названия объекта
|
||
const currentMediaType = hardcodeType
|
||
? MEDIA_TYPE_VALUES[hardcodeType]
|
||
: 1; // По умолчанию фото
|
||
defaultName = generateDefaultMediaName(
|
||
"",
|
||
fileToUpload.name,
|
||
currentMediaType,
|
||
false
|
||
);
|
||
}
|
||
|
||
setMediaName(defaultName);
|
||
}
|
||
}
|
||
}, [fileToUpload, contextObjectName, hardcodeType, isArticle, articleName]);
|
||
|
||
// Обновляем название при изменении типа медиа
|
||
useEffect(() => {
|
||
if (mediaFilename && mediaType > 0) {
|
||
let defaultName = "";
|
||
|
||
if (isArticle && articleName && contextObjectName) {
|
||
// Для статей: "Название достопримечательности_название файла_название статьи"
|
||
defaultName = generateDefaultMediaName(
|
||
contextObjectName,
|
||
mediaFilename,
|
||
articleName,
|
||
true
|
||
);
|
||
} else if (contextObjectName && contextObjectName.trim() !== "") {
|
||
// Для обычных медиа с названием объекта
|
||
const currentMediaType = hardcodeType
|
||
? MEDIA_TYPE_VALUES[hardcodeType]
|
||
: mediaType;
|
||
defaultName = generateDefaultMediaName(
|
||
contextObjectName,
|
||
mediaFilename,
|
||
currentMediaType,
|
||
false
|
||
);
|
||
} else {
|
||
// Для медиа без названия объекта
|
||
const currentMediaType = hardcodeType
|
||
? MEDIA_TYPE_VALUES[hardcodeType]
|
||
: mediaType;
|
||
defaultName = generateDefaultMediaName(
|
||
"",
|
||
mediaFilename,
|
||
currentMediaType,
|
||
false
|
||
);
|
||
}
|
||
|
||
setMediaName(defaultName);
|
||
}
|
||
}, [
|
||
mediaType,
|
||
contextObjectName,
|
||
mediaFilename,
|
||
hardcodeType,
|
||
isArticle,
|
||
articleName,
|
||
]);
|
||
|
||
useEffect(() => {
|
||
if (mediaFile) {
|
||
// Очищаем предыдущий blob URL и кеш GLTF если он существует
|
||
if (
|
||
previousMediaUrlRef.current &&
|
||
previousMediaUrlRef.current.startsWith("blob:")
|
||
) {
|
||
clearBlobAndGLTFCache(previousMediaUrlRef.current);
|
||
}
|
||
|
||
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) => {
|
||
// if (event.key.toLowerCase() === "enter" && !event.ctrlKey) {
|
||
// event.preventDefault();
|
||
// onClose();
|
||
// }
|
||
// };
|
||
|
||
// window.addEventListener("keydown", handleKeyPress);
|
||
// return () => window.removeEventListener("keydown", handleKeyPress);
|
||
// }, [onClose]);
|
||
|
||
const handleSave = async () => {
|
||
if (!mediaFile) return;
|
||
|
||
setIsLoading(true);
|
||
setError(null);
|
||
|
||
try {
|
||
const media = await uploadMedia(
|
||
mediaFilename,
|
||
hardcodeType
|
||
? (MEDIA_TYPE_VALUES[hardcodeType] as number)
|
||
: mediaType,
|
||
mediaFile,
|
||
mediaName
|
||
);
|
||
if (media) {
|
||
if (afterUploadSight) {
|
||
await afterUploadSight(media.id);
|
||
} else if (afterUpload) {
|
||
await afterUpload(media);
|
||
}
|
||
}
|
||
setSuccess(true);
|
||
// Закрываем модальное окно после успешного сохранения
|
||
setTimeout(() => {
|
||
handleClose();
|
||
}, 1000); // Небольшая задержка, чтобы пользователь увидел сообщение об успехе
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : "Failed to save media");
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
};
|
||
|
||
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();
|
||
};
|
||
|
||
return (
|
||
<>
|
||
<Dialog open={open} onClose={handleClose} maxWidth="md" fullWidth>
|
||
<DialogTitle>Просмотр медиа</DialogTitle>
|
||
<DialogContent
|
||
className="flex gap-4"
|
||
dividers
|
||
sx={{
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
gap: 2,
|
||
pt: 2,
|
||
}}
|
||
>
|
||
<Box className="flex flex-col gap-4">
|
||
<Box className="flex gap-2">
|
||
<TextField
|
||
fullWidth
|
||
value={mediaName}
|
||
onChange={(e) => setMediaName(e.target.value)}
|
||
label="Название медиа"
|
||
disabled={isLoading}
|
||
/>
|
||
<TextField
|
||
fullWidth
|
||
value={mediaFilename}
|
||
onChange={(e) => setMediaFilename(e.target.value)}
|
||
label="Название файла"
|
||
disabled={isLoading}
|
||
/>
|
||
</Box>
|
||
|
||
<FormControl fullWidth sx={{ width: "50%" }}>
|
||
<InputLabel>Тип медиа</InputLabel>
|
||
<Select
|
||
value={
|
||
hardcodeType ? MEDIA_TYPE_VALUES[hardcodeType] : mediaType
|
||
}
|
||
disabled={!!hardcodeType}
|
||
label="Тип медиа"
|
||
onChange={(e) => setMediaType(Number(e.target.value))}
|
||
>
|
||
{availableMediaTypes.map((type) => (
|
||
<MenuItem key={type} value={type}>
|
||
{
|
||
MEDIA_TYPE_LABELS[
|
||
type as keyof typeof MEDIA_TYPE_LABELS
|
||
]
|
||
}
|
||
</MenuItem>
|
||
))}
|
||
</Select>
|
||
</FormControl>
|
||
|
||
<Box className="flex gap-4 h-[40vh]">
|
||
<Paper
|
||
elevation={2}
|
||
sx={{
|
||
flex: 1,
|
||
p: 2,
|
||
display: "flex",
|
||
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}
|
||
autoPlay
|
||
muted
|
||
loop
|
||
controls
|
||
style={{ maxWidth: "100%", maxHeight: "100%" }}
|
||
onLoadedData={() => setIsPreviewLoaded(true)}
|
||
onError={() => setIsPreviewLoaded(true)}
|
||
/>
|
||
)}
|
||
{mediaType === 6 && mediaUrl && (
|
||
<ModelViewer3D
|
||
fileUrl={mediaUrl}
|
||
height="100%"
|
||
onLoad={() => setIsPreviewLoaded(true)}
|
||
/>
|
||
)}
|
||
{mediaType !== 6 && mediaType !== 2 && mediaUrl && (
|
||
<img
|
||
src={mediaUrl ?? ""}
|
||
alt="Uploaded media"
|
||
style={{
|
||
height: "100%",
|
||
objectFit: "contain",
|
||
}}
|
||
onLoad={() => setIsPreviewLoaded(true)}
|
||
onError={() => setIsPreviewLoaded(true)}
|
||
/>
|
||
)}
|
||
</Paper>
|
||
|
||
<Box className="flex flex-col gap-2 self-end">
|
||
<Button
|
||
variant="contained"
|
||
sx={{
|
||
backgroundColor: isLoading ? "#9e9e9e" : "#4caf50",
|
||
"&:hover": {
|
||
backgroundColor: isLoading ? "#9e9e9e" : "#45a049",
|
||
},
|
||
}}
|
||
startIcon={
|
||
isLoading ? (
|
||
<CircularProgress size={16} color="inherit" />
|
||
) : (
|
||
<Save size={16} />
|
||
)
|
||
}
|
||
onClick={handleSave}
|
||
disabled={
|
||
isLoading ||
|
||
(!mediaName && !mediaFilename) ||
|
||
!isPreviewLoaded
|
||
}
|
||
>
|
||
{isLoading
|
||
? "Сохранение..."
|
||
: !isPreviewLoaded
|
||
? "Загрузка превью..."
|
||
: "Сохранить"}
|
||
</Button>
|
||
</Box>
|
||
</Box>
|
||
</Box>
|
||
</DialogContent>
|
||
<DialogActions>
|
||
<Button onClick={handleClose} disabled={isLoading}>
|
||
Отмена
|
||
</Button>
|
||
</DialogActions>
|
||
</Dialog>
|
||
|
||
<Snackbar
|
||
open={!!error}
|
||
autoHideDuration={6000}
|
||
onClose={() => setError(null)}
|
||
>
|
||
<Alert severity="error" onClose={() => setError(null)}>
|
||
{error}
|
||
</Alert>
|
||
</Snackbar>
|
||
|
||
<Snackbar
|
||
open={success}
|
||
autoHideDuration={3000}
|
||
onClose={() => setSuccess(false)}
|
||
>
|
||
<Alert severity="success" onClose={() => setSuccess(false)}>
|
||
Медиа успешно сохранено
|
||
</Alert>
|
||
</Snackbar>
|
||
</>
|
||
);
|
||
}
|
||
);
|