Update file manager design a bit

This commit is contained in:
DaneEveritt 2022-06-20 14:16:42 -04:00
parent 8bd518048e
commit 2824db7352
No known key found for this signature in database
GPG key ID: EEA66103B3D71F53
9 changed files with 184 additions and 152 deletions

View file

@ -61,7 +61,7 @@ return [
'copy' => 'Created a copy of :file', 'copy' => 'Created a copy of :file',
'create-directory' => 'Created a new directory :name in :directory', 'create-directory' => 'Created a new directory :name in :directory',
'decompress' => 'Decompressed :files in :directory', 'decompress' => 'Decompressed :files in :directory',
'delete_one' => 'Deleted :directory:files', 'delete_one' => 'Deleted :directory:files.0',
'delete_other' => 'Deleted :count files in :directory', 'delete_other' => 'Deleted :count files in :directory',
'download' => 'Downloaded :file', 'download' => 'Downloaded :file',
'pull' => 'Downloaded a remote file from :url to :directory', 'pull' => 'Downloaded a remote file from :url to :directory',

View file

@ -0,0 +1,8 @@
import React, { useRef } from 'react';
import { createPortal } from 'react-dom';
export default ({ children }: { children: React.ReactNode }) => {
const element = useRef(document.getElementById('modal-portal'));
return createPortal(children, element!.current!);
};

View file

@ -44,8 +44,8 @@ const Dialog = ({ open, title, description, onClose, hideCloseIcon, children }:
open={open} open={open}
onClose={onClose} onClose={onClose}
> >
<div className={'fixed inset-0 bg-gray-900/50'}/> <div className={'fixed inset-0 bg-gray-900/50 z-40'}/>
<div className={'fixed inset-0 overflow-y-auto'}> <div className={'fixed inset-0 overflow-y-auto z-50'}>
<div className={'flex min-h-full items-center justify-center p-4 text-center'}> <div className={'flex min-h-full items-center justify-center p-4 text-center'}>
<HDialog.Panel <HDialog.Panel
as={motion.div} as={motion.div}
@ -58,7 +58,7 @@ const Dialog = ({ open, title, description, onClose, hideCloseIcon, children }:
'ring-4 ring-gray-800 ring-opacity-80', 'ring-4 ring-gray-800 ring-opacity-80',
])} ])}
> >
<div className={'flex p-6'}> <div className={'flex p-6 overflow-y-auto'}>
{icon && <div className={'mr-4'}>{icon}</div>} {icon && <div className={'mr-4'}>{icon}</div>}
<div className={'flex-1 max-h-[70vh]'}> <div className={'flex-1 max-h-[70vh]'}>
{title && {title &&

View file

@ -30,8 +30,8 @@ import useEventListener from '@/plugins/useEventListener';
import compressFiles from '@/api/server/files/compressFiles'; import compressFiles from '@/api/server/files/compressFiles';
import decompressFiles from '@/api/server/files/decompressFiles'; import decompressFiles from '@/api/server/files/decompressFiles';
import isEqual from 'react-fast-compare'; import isEqual from 'react-fast-compare';
import ConfirmationModal from '@/components/elements/ConfirmationModal';
import ChmodFileModal from '@/components/server/files/ChmodFileModal'; import ChmodFileModal from '@/components/server/files/ChmodFileModal';
import { Dialog } from '@/components/elements/dialog';
type ModalType = 'rename' | 'move' | 'chmod'; type ModalType = 'rename' | 'move' | 'chmod';
@ -128,15 +128,16 @@ const FileDropdownMenu = ({ file }: { file: FileObject }) => {
return ( return (
<> <>
<ConfirmationModal <Dialog.Confirm
visible={showConfirmation} open={showConfirmation}
title={`Delete this ${file.isFile ? 'File' : 'Directory'}?`} onClose={() => setShowConfirmation(false)}
buttonText={`Yes, Delete ${file.isFile ? 'File' : 'Directory'}`} title={`Delete ${file.isFile ? 'File' : 'Directory'}`}
confirm={'Delete'}
onConfirmed={doDeletion} onConfirmed={doDeletion}
onModalDismissed={() => setShowConfirmation(false)}
> >
Deleting files is a permanent operation, you cannot undo this action. You will not be able to recover the contents of&nbsp;
</ConfirmationModal> <span className={'font-semibold text-gray-50'}>{file.name}</span> once deleted.
</Dialog.Confirm>
<DropdownMenu <DropdownMenu
ref={onClickRef} ref={onClickRef}
renderToggle={onClick => ( renderToggle={onClick => (

View file

@ -10,7 +10,7 @@ import { NavLink, useLocation } from 'react-router-dom';
import Can from '@/components/elements/Can'; import Can from '@/components/elements/Can';
import { ServerError } from '@/components/elements/ScreenBlock'; import { ServerError } from '@/components/elements/ScreenBlock';
import tw from 'twin.macro'; import tw from 'twin.macro';
import Button from '@/components/elements/Button'; import { Button } from '@/components/elements/button/index';
import { ServerContext } from '@/state/server'; import { ServerContext } from '@/state/server';
import useFileManagerSwr from '@/plugins/useFileManagerSwr'; import useFileManagerSwr from '@/plugins/useFileManagerSwr';
import MassActionsBar from '@/components/server/files/MassActionsBar'; import MassActionsBar from '@/components/server/files/MassActionsBar';
@ -20,6 +20,7 @@ import { useStoreActions } from '@/state/hooks';
import ErrorBoundary from '@/components/elements/ErrorBoundary'; import ErrorBoundary from '@/components/elements/ErrorBoundary';
import { FileActionCheckbox } from '@/components/server/files/SelectFileCheckbox'; import { FileActionCheckbox } from '@/components/server/files/SelectFileCheckbox';
import { hashToPath } from '@/helpers'; import { hashToPath } from '@/helpers';
import style from './style.module.css';
const sortFiles = (files: FileObject[]): FileObject[] => { const sortFiles = (files: FileObject[]): FileObject[] => {
const sortedFiles: FileObject[] = files.sort((a, b) => a.name.localeCompare(b.name)).sort((a, b) => a.isFile === b.isFile ? 0 : (a.isFile ? 1 : -1)); const sortedFiles: FileObject[] = files.sort((a, b) => a.name.localeCompare(b.name)).sort((a, b) => a.isFile === b.isFile ? 0 : (a.isFile ? 1 : -1));
@ -59,8 +60,8 @@ export default () => {
return ( return (
<ServerContentBlock title={'File Manager'} showFlashKey={'files'}> <ServerContentBlock title={'File Manager'} showFlashKey={'files'}>
<div css={tw`flex flex-wrap-reverse md:flex-nowrap justify-center mb-4`}> <ErrorBoundary>
<ErrorBoundary> <div className={'flex flex-wrap-reverse md:flex-nowrap mb-4'}>
<FileManagerBreadcrumbs <FileManagerBreadcrumbs
renderLeft={ renderLeft={
<FileActionCheckbox <FileActionCheckbox
@ -71,24 +72,17 @@ export default () => {
/> />
} }
/> />
</ErrorBoundary> <Can action={'file.create'}>
<Can action={'file.create'}> <div className={style.manager_actions}>
<ErrorBoundary> <NewDirectoryButton/>
<div css={tw`flex flex-shrink-0 flex-wrap-reverse md:flex-nowrap justify-end mb-4 md:mb-0 ml-0 md:ml-auto`}> <UploadButton/>
<NewDirectoryButton css={tw`w-full flex-none mt-4 sm:mt-0 sm:w-auto sm:mr-4`}/> <NavLink to={`/server/${id}/files/new${window.location.hash}`}>
<UploadButton css={tw`flex-1 mr-4 sm:flex-none sm:mt-0`}/> <Button>New File</Button>
<NavLink
to={`/server/${id}/files/new${window.location.hash}`}
css={tw`flex-1 sm:flex-none sm:mt-0`}
>
<Button css={tw`w-full`}>
New File
</Button>
</NavLink> </NavLink>
</div> </div>
</ErrorBoundary> </Can>
</Can> </div>
</div> </ErrorBoundary>
{ {
!files ? !files ?
<Spinner size={'large'} centered/> <Spinner size={'large'} centered/>
@ -102,12 +96,12 @@ export default () => {
<CSSTransition classNames={'fade'} timeout={150} appear in> <CSSTransition classNames={'fade'} timeout={150} appear in>
<div> <div>
{files.length > 250 && {files.length > 250 &&
<div css={tw`rounded bg-yellow-400 mb-px p-3`}> <div css={tw`rounded bg-yellow-400 mb-px p-3`}>
<p css={tw`text-yellow-900 text-sm text-center`}> <p css={tw`text-yellow-900 text-sm text-center`}>
This directory is too large to display in the browser, This directory is too large to display in the browser,
limiting the output to the first 250 files. limiting the output to the first 250 files.
</p> </p>
</div> </div>
} }
{ {
sortFiles(files.slice(0, 250)).map(file => ( sortFiles(files.slice(0, 250)).map(file => (

View file

@ -1,17 +1,16 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import tw from 'twin.macro'; import tw from 'twin.macro';
import Button from '@/components/elements/Button'; import { Button } from '@/components/elements/button/index';
import Fade from '@/components/elements/Fade'; import Fade from '@/components/elements/Fade';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faFileArchive, faLevelUpAlt, faTrashAlt } from '@fortawesome/free-solid-svg-icons';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay'; import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
import useFileManagerSwr from '@/plugins/useFileManagerSwr'; import useFileManagerSwr from '@/plugins/useFileManagerSwr';
import useFlash from '@/plugins/useFlash'; import useFlash from '@/plugins/useFlash';
import compressFiles from '@/api/server/files/compressFiles'; import compressFiles from '@/api/server/files/compressFiles';
import { ServerContext } from '@/state/server'; import { ServerContext } from '@/state/server';
import ConfirmationModal from '@/components/elements/ConfirmationModal';
import deleteFiles from '@/api/server/files/deleteFiles'; import deleteFiles from '@/api/server/files/deleteFiles';
import RenameFileModal from '@/components/server/files/RenameFileModal'; import RenameFileModal from '@/components/server/files/RenameFileModal';
import Portal from '@/components/elements/Portal';
import { Dialog } from '@/components/elements/dialog';
const MassActionsBar = () => { const MassActionsBar = () => {
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid); const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
@ -62,53 +61,54 @@ const MassActionsBar = () => {
}; };
return ( return (
<Fade timeout={75} in={selectedFiles.length > 0} unmountOnExit> <>
<div css={tw`pointer-events-none fixed bottom-0 z-20 left-0 right-0 flex justify-center`}> <div css={tw`pointer-events-none fixed bottom-0 z-20 left-0 right-0 flex justify-center`}>
<SpinnerOverlay visible={loading} size={'large'} fixed> <SpinnerOverlay visible={loading} size={'large'} fixed>
{loadingMessage} {loadingMessage}
</SpinnerOverlay> </SpinnerOverlay>
<ConfirmationModal <Dialog.Confirm
visible={showConfirm} title={'Delete Files'}
title={'Delete these files?'} open={showConfirm}
buttonText={'Yes, Delete Files'} confirm={'Delete'}
onClose={() => setShowConfirm(false)}
onConfirmed={onClickConfirmDeletion} onConfirmed={onClickConfirmDeletion}
onModalDismissed={() => setShowConfirm(false)}
> >
Are you sure you want to delete {selectedFiles.length} file(s)? <p className={'mb-2'}>
<br/> Are you sure you want to delete&nbsp;
Deleting the file(s) listed below is a permanent operation, you cannot undo this action. <span className={'font-semibold text-gray-50'}>{selectedFiles.length} files</span>? This is
<br/> a permanent action and the files cannot be recovered.
<code> </p>
{ selectedFiles.slice(0, 15).map(file => ( {selectedFiles.slice(0, 15).map(file => (
<li key={file}>{file}<br/></li>)) <li key={file}>{file}</li>))
} }
{ selectedFiles.length > 15 && {selectedFiles.length > 15 &&
<li> + {selectedFiles.length - 15} other(s) </li> <li>and {selectedFiles.length - 15} others</li>
} }
</code> </Dialog.Confirm>
</ConfirmationModal>
{showMove && {showMove &&
<RenameFileModal <RenameFileModal
files={selectedFiles} files={selectedFiles}
visible visible
appear appear
useMoveTerminology useMoveTerminology
onDismissed={() => setShowMove(false)} onDismissed={() => setShowMove(false)}
/> />
} }
<div css={tw`pointer-events-auto rounded p-4 mb-6`} style={{ background: 'rgba(0, 0, 0, 0.35)' }}> <Portal>
<Button size={'xsmall'} css={tw`mr-4`} onClick={() => setShowMove(true)}> <div className={'fixed bottom-0 mb-6 flex justify-center w-full z-50'}>
<FontAwesomeIcon icon={faLevelUpAlt} css={tw`mr-2`}/> Move <Fade timeout={75} in={selectedFiles.length > 0} unmountOnExit>
</Button> <div css={tw`flex items-center space-x-4 pointer-events-auto rounded p-4 bg-black/50`}>
<Button size={'xsmall'} css={tw`mr-4`} onClick={onClickCompress}> <Button onClick={() => setShowMove(true)}>Move</Button>
<FontAwesomeIcon icon={faFileArchive} css={tw`mr-2`}/> Archive <Button onClick={onClickCompress}>Archive</Button>
</Button> <Button.Danger variant={Button.Variants.Secondary} onClick={() => setShowConfirm(true)}>
<Button size={'xsmall'} color={'red'} isSecondary onClick={() => setShowConfirm(true)}> Delete
<FontAwesomeIcon icon={faTrashAlt} css={tw`mr-2`}/> Delete </Button.Danger>
</Button> </div>
</div> </Fade>
</div>
</Portal>
</div> </div>
</Fade> </>
); );
}; };

View file

@ -1,5 +1,4 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import Modal from '@/components/elements/Modal';
import { ServerContext } from '@/state/server'; import { ServerContext } from '@/state/server';
import { Form, Formik, FormikHelpers } from 'formik'; import { Form, Formik, FormikHelpers } from 'formik';
import Field from '@/components/elements/Field'; import Field from '@/components/elements/Field';
@ -7,12 +6,15 @@ import { join } from 'path';
import { object, string } from 'yup'; import { object, string } from 'yup';
import createDirectory from '@/api/server/files/createDirectory'; import createDirectory from '@/api/server/files/createDirectory';
import tw from 'twin.macro'; import tw from 'twin.macro';
import Button from '@/components/elements/Button'; import { Button } from '@/components/elements/button/index';
import { FileObject } from '@/api/server/files/loadDirectory'; import { FileObject } from '@/api/server/files/loadDirectory';
import useFlash from '@/plugins/useFlash'; import useFlash from '@/plugins/useFlash';
import useFileManagerSwr from '@/plugins/useFileManagerSwr'; import useFileManagerSwr from '@/plugins/useFileManagerSwr';
import { WithClassname } from '@/components/types'; import { WithClassname } from '@/components/types';
import FlashMessageRender from '@/components/FlashMessageRender'; import FlashMessageRender from '@/components/FlashMessageRender';
import { Dialog } from '@/components/elements/dialog';
import Portal from '@/components/elements/Portal';
import Code from '@/components/elements/Code';
interface Values { interface Values {
directoryName: string; directoryName: string;
@ -66,48 +68,57 @@ export default ({ className }: WithClassname) => {
return ( return (
<> <>
<Formik <Portal>
onSubmit={submit} <Formik
validationSchema={schema} onSubmit={submit}
initialValues={{ directoryName: '' }} validationSchema={schema}
> initialValues={{ directoryName: '' }}
{({ resetForm, isSubmitting, values }) => ( >
<Modal {({ resetForm, submitForm, isSubmitting: _, values }) => (
visible={visible} <Dialog
dismissable={!isSubmitting} title={'Create Directory'}
showSpinnerOverlay={isSubmitting} open={visible}
onDismissed={() => { onClose={() => {
setVisible(false); setVisible(false);
resetForm(); resetForm();
}} }}
> >
<FlashMessageRender key={'files:directory-modal'}/> <FlashMessageRender key={'files:directory-modal'}/>
<Form css={tw`m-0`}> <Form css={tw`m-0`}>
<Field <Field
autoFocus autoFocus
id={'directoryName'} id={'directoryName'}
name={'directoryName'} name={'directoryName'}
label={'Directory Name'} label={'Name'}
/> />
<p css={tw`text-xs mt-2 text-neutral-400 break-all`}> <p css={tw`mt-2 text-sm md:text-base break-all`}>
<span css={tw`text-neutral-200`}>This directory will be created as</span> <span css={tw`text-neutral-200`}>This directory will be created as&nbsp;</span>
&nbsp;/home/container/ <Code>/home/container/
<span css={tw`text-cyan-200`}> <span css={tw`text-cyan-200`}>
{join(directory, values.directoryName).replace(/^(\.\.\/|\/)+/, '')} {join(directory, values.directoryName).replace(/^(\.\.\/|\/)+/, '')}
</span> </span>
</p> </Code>
<div css={tw`flex justify-end`}> </p>
<Button css={tw`mt-8`}> </Form>
Create Directory <Dialog.Buttons>
</Button> <Button.Text
</div> className={'w-full sm:w-auto'}
</Form> onClick={() => {
</Modal> setVisible(false);
)} resetForm();
</Formik> }}
<Button isSecondary onClick={() => setVisible(true)} className={className}> >
Cancel
</Button.Text>
<Button className={'w-full sm:w-auto'} onClick={submitForm}>Create</Button>
</Dialog.Buttons>
</Dialog>
)}
</Formik>
</Portal>
<Button.Text onClick={() => setVisible(true)} className={className}>
Create Directory Create Directory
</Button> </Button.Text>
</> </>
); );
}; };

View file

@ -1,7 +1,7 @@
import axios from 'axios'; import axios from 'axios';
import getFileUploadUrl from '@/api/server/files/getFileUploadUrl'; import getFileUploadUrl from '@/api/server/files/getFileUploadUrl';
import tw from 'twin.macro'; import tw from 'twin.macro';
import Button from '@/components/elements/Button'; import { Button } from '@/components/elements/button/index';
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import styled from 'styled-components/macro'; import styled from 'styled-components/macro';
import { ModalMask } from '@/components/elements/Modal'; import { ModalMask } from '@/components/elements/Modal';
@ -12,6 +12,7 @@ import useFlash from '@/plugins/useFlash';
import useFileManagerSwr from '@/plugins/useFileManagerSwr'; import useFileManagerSwr from '@/plugins/useFileManagerSwr';
import { ServerContext } from '@/state/server'; import { ServerContext } from '@/state/server';
import { WithClassname } from '@/components/types'; import { WithClassname } from '@/components/types';
import Portal from '@/components/elements/Portal';
const InnerContainer = styled.div` const InnerContainer = styled.div`
max-width: 600px; max-width: 600px;
@ -71,36 +72,38 @@ export default ({ className }: WithClassname) => {
return ( return (
<> <>
<Fade <Portal>
appear <Fade
in={visible} appear
timeout={75} in={visible}
key={'upload_modal_mask'} timeout={75}
unmountOnExit key={'upload_modal_mask'}
> unmountOnExit
<ModalMask
onClick={() => setVisible(false)}
onDragOver={e => e.preventDefault()}
onDrop={e => {
e.preventDefault();
e.stopPropagation();
setVisible(false);
if (!e.dataTransfer?.files.length) return;
onFileSubmission(e.dataTransfer.files);
}}
> >
<div css={tw`w-full flex items-center justify-center`} style={{ pointerEvents: 'none' }}> <ModalMask
<InnerContainer> onClick={() => setVisible(false)}
<p css={tw`text-lg text-neutral-200 text-center`}> onDragOver={e => e.preventDefault()}
Drag and drop files to upload. onDrop={e => {
</p> e.preventDefault();
</InnerContainer> e.stopPropagation();
</div>
</ModalMask> setVisible(false);
</Fade> if (!e.dataTransfer?.files.length) return;
<SpinnerOverlay visible={loading} size={'large'} fixed/>
onFileSubmission(e.dataTransfer.files);
}}
>
<div css={tw`w-full flex items-center justify-center`} style={{ pointerEvents: 'none' }}>
<InnerContainer>
<p css={tw`text-lg text-neutral-200 text-center`}>
Drag and drop files to upload.
</p>
</InnerContainer>
</div>
</ModalMask>
</Fade>
<SpinnerOverlay visible={loading} size={'large'} fixed/>
</Portal>
<input <input
type={'file'} type={'file'}
ref={fileUploadInput} ref={fileUploadInput}

View file

@ -0,0 +1,15 @@
.manager_actions {
@apply grid grid-cols-2 sm:grid-cols-3 w-full gap-4 mb-4;
& button {
@apply w-full first:col-span-2 sm:first:col-span-1;
}
@screen md {
@apply flex flex-1 justify-end mb-0;
& button {
@apply w-auto;
}
}
}