Files
WhiteNightsAdminPanel/src/widgets/SightTabs/CreateRightTab/index.tsx

639 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
Box,
Button,
Typography,
TextField,
Slider,
Stack,
} from "@mui/material";
import {
BackButton,
createSightStore,
editSightStore,
languageStore,
TabPanel,
SelectMediaDialog,
UploadMediaDialog,
Media,
} from "@shared";
import {
LanguageSwitcher,
MediaArea,
MediaAreaForSight,
ReactMarkdownEditor,
DeleteModal,
} from "@widgets";
import { Plus, Save, Trash2, Unlink, X } from "lucide-react";
import { observer } from "mobx-react-lite";
import { useState, useEffect, useRef } from "react";
import { useNavigate } from "react-router-dom";
import { MediaViewer } from "../../MediaViewer/index";
import { toast } from "react-toastify";
import { authInstance } from "@shared";
import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd";
import { SightFramePreview } from "../RightWidgetTab/SightFramePreview";
type MediaItemShared = {
id: string;
filename: string;
media_name?: string;
media_type: number;
};
export const CreateRightTab = observer(
({ value, index }: { value: number; index: number }) => {
const {
sight,
createNewRightArticle,
updateRightArticleInfo,
linkPreviewMedia,
unlinkPreviewMedia,
createLinkWithRightArticle,
deleteRightArticleMedia,
unlinkRightAritcle,
deleteRightArticle,
createSight,
updateRightArticles,
updateSightInfo,
} = createSightStore;
const { language } = languageStore;
const navigate = useNavigate();
const { setFileToUpload, setUploadMediaOpen, uploadMediaOpen } =
editSightStore;
const [activeArticleIndex, setActiveArticleIndex] = useState<number | null>(
null,
);
const [type, setType] = useState<"article" | "media">("media");
const [previewSection, setPreviewSection] = useState<number>(-1);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isSelectMediaDialogOpen, setIsSelectMediaDialogOpen] =
useState(false);
const [mediaTarget, setMediaTarget] = useState<
"sightPreview" | "rightArticle" | null
>(null);
const [previewMedia, setPreviewMedia] = useState<Media | null>(null);
const shortNameRef = useRef<HTMLTextAreaElement | null>(null);
const insertNewline = () => {
const input = shortNameRef.current;
const currentValue = sight[language].name || "";
if (!input) {
updateSightInfo({ name: currentValue + "\n" }, language);
return;
}
const start = input.selectionStart ?? currentValue.length;
const end = input.selectionEnd ?? start;
const newValue =
currentValue.slice(0, start) + "\n" + currentValue.slice(end);
updateSightInfo({ name: newValue }, language);
requestAnimationFrame(() => {
if (shortNameRef.current) {
shortNameRef.current.selectionStart = start + 1;
shortNameRef.current.selectionEnd = start + 1;
shortNameRef.current.focus();
}
});
};
useEffect(() => {
if (sight.preview_media) {
const fetchMedia = async () => {
const response = await authInstance.get(
`/media/${sight.preview_media}`,
);
setPreviewMedia(response.data);
};
fetchMedia();
}
}, [sight.preview_media]);
useEffect(() => {
if (
activeArticleIndex !== null &&
activeArticleIndex >= sight[language].right.length
) {
setActiveArticleIndex(null);
setType("media");
setPreviewSection(-1);
}
}, [language, sight[language].right, activeArticleIndex]);
const handleSave = async () => {
try {
const newSightId = await createSight(language);
console.log("[handleSave] newSightId:", newSightId, "needLeaveAgree:", createSightStore.needLeaveAgree);
toast.success("Достопримечательность успешно создана!");
navigate(`/sight/${newSightId}/edit`);
console.log("[handleSave] navigate called to:", `/sight/${newSightId}/edit`);
} catch (error) {
console.error("Failed to save sight:", error);
toast.error("Ошибка при создании достопримечательности.");
}
};
const handleDisplayArticleFromList = (idx: number) => {
setActiveArticleIndex(idx);
setType("article");
setPreviewSection(idx);
};
const handleCreateNewLocalArticle = async () => {
try {
const newArticleId = await createNewRightArticle();
const newIndex = sight[language].right.findIndex(
(a) => a.id === newArticleId,
);
const resolvedIndex =
newIndex > -1 ? newIndex : sight[language].right.length - 1;
setActiveArticleIndex(resolvedIndex);
setType("article");
setPreviewSection(resolvedIndex);
} catch (error) {
toast.error("Не удалось создать новую статью.");
}
};
const currentRightArticle =
activeArticleIndex !== null && sight[language].right[activeArticleIndex]
? sight[language].right[activeArticleIndex]
: null;
const handleOpenUploadMedia = () => {
setUploadMediaOpen(true);
};
const handleOpenSelectMediaDialog = (
target: "sightPreview" | "rightArticle",
) => {
setMediaTarget(target);
setIsSelectMediaDialogOpen(true);
};
const handleMediaSelectedFromDialog = async (media: MediaItemShared) => {
setIsSelectMediaDialogOpen(false);
if (mediaTarget === "sightPreview") {
await linkPreviewMedia(media.id);
} else if (mediaTarget === "rightArticle" && currentRightArticle) {
await createLinkWithRightArticle(media, currentRightArticle.id);
}
setMediaTarget(null);
};
const handleUnlinkPreviewMedia = async () => {
await unlinkPreviewMedia();
setPreviewMedia(null);
};
const handleMediaUploaded = async (media: MediaItemShared) => {
setUploadMediaOpen(false);
setFileToUpload(null);
if (mediaTarget === "sightPreview") {
linkPreviewMedia(media.id);
} else if (mediaTarget === "rightArticle" && currentRightArticle) {
await createLinkWithRightArticle(media, currentRightArticle.id);
}
setMediaTarget(null);
};
const handleDragEnd = (result: any) => {
const { source, destination } = result;
if (!destination) return;
const sourceIndex = source.index;
const destinationIndex = destination.index;
if (sourceIndex === destinationIndex) return;
const newRightArticles = [...sight[language].right];
const [movedArticle] = newRightArticles.splice(sourceIndex, 1);
newRightArticles.splice(destinationIndex, 0, movedArticle);
updateRightArticles(newRightArticles);
};
return (
<TabPanel value={value} index={index}>
<LanguageSwitcher />
<Box
sx={{
display: "flex",
flexDirection: "column",
height: "100%",
minHeight: "calc(100vh - 200px)",
gap: 2,
paddingBottom: "70px",
position: "relative",
}}
>
<div className="flex gap-10 items-center mb-5 max-w-[80%]">
<BackButton />
<h1 className="text-3xl break-words">{sight[language].name}</h1>
</div>
<Box sx={{ display: "flex", flexGrow: 1, gap: 2.5 }}>
<Box
className="flex flex-col gap-2"
sx={{ flexGrow: 1, minWidth: 0 }}
>
<Box className="w-full flex gap-2">
<Box className="relative w-[20%] h-[70vh] flex flex-col rounded-2xl overflow-y-auto gap-3 border border-gray-300 p-3">
<Box className="flex flex-col gap-3 max-h-[60vh] overflow-y-auto">
<Box
onClick={() => {
setType("media");
setPreviewSection(-1);
}}
className={`w-full p-4 rounded-2xl cursor-pointer text-sm transition-all duration-300 ${
type === "media"
? "bg-blue-400 text-white"
: "bg-gray-200 hover:bg-gray-300"
}`}
>
<Typography>Предпросмотр медиа</Typography>
</Box>
<Box>
<DragDropContext onDragEnd={handleDragEnd}>
<Droppable droppableId="articles">
{(provided) => (
<Box
ref={provided.innerRef}
{...provided.droppableProps}
className="flex flex-col gap-2"
>
{sight[language].right.length > 0
? sight[language].right.map(
(article, index) => (
<Draggable
key={article.id.toString()}
draggableId={article.id.toString()}
index={index}
>
{(provided, snapshot) => (
<Box
ref={provided.innerRef}
{...provided.draggableProps}
className={`w-full p-4 rounded-2xl text-sm cursor-pointer transition-all duration-300 ${
snapshot.isDragging
? "shadow-lg bg-gray-200"
: activeArticleIndex ===
index &&
type === "article"
? "bg-blue-400 text-white"
: "bg-gray-200 hover:bg-gray-300"
}`}
onClick={() => {
handleDisplayArticleFromList(
index,
);
}}
>
<Box {...provided.dragHandleProps}>
<Typography>
{article.heading}
</Typography>
</Box>
</Box>
)}
</Draggable>
),
)
: null}
{provided.placeholder}
</Box>
)}
</Droppable>
</DragDropContext>
</Box>
</Box>
<button
className="w-10 h-10 bg-blue-500 rounded-full absolute bottom-5 left-5 flex items-center justify-center hover:bg-blue-600"
onClick={handleCreateNewLocalArticle}
>
<Plus size={20} color="white" />
</button>
</Box>
{type === "article" && currentRightArticle ? (
<Box className="w-[80%] border border-gray-300 p-3 flex flex-col gap-2 overflow-hidden">
<Box className="flex justify-end gap-2 mb-1 flex-shrink-0">
<Button
variant="contained"
color="primary"
size="small"
startIcon={<Unlink color="white" size={18} />}
onClick={() => {
if (currentRightArticle) {
unlinkRightAritcle(currentRightArticle.id);
setActiveArticleIndex(null);
setType("media");
setPreviewSection(-1);
}
}}
>
Открепить
</Button>
<Button
variant="contained"
color="error"
size="small"
startIcon={<Trash2 size={18} />}
onClick={() => setIsDeleteModalOpen(true)}
>
Удалить
</Button>
</Box>
<Box
sx={{
flexGrow: 1,
display: "flex",
flexDirection: "column",
gap: 2,
overflowY: "auto",
pt: 7,
}}
>
<TextField
label="Название информации (правый виджет)"
value={currentRightArticle.heading}
onChange={(e) =>
activeArticleIndex !== null &&
updateRightArticleInfo(
activeArticleIndex,
language,
e.target.value,
currentRightArticle.body,
)
}
variant="outlined"
fullWidth
/>
<Box sx={{ minHeight: 200, flexGrow: 1 }}>
<ReactMarkdownEditor
value={currentRightArticle.body}
onChange={(mdValue: any) =>
activeArticleIndex !== null &&
updateRightArticleInfo(
activeArticleIndex,
language,
currentRightArticle.heading,
mdValue || "",
)
}
/>
</Box>
<MediaArea
articleId={currentRightArticle.id}
mediaIds={currentRightArticle.media || []}
onFilesDrop={(files) => {
if (files.length > 0) {
setFileToUpload(files[0]);
setMediaTarget("rightArticle");
handleOpenUploadMedia();
}
}}
deleteMedia={deleteRightArticleMedia}
setSelectMediaDialogOpen={() =>
handleOpenSelectMediaDialog("rightArticle")
}
/>
</Box>
</Box>
) : (
<Box className="w-[80%] border border-gray-300 rounded-2xl relative flex justify-center items-center">
{sight.preview_media && (
<Box className="w-full h-full rounded-2xl relative flex items-center justify-center">
{previewMedia && (
<>
<Box className="absolute top-4 right-4 z-10">
<button
className="w-10 h-10 flex items-center justify-center z-10"
onClick={handleUnlinkPreviewMedia}
>
<X size={20} color="red" />
</button>
</Box>
<Box className="w-1/2 h-1/2 flex justify-center items-center">
<MediaViewer
media={{
id: previewMedia.id || "",
media_type: previewMedia.media_type,
filename: previewMedia.filename || "",
}}
fullWidth
fullHeight
/>
</Box>
</>
)}
</Box>
)}
{!sight.preview_media && (
<Box className="w-full h-full flex justify-center items-center">
<Box
sx={{
maxWidth: "500px",
maxHeight: "100%",
display: "flex",
flexGrow: 1,
margin: "0 auto",
justifyContent: "center",
}}
>
<MediaAreaForSight
onFinishUpload={(mediaId) => {
linkPreviewMedia(mediaId);
}}
onFilesDrop={() => {}}
contextObjectName={sight[language].name}
contextType="sight"
isArticle={false}
/>
</Box>
</Box>
)}
</Box>
)}
</Box>
</Box>
<Box
sx={{
flexShrink: 0,
width: "550px",
display: "flex",
flexDirection: "column",
gap: 1,
}}
>
{type === "media" && (
<Stack direction="row" spacing={2} alignItems="center">
<TextField
type="number"
label="Размер шрифта превью (px)"
size="small"
value={sight.preview_font_size ?? ""}
onChange={(e) => {
const raw = e.target.value;
if (raw === "") {
updateSightInfo({ preview_font_size: undefined });
return;
}
const val = Math.max(
1,
Math.min(300, Math.round(Number(raw))),
);
if (Number.isFinite(val)) {
updateSightInfo({ preview_font_size: val });
}
}}
slotProps={{ input: { min: 1, max: 300 } }}
sx={{ width: "200px" }}
/>
<Slider
value={sight.preview_font_size ?? 40}
min={1}
max={300}
step={1}
onChange={(_, newValue) => {
if (typeof newValue === "number") {
updateSightInfo({ preview_font_size: newValue });
}
}}
sx={{ flexGrow: 1 }}
/>
</Stack>
)}
<Stack direction="row" spacing={1} alignItems="flex-start">
<TextField
label="Полное название (поддерживает перенос ↵)"
multiline
minRows={1}
maxRows={4}
size="small"
value={sight[language].name}
onChange={(e) =>
updateSightInfo({ name: e.target.value }, language)
}
inputRef={shortNameRef}
sx={{ flexGrow: 1 }}
/>
<Button
variant="outlined"
size="small"
onClick={insertNewline}
title="Вставить перенос строки"
sx={{
minWidth: 40,
height: 40,
fontSize: 18,
p: 0,
flexShrink: 0,
}}
>
</Button>
</Stack>
<SightFramePreview
sightName={sight[language].name}
previewMedia={previewMedia}
articles={sight[language].right}
onArticleSelect={(idx) => {
handleDisplayArticleFromList(idx);
}}
previewFontSize={sight.preview_font_size}
selectedSection={previewSection}
onSectionChange={(section) => {
setPreviewSection(section);
if (section === -1) {
setType("media");
} else {
handleDisplayArticleFromList(section);
}
}}
/>
</Box>
</Box>
<Box
sx={{
position: "absolute",
bottom: "-20px",
left: 0,
right: 0,
padding: 2,
backgroundColor: "background.paper",
width: "100%",
display: "flex",
justifyContent: "flex-end",
}}
>
<Button
variant="contained"
color="success"
onClick={handleSave}
size="large"
startIcon={<Save color="white" size={18} />}
>
Сохранить
</Button>
</Box>
</Box>
<UploadMediaDialog
open={uploadMediaOpen}
onClose={() => {
setUploadMediaOpen(false);
setFileToUpload(null);
setMediaTarget(null);
}}
contextObjectName={sight[language].name}
contextType="sight"
isArticle={mediaTarget === "rightArticle"}
articleName={
mediaTarget === "rightArticle" && activeArticleIndex !== null
? sight[language].right[activeArticleIndex].heading
: undefined
}
afterUpload={handleMediaUploaded}
/>
<SelectMediaDialog
open={isSelectMediaDialogOpen}
onClose={() => {
setIsSelectMediaDialogOpen(false);
setMediaTarget(null);
}}
onSelectMedia={handleMediaSelectedFromDialog}
/>
<DeleteModal
open={isDeleteModalOpen}
onDelete={async () => {
try {
const idx = activeArticleIndex ?? 0;
await deleteRightArticle(currentRightArticle?.id || 0);
setIsDeleteModalOpen(false);
if (idx > 0) {
setActiveArticleIndex(idx - 1);
setPreviewSection(idx - 1);
setType("article");
} else {
setActiveArticleIndex(null);
setPreviewSection(-1);
setType("media");
}
toast.success("Статья удалена");
} catch {
toast.error("Не удалось удалить статью");
}
}}
onCancel={() => setIsDeleteModalOpen(false)}
/>
</TabPanel>
);
},
);