From 3bf4c1bc2a7848c139aee70f166fd7b20320ff23 Mon Sep 17 00:00:00 2001 From: Nevo David Date: Mon, 6 Jan 2025 02:08:32 +0700 Subject: [PATCH] feat: media uploader --- .../src/api/routes/media.controller.ts | 14 +- .../src/components/media/media.component.tsx | 462 +++++++++++++----- .../src/components/media/new.uploader.tsx | 17 +- .../database/prisma/media/media.repository.ts | 25 +- .../database/prisma/media/media.service.ts | 55 ++- .../src/database/prisma/schema.prisma | 1 + 6 files changed, 415 insertions(+), 159 deletions(-) diff --git a/apps/backend/src/api/routes/media.controller.ts b/apps/backend/src/api/routes/media.controller.ts index 8b520f3c..18b807eb 100644 --- a/apps/backend/src/api/routes/media.controller.ts +++ b/apps/backend/src/api/routes/media.controller.ts @@ -1,6 +1,7 @@ import { Body, Controller, + Delete, Get, Param, Post, @@ -31,6 +32,10 @@ export class MediaController { private _subscriptionService: SubscriptionService ) {} + @Delete('/:id') + deleteMedia(@GetOrgFromRequest() org: Organization, @Param('id') id: string) { + return this._mediaService.deleteMedia(org.id, id); + } @Post('/generate-image') async generateImage( @GetOrgFromRequest() org: Organization, @@ -96,15 +101,11 @@ export class MediaController { } @Post('/:endpoint') - // @UseInterceptors(FileInterceptor('file')) - // @UsePipes(new CustomFileValidationPipe()) async uploadFile( @GetOrgFromRequest() org: Organization, @Req() req: Request, @Res() res: Response, @Param('endpoint') endpoint: string - // @UploadedFile('file') - // file: Express.Multer.File ) { const upload = await handleR2Upload(endpoint, req, res); if (endpoint !== 'complete-multipart-upload') { @@ -122,11 +123,6 @@ export class MediaController { ); res.status(200).json({ ...upload, saved: saveFile }); - // const filePath = - // file.path.indexOf('http') === 0 - // ? file.path - // : file.path.replace(process.env.UPLOAD_DIRECTORY, ''); - // return this._mediaService.saveFile(org.id, file.originalname, filePath); } @Get('/') diff --git a/apps/frontend/src/components/media/media.component.tsx b/apps/frontend/src/components/media/media.component.tsx index e74b336d..da8540bb 100644 --- a/apps/frontend/src/components/media/media.component.tsx +++ b/apps/frontend/src/components/media/media.component.tsx @@ -1,6 +1,14 @@ 'use client'; -import { FC, useCallback, useEffect, useState } from 'react'; +import { + ClipboardEvent, + FC, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import { Button } from '@gitroom/react/form/button'; import useSWR from 'swr'; import { useFetch } from '@gitroom/helpers/utils/custom.fetch'; @@ -16,11 +24,95 @@ import { MultipartFileUploader } from '@gitroom/frontend/components/media/new.up import dynamic from 'next/dynamic'; import { useUser } from '@gitroom/frontend/components/layout/user.context'; import { AiImage } from '@gitroom/frontend/components/launches/ai.image'; +import Image from 'next/image'; +import { DropFiles } from '@gitroom/frontend/components/layout/drop.files'; +import { deleteDialog } from '@gitroom/react/helpers/delete.dialog'; + const Polonto = dynamic( () => import('@gitroom/frontend/components/launches/polonto') ); const showModalEmitter = new EventEmitter(); +export const Pagination: FC<{ + current: number; + totalPages: number; + setPage: (num: number) => void; +}> = (props) => { + const { current, totalPages, setPage } = props; + + const totalPagesList = useMemo(() => { + return Array.from({ length: totalPages }, (_, i) => i); + }, [totalPages]); + + return ( + + ); +}; export const ShowMediaBoxModal: FC = () => { const [showModal, setShowModal] = useState(false); const [callBack, setCallBack] = @@ -63,31 +155,132 @@ export const MediaBox: FC<{ closeModal: () => void; }> = (props) => { const { setMedia, type, closeModal } = props; - const [pages, setPages] = useState(0); const [mediaList, setListMedia] = useState([]); const fetch = useFetch(); const mediaDirectory = useMediaDirectory(); + const [page, setPage] = useState(0); + const [pages, setPages] = useState(0); - const [loading, setLoading] = useState(false); + const [selectedMedia, setSelectedMedia] = useState([]); + const ref = useRef(null); const loadMedia = useCallback(async () => { - return (await fetch('/media')).json(); - }, []); + return (await fetch(`/media?page=${page + 1}`)).json(); + }, [page]); const setNewMedia = useCallback( (media: Media) => () => { - setMedia(media); - closeModal(); + setSelectedMedia( + selectedMedia.find((p) => p.id === media.id) + ? selectedMedia.filter((f) => f.id !== media.id) + : [...selectedMedia.map((p) => ({ ...p })), { ...media }] + ); + // closeModal(); }, - [] + [selectedMedia] ); - const { data, mutate } = useSWR('get-media', loadMedia); + const removeMedia = useCallback( + (media: Media) => () => { + setSelectedMedia(selectedMedia.filter((f) => f.id !== media.id)); + setListMedia(mediaList.filter((f) => f.id !== media.id)); + }, + [selectedMedia] + ); + + const addNewMedia = useCallback( + (media: Media[]) => () => { + setSelectedMedia((currentMedia) => [...currentMedia, ...media]); + // closeModal(); + }, + [selectedMedia] + ); + + const addMedia = useCallback(async () => { + // @ts-ignore + setMedia(selectedMedia); + closeModal(); + }, [selectedMedia]); + + const { data, mutate } = useSWR(`get-media-${page}`, loadMedia); const finishUpload = useCallback(async () => { + const lastMedia = mediaList?.[0]?.id; const newData = await mutate(); - setNewMedia(newData.results[0])(); - }, [mutate, setNewMedia]); + const untilLastMedia = newData.results.findIndex( + (f: any) => f.id === lastMedia + ); + const onlyNewMedia = newData.results.slice( + 0, + untilLastMedia === -1 ? newData.results.length : untilLastMedia + ); + + addNewMedia(onlyNewMedia)(); + }, [mutate, addNewMedia, mediaList, selectedMedia]); + + const dragAndDrop = useCallback( + async (event: ClipboardEvent | File[]) => { + // @ts-ignore + const clipboardItems = event.map((p) => ({ + kind: 'file', + getAsFile: () => p, + })); + + if (!clipboardItems) { + return; + } + + const files: File[] = []; + + // @ts-ignore + for (const item of clipboardItems) { + if (item.kind === 'file') { + const file = item.getAsFile(); + if (file) { + const isImage = file.type.startsWith('image/'); + const isVideo = file.type.startsWith('video/'); + if (isImage || isVideo) { + files.push(file); // Collect images or videos + } + } + } + } + if (files.length === 0) { + return; + } + + ref.current.setOptions({ + autoProceed: false, + }); + for (const file of files) { + ref.current.addFile(file); + await ref.current.upload(); + ref.current.clear(); + } + ref.current.setOptions({ + autoProceed: true, + }); + + finishUpload(); + }, + [mutate, addNewMedia, mediaList, selectedMedia] + ); + + const removeItem = useCallback( + (media: Media) => async (e: any) => { + e.stopPropagation(); + if (!(await deleteDialog('Are you sure you want to delete the image?'))) { + return; + } + + await fetch(`/media/${media.id}`, { + method: 'DELETE', + }); + + mutate(); + }, + [mutate] + ); useEffect(() => { if (data?.pages) { @@ -100,110 +293,144 @@ export const MediaBox: FC<{ return (
-
-
-
- -
- - - {!!mediaList.length && ( - - )} -
-
- {!mediaList.length && ( -
-
You don{"'"}t have any assets yet.
-
Click the button below to upload one
-
- -
-
- )} - {mediaList - .filter((f) => { - if (type === 'video') { - return f.path.indexOf('mp4') > -1; - } else if (type === 'image') { - return f.path.indexOf('mp4') === -1; - } - return true; - }) - .map((media) => ( -
- {media.path.indexOf('mp4') > -1 ? ( - - ) : ( - media - )} -
- ))} - {loading && ( -
-
- + + + + + +
+ Select or upload pictures (maximum 5 at a time) +
+ You can also drag & drop pictures
+ + {!!mediaList.length && ( + <> +
+
+
+ +
+
+ + )}
- )} -
+
+ {!mediaList.length ? ( +
+
You don{"'"}t have any assets yet.
+
Click the button below to upload one
+
+ +
+
+ ) : ( + <> + {selectedMedia.length > 0 && ( +
+ +
+ )} + + )} + {mediaList + .filter((f) => { + if (type === 'video') { + return f.path.indexOf('mp4') > -1; + } else if (type === 'image') { + return f.path.indexOf('mp4') === -1; + } + return true; + }) + .map((media) => ( +
p.id === media.id) + ? 'border-4 border-forth' + : 'border-tableBorder border-2' + )} + onClick={setNewMedia(media)} + > +
+ X +
+ + {media.path.indexOf('mp4') > -1 ? ( + + ) : ( + media + )} +
+ ))} + {(pages || 0) > 1 && ( + + )} +
+
+
); @@ -236,8 +463,9 @@ export const MultiMediaComponent: FC<{ const mediaDirectory = useMediaDirectory(); const changeMedia = useCallback( - (m: { path: string; id: string }) => { - const newMedia = [...(currentMedia || []), m]; + (m: { path: string; id: string } | { path: string; id: string }[]) => { + const mediaArray = Array.isArray(m) ? m : [m]; + const newMedia = [...(currentMedia || []), ...mediaArray]; setCurrentMedia(newMedia); onChange({ target: { name, value: newMedia } }); }, @@ -267,7 +495,7 @@ export const MultiMediaComponent: FC<{ return ( <> -
+
{modal && } {mediaModal && !!user?.tier?.ai && ( @@ -326,10 +554,7 @@ export const MultiMediaComponent: FC<{ {!!user?.tier?.ai && ( - + )}
@@ -338,6 +563,7 @@ export const MultiMediaComponent: FC<{ <>
window.open(mediaDirectory.set(media.path))} > {media.path.indexOf('mp4') > -1 ? ( diff --git a/apps/frontend/src/components/media/new.uploader.tsx b/apps/frontend/src/components/media/new.uploader.tsx index ba3ab2ca..e1956758 100644 --- a/apps/frontend/src/components/media/new.uploader.tsx +++ b/apps/frontend/src/components/media/new.uploader.tsx @@ -15,10 +15,12 @@ import Compressor from '@uppy/compressor'; export function MultipartFileUploader({ onUploadSuccess, allowedFileTypes, + uppRef, }: { // @ts-ignore onUploadSuccess: (result: UploadResult) => void; allowedFileTypes: string; + uppRef?: any; }) { const [loaded, setLoaded] = useState(false); const [reload, setReload] = useState(false); @@ -49,6 +51,7 @@ export function MultipartFileUploader({ return ( @@ -68,7 +71,7 @@ export function useUppyUploader(props: { const uppy2 = new Uppy({ autoProceed: true, restrictions: { - maxNumberOfFiles: 1, + maxNumberOfFiles: 5, allowedFileTypes: allowedFileTypes.split(','), maxFileSize: 1000000000, }, @@ -117,19 +120,27 @@ export function useUppyUploader(props: { export function MultipartFileUploaderAfter({ onUploadSuccess, allowedFileTypes, + uppRef, }: { // @ts-ignore onUploadSuccess: (result: UploadResult) => void; allowedFileTypes: string; + uppRef: any; }) { const uppy = useUppyUploader({ onUploadSuccess, allowedFileTypes }); + const uppyInstance = useMemo(() => { + uppRef.current = uppy; + return uppy; + }, []); return ( <> {/* */} - +
+ +