Skip to content

Commit

Permalink
feat: bulk upload of all files at once (#8)
Browse files Browse the repository at this point in the history
  • Loading branch information
james-elicx authored Sep 1, 2023
1 parent 258a207 commit 4f4a7c2
Show file tree
Hide file tree
Showing 11 changed files with 282 additions and 158 deletions.
95 changes: 69 additions & 26 deletions components/file-upload/upload-file-button.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,26 @@
'use client';

import { useEffect, useRef, useState } from 'react';
import { useEffect, useRef } from 'react';
import { useRouter } from 'next/navigation';
import { twMerge } from 'tailwind-merge';
import { UploadSimple, XCircle } from '../icons';
import { useLocation } from '../providers';
import type { DroppedFiles } from './drop-zone';
import { useLocation, useUploadFiles } from '../providers';
import { DropZone } from './drop-zone';
import { UploadFileRow } from './upload-file-row';

export const UploadFileButton = (): JSX.Element => {
const router = useRouter();
const { currentBucket, location } = useLocation();
const locationPath = [...(currentBucket ? [currentBucket.parsed] : []), ...location].join('/');
const directoryStr = location.join('/');
const locationPath = [...(currentBucket ? [currentBucket.parsed] : []), directoryStr].join('/');

const modal = useRef<HTMLDialogElement>(null);
const [droppedFiles, setDroppedFiles] = useState<DroppedFiles | null>(null);
const { files, updateFiles, uploadFiles, progress, isDone, error, isUploading } =
useUploadFiles();

useEffect(() => {
const onCloseModal = () => {
setDroppedFiles(null);
updateFiles([]);
router.refresh();
};

Expand All @@ -29,7 +31,7 @@ export const UploadFileButton = (): JSX.Element => {
return () => {
modalInstance?.removeEventListener('close', onCloseModal);
};
}, [modal, router]);
}, [modal, router, updateFiles]);

return (
<>
Expand All @@ -50,36 +52,77 @@ export const UploadFileButton = (): JSX.Element => {
<div className="flex w-full flex-row justify-center">
<h5>Upload Files</h5>

<button type="button" onClick={() => modal.current?.close()}>
<button
type="button"
onClick={() => modal.current?.close()}
disabled={isUploading}
className="disabled:cursor-not-allowed disabled:opacity-50"
>
<XCircle
weight="bold"
className="absolute right-2 top-2 h-4 w-4 text-secondary transition-colors hover:text-primary dark:text-secondary-dark hover:dark:text-primary-dark"
/>
</button>
</div>

{droppedFiles?.valid?.length || droppedFiles?.invalid?.length ? (
droppedFiles.valid.map((file) => (
<UploadFileRow
key={`${file.name}-${file.size}`}
file={file}
bucket={currentBucket?.raw ?? null}
dirPath={location.join('/')}
/>
))
{files?.length ? (
<div className="flex max-h-40 w-full flex-col gap-2 overflow-y-auto scrollbar:w-1 scrollbar-track:bg-secondary/30 scrollbar-thumb:bg-secondary dark:scrollbar-track:bg-secondary-dark/30 dark:scrollbar-thumb:bg-secondary-dark">
{files.map(({ file }) => (
<UploadFileRow key={`${file.name}-${file.size}`} file={file} />
))}
</div>
) : (
<DropZone onDrop={(files) => setDroppedFiles(files)} multiple>
Drop a file here
<DropZone
onDrop={(droppedFiles) =>
updateFiles(droppedFiles.valid.map((file) => ({ dir: directoryStr, file })))
}
multiple
>
Drop your files here
</DropZone>
)}

<span
className="w-full truncate pt-2 text-left text-xs text-secondary dark:text-secondary-dark"
title={locationPath}
>
<span className="mr-1 font-semibold">Location:</span>
{locationPath}
</span>
{error && <span className="text-xs text-status-error">{error}</span>}

<div className="flex w-full flex-row justify-between">
<span
className="w-full truncate pt-2 text-left text-xs text-secondary dark:text-secondary-dark"
title={locationPath}
>
<span className="mr-1 font-semibold">Location:</span>
{locationPath}
</span>

<button
disabled={!files.length || isUploading || progress > 0}
type="button"
aria-label="Upload file"
className={twMerge(
'flex h-7 w-7 items-center justify-center rounded-full border-1 border-secondary-dark/20 bg-background px-1 transition-[background] duration-75 hover:bg-secondary hover:bg-opacity-30 disabled:cursor-not-allowed dark:border-secondary-dark/20 dark:bg-background-dark dark:hover:bg-secondary-dark',
!files.length && 'opacity-50',
isUploading &&
'border-status-info text-status-info dark:border-status-info dark:text-status-info',
isDone &&
progress === 100 &&
'border-status-success text-status-success dark:border-status-success dark:text-status-success',
error &&
'border-status-error text-status-error dark:border-status-error dark:text-status-error',
)}
style={
{
'--upload-progress': `${progress}%`,
...(!isDone &&
isUploading && {
background:
'radial-gradient(closest-side, rgb(250 250 250 / 0) 79%, transparent 80% 100%), conic-gradient(rgb(33 150 253 / 0.6) var(--upload-progress), rgb(33 150 253 / 0.1) 0)',
}),
} as React.CSSProperties
}
onClick={() => !isUploading && progress === 0 && uploadFiles()}
>
<UploadSimple weight="bold" className="h-4 w-4" />
</button>
</div>
</div>
</dialog>
</>
Expand Down
137 changes: 14 additions & 123 deletions components/file-upload/upload-file-row.tsx
Original file line number Diff line number Diff line change
@@ -1,134 +1,25 @@
'use client';

import { useState, useCallback, useRef, useEffect } from 'react';
import { twMerge } from 'tailwind-merge';
import { bytesToString } from '@/utils';
import type { FileInfo } from '@/app/api/bucket/[bucket]/route';
import { UploadSimple } from '../icons';

type Props = {
bucket: string | null;
dirPath: string;
file: File;
};

export const UploadFileRow = ({ bucket, dirPath, file }: Props) => {
const req = useRef<XMLHttpRequest>(new XMLHttpRequest());

const [isUploading, setIsUploading] = useState(false);
const [isDone, setIsDone] = useState(false);
const [progress, setProgress] = useState(0);
const [error, setError] = useState<string | null>(null);

useEffect(() => {
const reqInstance = req.current;

const onProgress = ({ loaded, total }: ProgressEvent) =>
setProgress(Math.round((loaded / total) * 100));
const onLoad = () => {
setIsUploading(false);
setProgress(0);
};
const onError = () => {
setIsUploading(false);
setError('An error occurred while uploading the file');
};
const onAbort = () => {
setIsUploading(false);
setError('The upload has been canceled by the user or the browser dropped the connection');
};
const onStart = () => {
setIsDone(false);
setIsUploading(true);
setError(null);
};
const onDone = () => {
setIsUploading(false);
setIsDone(true);

if (reqInstance?.status !== 200) {
const resp = reqInstance?.responseText;
setError(resp || 'An error occurred while uploading the file');
} else {
setProgress(100);
}
};

reqInstance?.upload?.addEventListener('progress', onProgress);
reqInstance?.addEventListener('load', onLoad);
reqInstance?.addEventListener('loadstart', onStart);
reqInstance?.addEventListener('loadend', onDone);
reqInstance?.addEventListener('error', onError);
reqInstance?.addEventListener('abort', onAbort);

return () => {
reqInstance?.upload?.removeEventListener('progress', onProgress);
reqInstance?.removeEventListener('load', onLoad);
reqInstance?.removeEventListener('loadend', onDone);
reqInstance?.removeEventListener('loadstart', onStart);
reqInstance?.removeEventListener('error', onError);
reqInstance?.removeEventListener('abort', onAbort);
};
}, []);

const uploadFile = useCallback(async () => {
const reqInstance = req.current;

const key = `${dirPath}/${file.name}`.replace(/\/+/g, '/').replace(/^\//, '');
const fileInfo: FileInfo = { bucket: bucket as string, key, lastMod: file.lastModified };
const fileInfoStr = btoa(JSON.stringify(fileInfo));

reqInstance?.open('PUT', `/api/bucket/${bucket}`);
reqInstance?.setRequestHeader('x-content-type', file.type);

const formData = new FormData();
formData.append(fileInfoStr, file);
reqInstance?.send(formData);
}, [bucket, dirPath, file]);

return (
<div key={`${file.name}-${file.size}`} className="flex w-full flex-row items-center gap-1">
<div className="flex flex-grow flex-col truncate">
<span className="truncate" title={file.name}>
{file.name}
export const UploadFileRow = ({ file }: Props) => (
<div className="flex w-full flex-row items-center gap-1">
<div className="flex flex-grow flex-col truncate pr-1">
<span className="truncate" title={file.name}>
{file.name}
</span>

<div className="grid grid-cols-4 text-xs">
<span className="col-span-2 truncate pr-1">{file.type || 'unknown type'}</span>
<span>{bytesToString(file.size)}</span>
<span suppressHydrationWarning className="text-right">
{new Date(file.lastModified).toLocaleDateString()}
</span>

<div className="grid grid-cols-2 text-xs">
<span>{file.type || 'unknown type'}</span>
<span>{bytesToString(file.size)}</span>
</div>

{error && <span className="text-xs text-status-error">{error}</span>}
</div>

<button
disabled={isUploading || progress > 0}
type="button"
aria-label="Upload file"
className={twMerge(
'flex h-7 w-7 items-center justify-center rounded-full border-1 border-secondary-dark/20 bg-background px-1 transition-[background] duration-75 hover:bg-secondary hover:bg-opacity-30 disabled:cursor-not-allowed dark:border-secondary-dark/20 dark:bg-background-dark dark:hover:bg-secondary-dark',
isUploading &&
'border-status-info text-status-info dark:border-status-info dark:text-status-info',
isDone &&
progress === 100 &&
'border-status-success text-status-success dark:border-status-success dark:text-status-success',
error &&
'border-status-error text-status-error dark:border-status-error dark:text-status-error',
)}
style={
{
'--upload-progress': `${progress}%`,
...(!isDone &&
isUploading && {
background:
'radial-gradient(closest-side, rgb(250 250 250 / var(--tw-bg-opacity)) 79%, transparent 80% 100%), conic-gradient(rgb(33 150 253 / var(--tw-bg-opacity)) var(--upload-progress), rgb(33 150 253 / 0.3) 0)',
}),
} as React.CSSProperties
}
onClick={() => !isUploading && progress === 0 && uploadFile()}
>
<UploadSimple weight="bold" className="h-4 w-4" />
</button>
</div>
);
};
</div>
);
6 changes: 4 additions & 2 deletions components/navs/top-nav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { useRouter, useSelectedLayoutSegments } from 'next/navigation';
import { useMemo } from 'react';
import Link from 'next/link';
import { addLeadingSlash, formatFullPath, formatBucketName, toTitleCase } from '@/utils';
import { ThemeToggle } from '../providers';
import { ThemeToggle, UploadFilesProvider } from '../providers';
import { ArrowLeft, ArrowRight, CaretRight } from '../icons';
import { UploadFileButton } from '../file-upload';

Expand Down Expand Up @@ -79,7 +79,9 @@ export const TopNav = (): JSX.Element => {
</TopNavSection>

<TopNavSection>
<UploadFileButton />
<UploadFilesProvider>
<UploadFileButton />
</UploadFilesProvider>

<ThemeToggle />
</TopNavSection>
Expand Down
1 change: 1 addition & 0 deletions components/providers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export * from './theme-provider';
export * from './location-provider';
export * from './file-preview-provider';
export * from './object-explorer-provider';
export * from './upload-files-provider';
74 changes: 74 additions & 0 deletions components/providers/upload-files-provider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
'use client';

import { useXhr } from '@/utils/hooks';
import { createContext, useCallback, useContext, useMemo, useState } from 'react';
import type { FileInfo } from '@/app/api/bucket/[bucket]/route';
import { useLocation } from './location-provider';

type FileWithDir = { dir: string; file: File };

export type IUploadFilesContext = {
files: FileWithDir[];
updateFiles: (newFiles: FileWithDir[]) => void;
uploadFiles: () => void;
error: string | null;
progress: number;
isDone: boolean;
isUploading: boolean;
};

const UploadFilesContext = createContext<IUploadFilesContext>({
files: [],
updateFiles: () => {},
uploadFiles: () => {},
error: null,
progress: 0,
isDone: false,
isUploading: false,
});

export const useUploadFiles = () => useContext(UploadFilesContext);

type Props = {
children: React.ReactNode;
};

export const UploadFilesProvider = ({ children }: Props): JSX.Element => {
const { currentBucket } = useLocation();
const [files, setFiles] = useState<FileWithDir[]>([]);

const { error, progress, isDone, isUploading, putFormData } = useXhr();

const updateFiles = useCallback((newFiles: FileWithDir[]) => {
setFiles(newFiles);
}, []);

const uploadFiles = useCallback(async () => {
if (!currentBucket?.raw) return;

const processedFiles = files.map(({ dir, file }) => {
const key = `${dir}/${file.name}`.replace(/\/+/g, '/').replace(/^\//, '');
const fileInfo: FileInfo = { bucket: currentBucket.raw, key, lastMod: file.lastModified };
const fileInfoStr = btoa(JSON.stringify(fileInfo));

return { key: fileInfoStr, value: file };
});

if (processedFiles.length) {
putFormData(`/api/bucket/${currentBucket.raw}`, processedFiles);
}
}, [currentBucket, putFormData, files]);

return (
<UploadFilesContext.Provider
value={useMemo(
() => ({ files, updateFiles, uploadFiles, error, progress, isDone, isUploading }),
[files, updateFiles, uploadFiles, error, progress, isDone, isUploading],
)}
>
{children}
</UploadFilesContext.Provider>
);
};

export type { Props as UploadFilesProviderProps };
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
"@tanstack/react-form": "^0.0.12",
"@tanstack/react-table": "^8.9.3",
"@tanstack/react-virtual": "3.0.0-beta.56",
"cf-bindings-proxy": "^0.4.1",
"cf-bindings-proxy": "0.5.0",
"kysely": "^0.26.3",
"next": "13.4.19",
"next-auth": "0.0.0-manual.ffd05533",
Expand Down
Loading

0 comments on commit 4f4a7c2

Please sign in to comment.