add preview
functionality for /article
route
This commit is contained in:
parent
b105bfe395
commit
2fe19516f6
@ -28,6 +28,7 @@
|
|||||||
"react-dropzone": "^14.3.8",
|
"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-markdown": "^10.1.0",
|
||||||
"react-router": "^7.0.2",
|
"react-router": "^7.0.2",
|
||||||
"react-simplemde-editor": "^5.2.0"
|
"react-simplemde-editor": "^5.2.0"
|
||||||
},
|
},
|
||||||
|
658
pnpm-lock.yaml
658
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
@ -1,8 +1,9 @@
|
|||||||
import {Box, TextField} from '@mui/material'
|
import {Box, TextField, Typography, Paper} from '@mui/material'
|
||||||
import {Create} from '@refinedev/mui'
|
import {Create} from '@refinedev/mui'
|
||||||
import {useForm} from '@refinedev/react-hook-form'
|
import {useForm} from '@refinedev/react-hook-form'
|
||||||
import {Controller} from 'react-hook-form'
|
import {Controller} from 'react-hook-form'
|
||||||
import React from 'react'
|
import React, {useState, useEffect} from 'react'
|
||||||
|
import ReactMarkdown from 'react-markdown'
|
||||||
|
|
||||||
import {MarkdownEditor} from '../../components/MarkdownEditor'
|
import {MarkdownEditor} from '../../components/MarkdownEditor'
|
||||||
import 'easymde/dist/easymde.min.css'
|
import 'easymde/dist/easymde.min.css'
|
||||||
@ -15,6 +16,7 @@ export const ArticleCreate = () => {
|
|||||||
refineCore: {formLoading},
|
refineCore: {formLoading},
|
||||||
register,
|
register,
|
||||||
control,
|
control,
|
||||||
|
watch,
|
||||||
formState: {errors},
|
formState: {errors},
|
||||||
} = useForm({
|
} = useForm({
|
||||||
refineCoreProps: {
|
refineCoreProps: {
|
||||||
@ -22,6 +24,21 @@ export const ArticleCreate = () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const [preview, setPreview] = useState('')
|
||||||
|
const [headingPreview, setHeadingPreview] = useState('')
|
||||||
|
|
||||||
|
// Следим за изменениями в полях body и heading
|
||||||
|
const bodyContent = watch('body')
|
||||||
|
const headingContent = watch('heading')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPreview(bodyContent || '')
|
||||||
|
}, [bodyContent])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setHeadingPreview(headingContent || '')
|
||||||
|
}, [headingContent])
|
||||||
|
|
||||||
const simpleMDEOptions = React.useMemo(
|
const simpleMDEOptions = React.useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
placeholder: 'Введите контент в формате Markdown...',
|
placeholder: 'Введите контент в формате Markdown...',
|
||||||
@ -32,35 +49,99 @@ export const ArticleCreate = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Create isLoading={formLoading} saveButtonProps={saveButtonProps}>
|
<Create isLoading={formLoading} saveButtonProps={saveButtonProps}>
|
||||||
<Box component="form" sx={{display: 'flex', flexDirection: 'column'}} autoComplete="off">
|
<Box sx={{display: 'flex', gap: 2}}>
|
||||||
<TextField
|
{/* Форма создания */}
|
||||||
{...register('heading', {
|
<Box component="form" sx={{flex: 1, display: 'flex', flexDirection: 'column'}} autoComplete="off">
|
||||||
required: 'Это поле является обязательным',
|
<TextField
|
||||||
})}
|
{...register('heading', {
|
||||||
error={!!(errors as any)?.heading}
|
required: 'Это поле является обязательным',
|
||||||
helperText={(errors as any)?.heading?.message}
|
})}
|
||||||
margin="normal"
|
error={!!(errors as any)?.heading}
|
||||||
fullWidth
|
helperText={(errors as any)?.heading?.message}
|
||||||
InputLabelProps={{shrink: true}}
|
margin="normal"
|
||||||
type="text"
|
fullWidth
|
||||||
label={'Заголовок *'}
|
InputLabelProps={{shrink: true}}
|
||||||
name="heading"
|
type="text"
|
||||||
/>
|
label="Заголовок *"
|
||||||
|
name="heading"
|
||||||
|
/>
|
||||||
|
|
||||||
<Controller
|
<Controller control={control} name="body" rules={{required: 'Это поле является обязательным'}} defaultValue="" render={({field: {onChange, value}}) => <MemoizedSimpleMDE value={value} onChange={onChange} options={simpleMDEOptions} className="my-markdown-editor" />} />
|
||||||
control={control}
|
</Box>
|
||||||
name="body"
|
|
||||||
rules={{required: 'Это поле является обязательным'}}
|
{/* Блок предпросмотра */}
|
||||||
defaultValue=""
|
<Paper
|
||||||
render={({field: {onChange, value}}) => (
|
sx={{
|
||||||
<MemoizedSimpleMDE
|
flex: 1,
|
||||||
value={value} // markdown
|
p: 2,
|
||||||
onChange={onChange}
|
maxHeight: 'calc(100vh - 200px)',
|
||||||
options={simpleMDEOptions}
|
overflowY: 'auto',
|
||||||
className="my-markdown-editor"
|
position: 'sticky',
|
||||||
/>
|
top: 16,
|
||||||
)}
|
borderRadius: 2,
|
||||||
/>
|
border: '1px solid',
|
||||||
|
borderColor: 'primary.main',
|
||||||
|
bgcolor: (theme) => (theme.palette.mode === 'dark' ? 'background.paper' : '#fff'),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="h6" gutterBottom color="primary">
|
||||||
|
Предпросмотр
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{/* Заголовок статьи */}
|
||||||
|
<Typography
|
||||||
|
variant="h4"
|
||||||
|
gutterBottom
|
||||||
|
sx={{
|
||||||
|
color: (theme) => (theme.palette.mode === 'dark' ? 'grey.300' : 'grey.800'),
|
||||||
|
mb: 3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{headingPreview}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{/* Markdown контент */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
'& img': {
|
||||||
|
maxWidth: '100%',
|
||||||
|
height: 'auto',
|
||||||
|
borderRadius: 1,
|
||||||
|
},
|
||||||
|
'& h1, & h2, & h3, & h4, & h5, & h6': {
|
||||||
|
color: 'primary.main',
|
||||||
|
mt: 2,
|
||||||
|
mb: 1,
|
||||||
|
},
|
||||||
|
'& p': {
|
||||||
|
mb: 2,
|
||||||
|
color: (theme) => (theme.palette.mode === 'dark' ? 'grey.300' : 'grey.800'),
|
||||||
|
},
|
||||||
|
'& a': {
|
||||||
|
color: 'primary.main',
|
||||||
|
textDecoration: 'none',
|
||||||
|
'&:hover': {
|
||||||
|
textDecoration: 'underline',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'& blockquote': {
|
||||||
|
borderLeft: '4px solid',
|
||||||
|
borderColor: 'primary.main',
|
||||||
|
pl: 2,
|
||||||
|
my: 2,
|
||||||
|
color: 'text.secondary',
|
||||||
|
},
|
||||||
|
'& code': {
|
||||||
|
bgcolor: (theme) => (theme.palette.mode === 'dark' ? 'grey.900' : 'grey.100'),
|
||||||
|
p: 0.5,
|
||||||
|
borderRadius: 0.5,
|
||||||
|
color: 'primary.main',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ReactMarkdown>{preview}</ReactMarkdown>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
</Box>
|
</Box>
|
||||||
</Create>
|
</Create>
|
||||||
)
|
)
|
||||||
|
@ -1,13 +1,16 @@
|
|||||||
import {Box, TextField} from '@mui/material'
|
import {Box, TextField, Typography, Paper} from '@mui/material'
|
||||||
import {Edit} from '@refinedev/mui'
|
import {Edit} from '@refinedev/mui'
|
||||||
import {useForm} from '@refinedev/react-hook-form'
|
import {useForm} from '@refinedev/react-hook-form'
|
||||||
import {Controller} from 'react-hook-form'
|
import {Controller} from 'react-hook-form'
|
||||||
import {useParams} from 'react-router'
|
import {useParams} from 'react-router'
|
||||||
import React from 'react'
|
import React, {useState, useEffect} from 'react'
|
||||||
|
import ReactMarkdown from 'react-markdown'
|
||||||
|
import {useList} from '@refinedev/core'
|
||||||
|
|
||||||
import {MarkdownEditor} from '../../components/MarkdownEditor'
|
import {MarkdownEditor} from '../../components/MarkdownEditor'
|
||||||
import {LinkedItems} from '../../components/LinkedItems'
|
import {LinkedItems} from '../../components/LinkedItems'
|
||||||
import {MediaItem, mediaFields} from './types'
|
import {MediaItem, mediaFields} from './types'
|
||||||
|
import {TOKEN_KEY} from '../../authProvider'
|
||||||
import 'easymde/dist/easymde.min.css'
|
import 'easymde/dist/easymde.min.css'
|
||||||
|
|
||||||
const MemoizedSimpleMDE = React.memo(MarkdownEditor)
|
const MemoizedSimpleMDE = React.memo(MarkdownEditor)
|
||||||
@ -17,10 +20,33 @@ export const ArticleEdit = () => {
|
|||||||
saveButtonProps,
|
saveButtonProps,
|
||||||
register,
|
register,
|
||||||
control,
|
control,
|
||||||
|
watch,
|
||||||
formState: {errors},
|
formState: {errors},
|
||||||
} = useForm()
|
} = useForm()
|
||||||
|
|
||||||
const {id: articleId} = useParams<{id: string}>()
|
const {id: articleId} = useParams<{id: string}>()
|
||||||
|
const [preview, setPreview] = useState('')
|
||||||
|
const [headingPreview, setHeadingPreview] = useState('')
|
||||||
|
|
||||||
|
// Получаем привязанные медиа
|
||||||
|
const {data: mediaData} = useList<MediaItem>({
|
||||||
|
resource: `article/${articleId}/media`,
|
||||||
|
queryOptions: {
|
||||||
|
enabled: !!articleId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Следим за изменениями в полях body и heading
|
||||||
|
const bodyContent = watch('body')
|
||||||
|
const headingContent = watch('heading')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPreview(bodyContent || '')
|
||||||
|
}, [bodyContent])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setHeadingPreview(headingContent || '')
|
||||||
|
}, [headingContent])
|
||||||
|
|
||||||
const simpleMDEOptions = React.useMemo(
|
const simpleMDEOptions = React.useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
@ -32,37 +58,155 @@ export const ArticleEdit = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Edit saveButtonProps={saveButtonProps}>
|
<Edit saveButtonProps={saveButtonProps}>
|
||||||
<Box component="form" sx={{display: 'flex', flexDirection: 'column'}} autoComplete="off">
|
<Box sx={{display: 'flex', gap: 2}}>
|
||||||
<TextField
|
{/* Форма редактирования */}
|
||||||
{...register('heading', {
|
<Box component="form" sx={{flex: 1, display: 'flex', flexDirection: 'column'}} autoComplete="off">
|
||||||
required: 'Это поле является обязательным',
|
<TextField
|
||||||
})}
|
{...register('heading', {
|
||||||
error={!!(errors as any)?.heading}
|
required: 'Это поле является обязательным',
|
||||||
helperText={(errors as any)?.heading?.message}
|
})}
|
||||||
margin="normal"
|
error={!!(errors as any)?.heading}
|
||||||
fullWidth
|
helperText={(errors as any)?.heading?.message}
|
||||||
InputLabelProps={{shrink: true}}
|
margin="normal"
|
||||||
type="text"
|
fullWidth
|
||||||
label="Заголовок *"
|
InputLabelProps={{shrink: true}}
|
||||||
name="heading"
|
type="text"
|
||||||
/>
|
label="Заголовок *"
|
||||||
|
name="heading"
|
||||||
|
/>
|
||||||
|
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
name="body"
|
name="body"
|
||||||
rules={{required: 'Это поле является обязательным'}}
|
rules={{required: 'Это поле является обязательным'}}
|
||||||
defaultValue=""
|
defaultValue=""
|
||||||
render={({field: {onChange, value}}) => (
|
render={({field: {onChange, value}}) => (
|
||||||
<MemoizedSimpleMDE
|
<MemoizedSimpleMDE
|
||||||
value={value} // markdown
|
value={value} // markdown
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
options={simpleMDEOptions}
|
options={simpleMDEOptions}
|
||||||
className="my-markdown-editor"
|
className="my-markdown-editor"
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{articleId && <LinkedItems<MediaItem> type="edit" parentId={articleId} parentResource="article" childResource="media" fields={mediaFields} title="медиа" />}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Блок предпросмотра */}
|
||||||
|
<Paper
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
p: 2,
|
||||||
|
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'),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="h6" gutterBottom color="primary">
|
||||||
|
Предпросмотр
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{/* Заголовок статьи */}
|
||||||
|
<Typography
|
||||||
|
variant="h4"
|
||||||
|
gutterBottom
|
||||||
|
sx={{
|
||||||
|
color: (theme) => (theme.palette.mode === 'dark' ? 'grey.300' : 'grey.800'),
|
||||||
|
mb: 3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{headingPreview}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{/* Markdown контент */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
'& img': {
|
||||||
|
maxWidth: '100%',
|
||||||
|
height: 'auto',
|
||||||
|
borderRadius: 1,
|
||||||
|
},
|
||||||
|
'& h1, & h2, & h3, & h4, & h5, & h6': {
|
||||||
|
color: 'primary.main',
|
||||||
|
mt: 2,
|
||||||
|
mb: 1,
|
||||||
|
},
|
||||||
|
'& p': {
|
||||||
|
mb: 2,
|
||||||
|
color: (theme) => (theme.palette.mode === 'dark' ? 'grey.300' : 'grey.800'),
|
||||||
|
},
|
||||||
|
'& a': {
|
||||||
|
color: 'primary.main',
|
||||||
|
textDecoration: 'none',
|
||||||
|
'&:hover': {
|
||||||
|
textDecoration: 'underline',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'& blockquote': {
|
||||||
|
borderLeft: '4px solid',
|
||||||
|
borderColor: 'primary.main',
|
||||||
|
pl: 2,
|
||||||
|
my: 2,
|
||||||
|
color: 'text.secondary',
|
||||||
|
},
|
||||||
|
'& code': {
|
||||||
|
bgcolor: (theme) => (theme.palette.mode === 'dark' ? 'grey.900' : 'grey.100'),
|
||||||
|
p: 0.5,
|
||||||
|
borderRadius: 0.5,
|
||||||
|
color: 'primary.main',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ReactMarkdown>{preview}</ReactMarkdown>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Привязанные медиа */}
|
||||||
|
{mediaData?.data && mediaData.data.length > 0 && (
|
||||||
|
<Box sx={{mb: 3}}>
|
||||||
|
<Typography variant="subtitle1" gutterBottom color="primary">
|
||||||
|
Привязанные медиа:
|
||||||
|
</Typography>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: 1,
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
mb: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{mediaData.data.map((media) => (
|
||||||
|
<Box
|
||||||
|
key={media.id}
|
||||||
|
sx={{
|
||||||
|
width: 120,
|
||||||
|
height: 120,
|
||||||
|
borderRadius: 1,
|
||||||
|
overflow: 'hidden',
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'primary.main',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={`https://wn.krbl.ru/media/${media.id}/download?token=${localStorage.getItem(TOKEN_KEY)}`}
|
||||||
|
alt={media.media_name}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
objectFit: 'cover',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
)}
|
)}
|
||||||
/>
|
</Paper>
|
||||||
|
|
||||||
{articleId && <LinkedItems<MediaItem> type="edit" parentId={articleId} parentResource="article" childResource="media" fields={mediaFields} title="медиа" />}
|
|
||||||
</Box>
|
</Box>
|
||||||
</Edit>
|
</Edit>
|
||||||
)
|
)
|
||||||
|
Loading…
Reference in New Issue
Block a user