fix: Language cache sight
This commit is contained in:
@ -1 +1,9 @@
|
||||
export const API_URL = "https://wn.krbl.ru";
|
||||
export const MEDIA_TYPE_LABELS = {
|
||||
1: "Фото",
|
||||
2: "Видео",
|
||||
3: "Иконка",
|
||||
4: "Водяной знак",
|
||||
5: "Панорама",
|
||||
6: "3Д-модель",
|
||||
};
|
||||
|
@ -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,
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
229
src/shared/modals/PreviewMediaDialog/index.tsx
Normal file
229
src/shared/modals/PreviewMediaDialog/index.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
@ -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>
|
||||
);
|
||||
|
178
src/shared/modals/SelectMediaDialog/index.tsx
Normal file
178
src/shared/modals/SelectMediaDialog/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
);
|
@ -1 +1,3 @@
|
||||
export * from "./SelectArticleDialog";
|
||||
export * from "./SelectMediaDialog";
|
||||
export * from "./PreviewMediaDialog";
|
||||
|
@ -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();
|
||||
|
151
src/shared/store/EditSightStore/index.tsx
Normal file
151
src/shared/store/EditSightStore/index.tsx
Normal 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();
|
27
src/shared/store/MediaStore/index.tsx
Normal file
27
src/shared/store/MediaStore/index.tsx
Normal 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();
|
@ -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();
|
||||
|
@ -6,3 +6,5 @@ export * from "./SnapshotStore";
|
||||
export * from "./SightsStore";
|
||||
export * from "./CityStore";
|
||||
export * from "./ArticlesStore";
|
||||
export * from "./EditSightStore";
|
||||
export * from "./MediaStore";
|
||||
|
@ -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({
|
||||
|
Reference in New Issue
Block a user