Latest version #12
| @@ -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<MediaFile[]>([]); | ||||
| @@ -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, | ||||
|         }} | ||||
|       > | ||||
|         <Typography variant="subtitle1" fontWeight="bold"> | ||||
| @@ -192,6 +197,10 @@ export const CreateSightArticle = ({ | ||||
|             fullWidth | ||||
|             InputLabelProps={{ shrink: true }} | ||||
|             type="text" | ||||
|             sx={{ | ||||
|               zIndex: 2000, | ||||
|               backgroundColor: theme.palette.background.paper, | ||||
|             }} | ||||
|             label="Заголовок *" | ||||
|           /> | ||||
|  | ||||
|   | ||||
| @@ -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 = <T extends { id: number; [key: string]: any }>({ | ||||
|   type, | ||||
|   onSave, | ||||
| }: LinkedItemsProps<T>) => { | ||||
|   const { language } = languageStore; | ||||
|   const { setArticleModalOpenAction, setArticleIdAction } = articleStore; | ||||
|   const { setStationModalOpenAction, setStationIdAction } = stationStore; | ||||
|   const [position, setPosition] = useState<number>(1); | ||||
| @@ -152,7 +153,7 @@ export const LinkedItems = <T extends { id: number; [key: string]: any }>({ | ||||
|           setLinkedItems([]); | ||||
|         }); | ||||
|     } | ||||
|   }, [parentId, parentResource, childResource]); | ||||
|   }, [parentId, parentResource, childResource, language]); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     if (type === "edit") { | ||||
| @@ -354,7 +355,10 @@ export const LinkedItems = <T extends { id: number; [key: string]: any }>({ | ||||
|                                         variant="outlined" | ||||
|                                         color="error" | ||||
|                                         size="small" | ||||
|                                         onClick={() => deleteItem(item.id)} | ||||
|                                         onClick={(e) => { | ||||
|                                           e.stopPropagation(); | ||||
|                                           deleteItem(item.id); | ||||
|                                         }} | ||||
|                                       > | ||||
|                                         Отвязать | ||||
|                                       </Button> | ||||
| @@ -430,7 +434,7 @@ export const LinkedItems = <T extends { id: number; [key: string]: any }>({ | ||||
|                   <FormControl fullWidth> | ||||
|                     <TextField | ||||
|                       type="number" | ||||
|                       label="Номер страницы" | ||||
|                       label="Позиция добавляемой статьи" | ||||
|                       name="page_num" | ||||
|                       value={pageNum} | ||||
|                       onChange={(e) => { | ||||
|   | ||||
| @@ -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<MediaFile[]>([]); | ||||
|  | ||||
|   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 ( | ||||
|     <Modal | ||||
|       open={articleModalOpen} | ||||
| @@ -164,6 +310,88 @@ export const ArticleEditModal = observer(() => { | ||||
|               )} | ||||
|             /> | ||||
|           </Box> | ||||
|  | ||||
|           {/* Dropzone для медиа файлов */} | ||||
|           <Box sx={{ mt: 2, mb: 2 }}> | ||||
|             <Box | ||||
|               {...getRootProps()} | ||||
|               sx={{ | ||||
|                 border: "2px dashed", | ||||
|                 borderColor: isDragActive ? "primary.main" : "grey.300", | ||||
|                 borderRadius: 1, | ||||
|                 p: 2, | ||||
|                 textAlign: "center", | ||||
|                 cursor: "pointer", | ||||
|                 "&:hover": { | ||||
|                   borderColor: "primary.main", | ||||
|                 }, | ||||
|               }} | ||||
|             > | ||||
|               <input {...getInputProps()} /> | ||||
|               <Typography> | ||||
|                 {isDragActive | ||||
|                   ? "Перетащите файлы сюда..." | ||||
|                   : "Перетащите файлы сюда или кликните для выбора"} | ||||
|               </Typography> | ||||
|             </Box> | ||||
|  | ||||
|             {/* Превью загруженных файлов */} | ||||
|             <Box sx={{ mt: 2, display: "flex", flexWrap: "wrap", gap: 1 }}> | ||||
|               {mediaFiles.map((mediaFile, index) => ( | ||||
|                 <Box | ||||
|                   key={mediaFile.preview} | ||||
|                   sx={{ | ||||
|                     position: "relative", | ||||
|                     width: 100, | ||||
|                     height: 100, | ||||
|                   }} | ||||
|                 > | ||||
|                   {mediaFile.file.type.startsWith("image/") ? ( | ||||
|                     <img | ||||
|                       src={mediaFile.preview} | ||||
|                       alt={mediaFile.file.name} | ||||
|                       style={{ | ||||
|                         width: "100%", | ||||
|                         height: "100%", | ||||
|                         objectFit: "cover", | ||||
|                       }} | ||||
|                     /> | ||||
|                   ) : ( | ||||
|                     <Box | ||||
|                       sx={{ | ||||
|                         width: "100%", | ||||
|                         height: "100%", | ||||
|                         display: "flex", | ||||
|                         alignItems: "center", | ||||
|                         justifyContent: "center", | ||||
|                         bgcolor: "grey.200", | ||||
|                       }} | ||||
|                     > | ||||
|                       <Typography variant="caption"> | ||||
|                         {mediaFile.file.name} | ||||
|                       </Typography> | ||||
|                     </Box> | ||||
|                   )} | ||||
|                   <Button | ||||
|                     size="small" | ||||
|                     color="error" | ||||
|                     onClick={() => removeMedia(index)} | ||||
|                     sx={{ | ||||
|                       position: "absolute", | ||||
|                       top: 0, | ||||
|                       right: 0, | ||||
|                       minWidth: "auto", | ||||
|                       width: 20, | ||||
|                       height: 20, | ||||
|                       p: 0, | ||||
|                     }} | ||||
|                   > | ||||
|                     × | ||||
|                   </Button> | ||||
|                 </Box> | ||||
|               ))} | ||||
|             </Box> | ||||
|           </Box> | ||||
|         </Edit> | ||||
|       </Box> | ||||
|     </Modal> | ||||
|   | ||||
| @@ -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 ( | ||||
|     <Canvas style={{ width: "100%", height: "400px" }}> | ||||
|     <Canvas style={{ width: "100%", height: height }}> | ||||
|       <ambientLight /> | ||||
|       <directionalLight /> | ||||
|       <Stage environment="city" intensity={0.6}> | ||||
|   | ||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
		Reference in New Issue
	
	Block a user