fix: Language cache sight

This commit is contained in:
2025-05-31 21:17:27 +03:00
parent 2e6917406e
commit 0d9bbb140f
28 changed files with 2760 additions and 1013 deletions

View File

@ -1 +1,9 @@
export const API_URL = "https://wn.krbl.ru";
export const MEDIA_TYPE_LABELS = {
1: "Фото",
2: "Видео",
3: "Иконка",
4: "Водяной знак",
5: "Панорама",
6: "3Д-модель",
};

View File

@ -1,17 +1,45 @@
import { createTheme } from "@mui/material/styles";
export const theme = createTheme({
// You can customize your theme here
export const COLORS = {
primary: "#7f6b58",
secondary: "#48989f",
};
const theme = {
palette: {
mode: "light",
primary: {
main: COLORS.primary,
},
secondary: {
main: COLORS.secondary,
},
},
components: {
MuiDrawer: {
MuiAppBar: {
styleOverrides: {
paper: {
backgroundColor: "#fff",
root: {
backgroundColor: COLORS.secondary,
},
},
},
},
});
};
export const CustomTheme = {
Light: createTheme({
palette: {
...theme.palette,
},
components: {
...theme.components,
},
}),
Dark: createTheme({
palette: {
...theme.palette,
},
components: {
...theme.components,
},
}),
};

View File

@ -0,0 +1,229 @@
import {
articlesStore,
authStore,
Language,
mediaStore,
MEDIA_TYPE_LABELS,
API_URL,
} from "@shared";
import { observer } from "mobx-react-lite";
import { useEffect, useRef, useState, useCallback } from "react";
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
TextField,
Paper,
Box,
Typography,
CircularProgress,
Alert,
Snackbar,
} from "@mui/material";
import { Download, Save } from "lucide-react";
import { ReactMarkdownComponent, MediaViewer } from "@widgets";
import { authInstance } from "@shared";
interface PreviewMediaDialogProps {
open: boolean;
onClose: () => void;
mediaId: string;
}
export const PreviewMediaDialog = observer(
({ open, onClose, mediaId }: PreviewMediaDialogProps) => {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const media = mediaId
? mediaStore.media.find((m) => m.id === mediaId)
: null;
const [mediaName, setMediaName] = useState(media?.media_name ?? "");
const [mediaFilename, setMediaFilename] = useState(media?.filename ?? "");
// Reset form when media changes
useEffect(() => {
if (media) {
setMediaName(media.media_name);
setMediaFilename(media.filename);
}
}, [media]);
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 (!mediaId) return;
setIsLoading(true);
setError(null);
try {
await authInstance.patch(`/media/${mediaId}`, {
media_name: mediaName,
filename: mediaFilename,
type: media?.media_type,
});
// Update local store
await mediaStore.getMedia();
setSuccess(true);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to save media");
} finally {
setIsLoading(false);
}
};
const handleClose = () => {
setError(null);
setSuccess(false);
onClose();
};
if (!media) {
return null;
}
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>
<TextField
fullWidth
label="Тип медиа"
value={
MEDIA_TYPE_LABELS[
media.media_type as keyof typeof MEDIA_TYPE_LABELS
]
}
disabled
sx={{ width: "50%" }}
/>
<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: mediaId,
media_type: media.media_type,
filename: media.filename,
}}
/>
</Paper>
<Box className="flex flex-col gap-2 self-end">
<Button
variant="contained"
color="primary"
startIcon={<Download size={16} />}
component="a"
href={`${
import.meta.env.VITE_KRBL_MEDIA
}${mediaId}/download?token=${localStorage.getItem(
"token"
)}`}
target="_blank"
disabled={isLoading}
>
Скачать
</Button>
<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,6 +1,6 @@
import { articlesStore } from "@shared";
import { articlesStore, authStore, Language } from "@shared";
import { observer } from "mobx-react-lite";
import { useEffect, useRef, useState } from "react";
import { useEffect, useState } from "react";
import {
Dialog,
DialogTitle,
@ -31,38 +31,56 @@ export const SelectArticleModal = observer(
open,
onClose,
onSelectArticle,
linkedArticleIds = [], // Default to empty array if not provided
linkedArticleIds = [],
}: SelectArticleModalProps) => {
const { articles, getArticle } = articlesStore; // articles here refers to fetched articles from store
const { articles, getArticle, getArticleMedia } = articlesStore;
const [searchQuery, setSearchQuery] = useState("");
const [hoveredArticleId, setHoveredArticleId] = useState<string | null>(
const [selectedArticleId, setSelectedArticleId] = useState<string | null>(
null
);
const hoverTimerRef = useRef<NodeJS.Timeout | null>(null);
const [isLoading, setIsLoading] = useState(false);
// Reset selection when modal opens/closes
useEffect(() => {
if (open) {
setSelectedArticleId(null);
articlesStore.articleData = null;
articlesStore.articleMedia = null;
}
}, [open]);
useEffect(() => {
if (hoveredArticleId) {
hoverTimerRef.current = setTimeout(() => {
getArticle(hoveredArticleId);
}, 200);
}
return () => {
if (hoverTimerRef.current) {
clearTimeout(hoverTimerRef.current);
const handleKeyPress = (event: KeyboardEvent) => {
if (event.key.toLowerCase() === "enter") {
event.preventDefault();
if (selectedArticleId) {
onSelectArticle(selectedArticleId);
onClose();
}
}
};
}, [hoveredArticleId, getArticle]);
const handleArticleHover = (articleId: string) => {
setHoveredArticleId(articleId);
};
window.addEventListener("keydown", handleKeyPress);
return () => {
window.removeEventListener("keydown", handleKeyPress);
};
}, [selectedArticleId, onSelectArticle, onClose]);
const handleArticleLeave = () => {
setHoveredArticleId(null);
if (hoverTimerRef.current) {
clearTimeout(hoverTimerRef.current);
const handleArticleClick = async (articleId: string) => {
if (selectedArticleId === articleId) return;
setSelectedArticleId(articleId);
setIsLoading(true);
try {
await Promise.all([getArticle(articleId), getArticleMedia(articleId)]);
} catch (error) {
console.error("Failed to fetch article data:", error);
// Reset article data on error
articlesStore.articleData = null;
articlesStore.articleMedia = null;
} finally {
setIsLoading(false);
}
};
@ -72,21 +90,38 @@ export const SelectArticleModal = observer(
article.service_name.toLowerCase().includes(searchQuery.toLowerCase())
);
const token = localStorage.getItem("token");
return (
<Dialog open={open} onClose={onClose} maxWidth="lg" fullWidth>
<Dialog
open={open}
onClose={onClose}
maxWidth="lg"
fullWidth
PaperProps={{
sx: {
minHeight: "80vh",
maxHeight: "90vh",
},
}}
>
<DialogTitle>Выберите существующую статью</DialogTitle>
<DialogContent
className="flex gap-4"
dividers // Adds a divider below the title and above the actions
sx={{ height: "600px", display: "flex", flexDirection: "row" }} // Fixed height for DialogContent
dividers
sx={{
height: "600px",
display: "flex",
flexDirection: "row",
p: 2,
}}
>
<Paper className="w-[66%] flex flex-col">
<Paper className="w-[66%] flex flex-col" elevation={2}>
<TextField
fullWidth
placeholder="Поиск статей..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
sx={{ mb: 2, mt: 1 }}
sx={{ mb: 2, mt: 1, px: 2 }}
InputProps={{
startAdornment: (
<InputAdornment position="start">
@ -95,27 +130,51 @@ export const SelectArticleModal = observer(
),
}}
/>
<List sx={{ flexGrow: 1, overflowY: "auto" }}>
{filteredArticles.map((article) => (
<ListItemButton
key={article.id}
onClick={() => onSelectArticle(article.id)}
onMouseEnter={() => handleArticleHover(article.id)}
onMouseLeave={handleArticleLeave}
sx={{
borderRadius: 1,
mb: 0.5,
"&:hover": {
backgroundColor: "action.hover",
},
}}
<List sx={{ flexGrow: 1, overflowY: "auto", px: 2 }}>
{filteredArticles.length === 0 ? (
<Typography
variant="body2"
color="text.secondary"
sx={{ p: 2, textAlign: "center" }}
>
<ListItemText primary={article.service_name} />
</ListItemButton>
))}
{searchQuery ? "Статьи не найдены" : "Нет доступных статей"}
</Typography>
) : (
filteredArticles.map((article) => (
<ListItemButton
key={article.id}
onClick={() => handleArticleClick(article.id)}
onDoubleClick={() => onSelectArticle(article.id)}
selected={selectedArticleId === article.id}
disabled={isLoading}
sx={{
borderRadius: 1,
mb: 0.5,
"&:hover": {
backgroundColor: "action.hover",
},
"&.Mui-selected": {
backgroundColor: "primary.main",
color: "primary.contrastText",
"&:hover": {
backgroundColor: "primary.dark",
},
},
}}
>
<ListItemText
primary={article.service_name}
primaryTypographyProps={{
fontWeight:
selectedArticleId === article.id ? "bold" : "normal",
}}
/>
</ListItemButton>
))
)}
</List>
</Paper>
<Paper className="flex-1 flex flex-col">
<Paper className="flex-1 flex flex-col" elevation={2}>
<Box
className="rounded-2xl overflow-hidden"
sx={{
@ -127,60 +186,109 @@ export const SelectArticleModal = observer(
flexDirection: "column",
}}
>
{/* Media Preview Area */}
<Box
sx={{
width: "100%",
height: 200,
flexShrink: 0,
backgroundColor: "grey.300",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<ImagePlus size={48} color="grey" />
</Box>
{isLoading ? (
<Box
sx={{
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Typography color="white">Загрузка...</Typography>
</Box>
) : (
<>
{articlesStore.articleMedia && (
<Box sx={{ p: 2, backgroundColor: "rgba(0,0,0,0.1)" }}>
<img
src={`${import.meta.env.VITE_KRBL_MEDIA}${
articlesStore.articleMedia.id
}/download?token=${token}`}
alt={articlesStore.articleMedia.filename}
style={{
maxWidth: "100%",
height: "auto",
maxHeight: "300px",
objectFit: "contain",
borderRadius: 8,
}}
/>
</Box>
)}
{!articlesStore.articleMedia && (
<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>
)}
{/* Title Area */}
<Box
sx={{
width: "100%",
height: "70px",
background: "#877361",
display: "flex",
flexShrink: 0,
alignItems: "center",
borderBottom: "1px solid",
px: 2,
}}
>
<Typography variant="h6" color="white">
{articlesStore.articleData?.heading ||
"Нет данных для предпросмотра"}
</Typography>
</Box>
<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">
{articlesStore.articleData?.heading || "Выберите статью"}
</Typography>
</Box>
{/* Body Preview Area */}
<Box
sx={{
px: 2,
flexGrow: 1,
overflowY: "auto",
backgroundColor: "#877361", // To make markdown readable
color: "white",
py: 1,
}}
>
<ReactMarkdownComponent
value={articlesStore.articleData?.body || ""}
/>
</Box>
<Box
sx={{
px: 2,
flexGrow: 1,
overflowY: "auto",
backgroundColor: "#877361",
color: "white",
py: 1,
}}
>
{articlesStore.articleData?.body ? (
<ReactMarkdownComponent
value={articlesStore.articleData.body}
/>
) : (
<Typography
color="rgba(255,255,255,0.7)"
sx={{ textAlign: "center", mt: 4 }}
>
Предпросмотр статьи появится здесь
</Typography>
)}
</Box>
</>
)}
</Box>
</Paper>
</DialogContent>
<DialogActions>
<DialogActions sx={{ p: 2 }}>
<Button onClick={onClose}>Отмена</Button>
<Button
variant="contained"
onClick={() =>
selectedArticleId && onSelectArticle(selectedArticleId)
}
disabled={!selectedArticleId || isLoading}
>
Выбрать
</Button>
</DialogActions>
</Dialog>
);

View File

@ -0,0 +1,178 @@
import { articlesStore, authStore, Language, mediaStore } from "@shared";
import { observer } from "mobx-react-lite";
import { useEffect, useRef, useState } from "react";
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
TextField,
List,
ListItemButton,
ListItemText,
Paper,
Box,
Typography,
InputAdornment,
} from "@mui/material";
import { ImagePlus, Search } from "lucide-react";
import { ReactMarkdownComponent, MediaViewer } from "@widgets";
interface SelectMediaDialogProps {
open: boolean; // Corrected prop name
onClose: () => void;
onSelectMedia: (mediaId: string) => void; // Renamed from onSelectArticle
linkedMediaIds?: string[]; // Renamed from linkedArticleIds, assuming it refers to media already in use
}
export const SelectMediaDialog = observer(
({
open, // Corrected prop name
onClose,
onSelectMedia, // Renamed prop
linkedMediaIds = [], // Default to empty array if not provided, renamed
}: SelectMediaDialogProps) => {
const { media, getMedia } = mediaStore; // Confirmed: using mediaStore for media
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
// Keyboard event listener for "Enter" key to select hovered media
useEffect(() => {
const handleKeyPress = (event: KeyboardEvent) => {
if (event.key === "Enter") {
event.preventDefault(); // Prevent browser default action (e.g., form submission)
if (hoveredMediaId) {
onSelectMedia(hoveredMediaId); // Call onSelectMedia
onClose();
}
}
};
window.addEventListener("keydown", handleKeyPress);
return () => {
window.removeEventListener("keydown", handleKeyPress);
};
}, [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) =>
mediaItem.media_name.toLowerCase().includes(searchQuery.toLowerCase())
);
// Find the currently hovered media object for MediaViewer
const currentHoveredMedia = hoveredMediaId
? media.find((m) => m.id === hoveredMediaId)
: null;
return (
<Dialog open={open} onClose={onClose} maxWidth="lg" fullWidth>
<DialogTitle>Выберите существующее медиа</DialogTitle>
<DialogContent
className="flex gap-4"
dividers // Adds a divider below the title and above the actions
sx={{ height: "600px", display: "flex", flexDirection: "row" }} // Fixed height for DialogContent
>
<Paper className="w-[66%] flex flex-col" sx={{ p: 2 }}>
{" "}
{/* Added padding for consistency */}
<TextField
fullWidth
placeholder="Поиск медиа..." // Changed placeholder for clarity
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
sx={{ mb: 2, mt: 1 }}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<Search size={20} />
</InputAdornment>
),
}}
/>
<List sx={{ flexGrow: 1, overflowY: "auto" }}>
{filteredMedia.length > 0 ? (
filteredMedia.map(
(
mediaItem // Use mediaItem to avoid confusion
) => (
<ListItemButton
key={mediaItem.id}
onClick={() => onSelectMedia(mediaItem.id)} // Call onSelectMedia
onMouseEnter={() => handleMouseEnter(mediaItem.id)}
onMouseLeave={handleMouseLeave}
sx={{
borderRadius: 1,
mb: 0.5,
"&:hover": {
backgroundColor: "action.hover",
},
}}
>
<ListItemText primary={mediaItem.media_name} />
</ListItemButton>
)
)
) : (
<Typography
sx={{ mt: 2, textAlign: "center" }}
color="text.secondary"
>
Медиа не найдено или все медиа уже прикреплены.
</Typography>
)}
</List>
</Paper>
{currentHoveredMedia ? ( // Only render MediaViewer if currentHoveredMedia is found
<Paper className="w-[33%] h-[100%] flex justify-center items-center">
<MediaViewer
media={{
id: currentHoveredMedia.id,
media_type: currentHoveredMedia.media_type ?? 1, // Provide a default if media_type can be undefined
filename: currentHoveredMedia.filename,
}}
/>
</Paper>
) : (
<Paper className="w-[33%] h-[100%] flex justify-center items-center">
<Typography variant="body2" color="text.secondary">
Наведите на медиа в списке для предпросмотра.
</Typography>
</Paper>
)}
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Отмена</Button>
</DialogActions>
</Dialog>
);
}
);

View File

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

View File

@ -1,36 +1,78 @@
import { authInstance } from "@shared";
import { makeAutoObservable, runInAction } from "mobx";
import { authInstance, editSightStore, Language, languageStore } from "@shared";
import { computed, makeAutoObservable, runInAction } from "mobx";
export type Article = {
id: string;
id: number;
heading: string;
body: string;
service_name: string;
};
type Media = {
id: string;
filename: string;
media_name: string;
media_type: number;
};
class ArticlesStore {
constructor() {
makeAutoObservable(this);
}
articles: Article[] = [];
articles: { [key in Language]: Article[] } = {
ru: [],
en: [],
zh: [],
};
articleData: Article | null = null;
articleMedia: Media | null = null;
articleLoading: boolean = false;
getArticles = async () => {
getArticles = async (language: Language) => {
this.articleLoading = true;
const response = await authInstance.get("/article");
runInAction(() => {
this.articles = response.data;
this.articles[language] = response.data;
});
this.articleLoading = false;
};
getArticle = async (id: string) => {
getArticle = async (id: number) => {
this.articleLoading = true;
const response = await authInstance.get(`/article/${id}`);
runInAction(() => {
this.articleData = response.data;
});
this.articleLoading = false;
};
getSightArticles = async (id: number) => {
const response = await authInstance.get(`/sight/${id}/article`);
runInAction(() => {
editSightStore.sightInfo[languageStore.language].right = response.data;
});
};
getArticleMedia = async (id: number) => {
const response = await authInstance.get(`/article/${id}/media`);
runInAction(() => {
this.articleMedia = response.data[0];
});
};
getArticleByArticleId = computed(() => {
if (editSightStore.sightInfo.left_article) {
return this.articles[languageStore.language].find(
(a) => a.id == editSightStore.sightInfo.left_article
);
}
return null;
});
}
export const articlesStore = new ArticlesStore();

View File

@ -0,0 +1,151 @@
// @shared/stores/editSightStore.ts
import { Language } from "@shared";
import { makeAutoObservable } from "mobx";
export interface MediaObject {
id: string;
filename: string;
media_type: number;
}
type SightBaseInfo = {
id: number;
city_id: number;
city: string;
latitude: number;
longitude: number;
thumbnail: string;
watermark_lu: string;
watermark_rd: string;
left_article: number;
preview_media: string;
video_preview: string;
};
export interface RightArticleBlock {
id: string;
type: "article" | "preview_media";
name: string;
linkedArticleId?: string;
heading: string;
body: string;
media: MediaObject | null;
}
type SightInfo = SightBaseInfo & {
[key in Language]: {
info: {
name: string;
address: string;
};
left: {
loaded: boolean; // Означает, что данные для этого языка были инициализированы/загружены
heading: string;
body: string;
media: MediaObject | null;
};
right: RightArticleBlock[];
};
};
class EditSightStore {
sightInfo: SightInfo = {
id: 0,
city_id: 0,
city: "",
latitude: 0,
longitude: 0,
thumbnail: "",
watermark_lu: "",
watermark_rd: "",
left_article: 0,
preview_media: "",
video_preview: "",
ru: {
info: { name: "", address: "" },
left: { loaded: false, heading: "", body: "", media: null },
right: [],
},
en: {
info: { name: "", address: "" },
left: { loaded: false, heading: "", body: "", media: null },
right: [],
},
zh: {
info: { name: "", address: "" },
left: { loaded: false, heading: "", body: "", media: null },
right: [],
},
};
constructor() {
makeAutoObservable(this);
}
// loadSightInfo: Используется для первоначальной загрузки данных для ЯЗЫКА.
// Она устанавливает loaded: true, чтобы в будущем не перезатирать данные.
loadSightInfo = (
language: Language,
heading: string,
body: string,
media: MediaObject | null
) => {
// Важно: если данные уже были загружены или изменены, не перезаписывайте их.
// Это предотвращает потерю пользовательского ввода при переключении языков.
// Если хотите принудительную загрузку, добавьте другой метод или параметр.
if (!this.sightInfo[language].left.loaded) {
// <--- Только если еще не загружено
this.sightInfo[language].left.heading = heading;
this.sightInfo[language].left.body = body;
this.sightInfo[language].left.media = media;
this.sightInfo[language].left.loaded = true; // <--- Устанавливаем loaded только при загрузке
}
};
// updateSightInfo: Используется для сохранения ЛЮБЫХ пользовательских изменений.
// Она НЕ должна влиять на флаг 'loaded', который управляется 'loadSightInfo'.
updateSightInfo = (
language: Language,
heading: string,
body: string,
media: MediaObject | null
) => {
this.sightInfo[language].left.heading = heading;
this.sightInfo[language].left.body = body;
this.sightInfo[language].left.media = media;
// this.sightInfo[language].left.loaded = true; // <-- УДАЛИТЕ эту строку
};
clearSightInfo = () => {
this.sightInfo = {
id: 0,
city_id: 0,
city: "",
latitude: 0,
longitude: 0,
thumbnail: "",
watermark_lu: "",
watermark_rd: "",
left_article: 0,
preview_media: "",
video_preview: "",
ru: {
info: { name: "", address: "" },
left: { loaded: false, heading: "", body: "", media: null },
right: [],
},
en: {
info: { name: "", address: "" },
left: { loaded: false, heading: "", body: "", media: null },
right: [],
},
zh: {
info: { name: "", address: "" },
left: { loaded: false, heading: "", body: "", media: null },
right: [],
},
};
};
}
export const editSightStore = new EditSightStore();

View File

@ -0,0 +1,27 @@
import { makeAutoObservable, runInAction } from "mobx";
import { authInstance } from "@shared";
type Media = {
id: string;
filename: string;
media_name: string;
media_type: number;
};
class MediaStore {
media: Media[] = [];
constructor() {
makeAutoObservable(this);
}
getMedia = async () => {
const response = await authInstance.get("/media");
runInAction(() => {
this.media = [...response.data];
});
};
}
export const mediaStore = new MediaStore();

View File

@ -1,12 +1,17 @@
import { authInstance, languageInstance, languageStore } from "@shared";
import { makeAutoObservable, runInAction } from "mobx";
import {
articlesStore,
authInstance,
languageInstance,
languageStore,
editSightStore,
} from "@shared";
import { computed, makeAutoObservable, runInAction } from "mobx";
export type Language = "ru" | "en" | "zh";
export type MultilingualContent = {
[key in Language]: {
name: string;
description: string;
address: string;
};
};
@ -30,7 +35,6 @@ export type Sight = {
export type CreateSight = {
[key in Language]: {
name: string;
description: string;
address: string;
};
};
@ -39,9 +43,9 @@ class SightsStore {
sights: Sight[] = [];
sight: Sight | null = null;
createSight: CreateSight = {
ru: { name: "", description: "", address: "" },
en: { name: "", description: "", address: "" },
zh: { name: "", description: "", address: "" },
ru: { name: "", address: "" },
en: { name: "", address: "" },
zh: { name: "", address: "" },
};
constructor() {
@ -60,6 +64,41 @@ class SightsStore {
runInAction(() => {
this.sight = response.data;
editSightStore.sightInfo = {
...editSightStore.sightInfo,
id: response.data.id,
city_id: response.data.city_id,
city: response.data.city,
latitude: response.data.latitude,
longitude: response.data.longitude,
thumbnail: response.data.thumbnail,
watermark_lu: response.data.watermark_lu,
watermark_rd: response.data.watermark_rd,
left_article: response.data.left_article,
preview_media: response.data.preview_media,
video_preview: response.data.video_preview,
[languageStore.language]: {
info: {
name: response.data.name,
address: response.data.address,
description: response.data.description,
},
left: {
heading: editSightStore.sightInfo[languageStore.language].left
.loaded
? editSightStore.sightInfo[languageStore.language].left.heading
: articlesStore.articles[languageStore.language].find(
(article) => article.id === response.data.left_article
)?.heading,
body: editSightStore.sightInfo[languageStore.language].left.loaded
? editSightStore.sightInfo[languageStore.language].left.body
: articlesStore.articles[languageStore.language].find(
(article) => article.id === response.data.left_article
)?.body,
},
},
};
console.log(editSightStore.sightInfo);
});
};
@ -70,7 +109,6 @@ class SightsStore {
const id = (
await authInstance.post("/sight", {
name: this.createSight[languageStore.language].name,
description: this.createSight[languageStore.language].description,
address: this.createSight[languageStore.language].address,
city_id: city,
latitude: coordinates.latitude,
@ -86,8 +124,6 @@ class SightsStore {
`/sight/${id}`,
{
name: this.createSight[anotherLanguages[0] as Language].name,
description:
this.createSight[anotherLanguages[0] as Language].description,
address: this.createSight[anotherLanguages[0] as Language].address,
city_id: city,
latitude: coordinates.latitude,
@ -99,8 +135,6 @@ class SightsStore {
`/sight/${id}`,
{
name: this.createSight[anotherLanguages[1] as Language].name,
description:
this.createSight[anotherLanguages[1] as Language].description,
address: this.createSight[anotherLanguages[1] as Language].address,
city_id: city,
latitude: coordinates.latitude,
@ -110,9 +144,9 @@ class SightsStore {
runInAction(() => {
this.createSight = {
ru: { name: "", description: "", address: "" },
en: { name: "", description: "", address: "" },
zh: { name: "", description: "", address: "" },
ru: { name: "", address: "" },
en: { name: "", address: "" },
zh: { name: "", address: "" },
};
});
};
@ -139,22 +173,41 @@ class SightsStore {
this.createSight = {
ru: {
name: "",
description: "",
address: "",
},
en: {
name: "",
description: "",
address: "",
},
zh: {
name: "",
description: "",
address: "",
},
};
});
};
sightData = computed(() => {
return {
name: this.sight?.name,
address: this.sight?.address,
city_id: this.sight?.city_id,
latitude: this.sight?.latitude,
longitude: this.sight?.longitude,
thumbnail: this.sight?.thumbnail,
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,
[languageStore.language]: {
info: {
name: this.sight?.name,
address: this.sight?.address,
},
},
};
});
}
export const sightsStore = new SightsStore();

View File

@ -6,3 +6,5 @@ export * from "./SnapshotStore";
export * from "./SightsStore";
export * from "./CityStore";
export * from "./ArticlesStore";
export * from "./EditSightStore";
export * from "./MediaStore";

View File

@ -2,11 +2,15 @@ import { Box, TextField } from "@mui/material";
import { useEffect, useState } from "react";
export const CoordinatesInput = ({
initialValue,
setValue,
}: {
initialValue: { latitude: number; longitude: number };
setValue: (value: { latitude: number; longitude: number }) => void;
}) => {
const [inputValue, setInputValue] = useState<string>("");
const [inputValue, setInputValue] = useState<string>(
`${initialValue.latitude} ${initialValue.longitude}`
);
useEffect(() => {
setValue({