587 lines
22 KiB
TypeScript
587 lines
22 KiB
TypeScript
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>
|
||
);
|
||
}
|
||
);
|