feat: Add translation on 3 languages for sight page

This commit is contained in:
Илья Куприец 2025-06-01 00:34:59 +03:00
parent 0d9bbb140f
commit 87386c6a73
22 changed files with 768 additions and 732 deletions

View File

@ -1,7 +1,7 @@
import List from "@mui/material/List"; import List from "@mui/material/List";
import Divider from "@mui/material/Divider"; import Divider from "@mui/material/Divider";
import { NAVIGATION_ITEMS } from "@shared"; import { NAVIGATION_ITEMS } from "@shared";
import { NavigationItemComponent } from "@entities"; import { NavigationItem, NavigationItemComponent } from "@entities";
export const NavigationList = ({ open }: { open: boolean }) => { export const NavigationList = ({ open }: { open: boolean }) => {
const primaryItems = NAVIGATION_ITEMS.primary; const primaryItems = NAVIGATION_ITEMS.primary;
@ -11,13 +11,21 @@ export const NavigationList = ({ open }: { open: boolean }) => {
<> <>
<List> <List>
{primaryItems.map((item) => ( {primaryItems.map((item) => (
<NavigationItemComponent key={item.id} item={item} open={open} /> <NavigationItemComponent
key={item.id}
item={item as NavigationItem}
open={open}
/>
))} ))}
</List> </List>
<Divider /> <Divider />
<List> <List>
{secondaryItems.map((item) => ( {secondaryItems.map((item) => (
<NavigationItemComponent key={item.id} item={item} open={open} /> <NavigationItemComponent
key={item.id}
item={item as NavigationItem}
open={open}
/>
))} ))}
</List> </List>
</> </>

View File

@ -1,5 +1,5 @@
import { Box, Tab, Tabs } from "@mui/material"; import { Box, Tab, Tabs } from "@mui/material";
import { articlesStore, cityStore } from "@shared"; import { articlesStore, cityStore, languageStore } from "@shared";
import { InformationTab, RightWidgetTab } from "@widgets"; import { InformationTab, RightWidgetTab } from "@widgets";
import { LeftWidgetTab } from "@widgets"; import { LeftWidgetTab } from "@widgets";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
@ -22,7 +22,7 @@ export const CreateSightPage = observer(() => {
useEffect(() => { useEffect(() => {
getCities(); getCities();
getArticles(); getArticles(languageStore.language);
}, []); }, []);
return ( return (

View File

@ -3,7 +3,12 @@ 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 { articlesStore, languageStore, sightsStore } from "@shared"; import {
articlesStore,
cityStore,
editSightStore,
languageStore,
} from "@shared";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
function a11yProps(index: number) { function a11yProps(index: number) {
@ -15,10 +20,11 @@ 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 { getSightInfo } = editSightStore;
const { articles, getArticles } = articlesStore; const { getArticles } = articlesStore;
const { language } = languageStore; const { language } = languageStore;
const { id } = useParams(); const { id } = useParams();
const { getCities } = cityStore;
const handleChange = (_: React.SyntheticEvent, newValue: number) => { const handleChange = (_: React.SyntheticEvent, newValue: number) => {
setValue(newValue); setValue(newValue);
@ -27,16 +33,15 @@ export const EditSightPage = observer(() => {
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
if (id) { if (id) {
await getSight(Number(id)); await getSightInfo(+id, language);
await getArticles(language); await getArticles(language);
await getCities();
} }
}; };
fetchData(); fetchData();
}, [id, language]); }, [id, language]);
return ( return (
articles &&
sight && (
<Box <Box
sx={{ sx={{
width: "100%", width: "100%",
@ -76,6 +81,5 @@ export const EditSightPage = observer(() => {
<RightWidgetTab value={value} index={2} /> <RightWidgetTab value={value} index={2} />
</div> </div>
</Box> </Box>
)
); );
}); });

View File

@ -1,16 +1,28 @@
import { languageStore, Language } from "@shared"; import { languageStore, Language } from "@shared";
import axios from "axios"; import axios, { AxiosError, InternalAxiosRequestConfig } from "axios";
const authInstance = axios.create({ const authInstance = axios.create({
baseURL: "https://wn.krbl.ru", baseURL: "https://wn.krbl.ru",
}); });
authInstance.interceptors.request.use((config) => { authInstance.interceptors.request.use((config: InternalAxiosRequestConfig) => {
console.log(config);
config.headers.Authorization = `Bearer ${localStorage.getItem("token")}`; config.headers.Authorization = `Bearer ${localStorage.getItem("token")}`;
config.headers["X-Language"] = languageStore.language ?? "ru"; config.headers["X-Language"] = languageStore.language ?? "ru";
return config; return config;
}); });
authInstance.interceptors.response.use(
(response) => response,
(error: AxiosError) => {
if (error.response?.status === 401) {
localStorage.removeItem("token");
window.location.href = "/login";
}
return Promise.reject(error);
}
);
const languageInstance = (language: Language) => { const languageInstance = (language: Language) => {
const instance = axios.create({ const instance = axios.create({
baseURL: "https://wn.krbl.ru", baseURL: "https://wn.krbl.ru",

View File

@ -1,3 +1,4 @@
import { authStore } from "@shared";
import { Power, LucideIcon, Building2, MonitorSmartphone } from "lucide-react"; import { Power, LucideIcon, Building2, MonitorSmartphone } from "lucide-react";
export const DRAWER_WIDTH = 300; export const DRAWER_WIDTH = 300;
@ -5,7 +6,8 @@ interface NavigationItem {
id: string; id: string;
label: string; label: string;
icon: LucideIcon; icon: LucideIcon;
path: string; path?: string;
onClick?: () => void;
} }
export const NAVIGATION_ITEMS: { export const NAVIGATION_ITEMS: {
@ -31,7 +33,9 @@ export const NAVIGATION_ITEMS: {
id: "logout", id: "logout",
label: "Выйти", label: "Выйти",
icon: Power, icon: Power,
path: "/logout", onClick: () => {
authStore.logout();
},
}, },
], ],
}; };

View File

@ -1,13 +1,6 @@
import { import { mediaStore, MEDIA_TYPE_LABELS } from "@shared";
articlesStore,
authStore,
Language,
mediaStore,
MEDIA_TYPE_LABELS,
API_URL,
} from "@shared";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { useEffect, useRef, useState, useCallback } from "react"; import { useEffect, useState } from "react";
import { import {
Dialog, Dialog,
DialogTitle, DialogTitle,
@ -17,13 +10,12 @@ import {
TextField, TextField,
Paper, Paper,
Box, Box,
Typography,
CircularProgress, CircularProgress,
Alert, Alert,
Snackbar, Snackbar,
} from "@mui/material"; } from "@mui/material";
import { Download, Save } from "lucide-react"; import { Download, Save } from "lucide-react";
import { ReactMarkdownComponent, MediaViewer } from "@widgets"; import { MediaViewer } from "@widgets";
import { authInstance } from "@shared"; import { authInstance } from "@shared";
interface PreviewMediaDialogProps { interface PreviewMediaDialogProps {

View File

@ -1,4 +1,4 @@
import { articlesStore, authStore, Language } from "@shared"; import { articlesStore } from "@shared";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { import {
@ -73,7 +73,10 @@ export const SelectArticleModal = observer(
setIsLoading(true); setIsLoading(true);
try { try {
await Promise.all([getArticle(articleId), getArticleMedia(articleId)]); await Promise.all([
getArticle(Number(articleId)),
getArticleMedia(Number(articleId)),
]);
} catch (error) { } catch (error) {
console.error("Failed to fetch article data:", error); console.error("Failed to fetch article data:", error);
// Reset article data on error // Reset article data on error
@ -83,9 +86,11 @@ export const SelectArticleModal = observer(
setIsLoading(false); setIsLoading(false);
} }
}; };
// @ts-ignore
const filteredArticles = articles const filteredArticles = articles
// @ts-ignore
.filter((article) => !linkedArticleIds.includes(article.id)) .filter((article) => !linkedArticleIds.includes(article.id))
// @ts-ignore
.filter((article) => .filter((article) =>
article.service_name.toLowerCase().includes(searchQuery.toLowerCase()) article.service_name.toLowerCase().includes(searchQuery.toLowerCase())
); );
@ -140,6 +145,7 @@ export const SelectArticleModal = observer(
{searchQuery ? "Статьи не найдены" : "Нет доступных статей"} {searchQuery ? "Статьи не найдены" : "Нет доступных статей"}
</Typography> </Typography>
) : ( ) : (
// @ts-ignore
filteredArticles.map((article) => ( filteredArticles.map((article) => (
<ListItemButton <ListItemButton
key={article.id} key={article.id}

View File

@ -1,4 +1,4 @@
import { articlesStore, authStore, Language, mediaStore } from "@shared"; import { mediaStore } from "@shared";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { import {
@ -12,12 +12,11 @@ import {
ListItemButton, ListItemButton,
ListItemText, ListItemText,
Paper, Paper,
Box,
Typography, Typography,
InputAdornment, InputAdornment,
} from "@mui/material"; } from "@mui/material";
import { ImagePlus, Search } from "lucide-react"; import { Search } from "lucide-react";
import { ReactMarkdownComponent, MediaViewer } from "@widgets"; import { MediaViewer } from "@widgets";
interface SelectMediaDialogProps { interface SelectMediaDialogProps {
open: boolean; // Corrected prop name open: boolean; // Corrected prop name

View File

@ -53,7 +53,7 @@ class ArticlesStore {
const response = await authInstance.get(`/sight/${id}/article`); const response = await authInstance.get(`/sight/${id}/article`);
runInAction(() => { runInAction(() => {
editSightStore.sightInfo[languageStore.language].right = response.data; editSightStore.sight[languageStore.language].right = response.data;
}); });
}; };
@ -66,10 +66,14 @@ class ArticlesStore {
}; };
getArticleByArticleId = computed(() => { getArticleByArticleId = computed(() => {
if (editSightStore.sightInfo.left_article) { if (editSightStore.sight.common.left_article) {
return this.articles[languageStore.language].find( const language = languageStore.language;
(a) => a.id == editSightStore.sightInfo.left_article const foundArticle = this.articles[language].find(
(a) => a.id == editSightStore.sight.common.left_article
); );
editSightStore.sight[language].left.heading = foundArticle?.heading ?? "";
editSightStore.sight[language].left.body = foundArticle?.body ?? "";
return foundArticle;
} }
return null; return null;
}); });

View File

@ -79,7 +79,7 @@ class AuthStore {
}; };
get isAuthenticated() { get isAuthenticated() {
return this.payload?.token !== null; return this.payload !== null;
} }
get user() { get user() {

View File

@ -1,15 +1,16 @@
// @shared/stores/editSightStore.ts // @shared/stores/editSightStore.ts
import { Language } from "@shared"; import { authInstance, Language } from "@shared";
import { makeAutoObservable } from "mobx"; import { makeAutoObservable, runInAction } from "mobx";
export interface MediaObject { export type SightLanguageInfo = {
id: string;
filename: string;
media_type: number;
}
type SightBaseInfo = {
id: number; id: number;
name: string;
address: string;
left: { heading: string; body: string };
right: { heading: string; body: string }[];
};
export type SightCommonInfo = {
city_id: number; city_id: number;
city: string; city: string;
latitude: number; latitude: number;
@ -22,35 +23,14 @@ type SightBaseInfo = {
video_preview: string; video_preview: string;
}; };
export interface RightArticleBlock { export type SightBaseInfo = {
id: string; common: SightCommonInfo;
type: "article" | "preview_media"; } & {
name: string; [key in Language]: SightLanguageInfo;
linkedArticleId?: string;
heading: string;
body: string;
media: MediaObject | null;
}
type SightInfo = SightBaseInfo & {
[key in Language]: {
info: {
name: string;
address: string;
};
left: {
loaded: boolean; // Означает, что данные для этого языка были инициализированы/загружены
heading: string;
body: string;
media: MediaObject | null;
};
right: RightArticleBlock[];
};
}; };
class EditSightStore { class EditSightStore {
sightInfo: SightInfo = { sight: SightBaseInfo = {
id: 0, common: {
city_id: 0, city_id: 0,
city: "", city: "",
latitude: 0, latitude: 0,
@ -61,19 +41,26 @@ class EditSightStore {
left_article: 0, left_article: 0,
preview_media: "", preview_media: "",
video_preview: "", video_preview: "",
},
ru: { ru: {
info: { name: "", address: "" }, id: 0,
left: { loaded: false, heading: "", body: "", media: null }, name: "",
address: "",
left: { heading: "", body: "" },
right: [], right: [],
}, },
en: { en: {
info: { name: "", address: "" }, id: 0,
left: { loaded: false, heading: "", body: "", media: null }, name: "",
address: "",
left: { heading: "", body: "" },
right: [], right: [],
}, },
zh: { zh: {
info: { name: "", address: "" }, id: 0,
left: { loaded: false, heading: "", body: "", media: null }, name: "",
address: "",
left: { heading: "", body: "" },
right: [], right: [],
}, },
}; };
@ -82,43 +69,41 @@ class EditSightStore {
makeAutoObservable(this); makeAutoObservable(this);
} }
// loadSightInfo: Используется для первоначальной загрузки данных для ЯЗЫКА. hasLoadedCommon = false;
// Она устанавливает loaded: true, чтобы в будущем не перезатирать данные. getSightInfo = async (id: number, language: Language) => {
loadSightInfo = ( if (this.sight[language].id === id) {
language: Language, return;
heading: string,
body: string,
media: MediaObject | null
) => {
// Важно: если данные уже были загружены или изменены, не перезаписывайте их.
// Это предотвращает потерю пользовательского ввода при переключении языков.
// Если хотите принудительную загрузку, добавьте другой метод или параметр.
if (!this.sightInfo[language].left.loaded) {
// <--- Только если еще не загружено
this.sightInfo[language].left.heading = heading;
this.sightInfo[language].left.body = body;
this.sightInfo[language].left.media = media;
this.sightInfo[language].left.loaded = true; // <--- Устанавливаем loaded только при загрузке
} }
const response = await authInstance.get(`/sight/${id}`);
const data = response.data;
runInAction(() => {
// Обновляем языковую часть
this.sight[language] = {
...this.sight[language],
...data,
}; };
// updateSightInfo: Используется для сохранения ЛЮБЫХ пользовательских изменений. // Только при первом запросе обновляем общую часть
// Она НЕ должна влиять на флаг 'loaded', который управляется 'loadSightInfo'. if (!this.hasLoadedCommon) {
updateSightInfo = ( this.sight.common = {
language: Language, ...this.sight.common,
heading: string, ...data,
body: string, };
media: MediaObject | null this.hasLoadedCommon = true;
) => { }
this.sightInfo[language].left.heading = heading; });
this.sightInfo[language].left.body = body; };
this.sightInfo[language].left.media = media;
// this.sightInfo[language].left.loaded = true; // <-- УДАЛИТЕ эту строку updateLeftInfo = (language: Language, heading: string, body: string) => {
this.sight[language].left.heading = heading;
this.sight[language].left.body = body;
}; };
clearSightInfo = () => { clearSightInfo = () => {
this.sightInfo = { this.sight = {
id: 0, common: {
city_id: 0, city_id: 0,
city: "", city: "",
latitude: 0, latitude: 0,
@ -129,23 +114,50 @@ class EditSightStore {
left_article: 0, left_article: 0,
preview_media: "", preview_media: "",
video_preview: "", video_preview: "",
},
ru: { ru: {
info: { name: "", address: "" }, id: 0,
left: { loaded: false, heading: "", body: "", media: null }, name: "",
address: "",
left: { heading: "", body: "" },
right: [], right: [],
}, },
en: { en: {
info: { name: "", address: "" }, id: 0,
left: { loaded: false, heading: "", body: "", media: null }, name: "",
address: "",
left: { heading: "", body: "" },
right: [], right: [],
}, },
zh: { zh: {
info: { name: "", address: "" }, id: 0,
left: { loaded: false, heading: "", body: "", media: null }, name: "",
address: "",
left: { heading: "", body: "" },
right: [], right: [],
}, },
}; };
}; };
updateSightInfo = (
language: Language,
content: Partial<SightLanguageInfo | SightCommonInfo>,
common: boolean = false
) => {
if (common) {
this.sight.common = {
...this.sight.common,
...content,
};
} else {
this.sight[language] = {
...this.sight[language],
...content,
};
}
};
} }
export const editSightStore = new EditSightStore(); export const editSightStore = new EditSightStore();

View File

@ -1,9 +1,8 @@
import { import {
articlesStore,
authInstance, authInstance,
languageInstance, languageInstance,
languageStore, languageStore,
editSightStore, SightBaseInfo,
} from "@shared"; } from "@shared";
import { computed, makeAutoObservable, runInAction } from "mobx"; import { computed, makeAutoObservable, runInAction } from "mobx";
@ -59,48 +58,40 @@ class SightsStore {
}); });
}; };
getSight = async (id: number) => { // getSight = async (id: number) => {
const response = await authInstance.get(`/sight/${id}`); // const response = await authInstance.get(`/sight/${id}`);
runInAction(() => { // runInAction(() => {
this.sight = response.data; // this.sight = response.data;
editSightStore.sightInfo = { // editSightStore.sightInfo = {
...editSightStore.sightInfo, // ...editSightStore.sightInfo,
id: response.data.id, // id: response.data.id,
city_id: response.data.city_id, // city_id: response.data.city_id,
city: response.data.city, // city: response.data.city,
latitude: response.data.latitude, // latitude: response.data.latitude,
longitude: response.data.longitude, // longitude: response.data.longitude,
thumbnail: response.data.thumbnail, // thumbnail: response.data.thumbnail,
watermark_lu: response.data.watermark_lu, // watermark_lu: response.data.watermark_lu,
watermark_rd: response.data.watermark_rd, // watermark_rd: response.data.watermark_rd,
left_article: response.data.left_article, // left_article: response.data.left_article,
preview_media: response.data.preview_media, // preview_media: response.data.preview_media,
video_preview: response.data.video_preview, // video_preview: response.data.video_preview,
[languageStore.language]: {
info: { // [languageStore.language]: {
name: response.data.name, // info: {
address: response.data.address, // name: response.data.name,
description: response.data.description, // address: response.data.address,
}, // },
left: { // left: {
heading: editSightStore.sightInfo[languageStore.language].left // heading: articlesStore.articles[languageStore.language].find(
.loaded // (article) => article.id === response.data.left_article
? editSightStore.sightInfo[languageStore.language].left.heading // )?.heading,
: articlesStore.articles[languageStore.language].find( // body: articlesStore.articles[languageStore.language].find(
(article) => article.id === response.data.left_article // },
)?.heading, // },
body: editSightStore.sightInfo[languageStore.language].left.loaded // };
? editSightStore.sightInfo[languageStore.language].left.body // });
: articlesStore.articles[languageStore.language].find( // };
(article) => article.id === response.data.left_article
)?.body,
},
},
};
console.log(editSightStore.sightInfo);
});
};
createSightAction = async ( createSightAction = async (
city: number, city: number,
@ -160,12 +151,32 @@ class SightsStore {
language: Language, language: Language,
content: Partial<CreateSight[Language]> content: Partial<CreateSight[Language]>
) => { ) => {
runInAction(() => {
this.createSight[language] = { this.createSight[language] = {
...this.createSight[language], ...this.createSight[language],
...content, ...content,
}; };
}); };
updateSight = (
language: Language,
content: Partial<SightBaseInfo[Language]>,
common: boolean
) => {
if (common) {
// @ts-ignore
this.sight!.common = {
// @ts-ignore
...this.sight!.common,
...content,
};
} else {
// @ts-ignore
this.sight![language] = {
// @ts-ignore
...this.sight![language],
...content,
};
}
}; };
clearCreateSight = () => { clearCreateSight = () => {

View File

@ -23,7 +23,7 @@ export const CoordinatesInput = ({
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}> <Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<TextField <TextField
label="Координаты" label="Координаты"
value={inputValue} value={inputValue ?? ""}
onChange={(e) => { onChange={(e) => {
setInputValue(e.target.value); setInputValue(e.target.value);
}} }}

View File

@ -5,7 +5,7 @@ import TableContainer from "@mui/material/TableContainer";
import TableHead from "@mui/material/TableHead"; import TableHead from "@mui/material/TableHead";
import TableRow from "@mui/material/TableRow"; import TableRow from "@mui/material/TableRow";
import Paper from "@mui/material/Paper"; import Paper from "@mui/material/Paper";
import { Check, RotateCcw, Send, X } from "lucide-react"; import { Check, RotateCcw, X } from "lucide-react";
import { import {
authInstance, authInstance,
devicesStore, devicesStore,
@ -49,7 +49,7 @@ function createData(
} }
// Keep the rows function as you provided it, without additional filters // Keep the rows function as you provided it, without additional filters
const rows = (devices: any[], vehicles: any[]) => { const rows = (vehicles: any[]) => {
return vehicles.map((vehicle) => { return vehicles.map((vehicle) => {
return createData( return createData(
vehicle?.vehicle?.tail_number ?? "1243000", // Using tail_number as UUID, as in your original code vehicle?.vehicle?.tail_number ?? "1243000", // Using tail_number as UUID, as in your original code
@ -72,11 +72,11 @@ export const DevicesTable = observer(() => {
toggleSendSnapshotModal, toggleSendSnapshotModal,
} = devicesStore; } = devicesStore;
const { snapshots, getSnapshots } = snapshotStore; const { snapshots, getSnapshots } = snapshotStore;
const { vehicles, getVehicles } = vehicleStore; const { getVehicles } = vehicleStore;
const [selectedDevices, setSelectedDevices] = useState<string[]>([]); const [selectedDevices, setSelectedDevices] = useState<string[]>([]);
// Get the current list of rows displayed in the table // Get the current list of rows displayed in the table
const currentRows = rows(devices, vehicles); const currentRows = rows(devices);
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {

View File

@ -2,7 +2,6 @@ import * as React from "react";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import Toolbar from "@mui/material/Toolbar"; import Toolbar from "@mui/material/Toolbar";
import IconButton from "@mui/material/IconButton"; import IconButton from "@mui/material/IconButton";
import Typography from "@mui/material/Typography";
import { Menu, ChevronLeftIcon, ChevronRightIcon } from "lucide-react"; import { Menu, ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import { useTheme } from "@mui/material/styles"; import { useTheme } from "@mui/material/styles";
import { AppBar } from "./ui/AppBar"; import { AppBar } from "./ui/AppBar";

View File

@ -6,46 +6,45 @@ import {
Typography, Typography,
Paper, Paper,
Tooltip, Tooltip,
Dialog,
DialogTitle,
MenuItem, MenuItem,
Menu as MuiMenu, Menu as MuiMenu,
} from "@mui/material"; } from "@mui/material";
import { import {
BackButton, BackButton,
sightsStore,
TabPanel, TabPanel,
languageStore, languageStore,
CreateSight,
Language, Language,
cityStore, cityStore,
CoordinatesInput,
editSightStore, editSightStore,
SelectMediaDialog, SelectMediaDialog,
PreviewMediaDialog, PreviewMediaDialog,
SightLanguageInfo,
SightCommonInfo,
} from "@shared"; } from "@shared";
import { LanguageSwitcher } from "@widgets"; import { LanguageSwitcher } from "@widgets";
import { Info, ImagePlus } from "lucide-react"; import { Info, ImagePlus } from "lucide-react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { useRef, useState } from "react"; import { useEffect, useState } from "react";
// Мокап для всплывающей подсказки // Мокап для всплывающей подсказки
export const InformationTab = observer( export const InformationTab = observer(
({ value, index }: { value: number; index: number }) => { ({ value, index }: { value: number; index: number }) => {
const { cities } = cityStore; const { cities } = cityStore;
const [isMediaModalOpen, setIsMediaModalOpen] = useState(false); const [, setIsMediaModalOpen] = useState(false);
const [mediaId, setMediaId] = useState<string>(""); const [mediaId, setMediaId] = useState<string>("");
const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false); const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false);
const { createSight, updateCreateSight, createSightAction } = sightsStore;
const { sightInfo } = editSightStore;
const [city, setCity] = useState<number>(sightInfo.city_id ?? 0);
const [coordinates, setCoordinates] = useState({
latitude: sightInfo.latitude ?? 0,
longitude: sightInfo.longitude ?? 0,
});
const { language } = languageStore; const { language } = languageStore;
const { sight, updateSightInfo } = editSightStore;
const data = sight[language];
const common = sight.common;
const [, setCity] = useState<number>(common.city_id ?? 0);
const [coordinates, setCoordinates] = useState<string>(`0 0`);
const token = localStorage.getItem("token"); const token = localStorage.getItem("token");
// Menu state for each media button // Menu state for each media button
@ -63,6 +62,14 @@ export const InformationTab = observer(
setActiveMenuType(type); setActiveMenuType(type);
}; };
useEffect(() => {
// Показывать только при инициализации (не менять при ошибках пользователя)
if (common.latitude !== 0 || common.longitude !== 0) {
setCoordinates(`${common.latitude} ${common.longitude}`);
}
// если координаты обнулились — оставить поле как есть
}, [common.latitude, common.longitude]);
const handleMenuClose = () => { const handleMenuClose = () => {
setMenuAnchorEl(null); setMenuAnchorEl(null);
setActiveMenuType(null); setActiveMenuType(null);
@ -77,7 +84,7 @@ export const InformationTab = observer(
handleMenuClose(); handleMenuClose();
}; };
const handleMediaSelect = (selectedMediaId: string) => { const handleMediaSelect = () => {
if (!activeMenuType) return; if (!activeMenuType) return;
// Close the dialog // Close the dialog
@ -87,17 +94,10 @@ export const InformationTab = observer(
const handleChange = ( const handleChange = (
language: Language, language: Language,
content: Partial<CreateSight[Language]> content: Partial<SightLanguageInfo | SightCommonInfo>,
common: boolean = false
) => { ) => {
updateCreateSight(language, content); updateSightInfo(language, content, common);
};
const handleSave = async () => {
try {
await createSightAction(city, coordinates);
} catch (error) {
console.error(error);
}
}; };
return ( return (
@ -135,7 +135,7 @@ export const InformationTab = observer(
> >
<TextField <TextField
label={`Название (${language.toUpperCase()})`} label={`Название (${language.toUpperCase()})`}
value={sightInfo[language]?.info?.name ?? ""} value={data.name}
onChange={(e) => { onChange={(e) => {
handleChange(language as Language, { handleChange(language as Language, {
name: e.target.value, name: e.target.value,
@ -147,7 +147,7 @@ export const InformationTab = observer(
<TextField <TextField
label="Адрес" label="Адрес"
value={sightInfo[language]?.info?.address ?? ""} value={data.address}
onChange={(e) => { onChange={(e) => {
handleChange(language as Language, { handleChange(language as Language, {
address: e.target.value, address: e.target.value,
@ -158,20 +158,57 @@ export const InformationTab = observer(
/> />
<Autocomplete <Autocomplete
options={cities} options={cities ?? []}
value={cities.find((city) => city.id === sightInfo.city_id)} value={
cities.find((city) => city.id === common.city_id) ?? null
}
getOptionLabel={(option) => option.name} getOptionLabel={(option) => option.name}
onChange={(_, value) => { onChange={(_, value) => {
setCity(value?.id ?? 0); setCity(value?.id ?? 0);
handleChange(
language as Language,
{
city_id: value?.id ?? 0,
},
true
);
}} }}
renderInput={(params) => ( renderInput={(params) => (
<TextField {...params} label="Город" /> <TextField {...params} label="Город" />
)} )}
/> />
<CoordinatesInput <TextField
initialValue={coordinates} label="Координаты"
setValue={setCoordinates} value={coordinates}
onChange={(e) => {
const input = e.target.value;
setCoordinates(input); // показываем как есть
const [latStr, lonStr] = input.split(/\s+/); // учитываем любые пробелы
const lat = parseFloat(latStr);
const lon = parseFloat(lonStr);
// Проверка, что обе координаты валидные числа
const isValidLat = !isNaN(lat);
const isValidLon = !isNaN(lon);
if (isValidLat && isValidLon) {
handleChange(language as Language, {
latitude: lat,
longitude: lon,
});
} else {
handleChange(language as Language, {
latitude: 0,
longitude: 0,
});
}
}}
fullWidth
variant="outlined"
placeholder="Введите координаты в формате: широта долгота"
/> />
</Box> </Box>
@ -222,11 +259,9 @@ export const InformationTab = observer(
justifyContent: "center", justifyContent: "center",
borderRadius: 1, borderRadius: 1,
mb: 1, mb: 1,
cursor: editSightStore.sightInfo?.thumbnail cursor: common.thumbnail ? "pointer" : "default",
? "pointer"
: "default", // Only clickable if there's an image
"&:hover": { "&:hover": {
backgroundColor: editSightStore.sightInfo?.thumbnail backgroundColor: common.thumbnail
? "red.300" ? "red.300"
: "grey.200", : "grey.200",
}, },
@ -235,16 +270,16 @@ export const InformationTab = observer(
setIsMediaModalOpen(true); setIsMediaModalOpen(true);
}} }}
> >
{editSightStore.sightInfo?.thumbnail ? ( {common.thumbnail ? (
<img <img
src={`${import.meta.env.VITE_KRBL_MEDIA}${ src={`${import.meta.env.VITE_KRBL_MEDIA}${
editSightStore.sightInfo?.thumbnail common.thumbnail
}/download?token=${token}`} }/download?token=${token}`}
alt="Логотип" alt="Логотип"
style={{ maxWidth: "100%", maxHeight: "100%" }} style={{ maxWidth: "100%", maxHeight: "100%" }}
onClick={() => { onClick={() => {
setIsPreviewMediaOpen(true); setIsPreviewMediaOpen(true);
setMediaId(editSightStore.sightInfo?.thumbnail); setMediaId(common.thumbnail);
}} }}
/> />
) : ( ) : (
@ -297,31 +332,28 @@ export const InformationTab = observer(
justifyContent: "center", justifyContent: "center",
borderRadius: 1, borderRadius: 1,
mb: 1, mb: 1,
cursor: editSightStore.sightInfo?.watermark_lu cursor: common.watermark_lu ? "pointer" : "default",
? "pointer"
: "default", // Only clickable if there's an image
"&:hover": { "&:hover": {
backgroundColor: editSightStore.sightInfo backgroundColor: common.watermark_lu
?.watermark_lu
? "grey.300" ? "grey.300"
: "grey.200", : "grey.200",
}, },
}} }}
onClick={() => { onClick={() => {
setIsPreviewMediaOpen(true); setIsPreviewMediaOpen(true);
setMediaId(editSightStore.sightInfo?.watermark_lu); setMediaId(common.watermark_lu);
}} }}
> >
{editSightStore.sightInfo?.watermark_lu ? ( {common.watermark_lu ? (
<img <img
src={`${import.meta.env.VITE_KRBL_MEDIA}${ src={`${import.meta.env.VITE_KRBL_MEDIA}${
editSightStore.sightInfo?.watermark_lu common.watermark_lu
}/download?token=${token}`} }/download?token=${token}`}
alt="Знак л.в" alt="Знак л.в"
style={{ maxWidth: "100%", maxHeight: "100%" }} style={{ maxWidth: "100%", maxHeight: "100%" }}
onClick={() => { onClick={() => {
setIsMediaModalOpen(true); setIsMediaModalOpen(true);
setMediaId(editSightStore.sightInfo?.watermark_lu); setMediaId(common.watermark_lu);
}} }}
/> />
) : ( ) : (
@ -375,28 +407,28 @@ export const InformationTab = observer(
justifyContent: "center", justifyContent: "center",
borderRadius: 1, borderRadius: 1,
mb: 1, mb: 1,
cursor: editSightStore.sightInfo?.watermark_rd cursor: common.watermark_rd ? "pointer" : "default",
? "pointer"
: "default", // Only clickable if there's an image
"&:hover": { "&:hover": {
backgroundColor: editSightStore.sightInfo backgroundColor: common.watermark_rd
?.watermark_rd
? "grey.300" ? "grey.300"
: "grey.200", : "grey.200",
}, },
}} }}
onClick={() => editSightStore.sightInfo?.watermark_rd} onClick={() => {
setIsMediaModalOpen(true);
setMediaId(common.watermark_rd);
}}
> >
{editSightStore.sightInfo?.watermark_rd ? ( {common.watermark_rd ? (
<img <img
src={`${import.meta.env.VITE_KRBL_MEDIA}${ src={`${import.meta.env.VITE_KRBL_MEDIA}${
editSightStore.sightInfo?.watermark_rd common.watermark_rd
}/download?token=${token}`} }/download?token=${token}`}
alt="Знак п.в" alt="Знак п.в"
style={{ maxWidth: "100%", maxHeight: "100%" }} style={{ maxWidth: "100%", maxHeight: "100%" }}
onClick={() => { onClick={() => {
setIsPreviewMediaOpen(true); setIsPreviewMediaOpen(true);
setMediaId(editSightStore.sightInfo?.watermark_rd); setMediaId(common.watermark_rd);
}} }}
/> />
) : ( ) : (
@ -432,7 +464,13 @@ export const InformationTab = observer(
justifyContent: "flex-end", // Align to the right justifyContent: "flex-end", // Align to the right
}} }}
> >
<Button variant="contained" color="success" onClick={handleSave}> <Button
variant="contained"
color="success"
onClick={() => {
console.log(sight);
}}
>
Сохранить Сохранить
</Button> </Button>
</Box> </Box>
@ -463,7 +501,7 @@ export const InformationTab = observer(
setIsAddMediaOpen(false); setIsAddMediaOpen(false);
setActiveMenuType(null); setActiveMenuType(null);
}} }}
onSelectArticle={handleMediaSelect} onSelectMedia={handleMediaSelect}
/> />
<PreviewMediaDialog <PreviewMediaDialog

View File

@ -14,79 +14,44 @@ import {
ReactMarkdownEditor, ReactMarkdownEditor,
} from "@widgets"; } from "@widgets";
import { Unlink, Trash2, ImagePlus } from "lucide-react"; import { Unlink, Trash2, ImagePlus } from "lucide-react";
import { useState, useEffect, useCallback } from "react"; import { useState, useCallback } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
export const LeftWidgetTab = observer( export const LeftWidgetTab = observer(
({ value, index }: { value: number; index: number }) => { ({ value, index }: { value: number; index: number }) => {
const { sightInfo, updateSightInfo, loadSightInfo } = editSightStore; const { sight, updateSightInfo } = editSightStore;
const { articleLoading, getArticleByArticleId } = articlesStore; const { getArticleByArticleId } = articlesStore;
const { language } = languageStore;
const linkedArticle = getArticleByArticleId.get(); // Получаем связанную статью const linkedArticle = getArticleByArticleId.get(); // Получаем связанную статью
const data = sightInfo[languageStore.language]; // Получаем данные для текущего языка const data = sight[languageStore.language]; // Получаем данные для текущего языка
useEffect(() => {
// Этот useEffect должен загружать данные ИЗ СВЯЗАННОЙ СТАТЬИ
// ТОЛЬКО ЕСЛИ данные для ТЕКУЩЕГО ЯЗЫКА еще не были загружены
// или если sightInfo.left_article изменился (т.е. привязали новую статью).
// Мы также должны учитывать, что linkedArticle может измениться (т.е. новую статью привязали)
// или language изменился.
// Если для текущего языка данные еще не "загружены" (`loaded: false`),
// тогда мы берем их из `linkedArticle` и инициализируем.
console.log("data.left.loaded", data.left.loaded);
if (!data.left.loaded) {
// <--- КЛЮЧЕВОЕ УСЛОВИЕ
if (linkedArticle && !articleLoading) {
console.log("loadSightInfo", linkedArticle, language);
loadSightInfo(
languageStore.language,
linkedArticle.heading,
linkedArticle.body || "",
null
);
}
}
// Зависимости: linkedArticle (для реакции на изменение привязанной статьи),
// languageStore.language (для реакции на изменение языка),
// loadSightInfo (чтобы useEffect знал об изменениях в функции),
// data.left.loaded (чтобы useEffect перепроверил условие, когда этот флаг изменится).
// Важно: если data.left.loaded становится true, то этот эффект не будет
// перезапускаться для того же языка.
}, [
linkedArticle?.heading,
language,
loadSightInfo,
data.left.loaded,
articleLoading,
]);
const [isSelectMediaDialogOpen, setIsSelectMediaDialogOpen] = const [isSelectMediaDialogOpen, setIsSelectMediaDialogOpen] =
useState(false); useState(false);
const handleOpenMediaDialog = useCallback(() => { const handleMediaSelected = useCallback(() => {
setIsSelectMediaDialogOpen(true);
}, []);
const handleMediaSelected = useCallback(
(selectedMedia: any) => {
// При выборе медиа, обновляем данные для ТЕКУЩЕГО ЯЗЫКА // При выборе медиа, обновляем данные для ТЕКУЩЕГО ЯЗЫКА
// сохраняя текущие heading и body. // сохраняя текущие heading и body.
updateSightInfo( updateSightInfo(
languageStore.language, languageStore.language,
data.left.heading, {
data.left.body, left: {
selectedMedia heading: data.left.heading,
body: data.left.body,
},
},
false
); );
setIsSelectMediaDialogOpen(false); setIsSelectMediaDialogOpen(false);
}, }, [
[
languageStore.language, languageStore.language,
data.left.heading, {
data.left.body, left: {
updateSightInfo, heading: data.left.heading,
] body: data.left.body,
); },
},
false,
]);
const handleCloseMediaDialog = useCallback(() => { const handleCloseMediaDialog = useCallback(() => {
setIsSelectMediaDialogOpen(false); setIsSelectMediaDialogOpen(false);
@ -150,13 +115,17 @@ export const LeftWidgetTab = observer(
> >
<TextField <TextField
label="Название информации" label="Название информации"
value={data.left.heading} value={data?.left?.heading}
onChange={(e) => onChange={(e) =>
updateSightInfo( updateSightInfo(
languageStore.language, languageStore.language,
e.target.value, {
data.left.body, left: {
data.left.media heading: e.target.value,
body: data.left.body,
},
},
false
) )
} }
variant="outlined" variant="outlined"
@ -164,19 +133,23 @@ export const LeftWidgetTab = observer(
/> />
<ReactMarkdownEditor <ReactMarkdownEditor
value={data.left.body} value={data?.left?.body}
onChange={(value) => onChange={(value) =>
updateSightInfo( updateSightInfo(
languageStore.language, languageStore.language,
data.left.heading, {
value, left: {
data.left.media heading: data.left.heading,
body: value,
},
},
false
) )
} }
/> />
{/* Блок МЕДИА для статьи */} {/* Блок МЕДИА для статьи */}
<Paper elevation={1} sx={{ padding: 2, mt: 1 }}> {/* <Paper elevation={1} sx={{ padding: 2, mt: 1 }}>
<Typography variant="h6" gutterBottom> <Typography variant="h6" gutterBottom>
МЕДИА МЕДИА
</Typography> </Typography>
@ -226,16 +199,21 @@ export const LeftWidgetTab = observer(
onClick={() => onClick={() =>
updateSightInfo( updateSightInfo(
languageStore.language, languageStore.language,
data.left.heading, {
data.left.body, left: {
null heading: data.left.heading,
body: data.left.body,
media: null,
},
},
false
) )
} }
> >
Удалить медиа Удалить медиа
</Button> </Button>
)} )}
</Paper> </Paper> */}
</Box> </Box>
{/* Правая колонка: Предпросмотр */} {/* Правая колонка: Предпросмотр */}
@ -247,7 +225,6 @@ export const LeftWidgetTab = observer(
gap: 1.5, gap: 1.5,
}} }}
> >
<Typography variant="h6">Предпросмотр</Typography>
<Paper <Paper
elevation={3} elevation={3}
sx={{ sx={{
@ -263,8 +240,7 @@ export const LeftWidgetTab = observer(
flexDirection: "column", flexDirection: "column",
}} }}
> >
{/* Медиа в превью (если есть) */} {/* {data.left.media?.filename ? (
{data.left.media ? (
<Box <Box
sx={{ sx={{
width: "100%", width: "100%",
@ -276,7 +252,7 @@ export const LeftWidgetTab = observer(
}} }}
> >
<img <img
src={data.left.media.filename} src={data.left.media?.filename ?? ""}
alt="Превью медиа" alt="Превью медиа"
style={{ style={{
objectFit: "cover", objectFit: "cover",
@ -286,6 +262,9 @@ export const LeftWidgetTab = observer(
/> />
</Box> </Box>
) : ( ) : (
)} */}
<Box <Box
sx={{ sx={{
width: "100%", width: "100%",
@ -298,7 +277,6 @@ export const LeftWidgetTab = observer(
> >
<ImagePlus size={48} color="grey" /> <ImagePlus size={48} color="grey" />
</Box> </Box>
)}
{/* Заголовок в превью */} {/* Заголовок в превью */}
<Box <Box
@ -313,7 +291,7 @@ export const LeftWidgetTab = observer(
component="h2" component="h2"
sx={{ wordBreak: "break-word" }} sx={{ wordBreak: "break-word" }}
> >
{data.left.heading || "Название информации"} {data?.left?.heading || "Название информации"}
</Typography> </Typography>
</Box> </Box>
@ -324,7 +302,7 @@ export const LeftWidgetTab = observer(
flexGrow: 1, flexGrow: 1,
}} }}
> >
<ReactMarkdownComponent value={data.left.body} /> <ReactMarkdownComponent value={data?.left?.body} />
</Box> </Box>
</Paper> </Paper>
</Box> </Box>

View File

@ -1,404 +1,371 @@
// RightWidgetTab.tsx
import { Box, Button, Paper, TextField, Typography } from "@mui/material";
import { import {
TabPanel, Box,
Button,
List,
ListItemButton,
ListItemText,
Paper,
Typography,
Menu,
MenuItem,
} from "@mui/material";
import {
articlesStore,
BackButton, BackButton,
languageStore, // Предполагаем, что он есть в @shared SelectArticleModal,
Language, // Предполагаем, что он есть в @shared TabPanel,
// SelectArticleModal, // Добавим позже
// articlesStore, // Добавим позже
} from "@shared"; } from "@shared";
import { LanguageSwitcher } from "@widgets"; // Предполагаем, что LanguageSwitcher у вас есть import { SightEdit } from "@widgets";
import { Plus } from "lucide-react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { useState, useMemo, useEffect } from "react"; import { useState } from "react";
import { editSightStore, BlockItem } from "@shared"; // Путь к вашему стору
// Импортируем сюда же определения BlockItem, если не выносим в types.ts // --- Mock Data (can be moved to a separate file or fetched from an API) ---
// export interface BlockItem { id: string; type: 'media' | 'article'; nameForSidebar: string; linkedArticleStoreId?: string; } const mockRightWidgetBlocks = [
{ id: "preview_media", name: "Превью-медиа", type: "special" },
// --- Начальные данные для структуры блоков (позже это может загружаться) --- { id: "article_1", name: "1. История", type: "article" },
// ID здесь должны быть уникальными для списка. { id: "article_2", name: "2. Факты", type: "article" },
const initialBlockStructures: Omit<BlockItem, "nameForSidebar">[] = [ {
{ id: "preview_media_main", type: "media" }, id: "article_3",
{ id: "article_1_local", type: "article" }, // Эти статьи будут редактироваться локально name: "3. Блокада (Пример длинного названия)",
{ id: "article_2_local", type: "article" }, type: "article",
},
]; ];
interface RightWidgetTabProps { const mockSelectedBlockData = {
value: number; id: "article_1",
index: number; heading: "История основания Санкт-Петербурга",
body: "## Начало\nГород был основан 27 мая 1703 года Петром I...",
media: [],
};
// --- ArticleListSidebar Component ---
interface ArticleBlock {
id: string;
name: string;
type: string;
linkedArticleId?: string; // Added for linked articles
} }
export const RightWidgetTab = observer( interface ArticleListSidebarProps {
({ value, index }: RightWidgetTabProps) => { blocks: ArticleBlock[];
const { language } = languageStore; // Текущий язык selectedBlockId: string | null;
const { sightInfo } = editSightStore; // Данные достопримечательности onSelectBlock: (blockId: string) => void;
onCreateNew: () => void;
onSelectExisting: () => void;
}
// 1. Структура блоков: порядок, тип, связи (не сам контент) const ArticleListSidebar = ({
// Имена nameForSidebar будут динамически браться из sightInfo или articlesStore blocks,
const [blockItemsStructure, setBlockItemsStructure] = useState< selectedBlockId,
Omit<BlockItem, "nameForSidebar">[] onSelectBlock,
>(initialBlockStructures); onCreateNew,
onSelectExisting,
}: ArticleListSidebarProps) => {
const [menuAnchorEl, setMenuAnchorEl] = useState<null | HTMLElement>(null);
// 2. ID выбранного блока для редактирования const handleMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
const [selectedBlockId, setSelectedBlockId] = useState<string | null>( setMenuAnchorEl(event.currentTarget);
() => {
// По умолчанию выбираем первый блок, если он есть
return initialBlockStructures.length > 0
? initialBlockStructures[0].id
: null;
}
);
// 3. Состояние для модального окна выбора существующей статьи (добавим позже)
// const [isSelectModalOpen, setIsSelectModalOpen] = useState(false);
// --- Производные данные (Derived State) ---
// Блоки для отображения в сайдбаре (с локализованными именами)
const blocksForSidebar: BlockItem[] = useMemo(() => {
return blockItemsStructure.map((struct) => {
let name = `Блок ${struct.id}`; // Имя по умолчанию
if (struct.type === "media" && struct.id === "preview_media_main") {
name = "Превью-медиа"; // Фиксированное имя для этого блока
} else if (struct.type === "article") {
if (struct.linkedArticleStoreId) {
// TODO: Найти имя в articlesStore по struct.linkedArticleStoreId
name = `Связанная: ${struct.linkedArticleStoreId}`;
} else {
// Это локальная статья, берем заголовок из editSightStore
const articleContent = sightInfo[language]?.right?.find(
(a) => a.id === struct.id
);
name =
articleContent?.heading ||
`Статья ${struct.id.slice(-4)} (${language.toUpperCase()})`;
}
}
return { ...struct, nameForSidebar: name };
});
}, [blockItemsStructure, language, sightInfo]);
// Данные выбранного блока (структура + контент)
const selectedBlockData = useMemo(() => {
if (!selectedBlockId) return null;
const structure = blockItemsStructure.find(
(b) => b.id === selectedBlockId
);
if (!structure) return null;
if (structure.type === "article" && !structure.linkedArticleStoreId) {
const content = sightInfo[language]?.right?.find(
(a) => a.id === selectedBlockId
);
return {
structure,
content: content || { id: selectedBlockId, heading: "", body: "" }, // Заглушка, если нет контента
}; };
}
// Для media или связанных статей пока просто структура
return { structure, content: null };
}, [selectedBlockId, blockItemsStructure, language, sightInfo]);
// --- Обработчики событий --- const handleMenuClose = () => {
setMenuAnchorEl(null);
};
return (
<Paper
elevation={2}
sx={{
width: 260,
minWidth: 240,
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
padding: 1.5,
borderRadius: 2,
border: "1px solid",
borderColor: "divider",
}}
>
<List
dense
sx={{
overflowY: "auto",
flexGrow: 1,
maxHeight: "calc(100% - 60px)",
}}
>
{blocks.map((block) => (
<ListItemButton
key={block.id}
selected={selectedBlockId === block.id}
onClick={() => onSelectBlock(block.id)}
sx={{
borderRadius: 1,
mb: 0.5,
backgroundColor:
selectedBlockId === block.id ? "primary.light" : "transparent",
"&.Mui-selected": {
backgroundColor: "primary.main",
color: "primary.contrastText",
"&:hover": {
backgroundColor: "primary.dark",
},
},
"&:hover": {
backgroundColor:
selectedBlockId !== block.id ? "action.hover" : undefined,
},
}}
>
<ListItemText
primary={block.name}
primaryTypographyProps={{
fontWeight: selectedBlockId === block.id ? "bold" : "normal",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
/>
</ListItemButton>
))}
</List>
<button
className="w-8 h-8 rounded-full bg-blue-500 text-white flex items-center justify-center hover:bg-blue-600 transition-colors"
onClick={handleMenuOpen}
>
<Plus color="white" />
</button>
<Menu
anchorEl={menuAnchorEl}
open={Boolean(menuAnchorEl)}
onClose={handleMenuClose}
anchorOrigin={{
vertical: "top",
horizontal: "right",
}}
transformOrigin={{
vertical: "bottom",
horizontal: "right",
}}
>
<MenuItem onClick={onCreateNew}>Создать новую</MenuItem>
<MenuItem onClick={onSelectExisting}>Выбрать существующую</MenuItem>
</Menu>
</Paper>
);
};
// --- ArticleEditorPane Component ---
interface ArticleData {
id: string;
heading: string;
body: string;
media: any[]; // Define a proper type for media if available
}
interface ArticleEditorPaneProps {
articleData: ArticleData | null;
}
const ArticleEditorPane = ({ articleData }: 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 }: { value: number; index: number }) => {
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) => { const handleSelectBlock = (blockId: string) => {
setSelectedBlockId(blockId); setSelectedBlockId(blockId);
console.log("Selected block:", blockId);
}; };
const handleCreateNewArticle = () => { const handleCreateNew = () => {
const newBlockId = `article_local_${Date.now()}`; const newBlockId = `article_${Date.now()}`;
const newBlockStructure: Omit<BlockItem, "nameForSidebar"> = { setRightWidgetBlocks((prevBlocks) => [
...prevBlocks,
{
id: newBlockId, id: newBlockId,
name: `${
prevBlocks.filter((b) => b.type === "article").length + 1
}. Новый блок`,
type: "article", type: "article",
}; },
setBlockItemsStructure((prev) => [...prev, newBlockStructure]); ]);
// Добавляем пустой контент для этой статьи во все языки в editSightStore
const baseName = `Новая статья ${
blockItemsStructure.filter((b) => b.type === "article").length + 1
}`;
["ru", "en", "zh"].forEach((lang) => {
const currentLang = lang as Language;
if (
editSightStore.sightInfo[currentLang] &&
!editSightStore.sightInfo[currentLang].right?.find(
(r) => r.id === newBlockId
)
) {
editSightStore.sightInfo[currentLang].right.push({
id: newBlockId,
heading: `${baseName} (${currentLang.toUpperCase()})`,
body: `Содержимое для ${baseName} (${currentLang.toUpperCase()})...`,
});
}
});
setSelectedBlockId(newBlockId); setSelectedBlockId(newBlockId);
}; };
const handleHeadingChange = (newHeading: string) => { const handleSelectExisting = () => {
if ( setIsSelectModalOpen(true);
selectedBlockData &&
selectedBlockData.structure.type === "article" &&
!selectedBlockData.structure.linkedArticleStoreId
) {
const blockId = selectedBlockData.structure.id;
const langData = editSightStore.sightInfo[language];
const article = langData?.right?.find((a) => a.id === blockId);
if (article) {
article.heading = newHeading;
} else if (langData) {
// Если статьи еще нет, добавляем
langData.right.push({ id: blockId, heading: newHeading, body: "" });
}
// Обновить имя в сайдбаре (т.к. blocksForSidebar пересчитается)
// Для этого достаточно, чтобы sightInfo был observable и blocksForSidebar от него зависел
}
}; };
const handleBodyChange = (newBody: string) => { const handleCloseSelectModal = () => {
if ( setIsSelectModalOpen(false);
selectedBlockData &&
selectedBlockData.structure.type === "article" &&
!selectedBlockData.structure.linkedArticleStoreId
) {
const blockId = selectedBlockData.structure.id;
const langData = editSightStore.sightInfo[language];
const article = langData?.right?.find((a) => a.id === blockId);
if (article) {
article.body = newBody;
} else if (langData) {
// Если статьи еще нет, добавляем
langData.right.push({ id: blockId, heading: "", body: newBody });
}
}
}; };
const handleDeleteBlock = (blockIdToDelete: string) => { const handleSelectArticle = (articleId: string) => {
setBlockItemsStructure((prev) => // @ts-ignore
prev.filter((b) => b.id !== blockIdToDelete) const article = articlesStore.articles.find((a) => a.id === articleId);
); if (article) {
// Удаляем контент из editSightStore для всех языков const newBlockId = `article_linked_${article.id}_${Date.now()}`;
["ru", "en", "zh"].forEach((lang) => { setRightWidgetBlocks((prevBlocks) => [
const currentLang = lang as Language; ...prevBlocks,
if (editSightStore.sightInfo[currentLang]) { {
editSightStore.sightInfo[currentLang].right = id: newBlockId,
editSightStore.sightInfo[currentLang].right?.filter( name: `${
(r) => r.id !== blockIdToDelete prevBlocks.filter((b) => b.type === "article").length + 1
); }. ${article.service_name}`,
} type: "article",
}); linkedArticleId: article.id,
},
if (selectedBlockId === blockIdToDelete) { ]);
setSelectedBlockId( setSelectedBlockId(newBlockId);
blockItemsStructure.length > 1
? blockItemsStructure.filter((b) => b.id !== blockIdToDelete)[0]?.id
: null
);
} }
handleCloseSelectModal();
}; };
const handleSave = () => { const handleSave = () => {
console.log( console.log("Saving right widget...");
"Сохранение Right Widget:", // Implement save logic here, e.g., send data to an API
JSON.stringify(editSightStore.sightInfo, null, 2)
);
// Здесь будет логика отправки editSightStore.sightInfo на сервер
alert("Данные для сохранения (см. консоль)");
}; };
// --- Инициализация контента в сторе для initialBlockStructures (если его там нет) --- // Determine the current block data to pass to the editor pane
useEffect(() => { const currentBlockToEdit = selectedBlockId
initialBlockStructures.forEach((struct) => { ? selectedBlockId === mockSelectedBlockData.id
if (struct.type === "article" && !struct.linkedArticleStoreId) { ? mockSelectedBlockData
const baseName = `Статья ${struct.id.split("_")[1]}`; // Пример "История" или "Факты" : {
["ru", "en", "zh"].forEach((lang) => { id: selectedBlockId,
const currentLang = lang as Language; heading:
if ( rightWidgetBlocks.find((b) => b.id === selectedBlockId)?.name ||
editSightStore.sightInfo[currentLang] && "Заголовок...",
!editSightStore.sightInfo[currentLang].right?.find( body: "Содержимое...",
(r) => r.id === struct.id media: [],
)
) {
editSightStore.sightInfo[currentLang].right?.push({
id: struct.id,
heading: `${baseName} (${currentLang.toUpperCase()})`, // Например: "История (RU)"
body: `Начальное содержимое для ${baseName} на ${currentLang.toUpperCase()}.`,
});
} }
}); : null;
}
}); // Get list of already linked article IDs
}, []); // Запускается один раз при монтировании const linkedArticleIds = rightWidgetBlocks
.filter((block) => block.linkedArticleId)
.map((block) => block.linkedArticleId as string);
return ( return (
<TabPanel value={value} index={index}> <TabPanel value={value} index={index}>
<LanguageSwitcher />
<Box <Box
sx={{ sx={{
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
height: "100%", height: "100%",
minHeight: "calc(100vh - 200px)", minHeight: "calc(100vh - 200px)", // Adjust as needed
gap: 2, gap: 2,
paddingBottom: "70px", paddingBottom: "70px", // Space for the save button
position: "relative", position: "relative",
}} }}
> >
<BackButton /> <BackButton />
<Box sx={{ display: "flex", flexGrow: 1, gap: 2.5, minHeight: 0 }}> <Box sx={{ display: "flex", flexGrow: 1, gap: 2.5 }}>
{/* Компонент сайдбара списка блоков */} <ArticleListSidebar
<Paper blocks={rightWidgetBlocks}
elevation={1} selectedBlockId={selectedBlockId}
sx={{ onSelectBlock={handleSelectBlock}
width: 280, onCreateNew={handleCreateNew}
padding: 1.5, onSelectExisting={handleSelectExisting}
display: "flex", />
flexDirection: "column",
}}
>
<Typography variant="h6" gutterBottom>
Блоки
</Typography>
<Box sx={{ flexGrow: 1, overflowY: "auto" }}>
{blocksForSidebar.map((block) => (
<Button
key={block.id}
fullWidth
variant={
selectedBlockId === block.id ? "contained" : "outlined"
}
onClick={() => handleSelectBlock(block.id)}
sx={{
justifyContent: "flex-start",
mb: 0.5,
textTransform: "none",
}}
>
{block.nameForSidebar}
</Button>
))}
</Box>
<Button
variant="contained"
onClick={handleCreateNewArticle}
sx={{ mt: 1 }}
>
+ Новая статья
</Button>
{/* TODO: Кнопка "Выбрать существующую" */}
</Paper>
{/* Компонент редактора выбранного блока */} <ArticleEditorPane articleData={currentBlockToEdit} />
<Paper
elevation={1}
sx={{ flexGrow: 1, padding: 2.5, overflowY: "auto" }}
>
<Typography variant="h6" gutterBottom>
Редактор блока ({language.toUpperCase()})
</Typography>
{selectedBlockData ? (
<Box>
<Typography variant="subtitle1">
ID: {selectedBlockData.structure.id}
</Typography>
<Typography variant="subtitle1">
Тип: {selectedBlockData.structure.type}
</Typography>
{selectedBlockData.structure.type === "media" && (
<Box
my={2}
p={2}
border="1px dashed grey"
height={150}
display="flex"
alignItems="center"
justifyContent="center"
>
<Typography color="textSecondary">
Загрузчик медиа для "{selectedBlockData.structure.id}"
</Typography>
</Box>
)}
{selectedBlockData.structure.type === "article" &&
!selectedBlockData.structure.linkedArticleStoreId &&
selectedBlockData.content && (
<Box mt={2}>
<TextField
fullWidth
label="Заголовок статьи"
value={selectedBlockData.content.heading}
onChange={(e) => handleHeadingChange(e.target.value)}
sx={{ mb: 2 }}
/>
<TextField
fullWidth
multiline
rows={8}
label="Текст статьи"
value={selectedBlockData.content.body}
onChange={(e) => handleBodyChange(e.target.value)}
sx={{ mb: 2 }}
// Здесь позже можно будет вставить SightEdit
/>
{/* TODO: Секция медиа для статьи */}
<Button
color="error"
variant="outlined"
onClick={() =>
handleDeleteBlock(selectedBlockData.structure.id)
}
>
Удалить эту статью
</Button>
</Box>
)}
{selectedBlockData.structure.type === "article" &&
selectedBlockData.structure.linkedArticleStoreId && (
<Box mt={2}>
<Typography>
Это связанная статья:{" "}
{selectedBlockData.structure.linkedArticleStoreId}
</Typography>
{/* TODO: Кнопки "Открепить", "Удалить из списка" */}
</Box>
)}
</Box>
) : (
<Typography color="textSecondary">
Выберите блок для редактирования
</Typography>
)}
</Paper>
</Box> </Box>
<Box <Box
sx={{ sx={{
position: "absolute", position: "absolute",
bottom: 0, bottom: 0,
left: 0,
right: 0, right: 0,
padding: 2, padding: 2,
backgroundColor: "background.paper", backgroundColor: "background.paper", // Ensure button is visible
borderTop: "1px solid", width: "100%", // Cover the full width to make it a sticky footer
borderColor: "divider",
zIndex: 10,
display: "flex", display: "flex",
justifyContent: "flex-end", justifyContent: "flex-end",
}} }}
> >
<Button <Button variant="contained" color="success" onClick={handleSave}>
variant="contained"
color="success"
onClick={handleSave}
size="large"
>
Сохранить изменения Сохранить изменения
</Button> </Button>
</Box> </Box>
</Box> </Box>
{/* <SelectArticleModal open={isSelectModalOpen} ... /> */}
<SelectArticleModal
open={isSelectModalOpen}
onClose={handleCloseSelectModal}
onSelectArticle={handleSelectArticle}
linkedArticleIds={linkedArticleIds}
/>
</TabPanel> </TabPanel>
); );
} }

View File

@ -8,7 +8,7 @@ import Paper from "@mui/material/Paper";
import { authInstance, cityStore, languageStore, sightsStore } from "@shared"; import { authInstance, cityStore, languageStore, sightsStore } from "@shared";
import { useEffect } from "react"; import { useEffect } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { Button, Checkbox } from "@mui/material"; import { Button } from "@mui/material";
import { LanguageSwitcher } from "@widgets"; import { LanguageSwitcher } from "@widgets";
import { Pencil, Trash2 } from "lucide-react"; import { Pencil, Trash2 } from "lucide-react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";

View File

@ -1,4 +1,4 @@
import { articlesStore } from "@shared"; import { articlesStore, languageStore } from "@shared";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { import {
@ -44,7 +44,7 @@ export const SelectArticleModal = observer(
useEffect(() => { useEffect(() => {
if (hoveredArticleId) { if (hoveredArticleId) {
hoverTimerRef.current = setTimeout(() => { hoverTimerRef.current = setTimeout(() => {
getArticle(hoveredArticleId); getArticle(Number(hoveredArticleId));
}, 200); }, 200);
} }
@ -66,7 +66,8 @@ export const SelectArticleModal = observer(
} }
}; };
const filteredArticles = articles const filteredArticles = articles[languageStore.language]
// @ts-ignore
.filter((article) => !linkedArticleIds.includes(article.id)) .filter((article) => !linkedArticleIds.includes(article.id))
.filter((article) => .filter((article) =>
article.service_name.toLowerCase().includes(searchQuery.toLowerCase()) article.service_name.toLowerCase().includes(searchQuery.toLowerCase())
@ -96,11 +97,12 @@ export const SelectArticleModal = observer(
}} }}
/> />
<List sx={{ flexGrow: 1, overflowY: "auto" }}> <List sx={{ flexGrow: 1, overflowY: "auto" }}>
{/* @ts-ignore */}
{filteredArticles.map((article) => ( {filteredArticles.map((article) => (
<ListItemButton <ListItemButton
key={article.id} key={article.id}
onClick={() => onSelectArticle(article.id)} onClick={() => onSelectArticle(article.id.toString())}
onMouseEnter={() => handleArticleHover(article.id)} onMouseEnter={() => handleArticleHover(article.id.toString())}
onMouseLeave={handleArticleLeave} onMouseLeave={handleArticleLeave}
sx={{ sx={{
borderRadius: 1, borderRadius: 1,

View File

@ -25,6 +25,5 @@
"@app": ["src/app"] "@app": ["src/app"]
} }
}, },
"include": ["src"], "include": ["src"]
"references": [{ "path": "./tsconfig.node.json" }]
} }

1
tsconfig.tsbuildinfo Normal file
View File

@ -0,0 +1 @@
{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/app/index.tsx","./src/app/router/index.tsx","./src/entities/index.ts","./src/entities/navigation/index.ts","./src/entities/navigation/model/index.ts","./src/entities/navigation/ui/index.tsx","./src/features/index.ts","./src/features/navigation/index.ts","./src/features/navigation/ui/index.tsx","./src/pages/index.ts","./src/pages/createsightpage/index.tsx","./src/pages/devicespage/index.tsx","./src/pages/editsightpage/index.tsx","./src/pages/loginpage/index.tsx","./src/pages/mainpage/index.tsx","./src/pages/sightpage/index.tsx","./src/shared/index.tsx","./src/shared/api/index.tsx","./src/shared/config/constants.tsx","./src/shared/config/index.ts","./src/shared/const/index.ts","./src/shared/lib/index.ts","./src/shared/lib/decodejwt/index.ts","./src/shared/lib/mui/theme.ts","./src/shared/modals/index.ts","./src/shared/modals/previewmediadialog/index.tsx","./src/shared/modals/selectarticledialog/index.tsx","./src/shared/modals/selectmediadialog/index.tsx","./src/shared/store/index.ts","./src/shared/store/articlesstore/index.tsx","./src/shared/store/authstore/index.tsx","./src/shared/store/citystore/index.tsx","./src/shared/store/devicesstore/index.tsx","./src/shared/store/editsightstore/index.tsx","./src/shared/store/languagestore/index.tsx","./src/shared/store/mediastore/index.tsx","./src/shared/store/sightsstore/index.tsx","./src/shared/store/snapshotstore/index.ts","./src/shared/store/vehiclestore/index.ts","./src/shared/ui/index.ts","./src/shared/ui/backbutton/index.tsx","./src/shared/ui/coordinatesinput/index.tsx","./src/shared/ui/input/index.tsx","./src/shared/ui/modal/index.tsx","./src/shared/ui/tabpanel/index.tsx","./src/widgets/index.ts","./src/widgets/devicestable/index.tsx","./src/widgets/languageswitcher/index.tsx","./src/widgets/layout/index.tsx","./src/widgets/layout/ui/appbar.tsx","./src/widgets/layout/ui/drawer.tsx","./src/widgets/layout/ui/drawerheader.tsx","./src/widgets/mediaviewer/threeview.tsx","./src/widgets/mediaviewer/index.tsx","./src/widgets/reactmarkdown/index.tsx","./src/widgets/reactmarkdowneditor/index.tsx","./src/widgets/sightedit/index.tsx","./src/widgets/sightheader/index.ts","./src/widgets/sightheader/ui/index.tsx","./src/widgets/sighttabs/index.ts","./src/widgets/sighttabs/informationtab/index.tsx","./src/widgets/sighttabs/leftwidgettab/index.tsx","./src/widgets/sighttabs/rightwidgettab/index.tsx","./src/widgets/sightstable/index.tsx","./src/widgets/modals/index.ts","./src/widgets/modals/selectarticledialog/index.tsx"],"errors":true,"version":"5.8.3"}