diff --git a/components/file-upload/upload-file-button.tsx b/components/file-upload/upload-file-button.tsx index 33e31db..1f2b073 100644 --- a/components/file-upload/upload-file-button.tsx +++ b/components/file-upload/upload-file-button.tsx @@ -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(null); - const [droppedFiles, setDroppedFiles] = useState(null); + const { files, updateFiles, uploadFiles, progress, isDone, error, isUploading } = + useUploadFiles(); useEffect(() => { const onCloseModal = () => { - setDroppedFiles(null); + updateFiles([]); router.refresh(); }; @@ -29,7 +31,7 @@ export const UploadFileButton = (): JSX.Element => { return () => { modalInstance?.removeEventListener('close', onCloseModal); }; - }, [modal, router]); + }, [modal, router, updateFiles]); return ( <> @@ -50,7 +52,12 @@ export const UploadFileButton = (): JSX.Element => {
Upload Files
-
- {droppedFiles?.valid?.length || droppedFiles?.invalid?.length ? ( - droppedFiles.valid.map((file) => ( - - )) + {files?.length ? ( +
+ {files.map(({ file }) => ( + + ))} +
) : ( - setDroppedFiles(files)} multiple> - Drop a file here + + updateFiles(droppedFiles.valid.map((file) => ({ dir: directoryStr, file }))) + } + multiple + > + Drop your files here )} - - Location: - {locationPath} - + {error && {error}} + +
+ + Location: + {locationPath} + + + +
diff --git a/components/file-upload/upload-file-row.tsx b/components/file-upload/upload-file-row.tsx index 91899d9..2a2631c 100644 --- a/components/file-upload/upload-file-row.tsx +++ b/components/file-upload/upload-file-row.tsx @@ -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(new XMLHttpRequest()); - - const [isUploading, setIsUploading] = useState(false); - const [isDone, setIsDone] = useState(false); - const [progress, setProgress] = useState(0); - const [error, setError] = useState(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 ( -
-
- - {file.name} +export const UploadFileRow = ({ file }: Props) => ( +
+
+ + {file.name} + + +
+ {file.type || 'unknown type'} + {bytesToString(file.size)} + + {new Date(file.lastModified).toLocaleDateString()} - -
- {file.type || 'unknown type'} - {bytesToString(file.size)} -
- - {error && {error}}
- -
- ); -}; +
+); diff --git a/components/navs/top-nav.tsx b/components/navs/top-nav.tsx index 356e3d5..0c715c7 100644 --- a/components/navs/top-nav.tsx +++ b/components/navs/top-nav.tsx @@ -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'; @@ -79,7 +79,9 @@ export const TopNav = (): JSX.Element => { - + + + diff --git a/components/providers/index.ts b/components/providers/index.ts index e1a59f7..2204eca 100644 --- a/components/providers/index.ts +++ b/components/providers/index.ts @@ -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'; diff --git a/components/providers/upload-files-provider.tsx b/components/providers/upload-files-provider.tsx new file mode 100644 index 0000000..0b94f49 --- /dev/null +++ b/components/providers/upload-files-provider.tsx @@ -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({ + 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([]); + + 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 ( + ({ files, updateFiles, uploadFiles, error, progress, isDone, isUploading }), + [files, updateFiles, uploadFiles, error, progress, isDone, isUploading], + )} + > + {children} + + ); +}; + +export type { Props as UploadFilesProviderProps }; diff --git a/package.json b/package.json index 5c63c4b..7e5e685 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 09f7325..aec5538 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,8 +24,8 @@ dependencies: specifier: 3.0.0-beta.56 version: 3.0.0-beta.56(react@18.2.0) cf-bindings-proxy: - specifier: ^0.4.1 - version: 0.4.1(@cloudflare/workers-types@4.20230821.0)(wrangler@3.6.0) + specifier: 0.5.0 + version: 0.5.0(@cloudflare/workers-types@4.20230821.0)(wrangler@3.6.0) kysely: specifier: ^0.26.3 version: 0.26.3 @@ -2474,10 +2474,10 @@ packages: transitivePeerDependencies: - supports-color - /cf-bindings-proxy@0.4.1(@cloudflare/workers-types@4.20230821.0)(wrangler@3.6.0): + /cf-bindings-proxy@0.5.0(@cloudflare/workers-types@4.20230821.0)(wrangler@3.6.0): resolution: { - integrity: sha512-07kJ/zwL4KdnGh+NRSsq5kxTjCLCynw8hyoARnjyQy8XHJH6UL7XkAOT9G823aGOIIbMY50xpJsvyJzYwaM+Xg==, + integrity: sha512-nzpUv1J3uwmJ3Zf9BemI3ffZS0BVHmWuvL1YivdapiqXpmuQNnFhGi45eeXCG0U4n0yLVfegHvgxC847LgC70w==, } hasBin: true peerDependencies: diff --git a/tailwind.config.ts b/tailwind.config.ts index 7fd070f..cb1d47d 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -1,4 +1,5 @@ import type { Config } from 'tailwindcss'; +import type { PluginAPI } from 'tailwindcss/types/config'; const sharedColors = { transparent: 'transparent', @@ -69,7 +70,15 @@ const config: Config = { }, }, }, - plugins: [], + plugins: [ + ({ addVariant }: PluginAPI) => { + addVariant('scrollbar', '&::-webkit-scrollbar'); + addVariant('scrollbar-track', '&::-webkit-scrollbar-track'); + addVariant('scrollbar-thumb', '&::-webkit-scrollbar-thumb'); + addVariant('slider-track', '&::-webkit-slider-runnable-track'); + addVariant('slider-thumb', '&::-webkit-slider-thumb'); + }, + ], }; export default config; diff --git a/utils/file-object.ts b/utils/file-object.ts index 6b9b8f7..e26dd2a 100644 --- a/utils/file-object.ts +++ b/utils/file-object.ts @@ -72,7 +72,8 @@ export const parseObject = (object: string | R2Object) => { getLastModified: (): Date | null => { if (isDirectory) return null; - const lastMod = Number(object.customMetadata?.['mtime']); + const mtime = object.customMetadata?.['mtime']; + const lastMod = Number(mtime?.length === 10 ? mtime.padEnd(13, '0') : mtime); if (Number.isNaN(lastMod)) return null; return new Date(lastMod); diff --git a/utils/hooks/index.ts b/utils/hooks/index.ts index 06b4b6b..5d7cde8 100644 --- a/utils/hooks/index.ts +++ b/utils/hooks/index.ts @@ -1 +1,2 @@ export { useOnClickOutside } from './use-on-click-outside'; +export { useXhr } from './use-xhr'; diff --git a/utils/hooks/use-xhr.ts b/utils/hooks/use-xhr.ts new file mode 100644 index 0000000..bfc2406 --- /dev/null +++ b/utils/hooks/use-xhr.ts @@ -0,0 +1,102 @@ +'use client'; + +import { useRef, useState, useCallback, useEffect, useMemo } from 'react'; + +export const useXhr = () => { + const xhrRef = useRef<{ xhr: XMLHttpRequest; cleanup: () => void } | null>(null); + + const [progress, setProgress] = useState(0); + const [isUploading, setIsUploading] = useState(false); + const [isDone, setIsDone] = useState(false); + const [error, setError] = useState(null); + + const createXhr = useCallback(() => { + if (xhrRef.current) { + xhrRef.current.cleanup(); + } + + const xhr = new XMLHttpRequest(); + + setProgress(0); + setIsUploading(false); + setIsDone(false); + setError(null); + + 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 blob'); + }; + 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 (xhr.status !== 200) { + const resp = xhr.responseText; + setError(resp || 'An error occurred while uploading the blob'); + } else { + setProgress(100); + } + }; + + xhr.upload?.addEventListener('progress', onProgress); + xhr.addEventListener('load', onLoad); + xhr.addEventListener('loadstart', onStart); + xhr.addEventListener('loadend', onDone); + xhr.addEventListener('error', onError); + xhr.addEventListener('abort', onAbort); + + const cleanup = () => { + xhr.upload?.removeEventListener('progress', onProgress); + xhr.removeEventListener('load', onLoad); + xhr.removeEventListener('loadend', onDone); + xhr.removeEventListener('loadstart', onStart); + xhr.removeEventListener('error', onError); + xhr.removeEventListener('abort', onAbort); + }; + + xhrRef.current = { xhr, cleanup }; + }, []); + + useEffect(() => { + const xhr = xhrRef.current; + if (!xhr) return undefined; + + // ensure we remove the event listeners + return () => xhr.cleanup(); + }, []); + + const putFormData = useCallback( + async (path: string, data: { key: string; value: string | Blob }[]) => { + createXhr(); + const xhr = xhrRef.current?.xhr as XMLHttpRequest; + + xhr.open('PUT', path); + + const formData = new FormData(); + data.forEach(({ key, value }) => formData.append(key, value)); + + xhr.send(formData); + }, + [createXhr], + ); + + return useMemo( + () => ({ progress, isUploading, isDone, error, putFormData }), + [progress, isUploading, isDone, error, putFormData], + ); +};