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

3
.env
View File

@ -1 +1,2 @@
VITE_REACT_APP = 'https://wn.krbl.ru/'
VITE_REACT_APP ='https://wn.krbl.ru/'
VITE_KRBL_MEDIA='https://wn.krbl.ru/media/'

View File

@ -13,6 +13,9 @@
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@mui/material": "^7.1.0",
"@photo-sphere-viewer/core": "^5.13.2",
"@react-three/drei": "^10.1.2",
"@react-three/fiber": "^9.1.2",
"@tailwindcss/vite": "^4.1.8",
"axios": "^1.9.0",
"easymde": "^2.20.0",
@ -23,11 +26,13 @@
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-markdown": "^10.1.0",
"react-photo-sphere-viewer": "^6.2.3",
"react-router-dom": "^7.6.1",
"react-simplemde-editor": "^5.2.0",
"react-toastify": "^11.0.5",
"rehype-raw": "^7.0.0",
"tailwindcss": "^4.1.8"
"tailwindcss": "^4.1.8",
"three": "^0.177.0"
},
"devDependencies": {
"@eslint/js": "^9.25.0",

View File

@ -3,12 +3,12 @@ import * as React from "react";
import { BrowserRouter } from "react-router-dom";
import { Router } from "./router";
import { theme } from "@shared";
import { CustomTheme } from "@shared";
import { ThemeProvider } from "@mui/material/styles";
import { ToastContainer } from "react-toastify";
export const App: React.FC = () => (
<ThemeProvider theme={theme}>
<ThemeProvider theme={CustomTheme.Light}>
<ToastContainer />
<BrowserRouter>
<Router />

View File

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

View File

@ -16,7 +16,7 @@ function a11yProps(index: number) {
export const EditSightPage = observer(() => {
const [value, setValue] = useState(0);
const { sight, getSight } = sightsStore;
const { getArticles } = articlesStore;
const { articles, getArticles } = articlesStore;
const { language } = languageStore;
const { id } = useParams();
@ -28,53 +28,54 @@ export const EditSightPage = observer(() => {
const fetchData = async () => {
if (id) {
await getSight(Number(id));
await getArticles();
await getArticles(language);
}
};
fetchData();
}, [id, language]);
return (
<Box
sx={{
width: "100%",
display: "flex",
flexDirection: "column",
minHeight: "100vh",
}}
>
articles &&
sight && (
<Box
sx={{
borderBottom: 1,
borderColor: "divider",
width: "100%",
display: "flex",
justifyContent: "center",
flexDirection: "column",
minHeight: "100vh",
}}
>
<Tabs
value={value}
onChange={handleChange}
aria-label="sight tabs"
<Box
sx={{
width: "100%",
"& .MuiTabs-flexContainer": {
justifyContent: "center",
},
borderBottom: 1,
borderColor: "divider",
display: "flex",
justifyContent: "center",
}}
>
<Tab sx={{ flex: 1 }} label="Общая информация" {...a11yProps(0)} />
<Tab sx={{ flex: 1 }} label="Левый виджет" {...a11yProps(1)} />
<Tab sx={{ flex: 1 }} label="Правый виджет" {...a11yProps(2)} />
</Tabs>
</Box>
<Tabs
value={value}
onChange={handleChange}
aria-label="sight tabs"
sx={{
width: "100%",
"& .MuiTabs-flexContainer": {
justifyContent: "center",
},
}}
>
<Tab sx={{ flex: 1 }} label="Общая информация" {...a11yProps(0)} />
<Tab sx={{ flex: 1 }} label="Левый виджет" {...a11yProps(1)} />
<Tab sx={{ flex: 1 }} label="Правый виджет" {...a11yProps(2)} />
</Tabs>
</Box>
{sight && (
<div className="flex-1">
<InformationTab value={value} index={0} />
<LeftWidgetTab data={sight} value={value} index={1} />
<RightWidgetTab data={sight} value={value} index={2} />
<LeftWidgetTab value={value} index={1} />
<RightWidgetTab value={value} index={2} />
</div>
)}
</Box>
</Box>
)
);
});

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({

View File

@ -45,7 +45,7 @@ export const LanguageSwitcher = observer(() => {
};
return (
<div className="fixed top-1/2 -translate-y-1/2 right-0 flex flex-col gap-2 p-4 ">
<div className="fixed top-1/2 -translate-y-1/2 right-0 flex flex-col gap-2 p-4 z-10 ">
{/* Added some styling for better visualization */}
{LANGUAGES.map((lang) => (
<Button

View File

@ -0,0 +1,22 @@
import { Canvas } from "@react-three/fiber";
import { OrbitControls, Stage, useGLTF } from "@react-three/drei";
type ModelViewerProps = {
fileUrl: string;
height?: string;
};
export const ThreeView = ({ fileUrl, height = "100%" }: ModelViewerProps) => {
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

@ -0,0 +1,107 @@
import { Box } from "@mui/material";
import { ReactPhotoSphereViewer } from "react-photo-sphere-viewer";
import { ThreeView } from "./ThreeView";
export interface MediaData {
id: string | number;
media_type: number;
filename?: string;
}
export function MediaViewer({ media }: Readonly<{ media?: MediaData }>) {
const token = localStorage.getItem("token");
return (
<Box
sx={{
width: "80%",
height: "100%",
maxWidth: "600px",
display: "flex",
flexGrow: 1,
justifyContent: "center",
margin: "0 auto",
}}
>
{media?.media_type === 1 && (
<img
src={`${import.meta.env.VITE_KRBL_MEDIA}${
media?.id
}/download?token=${token}`}
alt={media?.filename}
style={{
maxWidth: "100%",
height: "auto",
objectFit: "contain",
borderRadius: 8,
}}
/>
)}
{media?.media_type === 2 && (
<video
src={`${import.meta.env.VITE_KRBL_MEDIA}${
media?.id
}/download?token=${token}`}
style={{
margin: "auto 0",
height: "fit-content",
width: "fit-content",
objectFit: "contain",
borderRadius: 30,
}}
controls
autoPlay
muted
/>
)}
{media?.media_type === 3 && (
<img
src={`${import.meta.env.VITE_KRBL_MEDIA}${
media?.id
}/download?token=${token}`}
alt={media?.filename}
style={{
maxWidth: "100%",
height: "100%",
objectFit: "contain",
borderRadius: 8,
}}
/>
)}
{media?.media_type === 4 && (
<img
src={`${import.meta.env.VITE_KRBL_MEDIA}${
media?.id
}/download?token=${token}`}
alt={media?.filename}
style={{
maxWidth: "100%",
height: "100%",
objectFit: "contain",
borderRadius: 8,
}}
/>
)}
{media?.media_type === 5 && (
<ReactPhotoSphereViewer
src={`${import.meta.env.VITE_KRBL_MEDIA}${
media?.id
}/download?token=${token}`}
width={"100%"}
height={"100%"}
/>
)}
{media?.media_type === 6 && (
<ThreeView
fileUrl={`${import.meta.env.VITE_KRBL_MEDIA}${
media?.id
}/download?token=${token}`}
height="100%"
/>
)}
</Box>
);
}

View File

@ -2,58 +2,58 @@ import { styled } from "@mui/material/styles";
import SimpleMDE, { SimpleMDEReactProps } from "react-simplemde-editor";
import "easymde/dist/easymde.min.css";
const StyledMarkdownEditor = styled("div")(() => ({
const StyledMarkdownEditor = styled("div")(({ theme }) => ({
"& .editor-toolbar": {
backgroundColor: "inherit",
borderColor: "inherit",
backgroundColor: theme.palette.background.paper,
borderColor: theme.palette.divider,
},
"& .editor-toolbar button": {
color: "inherit",
color: theme.palette.text.primary,
},
"& .editor-toolbar button:hover": {
backgroundColor: "inherit",
backgroundColor: theme.palette.action.hover,
},
"& .editor-toolbar button:active, & .editor-toolbar button.active": {
backgroundColor: "inherit",
backgroundColor: theme.palette.action.selected,
},
"& .editor-statusbar": {
display: "none",
},
// Стили для самого редактора
"& .CodeMirror": {
backgroundColor: "inherit",
color: "inherit",
borderColor: "inherit",
backgroundColor: theme.palette.background.paper,
color: theme.palette.text.primary,
borderColor: theme.palette.divider,
},
// Стили для текста в редакторе
"& .CodeMirror-selected": {
backgroundColor: "inherit !important",
backgroundColor: `${theme.palette.action.selected} !important`,
},
"& .CodeMirror-cursor": {
borderLeftColor: "inherit",
borderLeftColor: theme.palette.text.primary,
},
// Стили для markdown разметки
"& .cm-header": {
color: "inherit",
color: theme.palette.primary.main,
},
"& .cm-quote": {
color: "inherit",
color: theme.palette.text.secondary,
fontStyle: "italic",
},
"& .cm-link": {
color: "inherit",
color: theme.palette.primary.main,
},
"& .cm-url": {
color: "inherit",
color: theme.palette.secondary.main,
},
"& .cm-formatting": {
color: "inherit",
color: theme.palette.text.secondary,
},
"& .CodeMirror .editor-preview-full": {
backgroundColor: "inherit",
color: "inherit",
borderColor: "inherit",
backgroundColor: theme.palette.background.paper,
color: theme.palette.text.primary,
borderColor: theme.palette.divider,
},
"& .EasyMDEContainer": {
@ -100,8 +100,7 @@ export const ReactMarkdownEditor = (props: SimpleMDEReactProps) => {
];
return (
<StyledMarkdownEditor
autoFocus={false}
spellCheck={false}
className="my-markdown-editor"
sx={{ marginTop: 1.5, marginBottom: 3 }}
>
<SimpleMDE {...props} />

View File

@ -4,6 +4,12 @@ import {
Box,
Autocomplete,
Typography,
Paper,
Tooltip,
Dialog,
DialogTitle,
MenuItem,
Menu as MuiMenu,
} from "@mui/material";
import {
BackButton,
@ -14,24 +20,70 @@ import {
Language,
cityStore,
CoordinatesInput,
editSightStore,
SelectMediaDialog,
PreviewMediaDialog,
} from "@shared";
import { LanguageSwitcher } from "@widgets";
import { Info, ImagePlus } from "lucide-react";
import { observer } from "mobx-react-lite";
import { useState } from "react";
import { useRef, useState } from "react";
// Мокап для всплывающей подсказки
export const InformationTab = observer(
({ value, index }: { value: number; index: number }) => {
const { cities } = cityStore;
const [isMediaModalOpen, setIsMediaModalOpen] = useState(false);
const [mediaId, setMediaId] = useState<string>("");
const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false);
const { createSight, updateCreateSight, createSightAction } = sightsStore;
const [city, setCity] = useState<number>(0);
const { sightInfo } = editSightStore;
const [city, setCity] = useState<number>(sightInfo.city_id ?? 0);
const [coordinates, setCoordinates] = useState({
latitude: 0,
longitude: 0,
latitude: sightInfo.latitude ?? 0,
longitude: sightInfo.longitude ?? 0,
});
const { language } = languageStore;
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);
};
const handleMenuClose = () => {
setMenuAnchorEl(null);
setActiveMenuType(null);
};
const handleCreateNew = () => {
handleMenuClose();
};
const handleAddMedia = () => {
setIsAddMediaOpen(true);
handleMenuClose();
};
const handleMediaSelect = (selectedMediaId: string) => {
if (!activeMenuType) return;
// Close the dialog
setIsAddMediaOpen(false);
setActiveMenuType(null);
};
const handleChange = (
language: Language,
@ -42,325 +94,326 @@ export const InformationTab = observer(
const handleSave = async () => {
try {
await createSightAction(createSight[language], city, coordinates);
await createSightAction(city, coordinates);
} catch (error) {
console.error(error);
}
};
return (
<TabPanel value={value} index={index}>
<Box
sx={{
display: "flex",
flexDirection: "column",
gap: 3,
position: "relative",
paddingBottom: "70px" /* Space for save button */,
}}
>
<BackButton />
<>
<TabPanel value={value} index={index}>
<Box
sx={{
display: "flex",
gap: 4, // Added gap between the two main columns
width: "100%",
flexDirection: "column",
gap: 3,
position: "relative",
paddingBottom: "70px" /* Space for save button */,
}}
>
{/* Left column with main fields */}
<BackButton />
<Box
sx={{
flexGrow: 1,
display: "flex",
width: "80%",
gap: 4, // Added gap between the two main columns
width: "100%",
flexDirection: "column",
gap: 2.5,
}}
>
<TextField
label={`Название (${language.toUpperCase()})`}
value={createSight[language]?.name ?? ""}
onChange={(e) => {
handleChange(language as Language, {
name: e.target.value,
});
{/* Left column with main fields */}
<Box
sx={{
flexGrow: 1,
display: "flex",
width: "80%",
flexDirection: "column",
gap: 2.5,
}}
fullWidth
variant="outlined"
/>
<TextField
label={`Описание (${language.toUpperCase()})`}
value={createSight?.[language]?.description ?? ""}
onChange={(e) => {
handleChange(language as Language, {
description: e.target.value,
});
}}
fullWidth
variant="outlined"
multiline
rows={4}
/>
<TextField
label="Адрес"
value={createSight?.[language]?.address ?? ""}
onChange={(e) => {
handleChange(language as Language, {
address: e.target.value,
});
}}
fullWidth
variant="outlined"
/>
<Autocomplete
options={cities}
getOptionLabel={(option) => option.name}
onChange={(_, value) => {
setCity(value?.id ?? 0);
}}
renderInput={(params) => (
<TextField {...params} label="Город" />
)}
/>
>
<TextField
label={`Название (${language.toUpperCase()})`}
value={sightInfo[language]?.info?.name ?? ""}
onChange={(e) => {
handleChange(language as Language, {
name: e.target.value,
});
}}
fullWidth
variant="outlined"
/>
<CoordinatesInput setValue={setCoordinates} />
</Box>
<TextField
label="Адрес"
value={sightInfo[language]?.info?.address ?? ""}
onChange={(e) => {
handleChange(language as Language, {
address: e.target.value,
});
}}
fullWidth
variant="outlined"
/>
{/* Правая колонка для логотипа и водяных знаков
<Box
sx={{
display: "flex",
<Autocomplete
options={cities}
value={cities.find((city) => city.id === sightInfo.city_id)}
getOptionLabel={(option) => option.name}
onChange={(_, value) => {
setCity(value?.id ?? 0);
}}
renderInput={(params) => (
<TextField {...params} label="Город" />
)}
/>
gap: 4,
}}
>
<CoordinatesInput
initialValue={coordinates}
setValue={setCoordinates}
/>
</Box>
<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
gap: 4,
}}
>
<Paper
elevation={2}
<Box
sx={{
padding: 2,
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 1,
flex: 1,
minWidth: 150, // Ensure a minimum width
justifyContent: "space-around",
width: "80%",
gap: 2,
flexDirection: { xs: "column", sm: "row" }, // Stack on extra small, side-by-side on small and up
}}
>
<Box sx={{ display: "flex", alignItems: "center" }}>
<Typography
variant="subtitle2"
gutterBottom
sx={{ mb: 0, mr: 0.5 }}
>
Логотип
</Typography>
<Tooltip title={watermarkTooltipText}>
<Info
size={16}
color="gray"
style={{ cursor: "pointer" }}
/>
</Tooltip>
</Box>
<Box
<Paper
elevation={2}
sx={{
width: 80,
height: 80,
backgroundColor: "grey.200",
padding: 2,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
borderRadius: 1,
mb: 1,
cursor: mockSightData.watermark_lu
? "pointer"
: "default", // Only clickable if there's an image
"&:hover": {
backgroundColor: mockSightData.watermark_lu
? "grey.300"
: "grey.200",
},
gap: 1,
flex: 1,
minWidth: 150, // Ensure a minimum width
}}
onClick={() =>
mockSightData.watermark_lu &&
handleSelectMedia("watermark_lu")
}
>
{mockSightData.watermark_lu ? (
<img
src={mockSightData.watermark_lu}
alt="Знак л.в"
style={{ maxWidth: "100%", maxHeight: "100%" }}
/>
) : (
<ImagePlus size={24} color="grey" />
)}
</Box>
<Button
variant="outlined"
size="small"
onClick={() => handleSelectMedia("watermark_lu")}
>
Выбрать
</Button>
</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 }}
<Box sx={{ display: "flex", alignItems: "center" }}>
<Typography
variant="subtitle2"
gutterBottom
sx={{ mb: 0, mr: 0.5 }}
>
Логотип
</Typography>
</Box>
<Box
sx={{
width: 80,
height: 80,
backgroundColor: "grey.200",
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 1,
mb: 1,
cursor: editSightStore.sightInfo?.thumbnail
? "pointer"
: "default", // Only clickable if there's an image
"&:hover": {
backgroundColor: editSightStore.sightInfo?.thumbnail
? "red.300"
: "grey.200",
},
}}
onClick={() => {
setIsMediaModalOpen(true);
}}
>
Водяной знак (л.в)
</Typography>
<Tooltip title={watermarkTooltipText}>
<Info
size={16}
color="gray"
style={{ cursor: "pointer" }}
/>
</Tooltip>
</Box>
<Box
{editSightStore.sightInfo?.thumbnail ? (
<img
src={`${import.meta.env.VITE_KRBL_MEDIA}${
editSightStore.sightInfo?.thumbnail
}/download?token=${token}`}
alt="Логотип"
style={{ maxWidth: "100%", maxHeight: "100%" }}
onClick={() => {
setIsPreviewMediaOpen(true);
setMediaId(editSightStore.sightInfo?.thumbnail);
}}
/>
) : (
<ImagePlus size={24} color="grey" />
)}
</Box>
<Button
variant="outlined"
size="small"
onClick={(e) => handleMenuOpen(e, "thumbnail")}
>
Выбрать
</Button>
</Paper>
<Paper
elevation={2}
sx={{
width: 80,
height: 80,
backgroundColor: "grey.200",
padding: 2,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
borderRadius: 1,
mb: 1,
cursor: mockSightData.watermark_lu
? "pointer"
: "default", // Only clickable if there's an image
"&:hover": {
backgroundColor: mockSightData.watermark_lu
? "grey.300"
: "grey.200",
},
gap: 1,
flex: 1,
minWidth: 150, // Ensure a minimum width
}}
onClick={() =>
mockSightData.watermark_lu &&
handleSelectMedia("watermark_lu")
}
>
{mockSightData.watermark_lu ? (
<img
src={mockSightData.watermark_lu}
alt="Знак л.в"
style={{ maxWidth: "100%", maxHeight: "100%" }}
/>
) : (
<ImagePlus size={24} color="grey" />
)}
</Box>
<Button
variant="outlined"
size="small"
onClick={() => handleSelectMedia("watermark_lu")}
>
Выбрать
</Button>
</Paper>
<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={{
width: 80,
height: 80,
backgroundColor: "grey.200",
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 1,
mb: 1,
cursor: editSightStore.sightInfo?.watermark_lu
? "pointer"
: "default", // Only clickable if there's an image
"&:hover": {
backgroundColor: editSightStore.sightInfo
?.watermark_lu
? "grey.300"
: "grey.200",
},
}}
onClick={() => {
setIsPreviewMediaOpen(true);
setMediaId(editSightStore.sightInfo?.watermark_lu);
}}
>
{editSightStore.sightInfo?.watermark_lu ? (
<img
src={`${import.meta.env.VITE_KRBL_MEDIA}${
editSightStore.sightInfo?.watermark_lu
}/download?token=${token}`}
alt="Знак л.в"
style={{ maxWidth: "100%", maxHeight: "100%" }}
onClick={() => {
setIsMediaModalOpen(true);
setMediaId(editSightStore.sightInfo?.watermark_lu);
}}
/>
) : (
<ImagePlus size={24} color="grey" />
)}
</Box>
<Button
variant="outlined"
size="small"
onClick={(e) => handleMenuOpen(e, "watermark_lu")}
>
Выбрать
</Button>
</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={watermarkTooltipText}>
<Info
size={16}
color="gray"
style={{ cursor: "pointer" }}
/>
</Tooltip>
</Box>
<Box
<Paper
elevation={2}
sx={{
width: 80,
height: 80,
backgroundColor: "grey.200",
padding: 2,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
borderRadius: 1,
mb: 1,
cursor: mockSightData.watermark_rd
? "pointer"
: "default", // Only clickable if there's an image
"&:hover": {
backgroundColor: mockSightData.watermark_rd
? "grey.300"
: "grey.200",
},
gap: 1,
flex: 1,
minWidth: 150, // Ensure a minimum width
}}
onClick={() =>
mockSightData.watermark_rd &&
handleSelectMedia("watermark_rd")
}
>
{mockSightData.watermark_rd ? (
<img
src={mockSightData.watermark_rd}
alt="Знак п.в"
style={{ maxWidth: "100%", maxHeight: "100%" }}
/>
) : (
<ImagePlus size={24} color="grey" />
)}
</Box>
<Button
variant="outlined"
size="small"
onClick={() => handleSelectMedia("watermark_rd")}
>
Выбрать
</Button>
</Paper>
<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={{
width: 80,
height: 80,
backgroundColor: "grey.200",
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 1,
mb: 1,
cursor: editSightStore.sightInfo?.watermark_rd
? "pointer"
: "default", // Only clickable if there's an image
"&:hover": {
backgroundColor: editSightStore.sightInfo
?.watermark_rd
? "grey.300"
: "grey.200",
},
}}
onClick={() => editSightStore.sightInfo?.watermark_rd}
>
{editSightStore.sightInfo?.watermark_rd ? (
<img
src={`${import.meta.env.VITE_KRBL_MEDIA}${
editSightStore.sightInfo?.watermark_rd
}/download?token=${token}`}
alt="Знак п.в"
style={{ maxWidth: "100%", maxHeight: "100%" }}
onClick={() => {
setIsPreviewMediaOpen(true);
setMediaId(editSightStore.sightInfo?.watermark_rd);
}}
/>
) : (
<ImagePlus size={24} color="grey" />
)}
</Box>
<Button
variant="outlined"
size="small"
onClick={(e) => handleMenuOpen(e, "watermark_rd")}
>
Выбрать
</Button>
</Paper>
</Box>
</Box>
</Box>
</Box> */}
{/* LanguageSwitcher positioned at the top right */}
@ -384,8 +437,41 @@ export const InformationTab = observer(
</Button>
</Box>
</Box>
</Box>
</TabPanel>
</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);
}}
onSelectArticle={handleMediaSelect}
/>
<PreviewMediaDialog
open={isPreviewMediaOpen}
onClose={() => setIsPreviewMediaOpen(false)}
mediaId={mediaId}
/>
</>
);
}
);

View File

@ -1,242 +1,348 @@
// @widgets/LeftWidgetTab.tsx
import { Box, Button, TextField, Paper, Typography } from "@mui/material";
import { BackButton, Sight, TabPanel } from "@shared";
import { ReactMarkdownComponent, ReactMarkdownEditor } from "@widgets";
import {
articlesStore,
BackButton,
TabPanel,
languageStore,
SelectMediaDialog,
editSightStore,
} from "@shared";
import {
LanguageSwitcher,
ReactMarkdownComponent,
ReactMarkdownEditor,
} from "@widgets";
import { Unlink, Trash2, ImagePlus } from "lucide-react";
import { useState } from "react";
import { useState, useEffect, useCallback } from "react";
import { observer } from "mobx-react-lite";
export const LeftWidgetTab = ({
value,
index,
data,
}: {
value: number;
index: number;
data?: Sight;
}) => {
const [articleTitle, setArticleTitle] = useState("");
const [markdownContent, setMarkdownContent] = useState("");
const [articleMedia, setArticleMedia] = useState<string | null>(null); // Для превью медиа
export const LeftWidgetTab = observer(
({ value, index }: { value: number; index: number }) => {
const { sightInfo, updateSightInfo, loadSightInfo } = editSightStore;
const { articleLoading, getArticleByArticleId } = articlesStore;
const { language } = languageStore;
const linkedArticle = getArticleByArticleId.get(); // Получаем связанную статью
const data = sightInfo[languageStore.language]; // Получаем данные для текущего языка
const handleSelectMediaForArticle = () => {
// Логика открытия модального окна для выбора медиа для статьи
console.log("Select media fo r left article");
// Для примера, установим моковое изображение
// setArticleMedia("https://via.placeholder.com/350x200.png?text=Article+Media");
};
useEffect(() => {
// Этот useEffect должен загружать данные ИЗ СВЯЗАННОЙ СТАТЬИ
// ТОЛЬКО ЕСЛИ данные для ТЕКУЩЕГО ЯЗЫКА еще не были загружены
// или если sightInfo.left_article изменился (т.е. привязали новую статью).
const handleUnlinkArticle = () => {
console.log("Unlink left article");
};
// Мы также должны учитывать, что linkedArticle может измениться (т.е. новую статью привязали)
// или language изменился.
// Если для текущего языка данные еще не "загружены" (`loaded: false`),
// тогда мы берем их из `linkedArticle` и инициализируем.
console.log("data.left.loaded", data.left.loaded);
if (!data.left.loaded) {
// <--- КЛЮЧЕВОЕ УСЛОВИЕ
if (linkedArticle && !articleLoading) {
console.log("loadSightInfo", linkedArticle, language);
loadSightInfo(
languageStore.language,
linkedArticle.heading,
linkedArticle.body || "",
null
);
}
}
// Зависимости: linkedArticle (для реакции на изменение привязанной статьи),
// languageStore.language (для реакции на изменение языка),
// loadSightInfo (чтобы useEffect знал об изменениях в функции),
// data.left.loaded (чтобы useEffect перепроверил условие, когда этот флаг изменится).
// Важно: если data.left.loaded становится true, то этот эффект не будет
// перезапускаться для того же языка.
}, [
linkedArticle?.heading,
language,
loadSightInfo,
data.left.loaded,
articleLoading,
]);
const handleDeleteArticle = () => {
console.log("Delete left article");
};
const [isSelectMediaDialogOpen, setIsSelectMediaDialogOpen] =
useState(false);
const handleSave = () => {
console.log("Saving left widget...");
};
const handleOpenMediaDialog = useCallback(() => {
setIsSelectMediaDialogOpen(true);
}, []);
return (
<TabPanel value={value} index={index}>
<Box
sx={{
display: "flex",
flexDirection: "column",
gap: 3,
paddingBottom: "70px",
position: "relative",
}}
>
<BackButton />
const handleMediaSelected = useCallback(
(selectedMedia: any) => {
// При выборе медиа, обновляем данные для ТЕКУЩЕГО ЯЗЫКА
// сохраняя текущие heading и body.
updateSightInfo(
languageStore.language,
data.left.heading,
data.left.body,
selectedMedia
);
setIsSelectMediaDialogOpen(false);
},
[
languageStore.language,
data.left.heading,
data.left.body,
updateSightInfo,
]
);
<Paper
elevation={2}
const handleCloseMediaDialog = useCallback(() => {
setIsSelectMediaDialogOpen(false);
}, []);
// ... (остальной JSX код остался почти без изменений)
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 }}>
<Button
variant="outlined"
color="primary"
startIcon={<Unlink size={18} />}
onClick={handleUnlinkArticle}
size="small"
>
Открепить
</Button>
<Button
variant="outlined"
color="error"
startIcon={<Trash2 size={18} />}
onClick={handleDeleteArticle}
size="small"
>
Удалить
</Button>
</Box>
</Paper>
<BackButton />
<Box sx={{ display: "flex", gap: 3, flexGrow: 1 }}>
{/* Левая колонка: Редактирование */}
<Box
sx={{ flex: 2, display: "flex", flexDirection: "column", gap: 2 }}
<Paper
elevation={2}
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
paddingX: 2.5,
paddingY: 1.5,
borderRadius: 2,
border: "1px solid",
borderColor: "divider",
}}
>
<TextField
label="Название информации" // На макете "Название" для статьи, потом "Информация"
value={articleTitle}
onChange={(e) => setArticleTitle(e.target.value)}
variant="outlined"
sx={{ width: "100%" }} // Примерная ширина как на макете
/>
<ReactMarkdownEditor
value={markdownContent}
onChange={setMarkdownContent}
/>
{/* Блок МЕДИА для статьи */}
<Paper elevation={1} sx={{ padding: 2, mt: 1 }}>
<Typography variant="h6" gutterBottom>
МЕДИА
</Typography>
{/* Здесь будет UI для управления медиа статьи */}
{articleMedia ? (
<Box sx={{ mb: 1 }}>
<img
src={articleMedia}
alt="Article media"
style={{
maxWidth: "100%",
maxHeight: "150px",
borderRadius: "4px",
}}
/>
</Box>
) : (
<Box
sx={{
width: "100%",
height: 100,
backgroundColor: "grey.100",
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 1,
mb: 1,
border: "2px dashed",
}}
<Typography variant="h6">Левая статья</Typography>
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
{linkedArticle && (
<Button
variant="outlined"
color="primary"
startIcon={<Unlink size={18} />}
size="small"
>
<Typography color="text.secondary">Нет медиа</Typography>
</Box>
Открепить
</Button>
)}
<Button variant="contained" onClick={handleSelectMediaForArticle}>
Выбрать/Загрузить медиа
<Button
variant="outlined"
color="error"
startIcon={<Trash2 size={18} />}
size="small"
>
Удалить
</Button>
</Paper>
</Box>
</Box>
</Paper>
{/* Правая колонка: Предпросмотр */}
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
<Typography variant="h6">Предпросмотр</Typography>
<Paper
elevation={3}
<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,
e.target.value,
data.left.body,
data.left.media
)
}
variant="outlined"
fullWidth
/>
<ReactMarkdownEditor
value={data.left.body}
onChange={(value) =>
updateSightInfo(
languageStore.language,
data.left.heading,
value,
data.left.media
)
}
/>
{/* Блок МЕДИА для статьи */}
<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,
data.left.heading,
data.left.body,
null
)
}
>
Удалить медиа
</Button>
)}
</Paper>
</Box>
{/* Правая колонка: Предпросмотр */}
<Box
sx={{
width: "100%", // Ширина как на макете ~350px
minWidth: 320,
maxWidth: 400,
height: "auto", // Автоматическая высота или можно задать minHeight
minHeight: 500,
backgroundColor: "#877361", // Желтоватый фон
overflowY: "auto",
padding: 0,
flex: 1,
display: "flex",
flexDirection: "column",
gap: 1.5,
}}
>
{/* Медиа в превью (если есть) */}
{articleMedia && (
<Box
sx={{
width: "100%",
height: 200,
backgroundColor: "grey.300",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<img
src={articleMedia}
alt="Превью медиа"
style={{
objectFit: "cover",
width: "100%",
height: "100%",
}}
/>
</Box>
)}
{!articleMedia && (
<Box
sx={{
width: "100%",
height: 200,
backgroundColor: "grey.300",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<ImagePlus size={48} color="grey" />
</Box>
)}
{/* Заголовок в превью */}
<Box
<Typography variant="h6">Предпросмотр</Typography>
<Paper
elevation={3}
sx={{
width: "100%",
minWidth: 320,
maxWidth: 400,
height: "auto",
minHeight: 500,
backgroundColor: "#877361",
color: "white",
padding: 1.5,
overflowY: "auto",
padding: 0,
display: "flex",
flexDirection: "column",
}}
>
<Typography
variant="h5"
component="h2"
sx={{ wordBreak: "break-word" }}
{/* Медиа в превью (если есть) */}
{data.left.media ? (
<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",
}}
>
<ImagePlus size={48} color="grey" />
</Box>
)}
{/* Заголовок в превью */}
<Box
sx={{
backgroundColor: "#877361",
color: "white",
padding: 1.5,
}}
>
{articleTitle || "Название информации"}
</Typography>
</Box>
<Typography
variant="h5"
component="h2"
sx={{ wordBreak: "break-word" }}
>
{data.left.heading || "Название информации"}
</Typography>
</Box>
{/* Текст статьи в превью */}
<Box
sx={{
padding: 2,
{/* Текст статьи в превью */}
<Box
sx={{
padding: 2,
flexGrow: 1,
}}
>
<ReactMarkdownComponent value={data.left.body} />
</Box>
</Paper>
</Box>
</Box>
flexGrow: 1,
}}
>
<ReactMarkdownComponent value={markdownContent} />
</Box>
</Paper>
<Box sx={{ position: "absolute", bottom: 0, right: 0, padding: 2 }}>
<Button variant="contained" color="success">
Сохранить
</Button>
</Box>
</Box>
<Box sx={{ position: "absolute", bottom: 0, right: 0, padding: 2 }}>
<Button variant="contained" color="success" onClick={handleSave}>
Сохранить
</Button>
</Box>
</Box>
</TabPanel>
);
};
<SelectMediaDialog
open={isSelectMediaDialogOpen}
onClose={handleCloseMediaDialog}
onSelectMedia={handleMediaSelected}
/>
</TabPanel>
);
}
);

View File

@ -1,405 +1,404 @@
// RightWidgetTab.tsx
import { Box, Button, Paper, TextField, Typography } from "@mui/material";
import {
Box,
Button,
List,
ListItemButton,
ListItemText,
Paper,
Typography,
Menu,
MenuItem,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
InputAdornment,
} from "@mui/material";
import {
articlesStore,
BackButton,
SelectArticleModal,
Sight,
TabPanel,
BackButton,
languageStore, // Предполагаем, что он есть в @shared
Language, // Предполагаем, что он есть в @shared
// SelectArticleModal, // Добавим позже
// articlesStore, // Добавим позже
} from "@shared";
import { SightEdit } from "@widgets";
import { ImagePlus, Plus, Search } from "lucide-react";
import { LanguageSwitcher } from "@widgets"; // Предполагаем, что LanguageSwitcher у вас есть
import { observer } from "mobx-react-lite";
import { useState, useEffect, useRef } from "react";
import { useState, useMemo, useEffect } from "react";
import { editSightStore, BlockItem } from "@shared"; // Путь к вашему стору
// --- 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",
},
// Импортируем сюда же определения BlockItem, если не выносим в types.ts
// export interface BlockItem { id: string; type: 'media' | 'article'; nameForSidebar: string; linkedArticleStoreId?: string; }
// --- Начальные данные для структуры блоков (позже это может загружаться) ---
// ID здесь должны быть уникальными для списка.
const initialBlockStructures: Omit<BlockItem, "nameForSidebar">[] = [
{ id: "preview_media_main", type: "media" },
{ id: "article_1_local", type: "article" }, // Эти статьи будут редактироваться локально
{ id: "article_2_local", type: "article" },
];
const mockSelectedBlockData = {
id: "article_1",
heading: "История основания Санкт-Петербурга",
body: "## Начало\nГород был основан 27 мая 1703 года Петром I...",
media: [],
};
const mockExistingArticles = [
{ id: "existing_1", title: "История Эрмитажа", type: "article" },
{ id: "existing_2", title: "Петропавловская крепость", type: "article" },
{ id: "existing_3", title: "Исаакиевский собор", type: "article" },
{ id: "existing_4", title: "Кунсткамера", type: "article" },
];
// --- ArticleListSidebar Component ---
interface ArticleBlock {
id: string;
name: string;
type: string;
linkedArticleId?: string; // Added for linked articles
interface RightWidgetTabProps {
value: number;
index: number;
}
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;
onDelete: (blockId: string) => void;
}
const ArticleEditorPane = ({
articleData,
onDelete,
}: 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, data }: { value: number; index: number; data?: Sight }) => {
const [rightWidgetBlocks, setRightWidgetBlocks] = useState<ArticleBlock[]>(
mockRightWidgetBlocks
);
const [selectedBlockId, setSelectedBlockId] = useState<string | null>(
mockRightWidgetBlocks[1]?.id || null
);
const [isSelectModalOpen, setIsSelectModalOpen] = useState(false);
({ value, index }: RightWidgetTabProps) => {
const { language } = languageStore; // Текущий язык
const { sightInfo } = editSightStore; // Данные достопримечательности
// 1. Структура блоков: порядок, тип, связи (не сам контент)
// Имена nameForSidebar будут динамически браться из sightInfo или articlesStore
const [blockItemsStructure, setBlockItemsStructure] = useState<
Omit<BlockItem, "nameForSidebar">[]
>(initialBlockStructures);
// 2. ID выбранного блока для редактирования
const [selectedBlockId, setSelectedBlockId] = useState<string | null>(
() => {
// По умолчанию выбираем первый блок, если он есть
return initialBlockStructures.length > 0
? initialBlockStructures[0].id
: null;
}
);
// 3. Состояние для модального окна выбора существующей статьи (добавим позже)
// const [isSelectModalOpen, setIsSelectModalOpen] = useState(false);
// --- Производные данные (Derived State) ---
// Блоки для отображения в сайдбаре (с локализованными именами)
const blocksForSidebar: BlockItem[] = useMemo(() => {
return blockItemsStructure.map((struct) => {
let name = `Блок ${struct.id}`; // Имя по умолчанию
if (struct.type === "media" && struct.id === "preview_media_main") {
name = "Превью-медиа"; // Фиксированное имя для этого блока
} else if (struct.type === "article") {
if (struct.linkedArticleStoreId) {
// TODO: Найти имя в articlesStore по struct.linkedArticleStoreId
name = `Связанная: ${struct.linkedArticleStoreId}`;
} else {
// Это локальная статья, берем заголовок из editSightStore
const articleContent = sightInfo[language]?.right?.find(
(a) => a.id === struct.id
);
name =
articleContent?.heading ||
`Статья ${struct.id.slice(-4)} (${language.toUpperCase()})`;
}
}
return { ...struct, nameForSidebar: name };
});
}, [blockItemsStructure, language, sightInfo]);
// Данные выбранного блока (структура + контент)
const selectedBlockData = useMemo(() => {
if (!selectedBlockId) return null;
const structure = blockItemsStructure.find(
(b) => b.id === selectedBlockId
);
if (!structure) return null;
if (structure.type === "article" && !structure.linkedArticleStoreId) {
const content = sightInfo[language]?.right?.find(
(a) => a.id === selectedBlockId
);
return {
structure,
content: content || { id: selectedBlockId, heading: "", body: "" }, // Заглушка, если нет контента
};
}
// Для media или связанных статей пока просто структура
return { structure, content: null };
}, [selectedBlockId, blockItemsStructure, language, sightInfo]);
// --- Обработчики событий ---
const handleSelectBlock = (blockId: string) => {
setSelectedBlockId(blockId);
console.log("Selected block:", blockId);
};
const handleCreateNew = () => {
const newBlockId = `article_${Date.now()}`;
setRightWidgetBlocks((prevBlocks) => [
...prevBlocks,
{
id: newBlockId,
name: `${
prevBlocks.filter((b) => b.type === "article").length + 1
}. Новый блок`,
type: "article",
},
]);
const handleCreateNewArticle = () => {
const newBlockId = `article_local_${Date.now()}`;
const newBlockStructure: Omit<BlockItem, "nameForSidebar"> = {
id: newBlockId,
type: "article",
};
setBlockItemsStructure((prev) => [...prev, newBlockStructure]);
// Добавляем пустой контент для этой статьи во все языки в editSightStore
const baseName = `Новая статья ${
blockItemsStructure.filter((b) => b.type === "article").length + 1
}`;
["ru", "en", "zh"].forEach((lang) => {
const currentLang = lang as Language;
if (
editSightStore.sightInfo[currentLang] &&
!editSightStore.sightInfo[currentLang].right?.find(
(r) => r.id === newBlockId
)
) {
editSightStore.sightInfo[currentLang].right.push({
id: newBlockId,
heading: `${baseName} (${currentLang.toUpperCase()})`,
body: `Содержимое для ${baseName} (${currentLang.toUpperCase()})...`,
});
}
});
setSelectedBlockId(newBlockId);
};
const handleSelectExisting = () => {
setIsSelectModalOpen(true);
};
const handleCloseSelectModal = () => {
setIsSelectModalOpen(false);
};
const handleSelectArticle = (articleId: string) => {
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 handleHeadingChange = (newHeading: string) => {
if (
selectedBlockData &&
selectedBlockData.structure.type === "article" &&
!selectedBlockData.structure.linkedArticleStoreId
) {
const blockId = selectedBlockData.structure.id;
const langData = editSightStore.sightInfo[language];
const article = langData?.right?.find((a) => a.id === blockId);
if (article) {
article.heading = newHeading;
} else if (langData) {
// Если статьи еще нет, добавляем
langData.right.push({ id: blockId, heading: newHeading, body: "" });
}
// Обновить имя в сайдбаре (т.к. blocksForSidebar пересчитается)
// Для этого достаточно, чтобы sightInfo был observable и blocksForSidebar от него зависел
}
handleCloseSelectModal();
};
const handleUnlinkBlock = (blockId: string) => {
console.log("Unlink block:", blockId);
// Example: If a block is linked to an existing article, this might "unlink" it
// For now, it simply removes it, you might want to convert it to a new editable block.
setRightWidgetBlocks((blocks) => blocks.filter((b) => b.id !== blockId));
setSelectedBlockId(null);
const handleBodyChange = (newBody: string) => {
if (
selectedBlockData &&
selectedBlockData.structure.type === "article" &&
!selectedBlockData.structure.linkedArticleStoreId
) {
const blockId = selectedBlockData.structure.id;
const langData = editSightStore.sightInfo[language];
const article = langData?.right?.find((a) => a.id === blockId);
if (article) {
article.body = newBody;
} else if (langData) {
// Если статьи еще нет, добавляем
langData.right.push({ id: blockId, heading: "", body: newBody });
}
}
};
const handleDeleteBlock = (blockId: string) => {
console.log("Delete block:", blockId);
setRightWidgetBlocks((blocks) => blocks.filter((b) => b.id !== blockId));
setSelectedBlockId(null);
const handleDeleteBlock = (blockIdToDelete: string) => {
setBlockItemsStructure((prev) =>
prev.filter((b) => b.id !== blockIdToDelete)
);
// Удаляем контент из editSightStore для всех языков
["ru", "en", "zh"].forEach((lang) => {
const currentLang = lang as Language;
if (editSightStore.sightInfo[currentLang]) {
editSightStore.sightInfo[currentLang].right =
editSightStore.sightInfo[currentLang].right?.filter(
(r) => r.id !== blockIdToDelete
);
}
});
if (selectedBlockId === blockIdToDelete) {
setSelectedBlockId(
blockItemsStructure.length > 1
? blockItemsStructure.filter((b) => b.id !== blockIdToDelete)[0]?.id
: null
);
}
};
const handleSave = () => {
console.log("Saving right widget...");
// Implement save logic here, e.g., send data to an API
console.log(
"Сохранение Right Widget:",
JSON.stringify(editSightStore.sightInfo, null, 2)
);
// Здесь будет логика отправки editSightStore.sightInfo на сервер
alert("Данные для сохранения (см. консоль)");
};
// 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);
// --- Инициализация контента в сторе для initialBlockStructures (если его там нет) ---
useEffect(() => {
initialBlockStructures.forEach((struct) => {
if (struct.type === "article" && !struct.linkedArticleStoreId) {
const baseName = `Статья ${struct.id.split("_")[1]}`; // Пример "История" или "Факты"
["ru", "en", "zh"].forEach((lang) => {
const currentLang = lang as Language;
if (
editSightStore.sightInfo[currentLang] &&
!editSightStore.sightInfo[currentLang].right?.find(
(r) => r.id === struct.id
)
) {
editSightStore.sightInfo[currentLang].right?.push({
id: struct.id,
heading: `${baseName} (${currentLang.toUpperCase()})`, // Например: "История (RU)"
body: `Начальное содержимое для ${baseName} на ${currentLang.toUpperCase()}.`,
});
}
});
}
});
}, []); // Запускается один раз при монтировании
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 sx={{ display: "flex", flexGrow: 1, gap: 2.5, minHeight: 0 }}>
{/* Компонент сайдбара списка блоков */}
<Paper
elevation={1}
sx={{
width: 280,
padding: 1.5,
display: "flex",
flexDirection: "column",
}}
>
<Typography variant="h6" gutterBottom>
Блоки
</Typography>
<Box sx={{ flexGrow: 1, overflowY: "auto" }}>
{blocksForSidebar.map((block) => (
<Button
key={block.id}
fullWidth
variant={
selectedBlockId === block.id ? "contained" : "outlined"
}
onClick={() => handleSelectBlock(block.id)}
sx={{
justifyContent: "flex-start",
mb: 0.5,
textTransform: "none",
}}
>
{block.nameForSidebar}
</Button>
))}
</Box>
<Button
variant="contained"
onClick={handleCreateNewArticle}
sx={{ mt: 1 }}
>
+ Новая статья
</Button>
{/* TODO: Кнопка "Выбрать существующую" */}
</Paper>
<ArticleEditorPane
articleData={currentBlockToEdit}
onDelete={handleDeleteBlock}
/>
{/* Компонент редактора выбранного блока */}
<Paper
elevation={1}
sx={{ flexGrow: 1, padding: 2.5, overflowY: "auto" }}
>
<Typography variant="h6" gutterBottom>
Редактор блока ({language.toUpperCase()})
</Typography>
{selectedBlockData ? (
<Box>
<Typography variant="subtitle1">
ID: {selectedBlockData.structure.id}
</Typography>
<Typography variant="subtitle1">
Тип: {selectedBlockData.structure.type}
</Typography>
{selectedBlockData.structure.type === "media" && (
<Box
my={2}
p={2}
border="1px dashed grey"
height={150}
display="flex"
alignItems="center"
justifyContent="center"
>
<Typography color="textSecondary">
Загрузчик медиа для "{selectedBlockData.structure.id}"
</Typography>
</Box>
)}
{selectedBlockData.structure.type === "article" &&
!selectedBlockData.structure.linkedArticleStoreId &&
selectedBlockData.content && (
<Box mt={2}>
<TextField
fullWidth
label="Заголовок статьи"
value={selectedBlockData.content.heading}
onChange={(e) => handleHeadingChange(e.target.value)}
sx={{ mb: 2 }}
/>
<TextField
fullWidth
multiline
rows={8}
label="Текст статьи"
value={selectedBlockData.content.body}
onChange={(e) => handleBodyChange(e.target.value)}
sx={{ mb: 2 }}
// Здесь позже можно будет вставить SightEdit
/>
{/* TODO: Секция медиа для статьи */}
<Button
color="error"
variant="outlined"
onClick={() =>
handleDeleteBlock(selectedBlockData.structure.id)
}
>
Удалить эту статью
</Button>
</Box>
)}
{selectedBlockData.structure.type === "article" &&
selectedBlockData.structure.linkedArticleStoreId && (
<Box mt={2}>
<Typography>
Это связанная статья:{" "}
{selectedBlockData.structure.linkedArticleStoreId}
</Typography>
{/* TODO: Кнопки "Открепить", "Удалить из списка" */}
</Box>
)}
</Box>
) : (
<Typography color="textSecondary">
Выберите блок для редактирования
</Typography>
)}
</Paper>
</Box>
<Box
sx={{
position: "absolute",
bottom: 0,
left: 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",
borderTop: "1px solid",
borderColor: "divider",
zIndex: 10,
display: "flex",
justifyContent: "flex-end",
}}
>
<Button variant="contained" color="success" onClick={handleSave}>
<Button
variant="contained"
color="success"
onClick={handleSave}
size="large"
>
Сохранить изменения
</Button>
</Box>
</Box>
<SelectArticleModal
open={isSelectModalOpen}
onClose={handleCloseSelectModal}
onSelectArticle={handleSelectArticle}
linkedArticleIds={linkedArticleIds}
/>
{/* <SelectArticleModal open={isSelectModalOpen} ... /> */}
</TabPanel>
);
}

View File

@ -7,3 +7,4 @@ export * from "./SightEdit";
export * from "./LanguageSwitcher";
export * from "./DevicesTable";
export * from "./SightsTable";
export * from "./MediaViewer";

View File

@ -0,0 +1,188 @@
import { articlesStore } 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 } from "@widgets";
interface SelectArticleModalProps {
open: boolean;
onClose: () => void;
onSelectArticle: (articleId: string) => void;
linkedArticleIds?: string[]; // Add optional prop for linked articles
}
export const SelectArticleModal = observer(
({
open,
onClose,
onSelectArticle,
linkedArticleIds = [], // Default to empty array if not provided
}: SelectArticleModalProps) => {
const { articles, getArticle } = articlesStore; // articles here refers to fetched articles from store
const [searchQuery, setSearchQuery] = useState("");
const [hoveredArticleId, setHoveredArticleId] = useState<string | null>(
null
);
const hoverTimerRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
if (hoveredArticleId) {
hoverTimerRef.current = setTimeout(() => {
getArticle(hoveredArticleId);
}, 200);
}
return () => {
if (hoverTimerRef.current) {
clearTimeout(hoverTimerRef.current);
}
};
}, [hoveredArticleId, getArticle]);
const handleArticleHover = (articleId: string) => {
setHoveredArticleId(articleId);
};
const handleArticleLeave = () => {
setHoveredArticleId(null);
if (hoverTimerRef.current) {
clearTimeout(hoverTimerRef.current);
}
};
const filteredArticles = articles
.filter((article) => !linkedArticleIds.includes(article.id))
.filter((article) =>
article.service_name.toLowerCase().includes(searchQuery.toLowerCase())
);
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">
<TextField
fullWidth
placeholder="Поиск статей..."
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" }}>
{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",
},
}}
>
<ListItemText primary={article.service_name} />
</ListItemButton>
))}
</List>
</Paper>
<Paper className="flex-1 flex flex-col">
<Box
className="rounded-2xl overflow-hidden"
sx={{
width: "100%",
height: "100%",
background: "#877361",
borderColor: "grey.300",
display: "flex",
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>
{/* 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>
{/* 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>
</Paper>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Отмена</Button>
</DialogActions>
</Dialog>
);
}
);

View File

@ -0,0 +1 @@
export * from "./SelectArticleDialog";

397
yarn.lock
View File

@ -138,6 +138,11 @@
resolved "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.3.tgz"
integrity sha512-7EYtGezsdiDMyY80+65EzwiGmcJqpmcZCojSXaRgdrBaGtWTgDZKq69cPIVped6MkIM78cTQ2GOiEYjwOlG4xw==
"@babel/runtime@^7.17.8", "@babel/runtime@^7.26.0":
version "7.27.4"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.27.4.tgz#a91ec580e6c00c67118127777c316dfd5a5a6abf"
integrity sha512-t3yaEOuGu9NlIZ+hIeGbBjFtZT7j2cb2tg0fuaJKeGotchRjjLfrBA9Kwf8quhpP1EUuxModQg04q/mBwyg8uA==
"@babel/template@^7.27.2":
version "7.27.2"
resolved "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz"
@ -168,6 +173,11 @@
"@babel/helper-string-parser" "^7.27.1"
"@babel/helper-validator-identifier" "^7.27.1"
"@dimforge/rapier3d-compat@^0.12.0":
version "0.12.0"
resolved "https://registry.yarnpkg.com/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz#7b3365e1dfdc5cd957b45afe920b4ac06c7cd389"
integrity sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==
"@emnapi/core@^1.4.3":
version "1.4.3"
resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.4.3.tgz#9ac52d2d5aea958f67e52c40a065f51de59b77d6"
@ -555,6 +565,18 @@
"@jridgewell/resolve-uri" "^3.1.0"
"@jridgewell/sourcemap-codec" "^1.4.14"
"@mediapipe/tasks-vision@0.10.17":
version "0.10.17"
resolved "https://registry.yarnpkg.com/@mediapipe/tasks-vision/-/tasks-vision-0.10.17.tgz#2c1c73ed81902b21d37336a587b96183bb6882d5"
integrity sha512-CZWV/q6TTe8ta61cZXjfnnHsfWIdFhms03M9T7Cnd5y2mdpylJM0rF1qRq+wsQVRMLz1OYPVEBU9ph2Bx8cxrg==
"@monogrid/gainmap-js@^3.0.6":
version "3.1.0"
resolved "https://registry.yarnpkg.com/@monogrid/gainmap-js/-/gainmap-js-3.1.0.tgz#4ac1f88abd6affdf0b51d79318109183b499c502"
integrity sha512-Obb0/gEd/HReTlg8ttaYk+0m62gQJmCblMOjHSMHRrBP2zdfKMHLCRbh/6ex9fSUJMKdjjIEiohwkbGD3wj2Nw==
dependencies:
promise-worker-transferable "^1.0.4"
"@mui/core-downloads-tracker@^7.1.0":
version "7.1.0"
resolved "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-7.1.0.tgz"
@ -662,11 +684,63 @@
"@nodelib/fs.scandir" "2.1.5"
fastq "^1.6.0"
"@photo-sphere-viewer/core@^5.13.2":
version "5.13.2"
resolved "https://registry.yarnpkg.com/@photo-sphere-viewer/core/-/core-5.13.2.tgz#518f27a2b7ca5a80068d8922183a9999a1b33ad1"
integrity sha512-rL4Ey39Prx4Iyxt1f2tAqlXvqu4/ovXfUvIpLt540OpZJiFjWccs6qLywof9vuhBJ7PXHudHWCjRPce0W8kx8w==
dependencies:
three "^0.175.0"
"@popperjs/core@^2.11.8":
version "2.11.8"
resolved "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz"
integrity sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==
"@react-three/drei@^10.1.2":
version "10.1.2"
resolved "https://registry.yarnpkg.com/@react-three/drei/-/drei-10.1.2.tgz#3c41a0b19460aee7604067309cebe737147cf85a"
integrity sha512-CCcLAqZEvYiUErOcJgGzovY3RH6KgdrqD4ubeAx1nyGbSPLnKR9a8ynYbPdtZhIyiWqGc07z+RYQzpaOfN4ZIA==
dependencies:
"@babel/runtime" "^7.26.0"
"@mediapipe/tasks-vision" "0.10.17"
"@monogrid/gainmap-js" "^3.0.6"
"@use-gesture/react" "^10.3.1"
camera-controls "^2.9.0"
cross-env "^7.0.3"
detect-gpu "^5.0.56"
glsl-noise "^0.0.0"
hls.js "^1.5.17"
maath "^0.10.8"
meshline "^3.3.1"
stats-gl "^2.2.8"
stats.js "^0.17.0"
suspend-react "^0.1.3"
three-mesh-bvh "^0.8.3"
three-stdlib "^2.35.6"
troika-three-text "^0.52.4"
tunnel-rat "^0.1.2"
use-sync-external-store "^1.4.0"
utility-types "^3.11.0"
zustand "^5.0.1"
"@react-three/fiber@^9.1.2":
version "9.1.2"
resolved "https://registry.yarnpkg.com/@react-three/fiber/-/fiber-9.1.2.tgz#c988f3aa916f64771483784ca3bb6ba4b116395e"
integrity sha512-k8FR9yVHV9kIF3iuOD0ds5hVymXYXfgdKklqziBVod9ZEJ8uk05Zjw29J/omU3IKeUfLNAIHfxneN3TUYM4I2w==
dependencies:
"@babel/runtime" "^7.17.8"
"@types/react-reconciler" "^0.28.9"
"@types/webxr" "*"
base64-js "^1.5.1"
buffer "^6.0.3"
its-fine "^2.0.0"
react-reconciler "^0.31.0"
react-use-measure "^2.1.7"
scheduler "^0.25.0"
suspend-react "^0.1.3"
use-sync-external-store "^1.4.0"
zustand "^5.0.3"
"@rolldown/pluginutils@1.0.0-beta.9":
version "1.0.0-beta.9"
resolved "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.9.tgz"
@ -892,6 +966,11 @@
"@tailwindcss/oxide" "4.1.8"
tailwindcss "4.1.8"
"@tweenjs/tween.js@~23.1.3":
version "23.1.3"
resolved "https://registry.yarnpkg.com/@tweenjs/tween.js/-/tween.js-23.1.3.tgz#eff0245735c04a928bb19c026b58c2a56460539d"
integrity sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==
"@tybys/wasm-util@^0.9.0":
version "0.9.0"
resolved "https://registry.yarnpkg.com/@tybys/wasm-util/-/wasm-util-0.9.0.tgz#3e75eb00604c8d6db470bf18c37b7d984a0e3355"
@ -946,6 +1025,11 @@
dependencies:
"@types/ms" "*"
"@types/draco3d@^1.4.0":
version "1.4.10"
resolved "https://registry.yarnpkg.com/@types/draco3d/-/draco3d-1.4.10.tgz#63ec0ba78b30bd58203ec031f4e4f0198c596dca"
integrity sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw==
"@types/estree-jsx@^1.0.0":
version "1.0.5"
resolved "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz"
@ -994,6 +1078,11 @@
dependencies:
undici-types "~6.21.0"
"@types/offscreencanvas@^2019.6.4":
version "2019.7.3"
resolved "https://registry.yarnpkg.com/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz#90267db13f64d6e9ccb5ae3eac92786a7c77a516"
integrity sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==
"@types/parse-json@^4.0.0":
version "4.0.2"
resolved "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz"
@ -1009,6 +1098,11 @@
resolved "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.5.tgz"
integrity sha512-CMCjrWucUBZvohgZxkjd6S9h0nZxXjzus6yDfUb+xLxYM7VvjKNH1tQrE9GWLql1XoOP4/Ds3bwFqShHUYraGg==
"@types/react-reconciler@^0.28.9":
version "0.28.9"
resolved "https://registry.yarnpkg.com/@types/react-reconciler/-/react-reconciler-0.28.9.tgz#d24b4864c384e770c83275b3fe73fba00269c83b"
integrity sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==
"@types/react-transition-group@^4.4.12":
version "4.4.12"
resolved "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz"
@ -1021,6 +1115,11 @@
dependencies:
csstype "^3.0.2"
"@types/stats.js@*":
version "0.17.4"
resolved "https://registry.yarnpkg.com/@types/stats.js/-/stats.js-0.17.4.tgz#1933e5ff153a23c7664487833198d685c22e791e"
integrity sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==
"@types/tern@*":
version "0.23.9"
resolved "https://registry.npmjs.org/@types/tern/-/tern-0.23.9.tgz"
@ -1028,6 +1127,19 @@
dependencies:
"@types/estree" "*"
"@types/three@*":
version "0.176.0"
resolved "https://registry.yarnpkg.com/@types/three/-/three-0.176.0.tgz#b6eced2b05e839395a6171e066c4631bc5b0a1e0"
integrity sha512-FwfPXxCqOtP7EdYMagCFePNKoG1AGBDUEVKtluv2BTVRpSt7b+X27xNsirPCTCqY1pGYsPUzaM3jgWP7dXSxlw==
dependencies:
"@dimforge/rapier3d-compat" "^0.12.0"
"@tweenjs/tween.js" "~23.1.3"
"@types/stats.js" "*"
"@types/webxr" "*"
"@webgpu/types" "*"
fflate "~0.8.2"
meshoptimizer "~0.18.1"
"@types/unist@*", "@types/unist@^3.0.0":
version "3.0.3"
resolved "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz"
@ -1038,6 +1150,11 @@
resolved "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz"
integrity sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==
"@types/webxr@*", "@types/webxr@^0.5.2":
version "0.5.22"
resolved "https://registry.yarnpkg.com/@types/webxr/-/webxr-0.5.22.tgz#d8a14c12bbfaaa4a13de21ec2d4a8197b3e1b532"
integrity sha512-Vr6Stjv5jPRqH690f5I5GLjVk8GSsoQSYJ2FVd/3jJF7KaqfwPi3ehfBS96mlQ2kPCwZaX6U0rG2+NGHBKkA/A==
"@typescript-eslint/eslint-plugin@8.33.0":
version "8.33.0"
resolved "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.33.0.tgz"
@ -1140,6 +1257,18 @@
resolved "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz"
integrity sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==
"@use-gesture/core@10.3.1":
version "10.3.1"
resolved "https://registry.yarnpkg.com/@use-gesture/core/-/core-10.3.1.tgz#976c9421e905f0079d49822cfd5c2e56b808fc56"
integrity sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==
"@use-gesture/react@^10.3.1":
version "10.3.1"
resolved "https://registry.yarnpkg.com/@use-gesture/react/-/react-10.3.1.tgz#17a743a894d9bd9a0d1980c618f37f0164469867"
integrity sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==
dependencies:
"@use-gesture/core" "10.3.1"
"@vitejs/plugin-react@^4.4.1":
version "4.5.0"
resolved "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.5.0.tgz"
@ -1152,6 +1281,11 @@
"@types/babel__core" "^7.20.5"
react-refresh "^0.17.0"
"@webgpu/types@*":
version "0.1.61"
resolved "https://registry.yarnpkg.com/@webgpu/types/-/types-0.1.61.tgz#60ac1756bbeeae778b5357a94d4e6e160592d6f1"
integrity sha512-w2HbBvH+qO19SB5pJOJFKs533CdZqxl3fcGonqL321VHkW7W/iBo6H8bjDy6pr/+pbMwIu5dnuaAxH7NxBqUrQ==
acorn-jsx@^5.3.2:
version "5.3.2"
resolved "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz"
@ -1217,6 +1351,18 @@ balanced-match@^1.0.0:
resolved "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz"
integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
base64-js@^1.3.1, base64-js@^1.5.1:
version "1.5.1"
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
bidi-js@^1.0.2:
version "1.0.3"
resolved "https://registry.yarnpkg.com/bidi-js/-/bidi-js-1.0.3.tgz#6f8bcf3c877c4d9220ddf49b9bb6930c88f877d2"
integrity sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==
dependencies:
require-from-string "^2.0.2"
brace-expansion@^1.1.7:
version "1.1.11"
resolved "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz"
@ -1249,6 +1395,14 @@ browserslist@^4.24.0:
node-releases "^2.0.19"
update-browserslist-db "^1.1.3"
buffer@^6.0.3:
version "6.0.3"
resolved "https://registry.yarnpkg.com/buffer/-/buffer-6.0.3.tgz#2ace578459cc8fbe2a70aaa8f52ee63b6a74c6c6"
integrity sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==
dependencies:
base64-js "^1.3.1"
ieee754 "^1.2.1"
call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz#4b5428c222be985d79c3d82657479dbe0b59b2d6"
@ -1262,6 +1416,11 @@ callsites@^3.0.0:
resolved "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz"
integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==
camera-controls@^2.9.0:
version "2.10.1"
resolved "https://registry.yarnpkg.com/camera-controls/-/camera-controls-2.10.1.tgz#78bc58001a2d5925c29312154ce77d16967dec56"
integrity sha512-KnaKdcvkBJ1Irbrzl8XD6WtZltkRjp869Jx8c0ujs9K+9WD+1D7ryBsCiVqJYUqt6i/HR5FxT7RLASieUD+Q5w==
caniuse-lite@^1.0.30001716:
version "1.0.30001718"
resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001718.tgz"
@ -1377,7 +1536,14 @@ cosmiconfig@^7.0.0:
path-type "^4.0.0"
yaml "^1.10.0"
cross-spawn@^7.0.6:
cross-env@^7.0.3:
version "7.0.3"
resolved "https://registry.yarnpkg.com/cross-env/-/cross-env-7.0.3.tgz#865264b29677dc015ba8418918965dd232fc54cf"
integrity sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==
dependencies:
cross-spawn "^7.0.1"
cross-spawn@^7.0.1, cross-spawn@^7.0.6:
version "7.0.6"
resolved "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz"
integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==
@ -1425,6 +1591,13 @@ dequal@^2.0.0:
resolved "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz"
integrity sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==
detect-gpu@^5.0.56:
version "5.0.70"
resolved "https://registry.yarnpkg.com/detect-gpu/-/detect-gpu-5.0.70.tgz#db2202d3cd440714ba6e789ff8b62d1b584eabf7"
integrity sha512-bqerEP1Ese6nt3rFkwPnGbsUF9a4q+gMmpTVVOEzoCyeCc+y7/RvJnQZJx1JwhgQI5Ntg0Kgat8Uu7XpBqnz1w==
dependencies:
webgl-constants "^1.1.1"
detect-libc@^2.0.3, detect-libc@^2.0.4:
version "2.0.4"
resolved "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz"
@ -1445,6 +1618,11 @@ dom-helpers@^5.0.1:
"@babel/runtime" "^7.8.7"
csstype "^3.0.2"
draco3d@^1.4.1:
version "1.5.7"
resolved "https://registry.yarnpkg.com/draco3d/-/draco3d-1.5.7.tgz#94f9bce293eb8920c159dc91a4ce9124a9e899e0"
integrity sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==
dunder-proto@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/dunder-proto/-/dunder-proto-1.0.1.tgz#d7ae667e1dc83482f8b70fd0f6eefc50da30f58a"
@ -1665,6 +1843,11 @@ esutils@^2.0.2:
resolved "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz"
integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==
eventemitter3@^5.0.1:
version "5.0.1"
resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-5.0.1.tgz#53f5ffd0a492ac800721bb42c66b841de96423c4"
integrity sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==
extend@^3.0.0:
version "3.0.2"
resolved "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz"
@ -1708,6 +1891,16 @@ fdir@^6.4.4:
resolved "https://registry.npmjs.org/fdir/-/fdir-6.4.5.tgz"
integrity sha512-4BG7puHpVsIYxZUbiUE3RqGloLaSSwzYie5jvasC4LWuBWzZawynvYouhjbQKw2JuIGYdm0DzIxl8iVidKlUEw==
fflate@^0.6.9:
version "0.6.10"
resolved "https://registry.yarnpkg.com/fflate/-/fflate-0.6.10.tgz#5f40f9659205936a2d18abf88b2e7781662b6d43"
integrity sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg==
fflate@~0.8.2:
version "0.8.2"
resolved "https://registry.yarnpkg.com/fflate/-/fflate-0.8.2.tgz#fc8631f5347812ad6028bbe4a2308b2792aa1dea"
integrity sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==
file-entry-cache@^8.0.0:
version "8.0.0"
resolved "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz"
@ -1831,6 +2024,11 @@ globals@^16.0.0:
resolved "https://registry.npmjs.org/globals/-/globals-16.2.0.tgz"
integrity sha512-O+7l9tPdHCU320IigZZPj5zmRCFG9xHmx9cU8FqU2Rp+JN714seHV+2S9+JslCpY4gJwU2vOGox0wzgae/MCEg==
glsl-noise@^0.0.0:
version "0.0.0"
resolved "https://registry.yarnpkg.com/glsl-noise/-/glsl-noise-0.0.0.tgz#367745f3a33382c0eeec4cb54b7e99cfc1d7670b"
integrity sha512-b/ZCF6amfAUb7dJM/MxRs7AetQEahYzJ8PtgfrmEdtw6uyGOr+ZSGtgjFm6mfsBkxJ4d2W7kg+Nlqzqvn3Bc0w==
gopd@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.2.0.tgz#89f56b8217bdbc8802bd299df6d7f1081d7e51a1"
@ -1962,6 +2160,11 @@ hastscript@^9.0.0:
property-information "^7.0.0"
space-separated-tokens "^2.0.0"
hls.js@^1.5.17:
version "1.6.4"
resolved "https://registry.yarnpkg.com/hls.js/-/hls.js-1.6.4.tgz#0c4070f5f719eda2687e2ab13b061dbb01967dd8"
integrity sha512-sxFS61suCMJBvpOhmi4WLnarOZ8S8JAxK5J1icvrkopE8aRMc1pRB9WZWMX5Obh9nieVEML6uLLeyGksapyX5A==
hoist-non-react-statics@^3.3.1:
version "3.3.2"
resolved "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz"
@ -1979,6 +2182,11 @@ html-void-elements@^3.0.0:
resolved "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz"
integrity sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==
ieee754@^1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352"
integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==
ignore@^5.2.0:
version "5.3.2"
resolved "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz"
@ -1989,6 +2197,11 @@ ignore@^7.0.0:
resolved "https://registry.npmjs.org/ignore/-/ignore-7.0.4.tgz"
integrity sha512-gJzzk+PQNznz8ysRrC0aOkBNVRBDtE1n53IqyqEf3PXrYwomFs5q4pGMizBMJF+ykh03insJ27hB8gSrD2Hn8A==
immediate@~3.0.5:
version "3.0.6"
resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b"
integrity sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==
import-fresh@^3.2.1:
version "3.3.1"
resolved "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz"
@ -2069,11 +2282,23 @@ is-plain-obj@^4.0.0:
resolved "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz"
integrity sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==
is-promise@^2.1.0:
version "2.2.2"
resolved "https://registry.yarnpkg.com/is-promise/-/is-promise-2.2.2.tgz#39ab959ccbf9a774cf079f7b40c7a26f763135f1"
integrity sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==
isexe@^2.0.0:
version "2.0.0"
resolved "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz"
integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==
its-fine@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/its-fine/-/its-fine-2.0.0.tgz#a90b18a3ee4c211a1fb6faac2abcc2b682ce1f21"
integrity sha512-KLViCmWx94zOvpLwSlsx6yOCeMhZYaxrJV87Po5k/FoZzcPSahvK5qJ7fYhS61sZi5ikmh2S3Hz55A2l3U69ng==
dependencies:
"@types/react-reconciler" "^0.28.9"
jiti@^2.4.2:
version "2.4.2"
resolved "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz"
@ -2136,6 +2361,13 @@ levn@^0.4.1:
prelude-ls "^1.2.1"
type-check "~0.4.0"
lie@^3.0.2:
version "3.3.0"
resolved "https://registry.yarnpkg.com/lie/-/lie-3.3.0.tgz#dcf82dee545f46074daf200c7c1c5a08e0f40f6a"
integrity sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==
dependencies:
immediate "~3.0.5"
lightningcss-darwin-arm64@1.30.1:
version "1.30.1"
resolved "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.1.tgz"
@ -2255,6 +2487,11 @@ lucide-react@^0.511.0:
resolved "https://registry.npmjs.org/lucide-react/-/lucide-react-0.511.0.tgz"
integrity sha512-VK5a2ydJ7xm8GvBeKLS9mu1pVK6ucef9780JVUjw6bAjJL/QXnd4Y0p7SPeOUMC27YhzNCZvm5d/QX0Tp3rc0w==
maath@^0.10.8:
version "0.10.8"
resolved "https://registry.yarnpkg.com/maath/-/maath-0.10.8.tgz#cf647544430141bf6982da6e878abb6c4b804e24"
integrity sha512-tRvbDF0Pgqz+9XUa4jjfgAQ8/aPKmQdWXilFu2tMy4GWj4NOsx99HlULO4IeREfbO3a0sA145DZYyvXPkybm0g==
magic-string@^0.30.17:
version "0.30.17"
resolved "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz"
@ -2382,6 +2619,16 @@ merge2@^1.3.0:
resolved "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz"
integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==
meshline@^3.3.1:
version "3.3.1"
resolved "https://registry.yarnpkg.com/meshline/-/meshline-3.3.1.tgz#20decfd5cdd25c8469e862ddf0ab1ad167759734"
integrity sha512-/TQj+JdZkeSUOl5Mk2J7eLcYTLiQm2IDzmlSvYm7ov15anEcDJ92GHqqazxTSreeNgfnYu24kiEvvv0WlbCdFQ==
meshoptimizer@~0.18.1:
version "0.18.1"
resolved "https://registry.yarnpkg.com/meshoptimizer/-/meshoptimizer-0.18.1.tgz#cdb90907f30a7b5b1190facd3b7ee6b7087797d8"
integrity sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw==
micromark-core-commonmark@^2.0.0:
version "2.0.3"
resolved "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz"
@ -2787,6 +3034,11 @@ postcss@^8.5.3:
picocolors "^1.1.1"
source-map-js "^1.2.1"
potpack@^1.0.1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/potpack/-/potpack-1.0.2.tgz#23b99e64eb74f5741ffe7656b5b5c4ddce8dfc14"
integrity sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==
prelude-ls@^1.2.1:
version "1.2.1"
resolved "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz"
@ -2797,6 +3049,14 @@ process@^0.11.1:
resolved "https://registry.npmjs.org/process/-/process-0.11.10.tgz"
integrity sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==
promise-worker-transferable@^1.0.4:
version "1.0.4"
resolved "https://registry.yarnpkg.com/promise-worker-transferable/-/promise-worker-transferable-1.0.4.tgz#2c72861ba053e5ae42b487b4a83b1ed3ae3786e8"
integrity sha512-bN+0ehEnrXfxV2ZQvU2PetO0n4gqBD4ulq3MI1WOPLgr7/Mg9yRQkX5+0v1vagr74ZTsl7XtzlaYDo2EuCeYJw==
dependencies:
is-promise "^2.1.0"
lie "^3.0.2"
prop-types@^15.6.2, prop-types@^15.8.1:
version "15.8.1"
resolved "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz"
@ -2865,6 +3125,20 @@ react-markdown@^10.1.0:
unist-util-visit "^5.0.0"
vfile "^6.0.0"
react-photo-sphere-viewer@^6.2.3:
version "6.2.3"
resolved "https://registry.yarnpkg.com/react-photo-sphere-viewer/-/react-photo-sphere-viewer-6.2.3.tgz#bdbe33a03315077b1d49f2d4690f0dc72563ac9a"
integrity sha512-VzG0aY9CI8OIQjdIoJCjYF1QlnLFpN2pM+zKm1JrpAKQrBZ6B+Uxy94vpVQkGDERgn8FWE0+LIntTgAr60pLyQ==
dependencies:
eventemitter3 "^5.0.1"
react-reconciler@^0.31.0:
version "0.31.0"
resolved "https://registry.yarnpkg.com/react-reconciler/-/react-reconciler-0.31.0.tgz#6b7390fe8fab59210daf523d7400943973de1458"
integrity sha512-7Ob7Z+URmesIsIVRjnLoDGwBEG/tVitidU0nMsqX/eeJaLY89RISO/10ERe0MqmzuKUUB1rmY+h1itMbUHg9BQ==
dependencies:
scheduler "^0.25.0"
react-refresh@^0.17.0:
version "0.17.0"
resolved "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz"
@ -2909,6 +3183,11 @@ react-transition-group@^4.4.5:
loose-envify "^1.4.0"
prop-types "^15.6.2"
react-use-measure@^2.1.7:
version "2.1.7"
resolved "https://registry.yarnpkg.com/react-use-measure/-/react-use-measure-2.1.7.tgz#36b8a2e7fd2fa58109ab851b3addcb0aad66ad1d"
integrity sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==
react@^19.1.0:
version "19.1.0"
resolved "https://registry.npmjs.org/react/-/react-19.1.0.tgz"
@ -2944,6 +3223,11 @@ remark-rehype@^11.0.0:
unified "^11.0.0"
vfile "^6.0.0"
require-from-string@^2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/require-from-string/-/require-from-string-2.0.2.tgz#89a7fdd938261267318eafe14f9c32e598c36909"
integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==
resolve-from@^4.0.0:
version "4.0.0"
resolved "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz"
@ -2999,6 +3283,11 @@ run-parallel@^1.1.9:
dependencies:
queue-microtask "^1.2.2"
scheduler@^0.25.0:
version "0.25.0"
resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.25.0.tgz#336cd9768e8cceebf52d3c80e3dcf5de23e7e015"
integrity sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==
scheduler@^0.26.0:
version "0.26.0"
resolved "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz"
@ -3046,6 +3335,19 @@ space-separated-tokens@^2.0.0:
resolved "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz"
integrity sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==
stats-gl@^2.2.8:
version "2.4.2"
resolved "https://registry.yarnpkg.com/stats-gl/-/stats-gl-2.4.2.tgz#28a6c869fc3a36a8be608ef21df63c0aad99d1ba"
integrity sha512-g5O9B0hm9CvnM36+v7SFl39T7hmAlv541tU81ME8YeSb3i1CIP5/QdDeSB3A0la0bKNHpxpwxOVRo2wFTYEosQ==
dependencies:
"@types/three" "*"
three "^0.170.0"
stats.js@^0.17.0:
version "0.17.0"
resolved "https://registry.yarnpkg.com/stats.js/-/stats.js-0.17.0.tgz#b1c3dc46d94498b578b7fd3985b81ace7131cc7d"
integrity sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw==
stringify-entities@^4.0.0:
version "4.0.4"
resolved "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz"
@ -3090,6 +3392,11 @@ supports-preserve-symlinks-flag@^1.0.0:
resolved "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz"
integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==
suspend-react@^0.1.3:
version "0.1.3"
resolved "https://registry.yarnpkg.com/suspend-react/-/suspend-react-0.1.3.tgz#a52f49d21cfae9a2fb70bd0c68413d3f9d90768e"
integrity sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ==
tailwindcss@4.1.8, tailwindcss@^4.1.8:
version "4.1.8"
resolved "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.8.tgz"
@ -3112,6 +3419,38 @@ tar@^7.4.3:
mkdirp "^3.0.1"
yallist "^5.0.0"
three-mesh-bvh@^0.8.3:
version "0.8.3"
resolved "https://registry.yarnpkg.com/three-mesh-bvh/-/three-mesh-bvh-0.8.3.tgz#c5e72472e7f062ff79084157a25c122d73184163"
integrity sha512-4G5lBaF+g2auKX3P0yqx+MJC6oVt6sB5k+CchS6Ob0qvH0YIhuUk1eYr7ktsIpY+albCqE80/FVQGV190PmiAg==
three-stdlib@^2.35.6:
version "2.36.0"
resolved "https://registry.yarnpkg.com/three-stdlib/-/three-stdlib-2.36.0.tgz#1d806b8db9156a6c87ed10f61f56f8a3ab634b42"
integrity sha512-kv0Byb++AXztEGsULgMAs8U2jgUdz6HPpAB/wDJnLiLlaWQX2APHhiTJIN7rqW+Of0eRgcp7jn05U1BsCP3xBA==
dependencies:
"@types/draco3d" "^1.4.0"
"@types/offscreencanvas" "^2019.6.4"
"@types/webxr" "^0.5.2"
draco3d "^1.4.1"
fflate "^0.6.9"
potpack "^1.0.1"
three@^0.170.0:
version "0.170.0"
resolved "https://registry.yarnpkg.com/three/-/three-0.170.0.tgz#6087f97aab79e9e9312f9c89fcef6808642dfbb7"
integrity sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ==
three@^0.175.0:
version "0.175.0"
resolved "https://registry.yarnpkg.com/three/-/three-0.175.0.tgz#67b357b0b1ee8ef0445b9a768f59363ab1fa7921"
integrity sha512-nNE3pnTHxXN/Phw768u0Grr7W4+rumGg/H6PgeseNJojkJtmeHJfZWi41Gp2mpXl1pg1pf1zjwR4McM1jTqkpg==
three@^0.177.0:
version "0.177.0"
resolved "https://registry.yarnpkg.com/three/-/three-0.177.0.tgz#e51f2eb2b921fbab535bdfa81c403f9993b9dd83"
integrity sha512-EiXv5/qWAaGI+Vz2A+JfavwYCMdGjxVsrn3oBwllUoqYeaBO75J63ZfyaQKoiLrqNHoTlUc6PFgMXnS0kI45zg==
tinyglobby@^0.2.13:
version "0.2.14"
resolved "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz"
@ -3132,6 +3471,26 @@ trim-lines@^3.0.0:
resolved "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz"
integrity sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==
troika-three-text@^0.52.4:
version "0.52.4"
resolved "https://registry.yarnpkg.com/troika-three-text/-/troika-three-text-0.52.4.tgz#f7b2389a2067d9506a5757457771cf4f6356e738"
integrity sha512-V50EwcYGruV5rUZ9F4aNsrytGdKcXKALjEtQXIOBfhVoZU9VAqZNIoGQ3TMiooVqFAbR1w15T+f+8gkzoFzawg==
dependencies:
bidi-js "^1.0.2"
troika-three-utils "^0.52.4"
troika-worker-utils "^0.52.0"
webgl-sdf-generator "1.1.1"
troika-three-utils@^0.52.4:
version "0.52.4"
resolved "https://registry.yarnpkg.com/troika-three-utils/-/troika-three-utils-0.52.4.tgz#9292019e93cab97582af1cf491c4c895e5c03b66"
integrity sha512-NORAStSVa/BDiG52Mfudk4j1FG4jC4ILutB3foPnfGbOeIs9+G5vZLa0pnmnaftZUGm4UwSoqEpWdqvC7zms3A==
troika-worker-utils@^0.52.0:
version "0.52.0"
resolved "https://registry.yarnpkg.com/troika-worker-utils/-/troika-worker-utils-0.52.0.tgz#ba5525fc444345006ebab0bc9cabdd66f1561e66"
integrity sha512-W1CpvTHykaPH5brv5VHLfQo9D1OYuo0cSBEUQFFT/nBUzM8iD6Lq2/tgG/f1OelbAS1WtaTPQzE5uM49egnngw==
trough@^2.0.0:
version "2.2.0"
resolved "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz"
@ -3147,6 +3506,13 @@ tslib@^2.4.0, tslib@^2.8.0:
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f"
integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==
tunnel-rat@^0.1.2:
version "0.1.2"
resolved "https://registry.yarnpkg.com/tunnel-rat/-/tunnel-rat-0.1.2.tgz#1717efbc474ea2d8aa05a91622457a6e201c0aeb"
integrity sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ==
dependencies:
zustand "^4.3.2"
type-check@^0.4.0, type-check@~0.4.0:
version "0.4.0"
resolved "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz"
@ -3244,7 +3610,7 @@ uri-js@^4.2.2:
dependencies:
punycode "^2.1.0"
use-sync-external-store@^1.4.0:
use-sync-external-store@^1.2.2, use-sync-external-store@^1.4.0:
version "1.5.0"
resolved "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz"
integrity sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==
@ -3261,6 +3627,11 @@ util@^0.10.3:
dependencies:
inherits "2.0.3"
utility-types@^3.11.0:
version "3.11.0"
resolved "https://registry.yarnpkg.com/utility-types/-/utility-types-3.11.0.tgz#607c40edb4f258915e901ea7995607fdf319424c"
integrity sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==
vfile-location@^5.0.0:
version "5.0.3"
resolved "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz"
@ -3304,6 +3675,16 @@ web-namespaces@^2.0.0:
resolved "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz"
integrity sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==
webgl-constants@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/webgl-constants/-/webgl-constants-1.1.1.tgz#f9633ee87fea56647a60b9ce735cbdfb891c6855"
integrity sha512-LkBXKjU5r9vAW7Gcu3T5u+5cvSvh5WwINdr0C+9jpzVB41cjQAP5ePArDtk/WHYdVj0GefCgM73BA7FlIiNtdg==
webgl-sdf-generator@1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/webgl-sdf-generator/-/webgl-sdf-generator-1.1.1.tgz#3e1b422b3d87cd3cc77f2602c9db63bc0f6accbd"
integrity sha512-9Z0JcMTFxeE+b2x1LJTdnaT8rT8aEp7MVxkNwoycNmJWwPdzoXzMh0BjJSh/AEFP+KPYZUli814h8bJZFIZ2jA==
which@^2.0.1:
version "2.0.2"
resolved "https://registry.npmjs.org/which/-/which-2.0.2.tgz"
@ -3336,6 +3717,18 @@ yocto-queue@^0.1.0:
resolved "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz"
integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==
zustand@^4.3.2:
version "4.5.7"
resolved "https://registry.yarnpkg.com/zustand/-/zustand-4.5.7.tgz#7d6bb2026a142415dd8be8891d7870e6dbe65f55"
integrity sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==
dependencies:
use-sync-external-store "^1.2.2"
zustand@^5.0.1, zustand@^5.0.3:
version "5.0.5"
resolved "https://registry.yarnpkg.com/zustand/-/zustand-5.0.5.tgz#3e236f6a953142d975336d179bc735d97db17e84"
integrity sha512-mILtRfKW9xM47hqxGIxCv12gXusoY/xTSHBYApXozR0HmQv299whhBeeAcRy+KrPPybzosvJBCOmVjq6x12fCg==
zwitch@^2.0.0:
version "2.0.4"
resolved "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz"