fix: Language cache sight
This commit is contained in:
@ -3,12 +3,12 @@ import * as React from "react";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
|
||||
import { Router } from "./router";
|
||||
import { theme } from "@shared";
|
||||
import { CustomTheme } from "@shared";
|
||||
import { ThemeProvider } from "@mui/material/styles";
|
||||
import { ToastContainer } from "react-toastify";
|
||||
|
||||
export const App: React.FC = () => (
|
||||
<ThemeProvider theme={theme}>
|
||||
<ThemeProvider theme={CustomTheme.Light}>
|
||||
<ToastContainer />
|
||||
<BrowserRouter>
|
||||
<Router />
|
||||
|
@ -6,8 +6,9 @@ import {
|
||||
MainPage,
|
||||
SightPage,
|
||||
} from "@pages";
|
||||
import { authStore } from "@shared";
|
||||
import { authStore, editSightStore, sightsStore } from "@shared";
|
||||
import { Layout } from "@widgets";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { Navigate, Outlet, Route, Routes, useLocation } from "react-router-dom";
|
||||
|
||||
@ -32,6 +33,11 @@ const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
||||
};
|
||||
|
||||
export const Router = () => {
|
||||
const pathname = useLocation();
|
||||
useEffect(() => {
|
||||
editSightStore.clearSightInfo();
|
||||
sightsStore.clearCreateSight();
|
||||
}, [pathname]);
|
||||
return (
|
||||
<Routes>
|
||||
<Route
|
||||
|
@ -16,7 +16,7 @@ function a11yProps(index: number) {
|
||||
export const EditSightPage = observer(() => {
|
||||
const [value, setValue] = useState(0);
|
||||
const { sight, getSight } = sightsStore;
|
||||
const { getArticles } = articlesStore;
|
||||
const { articles, getArticles } = articlesStore;
|
||||
const { language } = languageStore;
|
||||
const { id } = useParams();
|
||||
|
||||
@ -28,53 +28,54 @@ export const EditSightPage = observer(() => {
|
||||
const fetchData = async () => {
|
||||
if (id) {
|
||||
await getSight(Number(id));
|
||||
await getArticles();
|
||||
await getArticles(language);
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
}, [id, language]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
minHeight: "100vh",
|
||||
}}
|
||||
>
|
||||
articles &&
|
||||
sight && (
|
||||
<Box
|
||||
sx={{
|
||||
borderBottom: 1,
|
||||
borderColor: "divider",
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
flexDirection: "column",
|
||||
minHeight: "100vh",
|
||||
}}
|
||||
>
|
||||
<Tabs
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
aria-label="sight tabs"
|
||||
<Box
|
||||
sx={{
|
||||
width: "100%",
|
||||
"& .MuiTabs-flexContainer": {
|
||||
justifyContent: "center",
|
||||
},
|
||||
borderBottom: 1,
|
||||
borderColor: "divider",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Tab sx={{ flex: 1 }} label="Общая информация" {...a11yProps(0)} />
|
||||
<Tab sx={{ flex: 1 }} label="Левый виджет" {...a11yProps(1)} />
|
||||
<Tab sx={{ flex: 1 }} label="Правый виджет" {...a11yProps(2)} />
|
||||
</Tabs>
|
||||
</Box>
|
||||
<Tabs
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
aria-label="sight tabs"
|
||||
sx={{
|
||||
width: "100%",
|
||||
"& .MuiTabs-flexContainer": {
|
||||
justifyContent: "center",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Tab sx={{ flex: 1 }} label="Общая информация" {...a11yProps(0)} />
|
||||
<Tab sx={{ flex: 1 }} label="Левый виджет" {...a11yProps(1)} />
|
||||
<Tab sx={{ flex: 1 }} label="Правый виджет" {...a11yProps(2)} />
|
||||
</Tabs>
|
||||
</Box>
|
||||
|
||||
{sight && (
|
||||
<div className="flex-1">
|
||||
<InformationTab value={value} index={0} />
|
||||
<LeftWidgetTab data={sight} value={value} index={1} />
|
||||
<RightWidgetTab data={sight} value={value} index={2} />
|
||||
<LeftWidgetTab value={value} index={1} />
|
||||
<RightWidgetTab value={value} index={2} />
|
||||
</div>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
});
|
||||
|
@ -1 +1,9 @@
|
||||
export const API_URL = "https://wn.krbl.ru";
|
||||
export const MEDIA_TYPE_LABELS = {
|
||||
1: "Фото",
|
||||
2: "Видео",
|
||||
3: "Иконка",
|
||||
4: "Водяной знак",
|
||||
5: "Панорама",
|
||||
6: "3Д-модель",
|
||||
};
|
||||
|
@ -1,17 +1,45 @@
|
||||
import { createTheme } from "@mui/material/styles";
|
||||
|
||||
export const theme = createTheme({
|
||||
// You can customize your theme here
|
||||
export const COLORS = {
|
||||
primary: "#7f6b58",
|
||||
secondary: "#48989f",
|
||||
};
|
||||
|
||||
const theme = {
|
||||
palette: {
|
||||
mode: "light",
|
||||
primary: {
|
||||
main: COLORS.primary,
|
||||
},
|
||||
secondary: {
|
||||
main: COLORS.secondary,
|
||||
},
|
||||
},
|
||||
components: {
|
||||
MuiDrawer: {
|
||||
MuiAppBar: {
|
||||
styleOverrides: {
|
||||
paper: {
|
||||
backgroundColor: "#fff",
|
||||
root: {
|
||||
backgroundColor: COLORS.secondary,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const CustomTheme = {
|
||||
Light: createTheme({
|
||||
palette: {
|
||||
...theme.palette,
|
||||
},
|
||||
components: {
|
||||
...theme.components,
|
||||
},
|
||||
}),
|
||||
Dark: createTheme({
|
||||
palette: {
|
||||
...theme.palette,
|
||||
},
|
||||
components: {
|
||||
...theme.components,
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
229
src/shared/modals/PreviewMediaDialog/index.tsx
Normal file
229
src/shared/modals/PreviewMediaDialog/index.tsx
Normal file
@ -0,0 +1,229 @@
|
||||
import {
|
||||
articlesStore,
|
||||
authStore,
|
||||
Language,
|
||||
mediaStore,
|
||||
MEDIA_TYPE_LABELS,
|
||||
API_URL,
|
||||
} from "@shared";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
TextField,
|
||||
Paper,
|
||||
Box,
|
||||
Typography,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
Snackbar,
|
||||
} from "@mui/material";
|
||||
import { Download, Save } from "lucide-react";
|
||||
import { ReactMarkdownComponent, MediaViewer } from "@widgets";
|
||||
import { authInstance } from "@shared";
|
||||
|
||||
interface PreviewMediaDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
mediaId: string;
|
||||
}
|
||||
|
||||
export const PreviewMediaDialog = observer(
|
||||
({ open, onClose, mediaId }: PreviewMediaDialogProps) => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const media = mediaId
|
||||
? mediaStore.media.find((m) => m.id === mediaId)
|
||||
: null;
|
||||
const [mediaName, setMediaName] = useState(media?.media_name ?? "");
|
||||
const [mediaFilename, setMediaFilename] = useState(media?.filename ?? "");
|
||||
|
||||
// Reset form when media changes
|
||||
useEffect(() => {
|
||||
if (media) {
|
||||
setMediaName(media.media_name);
|
||||
setMediaFilename(media.filename);
|
||||
}
|
||||
}, [media]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyPress = (event: KeyboardEvent) => {
|
||||
if (event.key.toLowerCase() === "enter" && !event.ctrlKey) {
|
||||
event.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyPress);
|
||||
return () => window.removeEventListener("keydown", handleKeyPress);
|
||||
}, [onClose]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!mediaId) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await authInstance.patch(`/media/${mediaId}`, {
|
||||
media_name: mediaName,
|
||||
filename: mediaFilename,
|
||||
type: media?.media_type,
|
||||
});
|
||||
|
||||
// Update local store
|
||||
await mediaStore.getMedia();
|
||||
setSuccess(true);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to save media");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setError(null);
|
||||
setSuccess(false);
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (!media) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onClose={handleClose} maxWidth="md" fullWidth>
|
||||
<DialogTitle>Просмотр медиа</DialogTitle>
|
||||
<DialogContent
|
||||
className="flex gap-4"
|
||||
dividers
|
||||
sx={{
|
||||
height: "600px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 2,
|
||||
pt: 2,
|
||||
}}
|
||||
>
|
||||
<Box className="flex flex-col gap-4">
|
||||
<Box className="flex gap-2">
|
||||
<TextField
|
||||
fullWidth
|
||||
value={mediaName}
|
||||
onChange={(e) => setMediaName(e.target.value)}
|
||||
label="Название медиа"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
value={mediaFilename}
|
||||
onChange={(e) => setMediaFilename(e.target.value)}
|
||||
label="Название файла"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Тип медиа"
|
||||
value={
|
||||
MEDIA_TYPE_LABELS[
|
||||
media.media_type as keyof typeof MEDIA_TYPE_LABELS
|
||||
]
|
||||
}
|
||||
disabled
|
||||
sx={{ width: "50%" }}
|
||||
/>
|
||||
|
||||
<Box className="flex gap-4 h-full">
|
||||
<Paper
|
||||
elevation={2}
|
||||
sx={{
|
||||
flex: 1,
|
||||
p: 2,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
minHeight: 400,
|
||||
}}
|
||||
>
|
||||
<MediaViewer
|
||||
media={{
|
||||
id: mediaId,
|
||||
media_type: media.media_type,
|
||||
filename: media.filename,
|
||||
}}
|
||||
/>
|
||||
</Paper>
|
||||
|
||||
<Box className="flex flex-col gap-2 self-end">
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
startIcon={<Download size={16} />}
|
||||
component="a"
|
||||
href={`${
|
||||
import.meta.env.VITE_KRBL_MEDIA
|
||||
}${mediaId}/download?token=${localStorage.getItem(
|
||||
"token"
|
||||
)}`}
|
||||
target="_blank"
|
||||
disabled={isLoading}
|
||||
>
|
||||
Скачать
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="success"
|
||||
startIcon={
|
||||
isLoading ? (
|
||||
<CircularProgress size={16} />
|
||||
) : (
|
||||
<Save size={16} />
|
||||
)
|
||||
}
|
||||
onClick={handleSave}
|
||||
disabled={isLoading || (!mediaName && !mediaFilename)}
|
||||
>
|
||||
Сохранить
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleClose} disabled={isLoading}>
|
||||
Отмена
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<Snackbar
|
||||
open={!!error}
|
||||
autoHideDuration={6000}
|
||||
onClose={() => setError(null)}
|
||||
>
|
||||
<Alert severity="error" onClose={() => setError(null)}>
|
||||
{error}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
|
||||
<Snackbar
|
||||
open={success}
|
||||
autoHideDuration={3000}
|
||||
onClose={() => setSuccess(false)}
|
||||
>
|
||||
<Alert severity="success" onClose={() => setSuccess(false)}>
|
||||
Медиа успешно сохранено
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
@ -1,6 +1,6 @@
|
||||
import { articlesStore } from "@shared";
|
||||
import { articlesStore, authStore, Language } from "@shared";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
@ -31,38 +31,56 @@ export const SelectArticleModal = observer(
|
||||
open,
|
||||
onClose,
|
||||
onSelectArticle,
|
||||
|
||||
linkedArticleIds = [], // Default to empty array if not provided
|
||||
linkedArticleIds = [],
|
||||
}: SelectArticleModalProps) => {
|
||||
const { articles, getArticle } = articlesStore; // articles here refers to fetched articles from store
|
||||
const { articles, getArticle, getArticleMedia } = articlesStore;
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [hoveredArticleId, setHoveredArticleId] = useState<string | null>(
|
||||
const [selectedArticleId, setSelectedArticleId] = useState<string | null>(
|
||||
null
|
||||
);
|
||||
const hoverTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// Reset selection when modal opens/closes
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSelectedArticleId(null);
|
||||
articlesStore.articleData = null;
|
||||
articlesStore.articleMedia = null;
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hoveredArticleId) {
|
||||
hoverTimerRef.current = setTimeout(() => {
|
||||
getArticle(hoveredArticleId);
|
||||
}, 200);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (hoverTimerRef.current) {
|
||||
clearTimeout(hoverTimerRef.current);
|
||||
const handleKeyPress = (event: KeyboardEvent) => {
|
||||
if (event.key.toLowerCase() === "enter") {
|
||||
event.preventDefault();
|
||||
if (selectedArticleId) {
|
||||
onSelectArticle(selectedArticleId);
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [hoveredArticleId, getArticle]);
|
||||
|
||||
const handleArticleHover = (articleId: string) => {
|
||||
setHoveredArticleId(articleId);
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyPress);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyPress);
|
||||
};
|
||||
}, [selectedArticleId, onSelectArticle, onClose]);
|
||||
|
||||
const handleArticleLeave = () => {
|
||||
setHoveredArticleId(null);
|
||||
if (hoverTimerRef.current) {
|
||||
clearTimeout(hoverTimerRef.current);
|
||||
const handleArticleClick = async (articleId: string) => {
|
||||
if (selectedArticleId === articleId) return;
|
||||
|
||||
setSelectedArticleId(articleId);
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
await Promise.all([getArticle(articleId), getArticleMedia(articleId)]);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch article data:", error);
|
||||
// Reset article data on error
|
||||
articlesStore.articleData = null;
|
||||
articlesStore.articleMedia = null;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
@ -72,21 +90,38 @@ export const SelectArticleModal = observer(
|
||||
article.service_name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
const token = localStorage.getItem("token");
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="lg" fullWidth>
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
maxWidth="lg"
|
||||
fullWidth
|
||||
PaperProps={{
|
||||
sx: {
|
||||
minHeight: "80vh",
|
||||
maxHeight: "90vh",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DialogTitle>Выберите существующую статью</DialogTitle>
|
||||
<DialogContent
|
||||
className="flex gap-4"
|
||||
dividers // Adds a divider below the title and above the actions
|
||||
sx={{ height: "600px", display: "flex", flexDirection: "row" }} // Fixed height for DialogContent
|
||||
dividers
|
||||
sx={{
|
||||
height: "600px",
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
p: 2,
|
||||
}}
|
||||
>
|
||||
<Paper className="w-[66%] flex flex-col">
|
||||
<Paper className="w-[66%] flex flex-col" elevation={2}>
|
||||
<TextField
|
||||
fullWidth
|
||||
placeholder="Поиск статей..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
sx={{ mb: 2, mt: 1 }}
|
||||
sx={{ mb: 2, mt: 1, px: 2 }}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
@ -95,27 +130,51 @@ export const SelectArticleModal = observer(
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<List sx={{ flexGrow: 1, overflowY: "auto" }}>
|
||||
{filteredArticles.map((article) => (
|
||||
<ListItemButton
|
||||
key={article.id}
|
||||
onClick={() => onSelectArticle(article.id)}
|
||||
onMouseEnter={() => handleArticleHover(article.id)}
|
||||
onMouseLeave={handleArticleLeave}
|
||||
sx={{
|
||||
borderRadius: 1,
|
||||
mb: 0.5,
|
||||
"&:hover": {
|
||||
backgroundColor: "action.hover",
|
||||
},
|
||||
}}
|
||||
<List sx={{ flexGrow: 1, overflowY: "auto", px: 2 }}>
|
||||
{filteredArticles.length === 0 ? (
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{ p: 2, textAlign: "center" }}
|
||||
>
|
||||
<ListItemText primary={article.service_name} />
|
||||
</ListItemButton>
|
||||
))}
|
||||
{searchQuery ? "Статьи не найдены" : "Нет доступных статей"}
|
||||
</Typography>
|
||||
) : (
|
||||
filteredArticles.map((article) => (
|
||||
<ListItemButton
|
||||
key={article.id}
|
||||
onClick={() => handleArticleClick(article.id)}
|
||||
onDoubleClick={() => onSelectArticle(article.id)}
|
||||
selected={selectedArticleId === article.id}
|
||||
disabled={isLoading}
|
||||
sx={{
|
||||
borderRadius: 1,
|
||||
mb: 0.5,
|
||||
"&:hover": {
|
||||
backgroundColor: "action.hover",
|
||||
},
|
||||
"&.Mui-selected": {
|
||||
backgroundColor: "primary.main",
|
||||
color: "primary.contrastText",
|
||||
"&:hover": {
|
||||
backgroundColor: "primary.dark",
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ListItemText
|
||||
primary={article.service_name}
|
||||
primaryTypographyProps={{
|
||||
fontWeight:
|
||||
selectedArticleId === article.id ? "bold" : "normal",
|
||||
}}
|
||||
/>
|
||||
</ListItemButton>
|
||||
))
|
||||
)}
|
||||
</List>
|
||||
</Paper>
|
||||
<Paper className="flex-1 flex flex-col">
|
||||
<Paper className="flex-1 flex flex-col" elevation={2}>
|
||||
<Box
|
||||
className="rounded-2xl overflow-hidden"
|
||||
sx={{
|
||||
@ -127,60 +186,109 @@ export const SelectArticleModal = observer(
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
{/* Media Preview Area */}
|
||||
<Box
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: 200,
|
||||
flexShrink: 0,
|
||||
backgroundColor: "grey.300",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<ImagePlus size={48} color="grey" />
|
||||
</Box>
|
||||
{isLoading ? (
|
||||
<Box
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Typography color="white">Загрузка...</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
{articlesStore.articleMedia && (
|
||||
<Box sx={{ p: 2, backgroundColor: "rgba(0,0,0,0.1)" }}>
|
||||
<img
|
||||
src={`${import.meta.env.VITE_KRBL_MEDIA}${
|
||||
articlesStore.articleMedia.id
|
||||
}/download?token=${token}`}
|
||||
alt={articlesStore.articleMedia.filename}
|
||||
style={{
|
||||
maxWidth: "100%",
|
||||
height: "auto",
|
||||
maxHeight: "300px",
|
||||
objectFit: "contain",
|
||||
borderRadius: 8,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
{!articlesStore.articleMedia && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Title Area */}
|
||||
<Box
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: "70px",
|
||||
background: "#877361",
|
||||
display: "flex",
|
||||
flexShrink: 0,
|
||||
alignItems: "center",
|
||||
borderBottom: "1px solid",
|
||||
px: 2,
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" color="white">
|
||||
{articlesStore.articleData?.heading ||
|
||||
"Нет данных для предпросмотра"}
|
||||
</Typography>
|
||||
</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">
|
||||
{articlesStore.articleData?.heading || "Выберите статью"}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Body Preview Area */}
|
||||
<Box
|
||||
sx={{
|
||||
px: 2,
|
||||
flexGrow: 1,
|
||||
overflowY: "auto",
|
||||
backgroundColor: "#877361", // To make markdown readable
|
||||
color: "white",
|
||||
py: 1,
|
||||
}}
|
||||
>
|
||||
<ReactMarkdownComponent
|
||||
value={articlesStore.articleData?.body || ""}
|
||||
/>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
px: 2,
|
||||
flexGrow: 1,
|
||||
overflowY: "auto",
|
||||
backgroundColor: "#877361",
|
||||
color: "white",
|
||||
py: 1,
|
||||
}}
|
||||
>
|
||||
{articlesStore.articleData?.body ? (
|
||||
<ReactMarkdownComponent
|
||||
value={articlesStore.articleData.body}
|
||||
/>
|
||||
) : (
|
||||
<Typography
|
||||
color="rgba(255,255,255,0.7)"
|
||||
sx={{ textAlign: "center", mt: 4 }}
|
||||
>
|
||||
Предпросмотр статьи появится здесь
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<DialogActions sx={{ p: 2 }}>
|
||||
<Button onClick={onClose}>Отмена</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() =>
|
||||
selectedArticleId && onSelectArticle(selectedArticleId)
|
||||
}
|
||||
disabled={!selectedArticleId || isLoading}
|
||||
>
|
||||
Выбрать
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
|
178
src/shared/modals/SelectMediaDialog/index.tsx
Normal file
178
src/shared/modals/SelectMediaDialog/index.tsx
Normal file
@ -0,0 +1,178 @@
|
||||
import { articlesStore, authStore, Language, mediaStore } from "@shared";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
TextField,
|
||||
List,
|
||||
ListItemButton,
|
||||
ListItemText,
|
||||
Paper,
|
||||
Box,
|
||||
Typography,
|
||||
InputAdornment,
|
||||
} from "@mui/material";
|
||||
import { ImagePlus, Search } from "lucide-react";
|
||||
import { ReactMarkdownComponent, MediaViewer } from "@widgets";
|
||||
|
||||
interface SelectMediaDialogProps {
|
||||
open: boolean; // Corrected prop name
|
||||
onClose: () => void;
|
||||
onSelectMedia: (mediaId: string) => void; // Renamed from onSelectArticle
|
||||
linkedMediaIds?: string[]; // Renamed from linkedArticleIds, assuming it refers to media already in use
|
||||
}
|
||||
|
||||
export const SelectMediaDialog = observer(
|
||||
({
|
||||
open, // Corrected prop name
|
||||
onClose,
|
||||
onSelectMedia, // Renamed prop
|
||||
linkedMediaIds = [], // Default to empty array if not provided, renamed
|
||||
}: SelectMediaDialogProps) => {
|
||||
const { media, getMedia } = mediaStore; // Confirmed: using mediaStore for media
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [hoveredMediaId, setHoveredMediaId] = useState<string | null>(null);
|
||||
const hoverTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Fetch media on component mount
|
||||
useEffect(() => {
|
||||
getMedia();
|
||||
}, [getMedia]); // getMedia should be a dependency to avoid lint warnings if it's not stable
|
||||
|
||||
// Keyboard event listener for "Enter" key to select hovered media
|
||||
useEffect(() => {
|
||||
const handleKeyPress = (event: KeyboardEvent) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault(); // Prevent browser default action (e.g., form submission)
|
||||
|
||||
if (hoveredMediaId) {
|
||||
onSelectMedia(hoveredMediaId); // Call onSelectMedia
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyPress);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyPress);
|
||||
};
|
||||
}, [hoveredMediaId, onSelectMedia, onClose]); // Dependencies for keyboard listener
|
||||
|
||||
// Effect for handling hover timeout (if you want to clear the preview after a delay)
|
||||
// Based on the original code, it seemed like you wanted a delay for showing,
|
||||
// but typically for a preview, it's immediate on hover and cleared on mouse leave.
|
||||
// I've removed the 5-second timeout for setting the ID as it's counter-intuitive for a live preview.
|
||||
// If you intend for the preview to disappear after a short while *after* the mouse leaves,
|
||||
// you would implement a mouseleave timer. For now, it will clear on mouseleave.
|
||||
|
||||
const handleMouseEnter = (mediaId: string) => {
|
||||
if (hoverTimerRef.current) {
|
||||
clearTimeout(hoverTimerRef.current);
|
||||
}
|
||||
setHoveredMediaId(mediaId);
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
// You can add a small delay here if you want the preview to linger for a moment
|
||||
// before disappearing, e.g., setTimeout(() => setHoveredMediaId(null), 200);
|
||||
setHoveredMediaId(null);
|
||||
};
|
||||
|
||||
const filteredMedia = media
|
||||
.filter((mediaItem) => !linkedMediaIds.includes(mediaItem.id)) // Use mediaItem to avoid name collision
|
||||
.filter((mediaItem) =>
|
||||
mediaItem.media_name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
// Find the currently hovered media object for MediaViewer
|
||||
const currentHoveredMedia = hoveredMediaId
|
||||
? media.find((m) => m.id === hoveredMediaId)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="lg" fullWidth>
|
||||
<DialogTitle>Выберите существующее медиа</DialogTitle>
|
||||
<DialogContent
|
||||
className="flex gap-4"
|
||||
dividers // Adds a divider below the title and above the actions
|
||||
sx={{ height: "600px", display: "flex", flexDirection: "row" }} // Fixed height for DialogContent
|
||||
>
|
||||
<Paper className="w-[66%] flex flex-col" sx={{ p: 2 }}>
|
||||
{" "}
|
||||
{/* Added padding for consistency */}
|
||||
<TextField
|
||||
fullWidth
|
||||
placeholder="Поиск медиа..." // Changed placeholder for clarity
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
sx={{ mb: 2, mt: 1 }}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<Search size={20} />
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<List sx={{ flexGrow: 1, overflowY: "auto" }}>
|
||||
{filteredMedia.length > 0 ? (
|
||||
filteredMedia.map(
|
||||
(
|
||||
mediaItem // Use mediaItem to avoid confusion
|
||||
) => (
|
||||
<ListItemButton
|
||||
key={mediaItem.id}
|
||||
onClick={() => onSelectMedia(mediaItem.id)} // Call onSelectMedia
|
||||
onMouseEnter={() => handleMouseEnter(mediaItem.id)}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
sx={{
|
||||
borderRadius: 1,
|
||||
mb: 0.5,
|
||||
"&:hover": {
|
||||
backgroundColor: "action.hover",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ListItemText primary={mediaItem.media_name} />
|
||||
</ListItemButton>
|
||||
)
|
||||
)
|
||||
) : (
|
||||
<Typography
|
||||
sx={{ mt: 2, textAlign: "center" }}
|
||||
color="text.secondary"
|
||||
>
|
||||
Медиа не найдено или все медиа уже прикреплены.
|
||||
</Typography>
|
||||
)}
|
||||
</List>
|
||||
</Paper>
|
||||
{currentHoveredMedia ? ( // Only render MediaViewer if currentHoveredMedia is found
|
||||
<Paper className="w-[33%] h-[100%] flex justify-center items-center">
|
||||
<MediaViewer
|
||||
media={{
|
||||
id: currentHoveredMedia.id,
|
||||
media_type: currentHoveredMedia.media_type ?? 1, // Provide a default if media_type can be undefined
|
||||
filename: currentHoveredMedia.filename,
|
||||
}}
|
||||
/>
|
||||
</Paper>
|
||||
) : (
|
||||
<Paper className="w-[33%] h-[100%] flex justify-center items-center">
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Наведите на медиа в списке для предпросмотра.
|
||||
</Typography>
|
||||
</Paper>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>Отмена</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
);
|
@ -1 +1,3 @@
|
||||
export * from "./SelectArticleDialog";
|
||||
export * from "./SelectMediaDialog";
|
||||
export * from "./PreviewMediaDialog";
|
||||
|
@ -1,36 +1,78 @@
|
||||
import { authInstance } from "@shared";
|
||||
import { makeAutoObservable, runInAction } from "mobx";
|
||||
import { authInstance, editSightStore, Language, languageStore } from "@shared";
|
||||
import { computed, makeAutoObservable, runInAction } from "mobx";
|
||||
|
||||
export type Article = {
|
||||
id: string;
|
||||
id: number;
|
||||
heading: string;
|
||||
body: string;
|
||||
service_name: string;
|
||||
};
|
||||
|
||||
type Media = {
|
||||
id: string;
|
||||
filename: string;
|
||||
media_name: string;
|
||||
media_type: number;
|
||||
};
|
||||
|
||||
class ArticlesStore {
|
||||
constructor() {
|
||||
makeAutoObservable(this);
|
||||
}
|
||||
|
||||
articles: Article[] = [];
|
||||
articles: { [key in Language]: Article[] } = {
|
||||
ru: [],
|
||||
en: [],
|
||||
zh: [],
|
||||
};
|
||||
articleData: Article | null = null;
|
||||
articleMedia: Media | null = null;
|
||||
articleLoading: boolean = false;
|
||||
|
||||
getArticles = async () => {
|
||||
getArticles = async (language: Language) => {
|
||||
this.articleLoading = true;
|
||||
const response = await authInstance.get("/article");
|
||||
|
||||
runInAction(() => {
|
||||
this.articles = response.data;
|
||||
this.articles[language] = response.data;
|
||||
});
|
||||
this.articleLoading = false;
|
||||
};
|
||||
|
||||
getArticle = async (id: string) => {
|
||||
getArticle = async (id: number) => {
|
||||
this.articleLoading = true;
|
||||
const response = await authInstance.get(`/article/${id}`);
|
||||
|
||||
runInAction(() => {
|
||||
this.articleData = response.data;
|
||||
});
|
||||
this.articleLoading = false;
|
||||
};
|
||||
|
||||
getSightArticles = async (id: number) => {
|
||||
const response = await authInstance.get(`/sight/${id}/article`);
|
||||
|
||||
runInAction(() => {
|
||||
editSightStore.sightInfo[languageStore.language].right = response.data;
|
||||
});
|
||||
};
|
||||
|
||||
getArticleMedia = async (id: number) => {
|
||||
const response = await authInstance.get(`/article/${id}/media`);
|
||||
|
||||
runInAction(() => {
|
||||
this.articleMedia = response.data[0];
|
||||
});
|
||||
};
|
||||
|
||||
getArticleByArticleId = computed(() => {
|
||||
if (editSightStore.sightInfo.left_article) {
|
||||
return this.articles[languageStore.language].find(
|
||||
(a) => a.id == editSightStore.sightInfo.left_article
|
||||
);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
export const articlesStore = new ArticlesStore();
|
||||
|
151
src/shared/store/EditSightStore/index.tsx
Normal file
151
src/shared/store/EditSightStore/index.tsx
Normal file
@ -0,0 +1,151 @@
|
||||
// @shared/stores/editSightStore.ts
|
||||
import { Language } from "@shared";
|
||||
import { makeAutoObservable } from "mobx";
|
||||
|
||||
export interface MediaObject {
|
||||
id: string;
|
||||
filename: string;
|
||||
media_type: number;
|
||||
}
|
||||
|
||||
type SightBaseInfo = {
|
||||
id: number;
|
||||
city_id: number;
|
||||
city: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
thumbnail: string;
|
||||
watermark_lu: string;
|
||||
watermark_rd: string;
|
||||
left_article: number;
|
||||
preview_media: string;
|
||||
video_preview: string;
|
||||
};
|
||||
|
||||
export interface RightArticleBlock {
|
||||
id: string;
|
||||
type: "article" | "preview_media";
|
||||
name: string;
|
||||
linkedArticleId?: string;
|
||||
heading: string;
|
||||
body: string;
|
||||
media: MediaObject | null;
|
||||
}
|
||||
|
||||
type SightInfo = SightBaseInfo & {
|
||||
[key in Language]: {
|
||||
info: {
|
||||
name: string;
|
||||
address: string;
|
||||
};
|
||||
left: {
|
||||
loaded: boolean; // Означает, что данные для этого языка были инициализированы/загружены
|
||||
heading: string;
|
||||
body: string;
|
||||
media: MediaObject | null;
|
||||
};
|
||||
right: RightArticleBlock[];
|
||||
};
|
||||
};
|
||||
|
||||
class EditSightStore {
|
||||
sightInfo: SightInfo = {
|
||||
id: 0,
|
||||
city_id: 0,
|
||||
city: "",
|
||||
latitude: 0,
|
||||
longitude: 0,
|
||||
thumbnail: "",
|
||||
watermark_lu: "",
|
||||
watermark_rd: "",
|
||||
left_article: 0,
|
||||
preview_media: "",
|
||||
video_preview: "",
|
||||
ru: {
|
||||
info: { name: "", address: "" },
|
||||
left: { loaded: false, heading: "", body: "", media: null },
|
||||
right: [],
|
||||
},
|
||||
en: {
|
||||
info: { name: "", address: "" },
|
||||
left: { loaded: false, heading: "", body: "", media: null },
|
||||
right: [],
|
||||
},
|
||||
zh: {
|
||||
info: { name: "", address: "" },
|
||||
left: { loaded: false, heading: "", body: "", media: null },
|
||||
right: [],
|
||||
},
|
||||
};
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(this);
|
||||
}
|
||||
|
||||
// loadSightInfo: Используется для первоначальной загрузки данных для ЯЗЫКА.
|
||||
// Она устанавливает loaded: true, чтобы в будущем не перезатирать данные.
|
||||
loadSightInfo = (
|
||||
language: Language,
|
||||
heading: string,
|
||||
body: string,
|
||||
media: MediaObject | null
|
||||
) => {
|
||||
// Важно: если данные уже были загружены или изменены, не перезаписывайте их.
|
||||
// Это предотвращает потерю пользовательского ввода при переключении языков.
|
||||
// Если хотите принудительную загрузку, добавьте другой метод или параметр.
|
||||
if (!this.sightInfo[language].left.loaded) {
|
||||
// <--- Только если еще не загружено
|
||||
this.sightInfo[language].left.heading = heading;
|
||||
this.sightInfo[language].left.body = body;
|
||||
this.sightInfo[language].left.media = media;
|
||||
this.sightInfo[language].left.loaded = true; // <--- Устанавливаем loaded только при загрузке
|
||||
}
|
||||
};
|
||||
|
||||
// updateSightInfo: Используется для сохранения ЛЮБЫХ пользовательских изменений.
|
||||
// Она НЕ должна влиять на флаг 'loaded', который управляется 'loadSightInfo'.
|
||||
updateSightInfo = (
|
||||
language: Language,
|
||||
heading: string,
|
||||
body: string,
|
||||
media: MediaObject | null
|
||||
) => {
|
||||
this.sightInfo[language].left.heading = heading;
|
||||
this.sightInfo[language].left.body = body;
|
||||
this.sightInfo[language].left.media = media;
|
||||
// this.sightInfo[language].left.loaded = true; // <-- УДАЛИТЕ эту строку
|
||||
};
|
||||
|
||||
clearSightInfo = () => {
|
||||
this.sightInfo = {
|
||||
id: 0,
|
||||
city_id: 0,
|
||||
city: "",
|
||||
latitude: 0,
|
||||
longitude: 0,
|
||||
thumbnail: "",
|
||||
watermark_lu: "",
|
||||
watermark_rd: "",
|
||||
left_article: 0,
|
||||
preview_media: "",
|
||||
video_preview: "",
|
||||
ru: {
|
||||
info: { name: "", address: "" },
|
||||
left: { loaded: false, heading: "", body: "", media: null },
|
||||
right: [],
|
||||
},
|
||||
en: {
|
||||
info: { name: "", address: "" },
|
||||
left: { loaded: false, heading: "", body: "", media: null },
|
||||
right: [],
|
||||
},
|
||||
zh: {
|
||||
info: { name: "", address: "" },
|
||||
left: { loaded: false, heading: "", body: "", media: null },
|
||||
right: [],
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export const editSightStore = new EditSightStore();
|
27
src/shared/store/MediaStore/index.tsx
Normal file
27
src/shared/store/MediaStore/index.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import { makeAutoObservable, runInAction } from "mobx";
|
||||
import { authInstance } from "@shared";
|
||||
|
||||
type Media = {
|
||||
id: string;
|
||||
filename: string;
|
||||
media_name: string;
|
||||
media_type: number;
|
||||
};
|
||||
|
||||
class MediaStore {
|
||||
media: Media[] = [];
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(this);
|
||||
}
|
||||
|
||||
getMedia = async () => {
|
||||
const response = await authInstance.get("/media");
|
||||
|
||||
runInAction(() => {
|
||||
this.media = [...response.data];
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export const mediaStore = new MediaStore();
|
@ -1,12 +1,17 @@
|
||||
import { authInstance, languageInstance, languageStore } from "@shared";
|
||||
import { makeAutoObservable, runInAction } from "mobx";
|
||||
import {
|
||||
articlesStore,
|
||||
authInstance,
|
||||
languageInstance,
|
||||
languageStore,
|
||||
editSightStore,
|
||||
} from "@shared";
|
||||
import { computed, makeAutoObservable, runInAction } from "mobx";
|
||||
|
||||
export type Language = "ru" | "en" | "zh";
|
||||
|
||||
export type MultilingualContent = {
|
||||
[key in Language]: {
|
||||
name: string;
|
||||
description: string;
|
||||
address: string;
|
||||
};
|
||||
};
|
||||
@ -30,7 +35,6 @@ export type Sight = {
|
||||
export type CreateSight = {
|
||||
[key in Language]: {
|
||||
name: string;
|
||||
description: string;
|
||||
address: string;
|
||||
};
|
||||
};
|
||||
@ -39,9 +43,9 @@ class SightsStore {
|
||||
sights: Sight[] = [];
|
||||
sight: Sight | null = null;
|
||||
createSight: CreateSight = {
|
||||
ru: { name: "", description: "", address: "" },
|
||||
en: { name: "", description: "", address: "" },
|
||||
zh: { name: "", description: "", address: "" },
|
||||
ru: { name: "", address: "" },
|
||||
en: { name: "", address: "" },
|
||||
zh: { name: "", address: "" },
|
||||
};
|
||||
|
||||
constructor() {
|
||||
@ -60,6 +64,41 @@ class SightsStore {
|
||||
|
||||
runInAction(() => {
|
||||
this.sight = response.data;
|
||||
editSightStore.sightInfo = {
|
||||
...editSightStore.sightInfo,
|
||||
id: response.data.id,
|
||||
city_id: response.data.city_id,
|
||||
city: response.data.city,
|
||||
latitude: response.data.latitude,
|
||||
longitude: response.data.longitude,
|
||||
thumbnail: response.data.thumbnail,
|
||||
watermark_lu: response.data.watermark_lu,
|
||||
watermark_rd: response.data.watermark_rd,
|
||||
left_article: response.data.left_article,
|
||||
preview_media: response.data.preview_media,
|
||||
video_preview: response.data.video_preview,
|
||||
[languageStore.language]: {
|
||||
info: {
|
||||
name: response.data.name,
|
||||
address: response.data.address,
|
||||
description: response.data.description,
|
||||
},
|
||||
left: {
|
||||
heading: editSightStore.sightInfo[languageStore.language].left
|
||||
.loaded
|
||||
? editSightStore.sightInfo[languageStore.language].left.heading
|
||||
: articlesStore.articles[languageStore.language].find(
|
||||
(article) => article.id === response.data.left_article
|
||||
)?.heading,
|
||||
body: editSightStore.sightInfo[languageStore.language].left.loaded
|
||||
? editSightStore.sightInfo[languageStore.language].left.body
|
||||
: articlesStore.articles[languageStore.language].find(
|
||||
(article) => article.id === response.data.left_article
|
||||
)?.body,
|
||||
},
|
||||
},
|
||||
};
|
||||
console.log(editSightStore.sightInfo);
|
||||
});
|
||||
};
|
||||
|
||||
@ -70,7 +109,6 @@ class SightsStore {
|
||||
const id = (
|
||||
await authInstance.post("/sight", {
|
||||
name: this.createSight[languageStore.language].name,
|
||||
description: this.createSight[languageStore.language].description,
|
||||
address: this.createSight[languageStore.language].address,
|
||||
city_id: city,
|
||||
latitude: coordinates.latitude,
|
||||
@ -86,8 +124,6 @@ class SightsStore {
|
||||
`/sight/${id}`,
|
||||
{
|
||||
name: this.createSight[anotherLanguages[0] as Language].name,
|
||||
description:
|
||||
this.createSight[anotherLanguages[0] as Language].description,
|
||||
address: this.createSight[anotherLanguages[0] as Language].address,
|
||||
city_id: city,
|
||||
latitude: coordinates.latitude,
|
||||
@ -99,8 +135,6 @@ class SightsStore {
|
||||
`/sight/${id}`,
|
||||
{
|
||||
name: this.createSight[anotherLanguages[1] as Language].name,
|
||||
description:
|
||||
this.createSight[anotherLanguages[1] as Language].description,
|
||||
address: this.createSight[anotherLanguages[1] as Language].address,
|
||||
city_id: city,
|
||||
latitude: coordinates.latitude,
|
||||
@ -110,9 +144,9 @@ class SightsStore {
|
||||
|
||||
runInAction(() => {
|
||||
this.createSight = {
|
||||
ru: { name: "", description: "", address: "" },
|
||||
en: { name: "", description: "", address: "" },
|
||||
zh: { name: "", description: "", address: "" },
|
||||
ru: { name: "", address: "" },
|
||||
en: { name: "", address: "" },
|
||||
zh: { name: "", address: "" },
|
||||
};
|
||||
});
|
||||
};
|
||||
@ -139,22 +173,41 @@ class SightsStore {
|
||||
this.createSight = {
|
||||
ru: {
|
||||
name: "",
|
||||
description: "",
|
||||
address: "",
|
||||
},
|
||||
en: {
|
||||
name: "",
|
||||
description: "",
|
||||
address: "",
|
||||
},
|
||||
zh: {
|
||||
name: "",
|
||||
description: "",
|
||||
address: "",
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
sightData = computed(() => {
|
||||
return {
|
||||
name: this.sight?.name,
|
||||
address: this.sight?.address,
|
||||
city_id: this.sight?.city_id,
|
||||
latitude: this.sight?.latitude,
|
||||
longitude: this.sight?.longitude,
|
||||
thumbnail: this.sight?.thumbnail,
|
||||
watermark_lu: this.sight?.watermark_lu,
|
||||
watermark_rd: this.sight?.watermark_rd,
|
||||
left_article: this.sight?.left_article,
|
||||
preview_media: this.sight?.preview_media,
|
||||
video_preview: this.sight?.video_preview,
|
||||
[languageStore.language]: {
|
||||
info: {
|
||||
name: this.sight?.name,
|
||||
address: this.sight?.address,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export const sightsStore = new SightsStore();
|
||||
|
@ -6,3 +6,5 @@ export * from "./SnapshotStore";
|
||||
export * from "./SightsStore";
|
||||
export * from "./CityStore";
|
||||
export * from "./ArticlesStore";
|
||||
export * from "./EditSightStore";
|
||||
export * from "./MediaStore";
|
||||
|
@ -2,11 +2,15 @@ import { Box, TextField } from "@mui/material";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export const CoordinatesInput = ({
|
||||
initialValue,
|
||||
setValue,
|
||||
}: {
|
||||
initialValue: { latitude: number; longitude: number };
|
||||
setValue: (value: { latitude: number; longitude: number }) => void;
|
||||
}) => {
|
||||
const [inputValue, setInputValue] = useState<string>("");
|
||||
const [inputValue, setInputValue] = useState<string>(
|
||||
`${initialValue.latitude} ${initialValue.longitude}`
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setValue({
|
||||
|
@ -45,7 +45,7 @@ export const LanguageSwitcher = observer(() => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed top-1/2 -translate-y-1/2 right-0 flex flex-col gap-2 p-4 ">
|
||||
<div className="fixed top-1/2 -translate-y-1/2 right-0 flex flex-col gap-2 p-4 z-10 ">
|
||||
{/* Added some styling for better visualization */}
|
||||
{LANGUAGES.map((lang) => (
|
||||
<Button
|
||||
|
22
src/widgets/MediaViewer/ThreeView.tsx
Normal file
22
src/widgets/MediaViewer/ThreeView.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import { Canvas } from "@react-three/fiber";
|
||||
import { OrbitControls, Stage, useGLTF } from "@react-three/drei";
|
||||
|
||||
type ModelViewerProps = {
|
||||
fileUrl: string;
|
||||
height?: string;
|
||||
};
|
||||
|
||||
export const ThreeView = ({ fileUrl, height = "100%" }: ModelViewerProps) => {
|
||||
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>
|
||||
);
|
||||
};
|
107
src/widgets/MediaViewer/index.tsx
Normal file
107
src/widgets/MediaViewer/index.tsx
Normal file
@ -0,0 +1,107 @@
|
||||
import { Box } from "@mui/material";
|
||||
|
||||
import { ReactPhotoSphereViewer } from "react-photo-sphere-viewer";
|
||||
import { ThreeView } from "./ThreeView";
|
||||
|
||||
export interface MediaData {
|
||||
id: string | number;
|
||||
media_type: number;
|
||||
filename?: string;
|
||||
}
|
||||
|
||||
export function MediaViewer({ media }: Readonly<{ media?: MediaData }>) {
|
||||
const token = localStorage.getItem("token");
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
width: "80%",
|
||||
height: "100%",
|
||||
maxWidth: "600px",
|
||||
display: "flex",
|
||||
flexGrow: 1,
|
||||
justifyContent: "center",
|
||||
margin: "0 auto",
|
||||
}}
|
||||
>
|
||||
{media?.media_type === 1 && (
|
||||
<img
|
||||
src={`${import.meta.env.VITE_KRBL_MEDIA}${
|
||||
media?.id
|
||||
}/download?token=${token}`}
|
||||
alt={media?.filename}
|
||||
style={{
|
||||
maxWidth: "100%",
|
||||
height: "auto",
|
||||
objectFit: "contain",
|
||||
borderRadius: 8,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{media?.media_type === 2 && (
|
||||
<video
|
||||
src={`${import.meta.env.VITE_KRBL_MEDIA}${
|
||||
media?.id
|
||||
}/download?token=${token}`}
|
||||
style={{
|
||||
margin: "auto 0",
|
||||
height: "fit-content",
|
||||
width: "fit-content",
|
||||
objectFit: "contain",
|
||||
borderRadius: 30,
|
||||
}}
|
||||
controls
|
||||
autoPlay
|
||||
muted
|
||||
/>
|
||||
)}
|
||||
{media?.media_type === 3 && (
|
||||
<img
|
||||
src={`${import.meta.env.VITE_KRBL_MEDIA}${
|
||||
media?.id
|
||||
}/download?token=${token}`}
|
||||
alt={media?.filename}
|
||||
style={{
|
||||
maxWidth: "100%",
|
||||
height: "100%",
|
||||
objectFit: "contain",
|
||||
borderRadius: 8,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{media?.media_type === 4 && (
|
||||
<img
|
||||
src={`${import.meta.env.VITE_KRBL_MEDIA}${
|
||||
media?.id
|
||||
}/download?token=${token}`}
|
||||
alt={media?.filename}
|
||||
style={{
|
||||
maxWidth: "100%",
|
||||
height: "100%",
|
||||
objectFit: "contain",
|
||||
borderRadius: 8,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{media?.media_type === 5 && (
|
||||
<ReactPhotoSphereViewer
|
||||
src={`${import.meta.env.VITE_KRBL_MEDIA}${
|
||||
media?.id
|
||||
}/download?token=${token}`}
|
||||
width={"100%"}
|
||||
height={"100%"}
|
||||
/>
|
||||
)}
|
||||
|
||||
{media?.media_type === 6 && (
|
||||
<ThreeView
|
||||
fileUrl={`${import.meta.env.VITE_KRBL_MEDIA}${
|
||||
media?.id
|
||||
}/download?token=${token}`}
|
||||
height="100%"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
@ -2,58 +2,58 @@ import { styled } from "@mui/material/styles";
|
||||
import SimpleMDE, { SimpleMDEReactProps } from "react-simplemde-editor";
|
||||
import "easymde/dist/easymde.min.css";
|
||||
|
||||
const StyledMarkdownEditor = styled("div")(() => ({
|
||||
const StyledMarkdownEditor = styled("div")(({ theme }) => ({
|
||||
"& .editor-toolbar": {
|
||||
backgroundColor: "inherit",
|
||||
borderColor: "inherit",
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
borderColor: theme.palette.divider,
|
||||
},
|
||||
"& .editor-toolbar button": {
|
||||
color: "inherit",
|
||||
color: theme.palette.text.primary,
|
||||
},
|
||||
"& .editor-toolbar button:hover": {
|
||||
backgroundColor: "inherit",
|
||||
backgroundColor: theme.palette.action.hover,
|
||||
},
|
||||
"& .editor-toolbar button:active, & .editor-toolbar button.active": {
|
||||
backgroundColor: "inherit",
|
||||
backgroundColor: theme.palette.action.selected,
|
||||
},
|
||||
"& .editor-statusbar": {
|
||||
display: "none",
|
||||
},
|
||||
// Стили для самого редактора
|
||||
"& .CodeMirror": {
|
||||
backgroundColor: "inherit",
|
||||
color: "inherit",
|
||||
borderColor: "inherit",
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
color: theme.palette.text.primary,
|
||||
borderColor: theme.palette.divider,
|
||||
},
|
||||
// Стили для текста в редакторе
|
||||
"& .CodeMirror-selected": {
|
||||
backgroundColor: "inherit !important",
|
||||
backgroundColor: `${theme.palette.action.selected} !important`,
|
||||
},
|
||||
"& .CodeMirror-cursor": {
|
||||
borderLeftColor: "inherit",
|
||||
borderLeftColor: theme.palette.text.primary,
|
||||
},
|
||||
// Стили для markdown разметки
|
||||
"& .cm-header": {
|
||||
color: "inherit",
|
||||
color: theme.palette.primary.main,
|
||||
},
|
||||
"& .cm-quote": {
|
||||
color: "inherit",
|
||||
color: theme.palette.text.secondary,
|
||||
fontStyle: "italic",
|
||||
},
|
||||
"& .cm-link": {
|
||||
color: "inherit",
|
||||
color: theme.palette.primary.main,
|
||||
},
|
||||
"& .cm-url": {
|
||||
color: "inherit",
|
||||
color: theme.palette.secondary.main,
|
||||
},
|
||||
"& .cm-formatting": {
|
||||
color: "inherit",
|
||||
color: theme.palette.text.secondary,
|
||||
},
|
||||
|
||||
"& .CodeMirror .editor-preview-full": {
|
||||
backgroundColor: "inherit",
|
||||
color: "inherit",
|
||||
borderColor: "inherit",
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
color: theme.palette.text.primary,
|
||||
borderColor: theme.palette.divider,
|
||||
},
|
||||
|
||||
"& .EasyMDEContainer": {
|
||||
@ -100,8 +100,7 @@ export const ReactMarkdownEditor = (props: SimpleMDEReactProps) => {
|
||||
];
|
||||
return (
|
||||
<StyledMarkdownEditor
|
||||
autoFocus={false}
|
||||
spellCheck={false}
|
||||
className="my-markdown-editor"
|
||||
sx={{ marginTop: 1.5, marginBottom: 3 }}
|
||||
>
|
||||
<SimpleMDE {...props} />
|
||||
|
@ -4,6 +4,12 @@ import {
|
||||
Box,
|
||||
Autocomplete,
|
||||
Typography,
|
||||
Paper,
|
||||
Tooltip,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
MenuItem,
|
||||
Menu as MuiMenu,
|
||||
} from "@mui/material";
|
||||
import {
|
||||
BackButton,
|
||||
@ -14,24 +20,70 @@ import {
|
||||
Language,
|
||||
cityStore,
|
||||
CoordinatesInput,
|
||||
editSightStore,
|
||||
SelectMediaDialog,
|
||||
PreviewMediaDialog,
|
||||
} from "@shared";
|
||||
import { LanguageSwitcher } from "@widgets";
|
||||
import { Info, ImagePlus } from "lucide-react";
|
||||
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useState } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
// Мокап для всплывающей подсказки
|
||||
|
||||
export const InformationTab = observer(
|
||||
({ value, index }: { value: number; index: number }) => {
|
||||
const { cities } = cityStore;
|
||||
const [isMediaModalOpen, setIsMediaModalOpen] = useState(false);
|
||||
const [mediaId, setMediaId] = useState<string>("");
|
||||
const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false);
|
||||
const { createSight, updateCreateSight, createSightAction } = sightsStore;
|
||||
const [city, setCity] = useState<number>(0);
|
||||
const { sightInfo } = editSightStore;
|
||||
const [city, setCity] = useState<number>(sightInfo.city_id ?? 0);
|
||||
const [coordinates, setCoordinates] = useState({
|
||||
latitude: 0,
|
||||
longitude: 0,
|
||||
latitude: sightInfo.latitude ?? 0,
|
||||
longitude: sightInfo.longitude ?? 0,
|
||||
});
|
||||
const { language } = languageStore;
|
||||
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);
|
||||
};
|
||||
|
||||
const handleMenuClose = () => {
|
||||
setMenuAnchorEl(null);
|
||||
setActiveMenuType(null);
|
||||
};
|
||||
|
||||
const handleCreateNew = () => {
|
||||
handleMenuClose();
|
||||
};
|
||||
|
||||
const handleAddMedia = () => {
|
||||
setIsAddMediaOpen(true);
|
||||
handleMenuClose();
|
||||
};
|
||||
|
||||
const handleMediaSelect = (selectedMediaId: string) => {
|
||||
if (!activeMenuType) return;
|
||||
|
||||
// Close the dialog
|
||||
setIsAddMediaOpen(false);
|
||||
setActiveMenuType(null);
|
||||
};
|
||||
|
||||
const handleChange = (
|
||||
language: Language,
|
||||
@ -42,325 +94,326 @@ export const InformationTab = observer(
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await createSightAction(createSight[language], city, coordinates);
|
||||
await createSightAction(city, coordinates);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<TabPanel value={value} index={index}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 3,
|
||||
position: "relative",
|
||||
paddingBottom: "70px" /* Space for save button */,
|
||||
}}
|
||||
>
|
||||
<BackButton />
|
||||
|
||||
<>
|
||||
<TabPanel value={value} index={index}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
|
||||
gap: 4, // Added gap between the two main columns
|
||||
width: "100%",
|
||||
flexDirection: "column",
|
||||
gap: 3,
|
||||
position: "relative",
|
||||
paddingBottom: "70px" /* Space for save button */,
|
||||
}}
|
||||
>
|
||||
{/* Left column with main fields */}
|
||||
<BackButton />
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
display: "flex",
|
||||
width: "80%",
|
||||
|
||||
gap: 4, // Added gap between the two main columns
|
||||
width: "100%",
|
||||
flexDirection: "column",
|
||||
gap: 2.5,
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
label={`Название (${language.toUpperCase()})`}
|
||||
value={createSight[language]?.name ?? ""}
|
||||
onChange={(e) => {
|
||||
handleChange(language as Language, {
|
||||
name: e.target.value,
|
||||
});
|
||||
{/* Left column with main fields */}
|
||||
<Box
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
display: "flex",
|
||||
width: "80%",
|
||||
flexDirection: "column",
|
||||
gap: 2.5,
|
||||
}}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
/>
|
||||
<TextField
|
||||
label={`Описание (${language.toUpperCase()})`}
|
||||
value={createSight?.[language]?.description ?? ""}
|
||||
onChange={(e) => {
|
||||
handleChange(language as Language, {
|
||||
description: e.target.value,
|
||||
});
|
||||
}}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
multiline
|
||||
rows={4}
|
||||
/>
|
||||
<TextField
|
||||
label="Адрес"
|
||||
value={createSight?.[language]?.address ?? ""}
|
||||
onChange={(e) => {
|
||||
handleChange(language as Language, {
|
||||
address: e.target.value,
|
||||
});
|
||||
}}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
/>
|
||||
<Autocomplete
|
||||
options={cities}
|
||||
getOptionLabel={(option) => option.name}
|
||||
onChange={(_, value) => {
|
||||
setCity(value?.id ?? 0);
|
||||
}}
|
||||
renderInput={(params) => (
|
||||
<TextField {...params} label="Город" />
|
||||
)}
|
||||
/>
|
||||
>
|
||||
<TextField
|
||||
label={`Название (${language.toUpperCase()})`}
|
||||
value={sightInfo[language]?.info?.name ?? ""}
|
||||
onChange={(e) => {
|
||||
handleChange(language as Language, {
|
||||
name: e.target.value,
|
||||
});
|
||||
}}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
/>
|
||||
|
||||
<CoordinatesInput setValue={setCoordinates} />
|
||||
</Box>
|
||||
<TextField
|
||||
label="Адрес"
|
||||
value={sightInfo[language]?.info?.address ?? ""}
|
||||
onChange={(e) => {
|
||||
handleChange(language as Language, {
|
||||
address: e.target.value,
|
||||
});
|
||||
}}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
/>
|
||||
|
||||
{/* Правая колонка для логотипа и водяных знаков
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
<Autocomplete
|
||||
options={cities}
|
||||
value={cities.find((city) => city.id === sightInfo.city_id)}
|
||||
getOptionLabel={(option) => option.name}
|
||||
onChange={(_, value) => {
|
||||
setCity(value?.id ?? 0);
|
||||
}}
|
||||
renderInput={(params) => (
|
||||
<TextField {...params} label="Город" />
|
||||
)}
|
||||
/>
|
||||
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
<CoordinatesInput
|
||||
initialValue={coordinates}
|
||||
setValue={setCoordinates}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<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
|
||||
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
<Paper
|
||||
elevation={2}
|
||||
<Box
|
||||
sx={{
|
||||
padding: 2,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: 1,
|
||||
flex: 1,
|
||||
minWidth: 150, // Ensure a minimum width
|
||||
justifyContent: "space-around",
|
||||
width: "80%",
|
||||
gap: 2,
|
||||
flexDirection: { xs: "column", sm: "row" }, // Stack on extra small, side-by-side on small and up
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: "flex", alignItems: "center" }}>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
gutterBottom
|
||||
sx={{ mb: 0, mr: 0.5 }}
|
||||
>
|
||||
Логотип
|
||||
</Typography>
|
||||
<Tooltip title={watermarkTooltipText}>
|
||||
<Info
|
||||
size={16}
|
||||
color="gray"
|
||||
style={{ cursor: "pointer" }}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<Box
|
||||
<Paper
|
||||
elevation={2}
|
||||
sx={{
|
||||
width: 80,
|
||||
height: 80,
|
||||
backgroundColor: "grey.200",
|
||||
padding: 2,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: 1,
|
||||
mb: 1,
|
||||
cursor: mockSightData.watermark_lu
|
||||
? "pointer"
|
||||
: "default", // Only clickable if there's an image
|
||||
"&:hover": {
|
||||
backgroundColor: mockSightData.watermark_lu
|
||||
? "grey.300"
|
||||
: "grey.200",
|
||||
},
|
||||
gap: 1,
|
||||
flex: 1,
|
||||
minWidth: 150, // Ensure a minimum width
|
||||
}}
|
||||
onClick={() =>
|
||||
mockSightData.watermark_lu &&
|
||||
handleSelectMedia("watermark_lu")
|
||||
}
|
||||
>
|
||||
{mockSightData.watermark_lu ? (
|
||||
<img
|
||||
src={mockSightData.watermark_lu}
|
||||
alt="Знак л.в"
|
||||
style={{ maxWidth: "100%", maxHeight: "100%" }}
|
||||
/>
|
||||
) : (
|
||||
<ImagePlus size={24} color="grey" />
|
||||
)}
|
||||
</Box>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={() => handleSelectMedia("watermark_lu")}
|
||||
>
|
||||
Выбрать
|
||||
</Button>
|
||||
</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 }}
|
||||
<Box sx={{ display: "flex", alignItems: "center" }}>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
gutterBottom
|
||||
sx={{ mb: 0, mr: 0.5 }}
|
||||
>
|
||||
Логотип
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
width: 80,
|
||||
height: 80,
|
||||
backgroundColor: "grey.200",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: 1,
|
||||
mb: 1,
|
||||
cursor: editSightStore.sightInfo?.thumbnail
|
||||
? "pointer"
|
||||
: "default", // Only clickable if there's an image
|
||||
"&:hover": {
|
||||
backgroundColor: editSightStore.sightInfo?.thumbnail
|
||||
? "red.300"
|
||||
: "grey.200",
|
||||
},
|
||||
}}
|
||||
onClick={() => {
|
||||
setIsMediaModalOpen(true);
|
||||
}}
|
||||
>
|
||||
Водяной знак (л.в)
|
||||
</Typography>
|
||||
<Tooltip title={watermarkTooltipText}>
|
||||
<Info
|
||||
size={16}
|
||||
color="gray"
|
||||
style={{ cursor: "pointer" }}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<Box
|
||||
{editSightStore.sightInfo?.thumbnail ? (
|
||||
<img
|
||||
src={`${import.meta.env.VITE_KRBL_MEDIA}${
|
||||
editSightStore.sightInfo?.thumbnail
|
||||
}/download?token=${token}`}
|
||||
alt="Логотип"
|
||||
style={{ maxWidth: "100%", maxHeight: "100%" }}
|
||||
onClick={() => {
|
||||
setIsPreviewMediaOpen(true);
|
||||
setMediaId(editSightStore.sightInfo?.thumbnail);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<ImagePlus size={24} color="grey" />
|
||||
)}
|
||||
</Box>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={(e) => handleMenuOpen(e, "thumbnail")}
|
||||
>
|
||||
Выбрать
|
||||
</Button>
|
||||
</Paper>
|
||||
<Paper
|
||||
elevation={2}
|
||||
sx={{
|
||||
width: 80,
|
||||
height: 80,
|
||||
backgroundColor: "grey.200",
|
||||
padding: 2,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: 1,
|
||||
mb: 1,
|
||||
cursor: mockSightData.watermark_lu
|
||||
? "pointer"
|
||||
: "default", // Only clickable if there's an image
|
||||
"&:hover": {
|
||||
backgroundColor: mockSightData.watermark_lu
|
||||
? "grey.300"
|
||||
: "grey.200",
|
||||
},
|
||||
gap: 1,
|
||||
flex: 1,
|
||||
minWidth: 150, // Ensure a minimum width
|
||||
}}
|
||||
onClick={() =>
|
||||
mockSightData.watermark_lu &&
|
||||
handleSelectMedia("watermark_lu")
|
||||
}
|
||||
>
|
||||
{mockSightData.watermark_lu ? (
|
||||
<img
|
||||
src={mockSightData.watermark_lu}
|
||||
alt="Знак л.в"
|
||||
style={{ maxWidth: "100%", maxHeight: "100%" }}
|
||||
/>
|
||||
) : (
|
||||
<ImagePlus size={24} color="grey" />
|
||||
)}
|
||||
</Box>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={() => handleSelectMedia("watermark_lu")}
|
||||
>
|
||||
Выбрать
|
||||
</Button>
|
||||
</Paper>
|
||||
<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={{
|
||||
width: 80,
|
||||
height: 80,
|
||||
backgroundColor: "grey.200",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: 1,
|
||||
mb: 1,
|
||||
cursor: editSightStore.sightInfo?.watermark_lu
|
||||
? "pointer"
|
||||
: "default", // Only clickable if there's an image
|
||||
"&:hover": {
|
||||
backgroundColor: editSightStore.sightInfo
|
||||
?.watermark_lu
|
||||
? "grey.300"
|
||||
: "grey.200",
|
||||
},
|
||||
}}
|
||||
onClick={() => {
|
||||
setIsPreviewMediaOpen(true);
|
||||
setMediaId(editSightStore.sightInfo?.watermark_lu);
|
||||
}}
|
||||
>
|
||||
{editSightStore.sightInfo?.watermark_lu ? (
|
||||
<img
|
||||
src={`${import.meta.env.VITE_KRBL_MEDIA}${
|
||||
editSightStore.sightInfo?.watermark_lu
|
||||
}/download?token=${token}`}
|
||||
alt="Знак л.в"
|
||||
style={{ maxWidth: "100%", maxHeight: "100%" }}
|
||||
onClick={() => {
|
||||
setIsMediaModalOpen(true);
|
||||
setMediaId(editSightStore.sightInfo?.watermark_lu);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<ImagePlus size={24} color="grey" />
|
||||
)}
|
||||
</Box>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={(e) => handleMenuOpen(e, "watermark_lu")}
|
||||
>
|
||||
Выбрать
|
||||
</Button>
|
||||
</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={watermarkTooltipText}>
|
||||
<Info
|
||||
size={16}
|
||||
color="gray"
|
||||
style={{ cursor: "pointer" }}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<Box
|
||||
<Paper
|
||||
elevation={2}
|
||||
sx={{
|
||||
width: 80,
|
||||
height: 80,
|
||||
backgroundColor: "grey.200",
|
||||
padding: 2,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: 1,
|
||||
mb: 1,
|
||||
cursor: mockSightData.watermark_rd
|
||||
? "pointer"
|
||||
: "default", // Only clickable if there's an image
|
||||
"&:hover": {
|
||||
backgroundColor: mockSightData.watermark_rd
|
||||
? "grey.300"
|
||||
: "grey.200",
|
||||
},
|
||||
gap: 1,
|
||||
flex: 1,
|
||||
minWidth: 150, // Ensure a minimum width
|
||||
}}
|
||||
onClick={() =>
|
||||
mockSightData.watermark_rd &&
|
||||
handleSelectMedia("watermark_rd")
|
||||
}
|
||||
>
|
||||
{mockSightData.watermark_rd ? (
|
||||
<img
|
||||
src={mockSightData.watermark_rd}
|
||||
alt="Знак п.в"
|
||||
style={{ maxWidth: "100%", maxHeight: "100%" }}
|
||||
/>
|
||||
) : (
|
||||
<ImagePlus size={24} color="grey" />
|
||||
)}
|
||||
</Box>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={() => handleSelectMedia("watermark_rd")}
|
||||
>
|
||||
Выбрать
|
||||
</Button>
|
||||
</Paper>
|
||||
<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={{
|
||||
width: 80,
|
||||
height: 80,
|
||||
backgroundColor: "grey.200",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: 1,
|
||||
mb: 1,
|
||||
cursor: editSightStore.sightInfo?.watermark_rd
|
||||
? "pointer"
|
||||
: "default", // Only clickable if there's an image
|
||||
"&:hover": {
|
||||
backgroundColor: editSightStore.sightInfo
|
||||
?.watermark_rd
|
||||
? "grey.300"
|
||||
: "grey.200",
|
||||
},
|
||||
}}
|
||||
onClick={() => editSightStore.sightInfo?.watermark_rd}
|
||||
>
|
||||
{editSightStore.sightInfo?.watermark_rd ? (
|
||||
<img
|
||||
src={`${import.meta.env.VITE_KRBL_MEDIA}${
|
||||
editSightStore.sightInfo?.watermark_rd
|
||||
}/download?token=${token}`}
|
||||
alt="Знак п.в"
|
||||
style={{ maxWidth: "100%", maxHeight: "100%" }}
|
||||
onClick={() => {
|
||||
setIsPreviewMediaOpen(true);
|
||||
setMediaId(editSightStore.sightInfo?.watermark_rd);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<ImagePlus size={24} color="grey" />
|
||||
)}
|
||||
</Box>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={(e) => handleMenuOpen(e, "watermark_rd")}
|
||||
>
|
||||
Выбрать
|
||||
</Button>
|
||||
</Paper>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box> */}
|
||||
|
||||
{/* LanguageSwitcher positioned at the top right */}
|
||||
|
||||
@ -384,8 +437,41 @@ export const InformationTab = observer(
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</TabPanel>
|
||||
</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);
|
||||
}}
|
||||
onSelectArticle={handleMediaSelect}
|
||||
/>
|
||||
|
||||
<PreviewMediaDialog
|
||||
open={isPreviewMediaOpen}
|
||||
onClose={() => setIsPreviewMediaOpen(false)}
|
||||
mediaId={mediaId}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
@ -1,242 +1,348 @@
|
||||
// @widgets/LeftWidgetTab.tsx
|
||||
import { Box, Button, TextField, Paper, Typography } from "@mui/material";
|
||||
import { BackButton, Sight, TabPanel } from "@shared";
|
||||
import { ReactMarkdownComponent, ReactMarkdownEditor } from "@widgets";
|
||||
import {
|
||||
articlesStore,
|
||||
BackButton,
|
||||
TabPanel,
|
||||
languageStore,
|
||||
SelectMediaDialog,
|
||||
editSightStore,
|
||||
} from "@shared";
|
||||
import {
|
||||
LanguageSwitcher,
|
||||
ReactMarkdownComponent,
|
||||
ReactMarkdownEditor,
|
||||
} from "@widgets";
|
||||
import { Unlink, Trash2, ImagePlus } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
|
||||
export const LeftWidgetTab = ({
|
||||
value,
|
||||
index,
|
||||
data,
|
||||
}: {
|
||||
value: number;
|
||||
index: number;
|
||||
data?: Sight;
|
||||
}) => {
|
||||
const [articleTitle, setArticleTitle] = useState("");
|
||||
const [markdownContent, setMarkdownContent] = useState("");
|
||||
const [articleMedia, setArticleMedia] = useState<string | null>(null); // Для превью медиа
|
||||
export const LeftWidgetTab = observer(
|
||||
({ value, index }: { value: number; index: number }) => {
|
||||
const { sightInfo, updateSightInfo, loadSightInfo } = editSightStore;
|
||||
const { articleLoading, getArticleByArticleId } = articlesStore;
|
||||
const { language } = languageStore;
|
||||
const linkedArticle = getArticleByArticleId.get(); // Получаем связанную статью
|
||||
const data = sightInfo[languageStore.language]; // Получаем данные для текущего языка
|
||||
|
||||
const handleSelectMediaForArticle = () => {
|
||||
// Логика открытия модального окна для выбора медиа для статьи
|
||||
console.log("Select media fo r left article");
|
||||
// Для примера, установим моковое изображение
|
||||
// setArticleMedia("https://via.placeholder.com/350x200.png?text=Article+Media");
|
||||
};
|
||||
useEffect(() => {
|
||||
// Этот useEffect должен загружать данные ИЗ СВЯЗАННОЙ СТАТЬИ
|
||||
// ТОЛЬКО ЕСЛИ данные для ТЕКУЩЕГО ЯЗЫКА еще не были загружены
|
||||
// или если sightInfo.left_article изменился (т.е. привязали новую статью).
|
||||
|
||||
const handleUnlinkArticle = () => {
|
||||
console.log("Unlink left article");
|
||||
};
|
||||
// Мы также должны учитывать, что linkedArticle может измениться (т.е. новую статью привязали)
|
||||
// или language изменился.
|
||||
// Если для текущего языка данные еще не "загружены" (`loaded: false`),
|
||||
// тогда мы берем их из `linkedArticle` и инициализируем.
|
||||
console.log("data.left.loaded", data.left.loaded);
|
||||
if (!data.left.loaded) {
|
||||
// <--- КЛЮЧЕВОЕ УСЛОВИЕ
|
||||
if (linkedArticle && !articleLoading) {
|
||||
console.log("loadSightInfo", linkedArticle, language);
|
||||
loadSightInfo(
|
||||
languageStore.language,
|
||||
linkedArticle.heading,
|
||||
linkedArticle.body || "",
|
||||
null
|
||||
);
|
||||
}
|
||||
}
|
||||
// Зависимости: linkedArticle (для реакции на изменение привязанной статьи),
|
||||
// languageStore.language (для реакции на изменение языка),
|
||||
// loadSightInfo (чтобы useEffect знал об изменениях в функции),
|
||||
// data.left.loaded (чтобы useEffect перепроверил условие, когда этот флаг изменится).
|
||||
// Важно: если data.left.loaded становится true, то этот эффект не будет
|
||||
// перезапускаться для того же языка.
|
||||
}, [
|
||||
linkedArticle?.heading,
|
||||
language,
|
||||
loadSightInfo,
|
||||
data.left.loaded,
|
||||
articleLoading,
|
||||
]);
|
||||
|
||||
const handleDeleteArticle = () => {
|
||||
console.log("Delete left article");
|
||||
};
|
||||
const [isSelectMediaDialogOpen, setIsSelectMediaDialogOpen] =
|
||||
useState(false);
|
||||
|
||||
const handleSave = () => {
|
||||
console.log("Saving left widget...");
|
||||
};
|
||||
const handleOpenMediaDialog = useCallback(() => {
|
||||
setIsSelectMediaDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<TabPanel value={value} index={index}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 3,
|
||||
paddingBottom: "70px",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<BackButton />
|
||||
const handleMediaSelected = useCallback(
|
||||
(selectedMedia: any) => {
|
||||
// При выборе медиа, обновляем данные для ТЕКУЩЕГО ЯЗЫКА
|
||||
// сохраняя текущие heading и body.
|
||||
updateSightInfo(
|
||||
languageStore.language,
|
||||
data.left.heading,
|
||||
data.left.body,
|
||||
selectedMedia
|
||||
);
|
||||
setIsSelectMediaDialogOpen(false);
|
||||
},
|
||||
[
|
||||
languageStore.language,
|
||||
data.left.heading,
|
||||
data.left.body,
|
||||
updateSightInfo,
|
||||
]
|
||||
);
|
||||
|
||||
<Paper
|
||||
elevation={2}
|
||||
const handleCloseMediaDialog = useCallback(() => {
|
||||
setIsSelectMediaDialogOpen(false);
|
||||
}, []);
|
||||
|
||||
// ... (остальной JSX код остался почти без изменений)
|
||||
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 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
startIcon={<Unlink size={18} />}
|
||||
onClick={handleUnlinkArticle}
|
||||
size="small"
|
||||
>
|
||||
Открепить
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
startIcon={<Trash2 size={18} />}
|
||||
onClick={handleDeleteArticle}
|
||||
size="small"
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
<BackButton />
|
||||
|
||||
<Box sx={{ display: "flex", gap: 3, flexGrow: 1 }}>
|
||||
{/* Левая колонка: Редактирование */}
|
||||
<Box
|
||||
sx={{ flex: 2, display: "flex", flexDirection: "column", gap: 2 }}
|
||||
<Paper
|
||||
elevation={2}
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
paddingX: 2.5,
|
||||
paddingY: 1.5,
|
||||
borderRadius: 2,
|
||||
border: "1px solid",
|
||||
borderColor: "divider",
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
label="Название информации" // На макете "Название" для статьи, потом "Информация"
|
||||
value={articleTitle}
|
||||
onChange={(e) => setArticleTitle(e.target.value)}
|
||||
variant="outlined"
|
||||
sx={{ width: "100%" }} // Примерная ширина как на макете
|
||||
/>
|
||||
|
||||
<ReactMarkdownEditor
|
||||
value={markdownContent}
|
||||
onChange={setMarkdownContent}
|
||||
/>
|
||||
|
||||
{/* Блок МЕДИА для статьи */}
|
||||
<Paper elevation={1} sx={{ padding: 2, mt: 1 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
МЕДИА
|
||||
</Typography>
|
||||
{/* Здесь будет UI для управления медиа статьи */}
|
||||
{articleMedia ? (
|
||||
<Box sx={{ mb: 1 }}>
|
||||
<img
|
||||
src={articleMedia}
|
||||
alt="Article media"
|
||||
style={{
|
||||
maxWidth: "100%",
|
||||
maxHeight: "150px",
|
||||
borderRadius: "4px",
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: 100,
|
||||
backgroundColor: "grey.100",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: 1,
|
||||
mb: 1,
|
||||
border: "2px dashed",
|
||||
}}
|
||||
<Typography variant="h6">Левая статья</Typography>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
|
||||
{linkedArticle && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
startIcon={<Unlink size={18} />}
|
||||
size="small"
|
||||
>
|
||||
<Typography color="text.secondary">Нет медиа</Typography>
|
||||
</Box>
|
||||
Открепить
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="contained" onClick={handleSelectMediaForArticle}>
|
||||
Выбрать/Загрузить медиа
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
startIcon={<Trash2 size={18} />}
|
||||
size="small"
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
</Paper>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
{/* Правая колонка: Предпросмотр */}
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
|
||||
<Typography variant="h6">Предпросмотр</Typography>
|
||||
<Paper
|
||||
elevation={3}
|
||||
<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,
|
||||
e.target.value,
|
||||
data.left.body,
|
||||
data.left.media
|
||||
)
|
||||
}
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
<ReactMarkdownEditor
|
||||
value={data.left.body}
|
||||
onChange={(value) =>
|
||||
updateSightInfo(
|
||||
languageStore.language,
|
||||
data.left.heading,
|
||||
value,
|
||||
data.left.media
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Блок МЕДИА для статьи */}
|
||||
<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,
|
||||
data.left.heading,
|
||||
data.left.body,
|
||||
null
|
||||
)
|
||||
}
|
||||
>
|
||||
Удалить медиа
|
||||
</Button>
|
||||
)}
|
||||
</Paper>
|
||||
</Box>
|
||||
|
||||
{/* Правая колонка: Предпросмотр */}
|
||||
<Box
|
||||
sx={{
|
||||
width: "100%", // Ширина как на макете ~350px
|
||||
minWidth: 320,
|
||||
maxWidth: 400,
|
||||
height: "auto", // Автоматическая высота или можно задать minHeight
|
||||
minHeight: 500,
|
||||
backgroundColor: "#877361", // Желтоватый фон
|
||||
overflowY: "auto",
|
||||
padding: 0,
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 1.5,
|
||||
}}
|
||||
>
|
||||
{/* Медиа в превью (если есть) */}
|
||||
{articleMedia && (
|
||||
<Box
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: 200,
|
||||
backgroundColor: "grey.300",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={articleMedia}
|
||||
alt="Превью медиа"
|
||||
style={{
|
||||
objectFit: "cover",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
{!articleMedia && (
|
||||
<Box
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: 200,
|
||||
backgroundColor: "grey.300",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<ImagePlus size={48} color="grey" />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Заголовок в превью */}
|
||||
<Box
|
||||
<Typography variant="h6">Предпросмотр</Typography>
|
||||
<Paper
|
||||
elevation={3}
|
||||
sx={{
|
||||
width: "100%",
|
||||
minWidth: 320,
|
||||
maxWidth: 400,
|
||||
height: "auto",
|
||||
minHeight: 500,
|
||||
backgroundColor: "#877361",
|
||||
color: "white",
|
||||
padding: 1.5,
|
||||
overflowY: "auto",
|
||||
padding: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="h5"
|
||||
component="h2"
|
||||
sx={{ wordBreak: "break-word" }}
|
||||
{/* Медиа в превью (если есть) */}
|
||||
{data.left.media ? (
|
||||
<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",
|
||||
}}
|
||||
>
|
||||
<ImagePlus size={48} color="grey" />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Заголовок в превью */}
|
||||
<Box
|
||||
sx={{
|
||||
backgroundColor: "#877361",
|
||||
color: "white",
|
||||
padding: 1.5,
|
||||
}}
|
||||
>
|
||||
{articleTitle || "Название информации"}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography
|
||||
variant="h5"
|
||||
component="h2"
|
||||
sx={{ wordBreak: "break-word" }}
|
||||
>
|
||||
{data.left.heading || "Название информации"}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Текст статьи в превью */}
|
||||
<Box
|
||||
sx={{
|
||||
padding: 2,
|
||||
{/* Текст статьи в превью */}
|
||||
<Box
|
||||
sx={{
|
||||
padding: 2,
|
||||
flexGrow: 1,
|
||||
}}
|
||||
>
|
||||
<ReactMarkdownComponent value={data.left.body} />
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
flexGrow: 1,
|
||||
}}
|
||||
>
|
||||
<ReactMarkdownComponent value={markdownContent} />
|
||||
</Box>
|
||||
</Paper>
|
||||
<Box sx={{ position: "absolute", bottom: 0, right: 0, padding: 2 }}>
|
||||
<Button variant="contained" color="success">
|
||||
Сохранить
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ position: "absolute", bottom: 0, right: 0, padding: 2 }}>
|
||||
<Button variant="contained" color="success" onClick={handleSave}>
|
||||
Сохранить
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</TabPanel>
|
||||
);
|
||||
};
|
||||
<SelectMediaDialog
|
||||
open={isSelectMediaDialogOpen}
|
||||
onClose={handleCloseMediaDialog}
|
||||
onSelectMedia={handleMediaSelected}
|
||||
/>
|
||||
</TabPanel>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -7,3 +7,4 @@ export * from "./SightEdit";
|
||||
export * from "./LanguageSwitcher";
|
||||
export * from "./DevicesTable";
|
||||
export * from "./SightsTable";
|
||||
export * from "./MediaViewer";
|
||||
|
188
src/widgets/modals/SelectArticleDialog/index.tsx
Normal file
188
src/widgets/modals/SelectArticleDialog/index.tsx
Normal file
@ -0,0 +1,188 @@
|
||||
import { articlesStore } from "@shared";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
TextField,
|
||||
List,
|
||||
ListItemButton,
|
||||
ListItemText,
|
||||
Paper,
|
||||
Box,
|
||||
Typography,
|
||||
InputAdornment,
|
||||
} from "@mui/material";
|
||||
import { ImagePlus, Search } from "lucide-react";
|
||||
import { ReactMarkdownComponent } from "@widgets";
|
||||
|
||||
interface SelectArticleModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSelectArticle: (articleId: string) => void;
|
||||
linkedArticleIds?: string[]; // Add optional prop for linked articles
|
||||
}
|
||||
|
||||
export const SelectArticleModal = observer(
|
||||
({
|
||||
open,
|
||||
onClose,
|
||||
onSelectArticle,
|
||||
|
||||
linkedArticleIds = [], // Default to empty array if not provided
|
||||
}: SelectArticleModalProps) => {
|
||||
const { articles, getArticle } = articlesStore; // articles here refers to fetched articles from store
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [hoveredArticleId, setHoveredArticleId] = useState<string | null>(
|
||||
null
|
||||
);
|
||||
const hoverTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (hoveredArticleId) {
|
||||
hoverTimerRef.current = setTimeout(() => {
|
||||
getArticle(hoveredArticleId);
|
||||
}, 200);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (hoverTimerRef.current) {
|
||||
clearTimeout(hoverTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, [hoveredArticleId, getArticle]);
|
||||
|
||||
const handleArticleHover = (articleId: string) => {
|
||||
setHoveredArticleId(articleId);
|
||||
};
|
||||
|
||||
const handleArticleLeave = () => {
|
||||
setHoveredArticleId(null);
|
||||
if (hoverTimerRef.current) {
|
||||
clearTimeout(hoverTimerRef.current);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredArticles = articles
|
||||
.filter((article) => !linkedArticleIds.includes(article.id))
|
||||
.filter((article) =>
|
||||
article.service_name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="lg" fullWidth>
|
||||
<DialogTitle>Выберите существующую статью</DialogTitle>
|
||||
<DialogContent
|
||||
className="flex gap-4"
|
||||
dividers // Adds a divider below the title and above the actions
|
||||
sx={{ height: "600px", display: "flex", flexDirection: "row" }} // Fixed height for DialogContent
|
||||
>
|
||||
<Paper className="w-[66%] flex flex-col">
|
||||
<TextField
|
||||
fullWidth
|
||||
placeholder="Поиск статей..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
sx={{ mb: 2, mt: 1 }}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<Search size={20} />
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<List sx={{ flexGrow: 1, overflowY: "auto" }}>
|
||||
{filteredArticles.map((article) => (
|
||||
<ListItemButton
|
||||
key={article.id}
|
||||
onClick={() => onSelectArticle(article.id)}
|
||||
onMouseEnter={() => handleArticleHover(article.id)}
|
||||
onMouseLeave={handleArticleLeave}
|
||||
sx={{
|
||||
borderRadius: 1,
|
||||
mb: 0.5,
|
||||
"&:hover": {
|
||||
backgroundColor: "action.hover",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ListItemText primary={article.service_name} />
|
||||
</ListItemButton>
|
||||
))}
|
||||
</List>
|
||||
</Paper>
|
||||
<Paper className="flex-1 flex flex-col">
|
||||
<Box
|
||||
className="rounded-2xl overflow-hidden"
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
background: "#877361",
|
||||
borderColor: "grey.300",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
{/* Media Preview Area */}
|
||||
<Box
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: 200,
|
||||
flexShrink: 0,
|
||||
backgroundColor: "grey.300",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<ImagePlus size={48} color="grey" />
|
||||
</Box>
|
||||
|
||||
{/* Title Area */}
|
||||
<Box
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: "70px",
|
||||
background: "#877361",
|
||||
display: "flex",
|
||||
flexShrink: 0,
|
||||
alignItems: "center",
|
||||
borderBottom: "1px solid",
|
||||
px: 2,
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" color="white">
|
||||
{articlesStore.articleData?.heading ||
|
||||
"Нет данных для предпросмотра"}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Body Preview Area */}
|
||||
<Box
|
||||
sx={{
|
||||
px: 2,
|
||||
flexGrow: 1,
|
||||
overflowY: "auto",
|
||||
backgroundColor: "#877361", // To make markdown readable
|
||||
color: "white",
|
||||
py: 1,
|
||||
}}
|
||||
>
|
||||
<ReactMarkdownComponent
|
||||
value={articlesStore.articleData?.body || ""}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>Отмена</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
);
|
1
src/widgets/modals/index.ts
Normal file
1
src/widgets/modals/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./SelectArticleDialog";
|
Reference in New Issue
Block a user