feat: drag and drop files

This commit is contained in:
Nevo David 2026-01-23 13:01:09 +07:00
parent baea8b28ee
commit 5cb69667b3
4 changed files with 184 additions and 134 deletions

View file

@ -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 (
<div {...getRootProps()} className={clsx("relative", props.className)}>

View file

@ -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<any>(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<HTMLInputElement>) => {
const files = Array.from(e.target.files).slice(0, 5);
// @ts-ignore
uppy.addFiles(files);
}, []);
const addToUpload = useCallback(
async (e: ChangeEvent<HTMLInputElement>) => {
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<HTMLDivElement> | 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: (
<div className="w-full h-full p-[50px]">
{media.path.indexOf('mp4') > -1 ? (
<VideoFrame autoplay={true} url={mediaDirectory.set(media.path)} />
<VideoFrame
autoplay={true}
url={mediaDirectory.set(media.path)}
/>
) : (
<img
width="100%"
@ -340,17 +382,24 @@ export const MediaBox: FC<{
const btn = useMemo(() => {
return (
<button
disabled={loading}
onClick={() => uploaderRef?.current?.click()}
className="cursor-pointer bg-btnSimple changeColor flex gap-[8px] h-[44px] px-[18px] justify-center items-center rounded-[8px]"
className="relative cursor-pointer bg-btnSimple changeColor flex gap-[8px] h-[44px] px-[18px] justify-center items-center rounded-[8px]"
>
<PlusIcon size={14} />
<div>{t('upload', 'Upload')}</div>
{loading ? (
<div className="absolute left-[50%] top-[50%] -translate-y-[50%] -translate-x-[50%]">
<div className="animate-spin h-[20px] w-[20px] border-4 border-white border-t-transparent rounded-full" />
</div>
) : (
<PlusIcon size={14} />
)}
<div className={loading && 'invisible'}>{t('upload', 'Upload')}</div>
</button>
);
}, [t]);
}, [t, loading]);
return (
<DropFiles className="flex flex-col flex-1" onDrop={dragAndDrop}>
<DropFiles disabled={loading} className="flex flex-col flex-1" onDrop={dragAndDrop}>
<div className="flex flex-col flex-1">
<div
className={clsx(
@ -361,8 +410,8 @@ export const MediaBox: FC<{
{!isLoading && !!data?.results?.length && (
<div className="flex-1 text-[14px] font-[600] whitespace-pre-line">
{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<{
</div>
<div className="whitespace-pre-line text-newTextColor/[0.6] text-center">
{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) ? (
<div className="text-white flex justify-center items-center text-[14px] font-[500] w-[24px] h-[24px] rounded-full bg-[#612BD3] absolute -bottom-[10px] -end-[10px]">
<div className="text-white flex z-[101] justify-center items-center text-[14px] font-[500] w-[24px] h-[24px] rounded-full bg-[#612BD3] absolute -bottom-[10px] -end-[10px]">
{selected.findIndex((z: any) => z.id === media.id) + 1}
</div>
) : (

View file

@ -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<M = any, B = any> extends Compressor<any, any> {
override async prepareUpload(fileIDs: string[]) {
@ -35,50 +35,11 @@ export class CompressionWrapper<M = any, B = any> extends Compressor<any, any> {
}
}
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<any, any>) => {
setReload(true);
onUploadSuccess(result);
},
[onUploadSuccess]
);
useEffect(() => {
if (reload) {
setTimeout(() => {
setReload(false);
}, 1);
}
}, [reload]);
useEffect(() => {
setLoaded(true);
}, []);
if (!loaded || reload) {
return null;
}
return (
<MultipartFileUploaderAfter
uppRef={uppRef || {}}
onUploadSuccess={onUploadSuccessExtended}
allowedFileTypes={allowedFileTypes}
/>
);
}
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<string>(
(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 (
<>
{/* <Dashboard uppy={uppy} /> */}
<div className="pointer-events-none bigWrap">
<Dashboard
height={23}
width={200}
className=""
uppy={uppyInstance}
id={`media-uploader`}
showProgressDetails={true}
hideUploadButton={true}
hideRetryButton={true}
hidePauseResumeButton={true}
hideCancelButton={true}
hideProgressAfterFinish={true}
/>
</div>
<FileInput
uppy={uppyInstance}
locale={{
strings: {
chooseFiles: t('upload', 'Upload'),
},
// @ts-ignore
pluralize: (n: any) => n,
}}
/>
</>
);
}

View file

@ -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<undefined | { editor: any }>();
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',
});