feat: Sight Page update

This commit is contained in:
Илья Куприец 2025-06-01 23:18:21 +03:00
parent 87386c6a73
commit a8777a974a
26 changed files with 3460 additions and 727 deletions

View File

@ -25,6 +25,7 @@
"path": "^0.12.7",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-dropzone": "^14.3.8",
"react-markdown": "^10.1.0",
"react-photo-sphere-viewer": "^6.2.3",
"react-router-dom": "^7.6.1",

View File

@ -6,8 +6,9 @@ import {
MainPage,
SightPage,
} from "@pages";
import { authStore, editSightStore, sightsStore } from "@shared";
import { authStore, createSightStore, editSightStore } from "@shared";
import { Layout } from "@widgets";
import { runInAction } from "mobx";
import { useEffect } from "react";
import { Navigate, Outlet, Route, Routes, useLocation } from "react-router-dom";
@ -34,10 +35,15 @@ const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
export const Router = () => {
const pathname = useLocation();
useEffect(() => {
editSightStore.clearSightInfo();
sightsStore.clearCreateSight();
createSightStore.clearCreateSight();
runInAction(() => {
editSightStore.hasLoadedCommon = false;
});
}, [pathname]);
return (
<Routes>
<Route

View File

@ -1,7 +1,6 @@
import { Box, Tab, Tabs } from "@mui/material";
import { articlesStore, cityStore, languageStore } from "@shared";
import { InformationTab, RightWidgetTab } from "@widgets";
import { LeftWidgetTab } from "@widgets";
import { CreateInformationTab, CreateLeftTab, CreateRightTab } from "@widgets";
import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite";
@ -21,8 +20,11 @@ export const CreateSightPage = observer(() => {
};
useEffect(() => {
getCities();
getArticles(languageStore.language);
const fetchData = async () => {
await getCities();
await getArticles(languageStore.language);
};
fetchData();
}, []);
return (
@ -60,9 +62,9 @@ export const CreateSightPage = observer(() => {
</Box>
<div className="flex-1">
<InformationTab value={value} index={0} />
<LeftWidgetTab value={value} index={1} />
<RightWidgetTab value={value} index={2} />
<CreateInformationTab value={value} index={0} />
<CreateLeftTab value={value} index={1} />
<CreateRightTab value={value} index={2} />
</div>
</Box>
);

View File

@ -20,7 +20,7 @@ function a11yProps(index: number) {
export const EditSightPage = observer(() => {
const [value, setValue] = useState(0);
const { getSightInfo } = editSightStore;
const { sight, getSightInfo } = editSightStore;
const { getArticles } = articlesStore;
const { language } = languageStore;
const { id } = useParams();
@ -75,11 +75,13 @@ export const EditSightPage = observer(() => {
</Tabs>
</Box>
<div className="flex-1">
<InformationTab value={value} index={0} />
<LeftWidgetTab value={value} index={1} />
<RightWidgetTab value={value} index={2} />
</div>
{sight.common.id !== 0 && (
<div className="flex-1">
<InformationTab value={value} index={0} />
<LeftWidgetTab value={value} index={1} />
<RightWidgetTab value={value} index={2} />
</div>
)}
</Box>
);
});

View File

@ -1,4 +1,4 @@
import { articlesStore } from "@shared";
import { articlesStore, authInstance, languageStore } from "@shared";
import { observer } from "mobx-react-lite";
import { useEffect, useState } from "react";
import {
@ -22,8 +22,13 @@ import { ReactMarkdownComponent } from "@widgets";
interface SelectArticleModalProps {
open: boolean;
onClose: () => void;
onSelectArticle: (articleId: string) => void;
linkedArticleIds?: string[]; // Add optional prop for linked articles
onSelectArticle: (
articleId: number,
heading: string,
body: string,
media: { id: string; media_type: number; filename: string }[]
) => void;
linkedArticleIds?: number[]; // Add optional prop for linked articles
}
export const SelectArticleModal = observer(
@ -35,7 +40,7 @@ export const SelectArticleModal = observer(
}: SelectArticleModalProps) => {
const { articles, getArticle, getArticleMedia } = articlesStore;
const [searchQuery, setSearchQuery] = useState("");
const [selectedArticleId, setSelectedArticleId] = useState<string | null>(
const [selectedArticleId, setSelectedArticleId] = useState<number | null>(
null
);
const [isLoading, setIsLoading] = useState(false);
@ -50,12 +55,21 @@ export const SelectArticleModal = observer(
}, [open]);
useEffect(() => {
const handleKeyPress = (event: KeyboardEvent) => {
const handleKeyPress = async (event: KeyboardEvent) => {
if (event.key.toLowerCase() === "enter") {
event.preventDefault();
if (selectedArticleId) {
onSelectArticle(selectedArticleId);
const media = await authInstance.get(
`/article/${selectedArticleId}/media`
);
onSelectArticle(
selectedArticleId,
articlesStore.articleData?.heading || "",
articlesStore.articleData?.body || "",
media.data || []
);
onClose();
setSelectedArticleId(null);
}
}
};
@ -66,9 +80,7 @@ export const SelectArticleModal = observer(
};
}, [selectedArticleId, onSelectArticle, onClose]);
const handleArticleClick = async (articleId: string) => {
if (selectedArticleId === articleId) return;
const handleArticleClick = async (articleId: number) => {
setSelectedArticleId(articleId);
setIsLoading(true);
@ -86,14 +98,13 @@ 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())
);
const filteredArticles = articles[languageStore.language].filter(
(article) => !linkedArticleIds.includes(article.id)
);
// .filter((article) =>
// article.service_name.toLowerCase().includes(searchQuery.toLowerCase())
// );
const token = localStorage.getItem("token");
return (
@ -150,7 +161,17 @@ export const SelectArticleModal = observer(
<ListItemButton
key={article.id}
onClick={() => handleArticleClick(article.id)}
onDoubleClick={() => onSelectArticle(article.id)}
onDoubleClick={async () => {
const media = await authInstance.get(
`/article/${article.id}/media`
);
onSelectArticle(
article.id,
article.heading,
article.body,
media.data
);
}}
selected={selectedArticleId === article.id}
disabled={isLoading}
sx={{
@ -288,9 +309,22 @@ export const SelectArticleModal = observer(
<Button onClick={onClose}>Отмена</Button>
<Button
variant="contained"
onClick={() =>
selectedArticleId && onSelectArticle(selectedArticleId)
}
onClick={async () => {
if (selectedArticleId) {
const media = await authInstance.get(
`/article/${selectedArticleId}/media`
);
onSelectArticle(
selectedArticleId,
articlesStore.articleData?.heading || "",
articlesStore.articleData?.body || "",
media.data
);
onClose();
setSelectedArticleId(null);
}
}}
disabled={!selectedArticleId || isLoading}
>
Выбрать

View File

@ -1,6 +1,6 @@
import { mediaStore } from "@shared";
import { observer } from "mobx-react-lite";
import { useEffect, useRef, useState } from "react";
import { useEffect, useState } from "react";
import {
Dialog,
DialogTitle,
@ -21,7 +21,12 @@ import { MediaViewer } from "@widgets";
interface SelectMediaDialogProps {
open: boolean; // Corrected prop name
onClose: () => void;
onSelectMedia: (mediaId: string) => void; // Renamed from onSelectArticle
onSelectMedia: (media: {
id: string;
filename: string;
media_name?: string;
media_type: number;
}) => void; // Renamed from onSelectArticle
linkedMediaIds?: string[]; // Renamed from linkedArticleIds, assuming it refers to media already in use
}
@ -32,15 +37,14 @@ export const SelectMediaDialog = observer(
onSelectMedia, // Renamed prop
linkedMediaIds = [], // Default to empty array if not provided, renamed
}: SelectMediaDialogProps) => {
const { media, getMedia } = mediaStore; // Confirmed: using mediaStore for media
const { media, getMedia } = mediaStore;
const [searchQuery, setSearchQuery] = useState("");
const [hoveredMediaId, setHoveredMediaId] = useState<string | null>(null);
const hoverTimerRef = useRef<NodeJS.Timeout | null>(null);
// Fetch media on component mount
useEffect(() => {
getMedia();
}, [getMedia]); // getMedia should be a dependency to avoid lint warnings if it's not stable
}, [getMedia]);
// Keyboard event listener for "Enter" key to select hovered media
useEffect(() => {
@ -49,7 +53,10 @@ export const SelectMediaDialog = observer(
event.preventDefault(); // Prevent browser default action (e.g., form submission)
if (hoveredMediaId) {
onSelectMedia(hoveredMediaId); // Call onSelectMedia
const mediaItem = media.find((m) => m.id === hoveredMediaId);
if (mediaItem) {
onSelectMedia(mediaItem);
}
onClose();
}
}
@ -61,26 +68,6 @@ export const SelectMediaDialog = observer(
};
}, [hoveredMediaId, onSelectMedia, onClose]); // Dependencies for keyboard listener
// Effect for handling hover timeout (if you want to clear the preview after a delay)
// Based on the original code, it seemed like you wanted a delay for showing,
// but typically for a preview, it's immediate on hover and cleared on mouse leave.
// I've removed the 5-second timeout for setting the ID as it's counter-intuitive for a live preview.
// If you intend for the preview to disappear after a short while *after* the mouse leaves,
// you would implement a mouseleave timer. For now, it will clear on mouseleave.
const handleMouseEnter = (mediaId: string) => {
if (hoverTimerRef.current) {
clearTimeout(hoverTimerRef.current);
}
setHoveredMediaId(mediaId);
};
const handleMouseLeave = () => {
// You can add a small delay here if you want the preview to linger for a moment
// before disappearing, e.g., setTimeout(() => setHoveredMediaId(null), 200);
setHoveredMediaId(null);
};
const filteredMedia = media
.filter((mediaItem) => !linkedMediaIds.includes(mediaItem.id)) // Use mediaItem to avoid name collision
.filter((mediaItem) =>
@ -125,9 +112,11 @@ export const SelectMediaDialog = observer(
) => (
<ListItemButton
key={mediaItem.id}
onClick={() => onSelectMedia(mediaItem.id)} // Call onSelectMedia
onMouseEnter={() => handleMouseEnter(mediaItem.id)}
onMouseLeave={handleMouseLeave}
onClick={() => setHoveredMediaId(mediaItem.id)} // Call onSelectMedia
onDoubleClick={() => {
onSelectMedia(mediaItem);
onClose();
}}
sx={{
borderRadius: 1,
mb: 0.5,

View File

@ -0,0 +1,259 @@
import { MEDIA_TYPE_LABELS, editSightStore } from "@shared";
import { observer } from "mobx-react-lite";
import { useEffect, useState } from "react";
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
TextField,
Paper,
Box,
CircularProgress,
Alert,
Snackbar,
Select,
MenuItem,
FormControl,
InputLabel,
} from "@mui/material";
import { Save } from "lucide-react";
import { ModelViewer3D } from "@widgets";
interface UploadMediaDialogProps {
open: boolean;
onClose: () => void;
afterUpload: (media: {
id: string;
filename: string;
media_name?: string;
media_type: number;
}) => void;
}
export const UploadMediaDialog = observer(
({ open, onClose, afterUpload }: UploadMediaDialogProps) => {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const [mediaName, setMediaName] = useState("");
const [mediaFilename, setMediaFilename] = useState("");
const [mediaType, setMediaType] = useState(0);
const [mediaFile, setMediaFile] = useState<File | null>(null);
const { fileToUpload, uploadMedia } = editSightStore;
const [mediaUrl, setMediaUrl] = useState<string | null>(null);
const [availableMediaTypes, setAvailableMediaTypes] = useState<number[]>(
[]
);
useEffect(() => {
if (fileToUpload) {
setMediaFile(fileToUpload);
setMediaFilename(fileToUpload.name);
// Try to determine media type from file extension
const extension = fileToUpload.name.split(".").pop()?.toLowerCase();
if (extension) {
if (["glb", "gltf"].includes(extension)) {
setAvailableMediaTypes([6]);
setMediaType(6);
}
if (["jpg", "jpeg", "png", "gif"].includes(extension)) {
// Для изображений доступны все типы кроме видео
setAvailableMediaTypes([1, 3, 4, 5]); // Фото, Иконка, Водяной знак, Панорама, 3Д-модель
setMediaType(1); // По умолчанию Фото
} else if (["mp4", "webm", "mov"].includes(extension)) {
// Для видео только тип Видео
setAvailableMediaTypes([2]);
setMediaType(2);
}
}
}
}, [fileToUpload]);
useEffect(() => {
if (mediaFile) {
setMediaUrl(URL.createObjectURL(mediaFile as Blob));
}
}, [mediaFile]);
// const fileFormat = useEffect(() => {
// const handleKeyPress = (event: KeyboardEvent) => {
// if (event.key.toLowerCase() === "enter" && !event.ctrlKey) {
// event.preventDefault();
// onClose();
// }
// };
// window.addEventListener("keydown", handleKeyPress);
// return () => window.removeEventListener("keydown", handleKeyPress);
// }, [onClose]);
const handleSave = async () => {
if (!mediaFile) return;
setIsLoading(true);
setError(null);
try {
const media = await uploadMedia(
mediaFilename,
mediaType,
mediaFile,
mediaName
);
if (media) {
await afterUpload(media);
}
setSuccess(true);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to save media");
} finally {
setIsLoading(false);
}
};
const handleClose = () => {
setError(null);
setSuccess(false);
onClose();
};
return (
<>
<Dialog open={open} onClose={handleClose} maxWidth="md" fullWidth>
<DialogTitle>Просмотр медиа</DialogTitle>
<DialogContent
className="flex gap-4"
dividers
sx={{
height: "600px",
display: "flex",
flexDirection: "column",
gap: 2,
pt: 2,
}}
>
<Box className="flex flex-col gap-4">
<Box className="flex gap-2">
<TextField
fullWidth
value={mediaName}
onChange={(e) => setMediaName(e.target.value)}
label="Название медиа"
disabled={isLoading}
/>
<TextField
fullWidth
value={mediaFilename}
onChange={(e) => setMediaFilename(e.target.value)}
label="Название файла"
disabled={isLoading}
/>
</Box>
<FormControl fullWidth sx={{ width: "50%" }}>
<InputLabel>Тип медиа</InputLabel>
<Select
value={mediaType}
label="Тип медиа"
onChange={(e) => setMediaType(Number(e.target.value))}
disabled={isLoading}
>
{availableMediaTypes.map((type) => (
<MenuItem key={type} value={type}>
{
MEDIA_TYPE_LABELS[
type as keyof typeof MEDIA_TYPE_LABELS
]
}
</MenuItem>
))}
</Select>
</FormControl>
<Box className="flex gap-4 h-full">
<Paper
elevation={2}
sx={{
flex: 1,
p: 2,
display: "flex",
alignItems: "center",
justifyContent: "center",
minHeight: 400,
}}
>
{/* <MediaViewer
media={{
id: "",
media_type: mediaType,
filename: mediaFilename,
}}
/> */}
{mediaType === 6 && mediaUrl && (
<ModelViewer3D fileUrl={mediaUrl} height="100%" />
)}
{mediaType !== 6 && mediaType !== 2 && mediaUrl && (
<img
src={mediaUrl ?? ""}
alt="Uploaded media"
style={{
maxWidth: "100%",
maxHeight: "100%",
objectFit: "contain",
}}
/>
)}
</Paper>
<Box className="flex flex-col gap-2 self-end">
<Button
variant="contained"
color="success"
startIcon={
isLoading ? (
<CircularProgress size={16} />
) : (
<Save size={16} />
)
}
onClick={handleSave}
disabled={isLoading || (!mediaName && !mediaFilename)}
>
Сохранить
</Button>
</Box>
</Box>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={handleClose} disabled={isLoading}>
Отмена
</Button>
</DialogActions>
</Dialog>
<Snackbar
open={!!error}
autoHideDuration={6000}
onClose={() => setError(null)}
>
<Alert severity="error" onClose={() => setError(null)}>
{error}
</Alert>
</Snackbar>
<Snackbar
open={success}
autoHideDuration={3000}
onClose={() => setSuccess(false)}
>
<Alert severity="success" onClose={() => setSuccess(false)}>
Медиа успешно сохранено
</Alert>
</Snackbar>
</>
);
}
);

View File

@ -1,3 +1,4 @@
export * from "./SelectArticleDialog";
export * from "./SelectMediaDialog";
export * from "./PreviewMediaDialog";
export * from "./UploadMediaDialog";

View File

@ -17,6 +17,8 @@ class CityStore {
}
getCities = async () => {
if (this.cities.length !== 0) return;
const response = await authInstance.get("/city");
runInAction(() => {
this.cities = response.data;

View File

@ -0,0 +1,449 @@
// @shared/stores/createSightStore.ts
import {
Language,
authInstance,
languageInstance,
articlesStore,
languageStore,
mediaStore,
} from "@shared";
import { makeAutoObservable } from "mobx";
type SightLanguageInfo = {
name: string;
address: string;
left: {
heading: string;
body: string;
media: {
id: string;
filename: string;
media_name?: string;
media_type: number;
}[];
};
right: { heading: string; body: string }[];
};
type SightCommonInfo = {
id: number;
city_id: number;
city: string;
latitude: number;
longitude: number;
thumbnail: string | null;
watermark_lu: string | null;
watermark_rd: string | null;
left_article: number;
preview_media: string | null;
video_preview: string | null;
};
type SightBaseInfo = SightCommonInfo & {
[key in Language]: SightLanguageInfo;
};
class CreateSightStore {
sight: SightBaseInfo = {
id: 0,
city_id: 0,
city: "",
latitude: 0,
longitude: 0,
thumbnail: null,
watermark_lu: null,
watermark_rd: null,
left_article: 0,
preview_media: null,
video_preview: null,
ru: {
name: "",
address: "",
left: { heading: "", body: "", media: [] },
right: [],
},
en: {
name: "",
address: "",
left: { heading: "", body: "", media: [] },
right: [],
},
zh: {
name: "",
address: "",
left: { heading: "", body: "", media: [] },
right: [],
},
};
uploadMediaOpen = false;
setUploadMediaOpen = (open: boolean) => {
this.uploadMediaOpen = open;
};
fileToUpload: File | null = null;
setFileToUpload = (file: File | null) => {
this.fileToUpload = file;
};
constructor() {
makeAutoObservable(this);
}
createNewRightArticle = () => {
this.sight.ru.right.push({
heading: "Введите русский заголовок",
body: "Введите русский текст",
});
this.sight.en.right.push({
heading: "Enter the English heading",
body: "Enter the English text",
});
this.sight.zh.right.push({
heading: "Введите китайский заголовок",
body: "Введите китайский текст",
});
};
updateLeftInfo = (language: Language, heading: string, body: string) => {
this.sight[language].left.heading = heading;
this.sight[language].left.body = body;
};
clearCreateSight = () => {
this.sight = {
id: 0,
city_id: 0,
city: "",
latitude: 0,
longitude: 0,
thumbnail: null,
watermark_lu: null,
watermark_rd: null,
left_article: 0,
preview_media: null,
video_preview: null,
ru: {
name: "",
address: "",
left: { heading: "", body: "", media: [] },
right: [],
},
en: {
name: "",
address: "",
left: { heading: "", body: "", media: [] },
right: [],
},
zh: {
name: "",
address: "",
left: { heading: "", body: "", media: [] },
right: [],
},
};
};
updateSightInfo = (
content: Partial<SightLanguageInfo | SightCommonInfo>,
language?: Language
) => {
if (language) {
this.sight[language] = {
...this.sight[language],
...content,
};
} else {
this.sight = {
...this.sight,
...content,
};
}
};
unlinkLeftArticle = () => {
this.sight.left_article = 0;
this.sight.ru.left.heading = "";
this.sight.en.left.heading = "";
this.sight.zh.left.heading = "";
this.sight.ru.left.body = "";
this.sight.en.left.body = "";
this.sight.zh.left.body = "";
};
updateLeftArticle = async (articleId: number) => {
this.sight.left_article = articleId;
if (articleId) {
const ruArticleData = await languageInstance("ru").get(
`/article/${articleId}`
);
const enArticleData = await languageInstance("en").get(
`/article/${articleId}`
);
const zhArticleData = await languageInstance("zh").get(
`/article/${articleId}`
);
this.sight.ru.left.heading = ruArticleData.data.heading;
this.sight.en.left.heading = enArticleData.data.heading;
this.sight.zh.left.heading = zhArticleData.data.heading;
this.sight.ru.left.body = ruArticleData.data.body;
this.sight.en.left.body = enArticleData.data.body;
this.sight.zh.left.body = zhArticleData.data.body;
} else {
this.sight.left_article = 0;
this.sight.ru.left.heading = "";
this.sight.en.left.heading = "";
this.sight.zh.left.heading = "";
this.sight.ru.left.body = "";
this.sight.en.left.body = "";
this.sight.zh.left.body = "";
}
};
deleteLeftArticle = async (articleId: number) => {
await authInstance.delete(`/article/${articleId}`);
articlesStore.getArticles(languageStore.language);
this.sight.left_article = 0;
this.sight.ru.left.heading = "";
this.sight.en.left.heading = "";
this.sight.zh.left.heading = "";
this.sight.ru.left.body = "";
};
createLeftArticle = async () => {
const response = await languageInstance("ru").post("/article", {
heading: "Новая статья",
body: "Заполните статью контентом",
});
this.sight.left_article = response.data.id;
this.sight.ru.left.heading = "Новая статья ";
this.sight.en.left.heading = "";
this.sight.zh.left.heading = "";
this.sight.ru.left.body = "Заполните статью контентом";
this.sight.en.left.body = "";
this.sight.zh.left.body = "";
};
createSight = async (language: Language) => {
const rightArticles: number[] = [];
if (this.sight.left_article !== 0) {
if (this.sight.left_article == 10000000) {
const response = await languageInstance("ru").post("/article", {
heading: this.sight.ru.left.heading,
body: this.sight.ru.left.body,
});
const { id } = response.data;
await languageInstance("en").patch(`/article/${id}`, {
heading: this.sight.en.left.heading,
body: this.sight.en.left.body,
});
await languageInstance("zh").patch(`/article/${id}`, {
heading: this.sight.zh.left.heading,
body: this.sight.zh.left.body,
});
this.sight.left_article = id;
} else {
await languageInstance("ru").patch(
`/article/${this.sight.left_article}`,
{
heading: this.sight.ru.left.heading,
body: this.sight.ru.left.body,
}
);
await languageInstance("en").patch(
`/article/${this.sight.left_article}`,
{
heading: this.sight.en.left.heading,
body: this.sight.en.left.body,
}
);
await languageInstance("zh").patch(
`/article/${this.sight.left_article}`,
{
heading: this.sight.zh.left.heading,
body: this.sight.zh.left.body,
}
);
}
}
this.sight[language].right.map(async (article, index) => {
try {
const response = await languageInstance(language).post("/article", {
heading: article.heading,
body: article.body,
});
const { id } = response.data;
const anotherLanguages = ["en", "zh", "ru"].filter(
(lang) => lang !== language
);
await languageInstance(anotherLanguages[0] as Language).patch(
`/article/${id}`,
{
heading:
this.sight[anotherLanguages[0] as Language].right[index].heading,
body: this.sight[anotherLanguages[0] as Language].right[index].body,
}
);
await languageInstance(anotherLanguages[1] as Language).patch(
`/article/${id}`,
{
heading:
this.sight[anotherLanguages[1] as Language].right[index].heading,
body: this.sight[anotherLanguages[1] as Language].right[index].body,
}
);
rightArticles.push(id);
} catch (error) {
console.log(error);
}
});
const response = await languageInstance(language).post("/sight", {
city_id: this.sight.city_id,
city: this.sight.city,
latitude: this.sight.latitude,
longitude: this.sight.longitude,
name: this.sight[language].name,
address: this.sight[language].address,
thumbnail: this.sight.thumbnail ?? null,
watermark_lu: this.sight.watermark_lu,
watermark_rd: this.sight.watermark_rd,
left_article: this.sight.left_article,
preview_media: this.sight.preview_media,
video_preview: this.sight.video_preview,
});
const { id } = response.data;
const anotherLanguages = ["en", "zh", "ru"].filter(
(lang) => lang !== language
);
await languageInstance(anotherLanguages[0] as Language).patch(
`/sight/${id}`,
{
city_id: this.sight.city_id,
city: this.sight.city,
latitude: this.sight.latitude,
longitude: this.sight.longitude,
name: this.sight[anotherLanguages[0] as Language as Language].name,
address:
this.sight[anotherLanguages[0] as Language as Language].address,
thumbnail: this.sight.thumbnail ?? null,
watermark_lu: this.sight.watermark_lu,
watermark_rd: this.sight.watermark_rd,
left_article: this.sight.left_article,
preview_media: this.sight.preview_media,
video_preview: this.sight.video_preview,
}
);
await languageInstance(anotherLanguages[1] as Language).patch(
`/sight/${id}`,
{
city_id: this.sight.city_id,
city: this.sight.city,
latitude: this.sight.latitude,
longitude: this.sight.longitude,
name: this.sight[anotherLanguages[1] as Language].name,
address: this.sight[anotherLanguages[1] as Language].address,
thumbnail: this.sight.thumbnail ?? null,
watermark_lu: this.sight.watermark_lu,
watermark_rd: this.sight.watermark_rd,
left_article: this.sight.left_article,
preview_media: this.sight.preview_media,
video_preview: this.sight.video_preview,
}
);
rightArticles.map(async (article, index) => {
await authInstance.post(`/sight/${id}/article`, {
article_id: article,
page_num: index + 1,
});
});
console.log("created");
};
updateRightArticleInfo = (
index: number,
language: Language,
heading: string,
body: string
) => {
this.sight[language].right[index].heading = heading;
this.sight[language].right[index].body = body;
};
uploadMedia = async (
filename: string,
type: number,
file: File,
media_name?: string
) => {
const formData = new FormData();
formData.append("file", file);
formData.append("filename", filename);
if (media_name) {
formData.append("media_name", media_name);
}
formData.append("type", type.toString());
try {
const response = await authInstance.post(`/media`, formData);
this.fileToUpload = null;
this.uploadMediaOpen = false;
mediaStore.getMedia();
return {
id: response.data.id,
filename: filename,
media_name: media_name,
media_type: type,
};
} catch (error) {
console.log(error);
throw error;
}
};
createLinkWithArticle = async (media: {
id: string;
filename: string;
media_name?: string;
media_type: number;
}) => {
await authInstance.post(`/article/${this.sight.left_article}/media`, {
media_id: media.id,
media_order: 1,
});
this.sight.ru.left.media.unshift({
id: media.id,
media_type: media.media_type,
filename: media.filename,
});
this.sight.en.left.media.unshift({
id: media.id,
media_type: media.media_type,
filename: media.filename,
});
this.sight.zh.left.media.unshift({
id: media.id,
media_type: media.media_type,
filename: media.filename,
});
};
}
export const createSightStore = new CreateSightStore();

View File

@ -1,26 +1,31 @@
// @shared/stores/editSightStore.ts
import { authInstance, Language } from "@shared";
import { authInstance, Language, languageInstance, mediaStore } from "@shared";
import { makeAutoObservable, runInAction } from "mobx";
export type SightLanguageInfo = {
id: number;
name: string;
address: string;
left: { heading: string; body: string };
left: {
heading: string;
body: string;
media: { id: string; media_type: number; filename: string }[];
};
right: { heading: string; body: string }[];
};
export type SightCommonInfo = {
id: number;
city_id: number;
city: string;
latitude: number;
longitude: number;
thumbnail: string;
watermark_lu: string;
watermark_rd: string;
thumbnail: string | null;
watermark_lu: string | null;
watermark_rd: string | null;
left_article: number;
preview_media: string;
video_preview: string;
preview_media: string | null;
video_preview: string | null;
};
export type SightBaseInfo = {
@ -31,36 +36,37 @@ export type SightBaseInfo = {
class EditSightStore {
sight: SightBaseInfo = {
common: {
id: 0,
city_id: 0,
city: "",
latitude: 0,
longitude: 0,
thumbnail: "",
watermark_lu: "",
watermark_rd: "",
thumbnail: null,
watermark_lu: null,
watermark_rd: null,
left_article: 0,
preview_media: "",
video_preview: "",
preview_media: null,
video_preview: null,
},
ru: {
id: 0,
name: "",
address: "",
left: { heading: "", body: "" },
left: { heading: "", body: "", media: [] },
right: [],
},
en: {
id: 0,
name: "",
address: "",
left: { heading: "", body: "" },
left: { heading: "", body: "", media: [] },
right: [],
},
zh: {
id: 0,
name: "",
address: "",
left: { heading: "", body: "" },
left: { heading: "", body: "", media: [] },
right: [],
},
};
@ -77,6 +83,9 @@ class EditSightStore {
const response = await authInstance.get(`/sight/${id}`);
const data = response.data;
if (data.left_article != 0) {
await this.getLeftArticle(data.left_article);
}
runInAction(() => {
// Обновляем языковую часть
@ -101,25 +110,62 @@ class EditSightStore {
this.sight[language].left.body = body;
};
getRightArticles = async (id: number) => {
const responseRu = await languageInstance("ru").get(`/sight/${id}/article`);
const responseEn = await languageInstance("en").get(`/sight/${id}/article`);
const responseZh = await languageInstance("zh").get(`/sight/${id}/article`);
const data = {
ru: {
right: responseRu.data,
},
en: {
right: responseEn.data,
},
zh: {
right: responseZh.data,
},
};
runInAction(() => {
this.sight = {
...this.sight,
ru: {
...this.sight.ru,
right: data.ru.right,
},
en: {
...this.sight.en,
right: data.en.right,
},
zh: {
...this.sight.zh,
right: data.zh.right,
},
};
});
};
clearSightInfo = () => {
this.sight = {
common: {
id: 0,
city_id: 0,
city: "",
latitude: 0,
longitude: 0,
thumbnail: "",
watermark_lu: "",
watermark_rd: "",
thumbnail: null,
watermark_lu: null,
watermark_rd: null,
left_article: 0,
preview_media: "",
video_preview: "",
preview_media: null,
video_preview: null,
},
ru: {
id: 0,
name: "",
address: "",
left: { heading: "", body: "" },
left: { heading: "", body: "", media: [] },
right: [],
},
@ -127,7 +173,7 @@ class EditSightStore {
id: 0,
name: "",
address: "",
left: { heading: "", body: "" },
left: { heading: "", body: "", media: [] },
right: [],
},
@ -135,7 +181,7 @@ class EditSightStore {
id: 0,
name: "",
address: "",
left: { heading: "", body: "" },
left: { heading: "", body: "", media: [] },
right: [],
},
};
@ -158,6 +204,244 @@ class EditSightStore {
};
}
};
unlinkLeftArticle = async () => {
this.sight.common.left_article = 0;
this.sight.ru.left.heading = "";
this.sight.en.left.heading = "";
this.sight.zh.left.heading = "";
this.sight.ru.left.body = "";
this.sight.en.left.body = "";
this.sight.zh.left.body = "";
};
updateSight = async () => {
let createdLeftArticleId = this.sight.common.left_article;
if (this.sight.common.left_article == 10000000) {
const response = await languageInstance("ru").post(`/article`, {
heading: this.sight.ru.left.heading,
body: this.sight.ru.left.body,
});
createdLeftArticleId = response.data.id;
await languageInstance("en").patch(`/article/${createdLeftArticleId}`, {
heading: this.sight.en.left.heading,
body: this.sight.en.left.body,
});
await languageInstance("zh").patch(`/article/${createdLeftArticleId}`, {
heading: this.sight.zh.left.heading,
body: this.sight.zh.left.body,
});
this.sight.common.left_article = createdLeftArticleId;
} else if (this.sight.common.left_article != 0) {
await languageInstance("ru").patch(
`/article/${this.sight.common.left_article}`,
{
heading: this.sight.ru.left.heading,
body: this.sight.ru.left.body,
}
);
await languageInstance("en").patch(
`/article/${this.sight.common.left_article}`,
{
heading: this.sight.en.left.heading,
body: this.sight.en.left.body,
}
);
await languageInstance("zh").patch(
`/article/${this.sight.common.left_article}`,
{
heading: this.sight.zh.left.heading,
body: this.sight.zh.left.body,
}
);
}
await languageInstance("ru").patch(`/sight/${this.sight.common.id}`, {
...this.sight.common,
name: this.sight.ru.name,
address: this.sight.ru.address,
left_article: createdLeftArticleId,
});
await languageInstance("en").patch(`/sight/${this.sight.common.id}`, {
...this.sight.common,
name: this.sight.en.name,
address: this.sight.en.address,
left_article: createdLeftArticleId,
});
await languageInstance("zh").patch(`/sight/${this.sight.common.id}`, {
...this.sight.common,
name: this.sight.zh.name,
address: this.sight.zh.address,
left_article: createdLeftArticleId,
});
if (this.sight.common.left_article == 0) {
return;
}
// await languageInstance("ru").patch(
// `/sight/${this.sight.common.left_article}/article`,
// {
// heading: this.sight.ru.left.heading,
// body: this.sight.ru.left.body,
// }
// );
// await languageInstance("en").patch(
// `/sight/${this.sight.common.left_article}/article`,
// {
// heading: this.sight.en.left.heading,
// body: this.sight.en.left.body,
// }
// );
// await languageInstance("zh").patch(
// `/sight/${this.sight.common.left_article}/article`,
// {
// heading: this.sight.zh.left.heading,
// body: this.sight.zh.left.body,
// }
// );
};
getLeftArticle = async (id: number) => {
const response = await languageInstance("ru").get(`/article/${id}`);
const responseEn = await languageInstance("en").get(`/article/${id}`);
const responseZh = await languageInstance("zh").get(`/article/${id}`);
const mediaIds = await authInstance.get(`/article/${id}/media`);
runInAction(() => {
this.sight.ru.left = {
heading: response.data.heading,
body: response.data.body,
media: mediaIds.data,
};
this.sight.en.left = {
heading: responseEn.data.heading,
body: responseEn.data.body,
media: mediaIds.data,
};
this.sight.zh.left = {
heading: responseZh.data.heading,
body: responseZh.data.body,
media: mediaIds.data,
};
});
};
deleteLeftArticle = async (id: number) => {
await authInstance.delete(`/article/${id}`);
this.sight.common.left_article = 0;
this.sight.ru.left.heading = "";
this.sight.en.left.heading = "";
this.sight.zh.left.heading = "";
this.sight.ru.left.body = "";
this.sight.en.left.body = "";
this.sight.zh.left.body = "";
};
createLeftArticle = async () => {
const response = await languageInstance("ru").post(`/article`, {
heading: "",
body: "",
});
this.sight.common.left_article = response.data.id;
this.sight.ru.left.heading = "";
this.sight.en.left.heading = "";
this.sight.zh.left.heading = "";
this.sight.ru.left.body = "";
this.sight.en.left.body = "";
this.sight.zh.left.body = "";
};
deleteMedia = async (article_id: number, media_id: string) => {
await authInstance.delete(`/article/${article_id}/media`, {
data: {
media_id: media_id,
},
});
this.sight.ru.left.media = this.sight.ru.left.media.filter(
(media) => media.id !== media_id
);
this.sight.en.left.media = this.sight.en.left.media.filter(
(media) => media.id !== media_id
);
this.sight.zh.left.media = this.sight.zh.left.media.filter(
(media) => media.id !== media_id
);
};
uploadMediaOpen = false;
setUploadMediaOpen = (open: boolean) => {
this.uploadMediaOpen = open;
};
fileToUpload: File | null = null;
setFileToUpload = (file: File | null) => {
this.fileToUpload = file;
};
uploadMedia = async (
filename: string,
type: number,
file: File,
media_name?: string
) => {
const formData = new FormData();
formData.append("file", file);
formData.append("filename", filename);
if (media_name) {
formData.append("media_name", media_name);
}
formData.append("type", type.toString());
try {
const response = await authInstance.post(`/media`, formData);
this.fileToUpload = null;
this.uploadMediaOpen = false;
mediaStore.getMedia();
return {
id: response.data.id,
filename: filename,
media_name: media_name,
media_type: type,
};
} catch (error) {
console.log(error);
}
};
createLinkWithArticle = async (media: {
id: string;
filename: string;
media_name?: string;
media_type: number;
}) => {
await authInstance.post(
`/article/${this.sight.common.left_article}/media`,
{
media_id: media.id,
media_order: 1,
}
);
this.sight.ru.left.media.unshift({
id: media.id,
media_type: media.media_type,
filename: media.filename,
});
this.sight.en.left.media.unshift({
id: media.id,
media_type: media.media_type,
filename: media.filename,
});
this.sight.zh.left.media.unshift({
id: media.id,
media_type: media.media_type,
filename: media.filename,
});
};
}
export const editSightStore = new EditSightStore();

View File

@ -8,3 +8,4 @@ export * from "./CityStore";
export * from "./ArticlesStore";
export * from "./EditSightStore";
export * from "./MediaStore";
export * from "./CreateSightStore";

View File

@ -0,0 +1,135 @@
import { Box, Button } from "@mui/material";
import { MediaViewer } from "@widgets";
import { PreviewMediaDialog } from "@shared";
import { X, Upload } from "lucide-react";
import { observer } from "mobx-react-lite";
import { useState, DragEvent, useRef } from "react";
export const MediaArea = observer(
({
articleId,
mediaIds,
deleteMedia,
onFilesDrop, // 👈 Проп для обработки загруженных файлов
setSelectMediaDialogOpen,
}: {
articleId: number;
mediaIds: { id: string; media_type: number; filename: string }[];
deleteMedia: (id: number, media_id: string) => void;
onFilesDrop?: (files: File[]) => void;
setSelectMediaDialogOpen: (open: boolean) => void;
}) => {
const [mediaModal, setMediaModal] = useState<boolean>(false);
const [mediaId, setMediaId] = useState<string>("");
const [isDragging, setIsDragging] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleMediaModal = (mediaId: string) => {
setMediaModal(true);
setMediaId(mediaId);
};
const handleDrop = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
const files = Array.from(e.dataTransfer.files);
if (files.length && onFilesDrop) {
onFilesDrop(files);
}
};
const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDragging(true);
};
const handleDragLeave = () => {
setIsDragging(false);
};
const handleClick = () => {
fileInputRef.current?.click();
};
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files || []);
if (files.length && onFilesDrop) {
onFilesDrop(files);
}
// Сбрасываем значение input, чтобы можно было выбрать тот же файл снова
event.target.value = "";
};
return (
<>
<input
type="file"
ref={fileInputRef}
onChange={handleFileSelect}
accept="image/*,video/*,.glb,.gltf"
multiple
style={{ display: "none" }}
/>
<Box className="w-full flex flex-col items-center justify-center border rounded-md p-4">
<div className="w-full flex flex-col items-center justify-center">
<div
className={`w-full h-40 flex flex-col justify-center items-center text-gray-400 border-dashed border-2 rounded-md border-gray-400 p-4 cursor-pointer hover:bg-gray-50 ${
isDragging ? "bg-blue-100 border-blue-400" : ""
}`}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onClick={handleClick}
>
<Upload size={32} className="mb-2" />
Перетащите медиа файлы сюда или нажмите для выбора
</div>
<div>или</div>
<Button
variant="contained"
color="primary"
onClick={() => setSelectMediaDialogOpen(true)}
>
Выбрать существующие медиа файлы
</Button>
</div>
<div className="w-full flex flex-start flex-wrap gap-2 mt-4">
{mediaIds.map((m) => (
<button
className="relative w-40 h-40"
key={m.id}
onClick={() => handleMediaModal(m.id)}
>
<MediaViewer
media={{
id: m.id,
media_type: m.media_type,
filename: m.filename,
}}
/>
<button
className="absolute top-2 right-2"
onClick={(e) => {
e.stopPropagation();
deleteMedia(articleId, m.id);
}}
>
<X size={16} color="red" />
</button>
</button>
))}
</div>
</Box>
<PreviewMediaDialog
open={mediaModal}
onClose={() => setMediaModal(false)}
mediaId={mediaId}
/>
</>
);
}
);

View File

@ -0,0 +1,24 @@
import { Stage, useGLTF } from "@react-three/drei";
import { Canvas } from "@react-three/fiber";
import { OrbitControls } from "@react-three/drei";
export const ModelViewer3D = ({
fileUrl,
height = "100%",
}: {
fileUrl: string;
height: string;
}) => {
const { scene } = useGLTF(fileUrl);
return (
<Canvas style={{ width: "100%", height: height }}>
<ambientLight />
<directionalLight />
<Stage environment="city" intensity={0.6}>
<primitive object={scene} />
</Stage>
<OrbitControls />
</Canvas>
);
};

View File

@ -24,6 +24,14 @@ const StyledMarkdownEditor = styled("div")(({ theme }) => ({
backgroundColor: theme.palette.background.paper,
color: theme.palette.text.primary,
borderColor: theme.palette.divider,
height: "auto",
minHeight: "200px",
maxHeight: "500px",
overflow: "auto",
},
"& .CodeMirror-scroll": {
minHeight: "200px",
maxHeight: "500px",
},
// Стили для текста в редакторе
"& .CodeMirror-selected": {

View File

@ -0,0 +1,158 @@
// import { Box, Button, Paper, Typography } from "@mui/material";
// import { X, Upload } from "lucide-react";
// import { useCallback, useState } from "react";
// import { useDropzone } from "react-dropzone";
// import { UploadMediaDialog } from "@shared";
// import { createSightStore } from "@shared";
// interface MediaUploadBoxProps {
// title: string;
// tooltip?: string;
// mediaId: string | null;
// onMediaSelect: (mediaId: string) => void;
// onMediaRemove: () => void;
// onPreviewClick: (mediaId: string) => void;
// token: string;
// type: "thumbnail" | "watermark_lu" | "watermark_rd";
// }
// export const MediaUploadBox = ({
// title,
// tooltip,
// mediaId,
// onMediaSelect,
// onMediaRemove,
// onPreviewClick,
// token,
// type,
// }: MediaUploadBoxProps) => {
// const [uploadMediaOpen, setUploadMediaOpen] = useState(false);
// const [fileToUpload, setFileToUpload] = useState<File | null>(null);
// const onDrop = useCallback((acceptedFiles: File[]) => {
// if (acceptedFiles.length > 0) {
// setFileToUpload(acceptedFiles[0]);
// setUploadMediaOpen(true);
// }
// }, []);
// const { getRootProps, getInputProps, isDragActive } = useDropzone({
// onDrop,
// accept: {
// "image/*": [".png", ".jpg", ".jpeg", ".gif"],
// },
// multiple: false,
// });
// const handleUploadComplete = async (media: {
// id: string;
// filename: string;
// media_name?: string;
// media_type: number;
// }) => {
// onMediaSelect(media.id);
// };
// return (
// <>
// <Paper
// elevation={2}
// sx={{
// padding: 2,
// display: "flex",
// flexDirection: "column",
// alignItems: "center",
// gap: 1,
// flex: 1,
// minWidth: 150,
// }}
// >
// <Box sx={{ display: "flex", alignItems: "center" }}>
// <Typography variant="subtitle2" gutterBottom sx={{ mb: 0, mr: 0.5 }}>
// {title}
// </Typography>
// </Box>
// <Box
// {...getRootProps()}
// sx={{
// position: "relative",
// width: "200px",
// height: "200px",
// display: "flex",
// alignItems: "center",
// justifyContent: "center",
// borderRadius: 1,
// mb: 1,
// cursor: mediaId ? "pointer" : "default",
// border: isDragActive ? "2px dashed #1976d2" : "none",
// backgroundColor: isDragActive
// ? "rgba(25, 118, 210, 0.04)"
// : "transparent",
// transition: "all 0.2s ease",
// }}
// >
// <input {...getInputProps()} />
// {mediaId && (
// <button
// className="absolute top-2 right-2 z-10"
// onClick={(e) => {
// e.stopPropagation();
// onMediaRemove();
// }}
// >
// <X color="red" />
// </button>
// )}
// {mediaId ? (
// <img
// src={`${
// import.meta.env.VITE_KRBL_MEDIA
// }${mediaId}/download?token=${token}`}
// alt={title}
// style={{ maxWidth: "100%", maxHeight: "100%" }}
// onClick={(e) => {
// e.stopPropagation();
// onPreviewClick(mediaId);
// }}
// />
// ) : (
// <div className="w-full flex flex-col items-center justify-center gap-3">
// <div
// className={`w-full h-20 border rounded-md flex flex-col items-center transition-all duration-300 justify-center border-dashed ${
// isDragActive
// ? "border-blue-500 bg-blue-50"
// : "border-gray-300"
// } cursor-pointer hover:bg-gray-100`}
// >
// <Upload size={24} className="mb-2" />
// <p>
// {isDragActive ? "Отпустите файл здесь" : "Перетащите файл"}
// </p>
// </div>
// <p>или</p>
// <Button
// variant="contained"
// color="primary"
// onClick={(e) => {
// e.stopPropagation();
// onMediaSelect("");
// }}
// >
// Выбрать файл
// </Button>
// </div>
// )}
// </Box>
// </Paper>
// <UploadMediaDialog
// open={uploadMediaOpen}
// onClose={() => {
// setUploadMediaOpen(false);
// setFileToUpload(null);
// }}
// afterUpload={handleUploadComplete}
// />
// </>
// );
// };

View File

@ -0,0 +1,582 @@
import {
Button,
TextField,
Box,
Autocomplete,
Typography,
Paper,
Tooltip,
MenuItem,
Menu as MuiMenu,
} from "@mui/material";
import {
BackButton,
TabPanel,
languageStore,
Language,
cityStore,
SelectMediaDialog,
PreviewMediaDialog,
SightLanguageInfo,
SightCommonInfo,
createSightStore,
} from "@shared";
import { LanguageSwitcher } from "@widgets";
import { Info, X } from "lucide-react";
import { observer } from "mobx-react-lite";
import { useEffect, useState } from "react";
import { toast } from "react-toastify";
// Мокап для всплывающей подсказки
export const CreateInformationTab = observer(
({ value, index }: { value: number; index: number }) => {
const { cities } = cityStore;
const [, setIsMediaModalOpen] = useState(false);
const [mediaId, setMediaId] = useState<string>("");
const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false);
const { language } = languageStore;
const { sight, updateSightInfo, createSight } = createSightStore;
const data = sight[language];
const [, setCity] = useState<number>(sight.city_id ?? 0);
const [coordinates, setCoordinates] = useState<string>(`0 0`);
const token = localStorage.getItem("token");
// Menu state for each media button
const [menuAnchorEl, setMenuAnchorEl] = useState<null | HTMLElement>(null);
const [activeMenuType, setActiveMenuType] = useState<
"thumbnail" | "watermark_lu" | "watermark_rd" | null
>(null);
const [isAddMediaOpen, setIsAddMediaOpen] = useState(false);
// const handleMenuOpen = (
// event: React.MouseEvent<HTMLElement>,
// type: "thumbnail" | "watermark_lu" | "watermark_rd"
// ) => {
// setMenuAnchorEl(event.currentTarget);
// setActiveMenuType(type);
// };
useEffect(() => {
// Показывать только при инициализации (не менять при ошибках пользователя)
if (sight.latitude !== 0 || sight.longitude !== 0) {
setCoordinates(`${sight.latitude} ${sight.longitude}`);
}
// если координаты обнулились — оставить поле как есть
}, [sight.latitude, sight.longitude]);
const handleMenuClose = () => {
setMenuAnchorEl(null);
setActiveMenuType(null);
};
const handleCreateNew = () => {
handleMenuClose();
};
const handleAddMedia = () => {
setIsAddMediaOpen(true);
handleMenuClose();
};
const handleChange = (
content: Partial<SightLanguageInfo | SightCommonInfo>,
language?: Language
) => {
if (language) {
updateSightInfo(content, language);
} else {
updateSightInfo(content);
}
};
const handleMediaSelect = (
media: {
id: string;
filename: string;
media_name?: string;
media_type: number;
},
type: "thumbnail" | "watermark_lu" | "watermark_rd"
) => {
handleChange({
[type]: media.id,
});
setActiveMenuType(null);
};
return (
<>
<TabPanel value={value} index={index}>
<Box
sx={{
display: "flex",
flexDirection: "column",
gap: 3,
position: "relative",
paddingBottom: "70px" /* Space for save button */,
}}
>
<BackButton />
<Box
sx={{
display: "flex",
gap: 4, // Added gap between the two main columns
width: "100%",
flexDirection: "column",
}}
>
{/* Left column with main fields */}
<Box
sx={{
flexGrow: 1,
display: "flex",
width: "80%",
flexDirection: "column",
gap: 2.5,
}}
>
<TextField
label={`Название (${language.toUpperCase()})`}
value={data.name}
onChange={(e) => {
handleChange(
{
name: e.target.value,
},
language
);
}}
fullWidth
variant="outlined"
/>
<TextField
label="Адрес"
value={data.address}
onChange={(e) => {
handleChange(
{
address: e.target.value,
},
language
);
}}
fullWidth
variant="outlined"
/>
<Autocomplete
options={cities ?? []}
value={
cities.find((city) => city.id === sight.city_id) ?? null
}
getOptionLabel={(option) => option.name}
onChange={(_, value) => {
setCity(value?.id ?? 0);
handleChange({
city_id: value?.id ?? 0,
});
}}
renderInput={(params) => (
<TextField {...params} label="Город" />
)}
/>
<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({
latitude: lat,
longitude: lon,
});
} else {
handleChange(
{
latitude: 0,
longitude: 0,
},
language
);
}
}}
fullWidth
variant="outlined"
placeholder="Введите координаты в формате: широта долгота"
/>
</Box>
<Box
sx={{
display: "flex",
gap: 4,
}}
>
<Box
sx={{
display: "flex",
justifyContent: "space-around",
width: "80%",
gap: 2,
flexDirection: { xs: "column", sm: "row" }, // Stack on extra small, side-by-side on small and up
}}
>
<Paper
elevation={2}
sx={{
padding: 2,
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 1,
flex: 1,
minWidth: 150, // Ensure a minimum width
}}
>
<Box sx={{ display: "flex", alignItems: "center" }}>
<Typography
variant="subtitle2"
gutterBottom
sx={{ mb: 0, mr: 0.5 }}
>
Логотип
</Typography>
</Box>
<Box
sx={{
position: "relative",
width: "200px",
height: "200px",
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 1,
mb: 1,
cursor: sight.thumbnail ? "pointer" : "default",
}}
onClick={() => {
setIsMediaModalOpen(true);
}}
>
{sight.thumbnail && (
<button
className="absolute top-2 right-2"
onClick={() => {
handleChange({
thumbnail: null,
});
setActiveMenuType(null);
}}
>
<X color="red" />
</button>
)}
{sight.thumbnail ? (
<img
src={`${import.meta.env.VITE_KRBL_MEDIA}${
sight.thumbnail
}/download?token=${token}`}
alt="Логотип"
style={{ maxWidth: "100%", maxHeight: "100%" }}
onClick={() => {
setIsPreviewMediaOpen(true);
setMediaId(sight.thumbnail ?? "");
}}
/>
) : (
<div className="w-full flex flex-col items-center justify-center gap-3">
<div className="w-full h-20 border rounded-md flex flex-col items-center transition-all duration-300 justify-center border-dashed border-gray-300 cursor-pointer hover:bg-gray-100">
<p>Перетащите файл</p>
</div>
<p>или</p>
<Button
variant="contained"
color="primary"
onClick={() => {
setIsAddMediaOpen(true);
setActiveMenuType("thumbnail");
}}
>
Выбрать файл
</Button>
</div>
)}
</Box>
</Paper>
<Paper
elevation={2}
sx={{
padding: 2,
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 1,
flex: 1,
minWidth: 150, // Ensure a minimum width
}}
>
<Box sx={{ display: "flex", alignItems: "center" }}>
<Typography
variant="subtitle2"
gutterBottom
sx={{ mb: 0, mr: 0.5 }}
>
Водяной знак (л.в)
</Typography>
<Tooltip title={"asf"}>
<Info
size={16}
color="gray"
style={{ cursor: "pointer" }}
/>
</Tooltip>
</Box>
<Box
sx={{
position: "relative",
width: "200px",
height: "200px",
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 1,
mb: 1,
cursor: sight.watermark_lu ? "pointer" : "default",
}}
onClick={() => {
setIsMediaModalOpen(true);
}}
>
{sight.watermark_lu && (
<button
className="absolute top-2 right-2"
onClick={() => {
handleChange({
watermark_lu: null,
});
setActiveMenuType(null);
}}
>
<X color="red" />
</button>
)}
{sight.watermark_lu ? (
<img
src={`${import.meta.env.VITE_KRBL_MEDIA}${
sight.watermark_lu
}/download?token=${token}`}
alt="Логотип"
style={{ maxWidth: "100%", maxHeight: "100%" }}
onClick={() => {
setIsPreviewMediaOpen(true);
setMediaId(sight.watermark_lu ?? "");
}}
/>
) : (
<div className="w-full flex flex-col items-center justify-center gap-3">
<div className="w-full h-20 border rounded-md flex flex-col items-center transition-all duration-300 justify-center border-dashed border-gray-300 cursor-pointer hover:bg-gray-100">
<p>Перетащите файл</p>
</div>
<p>или</p>
<Button
variant="contained"
color="primary"
onClick={() => {
setActiveMenuType("watermark_lu");
setIsAddMediaOpen(true);
}}
>
Выбрать файл
</Button>
</div>
)}
</Box>
</Paper>
<Paper
elevation={2}
sx={{
padding: 2,
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 1,
flex: 1,
minWidth: 150, // Ensure a minimum width
}}
>
<Box sx={{ display: "flex", alignItems: "center" }}>
<Typography
variant="subtitle2"
gutterBottom
sx={{ mb: 0, mr: 0.5 }}
>
Водяной знак (п.в)
</Typography>
<Tooltip title={"asfaf"}>
<Info
size={16}
color="gray"
style={{ cursor: "pointer" }}
/>
</Tooltip>
</Box>
<Box
sx={{
position: "relative",
width: "200px",
height: "200px",
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 1,
mb: 1,
cursor: sight.watermark_rd ? "pointer" : "default",
}}
onClick={() => {
setIsMediaModalOpen(true);
}}
>
{sight.watermark_rd && (
<button
className="absolute top-2 right-2"
onClick={() => {
handleChange({
watermark_rd: null,
});
setActiveMenuType(null);
}}
>
<X color="red" />
</button>
)}
{sight.watermark_rd ? (
<img
src={`${import.meta.env.VITE_KRBL_MEDIA}${
sight.watermark_rd
}/download?token=${token}`}
alt="Логотип"
style={{ maxWidth: "100%", maxHeight: "100%" }}
onClick={() => {
setIsPreviewMediaOpen(true);
setMediaId(sight.watermark_rd ?? "");
}}
/>
) : (
<div className="w-full flex flex-col items-center justify-center gap-3">
<div className="w-full h-20 border rounded-md flex flex-col items-center transition-all duration-300 justify-center border-dashed border-gray-300 cursor-pointer hover:bg-gray-100">
<p>Перетащите файл</p>
</div>
<p>или</p>
<Button
variant="contained"
color="primary"
onClick={() => {
setActiveMenuType("watermark_rd");
setIsAddMediaOpen(true);
}}
>
Выбрать файл
</Button>
</div>
)}
</Box>
</Paper>
</Box>
</Box>
</Box>
{/* LanguageSwitcher positioned at the top right */}
<LanguageSwitcher />
{/* Save Button fixed at the bottom right */}
<Box
sx={{
position: "absolute",
bottom: 0,
right: 0,
padding: 2,
backgroundColor: "background.paper", // To ensure it stands out over content
width: "100%", // Take full width to cover content below it
display: "flex",
justifyContent: "flex-end", // Align to the right
}}
>
<Button
variant="contained"
color="success"
onClick={async () => {
await createSight(language);
toast.success("Достопримечательность создана");
}}
>
Сохранить
</Button>
</Box>
</Box>
</TabPanel>
{/* Media Menu */}
<MuiMenu
anchorEl={menuAnchorEl}
open={Boolean(menuAnchorEl)}
onClose={handleMenuClose}
anchorOrigin={{
vertical: "top",
horizontal: "right",
}}
transformOrigin={{
vertical: "bottom",
horizontal: "right",
}}
>
<MenuItem onClick={handleCreateNew}>Создать новую</MenuItem>
<MenuItem onClick={handleAddMedia}>Выбрать существующую</MenuItem>
</MuiMenu>
<SelectMediaDialog
open={isAddMediaOpen}
onClose={() => {
setIsAddMediaOpen(false);
setActiveMenuType(null);
}}
onSelectMedia={(media) => {
handleMediaSelect(media, activeMenuType ?? "thumbnail");
}}
/>
<PreviewMediaDialog
open={isPreviewMediaOpen}
onClose={() => setIsPreviewMediaOpen(false)}
mediaId={mediaId}
/>
</>
);
}
);

View File

@ -0,0 +1,451 @@
// @widgets/LeftWidgetTab.tsx
import { Box, Button, TextField, Paper, Typography } from "@mui/material";
import {
BackButton,
TabPanel,
languageStore,
SelectMediaDialog,
editSightStore,
createSightStore,
SelectArticleModal,
UploadMediaDialog,
} from "@shared";
import {
LanguageSwitcher,
MediaArea,
ReactMarkdownComponent,
ReactMarkdownEditor,
MediaViewer,
} from "@widgets";
import { Trash2, ImagePlus } from "lucide-react";
import { useState, useCallback } from "react";
import { observer } from "mobx-react-lite";
import { toast } from "react-toastify";
export const CreateLeftTab = observer(
({ value, index }: { value: number; index: number }) => {
const {
sight,
updateSightInfo,
updateLeftArticle,
createSight,
deleteLeftArticle,
createLeftArticle,
unlinkLeftArticle,
createLinkWithArticle,
} = createSightStore;
const {
deleteMedia,
setFileToUpload,
uploadMediaOpen,
setUploadMediaOpen,
} = editSightStore;
const { language } = languageStore;
const [isSelectArticleDialogOpen, setIsSelectArticleDialogOpen] =
useState(false);
const [isSelectMediaDialogOpen, setIsSelectMediaDialogOpen] =
useState(false);
// const handleMediaSelected = useCallback(() => {
// // При выборе медиа, обновляем данные для ТЕКУЩЕГО ЯЗЫКА
// // сохраняя текущие heading и body.
// updateSightInfo(language, {
// left: {
// heading: data.left.heading,
// body: data.left.body,
// },
// });
// setIsSelectMediaDialogOpen(false);
// }, [language, data.left.heading, data.left.body]);
const handleCloseArticleDialog = useCallback(() => {
setIsSelectArticleDialogOpen(false);
}, []);
const handleCloseMediaDialog = useCallback(() => {
setIsSelectMediaDialogOpen(false);
}, []);
const handleMediaSelected = useCallback(
async (media: {
id: string;
filename: string;
media_name?: string;
media_type: number;
}) => {
await createLinkWithArticle(media);
setIsSelectMediaDialogOpen(false);
},
[createLinkWithArticle]
);
const handleArticleSelect = useCallback(
(articleId: number) => {
updateLeftArticle(articleId);
},
[updateLeftArticle]
);
return (
<TabPanel value={value} index={index}>
<LanguageSwitcher />
<Box
sx={{
display: "flex",
flexDirection: "column",
gap: 3,
paddingBottom: "70px",
position: "relative",
}}
>
<BackButton />
<Paper
elevation={2}
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
paddingX: 2.5,
paddingY: 1.5,
borderRadius: 2,
border: "1px solid",
borderColor: "divider",
}}
>
<Typography variant="h6">Левая статья</Typography>
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
{sight.left_article ? (
<>
<Button
variant="contained"
color="primary"
size="small"
style={{ transition: "0" }}
onClick={() => {
unlinkLeftArticle();
toast.success("Статья откреплена");
}}
>
Открепить
</Button>
<Button
variant="outlined"
color="error"
style={{ transition: "0" }}
startIcon={<Trash2 size={18} />}
size="small"
onClick={() => {
deleteLeftArticle(sight.left_article);
toast.success("Статья откреплена");
}}
>
Удалить
</Button>
</>
) : (
<>
<Button
variant="contained"
color="primary"
size="small"
onClick={() => setIsSelectArticleDialogOpen(true)}
>
Выбрать статью
</Button>
<Button
variant="contained"
color="primary"
size="small"
style={{ transition: "0" }}
onClick={createLeftArticle}
>
Создать статью
</Button>
</>
)}
</Box>
</Paper>
{sight.left_article > 0 && (
<>
<Box sx={{ display: "flex", gap: 3, flexGrow: 1 }}>
{/* Левая колонка: Редактирование */}
<Box
sx={{
flex: 2,
display: "flex",
flexDirection: "column",
gap: 2,
}}
>
<TextField
label="Название информации"
value={sight[language].left.heading}
onChange={(e) =>
updateSightInfo(
{
left: {
heading: e.target.value,
body: sight[language].left.body,
media: sight[language].left.media,
},
},
language
)
}
variant="outlined"
fullWidth
/>
<ReactMarkdownEditor
value={sight[language].left.body}
onChange={(value) =>
updateSightInfo(
{
left: {
heading: sight[language].left.heading,
body: value,
media: sight[language].left.media,
},
},
language
)
}
/>
<MediaArea
articleId={sight.left_article}
mediaIds={sight[language].left.media}
deleteMedia={deleteMedia}
setSelectMediaDialogOpen={setIsSelectMediaDialogOpen}
onFilesDrop={(files) => {
setFileToUpload(files[0]);
setUploadMediaOpen(true);
}}
/>
{/* Блок МЕДИА для статьи */}
{/* <Paper elevation={1} sx={{ padding: 2, mt: 1 }}>
<Typography variant="h6" gutterBottom>
МЕДИА
</Typography>
{data.left.media ? (
<Box sx={{ mb: 1 }}>
<img
src={data.left.media.filename}
alt="Selected media"
style={{
maxWidth: "100%",
maxHeight: "150px",
objectFit: "contain",
}}
/>
</Box>
) : (
<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"
startIcon={<ImagePlus size={18} />}
onClick={handleOpenMediaDialog}
>
Выбрать/Загрузить медиа
</Button>
{data.left.media && (
<Button
variant="outlined"
color="error"
size="small"
sx={{ ml: 1 }}
onClick={() =>
updateSightInfo(
languageStore.language,
{
left: {
heading: data.left.heading,
body: data.left.body,
media: null,
},
},
false
)
}
>
Удалить медиа
</Button>
)}
</Paper> */}
</Box>
{/* Правая колонка: Предпросмотр */}
<Box
sx={{
flex: 1,
display: "flex",
flexDirection: "column",
gap: 1.5,
}}
>
<Paper
elevation={3}
sx={{
width: "100%",
minWidth: 320,
maxWidth: 400,
height: "auto",
minHeight: 500,
backgroundColor: "#877361",
overflowY: "auto",
padding: 0,
display: "flex",
flexDirection: "column",
}}
>
{/* {data.left.media?.filename ? (
<Box
sx={{
width: "100%",
height: 200,
backgroundColor: "grey.300",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<img
src={data.left.media?.filename ?? ""}
alt="Превью медиа"
style={{
objectFit: "cover",
width: "100%",
height: "100%",
}}
/>
</Box>
) : (
)} */}
<Box
sx={{
width: "100%",
height: 200,
backgroundColor: "grey.300",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{sight[language].left.media.length > 0 ? (
<MediaViewer
media={{
id: sight[language].left.media[0].id,
media_type:
sight[language].left.media[0].media_type,
filename: sight[language].left.media[0].filename,
}}
/>
) : (
<ImagePlus size={48} color="grey" />
)}
</Box>
{/* Заголовок в превью */}
<Box
sx={{
backgroundColor: "#877361",
color: "white",
padding: 1.5,
}}
>
<Typography
variant="h5"
component="h2"
sx={{ wordBreak: "break-word" }}
>
{sight[language].left.heading || "Название информации"}
</Typography>
</Box>
{/* Текст статьи в превью */}
<Box
sx={{
padding: 2,
flexGrow: 1,
}}
>
<ReactMarkdownComponent
value={sight[language].left.body}
/>
</Box>
</Paper>
</Box>
</Box>
<Box
sx={{ position: "absolute", bottom: 0, right: 0, padding: 2 }}
>
<Button
variant="contained"
color="success"
onClick={async () => {
try {
await createSight(language);
toast.success("Странца создана");
} catch (error) {
console.error(error);
}
}}
>
Сохранить
</Button>
</Box>
</>
)}
</Box>
{/* <SelectMediaDialog
open={isSelectMediaDialogOpen}
onClose={handleCloseMediaDialog}
onSelectMedia={handleArticleSelect}
/> */}
<SelectMediaDialog
open={isSelectMediaDialogOpen}
onClose={handleCloseMediaDialog}
onSelectMedia={handleMediaSelected}
/>
<UploadMediaDialog
open={uploadMediaOpen}
onClose={() => setUploadMediaOpen(false)}
afterUpload={async (media) => {
setUploadMediaOpen(false);
setFileToUpload(null);
await createLinkWithArticle(media);
}}
/>
<SelectArticleModal
open={isSelectArticleDialogOpen}
onClose={handleCloseArticleDialog}
onSelectArticle={handleArticleSelect}
/>
</TabPanel>
);
}
);

View File

@ -0,0 +1,374 @@
import {
Box,
Button,
Paper,
Typography,
Menu,
MenuItem,
TextField,
} from "@mui/material";
import { BackButton, createSightStore, languageStore, TabPanel } from "@shared";
import {
LanguageSwitcher,
ReactMarkdownComponent,
ReactMarkdownEditor,
} from "@widgets";
import { ImagePlus, Plus } from "lucide-react";
import { observer } from "mobx-react-lite";
import { useState } from "react";
// --- RightWidgetTab (Parent) Component ---
export const CreateRightTab = observer(
({ value, index }: { value: number; index: number }) => {
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const { sight, createNewRightArticle, updateRightArticleInfo } =
createSightStore;
const { language } = languageStore;
const [activeArticleIndex, setActiveArticleIndex] = useState<number | null>(
null
);
const open = Boolean(anchorEl);
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
const handleSave = () => {
console.log("Saving right widget...");
};
const handleSelectArticle = (index: number) => {
setActiveArticleIndex(index);
};
return (
<TabPanel value={value} index={index}>
<LanguageSwitcher />
<Box
sx={{
display: "flex",
flexDirection: "column",
height: "100%",
minHeight: "calc(100vh - 200px)", // Adjust as needed
gap: 2,
paddingBottom: "70px", // Space for the save button
position: "relative",
}}
>
<BackButton />
<Box sx={{ display: "flex", flexGrow: 1, gap: 2.5 }}>
<Box className="flex flex-col w-[75%] gap-2">
<Box className="w-full flex gap-2 ">
<Box className="relative w-[20%] h-[70vh] flex flex-col rounded-2xl overflow-y-auto gap-3 border border-gray-300 p-3">
<Box className="flex flex-col gap-3 max-h-[60vh] overflow-y-auto">
<Box
onClick={() => {
// setMediaType("preview");
}}
className="w-full bg-gray-200 p-4 rounded-2xl cursor-pointer text-sm hover:bg-gray-300 transition-all duration-300"
>
<Typography>Предпросмотр медиа</Typography>
</Box>
{sight[language].right.map((article, index) => (
<Box
key={index}
className="w-full bg-gray-200 p-4 rounded-2xl text-sm cursor-pointer hover:bg-gray-300 transition-all duration-300"
onClick={() => {
handleSelectArticle(index);
}}
>
<Typography>{article.heading}</Typography>
</Box>
))}
</Box>
<button
className="w-10 h-10 bg-blue-500 rounded-full absolute bottom-5 left-5 flex items-center justify-center"
onClick={handleClick}
>
<Plus size={20} color="white" />
</button>
<Menu
id="basic-menu"
anchorEl={anchorEl}
open={open}
onClose={handleClose}
MenuListProps={{
"aria-labelledby": "basic-button",
}}
sx={{
mt: 1,
}}
>
<MenuItem
onClick={() => {
createNewRightArticle();
handleClose();
}}
>
<Typography>Создать новую</Typography>
</MenuItem>
<MenuItem
onClick={() => {
handleClose();
}}
>
<Typography>Выбрать существующую статью</Typography>
</MenuItem>
</Menu>
</Box>
<Box className="w-[80%] h-[70vh] border border-gray-300 rounded-2xl p-3">
{activeArticleIndex !== null && (
<>
<Box className="flex justify-end gap-2 mb-3">
<Button variant="contained" color="primary">
Открепить
</Button>
<Button variant="contained" color="success">
Удалить
</Button>
</Box>
<Box sx={{ display: "flex", gap: 3, flexGrow: 1 }}>
{/* Левая колонка: Редактирование */}
<Box
sx={{
flex: 2,
display: "flex",
flexDirection: "column",
gap: 2,
}}
>
<TextField
label="Название информации"
value={
sight[language].right[activeArticleIndex].heading
}
onChange={(e) =>
updateRightArticleInfo(
activeArticleIndex,
language,
e.target.value,
sight[language].right[activeArticleIndex].body
)
}
variant="outlined"
fullWidth
/>
<ReactMarkdownEditor
value={
sight[language].right[activeArticleIndex].body
}
onChange={(value) =>
updateRightArticleInfo(
activeArticleIndex,
language,
sight[language].right[activeArticleIndex]
.heading,
value
)
}
/>
</Box>
{/* Блок МЕДИА для статьи */}
{/* <Paper elevation={1} sx={{ padding: 2, mt: 1 }}>
<Typography variant="h6" gutterBottom>
МЕДИА
</Typography>
{data.left.media ? (
<Box sx={{ mb: 1 }}>
<img
src={data.left.media.filename}
alt="Selected media"
style={{
maxWidth: "100%",
maxHeight: "150px",
objectFit: "contain",
}}
/>
</Box>
) : (
<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"
startIcon={<ImagePlus size={18} />}
onClick={handleOpenMediaDialog}
>
Выбрать/Загрузить медиа
</Button>
{data.left.media && (
<Button
variant="outlined"
color="error"
size="small"
sx={{ ml: 1 }}
onClick={() =>
updateSightInfo(
languageStore.language,
{
left: {
heading: data.left.heading,
body: data.left.body,
media: null,
},
},
false
)
}
>
Удалить медиа
</Button>
)}
</Paper> */}
</Box>
</>
)}
</Box>
</Box>
</Box>
<Box className="w-[25%] mr-10">
{activeArticleIndex !== null && (
<Paper
className="flex-1 flex flex-col rounded-2xl"
elevation={2}
>
<Box
className="rounded-2xl overflow-hidden"
sx={{
width: "100%",
height: "75vh",
background: "#877361",
borderColor: "grey.300",
display: "flex",
flexDirection: "column",
}}
>
{false ? (
<Box
sx={{
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Typography color="white">Загрузка...</Typography>
</Box>
) : (
<>
<Box
sx={{
width: "100%",
height: 200,
flexShrink: 0,
backgroundColor: "rgba(0,0,0,0.1)",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<ImagePlus size={48} color="white" />
</Box>
<Box
sx={{
width: "100%",
minHeight: "70px",
background: "#877361",
display: "flex",
flexShrink: 0,
alignItems: "center",
borderBottom: "1px solid rgba(255,255,255,0.1)",
px: 2,
}}
>
<Typography variant="h6" color="white">
{sight[language].right[activeArticleIndex]
.heading || "Выберите статью"}
</Typography>
</Box>
<Box
sx={{
px: 2,
flexGrow: 1,
overflowY: "auto",
backgroundColor: "#877361",
color: "white",
py: 1,
}}
>
{sight[language].right[activeArticleIndex].body ? (
<ReactMarkdownComponent
value={
sight[language].right[activeArticleIndex].body
}
/>
) : (
<Typography
color="rgba(255,255,255,0.7)"
sx={{ textAlign: "center", mt: 4 }}
>
Предпросмотр статьи появится здесь
</Typography>
)}
</Box>
</>
)}
</Box>
</Paper>
)}
</Box>
</Box>
<Box
sx={{
position: "absolute",
bottom: 0,
right: 0,
padding: 2,
backgroundColor: "background.paper", // Ensure button is visible
width: "100%", // Cover the full width to make it a sticky footer
display: "flex",
justifyContent: "flex-end",
}}
>
<Button variant="contained" color="success" onClick={handleSave}>
Сохранить изменения
</Button>
</Box>
</Box>
{/*
<SelectArticleModal
open={openedType === "article"}
onClose={handleCloseSelectModal}
onSelectArticle={handleSelectArticle}
linkedArticleIds={linkedArticleIds}
/> */}
</TabPanel>
);
}
);

View File

@ -27,6 +27,8 @@ import { Info, ImagePlus } from "lucide-react";
import { observer } from "mobx-react-lite";
import { useEffect, useState } from "react";
import { toast } from "react-toastify";
// Мокап для всплывающей подсказки
export const InformationTab = observer(
@ -37,12 +39,10 @@ export const InformationTab = observer(
const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false);
const { language } = languageStore;
const { sight, updateSightInfo } = editSightStore;
const data = sight[language];
const common = sight.common;
const { sight, updateSightInfo, updateSight } = editSightStore;
const [, setCity] = useState<number>(common.city_id ?? 0);
const [, setCity] = useState<number>(sight.common.city_id ?? 0);
const [coordinates, setCoordinates] = useState<string>(`0 0`);
const token = localStorage.getItem("token");
@ -54,21 +54,13 @@ export const InformationTab = observer(
>(null);
const [isAddMediaOpen, setIsAddMediaOpen] = useState(false);
const handleMenuOpen = (
event: React.MouseEvent<HTMLElement>,
type: "thumbnail" | "watermark_lu" | "watermark_rd"
) => {
setMenuAnchorEl(event.currentTarget);
setActiveMenuType(type);
};
useEffect(() => {
// Показывать только при инициализации (не менять при ошибках пользователя)
if (common.latitude !== 0 || common.longitude !== 0) {
setCoordinates(`${common.latitude} ${common.longitude}`);
if (sight.common.latitude !== 0 || sight.common.longitude !== 0) {
setCoordinates(`${sight.common.latitude} ${sight.common.longitude}`);
}
// если координаты обнулились — оставить поле как есть
}, [common.latitude, common.longitude]);
}, [sight.common.latitude, sight.common.longitude]);
const handleMenuClose = () => {
setMenuAnchorEl(null);
@ -135,7 +127,7 @@ export const InformationTab = observer(
>
<TextField
label={`Название (${language.toUpperCase()})`}
value={data.name}
value={sight[language].name}
onChange={(e) => {
handleChange(language as Language, {
name: e.target.value,
@ -147,7 +139,7 @@ export const InformationTab = observer(
<TextField
label="Адрес"
value={data.address}
value={sight[language].address}
onChange={(e) => {
handleChange(language as Language, {
address: e.target.value,
@ -160,18 +152,15 @@ export const InformationTab = observer(
<Autocomplete
options={cities ?? []}
value={
cities.find((city) => city.id === common.city_id) ?? null
cities.find((city) => city.id === sight.common.city_id) ??
null
}
getOptionLabel={(option) => option.name}
onChange={(_, value) => {
setCity(value?.id ?? 0);
handleChange(
language as Language,
{
city_id: value?.id ?? 0,
},
true
);
handleChange(language as Language, {
city_id: value?.id ?? 0,
});
}}
renderInput={(params) => (
<TextField {...params} label="Город" />
@ -195,15 +184,23 @@ export const InformationTab = observer(
const isValidLon = !isNaN(lon);
if (isValidLat && isValidLon) {
handleChange(language as Language, {
latitude: lat,
longitude: lon,
});
handleChange(
language as Language,
{
latitude: lat,
longitude: lon,
},
true
);
} else {
handleChange(language as Language, {
latitude: 0,
longitude: 0,
});
handleChange(
language as Language,
{
latitude: 0,
longitude: 0,
},
true
);
}
}}
fullWidth
@ -251,17 +248,18 @@ export const InformationTab = observer(
</Box>
<Box
sx={{
width: 80,
height: 80,
position: "relative",
width: "200px",
height: "200px",
backgroundColor: "grey.200",
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 1,
mb: 1,
cursor: common.thumbnail ? "pointer" : "default",
cursor: sight.common.thumbnail ? "pointer" : "default",
"&:hover": {
backgroundColor: common.thumbnail
backgroundColor: sight.common.thumbnail
? "red.300"
: "grey.200",
},
@ -270,29 +268,22 @@ export const InformationTab = observer(
setIsMediaModalOpen(true);
}}
>
{common.thumbnail ? (
{sight.common.thumbnail ? (
<img
src={`${import.meta.env.VITE_KRBL_MEDIA}${
common.thumbnail
sight.common.thumbnail
}/download?token=${token}`}
alt="Логотип"
style={{ maxWidth: "100%", maxHeight: "100%" }}
onClick={() => {
setIsPreviewMediaOpen(true);
setMediaId(common.thumbnail);
setMediaId(sight.common.thumbnail ?? "");
}}
/>
) : (
<ImagePlus size={24} color="grey" />
)}
</Box>
<Button
variant="outlined"
size="small"
onClick={(e) => handleMenuOpen(e, "thumbnail")}
>
Выбрать
</Button>
</Paper>
<Paper
elevation={2}
@ -324,49 +315,45 @@ export const InformationTab = observer(
</Box>
<Box
sx={{
width: 80,
height: 80,
position: "relative",
width: "200px",
height: "200px",
backgroundColor: "grey.200",
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 1,
mb: 1,
cursor: common.watermark_lu ? "pointer" : "default",
cursor: sight.common.watermark_lu
? "pointer"
: "default",
"&:hover": {
backgroundColor: common.watermark_lu
backgroundColor: sight.common.watermark_lu
? "grey.300"
: "grey.200",
},
}}
onClick={() => {
setIsPreviewMediaOpen(true);
setMediaId(common.watermark_lu);
setMediaId(sight.common.watermark_lu ?? "");
}}
>
{common.watermark_lu ? (
{sight.common.watermark_lu ? (
<img
src={`${import.meta.env.VITE_KRBL_MEDIA}${
common.watermark_lu
sight.common.watermark_lu
}/download?token=${token}`}
alt="Знак л.в"
style={{ maxWidth: "100%", maxHeight: "100%" }}
onClick={() => {
setIsMediaModalOpen(true);
setMediaId(common.watermark_lu);
setMediaId(sight.common.watermark_lu ?? "");
}}
/>
) : (
<ImagePlus size={24} color="grey" />
)}
</Box>
<Button
variant="outlined"
size="small"
onClick={(e) => handleMenuOpen(e, "watermark_lu")}
>
Выбрать
</Button>
</Paper>
<Paper
@ -399,49 +386,45 @@ export const InformationTab = observer(
</Box>
<Box
sx={{
width: 80,
height: 80,
position: "relative",
width: "200px",
height: "200px",
backgroundColor: "grey.200",
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 1,
mb: 1,
cursor: common.watermark_rd ? "pointer" : "default",
cursor: sight.common.watermark_rd
? "pointer"
: "default",
"&:hover": {
backgroundColor: common.watermark_rd
backgroundColor: sight.common.watermark_rd
? "grey.300"
: "grey.200",
},
}}
onClick={() => {
setIsMediaModalOpen(true);
setMediaId(common.watermark_rd);
setMediaId(sight.common.watermark_rd ?? "");
}}
>
{common.watermark_rd ? (
{sight.common.watermark_rd ? (
<img
src={`${import.meta.env.VITE_KRBL_MEDIA}${
common.watermark_rd
sight.common.watermark_rd
}/download?token=${token}`}
alt="Знак п.в"
style={{ maxWidth: "100%", maxHeight: "100%" }}
onClick={() => {
setIsPreviewMediaOpen(true);
setMediaId(common.watermark_rd);
setMediaId(sight.common.watermark_rd ?? "");
}}
/>
) : (
<ImagePlus size={24} color="grey" />
)}
</Box>
<Button
variant="outlined"
size="small"
onClick={(e) => handleMenuOpen(e, "watermark_rd")}
>
Выбрать
</Button>
</Paper>
</Box>
</Box>
@ -467,8 +450,9 @@ export const InformationTab = observer(
<Button
variant="contained"
color="success"
onClick={() => {
console.log(sight);
onClick={async () => {
await updateSight();
toast.success("Достопримечательность сохранена");
}}
>
Сохранить

View File

@ -1,326 +1,345 @@
// @widgets/LeftWidgetTab.tsx
import { Box, Button, TextField, Paper, Typography } from "@mui/material";
import {
articlesStore,
BackButton,
TabPanel,
languageStore,
SelectMediaDialog,
editSightStore,
SelectArticleModal,
UploadMediaDialog,
} from "@shared";
import {
LanguageSwitcher,
ReactMarkdownComponent,
ReactMarkdownEditor,
MediaArea,
MediaViewer,
} from "@widgets";
import { Unlink, Trash2, ImagePlus } from "lucide-react";
import { Trash2, ImagePlus } from "lucide-react";
import { useState, useCallback } from "react";
import { observer } from "mobx-react-lite";
import { toast } from "react-toastify";
export const LeftWidgetTab = observer(
({ value, index }: { value: number; index: number }) => {
const { sight, updateSightInfo } = editSightStore;
const { getArticleByArticleId } = articlesStore;
const {
sight,
updateSightInfo,
unlinkLeftArticle,
updateSight,
deleteLeftArticle,
createLeftArticle,
deleteMedia,
uploadMediaOpen,
setUploadMediaOpen,
const linkedArticle = getArticleByArticleId.get(); // Получаем связанную статью
const data = sight[languageStore.language]; // Получаем данные для текущего языка
setFileToUpload,
createLinkWithArticle,
} = editSightStore;
const { language } = languageStore;
const data = sight[language];
const [isSelectMediaDialogOpen, setIsSelectMediaDialogOpen] =
useState(false);
const [isSelectArticleDialogOpen, setIsSelectArticleDialogOpen] =
useState(false);
const handleMediaSelected = useCallback(() => {
// При выборе медиа, обновляем данные для ТЕКУЩЕГО ЯЗЫКА
// сохраняя текущие heading и body.
updateSightInfo(
languageStore.language,
{
left: {
heading: data.left.heading,
body: data.left.body,
},
},
false
);
setIsSelectMediaDialogOpen(false);
}, [
languageStore.language,
{
left: {
heading: data.left.heading,
body: data.left.body,
},
const handleMediaSelected = useCallback(
async (media: {
id: string;
filename: string;
media_name?: string;
media_type: number;
}) => {
await createLinkWithArticle(media);
setIsSelectMediaDialogOpen(false);
},
false,
]);
[createLinkWithArticle]
);
const handleCloseMediaDialog = useCallback(() => {
setIsSelectMediaDialogOpen(false);
}, []);
// ... (остальной JSX код остался почти без изменений)
return (
<TabPanel value={value} index={index}>
<LanguageSwitcher />
<Box
sx={{
display: "flex",
flexDirection: "column",
gap: 3,
paddingBottom: "70px",
position: "relative",
}}
>
<BackButton />
const handleCloseArticleDialog = useCallback(() => {
setIsSelectArticleDialogOpen(false);
}, []);
<Paper
elevation={2}
const handleSelectArticle = useCallback(
(
articleId: number,
heading: string,
body: string,
media: { id: string; media_type: number; filename: string }[]
) => {
setIsSelectArticleDialogOpen(false);
updateSightInfo(languageStore.language, {
left: {
heading,
body,
media,
},
});
updateSightInfo(
languageStore.language,
{
left_article: articleId,
},
true
);
},
[]
);
return (
<>
<TabPanel value={value} index={index}>
<LanguageSwitcher />
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
paddingX: 2.5,
paddingY: 1.5,
borderRadius: 2,
border: "1px solid",
borderColor: "divider",
flexDirection: "column",
gap: 3,
paddingBottom: "70px",
position: "relative",
}}
>
<Typography variant="h6">Левая статья</Typography>
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
{linkedArticle && (
<Button
variant="outlined"
color="primary"
startIcon={<Unlink size={18} />}
size="small"
>
Открепить
</Button>
)}
<Button
variant="outlined"
color="error"
startIcon={<Trash2 size={18} />}
size="small"
>
Удалить
</Button>
</Box>
</Paper>
<BackButton />
<Box sx={{ display: "flex", gap: 3, flexGrow: 1 }}>
{/* Левая колонка: Редактирование */}
<Box
sx={{ flex: 2, display: "flex", flexDirection: "column", gap: 2 }}
>
<TextField
label="Название информации"
value={data?.left?.heading}
onChange={(e) =>
updateSightInfo(
languageStore.language,
{
left: {
heading: e.target.value,
body: data.left.body,
},
},
false
)
}
variant="outlined"
fullWidth
/>
<ReactMarkdownEditor
value={data?.left?.body}
onChange={(value) =>
updateSightInfo(
languageStore.language,
{
left: {
heading: data.left.heading,
body: value,
},
},
false
)
}
/>
{/* Блок МЕДИА для статьи */}
{/* <Paper elevation={1} sx={{ padding: 2, mt: 1 }}>
<Typography variant="h6" gutterBottom>
МЕДИА
</Typography>
{data.left.media ? (
<Box sx={{ mb: 1 }}>
<img
src={data.left.media.filename}
alt="Selected media"
style={{
maxWidth: "100%",
maxHeight: "150px",
objectFit: "contain",
}}
/>
</Box>
) : (
<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"
startIcon={<ImagePlus size={18} />}
onClick={handleOpenMediaDialog}
>
Выбрать/Загрузить медиа
</Button>
{data.left.media && (
<Button
variant="outlined"
color="error"
size="small"
sx={{ ml: 1 }}
onClick={() =>
updateSightInfo(
languageStore.language,
{
left: {
heading: data.left.heading,
body: data.left.body,
media: null,
},
},
false
)
}
>
Удалить медиа
</Button>
)}
</Paper> */}
</Box>
{/* Правая колонка: Предпросмотр */}
<Box
<Paper
elevation={2}
sx={{
flex: 1,
display: "flex",
flexDirection: "column",
gap: 1.5,
alignItems: "center",
justifyContent: "space-between",
paddingX: 2.5,
paddingY: 1.5,
borderRadius: 2,
border: "1px solid",
borderColor: "divider",
}}
>
<Paper
elevation={3}
sx={{
width: "100%",
minWidth: 320,
maxWidth: 400,
height: "auto",
minHeight: 500,
backgroundColor: "#877361",
overflowY: "auto",
padding: 0,
display: "flex",
flexDirection: "column",
}}
>
{/* {data.left.media?.filename ? (
<Box
<Typography variant="h6">Левая статья</Typography>
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
{sight.common.left_article ? (
<>
<Button
variant="contained"
color="primary"
size="small"
style={{ transition: "0" }}
onClick={() => {
unlinkLeftArticle();
toast.success("Статья откреплена");
}}
>
Открепить
</Button>
<Button
variant="outlined"
color="error"
style={{ transition: "0" }}
startIcon={<Trash2 size={18} />}
size="small"
onClick={() => {
deleteLeftArticle(sight.common.left_article);
toast.success("Статья откреплена");
}}
>
Удалить
</Button>
</>
) : (
<>
<Button
variant="contained"
color="primary"
size="small"
onClick={() => setIsSelectArticleDialogOpen(true)}
>
Выбрать статью
</Button>
<Button
variant="contained"
color="primary"
size="small"
style={{ transition: "0" }}
onClick={() => {
createLeftArticle();
toast.success("Статья создана");
}}
>
Создать статью
</Button>
</>
)}
</Box>
</Paper>
{sight.common.left_article > 0 && (
<Box sx={{ display: "flex", gap: 3, flexGrow: 1 }}>
<Box
sx={{
flex: 2,
display: "flex",
flexDirection: "column",
gap: 2,
}}
>
<TextField
label="Название информации"
value={data?.left?.heading}
onChange={(e) =>
updateSightInfo(languageStore.language, {
left: {
heading: e.target.value,
body: sight[languageStore.language].left.body,
media: data.left.media,
},
})
}
variant="outlined"
fullWidth
/>
<ReactMarkdownEditor
value={data?.left?.body}
onChange={(value) =>
updateSightInfo(languageStore.language, {
left: {
heading: sight[languageStore.language].left.heading,
body: value,
media: data.left.media,
},
})
}
/>
<MediaArea
articleId={sight.common.left_article}
mediaIds={data.left.media}
deleteMedia={deleteMedia}
setSelectMediaDialogOpen={setIsSelectMediaDialogOpen}
onFilesDrop={(files) => {
setFileToUpload(files[0]);
setUploadMediaOpen(true);
}}
/>
</Box>
<Box
sx={{
flex: 1,
display: "flex",
flexDirection: "column",
gap: 1.5,
}}
>
<Paper
elevation={3}
sx={{
width: "100%",
height: 200,
backgroundColor: "grey.300",
minWidth: 320,
maxWidth: 400,
height: "auto",
minHeight: 500,
backgroundColor: "#877361",
overflowY: "auto",
padding: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
flexDirection: "column",
}}
>
<img
src={data.left.media?.filename ?? ""}
alt="Превью медиа"
style={{
objectFit: "cover",
<Box
sx={{
width: "100%",
height: "100%",
height: 200,
backgroundColor: "grey.300",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
/>
</Box>
) : (
)} */}
>
{data.left.media.length > 0 ? (
<MediaViewer
media={{
id: data.left.media[0].id,
media_type: data.left.media[0].media_type,
filename: data.left.media[0].filename,
}}
/>
) : (
<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
sx={{
backgroundColor: "#877361",
color: "white",
padding: 1.5,
}}
>
<Typography
variant="h5"
component="h2"
sx={{ wordBreak: "break-word" }}
>
{data?.left?.heading || "Название информации"}
</Typography>
</Box>
{/* Заголовок в превью */}
<Box
sx={{
backgroundColor: "#877361",
color: "white",
padding: 1.5,
}}
>
<Typography
variant="h5"
component="h2"
sx={{ wordBreak: "break-word" }}
>
{data?.left?.heading || "Название информации"}
</Typography>
{data?.left?.body && (
<Box
sx={{
padding: 2,
flexGrow: 1,
}}
>
<ReactMarkdownComponent value={data?.left?.body} />
</Box>
)}
</Paper>
</Box>
</Box>
)}
{/* Текст статьи в превью */}
<Box
sx={{
padding: 2,
flexGrow: 1,
}}
>
<ReactMarkdownComponent value={data?.left?.body} />
</Box>
</Paper>
<Box sx={{ position: "absolute", bottom: 0, right: 0, padding: 2 }}>
<Button
variant="contained"
color="success"
onClick={async () => {
await updateSight();
toast.success("Достопримечательность сохранена");
}}
>
Сохранить
</Button>
</Box>
</Box>
<Box sx={{ position: "absolute", bottom: 0, right: 0, padding: 2 }}>
<Button variant="contained" color="success">
Сохранить
</Button>
</Box>
</Box>
</TabPanel>
<UploadMediaDialog
open={uploadMediaOpen}
onClose={() => setUploadMediaOpen(false)}
afterUpload={async (media) => {
setUploadMediaOpen(false);
setFileToUpload(null);
await createLinkWithArticle(media);
}}
/>
<SelectMediaDialog
open={isSelectMediaDialogOpen}
onClose={handleCloseMediaDialog}
onSelectMedia={handleMediaSelected}
/>
</TabPanel>
<SelectArticleModal
open={isSelectArticleDialogOpen}
onClose={handleCloseArticleDialog}
onSelectArticle={handleSelectArticle}
/>
</>
);
}
);

View File

@ -1,345 +1,288 @@
import {
Box,
Button,
List,
ListItemButton,
ListItemText,
Paper,
Typography,
Menu,
MenuItem,
TextField,
} from "@mui/material";
import {
articlesStore,
BackButton,
createSightStore,
editSightStore,
languageStore,
SelectArticleModal,
TabPanel,
} from "@shared";
import { SightEdit } from "@widgets";
import { Plus } from "lucide-react";
import {
LanguageSwitcher,
ReactMarkdownComponent,
ReactMarkdownEditor,
} from "@widgets";
import { ImagePlus, Plus } from "lucide-react";
import { observer } from "mobx-react-lite";
import { useState } from "react";
import { useEffect, useState } from "react";
import { toast } from "react-toastify";
// --- 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",
},
];
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
}
interface ArticleListSidebarProps {
blocks: ArticleBlock[];
selectedBlockId: string | null;
onSelectBlock: (blockId: string) => void;
onCreateNew: () => void;
onSelectExisting: () => void;
}
const ArticleListSidebar = ({
blocks,
selectedBlockId,
onSelectBlock,
onCreateNew,
onSelectExisting,
}: ArticleListSidebarProps) => {
const [menuAnchorEl, setMenuAnchorEl] = useState<null | HTMLElement>(null);
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
setMenuAnchorEl(event.currentTarget);
};
const 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 [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const { createNewRightArticle, updateRightArticleInfo } = createSightStore;
const { sight, getRightArticles, updateSight } = editSightStore;
const { language } = languageStore;
useEffect(() => {
if (sight.common.id) {
getRightArticles(sight.common.id);
}
}, [sight.common.id]);
const [activeArticleIndex, setActiveArticleIndex] = useState<number | null>(
null
);
const [isSelectModalOpen, setIsSelectModalOpen] = useState(false);
const handleSelectBlock = (blockId: string) => {
setSelectedBlockId(blockId);
console.log("Selected block:", blockId);
const open = Boolean(anchorEl);
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
const handleSelectArticle = (index: number) => {
setActiveArticleIndex(index);
};
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);
createNewRightArticle();
handleClose();
};
const handleSelectExisting = () => {
setIsSelectModalOpen(true);
handleClose();
};
const handleCloseSelectModal = () => {
setIsSelectModalOpen(false);
};
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);
}
const handleArticleSelect = () => {
// TODO: Implement article selection logic
handleCloseSelectModal();
};
const handleSave = () => {
console.log("Saving right widget...");
// Implement save logic here, e.g., send data to an API
const handleSave = async () => {
await updateSight();
toast.success("Достопримечательность сохранена");
};
// 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)", // Adjust as needed
minHeight: "calc(100vh - 200px)",
gap: 2,
paddingBottom: "70px", // Space for the save button
paddingBottom: "70px",
position: "relative",
}}
>
<BackButton />
<Box sx={{ display: "flex", flexGrow: 1, gap: 2.5 }}>
<ArticleListSidebar
blocks={rightWidgetBlocks}
selectedBlockId={selectedBlockId}
onSelectBlock={handleSelectBlock}
onCreateNew={handleCreateNew}
onSelectExisting={handleSelectExisting}
/>
<Box className="flex flex-col w-[75%] gap-2">
<Box className="w-full flex gap-2">
<Box className="relative w-[20%] h-[70vh] flex flex-col rounded-2xl overflow-y-auto gap-3 border border-gray-300 p-3">
<Box className="flex flex-col gap-3 max-h-[60vh] overflow-y-auto">
<Box
// onClick={() => setMediaType("preview")}
className="w-full bg-gray-200 p-4 rounded-2xl cursor-pointer text-sm hover:bg-gray-300 transition-all duration-300"
>
<Typography>Предпросмотр медиа</Typography>
</Box>
<ArticleEditorPane articleData={currentBlockToEdit} />
{sight[language].right.map((article, index) => (
<Box
key={index}
className="w-full bg-gray-200 p-4 rounded-2xl text-sm cursor-pointer hover:bg-gray-300 transition-all duration-300"
onClick={() => handleSelectArticle(index)}
>
<Typography>{article.heading}</Typography>
</Box>
))}
</Box>
<button
className="w-10 h-10 bg-blue-500 rounded-full absolute bottom-5 left-5 flex items-center justify-center"
onClick={handleClick}
>
<Plus size={20} color="white" />
</button>
<Menu
id="basic-menu"
anchorEl={anchorEl}
open={open}
onClose={handleClose}
MenuListProps={{
"aria-labelledby": "basic-button",
}}
sx={{ mt: 1 }}
>
<MenuItem onClick={handleCreateNew}>
<Typography>Создать новую</Typography>
</MenuItem>
<MenuItem onClick={handleSelectExisting}>
<Typography>Выбрать существующую статью</Typography>
</MenuItem>
</Menu>
</Box>
<Box className="w-[80%] border border-gray-300 rounded-2xl p-3">
{activeArticleIndex !== null && (
<>
<Box className="flex justify-end gap-2 mb-3">
<Button variant="contained" color="primary">
Открепить
</Button>
<Button variant="contained" color="success">
Удалить
</Button>
</Box>
<Box sx={{ display: "flex", gap: 3, flexGrow: 1 }}>
<Box
sx={{
flex: 2,
display: "flex",
flexDirection: "column",
gap: 2,
maxHeight: "70%",
}}
>
<TextField
label="Название информации"
value={
sight[language].right[activeArticleIndex].heading
}
onChange={(e) =>
updateRightArticleInfo(
activeArticleIndex,
language,
e.target.value,
sight[language].right[activeArticleIndex].body
)
}
variant="outlined"
fullWidth
/>
<ReactMarkdownEditor
value={
sight[language].right[activeArticleIndex].body
}
onChange={(value) =>
updateRightArticleInfo(
activeArticleIndex,
language,
sight[language].right[activeArticleIndex]
.heading,
value
)
}
/>
{/* <MediaArea
articleId={1}
mediaIds={[]}
deleteMedia={() => {}}
/> */}
</Box>
</Box>
</>
)}
</Box>
</Box>
</Box>
<Box className="w-[25%] mr-10">
{activeArticleIndex !== null && (
<Paper
className="flex-1 flex flex-col rounded-2xl"
elevation={2}
>
<Box
className="rounded-2xl overflow-hidden"
sx={{
width: "100%",
height: "75vh",
background: "#877361",
borderColor: "grey.300",
display: "flex",
flexDirection: "column",
}}
>
<Box
sx={{
width: "100%",
height: 200,
flexShrink: 0,
backgroundColor: "rgba(0,0,0,0.1)",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<ImagePlus size={48} color="white" />
</Box>
<Box
sx={{
width: "100%",
minHeight: "70px",
background: "#877361",
display: "flex",
flexShrink: 0,
alignItems: "center",
borderBottom: "1px solid rgba(255,255,255,0.1)",
px: 2,
}}
>
<Typography variant="h6" color="white">
{sight[language].right[activeArticleIndex].heading ||
"Выберите статью"}
</Typography>
</Box>
<Box
sx={{
px: 2,
flexGrow: 1,
overflowY: "auto",
backgroundColor: "#877361",
color: "white",
py: 1,
}}
>
{sight[language].right[activeArticleIndex].body ? (
<ReactMarkdownComponent
value={sight[language].right[activeArticleIndex].body}
/>
) : (
<Typography
color="rgba(255,255,255,0.7)"
sx={{ textAlign: "center", mt: 4 }}
>
Предпросмотр статьи появится здесь
</Typography>
)}
</Box>
</Box>
</Paper>
)}
</Box>
</Box>
<Box
@ -348,8 +291,8 @@ export const RightWidgetTab = observer(
bottom: 0,
right: 0,
padding: 2,
backgroundColor: "background.paper", // Ensure button is visible
width: "100%", // Cover the full width to make it a sticky footer
backgroundColor: "background.paper",
width: "100%",
display: "flex",
justifyContent: "flex-end",
}}
@ -363,8 +306,7 @@ export const RightWidgetTab = observer(
<SelectArticleModal
open={isSelectModalOpen}
onClose={handleCloseSelectModal}
onSelectArticle={handleSelectArticle}
linkedArticleIds={linkedArticleIds}
onSelectArticle={handleArticleSelect}
/>
</TabPanel>
);

View File

@ -1,3 +1,6 @@
export * from "./InformationTab";
export * from "./LeftWidgetTab";
export * from "./RightWidgetTab";
export * from "./CreateInformationTab";
export * from "./CreateLeftTab";
export * from "./CreateRightTab";

View File

@ -8,3 +8,5 @@ export * from "./LanguageSwitcher";
export * from "./DevicesTable";
export * from "./SightsTable";
export * from "./MediaViewer";
export * from "./MediaArea";
export * from "./ModelViewer3D";

View File

@ -1 +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"}
{"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/modals/uploadmediadialog/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/createsightstore/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/mediaarea/index.tsx","./src/widgets/mediaviewer/threeview.tsx","./src/widgets/mediaviewer/index.tsx","./src/widgets/modelviewer3d/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/createinformationtab/mediauploadbox.tsx","./src/widgets/sighttabs/createinformationtab/index.tsx","./src/widgets/sighttabs/createlefttab/index.tsx","./src/widgets/sighttabs/createrighttab/index.tsx","./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"],"version":"5.8.3"}

View File

@ -1323,6 +1323,11 @@ asynckit@^0.4.0:
resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79"
integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==
attr-accept@^2.2.4:
version "2.2.5"
resolved "https://registry.yarnpkg.com/attr-accept/-/attr-accept-2.2.5.tgz#d7061d958e6d4f97bf8665c68b75851a0713ab5e"
integrity sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==
axios@^1.9.0:
version "1.9.0"
resolved "https://registry.yarnpkg.com/axios/-/axios-1.9.0.tgz#25534e3b72b54540077d33046f77e3b8d7081901"
@ -1908,6 +1913,13 @@ file-entry-cache@^8.0.0:
dependencies:
flat-cache "^4.0.0"
file-selector@^2.1.0:
version "2.1.2"
resolved "https://registry.yarnpkg.com/file-selector/-/file-selector-2.1.2.tgz#fe7c7ee9e550952dfbc863d73b14dc740d7de8b4"
integrity sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig==
dependencies:
tslib "^2.7.0"
fill-range@^7.1.1:
version "7.1.1"
resolved "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz"
@ -3098,6 +3110,15 @@ react-dom@^19.1.0:
dependencies:
scheduler "^0.26.0"
react-dropzone@^14.3.8:
version "14.3.8"
resolved "https://registry.yarnpkg.com/react-dropzone/-/react-dropzone-14.3.8.tgz#a7eab118f8a452fe3f8b162d64454e81ba830582"
integrity sha512-sBgODnq+lcA4P296DY4wacOZz3JFpD99fp+hb//iBO2HHnyeZU3FwWyXJ6salNpqQdsZrgMrotuko/BdJMV8Ug==
dependencies:
attr-accept "^2.2.4"
file-selector "^2.1.0"
prop-types "^15.8.1"
react-is@^16.13.1, react-is@^16.7.0:
version "16.13.1"
resolved "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz"
@ -3501,7 +3522,7 @@ ts-api-utils@^2.1.0:
resolved "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz"
integrity sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==
tslib@^2.4.0, tslib@^2.8.0:
tslib@^2.4.0, tslib@^2.7.0, tslib@^2.8.0:
version "2.8.1"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f"
integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==