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

View File

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

View File

@ -3,7 +3,12 @@ import { InformationTab, RightWidgetTab } from "@widgets";
import { LeftWidgetTab } from "@widgets";
import { useEffect, useState } from "react";
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";
function a11yProps(index: number) {
@ -15,10 +20,11 @@ function a11yProps(index: number) {
export const EditSightPage = observer(() => {
const [value, setValue] = useState(0);
const { sight, getSight } = sightsStore;
const { articles, getArticles } = articlesStore;
const { getSightInfo } = editSightStore;
const { getArticles } = articlesStore;
const { language } = languageStore;
const { id } = useParams();
const { getCities } = cityStore;
const handleChange = (_: React.SyntheticEvent, newValue: number) => {
setValue(newValue);
@ -27,55 +33,53 @@ export const EditSightPage = observer(() => {
useEffect(() => {
const fetchData = async () => {
if (id) {
await getSight(Number(id));
await getSightInfo(+id, language);
await getArticles(language);
await getCities();
}
};
fetchData();
}, [id, language]);
return (
articles &&
sight && (
<Box
sx={{
width: "100%",
display: "flex",
flexDirection: "column",
minHeight: "100vh",
}}
>
<Box
sx={{
width: "100%",
borderBottom: 1,
borderColor: "divider",
display: "flex",
flexDirection: "column",
minHeight: "100vh",
justifyContent: "center",
}}
>
<Box
<Tabs
value={value}
onChange={handleChange}
aria-label="sight tabs"
sx={{
borderBottom: 1,
borderColor: "divider",
display: "flex",
justifyContent: "center",
width: "100%",
"& .MuiTabs-flexContainer": {
justifyContent: "center",
},
}}
>
<Tabs
value={value}
onChange={handleChange}
aria-label="sight tabs"
sx={{
width: "100%",
"& .MuiTabs-flexContainer": {
justifyContent: "center",
},
}}
>
<Tab sx={{ flex: 1 }} label="Общая информация" {...a11yProps(0)} />
<Tab sx={{ flex: 1 }} label="Левый виджет" {...a11yProps(1)} />
<Tab sx={{ flex: 1 }} label="Правый виджет" {...a11yProps(2)} />
</Tabs>
</Box>
<div className="flex-1">
<InformationTab value={value} index={0} />
<LeftWidgetTab value={value} index={1} />
<RightWidgetTab value={value} index={2} />
</div>
<Tab sx={{ flex: 1 }} label="Общая информация" {...a11yProps(0)} />
<Tab sx={{ flex: 1 }} label="Левый виджет" {...a11yProps(1)} />
<Tab sx={{ flex: 1 }} label="Правый виджет" {...a11yProps(2)} />
</Tabs>
</Box>
)
<div className="flex-1">
<InformationTab value={value} index={0} />
<LeftWidgetTab value={value} index={1} />
<RightWidgetTab value={value} index={2} />
</div>
</Box>
);
});

View File

@ -1,16 +1,28 @@
import { languageStore, Language } from "@shared";
import axios from "axios";
import axios, { AxiosError, InternalAxiosRequestConfig } from "axios";
const authInstance = axios.create({
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["X-Language"] = languageStore.language ?? "ru";
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 instance = axios.create({
baseURL: "https://wn.krbl.ru",

View File

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

View File

@ -1,13 +1,6 @@
import {
articlesStore,
authStore,
Language,
mediaStore,
MEDIA_TYPE_LABELS,
API_URL,
} from "@shared";
import { mediaStore, MEDIA_TYPE_LABELS } from "@shared";
import { observer } from "mobx-react-lite";
import { useEffect, useRef, useState, useCallback } from "react";
import { useEffect, useState } from "react";
import {
Dialog,
DialogTitle,
@ -17,13 +10,12 @@ import {
TextField,
Paper,
Box,
Typography,
CircularProgress,
Alert,
Snackbar,
} from "@mui/material";
import { Download, Save } from "lucide-react";
import { ReactMarkdownComponent, MediaViewer } from "@widgets";
import { MediaViewer } from "@widgets";
import { authInstance } from "@shared";
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 { useEffect, useState } from "react";
import {
@ -73,7 +73,10 @@ export const SelectArticleModal = observer(
setIsLoading(true);
try {
await Promise.all([getArticle(articleId), getArticleMedia(articleId)]);
await Promise.all([
getArticle(Number(articleId)),
getArticleMedia(Number(articleId)),
]);
} catch (error) {
console.error("Failed to fetch article data:", error);
// Reset article data on error
@ -83,9 +86,11 @@ export const SelectArticleModal = observer(
setIsLoading(false);
}
};
// @ts-ignore
const filteredArticles = articles
// @ts-ignore
.filter((article) => !linkedArticleIds.includes(article.id))
// @ts-ignore
.filter((article) =>
article.service_name.toLowerCase().includes(searchQuery.toLowerCase())
);
@ -140,6 +145,7 @@ export const SelectArticleModal = observer(
{searchQuery ? "Статьи не найдены" : "Нет доступных статей"}
</Typography>
) : (
// @ts-ignore
filteredArticles.map((article) => (
<ListItemButton
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 { useEffect, useRef, useState } from "react";
import {
@ -12,12 +12,11 @@ import {
ListItemButton,
ListItemText,
Paper,
Box,
Typography,
InputAdornment,
} from "@mui/material";
import { ImagePlus, Search } from "lucide-react";
import { ReactMarkdownComponent, MediaViewer } from "@widgets";
import { Search } from "lucide-react";
import { MediaViewer } from "@widgets";
interface SelectMediaDialogProps {
open: boolean; // Corrected prop name

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,404 +1,371 @@
// RightWidgetTab.tsx
import { Box, Button, Paper, TextField, Typography } from "@mui/material";
import {
TabPanel,
Box,
Button,
List,
ListItemButton,
ListItemText,
Paper,
Typography,
Menu,
MenuItem,
} from "@mui/material";
import {
articlesStore,
BackButton,
languageStore, // Предполагаем, что он есть в @shared
Language, // Предполагаем, что он есть в @shared
// SelectArticleModal, // Добавим позже
// articlesStore, // Добавим позже
SelectArticleModal,
TabPanel,
} from "@shared";
import { LanguageSwitcher } from "@widgets"; // Предполагаем, что LanguageSwitcher у вас есть
import { SightEdit } from "@widgets";
import { Plus } from "lucide-react";
import { observer } from "mobx-react-lite";
import { useState, useMemo, useEffect } from "react";
import { editSightStore, BlockItem } from "@shared"; // Путь к вашему стору
import { useState } from "react";
// Импортируем сюда же определения BlockItem, если не выносим в types.ts
// export interface BlockItem { id: string; type: 'media' | 'article'; nameForSidebar: string; linkedArticleStoreId?: string; }
// --- Начальные данные для структуры блоков (позже это может загружаться) ---
// ID здесь должны быть уникальными для списка.
const initialBlockStructures: Omit<BlockItem, "nameForSidebar">[] = [
{ id: "preview_media_main", type: "media" },
{ id: "article_1_local", type: "article" }, // Эти статьи будут редактироваться локально
{ id: "article_2_local", type: "article" },
// --- Mock Data (can be moved to a separate file or fetched from an API) ---
const mockRightWidgetBlocks = [
{ id: "preview_media", name: "Превью-медиа", type: "special" },
{ id: "article_1", name: "1. История", type: "article" },
{ id: "article_2", name: "2. Факты", type: "article" },
{
id: "article_3",
name: "3. Блокада (Пример длинного названия)",
type: "article",
},
];
interface RightWidgetTabProps {
value: number;
index: number;
const mockSelectedBlockData = {
id: "article_1",
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(
({ value, index }: RightWidgetTabProps) => {
const { language } = languageStore; // Текущий язык
const { sightInfo } = editSightStore; // Данные достопримечательности
interface ArticleListSidebarProps {
blocks: ArticleBlock[];
selectedBlockId: string | null;
onSelectBlock: (blockId: string) => void;
onCreateNew: () => void;
onSelectExisting: () => void;
}
// 1. Структура блоков: порядок, тип, связи (не сам контент)
// Имена nameForSidebar будут динамически браться из sightInfo или articlesStore
const [blockItemsStructure, setBlockItemsStructure] = useState<
Omit<BlockItem, "nameForSidebar">[]
>(initialBlockStructures);
const ArticleListSidebar = ({
blocks,
selectedBlockId,
onSelectBlock,
onCreateNew,
onSelectExisting,
}: ArticleListSidebarProps) => {
const [menuAnchorEl, setMenuAnchorEl] = useState<null | HTMLElement>(null);
// 2. ID выбранного блока для редактирования
const [selectedBlockId, setSelectedBlockId] = useState<string | null>(
() => {
// По умолчанию выбираем первый блок, если он есть
return initialBlockStructures.length > 0
? initialBlockStructures[0].id
: null;
}
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
setMenuAnchorEl(event.currentTarget);
};
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>
);
}
// 3. Состояние для модального окна выбора существующей статьи (добавим позже)
// const [isSelectModalOpen, setIsSelectModalOpen] = useState(false);
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>
);
};
// --- Производные данные (Derived State) ---
// --- 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 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 handleSelectBlock = (blockId: string) => {
setSelectedBlockId(blockId);
console.log("Selected block:", blockId);
};
const handleCreateNewArticle = () => {
const newBlockId = `article_local_${Date.now()}`;
const newBlockStructure: Omit<BlockItem, "nameForSidebar"> = {
id: newBlockId,
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()})...`,
});
}
});
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 handleHeadingChange = (newHeading: string) => {
if (
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 handleSelectExisting = () => {
setIsSelectModalOpen(true);
};
const handleBodyChange = (newBody: string) => {
if (
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 handleCloseSelectModal = () => {
setIsSelectModalOpen(false);
};
const handleDeleteBlock = (blockIdToDelete: string) => {
setBlockItemsStructure((prev) =>
prev.filter((b) => b.id !== blockIdToDelete)
);
// Удаляем контент из editSightStore для всех языков
["ru", "en", "zh"].forEach((lang) => {
const currentLang = lang as Language;
if (editSightStore.sightInfo[currentLang]) {
editSightStore.sightInfo[currentLang].right =
editSightStore.sightInfo[currentLang].right?.filter(
(r) => r.id !== blockIdToDelete
);
}
});
if (selectedBlockId === blockIdToDelete) {
setSelectedBlockId(
blockItemsStructure.length > 1
? blockItemsStructure.filter((b) => b.id !== blockIdToDelete)[0]?.id
: null
);
const handleSelectArticle = (articleId: string) => {
// @ts-ignore
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 handleSave = () => {
console.log(
"Сохранение Right Widget:",
JSON.stringify(editSightStore.sightInfo, null, 2)
);
// Здесь будет логика отправки editSightStore.sightInfo на сервер
alert("Данные для сохранения (см. консоль)");
console.log("Saving right widget...");
// Implement save logic here, e.g., send data to an API
};
// --- Инициализация контента в сторе для initialBlockStructures (если его там нет) ---
useEffect(() => {
initialBlockStructures.forEach((struct) => {
if (struct.type === "article" && !struct.linkedArticleStoreId) {
const baseName = `Статья ${struct.id.split("_")[1]}`; // Пример "История" или "Факты"
["ru", "en", "zh"].forEach((lang) => {
const currentLang = lang as Language;
if (
editSightStore.sightInfo[currentLang] &&
!editSightStore.sightInfo[currentLang].right?.find(
(r) => r.id === struct.id
)
) {
editSightStore.sightInfo[currentLang].right?.push({
id: struct.id,
heading: `${baseName} (${currentLang.toUpperCase()})`, // Например: "История (RU)"
body: `Начальное содержимое для ${baseName} на ${currentLang.toUpperCase()}.`,
});
}
});
}
});
}, []); // Запускается один раз при монтировании
// Determine the current block data to pass to the editor pane
const currentBlockToEdit = selectedBlockId
? selectedBlockId === mockSelectedBlockData.id
? mockSelectedBlockData
: {
id: selectedBlockId,
heading:
rightWidgetBlocks.find((b) => b.id === selectedBlockId)?.name ||
"Заголовок...",
body: "Содержимое...",
media: [],
}
: null;
// Get list of already linked article IDs
const linkedArticleIds = rightWidgetBlocks
.filter((block) => block.linkedArticleId)
.map((block) => block.linkedArticleId as string);
return (
<TabPanel value={value} index={index}>
<LanguageSwitcher />
<Box
sx={{
display: "flex",
flexDirection: "column",
height: "100%",
minHeight: "calc(100vh - 200px)",
minHeight: "calc(100vh - 200px)", // Adjust as needed
gap: 2,
paddingBottom: "70px",
paddingBottom: "70px", // Space for the save button
position: "relative",
}}
>
<BackButton />
<Box sx={{ display: "flex", flexGrow: 1, gap: 2.5, minHeight: 0 }}>
{/* Компонент сайдбара списка блоков */}
<Paper
elevation={1}
sx={{
width: 280,
padding: 1.5,
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>
<Box sx={{ display: "flex", flexGrow: 1, gap: 2.5 }}>
<ArticleListSidebar
blocks={rightWidgetBlocks}
selectedBlockId={selectedBlockId}
onSelectBlock={handleSelectBlock}
onCreateNew={handleCreateNew}
onSelectExisting={handleSelectExisting}
/>
{/* Компонент редактора выбранного блока */}
<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>
<ArticleEditorPane articleData={currentBlockToEdit} />
</Box>
<Box
sx={{
position: "absolute",
bottom: 0,
left: 0,
right: 0,
padding: 2,
backgroundColor: "background.paper",
borderTop: "1px solid",
borderColor: "divider",
zIndex: 10,
backgroundColor: "background.paper", // Ensure button is visible
width: "100%", // Cover the full width to make it a sticky footer
display: "flex",
justifyContent: "flex-end",
}}
>
<Button
variant="contained"
color="success"
onClick={handleSave}
size="large"
>
<Button variant="contained" color="success" onClick={handleSave}>
Сохранить изменения
</Button>
</Box>
</Box>
{/* <SelectArticleModal open={isSelectModalOpen} ... /> */}
<SelectArticleModal
open={isSelectModalOpen}
onClose={handleCloseSelectModal}
onSelectArticle={handleSelectArticle}
linkedArticleIds={linkedArticleIds}
/>
</TabPanel>
);
}

View File

@ -8,7 +8,7 @@ import Paper from "@mui/material/Paper";
import { authInstance, cityStore, languageStore, sightsStore } from "@shared";
import { useEffect } from "react";
import { observer } from "mobx-react-lite";
import { Button, Checkbox } from "@mui/material";
import { Button } from "@mui/material";
import { LanguageSwitcher } from "@widgets";
import { Pencil, Trash2 } from "lucide-react";
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 { useEffect, useRef, useState } from "react";
import {
@ -44,7 +44,7 @@ export const SelectArticleModal = observer(
useEffect(() => {
if (hoveredArticleId) {
hoverTimerRef.current = setTimeout(() => {
getArticle(hoveredArticleId);
getArticle(Number(hoveredArticleId));
}, 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) =>
article.service_name.toLowerCase().includes(searchQuery.toLowerCase())
@ -96,11 +97,12 @@ export const SelectArticleModal = observer(
}}
/>
<List sx={{ flexGrow: 1, overflowY: "auto" }}>
{/* @ts-ignore */}
{filteredArticles.map((article) => (
<ListItemButton
key={article.id}
onClick={() => onSelectArticle(article.id)}
onMouseEnter={() => handleArticleHover(article.id)}
onClick={() => onSelectArticle(article.id.toString())}
onMouseEnter={() => handleArticleHover(article.id.toString())}
onMouseLeave={handleArticleLeave}
sx={{
borderRadius: 1,

View File

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

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"}