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

587 lines
22 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 {
authInstance,
BackButton,
editSightStore,
languageStore,
SelectMediaDialog,
TabPanel,
UploadMediaDialog,
} from "@shared";
import {
DeleteModal,
LanguageSwitcher,
MediaArea,
MediaAreaForSight,
ReactMarkdownEditor,
} from "@widgets";
import { Plus, Save, Trash2, Unlink, X } from "lucide-react";
import { observer } from "mobx-react-lite";
import { useEffect, useRef, useState } from "react";
import { toast } from "react-toastify";
import { MediaViewer } from "../../MediaViewer/index";
import {
DragDropContext,
Droppable,
Draggable,
DropResult,
} from "@hello-pangea/dnd";
import { SightFramePreview } from "./SightFramePreview";
export const RightWidgetTab = observer(
({ value, index }: { value: number; index: number }) => {
const {
sight,
updateRightArticleInfo,
getRightArticles,
updateSight,
updateSightInfo,
unlinkPreviewMedia,
linkPreviewMedia,
unlinkRightArticle,
deleteRightArticle,
deleteRightArticleMedia,
createLinkWithRightArticle,
setFileToUpload,
createNewRightArticle,
updateRightArticles,
} = editSightStore;
const [previewMedia, setPreviewMedia] = useState<any | null>(null);
const shortNameRef = useRef<HTMLTextAreaElement | null>(null);
const insertNewline = () => {
const input = shortNameRef.current;
const currentValue = sight[language].name || "";
if (!input) {
updateSightInfo(language, { name: currentValue + "\n" });
return;
}
const start = input.selectionStart ?? currentValue.length;
const end = input.selectionEnd ?? start;
const newValue = currentValue.slice(0, start) + "\n" + currentValue.slice(end);
updateSightInfo(language, { name: newValue });
requestAnimationFrame(() => {
if (shortNameRef.current) {
shortNameRef.current.selectionStart = start + 1;
shortNameRef.current.selectionEnd = start + 1;
shortNameRef.current.focus();
}
});
};
useEffect(() => {
const fetchPreviewMedia = async () => {
if (sight.common.preview_media) {
const response = await authInstance.get(
`/media/${sight.common.preview_media}`
);
setPreviewMedia(response.data);
}
};
fetchPreviewMedia();
}, [sight.common.preview_media]);
const handleUnlinkPreviewMedia = () => {
unlinkPreviewMedia();
setPreviewMedia(null);
};
const [uploadMediaOpen, setUploadMediaOpen] = useState(false);
const { language } = languageStore;
const [type, setType] = useState<"article" | "media">("media");
useEffect(() => {
const fetchData = async () => {
if (sight.common.id) {
await getRightArticles(sight.common.id);
}
};
fetchData();
}, [sight.common.id]);
const [activeArticleIndex, setActiveArticleIndex] = useState<number | null>(
null
);
const [previewSection, setPreviewSection] = useState<number>(-1);
const [isSelectMediaModalOpen, setIsSelectMediaModalOpen] = useState(false);
const [isDeleteArticleModalOpen, setIsDeleteArticleModalOpen] =
useState(false);
const handleDeleteArticle = () => {
const idx = activeArticleIndex || 0;
deleteRightArticle(sight[language].right[idx].id);
if (idx > 0) {
setActiveArticleIndex(idx - 1);
setPreviewSection(idx - 1);
setType("article");
} else {
setActiveArticleIndex(null);
setPreviewSection(-1);
setType("media");
}
};
const handleSelectArticle = (index: number) => {
setActiveArticleIndex(index);
};
const handleCreateNew = async () => {
try {
const newArticleId = await createNewRightArticle();
const newIndex = sight[language].right.findIndex(
(article) => article.id === newArticleId
);
if (newIndex > -1) {
setActiveArticleIndex(newIndex);
setType("article");
setPreviewSection(newIndex);
}
} catch (error) {
console.error("Error creating new article:", error);
}
};
const handleMediaSelected = async (media: {
id: string;
filename: string;
media_name?: string;
media_type: number;
}) => {
await createLinkWithRightArticle(
media,
sight[language].right[activeArticleIndex || 0].id
);
};
const handleSave = async () => {
await updateSight();
toast.success("Достопримечательность сохранена");
};
const handleDragEnd = (result: DropResult) => {
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={() => {
handleSelectArticle(index);
setType("article");
setPreviewSection(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"
onClick={handleCreateNew}
>
<Plus size={20} color="white" />
</button>
</Box>
{type === "article" && (
<Box className="w-[80%] border border-gray-300 p-3">
{activeArticleIndex !== null && (
<>
<Box className="flex justify-end gap-2 mb-3">
<Button
variant="contained"
color="primary"
startIcon={<Unlink color="white" size={18} />}
onClick={() => {
unlinkRightArticle(
sight[language].right[activeArticleIndex].id
);
setActiveArticleIndex(null);
}}
>
Открепить
</Button>
<Button
variant="contained"
color="error"
startIcon={<Trash2 size={18} />}
onClick={() => {
setIsDeleteArticleModalOpen(true);
}}
>
Удалить
</Button>
</Box>
<Box sx={{ display: "flex", gap: 3, flexGrow: 1 }}>
<Box
sx={{
flex: 2,
display: "flex",
flexDirection: "column",
gap: 2,
maxHeight: "70%",
}}
>
<TextField
label="Название информации"
value={
sight[language].right[activeArticleIndex]
.heading
}
onChange={(e) =>
updateRightArticleInfo(
activeArticleIndex,
language,
e.target.value,
sight[language].right[activeArticleIndex].body
)
}
variant="outlined"
fullWidth
/>
<ReactMarkdownEditor
value={
sight[language].right[activeArticleIndex].body
}
onChange={(value: any) =>
updateRightArticleInfo(
activeArticleIndex,
language,
sight[language].right[activeArticleIndex]
.heading,
value
)
}
/>
<MediaArea
articleId={
sight[language].right[activeArticleIndex].id
}
mediaIds={
sight[language].right[activeArticleIndex].media
}
onFilesDrop={(files) => {
setFileToUpload(files[0]);
setUploadMediaOpen(true);
}}
deleteMedia={deleteRightArticleMedia}
setSelectMediaDialogOpen={() => {
setIsSelectMediaModalOpen(true);
}}
/>
</Box>
</Box>
</>
)}
</Box>
)}
{type === "media" && (
<Box className="w-[80%] border border-gray-300 rounded-2xl relative flex justify-center items-center">
{sight.common.preview_media && (
<>
{type === "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.common.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.common.preview_font_size ?? ""}
onChange={(e) => {
const raw = e.target.value;
if (raw === "") {
updateSightInfo(language, { preview_font_size: undefined }, true);
return;
}
const val = Math.max(1, Math.min(300, Math.round(Number(raw))));
if (Number.isFinite(val)) {
updateSightInfo(language, { preview_font_size: val }, true);
}
}}
slotProps={{ input: { min: 1, max: 300 } }}
sx={{ width: "200px" }}
/>
<Slider
value={sight.common.preview_font_size ?? 40}
min={1}
max={300}
step={1}
onChange={(_, newValue) => {
if (typeof newValue === "number") {
updateSightInfo(language, { preview_font_size: newValue }, true);
}
}}
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(language, { name: e.target.value })}
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) => {
handleSelectArticle(idx);
setType("article");
setPreviewSection(idx);
}}
previewFontSize={sight.common.preview_font_size}
selectedSection={previewSection}
onSectionChange={(section) => {
setPreviewSection(section);
if (section === -1) {
setType("media");
} else {
handleSelectArticle(section);
setType("article");
}
}}
/>
</Box>
</Box>
<Box
sx={{
position: "absolute",
bottom: 0,
right: 0,
padding: 2,
backgroundColor: "background.paper",
width: "100%",
display: "flex",
justifyContent: "flex-end",
}}
>
<Button
variant="contained"
startIcon={<Save color="white" size={18} />}
color="success"
onClick={handleSave}
>
Сохранить
</Button>
</Box>
</Box>
<UploadMediaDialog
open={uploadMediaOpen}
onClose={() => setUploadMediaOpen(false)}
contextObjectName={sight[language].name}
contextType="sight"
isArticle={true}
articleName={
activeArticleIndex !== null
? sight[language].right[activeArticleIndex].heading
: "Правая статья"
}
afterUpload={async (media) => {
setUploadMediaOpen(false);
setFileToUpload(null);
await createLinkWithRightArticle(
media,
sight[language].right[activeArticleIndex || 0].id
);
}}
/>
<DeleteModal
open={isDeleteArticleModalOpen}
onCancel={() => setIsDeleteArticleModalOpen(false)}
onDelete={() => {
handleDeleteArticle();
setIsDeleteArticleModalOpen(false);
}}
/>
<SelectMediaDialog
open={isSelectMediaModalOpen}
onClose={() => setIsSelectMediaModalOpen(false)}
onSelectMedia={handleMediaSelected}
/>
</TabPanel>
);
}
);