add preview functionality for /article route

This commit is contained in:
maxim 2025-04-06 23:45:13 +03:00
parent b105bfe395
commit 2fe19516f6
4 changed files with 945 additions and 61 deletions

View File

@ -28,6 +28,7 @@
"react-dropzone": "^14.3.8",
"react-hook-form": "^7.30.0",
"react-i18next": "^15.4.1",
"react-markdown": "^10.1.0",
"react-router": "^7.0.2",
"react-simplemde-editor": "^5.2.0"
},

File diff suppressed because it is too large Load Diff

View File

@ -1,8 +1,9 @@
import {Box, TextField} from '@mui/material'
import {Box, TextField, Typography, Paper} from '@mui/material'
import {Create} from '@refinedev/mui'
import {useForm} from '@refinedev/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 'easymde/dist/easymde.min.css'
@ -15,6 +16,7 @@ export const ArticleCreate = () => {
refineCore: {formLoading},
register,
control,
watch,
formState: {errors},
} = useForm({
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(
() => ({
placeholder: 'Введите контент в формате Markdown...',
@ -32,35 +49,99 @@ export const ArticleCreate = () => {
return (
<Create isLoading={formLoading} saveButtonProps={saveButtonProps}>
<Box component="form" sx={{display: 'flex', flexDirection: 'column'}} autoComplete="off">
<TextField
{...register('heading', {
required: 'Это поле является обязательным',
})}
error={!!(errors as any)?.heading}
helperText={(errors as any)?.heading?.message}
margin="normal"
fullWidth
InputLabelProps={{shrink: true}}
type="text"
label={'Заголовок *'}
name="heading"
/>
<Box sx={{display: 'flex', gap: 2}}>
{/* Форма создания */}
<Box component="form" sx={{flex: 1, display: 'flex', flexDirection: 'column'}} autoComplete="off">
<TextField
{...register('heading', {
required: 'Это поле является обязательным',
})}
error={!!(errors as any)?.heading}
helperText={(errors as any)?.heading?.message}
margin="normal"
fullWidth
InputLabelProps={{shrink: true}}
type="text"
label="Заголовок *"
name="heading"
/>
<Controller
control={control}
name="body"
rules={{required: 'Это поле является обязательным'}}
defaultValue=""
render={({field: {onChange, value}}) => (
<MemoizedSimpleMDE
value={value} // markdown
onChange={onChange}
options={simpleMDEOptions}
className="my-markdown-editor"
/>
)}
/>
<Controller control={control} name="body" rules={{required: 'Это поле является обязательным'}} defaultValue="" render={({field: {onChange, value}}) => <MemoizedSimpleMDE value={value} onChange={onChange} options={simpleMDEOptions} className="my-markdown-editor" />} />
</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>
</Paper>
</Box>
</Create>
)

View File

@ -1,13 +1,16 @@
import {Box, TextField} from '@mui/material'
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 from 'react'
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'
const MemoizedSimpleMDE = React.memo(MarkdownEditor)
@ -17,10 +20,33 @@ export const ArticleEdit = () => {
saveButtonProps,
register,
control,
watch,
formState: {errors},
} = useForm()
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(
() => ({
@ -32,37 +58,155 @@ export const ArticleEdit = () => {
return (
<Edit saveButtonProps={saveButtonProps}>
<Box component="form" sx={{display: 'flex', flexDirection: 'column'}} autoComplete="off">
<TextField
{...register('heading', {
required: 'Это поле является обязательным',
})}
error={!!(errors as any)?.heading}
helperText={(errors as any)?.heading?.message}
margin="normal"
fullWidth
InputLabelProps={{shrink: true}}
type="text"
label="Заголовок *"
name="heading"
/>
<Box sx={{display: 'flex', gap: 2}}>
{/* Форма редактирования */}
<Box component="form" sx={{flex: 1, display: 'flex', flexDirection: 'column'}} autoComplete="off">
<TextField
{...register('heading', {
required: 'Это поле является обязательным',
})}
error={!!(errors as any)?.heading}
helperText={(errors as any)?.heading?.message}
margin="normal"
fullWidth
InputLabelProps={{shrink: true}}
type="text"
label="Заголовок *"
name="heading"
/>
<Controller
control={control}
name="body"
rules={{required: 'Это поле является обязательным'}}
defaultValue=""
render={({field: {onChange, value}}) => (
<MemoizedSimpleMDE
value={value} // markdown
onChange={onChange}
options={simpleMDEOptions}
className="my-markdown-editor"
/>
<Controller
control={control}
name="body"
rules={{required: 'Это поле является обязательным'}}
defaultValue=""
render={({field: {onChange, value}}) => (
<MemoizedSimpleMDE
value={value} // markdown
onChange={onChange}
options={simpleMDEOptions}
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>
)}
/>
{articleId && <LinkedItems<MediaItem> type="edit" parentId={articleId} parentResource="article" childResource="media" fields={mediaFields} title="медиа" />}
</Paper>
</Box>
</Edit>
)