All checks were successful
release-tag / release-image (push) Successful in 2m17s
Co-authored-by: itoshi <kkzemeow@gmail.com> Co-authored-by: Spynder <19329095+Spynder@users.noreply.github.com> Reviewed-on: #12 Co-authored-by: Alexander Lazarenko <kerblif@unprism.ru> Co-committed-by: Alexander Lazarenko <kerblif@unprism.ru>
398 lines
11 KiB
TypeScript
398 lines
11 KiB
TypeScript
import {
|
||
Typography,
|
||
Button,
|
||
Box,
|
||
Accordion,
|
||
AccordionSummary,
|
||
AccordionDetails,
|
||
useTheme,
|
||
TextField,
|
||
} from "@mui/material";
|
||
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||
import { axiosInstance } from "../providers/data";
|
||
import { useForm, Controller } from "react-hook-form";
|
||
import { MarkdownEditor } from "./MarkdownEditor";
|
||
import React, { useState, useCallback, useEffect } from "react";
|
||
import { useDropzone } from "react-dropzone";
|
||
import {
|
||
ALLOWED_IMAGE_TYPES,
|
||
ALLOWED_VIDEO_TYPES,
|
||
} from "../components/media/MediaFormUtils";
|
||
import { EVERY_LANGUAGE, Languages } from "@stores";
|
||
import { useNotification } from "@refinedev/core";
|
||
|
||
const MemoizedSimpleMDE = React.memo(MarkdownEditor);
|
||
|
||
type MediaFile = {
|
||
file: File;
|
||
preview: string;
|
||
uploading: boolean;
|
||
mediaId?: number;
|
||
};
|
||
|
||
type Props = {
|
||
parentId?: string | number;
|
||
parentResource: string;
|
||
childResource: string;
|
||
title: string;
|
||
left?: boolean;
|
||
language: Languages;
|
||
setHeadingParent?: (heading: string) => void;
|
||
setBodyParent?: (body: string) => void;
|
||
onSave?: (something: any) => void;
|
||
noReset?: boolean;
|
||
};
|
||
|
||
export const CreateSightArticle = ({
|
||
parentId,
|
||
parentResource,
|
||
childResource,
|
||
title,
|
||
left,
|
||
language,
|
||
setHeadingParent,
|
||
setBodyParent,
|
||
onSave,
|
||
noReset,
|
||
}: Props) => {
|
||
const notification = useNotification();
|
||
const theme = useTheme();
|
||
const [mediaFiles, setMediaFiles] = useState<MediaFile[]>([]);
|
||
const [workingLanguage, setWorkingLanguage] = useState<Languages>(language);
|
||
|
||
const {
|
||
register: registerItem,
|
||
watch,
|
||
control: controlItem,
|
||
handleSubmit: handleSubmitItem,
|
||
reset: resetItem,
|
||
setValue,
|
||
formState: { errors: itemErrors },
|
||
} = useForm({
|
||
defaultValues: {
|
||
heading: "",
|
||
body: "",
|
||
},
|
||
});
|
||
|
||
const [articleData, setArticleData] = useState({
|
||
heading: EVERY_LANGUAGE(""),
|
||
body: EVERY_LANGUAGE(""),
|
||
});
|
||
|
||
function updateTranslations() {
|
||
const newArticleData = {
|
||
...articleData,
|
||
heading: {
|
||
...articleData.heading,
|
||
[workingLanguage]: watch("heading") ?? "",
|
||
},
|
||
body: {
|
||
...articleData.body,
|
||
[workingLanguage]: watch("body") ?? "",
|
||
},
|
||
};
|
||
setArticleData(newArticleData);
|
||
return newArticleData;
|
||
}
|
||
|
||
useEffect(() => {
|
||
setValue("heading", articleData.heading[workingLanguage] ?? "");
|
||
setValue("body", articleData.body[workingLanguage] ?? "");
|
||
}, [workingLanguage, articleData, setValue]);
|
||
|
||
useEffect(() => {
|
||
updateTranslations();
|
||
setWorkingLanguage(language);
|
||
}, [language]);
|
||
|
||
useEffect(() => {
|
||
setHeadingParent?.(watch("heading"));
|
||
setBodyParent?.(watch("body"));
|
||
}, [watch("heading"), watch("body"), setHeadingParent, setBodyParent]);
|
||
|
||
const simpleMDEOptions = React.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/jpeg": [".jpeg", ".jpg"],
|
||
"image/png": [".png"],
|
||
"image/webp": [".webp"],
|
||
"video/mp4": [".mp4"],
|
||
"video/webm": [".webm"],
|
||
"video/ogg": [".ogg"],
|
||
},
|
||
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 handleCreate = async (data: { heading: string; body: string }) => {
|
||
try {
|
||
// Создаем статью
|
||
const response = await axiosInstance.post(
|
||
`${import.meta.env.VITE_KRBL_API}/${childResource}`,
|
||
{
|
||
...data,
|
||
translations: updateTranslations(),
|
||
}
|
||
);
|
||
const itemId = response.data.id;
|
||
|
||
if (parentId) {
|
||
// Получаем существующие статьи для определения порядкового номера
|
||
const existingItemsResponse = await axiosInstance.get(
|
||
`${
|
||
import.meta.env.VITE_KRBL_API
|
||
}/${parentResource}/${parentId}/${childResource}`
|
||
);
|
||
const existingItems = existingItemsResponse.data ?? [];
|
||
const nextPageNum = existingItems.length + 1;
|
||
|
||
if (!left) {
|
||
await axiosInstance.post(
|
||
`${
|
||
import.meta.env.VITE_KRBL_API
|
||
}/${parentResource}/${parentId}/${childResource}/`,
|
||
{
|
||
[`${childResource}_id`]: itemId,
|
||
page_num: nextPageNum,
|
||
}
|
||
);
|
||
} else {
|
||
const response = await axiosInstance.get(
|
||
`${import.meta.env.VITE_KRBL_API}/${parentResource}/${parentId}/`
|
||
);
|
||
const data = response.data;
|
||
if (data) {
|
||
await axiosInstance.patch(
|
||
`${import.meta.env.VITE_KRBL_API}/${parentResource}/${parentId}/`,
|
||
{
|
||
...data,
|
||
left_article: itemId,
|
||
}
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Загружаем все медиа файлы и получаем их ID
|
||
const mediaIds = await Promise.all(
|
||
mediaFiles.map(async (mediaFile) => {
|
||
return await uploadMedia(mediaFile);
|
||
})
|
||
);
|
||
|
||
// Привязываем все медиа к статье
|
||
await Promise.all(
|
||
mediaIds.map((mediaId, index) =>
|
||
axiosInstance.post(
|
||
`${import.meta.env.VITE_KRBL_API}/article/${itemId}/media/`,
|
||
{
|
||
media_id: mediaId,
|
||
media_order: index + 1,
|
||
}
|
||
)
|
||
)
|
||
);
|
||
if (noReset) {
|
||
setValue("heading", "");
|
||
setValue("body", "");
|
||
} else {
|
||
resetItem();
|
||
}
|
||
if (onSave) {
|
||
onSave(response.data);
|
||
if (notification && typeof notification.open === "function") {
|
||
notification.open({
|
||
message: "Статья успешно создана",
|
||
type: "success",
|
||
});
|
||
}
|
||
} else {
|
||
window.location.reload();
|
||
}
|
||
} catch (err: any) {
|
||
console.error("Error creating item:", err);
|
||
}
|
||
};
|
||
|
||
const removeMedia = (index: number) => {
|
||
setMediaFiles((prev) => {
|
||
const newFiles = [...prev];
|
||
URL.revokeObjectURL(newFiles[index].preview);
|
||
newFiles.splice(index, 1);
|
||
return newFiles;
|
||
});
|
||
};
|
||
|
||
return (
|
||
<Box>
|
||
<TextField
|
||
{...registerItem("heading", {
|
||
required: "Это поле является обязательным",
|
||
})}
|
||
error={!!(itemErrors as any)?.heading}
|
||
helperText={(itemErrors as any)?.heading?.message}
|
||
margin="normal"
|
||
fullWidth
|
||
slotProps={{ inputLabel: { shrink: true } }}
|
||
type="text"
|
||
sx={{
|
||
backgroundColor: theme.palette.background.paper,
|
||
}}
|
||
label="Заголовок *"
|
||
/>
|
||
|
||
<Controller
|
||
control={controlItem}
|
||
name="body"
|
||
rules={{ required: "Это поле является обязательным" }}
|
||
defaultValue=""
|
||
render={({ field: { onChange, value } }) => (
|
||
<MemoizedSimpleMDE
|
||
value={value}
|
||
onChange={onChange}
|
||
options={simpleMDEOptions}
|
||
className="my-markdown-editor"
|
||
/>
|
||
)}
|
||
/>
|
||
|
||
{/* 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>
|
||
|
||
<Box sx={{ mt: 2, display: "flex", gap: 2 }}>
|
||
<Button
|
||
variant="contained"
|
||
color="primary"
|
||
type="submit"
|
||
onClick={handleSubmitItem(handleCreate)}
|
||
>
|
||
Создать
|
||
</Button>
|
||
<Button
|
||
variant="outlined"
|
||
onClick={() => {
|
||
resetItem();
|
||
mediaFiles.forEach((file) => URL.revokeObjectURL(file.preview));
|
||
setMediaFiles([]);
|
||
}}
|
||
>
|
||
Очистить
|
||
</Button>
|
||
</Box>
|
||
</Box>
|
||
);
|
||
};
|