feat: Select article list in sight

This commit is contained in:
Илья Куприец 2025-05-31 06:35:05 +03:00
parent 5ef61bcef4
commit 2e6917406e
21 changed files with 899 additions and 498 deletions

View File

@ -9,34 +9,57 @@ import {
import { authStore } from "@shared"; import { authStore } from "@shared";
import { Layout } from "@widgets"; import { Layout } from "@widgets";
import { Navigate, Outlet, Route, Routes } from "react-router-dom"; import { Navigate, Outlet, Route, Routes, useLocation } from "react-router-dom";
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => { const PublicRoute = ({ children }: { children: React.ReactNode }) => {
const { isAuthenticated } = authStore; const { isAuthenticated } = authStore;
if (!isAuthenticated) { if (isAuthenticated) {
return <Navigate to="/login" />; return <Navigate to="/sight" />;
} }
return children; return children;
}; };
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
const { isAuthenticated } = authStore;
const pathname = useLocation();
if (!isAuthenticated) {
return <Navigate to="/login" />;
}
if (pathname.pathname === "/") {
return <Navigate to="/sight" />;
}
return children;
};
export const Router = () => { export const Router = () => {
return ( return (
<Routes> <Routes>
<Route
path="/login"
element={
<PublicRoute>
<LoginPage />
</PublicRoute>
}
/>
{/* Protected routes with layout */}
<Route <Route
path="/" path="/"
element={ element={
<ProtectedRoute>
<Layout> <Layout>
<Outlet /> <Outlet />
</Layout> </Layout>
</ProtectedRoute>
} }
> >
<Route path="/" element={<MainPage />} /> <Route index element={<MainPage />} />
<Route path="/sight" element={<SightPage />} /> <Route path="sight" element={<SightPage />} />
<Route path="/sight/:id" element={<EditSightPage />} /> <Route path="sight/:id" element={<EditSightPage />} />
<Route path="/sight/create" element={<CreateSightPage />} /> <Route path="sight/create" element={<CreateSightPage />} />
<Route path="/devices" element={<DevicesPage />} /> <Route path="devices" element={<DevicesPage />} />
</Route> </Route>
<Route path="/login" element={<LoginPage />} />
</Routes> </Routes>
); );
}; };

View File

@ -1,7 +1,9 @@
import { Box, Tab, Tabs } from "@mui/material"; import { Box, Tab, Tabs } from "@mui/material";
import { articlesStore, cityStore } from "@shared";
import { InformationTab, RightWidgetTab } from "@widgets"; import { InformationTab, RightWidgetTab } from "@widgets";
import { LeftWidgetTab } from "@widgets"; import { LeftWidgetTab } from "@widgets";
import { useState } from "react"; import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite";
function a11yProps(index: number) { function a11yProps(index: number) {
return { return {
@ -10,13 +12,19 @@ function a11yProps(index: number) {
}; };
} }
export const CreateSightPage = () => { export const CreateSightPage = observer(() => {
const [value, setValue] = useState(0); const [value, setValue] = useState(0);
const { getCities } = cityStore;
const { getArticles } = articlesStore;
const handleChange = (_: React.SyntheticEvent, newValue: number) => { const handleChange = (_: React.SyntheticEvent, newValue: number) => {
setValue(newValue); setValue(newValue);
}; };
useEffect(() => {
getCities();
getArticles();
}, []);
return ( return (
<Box <Box
sx={{ sx={{
@ -58,4 +66,4 @@ export const CreateSightPage = () => {
</div> </div>
</Box> </Box>
); );
}; });

View File

@ -3,7 +3,7 @@ import { InformationTab, RightWidgetTab } from "@widgets";
import { LeftWidgetTab } from "@widgets"; import { LeftWidgetTab } from "@widgets";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { languageStore, sightsStore } from "@shared"; import { articlesStore, languageStore, sightsStore } from "@shared";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
function a11yProps(index: number) { function a11yProps(index: number) {
@ -16,6 +16,7 @@ function a11yProps(index: number) {
export const EditSightPage = observer(() => { export const EditSightPage = observer(() => {
const [value, setValue] = useState(0); const [value, setValue] = useState(0);
const { sight, getSight } = sightsStore; const { sight, getSight } = sightsStore;
const { getArticles } = articlesStore;
const { language } = languageStore; const { language } = languageStore;
const { id } = useParams(); const { id } = useParams();
@ -27,6 +28,7 @@ export const EditSightPage = observer(() => {
const fetchData = async () => { const fetchData = async () => {
if (id) { if (id) {
await getSight(Number(id)); await getSight(Number(id));
await getArticles();
} }
}; };
fetchData(); fetchData();

View File

@ -1,4 +1,4 @@
import { languageStore } from "@shared"; import { languageStore, Language } from "@shared";
import axios from "axios"; import axios from "axios";
const authInstance = axios.create({ const authInstance = axios.create({
@ -11,4 +11,16 @@ authInstance.interceptors.request.use((config) => {
return config; return config;
}); });
export { authInstance }; const languageInstance = (language: Language) => {
const instance = axios.create({
baseURL: "https://wn.krbl.ru",
});
instance.interceptors.request.use((config) => {
config.headers.Authorization = `Bearer ${localStorage.getItem("token")}`;
config.headers["X-Language"] = language;
return config;
});
return instance;
};
export { authInstance, languageInstance };

View File

@ -4,3 +4,4 @@ export * from "./ui";
export * from "./store"; export * from "./store";
export * from "./const"; export * from "./const";
export * from "./api"; export * from "./api";
export * from "./modals";

View 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>
);
}
);

View File

@ -0,0 +1 @@
export * from "./SelectArticleDialog";

View File

@ -0,0 +1,36 @@
import { authInstance } from "@shared";
import { makeAutoObservable, runInAction } from "mobx";
export type Article = {
id: string;
heading: string;
body: string;
service_name: string;
};
class ArticlesStore {
constructor() {
makeAutoObservable(this);
}
articles: Article[] = [];
articleData: Article | null = null;
getArticles = async () => {
const response = await authInstance.get("/article");
runInAction(() => {
this.articles = response.data;
});
};
getArticle = async (id: string) => {
const response = await authInstance.get(`/article/${id}`);
runInAction(() => {
this.articleData = response.data;
});
};
}
export const articlesStore = new ArticlesStore();

View File

@ -1,5 +1,5 @@
import { API_URL, decodeJWT } from "@shared"; import { API_URL, decodeJWT } from "@shared";
import { makeAutoObservable } from "mobx"; import { makeAutoObservable, runInAction } from "mobx";
import axios, { AxiosError } from "axios"; import axios, { AxiosError } from "axios";
type LoginResponse = { type LoginResponse = {
@ -14,6 +14,7 @@ type LoginResponse = {
class AuthStore { class AuthStore {
payload: LoginResponse | null = null; payload: LoginResponse | null = null;
token: string | null = null;
isLoading = false; isLoading = false;
error: string | null = null; error: string | null = null;
@ -28,21 +29,13 @@ class AuthStore {
if (decoded) { if (decoded) {
this.payload = decoded; this.payload = decoded;
// Set the token in axios defaults for future requests
if (storedToken) {
axios.defaults.headers.common[
"Authorization"
] = `Bearer ${storedToken}`;
}
} else { } else {
// If token is invalid or missing, clear it
this.logout(); this.logout();
} }
} }
private setAuthToken(token: string) { private setAuthToken(token: string) {
localStorage.setItem("token", token); localStorage.setItem("token", token);
axios.defaults.headers.common["Authorization"] = `Bearer ${token}`;
} }
login = async (email: string, password: string) => { login = async (email: string, password: string) => {
@ -58,12 +51,13 @@ class AuthStore {
} }
); );
const { token } = response.data; const data = response.data;
// Update auth token and store state runInAction(() => {
this.setAuthToken(token); this.setAuthToken(data.token);
this.payload = response.data; this.payload = response.data;
this.error = null; this.error = null;
});
} catch (error) { } catch (error) {
if (error instanceof AxiosError) { if (error instanceof AxiosError) {
this.error = this.error =
@ -85,7 +79,7 @@ class AuthStore {
}; };
get isAuthenticated() { get isAuthenticated() {
return !!this.payload?.token; return this.payload?.token !== null;
} }
get user() { get user() {

View File

@ -1,5 +1,5 @@
import { authInstance } from "@shared"; import { authInstance } from "@shared";
import { makeAutoObservable } from "mobx"; import { makeAutoObservable, runInAction } from "mobx";
type City = { type City = {
id: number; id: number;
@ -18,7 +18,9 @@ class CityStore {
getCities = async () => { getCities = async () => {
const response = await authInstance.get("/city"); const response = await authInstance.get("/city");
runInAction(() => {
this.cities = response.data; this.cities = response.data;
});
}; };
} }

View File

@ -1,5 +1,5 @@
import { API_URL, authInstance } from "@shared"; import { API_URL, authInstance } from "@shared";
import { makeAutoObservable } from "mobx"; import { makeAutoObservable, runInAction } from "mobx";
class DevicesStore { class DevicesStore {
devices: string[] = []; devices: string[] = [];
@ -12,7 +12,9 @@ class DevicesStore {
getDevices = async () => { getDevices = async () => {
const response = await authInstance.get(`${API_URL}/devices/connected`); const response = await authInstance.get(`${API_URL}/devices/connected`);
runInAction(() => {
this.devices = response.data; this.devices = response.data;
});
}; };
setSelectedDevice = (uuid: string) => { setSelectedDevice = (uuid: string) => {

View File

@ -1,13 +1,14 @@
import { makeAutoObservable } from "mobx"; import { makeAutoObservable } from "mobx";
import { Language } from "../SightsStore";
class LanguageStore { class LanguageStore {
language: string = "ru"; language: Language = "ru";
constructor() { constructor() {
makeAutoObservable(this); makeAutoObservable(this);
} }
setLanguage = (language: string) => { setLanguage = (language: Language) => {
this.language = language; this.language = language;
}; };
} }

View File

@ -1,4 +1,4 @@
import { authInstance } from "@shared"; import { authInstance, languageInstance, languageStore } from "@shared";
import { makeAutoObservable, runInAction } from "mobx"; import { makeAutoObservable, runInAction } from "mobx";
export type Language = "ru" | "en" | "zh"; export type Language = "ru" | "en" | "zh";
@ -8,7 +8,6 @@ export type MultilingualContent = {
name: string; name: string;
description: string; description: string;
address: string; address: string;
// Add other fields that need to be multilingual
}; };
}; };
@ -28,10 +27,22 @@ export type Sight = {
video_preview: string | null; video_preview: string | null;
}; };
export type CreateSight = {
[key in Language]: {
name: string;
description: string;
address: string;
};
};
class SightsStore { class SightsStore {
sights: Sight[] = []; sights: Sight[] = [];
sight: Sight | null = null; sight: Sight | null = null;
cachedMultilingualContent: MultilingualContent | null = null; createSight: CreateSight = {
ru: { name: "", description: "", address: "" },
en: { name: "", description: "", address: "" },
zh: { name: "", description: "", address: "" },
};
constructor() { constructor() {
makeAutoObservable(this); makeAutoObservable(this);
@ -52,34 +63,96 @@ class SightsStore {
}); });
}; };
setCachedMultilingualContent = (content: MultilingualContent) => { createSightAction = async (
runInAction(() => { city: number,
this.cachedMultilingualContent = content; coordinates: { latitude: number; longitude: number }
});
};
updateCachedLanguageContent = (
language: Language,
content: Partial<MultilingualContent[Language]>
) => { ) => {
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,
longitude: coordinates.longitude,
})
).data.id;
const anotherLanguages = ["ru", "en", "zh"].filter(
(language) => language !== languageStore.language
);
await languageInstance(anotherLanguages[0] as Language).patch(
`/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,
longitude: coordinates.longitude,
}
);
await languageInstance(anotherLanguages[1] as Language).patch(
`/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,
longitude: coordinates.longitude,
}
);
runInAction(() => { runInAction(() => {
if (!this.cachedMultilingualContent) { this.createSight = {
this.cachedMultilingualContent = {
ru: { name: "", description: "", address: "" }, ru: { name: "", description: "", address: "" },
en: { name: "", description: "", address: "" }, en: { name: "", description: "", address: "" },
zh: { name: "", description: "", address: "" }, zh: { name: "", description: "", address: "" },
}; };
} });
this.cachedMultilingualContent[language] = { };
...this.cachedMultilingualContent[language], setCreateSight = (content: CreateSight) => {
runInAction(() => {
this.createSight = content;
});
};
updateCreateSight = (
language: Language,
content: Partial<CreateSight[Language]>
) => {
runInAction(() => {
this.createSight[language] = {
...this.createSight[language],
...content, ...content,
}; };
}); });
}; };
clearCachedMultilingualContent = () => { clearCreateSight = () => {
runInAction(() => { runInAction(() => {
this.cachedMultilingualContent = null; this.createSight = {
ru: {
name: "",
description: "",
address: "",
},
en: {
name: "",
description: "",
address: "",
},
zh: {
name: "",
description: "",
address: "",
},
};
}); });
}; };
} }

View File

@ -5,3 +5,4 @@ export * from "./VehicleStore";
export * from "./SnapshotStore"; export * from "./SnapshotStore";
export * from "./SightsStore"; export * from "./SightsStore";
export * from "./CityStore"; export * from "./CityStore";
export * from "./ArticlesStore";

View File

@ -0,0 +1,32 @@
import { Box, TextField } from "@mui/material";
import { useEffect, useState } from "react";
export const CoordinatesInput = ({
setValue,
}: {
setValue: (value: { latitude: number; longitude: number }) => void;
}) => {
const [inputValue, setInputValue] = useState<string>("");
useEffect(() => {
setValue({
latitude: Number(inputValue.split(" ")[0]),
longitude: Number(inputValue.split(" ")[1]),
});
}, [inputValue]);
return (
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<TextField
label="Координаты"
value={inputValue}
onChange={(e) => {
setInputValue(e.target.value);
}}
fullWidth
variant="outlined"
helperText="Формат: широта, долгота (например, 59.9398, 30.3146)"
/>
</Box>
);
};

View File

@ -1,3 +1,4 @@
export * from "./TabPanel"; export * from "./TabPanel";
export * from "./BackButton"; export * from "./BackButton";
export * from "./Modal"; export * from "./Modal";
export * from "./CoordinatesInput";

View File

@ -5,6 +5,7 @@ import rehypeRaw from "rehype-raw";
export const ReactMarkdownComponent = ({ value }: { value: string }) => { export const ReactMarkdownComponent = ({ value }: { value: string }) => {
return ( return (
<Box <Box
className="prose prose-sm prose-invert"
sx={{ sx={{
"& img": { "& img": {
maxWidth: "100%", maxWidth: "100%",

View File

@ -2,58 +2,58 @@ import { styled } from "@mui/material/styles";
import SimpleMDE, { SimpleMDEReactProps } from "react-simplemde-editor"; import SimpleMDE, { SimpleMDEReactProps } from "react-simplemde-editor";
import "easymde/dist/easymde.min.css"; import "easymde/dist/easymde.min.css";
const StyledMarkdownEditor = styled("div")(({ theme }) => ({ const StyledMarkdownEditor = styled("div")(() => ({
"& .editor-toolbar": { "& .editor-toolbar": {
backgroundColor: theme.palette.background.paper, backgroundColor: "inherit",
borderColor: theme.palette.divider, borderColor: "inherit",
}, },
"& .editor-toolbar button": { "& .editor-toolbar button": {
color: theme.palette.text.primary, color: "inherit",
}, },
"& .editor-toolbar button:hover": { "& .editor-toolbar button:hover": {
backgroundColor: theme.palette.action.hover, backgroundColor: "inherit",
}, },
"& .editor-toolbar button:active, & .editor-toolbar button.active": { "& .editor-toolbar button:active, & .editor-toolbar button.active": {
backgroundColor: theme.palette.action.selected, backgroundColor: "inherit",
}, },
"& .editor-statusbar": { "& .editor-statusbar": {
display: "none", display: "none",
}, },
// Стили для самого редактора // Стили для самого редактора
"& .CodeMirror": { "& .CodeMirror": {
backgroundColor: theme.palette.background.paper, backgroundColor: "inherit",
color: theme.palette.text.primary, color: "inherit",
borderColor: theme.palette.divider, borderColor: "inherit",
}, },
// Стили для текста в редакторе // Стили для текста в редакторе
"& .CodeMirror-selected": { "& .CodeMirror-selected": {
backgroundColor: `${theme.palette.action.selected} !important`, backgroundColor: "inherit !important",
}, },
"& .CodeMirror-cursor": { "& .CodeMirror-cursor": {
borderLeftColor: theme.palette.text.primary, borderLeftColor: "inherit",
}, },
// Стили для markdown разметки // Стили для markdown разметки
"& .cm-header": { "& .cm-header": {
color: theme.palette.primary.main, color: "inherit",
}, },
"& .cm-quote": { "& .cm-quote": {
color: theme.palette.text.secondary, color: "inherit",
fontStyle: "italic", fontStyle: "italic",
}, },
"& .cm-link": { "& .cm-link": {
color: theme.palette.primary.main, color: "inherit",
}, },
"& .cm-url": { "& .cm-url": {
color: theme.palette.secondary.main, color: "inherit",
}, },
"& .cm-formatting": { "& .cm-formatting": {
color: theme.palette.text.secondary, color: "inherit",
}, },
"& .CodeMirror .editor-preview-full": { "& .CodeMirror .editor-preview-full": {
backgroundColor: theme.palette.background.paper, backgroundColor: "inherit",
color: theme.palette.text.primary, color: "inherit",
borderColor: theme.palette.divider, borderColor: "inherit",
}, },
"& .EasyMDEContainer": { "& .EasyMDEContainer": {
@ -100,7 +100,8 @@ export const ReactMarkdownEditor = (props: SimpleMDEReactProps) => {
]; ];
return ( return (
<StyledMarkdownEditor <StyledMarkdownEditor
className="my-markdown-editor" autoFocus={false}
spellCheck={false}
sx={{ marginTop: 1.5, marginBottom: 3 }} sx={{ marginTop: 1.5, marginBottom: 3 }}
> >
<SimpleMDE {...props} /> <SimpleMDE {...props} />

View File

@ -2,133 +2,50 @@ import {
Button, Button,
TextField, TextField,
Box, Box,
Autocomplete,
Typography, Typography,
IconButton,
Paper,
Tooltip,
} from "@mui/material"; } from "@mui/material";
import { BackButton, Sight, sightsStore, TabPanel, Language } from "@shared"; import {
BackButton,
sightsStore,
TabPanel,
languageStore,
CreateSight,
Language,
cityStore,
CoordinatesInput,
} from "@shared";
import { LanguageSwitcher } from "@widgets"; import { LanguageSwitcher } from "@widgets";
import { ImagePlus, Info } from "lucide-react";
import { observer } from "mobx-react-lite";
import { useState, useEffect } from "react";
import { useLocation } from "react-router-dom";
// Мокап данных для отображения, потом это будет приходить из store/props import { observer } from "mobx-react-lite";
// Keeping this mock for demonstration, but in a real app, import { useState } from "react";
// this would come from the MobX store's 'sight' object.
const mockSightData = {
name: "Эрмитаж",
address: "Дворцовая площадь, 2",
city: "Санкт-Петербург", // или city_id, если будет Select
coordinates: "59.9398, 30.3146",
logo: null, // null или URL/ID медиа
watermark_lu: null,
watermark_rd: null,
};
// Мокап для всплывающей подсказки // Мокап для всплывающей подсказки
const watermarkTooltipText = "При наведении открывается просмотр в поп-апе";
const logoTooltipText = "При наведении открывается просмотр логотипа в поп-апе";
export const InformationTab = observer( export const InformationTab = observer(
({ value, index }: { value: number; index: number }) => { ({ value, index }: { value: number; index: number }) => {
const { const { cities } = cityStore;
sight, const { createSight, updateCreateSight, createSightAction } = sightsStore;
cachedMultilingualContent, const [city, setCity] = useState<number>(0);
updateCachedLanguageContent, const [coordinates, setCoordinates] = useState({
clearCachedMultilingualContent, latitude: 0,
// Assuming you'll have an action to update the main sight object longitude: 0,
updateSight,
} = sightsStore;
// Initialize local states with data from the MobX store's 'sight'
const [address, setAddress] = useState(sight?.address ?? "");
const [city, setCity] = useState(sight?.city ?? "");
const [coordinates, setCoordinates] = useState(
sight?.latitude && sight?.longitude
? `${sight.latitude}, ${sight.longitude}`
: ""
);
const [currentLanguage, setCurrentLanguage] = useState<Language>("ru");
const pathname = useLocation().pathname;
// Effect to initialize local states when `sight` data becomes available or changes
useEffect(() => {
if (sight) {
setAddress(sight.address ?? "");
setCity(sight.city ?? "");
setCoordinates(
sight.latitude && sight.longitude
? `${sight.latitude}, ${sight.longitude}`
: ""
);
// Initialize cached content if not already set
if (!cachedMultilingualContent) {
sightsStore.setCachedMultilingualContent({
ru: { name: sight.name, description: "", address: sight.address },
en: { name: "", description: "", address: "" },
zh: { name: "", description: "", address: "" },
}); });
} const { language } = languageStore;
}
}, [sight, cachedMultilingualContent]); // Add cachedMultilingualContent to dependencies
// Effect to clear cached content when the route changes const handleChange = (
useEffect(() => { language: Language,
clearCachedMultilingualContent(); content: Partial<CreateSight[Language]>
}, [pathname, clearCachedMultilingualContent]);
const handleLanguageChange = (lang: Language) => {
setCurrentLanguage(lang);
};
const handleSelectMedia = (
type: "logo" | "watermark_lu" | "watermark_rd"
) => { ) => {
// Here will be logic for opening modal window for media selection updateCreateSight(language, content);
console.log("Select media for:", type);
// In a real application, you might open a dialog here
// and update the sight object with the selected media URL/ID.
}; };
const handleSave = () => { const handleSave = async () => {
// Parse coordinates back to latitude and longitude try {
let latitude: number | undefined; await createSightAction(createSight[language], city, coordinates);
let longitude: number | undefined; } catch (error) {
const coordsArray = coordinates console.error(error);
.split(",")
.map((coord) => parseFloat(coord.trim()));
if (
coordsArray.length === 2 &&
!isNaN(coordsArray[0]) &&
!isNaN(coordsArray[1])
) {
latitude = coordsArray[0];
longitude = coordsArray[1];
} }
// Prepare the updated sight data
const updatedSightData = {
...sight, // Keep existing sight data
address: address,
city: city,
latitude: latitude,
longitude: longitude,
// Assuming logo and watermark updates would happen via handleSelectMedia
// and then be reflected in the sight object in the store.
};
// Here we would save both the sight data and the multilingual content
console.log("Saving general information and multilingual content...", {
updatedSightData,
multilingualContent: cachedMultilingualContent,
});
// Call an action from your store to save the data
// For example:
// sightsStore.saveSight({ ...updatedSightData, multilingualContent: cachedMultilingualContent });
// You might have a specific action in your store for saving all this data.
}; };
return ( return (
@ -164,10 +81,10 @@ export const InformationTab = observer(
}} }}
> >
<TextField <TextField
label={`Название (${currentLanguage.toUpperCase()})`} label={`Название (${language.toUpperCase()})`}
value={cachedMultilingualContent?.[currentLanguage]?.name ?? ""} value={createSight[language]?.name ?? ""}
onChange={(e) => { onChange={(e) => {
updateCachedLanguageContent(currentLanguage, { handleChange(language as Language, {
name: e.target.value, name: e.target.value,
}); });
}} }}
@ -175,13 +92,10 @@ export const InformationTab = observer(
variant="outlined" variant="outlined"
/> />
<TextField <TextField
label={`Описание (${currentLanguage.toUpperCase()})`} label={`Описание (${language.toUpperCase()})`}
value={ value={createSight?.[language]?.description ?? ""}
cachedMultilingualContent?.[currentLanguage]?.description ??
""
}
onChange={(e) => { onChange={(e) => {
updateCachedLanguageContent(currentLanguage, { handleChange(language as Language, {
description: e.target.value, description: e.target.value,
}); });
}} }}
@ -192,37 +106,30 @@ export const InformationTab = observer(
/> />
<TextField <TextField
label="Адрес" label="Адрес"
value={address} value={createSight?.[language]?.address ?? ""}
onChange={(e) => { onChange={(e) => {
setAddress(e.target.value); handleChange(language as Language, {
address: e.target.value,
});
}} }}
fullWidth fullWidth
variant="outlined" variant="outlined"
/> />
<TextField <Autocomplete
label="Город" options={cities}
value={city} getOptionLabel={(option) => option.name}
onChange={(e) => { onChange={(_, value) => {
setCity(e.target.value); setCity(value?.id ?? 0);
}} }}
fullWidth renderInput={(params) => (
variant="outlined" <TextField {...params} label="Город" />
)}
/> />
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<TextField <CoordinatesInput setValue={setCoordinates} />
label="Координаты"
value={coordinates}
onChange={(e) => {
setCoordinates(e.target.value);
}}
fullWidth
variant="outlined"
helperText="Формат: широта, долгота (например, 59.9398, 30.3146)"
/>
</Box>
</Box> </Box>
{/* Правая колонка для логотипа и водяных знаков */} {/* Правая колонка для логотипа и водяных знаков
<Box <Box
sx={{ sx={{
display: "flex", display: "flex",
@ -230,7 +137,7 @@ export const InformationTab = observer(
gap: 4, gap: 4,
}} }}
> >
{/* Водяные знаки */}
<Box <Box
sx={{ sx={{
display: "flex", display: "flex",
@ -453,12 +360,11 @@ export const InformationTab = observer(
</Paper> </Paper>
</Box> </Box>
</Box> </Box>
</Box> </Box> */}
{/* LanguageSwitcher positioned at the top right */} {/* LanguageSwitcher positioned at the top right */}
<Box sx={{ position: "absolute", top: 0, right: 0, zIndex: 1 }}>
<LanguageSwitcher onLanguageChange={handleLanguageChange} /> <LanguageSwitcher />
</Box>
{/* Save Button fixed at the bottom right */} {/* Save Button fixed at the bottom right */}
<Box <Box
@ -478,6 +384,7 @@ export const InformationTab = observer(
</Button> </Button>
</Box> </Box>
</Box> </Box>
</Box>
</TabPanel> </TabPanel>
); );
} }

View File

@ -4,22 +4,6 @@ import { ReactMarkdownComponent, ReactMarkdownEditor } from "@widgets";
import { Unlink, Trash2, ImagePlus } from "lucide-react"; import { Unlink, Trash2, ImagePlus } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
// Мокап данных для левой статьи
const mockLeftArticle = {
title: "История основания",
markdownContent: `## Заголовок статьи H2
Какой-то **текст** для левой статьи.
Можно использовать *markdown*.
- Список 1
- Список 2
[Ссылка на Яндекс](https://ya.ru)
`,
media: null, // null или URL/ID медиа
};
export const LeftWidgetTab = ({ export const LeftWidgetTab = ({
value, value,
index, index,
@ -29,13 +13,9 @@ export const LeftWidgetTab = ({
index: number; index: number;
data?: Sight; data?: Sight;
}) => { }) => {
const [articleTitle, setArticleTitle] = useState(mockLeftArticle.title); const [articleTitle, setArticleTitle] = useState("");
const [markdownContent, setMarkdownContent] = useState( const [markdownContent, setMarkdownContent] = useState("");
mockLeftArticle.markdownContent const [articleMedia, setArticleMedia] = useState<string | null>(null); // Для превью медиа
);
const [articleMedia, setArticleMedia] = useState<string | null>(
mockLeftArticle.media
); // Для превью медиа
const handleSelectMediaForArticle = () => { const handleSelectMediaForArticle = () => {
// Логика открытия модального окна для выбора медиа для статьи // Логика открытия модального окна для выбора медиа для статьи
@ -118,10 +98,6 @@ export const LeftWidgetTab = ({
sx={{ width: "100%" }} // Примерная ширина как на макете sx={{ width: "100%" }} // Примерная ширина как на макете
/> />
{/* Редактор Markdown */}
<Typography variant="subtitle2" sx={{ mt: 1 }}>
Текст
</Typography>
<ReactMarkdownEditor <ReactMarkdownEditor
value={markdownContent} value={markdownContent}
onChange={setMarkdownContent} onChange={setMarkdownContent}
@ -157,7 +133,6 @@ export const LeftWidgetTab = ({
borderRadius: 1, borderRadius: 1,
mb: 1, mb: 1,
border: "2px dashed", border: "2px dashed",
borderColor: "grey.300",
}} }}
> >
<Typography color="text.secondary">Нет медиа</Typography> <Typography color="text.secondary">Нет медиа</Typography>
@ -246,10 +221,8 @@ export const LeftWidgetTab = ({
<Box <Box
sx={{ sx={{
padding: 2, padding: 2,
backgroundColor: "#877361",
flexGrow: 1, flexGrow: 1,
color: "white",
"& img": { maxWidth: "100%" },
}} }}
> >
<ReactMarkdownComponent value={markdownContent} /> <ReactMarkdownComponent value={markdownContent} />

View File

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