feat: Select article list in sight
This commit is contained in:
parent
5ef61bcef4
commit
2e6917406e
@ -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={
|
||||||
<Layout>
|
<ProtectedRoute>
|
||||||
<Outlet />
|
<Layout>
|
||||||
</Layout>
|
<Outlet />
|
||||||
|
</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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
@ -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();
|
||||||
|
@ -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 };
|
||||||
|
@ -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";
|
||||||
|
188
src/shared/modals/SelectArticleDialog/index.tsx
Normal file
188
src/shared/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/shared/modals/index.ts
Normal file
1
src/shared/modals/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from "./SelectArticleDialog";
|
36
src/shared/store/ArticlesStore/index.tsx
Normal file
36
src/shared/store/ArticlesStore/index.tsx
Normal 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();
|
@ -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() {
|
||||||
|
@ -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");
|
||||||
this.cities = response.data;
|
runInAction(() => {
|
||||||
|
this.cities = response.data;
|
||||||
|
});
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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`);
|
||||||
this.devices = response.data;
|
runInAction(() => {
|
||||||
|
this.devices = response.data;
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
setSelectedDevice = (uuid: string) => {
|
setSelectedDevice = (uuid: string) => {
|
||||||
|
@ -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;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -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 (
|
||||||
|
city: number,
|
||||||
|
coordinates: { latitude: number; longitude: number }
|
||||||
|
) => {
|
||||||
|
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(() => {
|
||||||
this.cachedMultilingualContent = content;
|
this.createSight = {
|
||||||
|
ru: { name: "", description: "", address: "" },
|
||||||
|
en: { name: "", description: "", address: "" },
|
||||||
|
zh: { name: "", description: "", address: "" },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
setCreateSight = (content: CreateSight) => {
|
||||||
|
runInAction(() => {
|
||||||
|
this.createSight = content;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
updateCachedLanguageContent = (
|
updateCreateSight = (
|
||||||
language: Language,
|
language: Language,
|
||||||
content: Partial<MultilingualContent[Language]>
|
content: Partial<CreateSight[Language]>
|
||||||
) => {
|
) => {
|
||||||
runInAction(() => {
|
runInAction(() => {
|
||||||
if (!this.cachedMultilingualContent) {
|
this.createSight[language] = {
|
||||||
this.cachedMultilingualContent = {
|
...this.createSight[language],
|
||||||
ru: { name: "", description: "", address: "" },
|
|
||||||
en: { name: "", description: "", address: "" },
|
|
||||||
zh: { name: "", description: "", address: "" },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
this.cachedMultilingualContent[language] = {
|
|
||||||
...this.cachedMultilingualContent[language],
|
|
||||||
...content,
|
...content,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
clearCachedMultilingualContent = () => {
|
clearCreateSight = () => {
|
||||||
runInAction(() => {
|
runInAction(() => {
|
||||||
this.cachedMultilingualContent = null;
|
this.createSight = {
|
||||||
|
ru: {
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
address: "",
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
address: "",
|
||||||
|
},
|
||||||
|
zh: {
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
address: "",
|
||||||
|
},
|
||||||
|
};
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -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";
|
||||||
|
32
src/shared/ui/CoordinatesInput/index.tsx
Normal file
32
src/shared/ui/CoordinatesInput/index.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
@ -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";
|
||||||
|
@ -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%",
|
||||||
|
@ -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} />
|
||||||
|
@ -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;
|
const { language } = languageStore;
|
||||||
|
|
||||||
// Initialize local states with data from the MobX store's 'sight'
|
const handleChange = (
|
||||||
const [address, setAddress] = useState(sight?.address ?? "");
|
language: Language,
|
||||||
const [city, setCity] = useState(sight?.city ?? "");
|
content: Partial<CreateSight[Language]>
|
||||||
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: "" },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [sight, cachedMultilingualContent]); // Add cachedMultilingualContent to dependencies
|
|
||||||
|
|
||||||
// Effect to clear cached content when the route changes
|
|
||||||
useEffect(() => {
|
|
||||||
clearCachedMultilingualContent();
|
|
||||||
}, [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,29 +360,29 @@ 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} />
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Save Button fixed at the bottom right */}
|
<LanguageSwitcher />
|
||||||
<Box
|
|
||||||
sx={{
|
{/* Save Button fixed at the bottom right */}
|
||||||
position: "absolute",
|
<Box
|
||||||
bottom: 0,
|
sx={{
|
||||||
right: 0,
|
position: "absolute",
|
||||||
padding: 2,
|
bottom: 0,
|
||||||
backgroundColor: "background.paper", // To ensure it stands out over content
|
right: 0,
|
||||||
width: "100%", // Take full width to cover content below it
|
padding: 2,
|
||||||
display: "flex",
|
backgroundColor: "background.paper", // To ensure it stands out over content
|
||||||
justifyContent: "flex-end", // Align to the right
|
width: "100%", // Take full width to cover content below it
|
||||||
}}
|
display: "flex",
|
||||||
>
|
justifyContent: "flex-end", // Align to the right
|
||||||
<Button variant="contained" color="success" onClick={handleSave}>
|
}}
|
||||||
Сохранить
|
>
|
||||||
</Button>
|
<Button variant="contained" color="success" onClick={handleSave}>
|
||||||
|
Сохранить
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
@ -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} />
|
||||||
|
@ -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,241 +39,368 @@ 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}>
|
<Paper
|
||||||
<Box
|
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={{
|
sx={{
|
||||||
display: "flex",
|
overflowY: "auto",
|
||||||
flexDirection: "column",
|
flexGrow: 1,
|
||||||
height: "100%",
|
maxHeight: "calc(100% - 60px)",
|
||||||
minHeight: "calc(100vh - 200px)",
|
|
||||||
gap: 2,
|
|
||||||
paddingBottom: "70px",
|
|
||||||
position: "relative",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<BackButton />
|
{blocks.map((block) => (
|
||||||
|
<ListItemButton
|
||||||
<Box sx={{ display: "flex", flexGrow: 1, gap: 2.5 }}>
|
key={block.id}
|
||||||
{/* Левая колонка: Список блоков/статей */}
|
selected={selectedBlockId === block.id}
|
||||||
<Paper
|
onClick={() => onSelectBlock(block.id)}
|
||||||
elevation={2}
|
|
||||||
sx={{
|
sx={{
|
||||||
width: 260, // Ширина как на макете
|
borderRadius: 1,
|
||||||
minWidth: 240,
|
mb: 0.5,
|
||||||
display: "flex",
|
backgroundColor:
|
||||||
flexDirection: "column",
|
selectedBlockId === block.id ? "primary.light" : "transparent",
|
||||||
justifyContent: "space-between",
|
"&.Mui-selected": {
|
||||||
padding: 1.5,
|
backgroundColor: "primary.main",
|
||||||
borderRadius: 2,
|
color: "primary.contrastText",
|
||||||
border: "1px solid",
|
"&:hover": {
|
||||||
borderColor: "divider",
|
backgroundColor: "primary.dark",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"&:hover": {
|
||||||
|
backgroundColor:
|
||||||
|
selectedBlockId !== block.id ? "action.hover" : undefined,
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<List
|
<ListItemText
|
||||||
dense
|
primary={block.name}
|
||||||
sx={{
|
primaryTypographyProps={{
|
||||||
overflowY: "auto",
|
fontWeight: selectedBlockId === block.id ? "bold" : "normal",
|
||||||
flexGrow: 1,
|
overflow: "hidden",
|
||||||
maxHeight:
|
textOverflow: "ellipsis",
|
||||||
"calc(100% - 60px)" /* Adjust based on button size */,
|
whiteSpace: "nowrap",
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
{rightWidgetBlocks.map((block) => (
|
</ListItemButton>
|
||||||
<ListItemButton
|
))}
|
||||||
key={block.id}
|
</List>
|
||||||
selected={selectedBlockId === block.id}
|
|
||||||
onClick={() => handleSelectBlock(block.id)}
|
|
||||||
sx={{
|
|
||||||
borderRadius: 1,
|
|
||||||
mb: 0.5,
|
|
||||||
backgroundColor:
|
|
||||||
selectedBlockId === block.id
|
|
||||||
? "primary.light"
|
|
||||||
: "transparent",
|
|
||||||
"&.Mui-selected": {
|
|
||||||
backgroundColor: "primary.main",
|
|
||||||
color: "primary.contrastText",
|
|
||||||
"&:hover": {
|
|
||||||
backgroundColor: "primary.dark",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"&:hover": {
|
|
||||||
backgroundColor:
|
|
||||||
selectedBlockId !== block.id
|
|
||||||
? "action.hover"
|
|
||||||
: undefined,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ListItemText
|
|
||||||
primary={block.name}
|
|
||||||
primaryTypographyProps={{
|
|
||||||
fontWeight:
|
|
||||||
selectedBlockId === block.id ? "bold" : "normal",
|
|
||||||
overflow: "hidden",
|
|
||||||
textOverflow: "ellipsis",
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</ListItemButton>
|
|
||||||
))}
|
|
||||||
</List>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "center",
|
|
||||||
pt: 1.5,
|
|
||||||
borderTop: "1px solid",
|
|
||||||
borderColor: "divider",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
onClick={handleAddBlock}
|
|
||||||
startIcon={<Plus />}
|
|
||||||
fullWidth
|
|
||||||
>
|
|
||||||
Добавить блок
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
{/* Правая колонка: Редактор выбранного блока (SightEdit) */}
|
<button
|
||||||
<Paper
|
className="w-8 h-8 rounded-full bg-blue-500 text-white flex items-center justify-center hover:bg-blue-600 transition-colors"
|
||||||
elevation={2}
|
onClick={handleMenuOpen}
|
||||||
sx={{
|
>
|
||||||
flexGrow: 1,
|
<Plus color="white" />
|
||||||
padding: 2.5,
|
</button>
|
||||||
borderRadius: 2,
|
<Menu
|
||||||
border: "1px solid",
|
anchorEl={menuAnchorEl}
|
||||||
borderColor: "divider",
|
open={Boolean(menuAnchorEl)}
|
||||||
overflowY: "auto", // Если контент будет больше
|
onClose={handleMenuClose}
|
||||||
}}
|
anchorOrigin={{
|
||||||
>
|
vertical: "top",
|
||||||
{currentBlockToEdit ? (
|
horizontal: "right",
|
||||||
<>
|
}}
|
||||||
<SightEdit
|
transformOrigin={{
|
||||||
onUnlink={() => console.log("Unlink block:", selectedBlockId)}
|
vertical: "bottom",
|
||||||
onDelete={() => {
|
horizontal: "right",
|
||||||
console.log("Delete block:", selectedBlockId);
|
}}
|
||||||
setRightWidgetBlocks((blocks) =>
|
>
|
||||||
blocks.filter((b) => b.id !== selectedBlockId)
|
<MenuItem onClick={onCreateNew}>Создать новую</MenuItem>
|
||||||
);
|
<MenuItem onClick={onSelectExisting}>Выбрать существующую</MenuItem>
|
||||||
setSelectedBlockId(null);
|
</Menu>
|
||||||
}}
|
</Paper>
|
||||||
/>
|
|
||||||
<Paper elevation={1} sx={{ padding: 2, mt: 1, width: "75%" }}>
|
|
||||||
<Typography variant="h6" gutterBottom>
|
|
||||||
МЕДИА
|
|
||||||
</Typography>
|
|
||||||
{/* Здесь будет UI для управления медиа статьи */}
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
width: "100%",
|
|
||||||
height: 100,
|
|
||||||
backgroundColor: "grey.100",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
borderRadius: 1,
|
|
||||||
mb: 1,
|
|
||||||
border: "2px dashed",
|
|
||||||
borderColor: "grey.300",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography color="text.secondary">Нет медиа</Typography>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Button variant="contained">Выбрать/Загрузить медиа</Button>
|
|
||||||
</Paper>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<></>
|
|
||||||
)}
|
|
||||||
</Paper>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Блок МЕДИА для статьи */}
|
|
||||||
|
|
||||||
<Box sx={{ position: "absolute", bottom: 0, right: 0, padding: 2 }}>
|
|
||||||
<Button variant="contained" color="success" onClick={handleSave}>
|
|
||||||
Сохранить
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</TabPanel>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// --- ArticleEditorPane Component ---
|
||||||
|
interface ArticleData {
|
||||||
|
id: string;
|
||||||
|
heading: string;
|
||||||
|
body: string;
|
||||||
|
media: any[]; // Define a proper type for media if available
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ArticleEditorPaneProps {
|
||||||
|
articleData: ArticleData | null;
|
||||||
|
onDelete: (blockId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ArticleEditorPane = ({
|
||||||
|
articleData,
|
||||||
|
onDelete,
|
||||||
|
}: ArticleEditorPaneProps) => {
|
||||||
|
if (!articleData) {
|
||||||
|
return (
|
||||||
|
<Paper
|
||||||
|
elevation={2}
|
||||||
|
sx={{
|
||||||
|
flexGrow: 1,
|
||||||
|
padding: 2.5,
|
||||||
|
borderRadius: 2,
|
||||||
|
border: "1px solid",
|
||||||
|
borderColor: "divider",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="h6" color="text.secondary">
|
||||||
|
Выберите блок для редактирования
|
||||||
|
</Typography>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper
|
||||||
|
elevation={2}
|
||||||
|
sx={{
|
||||||
|
flexGrow: 1,
|
||||||
|
padding: 2.5,
|
||||||
|
borderRadius: 2,
|
||||||
|
border: "1px solid",
|
||||||
|
borderColor: "divider",
|
||||||
|
overflowY: "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SightEdit />
|
||||||
|
<Paper elevation={1} sx={{ padding: 2, mt: 1, width: "75%" }}>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
МЕДИА
|
||||||
|
</Typography>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: "100%",
|
||||||
|
height: 100,
|
||||||
|
backgroundColor: "grey.100",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
borderRadius: 1,
|
||||||
|
mb: 1,
|
||||||
|
border: "2px dashed",
|
||||||
|
borderColor: "grey.300",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography color="text.secondary">Нет медиа</Typography>
|
||||||
|
</Box>
|
||||||
|
<Button variant="contained">Выбрать/Загрузить медиа</Button>
|
||||||
|
</Paper>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- RightWidgetTab (Parent) Component ---
|
||||||
|
export const RightWidgetTab = observer(
|
||||||
|
({ value, index, data }: { value: number; index: number; data?: Sight }) => {
|
||||||
|
const [rightWidgetBlocks, setRightWidgetBlocks] = useState<ArticleBlock[]>(
|
||||||
|
mockRightWidgetBlocks
|
||||||
|
);
|
||||||
|
const [selectedBlockId, setSelectedBlockId] = useState<string | null>(
|
||||||
|
mockRightWidgetBlocks[1]?.id || null
|
||||||
|
);
|
||||||
|
const [isSelectModalOpen, setIsSelectModalOpen] = useState(false);
|
||||||
|
|
||||||
|
const handleSelectBlock = (blockId: string) => {
|
||||||
|
setSelectedBlockId(blockId);
|
||||||
|
console.log("Selected block:", blockId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateNew = () => {
|
||||||
|
const newBlockId = `article_${Date.now()}`;
|
||||||
|
setRightWidgetBlocks((prevBlocks) => [
|
||||||
|
...prevBlocks,
|
||||||
|
{
|
||||||
|
id: newBlockId,
|
||||||
|
name: `${
|
||||||
|
prevBlocks.filter((b) => b.type === "article").length + 1
|
||||||
|
}. Новый блок`,
|
||||||
|
type: "article",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
setSelectedBlockId(newBlockId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectExisting = () => {
|
||||||
|
setIsSelectModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseSelectModal = () => {
|
||||||
|
setIsSelectModalOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectArticle = (articleId: string) => {
|
||||||
|
const article = articlesStore.articles.find((a) => a.id === articleId);
|
||||||
|
if (article) {
|
||||||
|
const newBlockId = `article_linked_${article.id}_${Date.now()}`;
|
||||||
|
setRightWidgetBlocks((prevBlocks) => [
|
||||||
|
...prevBlocks,
|
||||||
|
{
|
||||||
|
id: newBlockId,
|
||||||
|
name: `${
|
||||||
|
prevBlocks.filter((b) => b.type === "article").length + 1
|
||||||
|
}. ${article.service_name}`,
|
||||||
|
type: "article",
|
||||||
|
linkedArticleId: article.id,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
setSelectedBlockId(newBlockId);
|
||||||
|
}
|
||||||
|
handleCloseSelectModal();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUnlinkBlock = (blockId: string) => {
|
||||||
|
console.log("Unlink block:", blockId);
|
||||||
|
// Example: If a block is linked to an existing article, this might "unlink" it
|
||||||
|
// For now, it simply removes it, you might want to convert it to a new editable block.
|
||||||
|
setRightWidgetBlocks((blocks) => blocks.filter((b) => b.id !== blockId));
|
||||||
|
setSelectedBlockId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteBlock = (blockId: string) => {
|
||||||
|
console.log("Delete block:", blockId);
|
||||||
|
setRightWidgetBlocks((blocks) => blocks.filter((b) => b.id !== blockId));
|
||||||
|
setSelectedBlockId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
console.log("Saving right widget...");
|
||||||
|
// Implement save logic here, e.g., send data to an API
|
||||||
|
};
|
||||||
|
|
||||||
|
// Determine the current block data to pass to the editor pane
|
||||||
|
const currentBlockToEdit = selectedBlockId
|
||||||
|
? selectedBlockId === mockSelectedBlockData.id
|
||||||
|
? mockSelectedBlockData
|
||||||
|
: {
|
||||||
|
id: selectedBlockId,
|
||||||
|
heading:
|
||||||
|
rightWidgetBlocks.find((b) => b.id === selectedBlockId)?.name ||
|
||||||
|
"Заголовок...",
|
||||||
|
body: "Содержимое...",
|
||||||
|
media: [],
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Get list of already linked article IDs
|
||||||
|
const linkedArticleIds = rightWidgetBlocks
|
||||||
|
.filter((block) => block.linkedArticleId)
|
||||||
|
.map((block) => block.linkedArticleId as string);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TabPanel value={value} index={index}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
height: "100%",
|
||||||
|
minHeight: "calc(100vh - 200px)", // Adjust as needed
|
||||||
|
gap: 2,
|
||||||
|
paddingBottom: "70px", // Space for the save button
|
||||||
|
position: "relative",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<BackButton />
|
||||||
|
|
||||||
|
<Box sx={{ display: "flex", flexGrow: 1, gap: 2.5 }}>
|
||||||
|
<ArticleListSidebar
|
||||||
|
blocks={rightWidgetBlocks}
|
||||||
|
selectedBlockId={selectedBlockId}
|
||||||
|
onSelectBlock={handleSelectBlock}
|
||||||
|
onCreateNew={handleCreateNew}
|
||||||
|
onSelectExisting={handleSelectExisting}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ArticleEditorPane
|
||||||
|
articleData={currentBlockToEdit}
|
||||||
|
onDelete={handleDeleteBlock}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: 0,
|
||||||
|
right: 0,
|
||||||
|
padding: 2,
|
||||||
|
backgroundColor: "background.paper", // Ensure button is visible
|
||||||
|
width: "100%", // Cover the full width to make it a sticky footer
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button variant="contained" color="success" onClick={handleSave}>
|
||||||
|
Сохранить изменения
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<SelectArticleModal
|
||||||
|
open={isSelectModalOpen}
|
||||||
|
onClose={handleCloseSelectModal}
|
||||||
|
onSelectArticle={handleSelectArticle}
|
||||||
|
linkedArticleIds={linkedArticleIds}
|
||||||
|
/>
|
||||||
|
</TabPanel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Loading…
Reference in New Issue
Block a user