feat: DND for Articles
This commit is contained in:
@ -31,6 +31,7 @@ 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
|
||||
@ -59,6 +60,7 @@ export const CreateRightTab = observer(
|
||||
linkExistingRightArticle,
|
||||
createSight,
|
||||
clearCreateSight, // For resetting form
|
||||
updateRightArticles,
|
||||
} = createSightStore;
|
||||
const { language } = languageStore;
|
||||
|
||||
@ -210,6 +212,36 @@ export const CreateRightTab = observer(
|
||||
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 />
|
||||
@ -245,22 +277,56 @@ export const CreateRightTab = observer(
|
||||
>
|
||||
<Typography>Предпросмотр медиа</Typography>
|
||||
</Box>
|
||||
|
||||
{sight[language].right.map((article, artIdx) => (
|
||||
<Box
|
||||
key={article.id || artIdx} // article.id should be preferred
|
||||
className={`w-full p-4 rounded-2xl text-sm cursor-pointer hover:bg-gray-300 transition-all duration-300 ${
|
||||
type === "article" && activeArticleIndex === artIdx
|
||||
? "bg-blue-300 font-semibold"
|
||||
: "bg-gray-200"
|
||||
}`}
|
||||
onClick={() => handleDisplayArticleFromList(artIdx)}
|
||||
>
|
||||
<Typography noWrap title={article.heading}>
|
||||
{article.heading || "Без названия"}
|
||||
</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"
|
||||
@ -298,7 +364,7 @@ export const CreateRightTab = observer(
|
||||
<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="outlined"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
size="small"
|
||||
startIcon={<Unlink color="white" size={18} />}
|
||||
@ -381,40 +447,52 @@ export const CreateRightTab = observer(
|
||||
</Box>
|
||||
</Box>
|
||||
) : type === "media" ? (
|
||||
<Box className="w-[80%] h-[70vh] border border-gray-300 rounded-2xl p-3 relative flex justify-center items-center">
|
||||
{type === "media" && (
|
||||
<Box className="w-[80%] border border-gray-300 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-[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 sx={{}}>
|
||||
<MediaViewer
|
||||
media={{
|
||||
id: previewMedia.id || "",
|
||||
media_type: previewMedia.media_type,
|
||||
filename: previewMedia.filename || "",
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
{!previewMedia && (
|
||||
<MediaAreaForSight
|
||||
onFinishUpload={(mediaId) => {
|
||||
linkPreviewMedia(mediaId);
|
||||
}}
|
||||
onFilesDrop={() => {}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
{!previewMedia && (
|
||||
<MediaAreaForSight
|
||||
onFinishUpload={(mediaId) => {
|
||||
linkPreviewMedia(mediaId);
|
||||
}}
|
||||
onFilesDrop={() => {}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
) : (
|
||||
@ -429,31 +507,52 @@ export const CreateRightTab = observer(
|
||||
|
||||
{/* Right Column: Live Preview */}
|
||||
<Box className="w-[25%] mr-10">
|
||||
{type === "article" && currentRightArticle && (
|
||||
{type === "article" && activeArticleIndex !== null && (
|
||||
<Paper
|
||||
className="flex-1 flex flex-col rounded-2xl"
|
||||
className="flex-1 flex flex-col max-w-[500px]"
|
||||
sx={{
|
||||
borderRadius: "16px",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
elevation={2}
|
||||
sx={{ height: "75vh", overflow: "hidden" }}
|
||||
>
|
||||
<Box
|
||||
className="rounded-2xl overflow-hidden"
|
||||
className=" overflow-hidden"
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
background: "#877361", // Theme background
|
||||
|
||||
background: "#877361",
|
||||
borderColor: "grey.300",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
{currentRightArticle.media &&
|
||||
currentRightArticle.media.length > 0 ? (
|
||||
<MediaViewer media={currentRightArticle.media[0]} />
|
||||
{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",
|
||||
@ -463,57 +562,85 @@ export const CreateRightTab = observer(
|
||||
<ImagePlus size={48} color="white" />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
width: "100%",
|
||||
minHeight: "70px", // Fixed height for heading container
|
||||
background: "#877361", // Consistent with theme
|
||||
display: "flex",
|
||||
flexShrink: 0,
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
borderBottom: "1px solid rgba(255,255,255,0.1)",
|
||||
px: 2,
|
||||
py: 1,
|
||||
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"
|
||||
noWrap
|
||||
title={currentRightArticle.heading}
|
||||
>
|
||||
{currentRightArticle.heading || "Заголовок"}
|
||||
<Typography variant="h6" color="white">
|
||||
{sight[language].right[activeArticleIndex].heading ||
|
||||
"Выберите статью"}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
px: 2,
|
||||
py: 1,
|
||||
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,
|
||||
overflowY: "auto",
|
||||
backgroundColor: "#877361",
|
||||
color: "white",
|
||||
"&::-webkit-scrollbar": { width: "8px" },
|
||||
"&::-webkit-scrollbar-thumb": {
|
||||
backgroundColor: "rgba(255,255,255,0.3)",
|
||||
borderRadius: "4px",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{currentRightArticle.body ? (
|
||||
{sight[language].right[activeArticleIndex].body ? (
|
||||
<ReactMarkdownComponent
|
||||
value={currentRightArticle.body}
|
||||
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>
|
||||
)}
|
||||
|
Reference in New Issue
Block a user