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 { 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;
if (!isAuthenticated) {
return <Navigate to="/login" />;
if (isAuthenticated) {
return <Navigate to="/sight" />;
}
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 = () => {
return (
<Routes>
<Route
path="/login"
element={
<PublicRoute>
<LoginPage />
</PublicRoute>
}
/>
{/* Protected routes with layout */}
<Route
path="/"
element={
<Layout>
<Outlet />
</Layout>
<ProtectedRoute>
<Layout>
<Outlet />
</Layout>
</ProtectedRoute>
}
>
<Route path="/" element={<MainPage />} />
<Route path="/sight" element={<SightPage />} />
<Route path="/sight/:id" element={<EditSightPage />} />
<Route path="/sight/create" element={<CreateSightPage />} />
<Route path="/devices" element={<DevicesPage />} />
<Route index element={<MainPage />} />
<Route path="sight" element={<SightPage />} />
<Route path="sight/:id" element={<EditSightPage />} />
<Route path="sight/create" element={<CreateSightPage />} />
<Route path="devices" element={<DevicesPage />} />
</Route>
<Route path="/login" element={<LoginPage />} />
</Routes>
);
};

View File

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

View File

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

View File

@ -1,4 +1,4 @@
import { languageStore } from "@shared";
import { languageStore, Language } from "@shared";
import axios from "axios";
const authInstance = axios.create({
@ -11,4 +11,16 @@ authInstance.interceptors.request.use((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 "./const";
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 { makeAutoObservable } from "mobx";
import { makeAutoObservable, runInAction } from "mobx";
import axios, { AxiosError } from "axios";
type LoginResponse = {
@ -14,6 +14,7 @@ type LoginResponse = {
class AuthStore {
payload: LoginResponse | null = null;
token: string | null = null;
isLoading = false;
error: string | null = null;
@ -28,21 +29,13 @@ class AuthStore {
if (decoded) {
this.payload = decoded;
// Set the token in axios defaults for future requests
if (storedToken) {
axios.defaults.headers.common[
"Authorization"
] = `Bearer ${storedToken}`;
}
} else {
// If token is invalid or missing, clear it
this.logout();
}
}
private setAuthToken(token: string) {
localStorage.setItem("token", token);
axios.defaults.headers.common["Authorization"] = `Bearer ${token}`;
}
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
this.setAuthToken(token);
this.payload = response.data;
this.error = null;
runInAction(() => {
this.setAuthToken(data.token);
this.payload = response.data;
this.error = null;
});
} catch (error) {
if (error instanceof AxiosError) {
this.error =
@ -85,7 +79,7 @@ class AuthStore {
};
get isAuthenticated() {
return !!this.payload?.token;
return this.payload?.token !== null;
}
get user() {

View File

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

View File

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

View File

@ -1,13 +1,14 @@
import { makeAutoObservable } from "mobx";
import { Language } from "../SightsStore";
class LanguageStore {
language: string = "ru";
language: Language = "ru";
constructor() {
makeAutoObservable(this);
}
setLanguage = (language: string) => {
setLanguage = (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";
export type Language = "ru" | "en" | "zh";
@ -8,7 +8,6 @@ export type MultilingualContent = {
name: string;
description: string;
address: string;
// Add other fields that need to be multilingual
};
};
@ -28,10 +27,22 @@ export type Sight = {
video_preview: string | null;
};
export type CreateSight = {
[key in Language]: {
name: string;
description: string;
address: string;
};
};
class SightsStore {
sights: Sight[] = [];
sight: Sight | null = null;
cachedMultilingualContent: MultilingualContent | null = null;
createSight: CreateSight = {
ru: { name: "", description: "", address: "" },
en: { name: "", description: "", address: "" },
zh: { name: "", description: "", address: "" },
};
constructor() {
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(() => {
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,
content: Partial<MultilingualContent[Language]>
content: Partial<CreateSight[Language]>
) => {
runInAction(() => {
if (!this.cachedMultilingualContent) {
this.cachedMultilingualContent = {
ru: { name: "", description: "", address: "" },
en: { name: "", description: "", address: "" },
zh: { name: "", description: "", address: "" },
};
}
this.cachedMultilingualContent[language] = {
...this.cachedMultilingualContent[language],
this.createSight[language] = {
...this.createSight[language],
...content,
};
});
};
clearCachedMultilingualContent = () => {
clearCreateSight = () => {
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 "./SightsStore";
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 "./BackButton";
export * from "./Modal";
export * from "./CoordinatesInput";

View File

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

View File

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

View File

@ -2,133 +2,50 @@ import {
Button,
TextField,
Box,
Autocomplete,
Typography,
IconButton,
Paper,
Tooltip,
} 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 { ImagePlus, Info } from "lucide-react";
import { observer } from "mobx-react-lite";
import { useState, useEffect } from "react";
import { useLocation } from "react-router-dom";
// Мокап данных для отображения, потом это будет приходить из store/props
// Keeping this mock for demonstration, but in a real app,
// 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,
};
import { observer } from "mobx-react-lite";
import { useState } from "react";
// Мокап для всплывающей подсказки
const watermarkTooltipText = "При наведении открывается просмотр в поп-апе";
const logoTooltipText = "При наведении открывается просмотр логотипа в поп-апе";
export const InformationTab = observer(
({ value, index }: { value: number; index: number }) => {
const {
sight,
cachedMultilingualContent,
updateCachedLanguageContent,
clearCachedMultilingualContent,
// Assuming you'll have an action to update the main sight object
updateSight,
} = sightsStore;
const { cities } = cityStore;
const { createSight, updateCreateSight, createSightAction } = sightsStore;
const [city, setCity] = useState<number>(0);
const [coordinates, setCoordinates] = useState({
latitude: 0,
longitude: 0,
});
const { language } = languageStore;
// 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: "" },
});
}
}
}, [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"
const handleChange = (
language: Language,
content: Partial<CreateSight[Language]>
) => {
// Here will be logic for opening modal window for media selection
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.
updateCreateSight(language, content);
};
const handleSave = () => {
// Parse coordinates back to latitude and longitude
let latitude: number | undefined;
let longitude: number | undefined;
const coordsArray = coordinates
.split(",")
.map((coord) => parseFloat(coord.trim()));
if (
coordsArray.length === 2 &&
!isNaN(coordsArray[0]) &&
!isNaN(coordsArray[1])
) {
latitude = coordsArray[0];
longitude = coordsArray[1];
const handleSave = async () => {
try {
await createSightAction(createSight[language], city, coordinates);
} catch (error) {
console.error(error);
}
// 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 (
@ -164,10 +81,10 @@ export const InformationTab = observer(
}}
>
<TextField
label={`Название (${currentLanguage.toUpperCase()})`}
value={cachedMultilingualContent?.[currentLanguage]?.name ?? ""}
label={`Название (${language.toUpperCase()})`}
value={createSight[language]?.name ?? ""}
onChange={(e) => {
updateCachedLanguageContent(currentLanguage, {
handleChange(language as Language, {
name: e.target.value,
});
}}
@ -175,13 +92,10 @@ export const InformationTab = observer(
variant="outlined"
/>
<TextField
label={`Описание (${currentLanguage.toUpperCase()})`}
value={
cachedMultilingualContent?.[currentLanguage]?.description ??
""
}
label={`Описание (${language.toUpperCase()})`}
value={createSight?.[language]?.description ?? ""}
onChange={(e) => {
updateCachedLanguageContent(currentLanguage, {
handleChange(language as Language, {
description: e.target.value,
});
}}
@ -192,37 +106,30 @@ export const InformationTab = observer(
/>
<TextField
label="Адрес"
value={address}
value={createSight?.[language]?.address ?? ""}
onChange={(e) => {
setAddress(e.target.value);
handleChange(language as Language, {
address: e.target.value,
});
}}
fullWidth
variant="outlined"
/>
<TextField
label="Город"
value={city}
onChange={(e) => {
setCity(e.target.value);
<Autocomplete
options={cities}
getOptionLabel={(option) => option.name}
onChange={(_, value) => {
setCity(value?.id ?? 0);
}}
fullWidth
variant="outlined"
renderInput={(params) => (
<TextField {...params} label="Город" />
)}
/>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<TextField
label="Координаты"
value={coordinates}
onChange={(e) => {
setCoordinates(e.target.value);
}}
fullWidth
variant="outlined"
helperText="Формат: широта, долгота (например, 59.9398, 30.3146)"
/>
</Box>
<CoordinatesInput setValue={setCoordinates} />
</Box>
{/* Правая колонка для логотипа и водяных знаков */}
{/* Правая колонка для логотипа и водяных знаков
<Box
sx={{
display: "flex",
@ -230,7 +137,7 @@ export const InformationTab = observer(
gap: 4,
}}
>
{/* Водяные знаки */}
<Box
sx={{
display: "flex",
@ -453,29 +360,29 @@ export const InformationTab = observer(
</Paper>
</Box>
</Box>
</Box>
</Box> */}
{/* LanguageSwitcher positioned at the top right */}
<Box sx={{ position: "absolute", top: 0, right: 0, zIndex: 1 }}>
<LanguageSwitcher onLanguageChange={handleLanguageChange} />
</Box>
{/* LanguageSwitcher positioned at the top right */}
{/* Save Button fixed at the bottom right */}
<Box
sx={{
position: "absolute",
bottom: 0,
right: 0,
padding: 2,
backgroundColor: "background.paper", // To ensure it stands out over content
width: "100%", // Take full width to cover content below it
display: "flex",
justifyContent: "flex-end", // Align to the right
}}
>
<Button variant="contained" color="success" onClick={handleSave}>
Сохранить
</Button>
<LanguageSwitcher />
{/* Save Button fixed at the bottom right */}
<Box
sx={{
position: "absolute",
bottom: 0,
right: 0,
padding: 2,
backgroundColor: "background.paper", // To ensure it stands out over content
width: "100%", // Take full width to cover content below it
display: "flex",
justifyContent: "flex-end", // Align to the right
}}
>
<Button variant="contained" color="success" onClick={handleSave}>
Сохранить
</Button>
</Box>
</Box>
</Box>
</TabPanel>

View File

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

View File

@ -6,15 +6,30 @@ import {
ListItemText,
Paper,
Typography,
Menu,
MenuItem,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
InputAdornment,
} from "@mui/material";
import { BackButton, Sight, TabPanel } from "@shared";
import {
articlesStore,
BackButton,
SelectArticleModal,
Sight,
TabPanel,
} from "@shared";
import { SightEdit } from "@widgets";
import { Plus } from "lucide-react";
import { useState } from "react";
import { ImagePlus, Plus, Search } from "lucide-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 = [
{ id: "preview_media", name: "Превью-медиа", type: "special" }, // Особый блок?
{ id: "preview_media", name: "Превью-медиа", type: "special" },
{ id: "article_1", name: "1. История", type: "article" },
{ id: "article_2", name: "2. Факты", type: "article" },
{
@ -24,241 +39,368 @@ const mockRightWidgetBlocks = [
},
];
// Мокап данных для выбранного блока для редактирования
// В реальности это будет объект Article из API
const mockSelectedBlockData = {
id: "article_1",
heading: "История основания Санкт-Петербурга",
body: "## Начало\nГород был основан 27 мая 1703 года Петром I...",
media: [
// Предполагаем, что у статьи может быть несколько медиа
// { id: "media_1", url: "https://via.placeholder.com/300x200.png?text=History+Image+1", type: "image" }
],
media: [],
};
export const RightWidgetTab = ({
value,
index,
data,
}: {
value: number;
index: number;
data?: Sight;
}) => {
const [rightWidgetBlocks, setRightWidgetBlocks] = useState(
mockRightWidgetBlocks
);
const [selectedBlockId, setSelectedBlockId] = useState<string | null>(
mockRightWidgetBlocks[1]?.id || null
); // Выбираем первый "article" по умолчанию
const mockExistingArticles = [
{ id: "existing_1", title: "История Эрмитажа", type: "article" },
{ id: "existing_2", title: "Петропавловская крепость", type: "article" },
{ id: "existing_3", title: "Исаакиевский собор", type: "article" },
{ id: "existing_4", title: "Кунсткамера", type: "article" },
];
const handleSelectBlock = (blockId: string) => {
setSelectedBlockId(blockId);
// Здесь будет логика загрузки данных для выбранного блока, если они не загружены
console.log("Selected block:", blockId);
// --- ArticleListSidebar Component ---
interface ArticleBlock {
id: string;
name: string;
type: string;
linkedArticleId?: string; // Added for linked articles
}
interface ArticleListSidebarProps {
blocks: ArticleBlock[];
selectedBlockId: string | null;
onSelectBlock: (blockId: string) => void;
onCreateNew: () => void;
onSelectExisting: () => void;
}
const ArticleListSidebar = ({
blocks,
selectedBlockId,
onSelectBlock,
onCreateNew,
onSelectExisting,
}: ArticleListSidebarProps) => {
const [menuAnchorEl, setMenuAnchorEl] = useState<null | HTMLElement>(null);
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
setMenuAnchorEl(event.currentTarget);
};
const handleAddBlock = () => {
// Логика открытия модала/формы для создания нового блока/статьи
// или выбора существующей статьи для привязки
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 handleMenuClose = () => {
setMenuAnchorEl(null);
};
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 (
<TabPanel value={value} index={index}>
<Box
<Paper
elevation={2}
sx={{
width: 260,
minWidth: 240,
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
padding: 1.5,
borderRadius: 2,
border: "1px solid",
borderColor: "divider",
}}
>
<List
dense
sx={{
display: "flex",
flexDirection: "column",
height: "100%",
minHeight: "calc(100vh - 200px)",
gap: 2,
paddingBottom: "70px",
position: "relative",
overflowY: "auto",
flexGrow: 1,
maxHeight: "calc(100% - 60px)",
}}
>
<BackButton />
<Box sx={{ display: "flex", flexGrow: 1, gap: 2.5 }}>
{/* Левая колонка: Список блоков/статей */}
<Paper
elevation={2}
{blocks.map((block) => (
<ListItemButton
key={block.id}
selected={selectedBlockId === block.id}
onClick={() => onSelectBlock(block.id)}
sx={{
width: 260, // Ширина как на макете
minWidth: 240,
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
padding: 1.5,
borderRadius: 2,
border: "1px solid",
borderColor: "divider",
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,
},
}}
>
<List
dense
sx={{
overflowY: "auto",
flexGrow: 1,
maxHeight:
"calc(100% - 60px)" /* Adjust based on button size */,
<ListItemText
primary={block.name}
primaryTypographyProps={{
fontWeight: selectedBlockId === block.id ? "bold" : "normal",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{rightWidgetBlocks.map((block) => (
<ListItemButton
key={block.id}
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>
/>
</ListItemButton>
))}
</List>
{/* Правая колонка: Редактор выбранного блока (SightEdit) */}
<Paper
elevation={2}
sx={{
flexGrow: 1,
padding: 2.5,
borderRadius: 2,
border: "1px solid",
borderColor: "divider",
overflowY: "auto", // Если контент будет больше
}}
>
{currentBlockToEdit ? (
<>
<SightEdit
onUnlink={() => console.log("Unlink block:", selectedBlockId)}
onDelete={() => {
console.log("Delete block:", selectedBlockId);
setRightWidgetBlocks((blocks) =>
blocks.filter((b) => b.id !== selectedBlockId)
);
setSelectedBlockId(null);
}}
/>
<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>
<button
className="w-8 h-8 rounded-full bg-blue-500 text-white flex items-center justify-center hover:bg-blue-600 transition-colors"
onClick={handleMenuOpen}
>
<Plus color="white" />
</button>
<Menu
anchorEl={menuAnchorEl}
open={Boolean(menuAnchorEl)}
onClose={handleMenuClose}
anchorOrigin={{
vertical: "top",
horizontal: "right",
}}
transformOrigin={{
vertical: "bottom",
horizontal: "right",
}}
>
<MenuItem onClick={onCreateNew}>Создать новую</MenuItem>
<MenuItem onClick={onSelectExisting}>Выбрать существующую</MenuItem>
</Menu>
</Paper>
);
};
// --- ArticleEditorPane Component ---
interface ArticleData {
id: string;
heading: string;
body: string;
media: any[]; // Define a proper type for media if available
}
interface ArticleEditorPaneProps {
articleData: ArticleData | null;
onDelete: (blockId: string) => void;
}
const ArticleEditorPane = ({
articleData,
onDelete,
}: ArticleEditorPaneProps) => {
if (!articleData) {
return (
<Paper
elevation={2}
sx={{
flexGrow: 1,
padding: 2.5,
borderRadius: 2,
border: "1px solid",
borderColor: "divider",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Typography variant="h6" color="text.secondary">
Выберите блок для редактирования
</Typography>
</Paper>
);
}
return (
<Paper
elevation={2}
sx={{
flexGrow: 1,
padding: 2.5,
borderRadius: 2,
border: "1px solid",
borderColor: "divider",
overflowY: "auto",
}}
>
<SightEdit />
<Paper elevation={1} sx={{ padding: 2, mt: 1, width: "75%" }}>
<Typography variant="h6" gutterBottom>
МЕДИА
</Typography>
<Box
sx={{
width: "100%",
height: 100,
backgroundColor: "grey.100",
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 1,
mb: 1,
border: "2px dashed",
borderColor: "grey.300",
}}
>
<Typography color="text.secondary">Нет медиа</Typography>
</Box>
<Button variant="contained">Выбрать/Загрузить медиа</Button>
</Paper>
</Paper>
);
};
// --- RightWidgetTab (Parent) Component ---
export const RightWidgetTab = observer(
({ value, index, data }: { value: number; index: number; data?: Sight }) => {
const [rightWidgetBlocks, setRightWidgetBlocks] = useState<ArticleBlock[]>(
mockRightWidgetBlocks
);
const [selectedBlockId, setSelectedBlockId] = useState<string | null>(
mockRightWidgetBlocks[1]?.id || null
);
const [isSelectModalOpen, setIsSelectModalOpen] = useState(false);
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>
);
}
);