diff --git a/src/components/CreateSightArticle.tsx b/src/components/CreateSightArticle.tsx index 9432b7a..940fd5e 100644 --- a/src/components/CreateSightArticle.tsx +++ b/src/components/CreateSightArticle.tsx @@ -33,6 +33,7 @@ type Props = { parentResource: string; childResource: string; title: string; + left?: boolean; }; export const CreateSightArticle = ({ @@ -40,6 +41,7 @@ export const CreateSightArticle = ({ parentResource, childResource, title, + left, }: Props) => { const theme = useTheme(); const [mediaFiles, setMediaFiles] = useState([]); @@ -118,16 +120,18 @@ export const CreateSightArticle = ({ const existingItems = existingItemsResponse.data || []; const nextPageNum = existingItems.length + 1; - // Привязываем статью к достопримечательности - await axiosInstance.post( - `${ - import.meta.env.VITE_KRBL_API - }/${parentResource}/${parentId}/${childResource}/`, - { - [`${childResource}_id`]: itemId, - page_num: nextPageNum, - } - ); + if (!left) { + // Привязываем статью к достопримечательности если она не левая + await axiosInstance.post( + `${ + import.meta.env.VITE_KRBL_API + }/${parentResource}/${parentId}/${childResource}/`, + { + [`${childResource}_id`]: itemId, + page_num: nextPageNum, + } + ); + } // Загружаем все медиа файлы и получаем их ID const mediaIds = await Promise.all( @@ -174,6 +178,7 @@ export const CreateSightArticle = ({ marginTop: 2, background: theme.palette.background.paper, borderBottom: `1px solid ${theme.palette.divider}`, + zIndex: 2000, }} > @@ -192,6 +197,10 @@ export const CreateSightArticle = ({ fullWidth InputLabelProps={{ shrink: true }} type="text" + sx={{ + zIndex: 2000, + backgroundColor: theme.palette.background.paper, + }} label="Заголовок *" /> diff --git a/src/components/LinkedItems.tsx b/src/components/LinkedItems.tsx index bf25d84..5147a94 100644 --- a/src/components/LinkedItems.tsx +++ b/src/components/LinkedItems.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from "react"; -import { Close } from "@mui/icons-material"; +import { languageStore } from "../store/LanguageStore"; import { Stack, Typography, @@ -88,6 +88,7 @@ export const LinkedItems = ({ type, onSave, }: LinkedItemsProps) => { + const { language } = languageStore; const { setArticleModalOpenAction, setArticleIdAction } = articleStore; const { setStationModalOpenAction, setStationIdAction } = stationStore; const [position, setPosition] = useState(1); @@ -152,7 +153,7 @@ export const LinkedItems = ({ setLinkedItems([]); }); } - }, [parentId, parentResource, childResource]); + }, [parentId, parentResource, childResource, language]); useEffect(() => { if (type === "edit") { @@ -354,7 +355,10 @@ export const LinkedItems = ({ variant="outlined" color="error" size="small" - onClick={() => deleteItem(item.id)} + onClick={(e) => { + e.stopPropagation(); + deleteItem(item.id); + }} > Отвязать @@ -430,7 +434,7 @@ export const LinkedItems = ({ { diff --git a/src/components/modals/ArticleEditModal/index.tsx b/src/components/modals/ArticleEditModal/index.tsx index a843dc7..ae11f03 100644 --- a/src/components/modals/ArticleEditModal/index.tsx +++ b/src/components/modals/ArticleEditModal/index.tsx @@ -4,16 +4,30 @@ import { observer } from "mobx-react-lite"; import { useForm } from "@refinedev/react-hook-form"; import { Controller } from "react-hook-form"; import "easymde/dist/easymde.min.css"; -import { memo, useMemo, useEffect } from "react"; +import { memo, useMemo, useEffect, useCallback } from "react"; import { MarkdownEditor } from "../../MarkdownEditor"; import { Edit } from "@refinedev/mui"; import { languageStore } from "../../../store/LanguageStore"; import { LanguageSwitch } from "../../LanguageSwitch/index"; import { useNavigate } from "react-router"; import { useState } from "react"; +import { useDropzone } from "react-dropzone"; +import { + ALLOWED_IMAGE_TYPES, + ALLOWED_VIDEO_TYPES, +} from "../../media/MediaFormUtils"; +import { axiosInstance } from "../../../providers/data"; +import { TOKEN_KEY } from "../../../authProvider"; const MemoizedSimpleMDE = memo(MarkdownEditor); +type MediaFile = { + file: File; + preview: string; + uploading: boolean; + mediaId?: number; +}; + const style = { position: "absolute", top: "50%", @@ -45,12 +59,58 @@ export const ArticleEditModal = observer(() => { articleStore; const { language } = languageStore; + const [mediaFiles, setMediaFiles] = useState([]); + useEffect(() => { return () => { setArticleModalOpenAction(false); }; }, []); + // Load existing media files when editing an article + useEffect(() => { + const loadExistingMedia = async () => { + if (selectedArticleId) { + try { + const response = await axiosInstance.get( + `${ + import.meta.env.VITE_KRBL_API + }/article/${selectedArticleId}/media` + ); + const existingMedia = response.data; + + // Convert existing media to MediaFile format + const mediaFiles = await Promise.all( + existingMedia.map(async (media: any) => { + const response = await fetch( + `${import.meta.env.VITE_KRBL_MEDIA}${ + media.id + }/download?token=${localStorage.getItem(TOKEN_KEY)}` + ); + const blob = await response.blob(); + const file = new File([blob], media.filename, { + type: media.media_type === 1 ? "image/jpeg" : "video/mp4", + }); + + return { + file, + preview: URL.createObjectURL(blob), + uploading: false, + mediaId: media.id, + }; + }) + ); + + setMediaFiles(mediaFiles); + } catch (error) { + console.error("Error loading existing media:", error); + } + } + }; + + loadExistingMedia(); + }, [selectedArticleId]); + const { register, control, @@ -66,10 +126,37 @@ export const ArticleEditModal = observer(() => { action: "edit", redirect: false, - onMutationSuccess: () => { - setArticleModalOpenAction(false); - reset(); - window.location.reload(); + onMutationSuccess: async () => { + try { + // Upload new media files + const newMediaFiles = mediaFiles.filter((file) => !file.mediaId); + const mediaIds = await Promise.all( + newMediaFiles.map(async (mediaFile) => { + return await uploadMedia(mediaFile); + }) + ); + + // Associate all media with the article + await Promise.all( + mediaIds.map((mediaId, index) => + axiosInstance.post( + `${ + import.meta.env.VITE_KRBL_API + }/article/${selectedArticleId}/media/`, + { + media_id: mediaId, + media_order: index + 1, + } + ) + ) + ); + + setArticleModalOpenAction(false); + reset(); + window.location.reload(); + } catch (error) { + console.error("Error handling media:", error); + } }, meta: { headers: { @@ -112,6 +199,65 @@ export const ArticleEditModal = observer(() => { [] ); + const onDrop = useCallback((acceptedFiles: File[]) => { + const newFiles = acceptedFiles.map((file) => ({ + file, + preview: URL.createObjectURL(file), + uploading: false, + })); + setMediaFiles((prev) => [...prev, ...newFiles]); + }, []); + + const { getRootProps, getInputProps, isDragActive } = useDropzone({ + onDrop, + accept: { + "image/*": ALLOWED_IMAGE_TYPES, + "video/*": ALLOWED_VIDEO_TYPES, + }, + multiple: true, + }); + + const uploadMedia = async (mediaFile: MediaFile) => { + const formData = new FormData(); + formData.append("media_name", mediaFile.file.name); + formData.append("filename", mediaFile.file.name); + formData.append( + "type", + mediaFile.file.type.startsWith("image/") ? "1" : "2" + ); + formData.append("file", mediaFile.file); + + const response = await axiosInstance.post( + `${import.meta.env.VITE_KRBL_API}/media`, + formData + ); + return response.data.id; + }; + + const removeMedia = async (index: number) => { + const mediaFile = mediaFiles[index]; + + // If it's an existing media file (has mediaId), delete it from the server + if (mediaFile.mediaId) { + try { + await axiosInstance.delete( + `${import.meta.env.VITE_KRBL_API}/media/${mediaFile.mediaId}` + ); + } catch (error) { + console.error("Error deleting media:", error); + return; // Don't remove from UI if server deletion failed + } + } + + // Remove from UI and cleanup + setMediaFiles((prev) => { + const newFiles = [...prev]; + URL.revokeObjectURL(newFiles[index].preview); + newFiles.splice(index, 1); + return newFiles; + }); + }; + return ( { )} /> + + {/* Dropzone для медиа файлов */} + + + + + {isDragActive + ? "Перетащите файлы сюда..." + : "Перетащите файлы сюда или кликните для выбора"} + + + + {/* Превью загруженных файлов */} + + {mediaFiles.map((mediaFile, index) => ( + + {mediaFile.file.type.startsWith("image/") ? ( + {mediaFile.file.name} + ) : ( + + + {mediaFile.file.name} + + + )} + + + ))} + + diff --git a/src/pages/media/ModelViewer/index.tsx b/src/pages/media/ModelViewer/index.tsx index ce75996..0d58c3e 100644 --- a/src/pages/media/ModelViewer/index.tsx +++ b/src/pages/media/ModelViewer/index.tsx @@ -6,11 +6,11 @@ type ModelViewerProps = { height?: string; }; -export const ModelViewer = ({ fileUrl, height }: ModelViewerProps) => { +export const ModelViewer = ({ fileUrl, height = "80vh" }: ModelViewerProps) => { const { scene } = useGLTF(fileUrl); return ( - + diff --git a/src/pages/sight/edit.tsx b/src/pages/sight/edit.tsx index 994c9ca..8c69c85 100644 --- a/src/pages/sight/edit.tsx +++ b/src/pages/sight/edit.tsx @@ -6,6 +6,7 @@ import { Typography, Tab, Tabs, + Button, } from "@mui/material"; import { Edit, useAutocomplete } from "@refinedev/mui"; import { useForm } from "@refinedev/react-hook-form"; @@ -23,6 +24,8 @@ import axios from "axios"; import { LanguageSwitch } from "../../components/LanguageSwitch/index"; import { ReactPhotoSphereViewer } from "react-photo-sphere-viewer"; import { ModelViewer } from "../media/ModelViewer"; +import { articleStore } from "../../store/ArticleStore"; +import { ArticleEditModal } from "../../components/modals/ArticleEditModal/index"; function a11yProps(index: number) { return { @@ -56,7 +59,8 @@ function CustomTabPanel(props: TabPanelProps) { export const SightEdit = observer(() => { const { id: sightId } = useParams<{ id: string }>(); const { language, setLanguageAction } = languageStore; - + const [previewSelected, setPreviewSelected] = useState(true); + const { setArticleModalOpenAction, setArticleIdAction } = articleStore; const [sightData, setSightData] = useState({ ru: { name: "", @@ -129,13 +133,16 @@ export const SightEdit = observer(() => { value, }, ], + meta: { + headers: { + "Accept-Language": language, + }, + }, }); const { autocompleteProps: articleAutocompleteProps } = useAutocomplete({ resource: "article", - queryOptions: { - queryKey: ["article", language], - }, + onSearch: (value) => [ { field: "heading", @@ -148,6 +155,12 @@ export const SightEdit = observer(() => { value, }, ], + + meta: { + headers: { + "Accept-Language": language, + }, + }, }); useEffect(() => { @@ -189,7 +202,7 @@ export const SightEdit = observer(() => { latitude: "", longitude: "", }); - const [selectedArticleIndex, setSelectedArticleIndex] = useState(0); + const [selectedArticleIndex, setSelectedArticleIndex] = useState(-1); const [cityPreview, setCityPreview] = useState(""); const [thumbnailPreview, setThumbnailPreview] = useState(null); const [watermarkLUPreview, setWatermarkLUPreview] = useState( @@ -198,11 +211,64 @@ export const SightEdit = observer(() => { const [watermarkRDPreview, setWatermarkRDPreview] = useState( null ); - const [leftArticlePreview, setLeftArticlePreview] = useState(""); - const [previewArticlePreview, setPreviewArticlePreview] = useState(""); + const [linkedArticles, setLinkedArticles] = useState([]); // Следим за изменениями во всех полях const selectedArticle = linkedArticles[selectedArticleIndex]; + const [previewMedia, setPreviewMedia] = useState<{ + src: string; + media_type: number; + filename: string; + } | null>(null); + const [leftArticleMedia, setLeftArticleMedia] = useState<{ + src: string; + media_type: number; + filename: string; + } | null>(null); + + const previewMediaId = watch("preview_media"); + const leftArticleId = watch("left_article"); + useEffect(() => { + if (previewMediaId) { + const getMedia = async () => { + try { + const response = await axios.get( + `${import.meta.env.VITE_KRBL_API}/article/${previewMediaId}/media`, + { + headers: { + Authorization: `Bearer ${localStorage.getItem(TOKEN_KEY)}`, + "Accept-Language": language, + }, + } + ); + const media = response.data[0]; + if (media) { + setPreviewMedia({ + src: `${import.meta.env.VITE_KRBL_MEDIA}${ + media.id + }/download?token=${localStorage.getItem(TOKEN_KEY)}`, + media_type: media.media_type, + filename: media.filename, + }); + } else { + setPreviewMedia({ + src: "", + media_type: 1, + filename: "", + }); // или другой дефолт + } + } catch (error) { + console.error("Error fetching media:", error); + setPreviewMedia({ + src: "", + media_type: 1, + filename: "", + }); // или другой дефолт + } + }; + getMedia(); + } + }, [previewMediaId]); const addressContent = watch("address"); const nameContent = watch("name"); @@ -244,6 +310,7 @@ export const SightEdit = observer(() => { { headers: { Authorization: `Bearer ${localStorage.getItem(TOKEN_KEY)}`, + "Accept-Language": language, }, } ); @@ -322,18 +389,36 @@ export const SightEdit = observer(() => { }, [watermarkRDContent, mediaAutocompleteProps.options]); useEffect(() => { - const selectedLeftArticle = articleAutocompleteProps.options.find( - (option) => option.id === leftArticleContent - ); - setLeftArticlePreview(selectedLeftArticle?.heading || ""); - }, [leftArticleContent, articleAutocompleteProps.options]); + const getMedia = async () => { + const selectedLeftArticle = articleAutocompleteProps.options.find( + (option) => option.id === leftArticleContent + ); + if (!selectedLeftArticle) return; + const response = await axios.get( + `${import.meta.env.VITE_KRBL_API}/article/${ + selectedLeftArticle?.id + }/media`, + { + headers: { + Authorization: `Bearer ${localStorage.getItem(TOKEN_KEY)}`, + "Accept-Language": language, + }, + } + ); + const media = response.data[0]; + if (media) { + setLeftArticleMedia({ + src: `${import.meta.env.VITE_KRBL_MEDIA}${ + media.id + }/download?token=${localStorage.getItem(TOKEN_KEY)}`, + media_type: media.media_type, + filename: media.filename, + }); + } + }; - useEffect(() => { - const selectedPreviewArticle = articleAutocompleteProps.options.find( - (option) => option.id === previewArticleContent - ); - setPreviewArticlePreview(selectedPreviewArticle?.heading || ""); - }, [previewArticleContent, articleAutocompleteProps.options]); + getMedia(); + }, [leftArticleId, leftArticleContent]); const handleLanguageChange = (lang: string) => { setSightData((prevData) => ({ @@ -567,51 +652,97 @@ export const SightEdit = observer(() => { )} /> - ( - option.id === field.value - ) || null - } - onChange={(_, value) => { - field.onChange(value?.id || ""); - }} - getOptionLabel={(item) => { - return item ? item.media_name : ""; - }} - isOptionEqualToValue={(option, value) => { - return option.id === value?.id; - }} - filterOptions={(options, { inputValue }) => { - return options.filter( - (option) => + + ( + option.id === field.value + ) || null + } + onChange={(_, value) => { + field.onChange(value?.id || ""); + }} + getOptionLabel={(item) => { + return item ? item.media_name : ""; + }} + isOptionEqualToValue={(option, value) => { + return option.id === value?.id; + }} + filterOptions={(options, { inputValue }) => { + return options.filter( + (option) => + option.media_name + .toLowerCase() + .includes(inputValue.toLowerCase()) && + option.media_type === 3 + ); + }} + renderInput={(params) => ( + + )} + /> + )} + /> + + + + ( + option.id === field.value + ) || null + } + onChange={(_, value) => { + field.onChange(value?.id || ""); + }} + getOptionLabel={(item) => { + return item ? item.media_name : ""; + }} + isOptionEqualToValue={(option, value) => { + return option.id === value?.id; + }} + filterOptions={(options, { inputValue }) => { + return options.filter((option) => option.media_name .toLowerCase() - .includes(inputValue.toLowerCase()) && - option.media_type === 3 - ); - }} - renderInput={(params) => ( - - )} - /> - )} - /> - + .includes(inputValue.toLowerCase()) + ); + }} + renderInput={(params) => ( + + )} + /> + )} + /> + { } onChange={(_, value) => { field.onChange(value?.id || ""); + setLeftArticleMedia(null); }} getOptionLabel={(item) => { return item ? item.heading : ""; @@ -743,49 +875,6 @@ export const SightEdit = observer(() => { /> )} /> - - ( - option.id === field.value - ) || null - } - onChange={(_, value) => { - field.onChange(value?.id || ""); - }} - getOptionLabel={(item) => { - return item ? item.heading : ""; - }} - isOptionEqualToValue={(option, value) => { - return option.id === value?.id; - }} - filterOptions={(options, { inputValue }) => { - return options.filter((option) => - option.heading - .toLowerCase() - .includes(inputValue.toLowerCase()) - ); - }} - renderInput={(params) => ( - - )} - /> - )} - /> @@ -797,20 +886,99 @@ export const SightEdit = observer(() => { width: "30%", top: "179px", - + minHeight: "600px", right: 50, zIndex: 1000, borderRadius: 2, border: "1px solid", borderColor: "primary.main", - bgcolor: (theme) => - theme.palette.mode === "dark" ? "background.paper" : "#fff", + bgcolor: "#806c59", }} > - - Предпросмотр - + + {leftArticleMedia && + leftArticleMedia.src && + leftArticleMedia.media_type === 1 && ( + {leftArticleMedia.filename} + )} + + {leftArticleMedia && leftArticleMedia.media_type === 2 && ( + {/* Название достопримечательности */} { {addressContent} - - {/* Обложка */} - {thumbnailPreview && ( - - - Логотип достопримечательности: - - - - )} + {!leftArticleId && ( + + )} + {leftArticleId && ( + + )} @@ -909,18 +1077,52 @@ export const SightEdit = observer(() => { sx={{ flex: 1, display: "flex", flexDirection: "column" }} autoComplete="off" > - ( + option.id === field.value + ) || null + } + onClick={() => {}} + onChange={(_, value) => { + field.onChange(value?.id || ""); + }} + getOptionLabel={(item) => { + return item ? item.heading : ""; + }} + isOptionEqualToValue={(option, value) => { + return option.id === value?.id; + }} + filterOptions={(options, { inputValue }) => { + return options.filter((option) => + option.heading + .toLowerCase() + .includes(inputValue.toLowerCase()) + ); + }} + renderInput={(params) => ( + { + setPreviewSelected(true); + setSelectedArticleIndex(-1); + }} + label="Медиа-предпросмотр" + margin="normal" + variant="outlined" + error={!!errors.arms} + helperText={(errors as any)?.arms?.message} + required + /> + )} + /> + )} /> @@ -941,6 +1143,7 @@ export const SightEdit = observer(() => { parentResource="sight" childResource="article" title="статью" + left /> @@ -951,162 +1154,271 @@ export const SightEdit = observer(() => { flexDirection: "column", position: "fixed", p: 2, - + height: "max-content", width: "30%", - overflowY: "scroll", - height: "80vh", - top: "178px", + top: "178px", + minHeight: "600px", right: 50, zIndex: 1000, borderRadius: 2, border: "1px solid", - borderColor: "primary.main", - bgcolor: (theme) => - theme.palette.mode === "dark" ? "background.paper" : "#fff", + + bgcolor: "#806c59", }} > - - Предпросмотр - - - - {mediaFile && mediaFile.src && mediaFile.media_type === 1 && ( - {mediaFile.filename} - )} - - {mediaFile && mediaFile.media_type === 2 && ( - - {/* Водяные знаки */} - - {selectedArticle && ( - - {selectedArticle.heading} - - )} + {!previewSelected && ( + + {mediaFile && mediaFile.src && mediaFile.media_type === 1 && ( + {mediaFile.filename} + )} - {selectedArticle && ( - - {selectedArticle.body} - - )} - {selectedArticle && ( - - {selectedArticle.body} - - )} - {selectedArticle && ( - - {selectedArticle.body} - - )} - + {mediaFile && mediaFile.media_type === 2 && ( + + } @@ -1134,7 +1446,6 @@ export const SightEdit = observer(() => { sx={{ flex: 1, display: "flex", flexDirection: "column" }} autoComplete="off" > - { {...mediaAutocompleteProps} value={ mediaAutocompleteProps.options.find( - (option) => - option.id === field.value && option.media_type === 3 + (option) => option.id === field.value ) || null } onChange={(_, value) => { @@ -1158,16 +1468,18 @@ export const SightEdit = observer(() => { return option.id === value?.id; }} filterOptions={(options, { inputValue }) => { - return options.filter((option) => - option.media_name - .toLowerCase() - .includes(inputValue.toLowerCase()) + return options.filter( + (option) => + option.media_name + .toLowerCase() + .includes(inputValue.toLowerCase()) && + option.media_type === 3 ); }} renderInput={(params) => ( { return option.id === value?.id; }} filterOptions={(options, { inputValue }) => { - return options.filter((option) => - option.media_name - .toLowerCase() - .includes(inputValue.toLowerCase()) + return options.filter( + (option) => + option.media_name + .toLowerCase() + .includes(inputValue.toLowerCase()) && + option.media_type === 4 ); }} renderInput={(params) => ( @@ -1244,10 +1558,12 @@ export const SightEdit = observer(() => { return option.id === value?.id; }} filterOptions={(options, { inputValue }) => { - return options.filter((option) => - option.media_name - .toLowerCase() - .includes(inputValue.toLowerCase()) + return options.filter( + (option) => + option.media_name + .toLowerCase() + .includes(inputValue.toLowerCase()) && + option.media_type === 4 ); }} renderInput={(params) => ( @@ -1405,6 +1721,7 @@ export const SightEdit = observer(() => { + ); });