feat: Sight Page update

This commit is contained in:
2025-06-01 23:18:21 +03:00
parent 87386c6a73
commit a8777a974a
26 changed files with 3460 additions and 727 deletions

View File

@ -0,0 +1,135 @@
import { Box, Button } from "@mui/material";
import { MediaViewer } from "@widgets";
import { PreviewMediaDialog } from "@shared";
import { X, Upload } from "lucide-react";
import { observer } from "mobx-react-lite";
import { useState, DragEvent, useRef } from "react";
export const MediaArea = observer(
({
articleId,
mediaIds,
deleteMedia,
onFilesDrop, // 👈 Проп для обработки загруженных файлов
setSelectMediaDialogOpen,
}: {
articleId: number;
mediaIds: { id: string; media_type: number; filename: string }[];
deleteMedia: (id: number, media_id: string) => void;
onFilesDrop?: (files: File[]) => void;
setSelectMediaDialogOpen: (open: boolean) => void;
}) => {
const [mediaModal, setMediaModal] = useState<boolean>(false);
const [mediaId, setMediaId] = useState<string>("");
const [isDragging, setIsDragging] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleMediaModal = (mediaId: string) => {
setMediaModal(true);
setMediaId(mediaId);
};
const handleDrop = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
const files = Array.from(e.dataTransfer.files);
if (files.length && onFilesDrop) {
onFilesDrop(files);
}
};
const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDragging(true);
};
const handleDragLeave = () => {
setIsDragging(false);
};
const handleClick = () => {
fileInputRef.current?.click();
};
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files || []);
if (files.length && onFilesDrop) {
onFilesDrop(files);
}
// Сбрасываем значение input, чтобы можно было выбрать тот же файл снова
event.target.value = "";
};
return (
<>
<input
type="file"
ref={fileInputRef}
onChange={handleFileSelect}
accept="image/*,video/*,.glb,.gltf"
multiple
style={{ display: "none" }}
/>
<Box className="w-full flex flex-col items-center justify-center border rounded-md p-4">
<div className="w-full flex flex-col items-center justify-center">
<div
className={`w-full h-40 flex flex-col justify-center items-center text-gray-400 border-dashed border-2 rounded-md border-gray-400 p-4 cursor-pointer hover:bg-gray-50 ${
isDragging ? "bg-blue-100 border-blue-400" : ""
}`}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onClick={handleClick}
>
<Upload size={32} className="mb-2" />
Перетащите медиа файлы сюда или нажмите для выбора
</div>
<div>или</div>
<Button
variant="contained"
color="primary"
onClick={() => setSelectMediaDialogOpen(true)}
>
Выбрать существующие медиа файлы
</Button>
</div>
<div className="w-full flex flex-start flex-wrap gap-2 mt-4">
{mediaIds.map((m) => (
<button
className="relative w-40 h-40"
key={m.id}
onClick={() => handleMediaModal(m.id)}
>
<MediaViewer
media={{
id: m.id,
media_type: m.media_type,
filename: m.filename,
}}
/>
<button
className="absolute top-2 right-2"
onClick={(e) => {
e.stopPropagation();
deleteMedia(articleId, m.id);
}}
>
<X size={16} color="red" />
</button>
</button>
))}
</div>
</Box>
<PreviewMediaDialog
open={mediaModal}
onClose={() => setMediaModal(false)}
mediaId={mediaId}
/>
</>
);
}
);

View File

@ -0,0 +1,24 @@
import { Stage, useGLTF } from "@react-three/drei";
import { Canvas } from "@react-three/fiber";
import { OrbitControls } from "@react-three/drei";
export const ModelViewer3D = ({
fileUrl,
height = "100%",
}: {
fileUrl: string;
height: string;
}) => {
const { scene } = useGLTF(fileUrl);
return (
<Canvas style={{ width: "100%", height: height }}>
<ambientLight />
<directionalLight />
<Stage environment="city" intensity={0.6}>
<primitive object={scene} />
</Stage>
<OrbitControls />
</Canvas>
);
};

View File

@ -24,6 +24,14 @@ const StyledMarkdownEditor = styled("div")(({ theme }) => ({
backgroundColor: theme.palette.background.paper,
color: theme.palette.text.primary,
borderColor: theme.palette.divider,
height: "auto",
minHeight: "200px",
maxHeight: "500px",
overflow: "auto",
},
"& .CodeMirror-scroll": {
minHeight: "200px",
maxHeight: "500px",
},
// Стили для текста в редакторе
"& .CodeMirror-selected": {

View File

@ -0,0 +1,158 @@
// import { Box, Button, Paper, Typography } from "@mui/material";
// import { X, Upload } from "lucide-react";
// import { useCallback, useState } from "react";
// import { useDropzone } from "react-dropzone";
// import { UploadMediaDialog } from "@shared";
// import { createSightStore } from "@shared";
// interface MediaUploadBoxProps {
// title: string;
// tooltip?: string;
// mediaId: string | null;
// onMediaSelect: (mediaId: string) => void;
// onMediaRemove: () => void;
// onPreviewClick: (mediaId: string) => void;
// token: string;
// type: "thumbnail" | "watermark_lu" | "watermark_rd";
// }
// export const MediaUploadBox = ({
// title,
// tooltip,
// mediaId,
// onMediaSelect,
// onMediaRemove,
// onPreviewClick,
// token,
// type,
// }: MediaUploadBoxProps) => {
// const [uploadMediaOpen, setUploadMediaOpen] = useState(false);
// const [fileToUpload, setFileToUpload] = useState<File | null>(null);
// const onDrop = useCallback((acceptedFiles: File[]) => {
// if (acceptedFiles.length > 0) {
// setFileToUpload(acceptedFiles[0]);
// setUploadMediaOpen(true);
// }
// }, []);
// const { getRootProps, getInputProps, isDragActive } = useDropzone({
// onDrop,
// accept: {
// "image/*": [".png", ".jpg", ".jpeg", ".gif"],
// },
// multiple: false,
// });
// const handleUploadComplete = async (media: {
// id: string;
// filename: string;
// media_name?: string;
// media_type: number;
// }) => {
// onMediaSelect(media.id);
// };
// return (
// <>
// <Paper
// elevation={2}
// sx={{
// padding: 2,
// display: "flex",
// flexDirection: "column",
// alignItems: "center",
// gap: 1,
// flex: 1,
// minWidth: 150,
// }}
// >
// <Box sx={{ display: "flex", alignItems: "center" }}>
// <Typography variant="subtitle2" gutterBottom sx={{ mb: 0, mr: 0.5 }}>
// {title}
// </Typography>
// </Box>
// <Box
// {...getRootProps()}
// sx={{
// position: "relative",
// width: "200px",
// height: "200px",
// display: "flex",
// alignItems: "center",
// justifyContent: "center",
// borderRadius: 1,
// mb: 1,
// cursor: mediaId ? "pointer" : "default",
// border: isDragActive ? "2px dashed #1976d2" : "none",
// backgroundColor: isDragActive
// ? "rgba(25, 118, 210, 0.04)"
// : "transparent",
// transition: "all 0.2s ease",
// }}
// >
// <input {...getInputProps()} />
// {mediaId && (
// <button
// className="absolute top-2 right-2 z-10"
// onClick={(e) => {
// e.stopPropagation();
// onMediaRemove();
// }}
// >
// <X color="red" />
// </button>
// )}
// {mediaId ? (
// <img
// src={`${
// import.meta.env.VITE_KRBL_MEDIA
// }${mediaId}/download?token=${token}`}
// alt={title}
// style={{ maxWidth: "100%", maxHeight: "100%" }}
// onClick={(e) => {
// e.stopPropagation();
// onPreviewClick(mediaId);
// }}
// />
// ) : (
// <div className="w-full flex flex-col items-center justify-center gap-3">
// <div
// className={`w-full h-20 border rounded-md flex flex-col items-center transition-all duration-300 justify-center border-dashed ${
// isDragActive
// ? "border-blue-500 bg-blue-50"
// : "border-gray-300"
// } cursor-pointer hover:bg-gray-100`}
// >
// <Upload size={24} className="mb-2" />
// <p>
// {isDragActive ? "Отпустите файл здесь" : "Перетащите файл"}
// </p>
// </div>
// <p>или</p>
// <Button
// variant="contained"
// color="primary"
// onClick={(e) => {
// e.stopPropagation();
// onMediaSelect("");
// }}
// >
// Выбрать файл
// </Button>
// </div>
// )}
// </Box>
// </Paper>
// <UploadMediaDialog
// open={uploadMediaOpen}
// onClose={() => {
// setUploadMediaOpen(false);
// setFileToUpload(null);
// }}
// afterUpload={handleUploadComplete}
// />
// </>
// );
// };

View File

@ -0,0 +1,582 @@
import {
Button,
TextField,
Box,
Autocomplete,
Typography,
Paper,
Tooltip,
MenuItem,
Menu as MuiMenu,
} from "@mui/material";
import {
BackButton,
TabPanel,
languageStore,
Language,
cityStore,
SelectMediaDialog,
PreviewMediaDialog,
SightLanguageInfo,
SightCommonInfo,
createSightStore,
} from "@shared";
import { LanguageSwitcher } from "@widgets";
import { Info, X } from "lucide-react";
import { observer } from "mobx-react-lite";
import { useEffect, useState } from "react";
import { toast } from "react-toastify";
// Мокап для всплывающей подсказки
export const CreateInformationTab = observer(
({ value, index }: { value: number; index: number }) => {
const { cities } = cityStore;
const [, setIsMediaModalOpen] = useState(false);
const [mediaId, setMediaId] = useState<string>("");
const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false);
const { language } = languageStore;
const { sight, updateSightInfo, createSight } = createSightStore;
const data = sight[language];
const [, setCity] = useState<number>(sight.city_id ?? 0);
const [coordinates, setCoordinates] = useState<string>(`0 0`);
const token = localStorage.getItem("token");
// Menu state for each media button
const [menuAnchorEl, setMenuAnchorEl] = useState<null | HTMLElement>(null);
const [activeMenuType, setActiveMenuType] = useState<
"thumbnail" | "watermark_lu" | "watermark_rd" | null
>(null);
const [isAddMediaOpen, setIsAddMediaOpen] = useState(false);
// const handleMenuOpen = (
// event: React.MouseEvent<HTMLElement>,
// type: "thumbnail" | "watermark_lu" | "watermark_rd"
// ) => {
// setMenuAnchorEl(event.currentTarget);
// setActiveMenuType(type);
// };
useEffect(() => {
// Показывать только при инициализации (не менять при ошибках пользователя)
if (sight.latitude !== 0 || sight.longitude !== 0) {
setCoordinates(`${sight.latitude} ${sight.longitude}`);
}
// если координаты обнулились — оставить поле как есть
}, [sight.latitude, sight.longitude]);
const handleMenuClose = () => {
setMenuAnchorEl(null);
setActiveMenuType(null);
};
const handleCreateNew = () => {
handleMenuClose();
};
const handleAddMedia = () => {
setIsAddMediaOpen(true);
handleMenuClose();
};
const handleChange = (
content: Partial<SightLanguageInfo | SightCommonInfo>,
language?: Language
) => {
if (language) {
updateSightInfo(content, language);
} else {
updateSightInfo(content);
}
};
const handleMediaSelect = (
media: {
id: string;
filename: string;
media_name?: string;
media_type: number;
},
type: "thumbnail" | "watermark_lu" | "watermark_rd"
) => {
handleChange({
[type]: media.id,
});
setActiveMenuType(null);
};
return (
<>
<TabPanel value={value} index={index}>
<Box
sx={{
display: "flex",
flexDirection: "column",
gap: 3,
position: "relative",
paddingBottom: "70px" /* Space for save button */,
}}
>
<BackButton />
<Box
sx={{
display: "flex",
gap: 4, // Added gap between the two main columns
width: "100%",
flexDirection: "column",
}}
>
{/* Left column with main fields */}
<Box
sx={{
flexGrow: 1,
display: "flex",
width: "80%",
flexDirection: "column",
gap: 2.5,
}}
>
<TextField
label={`Название (${language.toUpperCase()})`}
value={data.name}
onChange={(e) => {
handleChange(
{
name: e.target.value,
},
language
);
}}
fullWidth
variant="outlined"
/>
<TextField
label="Адрес"
value={data.address}
onChange={(e) => {
handleChange(
{
address: e.target.value,
},
language
);
}}
fullWidth
variant="outlined"
/>
<Autocomplete
options={cities ?? []}
value={
cities.find((city) => city.id === sight.city_id) ?? null
}
getOptionLabel={(option) => option.name}
onChange={(_, value) => {
setCity(value?.id ?? 0);
handleChange({
city_id: value?.id ?? 0,
});
}}
renderInput={(params) => (
<TextField {...params} label="Город" />
)}
/>
<TextField
label="Координаты"
value={coordinates}
onChange={(e) => {
const input = e.target.value;
setCoordinates(input); // показываем как есть
const [latStr, lonStr] = input.split(/\s+/); // учитываем любые пробелы
const lat = parseFloat(latStr);
const lon = parseFloat(lonStr);
// Проверка, что обе координаты валидные числа
const isValidLat = !isNaN(lat);
const isValidLon = !isNaN(lon);
if (isValidLat && isValidLon) {
handleChange({
latitude: lat,
longitude: lon,
});
} else {
handleChange(
{
latitude: 0,
longitude: 0,
},
language
);
}
}}
fullWidth
variant="outlined"
placeholder="Введите координаты в формате: широта долгота"
/>
</Box>
<Box
sx={{
display: "flex",
gap: 4,
}}
>
<Box
sx={{
display: "flex",
justifyContent: "space-around",
width: "80%",
gap: 2,
flexDirection: { xs: "column", sm: "row" }, // Stack on extra small, side-by-side on small and up
}}
>
<Paper
elevation={2}
sx={{
padding: 2,
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 1,
flex: 1,
minWidth: 150, // Ensure a minimum width
}}
>
<Box sx={{ display: "flex", alignItems: "center" }}>
<Typography
variant="subtitle2"
gutterBottom
sx={{ mb: 0, mr: 0.5 }}
>
Логотип
</Typography>
</Box>
<Box
sx={{
position: "relative",
width: "200px",
height: "200px",
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 1,
mb: 1,
cursor: sight.thumbnail ? "pointer" : "default",
}}
onClick={() => {
setIsMediaModalOpen(true);
}}
>
{sight.thumbnail && (
<button
className="absolute top-2 right-2"
onClick={() => {
handleChange({
thumbnail: null,
});
setActiveMenuType(null);
}}
>
<X color="red" />
</button>
)}
{sight.thumbnail ? (
<img
src={`${import.meta.env.VITE_KRBL_MEDIA}${
sight.thumbnail
}/download?token=${token}`}
alt="Логотип"
style={{ maxWidth: "100%", maxHeight: "100%" }}
onClick={() => {
setIsPreviewMediaOpen(true);
setMediaId(sight.thumbnail ?? "");
}}
/>
) : (
<div className="w-full flex flex-col items-center justify-center gap-3">
<div className="w-full h-20 border rounded-md flex flex-col items-center transition-all duration-300 justify-center border-dashed border-gray-300 cursor-pointer hover:bg-gray-100">
<p>Перетащите файл</p>
</div>
<p>или</p>
<Button
variant="contained"
color="primary"
onClick={() => {
setIsAddMediaOpen(true);
setActiveMenuType("thumbnail");
}}
>
Выбрать файл
</Button>
</div>
)}
</Box>
</Paper>
<Paper
elevation={2}
sx={{
padding: 2,
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 1,
flex: 1,
minWidth: 150, // Ensure a minimum width
}}
>
<Box sx={{ display: "flex", alignItems: "center" }}>
<Typography
variant="subtitle2"
gutterBottom
sx={{ mb: 0, mr: 0.5 }}
>
Водяной знак (л.в)
</Typography>
<Tooltip title={"asf"}>
<Info
size={16}
color="gray"
style={{ cursor: "pointer" }}
/>
</Tooltip>
</Box>
<Box
sx={{
position: "relative",
width: "200px",
height: "200px",
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 1,
mb: 1,
cursor: sight.watermark_lu ? "pointer" : "default",
}}
onClick={() => {
setIsMediaModalOpen(true);
}}
>
{sight.watermark_lu && (
<button
className="absolute top-2 right-2"
onClick={() => {
handleChange({
watermark_lu: null,
});
setActiveMenuType(null);
}}
>
<X color="red" />
</button>
)}
{sight.watermark_lu ? (
<img
src={`${import.meta.env.VITE_KRBL_MEDIA}${
sight.watermark_lu
}/download?token=${token}`}
alt="Логотип"
style={{ maxWidth: "100%", maxHeight: "100%" }}
onClick={() => {
setIsPreviewMediaOpen(true);
setMediaId(sight.watermark_lu ?? "");
}}
/>
) : (
<div className="w-full flex flex-col items-center justify-center gap-3">
<div className="w-full h-20 border rounded-md flex flex-col items-center transition-all duration-300 justify-center border-dashed border-gray-300 cursor-pointer hover:bg-gray-100">
<p>Перетащите файл</p>
</div>
<p>или</p>
<Button
variant="contained"
color="primary"
onClick={() => {
setActiveMenuType("watermark_lu");
setIsAddMediaOpen(true);
}}
>
Выбрать файл
</Button>
</div>
)}
</Box>
</Paper>
<Paper
elevation={2}
sx={{
padding: 2,
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 1,
flex: 1,
minWidth: 150, // Ensure a minimum width
}}
>
<Box sx={{ display: "flex", alignItems: "center" }}>
<Typography
variant="subtitle2"
gutterBottom
sx={{ mb: 0, mr: 0.5 }}
>
Водяной знак (п.в)
</Typography>
<Tooltip title={"asfaf"}>
<Info
size={16}
color="gray"
style={{ cursor: "pointer" }}
/>
</Tooltip>
</Box>
<Box
sx={{
position: "relative",
width: "200px",
height: "200px",
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 1,
mb: 1,
cursor: sight.watermark_rd ? "pointer" : "default",
}}
onClick={() => {
setIsMediaModalOpen(true);
}}
>
{sight.watermark_rd && (
<button
className="absolute top-2 right-2"
onClick={() => {
handleChange({
watermark_rd: null,
});
setActiveMenuType(null);
}}
>
<X color="red" />
</button>
)}
{sight.watermark_rd ? (
<img
src={`${import.meta.env.VITE_KRBL_MEDIA}${
sight.watermark_rd
}/download?token=${token}`}
alt="Логотип"
style={{ maxWidth: "100%", maxHeight: "100%" }}
onClick={() => {
setIsPreviewMediaOpen(true);
setMediaId(sight.watermark_rd ?? "");
}}
/>
) : (
<div className="w-full flex flex-col items-center justify-center gap-3">
<div className="w-full h-20 border rounded-md flex flex-col items-center transition-all duration-300 justify-center border-dashed border-gray-300 cursor-pointer hover:bg-gray-100">
<p>Перетащите файл</p>
</div>
<p>или</p>
<Button
variant="contained"
color="primary"
onClick={() => {
setActiveMenuType("watermark_rd");
setIsAddMediaOpen(true);
}}
>
Выбрать файл
</Button>
</div>
)}
</Box>
</Paper>
</Box>
</Box>
</Box>
{/* LanguageSwitcher positioned at the top right */}
<LanguageSwitcher />
{/* Save Button fixed at the bottom right */}
<Box
sx={{
position: "absolute",
bottom: 0,
right: 0,
padding: 2,
backgroundColor: "background.paper", // To ensure it stands out over content
width: "100%", // Take full width to cover content below it
display: "flex",
justifyContent: "flex-end", // Align to the right
}}
>
<Button
variant="contained"
color="success"
onClick={async () => {
await createSight(language);
toast.success("Достопримечательность создана");
}}
>
Сохранить
</Button>
</Box>
</Box>
</TabPanel>
{/* Media Menu */}
<MuiMenu
anchorEl={menuAnchorEl}
open={Boolean(menuAnchorEl)}
onClose={handleMenuClose}
anchorOrigin={{
vertical: "top",
horizontal: "right",
}}
transformOrigin={{
vertical: "bottom",
horizontal: "right",
}}
>
<MenuItem onClick={handleCreateNew}>Создать новую</MenuItem>
<MenuItem onClick={handleAddMedia}>Выбрать существующую</MenuItem>
</MuiMenu>
<SelectMediaDialog
open={isAddMediaOpen}
onClose={() => {
setIsAddMediaOpen(false);
setActiveMenuType(null);
}}
onSelectMedia={(media) => {
handleMediaSelect(media, activeMenuType ?? "thumbnail");
}}
/>
<PreviewMediaDialog
open={isPreviewMediaOpen}
onClose={() => setIsPreviewMediaOpen(false)}
mediaId={mediaId}
/>
</>
);
}
);

View File

@ -0,0 +1,451 @@
// @widgets/LeftWidgetTab.tsx
import { Box, Button, TextField, Paper, Typography } from "@mui/material";
import {
BackButton,
TabPanel,
languageStore,
SelectMediaDialog,
editSightStore,
createSightStore,
SelectArticleModal,
UploadMediaDialog,
} from "@shared";
import {
LanguageSwitcher,
MediaArea,
ReactMarkdownComponent,
ReactMarkdownEditor,
MediaViewer,
} from "@widgets";
import { Trash2, ImagePlus } from "lucide-react";
import { useState, useCallback } from "react";
import { observer } from "mobx-react-lite";
import { toast } from "react-toastify";
export const CreateLeftTab = observer(
({ value, index }: { value: number; index: number }) => {
const {
sight,
updateSightInfo,
updateLeftArticle,
createSight,
deleteLeftArticle,
createLeftArticle,
unlinkLeftArticle,
createLinkWithArticle,
} = createSightStore;
const {
deleteMedia,
setFileToUpload,
uploadMediaOpen,
setUploadMediaOpen,
} = editSightStore;
const { language } = languageStore;
const [isSelectArticleDialogOpen, setIsSelectArticleDialogOpen] =
useState(false);
const [isSelectMediaDialogOpen, setIsSelectMediaDialogOpen] =
useState(false);
// const handleMediaSelected = useCallback(() => {
// // При выборе медиа, обновляем данные для ТЕКУЩЕГО ЯЗЫКА
// // сохраняя текущие heading и body.
// updateSightInfo(language, {
// left: {
// heading: data.left.heading,
// body: data.left.body,
// },
// });
// setIsSelectMediaDialogOpen(false);
// }, [language, data.left.heading, data.left.body]);
const handleCloseArticleDialog = useCallback(() => {
setIsSelectArticleDialogOpen(false);
}, []);
const handleCloseMediaDialog = useCallback(() => {
setIsSelectMediaDialogOpen(false);
}, []);
const handleMediaSelected = useCallback(
async (media: {
id: string;
filename: string;
media_name?: string;
media_type: number;
}) => {
await createLinkWithArticle(media);
setIsSelectMediaDialogOpen(false);
},
[createLinkWithArticle]
);
const handleArticleSelect = useCallback(
(articleId: number) => {
updateLeftArticle(articleId);
},
[updateLeftArticle]
);
return (
<TabPanel value={value} index={index}>
<LanguageSwitcher />
<Box
sx={{
display: "flex",
flexDirection: "column",
gap: 3,
paddingBottom: "70px",
position: "relative",
}}
>
<BackButton />
<Paper
elevation={2}
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
paddingX: 2.5,
paddingY: 1.5,
borderRadius: 2,
border: "1px solid",
borderColor: "divider",
}}
>
<Typography variant="h6">Левая статья</Typography>
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
{sight.left_article ? (
<>
<Button
variant="contained"
color="primary"
size="small"
style={{ transition: "0" }}
onClick={() => {
unlinkLeftArticle();
toast.success("Статья откреплена");
}}
>
Открепить
</Button>
<Button
variant="outlined"
color="error"
style={{ transition: "0" }}
startIcon={<Trash2 size={18} />}
size="small"
onClick={() => {
deleteLeftArticle(sight.left_article);
toast.success("Статья откреплена");
}}
>
Удалить
</Button>
</>
) : (
<>
<Button
variant="contained"
color="primary"
size="small"
onClick={() => setIsSelectArticleDialogOpen(true)}
>
Выбрать статью
</Button>
<Button
variant="contained"
color="primary"
size="small"
style={{ transition: "0" }}
onClick={createLeftArticle}
>
Создать статью
</Button>
</>
)}
</Box>
</Paper>
{sight.left_article > 0 && (
<>
<Box sx={{ display: "flex", gap: 3, flexGrow: 1 }}>
{/* Левая колонка: Редактирование */}
<Box
sx={{
flex: 2,
display: "flex",
flexDirection: "column",
gap: 2,
}}
>
<TextField
label="Название информации"
value={sight[language].left.heading}
onChange={(e) =>
updateSightInfo(
{
left: {
heading: e.target.value,
body: sight[language].left.body,
media: sight[language].left.media,
},
},
language
)
}
variant="outlined"
fullWidth
/>
<ReactMarkdownEditor
value={sight[language].left.body}
onChange={(value) =>
updateSightInfo(
{
left: {
heading: sight[language].left.heading,
body: value,
media: sight[language].left.media,
},
},
language
)
}
/>
<MediaArea
articleId={sight.left_article}
mediaIds={sight[language].left.media}
deleteMedia={deleteMedia}
setSelectMediaDialogOpen={setIsSelectMediaDialogOpen}
onFilesDrop={(files) => {
setFileToUpload(files[0]);
setUploadMediaOpen(true);
}}
/>
{/* Блок МЕДИА для статьи */}
{/* <Paper elevation={1} sx={{ padding: 2, mt: 1 }}>
<Typography variant="h6" gutterBottom>
МЕДИА
</Typography>
{data.left.media ? (
<Box sx={{ mb: 1 }}>
<img
src={data.left.media.filename}
alt="Selected media"
style={{
maxWidth: "100%",
maxHeight: "150px",
objectFit: "contain",
}}
/>
</Box>
) : (
<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"
startIcon={<ImagePlus size={18} />}
onClick={handleOpenMediaDialog}
>
Выбрать/Загрузить медиа
</Button>
{data.left.media && (
<Button
variant="outlined"
color="error"
size="small"
sx={{ ml: 1 }}
onClick={() =>
updateSightInfo(
languageStore.language,
{
left: {
heading: data.left.heading,
body: data.left.body,
media: null,
},
},
false
)
}
>
Удалить медиа
</Button>
)}
</Paper> */}
</Box>
{/* Правая колонка: Предпросмотр */}
<Box
sx={{
flex: 1,
display: "flex",
flexDirection: "column",
gap: 1.5,
}}
>
<Paper
elevation={3}
sx={{
width: "100%",
minWidth: 320,
maxWidth: 400,
height: "auto",
minHeight: 500,
backgroundColor: "#877361",
overflowY: "auto",
padding: 0,
display: "flex",
flexDirection: "column",
}}
>
{/* {data.left.media?.filename ? (
<Box
sx={{
width: "100%",
height: 200,
backgroundColor: "grey.300",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<img
src={data.left.media?.filename ?? ""}
alt="Превью медиа"
style={{
objectFit: "cover",
width: "100%",
height: "100%",
}}
/>
</Box>
) : (
)} */}
<Box
sx={{
width: "100%",
height: 200,
backgroundColor: "grey.300",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{sight[language].left.media.length > 0 ? (
<MediaViewer
media={{
id: sight[language].left.media[0].id,
media_type:
sight[language].left.media[0].media_type,
filename: sight[language].left.media[0].filename,
}}
/>
) : (
<ImagePlus size={48} color="grey" />
)}
</Box>
{/* Заголовок в превью */}
<Box
sx={{
backgroundColor: "#877361",
color: "white",
padding: 1.5,
}}
>
<Typography
variant="h5"
component="h2"
sx={{ wordBreak: "break-word" }}
>
{sight[language].left.heading || "Название информации"}
</Typography>
</Box>
{/* Текст статьи в превью */}
<Box
sx={{
padding: 2,
flexGrow: 1,
}}
>
<ReactMarkdownComponent
value={sight[language].left.body}
/>
</Box>
</Paper>
</Box>
</Box>
<Box
sx={{ position: "absolute", bottom: 0, right: 0, padding: 2 }}
>
<Button
variant="contained"
color="success"
onClick={async () => {
try {
await createSight(language);
toast.success("Странца создана");
} catch (error) {
console.error(error);
}
}}
>
Сохранить
</Button>
</Box>
</>
)}
</Box>
{/* <SelectMediaDialog
open={isSelectMediaDialogOpen}
onClose={handleCloseMediaDialog}
onSelectMedia={handleArticleSelect}
/> */}
<SelectMediaDialog
open={isSelectMediaDialogOpen}
onClose={handleCloseMediaDialog}
onSelectMedia={handleMediaSelected}
/>
<UploadMediaDialog
open={uploadMediaOpen}
onClose={() => setUploadMediaOpen(false)}
afterUpload={async (media) => {
setUploadMediaOpen(false);
setFileToUpload(null);
await createLinkWithArticle(media);
}}
/>
<SelectArticleModal
open={isSelectArticleDialogOpen}
onClose={handleCloseArticleDialog}
onSelectArticle={handleArticleSelect}
/>
</TabPanel>
);
}
);

View File

@ -0,0 +1,374 @@
import {
Box,
Button,
Paper,
Typography,
Menu,
MenuItem,
TextField,
} from "@mui/material";
import { BackButton, createSightStore, languageStore, TabPanel } from "@shared";
import {
LanguageSwitcher,
ReactMarkdownComponent,
ReactMarkdownEditor,
} from "@widgets";
import { ImagePlus, Plus } from "lucide-react";
import { observer } from "mobx-react-lite";
import { useState } from "react";
// --- RightWidgetTab (Parent) Component ---
export const CreateRightTab = observer(
({ value, index }: { value: number; index: number }) => {
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const { sight, createNewRightArticle, updateRightArticleInfo } =
createSightStore;
const { language } = languageStore;
const [activeArticleIndex, setActiveArticleIndex] = useState<number | null>(
null
);
const open = Boolean(anchorEl);
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
const handleSave = () => {
console.log("Saving right widget...");
};
const handleSelectArticle = (index: number) => {
setActiveArticleIndex(index);
};
return (
<TabPanel value={value} index={index}>
<LanguageSwitcher />
<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 }}>
<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={() => {
// setMediaType("preview");
}}
className="w-full bg-gray-200 p-4 rounded-2xl cursor-pointer text-sm hover:bg-gray-300 transition-all duration-300"
>
<Typography>Предпросмотр медиа</Typography>
</Box>
{sight[language].right.map((article, index) => (
<Box
key={index}
className="w-full bg-gray-200 p-4 rounded-2xl text-sm cursor-pointer hover:bg-gray-300 transition-all duration-300"
onClick={() => {
handleSelectArticle(index);
}}
>
<Typography>{article.heading}</Typography>
</Box>
))}
</Box>
<button
className="w-10 h-10 bg-blue-500 rounded-full absolute bottom-5 left-5 flex items-center justify-center"
onClick={handleClick}
>
<Plus size={20} color="white" />
</button>
<Menu
id="basic-menu"
anchorEl={anchorEl}
open={open}
onClose={handleClose}
MenuListProps={{
"aria-labelledby": "basic-button",
}}
sx={{
mt: 1,
}}
>
<MenuItem
onClick={() => {
createNewRightArticle();
handleClose();
}}
>
<Typography>Создать новую</Typography>
</MenuItem>
<MenuItem
onClick={() => {
handleClose();
}}
>
<Typography>Выбрать существующую статью</Typography>
</MenuItem>
</Menu>
</Box>
<Box className="w-[80%] h-[70vh] border border-gray-300 rounded-2xl p-3">
{activeArticleIndex !== null && (
<>
<Box className="flex justify-end gap-2 mb-3">
<Button variant="contained" color="primary">
Открепить
</Button>
<Button variant="contained" color="success">
Удалить
</Button>
</Box>
<Box sx={{ display: "flex", gap: 3, flexGrow: 1 }}>
{/* Левая колонка: Редактирование */}
<Box
sx={{
flex: 2,
display: "flex",
flexDirection: "column",
gap: 2,
}}
>
<TextField
label="Название информации"
value={
sight[language].right[activeArticleIndex].heading
}
onChange={(e) =>
updateRightArticleInfo(
activeArticleIndex,
language,
e.target.value,
sight[language].right[activeArticleIndex].body
)
}
variant="outlined"
fullWidth
/>
<ReactMarkdownEditor
value={
sight[language].right[activeArticleIndex].body
}
onChange={(value) =>
updateRightArticleInfo(
activeArticleIndex,
language,
sight[language].right[activeArticleIndex]
.heading,
value
)
}
/>
</Box>
{/* Блок МЕДИА для статьи */}
{/* <Paper elevation={1} sx={{ padding: 2, mt: 1 }}>
<Typography variant="h6" gutterBottom>
МЕДИА
</Typography>
{data.left.media ? (
<Box sx={{ mb: 1 }}>
<img
src={data.left.media.filename}
alt="Selected media"
style={{
maxWidth: "100%",
maxHeight: "150px",
objectFit: "contain",
}}
/>
</Box>
) : (
<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"
startIcon={<ImagePlus size={18} />}
onClick={handleOpenMediaDialog}
>
Выбрать/Загрузить медиа
</Button>
{data.left.media && (
<Button
variant="outlined"
color="error"
size="small"
sx={{ ml: 1 }}
onClick={() =>
updateSightInfo(
languageStore.language,
{
left: {
heading: data.left.heading,
body: data.left.body,
media: null,
},
},
false
)
}
>
Удалить медиа
</Button>
)}
</Paper> */}
</Box>
</>
)}
</Box>
</Box>
</Box>
<Box className="w-[25%] mr-10">
{activeArticleIndex !== null && (
<Paper
className="flex-1 flex flex-col rounded-2xl"
elevation={2}
>
<Box
className="rounded-2xl overflow-hidden"
sx={{
width: "100%",
height: "75vh",
background: "#877361",
borderColor: "grey.300",
display: "flex",
flexDirection: "column",
}}
>
{false ? (
<Box
sx={{
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Typography color="white">Загрузка...</Typography>
</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={{
width: "100%",
minHeight: "70px",
background: "#877361",
display: "flex",
flexShrink: 0,
alignItems: "center",
borderBottom: "1px solid rgba(255,255,255,0.1)",
px: 2,
}}
>
<Typography variant="h6" color="white">
{sight[language].right[activeArticleIndex]
.heading || "Выберите статью"}
</Typography>
</Box>
<Box
sx={{
px: 2,
flexGrow: 1,
overflowY: "auto",
backgroundColor: "#877361",
color: "white",
py: 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>
</Paper>
)}
</Box>
</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={openedType === "article"}
onClose={handleCloseSelectModal}
onSelectArticle={handleSelectArticle}
linkedArticleIds={linkedArticleIds}
/> */}
</TabPanel>
);
}
);

View File

@ -27,6 +27,8 @@ import { Info, ImagePlus } from "lucide-react";
import { observer } from "mobx-react-lite";
import { useEffect, useState } from "react";
import { toast } from "react-toastify";
// Мокап для всплывающей подсказки
export const InformationTab = observer(
@ -37,12 +39,10 @@ export const InformationTab = observer(
const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false);
const { language } = languageStore;
const { sight, updateSightInfo } = editSightStore;
const data = sight[language];
const common = sight.common;
const { sight, updateSightInfo, updateSight } = editSightStore;
const [, setCity] = useState<number>(common.city_id ?? 0);
const [, setCity] = useState<number>(sight.common.city_id ?? 0);
const [coordinates, setCoordinates] = useState<string>(`0 0`);
const token = localStorage.getItem("token");
@ -54,21 +54,13 @@ export const InformationTab = observer(
>(null);
const [isAddMediaOpen, setIsAddMediaOpen] = useState(false);
const handleMenuOpen = (
event: React.MouseEvent<HTMLElement>,
type: "thumbnail" | "watermark_lu" | "watermark_rd"
) => {
setMenuAnchorEl(event.currentTarget);
setActiveMenuType(type);
};
useEffect(() => {
// Показывать только при инициализации (не менять при ошибках пользователя)
if (common.latitude !== 0 || common.longitude !== 0) {
setCoordinates(`${common.latitude} ${common.longitude}`);
if (sight.common.latitude !== 0 || sight.common.longitude !== 0) {
setCoordinates(`${sight.common.latitude} ${sight.common.longitude}`);
}
// если координаты обнулились — оставить поле как есть
}, [common.latitude, common.longitude]);
}, [sight.common.latitude, sight.common.longitude]);
const handleMenuClose = () => {
setMenuAnchorEl(null);
@ -135,7 +127,7 @@ export const InformationTab = observer(
>
<TextField
label={`Название (${language.toUpperCase()})`}
value={data.name}
value={sight[language].name}
onChange={(e) => {
handleChange(language as Language, {
name: e.target.value,
@ -147,7 +139,7 @@ export const InformationTab = observer(
<TextField
label="Адрес"
value={data.address}
value={sight[language].address}
onChange={(e) => {
handleChange(language as Language, {
address: e.target.value,
@ -160,18 +152,15 @@ export const InformationTab = observer(
<Autocomplete
options={cities ?? []}
value={
cities.find((city) => city.id === common.city_id) ?? null
cities.find((city) => city.id === sight.common.city_id) ??
null
}
getOptionLabel={(option) => option.name}
onChange={(_, value) => {
setCity(value?.id ?? 0);
handleChange(
language as Language,
{
city_id: value?.id ?? 0,
},
true
);
handleChange(language as Language, {
city_id: value?.id ?? 0,
});
}}
renderInput={(params) => (
<TextField {...params} label="Город" />
@ -195,15 +184,23 @@ export const InformationTab = observer(
const isValidLon = !isNaN(lon);
if (isValidLat && isValidLon) {
handleChange(language as Language, {
latitude: lat,
longitude: lon,
});
handleChange(
language as Language,
{
latitude: lat,
longitude: lon,
},
true
);
} else {
handleChange(language as Language, {
latitude: 0,
longitude: 0,
});
handleChange(
language as Language,
{
latitude: 0,
longitude: 0,
},
true
);
}
}}
fullWidth
@ -251,17 +248,18 @@ export const InformationTab = observer(
</Box>
<Box
sx={{
width: 80,
height: 80,
position: "relative",
width: "200px",
height: "200px",
backgroundColor: "grey.200",
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 1,
mb: 1,
cursor: common.thumbnail ? "pointer" : "default",
cursor: sight.common.thumbnail ? "pointer" : "default",
"&:hover": {
backgroundColor: common.thumbnail
backgroundColor: sight.common.thumbnail
? "red.300"
: "grey.200",
},
@ -270,29 +268,22 @@ export const InformationTab = observer(
setIsMediaModalOpen(true);
}}
>
{common.thumbnail ? (
{sight.common.thumbnail ? (
<img
src={`${import.meta.env.VITE_KRBL_MEDIA}${
common.thumbnail
sight.common.thumbnail
}/download?token=${token}`}
alt="Логотип"
style={{ maxWidth: "100%", maxHeight: "100%" }}
onClick={() => {
setIsPreviewMediaOpen(true);
setMediaId(common.thumbnail);
setMediaId(sight.common.thumbnail ?? "");
}}
/>
) : (
<ImagePlus size={24} color="grey" />
)}
</Box>
<Button
variant="outlined"
size="small"
onClick={(e) => handleMenuOpen(e, "thumbnail")}
>
Выбрать
</Button>
</Paper>
<Paper
elevation={2}
@ -324,49 +315,45 @@ export const InformationTab = observer(
</Box>
<Box
sx={{
width: 80,
height: 80,
position: "relative",
width: "200px",
height: "200px",
backgroundColor: "grey.200",
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 1,
mb: 1,
cursor: common.watermark_lu ? "pointer" : "default",
cursor: sight.common.watermark_lu
? "pointer"
: "default",
"&:hover": {
backgroundColor: common.watermark_lu
backgroundColor: sight.common.watermark_lu
? "grey.300"
: "grey.200",
},
}}
onClick={() => {
setIsPreviewMediaOpen(true);
setMediaId(common.watermark_lu);
setMediaId(sight.common.watermark_lu ?? "");
}}
>
{common.watermark_lu ? (
{sight.common.watermark_lu ? (
<img
src={`${import.meta.env.VITE_KRBL_MEDIA}${
common.watermark_lu
sight.common.watermark_lu
}/download?token=${token}`}
alt="Знак л.в"
style={{ maxWidth: "100%", maxHeight: "100%" }}
onClick={() => {
setIsMediaModalOpen(true);
setMediaId(common.watermark_lu);
setMediaId(sight.common.watermark_lu ?? "");
}}
/>
) : (
<ImagePlus size={24} color="grey" />
)}
</Box>
<Button
variant="outlined"
size="small"
onClick={(e) => handleMenuOpen(e, "watermark_lu")}
>
Выбрать
</Button>
</Paper>
<Paper
@ -399,49 +386,45 @@ export const InformationTab = observer(
</Box>
<Box
sx={{
width: 80,
height: 80,
position: "relative",
width: "200px",
height: "200px",
backgroundColor: "grey.200",
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 1,
mb: 1,
cursor: common.watermark_rd ? "pointer" : "default",
cursor: sight.common.watermark_rd
? "pointer"
: "default",
"&:hover": {
backgroundColor: common.watermark_rd
backgroundColor: sight.common.watermark_rd
? "grey.300"
: "grey.200",
},
}}
onClick={() => {
setIsMediaModalOpen(true);
setMediaId(common.watermark_rd);
setMediaId(sight.common.watermark_rd ?? "");
}}
>
{common.watermark_rd ? (
{sight.common.watermark_rd ? (
<img
src={`${import.meta.env.VITE_KRBL_MEDIA}${
common.watermark_rd
sight.common.watermark_rd
}/download?token=${token}`}
alt="Знак п.в"
style={{ maxWidth: "100%", maxHeight: "100%" }}
onClick={() => {
setIsPreviewMediaOpen(true);
setMediaId(common.watermark_rd);
setMediaId(sight.common.watermark_rd ?? "");
}}
/>
) : (
<ImagePlus size={24} color="grey" />
)}
</Box>
<Button
variant="outlined"
size="small"
onClick={(e) => handleMenuOpen(e, "watermark_rd")}
>
Выбрать
</Button>
</Paper>
</Box>
</Box>
@ -467,8 +450,9 @@ export const InformationTab = observer(
<Button
variant="contained"
color="success"
onClick={() => {
console.log(sight);
onClick={async () => {
await updateSight();
toast.success("Достопримечательность сохранена");
}}
>
Сохранить

View File

@ -1,326 +1,345 @@
// @widgets/LeftWidgetTab.tsx
import { Box, Button, TextField, Paper, Typography } from "@mui/material";
import {
articlesStore,
BackButton,
TabPanel,
languageStore,
SelectMediaDialog,
editSightStore,
SelectArticleModal,
UploadMediaDialog,
} from "@shared";
import {
LanguageSwitcher,
ReactMarkdownComponent,
ReactMarkdownEditor,
MediaArea,
MediaViewer,
} from "@widgets";
import { Unlink, Trash2, ImagePlus } from "lucide-react";
import { Trash2, ImagePlus } from "lucide-react";
import { useState, useCallback } from "react";
import { observer } from "mobx-react-lite";
import { toast } from "react-toastify";
export const LeftWidgetTab = observer(
({ value, index }: { value: number; index: number }) => {
const { sight, updateSightInfo } = editSightStore;
const { getArticleByArticleId } = articlesStore;
const {
sight,
updateSightInfo,
unlinkLeftArticle,
updateSight,
deleteLeftArticle,
createLeftArticle,
deleteMedia,
uploadMediaOpen,
setUploadMediaOpen,
const linkedArticle = getArticleByArticleId.get(); // Получаем связанную статью
const data = sight[languageStore.language]; // Получаем данные для текущего языка
setFileToUpload,
createLinkWithArticle,
} = editSightStore;
const { language } = languageStore;
const data = sight[language];
const [isSelectMediaDialogOpen, setIsSelectMediaDialogOpen] =
useState(false);
const [isSelectArticleDialogOpen, setIsSelectArticleDialogOpen] =
useState(false);
const handleMediaSelected = useCallback(() => {
// При выборе медиа, обновляем данные для ТЕКУЩЕГО ЯЗЫКА
// сохраняя текущие heading и body.
updateSightInfo(
languageStore.language,
{
left: {
heading: data.left.heading,
body: data.left.body,
},
},
false
);
setIsSelectMediaDialogOpen(false);
}, [
languageStore.language,
{
left: {
heading: data.left.heading,
body: data.left.body,
},
const handleMediaSelected = useCallback(
async (media: {
id: string;
filename: string;
media_name?: string;
media_type: number;
}) => {
await createLinkWithArticle(media);
setIsSelectMediaDialogOpen(false);
},
false,
]);
[createLinkWithArticle]
);
const handleCloseMediaDialog = useCallback(() => {
setIsSelectMediaDialogOpen(false);
}, []);
// ... (остальной JSX код остался почти без изменений)
return (
<TabPanel value={value} index={index}>
<LanguageSwitcher />
<Box
sx={{
display: "flex",
flexDirection: "column",
gap: 3,
paddingBottom: "70px",
position: "relative",
}}
>
<BackButton />
const handleCloseArticleDialog = useCallback(() => {
setIsSelectArticleDialogOpen(false);
}, []);
<Paper
elevation={2}
const handleSelectArticle = useCallback(
(
articleId: number,
heading: string,
body: string,
media: { id: string; media_type: number; filename: string }[]
) => {
setIsSelectArticleDialogOpen(false);
updateSightInfo(languageStore.language, {
left: {
heading,
body,
media,
},
});
updateSightInfo(
languageStore.language,
{
left_article: articleId,
},
true
);
},
[]
);
return (
<>
<TabPanel value={value} index={index}>
<LanguageSwitcher />
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
paddingX: 2.5,
paddingY: 1.5,
borderRadius: 2,
border: "1px solid",
borderColor: "divider",
flexDirection: "column",
gap: 3,
paddingBottom: "70px",
position: "relative",
}}
>
<Typography variant="h6">Левая статья</Typography>
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
{linkedArticle && (
<Button
variant="outlined"
color="primary"
startIcon={<Unlink size={18} />}
size="small"
>
Открепить
</Button>
)}
<Button
variant="outlined"
color="error"
startIcon={<Trash2 size={18} />}
size="small"
>
Удалить
</Button>
</Box>
</Paper>
<BackButton />
<Box sx={{ display: "flex", gap: 3, flexGrow: 1 }}>
{/* Левая колонка: Редактирование */}
<Box
sx={{ flex: 2, display: "flex", flexDirection: "column", gap: 2 }}
>
<TextField
label="Название информации"
value={data?.left?.heading}
onChange={(e) =>
updateSightInfo(
languageStore.language,
{
left: {
heading: e.target.value,
body: data.left.body,
},
},
false
)
}
variant="outlined"
fullWidth
/>
<ReactMarkdownEditor
value={data?.left?.body}
onChange={(value) =>
updateSightInfo(
languageStore.language,
{
left: {
heading: data.left.heading,
body: value,
},
},
false
)
}
/>
{/* Блок МЕДИА для статьи */}
{/* <Paper elevation={1} sx={{ padding: 2, mt: 1 }}>
<Typography variant="h6" gutterBottom>
МЕДИА
</Typography>
{data.left.media ? (
<Box sx={{ mb: 1 }}>
<img
src={data.left.media.filename}
alt="Selected media"
style={{
maxWidth: "100%",
maxHeight: "150px",
objectFit: "contain",
}}
/>
</Box>
) : (
<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"
startIcon={<ImagePlus size={18} />}
onClick={handleOpenMediaDialog}
>
Выбрать/Загрузить медиа
</Button>
{data.left.media && (
<Button
variant="outlined"
color="error"
size="small"
sx={{ ml: 1 }}
onClick={() =>
updateSightInfo(
languageStore.language,
{
left: {
heading: data.left.heading,
body: data.left.body,
media: null,
},
},
false
)
}
>
Удалить медиа
</Button>
)}
</Paper> */}
</Box>
{/* Правая колонка: Предпросмотр */}
<Box
<Paper
elevation={2}
sx={{
flex: 1,
display: "flex",
flexDirection: "column",
gap: 1.5,
alignItems: "center",
justifyContent: "space-between",
paddingX: 2.5,
paddingY: 1.5,
borderRadius: 2,
border: "1px solid",
borderColor: "divider",
}}
>
<Paper
elevation={3}
sx={{
width: "100%",
minWidth: 320,
maxWidth: 400,
height: "auto",
minHeight: 500,
backgroundColor: "#877361",
overflowY: "auto",
padding: 0,
display: "flex",
flexDirection: "column",
}}
>
{/* {data.left.media?.filename ? (
<Box
<Typography variant="h6">Левая статья</Typography>
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
{sight.common.left_article ? (
<>
<Button
variant="contained"
color="primary"
size="small"
style={{ transition: "0" }}
onClick={() => {
unlinkLeftArticle();
toast.success("Статья откреплена");
}}
>
Открепить
</Button>
<Button
variant="outlined"
color="error"
style={{ transition: "0" }}
startIcon={<Trash2 size={18} />}
size="small"
onClick={() => {
deleteLeftArticle(sight.common.left_article);
toast.success("Статья откреплена");
}}
>
Удалить
</Button>
</>
) : (
<>
<Button
variant="contained"
color="primary"
size="small"
onClick={() => setIsSelectArticleDialogOpen(true)}
>
Выбрать статью
</Button>
<Button
variant="contained"
color="primary"
size="small"
style={{ transition: "0" }}
onClick={() => {
createLeftArticle();
toast.success("Статья создана");
}}
>
Создать статью
</Button>
</>
)}
</Box>
</Paper>
{sight.common.left_article > 0 && (
<Box sx={{ display: "flex", gap: 3, flexGrow: 1 }}>
<Box
sx={{
flex: 2,
display: "flex",
flexDirection: "column",
gap: 2,
}}
>
<TextField
label="Название информации"
value={data?.left?.heading}
onChange={(e) =>
updateSightInfo(languageStore.language, {
left: {
heading: e.target.value,
body: sight[languageStore.language].left.body,
media: data.left.media,
},
})
}
variant="outlined"
fullWidth
/>
<ReactMarkdownEditor
value={data?.left?.body}
onChange={(value) =>
updateSightInfo(languageStore.language, {
left: {
heading: sight[languageStore.language].left.heading,
body: value,
media: data.left.media,
},
})
}
/>
<MediaArea
articleId={sight.common.left_article}
mediaIds={data.left.media}
deleteMedia={deleteMedia}
setSelectMediaDialogOpen={setIsSelectMediaDialogOpen}
onFilesDrop={(files) => {
setFileToUpload(files[0]);
setUploadMediaOpen(true);
}}
/>
</Box>
<Box
sx={{
flex: 1,
display: "flex",
flexDirection: "column",
gap: 1.5,
}}
>
<Paper
elevation={3}
sx={{
width: "100%",
height: 200,
backgroundColor: "grey.300",
minWidth: 320,
maxWidth: 400,
height: "auto",
minHeight: 500,
backgroundColor: "#877361",
overflowY: "auto",
padding: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
flexDirection: "column",
}}
>
<img
src={data.left.media?.filename ?? ""}
alt="Превью медиа"
style={{
objectFit: "cover",
<Box
sx={{
width: "100%",
height: "100%",
height: 200,
backgroundColor: "grey.300",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
/>
</Box>
) : (
)} */}
>
{data.left.media.length > 0 ? (
<MediaViewer
media={{
id: data.left.media[0].id,
media_type: data.left.media[0].media_type,
filename: data.left.media[0].filename,
}}
/>
) : (
<ImagePlus size={48} color="grey" />
)}
</Box>
<Box
sx={{
width: "100%",
height: 200,
backgroundColor: "grey.300",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<ImagePlus size={48} color="grey" />
</Box>
<Box
sx={{
backgroundColor: "#877361",
color: "white",
padding: 1.5,
}}
>
<Typography
variant="h5"
component="h2"
sx={{ wordBreak: "break-word" }}
>
{data?.left?.heading || "Название информации"}
</Typography>
</Box>
{/* Заголовок в превью */}
<Box
sx={{
backgroundColor: "#877361",
color: "white",
padding: 1.5,
}}
>
<Typography
variant="h5"
component="h2"
sx={{ wordBreak: "break-word" }}
>
{data?.left?.heading || "Название информации"}
</Typography>
{data?.left?.body && (
<Box
sx={{
padding: 2,
flexGrow: 1,
}}
>
<ReactMarkdownComponent value={data?.left?.body} />
</Box>
)}
</Paper>
</Box>
</Box>
)}
{/* Текст статьи в превью */}
<Box
sx={{
padding: 2,
flexGrow: 1,
}}
>
<ReactMarkdownComponent value={data?.left?.body} />
</Box>
</Paper>
<Box sx={{ position: "absolute", bottom: 0, right: 0, padding: 2 }}>
<Button
variant="contained"
color="success"
onClick={async () => {
await updateSight();
toast.success("Достопримечательность сохранена");
}}
>
Сохранить
</Button>
</Box>
</Box>
<Box sx={{ position: "absolute", bottom: 0, right: 0, padding: 2 }}>
<Button variant="contained" color="success">
Сохранить
</Button>
</Box>
</Box>
</TabPanel>
<UploadMediaDialog
open={uploadMediaOpen}
onClose={() => setUploadMediaOpen(false)}
afterUpload={async (media) => {
setUploadMediaOpen(false);
setFileToUpload(null);
await createLinkWithArticle(media);
}}
/>
<SelectMediaDialog
open={isSelectMediaDialogOpen}
onClose={handleCloseMediaDialog}
onSelectMedia={handleMediaSelected}
/>
</TabPanel>
<SelectArticleModal
open={isSelectArticleDialogOpen}
onClose={handleCloseArticleDialog}
onSelectArticle={handleSelectArticle}
/>
</>
);
}
);

View File

@ -1,345 +1,288 @@
import {
Box,
Button,
List,
ListItemButton,
ListItemText,
Paper,
Typography,
Menu,
MenuItem,
TextField,
} from "@mui/material";
import {
articlesStore,
BackButton,
createSightStore,
editSightStore,
languageStore,
SelectArticleModal,
TabPanel,
} from "@shared";
import { SightEdit } from "@widgets";
import { Plus } from "lucide-react";
import {
LanguageSwitcher,
ReactMarkdownComponent,
ReactMarkdownEditor,
} from "@widgets";
import { ImagePlus, Plus } from "lucide-react";
import { observer } from "mobx-react-lite";
import { useState } from "react";
import { useEffect, useState } from "react";
import { toast } from "react-toastify";
// --- 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",
},
];
const mockSelectedBlockData = {
id: "article_1",
heading: "История основания Санкт-Петербурга",
body: "## Начало\nГород был основан 27 мая 1703 года Петром I...",
media: [],
};
// --- 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 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;
}
const ArticleEditorPane = ({ articleData }: 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 }: { value: number; index: number }) => {
const [rightWidgetBlocks, setRightWidgetBlocks] = useState<ArticleBlock[]>(
mockRightWidgetBlocks
);
const [selectedBlockId, setSelectedBlockId] = useState<string | null>(
mockRightWidgetBlocks[1]?.id || null
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const { createNewRightArticle, updateRightArticleInfo } = createSightStore;
const { sight, getRightArticles, updateSight } = editSightStore;
const { language } = languageStore;
useEffect(() => {
if (sight.common.id) {
getRightArticles(sight.common.id);
}
}, [sight.common.id]);
const [activeArticleIndex, setActiveArticleIndex] = useState<number | null>(
null
);
const [isSelectModalOpen, setIsSelectModalOpen] = useState(false);
const handleSelectBlock = (blockId: string) => {
setSelectedBlockId(blockId);
console.log("Selected block:", blockId);
const open = Boolean(anchorEl);
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
const handleSelectArticle = (index: number) => {
setActiveArticleIndex(index);
};
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);
createNewRightArticle();
handleClose();
};
const handleSelectExisting = () => {
setIsSelectModalOpen(true);
handleClose();
};
const handleCloseSelectModal = () => {
setIsSelectModalOpen(false);
};
const handleSelectArticle = (articleId: string) => {
// @ts-ignore
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 handleArticleSelect = () => {
// TODO: Implement article selection logic
handleCloseSelectModal();
};
const handleSave = () => {
console.log("Saving right widget...");
// Implement save logic here, e.g., send data to an API
const handleSave = async () => {
await updateSight();
toast.success("Достопримечательность сохранена");
};
// 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}>
<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 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={() => setMediaType("preview")}
className="w-full bg-gray-200 p-4 rounded-2xl cursor-pointer text-sm hover:bg-gray-300 transition-all duration-300"
>
<Typography>Предпросмотр медиа</Typography>
</Box>
<ArticleEditorPane articleData={currentBlockToEdit} />
{sight[language].right.map((article, index) => (
<Box
key={index}
className="w-full bg-gray-200 p-4 rounded-2xl text-sm cursor-pointer hover:bg-gray-300 transition-all duration-300"
onClick={() => handleSelectArticle(index)}
>
<Typography>{article.heading}</Typography>
</Box>
))}
</Box>
<button
className="w-10 h-10 bg-blue-500 rounded-full absolute bottom-5 left-5 flex items-center justify-center"
onClick={handleClick}
>
<Plus size={20} color="white" />
</button>
<Menu
id="basic-menu"
anchorEl={anchorEl}
open={open}
onClose={handleClose}
MenuListProps={{
"aria-labelledby": "basic-button",
}}
sx={{ mt: 1 }}
>
<MenuItem onClick={handleCreateNew}>
<Typography>Создать новую</Typography>
</MenuItem>
<MenuItem onClick={handleSelectExisting}>
<Typography>Выбрать существующую статью</Typography>
</MenuItem>
</Menu>
</Box>
<Box className="w-[80%] border border-gray-300 rounded-2xl p-3">
{activeArticleIndex !== null && (
<>
<Box className="flex justify-end gap-2 mb-3">
<Button variant="contained" color="primary">
Открепить
</Button>
<Button variant="contained" color="success">
Удалить
</Button>
</Box>
<Box sx={{ display: "flex", gap: 3, flexGrow: 1 }}>
<Box
sx={{
flex: 2,
display: "flex",
flexDirection: "column",
gap: 2,
maxHeight: "70%",
}}
>
<TextField
label="Название информации"
value={
sight[language].right[activeArticleIndex].heading
}
onChange={(e) =>
updateRightArticleInfo(
activeArticleIndex,
language,
e.target.value,
sight[language].right[activeArticleIndex].body
)
}
variant="outlined"
fullWidth
/>
<ReactMarkdownEditor
value={
sight[language].right[activeArticleIndex].body
}
onChange={(value) =>
updateRightArticleInfo(
activeArticleIndex,
language,
sight[language].right[activeArticleIndex]
.heading,
value
)
}
/>
{/* <MediaArea
articleId={1}
mediaIds={[]}
deleteMedia={() => {}}
/> */}
</Box>
</Box>
</>
)}
</Box>
</Box>
</Box>
<Box className="w-[25%] mr-10">
{activeArticleIndex !== null && (
<Paper
className="flex-1 flex flex-col rounded-2xl"
elevation={2}
>
<Box
className="rounded-2xl overflow-hidden"
sx={{
width: "100%",
height: "75vh",
background: "#877361",
borderColor: "grey.300",
display: "flex",
flexDirection: "column",
}}
>
<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={{
width: "100%",
minHeight: "70px",
background: "#877361",
display: "flex",
flexShrink: 0,
alignItems: "center",
borderBottom: "1px solid rgba(255,255,255,0.1)",
px: 2,
}}
>
<Typography variant="h6" color="white">
{sight[language].right[activeArticleIndex].heading ||
"Выберите статью"}
</Typography>
</Box>
<Box
sx={{
px: 2,
flexGrow: 1,
overflowY: "auto",
backgroundColor: "#877361",
color: "white",
py: 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>
</Paper>
)}
</Box>
</Box>
<Box
@ -348,8 +291,8 @@ export const RightWidgetTab = observer(
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
backgroundColor: "background.paper",
width: "100%",
display: "flex",
justifyContent: "flex-end",
}}
@ -363,8 +306,7 @@ export const RightWidgetTab = observer(
<SelectArticleModal
open={isSelectModalOpen}
onClose={handleCloseSelectModal}
onSelectArticle={handleSelectArticle}
linkedArticleIds={linkedArticleIds}
onSelectArticle={handleArticleSelect}
/>
</TabPanel>
);

View File

@ -1,3 +1,6 @@
export * from "./InformationTab";
export * from "./LeftWidgetTab";
export * from "./RightWidgetTab";
export * from "./CreateInformationTab";
export * from "./CreateLeftTab";
export * from "./CreateRightTab";

View File

@ -8,3 +8,5 @@ export * from "./LanguageSwitcher";
export * from "./DevicesTable";
export * from "./SightsTable";
export * from "./MediaViewer";
export * from "./MediaArea";
export * from "./ModelViewer3D";