diff --git a/resources/scripts/components/server/files/FileDropdownMenu.tsx b/resources/scripts/components/server/files/FileDropdownMenu.tsx index b2e231b88..b78fe4a4c 100644 --- a/resources/scripts/components/server/files/FileDropdownMenu.tsx +++ b/resources/scripts/components/server/files/FileDropdownMenu.tsx @@ -1,17 +1,6 @@ import React, { memo, useRef, useState } from 'react'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { - faBoxOpen, - faCopy, - faEllipsisH, - faFileArchive, - faFileCode, - faFileDownload, - faLevelUpAlt, - faPencilAlt, - faTrashAlt, - IconDefinition, -} from '@fortawesome/free-solid-svg-icons'; +import { faBoxOpen, faCopy, faEllipsisH, faFileArchive, faFileCode, faFileDownload, faLevelUpAlt, faPencilAlt, faTrashAlt, IconDefinition } from '@fortawesome/free-solid-svg-icons'; import RenameFileModal from '@/components/server/files/RenameFileModal'; import { ServerContext } from '@/state/server'; import { join } from 'path'; @@ -115,7 +104,7 @@ const FileDropdownMenu = ({ file }: { file: FileObject }) => { .then(() => setShowSpinner(false)); }; - const doUnarchive = () => { + const doExtraction = () => { setShowSpinner(true); clearFlashes('files'); @@ -175,7 +164,7 @@ const FileDropdownMenu = ({ file }: { file: FileObject }) => { } {file.isArchiveType() ? - + : diff --git a/resources/scripts/components/server/files/FileManagerContainer.tsx b/resources/scripts/components/server/files/FileManagerContainer.tsx index 2362fcb1c..003a12648 100644 --- a/resources/scripts/components/server/files/FileManagerContainer.tsx +++ b/resources/scripts/components/server/files/FileManagerContainer.tsx @@ -13,6 +13,7 @@ import tw from 'twin.macro'; import Button from '@/components/elements/Button'; import { ServerContext } from '@/state/server'; import useFileManagerSwr from '@/plugins/useFileManagerSwr'; +import FileManagerStatus from '@/components/server/files/FileManagerStatus'; import MassActionsBar from '@/components/server/files/MassActionsBar'; import UploadButton from '@/components/server/files/UploadButton'; import ServerContentBlock from '@/components/elements/ServerContentBlock'; @@ -123,6 +124,7 @@ export default () => { )) } + } diff --git a/resources/scripts/components/server/files/FileManagerStatus.tsx b/resources/scripts/components/server/files/FileManagerStatus.tsx new file mode 100644 index 000000000..772da3e9c --- /dev/null +++ b/resources/scripts/components/server/files/FileManagerStatus.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import tw, { styled } from 'twin.macro'; +import { ServerContext } from '@/state/server'; + +const SpinnerCircle = styled.circle` + transition: stroke-dashoffset 0.35s; + transform: rotate(-90deg); + transform-origin: 50% 50%; +`; + +function Spinner ({ progress }: { progress: number }) { + const stroke = 3; + const radius = 20; + const normalizedRadius = radius - stroke * 2; + const circumference = normalizedRadius * 2 * Math.PI; + + return ( + + + + + ); +} + +function FileManagerStatus () { + const uploads = ServerContext.useStoreState(state => state.files.uploads); + + return ( +
+ {uploads.length > 0 && +
+ {uploads.sort((a, b) => a.total - b.total).map(f => ( +
+
+ +
+ +
+ {f.name} +
+
+ ))} +
+ } +
+ ); +} + +export default FileManagerStatus; diff --git a/resources/scripts/components/server/files/UploadButton.tsx b/resources/scripts/components/server/files/UploadButton.tsx index 067642fe0..e9c37aae9 100644 --- a/resources/scripts/components/server/files/UploadButton.tsx +++ b/resources/scripts/components/server/files/UploadButton.tsx @@ -6,32 +6,56 @@ import React, { useEffect, useRef, useState } from 'react'; import { ModalMask } from '@/components/elements/Modal'; import Fade from '@/components/elements/Fade'; import useEventListener from '@/plugins/useEventListener'; -import SpinnerOverlay from '@/components/elements/SpinnerOverlay'; import useFlash from '@/plugins/useFlash'; import useFileManagerSwr from '@/plugins/useFileManagerSwr'; import { ServerContext } from '@/state/server'; import { WithClassname } from '@/components/types'; const InnerContainer = styled.div` - max-width: 600px; - ${tw`bg-black w-full border-4 border-primary-500 border-dashed rounded p-10 mx-10`}; + max-width: 600px; + ${tw`bg-black w-full border-4 border-primary-500 border-dashed rounded p-10 mx-10`}; `; +function isFileOrDirectory (event: DragEvent): boolean { + if (!event.dataTransfer?.types) { + return false; + } + + for (let i = 0; i < event.dataTransfer.types.length; i++) { + // Check if the item being dragged is not a file. + if (event.dataTransfer.types[i] !== 'Files') { + return false; + } + } + + return true; +} + export default ({ className }: WithClassname) => { const fileUploadInput = useRef(null); - const uuid = ServerContext.useStoreState(state => state.server.data!.uuid); + + const [ timeouts, setTimeouts ] = useState([]); const [ visible, setVisible ] = useState(false); - const [ loading, setLoading ] = useState(false); const { mutate } = useFileManagerSwr(); const { clearFlashes, clearAndAddHttpError } = useFlash(); + + const uuid = ServerContext.useStoreState(state => state.server.data!.uuid); const directory = ServerContext.useStoreState(state => state.files.directory); + const appendFileUpload = ServerContext.useStoreActions(actions => actions.files.appendFileUpload); + const removeFileUpload = ServerContext.useStoreActions(actions => actions.files.removeFileUpload); useEventListener('dragenter', e => { + if (!isFileOrDirectory(e)) { + return; + } e.stopPropagation(); setVisible(true); }, true); useEventListener('dragexit', e => { + if (!isFileOrDirectory(e)) { + return; + } e.stopPropagation(); setVisible(false); }, true); @@ -47,25 +71,47 @@ export default ({ className }: WithClassname) => { }; }, [ visible ]); - const onFileSubmission = (files: FileList) => { - const form = new FormData(); - Array.from(files).forEach(file => form.append('files', file)); + useEffect(() => { + return () => timeouts.forEach(clearTimeout); + }, []); + + const onFileSubmission = (files: FileList) => { + const formData: FormData[] = []; + Array.from(files).forEach(file => { + const form = new FormData(); + form.append('files', file); + formData.push(form); + }); - setLoading(true); clearFlashes('files'); - getFileUploadUrl(uuid) - .then(url => axios.post(`${url}&directory=${directory}`, form, { - headers: { - 'Content-Type': 'multipart/form-data', - }, - })) + + Promise.all( + Array.from(formData).map(f => getFileUploadUrl(uuid) + .then(url => axios.post(`${url}&directory=${directory}`, f, { + headers: { 'Content-Type': 'multipart/form-data' }, + onUploadProgress: (data: ProgressEvent) => { + // @ts-ignore + const name = f.getAll('files')[0].name; + + appendFileUpload({ + name: name, + loaded: data.loaded, + total: data.total, + }); + + if (data.loaded === data.total) { + const timeout = setTimeout(() => removeFileUpload(name), 5000); + setTimeouts(t => [ ...t, timeout ]); + } + }, + })) + ) + ) .then(() => mutate()) .catch(error => { console.error(error); clearAndAddHttpError({ error, key: 'files' }); - }) - .then(() => setVisible(false)) - .then(() => setLoading(false)); + }); }; return ( @@ -90,7 +136,7 @@ export default ({ className }: WithClassname) => { onFileSubmission(e.dataTransfer.files); }} > -
+

Drag and drop files to upload. @@ -99,14 +145,12 @@ export default ({ className }: WithClassname) => {

- { if (!e.currentTarget.files) return; - onFileSubmission(e.currentTarget.files); if (fileUploadInput.current) { fileUploadInput.current.files = null; @@ -115,11 +159,7 @@ export default ({ className }: WithClassname) => { /> diff --git a/resources/scripts/state/server/files.ts b/resources/scripts/state/server/files.ts index 04098572d..34abbb7b5 100644 --- a/resources/scripts/state/server/files.ts +++ b/resources/scripts/state/server/files.ts @@ -1,35 +1,50 @@ import { action, Action } from 'easy-peasy'; import { cleanDirectoryPath } from '@/helpers'; +export interface FileUpload { + name: string; + loaded: number; + readonly total: number; +} + export interface ServerFileStore { directory: string; selectedFiles: string[]; + uploads: FileUpload[]; setDirectory: Action; setSelectedFiles: Action; appendSelectedFile: Action; removeSelectedFile: Action; + + appendFileUpload: Action; + removeFileUpload: Action; } const files: ServerFileStore = { directory: '/', selectedFiles: [], + uploads: [], setDirectory: action((state, payload) => { state.directory = cleanDirectoryPath(payload); }), - setSelectedFiles: action((state, payload) => { state.selectedFiles = payload; }), - appendSelectedFile: action((state, payload) => { state.selectedFiles = state.selectedFiles.filter(f => f !== payload).concat(payload); }), - removeSelectedFile: action((state, payload) => { state.selectedFiles = state.selectedFiles.filter(f => f !== payload); }), + + appendFileUpload: action((state, payload) => { + state.uploads = state.uploads.filter(f => f.name !== payload.name).concat(payload); + }), + removeFileUpload: action((state, payload) => { + state.uploads = state.uploads.filter(f => f.name !== payload); + }), }; export default files; diff --git a/routes/api-client.php b/routes/api-client.php index 755cabde4..72b6729fe 100644 --- a/routes/api-client.php +++ b/routes/api-client.php @@ -77,7 +77,7 @@ Route::group(['prefix' => '/servers/{server}', 'middleware' => [AuthenticateServ Route::post('/create-folder', [Client\Servers\FileController::class, 'create']); Route::post('/chmod', [Client\Servers\FileController::class, 'chmod']); Route::post('/pull', [Client\Servers\FileController::class, 'pull'])->middleware(['throttle:pull']); - Route::get('/upload', [Client\Servers\FileUploadController::class]); + Route::get('/upload', [Client\Servers\FileUploadController::class, '__invoke']); }); Route::group(['prefix' => '/schedules'], function () {