From 2e6917406e151e2053dd62623ff4ee3ca1688725 Mon Sep 17 00:00:00 2001 From: itoshi Date: Sat, 31 May 2025 06:35:05 +0300 Subject: [PATCH] feat: Select article list in sight --- src/app/router/index.tsx | 51 +- src/pages/CreateSightPage/index.tsx | 16 +- src/pages/EditSightPage/index.tsx | 4 +- src/shared/api/index.tsx | 16 +- src/shared/index.tsx | 1 + .../modals/SelectArticleDialog/index.tsx | 188 ++++++ src/shared/modals/index.ts | 1 + src/shared/store/ArticlesStore/index.tsx | 36 ++ src/shared/store/AuthStore/index.tsx | 24 +- src/shared/store/CityStore/index.tsx | 6 +- src/shared/store/DevicesStore/index.tsx | 6 +- src/shared/store/LanguageStore/index.tsx | 5 +- src/shared/store/SightsStore/index.tsx | 109 +++- src/shared/store/index.ts | 1 + src/shared/ui/CoordinatesInput/index.tsx | 32 + src/shared/ui/index.ts | 1 + src/widgets/ReactMarkdown/index.tsx | 1 + src/widgets/ReactMarkdownEditor/index.tsx | 41 +- .../SightTabs/InformationTab/index.tsx | 239 +++---- src/widgets/SightTabs/LeftWidgetTab/index.tsx | 35 +- .../SightTabs/RightWidgetTab/index.tsx | 584 +++++++++++------- 21 files changed, 899 insertions(+), 498 deletions(-) create mode 100644 src/shared/modals/SelectArticleDialog/index.tsx create mode 100644 src/shared/modals/index.ts create mode 100644 src/shared/store/ArticlesStore/index.tsx create mode 100644 src/shared/ui/CoordinatesInput/index.tsx diff --git a/src/app/router/index.tsx b/src/app/router/index.tsx index 7aeadc7..5889955 100644 --- a/src/app/router/index.tsx +++ b/src/app/router/index.tsx @@ -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 ; + if (isAuthenticated) { + return ; } return children; }; + +const ProtectedRoute = ({ children }: { children: React.ReactNode }) => { + const { isAuthenticated } = authStore; + const pathname = useLocation(); + if (!isAuthenticated) { + return ; + } + if (pathname.pathname === "/") { + return ; + } + return children; +}; + export const Router = () => { return ( + + + + } + /> + + {/* Protected routes with layout */} - - + + + + + } > - } /> - } /> - } /> - } /> - } /> + } /> + } /> + } /> + } /> + } /> - - } /> ); }; diff --git a/src/pages/CreateSightPage/index.tsx b/src/pages/CreateSightPage/index.tsx index 0d1e3ac..8134015 100644 --- a/src/pages/CreateSightPage/index.tsx +++ b/src/pages/CreateSightPage/index.tsx @@ -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 ( { ); -}; +}); diff --git a/src/pages/EditSightPage/index.tsx b/src/pages/EditSightPage/index.tsx index de008a0..8774d80 100644 --- a/src/pages/EditSightPage/index.tsx +++ b/src/pages/EditSightPage/index.tsx @@ -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(); diff --git a/src/shared/api/index.tsx b/src/shared/api/index.tsx index cb215e4..dd3973d 100644 --- a/src/shared/api/index.tsx +++ b/src/shared/api/index.tsx @@ -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 }; diff --git a/src/shared/index.tsx b/src/shared/index.tsx index bfc3876..291c73a 100644 --- a/src/shared/index.tsx +++ b/src/shared/index.tsx @@ -4,3 +4,4 @@ export * from "./ui"; export * from "./store"; export * from "./const"; export * from "./api"; +export * from "./modals"; diff --git a/src/shared/modals/SelectArticleDialog/index.tsx b/src/shared/modals/SelectArticleDialog/index.tsx new file mode 100644 index 0000000..e59b718 --- /dev/null +++ b/src/shared/modals/SelectArticleDialog/index.tsx @@ -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( + null + ); + const hoverTimerRef = useRef(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 ( + + Выберите существующую статью + + + setSearchQuery(e.target.value)} + sx={{ mb: 2, mt: 1 }} + InputProps={{ + startAdornment: ( + + + + ), + }} + /> + + {filteredArticles.map((article) => ( + onSelectArticle(article.id)} + onMouseEnter={() => handleArticleHover(article.id)} + onMouseLeave={handleArticleLeave} + sx={{ + borderRadius: 1, + mb: 0.5, + "&:hover": { + backgroundColor: "action.hover", + }, + }} + > + + + ))} + + + + + {/* Media Preview Area */} + + + + + {/* Title Area */} + + + {articlesStore.articleData?.heading || + "Нет данных для предпросмотра"} + + + + {/* Body Preview Area */} + + + + + + + + + + + ); + } +); diff --git a/src/shared/modals/index.ts b/src/shared/modals/index.ts new file mode 100644 index 0000000..e714367 --- /dev/null +++ b/src/shared/modals/index.ts @@ -0,0 +1 @@ +export * from "./SelectArticleDialog"; diff --git a/src/shared/store/ArticlesStore/index.tsx b/src/shared/store/ArticlesStore/index.tsx new file mode 100644 index 0000000..600eb8f --- /dev/null +++ b/src/shared/store/ArticlesStore/index.tsx @@ -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(); diff --git a/src/shared/store/AuthStore/index.tsx b/src/shared/store/AuthStore/index.tsx index f5dae9d..0b30d65 100644 --- a/src/shared/store/AuthStore/index.tsx +++ b/src/shared/store/AuthStore/index.tsx @@ -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() { diff --git a/src/shared/store/CityStore/index.tsx b/src/shared/store/CityStore/index.tsx index 91c46b2..154d96d 100644 --- a/src/shared/store/CityStore/index.tsx +++ b/src/shared/store/CityStore/index.tsx @@ -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; + }); }; } diff --git a/src/shared/store/DevicesStore/index.tsx b/src/shared/store/DevicesStore/index.tsx index d695479..ff165dc 100644 --- a/src/shared/store/DevicesStore/index.tsx +++ b/src/shared/store/DevicesStore/index.tsx @@ -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) => { diff --git a/src/shared/store/LanguageStore/index.tsx b/src/shared/store/LanguageStore/index.tsx index 3075829..dab5420 100644 --- a/src/shared/store/LanguageStore/index.tsx +++ b/src/shared/store/LanguageStore/index.tsx @@ -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; }; } diff --git a/src/shared/store/SightsStore/index.tsx b/src/shared/store/SightsStore/index.tsx index 272818e..7acd0d6 100644 --- a/src/shared/store/SightsStore/index.tsx +++ b/src/shared/store/SightsStore/index.tsx @@ -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 + content: Partial ) => { 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: "", + }, + }; }); }; } diff --git a/src/shared/store/index.ts b/src/shared/store/index.ts index 9dc3f47..9be9b82 100644 --- a/src/shared/store/index.ts +++ b/src/shared/store/index.ts @@ -5,3 +5,4 @@ export * from "./VehicleStore"; export * from "./SnapshotStore"; export * from "./SightsStore"; export * from "./CityStore"; +export * from "./ArticlesStore"; diff --git a/src/shared/ui/CoordinatesInput/index.tsx b/src/shared/ui/CoordinatesInput/index.tsx new file mode 100644 index 0000000..60918af --- /dev/null +++ b/src/shared/ui/CoordinatesInput/index.tsx @@ -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(""); + + useEffect(() => { + setValue({ + latitude: Number(inputValue.split(" ")[0]), + longitude: Number(inputValue.split(" ")[1]), + }); + }, [inputValue]); + + return ( + + { + setInputValue(e.target.value); + }} + fullWidth + variant="outlined" + helperText="Формат: широта, долгота (например, 59.9398, 30.3146)" + /> + + ); +}; diff --git a/src/shared/ui/index.ts b/src/shared/ui/index.ts index cfaca31..584c712 100644 --- a/src/shared/ui/index.ts +++ b/src/shared/ui/index.ts @@ -1,3 +1,4 @@ export * from "./TabPanel"; export * from "./BackButton"; export * from "./Modal"; +export * from "./CoordinatesInput"; diff --git a/src/widgets/ReactMarkdown/index.tsx b/src/widgets/ReactMarkdown/index.tsx index 81493fa..3ec4b84 100644 --- a/src/widgets/ReactMarkdown/index.tsx +++ b/src/widgets/ReactMarkdown/index.tsx @@ -5,6 +5,7 @@ import rehypeRaw from "rehype-raw"; export const ReactMarkdownComponent = ({ value }: { value: string }) => { return ( ({ +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 ( diff --git a/src/widgets/SightTabs/InformationTab/index.tsx b/src/widgets/SightTabs/InformationTab/index.tsx index de6deea..b6215f0 100644 --- a/src/widgets/SightTabs/InformationTab/index.tsx +++ b/src/widgets/SightTabs/InformationTab/index.tsx @@ -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(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("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 ) => { - // 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( }} > { - updateCachedLanguageContent(currentLanguage, { + handleChange(language as Language, { name: e.target.value, }); }} @@ -175,13 +92,10 @@ export const InformationTab = observer( variant="outlined" /> { - updateCachedLanguageContent(currentLanguage, { + handleChange(language as Language, { description: e.target.value, }); }} @@ -192,37 +106,30 @@ export const InformationTab = observer( /> { - setAddress(e.target.value); + handleChange(language as Language, { + address: e.target.value, + }); }} fullWidth variant="outlined" /> - { - setCity(e.target.value); + option.name} + onChange={(_, value) => { + setCity(value?.id ?? 0); }} - fullWidth - variant="outlined" + renderInput={(params) => ( + + )} /> - - { - setCoordinates(e.target.value); - }} - fullWidth - variant="outlined" - helperText="Формат: широта, долгота (например, 59.9398, 30.3146)" - /> - + + - {/* Правая колонка для логотипа и водяных знаков */} + {/* Правая колонка для логотипа и водяных знаков - {/* Водяные знаки */} + - + */} - {/* LanguageSwitcher positioned at the top right */} - - - + {/* LanguageSwitcher positioned at the top right */} - {/* Save Button fixed at the bottom right */} - - + + + {/* Save Button fixed at the bottom right */} + + + diff --git a/src/widgets/SightTabs/LeftWidgetTab/index.tsx b/src/widgets/SightTabs/LeftWidgetTab/index.tsx index db0cea7..98d1742 100644 --- a/src/widgets/SightTabs/LeftWidgetTab/index.tsx +++ b/src/widgets/SightTabs/LeftWidgetTab/index.tsx @@ -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( - mockLeftArticle.media - ); // Для превью медиа + const [articleTitle, setArticleTitle] = useState(""); + const [markdownContent, setMarkdownContent] = useState(""); + const [articleMedia, setArticleMedia] = useState(null); // Для превью медиа const handleSelectMediaForArticle = () => { // Логика открытия модального окна для выбора медиа для статьи @@ -118,10 +98,6 @@ export const LeftWidgetTab = ({ sx={{ width: "100%" }} // Примерная ширина как на макете /> - {/* Редактор Markdown */} - - Текст - Нет медиа @@ -246,10 +221,8 @@ export const LeftWidgetTab = ({ diff --git a/src/widgets/SightTabs/RightWidgetTab/index.tsx b/src/widgets/SightTabs/RightWidgetTab/index.tsx index 27478e9..c8be167 100644 --- a/src/widgets/SightTabs/RightWidgetTab/index.tsx +++ b/src/widgets/SightTabs/RightWidgetTab/index.tsx @@ -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( - 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); + + const handleMenuOpen = (event: React.MouseEvent) => { + 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 ( - - + - - - - {/* Левая колонка: Список блоков/статей */} - ( + 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, + }, }} > - - {rightWidgetBlocks.map((block) => ( - 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, - }, - }} - > - - - ))} - - - - - + /> + + ))} + - {/* Правая колонка: Редактор выбранного блока (SightEdit) */} - - {currentBlockToEdit ? ( - <> - console.log("Unlink block:", selectedBlockId)} - onDelete={() => { - console.log("Delete block:", selectedBlockId); - setRightWidgetBlocks((blocks) => - blocks.filter((b) => b.id !== selectedBlockId) - ); - setSelectedBlockId(null); - }} - /> - - - МЕДИА - - {/* Здесь будет UI для управления медиа статьи */} - - Нет медиа - - - - - - ) : ( - <> - )} - - - - {/* Блок МЕДИА для статьи */} - - - - - - + + + Создать новую + Выбрать существующую + + ); }; + +// --- 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 ( + + + Выберите блок для редактирования + + + ); + } + + return ( + + + + + МЕДИА + + + Нет медиа + + + + + ); +}; + +// --- RightWidgetTab (Parent) Component --- +export const RightWidgetTab = observer( + ({ value, index, data }: { value: number; index: number; data?: Sight }) => { + const [rightWidgetBlocks, setRightWidgetBlocks] = useState( + mockRightWidgetBlocks + ); + const [selectedBlockId, setSelectedBlockId] = useState( + 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 ( + + + + + + + + + + + + + + + + + + ); + } +);