From 5cb69667b39e665c415434c7c8dc5a69bbae3cd9 Mon Sep 17 00:00:00 2001 From: Nevo David Date: Fri, 23 Jan 2026 13:01:09 +0700 Subject: [PATCH] feat: drag and drop files --- .../src/components/layout/drop.files.tsx | 11 +- .../src/components/media/media.component.tsx | 89 +++++++--- .../src/components/media/new.uploader.tsx | 162 ++++++------------ .../src/components/new-launch/editor.tsx | 56 +++++- 4 files changed, 184 insertions(+), 134 deletions(-) diff --git a/apps/frontend/src/components/layout/drop.files.tsx b/apps/frontend/src/components/layout/drop.files.tsx index 746c1585..e84b9c7a 100644 --- a/apps/frontend/src/components/layout/drop.files.tsx +++ b/apps/frontend/src/components/layout/drop.files.tsx @@ -2,15 +2,24 @@ import { useDropzone } from 'react-dropzone'; import { FC, ReactNode } from 'react'; import { useT } from '@gitroom/react/translation/get.transation.service.client'; import clsx from 'clsx'; +import { useToaster } from '@gitroom/react/toaster/toaster'; export const DropFiles: FC<{ children: ReactNode; className?: string; onDrop: (files: File[]) => void; + disabled?: boolean; }> = (props) => { const t = useT(); + const toaster = useToaster(); const { getRootProps, isDragActive } = useDropzone({ - onDrop: props.onDrop, + onDrop: (files) => { + if (props.disabled) { + toaster.show('Upload current in progress, please wait and then try again.', 'warning'); + return ; + } + props.onDrop(files); + }, }); return (
diff --git a/apps/frontend/src/components/media/media.component.tsx b/apps/frontend/src/components/media/media.component.tsx index 9a289958..97a10637 100644 --- a/apps/frontend/src/components/media/media.component.tsx +++ b/apps/frontend/src/components/media/media.component.tsx @@ -18,6 +18,7 @@ import { Media } from '@prisma/client'; import { useMediaDirectory } from '@gitroom/react/helpers/use.media.directory'; import { useSettings } from '@gitroom/frontend/components/launches/helpers/use.values'; import EventEmitter from 'events'; +import { useToaster } from '@gitroom/react/toaster/toaster'; import clsx from 'clsx'; import { VideoFrame } from '@gitroom/react/helpers/video.frame'; import { useUppyUploader } from '@gitroom/frontend/components/media/new.uploader'; @@ -48,6 +49,7 @@ import { } from '@gitroom/frontend/components/ui/icons'; import { useLaunchStore } from '@gitroom/frontend/components/new-launch/store'; import { useShallow } from 'zustand/react/shallow'; +import { LoadingComponent } from '@gitroom/frontend/components/layout/loading'; const Polonto = dynamic( () => import('@gitroom/frontend/components/launches/polonto') ); @@ -194,6 +196,7 @@ export const showMediaBox = ( showModalEmitter.emit('show-modal', callback); }; const CHUNK_SIZE = 1024 * 1024; +const MAX_UPLOAD_SIZE = 1024 * 1024 * 1024; // 1 GB export const MediaBox: FC<{ setMedia: (params: { id: string; path: string }[]) => void; standalone?: boolean; @@ -203,6 +206,7 @@ export const MediaBox: FC<{ const [page, setPage] = useState(0); const fetch = useFetch(); const modals = useModals(); + const toaster = useToaster(); const loadMedia = useCallback(async () => { return (await fetch(`/media?page=${page + 1}`)).json(); }, [page]); @@ -211,6 +215,7 @@ export const MediaBox: FC<{ const t = useT(); const uploaderRef = useRef(null); const mediaDirectory = useMediaDirectory(); + const [loading, setLoading] = useState(false); const uppy = useUppyUploader({ allowedFileTypes: @@ -220,7 +225,6 @@ export const MediaBox: FC<{ ? 'video/mp4' : 'image/*,video/mp4', onUploadSuccess: async (arr) => { - uppy.clear(); await mutate(); if (standalone) { return; @@ -229,6 +233,8 @@ export const MediaBox: FC<{ return [...prevSelected, ...arr]; }); }, + onStart: () => setLoading(true), + onEnd: () => setLoading(false), }); const addRemoveSelected = useCallback( @@ -255,11 +261,29 @@ export const MediaBox: FC<{ modals.closeCurrent(); }, [selected]); - const addToUpload = useCallback(async (e: ChangeEvent) => { - const files = Array.from(e.target.files).slice(0, 5); - // @ts-ignore - uppy.addFiles(files); - }, []); + const addToUpload = useCallback( + async (e: ChangeEvent) => { + const files = Array.from(e.target.files || []); + const totalSize = files.reduce((acc, file) => acc + file.size, 0); + + if (totalSize > MAX_UPLOAD_SIZE) { + toaster.show( + t( + 'upload_size_limit_exceeded', + 'Upload size limit exceeded. Maximum 1 GB per upload session.' + ), + 'warning' + ); + return; + } + + setLoading(true); + + // @ts-ignore + uppy.addFiles(files); + }, + [toaster, t] + ); const dragAndDrop = useCallback( async (event: ClipboardEvent | File[]) => { @@ -272,7 +296,7 @@ export const MediaBox: FC<{ return; } - const files = []; + const files: File[] = []; // @ts-ignore for (const item of clipboardItems) { if (item.kind === 'file') { @@ -283,11 +307,26 @@ export const MediaBox: FC<{ } } - for (const file of files.slice(0, 5)) { + const totalSize = files.reduce((acc, file) => acc + file.size, 0); + + if (totalSize > MAX_UPLOAD_SIZE) { + toaster.show( + t( + 'upload_size_limit_exceeded', + 'Upload size limit exceeded. Maximum 1 GB per upload session.' + ), + 'warning' + ); + return; + } + + setLoading(true); + + for (const file of files) { uppy.addFile(file); } }, - [] + [toaster, t] ); const maximize = useCallback( @@ -299,7 +338,10 @@ export const MediaBox: FC<{ children: (
{media.path.indexOf('mp4') > -1 ? ( - + ) : ( { return ( ); - }, [t]); + }, [t, loading]); return ( - +
{t( - 'select_or_upload_pictures_max_5', - 'Select or upload pictures (maximum 5 at a time).' + 'select_or_upload_pictures_max_1gb', + 'Select or upload pictures (maximum 1 GB per upload).' )} {'\n'} {t( @@ -423,8 +472,8 @@ export const MediaBox: FC<{
{t( - 'select_or_upload_pictures_max_5', - 'Select or upload pictures (maximum 5 at a time).' + 'select_or_upload_pictures_max_1gb', + 'Select or upload pictures (maximum 1 GB per upload).' )}{' '} {'\n'} {t( @@ -476,7 +525,7 @@ export const MediaBox: FC<{ onClick={addRemoveSelected(media)} > {!!selected.find((p: any) => p.id === media.id) ? ( -
+
{selected.findIndex((z: any) => z.id === media.id) + 1}
) : ( diff --git a/apps/frontend/src/components/media/new.uploader.tsx b/apps/frontend/src/components/media/new.uploader.tsx index 540b9e10..fe7172e1 100644 --- a/apps/frontend/src/components/media/new.uploader.tsx +++ b/apps/frontend/src/components/media/new.uploader.tsx @@ -12,7 +12,7 @@ import Compressor from '@uppy/compressor'; import { useT } from '@gitroom/react/translation/get.transation.service.client'; import { useToaster } from '@gitroom/react/toaster/toaster'; import { useLaunchStore } from '@gitroom/frontend/components/new-launch/store'; -import { uniq } from 'lodash'; +import { uniqBy } from 'lodash'; export class CompressionWrapper extends Compressor { override async prepareUpload(fileIDs: string[]) { @@ -35,50 +35,11 @@ export class CompressionWrapper extends 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); - const onUploadSuccessExtended = useCallback( - (result: UploadResult) => { - setReload(true); - onUploadSuccess(result); - }, - [onUploadSuccess] - ); - useEffect(() => { - if (reload) { - setTimeout(() => { - setReload(false); - }, 1); - } - }, [reload]); - useEffect(() => { - setLoaded(true); - }, []); - if (!loaded || reload) { - return null; - } - return ( - - ); -} - export function useUppyUploader(props: { // @ts-ignore onUploadSuccess: (result: UploadResult) => void; + onStart: () => void; + onEnd: () => void; allowedFileTypes: string; }) { const setLocked = useLaunchStore((state) => state.setLocked); @@ -88,6 +49,9 @@ export function useUppyUploader(props: { const { onUploadSuccess, allowedFileTypes } = props; const fetch = useFetch(); return useMemo(() => { + // Track file order to maintain original sequence after upload + let fileOrderIndex = 0; + const uppy2 = new Uppy({ autoProceed: true, restrictions: { @@ -221,49 +185,79 @@ export function useUppyUploader(props: { setLocked(true); uppy2.setFileMeta(file.id, { useCloudflare: storageProvider === 'cloudflare' ? 'true' : 'false', // Example of adding a custom field + addedOrder: fileOrderIndex++, // Track original order for sorting after upload // Add more fields as needed }); }); uppy2.on('error', (result) => { uppy2.clear(); setLocked(false); + props.onEnd(); + fileOrderIndex = 0; + }); + uppy2.on('upload-start', () => { + props.onStart(); }); uppy2.on('complete', async (result) => { + for (const file of [...result.successful]) { + uppy2.removeFile(file.id); + } + + props.onEnd(); + // Sort results by original add order to maintain file sequence + const sortedSuccessful = [...result.successful].sort((a, b) => { + const orderA = +((a.meta as any)?.addedOrder ?? 0); + const orderB = +((b.meta as any)?.addedOrder ?? 0); + return orderA - orderB; + }); + if (storageProvider === 'local') { setLocked(false); - onUploadSuccess(result.successful.map((p) => p.response.body)); + fileOrderIndex = 0; + onUploadSuccess(sortedSuccessful.map((p) => p.response.body)); return; } if (transloadit.length > 0) { // @ts-ignore const allRes = result.transloadit[0].results; - const toSave = uniq( - (allRes[Object.keys(allRes)[0]] || []).flatMap((item: any) => - item.url.split('/').pop() - ) + const toSave = uniqBy<{ name: string; order: number }>( + (allRes[Object.keys(allRes)[0]] || []).flatMap((item: any) => ({ + name: item.url.split('/').pop(), + order: +item.user_meta.addedOrder, + })), + (item) => item.name ); - const loadAllMedia = await Promise.all( - toSave.map(async (name) => { - return ( - await fetch('/media/save-media', { - method: 'POST', - body: JSON.stringify({ - name, - }), - }) - ).json(); + const loadAllMedia = ( + await Promise.all( + toSave.map(async ({ name, order }) => ({ + file: await ( + await fetch('/media/save-media', { + method: 'POST', + body: JSON.stringify({ + name, + }), + }) + ).json(), + order, + })) + ) + ) + .sort((a, b) => { + return a.order - b.order; }) - ); + .map((p) => p.file); setLocked(false); + fileOrderIndex = 0; onUploadSuccess(loadAllMedia); return; } setLocked(false); - onUploadSuccess(result.successful.map((p) => p.response.body.saved)); + fileOrderIndex = 0; + onUploadSuccess(sortedSuccessful.map((p) => p.response.body.saved)); }); uppy2.on('upload-success', (file, response) => { // @ts-ignore @@ -279,53 +273,3 @@ export function useUppyUploader(props: { return uppy2; }, []); } -export function MultipartFileUploaderAfter({ - onUploadSuccess, - allowedFileTypes, - uppRef, -}: { - // @ts-ignore - onUploadSuccess: (result: UploadResult) => void; - allowedFileTypes: string; - uppRef: any; -}) { - const t = useT(); - const uppy = useUppyUploader({ - onUploadSuccess, - allowedFileTypes, - }); - const uppyInstance = useMemo(() => { - uppRef.current = uppy; - return uppy; - }, []); - return ( - <> - {/* */} -
- -
- n, - }} - /> - - ); -} diff --git a/apps/frontend/src/components/new-launch/editor.tsx b/apps/frontend/src/components/new-launch/editor.tsx index 0c56ed0e..87ff52d2 100644 --- a/apps/frontend/src/components/new-launch/editor.tsx +++ b/apps/frontend/src/components/new-launch/editor.tsx @@ -56,6 +56,7 @@ import { suggestion } from '@gitroom/frontend/components/new-launch/mention.comp import { useFetch } from '@gitroom/helpers/utils/custom.fetch'; import { AComponent } from '@gitroom/frontend/components/new-launch/a.component'; import { Placeholder } from '@tiptap/extensions'; +import { useToaster } from '@gitroom/react/toaster/toaster'; import { InformationComponent } from '@gitroom/frontend/components/launches/information.component'; import { LockIcon, @@ -67,6 +68,8 @@ import { } from '@gitroom/frontend/components/ui/icons'; import { DelayComponent } from '@gitroom/frontend/components/new-launch/delay.component'; +const MAX_UPLOAD_SIZE = 1024 * 1024 * 1024; // 1 GB + const InterceptBoldShortcut = Extension.create({ name: 'preventBoldWithUnderline', @@ -558,7 +561,9 @@ export const Editor: FC<{ const [id] = useState(makeId(10)); const [emojiPickerOpen, setEmojiPickerOpen] = useState(false); const t = useT(); + const toaster = useToaster(); const editorRef = useRef(); + const [loading, setLoading] = useState(false); const uppy = useUppyUploader({ onUploadSuccess: (result: any) => { @@ -566,15 +571,32 @@ export const Editor: FC<{ uppy.clear(); }, allowedFileTypes: 'image/*,video/mp4', + onStart: () => setLoading(true), + onEnd: () => setLoading(false), }); const onDrop = useCallback( (acceptedFiles: File[]) => { + const totalSize = acceptedFiles.reduce((acc, file) => acc + file.size, 0); + + if (totalSize > MAX_UPLOAD_SIZE) { + toaster.show( + t( + 'upload_size_limit_exceeded', + 'Upload size limit exceeded. Maximum 1 GB per upload session.' + ), + 'warning' + ); + return; + } + + setLoading(true); + for (const file of acceptedFiles) { uppy.addFile(file); } }, - [uppy] + [uppy, toaster, t] ); const paste = useCallback( @@ -588,21 +610,47 @@ export const Editor: FC<{ return; } + const files: File[] = []; // @ts-ignore for (const item of clipboardItems) { if (item.kind === 'file') { const file = item.getAsFile(); if (file) { - uppy.addFile(file); + files.push(file); } } } + + const totalSize = files.reduce((acc, file) => acc + file.size, 0); + + if (totalSize > MAX_UPLOAD_SIZE) { + toaster.show( + t( + 'upload_size_limit_exceeded', + 'Upload size limit exceeded. Maximum 1 GB per upload session.' + ), + 'warning' + ); + return; + } + + setLoading(true); + + for (const file of files) { + uppy.addFile(file); + } }, - [uppy, num, comments] + [uppy, num, comments, toaster, t] ); const { getRootProps, isDragActive } = useDropzone({ - onDrop, + onDrop: (files) => { + if (loading) { + toaster.show('Upload current in progress, please wait and then try again.', 'warning'); + return ; + } + onDrop(files); + }, noDrag: num > 0 && comments === 'no-media', });