WhiteNightsAdminPanel/src/widgets/SightTabs/CreateRightTab/index.tsx
2025-06-04 22:01:20 +03:00

720 lines
28 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 {
BackButton,
createSightStore,
languageStore,
SelectArticleModal,
TabPanel,
SelectMediaDialog, // Import
UploadMediaDialog,
Media, // Import
} from "@shared";
import {
LanguageSwitcher,
MediaArea, // Import
MediaAreaForSight, // Import
ReactMarkdownComponent,
ReactMarkdownEditor,
DeleteModal,
} from "@widgets";
import { ImagePlus, Plus, Save, Trash2, Unlink, X } from "lucide-react"; // Import X
import { observer } from "mobx-react-lite";
import { useState, useEffect } from "react"; // Added useEffect
import { MediaViewer } from "../../MediaViewer/index";
import { toast } from "react-toastify";
import { authInstance } from "@shared";
import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd";
type MediaItemShared = {
// Define if not already available from @shared
id: string;
filename: string;
media_name?: string;
media_type: number;
};
export const CreateRightTab = observer(
({ value, index }: { value: number; index: number }) => {
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const {
sight,
createNewRightArticle,
updateRightArticleInfo,
linkPreviewMedia,
unlinkPreviewMedia,
createLinkWithRightArticle,
deleteRightArticleMedia,
setFileToUpload, // From store
setUploadMediaOpen, // From store
uploadMediaOpen, // From store
unlinkRightAritcle, // Corrected spelling
deleteRightArticle,
linkExistingRightArticle,
createSight,
clearCreateSight, // For resetting form
updateRightArticles,
} = createSightStore;
const { language } = languageStore;
const [selectArticleDialogOpen, setSelectArticleDialogOpen] =
useState(false);
const [activeArticleIndex, setActiveArticleIndex] = useState<number | null>(
null
);
const [type, setType] = useState<"article" | "media">("media");
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);
// Reset activeArticleIndex if language changes and index is out of bounds
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"); // Default back to media preview if selected article disappears
}
}, [language, sight[language].right, activeArticleIndex]);
const openMenu = Boolean(anchorEl);
const handleClickMenu = (event: React.MouseEvent<HTMLButtonElement>) => {
setAnchorEl(event.currentTarget);
};
const handleCloseMenu = () => {
setAnchorEl(null);
};
const handleSave = async () => {
try {
await createSight(language);
toast.success("Достопримечательность успешно создана!");
clearCreateSight(); // Reset form
setActiveArticleIndex(null);
setType("media");
// Potentially navigate away: history.push('/sights-list');
} catch (error) {
console.error("Failed to save sight:", error);
toast.error("Ошибка при создании достопримечательности.");
}
};
const handleDisplayArticleFromList = (idx: number) => {
setActiveArticleIndex(idx);
setType("article");
};
const handleCreateNewLocalArticle = async () => {
handleCloseMenu();
try {
const newArticleId = await createNewRightArticle();
// Automatically select the new article if ID is returned
const newIndex = sight[language].right.findIndex(
(a) => a.id === newArticleId
);
if (newIndex > -1) {
setActiveArticleIndex(newIndex);
setType("article");
} else {
// Fallback if findIndex fails (should not happen if store updates correctly)
setActiveArticleIndex(sight[language].right.length - 1);
setType("article");
}
} catch (error) {
toast.error("Не удалось создать новую статью.");
}
};
const handleSelectExistingArticleAndLink = async (
selectedArticleId: number
) => {
try {
await linkExistingRightArticle(selectedArticleId);
setSelectArticleDialogOpen(false); // Close dialog
const newIndex = sight[language].right.findIndex(
(a) => a.id === selectedArticleId
);
if (newIndex > -1) {
setActiveArticleIndex(newIndex);
setType("article");
}
} catch (error) {
toast.error("Не удалось привязать существующую статью.");
}
};
const currentRightArticle =
activeArticleIndex !== null && sight[language].right[activeArticleIndex]
? sight[language].right[activeArticleIndex]
: null;
// Media Handling for Dialogs
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) => {
// After UploadMediaDialog finishes
setUploadMediaOpen(false);
setFileToUpload(null);
if (mediaTarget === "sightPreview") {
linkPreviewMedia(media.id);
} else if (mediaTarget === "rightArticle" && currentRightArticle) {
await createLinkWithRightArticle(media, currentRightArticle.id);
}
setMediaTarget(null); // Reset target
};
const handleDragEnd = (result: any) => {
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, language);
};
return (
<TabPanel value={value} index={index}>
<LanguageSwitcher />
<Box
sx={{
display: "flex",
flexDirection: "column",
height: "100%",
minHeight: "calc(100vh - 200px)",
gap: 2,
paddingBottom: "70px", // Space for the save button
position: "relative",
}}
>
<BackButton />
<Box sx={{ display: "flex", flexGrow: 1, gap: 2.5 }}>
{/* Left Column: Navigation & Article List */}
<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");
// setActiveArticleIndex(null); // Optional: deselect article when switching to general media view
}}
className={`w-full p-4 rounded-2xl cursor-pointer text-sm hover:bg-gray-300 transition-all duration-300 ${
type === "media"
? "bg-green-300 font-semibold"
: "bg-green-200"
}`}
>
<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={() => {
handleDisplayArticleFromList(
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 hover:bg-blue-600"
onClick={handleClickMenu}
aria-controls={openMenu ? "add-article-menu" : undefined}
aria-haspopup="true"
aria-expanded={openMenu ? "true" : undefined}
>
<Plus size={20} color="white" />
</button>
<Menu
id="add-article-menu"
anchorEl={anchorEl}
open={openMenu}
onClose={handleCloseMenu}
MenuListProps={{ "aria-labelledby": "basic-button" }}
sx={{ mt: 1 }}
>
<MenuItem onClick={handleCreateNewLocalArticle}>
<Typography>Создать новую</Typography>
</MenuItem>
<MenuItem
onClick={() => {
setSelectArticleDialogOpen(true);
handleCloseMenu();
}}
>
<Typography>Выбрать существующую статью</Typography>
</MenuItem>
</Menu>
</Box>
{/* Main content area: Article Editor or Sight Media Preview */}
{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); // Corrected function name
setActiveArticleIndex(null);
setType("media");
}
}}
>
Открепить
</Button>
<Button
variant="contained"
color="error"
size="small"
startIcon={<Trash2 size={18} />}
onClick={async () => {
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) =>
activeArticleIndex !== null &&
updateRightArticleInfo(
activeArticleIndex,
language,
currentRightArticle.heading,
mdValue || ""
)
}
/>
</Box>
<MediaArea
articleId={currentRightArticle.id} // Needs a real ID
mediaIds={currentRightArticle.media || []}
onFilesDrop={(files) => {
if (files.length > 0) {
setFileToUpload(files[0]);
setMediaTarget("rightArticle");
handleOpenUploadMedia();
}
}}
deleteMedia={deleteRightArticleMedia}
setSelectMediaDialogOpen={() =>
handleOpenSelectMediaDialog("rightArticle")
}
/>
</Box>
</Box>
) : type === "media" ? (
<Box className="w-[80%] border border-gray-300 rounded-2xl relative flex items-center justify-center">
{sight.preview_media && (
<>
{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 || "",
}}
/>
</Box>
</>
)}
{!previewMedia && (
<MediaAreaForSight
onFinishUpload={(mediaId) => {
linkPreviewMedia(mediaId);
}}
onFilesDrop={() => {}}
/>
)}
</Box>
)}
</>
)}
{!previewMedia && (
<MediaAreaForSight
onFinishUpload={(mediaId) => {
linkPreviewMedia(mediaId);
}}
onFilesDrop={() => {}}
/>
)}
</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>
)}
</Box>
</Box>
{/* Right Column: Live Preview */}
<Box className="w-[25%] mr-10">
{type === "article" && activeArticleIndex !== null && (
<Paper
className="flex-1 flex flex-col max-w-[500px]"
sx={{
borderRadius: "16px",
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={{
width: "100%",
maxHeight: "290px",
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<MediaViewer
media={
sight[language].right[activeArticleIndex].media[0]
}
/>
</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)",
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={() => {
setActiveArticleIndex(index);
setType("article");
}}
>
{article.heading}
</button>
))}
</Box>
</Box>
</Paper>
)}
</Box>
</Box>
{/* Sticky Save Button Footer */}
<Box
sx={{
position: "absolute",
bottom: "-20px",
left: 0, // ensure it spans from left
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>
{/* Modals */}
<SelectArticleModal
open={selectArticleDialogOpen}
onClose={() => setSelectArticleDialogOpen(false)}
onSelectArticle={handleSelectExistingArticleAndLink}
// Pass IDs of already linked/added right articles to exclude them from selection
linkedArticleIds={sight[language].right.map((article) => article.id)}
/>
<UploadMediaDialog
open={uploadMediaOpen} // From store
onClose={() => {
setUploadMediaOpen(false);
setFileToUpload(null); // Clear file if dialog is closed without upload
setMediaTarget(null);
}}
afterUpload={handleMediaUploaded} // This will use the mediaTarget
/>
<SelectMediaDialog
open={isSelectMediaDialogOpen}
onClose={() => {
setIsSelectMediaDialogOpen(false);
setMediaTarget(null);
}}
onSelectMedia={handleMediaSelectedFromDialog}
/>
<DeleteModal
open={isDeleteModalOpen}
onDelete={async () => {
try {
await deleteRightArticle(currentRightArticle?.id || 0);
setActiveArticleIndex(null);
setType("media");
toast.success("Статья удалена");
} catch {
toast.error("Не удалось удалить статью");
}
}}
onCancel={() => setIsDeleteModalOpen(false)}
/>
</TabPanel>
);
}
);