[2] upgrade CreateSightArticle
for /sight
route
This commit is contained in:
parent
18dda0cb0b
commit
0f2b7095b5
@ -25,6 +25,7 @@
|
|||||||
"jwt-decode": "^4.0.0",
|
"jwt-decode": "^4.0.0",
|
||||||
"react": "^18.0.0",
|
"react": "^18.0.0",
|
||||||
"react-dom": "^18.0.0",
|
"react-dom": "^18.0.0",
|
||||||
|
"react-dropzone": "^14.3.8",
|
||||||
"react-hook-form": "^7.30.0",
|
"react-hook-form": "^7.30.0",
|
||||||
"react-i18next": "^15.4.1",
|
"react-i18next": "^15.4.1",
|
||||||
"react-router": "^7.0.2",
|
"react-router": "^7.0.2",
|
||||||
|
@ -71,6 +71,9 @@ importers:
|
|||||||
react-dom:
|
react-dom:
|
||||||
specifier: ^18.0.0
|
specifier: ^18.0.0
|
||||||
version: 18.3.1(react@18.3.1)
|
version: 18.3.1(react@18.3.1)
|
||||||
|
react-dropzone:
|
||||||
|
specifier: ^14.3.8
|
||||||
|
version: 14.3.8(react@18.3.1)
|
||||||
react-hook-form:
|
react-hook-form:
|
||||||
specifier: ^7.30.0
|
specifier: ^7.30.0
|
||||||
version: 7.54.2(react@18.3.1)
|
version: 7.54.2(react@18.3.1)
|
||||||
@ -1162,6 +1165,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-Xcz9l0z7y9yQ9rdDaxlmaI4uJHf/T8g9hOEzJcsEqX2SjCj4J20uK7+ldkDHMbpJDK76wF7xEIgxc/vSlsfw5w==}
|
resolution: {integrity: sha512-Xcz9l0z7y9yQ9rdDaxlmaI4uJHf/T8g9hOEzJcsEqX2SjCj4J20uK7+ldkDHMbpJDK76wF7xEIgxc/vSlsfw5w==}
|
||||||
engines: {node: '>=10.12.0'}
|
engines: {node: '>=10.12.0'}
|
||||||
|
|
||||||
|
attr-accept@2.2.5:
|
||||||
|
resolution: {integrity: sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==}
|
||||||
|
engines: {node: '>=4'}
|
||||||
|
|
||||||
axios@1.7.9:
|
axios@1.7.9:
|
||||||
resolution: {integrity: sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==}
|
resolution: {integrity: sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==}
|
||||||
|
|
||||||
@ -1674,6 +1681,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==}
|
resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==}
|
||||||
engines: {node: ^10.12.0 || >=12.0.0}
|
engines: {node: ^10.12.0 || >=12.0.0}
|
||||||
|
|
||||||
|
file-selector@2.1.2:
|
||||||
|
resolution: {integrity: sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig==}
|
||||||
|
engines: {node: '>= 12'}
|
||||||
|
|
||||||
fill-range@7.1.1:
|
fill-range@7.1.1:
|
||||||
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
|
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@ -2598,6 +2609,12 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^18.3.1
|
react: ^18.3.1
|
||||||
|
|
||||||
|
react-dropzone@14.3.8:
|
||||||
|
resolution: {integrity: sha512-sBgODnq+lcA4P296DY4wacOZz3JFpD99fp+hb//iBO2HHnyeZU3FwWyXJ6salNpqQdsZrgMrotuko/BdJMV8Ug==}
|
||||||
|
engines: {node: '>= 10.13'}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>= 16.8 || 18.0.0'
|
||||||
|
|
||||||
react-hook-form@7.54.2:
|
react-hook-form@7.54.2:
|
||||||
resolution: {integrity: sha512-eHpAUgUjWbZocoQYUHposymRb4ZP6d0uwUnooL2uOybA9/3tPUvoAKqEWK1WaSiTxxOfTpffNZP7QwlnM3/gEg==}
|
resolution: {integrity: sha512-eHpAUgUjWbZocoQYUHposymRb4ZP6d0uwUnooL2uOybA9/3tPUvoAKqEWK1WaSiTxxOfTpffNZP7QwlnM3/gEg==}
|
||||||
engines: {node: '>=18.0.0'}
|
engines: {node: '>=18.0.0'}
|
||||||
@ -4433,6 +4450,8 @@ snapshots:
|
|||||||
|
|
||||||
atomically@1.7.0: {}
|
atomically@1.7.0: {}
|
||||||
|
|
||||||
|
attr-accept@2.2.5: {}
|
||||||
|
|
||||||
axios@1.7.9:
|
axios@1.7.9:
|
||||||
dependencies:
|
dependencies:
|
||||||
follow-redirects: 1.15.9(debug@4.4.0)
|
follow-redirects: 1.15.9(debug@4.4.0)
|
||||||
@ -5001,6 +5020,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
flat-cache: 3.2.0
|
flat-cache: 3.2.0
|
||||||
|
|
||||||
|
file-selector@2.1.2:
|
||||||
|
dependencies:
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
fill-range@7.1.1:
|
fill-range@7.1.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
to-regex-range: 5.0.1
|
to-regex-range: 5.0.1
|
||||||
@ -5964,6 +5987,13 @@ snapshots:
|
|||||||
react: 18.3.1
|
react: 18.3.1
|
||||||
scheduler: 0.23.2
|
scheduler: 0.23.2
|
||||||
|
|
||||||
|
react-dropzone@14.3.8(react@18.3.1):
|
||||||
|
dependencies:
|
||||||
|
attr-accept: 2.2.5
|
||||||
|
file-selector: 2.1.2
|
||||||
|
prop-types: 15.8.1
|
||||||
|
react: 18.3.1
|
||||||
|
|
||||||
react-hook-form@7.54.2(react@18.3.1):
|
react-hook-form@7.54.2(react@18.3.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 18.3.1
|
react: 18.3.1
|
||||||
|
@ -4,10 +4,19 @@ import {axiosInstance} from '../providers/data'
|
|||||||
import {BACKEND_URL} from '../lib/constants'
|
import {BACKEND_URL} from '../lib/constants'
|
||||||
import {useForm, Controller} from 'react-hook-form'
|
import {useForm, Controller} from 'react-hook-form'
|
||||||
import {MarkdownEditor} from './MarkdownEditor'
|
import {MarkdownEditor} from './MarkdownEditor'
|
||||||
import React from 'react'
|
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
|
||||||
|
}
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
parentId: string | number
|
parentId: string | number
|
||||||
parentResource: string
|
parentResource: string
|
||||||
@ -17,6 +26,7 @@ type Props = {
|
|||||||
|
|
||||||
export const CreateSightArticle = ({parentId, parentResource, childResource, title}: Props) => {
|
export const CreateSightArticle = ({parentId, parentResource, childResource, title}: Props) => {
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
|
const [mediaFiles, setMediaFiles] = useState<MediaFile[]>([])
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register: registerItem,
|
register: registerItem,
|
||||||
@ -39,30 +49,84 @@ export const CreateSightArticle = ({parentId, parentResource, childResource, tit
|
|||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const onDrop = useCallback((acceptedFiles: File[]) => {
|
||||||
|
const newFiles = acceptedFiles.map((file) => ({
|
||||||
|
file,
|
||||||
|
preview: URL.createObjectURL(file),
|
||||||
|
uploading: false,
|
||||||
|
}))
|
||||||
|
setMediaFiles((prev) => [...prev, ...newFiles])
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const {getRootProps, getInputProps, isDragActive} = useDropzone({
|
||||||
|
onDrop,
|
||||||
|
accept: {
|
||||||
|
'image/*': ALLOWED_IMAGE_TYPES,
|
||||||
|
'video/*': ALLOWED_VIDEO_TYPES,
|
||||||
|
},
|
||||||
|
multiple: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const uploadMedia = async (mediaFile: MediaFile) => {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('media_name', mediaFile.file.name)
|
||||||
|
formData.append('filename', mediaFile.file.name)
|
||||||
|
formData.append('type', mediaFile.file.type.startsWith('image/') ? '1' : '2')
|
||||||
|
formData.append('file', mediaFile.file)
|
||||||
|
|
||||||
|
const response = await axiosInstance.post(`${BACKEND_URL}/media`, formData)
|
||||||
|
return response.data.id
|
||||||
|
}
|
||||||
|
|
||||||
const handleCreate = async (data: {heading: string; body: string}) => {
|
const handleCreate = async (data: {heading: string; body: string}) => {
|
||||||
try {
|
try {
|
||||||
|
// Создаем статью
|
||||||
const response = await axiosInstance.post(`${BACKEND_URL}/${childResource}`, data)
|
const response = await axiosInstance.post(`${BACKEND_URL}/${childResource}`, data)
|
||||||
const itemId = response.data.id
|
const itemId = response.data.id
|
||||||
|
|
||||||
|
// Получаем существующие статьи для определения порядкового номера
|
||||||
const existingItemsResponse = await axiosInstance.get(`${BACKEND_URL}/${parentResource}/${parentId}/${childResource}`)
|
const existingItemsResponse = await axiosInstance.get(`${BACKEND_URL}/${parentResource}/${parentId}/${childResource}`)
|
||||||
const existingItems = existingItemsResponse.data || []
|
const existingItems = existingItemsResponse.data || []
|
||||||
|
|
||||||
const nextPageNum = existingItems.length + 1
|
const nextPageNum = existingItems.length + 1
|
||||||
|
|
||||||
|
// Привязываем статью к достопримечательности
|
||||||
await axiosInstance.post(`${BACKEND_URL}/${parentResource}/${parentId}/${childResource}/`, {
|
await axiosInstance.post(`${BACKEND_URL}/${parentResource}/${parentId}/${childResource}/`, {
|
||||||
[`${childResource}_id`]: itemId,
|
[`${childResource}_id`]: itemId,
|
||||||
page_num: nextPageNum,
|
page_num: nextPageNum,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Загружаем все медиа файлы и получаем их ID
|
||||||
|
const mediaIds = await Promise.all(
|
||||||
|
mediaFiles.map(async (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,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
resetItem()
|
resetItem()
|
||||||
|
setMediaFiles([])
|
||||||
window.location.reload()
|
window.location.reload()
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Error creating item:', err)
|
console.error('Error creating item:', err)
|
||||||
if (err?.response) {
|
|
||||||
console.error('Error response:', err.response.data)
|
|
||||||
console.error('Error status:', err.response.status)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const removeMedia = (index: number) => {
|
||||||
|
setMediaFiles((prev) => {
|
||||||
|
const newFiles = [...prev]
|
||||||
|
URL.revokeObjectURL(newFiles[index].preview)
|
||||||
|
newFiles.splice(index, 1)
|
||||||
|
return newFiles
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -96,6 +160,82 @@ export const CreateSightArticle = ({parentId, parentResource, childResource, tit
|
|||||||
|
|
||||||
<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
|
||||||
|
{...getRootProps()}
|
||||||
|
sx={{
|
||||||
|
border: '2px dashed',
|
||||||
|
borderColor: isDragActive ? 'primary.main' : 'grey.300',
|
||||||
|
borderRadius: 1,
|
||||||
|
p: 2,
|
||||||
|
textAlign: 'center',
|
||||||
|
cursor: 'pointer',
|
||||||
|
'&:hover': {
|
||||||
|
borderColor: 'primary.main',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input {...getInputProps()} />
|
||||||
|
<Typography>{isDragActive ? 'Перетащите файлы сюда...' : 'Перетащите файлы сюда или кликните для выбора'}</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Превью загруженных файлов */}
|
||||||
|
<Box sx={{mt: 2, display: 'flex', flexWrap: 'wrap', gap: 1}}>
|
||||||
|
{mediaFiles.map((mediaFile, index) => (
|
||||||
|
<Box
|
||||||
|
key={mediaFile.preview}
|
||||||
|
sx={{
|
||||||
|
position: 'relative',
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{mediaFile.file.type.startsWith('image/') ? (
|
||||||
|
<img
|
||||||
|
src={mediaFile.preview}
|
||||||
|
alt={mediaFile.file.name}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
objectFit: 'cover',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
bgcolor: 'grey.200',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="caption">{mediaFile.file.name}</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
onClick={() => removeMedia(index)}
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
minWidth: 'auto',
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
p: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
<Box sx={{mt: 2, display: 'flex', gap: 2}}>
|
<Box sx={{mt: 2, display: 'flex', gap: 2}}>
|
||||||
<Button variant="contained" color="primary" type="submit">
|
<Button variant="contained" color="primary" type="submit">
|
||||||
Создать
|
Создать
|
||||||
@ -104,6 +244,8 @@ export const CreateSightArticle = ({parentId, parentResource, childResource, tit
|
|||||||
variant="outlined"
|
variant="outlined"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
resetItem()
|
resetItem()
|
||||||
|
mediaFiles.forEach((file) => URL.revokeObjectURL(file.preview))
|
||||||
|
setMediaFiles([])
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Очистить
|
Очистить
|
||||||
|
Loading…
Reference in New Issue
Block a user