fix: Language cache sight

This commit is contained in:
2025-05-31 21:17:27 +03:00
parent 2e6917406e
commit 0d9bbb140f
28 changed files with 2760 additions and 1013 deletions

View File

@ -1,405 +1,404 @@
// RightWidgetTab.tsx
import { Box, Button, Paper, TextField, Typography } from "@mui/material";
import {
Box,
Button,
List,
ListItemButton,
ListItemText,
Paper,
Typography,
Menu,
MenuItem,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
InputAdornment,
} from "@mui/material";
import {
articlesStore,
BackButton,
SelectArticleModal,
Sight,
TabPanel,
BackButton,
languageStore, // Предполагаем, что он есть в @shared
Language, // Предполагаем, что он есть в @shared
// SelectArticleModal, // Добавим позже
// articlesStore, // Добавим позже
} from "@shared";
import { SightEdit } from "@widgets";
import { ImagePlus, Plus, Search } from "lucide-react";
import { LanguageSwitcher } from "@widgets"; // Предполагаем, что LanguageSwitcher у вас есть
import { observer } from "mobx-react-lite";
import { useState, useEffect, useRef } from "react";
import { useState, useMemo, useEffect } from "react";
import { editSightStore, BlockItem } from "@shared"; // Путь к вашему стору
// --- Mock Data (can be moved to a separate file or fetched from an API) ---
const mockRightWidgetBlocks = [
{ id: "preview_media", name: "Превью-медиа", type: "special" },
{ id: "article_1", name: "1. История", type: "article" },
{ id: "article_2", name: "2. Факты", type: "article" },
{
id: "article_3",
name: "3. Блокада (Пример длинного названия)",
type: "article",
},
// Импортируем сюда же определения BlockItem, если не выносим в types.ts
// export interface BlockItem { id: string; type: 'media' | 'article'; nameForSidebar: string; linkedArticleStoreId?: string; }
// --- Начальные данные для структуры блоков (позже это может загружаться) ---
// ID здесь должны быть уникальными для списка.
const initialBlockStructures: Omit<BlockItem, "nameForSidebar">[] = [
{ id: "preview_media_main", type: "media" },
{ id: "article_1_local", type: "article" }, // Эти статьи будут редактироваться локально
{ id: "article_2_local", type: "article" },
];
const mockSelectedBlockData = {
id: "article_1",
heading: "История основания Санкт-Петербурга",
body: "## Начало\nГород был основан 27 мая 1703 года Петром I...",
media: [],
};
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" },
];
// --- ArticleListSidebar Component ---
interface ArticleBlock {
id: string;
name: string;
type: string;
linkedArticleId?: string; // Added for linked articles
interface RightWidgetTabProps {
value: number;
index: number;
}
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 handleMenuClose = () => {
setMenuAnchorEl(null);
};
return (
<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={{
overflowY: "auto",
flexGrow: 1,
maxHeight: "calc(100% - 60px)",
}}
>
{blocks.map((block) => (
<ListItemButton
key={block.id}
selected={selectedBlockId === block.id}
onClick={() => onSelectBlock(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>
<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);
({ value, index }: RightWidgetTabProps) => {
const { language } = languageStore; // Текущий язык
const { sightInfo } = editSightStore; // Данные достопримечательности
// 1. Структура блоков: порядок, тип, связи (не сам контент)
// Имена nameForSidebar будут динамически браться из sightInfo или articlesStore
const [blockItemsStructure, setBlockItemsStructure] = useState<
Omit<BlockItem, "nameForSidebar">[]
>(initialBlockStructures);
// 2. ID выбранного блока для редактирования
const [selectedBlockId, setSelectedBlockId] = useState<string | null>(
() => {
// По умолчанию выбираем первый блок, если он есть
return initialBlockStructures.length > 0
? initialBlockStructures[0].id
: null;
}
);
// 3. Состояние для модального окна выбора существующей статьи (добавим позже)
// const [isSelectModalOpen, setIsSelectModalOpen] = useState(false);
// --- Производные данные (Derived State) ---
// Блоки для отображения в сайдбаре (с локализованными именами)
const blocksForSidebar: BlockItem[] = useMemo(() => {
return blockItemsStructure.map((struct) => {
let name = `Блок ${struct.id}`; // Имя по умолчанию
if (struct.type === "media" && struct.id === "preview_media_main") {
name = "Превью-медиа"; // Фиксированное имя для этого блока
} else if (struct.type === "article") {
if (struct.linkedArticleStoreId) {
// TODO: Найти имя в articlesStore по struct.linkedArticleStoreId
name = `Связанная: ${struct.linkedArticleStoreId}`;
} else {
// Это локальная статья, берем заголовок из editSightStore
const articleContent = sightInfo[language]?.right?.find(
(a) => a.id === struct.id
);
name =
articleContent?.heading ||
`Статья ${struct.id.slice(-4)} (${language.toUpperCase()})`;
}
}
return { ...struct, nameForSidebar: name };
});
}, [blockItemsStructure, language, sightInfo]);
// Данные выбранного блока (структура + контент)
const selectedBlockData = useMemo(() => {
if (!selectedBlockId) return null;
const structure = blockItemsStructure.find(
(b) => b.id === selectedBlockId
);
if (!structure) return null;
if (structure.type === "article" && !structure.linkedArticleStoreId) {
const content = sightInfo[language]?.right?.find(
(a) => a.id === selectedBlockId
);
return {
structure,
content: content || { id: selectedBlockId, heading: "", body: "" }, // Заглушка, если нет контента
};
}
// Для media или связанных статей пока просто структура
return { structure, content: null };
}, [selectedBlockId, blockItemsStructure, language, sightInfo]);
// --- Обработчики событий ---
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",
},
]);
const handleCreateNewArticle = () => {
const newBlockId = `article_local_${Date.now()}`;
const newBlockStructure: Omit<BlockItem, "nameForSidebar"> = {
id: newBlockId,
type: "article",
};
setBlockItemsStructure((prev) => [...prev, newBlockStructure]);
// Добавляем пустой контент для этой статьи во все языки в editSightStore
const baseName = `Новая статья ${
blockItemsStructure.filter((b) => b.type === "article").length + 1
}`;
["ru", "en", "zh"].forEach((lang) => {
const currentLang = lang as Language;
if (
editSightStore.sightInfo[currentLang] &&
!editSightStore.sightInfo[currentLang].right?.find(
(r) => r.id === newBlockId
)
) {
editSightStore.sightInfo[currentLang].right.push({
id: newBlockId,
heading: `${baseName} (${currentLang.toUpperCase()})`,
body: `Содержимое для ${baseName} (${currentLang.toUpperCase()})...`,
});
}
});
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);
const handleHeadingChange = (newHeading: string) => {
if (
selectedBlockData &&
selectedBlockData.structure.type === "article" &&
!selectedBlockData.structure.linkedArticleStoreId
) {
const blockId = selectedBlockData.structure.id;
const langData = editSightStore.sightInfo[language];
const article = langData?.right?.find((a) => a.id === blockId);
if (article) {
article.heading = newHeading;
} else if (langData) {
// Если статьи еще нет, добавляем
langData.right.push({ id: blockId, heading: newHeading, body: "" });
}
// Обновить имя в сайдбаре (т.к. blocksForSidebar пересчитается)
// Для этого достаточно, чтобы sightInfo был observable и blocksForSidebar от него зависел
}
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 handleBodyChange = (newBody: string) => {
if (
selectedBlockData &&
selectedBlockData.structure.type === "article" &&
!selectedBlockData.structure.linkedArticleStoreId
) {
const blockId = selectedBlockData.structure.id;
const langData = editSightStore.sightInfo[language];
const article = langData?.right?.find((a) => a.id === blockId);
if (article) {
article.body = newBody;
} else if (langData) {
// Если статьи еще нет, добавляем
langData.right.push({ id: blockId, heading: "", body: newBody });
}
}
};
const handleDeleteBlock = (blockId: string) => {
console.log("Delete block:", blockId);
setRightWidgetBlocks((blocks) => blocks.filter((b) => b.id !== blockId));
setSelectedBlockId(null);
const handleDeleteBlock = (blockIdToDelete: string) => {
setBlockItemsStructure((prev) =>
prev.filter((b) => b.id !== blockIdToDelete)
);
// Удаляем контент из editSightStore для всех языков
["ru", "en", "zh"].forEach((lang) => {
const currentLang = lang as Language;
if (editSightStore.sightInfo[currentLang]) {
editSightStore.sightInfo[currentLang].right =
editSightStore.sightInfo[currentLang].right?.filter(
(r) => r.id !== blockIdToDelete
);
}
});
if (selectedBlockId === blockIdToDelete) {
setSelectedBlockId(
blockItemsStructure.length > 1
? blockItemsStructure.filter((b) => b.id !== blockIdToDelete)[0]?.id
: null
);
}
};
const handleSave = () => {
console.log("Saving right widget...");
// Implement save logic here, e.g., send data to an API
console.log(
"Сохранение Right Widget:",
JSON.stringify(editSightStore.sightInfo, null, 2)
);
// Здесь будет логика отправки editSightStore.sightInfo на сервер
alert("Данные для сохранения (см. консоль)");
};
// 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);
// --- Инициализация контента в сторе для initialBlockStructures (если его там нет) ---
useEffect(() => {
initialBlockStructures.forEach((struct) => {
if (struct.type === "article" && !struct.linkedArticleStoreId) {
const baseName = `Статья ${struct.id.split("_")[1]}`; // Пример "История" или "Факты"
["ru", "en", "zh"].forEach((lang) => {
const currentLang = lang as Language;
if (
editSightStore.sightInfo[currentLang] &&
!editSightStore.sightInfo[currentLang].right?.find(
(r) => r.id === struct.id
)
) {
editSightStore.sightInfo[currentLang].right?.push({
id: struct.id,
heading: `${baseName} (${currentLang.toUpperCase()})`, // Например: "История (RU)"
body: `Начальное содержимое для ${baseName} на ${currentLang.toUpperCase()}.`,
});
}
});
}
});
}, []); // Запускается один раз при монтировании
return (
<TabPanel value={value} index={index}>
<LanguageSwitcher />
<Box
sx={{
display: "flex",
flexDirection: "column",
height: "100%",
minHeight: "calc(100vh - 200px)", // Adjust as needed
minHeight: "calc(100vh - 200px)",
gap: 2,
paddingBottom: "70px", // Space for the save button
paddingBottom: "70px",
position: "relative",
}}
>
<BackButton />
<Box sx={{ display: "flex", flexGrow: 1, gap: 2.5 }}>
<ArticleListSidebar
blocks={rightWidgetBlocks}
selectedBlockId={selectedBlockId}
onSelectBlock={handleSelectBlock}
onCreateNew={handleCreateNew}
onSelectExisting={handleSelectExisting}
/>
<Box sx={{ display: "flex", flexGrow: 1, gap: 2.5, minHeight: 0 }}>
{/* Компонент сайдбара списка блоков */}
<Paper
elevation={1}
sx={{
width: 280,
padding: 1.5,
display: "flex",
flexDirection: "column",
}}
>
<Typography variant="h6" gutterBottom>
Блоки
</Typography>
<Box sx={{ flexGrow: 1, overflowY: "auto" }}>
{blocksForSidebar.map((block) => (
<Button
key={block.id}
fullWidth
variant={
selectedBlockId === block.id ? "contained" : "outlined"
}
onClick={() => handleSelectBlock(block.id)}
sx={{
justifyContent: "flex-start",
mb: 0.5,
textTransform: "none",
}}
>
{block.nameForSidebar}
</Button>
))}
</Box>
<Button
variant="contained"
onClick={handleCreateNewArticle}
sx={{ mt: 1 }}
>
+ Новая статья
</Button>
{/* TODO: Кнопка "Выбрать существующую" */}
</Paper>
<ArticleEditorPane
articleData={currentBlockToEdit}
onDelete={handleDeleteBlock}
/>
{/* Компонент редактора выбранного блока */}
<Paper
elevation={1}
sx={{ flexGrow: 1, padding: 2.5, overflowY: "auto" }}
>
<Typography variant="h6" gutterBottom>
Редактор блока ({language.toUpperCase()})
</Typography>
{selectedBlockData ? (
<Box>
<Typography variant="subtitle1">
ID: {selectedBlockData.structure.id}
</Typography>
<Typography variant="subtitle1">
Тип: {selectedBlockData.structure.type}
</Typography>
{selectedBlockData.structure.type === "media" && (
<Box
my={2}
p={2}
border="1px dashed grey"
height={150}
display="flex"
alignItems="center"
justifyContent="center"
>
<Typography color="textSecondary">
Загрузчик медиа для "{selectedBlockData.structure.id}"
</Typography>
</Box>
)}
{selectedBlockData.structure.type === "article" &&
!selectedBlockData.structure.linkedArticleStoreId &&
selectedBlockData.content && (
<Box mt={2}>
<TextField
fullWidth
label="Заголовок статьи"
value={selectedBlockData.content.heading}
onChange={(e) => handleHeadingChange(e.target.value)}
sx={{ mb: 2 }}
/>
<TextField
fullWidth
multiline
rows={8}
label="Текст статьи"
value={selectedBlockData.content.body}
onChange={(e) => handleBodyChange(e.target.value)}
sx={{ mb: 2 }}
// Здесь позже можно будет вставить SightEdit
/>
{/* TODO: Секция медиа для статьи */}
<Button
color="error"
variant="outlined"
onClick={() =>
handleDeleteBlock(selectedBlockData.structure.id)
}
>
Удалить эту статью
</Button>
</Box>
)}
{selectedBlockData.structure.type === "article" &&
selectedBlockData.structure.linkedArticleStoreId && (
<Box mt={2}>
<Typography>
Это связанная статья:{" "}
{selectedBlockData.structure.linkedArticleStoreId}
</Typography>
{/* TODO: Кнопки "Открепить", "Удалить из списка" */}
</Box>
)}
</Box>
) : (
<Typography color="textSecondary">
Выберите блок для редактирования
</Typography>
)}
</Paper>
</Box>
<Box
sx={{
position: "absolute",
bottom: 0,
left: 0,
right: 0,
padding: 2,
backgroundColor: "background.paper", // Ensure button is visible
width: "100%", // Cover the full width to make it a sticky footer
backgroundColor: "background.paper",
borderTop: "1px solid",
borderColor: "divider",
zIndex: 10,
display: "flex",
justifyContent: "flex-end",
}}
>
<Button variant="contained" color="success" onClick={handleSave}>
<Button
variant="contained"
color="success"
onClick={handleSave}
size="large"
>
Сохранить изменения
</Button>
</Box>
</Box>
<SelectArticleModal
open={isSelectModalOpen}
onClose={handleCloseSelectModal}
onSelectArticle={handleSelectArticle}
linkedArticleIds={linkedArticleIds}
/>
{/* <SelectArticleModal open={isSelectModalOpen} ... /> */}
</TabPanel>
);
}