integrate LinkedItems into /article pages
				
					
				
			This commit is contained in:
		| @@ -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> | ||||||
|   | |||||||
| @@ -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> | ||||||
| ) | ) | ||||||
|   | |||||||
| @@ -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> | ||||||
|   ) |   ) | ||||||
|   | |||||||
| @@ -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> | ||||||
|   ) |   ) | ||||||
|   | |||||||
							
								
								
									
										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