integrate LinkedItems into /article pages
				
					
				
			This commit is contained in:
		| @@ -9,6 +9,13 @@ type Field<T> = { | ||||
|   data: keyof T | ||||
| } | ||||
|  | ||||
| type ExtraFieldConfig = { | ||||
|   type: 'number' | ||||
|   label: string | ||||
|   minValue: number | ||||
|   maxValue: (linkedItems: any[]) => number | ||||
| } | ||||
|  | ||||
| type LinkedItemsProps<T> = { | ||||
|   parentId: string | number | ||||
|   parentResource: string | ||||
| @@ -16,14 +23,16 @@ type LinkedItemsProps<T> = { | ||||
|   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 | ''>('') | ||||
|   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(() => { | ||||
| @@ -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 linkItem = () => { | ||||
|     if (selectedItemId) { | ||||
|     if (selectedItemId !== null) { | ||||
|       const requestData = | ||||
|         childResource === 'article' | ||||
|           ? { | ||||
|               [`${childResource}_id`]: selectedItemId, | ||||
|               page_num: pageNum, | ||||
|             } | ||||
|           : childResource === 'media' | ||||
|           ? { | ||||
|               [`${childResource}_id`]: selectedItemId, | ||||
|               media_order: mediaOrder, | ||||
|             } | ||||
|           : { | ||||
|               [`${childResource}_id`]: selectedItemId, | ||||
|             } | ||||
| @@ -81,7 +95,7 @@ export const LinkedItems = <T extends {id: number; [key: string]: any}>({parentI | ||||
|         .then(() => { | ||||
|           axios.get(`${BACKEND_URL}/${parentResource}/${parentId}/${childResource}`).then((response) => { | ||||
|             setLinkedItems(response?.data || []) | ||||
|             setSelectedItemId('') | ||||
|             setSelectedItemId(null) | ||||
|             if (childResource === 'article') { | ||||
|               setPageNum(pageNum + 1) | ||||
|             } | ||||
| @@ -107,7 +121,7 @@ export const LinkedItems = <T extends {id: number; [key: string]: any}>({parentI | ||||
|   } | ||||
|  | ||||
|   return ( | ||||
|     <Accordion defaultExpanded> | ||||
|     <Accordion> | ||||
|       <AccordionSummary | ||||
|         expandIcon={<ExpandMoreIcon />} | ||||
|         sx={{ | ||||
| @@ -160,9 +174,10 @@ export const LinkedItems = <T extends {id: number; [key: string]: any}>({parentI | ||||
|               <Typography variant="subtitle1">Добавить {title}</Typography> | ||||
|               <FormControl fullWidth> | ||||
|                 <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) => ( | ||||
|                     <MenuItem key={item.id} value={item.id}> | ||||
|                       {/* {fields.map((field) => item[field.data]).join(' - ')} */} | ||||
|                       {item[fields[0].data]} | ||||
|                     </MenuItem> | ||||
|                   ))} | ||||
| @@ -187,6 +202,24 @@ export const LinkedItems = <T extends {id: number; [key: string]: any}>({parentI | ||||
|                 </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> | ||||
|   | ||||
| @@ -66,7 +66,7 @@ const StyledMarkdownEditor = styled('div')(({theme}) => ({ | ||||
| })) | ||||
|  | ||||
| export const MarkdownEditor = (props: SimpleMDEReactProps) => ( | ||||
|   <StyledMarkdownEditor className="my-markdown-editor"> | ||||
|   <StyledMarkdownEditor className="my-markdown-editor" sx={{marginTop: 1.5, marginBottom: 3}}> | ||||
|     <SimpleMDE {...props} /> | ||||
|   </StyledMarkdownEditor> | ||||
| ) | ||||
|   | ||||
| @@ -2,9 +2,12 @@ import {Box, TextField} 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 {MarkdownEditor} from '../../components/MarkdownEditor' | ||||
| import {LinkedItems} from '../../components/LinkedItems' | ||||
| import {MediaItem, mediaFields} from './types' | ||||
| import 'easymde/dist/easymde.min.css' | ||||
|  | ||||
| const MemoizedSimpleMDE = React.memo(MarkdownEditor) | ||||
| @@ -17,6 +20,8 @@ export const ArticleEdit = () => { | ||||
|     formState: {errors}, | ||||
|   } = useForm() | ||||
|  | ||||
|   const {id: articleId} = useParams<{id: string}>() | ||||
|  | ||||
|   const simpleMDEOptions = React.useMemo( | ||||
|     () => ({ | ||||
|       placeholder: 'Введите контент в формате Markdown...', | ||||
| @@ -56,6 +61,8 @@ export const ArticleEdit = () => { | ||||
|             /> | ||||
|           )} | ||||
|         /> | ||||
|  | ||||
|         {articleId && <LinkedItems<MediaItem> type="edit" parentId={articleId} parentResource="article" childResource="media" fields={mediaFields} title="медиа" />} | ||||
|       </Box> | ||||
|     </Edit> | ||||
|   ) | ||||
|   | ||||
| @@ -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 {Show, TextFieldComponent} from '@refinedev/mui' | ||||
|  | ||||
| import {useEffect, useState} from 'react' | ||||
| import axios from 'axios' | ||||
|  | ||||
| import {BACKEND_URL} from '../../lib/constants' | ||||
|  | ||||
| type MediaItem = { | ||||
|   id: string | ||||
|   filename: string | ||||
|   media_type: string | ||||
| } | ||||
| import {LinkedItems} from '../../components/LinkedItems' | ||||
| import {MediaItem, articleFields, mediaFields} from './types' | ||||
|  | ||||
| export const ArticleShow = () => { | ||||
|   const {query} = useShow({}) | ||||
|   const {data, isLoading} = query | ||||
|   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 ( | ||||
|     <Show isLoading={isLoading}> | ||||
|       <Stack gap={4}> | ||||
|         {fields.map(({label, data}) => ( | ||||
|         {articleFields.map(({label, data}) => ( | ||||
|           <Stack key={data} gap={1}> | ||||
|             <Typography variant="h6" fontWeight="bold"> | ||||
|               {label} | ||||
| @@ -121,66 +21,7 @@ export const ArticleShow = () => { | ||||
|           </Stack> | ||||
|         ))} | ||||
|  | ||||
|         <Stack gap={2}> | ||||
|           <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> | ||||
|         {record?.id && <LinkedItems<MediaItem> parentId={record.id} parentResource="article" childResource="media" fields={mediaFields} title="медиа" type="show" />} | ||||
|       </Stack> | ||||
|     </Show> | ||||
|   ) | ||||
|   | ||||
							
								
								
									
										28
									
								
								src/pages/article/types.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								src/pages/article/types.ts
									
									
									
									
									
										Normal 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'}, | ||||
| ] | ||||
		Reference in New Issue
	
	Block a user