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

664 lines
25 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,
Paper,
Typography,
Menu,
MenuItem,
TextField,
} from "@mui/material";
import {
authInstance,
BackButton,
editSightStore,
languageStore,
SelectArticleModal,
SelectMediaDialog,
TabPanel,
UploadMediaDialog,
} from "@shared";
import {
DeleteModal,
LanguageSwitcher,
MediaArea,
MediaAreaForSight,
ReactMarkdownComponent,
ReactMarkdownEditor,
} from "@widgets";
import { ImagePlus, Plus, Save, Trash2, Unlink, X } from "lucide-react";
import { observer } from "mobx-react-lite";
import { useEffect, useState } from "react";
import { toast } from "react-toastify";
import { MediaViewer } from "../../MediaViewer/index";
import {
DragDropContext,
Droppable,
Draggable,
DropResult,
} from "@hello-pangea/dnd";
export const RightWidgetTab = observer(
({ value, index }: { value: number; index: number }) => {
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const {
sight,
updateRightArticleInfo,
getRightArticles,
updateSight,
unlinkPreviewMedia,
linkPreviewMedia,
unlinkRightArticle,
deleteRightArticle,
linkArticle,
deleteRightArticleMedia,
createLinkWithRightArticle,
setFileToUpload,
createNewRightArticle,
updateRightArticles,
} = editSightStore;
const [previewMedia, setPreviewMedia] = useState<any | null>(null);
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();
console.log(sight[language].right);
}, [sight.common.id]);
const [activeArticleIndex, setActiveArticleIndex] = useState<number | null>(
null
);
const [isSelectModalOpen, setIsSelectModalOpen] = useState(false);
const [isSelectMediaModalOpen, setIsSelectMediaModalOpen] = useState(false);
const [isDeleteArticleModalOpen, setIsDeleteArticleModalOpen] =
useState(false);
const open = Boolean(anchorEl);
const handleDeleteArticle = () => {
deleteRightArticle(sight[language].right[activeArticleIndex || 0].id);
setActiveArticleIndex(null);
};
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
const handleSelectArticle = (index: number) => {
setActiveArticleIndex(index);
};
const handleCreateNew = () => {
createNewRightArticle();
handleClose();
};
const handleSelectExisting = () => {
setIsSelectModalOpen(true);
handleClose();
};
const handleCloseSelectModal = () => {
setIsSelectModalOpen(false);
};
const handleArticleSelect = (id: number) => {
linkArticle(id);
handleCloseSelectModal();
};
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("Достопримечательность сохранена");
};
useEffect(() => {
console.log(sight[language].right);
}, [sight[language].right]);
const handleDragEnd = (result: DropResult) => {
const { source, destination } = result;
// 1. Guard clause: If dropped outside any droppable area, do nothing.
if (!destination) return;
// Extract source and destination indices
const sourceIndex = source.index;
const destinationIndex = destination.index;
// 2. Guard clause: If dropped in the same position, do nothing.
if (sourceIndex === destinationIndex) return;
// 3. Create a new array with reordered articles:
// - Create a shallow copy of the current articles array.
// This is important for immutability and triggering re-renders.
const newRightArticles = [...sight[language].right];
// - Remove the dragged article from its original position.
// `splice` returns an array of removed items, so we destructure the first (and only) one.
const [movedArticle] = newRightArticles.splice(sourceIndex, 1);
// - Insert the moved article into its new position.
newRightArticles.splice(destinationIndex, 0, movedArticle);
// 4. Update the store with the new order:
// This will typically trigger a re-render of the component with the updated list.
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 w-[75%] gap-2">
<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")}
className="w-full bg-green-200 p-4 rounded-2xl cursor-pointer text-sm hover:bg-gray-300 transition-all duration-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 bg-gray-200 p-4 rounded-2xl text-sm cursor-pointer hover:bg-gray-300 ${
snapshot.isDragging
? "shadow-lg"
: ""
}`}
onClick={() => {
handleSelectArticle(index);
setType("article");
}}
>
<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={handleClick}
>
<Plus size={20} color="white" />
</button>
<Menu
id="basic-menu"
anchorEl={anchorEl}
open={open}
onClose={handleClose}
MenuListProps={{
"aria-labelledby": "basic-button",
}}
sx={{ mt: 1 }}
>
<MenuItem onClick={handleCreateNew}>
<Typography>Создать новую</Typography>
</MenuItem>
<MenuItem onClick={handleSelectExisting}>
<Typography>Выбрать существующую статью</Typography>
</MenuItem>
</Menu>
</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 || "",
}}
/>
</Box>
</>
)}
{!previewMedia && (
<MediaAreaForSight
onFinishUpload={(mediaId) => {
linkPreviewMedia(mediaId);
}}
onFilesDrop={() => {}}
/>
)}
</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={() => {}}
/>
</Box>
</Box>
)}
</Box>
)}
</Box>
</Box>
{type === "article" && (
<Box className="w-[25%] mr-10">
{activeArticleIndex !== null && (
<Paper
className="flex-1 flex flex-col max-w-[500px]"
sx={{
borderRadius: "10px",
overflow: "hidden",
}}
elevation={2}
>
<Box
className=" overflow-hidden"
sx={{
width: "100%",
background: "#877361",
borderColor: "grey.300",
display: "flex",
flexDirection: "column",
}}
>
{sight[language].right[activeArticleIndex].media.length >
0 ? (
<Box
sx={{
overflow: "hidden",
width: "100%",
padding: "2px 2px 0px 2px",
"& img": {
borderTopLeftRadius: "10px",
borderTopRightRadius: "10px",
width: "100%",
height: "auto",
objectFit: "contain",
},
}}
>
<MediaViewer
media={
sight[language].right[activeArticleIndex].media[0]
}
fullWidth
/>
</Box>
) : (
<Box
sx={{
width: "100%",
height: 200,
flexShrink: 0,
backgroundColor: "rgba(0,0,0,0.1)",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<ImagePlus size={48} color="white" />
</Box>
)}
<Box
sx={{
p: 1,
wordBreak: "break-word",
fontSize: "24px",
fontWeight: 700,
lineHeight: "120%",
backdropFilter: "blur(12px)",
borderBottom: "1px solid #A89F90",
boxShadow:
"inset 4px 4px 12px 0 rgba(255,255,255,0.12)",
background:
"#806c59 linear-gradient(90deg, rgba(255, 255, 255, 0.2) 12.5%, rgba(255, 255, 255, 0.2) 100%)",
}}
>
<Typography variant="h6" color="white">
{sight[language].right[activeArticleIndex].heading ||
"Выберите статью"}
</Typography>
</Box>
<Box
sx={{
padding: 1,
minHeight: "200px",
maxHeight: "300px",
overflowY: "scroll",
background:
"rgba(179, 165, 152, 0.4), linear-gradient(180deg, rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0.2) 100%)",
flexGrow: 1,
}}
>
{sight[language].right[activeArticleIndex].body ? (
<ReactMarkdownComponent
value={
sight[language].right[activeArticleIndex].body
}
/>
) : (
<Typography
color="rgba(255,255,255,0.7)"
sx={{ textAlign: "center", mt: 4 }}
>
Предпросмотр статьи появится здесь
</Typography>
)}
</Box>
<Box
sx={{
p: 2,
display: "flex",
justifyContent: "space-between",
fontSize: "24px",
fontWeight: 700,
lineHeight: "120%",
flexWrap: "wrap",
gap: 1,
backdropFilter: "blur(12px)",
boxShadow:
"inset 4px 4px 12px 0 rgba(255,255,255,0.12)",
background:
"#806c59 linear-gradient(90deg, rgba(255, 255, 255, 0.2) 12.5%, rgba(255, 255, 255, 0.2) 100%)",
}}
>
{sight[language].right.length > 0 &&
sight[language].right.map((article, index) => (
<button
className={`inline-block text-left text-xs text-white ${
activeArticleIndex === index ? "underline" : ""
}`}
onClick={() => {
handleSelectArticle(index);
setType("article");
}}
>
{article.heading}
</button>
))}
</Box>
</Box>
</Paper>
)}
</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)}
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);
}}
/>
<SelectArticleModal
open={isSelectModalOpen}
onClose={handleCloseSelectModal}
onSelectArticle={handleArticleSelect}
linkedArticleIds={sight[language].right.map((article) => article.id)}
/>
<SelectMediaDialog
open={isSelectMediaModalOpen}
onClose={() => setIsSelectMediaModalOpen(false)}
onSelectMedia={handleMediaSelected}
/>
</TabPanel>
);
}
);