diff --git a/index.html b/index.html index fec06e0d..74516e4d 100644 --- a/index.html +++ b/index.html @@ -21,7 +21,7 @@ - + Polyflix diff --git a/public/locales/en/attachments.json b/public/locales/en/attachments.json index c0f66d05..b5345a5f 100644 --- a/public/locales/en/attachments.json +++ b/public/locales/en/attachments.json @@ -30,6 +30,10 @@ } }, "errors": {} + }, + "selector": { + "title": "Select attachments", + "validate": "Close" } }, "closeModal": "Close", diff --git a/public/locales/en/videos.json b/public/locales/en/videos.json index fa4701c7..12e27b13 100644 --- a/public/locales/en/videos.json +++ b/public/locales/en/videos.json @@ -51,16 +51,18 @@ "youtubeUrl": "YouTube video URL", "description": "Description", "upload": "Drag 'n' drop your video here.", - "attachments": { - "label": "Label", - "url": "Attachment URL", - "empty": "Your video doesn't have attachments." - }, "submit": { "create": "Create video", "update": "Update video" } }, + "attachments": { + "label": "Attachments", + "description": "You can add or remove attachments to your video.", + "add": "Add attachments", + "remove": "Remove attachment", + "empty": "Your video does not include any attachments yet." + }, "errors": { "upload": "An error occured when uploading your file. Please see the logs for more informations." }, diff --git a/public/locales/fr/attachments.json b/public/locales/fr/attachments.json index 00fc7667..9de64865 100644 --- a/public/locales/fr/attachments.json +++ b/public/locales/fr/attachments.json @@ -30,6 +30,10 @@ } }, "errors": {} + }, + "selector": { + "title": "Veuillez sélectionner les pièces jointes", + "validate": "Fermer" } }, "closeModal": "Fermer", @@ -44,4 +48,4 @@ "actions": { "copyToClipboard": "Copier le lien dans le presse-papier" } -} \ No newline at end of file +} diff --git a/public/locales/fr/videos.json b/public/locales/fr/videos.json index 07c2fb32..d340a989 100644 --- a/public/locales/fr/videos.json +++ b/public/locales/fr/videos.json @@ -56,16 +56,18 @@ "youtubeUrl": "Lien de la vidéo sur YouTube", "description": "Description", "upload": "Glissez déposer votre vidéo ici", - "attachments": { - "label": "Nom", - "url": "Lien de la pièce jointe", - "empty": "Votre vidéo n'a aucune pièces jointes." - }, "submit": { "create": "Créer la vidéo", "update": "Mettre à jour la vidéo" } }, + "attachments": { + "label": "Pièces jointes", + "description": "Vous pouvez ajouter ou supprimer des pièces jointes à votre vidéo.", + "add": "Ajouter des pièces jointes", + "remove": "Supprimer la pièce jointe", + "empty": "Votre vidéo n'a aucune pièces jointes." + }, "errors": { "upload": "Une erreur est survenue lors de l'envoi de vos fichiers." }, diff --git a/src/modules/attachments/components/AttachmentAvatar.component.tsx b/src/modules/attachments/components/AttachmentAvatar.component.tsx new file mode 100644 index 00000000..b90080dd --- /dev/null +++ b/src/modules/attachments/components/AttachmentAvatar.component.tsx @@ -0,0 +1,49 @@ +import { Avatar, IconButton, ListItemIcon, Tooltip } from '@mui/material' +import CopyToClipboard from 'react-copy-to-clipboard' +import { useTranslation } from 'react-i18next' + +import { useInjection } from '@polyflix/di' + +import { Icon } from '@core/components/Icon/Icon.component' +import { SnackbarService } from '@core/services/snackbar.service' + +type Props = { + url: string + copyToClipboard?: boolean +} + +export const AttachmentAvatar = ({ url, copyToClipboard = true }: Props) => { + const { t: tUsers } = useTranslation('users') + const { t: tAttachments } = useTranslation('attachments') + const snackbarService = useInjection(SnackbarService) + + const avatarContent = () => ( + + + + ) + + if (copyToClipboard) { + return ( + + { + snackbarService.createSnackbar( + tUsers('profile.tabs.attachments.content.list.clipboard'), + { + variant: 'success', + } + ) + }} + text={url} + > + ('actions.copyToClipboard')}> + {avatarContent()} + + + + ) + } else { + return {avatarContent()} + } +} diff --git a/src/modules/attachments/components/AttachmentSelectorModal.component.tsx b/src/modules/attachments/components/AttachmentSelectorModal.component.tsx new file mode 100644 index 00000000..a5671f64 --- /dev/null +++ b/src/modules/attachments/components/AttachmentSelectorModal.component.tsx @@ -0,0 +1,201 @@ +import { + Box, + Button, + Checkbox, + Container, + Fade, + Link, + ListItem, + ListItemButton, + ListItemText, + Modal, + Paper, + Stack, + SxProps, + Theme, + Typography, +} from '@mui/material' +import { useEffect, useState } from 'react' +import { UseFieldArrayReturn } from 'react-hook-form' +import { useTranslation } from 'react-i18next' +import { Redirect } from 'react-router-dom' + +import { NoData } from '@core/components/NoData/NoData.component' +import { PaginationSynced } from '@core/components/Pagination/PaginationSynced.component' +import { Scrollbar } from '@core/components/Scrollbar/Scrollbar.component' +import { buildSkeletons } from '@core/utils/gui.utils' + +import { useAuth } from '@auth/hooks/useAuth.hook' + +import { IVideoForm } from '@videos/types/form.type' + +import { Attachment } from '@attachments/models/attachment.model' +import { AttachmentParams } from '@attachments/models/attachment.params' +import { useGetUserAttachmentsQuery } from '@attachments/services/attachment.service' + +import { AttachmentAvatar } from './AttachmentAvatar.component' + +interface Props { + attachments: UseFieldArrayReturn + videoId?: string + isOpen: boolean + onClose: () => void + sx?: SxProps +} +export const AttachmentSelectorModal = ({ + attachments, + videoId, + isOpen, + onClose, + sx: sxProps, +}: Props) => { + const { user } = useAuth() + const { t } = useTranslation('attachments') + + const [page] = useState(1) + + const [filters, setFilters] = useState({ + page, + pageSize: 10, + userId: user!.id, + }) + + const { data, isLoading } = useGetUserAttachmentsQuery(filters) + + const { fields, append, remove } = attachments + + const handleToggle = (attachment: Attachment) => () => { + const currentIndex = fields.findIndex((e) => e.id === attachment.id) + if (currentIndex === -1) { + append(attachment) + } else { + remove(currentIndex) + } + } + + useEffect(() => { + if (videoId && data) { + for (const a of data.items) { + if (a.videos.includes(videoId)) { + append(a) + } + } + } + }, [videoId, data]) + + const isAttachmentSelected = (attachment: Attachment) => + fields.some((e) => e.id === attachment.id) + + /* If the user has no attachment, he is redirected to the attachment creation form */ + if (data && data.totalCount === 0) + return + + return ( + onClose()} + aria-labelledby="element modal" + closeAfterTransition + BackdropProps={{ + timeout: 500, + }} + > + + + + {t('forms.selector.title')} + + `calc(100vh - ${theme.spacing(30)})`, + minHeight: '300px', + }} + > + + {{ data } + ? data?.items.map((item) => ( + + } + disablePadding + > + + + + + + + + )) + : buildSkeletons(3)} + + + + {!isLoading && + (data && + data.items.length > 0 && + data.items.length < data.totalCount ? ( + + + + ) : ( + !data || + (data.items.length === 0 && ( + + )) + ))} + + + + + + + + ) +} diff --git a/src/modules/attachments/services/attachment.service.ts b/src/modules/attachments/services/attachment.service.ts index 6158d9f3..7c6ffe07 100644 --- a/src/modules/attachments/services/attachment.service.ts +++ b/src/modules/attachments/services/attachment.service.ts @@ -40,7 +40,7 @@ export const attachmentsApi = createApi({ }), getVideoAttachments: builder.query({ query: (videoId: string) => { - return `${Endpoint.Attachments}/video/${videoId}` + return `${Endpoint.Attachments}/video/${videoId}?pageSize=50&page=1` // TODO : remove pagination from attachment service (only for /video/id) }, providesTags: (result) => result diff --git a/src/modules/users/pages/ProfileAttachments/ProfileAttachmentListItem.component.tsx b/src/modules/users/pages/ProfileAttachments/ProfileAttachmentListItem.component.tsx index 894f0773..adc4ce59 100644 --- a/src/modules/users/pages/ProfileAttachments/ProfileAttachmentListItem.component.tsx +++ b/src/modules/users/pages/ProfileAttachments/ProfileAttachmentListItem.component.tsx @@ -7,16 +7,11 @@ import { ListItemText, Paper, Skeleton, - Tooltip, } from '@mui/material' -import CopyToClipboard from 'react-copy-to-clipboard' -import { useTranslation } from 'react-i18next' - -import { useInjection } from '@polyflix/di' import { Icon } from '@core/components/Icon/Icon.component' -import { SnackbarService } from '@core/services/snackbar.service' +import { AttachmentAvatar } from '@attachments/components/AttachmentAvatar.component' import { Attachment } from '@attachments/models/attachment.model' import { AttachmentListMenu } from '@users/components/AttachmentListMenu/AttachmentListMenu.component' @@ -26,10 +21,6 @@ type Props = { onDelete: () => void } export const ProfileAttachmentListItem = ({ attachment, onDelete }: Props) => { - const { t: tUsers } = useTranslation('users') - const { t: tAttachments } = useTranslation('attachments') - const snackbarService = useInjection(SnackbarService) - return ( { variant="outlined" sx={{ mb: 1 }} > - - { - snackbarService.createSnackbar( - tUsers('profile.tabs.attachments.content.list.clipboard'), - { - variant: 'success', - } - ) - }} - text={attachment.url} - > - ('actions.copyToClipboard')}> - - - - - - - - + { const snackbarService = useInjection(SnackbarService) const minioService = useInjection(MinioService) const [isInProgress, setIsInProgress] = useState(false) + const [isModalAttachmentOpen, setIsModalAttachmentOpen] = useState(false) const { t } = useTranslation('videos') @@ -75,6 +95,7 @@ export const VideoForm = ({ source, video, isUpdate }: Props) => { } const { + control, register, handleSubmit, formState: { errors, isSubmitting }, @@ -91,10 +112,14 @@ export const VideoForm = ({ source, video, isUpdate }: Props) => { visibility: video?.visibility || Visibility.PUBLIC, thumbnail: video?.thumbnail, source: video?.source.replace('-nocookie', ''), - attachments: video?.attachments, + attachments: video + ? useGetVideoAttachmentsQuery(video.id).data?.items || [] + : [], }, }) + const attachments = useFieldArray({ control, name: 'attachments' }) + // Useful states for our compoennt // This boolean allow us to control when a video was autocompleted (YouTube for example) const [isAutocompleted, setIsAutocompleted] = useState(false) @@ -170,11 +195,20 @@ export const VideoForm = ({ source, video, isUpdate }: Props) => { } } + /** Need to send only attachments ids */ + const mappedData = { + ...data, + attachments: attachments.fields.map((a) => a.id), + } + try { // handle response and get video and thumbnail psu url to upload them const { videoPutPsu, thumbnailPutPsu } = await (isUpdate - ? updateVideo({ slug: video!.slug, body: data }) - : createVideo(data) + ? updateVideo({ + slug: video!.slug, + body: mappedData as unknown as IVideoForm, + }) + : createVideo(mappedData as unknown as IVideoForm) ).unwrap() if (!isYoutube) { @@ -337,6 +371,86 @@ export const VideoForm = ({ source, video, isUpdate }: Props) => { + + + + {t('forms.create-update.attachments.label')} + ('forms.create-update.attachments.add')}> + setIsModalAttachmentOpen(true)} + color="primary" + > + + + + + + {t('forms.create-update.attachments.description')} + + {attachments.fields.length > 0 ? ( + + {attachments.fields.map((attachment, i) => { + return ( + ( + 'forms.create-update.attachments.remove' + )} + > + attachments.remove(i)} + > + + + + } + > + + + + + + ) + })} + + ) : ( + + {t('forms.create-update.attachments.empty')} + + )} + {isModalAttachmentOpen && ( + setIsModalAttachmentOpen(false)} + /> + )} + + {t('forms.create-update.title.status')} diff --git a/src/modules/videos/components/PlayerSidebar/AttachmentsPanel/AttachmentsPanel.component.tsx b/src/modules/videos/components/PlayerSidebar/AttachmentsPanel/AttachmentsPanel.component.tsx index 13e1f24f..8971bedc 100644 --- a/src/modules/videos/components/PlayerSidebar/AttachmentsPanel/AttachmentsPanel.component.tsx +++ b/src/modules/videos/components/PlayerSidebar/AttachmentsPanel/AttachmentsPanel.component.tsx @@ -1,20 +1,11 @@ -import { - Alert, - Avatar, - Link, - List, - ListItem, - Stack, - Typography, -} from '@mui/material' +import { Alert, Link, List, ListItem, ListItemText } from '@mui/material' import { useTranslation } from 'react-i18next' import { AutoScrollBox } from '@core/components/AutoScrollBox/AutoScrollBox.component' -import { Icon } from '@core/components/Icon/Icon.component' import { Video } from '@videos/models/video.model' -import { getDomain } from '@attachments/helpers/favicon.helper' +import { AttachmentAvatar } from '@attachments/components/AttachmentAvatar.component' import { useGetVideoAttachmentsQuery } from '@attachments/services/attachment.service' interface AttachmentPanelProps { @@ -35,25 +26,20 @@ export const AttachmentsPanel = ({ video }: AttachmentPanelProps) => { ) : ( attachments?.items.map((attachment) => ( - - - - - - - - - {attachment.title} - - - - + + + + + )) ) diff --git a/src/modules/videos/models/video.model.ts b/src/modules/videos/models/video.model.ts index c3fda64a..bea81dfc 100644 --- a/src/modules/videos/models/video.model.ts +++ b/src/modules/videos/models/video.model.ts @@ -35,7 +35,7 @@ export interface Video { // TODO // tags?: Tag[]; userMeta?: WatchMetadata | undefined - attachments: Attachment[] + attachments?: Attachment[] watchtime?: Watchtime | undefined isLiked?: boolean availableLanguages: SubtitleLanguages[] diff --git a/src/modules/videos/services/video.service.ts b/src/modules/videos/services/video.service.ts index 28009875..90953cf2 100644 --- a/src/modules/videos/services/video.service.ts +++ b/src/modules/videos/services/video.service.ts @@ -90,7 +90,6 @@ export const videosApi = createApi({ url: Endpoint.Videos, method: 'POST', body, - // body: { ...body, attachments: body.attachments.map((a) => a.id) }, // TODO #416 }), // Invalidates all video-type queries providing the LIST id - after all, depending of the sort order // that newly created video could show up in any lists. @@ -105,7 +104,6 @@ export const videosApi = createApi({ url: `${Endpoint.Videos}/${slug}`, method: 'PUT', body, - // body: { ...body, attachments: body.attachments.map((a) => a.id) }, // TODO #416 }), // Invalidates all queries that subscribe to this Video `slug` only. // In this case, `getVideo` will be re-run. `getVideos` *might* rerun, if this id was under its results.