feat: Select article list in sight

This commit is contained in:
2025-05-31 06:35:05 +03:00
parent 5ef61bcef4
commit 2e6917406e
21 changed files with 899 additions and 498 deletions

View File

@ -6,15 +6,30 @@ import {
ListItemText,
Paper,
Typography,
Menu,
MenuItem,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
InputAdornment,
} from "@mui/material";
import { BackButton, Sight, TabPanel } from "@shared";
import {
articlesStore,
BackButton,
SelectArticleModal,
Sight,
TabPanel,
} from "@shared";
import { SightEdit } from "@widgets";
import { Plus } from "lucide-react";
import { useState } from "react";
import { ImagePlus, Plus, Search } from "lucide-react";
import { observer } from "mobx-react-lite";
import { useState, useEffect, useRef } from "react";
// Мокап данных для списка блоков правого виджета
// --- Mock Data (can be moved to a separate file or fetched from an API) ---
const mockRightWidgetBlocks = [
{ id: "preview_media", name: "Превью-медиа", type: "special" }, // Особый блок?
{ id: "preview_media", name: "Превью-медиа", type: "special" },
{ id: "article_1", name: "1. История", type: "article" },
{ id: "article_2", name: "2. Факты", type: "article" },
{
@ -24,241 +39,368 @@ const mockRightWidgetBlocks = [
},
];
// Мокап данных для выбранного блока для редактирования
// В реальности это будет объект Article из API
const mockSelectedBlockData = {
id: "article_1",
heading: "История основания Санкт-Петербурга",
body: "## Начало\nГород был основан 27 мая 1703 года Петром I...",
media: [
// Предполагаем, что у статьи может быть несколько медиа
// { id: "media_1", url: "https://via.placeholder.com/300x200.png?text=History+Image+1", type: "image" }
],
media: [],
};
export const RightWidgetTab = ({
value,
index,
data,
}: {
value: number;
index: number;
data?: Sight;
}) => {
const [rightWidgetBlocks, setRightWidgetBlocks] = useState(
mockRightWidgetBlocks
);
const [selectedBlockId, setSelectedBlockId] = useState<string | null>(
mockRightWidgetBlocks[1]?.id || null
); // Выбираем первый "article" по умолчанию
const mockExistingArticles = [
{ id: "existing_1", title: "История Эрмитажа", type: "article" },
{ id: "existing_2", title: "Петропавловская крепость", type: "article" },
{ id: "existing_3", title: "Исаакиевский собор", type: "article" },
{ id: "existing_4", title: "Кунсткамера", type: "article" },
];
const handleSelectBlock = (blockId: string) => {
setSelectedBlockId(blockId);
// Здесь будет логика загрузки данных для выбранного блока, если они не загружены
console.log("Selected block:", blockId);
// --- ArticleListSidebar Component ---
interface ArticleBlock {
id: string;
name: string;
type: string;
linkedArticleId?: string; // Added for linked articles
}
interface ArticleListSidebarProps {
blocks: ArticleBlock[];
selectedBlockId: string | null;
onSelectBlock: (blockId: string) => void;
onCreateNew: () => void;
onSelectExisting: () => void;
}
const ArticleListSidebar = ({
blocks,
selectedBlockId,
onSelectBlock,
onCreateNew,
onSelectExisting,
}: ArticleListSidebarProps) => {
const [menuAnchorEl, setMenuAnchorEl] = useState<null | HTMLElement>(null);
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
setMenuAnchorEl(event.currentTarget);
};
const handleAddBlock = () => {
// Логика открытия модала/формы для создания нового блока/статьи
// или выбора существующей статьи для привязки
console.log("Add new block");
const newBlockId = `article_${Date.now()}`;
setRightWidgetBlocks([
...rightWidgetBlocks,
{
id: newBlockId,
name: `${
rightWidgetBlocks.filter((b) => b.type === "article").length + 1
}. Новый блок`,
type: "article",
},
]);
setSelectedBlockId(newBlockId);
const handleMenuClose = () => {
setMenuAnchorEl(null);
};
const handleSave = () => {
console.log("Saving right widget...");
};
// Находим данные для редактирования на основе selectedBlockId
// В реальном приложении эти данные будут приходить из store или загружаться по API
const currentBlockToEdit =
selectedBlockId === mockSelectedBlockData.id
? mockSelectedBlockData
: selectedBlockId
? {
id: selectedBlockId,
heading:
rightWidgetBlocks.find((b) => b.id === selectedBlockId)?.name ||
"Заголовок...",
body: "Содержимое...",
media: [],
}
: null;
return (
<TabPanel value={value} index={index}>
<Box
<Paper
elevation={2}
sx={{
width: 260,
minWidth: 240,
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
padding: 1.5,
borderRadius: 2,
border: "1px solid",
borderColor: "divider",
}}
>
<List
dense
sx={{
display: "flex",
flexDirection: "column",
height: "100%",
minHeight: "calc(100vh - 200px)",
gap: 2,
paddingBottom: "70px",
position: "relative",
overflowY: "auto",
flexGrow: 1,
maxHeight: "calc(100% - 60px)",
}}
>
<BackButton />
<Box sx={{ display: "flex", flexGrow: 1, gap: 2.5 }}>
{/* Левая колонка: Список блоков/статей */}
<Paper
elevation={2}
{blocks.map((block) => (
<ListItemButton
key={block.id}
selected={selectedBlockId === block.id}
onClick={() => onSelectBlock(block.id)}
sx={{
width: 260, // Ширина как на макете
minWidth: 240,
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
padding: 1.5,
borderRadius: 2,
border: "1px solid",
borderColor: "divider",
borderRadius: 1,
mb: 0.5,
backgroundColor:
selectedBlockId === block.id ? "primary.light" : "transparent",
"&.Mui-selected": {
backgroundColor: "primary.main",
color: "primary.contrastText",
"&:hover": {
backgroundColor: "primary.dark",
},
},
"&:hover": {
backgroundColor:
selectedBlockId !== block.id ? "action.hover" : undefined,
},
}}
>
<List
dense
sx={{
overflowY: "auto",
flexGrow: 1,
maxHeight:
"calc(100% - 60px)" /* Adjust based on button size */,
<ListItemText
primary={block.name}
primaryTypographyProps={{
fontWeight: selectedBlockId === block.id ? "bold" : "normal",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{rightWidgetBlocks.map((block) => (
<ListItemButton
key={block.id}
selected={selectedBlockId === block.id}
onClick={() => handleSelectBlock(block.id)}
sx={{
borderRadius: 1,
mb: 0.5,
backgroundColor:
selectedBlockId === block.id
? "primary.light"
: "transparent",
"&.Mui-selected": {
backgroundColor: "primary.main",
color: "primary.contrastText",
"&:hover": {
backgroundColor: "primary.dark",
},
},
"&:hover": {
backgroundColor:
selectedBlockId !== block.id
? "action.hover"
: undefined,
},
}}
>
<ListItemText
primary={block.name}
primaryTypographyProps={{
fontWeight:
selectedBlockId === block.id ? "bold" : "normal",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
/>
</ListItemButton>
))}
</List>
<Box
sx={{
display: "flex",
justifyContent: "center",
pt: 1.5,
borderTop: "1px solid",
borderColor: "divider",
}}
>
<Button
variant="contained"
onClick={handleAddBlock}
startIcon={<Plus />}
fullWidth
>
Добавить блок
</Button>
</Box>
</Paper>
/>
</ListItemButton>
))}
</List>
{/* Правая колонка: Редактор выбранного блока (SightEdit) */}
<Paper
elevation={2}
sx={{
flexGrow: 1,
padding: 2.5,
borderRadius: 2,
border: "1px solid",
borderColor: "divider",
overflowY: "auto", // Если контент будет больше
}}
>
{currentBlockToEdit ? (
<>
<SightEdit
onUnlink={() => console.log("Unlink block:", selectedBlockId)}
onDelete={() => {
console.log("Delete block:", selectedBlockId);
setRightWidgetBlocks((blocks) =>
blocks.filter((b) => b.id !== selectedBlockId)
);
setSelectedBlockId(null);
}}
/>
<Paper elevation={1} sx={{ padding: 2, mt: 1, width: "75%" }}>
<Typography variant="h6" gutterBottom>
МЕДИА
</Typography>
{/* Здесь будет UI для управления медиа статьи */}
<Box
sx={{
width: "100%",
height: 100,
backgroundColor: "grey.100",
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 1,
mb: 1,
border: "2px dashed",
borderColor: "grey.300",
}}
>
<Typography color="text.secondary">Нет медиа</Typography>
</Box>
<Button variant="contained">Выбрать/Загрузить медиа</Button>
</Paper>
</>
) : (
<></>
)}
</Paper>
</Box>
{/* Блок МЕДИА для статьи */}
<Box sx={{ position: "absolute", bottom: 0, right: 0, padding: 2 }}>
<Button variant="contained" color="success" onClick={handleSave}>
Сохранить
</Button>
</Box>
</Box>
</TabPanel>
<button
className="w-8 h-8 rounded-full bg-blue-500 text-white flex items-center justify-center hover:bg-blue-600 transition-colors"
onClick={handleMenuOpen}
>
<Plus color="white" />
</button>
<Menu
anchorEl={menuAnchorEl}
open={Boolean(menuAnchorEl)}
onClose={handleMenuClose}
anchorOrigin={{
vertical: "top",
horizontal: "right",
}}
transformOrigin={{
vertical: "bottom",
horizontal: "right",
}}
>
<MenuItem onClick={onCreateNew}>Создать новую</MenuItem>
<MenuItem onClick={onSelectExisting}>Выбрать существующую</MenuItem>
</Menu>
</Paper>
);
};
// --- ArticleEditorPane Component ---
interface ArticleData {
id: string;
heading: string;
body: string;
media: any[]; // Define a proper type for media if available
}
interface ArticleEditorPaneProps {
articleData: ArticleData | null;
onDelete: (blockId: string) => void;
}
const ArticleEditorPane = ({
articleData,
onDelete,
}: ArticleEditorPaneProps) => {
if (!articleData) {
return (
<Paper
elevation={2}
sx={{
flexGrow: 1,
padding: 2.5,
borderRadius: 2,
border: "1px solid",
borderColor: "divider",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Typography variant="h6" color="text.secondary">
Выберите блок для редактирования
</Typography>
</Paper>
);
}
return (
<Paper
elevation={2}
sx={{
flexGrow: 1,
padding: 2.5,
borderRadius: 2,
border: "1px solid",
borderColor: "divider",
overflowY: "auto",
}}
>
<SightEdit />
<Paper elevation={1} sx={{ padding: 2, mt: 1, width: "75%" }}>
<Typography variant="h6" gutterBottom>
МЕДИА
</Typography>
<Box
sx={{
width: "100%",
height: 100,
backgroundColor: "grey.100",
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 1,
mb: 1,
border: "2px dashed",
borderColor: "grey.300",
}}
>
<Typography color="text.secondary">Нет медиа</Typography>
</Box>
<Button variant="contained">Выбрать/Загрузить медиа</Button>
</Paper>
</Paper>
);
};
// --- RightWidgetTab (Parent) Component ---
export const RightWidgetTab = observer(
({ value, index, data }: { value: number; index: number; data?: Sight }) => {
const [rightWidgetBlocks, setRightWidgetBlocks] = useState<ArticleBlock[]>(
mockRightWidgetBlocks
);
const [selectedBlockId, setSelectedBlockId] = useState<string | null>(
mockRightWidgetBlocks[1]?.id || null
);
const [isSelectModalOpen, setIsSelectModalOpen] = useState(false);
const handleSelectBlock = (blockId: string) => {
setSelectedBlockId(blockId);
console.log("Selected block:", blockId);
};
const handleCreateNew = () => {
const newBlockId = `article_${Date.now()}`;
setRightWidgetBlocks((prevBlocks) => [
...prevBlocks,
{
id: newBlockId,
name: `${
prevBlocks.filter((b) => b.type === "article").length + 1
}. Новый блок`,
type: "article",
},
]);
setSelectedBlockId(newBlockId);
};
const handleSelectExisting = () => {
setIsSelectModalOpen(true);
};
const handleCloseSelectModal = () => {
setIsSelectModalOpen(false);
};
const handleSelectArticle = (articleId: string) => {
const article = articlesStore.articles.find((a) => a.id === articleId);
if (article) {
const newBlockId = `article_linked_${article.id}_${Date.now()}`;
setRightWidgetBlocks((prevBlocks) => [
...prevBlocks,
{
id: newBlockId,
name: `${
prevBlocks.filter((b) => b.type === "article").length + 1
}. ${article.service_name}`,
type: "article",
linkedArticleId: article.id,
},
]);
setSelectedBlockId(newBlockId);
}
handleCloseSelectModal();
};
const handleUnlinkBlock = (blockId: string) => {
console.log("Unlink block:", blockId);
// Example: If a block is linked to an existing article, this might "unlink" it
// For now, it simply removes it, you might want to convert it to a new editable block.
setRightWidgetBlocks((blocks) => blocks.filter((b) => b.id !== blockId));
setSelectedBlockId(null);
};
const handleDeleteBlock = (blockId: string) => {
console.log("Delete block:", blockId);
setRightWidgetBlocks((blocks) => blocks.filter((b) => b.id !== blockId));
setSelectedBlockId(null);
};
const handleSave = () => {
console.log("Saving right widget...");
// Implement save logic here, e.g., send data to an API
};
// Determine the current block data to pass to the editor pane
const currentBlockToEdit = selectedBlockId
? selectedBlockId === mockSelectedBlockData.id
? mockSelectedBlockData
: {
id: selectedBlockId,
heading:
rightWidgetBlocks.find((b) => b.id === selectedBlockId)?.name ||
"Заголовок...",
body: "Содержимое...",
media: [],
}
: null;
// Get list of already linked article IDs
const linkedArticleIds = rightWidgetBlocks
.filter((block) => block.linkedArticleId)
.map((block) => block.linkedArticleId as string);
return (
<TabPanel value={value} index={index}>
<Box
sx={{
display: "flex",
flexDirection: "column",
height: "100%",
minHeight: "calc(100vh - 200px)", // Adjust as needed
gap: 2,
paddingBottom: "70px", // Space for the save button
position: "relative",
}}
>
<BackButton />
<Box sx={{ display: "flex", flexGrow: 1, gap: 2.5 }}>
<ArticleListSidebar
blocks={rightWidgetBlocks}
selectedBlockId={selectedBlockId}
onSelectBlock={handleSelectBlock}
onCreateNew={handleCreateNew}
onSelectExisting={handleSelectExisting}
/>
<ArticleEditorPane
articleData={currentBlockToEdit}
onDelete={handleDeleteBlock}
/>
</Box>
<Box
sx={{
position: "absolute",
bottom: 0,
right: 0,
padding: 2,
backgroundColor: "background.paper", // Ensure button is visible
width: "100%", // Cover the full width to make it a sticky footer
display: "flex",
justifyContent: "flex-end",
}}
>
<Button variant="contained" color="success" onClick={handleSave}>
Сохранить изменения
</Button>
</Box>
</Box>
<SelectArticleModal
open={isSelectModalOpen}
onClose={handleCloseSelectModal}
onSelectArticle={handleSelectArticle}
linkedArticleIds={linkedArticleIds}
/>
</TabPanel>
);
}
);