diff --git a/package.json b/package.json index 54bdc7f..ae27d52 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "path": "^0.12.7", "react": "^19.1.0", "react-dom": "^19.1.0", + "react-dropzone": "^14.3.8", "react-markdown": "^10.1.0", "react-photo-sphere-viewer": "^6.2.3", "react-router-dom": "^7.6.1", diff --git a/src/app/router/index.tsx b/src/app/router/index.tsx index 8eabdb9..8afce23 100644 --- a/src/app/router/index.tsx +++ b/src/app/router/index.tsx @@ -6,8 +6,9 @@ import { MainPage, SightPage, } from "@pages"; -import { authStore, editSightStore, sightsStore } from "@shared"; +import { authStore, createSightStore, editSightStore } from "@shared"; import { Layout } from "@widgets"; +import { runInAction } from "mobx"; import { useEffect } from "react"; import { Navigate, Outlet, Route, Routes, useLocation } from "react-router-dom"; @@ -34,10 +35,15 @@ const ProtectedRoute = ({ children }: { children: React.ReactNode }) => { export const Router = () => { const pathname = useLocation(); + useEffect(() => { editSightStore.clearSightInfo(); - sightsStore.clearCreateSight(); + createSightStore.clearCreateSight(); + runInAction(() => { + editSightStore.hasLoadedCommon = false; + }); }, [pathname]); + return ( <Routes> <Route diff --git a/src/pages/CreateSightPage/index.tsx b/src/pages/CreateSightPage/index.tsx index 38ed3a0..e5f0bfb 100644 --- a/src/pages/CreateSightPage/index.tsx +++ b/src/pages/CreateSightPage/index.tsx @@ -1,7 +1,6 @@ import { Box, Tab, Tabs } from "@mui/material"; import { articlesStore, cityStore, languageStore } from "@shared"; -import { InformationTab, RightWidgetTab } from "@widgets"; -import { LeftWidgetTab } from "@widgets"; +import { CreateInformationTab, CreateLeftTab, CreateRightTab } from "@widgets"; import { useEffect, useState } from "react"; import { observer } from "mobx-react-lite"; @@ -21,8 +20,11 @@ export const CreateSightPage = observer(() => { }; useEffect(() => { - getCities(); - getArticles(languageStore.language); + const fetchData = async () => { + await getCities(); + await getArticles(languageStore.language); + }; + fetchData(); }, []); return ( @@ -60,9 +62,9 @@ export const CreateSightPage = observer(() => { </Box> <div className="flex-1"> - <InformationTab value={value} index={0} /> - <LeftWidgetTab value={value} index={1} /> - <RightWidgetTab value={value} index={2} /> + <CreateInformationTab value={value} index={0} /> + <CreateLeftTab value={value} index={1} /> + <CreateRightTab value={value} index={2} /> </div> </Box> ); diff --git a/src/pages/EditSightPage/index.tsx b/src/pages/EditSightPage/index.tsx index 73c104c..cd1f932 100644 --- a/src/pages/EditSightPage/index.tsx +++ b/src/pages/EditSightPage/index.tsx @@ -20,7 +20,7 @@ function a11yProps(index: number) { export const EditSightPage = observer(() => { const [value, setValue] = useState(0); - const { getSightInfo } = editSightStore; + const { sight, getSightInfo } = editSightStore; const { getArticles } = articlesStore; const { language } = languageStore; const { id } = useParams(); @@ -75,11 +75,13 @@ export const EditSightPage = observer(() => { </Tabs> </Box> - <div className="flex-1"> - <InformationTab value={value} index={0} /> - <LeftWidgetTab value={value} index={1} /> - <RightWidgetTab value={value} index={2} /> - </div> + {sight.common.id !== 0 && ( + <div className="flex-1"> + <InformationTab value={value} index={0} /> + <LeftWidgetTab value={value} index={1} /> + <RightWidgetTab value={value} index={2} /> + </div> + )} </Box> ); }); diff --git a/src/shared/modals/SelectArticleDialog/index.tsx b/src/shared/modals/SelectArticleDialog/index.tsx index 8c0e831..0c709d6 100644 --- a/src/shared/modals/SelectArticleDialog/index.tsx +++ b/src/shared/modals/SelectArticleDialog/index.tsx @@ -1,4 +1,4 @@ -import { articlesStore } from "@shared"; +import { articlesStore, authInstance, languageStore } from "@shared"; import { observer } from "mobx-react-lite"; import { useEffect, useState } from "react"; import { @@ -22,8 +22,13 @@ import { ReactMarkdownComponent } from "@widgets"; interface SelectArticleModalProps { open: boolean; onClose: () => void; - onSelectArticle: (articleId: string) => void; - linkedArticleIds?: string[]; // Add optional prop for linked articles + onSelectArticle: ( + articleId: number, + heading: string, + body: string, + media: { id: string; media_type: number; filename: string }[] + ) => void; + linkedArticleIds?: number[]; // Add optional prop for linked articles } export const SelectArticleModal = observer( @@ -35,7 +40,7 @@ export const SelectArticleModal = observer( }: SelectArticleModalProps) => { const { articles, getArticle, getArticleMedia } = articlesStore; const [searchQuery, setSearchQuery] = useState(""); - const [selectedArticleId, setSelectedArticleId] = useState<string | null>( + const [selectedArticleId, setSelectedArticleId] = useState<number | null>( null ); const [isLoading, setIsLoading] = useState(false); @@ -50,12 +55,21 @@ export const SelectArticleModal = observer( }, [open]); useEffect(() => { - const handleKeyPress = (event: KeyboardEvent) => { + const handleKeyPress = async (event: KeyboardEvent) => { if (event.key.toLowerCase() === "enter") { event.preventDefault(); if (selectedArticleId) { - onSelectArticle(selectedArticleId); + const media = await authInstance.get( + `/article/${selectedArticleId}/media` + ); + onSelectArticle( + selectedArticleId, + articlesStore.articleData?.heading || "", + articlesStore.articleData?.body || "", + media.data || [] + ); onClose(); + setSelectedArticleId(null); } } }; @@ -66,9 +80,7 @@ export const SelectArticleModal = observer( }; }, [selectedArticleId, onSelectArticle, onClose]); - const handleArticleClick = async (articleId: string) => { - if (selectedArticleId === articleId) return; - + const handleArticleClick = async (articleId: number) => { setSelectedArticleId(articleId); setIsLoading(true); @@ -86,14 +98,13 @@ export const SelectArticleModal = observer( setIsLoading(false); } }; - // @ts-ignore - const filteredArticles = articles - // @ts-ignore - .filter((article) => !linkedArticleIds.includes(article.id)) - // @ts-ignore - .filter((article) => - article.service_name.toLowerCase().includes(searchQuery.toLowerCase()) - ); + + const filteredArticles = articles[languageStore.language].filter( + (article) => !linkedArticleIds.includes(article.id) + ); + // .filter((article) => + // article.service_name.toLowerCase().includes(searchQuery.toLowerCase()) + // ); const token = localStorage.getItem("token"); return ( @@ -150,7 +161,17 @@ export const SelectArticleModal = observer( <ListItemButton key={article.id} onClick={() => handleArticleClick(article.id)} - onDoubleClick={() => onSelectArticle(article.id)} + onDoubleClick={async () => { + const media = await authInstance.get( + `/article/${article.id}/media` + ); + onSelectArticle( + article.id, + article.heading, + article.body, + media.data + ); + }} selected={selectedArticleId === article.id} disabled={isLoading} sx={{ @@ -288,9 +309,22 @@ export const SelectArticleModal = observer( <Button onClick={onClose}>Отмена</Button> <Button variant="contained" - onClick={() => - selectedArticleId && onSelectArticle(selectedArticleId) - } + onClick={async () => { + if (selectedArticleId) { + const media = await authInstance.get( + `/article/${selectedArticleId}/media` + ); + + onSelectArticle( + selectedArticleId, + articlesStore.articleData?.heading || "", + articlesStore.articleData?.body || "", + media.data + ); + onClose(); + setSelectedArticleId(null); + } + }} disabled={!selectedArticleId || isLoading} > Выбрать diff --git a/src/shared/modals/SelectMediaDialog/index.tsx b/src/shared/modals/SelectMediaDialog/index.tsx index 82cb785..75f7a93 100644 --- a/src/shared/modals/SelectMediaDialog/index.tsx +++ b/src/shared/modals/SelectMediaDialog/index.tsx @@ -1,6 +1,6 @@ import { mediaStore } from "@shared"; import { observer } from "mobx-react-lite"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useState } from "react"; import { Dialog, DialogTitle, @@ -21,7 +21,12 @@ import { MediaViewer } from "@widgets"; interface SelectMediaDialogProps { open: boolean; // Corrected prop name onClose: () => void; - onSelectMedia: (mediaId: string) => void; // Renamed from onSelectArticle + onSelectMedia: (media: { + id: string; + filename: string; + media_name?: string; + media_type: number; + }) => void; // Renamed from onSelectArticle linkedMediaIds?: string[]; // Renamed from linkedArticleIds, assuming it refers to media already in use } @@ -32,15 +37,14 @@ export const SelectMediaDialog = observer( onSelectMedia, // Renamed prop linkedMediaIds = [], // Default to empty array if not provided, renamed }: SelectMediaDialogProps) => { - const { media, getMedia } = mediaStore; // Confirmed: using mediaStore for media + const { media, getMedia } = mediaStore; const [searchQuery, setSearchQuery] = useState(""); const [hoveredMediaId, setHoveredMediaId] = useState<string | null>(null); - const hoverTimerRef = useRef<NodeJS.Timeout | null>(null); // Fetch media on component mount useEffect(() => { getMedia(); - }, [getMedia]); // getMedia should be a dependency to avoid lint warnings if it's not stable + }, [getMedia]); // Keyboard event listener for "Enter" key to select hovered media useEffect(() => { @@ -49,7 +53,10 @@ export const SelectMediaDialog = observer( event.preventDefault(); // Prevent browser default action (e.g., form submission) if (hoveredMediaId) { - onSelectMedia(hoveredMediaId); // Call onSelectMedia + const mediaItem = media.find((m) => m.id === hoveredMediaId); + if (mediaItem) { + onSelectMedia(mediaItem); + } onClose(); } } @@ -61,26 +68,6 @@ export const SelectMediaDialog = observer( }; }, [hoveredMediaId, onSelectMedia, onClose]); // Dependencies for keyboard listener - // Effect for handling hover timeout (if you want to clear the preview after a delay) - // Based on the original code, it seemed like you wanted a delay for showing, - // but typically for a preview, it's immediate on hover and cleared on mouse leave. - // I've removed the 5-second timeout for setting the ID as it's counter-intuitive for a live preview. - // If you intend for the preview to disappear after a short while *after* the mouse leaves, - // you would implement a mouseleave timer. For now, it will clear on mouseleave. - - const handleMouseEnter = (mediaId: string) => { - if (hoverTimerRef.current) { - clearTimeout(hoverTimerRef.current); - } - setHoveredMediaId(mediaId); - }; - - const handleMouseLeave = () => { - // You can add a small delay here if you want the preview to linger for a moment - // before disappearing, e.g., setTimeout(() => setHoveredMediaId(null), 200); - setHoveredMediaId(null); - }; - const filteredMedia = media .filter((mediaItem) => !linkedMediaIds.includes(mediaItem.id)) // Use mediaItem to avoid name collision .filter((mediaItem) => @@ -125,9 +112,11 @@ export const SelectMediaDialog = observer( ) => ( <ListItemButton key={mediaItem.id} - onClick={() => onSelectMedia(mediaItem.id)} // Call onSelectMedia - onMouseEnter={() => handleMouseEnter(mediaItem.id)} - onMouseLeave={handleMouseLeave} + onClick={() => setHoveredMediaId(mediaItem.id)} // Call onSelectMedia + onDoubleClick={() => { + onSelectMedia(mediaItem); + onClose(); + }} sx={{ borderRadius: 1, mb: 0.5, diff --git a/src/shared/modals/UploadMediaDialog/index.tsx b/src/shared/modals/UploadMediaDialog/index.tsx new file mode 100644 index 0000000..8b35bb8 --- /dev/null +++ b/src/shared/modals/UploadMediaDialog/index.tsx @@ -0,0 +1,259 @@ +import { MEDIA_TYPE_LABELS, editSightStore } from "@shared"; +import { observer } from "mobx-react-lite"; +import { useEffect, useState } from "react"; +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + TextField, + Paper, + Box, + CircularProgress, + Alert, + Snackbar, + Select, + MenuItem, + FormControl, + InputLabel, +} from "@mui/material"; +import { Save } from "lucide-react"; +import { ModelViewer3D } from "@widgets"; + +interface UploadMediaDialogProps { + open: boolean; + onClose: () => void; + afterUpload: (media: { + id: string; + filename: string; + media_name?: string; + media_type: number; + }) => void; +} + +export const UploadMediaDialog = observer( + ({ open, onClose, afterUpload }: UploadMediaDialogProps) => { + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState<string | null>(null); + const [success, setSuccess] = useState(false); + const [mediaName, setMediaName] = useState(""); + const [mediaFilename, setMediaFilename] = useState(""); + const [mediaType, setMediaType] = useState(0); + const [mediaFile, setMediaFile] = useState<File | null>(null); + const { fileToUpload, uploadMedia } = editSightStore; + const [mediaUrl, setMediaUrl] = useState<string | null>(null); + const [availableMediaTypes, setAvailableMediaTypes] = useState<number[]>( + [] + ); + + useEffect(() => { + if (fileToUpload) { + setMediaFile(fileToUpload); + setMediaFilename(fileToUpload.name); + // Try to determine media type from file extension + const extension = fileToUpload.name.split(".").pop()?.toLowerCase(); + if (extension) { + if (["glb", "gltf"].includes(extension)) { + setAvailableMediaTypes([6]); + setMediaType(6); + } + if (["jpg", "jpeg", "png", "gif"].includes(extension)) { + // Для изображений доступны все типы кроме видео + setAvailableMediaTypes([1, 3, 4, 5]); // Фото, Иконка, Водяной знак, Панорама, 3Д-модель + setMediaType(1); // По умолчанию Фото + } else if (["mp4", "webm", "mov"].includes(extension)) { + // Для видео только тип Видео + setAvailableMediaTypes([2]); + setMediaType(2); + } + } + } + }, [fileToUpload]); + + useEffect(() => { + if (mediaFile) { + setMediaUrl(URL.createObjectURL(mediaFile as Blob)); + } + }, [mediaFile]); + + // const fileFormat = useEffect(() => { + // const handleKeyPress = (event: KeyboardEvent) => { + // if (event.key.toLowerCase() === "enter" && !event.ctrlKey) { + // event.preventDefault(); + // onClose(); + // } + // }; + + // window.addEventListener("keydown", handleKeyPress); + // return () => window.removeEventListener("keydown", handleKeyPress); + // }, [onClose]); + + const handleSave = async () => { + if (!mediaFile) return; + + setIsLoading(true); + setError(null); + + try { + const media = await uploadMedia( + mediaFilename, + mediaType, + mediaFile, + mediaName + ); + if (media) { + await afterUpload(media); + } + setSuccess(true); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to save media"); + } finally { + setIsLoading(false); + } + }; + + const handleClose = () => { + setError(null); + setSuccess(false); + onClose(); + }; + + return ( + <> + <Dialog open={open} onClose={handleClose} maxWidth="md" fullWidth> + <DialogTitle>Просмотр медиа</DialogTitle> + <DialogContent + className="flex gap-4" + dividers + sx={{ + height: "600px", + display: "flex", + flexDirection: "column", + gap: 2, + pt: 2, + }} + > + <Box className="flex flex-col gap-4"> + <Box className="flex gap-2"> + <TextField + fullWidth + value={mediaName} + onChange={(e) => setMediaName(e.target.value)} + label="Название медиа" + disabled={isLoading} + /> + <TextField + fullWidth + value={mediaFilename} + onChange={(e) => setMediaFilename(e.target.value)} + label="Название файла" + disabled={isLoading} + /> + </Box> + + <FormControl fullWidth sx={{ width: "50%" }}> + <InputLabel>Тип медиа</InputLabel> + <Select + value={mediaType} + label="Тип медиа" + onChange={(e) => setMediaType(Number(e.target.value))} + disabled={isLoading} + > + {availableMediaTypes.map((type) => ( + <MenuItem key={type} value={type}> + { + MEDIA_TYPE_LABELS[ + type as keyof typeof MEDIA_TYPE_LABELS + ] + } + </MenuItem> + ))} + </Select> + </FormControl> + + <Box className="flex gap-4 h-full"> + <Paper + elevation={2} + sx={{ + flex: 1, + p: 2, + display: "flex", + alignItems: "center", + justifyContent: "center", + minHeight: 400, + }} + > + {/* <MediaViewer + media={{ + id: "", + media_type: mediaType, + filename: mediaFilename, + }} + /> */} + {mediaType === 6 && mediaUrl && ( + <ModelViewer3D fileUrl={mediaUrl} height="100%" /> + )} + {mediaType !== 6 && mediaType !== 2 && mediaUrl && ( + <img + src={mediaUrl ?? ""} + alt="Uploaded media" + style={{ + maxWidth: "100%", + maxHeight: "100%", + objectFit: "contain", + }} + /> + )} + </Paper> + + <Box className="flex flex-col gap-2 self-end"> + <Button + variant="contained" + color="success" + startIcon={ + isLoading ? ( + <CircularProgress size={16} /> + ) : ( + <Save size={16} /> + ) + } + onClick={handleSave} + disabled={isLoading || (!mediaName && !mediaFilename)} + > + Сохранить + </Button> + </Box> + </Box> + </Box> + </DialogContent> + <DialogActions> + <Button onClick={handleClose} disabled={isLoading}> + Отмена + </Button> + </DialogActions> + </Dialog> + + <Snackbar + open={!!error} + autoHideDuration={6000} + onClose={() => setError(null)} + > + <Alert severity="error" onClose={() => setError(null)}> + {error} + </Alert> + </Snackbar> + + <Snackbar + open={success} + autoHideDuration={3000} + onClose={() => setSuccess(false)} + > + <Alert severity="success" onClose={() => setSuccess(false)}> + Медиа успешно сохранено + </Alert> + </Snackbar> + </> + ); + } +); diff --git a/src/shared/modals/index.ts b/src/shared/modals/index.ts index 22df340..378cd4f 100644 --- a/src/shared/modals/index.ts +++ b/src/shared/modals/index.ts @@ -1,3 +1,4 @@ export * from "./SelectArticleDialog"; export * from "./SelectMediaDialog"; export * from "./PreviewMediaDialog"; +export * from "./UploadMediaDialog"; diff --git a/src/shared/store/CityStore/index.tsx b/src/shared/store/CityStore/index.tsx index 154d96d..4f87be0 100644 --- a/src/shared/store/CityStore/index.tsx +++ b/src/shared/store/CityStore/index.tsx @@ -17,6 +17,8 @@ class CityStore { } getCities = async () => { + if (this.cities.length !== 0) return; + const response = await authInstance.get("/city"); runInAction(() => { this.cities = response.data; diff --git a/src/shared/store/CreateSightStore/index.tsx b/src/shared/store/CreateSightStore/index.tsx new file mode 100644 index 0000000..227fcb4 --- /dev/null +++ b/src/shared/store/CreateSightStore/index.tsx @@ -0,0 +1,449 @@ +// @shared/stores/createSightStore.ts +import { + Language, + authInstance, + languageInstance, + articlesStore, + languageStore, + mediaStore, +} from "@shared"; +import { makeAutoObservable } from "mobx"; + +type SightLanguageInfo = { + name: string; + address: string; + left: { + heading: string; + body: string; + media: { + id: string; + filename: string; + media_name?: string; + media_type: number; + }[]; + }; + right: { heading: string; body: string }[]; +}; + +type SightCommonInfo = { + id: number; + city_id: number; + city: string; + latitude: number; + longitude: number; + thumbnail: string | null; + watermark_lu: string | null; + watermark_rd: string | null; + left_article: number; + preview_media: string | null; + video_preview: string | null; +}; + +type SightBaseInfo = SightCommonInfo & { + [key in Language]: SightLanguageInfo; +}; + +class CreateSightStore { + sight: SightBaseInfo = { + id: 0, + city_id: 0, + city: "", + latitude: 0, + longitude: 0, + thumbnail: null, + watermark_lu: null, + watermark_rd: null, + left_article: 0, + preview_media: null, + video_preview: null, + + ru: { + name: "", + address: "", + left: { heading: "", body: "", media: [] }, + right: [], + }, + en: { + name: "", + address: "", + left: { heading: "", body: "", media: [] }, + right: [], + }, + zh: { + name: "", + address: "", + left: { heading: "", body: "", media: [] }, + right: [], + }, + }; + + uploadMediaOpen = false; + setUploadMediaOpen = (open: boolean) => { + this.uploadMediaOpen = open; + }; + fileToUpload: File | null = null; + setFileToUpload = (file: File | null) => { + this.fileToUpload = file; + }; + + constructor() { + makeAutoObservable(this); + } + + createNewRightArticle = () => { + this.sight.ru.right.push({ + heading: "Введите русский заголовок", + body: "Введите русский текст", + }); + this.sight.en.right.push({ + heading: "Enter the English heading", + body: "Enter the English text", + }); + this.sight.zh.right.push({ + heading: "Введите китайский заголовок", + body: "Введите китайский текст", + }); + }; + + updateLeftInfo = (language: Language, heading: string, body: string) => { + this.sight[language].left.heading = heading; + this.sight[language].left.body = body; + }; + + clearCreateSight = () => { + this.sight = { + id: 0, + city_id: 0, + city: "", + latitude: 0, + longitude: 0, + thumbnail: null, + watermark_lu: null, + watermark_rd: null, + left_article: 0, + preview_media: null, + video_preview: null, + + ru: { + name: "", + address: "", + left: { heading: "", body: "", media: [] }, + right: [], + }, + + en: { + name: "", + address: "", + left: { heading: "", body: "", media: [] }, + right: [], + }, + + zh: { + name: "", + address: "", + left: { heading: "", body: "", media: [] }, + right: [], + }, + }; + }; + + updateSightInfo = ( + content: Partial<SightLanguageInfo | SightCommonInfo>, + language?: Language + ) => { + if (language) { + this.sight[language] = { + ...this.sight[language], + ...content, + }; + } else { + this.sight = { + ...this.sight, + ...content, + }; + } + }; + + unlinkLeftArticle = () => { + this.sight.left_article = 0; + this.sight.ru.left.heading = ""; + this.sight.en.left.heading = ""; + this.sight.zh.left.heading = ""; + this.sight.ru.left.body = ""; + this.sight.en.left.body = ""; + this.sight.zh.left.body = ""; + }; + + updateLeftArticle = async (articleId: number) => { + this.sight.left_article = articleId; + + if (articleId) { + const ruArticleData = await languageInstance("ru").get( + `/article/${articleId}` + ); + const enArticleData = await languageInstance("en").get( + `/article/${articleId}` + ); + const zhArticleData = await languageInstance("zh").get( + `/article/${articleId}` + ); + + this.sight.ru.left.heading = ruArticleData.data.heading; + this.sight.en.left.heading = enArticleData.data.heading; + this.sight.zh.left.heading = zhArticleData.data.heading; + + this.sight.ru.left.body = ruArticleData.data.body; + this.sight.en.left.body = enArticleData.data.body; + this.sight.zh.left.body = zhArticleData.data.body; + } else { + this.sight.left_article = 0; + this.sight.ru.left.heading = ""; + this.sight.en.left.heading = ""; + this.sight.zh.left.heading = ""; + this.sight.ru.left.body = ""; + this.sight.en.left.body = ""; + this.sight.zh.left.body = ""; + } + }; + + deleteLeftArticle = async (articleId: number) => { + await authInstance.delete(`/article/${articleId}`); + articlesStore.getArticles(languageStore.language); + this.sight.left_article = 0; + this.sight.ru.left.heading = ""; + this.sight.en.left.heading = ""; + this.sight.zh.left.heading = ""; + this.sight.ru.left.body = ""; + }; + + createLeftArticle = async () => { + const response = await languageInstance("ru").post("/article", { + heading: "Новая статья", + body: "Заполните статью контентом", + }); + + this.sight.left_article = response.data.id; + + this.sight.ru.left.heading = "Новая статья "; + this.sight.en.left.heading = ""; + this.sight.zh.left.heading = ""; + this.sight.ru.left.body = "Заполните статью контентом"; + this.sight.en.left.body = ""; + this.sight.zh.left.body = ""; + }; + + createSight = async (language: Language) => { + const rightArticles: number[] = []; + + if (this.sight.left_article !== 0) { + if (this.sight.left_article == 10000000) { + const response = await languageInstance("ru").post("/article", { + heading: this.sight.ru.left.heading, + body: this.sight.ru.left.body, + }); + const { id } = response.data; + await languageInstance("en").patch(`/article/${id}`, { + heading: this.sight.en.left.heading, + body: this.sight.en.left.body, + }); + + await languageInstance("zh").patch(`/article/${id}`, { + heading: this.sight.zh.left.heading, + body: this.sight.zh.left.body, + }); + this.sight.left_article = id; + } else { + await languageInstance("ru").patch( + `/article/${this.sight.left_article}`, + { + heading: this.sight.ru.left.heading, + body: this.sight.ru.left.body, + } + ); + + await languageInstance("en").patch( + `/article/${this.sight.left_article}`, + { + heading: this.sight.en.left.heading, + body: this.sight.en.left.body, + } + ); + + await languageInstance("zh").patch( + `/article/${this.sight.left_article}`, + { + heading: this.sight.zh.left.heading, + body: this.sight.zh.left.body, + } + ); + } + } + + this.sight[language].right.map(async (article, index) => { + try { + const response = await languageInstance(language).post("/article", { + heading: article.heading, + body: article.body, + }); + const { id } = response.data; + const anotherLanguages = ["en", "zh", "ru"].filter( + (lang) => lang !== language + ); + await languageInstance(anotherLanguages[0] as Language).patch( + `/article/${id}`, + { + heading: + this.sight[anotherLanguages[0] as Language].right[index].heading, + body: this.sight[anotherLanguages[0] as Language].right[index].body, + } + ); + await languageInstance(anotherLanguages[1] as Language).patch( + `/article/${id}`, + { + heading: + this.sight[anotherLanguages[1] as Language].right[index].heading, + body: this.sight[anotherLanguages[1] as Language].right[index].body, + } + ); + rightArticles.push(id); + } catch (error) { + console.log(error); + } + }); + const response = await languageInstance(language).post("/sight", { + city_id: this.sight.city_id, + city: this.sight.city, + latitude: this.sight.latitude, + longitude: this.sight.longitude, + name: this.sight[language].name, + address: this.sight[language].address, + thumbnail: this.sight.thumbnail ?? null, + watermark_lu: this.sight.watermark_lu, + watermark_rd: this.sight.watermark_rd, + left_article: this.sight.left_article, + preview_media: this.sight.preview_media, + video_preview: this.sight.video_preview, + }); + + const { id } = response.data; + const anotherLanguages = ["en", "zh", "ru"].filter( + (lang) => lang !== language + ); + + await languageInstance(anotherLanguages[0] as Language).patch( + `/sight/${id}`, + { + city_id: this.sight.city_id, + city: this.sight.city, + latitude: this.sight.latitude, + longitude: this.sight.longitude, + name: this.sight[anotherLanguages[0] as Language as Language].name, + address: + this.sight[anotherLanguages[0] as Language as Language].address, + thumbnail: this.sight.thumbnail ?? null, + watermark_lu: this.sight.watermark_lu, + watermark_rd: this.sight.watermark_rd, + left_article: this.sight.left_article, + preview_media: this.sight.preview_media, + video_preview: this.sight.video_preview, + } + ); + await languageInstance(anotherLanguages[1] as Language).patch( + `/sight/${id}`, + { + city_id: this.sight.city_id, + city: this.sight.city, + latitude: this.sight.latitude, + longitude: this.sight.longitude, + name: this.sight[anotherLanguages[1] as Language].name, + address: this.sight[anotherLanguages[1] as Language].address, + thumbnail: this.sight.thumbnail ?? null, + watermark_lu: this.sight.watermark_lu, + watermark_rd: this.sight.watermark_rd, + left_article: this.sight.left_article, + preview_media: this.sight.preview_media, + video_preview: this.sight.video_preview, + } + ); + + rightArticles.map(async (article, index) => { + await authInstance.post(`/sight/${id}/article`, { + article_id: article, + page_num: index + 1, + }); + }); + console.log("created"); + }; + + updateRightArticleInfo = ( + index: number, + language: Language, + heading: string, + body: string + ) => { + this.sight[language].right[index].heading = heading; + this.sight[language].right[index].body = body; + }; + + uploadMedia = async ( + filename: string, + type: number, + file: File, + media_name?: string + ) => { + const formData = new FormData(); + formData.append("file", file); + formData.append("filename", filename); + if (media_name) { + formData.append("media_name", media_name); + } + formData.append("type", type.toString()); + try { + const response = await authInstance.post(`/media`, formData); + this.fileToUpload = null; + this.uploadMediaOpen = false; + mediaStore.getMedia(); + return { + id: response.data.id, + filename: filename, + media_name: media_name, + media_type: type, + }; + } catch (error) { + console.log(error); + throw error; + } + }; + + createLinkWithArticle = async (media: { + id: string; + filename: string; + media_name?: string; + media_type: number; + }) => { + await authInstance.post(`/article/${this.sight.left_article}/media`, { + media_id: media.id, + media_order: 1, + }); + + this.sight.ru.left.media.unshift({ + id: media.id, + media_type: media.media_type, + filename: media.filename, + }); + + this.sight.en.left.media.unshift({ + id: media.id, + media_type: media.media_type, + filename: media.filename, + }); + + this.sight.zh.left.media.unshift({ + id: media.id, + media_type: media.media_type, + filename: media.filename, + }); + }; +} + +export const createSightStore = new CreateSightStore(); diff --git a/src/shared/store/EditSightStore/index.tsx b/src/shared/store/EditSightStore/index.tsx index 19b8c8b..37af665 100644 --- a/src/shared/store/EditSightStore/index.tsx +++ b/src/shared/store/EditSightStore/index.tsx @@ -1,26 +1,31 @@ // @shared/stores/editSightStore.ts -import { authInstance, Language } from "@shared"; +import { authInstance, Language, languageInstance, mediaStore } from "@shared"; import { makeAutoObservable, runInAction } from "mobx"; export type SightLanguageInfo = { id: number; name: string; address: string; - left: { heading: string; body: string }; + left: { + heading: string; + body: string; + media: { id: string; media_type: number; filename: string }[]; + }; right: { heading: string; body: string }[]; }; export type SightCommonInfo = { + id: number; city_id: number; city: string; latitude: number; longitude: number; - thumbnail: string; - watermark_lu: string; - watermark_rd: string; + thumbnail: string | null; + watermark_lu: string | null; + watermark_rd: string | null; left_article: number; - preview_media: string; - video_preview: string; + preview_media: string | null; + video_preview: string | null; }; export type SightBaseInfo = { @@ -31,36 +36,37 @@ export type SightBaseInfo = { class EditSightStore { sight: SightBaseInfo = { common: { + id: 0, city_id: 0, city: "", latitude: 0, longitude: 0, - thumbnail: "", - watermark_lu: "", - watermark_rd: "", + thumbnail: null, + watermark_lu: null, + watermark_rd: null, left_article: 0, - preview_media: "", - video_preview: "", + preview_media: null, + video_preview: null, }, ru: { id: 0, name: "", address: "", - left: { heading: "", body: "" }, + left: { heading: "", body: "", media: [] }, right: [], }, en: { id: 0, name: "", address: "", - left: { heading: "", body: "" }, + left: { heading: "", body: "", media: [] }, right: [], }, zh: { id: 0, name: "", address: "", - left: { heading: "", body: "" }, + left: { heading: "", body: "", media: [] }, right: [], }, }; @@ -77,6 +83,9 @@ class EditSightStore { const response = await authInstance.get(`/sight/${id}`); const data = response.data; + if (data.left_article != 0) { + await this.getLeftArticle(data.left_article); + } runInAction(() => { // Обновляем языковую часть @@ -101,25 +110,62 @@ class EditSightStore { this.sight[language].left.body = body; }; + getRightArticles = async (id: number) => { + const responseRu = await languageInstance("ru").get(`/sight/${id}/article`); + const responseEn = await languageInstance("en").get(`/sight/${id}/article`); + const responseZh = await languageInstance("zh").get(`/sight/${id}/article`); + + const data = { + ru: { + right: responseRu.data, + }, + en: { + right: responseEn.data, + }, + zh: { + right: responseZh.data, + }, + }; + runInAction(() => { + this.sight = { + ...this.sight, + ru: { + ...this.sight.ru, + right: data.ru.right, + }, + en: { + ...this.sight.en, + right: data.en.right, + }, + + zh: { + ...this.sight.zh, + right: data.zh.right, + }, + }; + }); + }; + clearSightInfo = () => { this.sight = { common: { + id: 0, city_id: 0, city: "", latitude: 0, longitude: 0, - thumbnail: "", - watermark_lu: "", - watermark_rd: "", + thumbnail: null, + watermark_lu: null, + watermark_rd: null, left_article: 0, - preview_media: "", - video_preview: "", + preview_media: null, + video_preview: null, }, ru: { id: 0, name: "", address: "", - left: { heading: "", body: "" }, + left: { heading: "", body: "", media: [] }, right: [], }, @@ -127,7 +173,7 @@ class EditSightStore { id: 0, name: "", address: "", - left: { heading: "", body: "" }, + left: { heading: "", body: "", media: [] }, right: [], }, @@ -135,7 +181,7 @@ class EditSightStore { id: 0, name: "", address: "", - left: { heading: "", body: "" }, + left: { heading: "", body: "", media: [] }, right: [], }, }; @@ -158,6 +204,244 @@ class EditSightStore { }; } }; + + unlinkLeftArticle = async () => { + this.sight.common.left_article = 0; + this.sight.ru.left.heading = ""; + this.sight.en.left.heading = ""; + this.sight.zh.left.heading = ""; + this.sight.ru.left.body = ""; + this.sight.en.left.body = ""; + this.sight.zh.left.body = ""; + }; + + updateSight = async () => { + let createdLeftArticleId = this.sight.common.left_article; + + if (this.sight.common.left_article == 10000000) { + const response = await languageInstance("ru").post(`/article`, { + heading: this.sight.ru.left.heading, + body: this.sight.ru.left.body, + }); + createdLeftArticleId = response.data.id; + await languageInstance("en").patch(`/article/${createdLeftArticleId}`, { + heading: this.sight.en.left.heading, + body: this.sight.en.left.body, + }); + + await languageInstance("zh").patch(`/article/${createdLeftArticleId}`, { + heading: this.sight.zh.left.heading, + body: this.sight.zh.left.body, + }); + + this.sight.common.left_article = createdLeftArticleId; + } else if (this.sight.common.left_article != 0) { + await languageInstance("ru").patch( + `/article/${this.sight.common.left_article}`, + { + heading: this.sight.ru.left.heading, + body: this.sight.ru.left.body, + } + ); + await languageInstance("en").patch( + `/article/${this.sight.common.left_article}`, + { + heading: this.sight.en.left.heading, + body: this.sight.en.left.body, + } + ); + + await languageInstance("zh").patch( + `/article/${this.sight.common.left_article}`, + { + heading: this.sight.zh.left.heading, + body: this.sight.zh.left.body, + } + ); + } + + await languageInstance("ru").patch(`/sight/${this.sight.common.id}`, { + ...this.sight.common, + name: this.sight.ru.name, + address: this.sight.ru.address, + left_article: createdLeftArticleId, + }); + await languageInstance("en").patch(`/sight/${this.sight.common.id}`, { + ...this.sight.common, + name: this.sight.en.name, + address: this.sight.en.address, + left_article: createdLeftArticleId, + }); + await languageInstance("zh").patch(`/sight/${this.sight.common.id}`, { + ...this.sight.common, + name: this.sight.zh.name, + address: this.sight.zh.address, + left_article: createdLeftArticleId, + }); + + if (this.sight.common.left_article == 0) { + return; + } + + // await languageInstance("ru").patch( + // `/sight/${this.sight.common.left_article}/article`, + // { + // heading: this.sight.ru.left.heading, + // body: this.sight.ru.left.body, + // } + // ); + // await languageInstance("en").patch( + // `/sight/${this.sight.common.left_article}/article`, + // { + // heading: this.sight.en.left.heading, + // body: this.sight.en.left.body, + // } + // ); + // await languageInstance("zh").patch( + // `/sight/${this.sight.common.left_article}/article`, + // { + // heading: this.sight.zh.left.heading, + // body: this.sight.zh.left.body, + // } + // ); + }; + + getLeftArticle = async (id: number) => { + const response = await languageInstance("ru").get(`/article/${id}`); + const responseEn = await languageInstance("en").get(`/article/${id}`); + const responseZh = await languageInstance("zh").get(`/article/${id}`); + const mediaIds = await authInstance.get(`/article/${id}/media`); + runInAction(() => { + this.sight.ru.left = { + heading: response.data.heading, + body: response.data.body, + media: mediaIds.data, + }; + this.sight.en.left = { + heading: responseEn.data.heading, + body: responseEn.data.body, + media: mediaIds.data, + }; + this.sight.zh.left = { + heading: responseZh.data.heading, + body: responseZh.data.body, + media: mediaIds.data, + }; + }); + }; + + deleteLeftArticle = async (id: number) => { + await authInstance.delete(`/article/${id}`); + this.sight.common.left_article = 0; + this.sight.ru.left.heading = ""; + this.sight.en.left.heading = ""; + this.sight.zh.left.heading = ""; + this.sight.ru.left.body = ""; + this.sight.en.left.body = ""; + this.sight.zh.left.body = ""; + }; + + createLeftArticle = async () => { + const response = await languageInstance("ru").post(`/article`, { + heading: "", + body: "", + }); + + this.sight.common.left_article = response.data.id; + + this.sight.ru.left.heading = ""; + this.sight.en.left.heading = ""; + this.sight.zh.left.heading = ""; + this.sight.ru.left.body = ""; + this.sight.en.left.body = ""; + this.sight.zh.left.body = ""; + }; + + deleteMedia = async (article_id: number, media_id: string) => { + await authInstance.delete(`/article/${article_id}/media`, { + data: { + media_id: media_id, + }, + }); + + this.sight.ru.left.media = this.sight.ru.left.media.filter( + (media) => media.id !== media_id + ); + this.sight.en.left.media = this.sight.en.left.media.filter( + (media) => media.id !== media_id + ); + this.sight.zh.left.media = this.sight.zh.left.media.filter( + (media) => media.id !== media_id + ); + }; + + uploadMediaOpen = false; + setUploadMediaOpen = (open: boolean) => { + this.uploadMediaOpen = open; + }; + fileToUpload: File | null = null; + setFileToUpload = (file: File | null) => { + this.fileToUpload = file; + }; + uploadMedia = async ( + filename: string, + type: number, + file: File, + media_name?: string + ) => { + const formData = new FormData(); + formData.append("file", file); + formData.append("filename", filename); + if (media_name) { + formData.append("media_name", media_name); + } + formData.append("type", type.toString()); + try { + const response = await authInstance.post(`/media`, formData); + this.fileToUpload = null; + this.uploadMediaOpen = false; + mediaStore.getMedia(); + return { + id: response.data.id, + filename: filename, + media_name: media_name, + media_type: type, + }; + } catch (error) { + console.log(error); + } + }; + + createLinkWithArticle = async (media: { + id: string; + filename: string; + media_name?: string; + media_type: number; + }) => { + await authInstance.post( + `/article/${this.sight.common.left_article}/media`, + { + media_id: media.id, + media_order: 1, + } + ); + + this.sight.ru.left.media.unshift({ + id: media.id, + media_type: media.media_type, + filename: media.filename, + }); + this.sight.en.left.media.unshift({ + id: media.id, + media_type: media.media_type, + filename: media.filename, + }); + this.sight.zh.left.media.unshift({ + id: media.id, + media_type: media.media_type, + filename: media.filename, + }); + }; } export const editSightStore = new EditSightStore(); diff --git a/src/shared/store/index.ts b/src/shared/store/index.ts index cb82e5c..efa6fe6 100644 --- a/src/shared/store/index.ts +++ b/src/shared/store/index.ts @@ -8,3 +8,4 @@ export * from "./CityStore"; export * from "./ArticlesStore"; export * from "./EditSightStore"; export * from "./MediaStore"; +export * from "./CreateSightStore"; diff --git a/src/widgets/MediaArea/index.tsx b/src/widgets/MediaArea/index.tsx new file mode 100644 index 0000000..4b66d6d --- /dev/null +++ b/src/widgets/MediaArea/index.tsx @@ -0,0 +1,135 @@ +import { Box, Button } from "@mui/material"; +import { MediaViewer } from "@widgets"; +import { PreviewMediaDialog } from "@shared"; +import { X, Upload } from "lucide-react"; +import { observer } from "mobx-react-lite"; +import { useState, DragEvent, useRef } from "react"; + +export const MediaArea = observer( + ({ + articleId, + mediaIds, + deleteMedia, + onFilesDrop, // 👈 Проп для обработки загруженных файлов + setSelectMediaDialogOpen, + }: { + articleId: number; + mediaIds: { id: string; media_type: number; filename: string }[]; + deleteMedia: (id: number, media_id: string) => void; + onFilesDrop?: (files: File[]) => void; + setSelectMediaDialogOpen: (open: boolean) => void; + }) => { + const [mediaModal, setMediaModal] = useState<boolean>(false); + const [mediaId, setMediaId] = useState<string>(""); + const [isDragging, setIsDragging] = useState(false); + const fileInputRef = useRef<HTMLInputElement>(null); + + const handleMediaModal = (mediaId: string) => { + setMediaModal(true); + setMediaId(mediaId); + }; + + const handleDrop = (e: DragEvent<HTMLDivElement>) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(false); + + const files = Array.from(e.dataTransfer.files); + if (files.length && onFilesDrop) { + onFilesDrop(files); + } + }; + + const handleDragOver = (e: DragEvent<HTMLDivElement>) => { + e.preventDefault(); + setIsDragging(true); + }; + + const handleDragLeave = () => { + setIsDragging(false); + }; + + const handleClick = () => { + fileInputRef.current?.click(); + }; + + const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => { + const files = Array.from(event.target.files || []); + if (files.length && onFilesDrop) { + onFilesDrop(files); + } + // Сбрасываем значение input, чтобы можно было выбрать тот же файл снова + event.target.value = ""; + }; + + return ( + <> + <input + type="file" + ref={fileInputRef} + onChange={handleFileSelect} + accept="image/*,video/*,.glb,.gltf" + multiple + style={{ display: "none" }} + /> + <Box className="w-full flex flex-col items-center justify-center border rounded-md p-4"> + <div className="w-full flex flex-col items-center justify-center"> + <div + className={`w-full h-40 flex flex-col justify-center items-center text-gray-400 border-dashed border-2 rounded-md border-gray-400 p-4 cursor-pointer hover:bg-gray-50 ${ + isDragging ? "bg-blue-100 border-blue-400" : "" + }`} + onDrop={handleDrop} + onDragOver={handleDragOver} + onDragLeave={handleDragLeave} + onClick={handleClick} + > + <Upload size={32} className="mb-2" /> + Перетащите медиа файлы сюда или нажмите для выбора + </div> + <div>или</div> + <Button + variant="contained" + color="primary" + onClick={() => setSelectMediaDialogOpen(true)} + > + Выбрать существующие медиа файлы + </Button> + </div> + + <div className="w-full flex flex-start flex-wrap gap-2 mt-4"> + {mediaIds.map((m) => ( + <button + className="relative w-40 h-40" + key={m.id} + onClick={() => handleMediaModal(m.id)} + > + <MediaViewer + media={{ + id: m.id, + media_type: m.media_type, + filename: m.filename, + }} + /> + <button + className="absolute top-2 right-2" + onClick={(e) => { + e.stopPropagation(); + deleteMedia(articleId, m.id); + }} + > + <X size={16} color="red" /> + </button> + </button> + ))} + </div> + </Box> + + <PreviewMediaDialog + open={mediaModal} + onClose={() => setMediaModal(false)} + mediaId={mediaId} + /> + </> + ); + } +); diff --git a/src/widgets/ModelViewer3D/index.tsx b/src/widgets/ModelViewer3D/index.tsx new file mode 100644 index 0000000..5648efd --- /dev/null +++ b/src/widgets/ModelViewer3D/index.tsx @@ -0,0 +1,24 @@ +import { Stage, useGLTF } from "@react-three/drei"; +import { Canvas } from "@react-three/fiber"; +import { OrbitControls } from "@react-three/drei"; + +export const ModelViewer3D = ({ + fileUrl, + height = "100%", +}: { + fileUrl: string; + height: string; +}) => { + const { scene } = useGLTF(fileUrl); + + return ( + <Canvas style={{ width: "100%", height: height }}> + <ambientLight /> + <directionalLight /> + <Stage environment="city" intensity={0.6}> + <primitive object={scene} /> + </Stage> + <OrbitControls /> + </Canvas> + ); +}; diff --git a/src/widgets/ReactMarkdownEditor/index.tsx b/src/widgets/ReactMarkdownEditor/index.tsx index e4c498e..b695afa 100644 --- a/src/widgets/ReactMarkdownEditor/index.tsx +++ b/src/widgets/ReactMarkdownEditor/index.tsx @@ -24,6 +24,14 @@ const StyledMarkdownEditor = styled("div")(({ theme }) => ({ backgroundColor: theme.palette.background.paper, color: theme.palette.text.primary, borderColor: theme.palette.divider, + height: "auto", + minHeight: "200px", + maxHeight: "500px", + overflow: "auto", + }, + "& .CodeMirror-scroll": { + minHeight: "200px", + maxHeight: "500px", }, // Стили для текста в редакторе "& .CodeMirror-selected": { diff --git a/src/widgets/SightTabs/CreateInformationTab/MediaUploadBox.tsx b/src/widgets/SightTabs/CreateInformationTab/MediaUploadBox.tsx new file mode 100644 index 0000000..a148144 --- /dev/null +++ b/src/widgets/SightTabs/CreateInformationTab/MediaUploadBox.tsx @@ -0,0 +1,158 @@ +// import { Box, Button, Paper, Typography } from "@mui/material"; +// import { X, Upload } from "lucide-react"; +// import { useCallback, useState } from "react"; +// import { useDropzone } from "react-dropzone"; +// import { UploadMediaDialog } from "@shared"; +// import { createSightStore } from "@shared"; + +// interface MediaUploadBoxProps { +// title: string; +// tooltip?: string; +// mediaId: string | null; +// onMediaSelect: (mediaId: string) => void; +// onMediaRemove: () => void; +// onPreviewClick: (mediaId: string) => void; +// token: string; +// type: "thumbnail" | "watermark_lu" | "watermark_rd"; +// } + +// export const MediaUploadBox = ({ +// title, +// tooltip, +// mediaId, +// onMediaSelect, +// onMediaRemove, +// onPreviewClick, +// token, +// type, +// }: MediaUploadBoxProps) => { +// const [uploadMediaOpen, setUploadMediaOpen] = useState(false); +// const [fileToUpload, setFileToUpload] = useState<File | null>(null); + +// const onDrop = useCallback((acceptedFiles: File[]) => { +// if (acceptedFiles.length > 0) { +// setFileToUpload(acceptedFiles[0]); +// setUploadMediaOpen(true); +// } +// }, []); + +// const { getRootProps, getInputProps, isDragActive } = useDropzone({ +// onDrop, +// accept: { +// "image/*": [".png", ".jpg", ".jpeg", ".gif"], +// }, +// multiple: false, +// }); + +// const handleUploadComplete = async (media: { +// id: string; +// filename: string; +// media_name?: string; +// media_type: number; +// }) => { +// onMediaSelect(media.id); +// }; + +// return ( +// <> +// <Paper +// elevation={2} +// sx={{ +// padding: 2, +// display: "flex", +// flexDirection: "column", +// alignItems: "center", +// gap: 1, +// flex: 1, +// minWidth: 150, +// }} +// > +// <Box sx={{ display: "flex", alignItems: "center" }}> +// <Typography variant="subtitle2" gutterBottom sx={{ mb: 0, mr: 0.5 }}> +// {title} +// </Typography> +// </Box> +// <Box +// {...getRootProps()} +// sx={{ +// position: "relative", +// width: "200px", +// height: "200px", +// display: "flex", +// alignItems: "center", +// justifyContent: "center", +// borderRadius: 1, +// mb: 1, +// cursor: mediaId ? "pointer" : "default", +// border: isDragActive ? "2px dashed #1976d2" : "none", +// backgroundColor: isDragActive +// ? "rgba(25, 118, 210, 0.04)" +// : "transparent", +// transition: "all 0.2s ease", +// }} +// > +// <input {...getInputProps()} /> +// {mediaId && ( +// <button +// className="absolute top-2 right-2 z-10" +// onClick={(e) => { +// e.stopPropagation(); +// onMediaRemove(); +// }} +// > +// <X color="red" /> +// </button> +// )} +// {mediaId ? ( +// <img +// src={`${ +// import.meta.env.VITE_KRBL_MEDIA +// }${mediaId}/download?token=${token}`} +// alt={title} +// style={{ maxWidth: "100%", maxHeight: "100%" }} +// onClick={(e) => { +// e.stopPropagation(); +// onPreviewClick(mediaId); +// }} +// /> +// ) : ( +// <div className="w-full flex flex-col items-center justify-center gap-3"> +// <div +// className={`w-full h-20 border rounded-md flex flex-col items-center transition-all duration-300 justify-center border-dashed ${ +// isDragActive +// ? "border-blue-500 bg-blue-50" +// : "border-gray-300" +// } cursor-pointer hover:bg-gray-100`} +// > +// <Upload size={24} className="mb-2" /> +// <p> +// {isDragActive ? "Отпустите файл здесь" : "Перетащите файл"} +// </p> +// </div> +// <p>или</p> +// <Button +// variant="contained" +// color="primary" +// onClick={(e) => { +// e.stopPropagation(); +// onMediaSelect(""); +// }} +// > +// Выбрать файл +// </Button> +// </div> +// )} +// </Box> +// </Paper> + +// <UploadMediaDialog +// open={uploadMediaOpen} +// onClose={() => { +// setUploadMediaOpen(false); +// setFileToUpload(null); +// }} +// afterUpload={handleUploadComplete} +// /> +// </> +// ); +// }; diff --git a/src/widgets/SightTabs/CreateInformationTab/index.tsx b/src/widgets/SightTabs/CreateInformationTab/index.tsx new file mode 100644 index 0000000..3288ef4 --- /dev/null +++ b/src/widgets/SightTabs/CreateInformationTab/index.tsx @@ -0,0 +1,582 @@ +import { + Button, + TextField, + Box, + Autocomplete, + Typography, + Paper, + Tooltip, + MenuItem, + Menu as MuiMenu, +} from "@mui/material"; +import { + BackButton, + TabPanel, + languageStore, + Language, + cityStore, + SelectMediaDialog, + PreviewMediaDialog, + SightLanguageInfo, + SightCommonInfo, + createSightStore, +} from "@shared"; +import { LanguageSwitcher } from "@widgets"; +import { Info, X } from "lucide-react"; + +import { observer } from "mobx-react-lite"; +import { useEffect, useState } from "react"; +import { toast } from "react-toastify"; + +// Мокап для всплывающей подсказки + +export const CreateInformationTab = observer( + ({ value, index }: { value: number; index: number }) => { + const { cities } = cityStore; + const [, setIsMediaModalOpen] = useState(false); + const [mediaId, setMediaId] = useState<string>(""); + const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false); + + const { language } = languageStore; + const { sight, updateSightInfo, createSight } = createSightStore; + + const data = sight[language]; + + const [, setCity] = useState<number>(sight.city_id ?? 0); + const [coordinates, setCoordinates] = useState<string>(`0 0`); + + const token = localStorage.getItem("token"); + + // Menu state for each media button + const [menuAnchorEl, setMenuAnchorEl] = useState<null | HTMLElement>(null); + const [activeMenuType, setActiveMenuType] = useState< + "thumbnail" | "watermark_lu" | "watermark_rd" | null + >(null); + const [isAddMediaOpen, setIsAddMediaOpen] = useState(false); + + // const handleMenuOpen = ( + // event: React.MouseEvent<HTMLElement>, + // type: "thumbnail" | "watermark_lu" | "watermark_rd" + // ) => { + // setMenuAnchorEl(event.currentTarget); + // setActiveMenuType(type); + // }; + + useEffect(() => { + // Показывать только при инициализации (не менять при ошибках пользователя) + if (sight.latitude !== 0 || sight.longitude !== 0) { + setCoordinates(`${sight.latitude} ${sight.longitude}`); + } + // если координаты обнулились — оставить поле как есть + }, [sight.latitude, sight.longitude]); + + const handleMenuClose = () => { + setMenuAnchorEl(null); + setActiveMenuType(null); + }; + + const handleCreateNew = () => { + handleMenuClose(); + }; + + const handleAddMedia = () => { + setIsAddMediaOpen(true); + handleMenuClose(); + }; + + const handleChange = ( + content: Partial<SightLanguageInfo | SightCommonInfo>, + language?: Language + ) => { + if (language) { + updateSightInfo(content, language); + } else { + updateSightInfo(content); + } + }; + + const handleMediaSelect = ( + media: { + id: string; + filename: string; + media_name?: string; + media_type: number; + }, + type: "thumbnail" | "watermark_lu" | "watermark_rd" + ) => { + handleChange({ + [type]: media.id, + }); + setActiveMenuType(null); + }; + return ( + <> + <TabPanel value={value} index={index}> + <Box + sx={{ + display: "flex", + flexDirection: "column", + gap: 3, + position: "relative", + paddingBottom: "70px" /* Space for save button */, + }} + > + <BackButton /> + + <Box + sx={{ + display: "flex", + + gap: 4, // Added gap between the two main columns + width: "100%", + flexDirection: "column", + }} + > + {/* Left column with main fields */} + <Box + sx={{ + flexGrow: 1, + display: "flex", + width: "80%", + flexDirection: "column", + gap: 2.5, + }} + > + <TextField + label={`Название (${language.toUpperCase()})`} + value={data.name} + onChange={(e) => { + handleChange( + { + name: e.target.value, + }, + language + ); + }} + fullWidth + variant="outlined" + /> + + <TextField + label="Адрес" + value={data.address} + onChange={(e) => { + handleChange( + { + address: e.target.value, + }, + language + ); + }} + fullWidth + variant="outlined" + /> + + <Autocomplete + options={cities ?? []} + value={ + cities.find((city) => city.id === sight.city_id) ?? null + } + getOptionLabel={(option) => option.name} + onChange={(_, value) => { + setCity(value?.id ?? 0); + handleChange({ + city_id: value?.id ?? 0, + }); + }} + renderInput={(params) => ( + <TextField {...params} label="Город" /> + )} + /> + + <TextField + label="Координаты" + value={coordinates} + onChange={(e) => { + const input = e.target.value; + setCoordinates(input); // показываем как есть + + const [latStr, lonStr] = input.split(/\s+/); // учитываем любые пробелы + + const lat = parseFloat(latStr); + const lon = parseFloat(lonStr); + + // Проверка, что обе координаты валидные числа + const isValidLat = !isNaN(lat); + const isValidLon = !isNaN(lon); + + if (isValidLat && isValidLon) { + handleChange({ + latitude: lat, + longitude: lon, + }); + } else { + handleChange( + { + latitude: 0, + longitude: 0, + }, + language + ); + } + }} + fullWidth + variant="outlined" + placeholder="Введите координаты в формате: широта долгота" + /> + </Box> + + <Box + sx={{ + display: "flex", + + gap: 4, + }} + > + <Box + sx={{ + display: "flex", + justifyContent: "space-around", + width: "80%", + gap: 2, + flexDirection: { xs: "column", sm: "row" }, // Stack on extra small, side-by-side on small and up + }} + > + <Paper + elevation={2} + sx={{ + padding: 2, + display: "flex", + flexDirection: "column", + alignItems: "center", + gap: 1, + flex: 1, + minWidth: 150, // Ensure a minimum width + }} + > + <Box sx={{ display: "flex", alignItems: "center" }}> + <Typography + variant="subtitle2" + gutterBottom + sx={{ mb: 0, mr: 0.5 }} + > + Логотип + </Typography> + </Box> + <Box + sx={{ + position: "relative", + width: "200px", + height: "200px", + + display: "flex", + alignItems: "center", + justifyContent: "center", + borderRadius: 1, + mb: 1, + cursor: sight.thumbnail ? "pointer" : "default", + }} + onClick={() => { + setIsMediaModalOpen(true); + }} + > + {sight.thumbnail && ( + <button + className="absolute top-2 right-2" + onClick={() => { + handleChange({ + thumbnail: null, + }); + setActiveMenuType(null); + }} + > + <X color="red" /> + </button> + )} + {sight.thumbnail ? ( + <img + src={`${import.meta.env.VITE_KRBL_MEDIA}${ + sight.thumbnail + }/download?token=${token}`} + alt="Логотип" + style={{ maxWidth: "100%", maxHeight: "100%" }} + onClick={() => { + setIsPreviewMediaOpen(true); + setMediaId(sight.thumbnail ?? ""); + }} + /> + ) : ( + <div className="w-full flex flex-col items-center justify-center gap-3"> + <div className="w-full h-20 border rounded-md flex flex-col items-center transition-all duration-300 justify-center border-dashed border-gray-300 cursor-pointer hover:bg-gray-100"> + <p>Перетащите файл</p> + </div> + <p>или</p> + <Button + variant="contained" + color="primary" + onClick={() => { + setIsAddMediaOpen(true); + setActiveMenuType("thumbnail"); + }} + > + Выбрать файл + </Button> + </div> + )} + </Box> + </Paper> + <Paper + elevation={2} + sx={{ + padding: 2, + display: "flex", + flexDirection: "column", + alignItems: "center", + gap: 1, + flex: 1, + minWidth: 150, // Ensure a minimum width + }} + > + <Box sx={{ display: "flex", alignItems: "center" }}> + <Typography + variant="subtitle2" + gutterBottom + sx={{ mb: 0, mr: 0.5 }} + > + Водяной знак (л.в) + </Typography> + <Tooltip title={"asf"}> + <Info + size={16} + color="gray" + style={{ cursor: "pointer" }} + /> + </Tooltip> + </Box> + + <Box + sx={{ + position: "relative", + width: "200px", + height: "200px", + + display: "flex", + alignItems: "center", + justifyContent: "center", + borderRadius: 1, + mb: 1, + cursor: sight.watermark_lu ? "pointer" : "default", + }} + onClick={() => { + setIsMediaModalOpen(true); + }} + > + {sight.watermark_lu && ( + <button + className="absolute top-2 right-2" + onClick={() => { + handleChange({ + watermark_lu: null, + }); + setActiveMenuType(null); + }} + > + <X color="red" /> + </button> + )} + {sight.watermark_lu ? ( + <img + src={`${import.meta.env.VITE_KRBL_MEDIA}${ + sight.watermark_lu + }/download?token=${token}`} + alt="Логотип" + style={{ maxWidth: "100%", maxHeight: "100%" }} + onClick={() => { + setIsPreviewMediaOpen(true); + setMediaId(sight.watermark_lu ?? ""); + }} + /> + ) : ( + <div className="w-full flex flex-col items-center justify-center gap-3"> + <div className="w-full h-20 border rounded-md flex flex-col items-center transition-all duration-300 justify-center border-dashed border-gray-300 cursor-pointer hover:bg-gray-100"> + <p>Перетащите файл</p> + </div> + <p>или</p> + <Button + variant="contained" + color="primary" + onClick={() => { + setActiveMenuType("watermark_lu"); + setIsAddMediaOpen(true); + }} + > + Выбрать файл + </Button> + </div> + )} + </Box> + </Paper> + + <Paper + elevation={2} + sx={{ + padding: 2, + display: "flex", + flexDirection: "column", + alignItems: "center", + gap: 1, + flex: 1, + minWidth: 150, // Ensure a minimum width + }} + > + <Box sx={{ display: "flex", alignItems: "center" }}> + <Typography + variant="subtitle2" + gutterBottom + sx={{ mb: 0, mr: 0.5 }} + > + Водяной знак (п.в) + </Typography> + <Tooltip title={"asfaf"}> + <Info + size={16} + color="gray" + style={{ cursor: "pointer" }} + /> + </Tooltip> + </Box> + <Box + sx={{ + position: "relative", + width: "200px", + height: "200px", + + display: "flex", + alignItems: "center", + justifyContent: "center", + borderRadius: 1, + mb: 1, + cursor: sight.watermark_rd ? "pointer" : "default", + }} + onClick={() => { + setIsMediaModalOpen(true); + }} + > + {sight.watermark_rd && ( + <button + className="absolute top-2 right-2" + onClick={() => { + handleChange({ + watermark_rd: null, + }); + setActiveMenuType(null); + }} + > + <X color="red" /> + </button> + )} + {sight.watermark_rd ? ( + <img + src={`${import.meta.env.VITE_KRBL_MEDIA}${ + sight.watermark_rd + }/download?token=${token}`} + alt="Логотип" + style={{ maxWidth: "100%", maxHeight: "100%" }} + onClick={() => { + setIsPreviewMediaOpen(true); + setMediaId(sight.watermark_rd ?? ""); + }} + /> + ) : ( + <div className="w-full flex flex-col items-center justify-center gap-3"> + <div className="w-full h-20 border rounded-md flex flex-col items-center transition-all duration-300 justify-center border-dashed border-gray-300 cursor-pointer hover:bg-gray-100"> + <p>Перетащите файл</p> + </div> + <p>или</p> + <Button + variant="contained" + color="primary" + onClick={() => { + setActiveMenuType("watermark_rd"); + setIsAddMediaOpen(true); + }} + > + Выбрать файл + </Button> + </div> + )} + </Box> + </Paper> + </Box> + </Box> + </Box> + + {/* LanguageSwitcher positioned at the top right */} + + <LanguageSwitcher /> + + {/* Save Button fixed at the bottom right */} + <Box + sx={{ + position: "absolute", + bottom: 0, + right: 0, + padding: 2, + backgroundColor: "background.paper", // To ensure it stands out over content + width: "100%", // Take full width to cover content below it + display: "flex", + justifyContent: "flex-end", // Align to the right + }} + > + <Button + variant="contained" + color="success" + onClick={async () => { + await createSight(language); + toast.success("Достопримечательность создана"); + }} + > + Сохранить + </Button> + </Box> + </Box> + </TabPanel> + + {/* Media Menu */} + <MuiMenu + anchorEl={menuAnchorEl} + open={Boolean(menuAnchorEl)} + onClose={handleMenuClose} + anchorOrigin={{ + vertical: "top", + horizontal: "right", + }} + transformOrigin={{ + vertical: "bottom", + horizontal: "right", + }} + > + <MenuItem onClick={handleCreateNew}>Создать новую</MenuItem> + <MenuItem onClick={handleAddMedia}>Выбрать существующую</MenuItem> + </MuiMenu> + + <SelectMediaDialog + open={isAddMediaOpen} + onClose={() => { + setIsAddMediaOpen(false); + setActiveMenuType(null); + }} + onSelectMedia={(media) => { + handleMediaSelect(media, activeMenuType ?? "thumbnail"); + }} + /> + + <PreviewMediaDialog + open={isPreviewMediaOpen} + onClose={() => setIsPreviewMediaOpen(false)} + mediaId={mediaId} + /> + </> + ); + } +); diff --git a/src/widgets/SightTabs/CreateLeftTab/index.tsx b/src/widgets/SightTabs/CreateLeftTab/index.tsx new file mode 100644 index 0000000..c180068 --- /dev/null +++ b/src/widgets/SightTabs/CreateLeftTab/index.tsx @@ -0,0 +1,451 @@ +// @widgets/LeftWidgetTab.tsx +import { Box, Button, TextField, Paper, Typography } from "@mui/material"; +import { + BackButton, + TabPanel, + languageStore, + SelectMediaDialog, + editSightStore, + createSightStore, + SelectArticleModal, + UploadMediaDialog, +} from "@shared"; +import { + LanguageSwitcher, + MediaArea, + ReactMarkdownComponent, + ReactMarkdownEditor, + MediaViewer, +} from "@widgets"; +import { Trash2, ImagePlus } from "lucide-react"; +import { useState, useCallback } from "react"; +import { observer } from "mobx-react-lite"; +import { toast } from "react-toastify"; + +export const CreateLeftTab = observer( + ({ value, index }: { value: number; index: number }) => { + const { + sight, + updateSightInfo, + updateLeftArticle, + createSight, + deleteLeftArticle, + createLeftArticle, + unlinkLeftArticle, + createLinkWithArticle, + } = createSightStore; + const { + deleteMedia, + setFileToUpload, + uploadMediaOpen, + setUploadMediaOpen, + } = editSightStore; + + const { language } = languageStore; + + const [isSelectArticleDialogOpen, setIsSelectArticleDialogOpen] = + useState(false); + const [isSelectMediaDialogOpen, setIsSelectMediaDialogOpen] = + useState(false); + + // const handleMediaSelected = useCallback(() => { + // // При выборе медиа, обновляем данные для ТЕКУЩЕГО ЯЗЫКА + // // сохраняя текущие heading и body. + // updateSightInfo(language, { + // left: { + // heading: data.left.heading, + // body: data.left.body, + // }, + // }); + // setIsSelectMediaDialogOpen(false); + // }, [language, data.left.heading, data.left.body]); + + const handleCloseArticleDialog = useCallback(() => { + setIsSelectArticleDialogOpen(false); + }, []); + + const handleCloseMediaDialog = useCallback(() => { + setIsSelectMediaDialogOpen(false); + }, []); + + const handleMediaSelected = useCallback( + async (media: { + id: string; + filename: string; + media_name?: string; + media_type: number; + }) => { + await createLinkWithArticle(media); + setIsSelectMediaDialogOpen(false); + }, + [createLinkWithArticle] + ); + + const handleArticleSelect = useCallback( + (articleId: number) => { + updateLeftArticle(articleId); + }, + [updateLeftArticle] + ); + + return ( + <TabPanel value={value} index={index}> + <LanguageSwitcher /> + <Box + sx={{ + display: "flex", + flexDirection: "column", + gap: 3, + paddingBottom: "70px", + position: "relative", + }} + > + <BackButton /> + <Paper + elevation={2} + sx={{ + display: "flex", + alignItems: "center", + justifyContent: "space-between", + paddingX: 2.5, + paddingY: 1.5, + borderRadius: 2, + border: "1px solid", + borderColor: "divider", + }} + > + <Typography variant="h6">Левая статья</Typography> + <Box sx={{ display: "flex", alignItems: "center", gap: 2 }}> + {sight.left_article ? ( + <> + <Button + variant="contained" + color="primary" + size="small" + style={{ transition: "0" }} + onClick={() => { + unlinkLeftArticle(); + toast.success("Статья откреплена"); + }} + > + Открепить + </Button> + <Button + variant="outlined" + color="error" + style={{ transition: "0" }} + startIcon={<Trash2 size={18} />} + size="small" + onClick={() => { + deleteLeftArticle(sight.left_article); + toast.success("Статья откреплена"); + }} + > + Удалить + </Button> + </> + ) : ( + <> + <Button + variant="contained" + color="primary" + size="small" + onClick={() => setIsSelectArticleDialogOpen(true)} + > + Выбрать статью + </Button> + <Button + variant="contained" + color="primary" + size="small" + style={{ transition: "0" }} + onClick={createLeftArticle} + > + Создать статью + </Button> + </> + )} + </Box> + </Paper> + {sight.left_article > 0 && ( + <> + <Box sx={{ display: "flex", gap: 3, flexGrow: 1 }}> + {/* Левая колонка: Редактирование */} + + <Box + sx={{ + flex: 2, + display: "flex", + flexDirection: "column", + gap: 2, + }} + > + <TextField + label="Название информации" + value={sight[language].left.heading} + onChange={(e) => + updateSightInfo( + { + left: { + heading: e.target.value, + body: sight[language].left.body, + media: sight[language].left.media, + }, + }, + language + ) + } + variant="outlined" + fullWidth + /> + + <ReactMarkdownEditor + value={sight[language].left.body} + onChange={(value) => + updateSightInfo( + { + left: { + heading: sight[language].left.heading, + body: value, + media: sight[language].left.media, + }, + }, + language + ) + } + /> + + <MediaArea + articleId={sight.left_article} + mediaIds={sight[language].left.media} + deleteMedia={deleteMedia} + setSelectMediaDialogOpen={setIsSelectMediaDialogOpen} + onFilesDrop={(files) => { + setFileToUpload(files[0]); + setUploadMediaOpen(true); + }} + /> + + {/* Блок МЕДИА для статьи */} + {/* <Paper elevation={1} sx={{ padding: 2, mt: 1 }}> + <Typography variant="h6" gutterBottom> + МЕДИА + </Typography> + {data.left.media ? ( + <Box sx={{ mb: 1 }}> + <img + src={data.left.media.filename} + alt="Selected media" + style={{ + maxWidth: "100%", + maxHeight: "150px", + objectFit: "contain", + }} + /> + </Box> + ) : ( + <Box + sx={{ + width: "100%", + height: 100, + backgroundColor: "grey.100", + display: "flex", + alignItems: "center", + justifyContent: "center", + borderRadius: 1, + mb: 1, + border: "2px dashed", + borderColor: "grey.300", + }} + > + <Typography color="text.secondary">Нет медиа</Typography> + </Box> + )} + <Button + variant="contained" + startIcon={<ImagePlus size={18} />} + onClick={handleOpenMediaDialog} + > + Выбрать/Загрузить медиа + </Button> + {data.left.media && ( + <Button + variant="outlined" + color="error" + size="small" + sx={{ ml: 1 }} + onClick={() => + updateSightInfo( + languageStore.language, + { + left: { + heading: data.left.heading, + body: data.left.body, + media: null, + }, + }, + false + ) + } + > + Удалить медиа + </Button> + )} + </Paper> */} + </Box> + + {/* Правая колонка: Предпросмотр */} + <Box + sx={{ + flex: 1, + display: "flex", + flexDirection: "column", + gap: 1.5, + }} + > + <Paper + elevation={3} + sx={{ + width: "100%", + minWidth: 320, + maxWidth: 400, + height: "auto", + minHeight: 500, + backgroundColor: "#877361", + overflowY: "auto", + padding: 0, + display: "flex", + flexDirection: "column", + }} + > + {/* {data.left.media?.filename ? ( + <Box + sx={{ + width: "100%", + height: 200, + backgroundColor: "grey.300", + display: "flex", + alignItems: "center", + justifyContent: "center", + }} + > + <img + src={data.left.media?.filename ?? ""} + alt="Превью медиа" + style={{ + objectFit: "cover", + width: "100%", + height: "100%", + }} + /> + </Box> + ) : ( + + )} */} + + <Box + sx={{ + width: "100%", + height: 200, + backgroundColor: "grey.300", + display: "flex", + alignItems: "center", + justifyContent: "center", + }} + > + {sight[language].left.media.length > 0 ? ( + <MediaViewer + media={{ + id: sight[language].left.media[0].id, + media_type: + sight[language].left.media[0].media_type, + filename: sight[language].left.media[0].filename, + }} + /> + ) : ( + <ImagePlus size={48} color="grey" /> + )} + </Box> + + {/* Заголовок в превью */} + <Box + sx={{ + backgroundColor: "#877361", + color: "white", + padding: 1.5, + }} + > + <Typography + variant="h5" + component="h2" + sx={{ wordBreak: "break-word" }} + > + {sight[language].left.heading || "Название информации"} + </Typography> + </Box> + + {/* Текст статьи в превью */} + <Box + sx={{ + padding: 2, + flexGrow: 1, + }} + > + <ReactMarkdownComponent + value={sight[language].left.body} + /> + </Box> + </Paper> + </Box> + </Box> + + <Box + sx={{ position: "absolute", bottom: 0, right: 0, padding: 2 }} + > + <Button + variant="contained" + color="success" + onClick={async () => { + try { + await createSight(language); + toast.success("Странца создана"); + } catch (error) { + console.error(error); + } + }} + > + Сохранить + </Button> + </Box> + </> + )} + </Box> + + {/* <SelectMediaDialog + open={isSelectMediaDialogOpen} + onClose={handleCloseMediaDialog} + onSelectMedia={handleArticleSelect} + /> */} + <SelectMediaDialog + open={isSelectMediaDialogOpen} + onClose={handleCloseMediaDialog} + onSelectMedia={handleMediaSelected} + /> + <UploadMediaDialog + open={uploadMediaOpen} + onClose={() => setUploadMediaOpen(false)} + afterUpload={async (media) => { + setUploadMediaOpen(false); + setFileToUpload(null); + await createLinkWithArticle(media); + }} + /> + <SelectArticleModal + open={isSelectArticleDialogOpen} + onClose={handleCloseArticleDialog} + onSelectArticle={handleArticleSelect} + /> + </TabPanel> + ); + } +); diff --git a/src/widgets/SightTabs/CreateRightTab/index.tsx b/src/widgets/SightTabs/CreateRightTab/index.tsx new file mode 100644 index 0000000..97b4723 --- /dev/null +++ b/src/widgets/SightTabs/CreateRightTab/index.tsx @@ -0,0 +1,374 @@ +import { + Box, + Button, + Paper, + Typography, + Menu, + MenuItem, + TextField, +} from "@mui/material"; +import { BackButton, createSightStore, languageStore, TabPanel } from "@shared"; +import { + LanguageSwitcher, + ReactMarkdownComponent, + ReactMarkdownEditor, +} from "@widgets"; +import { ImagePlus, Plus } from "lucide-react"; +import { observer } from "mobx-react-lite"; +import { useState } from "react"; + +// --- RightWidgetTab (Parent) Component --- +export const CreateRightTab = observer( + ({ value, index }: { value: number; index: number }) => { + const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null); + const { sight, createNewRightArticle, updateRightArticleInfo } = + createSightStore; + const { language } = languageStore; + + const [activeArticleIndex, setActiveArticleIndex] = useState<number | null>( + null + ); + const open = Boolean(anchorEl); + const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => { + setAnchorEl(event.currentTarget); + }; + const handleClose = () => { + setAnchorEl(null); + }; + const handleSave = () => { + console.log("Saving right widget..."); + }; + + const handleSelectArticle = (index: number) => { + setActiveArticleIndex(index); + }; + + return ( + <TabPanel value={value} index={index}> + <LanguageSwitcher /> + <Box + sx={{ + display: "flex", + flexDirection: "column", + height: "100%", + minHeight: "calc(100vh - 200px)", // Adjust as needed + gap: 2, + paddingBottom: "70px", // Space for the save button + position: "relative", + }} + > + <BackButton /> + + <Box sx={{ display: "flex", flexGrow: 1, gap: 2.5 }}> + <Box className="flex flex-col w-[75%] gap-2"> + <Box className="w-full flex gap-2 "> + <Box className="relative w-[20%] h-[70vh] flex flex-col rounded-2xl overflow-y-auto gap-3 border border-gray-300 p-3"> + <Box className="flex flex-col gap-3 max-h-[60vh] overflow-y-auto"> + <Box + onClick={() => { + // setMediaType("preview"); + }} + className="w-full bg-gray-200 p-4 rounded-2xl cursor-pointer text-sm hover:bg-gray-300 transition-all duration-300" + > + <Typography>Предпросмотр медиа</Typography> + </Box> + + {sight[language].right.map((article, index) => ( + <Box + key={index} + className="w-full bg-gray-200 p-4 rounded-2xl text-sm cursor-pointer hover:bg-gray-300 transition-all duration-300" + onClick={() => { + handleSelectArticle(index); + }} + > + <Typography>{article.heading}</Typography> + </Box> + ))} + </Box> + <button + className="w-10 h-10 bg-blue-500 rounded-full absolute bottom-5 left-5 flex items-center justify-center" + onClick={handleClick} + > + <Plus size={20} color="white" /> + </button> + <Menu + id="basic-menu" + anchorEl={anchorEl} + open={open} + onClose={handleClose} + MenuListProps={{ + "aria-labelledby": "basic-button", + }} + sx={{ + mt: 1, + }} + > + <MenuItem + onClick={() => { + createNewRightArticle(); + handleClose(); + }} + > + <Typography>Создать новую</Typography> + </MenuItem> + <MenuItem + onClick={() => { + handleClose(); + }} + > + <Typography>Выбрать существующую статью</Typography> + </MenuItem> + </Menu> + </Box> + <Box className="w-[80%] h-[70vh] border border-gray-300 rounded-2xl p-3"> + {activeArticleIndex !== null && ( + <> + <Box className="flex justify-end gap-2 mb-3"> + <Button variant="contained" color="primary"> + Открепить + </Button> + + <Button variant="contained" color="success"> + Удалить + </Button> + </Box> + <Box sx={{ display: "flex", gap: 3, flexGrow: 1 }}> + {/* Левая колонка: Редактирование */} + + <Box + sx={{ + flex: 2, + display: "flex", + flexDirection: "column", + gap: 2, + }} + > + <TextField + label="Название информации" + value={ + sight[language].right[activeArticleIndex].heading + } + onChange={(e) => + updateRightArticleInfo( + activeArticleIndex, + language, + e.target.value, + sight[language].right[activeArticleIndex].body + ) + } + variant="outlined" + fullWidth + /> + + <ReactMarkdownEditor + value={ + sight[language].right[activeArticleIndex].body + } + onChange={(value) => + updateRightArticleInfo( + activeArticleIndex, + language, + sight[language].right[activeArticleIndex] + .heading, + value + ) + } + /> + </Box> + {/* Блок МЕДИА для статьи */} + {/* <Paper elevation={1} sx={{ padding: 2, mt: 1 }}> + <Typography variant="h6" gutterBottom> + МЕДИА + </Typography> + {data.left.media ? ( + <Box sx={{ mb: 1 }}> + <img + src={data.left.media.filename} + alt="Selected media" + style={{ + maxWidth: "100%", + maxHeight: "150px", + objectFit: "contain", + }} + /> + </Box> + ) : ( + <Box + sx={{ + width: "100%", + height: 100, + backgroundColor: "grey.100", + display: "flex", + alignItems: "center", + justifyContent: "center", + borderRadius: 1, + mb: 1, + border: "2px dashed", + borderColor: "grey.300", + }} + > + <Typography color="text.secondary">Нет медиа</Typography> + </Box> + )} + <Button + variant="contained" + startIcon={<ImagePlus size={18} />} + onClick={handleOpenMediaDialog} + > + Выбрать/Загрузить медиа + </Button> + {data.left.media && ( + <Button + variant="outlined" + color="error" + size="small" + sx={{ ml: 1 }} + onClick={() => + updateSightInfo( + languageStore.language, + { + left: { + heading: data.left.heading, + body: data.left.body, + media: null, + }, + }, + false + ) + } + > + Удалить медиа + </Button> + )} + </Paper> */} + </Box> + </> + )} + </Box> + </Box> + </Box> + <Box className="w-[25%] mr-10"> + {activeArticleIndex !== null && ( + <Paper + className="flex-1 flex flex-col rounded-2xl" + elevation={2} + > + <Box + className="rounded-2xl overflow-hidden" + sx={{ + width: "100%", + height: "75vh", + background: "#877361", + borderColor: "grey.300", + display: "flex", + flexDirection: "column", + }} + > + {false ? ( + <Box + sx={{ + width: "100%", + height: "100%", + display: "flex", + alignItems: "center", + justifyContent: "center", + }} + > + <Typography color="white">Загрузка...</Typography> + </Box> + ) : ( + <> + <Box + sx={{ + width: "100%", + height: 200, + flexShrink: 0, + backgroundColor: "rgba(0,0,0,0.1)", + display: "flex", + alignItems: "center", + justifyContent: "center", + }} + > + <ImagePlus size={48} color="white" /> + </Box> + + <Box + sx={{ + width: "100%", + minHeight: "70px", + background: "#877361", + display: "flex", + flexShrink: 0, + alignItems: "center", + borderBottom: "1px solid rgba(255,255,255,0.1)", + px: 2, + }} + > + <Typography variant="h6" color="white"> + {sight[language].right[activeArticleIndex] + .heading || "Выберите статью"} + </Typography> + </Box> + + <Box + sx={{ + px: 2, + flexGrow: 1, + + overflowY: "auto", + backgroundColor: "#877361", + color: "white", + py: 1, + }} + > + {sight[language].right[activeArticleIndex].body ? ( + <ReactMarkdownComponent + value={ + sight[language].right[activeArticleIndex].body + } + /> + ) : ( + <Typography + color="rgba(255,255,255,0.7)" + sx={{ textAlign: "center", mt: 4 }} + > + Предпросмотр статьи появится здесь + </Typography> + )} + </Box> + </> + )} + </Box> + </Paper> + )} + </Box> + </Box> + + <Box + sx={{ + position: "absolute", + bottom: 0, + right: 0, + padding: 2, + backgroundColor: "background.paper", // Ensure button is visible + width: "100%", // Cover the full width to make it a sticky footer + display: "flex", + justifyContent: "flex-end", + }} + > + <Button variant="contained" color="success" onClick={handleSave}> + Сохранить изменения + </Button> + </Box> + </Box> + {/* + <SelectArticleModal + open={openedType === "article"} + onClose={handleCloseSelectModal} + onSelectArticle={handleSelectArticle} + linkedArticleIds={linkedArticleIds} + /> */} + </TabPanel> + ); + } +); diff --git a/src/widgets/SightTabs/InformationTab/index.tsx b/src/widgets/SightTabs/InformationTab/index.tsx index 3b771a5..cdcfcfb 100644 --- a/src/widgets/SightTabs/InformationTab/index.tsx +++ b/src/widgets/SightTabs/InformationTab/index.tsx @@ -27,6 +27,8 @@ import { Info, ImagePlus } from "lucide-react"; import { observer } from "mobx-react-lite"; import { useEffect, useState } from "react"; +import { toast } from "react-toastify"; + // Мокап для всплывающей подсказки export const InformationTab = observer( @@ -37,12 +39,10 @@ export const InformationTab = observer( const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false); const { language } = languageStore; - const { sight, updateSightInfo } = editSightStore; - const data = sight[language]; - const common = sight.common; + const { sight, updateSightInfo, updateSight } = editSightStore; - const [, setCity] = useState<number>(common.city_id ?? 0); + const [, setCity] = useState<number>(sight.common.city_id ?? 0); const [coordinates, setCoordinates] = useState<string>(`0 0`); const token = localStorage.getItem("token"); @@ -54,21 +54,13 @@ export const InformationTab = observer( >(null); const [isAddMediaOpen, setIsAddMediaOpen] = useState(false); - const handleMenuOpen = ( - event: React.MouseEvent<HTMLElement>, - type: "thumbnail" | "watermark_lu" | "watermark_rd" - ) => { - setMenuAnchorEl(event.currentTarget); - setActiveMenuType(type); - }; - useEffect(() => { // Показывать только при инициализации (не менять при ошибках пользователя) - if (common.latitude !== 0 || common.longitude !== 0) { - setCoordinates(`${common.latitude} ${common.longitude}`); + if (sight.common.latitude !== 0 || sight.common.longitude !== 0) { + setCoordinates(`${sight.common.latitude} ${sight.common.longitude}`); } // если координаты обнулились — оставить поле как есть - }, [common.latitude, common.longitude]); + }, [sight.common.latitude, sight.common.longitude]); const handleMenuClose = () => { setMenuAnchorEl(null); @@ -135,7 +127,7 @@ export const InformationTab = observer( > <TextField label={`Название (${language.toUpperCase()})`} - value={data.name} + value={sight[language].name} onChange={(e) => { handleChange(language as Language, { name: e.target.value, @@ -147,7 +139,7 @@ export const InformationTab = observer( <TextField label="Адрес" - value={data.address} + value={sight[language].address} onChange={(e) => { handleChange(language as Language, { address: e.target.value, @@ -160,18 +152,15 @@ export const InformationTab = observer( <Autocomplete options={cities ?? []} value={ - cities.find((city) => city.id === common.city_id) ?? null + cities.find((city) => city.id === sight.common.city_id) ?? + null } getOptionLabel={(option) => option.name} onChange={(_, value) => { setCity(value?.id ?? 0); - handleChange( - language as Language, - { - city_id: value?.id ?? 0, - }, - true - ); + handleChange(language as Language, { + city_id: value?.id ?? 0, + }); }} renderInput={(params) => ( <TextField {...params} label="Город" /> @@ -195,15 +184,23 @@ export const InformationTab = observer( const isValidLon = !isNaN(lon); if (isValidLat && isValidLon) { - handleChange(language as Language, { - latitude: lat, - longitude: lon, - }); + handleChange( + language as Language, + { + latitude: lat, + longitude: lon, + }, + true + ); } else { - handleChange(language as Language, { - latitude: 0, - longitude: 0, - }); + handleChange( + language as Language, + { + latitude: 0, + longitude: 0, + }, + true + ); } }} fullWidth @@ -251,17 +248,18 @@ export const InformationTab = observer( </Box> <Box sx={{ - width: 80, - height: 80, + position: "relative", + width: "200px", + height: "200px", backgroundColor: "grey.200", display: "flex", alignItems: "center", justifyContent: "center", borderRadius: 1, mb: 1, - cursor: common.thumbnail ? "pointer" : "default", + cursor: sight.common.thumbnail ? "pointer" : "default", "&:hover": { - backgroundColor: common.thumbnail + backgroundColor: sight.common.thumbnail ? "red.300" : "grey.200", }, @@ -270,29 +268,22 @@ export const InformationTab = observer( setIsMediaModalOpen(true); }} > - {common.thumbnail ? ( + {sight.common.thumbnail ? ( <img src={`${import.meta.env.VITE_KRBL_MEDIA}${ - common.thumbnail + sight.common.thumbnail }/download?token=${token}`} alt="Логотип" style={{ maxWidth: "100%", maxHeight: "100%" }} onClick={() => { setIsPreviewMediaOpen(true); - setMediaId(common.thumbnail); + setMediaId(sight.common.thumbnail ?? ""); }} /> ) : ( <ImagePlus size={24} color="grey" /> )} </Box> - <Button - variant="outlined" - size="small" - onClick={(e) => handleMenuOpen(e, "thumbnail")} - > - Выбрать - </Button> </Paper> <Paper elevation={2} @@ -324,49 +315,45 @@ export const InformationTab = observer( </Box> <Box sx={{ - width: 80, - height: 80, + position: "relative", + width: "200px", + height: "200px", backgroundColor: "grey.200", display: "flex", alignItems: "center", justifyContent: "center", borderRadius: 1, mb: 1, - cursor: common.watermark_lu ? "pointer" : "default", + cursor: sight.common.watermark_lu + ? "pointer" + : "default", "&:hover": { - backgroundColor: common.watermark_lu + backgroundColor: sight.common.watermark_lu ? "grey.300" : "grey.200", }, }} onClick={() => { setIsPreviewMediaOpen(true); - setMediaId(common.watermark_lu); + setMediaId(sight.common.watermark_lu ?? ""); }} > - {common.watermark_lu ? ( + {sight.common.watermark_lu ? ( <img src={`${import.meta.env.VITE_KRBL_MEDIA}${ - common.watermark_lu + sight.common.watermark_lu }/download?token=${token}`} alt="Знак л.в" style={{ maxWidth: "100%", maxHeight: "100%" }} onClick={() => { setIsMediaModalOpen(true); - setMediaId(common.watermark_lu); + setMediaId(sight.common.watermark_lu ?? ""); }} /> ) : ( <ImagePlus size={24} color="grey" /> )} </Box> - <Button - variant="outlined" - size="small" - onClick={(e) => handleMenuOpen(e, "watermark_lu")} - > - Выбрать - </Button> </Paper> <Paper @@ -399,49 +386,45 @@ export const InformationTab = observer( </Box> <Box sx={{ - width: 80, - height: 80, + position: "relative", + width: "200px", + height: "200px", backgroundColor: "grey.200", display: "flex", alignItems: "center", justifyContent: "center", borderRadius: 1, mb: 1, - cursor: common.watermark_rd ? "pointer" : "default", + cursor: sight.common.watermark_rd + ? "pointer" + : "default", "&:hover": { - backgroundColor: common.watermark_rd + backgroundColor: sight.common.watermark_rd ? "grey.300" : "grey.200", }, }} onClick={() => { setIsMediaModalOpen(true); - setMediaId(common.watermark_rd); + setMediaId(sight.common.watermark_rd ?? ""); }} > - {common.watermark_rd ? ( + {sight.common.watermark_rd ? ( <img src={`${import.meta.env.VITE_KRBL_MEDIA}${ - common.watermark_rd + sight.common.watermark_rd }/download?token=${token}`} alt="Знак п.в" style={{ maxWidth: "100%", maxHeight: "100%" }} onClick={() => { setIsPreviewMediaOpen(true); - setMediaId(common.watermark_rd); + setMediaId(sight.common.watermark_rd ?? ""); }} /> ) : ( <ImagePlus size={24} color="grey" /> )} </Box> - <Button - variant="outlined" - size="small" - onClick={(e) => handleMenuOpen(e, "watermark_rd")} - > - Выбрать - </Button> </Paper> </Box> </Box> @@ -467,8 +450,9 @@ export const InformationTab = observer( <Button variant="contained" color="success" - onClick={() => { - console.log(sight); + onClick={async () => { + await updateSight(); + toast.success("Достопримечательность сохранена"); }} > Сохранить diff --git a/src/widgets/SightTabs/LeftWidgetTab/index.tsx b/src/widgets/SightTabs/LeftWidgetTab/index.tsx index 4990c42..5576e4f 100644 --- a/src/widgets/SightTabs/LeftWidgetTab/index.tsx +++ b/src/widgets/SightTabs/LeftWidgetTab/index.tsx @@ -1,326 +1,345 @@ // @widgets/LeftWidgetTab.tsx import { Box, Button, TextField, Paper, Typography } from "@mui/material"; import { - articlesStore, BackButton, TabPanel, languageStore, SelectMediaDialog, editSightStore, + SelectArticleModal, + UploadMediaDialog, } from "@shared"; import { LanguageSwitcher, ReactMarkdownComponent, ReactMarkdownEditor, + MediaArea, + MediaViewer, } from "@widgets"; -import { Unlink, Trash2, ImagePlus } from "lucide-react"; +import { Trash2, ImagePlus } from "lucide-react"; import { useState, useCallback } from "react"; import { observer } from "mobx-react-lite"; +import { toast } from "react-toastify"; export const LeftWidgetTab = observer( ({ value, index }: { value: number; index: number }) => { - const { sight, updateSightInfo } = editSightStore; - const { getArticleByArticleId } = articlesStore; + const { + sight, + updateSightInfo, + unlinkLeftArticle, + updateSight, + deleteLeftArticle, + createLeftArticle, + deleteMedia, + uploadMediaOpen, + setUploadMediaOpen, - const linkedArticle = getArticleByArticleId.get(); // Получаем связанную статью - const data = sight[languageStore.language]; // Получаем данные для текущего языка + setFileToUpload, + createLinkWithArticle, + } = editSightStore; + + const { language } = languageStore; + const data = sight[language]; const [isSelectMediaDialogOpen, setIsSelectMediaDialogOpen] = useState(false); + const [isSelectArticleDialogOpen, setIsSelectArticleDialogOpen] = + useState(false); - const handleMediaSelected = useCallback(() => { - // При выборе медиа, обновляем данные для ТЕКУЩЕГО ЯЗЫКА - // сохраняя текущие heading и body. - updateSightInfo( - languageStore.language, - { - left: { - heading: data.left.heading, - body: data.left.body, - }, - }, - false - ); - setIsSelectMediaDialogOpen(false); - }, [ - languageStore.language, - { - left: { - heading: data.left.heading, - body: data.left.body, - }, + const handleMediaSelected = useCallback( + async (media: { + id: string; + filename: string; + media_name?: string; + media_type: number; + }) => { + await createLinkWithArticle(media); + setIsSelectMediaDialogOpen(false); }, - false, - ]); + [createLinkWithArticle] + ); const handleCloseMediaDialog = useCallback(() => { setIsSelectMediaDialogOpen(false); }, []); - // ... (остальной JSX код остался почти без изменений) - return ( - <TabPanel value={value} index={index}> - <LanguageSwitcher /> - <Box - sx={{ - display: "flex", - flexDirection: "column", - gap: 3, - paddingBottom: "70px", - position: "relative", - }} - > - <BackButton /> + const handleCloseArticleDialog = useCallback(() => { + setIsSelectArticleDialogOpen(false); + }, []); - <Paper - elevation={2} + const handleSelectArticle = useCallback( + ( + articleId: number, + heading: string, + body: string, + media: { id: string; media_type: number; filename: string }[] + ) => { + setIsSelectArticleDialogOpen(false); + updateSightInfo(languageStore.language, { + left: { + heading, + body, + media, + }, + }); + updateSightInfo( + languageStore.language, + { + left_article: articleId, + }, + true + ); + }, + [] + ); + + return ( + <> + <TabPanel value={value} index={index}> + <LanguageSwitcher /> + <Box sx={{ display: "flex", - alignItems: "center", - justifyContent: "space-between", - paddingX: 2.5, - paddingY: 1.5, - borderRadius: 2, - border: "1px solid", - borderColor: "divider", + flexDirection: "column", + gap: 3, + paddingBottom: "70px", + position: "relative", }} > - <Typography variant="h6">Левая статья</Typography> - <Box sx={{ display: "flex", alignItems: "center", gap: 2 }}> - {linkedArticle && ( - <Button - variant="outlined" - color="primary" - startIcon={<Unlink size={18} />} - size="small" - > - Открепить - </Button> - )} - <Button - variant="outlined" - color="error" - startIcon={<Trash2 size={18} />} - size="small" - > - Удалить - </Button> - </Box> - </Paper> + <BackButton /> - <Box sx={{ display: "flex", gap: 3, flexGrow: 1 }}> - {/* Левая колонка: Редактирование */} - <Box - sx={{ flex: 2, display: "flex", flexDirection: "column", gap: 2 }} - > - <TextField - label="Название информации" - value={data?.left?.heading} - onChange={(e) => - updateSightInfo( - languageStore.language, - { - left: { - heading: e.target.value, - body: data.left.body, - }, - }, - false - ) - } - variant="outlined" - fullWidth - /> - - <ReactMarkdownEditor - value={data?.left?.body} - onChange={(value) => - updateSightInfo( - languageStore.language, - { - left: { - heading: data.left.heading, - body: value, - }, - }, - false - ) - } - /> - - {/* Блок МЕДИА для статьи */} - {/* <Paper elevation={1} sx={{ padding: 2, mt: 1 }}> - <Typography variant="h6" gutterBottom> - МЕДИА - </Typography> - {data.left.media ? ( - <Box sx={{ mb: 1 }}> - <img - src={data.left.media.filename} - alt="Selected media" - style={{ - maxWidth: "100%", - maxHeight: "150px", - objectFit: "contain", - }} - /> - </Box> - ) : ( - <Box - sx={{ - width: "100%", - height: 100, - backgroundColor: "grey.100", - display: "flex", - alignItems: "center", - justifyContent: "center", - borderRadius: 1, - mb: 1, - border: "2px dashed", - borderColor: "grey.300", - }} - > - <Typography color="text.secondary">Нет медиа</Typography> - </Box> - )} - <Button - variant="contained" - startIcon={<ImagePlus size={18} />} - onClick={handleOpenMediaDialog} - > - Выбрать/Загрузить медиа - </Button> - {data.left.media && ( - <Button - variant="outlined" - color="error" - size="small" - sx={{ ml: 1 }} - onClick={() => - updateSightInfo( - languageStore.language, - { - left: { - heading: data.left.heading, - body: data.left.body, - media: null, - }, - }, - false - ) - } - > - Удалить медиа - </Button> - )} - </Paper> */} - </Box> - - {/* Правая колонка: Предпросмотр */} - <Box + <Paper + elevation={2} sx={{ - flex: 1, display: "flex", - flexDirection: "column", - gap: 1.5, + alignItems: "center", + justifyContent: "space-between", + paddingX: 2.5, + paddingY: 1.5, + borderRadius: 2, + border: "1px solid", + borderColor: "divider", }} > - <Paper - elevation={3} - sx={{ - width: "100%", - minWidth: 320, - maxWidth: 400, - height: "auto", - minHeight: 500, - backgroundColor: "#877361", - overflowY: "auto", - padding: 0, - display: "flex", - flexDirection: "column", - }} - > - {/* {data.left.media?.filename ? ( - <Box + <Typography variant="h6">Левая статья</Typography> + <Box sx={{ display: "flex", alignItems: "center", gap: 2 }}> + {sight.common.left_article ? ( + <> + <Button + variant="contained" + color="primary" + size="small" + style={{ transition: "0" }} + onClick={() => { + unlinkLeftArticle(); + toast.success("Статья откреплена"); + }} + > + Открепить + </Button> + <Button + variant="outlined" + color="error" + style={{ transition: "0" }} + startIcon={<Trash2 size={18} />} + size="small" + onClick={() => { + deleteLeftArticle(sight.common.left_article); + toast.success("Статья откреплена"); + }} + > + Удалить + </Button> + </> + ) : ( + <> + <Button + variant="contained" + color="primary" + size="small" + onClick={() => setIsSelectArticleDialogOpen(true)} + > + Выбрать статью + </Button> + <Button + variant="contained" + color="primary" + size="small" + style={{ transition: "0" }} + onClick={() => { + createLeftArticle(); + toast.success("Статья создана"); + }} + > + Создать статью + </Button> + </> + )} + </Box> + </Paper> + + {sight.common.left_article > 0 && ( + <Box sx={{ display: "flex", gap: 3, flexGrow: 1 }}> + <Box + sx={{ + flex: 2, + display: "flex", + flexDirection: "column", + gap: 2, + }} + > + <TextField + label="Название информации" + value={data?.left?.heading} + onChange={(e) => + updateSightInfo(languageStore.language, { + left: { + heading: e.target.value, + body: sight[languageStore.language].left.body, + media: data.left.media, + }, + }) + } + variant="outlined" + fullWidth + /> + + <ReactMarkdownEditor + value={data?.left?.body} + onChange={(value) => + updateSightInfo(languageStore.language, { + left: { + heading: sight[languageStore.language].left.heading, + body: value, + media: data.left.media, + }, + }) + } + /> + + <MediaArea + articleId={sight.common.left_article} + mediaIds={data.left.media} + deleteMedia={deleteMedia} + setSelectMediaDialogOpen={setIsSelectMediaDialogOpen} + onFilesDrop={(files) => { + setFileToUpload(files[0]); + setUploadMediaOpen(true); + }} + /> + </Box> + + <Box + sx={{ + flex: 1, + display: "flex", + flexDirection: "column", + gap: 1.5, + }} + > + <Paper + elevation={3} sx={{ width: "100%", - height: 200, - backgroundColor: "grey.300", + minWidth: 320, + maxWidth: 400, + height: "auto", + minHeight: 500, + backgroundColor: "#877361", + overflowY: "auto", + padding: 0, display: "flex", - alignItems: "center", - justifyContent: "center", + flexDirection: "column", }} > - <img - src={data.left.media?.filename ?? ""} - alt="Превью медиа" - style={{ - objectFit: "cover", + <Box + sx={{ width: "100%", - height: "100%", + height: 200, + backgroundColor: "grey.300", + display: "flex", + alignItems: "center", + justifyContent: "center", }} - /> - </Box> - ) : ( - - )} */} + > + {data.left.media.length > 0 ? ( + <MediaViewer + media={{ + id: data.left.media[0].id, + media_type: data.left.media[0].media_type, + filename: data.left.media[0].filename, + }} + /> + ) : ( + <ImagePlus size={48} color="grey" /> + )} + </Box> - <Box - sx={{ - width: "100%", - height: 200, - backgroundColor: "grey.300", - display: "flex", - alignItems: "center", - justifyContent: "center", - }} - > - <ImagePlus size={48} color="grey" /> - </Box> + <Box + sx={{ + backgroundColor: "#877361", + color: "white", + padding: 1.5, + }} + > + <Typography + variant="h5" + component="h2" + sx={{ wordBreak: "break-word" }} + > + {data?.left?.heading || "Название информации"} + </Typography> + </Box> - {/* Заголовок в превью */} - <Box - sx={{ - backgroundColor: "#877361", - color: "white", - padding: 1.5, - }} - > - <Typography - variant="h5" - component="h2" - sx={{ wordBreak: "break-word" }} - > - {data?.left?.heading || "Название информации"} - </Typography> + {data?.left?.body && ( + <Box + sx={{ + padding: 2, + flexGrow: 1, + }} + > + <ReactMarkdownComponent value={data?.left?.body} /> + </Box> + )} + </Paper> </Box> + </Box> + )} - {/* Текст статьи в превью */} - <Box - sx={{ - padding: 2, - flexGrow: 1, - }} - > - <ReactMarkdownComponent value={data?.left?.body} /> - </Box> - </Paper> + <Box sx={{ position: "absolute", bottom: 0, right: 0, padding: 2 }}> + <Button + variant="contained" + color="success" + onClick={async () => { + await updateSight(); + toast.success("Достопримечательность сохранена"); + }} + > + Сохранить + </Button> </Box> </Box> - - <Box sx={{ position: "absolute", bottom: 0, right: 0, padding: 2 }}> - <Button variant="contained" color="success"> - Сохранить - </Button> - </Box> - </Box> - + </TabPanel> + <UploadMediaDialog + open={uploadMediaOpen} + onClose={() => setUploadMediaOpen(false)} + afterUpload={async (media) => { + setUploadMediaOpen(false); + setFileToUpload(null); + await createLinkWithArticle(media); + }} + /> <SelectMediaDialog open={isSelectMediaDialogOpen} onClose={handleCloseMediaDialog} onSelectMedia={handleMediaSelected} /> - </TabPanel> + <SelectArticleModal + open={isSelectArticleDialogOpen} + onClose={handleCloseArticleDialog} + onSelectArticle={handleSelectArticle} + /> + </> ); } ); diff --git a/src/widgets/SightTabs/RightWidgetTab/index.tsx b/src/widgets/SightTabs/RightWidgetTab/index.tsx index c04442c..b8ab094 100644 --- a/src/widgets/SightTabs/RightWidgetTab/index.tsx +++ b/src/widgets/SightTabs/RightWidgetTab/index.tsx @@ -1,345 +1,288 @@ import { Box, Button, - List, - ListItemButton, - ListItemText, Paper, Typography, Menu, MenuItem, + TextField, } from "@mui/material"; import { - articlesStore, BackButton, + createSightStore, + editSightStore, + languageStore, SelectArticleModal, TabPanel, } from "@shared"; -import { SightEdit } from "@widgets"; -import { Plus } from "lucide-react"; +import { + LanguageSwitcher, + ReactMarkdownComponent, + ReactMarkdownEditor, +} from "@widgets"; +import { ImagePlus, Plus } from "lucide-react"; import { observer } from "mobx-react-lite"; -import { useState } from "react"; +import { useEffect, useState } from "react"; +import { toast } from "react-toastify"; -// --- Mock Data (can be moved to a separate file or fetched from an API) --- -const mockRightWidgetBlocks = [ - { id: "preview_media", name: "Превью-медиа", type: "special" }, - { id: "article_1", name: "1. История", type: "article" }, - { id: "article_2", name: "2. Факты", type: "article" }, - { - id: "article_3", - name: "3. Блокада (Пример длинного названия)", - type: "article", - }, -]; - -const mockSelectedBlockData = { - id: "article_1", - heading: "История основания Санкт-Петербурга", - body: "## Начало\nГород был основан 27 мая 1703 года Петром I...", - media: [], -}; - -// --- ArticleListSidebar Component --- -interface ArticleBlock { - id: string; - name: string; - type: string; - linkedArticleId?: string; // Added for linked articles -} - -interface ArticleListSidebarProps { - blocks: ArticleBlock[]; - selectedBlockId: string | null; - onSelectBlock: (blockId: string) => void; - onCreateNew: () => void; - onSelectExisting: () => void; -} - -const ArticleListSidebar = ({ - blocks, - selectedBlockId, - onSelectBlock, - onCreateNew, - onSelectExisting, -}: ArticleListSidebarProps) => { - const [menuAnchorEl, setMenuAnchorEl] = useState<null | HTMLElement>(null); - - const handleMenuOpen = (event: React.MouseEvent<HTMLElement>) => { - setMenuAnchorEl(event.currentTarget); - }; - - const handleMenuClose = () => { - setMenuAnchorEl(null); - }; - - return ( - <Paper - elevation={2} - sx={{ - width: 260, - minWidth: 240, - display: "flex", - flexDirection: "column", - justifyContent: "space-between", - padding: 1.5, - borderRadius: 2, - border: "1px solid", - borderColor: "divider", - }} - > - <List - dense - sx={{ - overflowY: "auto", - flexGrow: 1, - maxHeight: "calc(100% - 60px)", - }} - > - {blocks.map((block) => ( - <ListItemButton - key={block.id} - selected={selectedBlockId === block.id} - onClick={() => onSelectBlock(block.id)} - sx={{ - borderRadius: 1, - mb: 0.5, - backgroundColor: - selectedBlockId === block.id ? "primary.light" : "transparent", - "&.Mui-selected": { - backgroundColor: "primary.main", - color: "primary.contrastText", - "&:hover": { - backgroundColor: "primary.dark", - }, - }, - "&:hover": { - backgroundColor: - selectedBlockId !== block.id ? "action.hover" : undefined, - }, - }} - > - <ListItemText - primary={block.name} - primaryTypographyProps={{ - fontWeight: selectedBlockId === block.id ? "bold" : "normal", - overflow: "hidden", - textOverflow: "ellipsis", - whiteSpace: "nowrap", - }} - /> - </ListItemButton> - ))} - </List> - - <button - className="w-8 h-8 rounded-full bg-blue-500 text-white flex items-center justify-center hover:bg-blue-600 transition-colors" - onClick={handleMenuOpen} - > - <Plus color="white" /> - </button> - <Menu - anchorEl={menuAnchorEl} - open={Boolean(menuAnchorEl)} - onClose={handleMenuClose} - anchorOrigin={{ - vertical: "top", - horizontal: "right", - }} - transformOrigin={{ - vertical: "bottom", - horizontal: "right", - }} - > - <MenuItem onClick={onCreateNew}>Создать новую</MenuItem> - <MenuItem onClick={onSelectExisting}>Выбрать существующую</MenuItem> - </Menu> - </Paper> - ); -}; - -// --- ArticleEditorPane Component --- -interface ArticleData { - id: string; - heading: string; - body: string; - media: any[]; // Define a proper type for media if available -} - -interface ArticleEditorPaneProps { - articleData: ArticleData | null; -} - -const ArticleEditorPane = ({ articleData }: ArticleEditorPaneProps) => { - if (!articleData) { - return ( - <Paper - elevation={2} - sx={{ - flexGrow: 1, - padding: 2.5, - borderRadius: 2, - border: "1px solid", - borderColor: "divider", - display: "flex", - alignItems: "center", - justifyContent: "center", - }} - > - <Typography variant="h6" color="text.secondary"> - Выберите блок для редактирования - </Typography> - </Paper> - ); - } - - return ( - <Paper - elevation={2} - sx={{ - flexGrow: 1, - padding: 2.5, - borderRadius: 2, - border: "1px solid", - borderColor: "divider", - overflowY: "auto", - }} - > - <SightEdit /> - <Paper elevation={1} sx={{ padding: 2, mt: 1, width: "75%" }}> - <Typography variant="h6" gutterBottom> - МЕДИА - </Typography> - <Box - sx={{ - width: "100%", - height: 100, - backgroundColor: "grey.100", - display: "flex", - alignItems: "center", - justifyContent: "center", - borderRadius: 1, - mb: 1, - border: "2px dashed", - borderColor: "grey.300", - }} - > - <Typography color="text.secondary">Нет медиа</Typography> - </Box> - <Button variant="contained">Выбрать/Загрузить медиа</Button> - </Paper> - </Paper> - ); -}; - -// --- RightWidgetTab (Parent) Component --- export const RightWidgetTab = observer( ({ value, index }: { value: number; index: number }) => { - const [rightWidgetBlocks, setRightWidgetBlocks] = useState<ArticleBlock[]>( - mockRightWidgetBlocks - ); - const [selectedBlockId, setSelectedBlockId] = useState<string | null>( - mockRightWidgetBlocks[1]?.id || null + const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null); + const { createNewRightArticle, updateRightArticleInfo } = createSightStore; + const { sight, getRightArticles, updateSight } = editSightStore; + const { language } = languageStore; + + useEffect(() => { + if (sight.common.id) { + getRightArticles(sight.common.id); + } + }, [sight.common.id]); + + const [activeArticleIndex, setActiveArticleIndex] = useState<number | null>( + null ); const [isSelectModalOpen, setIsSelectModalOpen] = useState(false); - const handleSelectBlock = (blockId: string) => { - setSelectedBlockId(blockId); - console.log("Selected block:", blockId); + const open = Boolean(anchorEl); + const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => { + setAnchorEl(event.currentTarget); + }; + const handleClose = () => { + setAnchorEl(null); + }; + + const handleSelectArticle = (index: number) => { + setActiveArticleIndex(index); }; const handleCreateNew = () => { - const newBlockId = `article_${Date.now()}`; - setRightWidgetBlocks((prevBlocks) => [ - ...prevBlocks, - { - id: newBlockId, - name: `${ - prevBlocks.filter((b) => b.type === "article").length + 1 - }. Новый блок`, - type: "article", - }, - ]); - setSelectedBlockId(newBlockId); + createNewRightArticle(); + handleClose(); }; const handleSelectExisting = () => { setIsSelectModalOpen(true); + handleClose(); }; const handleCloseSelectModal = () => { setIsSelectModalOpen(false); }; - const handleSelectArticle = (articleId: string) => { - // @ts-ignore - const article = articlesStore.articles.find((a) => a.id === articleId); - if (article) { - const newBlockId = `article_linked_${article.id}_${Date.now()}`; - setRightWidgetBlocks((prevBlocks) => [ - ...prevBlocks, - { - id: newBlockId, - name: `${ - prevBlocks.filter((b) => b.type === "article").length + 1 - }. ${article.service_name}`, - type: "article", - linkedArticleId: article.id, - }, - ]); - setSelectedBlockId(newBlockId); - } + const handleArticleSelect = () => { + // TODO: Implement article selection logic handleCloseSelectModal(); }; - const handleSave = () => { - console.log("Saving right widget..."); - // Implement save logic here, e.g., send data to an API + const handleSave = async () => { + await updateSight(); + toast.success("Достопримечательность сохранена"); }; - // Determine the current block data to pass to the editor pane - const currentBlockToEdit = selectedBlockId - ? selectedBlockId === mockSelectedBlockData.id - ? mockSelectedBlockData - : { - id: selectedBlockId, - heading: - rightWidgetBlocks.find((b) => b.id === selectedBlockId)?.name || - "Заголовок...", - body: "Содержимое...", - media: [], - } - : null; - - // Get list of already linked article IDs - const linkedArticleIds = rightWidgetBlocks - .filter((block) => block.linkedArticleId) - .map((block) => block.linkedArticleId as string); - return ( <TabPanel value={value} index={index}> + <LanguageSwitcher /> <Box sx={{ display: "flex", flexDirection: "column", height: "100%", - minHeight: "calc(100vh - 200px)", // Adjust as needed + minHeight: "calc(100vh - 200px)", gap: 2, - paddingBottom: "70px", // Space for the save button + paddingBottom: "70px", position: "relative", }} > <BackButton /> <Box sx={{ display: "flex", flexGrow: 1, gap: 2.5 }}> - <ArticleListSidebar - blocks={rightWidgetBlocks} - selectedBlockId={selectedBlockId} - onSelectBlock={handleSelectBlock} - onCreateNew={handleCreateNew} - onSelectExisting={handleSelectExisting} - /> + <Box className="flex flex-col w-[75%] gap-2"> + <Box className="w-full flex gap-2"> + <Box className="relative w-[20%] h-[70vh] flex flex-col rounded-2xl overflow-y-auto gap-3 border border-gray-300 p-3"> + <Box className="flex flex-col gap-3 max-h-[60vh] overflow-y-auto"> + <Box + // onClick={() => setMediaType("preview")} + className="w-full bg-gray-200 p-4 rounded-2xl cursor-pointer text-sm hover:bg-gray-300 transition-all duration-300" + > + <Typography>Предпросмотр медиа</Typography> + </Box> - <ArticleEditorPane articleData={currentBlockToEdit} /> + {sight[language].right.map((article, index) => ( + <Box + key={index} + className="w-full bg-gray-200 p-4 rounded-2xl text-sm cursor-pointer hover:bg-gray-300 transition-all duration-300" + onClick={() => handleSelectArticle(index)} + > + <Typography>{article.heading}</Typography> + </Box> + ))} + </Box> + <button + className="w-10 h-10 bg-blue-500 rounded-full absolute bottom-5 left-5 flex items-center justify-center" + onClick={handleClick} + > + <Plus size={20} color="white" /> + </button> + <Menu + id="basic-menu" + anchorEl={anchorEl} + open={open} + onClose={handleClose} + MenuListProps={{ + "aria-labelledby": "basic-button", + }} + sx={{ mt: 1 }} + > + <MenuItem onClick={handleCreateNew}> + <Typography>Создать новую</Typography> + </MenuItem> + <MenuItem onClick={handleSelectExisting}> + <Typography>Выбрать существующую статью</Typography> + </MenuItem> + </Menu> + </Box> + + <Box className="w-[80%] border border-gray-300 rounded-2xl p-3"> + {activeArticleIndex !== null && ( + <> + <Box className="flex justify-end gap-2 mb-3"> + <Button variant="contained" color="primary"> + Открепить + </Button> + <Button variant="contained" color="success"> + Удалить + </Button> + </Box> + <Box sx={{ display: "flex", gap: 3, flexGrow: 1 }}> + <Box + sx={{ + flex: 2, + display: "flex", + flexDirection: "column", + gap: 2, + maxHeight: "70%", + }} + > + <TextField + label="Название информации" + value={ + sight[language].right[activeArticleIndex].heading + } + onChange={(e) => + updateRightArticleInfo( + activeArticleIndex, + language, + e.target.value, + sight[language].right[activeArticleIndex].body + ) + } + variant="outlined" + fullWidth + /> + + <ReactMarkdownEditor + value={ + sight[language].right[activeArticleIndex].body + } + onChange={(value) => + updateRightArticleInfo( + activeArticleIndex, + language, + sight[language].right[activeArticleIndex] + .heading, + value + ) + } + /> + {/* <MediaArea + articleId={1} + mediaIds={[]} + deleteMedia={() => {}} + /> */} + </Box> + </Box> + </> + )} + </Box> + </Box> + </Box> + + <Box className="w-[25%] mr-10"> + {activeArticleIndex !== null && ( + <Paper + className="flex-1 flex flex-col rounded-2xl" + elevation={2} + > + <Box + className="rounded-2xl overflow-hidden" + sx={{ + width: "100%", + height: "75vh", + background: "#877361", + borderColor: "grey.300", + display: "flex", + flexDirection: "column", + }} + > + <Box + sx={{ + width: "100%", + height: 200, + flexShrink: 0, + backgroundColor: "rgba(0,0,0,0.1)", + display: "flex", + alignItems: "center", + justifyContent: "center", + }} + > + <ImagePlus size={48} color="white" /> + </Box> + + <Box + sx={{ + width: "100%", + minHeight: "70px", + background: "#877361", + display: "flex", + flexShrink: 0, + alignItems: "center", + borderBottom: "1px solid rgba(255,255,255,0.1)", + px: 2, + }} + > + <Typography variant="h6" color="white"> + {sight[language].right[activeArticleIndex].heading || + "Выберите статью"} + </Typography> + </Box> + + <Box + sx={{ + px: 2, + flexGrow: 1, + overflowY: "auto", + backgroundColor: "#877361", + color: "white", + py: 1, + }} + > + {sight[language].right[activeArticleIndex].body ? ( + <ReactMarkdownComponent + value={sight[language].right[activeArticleIndex].body} + /> + ) : ( + <Typography + color="rgba(255,255,255,0.7)" + sx={{ textAlign: "center", mt: 4 }} + > + Предпросмотр статьи появится здесь + </Typography> + )} + </Box> + </Box> + </Paper> + )} + </Box> </Box> <Box @@ -348,8 +291,8 @@ export const RightWidgetTab = observer( bottom: 0, right: 0, padding: 2, - backgroundColor: "background.paper", // Ensure button is visible - width: "100%", // Cover the full width to make it a sticky footer + backgroundColor: "background.paper", + width: "100%", display: "flex", justifyContent: "flex-end", }} @@ -363,8 +306,7 @@ export const RightWidgetTab = observer( <SelectArticleModal open={isSelectModalOpen} onClose={handleCloseSelectModal} - onSelectArticle={handleSelectArticle} - linkedArticleIds={linkedArticleIds} + onSelectArticle={handleArticleSelect} /> </TabPanel> ); diff --git a/src/widgets/SightTabs/index.ts b/src/widgets/SightTabs/index.ts index 1866c7f..20dd419 100644 --- a/src/widgets/SightTabs/index.ts +++ b/src/widgets/SightTabs/index.ts @@ -1,3 +1,6 @@ export * from "./InformationTab"; export * from "./LeftWidgetTab"; export * from "./RightWidgetTab"; +export * from "./CreateInformationTab"; +export * from "./CreateLeftTab"; +export * from "./CreateRightTab"; diff --git a/src/widgets/index.ts b/src/widgets/index.ts index 5db03b3..4ca557b 100644 --- a/src/widgets/index.ts +++ b/src/widgets/index.ts @@ -8,3 +8,5 @@ export * from "./LanguageSwitcher"; export * from "./DevicesTable"; export * from "./SightsTable"; export * from "./MediaViewer"; +export * from "./MediaArea"; +export * from "./ModelViewer3D"; diff --git a/tsconfig.tsbuildinfo b/tsconfig.tsbuildinfo index 1735a1a..089bd8d 100644 --- a/tsconfig.tsbuildinfo +++ b/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/app/index.tsx","./src/app/router/index.tsx","./src/entities/index.ts","./src/entities/navigation/index.ts","./src/entities/navigation/model/index.ts","./src/entities/navigation/ui/index.tsx","./src/features/index.ts","./src/features/navigation/index.ts","./src/features/navigation/ui/index.tsx","./src/pages/index.ts","./src/pages/createsightpage/index.tsx","./src/pages/devicespage/index.tsx","./src/pages/editsightpage/index.tsx","./src/pages/loginpage/index.tsx","./src/pages/mainpage/index.tsx","./src/pages/sightpage/index.tsx","./src/shared/index.tsx","./src/shared/api/index.tsx","./src/shared/config/constants.tsx","./src/shared/config/index.ts","./src/shared/const/index.ts","./src/shared/lib/index.ts","./src/shared/lib/decodejwt/index.ts","./src/shared/lib/mui/theme.ts","./src/shared/modals/index.ts","./src/shared/modals/previewmediadialog/index.tsx","./src/shared/modals/selectarticledialog/index.tsx","./src/shared/modals/selectmediadialog/index.tsx","./src/shared/store/index.ts","./src/shared/store/articlesstore/index.tsx","./src/shared/store/authstore/index.tsx","./src/shared/store/citystore/index.tsx","./src/shared/store/devicesstore/index.tsx","./src/shared/store/editsightstore/index.tsx","./src/shared/store/languagestore/index.tsx","./src/shared/store/mediastore/index.tsx","./src/shared/store/sightsstore/index.tsx","./src/shared/store/snapshotstore/index.ts","./src/shared/store/vehiclestore/index.ts","./src/shared/ui/index.ts","./src/shared/ui/backbutton/index.tsx","./src/shared/ui/coordinatesinput/index.tsx","./src/shared/ui/input/index.tsx","./src/shared/ui/modal/index.tsx","./src/shared/ui/tabpanel/index.tsx","./src/widgets/index.ts","./src/widgets/devicestable/index.tsx","./src/widgets/languageswitcher/index.tsx","./src/widgets/layout/index.tsx","./src/widgets/layout/ui/appbar.tsx","./src/widgets/layout/ui/drawer.tsx","./src/widgets/layout/ui/drawerheader.tsx","./src/widgets/mediaviewer/threeview.tsx","./src/widgets/mediaviewer/index.tsx","./src/widgets/reactmarkdown/index.tsx","./src/widgets/reactmarkdowneditor/index.tsx","./src/widgets/sightedit/index.tsx","./src/widgets/sightheader/index.ts","./src/widgets/sightheader/ui/index.tsx","./src/widgets/sighttabs/index.ts","./src/widgets/sighttabs/informationtab/index.tsx","./src/widgets/sighttabs/leftwidgettab/index.tsx","./src/widgets/sighttabs/rightwidgettab/index.tsx","./src/widgets/sightstable/index.tsx","./src/widgets/modals/index.ts","./src/widgets/modals/selectarticledialog/index.tsx"],"errors":true,"version":"5.8.3"} \ No newline at end of file +{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/app/index.tsx","./src/app/router/index.tsx","./src/entities/index.ts","./src/entities/navigation/index.ts","./src/entities/navigation/model/index.ts","./src/entities/navigation/ui/index.tsx","./src/features/index.ts","./src/features/navigation/index.ts","./src/features/navigation/ui/index.tsx","./src/pages/index.ts","./src/pages/createsightpage/index.tsx","./src/pages/devicespage/index.tsx","./src/pages/editsightpage/index.tsx","./src/pages/loginpage/index.tsx","./src/pages/mainpage/index.tsx","./src/pages/sightpage/index.tsx","./src/shared/index.tsx","./src/shared/api/index.tsx","./src/shared/config/constants.tsx","./src/shared/config/index.ts","./src/shared/const/index.ts","./src/shared/lib/index.ts","./src/shared/lib/decodejwt/index.ts","./src/shared/lib/mui/theme.ts","./src/shared/modals/index.ts","./src/shared/modals/previewmediadialog/index.tsx","./src/shared/modals/selectarticledialog/index.tsx","./src/shared/modals/selectmediadialog/index.tsx","./src/shared/modals/uploadmediadialog/index.tsx","./src/shared/store/index.ts","./src/shared/store/articlesstore/index.tsx","./src/shared/store/authstore/index.tsx","./src/shared/store/citystore/index.tsx","./src/shared/store/createsightstore/index.tsx","./src/shared/store/devicesstore/index.tsx","./src/shared/store/editsightstore/index.tsx","./src/shared/store/languagestore/index.tsx","./src/shared/store/mediastore/index.tsx","./src/shared/store/sightsstore/index.tsx","./src/shared/store/snapshotstore/index.ts","./src/shared/store/vehiclestore/index.ts","./src/shared/ui/index.ts","./src/shared/ui/backbutton/index.tsx","./src/shared/ui/coordinatesinput/index.tsx","./src/shared/ui/input/index.tsx","./src/shared/ui/modal/index.tsx","./src/shared/ui/tabpanel/index.tsx","./src/widgets/index.ts","./src/widgets/devicestable/index.tsx","./src/widgets/languageswitcher/index.tsx","./src/widgets/layout/index.tsx","./src/widgets/layout/ui/appbar.tsx","./src/widgets/layout/ui/drawer.tsx","./src/widgets/layout/ui/drawerheader.tsx","./src/widgets/mediaarea/index.tsx","./src/widgets/mediaviewer/threeview.tsx","./src/widgets/mediaviewer/index.tsx","./src/widgets/modelviewer3d/index.tsx","./src/widgets/reactmarkdown/index.tsx","./src/widgets/reactmarkdowneditor/index.tsx","./src/widgets/sightedit/index.tsx","./src/widgets/sightheader/index.ts","./src/widgets/sightheader/ui/index.tsx","./src/widgets/sighttabs/index.ts","./src/widgets/sighttabs/createinformationtab/mediauploadbox.tsx","./src/widgets/sighttabs/createinformationtab/index.tsx","./src/widgets/sighttabs/createlefttab/index.tsx","./src/widgets/sighttabs/createrighttab/index.tsx","./src/widgets/sighttabs/informationtab/index.tsx","./src/widgets/sighttabs/leftwidgettab/index.tsx","./src/widgets/sighttabs/rightwidgettab/index.tsx","./src/widgets/sightstable/index.tsx","./src/widgets/modals/index.ts","./src/widgets/modals/selectarticledialog/index.tsx"],"version":"5.8.3"} \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 84388b2..d15ebca 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1323,6 +1323,11 @@ asynckit@^0.4.0: resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q== +attr-accept@^2.2.4: + version "2.2.5" + resolved "https://registry.yarnpkg.com/attr-accept/-/attr-accept-2.2.5.tgz#d7061d958e6d4f97bf8665c68b75851a0713ab5e" + integrity sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ== + axios@^1.9.0: version "1.9.0" resolved "https://registry.yarnpkg.com/axios/-/axios-1.9.0.tgz#25534e3b72b54540077d33046f77e3b8d7081901" @@ -1908,6 +1913,13 @@ file-entry-cache@^8.0.0: dependencies: flat-cache "^4.0.0" +file-selector@^2.1.0: + version "2.1.2" + resolved "https://registry.yarnpkg.com/file-selector/-/file-selector-2.1.2.tgz#fe7c7ee9e550952dfbc863d73b14dc740d7de8b4" + integrity sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig== + dependencies: + tslib "^2.7.0" + fill-range@^7.1.1: version "7.1.1" resolved "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz" @@ -3098,6 +3110,15 @@ react-dom@^19.1.0: dependencies: scheduler "^0.26.0" +react-dropzone@^14.3.8: + version "14.3.8" + resolved "https://registry.yarnpkg.com/react-dropzone/-/react-dropzone-14.3.8.tgz#a7eab118f8a452fe3f8b162d64454e81ba830582" + integrity sha512-sBgODnq+lcA4P296DY4wacOZz3JFpD99fp+hb//iBO2HHnyeZU3FwWyXJ6salNpqQdsZrgMrotuko/BdJMV8Ug== + dependencies: + attr-accept "^2.2.4" + file-selector "^2.1.0" + prop-types "^15.8.1" + react-is@^16.13.1, react-is@^16.7.0: version "16.13.1" resolved "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz" @@ -3501,7 +3522,7 @@ ts-api-utils@^2.1.0: resolved "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz" integrity sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ== -tslib@^2.4.0, tslib@^2.8.0: +tslib@^2.4.0, tslib@^2.7.0, tslib@^2.8.0: version "2.8.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==