abstract urls
This commit is contained in:
		| @@ -1,174 +1,182 @@ | ||||
| import type {AuthProvider} from '@refinedev/core' | ||||
| import axios, {AxiosError} from 'axios' | ||||
| import {BACKEND_URL} from './lib/constants' | ||||
| import {jwtDecode} from 'jwt-decode' | ||||
| import type { AuthProvider } from "@refinedev/core"; | ||||
| import axios, { AxiosError } from "axios"; | ||||
|  | ||||
| export const TOKEN_KEY = 'refine-auth' | ||||
| import { jwtDecode } from "jwt-decode"; | ||||
|  | ||||
| export const TOKEN_KEY = "refine-auth"; | ||||
|  | ||||
| interface AuthResponse { | ||||
|   token: string | ||||
|   token: string; | ||||
|   user: { | ||||
|     id: number | ||||
|     name: string | ||||
|     email: string | ||||
|     is_admin: boolean | ||||
|   } | ||||
|     id: number; | ||||
|     name: string; | ||||
|     email: string; | ||||
|     is_admin: boolean; | ||||
|   }; | ||||
| } | ||||
|  | ||||
| interface ErrorResponse { | ||||
|   message: string | ||||
|   message: string; | ||||
| } | ||||
|  | ||||
| class AuthError extends Error { | ||||
|   constructor(message: string) { | ||||
|     super(message) | ||||
|     this.name = 'AuthError' | ||||
|     super(message); | ||||
|     this.name = "AuthError"; | ||||
|   } | ||||
| } | ||||
|  | ||||
| interface JWTPayload { | ||||
|   user_id: number | ||||
|   email: string | ||||
|   is_admin: boolean | ||||
|   exp: number | ||||
|   user_id: number; | ||||
|   email: string; | ||||
|   is_admin: boolean; | ||||
|   exp: number; | ||||
| } | ||||
|  | ||||
| export const authProvider: AuthProvider = { | ||||
|   login: async ({email, password}) => { | ||||
|   login: async ({ email, password }) => { | ||||
|     try { | ||||
|       const response = await axios.post<AuthResponse>(`${BACKEND_URL}/auth/login`, { | ||||
|         email, | ||||
|         password, | ||||
|       }) | ||||
|       const response = await axios.post<AuthResponse>( | ||||
|         `${import.meta.env.VITE_KRBL_API}/auth/login`, | ||||
|         { | ||||
|           email, | ||||
|           password, | ||||
|         } | ||||
|       ); | ||||
|  | ||||
|       const {token, user} = response.data | ||||
|       const { token, user } = response.data; | ||||
|  | ||||
|       if (token) { | ||||
|         localStorage.setItem(TOKEN_KEY, token) | ||||
|         localStorage.setItem('user', JSON.stringify(user)) | ||||
|         localStorage.setItem(TOKEN_KEY, token); | ||||
|         localStorage.setItem("user", JSON.stringify(user)); | ||||
|  | ||||
|         return { | ||||
|           success: true, | ||||
|           redirectTo: '/', | ||||
|         } | ||||
|           redirectTo: "/", | ||||
|         }; | ||||
|       } | ||||
|  | ||||
|       throw new AuthError('Неверный email или пароль') | ||||
|       throw new AuthError("Неверный email или пароль"); | ||||
|     } catch (error) { | ||||
|       const errorMessage = (error as AxiosError<ErrorResponse>)?.response?.data?.message || 'Неверный email или пароль' | ||||
|       const errorMessage = | ||||
|         (error as AxiosError<ErrorResponse>)?.response?.data?.message || | ||||
|         "Неверный email или пароль"; | ||||
|  | ||||
|       return { | ||||
|         success: false, | ||||
|         error: new AuthError(errorMessage), | ||||
|       } | ||||
|       }; | ||||
|     } | ||||
|   }, | ||||
|   logout: async () => { | ||||
|     try { | ||||
|       await axios.post( | ||||
|         `${BACKEND_URL}/auth/logout`, | ||||
|         `${import.meta.env.VITE_KRBL_API}/auth/logout`, | ||||
|         {}, | ||||
|         { | ||||
|           headers: { | ||||
|             Authorization: `Bearer ${localStorage.getItem(TOKEN_KEY)}`, | ||||
|           }, | ||||
|         }, | ||||
|       ) | ||||
|         } | ||||
|       ); | ||||
|     } catch (error) { | ||||
|       console.error('Ошибка при выходе:', error) | ||||
|       console.error("Ошибка при выходе:", error); | ||||
|     } | ||||
|  | ||||
|     localStorage.removeItem(TOKEN_KEY) | ||||
|     localStorage.removeItem('user') | ||||
|     localStorage.removeItem(TOKEN_KEY); | ||||
|     localStorage.removeItem("user"); | ||||
|     return { | ||||
|       success: true, | ||||
|       redirectTo: '/login', | ||||
|     } | ||||
|       redirectTo: "/login", | ||||
|     }; | ||||
|   }, | ||||
|   check: async () => { | ||||
|     const token = localStorage.getItem(TOKEN_KEY) | ||||
|     const token = localStorage.getItem(TOKEN_KEY); | ||||
|     if (!token) { | ||||
|       return { | ||||
|         authenticated: false, | ||||
|         redirectTo: '/login', | ||||
|       } | ||||
|         redirectTo: "/login", | ||||
|       }; | ||||
|     } | ||||
|  | ||||
|     try { | ||||
|       const response = await axios.get(`${BACKEND_URL}/auth/me`, { | ||||
|         headers: { | ||||
|           Authorization: `Bearer ${token}`, | ||||
|         }, | ||||
|       }) | ||||
|       const response = await axios.get( | ||||
|         `${import.meta.env.VITE_KRBL_API}/auth/me`, | ||||
|         { | ||||
|           headers: { | ||||
|             Authorization: `Bearer ${token}`, | ||||
|           }, | ||||
|         } | ||||
|       ); | ||||
|  | ||||
|       if (response.status === 200) { | ||||
|         // Обновляем информацию о пользователе | ||||
|         localStorage.setItem('user', JSON.stringify(response.data)) | ||||
|         localStorage.setItem("user", JSON.stringify(response.data)); | ||||
|         return { | ||||
|           authenticated: true, | ||||
|         } | ||||
|         }; | ||||
|       } | ||||
|     } catch (error) { | ||||
|       localStorage.removeItem(TOKEN_KEY) | ||||
|       localStorage.removeItem('user') | ||||
|       localStorage.removeItem(TOKEN_KEY); | ||||
|       localStorage.removeItem("user"); | ||||
|       return { | ||||
|         authenticated: false, | ||||
|         redirectTo: '/login', | ||||
|         error: new AuthError('Сессия истекла, пожалуйста, войдите снова'), | ||||
|       } | ||||
|         redirectTo: "/login", | ||||
|         error: new AuthError("Сессия истекла, пожалуйста, войдите снова"), | ||||
|       }; | ||||
|     } | ||||
|  | ||||
|     return { | ||||
|       authenticated: false, | ||||
|       redirectTo: '/login', | ||||
|     } | ||||
|       redirectTo: "/login", | ||||
|     }; | ||||
|   }, | ||||
|   getPermissions: async () => { | ||||
|     const token = localStorage.getItem(TOKEN_KEY) | ||||
|     if (!token) return null | ||||
|     const token = localStorage.getItem(TOKEN_KEY); | ||||
|     if (!token) return null; | ||||
|  | ||||
|     try { | ||||
|       const decoded = jwtDecode<JWTPayload>(token) | ||||
|       const decoded = jwtDecode<JWTPayload>(token); | ||||
|       if (decoded.is_admin) { | ||||
|         document.body.classList.add('is-admin') | ||||
|         document.body.classList.add("is-admin"); | ||||
|       } else { | ||||
|         document.body.classList.remove('is-admin') | ||||
|         document.body.classList.remove("is-admin"); | ||||
|       } | ||||
|       return decoded.is_admin ? ['admin'] : ['user'] | ||||
|       return decoded.is_admin ? ["admin"] : ["user"]; | ||||
|     } catch { | ||||
|       document.body.classList.remove('is-admin') | ||||
|       return ['user'] | ||||
|       document.body.classList.remove("is-admin"); | ||||
|       return ["user"]; | ||||
|     } | ||||
|   }, | ||||
|   getIdentity: async () => { | ||||
|     const token = localStorage.getItem(TOKEN_KEY) | ||||
|     const user = localStorage.getItem('user') | ||||
|     const token = localStorage.getItem(TOKEN_KEY); | ||||
|     const user = localStorage.getItem("user"); | ||||
|  | ||||
|     if (!token || !user) return null | ||||
|     if (!token || !user) return null; | ||||
|  | ||||
|     try { | ||||
|       const decoded = jwtDecode<JWTPayload>(token) | ||||
|       const userData = JSON.parse(user) | ||||
|       const decoded = jwtDecode<JWTPayload>(token); | ||||
|       const userData = JSON.parse(user); | ||||
|  | ||||
|       return { | ||||
|         ...userData, | ||||
|         is_admin: decoded.is_admin, // всегда используем значение из токена | ||||
|       } | ||||
|       }; | ||||
|     } catch { | ||||
|       return null | ||||
|       return null; | ||||
|     } | ||||
|   }, | ||||
|   onError: async (error) => { | ||||
|     const status = (error as AxiosError)?.response?.status | ||||
|     const status = (error as AxiosError)?.response?.status; | ||||
|     if (status === 401 || status === 403) { | ||||
|       localStorage.removeItem(TOKEN_KEY) | ||||
|       localStorage.removeItem('user') | ||||
|       localStorage.removeItem(TOKEN_KEY); | ||||
|       localStorage.removeItem("user"); | ||||
|       return { | ||||
|         logout: true, | ||||
|         redirectTo: '/login', | ||||
|         error: new AuthError('Сессия истекла, пожалуйста, войдите снова'), | ||||
|       } | ||||
|         redirectTo: "/login", | ||||
|         error: new AuthError("Сессия истекла, пожалуйста, войдите снова"), | ||||
|       }; | ||||
|     } | ||||
|     return {error} | ||||
|     return { error }; | ||||
|   }, | ||||
| } | ||||
| }; | ||||
|   | ||||
| @@ -1,133 +1,170 @@ | ||||
| import {Typography, Button, Box, Accordion, AccordionSummary, AccordionDetails, useTheme, TextField} from '@mui/material' | ||||
| import ExpandMoreIcon from '@mui/icons-material/ExpandMore' | ||||
| import {axiosInstance} from '../providers/data' | ||||
| import {BACKEND_URL} from '../lib/constants' | ||||
| import {useForm, Controller} from 'react-hook-form' | ||||
| import {MarkdownEditor} from './MarkdownEditor' | ||||
| import React, {useState, useCallback} from 'react' | ||||
| import {useDropzone} from 'react-dropzone' | ||||
| import {ALLOWED_IMAGE_TYPES, ALLOWED_VIDEO_TYPES} from '../components/media/MediaFormUtils' | ||||
| import { | ||||
|   Typography, | ||||
|   Button, | ||||
|   Box, | ||||
|   Accordion, | ||||
|   AccordionSummary, | ||||
|   AccordionDetails, | ||||
|   useTheme, | ||||
|   TextField, | ||||
| } from "@mui/material"; | ||||
| import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; | ||||
| import { axiosInstance } from "../providers/data"; | ||||
| import { useForm, Controller } from "react-hook-form"; | ||||
| import { MarkdownEditor } from "./MarkdownEditor"; | ||||
| import React, { useState, useCallback } from "react"; | ||||
| import { useDropzone } from "react-dropzone"; | ||||
| import { | ||||
|   ALLOWED_IMAGE_TYPES, | ||||
|   ALLOWED_VIDEO_TYPES, | ||||
| } from "../components/media/MediaFormUtils"; | ||||
|  | ||||
| const MemoizedSimpleMDE = React.memo(MarkdownEditor) | ||||
| const MemoizedSimpleMDE = React.memo(MarkdownEditor); | ||||
|  | ||||
| type MediaFile = { | ||||
|   file: File | ||||
|   preview: string | ||||
|   uploading: boolean | ||||
|   mediaId?: number | ||||
| } | ||||
|   file: File; | ||||
|   preview: string; | ||||
|   uploading: boolean; | ||||
|   mediaId?: number; | ||||
| }; | ||||
|  | ||||
| type Props = { | ||||
|   parentId: string | number | ||||
|   parentResource: string | ||||
|   childResource: string | ||||
|   title: string | ||||
| } | ||||
|   parentId: string | number; | ||||
|   parentResource: string; | ||||
|   childResource: string; | ||||
|   title: string; | ||||
| }; | ||||
|  | ||||
| export const CreateSightArticle = ({parentId, parentResource, childResource, title}: Props) => { | ||||
|   const theme = useTheme() | ||||
|   const [mediaFiles, setMediaFiles] = useState<MediaFile[]>([]) | ||||
| export const CreateSightArticle = ({ | ||||
|   parentId, | ||||
|   parentResource, | ||||
|   childResource, | ||||
|   title, | ||||
| }: Props) => { | ||||
|   const theme = useTheme(); | ||||
|   const [mediaFiles, setMediaFiles] = useState<MediaFile[]>([]); | ||||
|  | ||||
|   const { | ||||
|     register: registerItem, | ||||
|     control: controlItem, | ||||
|     handleSubmit: handleSubmitItem, | ||||
|     reset: resetItem, | ||||
|     formState: {errors: itemErrors}, | ||||
|     formState: { errors: itemErrors }, | ||||
|   } = useForm({ | ||||
|     defaultValues: { | ||||
|       heading: '', | ||||
|       body: '', | ||||
|       heading: "", | ||||
|       body: "", | ||||
|     }, | ||||
|   }) | ||||
|   }); | ||||
|  | ||||
|   const simpleMDEOptions = React.useMemo( | ||||
|     () => ({ | ||||
|       placeholder: 'Введите контент в формате Markdown...', | ||||
|       placeholder: "Введите контент в формате Markdown...", | ||||
|       spellChecker: false, | ||||
|     }), | ||||
|     [], | ||||
|   ) | ||||
|     [] | ||||
|   ); | ||||
|  | ||||
|   const onDrop = useCallback((acceptedFiles: File[]) => { | ||||
|     const newFiles = acceptedFiles.map((file) => ({ | ||||
|       file, | ||||
|       preview: URL.createObjectURL(file), | ||||
|       uploading: false, | ||||
|     })) | ||||
|     setMediaFiles((prev) => [...prev, ...newFiles]) | ||||
|   }, []) | ||||
|     })); | ||||
|     setMediaFiles((prev) => [...prev, ...newFiles]); | ||||
|   }, []); | ||||
|  | ||||
|   const {getRootProps, getInputProps, isDragActive} = useDropzone({ | ||||
|   const { getRootProps, getInputProps, isDragActive } = useDropzone({ | ||||
|     onDrop, | ||||
|     accept: { | ||||
|       'image/*': ALLOWED_IMAGE_TYPES, | ||||
|       'video/*': ALLOWED_VIDEO_TYPES, | ||||
|       "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 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(`${BACKEND_URL}/media`, formData) | ||||
|     return response.data.id | ||||
|   } | ||||
|     const response = await axiosInstance.post( | ||||
|       `${import.meta.env.VITE_KRBL_API}/media`, | ||||
|       formData | ||||
|     ); | ||||
|     return response.data.id; | ||||
|   }; | ||||
|  | ||||
|   const handleCreate = async (data: {heading: string; body: string}) => { | ||||
|   const handleCreate = async (data: { heading: string; body: string }) => { | ||||
|     try { | ||||
|       // Создаем статью | ||||
|       const response = await axiosInstance.post(`${BACKEND_URL}/${childResource}`, data) | ||||
|       const itemId = response.data.id | ||||
|       const response = await axiosInstance.post( | ||||
|         `${import.meta.env.VITE_KRBL_API}/${childResource}`, | ||||
|         data | ||||
|       ); | ||||
|       const itemId = response.data.id; | ||||
|  | ||||
|       // Получаем существующие статьи для определения порядкового номера | ||||
|       const existingItemsResponse = await axiosInstance.get(`${BACKEND_URL}/${parentResource}/${parentId}/${childResource}`) | ||||
|       const existingItems = existingItemsResponse.data || [] | ||||
|       const nextPageNum = existingItems.length + 1 | ||||
|       const existingItemsResponse = await axiosInstance.get( | ||||
|         `${ | ||||
|           import.meta.env.VITE_KRBL_API | ||||
|         }/${parentResource}/${parentId}/${childResource}` | ||||
|       ); | ||||
|       const existingItems = existingItemsResponse.data || []; | ||||
|       const nextPageNum = existingItems.length + 1; | ||||
|  | ||||
|       // Привязываем статью к достопримечательности | ||||
|       await axiosInstance.post(`${BACKEND_URL}/${parentResource}/${parentId}/${childResource}/`, { | ||||
|         [`${childResource}_id`]: itemId, | ||||
|         page_num: nextPageNum, | ||||
|       }) | ||||
|       await axiosInstance.post( | ||||
|         `${ | ||||
|           import.meta.env.VITE_KRBL_API | ||||
|         }/${parentResource}/${parentId}/${childResource}/`, | ||||
|         { | ||||
|           [`${childResource}_id`]: itemId, | ||||
|           page_num: nextPageNum, | ||||
|         } | ||||
|       ); | ||||
|  | ||||
|       // Загружаем все медиа файлы и получаем их ID | ||||
|       const mediaIds = await Promise.all( | ||||
|         mediaFiles.map(async (mediaFile) => { | ||||
|           return await uploadMedia(mediaFile) | ||||
|         }), | ||||
|       ) | ||||
|           return await uploadMedia(mediaFile); | ||||
|         }) | ||||
|       ); | ||||
|  | ||||
|       // Привязываем все медиа к статье | ||||
|       await Promise.all( | ||||
|         mediaIds.map((mediaId, index) => | ||||
|           axiosInstance.post(`${BACKEND_URL}/article/${itemId}/media/`, { | ||||
|             media_id: mediaId, | ||||
|             media_order: index + 1, | ||||
|           }), | ||||
|         ), | ||||
|       ) | ||||
|           axiosInstance.post( | ||||
|             `${import.meta.env.VITE_KRBL_API}/article/${itemId}/media/`, | ||||
|             { | ||||
|               media_id: mediaId, | ||||
|               media_order: index + 1, | ||||
|             } | ||||
|           ) | ||||
|         ) | ||||
|       ); | ||||
|  | ||||
|       resetItem() | ||||
|       setMediaFiles([]) | ||||
|       window.location.reload() | ||||
|       resetItem(); | ||||
|       setMediaFiles([]); | ||||
|       window.location.reload(); | ||||
|     } catch (err: any) { | ||||
|       console.error('Error creating item:', err) | ||||
|       console.error("Error creating item:", err); | ||||
|     } | ||||
|   } | ||||
|   }; | ||||
|  | ||||
|   const removeMedia = (index: number) => { | ||||
|     setMediaFiles((prev) => { | ||||
|       const newFiles = [...prev] | ||||
|       URL.revokeObjectURL(newFiles[index].preview) | ||||
|       newFiles.splice(index, 1) | ||||
|       return newFiles | ||||
|     }) | ||||
|   } | ||||
|       const newFiles = [...prev]; | ||||
|       URL.revokeObjectURL(newFiles[index].preview); | ||||
|       newFiles.splice(index, 1); | ||||
|       return newFiles; | ||||
|     }); | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <Accordion> | ||||
| @@ -143,76 +180,95 @@ export const CreateSightArticle = ({parentId, parentResource, childResource, tit | ||||
|           Создать {title} | ||||
|         </Typography> | ||||
|       </AccordionSummary> | ||||
|       <AccordionDetails sx={{background: theme.palette.background.paper}}> | ||||
|       <AccordionDetails sx={{ background: theme.palette.background.paper }}> | ||||
|         <Box component="form" onSubmit={handleSubmitItem(handleCreate)}> | ||||
|           <TextField | ||||
|             {...registerItem('heading', { | ||||
|               required: 'Это поле является обязательным', | ||||
|             {...registerItem("heading", { | ||||
|               required: "Это поле является обязательным", | ||||
|             })} | ||||
|             error={!!(itemErrors as any)?.heading} | ||||
|             helperText={(itemErrors as any)?.heading?.message} | ||||
|             margin="normal" | ||||
|             fullWidth | ||||
|             InputLabelProps={{shrink: true}} | ||||
|             InputLabelProps={{ shrink: true }} | ||||
|             type="text" | ||||
|             label="Заголовок *" | ||||
|           /> | ||||
|  | ||||
|           <Controller control={controlItem} name="body" rules={{required: 'Это поле является обязательным'}} defaultValue="" render={({field: {onChange, value}}) => <MemoizedSimpleMDE value={value} onChange={onChange} options={simpleMDEOptions} className="my-markdown-editor" />} /> | ||||
|           <Controller | ||||
|             control={controlItem} | ||||
|             name="body" | ||||
|             rules={{ required: "Это поле является обязательным" }} | ||||
|             defaultValue="" | ||||
|             render={({ field: { onChange, value } }) => ( | ||||
|               <MemoizedSimpleMDE | ||||
|                 value={value} | ||||
|                 onChange={onChange} | ||||
|                 options={simpleMDEOptions} | ||||
|                 className="my-markdown-editor" | ||||
|               /> | ||||
|             )} | ||||
|           /> | ||||
|  | ||||
|           {/* Dropzone для медиа файлов */} | ||||
|           <Box sx={{mt: 2, mb: 2}}> | ||||
|           <Box sx={{ mt: 2, mb: 2 }}> | ||||
|             <Box | ||||
|               {...getRootProps()} | ||||
|               sx={{ | ||||
|                 border: '2px dashed', | ||||
|                 borderColor: isDragActive ? 'primary.main' : 'grey.300', | ||||
|                 border: "2px dashed", | ||||
|                 borderColor: isDragActive ? "primary.main" : "grey.300", | ||||
|                 borderRadius: 1, | ||||
|                 p: 2, | ||||
|                 textAlign: 'center', | ||||
|                 cursor: 'pointer', | ||||
|                 '&:hover': { | ||||
|                   borderColor: 'primary.main', | ||||
|                 textAlign: "center", | ||||
|                 cursor: "pointer", | ||||
|                 "&:hover": { | ||||
|                   borderColor: "primary.main", | ||||
|                 }, | ||||
|               }} | ||||
|             > | ||||
|               <input {...getInputProps()} /> | ||||
|               <Typography>{isDragActive ? 'Перетащите файлы сюда...' : 'Перетащите файлы сюда или кликните для выбора'}</Typography> | ||||
|               <Typography> | ||||
|                 {isDragActive | ||||
|                   ? "Перетащите файлы сюда..." | ||||
|                   : "Перетащите файлы сюда или кликните для выбора"} | ||||
|               </Typography> | ||||
|             </Box> | ||||
|  | ||||
|             {/* Превью загруженных файлов */} | ||||
|             <Box sx={{mt: 2, display: 'flex', flexWrap: 'wrap', gap: 1}}> | ||||
|             <Box sx={{ mt: 2, display: "flex", flexWrap: "wrap", gap: 1 }}> | ||||
|               {mediaFiles.map((mediaFile, index) => ( | ||||
|                 <Box | ||||
|                   key={mediaFile.preview} | ||||
|                   sx={{ | ||||
|                     position: 'relative', | ||||
|                     position: "relative", | ||||
|                     width: 100, | ||||
|                     height: 100, | ||||
|                   }} | ||||
|                 > | ||||
|                   {mediaFile.file.type.startsWith('image/') ? ( | ||||
|                   {mediaFile.file.type.startsWith("image/") ? ( | ||||
|                     <img | ||||
|                       src={mediaFile.preview} | ||||
|                       alt={mediaFile.file.name} | ||||
|                       style={{ | ||||
|                         width: '100%', | ||||
|                         height: '100%', | ||||
|                         objectFit: 'cover', | ||||
|                         width: "100%", | ||||
|                         height: "100%", | ||||
|                         objectFit: "cover", | ||||
|                       }} | ||||
|                     /> | ||||
|                   ) : ( | ||||
|                     <Box | ||||
|                       sx={{ | ||||
|                         width: '100%', | ||||
|                         height: '100%', | ||||
|                         display: 'flex', | ||||
|                         alignItems: 'center', | ||||
|                         justifyContent: 'center', | ||||
|                         bgcolor: 'grey.200', | ||||
|                         width: "100%", | ||||
|                         height: "100%", | ||||
|                         display: "flex", | ||||
|                         alignItems: "center", | ||||
|                         justifyContent: "center", | ||||
|                         bgcolor: "grey.200", | ||||
|                       }} | ||||
|                     > | ||||
|                       <Typography variant="caption">{mediaFile.file.name}</Typography> | ||||
|                       <Typography variant="caption"> | ||||
|                         {mediaFile.file.name} | ||||
|                       </Typography> | ||||
|                     </Box> | ||||
|                   )} | ||||
|                   <Button | ||||
| @@ -220,10 +276,10 @@ export const CreateSightArticle = ({parentId, parentResource, childResource, tit | ||||
|                     color="error" | ||||
|                     onClick={() => removeMedia(index)} | ||||
|                     sx={{ | ||||
|                       position: 'absolute', | ||||
|                       position: "absolute", | ||||
|                       top: 0, | ||||
|                       right: 0, | ||||
|                       minWidth: 'auto', | ||||
|                       minWidth: "auto", | ||||
|                       width: 20, | ||||
|                       height: 20, | ||||
|                       p: 0, | ||||
| @@ -236,16 +292,16 @@ export const CreateSightArticle = ({parentId, parentResource, childResource, tit | ||||
|             </Box> | ||||
|           </Box> | ||||
|  | ||||
|           <Box sx={{mt: 2, display: 'flex', gap: 2}}> | ||||
|           <Box sx={{ mt: 2, display: "flex", gap: 2 }}> | ||||
|             <Button variant="contained" color="primary" type="submit"> | ||||
|               Создать | ||||
|             </Button> | ||||
|             <Button | ||||
|               variant="outlined" | ||||
|               onClick={() => { | ||||
|                 resetItem() | ||||
|                 mediaFiles.forEach((file) => URL.revokeObjectURL(file.preview)) | ||||
|                 setMediaFiles([]) | ||||
|                 resetItem(); | ||||
|                 mediaFiles.forEach((file) => URL.revokeObjectURL(file.preview)); | ||||
|                 setMediaFiles([]); | ||||
|               }} | ||||
|             > | ||||
|               Очистить | ||||
| @@ -254,5 +310,5 @@ export const CreateSightArticle = ({parentId, parentResource, childResource, tit | ||||
|         </Box> | ||||
|       </AccordionDetails> | ||||
|     </Accordion> | ||||
|   ) | ||||
| } | ||||
|   ); | ||||
| }; | ||||
|   | ||||
| @@ -1,127 +1,169 @@ | ||||
| import {useState, useEffect} from 'react' | ||||
| import {Stack, Typography, Button, FormControl, Grid, Box, Accordion, AccordionSummary, AccordionDetails, useTheme, TextField, Autocomplete} from '@mui/material' | ||||
| import ExpandMoreIcon from '@mui/icons-material/ExpandMore' | ||||
| import {axiosInstance} from '../providers/data' | ||||
| import {BACKEND_URL} from '../lib/constants' | ||||
| import {Link} from 'react-router' | ||||
| import {TOKEN_KEY} from '../authProvider' | ||||
| import { useState, useEffect } from "react"; | ||||
| import { | ||||
|   Stack, | ||||
|   Typography, | ||||
|   Button, | ||||
|   FormControl, | ||||
|   Grid, | ||||
|   Box, | ||||
|   Accordion, | ||||
|   AccordionSummary, | ||||
|   AccordionDetails, | ||||
|   useTheme, | ||||
|   TextField, | ||||
|   Autocomplete, | ||||
| } from "@mui/material"; | ||||
| import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; | ||||
| import { axiosInstance } from "../providers/data"; | ||||
|  | ||||
| import { Link } from "react-router"; | ||||
| import { TOKEN_KEY } from "../authProvider"; | ||||
|  | ||||
| type Field<T> = { | ||||
|   label: string | ||||
|   data: keyof T | ||||
|   render?: (value: any) => React.ReactNode | ||||
| } | ||||
|   label: string; | ||||
|   data: keyof T; | ||||
|   render?: (value: any) => React.ReactNode; | ||||
| }; | ||||
|  | ||||
| type ExtraFieldConfig = { | ||||
|   type: 'number' | ||||
|   label: string | ||||
|   minValue: number | ||||
|   maxValue: (linkedItems: any[]) => number | ||||
| } | ||||
|   type: "number"; | ||||
|   label: string; | ||||
|   minValue: number; | ||||
|   maxValue: (linkedItems: any[]) => number; | ||||
| }; | ||||
|  | ||||
| type LinkedItemsProps<T> = { | ||||
|   parentId: string | number | ||||
|   parentResource: string | ||||
|   childResource: string | ||||
|   fields: Field<T>[] | ||||
|   title: string | ||||
|   type: 'show' | 'edit' | ||||
|   extraField?: ExtraFieldConfig | ||||
| } | ||||
|   parentId: string | number; | ||||
|   parentResource: string; | ||||
|   childResource: string; | ||||
|   fields: Field<T>[]; | ||||
|   title: string; | ||||
|   type: "show" | "edit"; | ||||
|   extraField?: ExtraFieldConfig; | ||||
| }; | ||||
|  | ||||
| export const LinkedItems = <T extends {id: number; [key: string]: any}>({parentId, parentResource, childResource, fields, title, type}: LinkedItemsProps<T>) => { | ||||
|   const [items, setItems] = useState<T[]>([]) | ||||
|   const [linkedItems, setLinkedItems] = useState<T[]>([]) | ||||
|   const [selectedItemId, setSelectedItemId] = useState<number | null>(null) | ||||
|   const [pageNum, setPageNum] = useState<number>(1) | ||||
|   const [isLoading, setIsLoading] = useState<boolean>(true) | ||||
|   const [mediaOrder, setMediaOrder] = useState<number>(1) | ||||
|   const theme = useTheme() | ||||
| export const LinkedItems = <T extends { id: number; [key: string]: any }>({ | ||||
|   parentId, | ||||
|   parentResource, | ||||
|   childResource, | ||||
|   fields, | ||||
|   title, | ||||
|   type, | ||||
| }: LinkedItemsProps<T>) => { | ||||
|   const [items, setItems] = useState<T[]>([]); | ||||
|   const [linkedItems, setLinkedItems] = useState<T[]>([]); | ||||
|   const [selectedItemId, setSelectedItemId] = useState<number | null>(null); | ||||
|   const [pageNum, setPageNum] = useState<number>(1); | ||||
|   const [isLoading, setIsLoading] = useState<boolean>(true); | ||||
|   const [mediaOrder, setMediaOrder] = useState<number>(1); | ||||
|   const theme = useTheme(); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     if (parentId) { | ||||
|       axiosInstance | ||||
|         .get(`${BACKEND_URL}/${parentResource}/${parentId}/${childResource}`) | ||||
|         .get( | ||||
|           `${ | ||||
|             import.meta.env.VITE_KRBL_API | ||||
|           }/${parentResource}/${parentId}/${childResource}` | ||||
|         ) | ||||
|         .then((response) => { | ||||
|           setLinkedItems(response?.data || []) | ||||
|           setLinkedItems(response?.data || []); | ||||
|         }) | ||||
|         .catch(() => { | ||||
|           setLinkedItems([]) | ||||
|         }) | ||||
|           setLinkedItems([]); | ||||
|         }); | ||||
|     } | ||||
|   }, [parentId, parentResource, childResource]) | ||||
|   }, [parentId, parentResource, childResource]); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     if (type === 'edit') { | ||||
|     if (type === "edit") { | ||||
|       axiosInstance | ||||
|         .get(`${BACKEND_URL}/${childResource}/`) | ||||
|         .get(`${import.meta.env.VITE_KRBL_API}/${childResource}/`) | ||||
|         .then((response) => { | ||||
|           setItems(response?.data || []) | ||||
|           setIsLoading(false) | ||||
|           setItems(response?.data || []); | ||||
|           setIsLoading(false); | ||||
|         }) | ||||
|         .catch(() => { | ||||
|           setItems([]) | ||||
|           setIsLoading(false) | ||||
|         }) | ||||
|           setItems([]); | ||||
|           setIsLoading(false); | ||||
|         }); | ||||
|     } else { | ||||
|       setIsLoading(false) | ||||
|       setIsLoading(false); | ||||
|     } | ||||
|   }, [childResource, type]) | ||||
|   }, [childResource, type]); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     if (childResource === 'article' && parentResource === 'sight') { | ||||
|       setPageNum(linkedItems.length + 1) | ||||
|     if (childResource === "article" && parentResource === "sight") { | ||||
|       setPageNum(linkedItems.length + 1); | ||||
|     } | ||||
|   }, [linkedItems, childResource, parentResource]) | ||||
|   }, [linkedItems, childResource, parentResource]); | ||||
|  | ||||
|   const availableItems = items.filter((item) => !linkedItems.some((linked) => linked.id === item.id)) | ||||
|   const availableItems = items.filter( | ||||
|     (item) => !linkedItems.some((linked) => linked.id === item.id) | ||||
|   ); | ||||
|  | ||||
|   const linkItem = () => { | ||||
|     if (selectedItemId !== null) { | ||||
|       const requestData = | ||||
|         childResource === 'article' | ||||
|         childResource === "article" | ||||
|           ? { | ||||
|               [`${childResource}_id`]: selectedItemId, | ||||
|               page_num: pageNum, | ||||
|             } | ||||
|           : childResource === 'media' | ||||
|           : childResource === "media" | ||||
|           ? { | ||||
|               [`${childResource}_id`]: selectedItemId, | ||||
|               media_order: mediaOrder, | ||||
|             } | ||||
|           : { | ||||
|               [`${childResource}_id`]: selectedItemId, | ||||
|             } | ||||
|             }; | ||||
|  | ||||
|       axiosInstance | ||||
|         .post(`${BACKEND_URL}/${parentResource}/${parentId}/${childResource}`, requestData) | ||||
|         .post( | ||||
|           `${ | ||||
|             import.meta.env.VITE_KRBL_API | ||||
|           }/${parentResource}/${parentId}/${childResource}`, | ||||
|           requestData | ||||
|         ) | ||||
|         .then(() => { | ||||
|           axiosInstance.get(`${BACKEND_URL}/${parentResource}/${parentId}/${childResource}`).then((response) => { | ||||
|             setLinkedItems(response?.data || []) | ||||
|             setSelectedItemId(null) | ||||
|             if (childResource === 'article') { | ||||
|               setPageNum(pageNum + 1) | ||||
|             } | ||||
|           }) | ||||
|           axiosInstance | ||||
|             .get( | ||||
|               `${ | ||||
|                 import.meta.env.VITE_KRBL_API | ||||
|               }/${parentResource}/${parentId}/${childResource}` | ||||
|             ) | ||||
|             .then((response) => { | ||||
|               setLinkedItems(response?.data || []); | ||||
|               setSelectedItemId(null); | ||||
|               if (childResource === "article") { | ||||
|                 setPageNum(pageNum + 1); | ||||
|               } | ||||
|             }); | ||||
|         }) | ||||
|         .catch((error) => { | ||||
|           console.error('Error linking item:', error) | ||||
|         }) | ||||
|           console.error("Error linking item:", error); | ||||
|         }); | ||||
|     } | ||||
|   } | ||||
|   }; | ||||
|  | ||||
|   const deleteItem = (itemId: number) => { | ||||
|     axiosInstance | ||||
|       .delete(`${BACKEND_URL}/${parentResource}/${parentId}/${childResource}`, { | ||||
|         data: {[`${childResource}_id`]: itemId}, | ||||
|       }) | ||||
|       .delete( | ||||
|         `${ | ||||
|           import.meta.env.VITE_KRBL_API | ||||
|         }/${parentResource}/${parentId}/${childResource}`, | ||||
|         { | ||||
|           data: { [`${childResource}_id`]: itemId }, | ||||
|         } | ||||
|       ) | ||||
|       .then(() => { | ||||
|         setLinkedItems((prev) => prev.filter((item) => item.id !== itemId)) | ||||
|         setLinkedItems((prev) => prev.filter((item) => item.id !== itemId)); | ||||
|       }) | ||||
|       .catch((error) => { | ||||
|         console.error('Error unlinking item:', error) | ||||
|       }) | ||||
|   } | ||||
|         console.error("Error unlinking item:", error); | ||||
|       }); | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <Accordion> | ||||
| @@ -137,7 +179,7 @@ export const LinkedItems = <T extends {id: number; [key: string]: any}>({parentI | ||||
|         </Typography> | ||||
|       </AccordionSummary> | ||||
|  | ||||
|       <AccordionDetails sx={{background: theme.palette.background.paper}}> | ||||
|       <AccordionDetails sx={{ background: theme.palette.background.paper }}> | ||||
|         <Stack gap={2}> | ||||
|           <Grid container gap={1.25}> | ||||
|             {isLoading ? ( | ||||
| @@ -149,48 +191,55 @@ export const LinkedItems = <T extends {id: number; [key: string]: any}>({parentI | ||||
|                   to={`/${childResource}/show/${item.id}`} | ||||
|                   key={index} | ||||
|                   sx={{ | ||||
|                     marginTop: '8px', | ||||
|                     padding: '14px', | ||||
|                     marginTop: "8px", | ||||
|                     padding: "14px", | ||||
|                     borderRadius: 2, | ||||
|                     border: `2px solid ${theme.palette.divider}`, | ||||
|                     width: childResource === 'article' ? '100%' : 'auto', | ||||
|                     textDecoration: 'none', | ||||
|                     color: 'inherit', | ||||
|                     display: 'block', | ||||
|                     '&:hover': { | ||||
|                     width: childResource === "article" ? "100%" : "auto", | ||||
|                     textDecoration: "none", | ||||
|                     color: "inherit", | ||||
|                     display: "block", | ||||
|                     "&:hover": { | ||||
|                       backgroundColor: theme.palette.action.hover, | ||||
|                     }, | ||||
|                   }} | ||||
|                 > | ||||
|                   <Stack gap={0.25}> | ||||
|                     {childResource === 'media' && item.id && ( | ||||
|                     {childResource === "media" && item.id && ( | ||||
|                       <img | ||||
|                         src={`https://wn.krbl.ru/media/${item.id}/download?token=${localStorage.getItem(TOKEN_KEY)}`} | ||||
|                         src={`${import.meta.env.VITE_KRBL_MEDIA}/${ | ||||
|                           item.id | ||||
|                         }/download?token=${localStorage.getItem(TOKEN_KEY)}`} | ||||
|                         alt={String(item.media_name)} | ||||
|                         style={{ | ||||
|                           width: '100%', | ||||
|                           height: '120px', | ||||
|                           objectFit: 'contain', | ||||
|                           marginBottom: '8px', | ||||
|                           width: "100%", | ||||
|                           height: "120px", | ||||
|                           objectFit: "contain", | ||||
|                           marginBottom: "8px", | ||||
|                           borderRadius: 4, | ||||
|                         }} | ||||
|                       /> | ||||
|                     )} | ||||
|                     {fields.map(({label, data, render}) => ( | ||||
|                       <Typography variant="body2" color="textSecondary" key={String(data)}> | ||||
|                         <strong>{label}:</strong> {render ? render(item[data]) : item[data]} | ||||
|                     {fields.map(({ label, data, render }) => ( | ||||
|                       <Typography | ||||
|                         variant="body2" | ||||
|                         color="textSecondary" | ||||
|                         key={String(data)} | ||||
|                       > | ||||
|                         <strong>{label}:</strong>{" "} | ||||
|                         {render ? render(item[data]) : item[data]} | ||||
|                       </Typography> | ||||
|                     ))} | ||||
|                     {type === 'edit' && ( | ||||
|                     {type === "edit" && ( | ||||
|                       <Button | ||||
|                         variant="outlined" | ||||
|                         color="error" | ||||
|                         size="small" | ||||
|                         onClick={(e) => { | ||||
|                           e.preventDefault() | ||||
|                           deleteItem(item.id) | ||||
|                           e.preventDefault(); | ||||
|                           deleteItem(item.id); | ||||
|                         }} | ||||
|                         sx={{mt: 1.5}} | ||||
|                         sx={{ mt: 1.5 }} | ||||
|                       > | ||||
|                         Отвязать | ||||
|                       </Button> | ||||
| @@ -203,31 +252,48 @@ export const LinkedItems = <T extends {id: number; [key: string]: any}>({parentI | ||||
|             )} | ||||
|           </Grid> | ||||
|  | ||||
|           {type === 'edit' && ( | ||||
|           {type === "edit" && ( | ||||
|             <Stack gap={2}> | ||||
|               <Typography variant="subtitle1">Добавить {title}</Typography> | ||||
|               <Autocomplete | ||||
|                 fullWidth | ||||
|                 value={availableItems.find((item) => item.id === selectedItemId) || null} | ||||
|                 onChange={(_, newValue) => setSelectedItemId(newValue?.id || null)} | ||||
|                 value={ | ||||
|                   availableItems.find((item) => item.id === selectedItemId) || | ||||
|                   null | ||||
|                 } | ||||
|                 onChange={(_, newValue) => | ||||
|                   setSelectedItemId(newValue?.id || null) | ||||
|                 } | ||||
|                 options={availableItems} | ||||
|                 getOptionLabel={(item) => String(item[fields[0].data])} | ||||
|                 renderInput={(params) => <TextField {...params} label={`Выберите ${title}`} fullWidth />} | ||||
|                 isOptionEqualToValue={(option, value) => option.id === value?.id} | ||||
|                 filterOptions={(options, {inputValue}) => { | ||||
|                 renderInput={(params) => ( | ||||
|                   <TextField | ||||
|                     {...params} | ||||
|                     label={`Выберите ${title}`} | ||||
|                     fullWidth | ||||
|                   /> | ||||
|                 )} | ||||
|                 isOptionEqualToValue={(option, value) => | ||||
|                   option.id === value?.id | ||||
|                 } | ||||
|                 filterOptions={(options, { inputValue }) => { | ||||
|                   // return options.filter((option) => String(option[fields[0].data]).toLowerCase().includes(inputValue.toLowerCase())) | ||||
|                   const searchWords = inputValue | ||||
|                     .toLowerCase() | ||||
|                     .split(' ') | ||||
|                     .filter((word) => word.length > 0) | ||||
|                     .split(" ") | ||||
|                     .filter((word) => word.length > 0); | ||||
|                   return options.filter((option) => { | ||||
|                     const optionWords = String(option[fields[0].data]).toLowerCase().split(' ') | ||||
|                     return searchWords.every((searchWord) => optionWords.some((word) => word.startsWith(searchWord))) | ||||
|                   }) | ||||
|                     const optionWords = String(option[fields[0].data]) | ||||
|                       .toLowerCase() | ||||
|                       .split(" "); | ||||
|                     return searchWords.every((searchWord) => | ||||
|                       optionWords.some((word) => word.startsWith(searchWord)) | ||||
|                     ); | ||||
|                   }); | ||||
|                 }} | ||||
|               /> | ||||
|  | ||||
|               {childResource === 'article' && ( | ||||
|               {childResource === "article" && ( | ||||
|                 <FormControl fullWidth> | ||||
|                   <TextField | ||||
|                     type="number" | ||||
| @@ -235,35 +301,39 @@ export const LinkedItems = <T extends {id: number; [key: string]: any}>({parentI | ||||
|                     name="page_num" | ||||
|                     value={pageNum} | ||||
|                     onChange={(e) => { | ||||
|                       const newValue = Number(e.target.value) | ||||
|                       const minValue = linkedItems.length + 1 // page number on articles lenght | ||||
|                       setPageNum(newValue < minValue ? minValue : newValue) | ||||
|                       const newValue = Number(e.target.value); | ||||
|                       const minValue = linkedItems.length + 1; // page number on articles lenght | ||||
|                       setPageNum(newValue < minValue ? minValue : newValue); | ||||
|                     }} | ||||
|                     fullWidth | ||||
|                     InputLabelProps={{shrink: true}} | ||||
|                     InputLabelProps={{ shrink: true }} | ||||
|                   /> | ||||
|                 </FormControl> | ||||
|               )} | ||||
|  | ||||
|               {childResource === 'media' && type === 'edit' && ( | ||||
|               {childResource === "media" && type === "edit" && ( | ||||
|                 <FormControl fullWidth> | ||||
|                   <TextField | ||||
|                     type="number" | ||||
|                     label="Порядок отображения медиа" | ||||
|                     value={mediaOrder} | ||||
|                     onChange={(e) => { | ||||
|                       const newValue = Number(e.target.value) | ||||
|                       const maxValue = linkedItems.length + 1 | ||||
|                       const value = Math.max(1, Math.min(newValue, maxValue)) | ||||
|                       setMediaOrder(value) | ||||
|                       const newValue = Number(e.target.value); | ||||
|                       const maxValue = linkedItems.length + 1; | ||||
|                       const value = Math.max(1, Math.min(newValue, maxValue)); | ||||
|                       setMediaOrder(value); | ||||
|                     }} | ||||
|                     fullWidth | ||||
|                     InputLabelProps={{shrink: true}} | ||||
|                     InputLabelProps={{ shrink: true }} | ||||
|                   /> | ||||
|                 </FormControl> | ||||
|               )} | ||||
|  | ||||
|               <Button variant="contained" onClick={linkItem} disabled={!selectedItemId}> | ||||
|               <Button | ||||
|                 variant="contained" | ||||
|                 onClick={linkItem} | ||||
|                 disabled={!selectedItemId} | ||||
|               > | ||||
|                 Добавить | ||||
|               </Button> | ||||
|             </Stack> | ||||
| @@ -271,5 +341,5 @@ export const LinkedItems = <T extends {id: number; [key: string]: any}>({parentI | ||||
|         </Stack> | ||||
|       </AccordionDetails> | ||||
|     </Accordion> | ||||
|   ) | ||||
| } | ||||
|   ); | ||||
| }; | ||||
|   | ||||
| @@ -1,11 +1,9 @@ | ||||
| export const BACKEND_URL = 'https://wn.krbl.ru' | ||||
|  | ||||
| export const MEDIA_TYPES = [ | ||||
|   {label: 'Фото', value: 1}, | ||||
|   {label: 'Видео', value: 2}, | ||||
| ] | ||||
|   { label: "Фото", value: 1 }, | ||||
|   { label: "Видео", value: 2 }, | ||||
| ]; | ||||
|  | ||||
| export const VEHICLE_TYPES = [ | ||||
|   {label: 'Трамвай', value: 1}, | ||||
|   {label: 'Троллейбус', value: 2}, | ||||
| ] | ||||
|   { label: "Трамвай", value: 1 }, | ||||
|   { label: "Троллейбус", value: 2 }, | ||||
| ]; | ||||
|   | ||||
| @@ -1,19 +1,19 @@ | ||||
| import {Box, TextField, Typography, Paper} from '@mui/material' | ||||
| import {Edit} from '@refinedev/mui' | ||||
| import {useForm} from '@refinedev/react-hook-form' | ||||
| import {Controller} from 'react-hook-form' | ||||
| import {useParams} from 'react-router' | ||||
| import React, {useState, useEffect} from 'react' | ||||
| import ReactMarkdown from 'react-markdown' | ||||
| import {useList} from '@refinedev/core' | ||||
| import { Box, TextField, Typography, Paper } from "@mui/material"; | ||||
| import { Edit } from "@refinedev/mui"; | ||||
| import { useForm } from "@refinedev/react-hook-form"; | ||||
| import { Controller } from "react-hook-form"; | ||||
| import { useParams } from "react-router"; | ||||
| import React, { useState, useEffect } from "react"; | ||||
| import ReactMarkdown from "react-markdown"; | ||||
| import { useList } from "@refinedev/core"; | ||||
|  | ||||
| import {MarkdownEditor} from '../../components/MarkdownEditor' | ||||
| import {LinkedItems} from '../../components/LinkedItems' | ||||
| import {MediaItem, mediaFields} from './types' | ||||
| import {TOKEN_KEY} from '../../authProvider' | ||||
| import 'easymde/dist/easymde.min.css' | ||||
| import { MarkdownEditor } from "../../components/MarkdownEditor"; | ||||
| import { LinkedItems } from "../../components/LinkedItems"; | ||||
| import { MediaItem, mediaFields } from "./types"; | ||||
| import { TOKEN_KEY } from "../../authProvider"; | ||||
| import "easymde/dist/easymde.min.css"; | ||||
|  | ||||
| const MemoizedSimpleMDE = React.memo(MarkdownEditor) | ||||
| const MemoizedSimpleMDE = React.memo(MarkdownEditor); | ||||
|  | ||||
| export const ArticleEdit = () => { | ||||
|   const { | ||||
| @@ -21,55 +21,59 @@ export const ArticleEdit = () => { | ||||
|     register, | ||||
|     control, | ||||
|     watch, | ||||
|     formState: {errors}, | ||||
|   } = useForm() | ||||
|     formState: { errors }, | ||||
|   } = useForm(); | ||||
|  | ||||
|   const {id: articleId} = useParams<{id: string}>() | ||||
|   const [preview, setPreview] = useState('') | ||||
|   const [headingPreview, setHeadingPreview] = useState('') | ||||
|   const { id: articleId } = useParams<{ id: string }>(); | ||||
|   const [preview, setPreview] = useState(""); | ||||
|   const [headingPreview, setHeadingPreview] = useState(""); | ||||
|  | ||||
|   // Получаем привязанные медиа | ||||
|   const {data: mediaData} = useList<MediaItem>({ | ||||
|   const { data: mediaData } = useList<MediaItem>({ | ||||
|     resource: `article/${articleId}/media`, | ||||
|     queryOptions: { | ||||
|       enabled: !!articleId, | ||||
|     }, | ||||
|   }) | ||||
|   }); | ||||
|  | ||||
|   // Следим за изменениями в полях body и heading | ||||
|   const bodyContent = watch('body') | ||||
|   const headingContent = watch('heading') | ||||
|   const bodyContent = watch("body"); | ||||
|   const headingContent = watch("heading"); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     setPreview(bodyContent || '') | ||||
|   }, [bodyContent]) | ||||
|     setPreview(bodyContent || ""); | ||||
|   }, [bodyContent]); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     setHeadingPreview(headingContent || '') | ||||
|   }, [headingContent]) | ||||
|     setHeadingPreview(headingContent || ""); | ||||
|   }, [headingContent]); | ||||
|  | ||||
|   const simpleMDEOptions = React.useMemo( | ||||
|     () => ({ | ||||
|       placeholder: 'Введите контент в формате Markdown...', | ||||
|       placeholder: "Введите контент в формате Markdown...", | ||||
|       spellChecker: false, | ||||
|     }), | ||||
|     [], | ||||
|   ) | ||||
|     [] | ||||
|   ); | ||||
|  | ||||
|   return ( | ||||
|     <Edit saveButtonProps={saveButtonProps}> | ||||
|       <Box sx={{display: 'flex', gap: 2}}> | ||||
|       <Box sx={{ display: "flex", gap: 2 }}> | ||||
|         {/* Форма редактирования */} | ||||
|         <Box component="form" sx={{flex: 1, display: 'flex', flexDirection: 'column'}} autoComplete="off"> | ||||
|         <Box | ||||
|           component="form" | ||||
|           sx={{ flex: 1, display: "flex", flexDirection: "column" }} | ||||
|           autoComplete="off" | ||||
|         > | ||||
|           <TextField | ||||
|             {...register('heading', { | ||||
|               required: 'Это поле является обязательным', | ||||
|             {...register("heading", { | ||||
|               required: "Это поле является обязательным", | ||||
|             })} | ||||
|             error={!!(errors as any)?.heading} | ||||
|             helperText={(errors as any)?.heading?.message} | ||||
|             margin="normal" | ||||
|             fullWidth | ||||
|             InputLabelProps={{shrink: true}} | ||||
|             InputLabelProps={{ shrink: true }} | ||||
|             type="text" | ||||
|             label="Заголовок *" | ||||
|             name="heading" | ||||
| @@ -78,9 +82,9 @@ export const ArticleEdit = () => { | ||||
|           <Controller | ||||
|             control={control} | ||||
|             name="body" | ||||
|             rules={{required: 'Это поле является обязательным'}} | ||||
|             rules={{ required: "Это поле является обязательным" }} | ||||
|             defaultValue="" | ||||
|             render={({field: {onChange, value}}) => ( | ||||
|             render={({ field: { onChange, value } }) => ( | ||||
|               <MemoizedSimpleMDE | ||||
|                 value={value} // markdown | ||||
|                 onChange={onChange} | ||||
| @@ -90,7 +94,16 @@ export const ArticleEdit = () => { | ||||
|             )} | ||||
|           /> | ||||
|  | ||||
|           {articleId && <LinkedItems<MediaItem> type="edit" parentId={articleId} parentResource="article" childResource="media" fields={mediaFields} title="медиа" />} | ||||
|           {articleId && ( | ||||
|             <LinkedItems<MediaItem> | ||||
|               type="edit" | ||||
|               parentId={articleId} | ||||
|               parentResource="article" | ||||
|               childResource="media" | ||||
|               fields={mediaFields} | ||||
|               title="медиа" | ||||
|             /> | ||||
|           )} | ||||
|         </Box> | ||||
|  | ||||
|         {/* Блок предпросмотра */} | ||||
| @@ -98,14 +111,15 @@ export const ArticleEdit = () => { | ||||
|           sx={{ | ||||
|             flex: 1, | ||||
|             p: 2, | ||||
|             maxHeight: 'calc(100vh - 200px)', | ||||
|             overflowY: 'auto', | ||||
|             position: 'sticky', | ||||
|             maxHeight: "calc(100vh - 200px)", | ||||
|             overflowY: "auto", | ||||
|             position: "sticky", | ||||
|             top: 16, | ||||
|             borderRadius: 2, | ||||
|             border: '1px solid', | ||||
|             borderColor: 'primary.main', | ||||
|             bgcolor: (theme) => (theme.palette.mode === 'dark' ? 'background.paper' : '#fff'), | ||||
|             border: "1px solid", | ||||
|             borderColor: "primary.main", | ||||
|             bgcolor: (theme) => | ||||
|               theme.palette.mode === "dark" ? "background.paper" : "#fff", | ||||
|           }} | ||||
|         > | ||||
|           <Typography variant="h6" gutterBottom color="primary"> | ||||
| @@ -117,7 +131,8 @@ export const ArticleEdit = () => { | ||||
|             variant="h4" | ||||
|             gutterBottom | ||||
|             sx={{ | ||||
|               color: (theme) => (theme.palette.mode === 'dark' ? 'grey.300' : 'grey.800'), | ||||
|               color: (theme) => | ||||
|                 theme.palette.mode === "dark" ? "grey.300" : "grey.800", | ||||
|               mb: 3, | ||||
|             }} | ||||
|           > | ||||
| @@ -127,39 +142,41 @@ export const ArticleEdit = () => { | ||||
|           {/* Markdown контент */} | ||||
|           <Box | ||||
|             sx={{ | ||||
|               '& img': { | ||||
|                 maxWidth: '100%', | ||||
|                 height: 'auto', | ||||
|               "& img": { | ||||
|                 maxWidth: "100%", | ||||
|                 height: "auto", | ||||
|                 borderRadius: 1, | ||||
|               }, | ||||
|               '& h1, & h2, & h3, & h4, & h5, & h6': { | ||||
|                 color: 'primary.main', | ||||
|               "& h1, & h2, & h3, & h4, & h5, & h6": { | ||||
|                 color: "primary.main", | ||||
|                 mt: 2, | ||||
|                 mb: 1, | ||||
|               }, | ||||
|               '& p': { | ||||
|               "& p": { | ||||
|                 mb: 2, | ||||
|                 color: (theme) => (theme.palette.mode === 'dark' ? 'grey.300' : 'grey.800'), | ||||
|                 color: (theme) => | ||||
|                   theme.palette.mode === "dark" ? "grey.300" : "grey.800", | ||||
|               }, | ||||
|               '& a': { | ||||
|                 color: 'primary.main', | ||||
|                 textDecoration: 'none', | ||||
|                 '&:hover': { | ||||
|                   textDecoration: 'underline', | ||||
|               "& a": { | ||||
|                 color: "primary.main", | ||||
|                 textDecoration: "none", | ||||
|                 "&:hover": { | ||||
|                   textDecoration: "underline", | ||||
|                 }, | ||||
|               }, | ||||
|               '& blockquote': { | ||||
|                 borderLeft: '4px solid', | ||||
|                 borderColor: 'primary.main', | ||||
|               "& blockquote": { | ||||
|                 borderLeft: "4px solid", | ||||
|                 borderColor: "primary.main", | ||||
|                 pl: 2, | ||||
|                 my: 2, | ||||
|                 color: 'text.secondary', | ||||
|                 color: "text.secondary", | ||||
|               }, | ||||
|               '& code': { | ||||
|                 bgcolor: (theme) => (theme.palette.mode === 'dark' ? 'grey.900' : 'grey.100'), | ||||
|               "& code": { | ||||
|                 bgcolor: (theme) => | ||||
|                   theme.palette.mode === "dark" ? "grey.900" : "grey.100", | ||||
|                 p: 0.5, | ||||
|                 borderRadius: 0.5, | ||||
|                 color: 'primary.main', | ||||
|                 color: "primary.main", | ||||
|               }, | ||||
|             }} | ||||
|           > | ||||
| @@ -168,15 +185,15 @@ export const ArticleEdit = () => { | ||||
|  | ||||
|           {/* Привязанные медиа */} | ||||
|           {mediaData?.data && mediaData.data.length > 0 && ( | ||||
|             <Box sx={{mb: 3}}> | ||||
|             <Box sx={{ mb: 3 }}> | ||||
|               <Typography variant="subtitle1" gutterBottom color="primary"> | ||||
|                 Привязанные медиа: | ||||
|               </Typography> | ||||
|               <Box | ||||
|                 sx={{ | ||||
|                   display: 'flex', | ||||
|                   display: "flex", | ||||
|                   gap: 1, | ||||
|                   flexWrap: 'wrap', | ||||
|                   flexWrap: "wrap", | ||||
|                   mb: 2, | ||||
|                 }} | ||||
|               > | ||||
| @@ -187,18 +204,20 @@ export const ArticleEdit = () => { | ||||
|                       width: 120, | ||||
|                       height: 120, | ||||
|                       borderRadius: 1, | ||||
|                       overflow: 'hidden', | ||||
|                       border: '1px solid', | ||||
|                       borderColor: 'primary.main', | ||||
|                       overflow: "hidden", | ||||
|                       border: "1px solid", | ||||
|                       borderColor: "primary.main", | ||||
|                     }} | ||||
|                   > | ||||
|                     <img | ||||
|                       src={`https://wn.krbl.ru/media/${media.id}/download?token=${localStorage.getItem(TOKEN_KEY)}`} | ||||
|                       src={`${import.meta.env.VITE_KRBL_MEDIA}${ | ||||
|                         media.id | ||||
|                       }/download?token=${localStorage.getItem(TOKEN_KEY)}`} | ||||
|                       alt={media.media_name} | ||||
|                       style={{ | ||||
|                         width: '100%', | ||||
|                         height: '100%', | ||||
|                         objectFit: 'cover', | ||||
|                         width: "100%", | ||||
|                         height: "100%", | ||||
|                         objectFit: "cover", | ||||
|                       }} | ||||
|                     /> | ||||
|                   </Box> | ||||
| @@ -209,5 +228,5 @@ export const ArticleEdit = () => { | ||||
|         </Paper> | ||||
|       </Box> | ||||
|     </Edit> | ||||
|   ) | ||||
| } | ||||
|   ); | ||||
| }; | ||||
|   | ||||
| @@ -1,60 +1,114 @@ | ||||
| import {Box, Stack, Typography} from '@mui/material' | ||||
| import {useShow} from '@refinedev/core' | ||||
| import {Show, TextFieldComponent as TextField} from '@refinedev/mui' | ||||
| import {TOKEN_KEY} from '../../authProvider' | ||||
| import { Box, Stack, Typography } from "@mui/material"; | ||||
| import { useShow } from "@refinedev/core"; | ||||
| import { Show, TextFieldComponent as TextField } from "@refinedev/mui"; | ||||
| import { TOKEN_KEY } from "../../authProvider"; | ||||
|  | ||||
| export type FieldType = { | ||||
|   label: string | ||||
|   data: any | ||||
|   render?: (value: any) => React.ReactNode | ||||
| } | ||||
|   label: string; | ||||
|   data: any; | ||||
|   render?: (value: any) => React.ReactNode; | ||||
| }; | ||||
|  | ||||
| export const CarrierShow = () => { | ||||
|   const {query} = useShow({}) | ||||
|   const {data, isLoading} = query | ||||
|   const { query } = useShow({}); | ||||
|   const { data, isLoading } = query; | ||||
|  | ||||
|   const record = data?.data | ||||
|   const record = data?.data; | ||||
|  | ||||
|   const fields: FieldType[] = [ | ||||
|     {label: 'Полное имя', data: 'full_name'}, | ||||
|     {label: 'Короткое имя', data: 'short_name'}, | ||||
|     {label: 'Город', data: 'city'}, | ||||
|     { label: "Полное имя", data: "full_name" }, | ||||
|     { label: "Короткое имя", data: "short_name" }, | ||||
|     { label: "Город", data: "city" }, | ||||
|     { | ||||
|       label: 'Основной цвет', | ||||
|       data: 'main_color', | ||||
|       render: (value: string) => <Box sx={{display: 'grid', placeItems: 'center', width: 'fit-content', paddingInline: '6px', height: '100%', backgroundColor: `${value}20`, borderRadius: 1}}>{value}</Box>, | ||||
|       label: "Основной цвет", | ||||
|       data: "main_color", | ||||
|       render: (value: string) => ( | ||||
|         <Box | ||||
|           sx={{ | ||||
|             display: "grid", | ||||
|             placeItems: "center", | ||||
|             width: "fit-content", | ||||
|             paddingInline: "6px", | ||||
|             height: "100%", | ||||
|             backgroundColor: `${value}20`, | ||||
|             borderRadius: 1, | ||||
|           }} | ||||
|         > | ||||
|           {value} | ||||
|         </Box> | ||||
|       ), | ||||
|     }, | ||||
|     { | ||||
|       label: 'Цвет левого виджета', | ||||
|       data: 'left_color', | ||||
|       render: (value: string) => <Box sx={{display: 'grid', placeItems: 'center', width: 'fit-content', paddingInline: '6px', height: '100%', backgroundColor: `${value}20`, borderRadius: 1}}>{value}</Box>, | ||||
|       label: "Цвет левого виджета", | ||||
|       data: "left_color", | ||||
|       render: (value: string) => ( | ||||
|         <Box | ||||
|           sx={{ | ||||
|             display: "grid", | ||||
|             placeItems: "center", | ||||
|             width: "fit-content", | ||||
|             paddingInline: "6px", | ||||
|             height: "100%", | ||||
|             backgroundColor: `${value}20`, | ||||
|             borderRadius: 1, | ||||
|           }} | ||||
|         > | ||||
|           {value} | ||||
|         </Box> | ||||
|       ), | ||||
|     }, | ||||
|     { | ||||
|       label: 'Цвет правого виджета', | ||||
|       data: 'right_color', | ||||
|       render: (value: string) => <Box sx={{display: 'grid', placeItems: 'center', width: 'fit-content', paddingInline: '6px', height: '100%', backgroundColor: `${value}20`, borderRadius: 1}}>{value}</Box>, | ||||
|       label: "Цвет правого виджета", | ||||
|       data: "right_color", | ||||
|       render: (value: string) => ( | ||||
|         <Box | ||||
|           sx={{ | ||||
|             display: "grid", | ||||
|             placeItems: "center", | ||||
|             width: "fit-content", | ||||
|             paddingInline: "6px", | ||||
|             height: "100%", | ||||
|             backgroundColor: `${value}20`, | ||||
|             borderRadius: 1, | ||||
|           }} | ||||
|         > | ||||
|           {value} | ||||
|         </Box> | ||||
|       ), | ||||
|     }, | ||||
|     {label: 'Слоган', data: 'slogan'}, | ||||
|     { label: "Слоган", data: "slogan" }, | ||||
|     { | ||||
|       label: 'Логотип', | ||||
|       data: 'logo', | ||||
|       render: (value: number) => <img src={`https://wn.krbl.ru/media/${value}/download?token=${localStorage.getItem(TOKEN_KEY)}`} alt={String(value)} style={{maxWidth: '10%', objectFit: 'contain', borderRadius: 8}} />, | ||||
|       label: "Логотип", | ||||
|       data: "logo", | ||||
|       render: (value: number) => ( | ||||
|         <img | ||||
|           src={`${ | ||||
|             import.meta.env.VITE_KRBL_MEDIA | ||||
|           }${value}/download?token=${localStorage.getItem(TOKEN_KEY)}`} | ||||
|           alt={String(value)} | ||||
|           style={{ maxWidth: "10%", objectFit: "contain", borderRadius: 8 }} | ||||
|         /> | ||||
|       ), | ||||
|     }, | ||||
|   ] | ||||
|   ]; | ||||
|  | ||||
|   return ( | ||||
|     <Show isLoading={isLoading}> | ||||
|       <Stack gap={4}> | ||||
|         {fields.map(({label, data, render}) => ( | ||||
|         {fields.map(({ label, data, render }) => ( | ||||
|           <Stack key={data} gap={1}> | ||||
|             <Typography variant="body1" fontWeight="bold"> | ||||
|               {label} | ||||
|             </Typography> | ||||
|  | ||||
|             {render ? render(record?.[data]) : <TextField value={record?.[data]} />} | ||||
|             {render ? ( | ||||
|               render(record?.[data]) | ||||
|             ) : ( | ||||
|               <TextField value={record?.[data]} /> | ||||
|             )} | ||||
|           </Stack> | ||||
|         ))} | ||||
|       </Stack> | ||||
|     </Show> | ||||
|   ) | ||||
| } | ||||
|   ); | ||||
| }; | ||||
|   | ||||
| @@ -1,35 +1,51 @@ | ||||
| import {Stack, Typography} from '@mui/material' | ||||
| import {useShow} from '@refinedev/core' | ||||
| import {Show, TextFieldComponent as TextField} from '@refinedev/mui' | ||||
| import {TOKEN_KEY} from '../../authProvider' | ||||
| import { Stack, Typography } from "@mui/material"; | ||||
| import { useShow } from "@refinedev/core"; | ||||
| import { Show, TextFieldComponent as TextField } from "@refinedev/mui"; | ||||
| import { TOKEN_KEY } from "../../authProvider"; | ||||
|  | ||||
| export const CityShow = () => { | ||||
|   const {query} = useShow({}) | ||||
|   const {data, isLoading} = query | ||||
|   const { query } = useShow({}); | ||||
|   const { data, isLoading } = query; | ||||
|  | ||||
|   const record = data?.data | ||||
|   const record = data?.data; | ||||
|  | ||||
|   const fields = [ | ||||
|     // {label: 'ID', data: 'id'}, | ||||
|     {label: 'Название', data: 'name'}, | ||||
|     { label: "Название", data: "name" }, | ||||
|     // {label: 'Код страны', data: 'country_code'}, | ||||
|     {label: 'Страна', data: 'country'}, | ||||
|     {label: 'Герб', data: 'arms', render: (value: number) => <img src={`https://wn.krbl.ru/media/${value}/download?token=${localStorage.getItem(TOKEN_KEY)}`} alt={String(value)} style={{maxWidth: '10%', objectFit: 'contain', borderRadius: 8}} />}, | ||||
|   ] | ||||
|     { label: "Страна", data: "country" }, | ||||
|     { | ||||
|       label: "Герб", | ||||
|       data: "arms", | ||||
|       render: (value: number) => ( | ||||
|         <img | ||||
|           src={`${ | ||||
|             import.meta.env.VITE_KRBL_MEDIA | ||||
|           }${value}/download?token=${localStorage.getItem(TOKEN_KEY)}`} | ||||
|           alt={String(value)} | ||||
|           style={{ maxWidth: "10%", objectFit: "contain", borderRadius: 8 }} | ||||
|         /> | ||||
|       ), | ||||
|     }, | ||||
|   ]; | ||||
|  | ||||
|   return ( | ||||
|     <Show isLoading={isLoading}> | ||||
|       <Stack gap={4}> | ||||
|         {fields.map(({label, data, render}) => ( | ||||
|         {fields.map(({ label, data, render }) => ( | ||||
|           <Stack key={data} gap={1}> | ||||
|             <Typography variant="body1" fontWeight="bold"> | ||||
|               {label} | ||||
|             </Typography> | ||||
|  | ||||
|             {render ? render(record?.[data]) : <TextField value={record?.[data]} />} | ||||
|             {render ? ( | ||||
|               render(record?.[data]) | ||||
|             ) : ( | ||||
|               <TextField value={record?.[data]} /> | ||||
|             )} | ||||
|           </Stack> | ||||
|         ))} | ||||
|       </Stack> | ||||
|     </Show> | ||||
|   ) | ||||
| } | ||||
|   ); | ||||
| }; | ||||
|   | ||||
| @@ -1,26 +1,36 @@ | ||||
| import {Box, TextField, Button, Typography, Autocomplete} from '@mui/material' | ||||
| import {Edit} from '@refinedev/mui' | ||||
| import {useForm} from '@refinedev/react-hook-form' | ||||
| import {useEffect} from 'react' | ||||
| import {useShow} from '@refinedev/core' | ||||
| import {Controller} from 'react-hook-form' | ||||
| import { | ||||
|   Box, | ||||
|   TextField, | ||||
|   Button, | ||||
|   Typography, | ||||
|   Autocomplete, | ||||
| } from "@mui/material"; | ||||
| import { Edit } from "@refinedev/mui"; | ||||
| import { useForm } from "@refinedev/react-hook-form"; | ||||
| import { useEffect } from "react"; | ||||
| import { useShow } from "@refinedev/core"; | ||||
| import { Controller } from "react-hook-form"; | ||||
|  | ||||
| import {MEDIA_TYPES} from '../../lib/constants' | ||||
| import {ALLOWED_IMAGE_TYPES, ALLOWED_VIDEO_TYPES, useMediaFileUpload} from '../../components/media/MediaFormUtils' | ||||
| import {TOKEN_KEY} from '../../authProvider' | ||||
| import { MEDIA_TYPES } from "../../lib/constants"; | ||||
| import { | ||||
|   ALLOWED_IMAGE_TYPES, | ||||
|   ALLOWED_VIDEO_TYPES, | ||||
|   useMediaFileUpload, | ||||
| } from "../../components/media/MediaFormUtils"; | ||||
| import { TOKEN_KEY } from "../../authProvider"; | ||||
|  | ||||
| type MediaFormValues = { | ||||
|   media_name: string | ||||
|   media_type: number | ||||
|   file?: File | ||||
| } | ||||
|   media_name: string; | ||||
|   media_type: number; | ||||
|   file?: File; | ||||
| }; | ||||
|  | ||||
| export const MediaEdit = () => { | ||||
|   const { | ||||
|     saveButtonProps, | ||||
|     refineCore: {onFinish}, | ||||
|     refineCore: { onFinish }, | ||||
|     register, | ||||
|     formState: {errors}, | ||||
|     formState: { errors }, | ||||
|     setValue, | ||||
|     handleSubmit, | ||||
|     watch, | ||||
| @@ -29,32 +39,42 @@ export const MediaEdit = () => { | ||||
|     control, | ||||
|   } = useForm<MediaFormValues>({ | ||||
|     defaultValues: { | ||||
|       media_name: '', | ||||
|       media_type: '', | ||||
|       media_name: "", | ||||
|       media_type: "", | ||||
|       file: undefined, | ||||
|     }, | ||||
|   }) | ||||
|   }); | ||||
|  | ||||
|   const {query} = useShow() | ||||
|   const {data} = query | ||||
|   const record = data?.data | ||||
|   const { query } = useShow(); | ||||
|   const { data } = query; | ||||
|   const record = data?.data; | ||||
|  | ||||
|   const selectedMediaType = watch('media_type') | ||||
|   const selectedMediaType = watch("media_type"); | ||||
|  | ||||
|   const {selectedFile, previewUrl, setPreviewUrl, handleFileChange, handleMediaTypeChange} = useMediaFileUpload({ | ||||
|   const { | ||||
|     selectedFile, | ||||
|     previewUrl, | ||||
|     setPreviewUrl, | ||||
|     handleFileChange, | ||||
|     handleMediaTypeChange, | ||||
|   } = useMediaFileUpload({ | ||||
|     selectedMediaType, | ||||
|     setError, | ||||
|     clearErrors, | ||||
|     setValue, | ||||
|   }) | ||||
|   }); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     if (record?.id) { | ||||
|       setPreviewUrl(`https://wn.krbl.ru/media/${record.id}/download?token=${localStorage.getItem(TOKEN_KEY)}`) | ||||
|       setValue('media_name', record?.media_name || '') | ||||
|       setValue('media_type', record?.media_type) | ||||
|       setPreviewUrl( | ||||
|         `${import.meta.env.VITE_KRBL_MEDIA}${ | ||||
|           record.id | ||||
|         }/download?token=${localStorage.getItem(TOKEN_KEY)}` | ||||
|       ); | ||||
|       setValue("media_name", record?.media_name || ""); | ||||
|       setValue("media_type", record?.media_type); | ||||
|     } | ||||
|   }, [record, setValue, setPreviewUrl]) | ||||
|   }, [record, setValue, setPreviewUrl]); | ||||
|  | ||||
|   return ( | ||||
|     <Edit | ||||
| @@ -66,57 +86,98 @@ export const MediaEdit = () => { | ||||
|             media_name: data.media_name, | ||||
|             filename: selectedFile?.name || record?.filename, | ||||
|             type: Number(data.media_type), | ||||
|           } | ||||
|           onFinish(formData) | ||||
|           }; | ||||
|           onFinish(formData); | ||||
|         }), | ||||
|       }} | ||||
|     > | ||||
|       <Box component="form" sx={{display: 'flex', flexDirection: 'column'}} autoComplete="off"> | ||||
|       <Box | ||||
|         component="form" | ||||
|         sx={{ display: "flex", flexDirection: "column" }} | ||||
|         autoComplete="off" | ||||
|       > | ||||
|         <Controller | ||||
|           control={control} | ||||
|           name="media_type" | ||||
|           rules={{ | ||||
|             required: 'Это поле является обязательным', | ||||
|             required: "Это поле является обязательным", | ||||
|           }} | ||||
|           defaultValue={null} | ||||
|           render={({field}) => ( | ||||
|           render={({ field }) => ( | ||||
|             <Autocomplete | ||||
|               options={MEDIA_TYPES} | ||||
|               value={MEDIA_TYPES.find((option) => option.value === field.value) || null} | ||||
|               value={ | ||||
|                 MEDIA_TYPES.find((option) => option.value === field.value) || | ||||
|                 null | ||||
|               } | ||||
|               onChange={(_, value) => { | ||||
|                 field.onChange(value?.value || null) | ||||
|                 handleMediaTypeChange(value?.value || null) | ||||
|                 field.onChange(value?.value || null); | ||||
|                 handleMediaTypeChange(value?.value || null); | ||||
|               }} | ||||
|               getOptionLabel={(item) => { | ||||
|                 return item ? item.label : '' | ||||
|                 return item ? item.label : ""; | ||||
|               }} | ||||
|               isOptionEqualToValue={(option, value) => { | ||||
|                 return option.value === value?.value | ||||
|                 return option.value === value?.value; | ||||
|               }} | ||||
|               renderInput={(params) => <TextField {...params} label="Тип" margin="normal" variant="outlined" error={!!errors.media_type} helperText={(errors as any)?.media_type?.message} required />} | ||||
|               renderInput={(params) => ( | ||||
|                 <TextField | ||||
|                   {...params} | ||||
|                   label="Тип" | ||||
|                   margin="normal" | ||||
|                   variant="outlined" | ||||
|                   error={!!errors.media_type} | ||||
|                   helperText={(errors as any)?.media_type?.message} | ||||
|                   required | ||||
|                 /> | ||||
|               )} | ||||
|             /> | ||||
|           )} | ||||
|         /> | ||||
|  | ||||
|         <TextField | ||||
|           {...register('media_name', { | ||||
|             required: 'Это поле является обязательным', | ||||
|           {...register("media_name", { | ||||
|             required: "Это поле является обязательным", | ||||
|           })} | ||||
|           error={!!(errors as any)?.media_name} | ||||
|           helperText={(errors as any)?.media_name?.message} | ||||
|           margin="normal" | ||||
|           fullWidth | ||||
|           InputLabelProps={{shrink: true}} | ||||
|           InputLabelProps={{ shrink: true }} | ||||
|           type="text" | ||||
|           label="Название *" | ||||
|           name="media_name" | ||||
|         /> | ||||
|  | ||||
|         <Box display="flex" flexDirection="column-reverse" alignItems="center" gap={4} style={{marginTop: 10}}> | ||||
|           <Box display="flex" flexDirection="column" alignItems="center" gap={2}> | ||||
|             <Button variant="contained" component="label" disabled={!selectedMediaType}> | ||||
|               {selectedFile ? 'Изменить файл' : 'Загрузить файл'} | ||||
|               <input type="file" hidden onChange={handleFileChange} accept={selectedMediaType === 1 ? ALLOWED_IMAGE_TYPES.join(',') : ALLOWED_VIDEO_TYPES.join(',')} /> | ||||
|         <Box | ||||
|           display="flex" | ||||
|           flexDirection="column-reverse" | ||||
|           alignItems="center" | ||||
|           gap={4} | ||||
|           style={{ marginTop: 10 }} | ||||
|         > | ||||
|           <Box | ||||
|             display="flex" | ||||
|             flexDirection="column" | ||||
|             alignItems="center" | ||||
|             gap={2} | ||||
|           > | ||||
|             <Button | ||||
|               variant="contained" | ||||
|               component="label" | ||||
|               disabled={!selectedMediaType} | ||||
|             > | ||||
|               {selectedFile ? "Изменить файл" : "Загрузить файл"} | ||||
|               <input | ||||
|                 type="file" | ||||
|                 hidden | ||||
|                 onChange={handleFileChange} | ||||
|                 accept={ | ||||
|                   selectedMediaType === 1 | ||||
|                     ? ALLOWED_IMAGE_TYPES.join(",") | ||||
|                     : ALLOWED_VIDEO_TYPES.join(",") | ||||
|                 } | ||||
|               /> | ||||
|             </Button> | ||||
|  | ||||
|             {selectedFile && ( | ||||
| @@ -134,11 +195,15 @@ export const MediaEdit = () => { | ||||
|  | ||||
|           {previewUrl && selectedMediaType === 1 && ( | ||||
|             <Box mt={2} display="flex" justifyContent="center"> | ||||
|               <img src={previewUrl} alt="Preview" style={{maxWidth: '200px', borderRadius: 8}} /> | ||||
|               <img | ||||
|                 src={previewUrl} | ||||
|                 alt="Preview" | ||||
|                 style={{ maxWidth: "200px", borderRadius: 8 }} | ||||
|               /> | ||||
|             </Box> | ||||
|           )} | ||||
|         </Box> | ||||
|       </Box> | ||||
|     </Edit> | ||||
|   ) | ||||
| } | ||||
|   ); | ||||
| }; | ||||
|   | ||||
| @@ -1,39 +1,42 @@ | ||||
| import {Stack, Typography, Box, Button} from '@mui/material' | ||||
| import {useShow} from '@refinedev/core' | ||||
| import {Show, TextFieldComponent as TextField} from '@refinedev/mui' | ||||
| import { Stack, Typography, Box, Button } from "@mui/material"; | ||||
| import { useShow } from "@refinedev/core"; | ||||
| import { Show, TextFieldComponent as TextField } from "@refinedev/mui"; | ||||
|  | ||||
| import {MEDIA_TYPES} from '../../lib/constants' | ||||
| import {TOKEN_KEY} from '../../authProvider' | ||||
| import { MEDIA_TYPES } from "../../lib/constants"; | ||||
| import { TOKEN_KEY } from "../../authProvider"; | ||||
|  | ||||
| export const MediaShow = () => { | ||||
|   const {query} = useShow({}) | ||||
|   const {data, isLoading} = query | ||||
|   const { query } = useShow({}); | ||||
|   const { data, isLoading } = query; | ||||
|  | ||||
|   const record = data?.data | ||||
|   const token = localStorage.getItem(TOKEN_KEY) | ||||
|   const record = data?.data; | ||||
|   const token = localStorage.getItem(TOKEN_KEY); | ||||
|  | ||||
|   const fields = [ | ||||
|     // {label: 'Название файла', data: 'filename'}, | ||||
|     {label: 'Название', data: 'media_name'}, | ||||
|     { label: "Название", data: "media_name" }, | ||||
|     { | ||||
|       label: 'Тип', | ||||
|       data: 'media_type', | ||||
|       render: (value: number) => MEDIA_TYPES.find((type) => type.value === value)?.label || value, | ||||
|       label: "Тип", | ||||
|       data: "media_type", | ||||
|       render: (value: number) => | ||||
|         MEDIA_TYPES.find((type) => type.value === value)?.label || value, | ||||
|     }, | ||||
|     // {label: 'ID', data: 'id'}, | ||||
|   ] | ||||
|   ]; | ||||
|  | ||||
|   return ( | ||||
|     <Show isLoading={isLoading}> | ||||
|       <Stack gap={4}> | ||||
|         {record && record.media_type === 1 && ( | ||||
|           <img | ||||
|             src={`https://wn.krbl.ru/media/${record?.id}/download?token=${token}`} | ||||
|             src={`${import.meta.env.VITE_KRBL_MEDIA}${ | ||||
|               record?.id | ||||
|             }/download?token=${token}`} | ||||
|             alt={record?.filename} | ||||
|             style={{ | ||||
|               maxWidth: '100%', | ||||
|               height: '40vh', | ||||
|               objectFit: 'contain', | ||||
|               maxWidth: "100%", | ||||
|               height: "40vh", | ||||
|               objectFit: "contain", | ||||
|               borderRadius: 8, | ||||
|             }} | ||||
|           /> | ||||
| @@ -43,36 +46,45 @@ export const MediaShow = () => { | ||||
|           <Box | ||||
|             sx={{ | ||||
|               p: 2, | ||||
|               border: '1px solid text.pimary', | ||||
|               border: "1px solid text.pimary", | ||||
|               borderRadius: 2, | ||||
|               bgcolor: 'primary.light', | ||||
|               width: 'fit-content', | ||||
|               bgcolor: "primary.light", | ||||
|               width: "fit-content", | ||||
|             }} | ||||
|           > | ||||
|             <Typography | ||||
|               variant="body1" | ||||
|               gutterBottom | ||||
|               sx={{ | ||||
|                 color: '#FFFFFF', | ||||
|                 color: "#FFFFFF", | ||||
|               }} | ||||
|             > | ||||
|               Видео доступно для скачивания по ссылке: | ||||
|             </Typography> | ||||
|             <Button variant="contained" href={`https://wn.krbl.ru/media/${record?.id}/download?token=${token}`} target="_blank" sx={{mt: 1, width: '100%'}}> | ||||
|             <Button | ||||
|               variant="contained" | ||||
|               href={`${import.meta.env.VITE_KRBL_MEDIA}${ | ||||
|                 record?.id | ||||
|               }/download?token=${token}`} | ||||
|               target="_blank" | ||||
|               sx={{ mt: 1, width: "100%" }} | ||||
|             > | ||||
|               Скачать видео | ||||
|             </Button> | ||||
|           </Box> | ||||
|         )} | ||||
|  | ||||
|         {fields.map(({label, data, render}) => ( | ||||
|         {fields.map(({ label, data, render }) => ( | ||||
|           <Stack key={data} gap={1}> | ||||
|             <Typography variant="body1" fontWeight="bold"> | ||||
|               {label} | ||||
|             </Typography> | ||||
|             <TextField value={render ? render(record?.[data]) : record?.[data]} /> | ||||
|             <TextField | ||||
|               value={render ? render(record?.[data]) : record?.[data]} | ||||
|             /> | ||||
|           </Stack> | ||||
|         ))} | ||||
|       </Stack> | ||||
|     </Show> | ||||
|   ) | ||||
| } | ||||
|   ); | ||||
| }; | ||||
|   | ||||
| @@ -1,73 +1,102 @@ | ||||
| import {Autocomplete, Box, TextField, FormControlLabel, Checkbox, Typography} from '@mui/material' | ||||
| import {Create, useAutocomplete} from '@refinedev/mui' | ||||
| import {useForm} from '@refinedev/react-hook-form' | ||||
| import {Controller} from 'react-hook-form' | ||||
| import { | ||||
|   Autocomplete, | ||||
|   Box, | ||||
|   TextField, | ||||
|   FormControlLabel, | ||||
|   Checkbox, | ||||
|   Typography, | ||||
| } from "@mui/material"; | ||||
| import { Create, useAutocomplete } from "@refinedev/mui"; | ||||
| import { useForm } from "@refinedev/react-hook-form"; | ||||
| import { Controller } from "react-hook-form"; | ||||
|  | ||||
| export const RouteCreate = () => { | ||||
|   const { | ||||
|     saveButtonProps, | ||||
|     refineCore: {formLoading}, | ||||
|     refineCore: { formLoading }, | ||||
|     register, | ||||
|     control, | ||||
|     formState: {errors}, | ||||
|     formState: { errors }, | ||||
|   } = useForm({ | ||||
|     refineCoreProps: { | ||||
|       resource: 'route/', | ||||
|       resource: "route/", | ||||
|     }, | ||||
|   }) | ||||
|   }); | ||||
|  | ||||
|   const {autocompleteProps: carrierAutocompleteProps} = useAutocomplete({ | ||||
|     resource: 'carrier', | ||||
|   const { autocompleteProps: carrierAutocompleteProps } = useAutocomplete({ | ||||
|     resource: "carrier", | ||||
|     onSearch: (value) => [ | ||||
|       { | ||||
|         field: 'short_name', | ||||
|         operator: 'contains', | ||||
|         field: "short_name", | ||||
|         operator: "contains", | ||||
|         value, | ||||
|       }, | ||||
|     ], | ||||
|   }) | ||||
|   }); | ||||
|  | ||||
|   return ( | ||||
|     <Create isLoading={formLoading} saveButtonProps={saveButtonProps}> | ||||
|       <Box component="form" sx={{display: 'flex', flexDirection: 'column'}} autoComplete="off"> | ||||
|       <Box | ||||
|         component="form" | ||||
|         sx={{ display: "flex", flexDirection: "column" }} | ||||
|         autoComplete="off" | ||||
|       > | ||||
|         <Controller | ||||
|           control={control} | ||||
|           name="carrier_id" | ||||
|           rules={{required: 'Это поле является обязательным'}} | ||||
|           rules={{ required: "Это поле является обязательным" }} | ||||
|           defaultValue={null} | ||||
|           render={({field}) => ( | ||||
|           render={({ field }) => ( | ||||
|             <Autocomplete | ||||
|               {...carrierAutocompleteProps} | ||||
|               value={carrierAutocompleteProps.options.find((option) => option.id === field.value) || null} | ||||
|               value={ | ||||
|                 carrierAutocompleteProps.options.find( | ||||
|                   (option) => option.id === field.value | ||||
|                 ) || null | ||||
|               } | ||||
|               onChange={(_, value) => { | ||||
|                 field.onChange(value?.id || '') | ||||
|                 field.onChange(value?.id || ""); | ||||
|               }} | ||||
|               getOptionLabel={(item) => { | ||||
|                 return item ? item.short_name : '' | ||||
|                 return item ? item.short_name : ""; | ||||
|               }} | ||||
|               isOptionEqualToValue={(option, value) => { | ||||
|                 return option.id === value?.id | ||||
|                 return option.id === value?.id; | ||||
|               }} | ||||
|               filterOptions={(options, {inputValue}) => { | ||||
|                 return options.filter((option) => option.short_name.toLowerCase().includes(inputValue.toLowerCase())) | ||||
|               filterOptions={(options, { inputValue }) => { | ||||
|                 return options.filter((option) => | ||||
|                   option.short_name | ||||
|                     .toLowerCase() | ||||
|                     .includes(inputValue.toLowerCase()) | ||||
|                 ); | ||||
|               }} | ||||
|               renderInput={(params) => <TextField {...params} label="Выберите перевозчика" margin="normal" variant="outlined" error={!!errors.carrier_id} helperText={(errors as any)?.carrier_id?.message} required />} | ||||
|               renderInput={(params) => ( | ||||
|                 <TextField | ||||
|                   {...params} | ||||
|                   label="Выберите перевозчика" | ||||
|                   margin="normal" | ||||
|                   variant="outlined" | ||||
|                   error={!!errors.carrier_id} | ||||
|                   helperText={(errors as any)?.carrier_id?.message} | ||||
|                   required | ||||
|                 /> | ||||
|               )} | ||||
|             /> | ||||
|           )} | ||||
|         /> | ||||
|  | ||||
|         <TextField | ||||
|           {...register('route_number', { | ||||
|             required: 'Это поле является обязательным', | ||||
|           {...register("route_number", { | ||||
|             required: "Это поле является обязательным", | ||||
|             setValueAs: (value) => String(value), | ||||
|           })} | ||||
|           error={!!(errors as any)?.route_number} | ||||
|           helperText={(errors as any)?.route_number?.message} | ||||
|           margin="normal" | ||||
|           fullWidth | ||||
|           InputLabelProps={{shrink: true}} | ||||
|           InputLabelProps={{ shrink: true }} | ||||
|           type="text" | ||||
|           label={'Номер маршрута *'} | ||||
|           label={"Номер маршрута *"} | ||||
|           name="route_number" | ||||
|         /> | ||||
|  | ||||
| @@ -75,143 +104,169 @@ export const RouteCreate = () => { | ||||
|           name="route_direction" // boolean | ||||
|           control={control} | ||||
|           defaultValue={false} | ||||
|           render={({field}: {field: any}) => <FormControlLabel label="Прямой маршрут? *" control={<Checkbox {...field} checked={field.value} onChange={(e) => field.onChange(e.target.checked)} />} />} | ||||
|           render={({ field }: { field: any }) => ( | ||||
|             <FormControlLabel | ||||
|               label="Прямой маршрут? *" | ||||
|               control={ | ||||
|                 <Checkbox | ||||
|                   {...field} | ||||
|                   checked={field.value} | ||||
|                   onChange={(e) => field.onChange(e.target.checked)} | ||||
|                 /> | ||||
|               } | ||||
|             /> | ||||
|           )} | ||||
|         /> | ||||
|         <Typography variant="caption" color="textSecondary" sx={{mt: 0, mb: 1}}> | ||||
|         <Typography | ||||
|           variant="caption" | ||||
|           color="textSecondary" | ||||
|           sx={{ mt: 0, mb: 1 }} | ||||
|         > | ||||
|           (Прямой / Обратный) | ||||
|         </Typography> | ||||
|  | ||||
|         <TextField | ||||
|           {...register('path', { | ||||
|             required: 'Это поле является обязательным', | ||||
|           {...register("path", { | ||||
|             required: "Это поле является обязательным", | ||||
|             setValueAs: (value: string) => { | ||||
|               try { | ||||
|                 // Парсим строку в массив массивов | ||||
|                 return JSON.parse(value) | ||||
|                 return JSON.parse(value); | ||||
|               } catch { | ||||
|                 return [] | ||||
|                 return []; | ||||
|               } | ||||
|             }, | ||||
|             validate: (value: unknown) => { | ||||
|               if (!Array.isArray(value)) return 'Неверный формат' | ||||
|               if (!value.every((point: unknown) => Array.isArray(point) && point.length === 2)) { | ||||
|                 return 'Каждая точка должна быть массивом из двух координат' | ||||
|               if (!Array.isArray(value)) return "Неверный формат"; | ||||
|               if ( | ||||
|                 !value.every( | ||||
|                   (point: unknown) => Array.isArray(point) && point.length === 2 | ||||
|                 ) | ||||
|               ) { | ||||
|                 return "Каждая точка должна быть массивом из двух координат"; | ||||
|               } | ||||
|               if (!value.every((point: unknown[]) => point.every((coord: unknown) => !isNaN(Number(coord)) && typeof coord === 'number'))) { | ||||
|                 return 'Координаты должны быть числами' | ||||
|               if ( | ||||
|                 !value.every((point: unknown[]) => | ||||
|                   point.every( | ||||
|                     (coord: unknown) => | ||||
|                       !isNaN(Number(coord)) && typeof coord === "number" | ||||
|                   ) | ||||
|                 ) | ||||
|               ) { | ||||
|                 return "Координаты должны быть числами"; | ||||
|               } | ||||
|               return true | ||||
|               return true; | ||||
|             }, | ||||
|           })} | ||||
|           error={!!(errors as any)?.path} | ||||
|           helperText={(errors as any)?.path?.message} // 'Формат: [[lat1,lon1], [lat2,lon2], ...]' | ||||
|           margin="normal" | ||||
|           fullWidth | ||||
|           InputLabelProps={{shrink: true}} | ||||
|           InputLabelProps={{ shrink: true }} | ||||
|           type="text" | ||||
|           label={'Координаты маршрута *'} | ||||
|           label={"Координаты маршрута *"} | ||||
|           name="path" | ||||
|           placeholder="[[1.1, 2.2], [2.1, 4.5]]" | ||||
|         /> | ||||
|  | ||||
|         <TextField | ||||
|           {...register('route_sys_number', { | ||||
|             required: 'Это поле является обязательным', | ||||
|           {...register("route_sys_number", { | ||||
|             required: "Это поле является обязательным", | ||||
|           })} | ||||
|           error={!!(errors as any)?.route_sys_number} | ||||
|           helperText={(errors as any)?.route_sys_number?.message} | ||||
|           margin="normal" | ||||
|           fullWidth | ||||
|           InputLabelProps={{shrink: true}} | ||||
|           InputLabelProps={{ shrink: true }} | ||||
|           type="number" | ||||
|           label={'Системный номер маршрута *'} | ||||
|           label={"Системный номер маршрута *"} | ||||
|           name="route_sys_number" | ||||
|         /> | ||||
|  | ||||
|         <TextField | ||||
|           {...register('governor_appeal', { | ||||
|           {...register("governor_appeal", { | ||||
|             // required: 'Это поле является обязательным', | ||||
|           })} | ||||
|           error={!!(errors as any)?.governor_appeal} | ||||
|           helperText={(errors as any)?.governor_appeal?.message} | ||||
|           margin="normal" | ||||
|           fullWidth | ||||
|           InputLabelProps={{shrink: true}} | ||||
|           InputLabelProps={{ shrink: true }} | ||||
|           type="number" | ||||
|           label={'Обращение губернатора'} | ||||
|           label={"Обращение губернатора"} | ||||
|           name="governor_appeal" | ||||
|         /> | ||||
|  | ||||
|         <TextField | ||||
|           {...register('scale_min', { | ||||
|           {...register("scale_min", { | ||||
|             // required: 'Это поле является обязательным', | ||||
|           })} | ||||
|           error={!!(errors as any)?.scale_min} | ||||
|           helperText={(errors as any)?.scale_min?.message} | ||||
|           margin="normal" | ||||
|           fullWidth | ||||
|           InputLabelProps={{shrink: true}} | ||||
|           InputLabelProps={{ shrink: true }} | ||||
|           type="number" | ||||
|           label={'Масштаб (мин)'} | ||||
|           label={"Масштаб (мин)"} | ||||
|           name="scale_min" | ||||
|         /> | ||||
|  | ||||
|         <TextField | ||||
|           {...register('scale_max', { | ||||
|           {...register("scale_max", { | ||||
|             // required: 'Это поле является обязательным', | ||||
|           })} | ||||
|           error={!!(errors as any)?.scale_max} | ||||
|           helperText={(errors as any)?.scale_max?.message} | ||||
|           margin="normal" | ||||
|           fullWidth | ||||
|           InputLabelProps={{shrink: true}} | ||||
|           InputLabelProps={{ shrink: true }} | ||||
|           type="number" | ||||
|           label={'Масштаб (макс)'} | ||||
|           label={"Масштаб (макс)"} | ||||
|           name="scale_max" | ||||
|         /> | ||||
|  | ||||
|         <TextField | ||||
|           {...register('rotate', { | ||||
|           {...register("rotate", { | ||||
|             // required: 'Это поле является обязательным', | ||||
|           })} | ||||
|           error={!!(errors as any)?.rotate} | ||||
|           helperText={(errors as any)?.rotate?.message} | ||||
|           margin="normal" | ||||
|           fullWidth | ||||
|           InputLabelProps={{shrink: true}} | ||||
|           InputLabelProps={{ shrink: true }} | ||||
|           type="number" | ||||
|           label={'Поворот'} | ||||
|           label={"Поворот"} | ||||
|           name="rotate" | ||||
|         /> | ||||
|  | ||||
|         <TextField | ||||
|           {...register('center_latitude', { | ||||
|           {...register("center_latitude", { | ||||
|             // required: 'Это поле является обязательным', | ||||
|           })} | ||||
|           error={!!(errors as any)?.center_latitude} | ||||
|           helperText={(errors as any)?.center_latitude?.message} | ||||
|           margin="normal" | ||||
|           fullWidth | ||||
|           InputLabelProps={{shrink: true}} | ||||
|           InputLabelProps={{ shrink: true }} | ||||
|           type="number" | ||||
|           label={'Центр. широта'} | ||||
|           label={"Центр. широта"} | ||||
|           name="center_latitude" | ||||
|         /> | ||||
|  | ||||
|         <TextField | ||||
|           {...register('center_longitude', { | ||||
|           {...register("center_longitude", { | ||||
|             // required: 'Это поле является обязательным', | ||||
|           })} | ||||
|           error={!!(errors as any)?.center_longitude} | ||||
|           helperText={(errors as any)?.center_longitude?.message} | ||||
|           margin="normal" | ||||
|           fullWidth | ||||
|           InputLabelProps={{shrink: true}} | ||||
|           InputLabelProps={{ shrink: true }} | ||||
|           type="number" | ||||
|           label={'Центр. долгота'} | ||||
|           label={"Центр. долгота"} | ||||
|           name="center_longitude" | ||||
|         /> | ||||
|       </Box> | ||||
|     </Create> | ||||
|   ) | ||||
| } | ||||
|   ); | ||||
| }; | ||||
|   | ||||
| @@ -1,191 +1,248 @@ | ||||
| import {Autocomplete, Box, TextField, Typography, Paper} from '@mui/material' | ||||
| import {Create, useAutocomplete} from '@refinedev/mui' | ||||
| import {useForm} from '@refinedev/react-hook-form' | ||||
| import {Controller} from 'react-hook-form' | ||||
| import {Link} from 'react-router' | ||||
| import React, {useState, useEffect} from 'react' | ||||
| import {TOKEN_KEY} from '../../authProvider' | ||||
| import { Autocomplete, Box, TextField, Typography, Paper } from "@mui/material"; | ||||
| import { Create, useAutocomplete } from "@refinedev/mui"; | ||||
| import { useForm } from "@refinedev/react-hook-form"; | ||||
| import { Controller } from "react-hook-form"; | ||||
| import { Link } from "react-router"; | ||||
| import React, { useState, useEffect } from "react"; | ||||
| import { TOKEN_KEY } from "../../authProvider"; | ||||
|  | ||||
| export const SightCreate = () => { | ||||
|   const { | ||||
|     saveButtonProps, | ||||
|     refineCore: {formLoading}, | ||||
|     refineCore: { formLoading }, | ||||
|     register, | ||||
|     control, | ||||
|     watch, | ||||
|     formState: {errors}, | ||||
|     formState: { errors }, | ||||
|   } = useForm({ | ||||
|     refineCoreProps: { | ||||
|       resource: 'sight/', | ||||
|       resource: "sight/", | ||||
|     }, | ||||
|   }) | ||||
|   }); | ||||
|  | ||||
|   // Состояния для предпросмотра | ||||
|   const [namePreview, setNamePreview] = useState('') | ||||
|   const [coordinatesPreview, setCoordinatesPreview] = useState({latitude: '', longitude: ''}) | ||||
|   const [cityPreview, setCityPreview] = useState('') | ||||
|   const [thumbnailPreview, setThumbnailPreview] = useState<string | null>(null) | ||||
|   const [watermarkLUPreview, setWatermarkLUPreview] = useState<string | null>(null) | ||||
|   const [watermarkRDPreview, setWatermarkRDPreview] = useState<string | null>(null) | ||||
|   const [leftArticlePreview, setLeftArticlePreview] = useState('') | ||||
|   const [previewArticlePreview, setPreviewArticlePreview] = useState('') | ||||
|   const [namePreview, setNamePreview] = useState(""); | ||||
|   const [coordinatesPreview, setCoordinatesPreview] = useState({ | ||||
|     latitude: "", | ||||
|     longitude: "", | ||||
|   }); | ||||
|   const [cityPreview, setCityPreview] = useState(""); | ||||
|   const [thumbnailPreview, setThumbnailPreview] = useState<string | null>(null); | ||||
|   const [watermarkLUPreview, setWatermarkLUPreview] = useState<string | null>( | ||||
|     null | ||||
|   ); | ||||
|   const [watermarkRDPreview, setWatermarkRDPreview] = useState<string | null>( | ||||
|     null | ||||
|   ); | ||||
|   const [leftArticlePreview, setLeftArticlePreview] = useState(""); | ||||
|   const [previewArticlePreview, setPreviewArticlePreview] = useState(""); | ||||
|  | ||||
|   // Автокомплиты | ||||
|   const {autocompleteProps: cityAutocompleteProps} = useAutocomplete({ | ||||
|     resource: 'city', | ||||
|   const { autocompleteProps: cityAutocompleteProps } = useAutocomplete({ | ||||
|     resource: "city", | ||||
|     onSearch: (value) => [ | ||||
|       { | ||||
|         field: 'name', | ||||
|         operator: 'contains', | ||||
|         field: "name", | ||||
|         operator: "contains", | ||||
|         value, | ||||
|       }, | ||||
|     ], | ||||
|   }) | ||||
|   }); | ||||
|  | ||||
|   const {autocompleteProps: mediaAutocompleteProps} = useAutocomplete({ | ||||
|     resource: 'media', | ||||
|   const { autocompleteProps: mediaAutocompleteProps } = useAutocomplete({ | ||||
|     resource: "media", | ||||
|     onSearch: (value) => [ | ||||
|       { | ||||
|         field: 'media_name', | ||||
|         operator: 'contains', | ||||
|         field: "media_name", | ||||
|         operator: "contains", | ||||
|         value, | ||||
|       }, | ||||
|     ], | ||||
|   }) | ||||
|   }); | ||||
|  | ||||
|   const {autocompleteProps: articleAutocompleteProps} = useAutocomplete({ | ||||
|     resource: 'article', | ||||
|   const { autocompleteProps: articleAutocompleteProps } = useAutocomplete({ | ||||
|     resource: "article", | ||||
|     onSearch: (value) => [ | ||||
|       { | ||||
|         field: 'heading', | ||||
|         operator: 'contains', | ||||
|         field: "heading", | ||||
|         operator: "contains", | ||||
|         value, | ||||
|       }, | ||||
|     ], | ||||
|   }) | ||||
|   }); | ||||
|  | ||||
|   // Следим за изменениями во всех полях | ||||
|   const nameContent = watch('name') | ||||
|   const latitudeContent = watch('latitude') | ||||
|   const longitudeContent = watch('longitude') | ||||
|   const cityContent = watch('city_id') | ||||
|   const thumbnailContent = watch('thumbnail') | ||||
|   const watermarkLUContent = watch('watermark_lu') | ||||
|   const watermarkRDContent = watch('watermark_rd') | ||||
|   const leftArticleContent = watch('left_article') | ||||
|   const previewArticleContent = watch('preview_article') | ||||
|   const nameContent = watch("name"); | ||||
|   const latitudeContent = watch("latitude"); | ||||
|   const longitudeContent = watch("longitude"); | ||||
|   const cityContent = watch("city_id"); | ||||
|   const thumbnailContent = watch("thumbnail"); | ||||
|   const watermarkLUContent = watch("watermark_lu"); | ||||
|   const watermarkRDContent = watch("watermark_rd"); | ||||
|   const leftArticleContent = watch("left_article"); | ||||
|   const previewArticleContent = watch("preview_article"); | ||||
|  | ||||
|   // Обновляем состояния при изменении полей | ||||
|   useEffect(() => { | ||||
|     setNamePreview(nameContent || '') | ||||
|   }, [nameContent]) | ||||
|     setNamePreview(nameContent || ""); | ||||
|   }, [nameContent]); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     setCoordinatesPreview({ | ||||
|       latitude: latitudeContent || '', | ||||
|       longitude: longitudeContent || '', | ||||
|     }) | ||||
|   }, [latitudeContent, longitudeContent]) | ||||
|       latitude: latitudeContent || "", | ||||
|       longitude: longitudeContent || "", | ||||
|     }); | ||||
|   }, [latitudeContent, longitudeContent]); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     const selectedCity = cityAutocompleteProps.options.find((option) => option.id === cityContent) | ||||
|     setCityPreview(selectedCity?.name || '') | ||||
|   }, [cityContent, cityAutocompleteProps.options]) | ||||
|     const selectedCity = cityAutocompleteProps.options.find( | ||||
|       (option) => option.id === cityContent | ||||
|     ); | ||||
|     setCityPreview(selectedCity?.name || ""); | ||||
|   }, [cityContent, cityAutocompleteProps.options]); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     const selectedThumbnail = mediaAutocompleteProps.options.find((option) => option.id === thumbnailContent) | ||||
|     setThumbnailPreview(selectedThumbnail ? `https://wn.krbl.ru/media/${selectedThumbnail.id}/download?token=${localStorage.getItem(TOKEN_KEY)}` : null) | ||||
|   }, [thumbnailContent, mediaAutocompleteProps.options]) | ||||
|     const selectedThumbnail = mediaAutocompleteProps.options.find( | ||||
|       (option) => option.id === thumbnailContent | ||||
|     ); | ||||
|     setThumbnailPreview( | ||||
|       selectedThumbnail | ||||
|         ? `${import.meta.env.VITE_KRBL_MEDIA}${ | ||||
|             selectedThumbnail.id | ||||
|           }/download?token=${localStorage.getItem(TOKEN_KEY)}` | ||||
|         : null | ||||
|     ); | ||||
|   }, [thumbnailContent, mediaAutocompleteProps.options]); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     const selectedWatermarkLU = mediaAutocompleteProps.options.find((option) => option.id === watermarkLUContent) | ||||
|     setWatermarkLUPreview(selectedWatermarkLU ? `https://wn.krbl.ru/media/${selectedWatermarkLU.id}/download?token=${localStorage.getItem(TOKEN_KEY)}` : null) | ||||
|   }, [watermarkLUContent, mediaAutocompleteProps.options]) | ||||
|     const selectedWatermarkLU = mediaAutocompleteProps.options.find( | ||||
|       (option) => option.id === watermarkLUContent | ||||
|     ); | ||||
|     setWatermarkLUPreview( | ||||
|       selectedWatermarkLU | ||||
|         ? `${import.meta.env.VITE_KRBL_MEDIA}${ | ||||
|             selectedWatermarkLU.id | ||||
|           }/download?token=${localStorage.getItem(TOKEN_KEY)}` | ||||
|         : null | ||||
|     ); | ||||
|   }, [watermarkLUContent, mediaAutocompleteProps.options]); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     const selectedWatermarkRD = mediaAutocompleteProps.options.find((option) => option.id === watermarkRDContent) | ||||
|     setWatermarkRDPreview(selectedWatermarkRD ? `https://wn.krbl.ru/media/${selectedWatermarkRD.id}/download?token=${localStorage.getItem(TOKEN_KEY)}` : null) | ||||
|   }, [watermarkRDContent, mediaAutocompleteProps.options]) | ||||
|     const selectedWatermarkRD = mediaAutocompleteProps.options.find( | ||||
|       (option) => option.id === watermarkRDContent | ||||
|     ); | ||||
|     setWatermarkRDPreview( | ||||
|       selectedWatermarkRD | ||||
|         ? `${import.meta.env.VITE_KRBL_MEDIA}${ | ||||
|             selectedWatermarkRD.id | ||||
|           }/download?token=${localStorage.getItem(TOKEN_KEY)}` | ||||
|         : null | ||||
|     ); | ||||
|   }, [watermarkRDContent, mediaAutocompleteProps.options]); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     const selectedLeftArticle = articleAutocompleteProps.options.find((option) => option.id === leftArticleContent) | ||||
|     setLeftArticlePreview(selectedLeftArticle?.heading || '') | ||||
|   }, [leftArticleContent, articleAutocompleteProps.options]) | ||||
|     const selectedLeftArticle = articleAutocompleteProps.options.find( | ||||
|       (option) => option.id === leftArticleContent | ||||
|     ); | ||||
|     setLeftArticlePreview(selectedLeftArticle?.heading || ""); | ||||
|   }, [leftArticleContent, articleAutocompleteProps.options]); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     const selectedPreviewArticle = articleAutocompleteProps.options.find((option) => option.id === previewArticleContent) | ||||
|     setPreviewArticlePreview(selectedPreviewArticle?.heading || '') | ||||
|   }, [previewArticleContent, articleAutocompleteProps.options]) | ||||
|     const selectedPreviewArticle = articleAutocompleteProps.options.find( | ||||
|       (option) => option.id === previewArticleContent | ||||
|     ); | ||||
|     setPreviewArticlePreview(selectedPreviewArticle?.heading || ""); | ||||
|   }, [previewArticleContent, articleAutocompleteProps.options]); | ||||
|  | ||||
|   return ( | ||||
|     <Create isLoading={formLoading} saveButtonProps={saveButtonProps}> | ||||
|       <Box sx={{display: 'flex', gap: 2}}> | ||||
|       <Box sx={{ display: "flex", gap: 2 }}> | ||||
|         {/* Форма создания */} | ||||
|         <Box component="form" sx={{flex: 1, display: 'flex', flexDirection: 'column'}} autoComplete="off"> | ||||
|         <Box | ||||
|           component="form" | ||||
|           sx={{ flex: 1, display: "flex", flexDirection: "column" }} | ||||
|           autoComplete="off" | ||||
|         > | ||||
|           <TextField | ||||
|             {...register('name', { | ||||
|               required: 'Это поле является обязательным', | ||||
|             {...register("name", { | ||||
|               required: "Это поле является обязательным", | ||||
|             })} | ||||
|             error={!!(errors as any)?.name} | ||||
|             helperText={(errors as any)?.name?.message} | ||||
|             margin="normal" | ||||
|             fullWidth | ||||
|             InputLabelProps={{shrink: true}} | ||||
|             InputLabelProps={{ shrink: true }} | ||||
|             type="text" | ||||
|             label={'Название *'} | ||||
|             label={"Название *"} | ||||
|             name="name" | ||||
|           /> | ||||
|           <TextField | ||||
|             {...register('latitude', { | ||||
|               required: 'Это поле является обязательным', | ||||
|             {...register("latitude", { | ||||
|               required: "Это поле является обязательным", | ||||
|               valueAsNumber: true, | ||||
|             })} | ||||
|             error={!!(errors as any)?.latitude} | ||||
|             helperText={(errors as any)?.latitude?.message} | ||||
|             margin="normal" | ||||
|             fullWidth | ||||
|             InputLabelProps={{shrink: true}} | ||||
|             InputLabelProps={{ shrink: true }} | ||||
|             type="number" | ||||
|             label={'Широта *'} | ||||
|             label={"Широта *"} | ||||
|             name="latitude" | ||||
|           /> | ||||
|           <TextField | ||||
|             {...register('longitude', { | ||||
|               required: 'Это поле является обязательным', | ||||
|             {...register("longitude", { | ||||
|               required: "Это поле является обязательным", | ||||
|               valueAsNumber: true, | ||||
|             })} | ||||
|             error={!!(errors as any)?.longitude} | ||||
|             helperText={(errors as any)?.longitude?.message} | ||||
|             margin="normal" | ||||
|             fullWidth | ||||
|             InputLabelProps={{shrink: true}} | ||||
|             InputLabelProps={{ shrink: true }} | ||||
|             type="number" | ||||
|             label={'Долгота *'} | ||||
|             label={"Долгота *"} | ||||
|             name="longitude" | ||||
|           /> | ||||
|  | ||||
|           <Controller | ||||
|             control={control} | ||||
|             name="city_id" | ||||
|             rules={{required: 'Это поле является обязательным'}} | ||||
|             rules={{ required: "Это поле является обязательным" }} | ||||
|             defaultValue={null} | ||||
|             render={({field}) => ( | ||||
|             render={({ field }) => ( | ||||
|               <Autocomplete | ||||
|                 {...cityAutocompleteProps} | ||||
|                 value={cityAutocompleteProps.options.find((option) => option.id === field.value) || null} | ||||
|                 value={ | ||||
|                   cityAutocompleteProps.options.find( | ||||
|                     (option) => option.id === field.value | ||||
|                   ) || null | ||||
|                 } | ||||
|                 onChange={(_, value) => { | ||||
|                   field.onChange(value?.id || '') | ||||
|                   field.onChange(value?.id || ""); | ||||
|                 }} | ||||
|                 getOptionLabel={(item) => { | ||||
|                   return item ? item.name : '' | ||||
|                   return item ? item.name : ""; | ||||
|                 }} | ||||
|                 isOptionEqualToValue={(option, value) => { | ||||
|                   return option.id === value?.id | ||||
|                   return option.id === value?.id; | ||||
|                 }} | ||||
|                 filterOptions={(options, {inputValue}) => { | ||||
|                   return options.filter((option) => option.name.toLowerCase().includes(inputValue.toLowerCase())) | ||||
|                 filterOptions={(options, { inputValue }) => { | ||||
|                   return options.filter((option) => | ||||
|                     option.name.toLowerCase().includes(inputValue.toLowerCase()) | ||||
|                   ); | ||||
|                 }} | ||||
|                 renderInput={(params) => <TextField {...params} label="Выберите город" margin="normal" variant="outlined" error={!!errors.city_id} helperText={(errors as any)?.city_id?.message} required />} | ||||
|                 renderInput={(params) => ( | ||||
|                   <TextField | ||||
|                     {...params} | ||||
|                     label="Выберите город" | ||||
|                     margin="normal" | ||||
|                     variant="outlined" | ||||
|                     error={!!errors.city_id} | ||||
|                     helperText={(errors as any)?.city_id?.message} | ||||
|                     required | ||||
|                   /> | ||||
|                 )} | ||||
|               /> | ||||
|             )} | ||||
|           /> | ||||
| @@ -194,23 +251,41 @@ export const SightCreate = () => { | ||||
|             control={control} | ||||
|             name="thumbnail" | ||||
|             defaultValue={null} | ||||
|             render={({field}) => ( | ||||
|             render={({ field }) => ( | ||||
|               <Autocomplete | ||||
|                 {...mediaAutocompleteProps} | ||||
|                 value={mediaAutocompleteProps.options.find((option) => option.id === field.value) || null} | ||||
|                 value={ | ||||
|                   mediaAutocompleteProps.options.find( | ||||
|                     (option) => option.id === field.value | ||||
|                   ) || null | ||||
|                 } | ||||
|                 onChange={(_, value) => { | ||||
|                   field.onChange(value?.id || '') | ||||
|                   field.onChange(value?.id || ""); | ||||
|                 }} | ||||
|                 getOptionLabel={(item) => { | ||||
|                   return item ? item.media_name : '' | ||||
|                   return item ? item.media_name : ""; | ||||
|                 }} | ||||
|                 isOptionEqualToValue={(option, value) => { | ||||
|                   return option.id === value?.id | ||||
|                   return option.id === value?.id; | ||||
|                 }} | ||||
|                 filterOptions={(options, {inputValue}) => { | ||||
|                   return options.filter((option) => option.media_name.toLowerCase().includes(inputValue.toLowerCase())) | ||||
|                 filterOptions={(options, { inputValue }) => { | ||||
|                   return options.filter((option) => | ||||
|                     option.media_name | ||||
|                       .toLowerCase() | ||||
|                       .includes(inputValue.toLowerCase()) | ||||
|                   ); | ||||
|                 }} | ||||
|                 renderInput={(params) => <TextField {...params} label="Выберите обложку" margin="normal" variant="outlined" error={!!errors.arms} helperText={(errors as any)?.arms?.message} required />} | ||||
|                 renderInput={(params) => ( | ||||
|                   <TextField | ||||
|                     {...params} | ||||
|                     label="Выберите обложку" | ||||
|                     margin="normal" | ||||
|                     variant="outlined" | ||||
|                     error={!!errors.arms} | ||||
|                     helperText={(errors as any)?.arms?.message} | ||||
|                     required | ||||
|                   /> | ||||
|                 )} | ||||
|               /> | ||||
|             )} | ||||
|           /> | ||||
| @@ -219,23 +294,41 @@ export const SightCreate = () => { | ||||
|             control={control} | ||||
|             name="watermark_lu" | ||||
|             defaultValue={null} | ||||
|             render={({field}) => ( | ||||
|             render={({ field }) => ( | ||||
|               <Autocomplete | ||||
|                 {...mediaAutocompleteProps} | ||||
|                 value={mediaAutocompleteProps.options.find((option) => option.id === field.value) || null} | ||||
|                 value={ | ||||
|                   mediaAutocompleteProps.options.find( | ||||
|                     (option) => option.id === field.value | ||||
|                   ) || null | ||||
|                 } | ||||
|                 onChange={(_, value) => { | ||||
|                   field.onChange(value?.id || '') | ||||
|                   field.onChange(value?.id || ""); | ||||
|                 }} | ||||
|                 getOptionLabel={(item) => { | ||||
|                   return item ? item.media_name : '' | ||||
|                   return item ? item.media_name : ""; | ||||
|                 }} | ||||
|                 isOptionEqualToValue={(option, value) => { | ||||
|                   return option.id === value?.id | ||||
|                   return option.id === value?.id; | ||||
|                 }} | ||||
|                 filterOptions={(options, {inputValue}) => { | ||||
|                   return options.filter((option) => option.media_name.toLowerCase().includes(inputValue.toLowerCase())) | ||||
|                 filterOptions={(options, { inputValue }) => { | ||||
|                   return options.filter((option) => | ||||
|                     option.media_name | ||||
|                       .toLowerCase() | ||||
|                       .includes(inputValue.toLowerCase()) | ||||
|                   ); | ||||
|                 }} | ||||
|                 renderInput={(params) => <TextField {...params} label="Выберите водный знак (Левый верх)" margin="normal" variant="outlined" error={!!errors.arms} helperText={(errors as any)?.arms?.message} required />} | ||||
|                 renderInput={(params) => ( | ||||
|                   <TextField | ||||
|                     {...params} | ||||
|                     label="Выберите водный знак (Левый верх)" | ||||
|                     margin="normal" | ||||
|                     variant="outlined" | ||||
|                     error={!!errors.arms} | ||||
|                     helperText={(errors as any)?.arms?.message} | ||||
|                     required | ||||
|                   /> | ||||
|                 )} | ||||
|               /> | ||||
|             )} | ||||
|           /> | ||||
| @@ -244,23 +337,41 @@ export const SightCreate = () => { | ||||
|             control={control} | ||||
|             name="watermark_rd" | ||||
|             defaultValue={null} | ||||
|             render={({field}) => ( | ||||
|             render={({ field }) => ( | ||||
|               <Autocomplete | ||||
|                 {...mediaAutocompleteProps} | ||||
|                 value={mediaAutocompleteProps.options.find((option) => option.id === field.value) || null} | ||||
|                 value={ | ||||
|                   mediaAutocompleteProps.options.find( | ||||
|                     (option) => option.id === field.value | ||||
|                   ) || null | ||||
|                 } | ||||
|                 onChange={(_, value) => { | ||||
|                   field.onChange(value?.id || '') | ||||
|                   field.onChange(value?.id || ""); | ||||
|                 }} | ||||
|                 getOptionLabel={(item) => { | ||||
|                   return item ? item.media_name : '' | ||||
|                   return item ? item.media_name : ""; | ||||
|                 }} | ||||
|                 isOptionEqualToValue={(option, value) => { | ||||
|                   return option.id === value?.id | ||||
|                   return option.id === value?.id; | ||||
|                 }} | ||||
|                 filterOptions={(options, {inputValue}) => { | ||||
|                   return options.filter((option) => option.media_name.toLowerCase().includes(inputValue.toLowerCase())) | ||||
|                 filterOptions={(options, { inputValue }) => { | ||||
|                   return options.filter((option) => | ||||
|                     option.media_name | ||||
|                       .toLowerCase() | ||||
|                       .includes(inputValue.toLowerCase()) | ||||
|                   ); | ||||
|                 }} | ||||
|                 renderInput={(params) => <TextField {...params} label="Выберите водный знак (Правый низ)" margin="normal" variant="outlined" error={!!errors.arms} helperText={(errors as any)?.arms?.message} required />} | ||||
|                 renderInput={(params) => ( | ||||
|                   <TextField | ||||
|                     {...params} | ||||
|                     label="Выберите водный знак (Правый низ)" | ||||
|                     margin="normal" | ||||
|                     variant="outlined" | ||||
|                     error={!!errors.arms} | ||||
|                     helperText={(errors as any)?.arms?.message} | ||||
|                     required | ||||
|                   /> | ||||
|                 )} | ||||
|               /> | ||||
|             )} | ||||
|           /> | ||||
| @@ -269,23 +380,41 @@ export const SightCreate = () => { | ||||
|             control={control} | ||||
|             name="left_article" | ||||
|             defaultValue={null} | ||||
|             render={({field}) => ( | ||||
|             render={({ field }) => ( | ||||
|               <Autocomplete | ||||
|                 {...articleAutocompleteProps} | ||||
|                 value={articleAutocompleteProps.options.find((option) => option.id === field.value) || null} | ||||
|                 value={ | ||||
|                   articleAutocompleteProps.options.find( | ||||
|                     (option) => option.id === field.value | ||||
|                   ) || null | ||||
|                 } | ||||
|                 onChange={(_, value) => { | ||||
|                   field.onChange(value?.id || '') | ||||
|                   field.onChange(value?.id || ""); | ||||
|                 }} | ||||
|                 getOptionLabel={(item) => { | ||||
|                   return item ? item.heading : '' | ||||
|                   return item ? item.heading : ""; | ||||
|                 }} | ||||
|                 isOptionEqualToValue={(option, value) => { | ||||
|                   return option.id === value?.id | ||||
|                   return option.id === value?.id; | ||||
|                 }} | ||||
|                 filterOptions={(options, {inputValue}) => { | ||||
|                   return options.filter((option) => option.heading.toLowerCase().includes(inputValue.toLowerCase())) | ||||
|                 filterOptions={(options, { inputValue }) => { | ||||
|                   return options.filter((option) => | ||||
|                     option.heading | ||||
|                       .toLowerCase() | ||||
|                       .includes(inputValue.toLowerCase()) | ||||
|                   ); | ||||
|                 }} | ||||
|                 renderInput={(params) => <TextField {...params} label="Левая статья" margin="normal" variant="outlined" error={!!errors.arms} helperText={(errors as any)?.arms?.message} required />} | ||||
|                 renderInput={(params) => ( | ||||
|                   <TextField | ||||
|                     {...params} | ||||
|                     label="Левая статья" | ||||
|                     margin="normal" | ||||
|                     variant="outlined" | ||||
|                     error={!!errors.arms} | ||||
|                     helperText={(errors as any)?.arms?.message} | ||||
|                     required | ||||
|                   /> | ||||
|                 )} | ||||
|               /> | ||||
|             )} | ||||
|           /> | ||||
| @@ -294,23 +423,41 @@ export const SightCreate = () => { | ||||
|             control={control} | ||||
|             name="preview_article" | ||||
|             defaultValue={null} | ||||
|             render={({field}) => ( | ||||
|             render={({ field }) => ( | ||||
|               <Autocomplete | ||||
|                 {...articleAutocompleteProps} | ||||
|                 value={articleAutocompleteProps.options.find((option) => option.id === field.value) || null} | ||||
|                 value={ | ||||
|                   articleAutocompleteProps.options.find( | ||||
|                     (option) => option.id === field.value | ||||
|                   ) || null | ||||
|                 } | ||||
|                 onChange={(_, value) => { | ||||
|                   field.onChange(value?.id || '') | ||||
|                   field.onChange(value?.id || ""); | ||||
|                 }} | ||||
|                 getOptionLabel={(item) => { | ||||
|                   return item ? item.heading : '' | ||||
|                   return item ? item.heading : ""; | ||||
|                 }} | ||||
|                 isOptionEqualToValue={(option, value) => { | ||||
|                   return option.id === value?.id | ||||
|                   return option.id === value?.id; | ||||
|                 }} | ||||
|                 filterOptions={(options, {inputValue}) => { | ||||
|                   return options.filter((option) => option.heading.toLowerCase().includes(inputValue.toLowerCase())) | ||||
|                 filterOptions={(options, { inputValue }) => { | ||||
|                   return options.filter((option) => | ||||
|                     option.heading | ||||
|                       .toLowerCase() | ||||
|                       .includes(inputValue.toLowerCase()) | ||||
|                   ); | ||||
|                 }} | ||||
|                 renderInput={(params) => <TextField {...params} label="Cтатья-предпросмотр" margin="normal" variant="outlined" error={!!errors.arms} helperText={(errors as any)?.arms?.message} required />} | ||||
|                 renderInput={(params) => ( | ||||
|                   <TextField | ||||
|                     {...params} | ||||
|                     label="Cтатья-предпросмотр" | ||||
|                     margin="normal" | ||||
|                     variant="outlined" | ||||
|                     error={!!errors.arms} | ||||
|                     helperText={(errors as any)?.arms?.message} | ||||
|                     required | ||||
|                   /> | ||||
|                 )} | ||||
|               /> | ||||
|             )} | ||||
|           /> | ||||
| @@ -321,14 +468,15 @@ export const SightCreate = () => { | ||||
|           sx={{ | ||||
|             flex: 1, | ||||
|             p: 2, | ||||
|             maxHeight: 'calc(100vh - 200px)', | ||||
|             overflowY: 'auto', | ||||
|             position: 'sticky', | ||||
|             maxHeight: "calc(100vh - 200px)", | ||||
|             overflowY: "auto", | ||||
|             position: "sticky", | ||||
|             top: 16, | ||||
|             borderRadius: 2, | ||||
|             border: '1px solid', | ||||
|             borderColor: 'primary.main', | ||||
|             bgcolor: (theme) => (theme.palette.mode === 'dark' ? 'background.paper' : '#fff'), | ||||
|             border: "1px solid", | ||||
|             borderColor: "primary.main", | ||||
|             bgcolor: (theme) => | ||||
|               theme.palette.mode === "dark" ? "background.paper" : "#fff", | ||||
|           }} | ||||
|         > | ||||
|           <Typography variant="h6" gutterBottom color="primary"> | ||||
| @@ -336,34 +484,57 @@ export const SightCreate = () => { | ||||
|           </Typography> | ||||
|  | ||||
|           {/* Название */} | ||||
|           <Typography variant="h4" gutterBottom sx={{color: (theme) => (theme.palette.mode === 'dark' ? 'grey.300' : 'grey.800')}}> | ||||
|           <Typography | ||||
|             variant="h4" | ||||
|             gutterBottom | ||||
|             sx={{ | ||||
|               color: (theme) => | ||||
|                 theme.palette.mode === "dark" ? "grey.300" : "grey.800", | ||||
|             }} | ||||
|           > | ||||
|             {namePreview} | ||||
|           </Typography> | ||||
|  | ||||
|           {/* Город */} | ||||
|           <Typography variant="body1" sx={{mb: 2}}> | ||||
|             <Box component="span" sx={{color: 'text.secondary'}}> | ||||
|               Город:{' '} | ||||
|           <Typography variant="body1" sx={{ mb: 2 }}> | ||||
|             <Box component="span" sx={{ color: "text.secondary" }}> | ||||
|               Город:{" "} | ||||
|             </Box> | ||||
|             <Box component="span" sx={{color: (theme) => (theme.palette.mode === 'dark' ? 'grey.300' : 'grey.800')}}> | ||||
|             <Box | ||||
|               component="span" | ||||
|               sx={{ | ||||
|                 color: (theme) => | ||||
|                   theme.palette.mode === "dark" ? "grey.300" : "grey.800", | ||||
|               }} | ||||
|             > | ||||
|               {cityPreview} | ||||
|             </Box> | ||||
|           </Typography> | ||||
|  | ||||
|           {/* Координаты */} | ||||
|           <Typography variant="body1" sx={{mb: 2}}> | ||||
|             <Box component="span" sx={{color: 'text.secondary'}}> | ||||
|               Координаты:{' '} | ||||
|           <Typography variant="body1" sx={{ mb: 2 }}> | ||||
|             <Box component="span" sx={{ color: "text.secondary" }}> | ||||
|               Координаты:{" "} | ||||
|             </Box> | ||||
|             <Box component="span" sx={{color: (theme) => (theme.palette.mode === 'dark' ? 'grey.300' : 'grey.800')}}> | ||||
|             <Box | ||||
|               component="span" | ||||
|               sx={{ | ||||
|                 color: (theme) => | ||||
|                   theme.palette.mode === "dark" ? "grey.300" : "grey.800", | ||||
|               }} | ||||
|             > | ||||
|               {coordinatesPreview.latitude}, {coordinatesPreview.longitude} | ||||
|             </Box> | ||||
|           </Typography> | ||||
|  | ||||
|           {/* Обложка */} | ||||
|           {thumbnailPreview && ( | ||||
|             <Box sx={{mb: 2}}> | ||||
|               <Typography variant="body1" gutterBottom sx={{color: 'text.secondary'}}> | ||||
|             <Box sx={{ mb: 2 }}> | ||||
|               <Typography | ||||
|                 variant="body1" | ||||
|                 gutterBottom | ||||
|                 sx={{ color: "text.secondary" }} | ||||
|               > | ||||
|                 Обложка: | ||||
|               </Typography> | ||||
|               <Box | ||||
| @@ -371,25 +542,33 @@ export const SightCreate = () => { | ||||
|                 src={thumbnailPreview} | ||||
|                 alt="Обложка" | ||||
|                 sx={{ | ||||
|                   maxWidth: '100%', | ||||
|                   height: 'auto', | ||||
|                   maxWidth: "100%", | ||||
|                   height: "auto", | ||||
|                   borderRadius: 1, | ||||
|                   border: '1px solid', | ||||
|                   borderColor: 'primary.main', | ||||
|                   border: "1px solid", | ||||
|                   borderColor: "primary.main", | ||||
|                 }} | ||||
|               /> | ||||
|             </Box> | ||||
|           )} | ||||
|  | ||||
|           {/* Водяные знаки */} | ||||
|           <Box sx={{mb: 2}}> | ||||
|             <Typography variant="body1" gutterBottom sx={{color: 'text.secondary'}}> | ||||
|           <Box sx={{ mb: 2 }}> | ||||
|             <Typography | ||||
|               variant="body1" | ||||
|               gutterBottom | ||||
|               sx={{ color: "text.secondary" }} | ||||
|             > | ||||
|               Водяные знаки: | ||||
|             </Typography> | ||||
|             <Box sx={{display: 'flex', gap: 2}}> | ||||
|             <Box sx={{ display: "flex", gap: 2 }}> | ||||
|               {watermarkLUPreview && ( | ||||
|                 <Box> | ||||
|                   <Typography variant="body2" gutterBottom sx={{color: 'text.secondary'}}> | ||||
|                   <Typography | ||||
|                     variant="body2" | ||||
|                     gutterBottom | ||||
|                     sx={{ color: "text.secondary" }} | ||||
|                   > | ||||
|                     Левый верхний: | ||||
|                   </Typography> | ||||
|                   <Box | ||||
| @@ -399,17 +578,21 @@ export const SightCreate = () => { | ||||
|                     sx={{ | ||||
|                       width: 100, | ||||
|                       height: 100, | ||||
|                       objectFit: 'cover', | ||||
|                       objectFit: "cover", | ||||
|                       borderRadius: 1, | ||||
|                       border: '1px solid', | ||||
|                       borderColor: 'primary.main', | ||||
|                       border: "1px solid", | ||||
|                       borderColor: "primary.main", | ||||
|                     }} | ||||
|                   /> | ||||
|                 </Box> | ||||
|               )} | ||||
|               {watermarkRDPreview && ( | ||||
|                 <Box> | ||||
|                   <Typography variant="body2" gutterBottom sx={{color: 'text.secondary'}}> | ||||
|                   <Typography | ||||
|                     variant="body2" | ||||
|                     gutterBottom | ||||
|                     sx={{ color: "text.secondary" }} | ||||
|                   > | ||||
|                     Правый нижний: | ||||
|                   </Typography> | ||||
|                   <Box | ||||
| @@ -419,10 +602,10 @@ export const SightCreate = () => { | ||||
|                     sx={{ | ||||
|                       width: 100, | ||||
|                       height: 100, | ||||
|                       objectFit: 'cover', | ||||
|                       objectFit: "cover", | ||||
|                       borderRadius: 1, | ||||
|                       border: '1px solid', | ||||
|                       borderColor: 'primary.main', | ||||
|                       border: "1px solid", | ||||
|                       borderColor: "primary.main", | ||||
|                     }} | ||||
|                   /> | ||||
|                 </Box> | ||||
| @@ -432,22 +615,27 @@ export const SightCreate = () => { | ||||
|  | ||||
|           {/* Связанные статьи */} | ||||
|           <Box> | ||||
|             <Typography variant="body1" gutterBottom sx={{color: 'text.secondary'}}> | ||||
|             <Typography | ||||
|               variant="body1" | ||||
|               gutterBottom | ||||
|               sx={{ color: "text.secondary" }} | ||||
|             > | ||||
|               Связанные статьи: | ||||
|             </Typography> | ||||
|             {leftArticlePreview && ( | ||||
|               <Typography variant="body1" gutterBottom> | ||||
|                 <Box component="span" sx={{color: 'text.secondary'}}> | ||||
|                   Левая статья:{' '} | ||||
|                 <Box component="span" sx={{ color: "text.secondary" }}> | ||||
|                   Левая статья:{" "} | ||||
|                 </Box> | ||||
|                 <Box | ||||
|                   component={Link} | ||||
|                   to={`/article/show/${watch('left_article')}`} | ||||
|                   to={`/article/show/${watch("left_article")}`} | ||||
|                   sx={{ | ||||
|                     color: (theme) => (theme.palette.mode === 'dark' ? 'grey.300' : 'grey.800'), | ||||
|                     textDecoration: 'none', | ||||
|                     '&:hover': { | ||||
|                       textDecoration: 'underline', | ||||
|                     color: (theme) => | ||||
|                       theme.palette.mode === "dark" ? "grey.300" : "grey.800", | ||||
|                     textDecoration: "none", | ||||
|                     "&:hover": { | ||||
|                       textDecoration: "underline", | ||||
|                     }, | ||||
|                   }} | ||||
|                 > | ||||
| @@ -457,17 +645,18 @@ export const SightCreate = () => { | ||||
|             )} | ||||
|             {previewArticlePreview && ( | ||||
|               <Typography variant="body1" gutterBottom> | ||||
|                 <Box component="span" sx={{color: 'text.secondary'}}> | ||||
|                   Статья-предпросмотр:{' '} | ||||
|                 <Box component="span" sx={{ color: "text.secondary" }}> | ||||
|                   Статья-предпросмотр:{" "} | ||||
|                 </Box> | ||||
|                 <Box | ||||
|                   component={Link} | ||||
|                   to={`/article/show/${watch('preview_article')}`} | ||||
|                   to={`/article/show/${watch("preview_article")}`} | ||||
|                   sx={{ | ||||
|                     color: (theme) => (theme.palette.mode === 'dark' ? 'grey.300' : 'grey.800'), | ||||
|                     textDecoration: 'none', | ||||
|                     '&:hover': { | ||||
|                       textDecoration: 'underline', | ||||
|                     color: (theme) => | ||||
|                       theme.palette.mode === "dark" ? "grey.300" : "grey.800", | ||||
|                     textDecoration: "none", | ||||
|                     "&:hover": { | ||||
|                       textDecoration: "underline", | ||||
|                     }, | ||||
|                   }} | ||||
|                 > | ||||
| @@ -479,5 +668,5 @@ export const SightCreate = () => { | ||||
|         </Paper> | ||||
|       </Box> | ||||
|     </Create> | ||||
|   ) | ||||
| } | ||||
|   ); | ||||
| }; | ||||
|   | ||||
| @@ -1,194 +1,248 @@ | ||||
| import {Autocomplete, Box, TextField, Paper, Typography} from '@mui/material' | ||||
| import {Edit, useAutocomplete} from '@refinedev/mui' | ||||
| import {useForm} from '@refinedev/react-hook-form' | ||||
| import {Controller} from 'react-hook-form' | ||||
| import {useParams} from 'react-router' | ||||
| import React, {useState, useEffect} from 'react' | ||||
| import {LinkedItems} from '../../components/LinkedItems' | ||||
| import {CreateSightArticle} from '../../components/CreateSightArticle' | ||||
| import {ArticleItem, articleFields} from './types' | ||||
| import {TOKEN_KEY} from '../../authProvider' | ||||
| import {Link} from 'react-router' | ||||
| import { Autocomplete, Box, TextField, Paper, Typography } from "@mui/material"; | ||||
| import { Edit, useAutocomplete } from "@refinedev/mui"; | ||||
| import { useForm } from "@refinedev/react-hook-form"; | ||||
| import { Controller } from "react-hook-form"; | ||||
| import { useParams } from "react-router"; | ||||
| import React, { useState, useEffect } from "react"; | ||||
| import { LinkedItems } from "../../components/LinkedItems"; | ||||
| import { CreateSightArticle } from "../../components/CreateSightArticle"; | ||||
| import { ArticleItem, articleFields } from "./types"; | ||||
| import { TOKEN_KEY } from "../../authProvider"; | ||||
| import { Link } from "react-router"; | ||||
|  | ||||
| export const SightEdit = () => { | ||||
|   const {id: sightId} = useParams<{id: string}>() | ||||
|   const { id: sightId } = useParams<{ id: string }>(); | ||||
|  | ||||
|   const { | ||||
|     saveButtonProps, | ||||
|     register, | ||||
|     control, | ||||
|     watch, | ||||
|     formState: {errors}, | ||||
|   } = useForm({}) | ||||
|     formState: { errors }, | ||||
|   } = useForm({}); | ||||
|  | ||||
|   const {autocompleteProps: cityAutocompleteProps} = useAutocomplete({ | ||||
|     resource: 'city', | ||||
|   const { autocompleteProps: cityAutocompleteProps } = useAutocomplete({ | ||||
|     resource: "city", | ||||
|     onSearch: (value) => [ | ||||
|       { | ||||
|         field: 'name', | ||||
|         operator: 'contains', | ||||
|         field: "name", | ||||
|         operator: "contains", | ||||
|         value, | ||||
|       }, | ||||
|     ], | ||||
|   }) | ||||
|   }); | ||||
|  | ||||
|   const {autocompleteProps: mediaAutocompleteProps} = useAutocomplete({ | ||||
|     resource: 'media', | ||||
|   const { autocompleteProps: mediaAutocompleteProps } = useAutocomplete({ | ||||
|     resource: "media", | ||||
|     onSearch: (value) => [ | ||||
|       { | ||||
|         field: 'media_name', | ||||
|         operator: 'contains', | ||||
|         field: "media_name", | ||||
|         operator: "contains", | ||||
|         value, | ||||
|       }, | ||||
|     ], | ||||
|   }) | ||||
|   }); | ||||
|  | ||||
|   const {autocompleteProps: articleAutocompleteProps} = useAutocomplete({ | ||||
|     resource: 'article', | ||||
|   const { autocompleteProps: articleAutocompleteProps } = useAutocomplete({ | ||||
|     resource: "article", | ||||
|     onSearch: (value) => [ | ||||
|       { | ||||
|         field: 'heading', | ||||
|         operator: 'contains', | ||||
|         field: "heading", | ||||
|         operator: "contains", | ||||
|         value, | ||||
|       }, | ||||
|     ], | ||||
|   }) | ||||
|   }); | ||||
|  | ||||
|   // Состояния для предпросмотра | ||||
|   const [namePreview, setNamePreview] = useState('') | ||||
|   const [namePreview, setNamePreview] = useState(""); | ||||
|   const [coordinatesPreview, setCoordinatesPreview] = useState({ | ||||
|     latitude: '', | ||||
|     longitude: '', | ||||
|   }) | ||||
|   const [cityPreview, setCityPreview] = useState('') | ||||
|   const [thumbnailPreview, setThumbnailPreview] = useState<string | null>(null) | ||||
|   const [watermarkLUPreview, setWatermarkLUPreview] = useState<string | null>(null) | ||||
|   const [watermarkRDPreview, setWatermarkRDPreview] = useState<string | null>(null) | ||||
|   const [leftArticlePreview, setLeftArticlePreview] = useState('') | ||||
|   const [previewArticlePreview, setPreviewArticlePreview] = useState('') | ||||
|     latitude: "", | ||||
|     longitude: "", | ||||
|   }); | ||||
|   const [cityPreview, setCityPreview] = useState(""); | ||||
|   const [thumbnailPreview, setThumbnailPreview] = useState<string | null>(null); | ||||
|   const [watermarkLUPreview, setWatermarkLUPreview] = useState<string | null>( | ||||
|     null | ||||
|   ); | ||||
|   const [watermarkRDPreview, setWatermarkRDPreview] = useState<string | null>( | ||||
|     null | ||||
|   ); | ||||
|   const [leftArticlePreview, setLeftArticlePreview] = useState(""); | ||||
|   const [previewArticlePreview, setPreviewArticlePreview] = useState(""); | ||||
|  | ||||
|   // Следим за изменениями во всех полях | ||||
|   const nameContent = watch('name') | ||||
|   const latitudeContent = watch('latitude') | ||||
|   const longitudeContent = watch('longitude') | ||||
|   const cityContent = watch('city_id') | ||||
|   const thumbnailContent = watch('thumbnail') | ||||
|   const watermarkLUContent = watch('watermark_lu') | ||||
|   const watermarkRDContent = watch('watermark_rd') | ||||
|   const leftArticleContent = watch('left_article') | ||||
|   const previewArticleContent = watch('preview_article') | ||||
|   const nameContent = watch("name"); | ||||
|   const latitudeContent = watch("latitude"); | ||||
|   const longitudeContent = watch("longitude"); | ||||
|   const cityContent = watch("city_id"); | ||||
|   const thumbnailContent = watch("thumbnail"); | ||||
|   const watermarkLUContent = watch("watermark_lu"); | ||||
|   const watermarkRDContent = watch("watermark_rd"); | ||||
|   const leftArticleContent = watch("left_article"); | ||||
|   const previewArticleContent = watch("preview_article"); | ||||
|  | ||||
|   // Обновляем состояния при изменении полей | ||||
|   useEffect(() => { | ||||
|     setNamePreview(nameContent || '') | ||||
|   }, [nameContent]) | ||||
|     setNamePreview(nameContent || ""); | ||||
|   }, [nameContent]); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     setCoordinatesPreview({ | ||||
|       latitude: latitudeContent || '', | ||||
|       longitude: longitudeContent || '', | ||||
|     }) | ||||
|   }, [latitudeContent, longitudeContent]) | ||||
|       latitude: latitudeContent || "", | ||||
|       longitude: longitudeContent || "", | ||||
|     }); | ||||
|   }, [latitudeContent, longitudeContent]); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     const selectedCity = cityAutocompleteProps.options.find((option) => option.id === cityContent) | ||||
|     setCityPreview(selectedCity?.name || '') | ||||
|   }, [cityContent, cityAutocompleteProps.options]) | ||||
|     const selectedCity = cityAutocompleteProps.options.find( | ||||
|       (option) => option.id === cityContent | ||||
|     ); | ||||
|     setCityPreview(selectedCity?.name || ""); | ||||
|   }, [cityContent, cityAutocompleteProps.options]); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     const selectedThumbnail = mediaAutocompleteProps.options.find((option) => option.id === thumbnailContent) | ||||
|     setThumbnailPreview(selectedThumbnail ? `https://wn.krbl.ru/media/${selectedThumbnail.id}/download?token=${localStorage.getItem(TOKEN_KEY)}` : null) | ||||
|   }, [thumbnailContent, mediaAutocompleteProps.options]) | ||||
|     const selectedThumbnail = mediaAutocompleteProps.options.find( | ||||
|       (option) => option.id === thumbnailContent | ||||
|     ); | ||||
|     setThumbnailPreview( | ||||
|       selectedThumbnail | ||||
|         ? `${import.meta.env.VITE_KRBL_MEDIA}${ | ||||
|             selectedThumbnail.id | ||||
|           }/download?token=${localStorage.getItem(TOKEN_KEY)}` | ||||
|         : null | ||||
|     ); | ||||
|   }, [thumbnailContent, mediaAutocompleteProps.options]); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     const selectedWatermarkLU = mediaAutocompleteProps.options.find((option) => option.id === watermarkLUContent) | ||||
|     setWatermarkLUPreview(selectedWatermarkLU ? `https://wn.krbl.ru/media/${selectedWatermarkLU.id}/download?token=${localStorage.getItem(TOKEN_KEY)}` : null) | ||||
|   }, [watermarkLUContent, mediaAutocompleteProps.options]) | ||||
|     const selectedWatermarkLU = mediaAutocompleteProps.options.find( | ||||
|       (option) => option.id === watermarkLUContent | ||||
|     ); | ||||
|     setWatermarkLUPreview( | ||||
|       selectedWatermarkLU | ||||
|         ? `${import.meta.env.VITE_KRBL_MEDIA}${ | ||||
|             selectedWatermarkLU.id | ||||
|           }/download?token=${localStorage.getItem(TOKEN_KEY)}` | ||||
|         : null | ||||
|     ); | ||||
|   }, [watermarkLUContent, mediaAutocompleteProps.options]); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     const selectedWatermarkRD = mediaAutocompleteProps.options.find((option) => option.id === watermarkRDContent) | ||||
|     setWatermarkRDPreview(selectedWatermarkRD ? `https://wn.krbl.ru/media/${selectedWatermarkRD.id}/download?token=${localStorage.getItem(TOKEN_KEY)}` : null) | ||||
|   }, [watermarkRDContent, mediaAutocompleteProps.options]) | ||||
|     const selectedWatermarkRD = mediaAutocompleteProps.options.find( | ||||
|       (option) => option.id === watermarkRDContent | ||||
|     ); | ||||
|     setWatermarkRDPreview( | ||||
|       selectedWatermarkRD | ||||
|         ? `${import.meta.env.VITE_KRBL_MEDIA}${ | ||||
|             selectedWatermarkRD.id | ||||
|           }/download?token=${localStorage.getItem(TOKEN_KEY)}` | ||||
|         : null | ||||
|     ); | ||||
|   }, [watermarkRDContent, mediaAutocompleteProps.options]); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     const selectedLeftArticle = articleAutocompleteProps.options.find((option) => option.id === leftArticleContent) | ||||
|     setLeftArticlePreview(selectedLeftArticle?.heading || '') | ||||
|   }, [leftArticleContent, articleAutocompleteProps.options]) | ||||
|     const selectedLeftArticle = articleAutocompleteProps.options.find( | ||||
|       (option) => option.id === leftArticleContent | ||||
|     ); | ||||
|     setLeftArticlePreview(selectedLeftArticle?.heading || ""); | ||||
|   }, [leftArticleContent, articleAutocompleteProps.options]); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     const selectedPreviewArticle = articleAutocompleteProps.options.find((option) => option.id === previewArticleContent) | ||||
|     setPreviewArticlePreview(selectedPreviewArticle?.heading || '') | ||||
|   }, [previewArticleContent, articleAutocompleteProps.options]) | ||||
|     const selectedPreviewArticle = articleAutocompleteProps.options.find( | ||||
|       (option) => option.id === previewArticleContent | ||||
|     ); | ||||
|     setPreviewArticlePreview(selectedPreviewArticle?.heading || ""); | ||||
|   }, [previewArticleContent, articleAutocompleteProps.options]); | ||||
|  | ||||
|   return ( | ||||
|     <Edit saveButtonProps={saveButtonProps}> | ||||
|       <Box sx={{display: 'flex', gap: 2}}> | ||||
|       <Box sx={{ display: "flex", gap: 2 }}> | ||||
|         {/* Форма редактирования */} | ||||
|         <Box component="form" sx={{flex: 1, display: 'flex', flexDirection: 'column'}} autoComplete="off"> | ||||
|         <Box | ||||
|           component="form" | ||||
|           sx={{ flex: 1, display: "flex", flexDirection: "column" }} | ||||
|           autoComplete="off" | ||||
|         > | ||||
|           <TextField | ||||
|             {...register('name', { | ||||
|               required: 'Это поле является обязательным', | ||||
|             {...register("name", { | ||||
|               required: "Это поле является обязательным", | ||||
|             })} | ||||
|             error={!!(errors as any)?.name} | ||||
|             helperText={(errors as any)?.name?.message} | ||||
|             margin="normal" | ||||
|             fullWidth | ||||
|             InputLabelProps={{shrink: true}} | ||||
|             InputLabelProps={{ shrink: true }} | ||||
|             type="text" | ||||
|             label={'Название *'} | ||||
|             label={"Название *"} | ||||
|             name="name" | ||||
|           /> | ||||
|           <TextField | ||||
|             {...register('latitude', { | ||||
|               required: 'Это поле является обязательным', | ||||
|             {...register("latitude", { | ||||
|               required: "Это поле является обязательным", | ||||
|               valueAsNumber: true, | ||||
|             })} | ||||
|             error={!!(errors as any)?.latitude} | ||||
|             helperText={(errors as any)?.latitude?.message} | ||||
|             margin="normal" | ||||
|             fullWidth | ||||
|             InputLabelProps={{shrink: true}} | ||||
|             InputLabelProps={{ shrink: true }} | ||||
|             type="number" | ||||
|             label={'Широта *'} | ||||
|             label={"Широта *"} | ||||
|             name="latitude" | ||||
|           /> | ||||
|           <TextField | ||||
|             {...register('longitude', { | ||||
|               required: 'Это поле является обязательным', | ||||
|             {...register("longitude", { | ||||
|               required: "Это поле является обязательным", | ||||
|               valueAsNumber: true, | ||||
|             })} | ||||
|             error={!!(errors as any)?.longitude} | ||||
|             helperText={(errors as any)?.longitude?.message} | ||||
|             margin="normal" | ||||
|             fullWidth | ||||
|             InputLabelProps={{shrink: true}} | ||||
|             InputLabelProps={{ shrink: true }} | ||||
|             type="number" | ||||
|             label={'Долгота *'} | ||||
|             label={"Долгота *"} | ||||
|             name="longitude" | ||||
|           /> | ||||
|  | ||||
|           <Controller | ||||
|             control={control} | ||||
|             name="city_id" | ||||
|             rules={{required: 'Это поле является обязательным'}} | ||||
|             rules={{ required: "Это поле является обязательным" }} | ||||
|             defaultValue={null} | ||||
|             render={({field}) => ( | ||||
|             render={({ field }) => ( | ||||
|               <Autocomplete | ||||
|                 {...cityAutocompleteProps} | ||||
|                 value={cityAutocompleteProps.options.find((option) => option.id === field.value) || null} | ||||
|                 value={ | ||||
|                   cityAutocompleteProps.options.find( | ||||
|                     (option) => option.id === field.value | ||||
|                   ) || null | ||||
|                 } | ||||
|                 onChange={(_, value) => { | ||||
|                   field.onChange(value?.id || '') | ||||
|                   field.onChange(value?.id || ""); | ||||
|                 }} | ||||
|                 getOptionLabel={(item) => { | ||||
|                   return item ? item.name : '' | ||||
|                   return item ? item.name : ""; | ||||
|                 }} | ||||
|                 isOptionEqualToValue={(option, value) => { | ||||
|                   return option.id === value?.id | ||||
|                   return option.id === value?.id; | ||||
|                 }} | ||||
|                 filterOptions={(options, {inputValue}) => { | ||||
|                   return options.filter((option) => option.name.toLowerCase().includes(inputValue.toLowerCase())) | ||||
|                 filterOptions={(options, { inputValue }) => { | ||||
|                   return options.filter((option) => | ||||
|                     option.name.toLowerCase().includes(inputValue.toLowerCase()) | ||||
|                   ); | ||||
|                 }} | ||||
|                 renderInput={(params) => <TextField {...params} label="Выберите город" margin="normal" variant="outlined" error={!!errors.city_id} helperText={(errors as any)?.city_id?.message} required />} | ||||
|                 renderInput={(params) => ( | ||||
|                   <TextField | ||||
|                     {...params} | ||||
|                     label="Выберите город" | ||||
|                     margin="normal" | ||||
|                     variant="outlined" | ||||
|                     error={!!errors.city_id} | ||||
|                     helperText={(errors as any)?.city_id?.message} | ||||
|                     required | ||||
|                   /> | ||||
|                 )} | ||||
|               /> | ||||
|             )} | ||||
|           /> | ||||
| @@ -197,23 +251,41 @@ export const SightEdit = () => { | ||||
|             control={control} | ||||
|             name="thumbnail" | ||||
|             defaultValue={null} | ||||
|             render={({field}) => ( | ||||
|             render={({ field }) => ( | ||||
|               <Autocomplete | ||||
|                 {...mediaAutocompleteProps} | ||||
|                 value={mediaAutocompleteProps.options.find((option) => option.id === field.value) || null} | ||||
|                 value={ | ||||
|                   mediaAutocompleteProps.options.find( | ||||
|                     (option) => option.id === field.value | ||||
|                   ) || null | ||||
|                 } | ||||
|                 onChange={(_, value) => { | ||||
|                   field.onChange(value?.id || '') | ||||
|                   field.onChange(value?.id || ""); | ||||
|                 }} | ||||
|                 getOptionLabel={(item) => { | ||||
|                   return item ? item.media_name : '' | ||||
|                   return item ? item.media_name : ""; | ||||
|                 }} | ||||
|                 isOptionEqualToValue={(option, value) => { | ||||
|                   return option.id === value?.id | ||||
|                   return option.id === value?.id; | ||||
|                 }} | ||||
|                 filterOptions={(options, {inputValue}) => { | ||||
|                   return options.filter((option) => option.media_name.toLowerCase().includes(inputValue.toLowerCase())) | ||||
|                 filterOptions={(options, { inputValue }) => { | ||||
|                   return options.filter((option) => | ||||
|                     option.media_name | ||||
|                       .toLowerCase() | ||||
|                       .includes(inputValue.toLowerCase()) | ||||
|                   ); | ||||
|                 }} | ||||
|                 renderInput={(params) => <TextField {...params} label="Выберите обложку" margin="normal" variant="outlined" error={!!errors.arms} helperText={(errors as any)?.arms?.message} required />} | ||||
|                 renderInput={(params) => ( | ||||
|                   <TextField | ||||
|                     {...params} | ||||
|                     label="Выберите обложку" | ||||
|                     margin="normal" | ||||
|                     variant="outlined" | ||||
|                     error={!!errors.arms} | ||||
|                     helperText={(errors as any)?.arms?.message} | ||||
|                     required | ||||
|                   /> | ||||
|                 )} | ||||
|               /> | ||||
|             )} | ||||
|           /> | ||||
| @@ -222,23 +294,41 @@ export const SightEdit = () => { | ||||
|             control={control} | ||||
|             name="watermark_lu" | ||||
|             defaultValue={null} | ||||
|             render={({field}) => ( | ||||
|             render={({ field }) => ( | ||||
|               <Autocomplete | ||||
|                 {...mediaAutocompleteProps} | ||||
|                 value={mediaAutocompleteProps.options.find((option) => option.id === field.value) || null} | ||||
|                 value={ | ||||
|                   mediaAutocompleteProps.options.find( | ||||
|                     (option) => option.id === field.value | ||||
|                   ) || null | ||||
|                 } | ||||
|                 onChange={(_, value) => { | ||||
|                   field.onChange(value?.id || '') | ||||
|                   field.onChange(value?.id || ""); | ||||
|                 }} | ||||
|                 getOptionLabel={(item) => { | ||||
|                   return item ? item.media_name : '' | ||||
|                   return item ? item.media_name : ""; | ||||
|                 }} | ||||
|                 isOptionEqualToValue={(option, value) => { | ||||
|                   return option.id === value?.id | ||||
|                   return option.id === value?.id; | ||||
|                 }} | ||||
|                 filterOptions={(options, {inputValue}) => { | ||||
|                   return options.filter((option) => option.media_name.toLowerCase().includes(inputValue.toLowerCase())) | ||||
|                 filterOptions={(options, { inputValue }) => { | ||||
|                   return options.filter((option) => | ||||
|                     option.media_name | ||||
|                       .toLowerCase() | ||||
|                       .includes(inputValue.toLowerCase()) | ||||
|                   ); | ||||
|                 }} | ||||
|                 renderInput={(params) => <TextField {...params} label="Выберите водный знак (Левый верх)" margin="normal" variant="outlined" error={!!errors.arms} helperText={(errors as any)?.arms?.message} required />} | ||||
|                 renderInput={(params) => ( | ||||
|                   <TextField | ||||
|                     {...params} | ||||
|                     label="Выберите водный знак (Левый верх)" | ||||
|                     margin="normal" | ||||
|                     variant="outlined" | ||||
|                     error={!!errors.arms} | ||||
|                     helperText={(errors as any)?.arms?.message} | ||||
|                     required | ||||
|                   /> | ||||
|                 )} | ||||
|               /> | ||||
|             )} | ||||
|           /> | ||||
| @@ -247,23 +337,41 @@ export const SightEdit = () => { | ||||
|             control={control} | ||||
|             name="watermark_rd" | ||||
|             defaultValue={null} | ||||
|             render={({field}) => ( | ||||
|             render={({ field }) => ( | ||||
|               <Autocomplete | ||||
|                 {...mediaAutocompleteProps} | ||||
|                 value={mediaAutocompleteProps.options.find((option) => option.id === field.value) || null} | ||||
|                 value={ | ||||
|                   mediaAutocompleteProps.options.find( | ||||
|                     (option) => option.id === field.value | ||||
|                   ) || null | ||||
|                 } | ||||
|                 onChange={(_, value) => { | ||||
|                   field.onChange(value?.id || '') | ||||
|                   field.onChange(value?.id || ""); | ||||
|                 }} | ||||
|                 getOptionLabel={(item) => { | ||||
|                   return item ? item.media_name : '' | ||||
|                   return item ? item.media_name : ""; | ||||
|                 }} | ||||
|                 isOptionEqualToValue={(option, value) => { | ||||
|                   return option.id === value?.id | ||||
|                   return option.id === value?.id; | ||||
|                 }} | ||||
|                 filterOptions={(options, {inputValue}) => { | ||||
|                   return options.filter((option) => option.media_name.toLowerCase().includes(inputValue.toLowerCase())) | ||||
|                 filterOptions={(options, { inputValue }) => { | ||||
|                   return options.filter((option) => | ||||
|                     option.media_name | ||||
|                       .toLowerCase() | ||||
|                       .includes(inputValue.toLowerCase()) | ||||
|                   ); | ||||
|                 }} | ||||
|                 renderInput={(params) => <TextField {...params} label="Выберите водный знак (Правый низ)" margin="normal" variant="outlined" error={!!errors.arms} helperText={(errors as any)?.arms?.message} required />} | ||||
|                 renderInput={(params) => ( | ||||
|                   <TextField | ||||
|                     {...params} | ||||
|                     label="Выберите водный знак (Правый низ)" | ||||
|                     margin="normal" | ||||
|                     variant="outlined" | ||||
|                     error={!!errors.arms} | ||||
|                     helperText={(errors as any)?.arms?.message} | ||||
|                     required | ||||
|                   /> | ||||
|                 )} | ||||
|               /> | ||||
|             )} | ||||
|           /> | ||||
| @@ -272,23 +380,41 @@ export const SightEdit = () => { | ||||
|             control={control} | ||||
|             name="left_article" | ||||
|             defaultValue={null} | ||||
|             render={({field}) => ( | ||||
|             render={({ field }) => ( | ||||
|               <Autocomplete | ||||
|                 {...articleAutocompleteProps} | ||||
|                 value={articleAutocompleteProps.options.find((option) => option.id === field.value) || null} | ||||
|                 value={ | ||||
|                   articleAutocompleteProps.options.find( | ||||
|                     (option) => option.id === field.value | ||||
|                   ) || null | ||||
|                 } | ||||
|                 onChange={(_, value) => { | ||||
|                   field.onChange(value?.id || '') | ||||
|                   field.onChange(value?.id || ""); | ||||
|                 }} | ||||
|                 getOptionLabel={(item) => { | ||||
|                   return item ? item.heading : '' | ||||
|                   return item ? item.heading : ""; | ||||
|                 }} | ||||
|                 isOptionEqualToValue={(option, value) => { | ||||
|                   return option.id === value?.id | ||||
|                   return option.id === value?.id; | ||||
|                 }} | ||||
|                 filterOptions={(options, {inputValue}) => { | ||||
|                   return options.filter((option) => option.heading.toLowerCase().includes(inputValue.toLowerCase())) | ||||
|                 filterOptions={(options, { inputValue }) => { | ||||
|                   return options.filter((option) => | ||||
|                     option.heading | ||||
|                       .toLowerCase() | ||||
|                       .includes(inputValue.toLowerCase()) | ||||
|                   ); | ||||
|                 }} | ||||
|                 renderInput={(params) => <TextField {...params} label="Левая статья" margin="normal" variant="outlined" error={!!errors.arms} helperText={(errors as any)?.arms?.message} required />} | ||||
|                 renderInput={(params) => ( | ||||
|                   <TextField | ||||
|                     {...params} | ||||
|                     label="Левая статья" | ||||
|                     margin="normal" | ||||
|                     variant="outlined" | ||||
|                     error={!!errors.arms} | ||||
|                     helperText={(errors as any)?.arms?.message} | ||||
|                     required | ||||
|                   /> | ||||
|                 )} | ||||
|               /> | ||||
|             )} | ||||
|           /> | ||||
| @@ -297,23 +423,41 @@ export const SightEdit = () => { | ||||
|             control={control} | ||||
|             name="preview_article" | ||||
|             defaultValue={null} | ||||
|             render={({field}) => ( | ||||
|             render={({ field }) => ( | ||||
|               <Autocomplete | ||||
|                 {...articleAutocompleteProps} | ||||
|                 value={articleAutocompleteProps.options.find((option) => option.id === field.value) || null} | ||||
|                 value={ | ||||
|                   articleAutocompleteProps.options.find( | ||||
|                     (option) => option.id === field.value | ||||
|                   ) || null | ||||
|                 } | ||||
|                 onChange={(_, value) => { | ||||
|                   field.onChange(value?.id || '') | ||||
|                   field.onChange(value?.id || ""); | ||||
|                 }} | ||||
|                 getOptionLabel={(item) => { | ||||
|                   return item ? item.heading : '' | ||||
|                   return item ? item.heading : ""; | ||||
|                 }} | ||||
|                 isOptionEqualToValue={(option, value) => { | ||||
|                   return option.id === value?.id | ||||
|                   return option.id === value?.id; | ||||
|                 }} | ||||
|                 filterOptions={(options, {inputValue}) => { | ||||
|                   return options.filter((option) => option.heading.toLowerCase().includes(inputValue.toLowerCase())) | ||||
|                 filterOptions={(options, { inputValue }) => { | ||||
|                   return options.filter((option) => | ||||
|                     option.heading | ||||
|                       .toLowerCase() | ||||
|                       .includes(inputValue.toLowerCase()) | ||||
|                   ); | ||||
|                 }} | ||||
|                 renderInput={(params) => <TextField {...params} label="Cтатья-предпросмотр" margin="normal" variant="outlined" error={!!errors.arms} helperText={(errors as any)?.arms?.message} required />} | ||||
|                 renderInput={(params) => ( | ||||
|                   <TextField | ||||
|                     {...params} | ||||
|                     label="Cтатья-предпросмотр" | ||||
|                     margin="normal" | ||||
|                     variant="outlined" | ||||
|                     error={!!errors.arms} | ||||
|                     helperText={(errors as any)?.arms?.message} | ||||
|                     required | ||||
|                   /> | ||||
|                 )} | ||||
|               /> | ||||
|             )} | ||||
|           /> | ||||
| @@ -324,14 +468,15 @@ export const SightEdit = () => { | ||||
|           sx={{ | ||||
|             flex: 1, | ||||
|             p: 2, | ||||
|             maxHeight: 'calc(100vh - 200px)', | ||||
|             overflowY: 'auto', | ||||
|             position: 'sticky', | ||||
|             maxHeight: "calc(100vh - 200px)", | ||||
|             overflowY: "auto", | ||||
|             position: "sticky", | ||||
|             top: 16, | ||||
|             borderRadius: 2, | ||||
|             border: '1px solid', | ||||
|             borderColor: 'primary.main', | ||||
|             bgcolor: (theme) => (theme.palette.mode === 'dark' ? 'background.paper' : '#fff'), | ||||
|             border: "1px solid", | ||||
|             borderColor: "primary.main", | ||||
|             bgcolor: (theme) => | ||||
|               theme.palette.mode === "dark" ? "background.paper" : "#fff", | ||||
|           }} | ||||
|         > | ||||
|           <Typography variant="h6" gutterBottom color="primary"> | ||||
| @@ -343,7 +488,8 @@ export const SightEdit = () => { | ||||
|             variant="h4" | ||||
|             gutterBottom | ||||
|             sx={{ | ||||
|               color: (theme) => (theme.palette.mode === 'dark' ? 'grey.300' : 'grey.800'), | ||||
|               color: (theme) => | ||||
|                 theme.palette.mode === "dark" ? "grey.300" : "grey.800", | ||||
|               mb: 3, | ||||
|             }} | ||||
|           > | ||||
| @@ -351,29 +497,45 @@ export const SightEdit = () => { | ||||
|           </Typography> | ||||
|  | ||||
|           {/* Город */} | ||||
|           <Typography variant="body1" sx={{mb: 2}}> | ||||
|             <Box component="span" sx={{color: 'text.secondary'}}> | ||||
|               Город:{' '} | ||||
|           <Typography variant="body1" sx={{ mb: 2 }}> | ||||
|             <Box component="span" sx={{ color: "text.secondary" }}> | ||||
|               Город:{" "} | ||||
|             </Box> | ||||
|             <Box component="span" sx={{color: (theme) => (theme.palette.mode === 'dark' ? 'grey.300' : 'grey.800')}}> | ||||
|             <Box | ||||
|               component="span" | ||||
|               sx={{ | ||||
|                 color: (theme) => | ||||
|                   theme.palette.mode === "dark" ? "grey.300" : "grey.800", | ||||
|               }} | ||||
|             > | ||||
|               {cityPreview} | ||||
|             </Box> | ||||
|           </Typography> | ||||
|  | ||||
|           {/* Координаты */} | ||||
|           <Typography variant="body1" sx={{mb: 2}}> | ||||
|             <Box component="span" sx={{color: 'text.secondary'}}> | ||||
|               Координаты:{' '} | ||||
|           <Typography variant="body1" sx={{ mb: 2 }}> | ||||
|             <Box component="span" sx={{ color: "text.secondary" }}> | ||||
|               Координаты:{" "} | ||||
|             </Box> | ||||
|             <Box component="span" sx={{color: (theme) => (theme.palette.mode === 'dark' ? 'grey.300' : 'grey.800')}}> | ||||
|             <Box | ||||
|               component="span" | ||||
|               sx={{ | ||||
|                 color: (theme) => | ||||
|                   theme.palette.mode === "dark" ? "grey.300" : "grey.800", | ||||
|               }} | ||||
|             > | ||||
|               {coordinatesPreview.latitude}, {coordinatesPreview.longitude} | ||||
|             </Box> | ||||
|           </Typography> | ||||
|  | ||||
|           {/* Обложка */} | ||||
|           {thumbnailPreview && ( | ||||
|             <Box sx={{mb: 2}}> | ||||
|               <Typography variant="body1" gutterBottom sx={{color: 'text.secondary'}}> | ||||
|             <Box sx={{ mb: 2 }}> | ||||
|               <Typography | ||||
|                 variant="body1" | ||||
|                 gutterBottom | ||||
|                 sx={{ color: "text.secondary" }} | ||||
|               > | ||||
|                 Обложка: | ||||
|               </Typography> | ||||
|               <Box | ||||
| @@ -381,25 +543,33 @@ export const SightEdit = () => { | ||||
|                 src={thumbnailPreview} | ||||
|                 alt="Обложка" | ||||
|                 sx={{ | ||||
|                   maxWidth: '100%', | ||||
|                   height: '40vh', | ||||
|                   maxWidth: "100%", | ||||
|                   height: "40vh", | ||||
|                   borderRadius: 2, | ||||
|                   border: '1px solid', | ||||
|                   borderColor: 'primary.main', | ||||
|                   border: "1px solid", | ||||
|                   borderColor: "primary.main", | ||||
|                 }} | ||||
|               /> | ||||
|             </Box> | ||||
|           )} | ||||
|  | ||||
|           {/* Водяные знаки */} | ||||
|           <Box sx={{mb: 2}}> | ||||
|             <Typography variant="body1" gutterBottom sx={{color: 'text.secondary'}}> | ||||
|           <Box sx={{ mb: 2 }}> | ||||
|             <Typography | ||||
|               variant="body1" | ||||
|               gutterBottom | ||||
|               sx={{ color: "text.secondary" }} | ||||
|             > | ||||
|               Водяные знаки: | ||||
|             </Typography> | ||||
|             <Box sx={{display: 'flex', gap: 2}}> | ||||
|             <Box sx={{ display: "flex", gap: 2 }}> | ||||
|               {watermarkLUPreview && ( | ||||
|                 <Box> | ||||
|                   <Typography variant="body2" gutterBottom sx={{color: 'text.secondary'}}> | ||||
|                   <Typography | ||||
|                     variant="body2" | ||||
|                     gutterBottom | ||||
|                     sx={{ color: "text.secondary" }} | ||||
|                   > | ||||
|                     Левый верхний: | ||||
|                   </Typography> | ||||
|                   <Box | ||||
| @@ -409,17 +579,21 @@ export const SightEdit = () => { | ||||
|                     sx={{ | ||||
|                       width: 100, | ||||
|                       height: 100, | ||||
|                       objectFit: 'cover', | ||||
|                       objectFit: "cover", | ||||
|                       borderRadius: 1, | ||||
|                       border: '1px solid', | ||||
|                       borderColor: 'primary.main', | ||||
|                       border: "1px solid", | ||||
|                       borderColor: "primary.main", | ||||
|                     }} | ||||
|                   /> | ||||
|                 </Box> | ||||
|               )} | ||||
|               {watermarkRDPreview && ( | ||||
|                 <Box> | ||||
|                   <Typography variant="body2" gutterBottom sx={{color: 'text.secondary'}}> | ||||
|                   <Typography | ||||
|                     variant="body2" | ||||
|                     gutterBottom | ||||
|                     sx={{ color: "text.secondary" }} | ||||
|                   > | ||||
|                     Правый нижний: | ||||
|                   </Typography> | ||||
|                   <Box | ||||
| @@ -429,10 +603,10 @@ export const SightEdit = () => { | ||||
|                     sx={{ | ||||
|                       width: 100, | ||||
|                       height: 100, | ||||
|                       objectFit: 'cover', | ||||
|                       objectFit: "cover", | ||||
|                       borderRadius: 1, | ||||
|                       border: '1px solid', | ||||
|                       borderColor: 'primary.main', | ||||
|                       border: "1px solid", | ||||
|                       borderColor: "primary.main", | ||||
|                     }} | ||||
|                   /> | ||||
|                 </Box> | ||||
| @@ -447,17 +621,18 @@ export const SightEdit = () => { | ||||
|             </Typography> */} | ||||
|             {leftArticlePreview && ( | ||||
|               <Typography variant="body1" gutterBottom> | ||||
|                 <Box component="span" sx={{color: 'text.secondary'}}> | ||||
|                   Левая статья:{' '} | ||||
|                 <Box component="span" sx={{ color: "text.secondary" }}> | ||||
|                   Левая статья:{" "} | ||||
|                 </Box> | ||||
|                 <Box | ||||
|                   component={Link} | ||||
|                   to={`/article/show/${watch('left_article')}`} | ||||
|                   to={`/article/show/${watch("left_article")}`} | ||||
|                   sx={{ | ||||
|                     color: (theme) => (theme.palette.mode === 'dark' ? 'grey.300' : 'grey.800'), | ||||
|                     textDecoration: 'none', | ||||
|                     '&:hover': { | ||||
|                       textDecoration: 'underline', | ||||
|                     color: (theme) => | ||||
|                       theme.palette.mode === "dark" ? "grey.300" : "grey.800", | ||||
|                     textDecoration: "none", | ||||
|                     "&:hover": { | ||||
|                       textDecoration: "underline", | ||||
|                     }, | ||||
|                   }} | ||||
|                 > | ||||
| @@ -467,17 +642,18 @@ export const SightEdit = () => { | ||||
|             )} | ||||
|             {previewArticlePreview && ( | ||||
|               <Typography variant="body1" gutterBottom> | ||||
|                 <Box component="span" sx={{color: 'text.secondary'}}> | ||||
|                   Статья-предпросмотр:{' '} | ||||
|                 <Box component="span" sx={{ color: "text.secondary" }}> | ||||
|                   Статья-предпросмотр:{" "} | ||||
|                 </Box> | ||||
|                 <Box | ||||
|                   component={Link} | ||||
|                   to={`/article/show/${watch('preview_article')}`} | ||||
|                   to={`/article/show/${watch("preview_article")}`} | ||||
|                   sx={{ | ||||
|                     color: (theme) => (theme.palette.mode === 'dark' ? 'grey.300' : 'grey.800'), | ||||
|                     textDecoration: 'none', | ||||
|                     '&:hover': { | ||||
|                       textDecoration: 'underline', | ||||
|                     color: (theme) => | ||||
|                       theme.palette.mode === "dark" ? "grey.300" : "grey.800", | ||||
|                     textDecoration: "none", | ||||
|                     "&:hover": { | ||||
|                       textDecoration: "underline", | ||||
|                     }, | ||||
|                   }} | ||||
|                 > | ||||
| @@ -490,12 +666,24 @@ export const SightEdit = () => { | ||||
|       </Box> | ||||
|  | ||||
|       {sightId && ( | ||||
|         <Box sx={{mt: 3}}> | ||||
|           <LinkedItems<ArticleItem> type="edit" parentId={sightId} parentResource="sight" childResource="article" fields={articleFields} title="статьи" /> | ||||
|         <Box sx={{ mt: 3 }}> | ||||
|           <LinkedItems<ArticleItem> | ||||
|             type="edit" | ||||
|             parentId={sightId} | ||||
|             parentResource="sight" | ||||
|             childResource="article" | ||||
|             fields={articleFields} | ||||
|             title="статьи" | ||||
|           /> | ||||
|  | ||||
|           <CreateSightArticle parentId={sightId} parentResource="sight" childResource="article" title="статью" /> | ||||
|           <CreateSightArticle | ||||
|             parentId={sightId} | ||||
|             parentResource="sight" | ||||
|             childResource="article" | ||||
|             title="статью" | ||||
|           /> | ||||
|         </Box> | ||||
|       )} | ||||
|     </Edit> | ||||
|   ) | ||||
| } | ||||
|   ); | ||||
| }; | ||||
|   | ||||
| @@ -1,25 +1,27 @@ | ||||
| import dataProvider from '@refinedev/simple-rest' | ||||
| import axios from 'axios' | ||||
| import {BACKEND_URL} from '../lib/constants' | ||||
| import {TOKEN_KEY} from '../authProvider' | ||||
| import Cookies from 'js-cookie' | ||||
| import dataProvider from "@refinedev/simple-rest"; | ||||
| import axios from "axios"; | ||||
|  | ||||
| export const axiosInstance = axios.create() | ||||
| import { TOKEN_KEY } from "../authProvider"; | ||||
| import Cookies from "js-cookie"; | ||||
|  | ||||
| export const axiosInstance = axios.create(); | ||||
|  | ||||
| axiosInstance.interceptors.request.use((config) => { | ||||
|   // Добавляем токен авторизации | ||||
|   const token = localStorage.getItem(TOKEN_KEY) | ||||
|   const token = localStorage.getItem(TOKEN_KEY); | ||||
|   if (token) { | ||||
|     config.headers.Authorization = `Bearer ${token}` | ||||
|     config.headers.Authorization = `Bearer ${token}`; | ||||
|   } | ||||
|  | ||||
|   // Добавляем язык в кастомный заголовок | ||||
|   const lang = Cookies.get('lang') || 'ru' | ||||
|   config.headers['X-Language'] = lang // или 'Accept-Language' | ||||
|   const lang = Cookies.get("lang") || "ru"; | ||||
|   config.headers["X-Language"] = lang; // или 'Accept-Language' | ||||
|  | ||||
|   // console.log('Request headers:', config.headers) | ||||
|  | ||||
|   return config | ||||
| }) | ||||
|   return config; | ||||
| }); | ||||
|  | ||||
| export const customDataProvider = dataProvider(BACKEND_URL, axiosInstance) | ||||
| const apiUrl = import.meta.env.VITE_KRBL_API; | ||||
|  | ||||
| export const customDataProvider = dataProvider(apiUrl, axiosInstance); | ||||
|   | ||||
		Reference in New Issue
	
	Block a user