integrate LinkedItems into /article pages

This commit is contained in:
maxim 2025-03-19 18:22:53 +03:00
parent faac402aa6
commit afa94b999c
5 changed files with 79 additions and 170 deletions

View File

@ -9,6 +9,13 @@ type Field<T> = {
data: keyof T data: keyof T
} }
type ExtraFieldConfig = {
type: 'number'
label: string
minValue: number
maxValue: (linkedItems: any[]) => number
}
type LinkedItemsProps<T> = { type LinkedItemsProps<T> = {
parentId: string | number parentId: string | number
parentResource: string parentResource: string
@ -16,14 +23,16 @@ type LinkedItemsProps<T> = {
fields: Field<T>[] fields: Field<T>[]
title: string title: string
type: 'show' | 'edit' type: 'show' | 'edit'
extraField?: ExtraFieldConfig
} }
export const LinkedItems = <T extends {id: number; [key: string]: any}>({parentId, parentResource, childResource, fields, title, type}: LinkedItemsProps<T>) => { export const LinkedItems = <T extends {id: number; [key: string]: any}>({parentId, parentResource, childResource, fields, title, type}: LinkedItemsProps<T>) => {
const [items, setItems] = useState<T[]>([]) const [items, setItems] = useState<T[]>([])
const [linkedItems, setLinkedItems] = useState<T[]>([]) const [linkedItems, setLinkedItems] = useState<T[]>([])
const [selectedItemId, setSelectedItemId] = useState<number | ''>('') const [selectedItemId, setSelectedItemId] = useState<number | null>(null)
const [pageNum, setPageNum] = useState<number>(1) const [pageNum, setPageNum] = useState<number>(1)
const [isLoading, setIsLoading] = useState<boolean>(true) const [isLoading, setIsLoading] = useState<boolean>(true)
const [mediaOrder, setMediaOrder] = useState<number>(1)
const theme = useTheme() const theme = useTheme()
useEffect(() => { useEffect(() => {
@ -65,13 +74,18 @@ export const LinkedItems = <T extends {id: number; [key: string]: any}>({parentI
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 = () => { const linkItem = () => {
if (selectedItemId) { if (selectedItemId !== null) {
const requestData = const requestData =
childResource === 'article' childResource === 'article'
? { ? {
[`${childResource}_id`]: selectedItemId, [`${childResource}_id`]: selectedItemId,
page_num: pageNum, page_num: pageNum,
} }
: childResource === 'media'
? {
[`${childResource}_id`]: selectedItemId,
media_order: mediaOrder,
}
: { : {
[`${childResource}_id`]: selectedItemId, [`${childResource}_id`]: selectedItemId,
} }
@ -81,7 +95,7 @@ export const LinkedItems = <T extends {id: number; [key: string]: any}>({parentI
.then(() => { .then(() => {
axios.get(`${BACKEND_URL}/${parentResource}/${parentId}/${childResource}`).then((response) => { axios.get(`${BACKEND_URL}/${parentResource}/${parentId}/${childResource}`).then((response) => {
setLinkedItems(response?.data || []) setLinkedItems(response?.data || [])
setSelectedItemId('') setSelectedItemId(null)
if (childResource === 'article') { if (childResource === 'article') {
setPageNum(pageNum + 1) setPageNum(pageNum + 1)
} }
@ -107,7 +121,7 @@ export const LinkedItems = <T extends {id: number; [key: string]: any}>({parentI
} }
return ( return (
<Accordion defaultExpanded> <Accordion>
<AccordionSummary <AccordionSummary
expandIcon={<ExpandMoreIcon />} expandIcon={<ExpandMoreIcon />}
sx={{ sx={{
@ -160,9 +174,10 @@ export const LinkedItems = <T extends {id: number; [key: string]: any}>({parentI
<Typography variant="subtitle1">Добавить {title}</Typography> <Typography variant="subtitle1">Добавить {title}</Typography>
<FormControl fullWidth> <FormControl fullWidth>
<InputLabel>Выберите {title}</InputLabel> <InputLabel>Выберите {title}</InputLabel>
<Select value={selectedItemId} onChange={(e) => setSelectedItemId(Number(e.target.value))} label={`Выберите ${title}`} fullWidth> <Select value={selectedItemId || ''} onChange={(e) => setSelectedItemId(e.target.value as number)} label={`Выберите ${title}`}>
{availableItems.map((item) => ( {availableItems.map((item) => (
<MenuItem key={item.id} value={item.id}> <MenuItem key={item.id} value={item.id}>
{/* {fields.map((field) => item[field.data]).join(' - ')} */}
{item[fields[0].data]} {item[fields[0].data]}
</MenuItem> </MenuItem>
))} ))}
@ -187,6 +202,24 @@ export const LinkedItems = <T extends {id: number; [key: string]: any}>({parentI
</FormControl> </FormControl>
)} )}
{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)
}}
fullWidth
InputLabelProps={{shrink: true}}
/>
</FormControl>
)}
<Button variant="contained" onClick={linkItem} disabled={!selectedItemId}> <Button variant="contained" onClick={linkItem} disabled={!selectedItemId}>
Добавить Добавить
</Button> </Button>

View File

@ -66,7 +66,7 @@ const StyledMarkdownEditor = styled('div')(({theme}) => ({
})) }))
export const MarkdownEditor = (props: SimpleMDEReactProps) => ( export const MarkdownEditor = (props: SimpleMDEReactProps) => (
<StyledMarkdownEditor className="my-markdown-editor"> <StyledMarkdownEditor className="my-markdown-editor" sx={{marginTop: 1.5, marginBottom: 3}}>
<SimpleMDE {...props} /> <SimpleMDE {...props} />
</StyledMarkdownEditor> </StyledMarkdownEditor>
) )

View File

@ -2,9 +2,12 @@ import {Box, TextField} 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 React from 'react' import React from 'react'
import {MarkdownEditor} from '../../components/MarkdownEditor' import {MarkdownEditor} from '../../components/MarkdownEditor'
import {LinkedItems} from '../../components/LinkedItems'
import {MediaItem, mediaFields} from './types'
import 'easymde/dist/easymde.min.css' import 'easymde/dist/easymde.min.css'
const MemoizedSimpleMDE = React.memo(MarkdownEditor) const MemoizedSimpleMDE = React.memo(MarkdownEditor)
@ -17,6 +20,8 @@ export const ArticleEdit = () => {
formState: {errors}, formState: {errors},
} = useForm() } = useForm()
const {id: articleId} = useParams<{id: string}>()
const simpleMDEOptions = React.useMemo( const simpleMDEOptions = React.useMemo(
() => ({ () => ({
placeholder: 'Введите контент в формате Markdown...', placeholder: 'Введите контент в формате Markdown...',
@ -56,6 +61,8 @@ export const ArticleEdit = () => {
/> />
)} )}
/> />
{articleId && <LinkedItems<MediaItem> type="edit" parentId={articleId} parentResource="article" childResource="media" fields={mediaFields} title="медиа" />}
</Box> </Box>
</Edit> </Edit>
) )

View File

@ -1,118 +1,18 @@
import {Stack, Typography, Grid2 as Grid, Button, MenuItem, Select, FormControl, InputLabel, TextField, Card, CardMedia, CardContent, CardActions} from '@mui/material' import {Stack, Typography} from '@mui/material'
import {useShow} from '@refinedev/core' import {useShow} from '@refinedev/core'
import {Show, TextFieldComponent} from '@refinedev/mui' import {Show, TextFieldComponent} from '@refinedev/mui'
import {LinkedItems} from '../../components/LinkedItems'
import {useEffect, useState} from 'react' import {MediaItem, articleFields, mediaFields} from './types'
import axios from 'axios'
import {BACKEND_URL} from '../../lib/constants'
type MediaItem = {
id: string
filename: string
media_type: string
}
export const ArticleShow = () => { export const ArticleShow = () => {
const {query} = useShow({}) const {query} = useShow({})
const {data, isLoading} = query const {data, isLoading} = query
const record = data?.data const record = data?.data
const [media, setMedia] = useState<MediaItem[]>([])
const [linkedMedia, setLinkedMedia] = useState<MediaItem[]>([])
const [selectedMediaId, setSelectedMediaId] = useState<string>('')
const [mediaOrder, setMediaOrder] = useState<number>(1)
const [mediaLoading, setMediaLoading] = useState<boolean>(true)
useEffect(() => {
if (record?.id) {
axios
.get(`${BACKEND_URL}/article/${record.id}/media`)
.then((response) => {
setLinkedMedia(response?.data || [])
})
.catch(() => {
setLinkedMedia([])
})
}
}, [record?.id])
useEffect(() => {
axios
.get(`${BACKEND_URL}/media`)
.then((response) => {
setMedia(response?.data || [])
setMediaLoading(false)
})
.catch(() => {
setMedia([])
setMediaLoading(false)
})
}, [])
const availableMedia = media.filter((mediaItem) => !linkedMedia.some((linkedItem) => linkedItem.id === mediaItem.id))
const linkMedia = () => {
if (selectedMediaId) {
const requestData = {
media_id: selectedMediaId,
media_order: mediaOrder,
}
axios
.post(`${BACKEND_URL}/article/${record?.id}/media`, requestData, {
headers: {
accept: 'application/json',
'Content-Type': 'application/json',
},
})
.then(() => {
axios
.get(`${BACKEND_URL}/article/${record?.id}/media`)
.then((response) => {
setLinkedMedia(response?.data || [])
setMediaOrder(mediaOrder + 1)
setSelectedMediaId('')
})
.catch(() => {
setLinkedMedia([])
})
})
.catch((error) => {
console.error('Error linking media:', error)
})
}
}
const deleteMedia = (mediaId: string) => {
axios
.delete(`${BACKEND_URL}/article/${record?.id}/media`, {
data: {media_id: mediaId},
})
.then(() => {
setLinkedMedia((prevMedia) => prevMedia.filter((item) => item.id !== mediaId))
})
.catch((error) => {
console.error('Error deleting media:', error)
})
}
const fields = [
// {label: 'ID', data: 'id'},
{label: 'Заголовок', data: 'heading'},
{label: 'Контент', data: 'body'},
]
const mediaFields = [
// {label: 'ID', data: 'id' as keyof MediaItem},
{label: 'Имя', data: 'filename' as keyof MediaItem},
{label: 'Тип', data: 'media_type' as keyof MediaItem},
]
return ( return (
<Show isLoading={isLoading}> <Show isLoading={isLoading}>
<Stack gap={4}> <Stack gap={4}>
{fields.map(({label, data}) => ( {articleFields.map(({label, data}) => (
<Stack key={data} gap={1}> <Stack key={data} gap={1}>
<Typography variant="h6" fontWeight="bold"> <Typography variant="h6" fontWeight="bold">
{label} {label}
@ -121,66 +21,7 @@ export const ArticleShow = () => {
</Stack> </Stack>
))} ))}
<Stack gap={2}> {record?.id && <LinkedItems<MediaItem> parentId={record.id} parentResource="article" childResource="media" fields={mediaFields} title="медиа" type="show" />}
<Typography variant="h6" fontWeight="bold">
Медиа
</Typography>
<Grid container spacing={2}>
{mediaLoading ? (
<Typography>Загрузка медиа...</Typography>
) : linkedMedia.length > 0 ? (
linkedMedia.map((mediaItem) => (
<Grid key={mediaItem.id}>
<Card
sx={{
marginBottom: '8px',
borderRadius: 2,
border: (theme) => `2px solid ${theme.palette.divider}`,
}}
>
<CardMedia component="img" height="200" image={`${BACKEND_URL}/media/${mediaItem?.id}/download`} alt={mediaItem?.filename} sx={{objectFit: 'contain'}} />
<CardContent>
{mediaFields.map(({label, data}) => (
<Typography key={data} variant="body2" color="textSecondary">
<strong>{label}:</strong> {mediaItem?.[data]}
</Typography>
))}
</CardContent>
<CardActions>
<Button variant="outlined" color="error" onClick={() => deleteMedia(mediaItem?.id)} fullWidth>
Отвязать
</Button>
</CardActions>
</Card>
</Grid>
))
) : (
<Typography>Нет привязанных медиа</Typography>
)}
</Grid>
{/* sx={{width: '650px'}} */}
<Stack gap={2} mt={4}>
{' '}
<Typography variant="h6" fontWeight="bold">
Привязать медиа
</Typography>
<FormControl fullWidth>
<InputLabel>Медиа</InputLabel>
<Select value={selectedMediaId} onChange={(e) => setSelectedMediaId(e.target.value)} fullWidth>
{availableMedia.map((mediaItem) => (
<MenuItem key={mediaItem.id} value={mediaItem.id}>
{mediaItem.filename}
</MenuItem>
))}
</Select>
</FormControl>
<TextField type="number" label="Порядок отображения медиа" value={mediaOrder} onChange={(e) => setMediaOrder(Number(e.target.value))} fullWidth InputLabelProps={{shrink: true}} />
<Button variant="contained" onClick={linkMedia} disabled={!selectedMediaId}>
Привязать
</Button>
</Stack>
</Stack>
</Stack> </Stack>
</Show> </Show>
) )

View File

@ -0,0 +1,28 @@
export type MediaItem = {
id: number
filename: string
media_type: string
media_order?: number
}
export type ArticleItem = {
id: number
heading: string
body: string
}
export type FieldType<T> = {
label: string
data: keyof T
render?: (value: any) => React.ReactNode
}
export const articleFields: Array<FieldType<ArticleItem>> = [
{label: 'Заголовок', data: 'heading'},
{label: 'Контент', data: 'body'},
]
export const mediaFields: Array<FieldType<MediaItem>> = [
{label: 'Имя', data: 'filename'},
{label: 'Тип', data: 'media_type'},
]