feat: cache delete + empty snapshot + route page

This commit is contained in:
2026-04-28 03:50:29 +03:00
parent 248eea6f85
commit 60c6840db4
21 changed files with 770 additions and 361 deletions

View File

@@ -1,9 +1,10 @@
import {
Box,
Button,
Paper,
Typography,
TextField,
Slider,
Stack,
} from "@mui/material";
import {
BackButton,
@@ -19,17 +20,18 @@ import {
LanguageSwitcher,
MediaArea,
MediaAreaForSight,
ReactMarkdownComponent,
ReactMarkdownEditor,
DeleteModal,
} from "@widgets";
import { ImagePlus, Plus, Save, Trash2, Unlink, X } from "lucide-react";
import { Plus, Save, Trash2, Unlink, X } from "lucide-react";
import { observer } from "mobx-react-lite";
import { useState, useEffect } from "react";
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;
@@ -51,17 +53,19 @@ export const CreateRightTab = observer(
unlinkRightAritcle,
deleteRightArticle,
createSight,
clearCreateSight,
updateRightArticles,
updateSightInfo,
} = createSightStore;
const { language } = languageStore;
const navigate = useNavigate();
const { setFileToUpload, setUploadMediaOpen, uploadMediaOpen } =
editSightStore;
const [activeArticleIndex, setActiveArticleIndex] = useState<number | null>(
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);
@@ -70,12 +74,34 @@ export const CreateRightTab = observer(
>(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}`
`/media/${sight.preview_media}`,
);
setPreviewMedia(response.data);
};
@@ -90,16 +116,17 @@ export const CreateRightTab = observer(
) {
setActiveArticleIndex(null);
setType("media");
setPreviewSection(-1);
}
}, [language, sight[language].right, activeArticleIndex]);
const handleSave = async () => {
try {
await createSight(language);
const newSightId = await createSight(language);
console.log("[handleSave] newSightId:", newSightId, "needLeaveAgree:", createSightStore.needLeaveAgree);
toast.success("Достопримечательность успешно создана!");
clearCreateSight();
setActiveArticleIndex(null);
setType("media");
navigate(`/sight/${newSightId}/edit`);
console.log("[handleSave] navigate called to:", `/sight/${newSightId}/edit`);
} catch (error) {
console.error("Failed to save sight:", error);
toast.error("Ошибка при создании достопримечательности.");
@@ -109,6 +136,7 @@ export const CreateRightTab = observer(
const handleDisplayArticleFromList = (idx: number) => {
setActiveArticleIndex(idx);
setType("article");
setPreviewSection(idx);
};
const handleCreateNewLocalArticle = async () => {
@@ -116,15 +144,13 @@ export const CreateRightTab = observer(
const newArticleId = await createNewRightArticle();
const newIndex = sight[language].right.findIndex(
(a) => a.id === newArticleId
(a) => a.id === newArticleId,
);
if (newIndex > -1) {
setActiveArticleIndex(newIndex);
setType("article");
} else {
setActiveArticleIndex(sight[language].right.length - 1);
setType("article");
}
const resolvedIndex =
newIndex > -1 ? newIndex : sight[language].right.length - 1;
setActiveArticleIndex(resolvedIndex);
setType("article");
setPreviewSection(resolvedIndex);
} catch (error) {
toast.error("Не удалось создать новую статью.");
}
@@ -140,7 +166,7 @@ export const CreateRightTab = observer(
};
const handleOpenSelectMediaDialog = (
target: "sightPreview" | "rightArticle"
target: "sightPreview" | "rightArticle",
) => {
setMediaTarget(target);
setIsSelectMediaDialogOpen(true);
@@ -184,11 +210,8 @@ export const CreateRightTab = observer(
if (sourceIndex === destinationIndex) return;
const newRightArticles = [...sight[language].right];
const [movedArticle] = newRightArticles.splice(sourceIndex, 1);
newRightArticles.splice(destinationIndex, 0, movedArticle);
updateRightArticles(newRightArticles);
};
@@ -212,18 +235,22 @@ export const CreateRightTab = observer(
</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="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 hover:bg-gray-300 transition-all duration-300 ${
className={`w-full p-4 rounded-2xl cursor-pointer text-sm transition-all duration-300 ${
type === "media"
? "bg-green-300 font-semibold"
: "bg-green-200"
? "bg-blue-400 text-white"
: "bg-gray-200 hover:bg-gray-300"
}`}
>
<Typography>Предпросмотр медиа</Typography>
@@ -249,16 +276,19 @@ export const CreateRightTab = observer(
<Box
ref={provided.innerRef}
{...provided.draggableProps}
className={`w-full bg-gray-200 p-4 rounded-2xl text-sm cursor-pointer hover:bg-gray-300 ${
className={`w-full p-4 rounded-2xl text-sm cursor-pointer transition-all duration-300 ${
snapshot.isDragging
? "shadow-lg"
: ""
? "shadow-lg bg-gray-200"
: activeArticleIndex ===
index &&
type === "article"
? "bg-blue-400 text-white"
: "bg-gray-200 hover:bg-gray-300"
}`}
onClick={() => {
handleDisplayArticleFromList(
index
index,
);
setType("article");
}}
>
<Box {...provided.dragHandleProps}>
@@ -269,7 +299,7 @@ export const CreateRightTab = observer(
</Box>
)}
</Draggable>
)
),
)
: null}
{provided.placeholder}
@@ -300,6 +330,7 @@ export const CreateRightTab = observer(
unlinkRightAritcle(currentRightArticle.id);
setActiveArticleIndex(null);
setType("media");
setPreviewSection(-1);
}
}}
>
@@ -310,9 +341,7 @@ export const CreateRightTab = observer(
color="error"
size="small"
startIcon={<Trash2 size={18} />}
onClick={async () => {
setIsDeleteModalOpen(true);
}}
onClick={() => setIsDeleteModalOpen(true)}
>
Удалить
</Button>
@@ -336,7 +365,7 @@ export const CreateRightTab = observer(
activeArticleIndex,
language,
e.target.value,
currentRightArticle.body
currentRightArticle.body,
)
}
variant="outlined"
@@ -351,7 +380,7 @@ export const CreateRightTab = observer(
activeArticleIndex,
language,
currentRightArticle.heading,
mdValue || ""
mdValue || "",
)
}
/>
@@ -373,225 +402,160 @@ export const CreateRightTab = observer(
/>
</Box>
</Box>
) : type === "media" ? (
<Box className="w-[80%] border border-gray-300 rounded-2xl relative flex items-center justify-center">
<>
{type === "media" && (
<Box className="w-[80%] 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">
<MediaViewer
media={{
id: previewMedia.id || "",
media_type: previewMedia.media_type,
filename: previewMedia.filename || "",
}}
fullWidth
fullHeight
/>
</Box>
</>
)}
{!previewMedia && (
<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 className="w-[80%] h-[70vh] border border-gray-300 rounded-2xl p-3 flex justify-center items-center">
<Typography variant="h6" color="text.secondary">
Выберите статью слева или секцию "Предпросмотр медиа"
</Typography>
<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 className="w-[25%] mr-10">
{type === "article" && activeArticleIndex !== null && sight[language].right[activeArticleIndex] && (
<Paper
className="flex-1 flex flex-col max-w-[500px]"
sx={{
borderRadius: "10px",
overflow: "hidden",
}}
elevation={2}
>
<Box
className=" overflow-hidden"
sx={{
width: "100%",
height: "100%",
overflow: "hidden",
background: "#877361",
borderColor: "grey.300",
display: "flex",
flexDirection: "column",
<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 });
}
}}
>
{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",
},
}}
>
<MediaViewer
media={
sight[language].right[activeArticleIndex].media[0]
}
fullWidth
fullHeight
/>
</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: "auto",
"&::-webkit-scrollbar": {
display: "none",
},
"&": {
scrollbarWidth: "none",
},
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={() => {
setActiveArticleIndex(index);
setType("article");
}}
>
{article.heading}
</button>
))}
</Box>
</Box>
</Paper>
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>
@@ -603,7 +567,6 @@ export const CreateRightTab = observer(
right: 0,
padding: 2,
backgroundColor: "background.paper",
width: "100%",
display: "flex",
justifyContent: "flex-end",
@@ -650,10 +613,18 @@ export const CreateRightTab = observer(
open={isDeleteModalOpen}
onDelete={async () => {
try {
const idx = activeArticleIndex ?? 0;
await deleteRightArticle(currentRightArticle?.id || 0);
setIsDeleteModalOpen(false);
setActiveArticleIndex(null);
setType("media");
if (idx > 0) {
setActiveArticleIndex(idx - 1);
setPreviewSection(idx - 1);
setType("article");
} else {
setActiveArticleIndex(null);
setPreviewSection(-1);
setType("media");
}
toast.success("Статья удалена");
} catch {
toast.error("Не удалось удалить статью");
@@ -663,5 +634,5 @@ export const CreateRightTab = observer(
/>
</TabPanel>
);
}
},
);