feat: drag and drop files
This commit is contained in:
parent
baea8b28ee
commit
5cb69667b3
4 changed files with 184 additions and 134 deletions
|
|
@ -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)}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue