408 lines
12 KiB
TypeScript
408 lines
12 KiB
TypeScript
import { Modal, Box, Button, TextField, Typography } from "@mui/material";
|
||
import { articleStore } from "../../../store/ArticleStore";
|
||
import { observer } from "mobx-react-lite";
|
||
import { useForm } from "@refinedev/react-hook-form";
|
||
import { Controller } from "react-hook-form";
|
||
import "easymde/dist/easymde.min.css";
|
||
import { memo, useMemo, useEffect, useCallback, useState } from "react";
|
||
import { MarkdownEditor } from "../../MarkdownEditor";
|
||
import { Edit } from "@refinedev/mui";
|
||
import { EVERY_LANGUAGE, languageStore } from "../../../store/LanguageStore";
|
||
import { LanguageSwitch } from "../../LanguageSwitch/index";
|
||
import { useDropzone } from "react-dropzone";
|
||
import {
|
||
ALLOWED_IMAGE_TYPES,
|
||
ALLOWED_VIDEO_TYPES,
|
||
} from "../../media/MediaFormUtils";
|
||
import { TOKEN_KEY, axiosInstance } from "@providers";
|
||
import { LinkedItems } from "../../../components/LinkedItems";
|
||
import { mediaFields, MediaItem } from "../../../pages/article/types";
|
||
|
||
const MemoizedSimpleMDE = memo(MarkdownEditor);
|
||
|
||
type MediaFile = {
|
||
file: File;
|
||
preview: string;
|
||
uploading: boolean;
|
||
media_id?: number;
|
||
};
|
||
|
||
const style = {
|
||
marginLeft: "auto",
|
||
marginRight: "auto",
|
||
//position: "absolute",
|
||
//top: "50%",
|
||
//left: "50%",
|
||
//transform: "translate(-50%, -50%)",
|
||
width: "60%",
|
||
bgcolor: "background.paper",
|
||
border: "2px solid #000",
|
||
boxShadow: 24,
|
||
p: 4
|
||
};
|
||
|
||
export const ArticleEditModal = observer(() => {
|
||
const { language } = languageStore;
|
||
const [articleData, setArticleData] = useState({
|
||
heading: EVERY_LANGUAGE(language),
|
||
body: EVERY_LANGUAGE(language),
|
||
});
|
||
const { articleModalOpen, setArticleModalOpenAction, selectedArticleId } =
|
||
articleStore;
|
||
|
||
const [mediaFiles, setMediaFiles] = useState<MediaFile[]>([]);
|
||
|
||
const [refresh, setRefresh] = useState(0);
|
||
|
||
useEffect(() => {
|
||
return () => {
|
||
setArticleModalOpenAction(false);
|
||
};
|
||
}, []);
|
||
|
||
// Load existing media files when editing an article
|
||
const loadExistingMedia = async () => {
|
||
console.log("Called loadExistingMedia")
|
||
if (selectedArticleId) {
|
||
try {
|
||
const response = await axiosInstance.get(
|
||
`${
|
||
import.meta.env.VITE_KRBL_API
|
||
}/article/${selectedArticleId}/media`
|
||
);
|
||
const existingMedia = response.data;
|
||
|
||
// Convert existing media to MediaFile format
|
||
const mediaFiles = await Promise.all(
|
||
existingMedia.map(async (media: any) => {
|
||
const response = await fetch(
|
||
`${import.meta.env.VITE_KRBL_MEDIA}${
|
||
media.id
|
||
}/download?token=${localStorage.getItem(TOKEN_KEY)}`
|
||
);
|
||
const blob = await response.blob();
|
||
const file = new File([blob], media.filename, {
|
||
type: media.media_type === 1 ? "image/jpeg" : "video/mp4",
|
||
});
|
||
|
||
return {
|
||
file,
|
||
preview: URL.createObjectURL(blob),
|
||
uploading: false,
|
||
mediaId: media.id,
|
||
};
|
||
})
|
||
);
|
||
|
||
setMediaFiles(mediaFiles);
|
||
setRefresh(refresh+1);
|
||
} catch (error) {
|
||
console.error("Error loading existing media:", error);
|
||
}
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
loadExistingMedia();
|
||
}, [selectedArticleId]);
|
||
|
||
const {
|
||
register,
|
||
control,
|
||
formState: { errors },
|
||
saveButtonProps,
|
||
reset,
|
||
setValue,
|
||
watch,
|
||
} = useForm({
|
||
refineCoreProps: {
|
||
resource: "article",
|
||
id: selectedArticleId ?? undefined,
|
||
action: "edit",
|
||
redirect: false,
|
||
|
||
onMutationSuccess: async () => {
|
||
try {
|
||
// Upload new media files
|
||
const newMediaFiles = mediaFiles.filter((file) => !file.media_id);
|
||
const existingMediaAmount = mediaFiles.filter((file) => file.media_id).length;
|
||
const mediaIds = await Promise.all(
|
||
newMediaFiles.map(async (mediaFile) => {
|
||
return await uploadMedia(mediaFile);
|
||
})
|
||
);
|
||
|
||
// Associate all media with the article
|
||
await Promise.all(
|
||
mediaIds.map((mediaId, index) =>
|
||
axiosInstance.post(
|
||
`${
|
||
import.meta.env.VITE_KRBL_API
|
||
}/article/${selectedArticleId}/media/`,
|
||
{
|
||
media_id: mediaId,
|
||
media_order: index + existingMediaAmount + 1,
|
||
}
|
||
)
|
||
)
|
||
);
|
||
|
||
setArticleModalOpenAction(false);
|
||
reset();
|
||
window.location.reload();
|
||
} catch (error) {
|
||
console.error("Error handling media:", error);
|
||
}
|
||
},
|
||
meta: {
|
||
headers: {
|
||
"Accept-Language": language,
|
||
},
|
||
},
|
||
},
|
||
});
|
||
|
||
useEffect(() => {
|
||
if (articleData.heading[language]) {
|
||
setValue("heading", articleData.heading[language])
|
||
}
|
||
if (articleData.body[language]) {
|
||
setValue("body", articleData.body[language])
|
||
}
|
||
}, [language, articleData, setValue]);
|
||
|
||
const handleLanguageChange = () => {
|
||
setArticleData((prevData) => ({
|
||
...prevData,
|
||
heading: {
|
||
...prevData.heading,
|
||
[language]: watch("heading") ?? ""
|
||
},
|
||
body: {
|
||
...prevData.body,
|
||
[language]: watch("body") ?? ""
|
||
}
|
||
}));
|
||
};
|
||
|
||
const simpleMDEOptions = useMemo(
|
||
() => ({
|
||
placeholder: "Введите контент в формате Markdown...",
|
||
spellChecker: false,
|
||
}),
|
||
[]
|
||
);
|
||
|
||
const onDrop = useCallback((acceptedFiles: File[]) => {
|
||
const newFiles = acceptedFiles.map((file) => ({
|
||
file,
|
||
preview: URL.createObjectURL(file),
|
||
uploading: false,
|
||
}));
|
||
setMediaFiles((prev) => [...prev, ...newFiles]);
|
||
}, []);
|
||
|
||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||
onDrop,
|
||
accept: {
|
||
"image/*": ALLOWED_IMAGE_TYPES,
|
||
"video/*": ALLOWED_VIDEO_TYPES,
|
||
},
|
||
multiple: true,
|
||
});
|
||
|
||
const uploadMedia = async (mediaFile: MediaFile) => {
|
||
const formData = new FormData();
|
||
formData.append("media_name", mediaFile.file.name);
|
||
formData.append("filename", mediaFile.file.name);
|
||
formData.append(
|
||
"type",
|
||
mediaFile.file.type.startsWith("image/") ? "1" : "2"
|
||
);
|
||
formData.append("file", mediaFile.file);
|
||
|
||
const response = await axiosInstance.post(
|
||
`${import.meta.env.VITE_KRBL_API}/media`,
|
||
formData
|
||
);
|
||
return response.data.id;
|
||
};
|
||
|
||
const removeMedia = async (index: number) => {
|
||
const mediaFile = mediaFiles[index];
|
||
|
||
// If it's an existing media file (has mediaId), delete it from the server
|
||
if (mediaFile.media_id) {
|
||
try {
|
||
await axiosInstance.delete(
|
||
`${import.meta.env.VITE_KRBL_API}/media/${mediaFile.media_id}`
|
||
);
|
||
} catch (error) {
|
||
console.error("Error deleting media:", error);
|
||
return; // Don't remove from UI if server deletion failed
|
||
}
|
||
}
|
||
|
||
// Remove from UI and cleanup
|
||
setMediaFiles((prev) => {
|
||
const newFiles = [...prev];
|
||
URL.revokeObjectURL(newFiles[index].preview);
|
||
newFiles.splice(index, 1);
|
||
return newFiles;
|
||
});
|
||
};
|
||
|
||
return (
|
||
<Modal
|
||
open={articleModalOpen}
|
||
onClose={() => setArticleModalOpenAction(false)}
|
||
aria-labelledby="modal-modal-title"
|
||
aria-describedby="modal-modal-description"
|
||
sx={{overflow: "auto"}}
|
||
>
|
||
<Box sx={style}>
|
||
<Edit
|
||
title={<Typography variant="h5">Редактирование статьи</Typography>}
|
||
headerProps={{
|
||
sx: {
|
||
fontSize: "50px",
|
||
},
|
||
}}
|
||
saveButtonProps={saveButtonProps}
|
||
>
|
||
<LanguageSwitch action={handleLanguageChange} />
|
||
<Box
|
||
component="form"
|
||
sx={{ display: "flex", flexDirection: "column" }}
|
||
autoComplete="off"
|
||
>
|
||
<TextField
|
||
{...register("heading", {
|
||
required: "Это поле является обязательным",
|
||
})}
|
||
error={!!errors.heading}
|
||
helperText={errors.heading?.message as string}
|
||
margin="normal"
|
||
fullWidth
|
||
InputLabelProps={{ shrink: true }}
|
||
type="text"
|
||
name="heading"
|
||
label="Заголовок *"
|
||
/>
|
||
|
||
<Controller
|
||
control={control}
|
||
name="body"
|
||
rules={{ required: "Это поле является обязательным" }}
|
||
defaultValue=""
|
||
render={({ field: { onChange, value } }) => (
|
||
<MemoizedSimpleMDE
|
||
value={value}
|
||
onChange={onChange}
|
||
options={simpleMDEOptions}
|
||
className="my-markdown-editor"
|
||
/>
|
||
)}
|
||
/>
|
||
|
||
{selectedArticleId && (
|
||
<LinkedItems<MediaItem>
|
||
type="edit"
|
||
parentId={selectedArticleId}
|
||
parentResource="article"
|
||
childResource="media"
|
||
fields={mediaFields}
|
||
title="медиа"
|
||
dontRecurse
|
||
onUpdate={loadExistingMedia}
|
||
/>
|
||
)}
|
||
</Box>
|
||
|
||
{/* Dropzone для медиа файлов */}
|
||
<Box sx={{ mt: 2, mb: 2 }}>
|
||
<Box
|
||
{...getRootProps()}
|
||
sx={{
|
||
border: "2px dashed",
|
||
borderColor: isDragActive ? "primary.main" : "grey.300",
|
||
borderRadius: 1,
|
||
p: 2,
|
||
textAlign: "center",
|
||
cursor: "pointer",
|
||
"&:hover": {
|
||
borderColor: "primary.main",
|
||
},
|
||
}}
|
||
>
|
||
<input {...getInputProps()} />
|
||
<Typography>
|
||
{isDragActive
|
||
? "Перетащите файлы сюда..."
|
||
: "Перетащите файлы сюда или кликните для выбора"}
|
||
</Typography>
|
||
</Box>
|
||
|
||
{/* Превью загруженных файлов */}
|
||
<Box sx={{ mt: 2, display: "flex", flexWrap: "wrap", gap: 1 }}>
|
||
{mediaFiles.map((mediaFile, index) => (
|
||
<Box
|
||
key={mediaFile.preview}
|
||
sx={{
|
||
position: "relative",
|
||
width: 100,
|
||
height: 100,
|
||
}}
|
||
>
|
||
{mediaFile.file.type.startsWith("image/") ? (
|
||
<img
|
||
src={mediaFile.preview}
|
||
alt={mediaFile.file.name}
|
||
style={{
|
||
width: "100%",
|
||
height: "100%",
|
||
objectFit: "cover",
|
||
}}
|
||
/>
|
||
) : (
|
||
<Box
|
||
sx={{
|
||
width: "100%",
|
||
height: "100%",
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
bgcolor: "grey.200",
|
||
}}
|
||
>
|
||
<Typography variant="caption">
|
||
{mediaFile.file.name}
|
||
</Typography>
|
||
</Box>
|
||
)}
|
||
<Button
|
||
size="small"
|
||
color="error"
|
||
onClick={() => removeMedia(index)}
|
||
sx={{
|
||
position: "absolute",
|
||
top: 0,
|
||
right: 0,
|
||
minWidth: "auto",
|
||
width: 20,
|
||
height: 20,
|
||
p: 0,
|
||
}}
|
||
>
|
||
×
|
||
</Button>
|
||
</Box>
|
||
))}
|
||
</Box>
|
||
</Box>
|
||
</Edit>
|
||
</Box>
|
||
</Modal>
|
||
);
|
||
});
|