feat: Add more pages

This commit is contained in:
2025-06-06 16:08:15 +03:00
parent f2aab1ab33
commit d74789a0d8
67 changed files with 3491 additions and 787 deletions

View File

@@ -0,0 +1,89 @@
import {
Button,
Paper,
TextField,
Select,
MenuItem,
FormControl,
InputLabel,
} from "@mui/material";
import { mediaStore, MEDIA_TYPE_LABELS } from "@shared";
import { observer } from "mobx-react-lite";
import { ArrowLeft, Loader2, Save } from "lucide-react";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify";
export const MediaCreatePage = observer(() => {
const navigate = useNavigate();
const [name, setName] = useState("");
const [type, setType] = useState("");
const [isLoading, setIsLoading] = useState(false);
const handleCreate = async () => {
try {
setIsLoading(true);
await mediaStore.createMedia(name, type);
toast.success("Медиа успешно создано");
navigate("/media");
} catch (error) {
toast.error("Ошибка при создании медиа");
} finally {
setIsLoading(false);
}
};
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)}
/>
<FormControl fullWidth>
<InputLabel>Тип</InputLabel>
<Select
value={type}
label="Тип"
onChange={(e) => setType(e.target.value)}
required
>
{Object.entries(MEDIA_TYPE_LABELS).map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</Select>
</FormControl>
<Button
variant="contained"
color="primary"
className="w-min flex gap-2 items-center"
startIcon={<Save size={20} />}
onClick={handleCreate}
disabled={isLoading || !name || !type}
>
{isLoading ? (
<Loader2 size={20} className="animate-spin" />
) : (
"Создать"
)}
</Button>
</div>
</Paper>
);
});

View File

@@ -0,0 +1,295 @@
import { useEffect, useState, useRef } from "react";
import { observer } from "mobx-react-lite";
import { useNavigate, useParams } from "react-router-dom";
import {
Box,
Button,
CircularProgress,
FormControl,
InputLabel,
MenuItem,
Paper,
Select,
TextField,
Typography,
Alert,
Snackbar,
} from "@mui/material";
import { Save, ArrowLeft } from "lucide-react";
import { authInstance, mediaStore, MEDIA_TYPE_LABELS } from "@shared";
import { MediaViewer } from "@widgets";
export const MediaEditPage = observer(() => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const [newFile, setNewFile] = useState<File | null>(null);
const [uploadDialogOpen, setUploadDialogOpen] = useState(false); // State for the upload dialog
const media = id ? mediaStore.media.find((m) => m.id === id) : null;
const [mediaName, setMediaName] = useState(media?.media_name ?? "");
const [mediaFilename, setMediaFilename] = useState(media?.filename ?? "");
const [mediaType, setMediaType] = useState(media?.media_type ?? 1);
const [availableMediaTypes, setAvailableMediaTypes] = useState<number[]>([]);
useEffect(() => {
if (id) {
mediaStore.getOneMedia(id);
}
console.log(newFile);
console.log(uploadDialogOpen);
}, [id]);
useEffect(() => {
if (media) {
setMediaName(media.media_name);
setMediaFilename(media.filename);
setMediaType(media.media_type);
// Set available media types based on current file extension
const extension = media.filename.split(".").pop()?.toLowerCase();
if (extension) {
if (["glb", "gltf"].includes(extension)) {
setAvailableMediaTypes([6]); // 3D model
} else if (["jpg", "jpeg", "png", "gif"].includes(extension)) {
setAvailableMediaTypes([1, 3, 4, 5]); // Photo, Icon, Watermark, Panorama
} else if (["mp4", "webm", "mov"].includes(extension)) {
setAvailableMediaTypes([2]); // Video
}
}
}
}, [media]);
// const handleDrop = (e: DragEvent<HTMLDivElement>) => {
// e.preventDefault();
// e.stopPropagation();
// setIsDragging(false);
// const files = Array.from(e.dataTransfer.files);
// if (files.length > 0) {
// setNewFile(files[0]);
// setMediaFilename(files[0].name);
// setUploadDialogOpen(true); // Open dialog on file drop
// }
// };
// const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
// e.preventDefault();
// setIsDragging(true);
// };
// const handleDragLeave = () => {
// setIsDragging(false);
// };
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (files && files.length > 0) {
const file = files[0];
setNewFile(file);
setMediaFilename(file.name);
// Determine media type based on file extension
const extension = file.name.split(".").pop()?.toLowerCase();
if (extension) {
if (["glb", "gltf"].includes(extension)) {
setAvailableMediaTypes([6]); // 3D model
setMediaType(6);
} else if (["jpg", "jpeg", "png", "gif"].includes(extension)) {
setAvailableMediaTypes([1, 3, 4, 5]); // Photo, Icon, Watermark, Panorama
setMediaType(1); // Default to Photo
} else if (["mp4", "webm", "mov"].includes(extension)) {
setAvailableMediaTypes([2]); // Video
setMediaType(2);
}
}
setUploadDialogOpen(true); // Open dialog on file selection
}
};
const handleSave = async () => {
if (!id) return;
setIsLoading(true);
setError(null);
try {
await authInstance.patch(`/media/${id}`, {
media_name: mediaName,
filename: mediaFilename,
type: mediaType,
});
// If a new file was selected, the actual file upload will happen
// via the UploadMediaDialog. We just need to make sure the metadata
// is updated correctly before or after.
// Since the dialog handles the actual upload, we don't call updateMediaFile here.
setSuccess(true);
handleUploadSuccess();
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to save media");
} finally {
setIsLoading(false);
}
};
const handleUploadSuccess = () => {
// After successful upload in the dialog, refresh media data if needed
if (id) {
mediaStore.getOneMedia(id);
}
setNewFile(null); // Clear the new file state after successful upload
setUploadDialogOpen(false);
setSuccess(true);
};
if (!media && id) {
// Only show loading if an ID is present and media is not yet loaded
return (
<Box className="flex justify-center items-center h-screen">
<CircularProgress />
</Box>
);
}
return (
<Box className="p-6 max-w-4xl mx-auto">
<Box className="flex items-center gap-4 mb-6">
<Button
variant="outlined"
startIcon={<ArrowLeft size={20} />}
onClick={() => navigate("/media")}
>
Назад
</Button>
<Typography variant="h5">Редактирование медиа</Typography>
</Box>
<Paper className="p-6">
<input
type="file"
ref={fileInputRef}
className="hidden"
onChange={handleFileSelect}
accept="image/*,video/*,.glb,.gltf"
/>
<Box className="flex flex-col gap-6">
<Box className="flex gap-4">
<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={mediaType}
label="Тип медиа"
onChange={(e) => setMediaType(Number(e.target.value))}
disabled={isLoading}
>
{availableMediaTypes.length > 0
? availableMediaTypes.map((type) => (
<MenuItem key={type} value={type}>
{
MEDIA_TYPE_LABELS[
type as keyof typeof MEDIA_TYPE_LABELS
]
}
</MenuItem>
))
: Object.entries(MEDIA_TYPE_LABELS).map(([type, label]) => (
<MenuItem key={type} value={Number(type)}>
{label}
</MenuItem>
))}
</Select>
</FormControl>
<Box className="flex gap-6">
<Paper
elevation={2}
sx={{
flex: 1,
p: 2,
display: "flex",
alignItems: "center",
justifyContent: "center",
minHeight: 400,
}}
>
<MediaViewer
media={{
id: id || "",
media_type: mediaType,
filename: mediaFilename,
}}
/>
</Paper>
<Box className="flex flex-col gap-4 self-end">
<Button
variant="contained"
color="primary"
startIcon={<Save size={20} />}
onClick={handleSave}
disabled={isLoading || (!mediaName && !mediaFilename)}
>
{isLoading ? <CircularProgress size={20} /> : "Сохранить"}
</Button>
{/* Only show "Replace file" button if no new file is currently selected */}
</Box>
</Box>
{error && (
<Typography color="error" className="mt-2">
{error}
</Typography>
)}
{success && (
<Typography color="success.main" className="mt-2">
Медиа успешно сохранено
</Typography>
)}
</Box>
</Paper>
<Snackbar
open={!!error}
autoHideDuration={6000}
onClose={() => setError(null)}
>
<Alert severity="error" onClose={() => setError(null)}>
{error}
</Alert>
</Snackbar>
<Snackbar
open={success}
autoHideDuration={2000}
onClose={() => setSuccess(false)}
>
<Alert severity="success" onClose={() => setSuccess(false)}>
Медиа успешно сохранено
</Alert>
</Snackbar>
</Box>
);
});

View File

@@ -0,0 +1,110 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { languageStore, MEDIA_TYPE_LABELS, mediaStore } from "@shared";
import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite";
import { Eye, Trash2 } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets";
export const MediaListPage = observer(() => {
const { media, getMedia, deleteMedia } = mediaStore;
const navigate = useNavigate();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [rowId, setRowId] = useState<string | null>(null); // Lifted state
const { language } = languageStore;
useEffect(() => {
getMedia();
}, [language]);
const columns: GridColDef[] = [
{
field: "media_name",
headerName: "Название",
flex: 1,
},
{
field: "media_type",
headerName: "Тип",
width: 200,
flex: 1,
renderCell: (params: GridRenderCellParams) => {
return (
<p>
{
MEDIA_TYPE_LABELS[
params.row.media_type as keyof typeof MEDIA_TYPE_LABELS
]
}
</p>
);
},
},
{
field: "actions",
headerName: "Действия",
flex: 1,
align: "center",
headerAlign: "center",
renderCell: (params: GridRenderCellParams) => {
return (
<div className="flex h-full gap-7 justify-center items-center">
<button onClick={() => navigate(`/media/${params.row.id}`)}>
<Eye size={20} className="text-green-500" />
</button>
<button
onClick={() => {
setIsDeleteModalOpen(true);
setRowId(params.row.id);
}}
>
<Trash2 size={20} className="text-red-500" />
</button>
</div>
);
},
},
];
const rows = media.map((media) => ({
id: media.id,
media_name: media.media_name,
media_type: media.media_type,
}));
return (
<>
<LanguageSwitcher />
<div className="w-full">
<div className="flex justify-between items-center mb-10">
<h1 className="text-2xl">Медиа</h1>
<CreateButton label="Создать медиа" path="/media/create" />
</div>
<DataGrid
rows={rows}
columns={columns}
hideFooterPagination
hideFooter
/>
</div>
<DeleteModal
open={isDeleteModalOpen}
onDelete={async () => {
if (rowId) {
deleteMedia(rowId.toString());
getMedia();
}
setIsDeleteModalOpen(false);
setRowId(null);
}}
onCancel={() => {
setIsDeleteModalOpen(false);
setRowId(null);
}}
/>
</>
);
});

View File

@@ -0,0 +1,44 @@
import { mediaStore } from "@shared";
import { useEffect } from "react";
import { useParams } from "react-router-dom";
import { MediaViewer } from "@widgets";
import { observer } from "mobx-react-lite";
import { Download } from "lucide-react";
import { Button } from "@mui/material";
export const MediaPreviewPage = observer(() => {
const { id } = useParams();
const { oneMedia, getOneMedia } = mediaStore;
useEffect(() => {
getOneMedia(id!);
}, []);
return (
<div className="w-full h-[80vh] flex flex-col justify-center items-center gap-4">
<div className="w-full h-full flex justify-center items-center">
<MediaViewer className="w-full h-full" media={oneMedia!} />
</div>
{oneMedia && (
<div className="flex flex-col items-center gap-4 bg-[#998879] p-4 rounded-md">
<p className="text-white text-center">
Чтобы скачать файл, нажмите на кнопку ниже
</p>
<Button
variant="contained"
color="primary"
startIcon={<Download size={16} />}
component="a"
href={`${
import.meta.env.VITE_KRBL_MEDIA
}${id}/download?token=${localStorage.getItem("token")}`}
target="_blank"
>
Скачать
</Button>
</div>
)}
</div>
);
});

3
src/pages/Media/index.ts Normal file
View File

@@ -0,0 +1,3 @@
export * from "./MediaEditPage";
export * from "./MediaListPage";
export * from "./MediaPreviewPage";