From 67995fd854ef7c206e643211efd777815d55d191 Mon Sep 17 00:00:00 2001 From: Ibrahim Ansari Date: Fri, 20 Dec 2024 18:35:12 +0530 Subject: [PATCH] Replace ESLint Love with Prettier and recommended --- .prettierrc.mjs | 7 + .vscode/extensions.json | 3 +- .vscode/settings.json | 3 +- .yarn/sdks/prettier/bin/prettier.cjs | 32 + .yarn/sdks/prettier/index.cjs | 32 + .yarn/sdks/prettier/package.json | 7 + __tests__/index.test.tsx | 4 +- eslint.config.mjs | 97 +-- imports/dashboard/console/consoleButtons.tsx | 42 +- imports/dashboard/console/consoleView.tsx | 53 +- imports/dashboard/dashboardLayout.tsx | 45 +- imports/dashboard/files/editor.tsx | 42 +- imports/dashboard/files/fileList.tsx | 103 +++- imports/dashboard/files/fileManager.tsx | 578 +++++++++++------- imports/dashboard/files/fileUtils.ts | 4 +- .../dashboard/files/folderCreationDialog.tsx | 17 +- imports/dashboard/files/massActionDialog.tsx | 212 ++++--- imports/dashboard/files/modifyFileDialog.tsx | 39 +- imports/dashboard/files/overlay.tsx | 29 +- imports/dashboard/files/uploadButton.tsx | 9 +- imports/dashboard/useOctyneData.tsx | 7 +- imports/errors/authFailure.tsx | 5 +- imports/errors/connectionFailure.tsx | 21 +- imports/helpers/message.tsx | 7 +- imports/helpers/title.tsx | 7 +- imports/helpers/unstyledLink.tsx | 14 +- imports/helpers/useInterval.ts | 12 +- imports/helpers/useKy.ts | 10 +- imports/layout.tsx | 16 +- imports/servers/commandDialog.tsx | 18 +- imports/servers/serverList.tsx | 71 ++- imports/servers/serverListItem.tsx | 39 +- imports/settings/accountDialog.tsx | 32 +- imports/settings/confirmDialog.tsx | 15 +- imports/settings/settingsLayout.tsx | 37 +- imports/theme.ts | 6 +- package.json | 8 +- pages/_app.tsx | 4 +- pages/_document.tsx | 12 +- pages/dashboard/[server]/console.tsx | 240 ++++---- .../dashboard/[server]/files/[[...path]].tsx | 14 +- pages/dashboard/[server]/index.tsx | 44 +- pages/index.tsx | 57 +- pages/servers.tsx | 23 +- pages/settings/about.tsx | 40 +- pages/settings/accounts.tsx | 57 +- pages/settings/config.tsx | 218 ++++--- yarn.lock | 127 ++-- 48 files changed, 1621 insertions(+), 898 deletions(-) create mode 100644 .prettierrc.mjs create mode 100755 .yarn/sdks/prettier/bin/prettier.cjs create mode 100644 .yarn/sdks/prettier/index.cjs create mode 100644 .yarn/sdks/prettier/package.json diff --git a/.prettierrc.mjs b/.prettierrc.mjs new file mode 100644 index 0000000..2cb1ba9 --- /dev/null +++ b/.prettierrc.mjs @@ -0,0 +1,7 @@ +export default { + semi: false, + singleQuote: true, + jsxSingleQuote: true, + arrowParens: 'avoid', + printWidth: 100, +} diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 8e8adf9..daaa5ee 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,6 +1,7 @@ { "recommendations": [ "arcanis.vscode-zipfs", - "dbaeumer.vscode-eslint" + "dbaeumer.vscode-eslint", + "esbenp.prettier-vscode" ] } diff --git a/.vscode/settings.json b/.vscode/settings.json index b6d4f3d..20c8c73 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -8,5 +8,6 @@ "**/.pnp.*": true }, "eslint.nodePath": ".yarn/sdks", - "typescript.enablePromptUseWorkspaceTsdk": true + "typescript.enablePromptUseWorkspaceTsdk": true, + "prettier.prettierPath": ".yarn/sdks/prettier/index.cjs" } diff --git a/.yarn/sdks/prettier/bin/prettier.cjs b/.yarn/sdks/prettier/bin/prettier.cjs new file mode 100755 index 0000000..9a4098f --- /dev/null +++ b/.yarn/sdks/prettier/bin/prettier.cjs @@ -0,0 +1,32 @@ +#!/usr/bin/env node + +const {existsSync} = require(`fs`); +const {createRequire, register} = require(`module`); +const {resolve} = require(`path`); +const {pathToFileURL} = require(`url`); + +const relPnpApiPath = "../../../../.pnp.cjs"; + +const absPnpApiPath = resolve(__dirname, relPnpApiPath); +const absUserWrapperPath = resolve(__dirname, `./sdk.user.cjs`); +const absRequire = createRequire(absPnpApiPath); + +const absPnpLoaderPath = resolve(absPnpApiPath, `../.pnp.loader.mjs`); +const isPnpLoaderEnabled = existsSync(absPnpLoaderPath); + +if (existsSync(absPnpApiPath)) { + if (!process.versions.pnp) { + // Setup the environment to be able to require prettier/bin/prettier.cjs + require(absPnpApiPath).setup(); + if (isPnpLoaderEnabled && register) { + register(pathToFileURL(absPnpLoaderPath)); + } + } +} + +const wrapWithUserWrapper = existsSync(absUserWrapperPath) + ? exports => absRequire(absUserWrapperPath)(exports) + : exports => exports; + +// Defer to the real prettier/bin/prettier.cjs your application uses +module.exports = wrapWithUserWrapper(absRequire(`prettier/bin/prettier.cjs`)); diff --git a/.yarn/sdks/prettier/index.cjs b/.yarn/sdks/prettier/index.cjs new file mode 100644 index 0000000..57cb2ab --- /dev/null +++ b/.yarn/sdks/prettier/index.cjs @@ -0,0 +1,32 @@ +#!/usr/bin/env node + +const {existsSync} = require(`fs`); +const {createRequire, register} = require(`module`); +const {resolve} = require(`path`); +const {pathToFileURL} = require(`url`); + +const relPnpApiPath = "../../../.pnp.cjs"; + +const absPnpApiPath = resolve(__dirname, relPnpApiPath); +const absUserWrapperPath = resolve(__dirname, `./sdk.user.cjs`); +const absRequire = createRequire(absPnpApiPath); + +const absPnpLoaderPath = resolve(absPnpApiPath, `../.pnp.loader.mjs`); +const isPnpLoaderEnabled = existsSync(absPnpLoaderPath); + +if (existsSync(absPnpApiPath)) { + if (!process.versions.pnp) { + // Setup the environment to be able to require prettier + require(absPnpApiPath).setup(); + if (isPnpLoaderEnabled && register) { + register(pathToFileURL(absPnpLoaderPath)); + } + } +} + +const wrapWithUserWrapper = existsSync(absUserWrapperPath) + ? exports => absRequire(absUserWrapperPath)(exports) + : exports => exports; + +// Defer to the real prettier your application uses +module.exports = wrapWithUserWrapper(absRequire(`prettier`)); diff --git a/.yarn/sdks/prettier/package.json b/.yarn/sdks/prettier/package.json new file mode 100644 index 0000000..b9ab57f --- /dev/null +++ b/.yarn/sdks/prettier/package.json @@ -0,0 +1,7 @@ +{ + "name": "prettier", + "version": "3.4.2-sdk", + "main": "./index.cjs", + "type": "commonjs", + "bin": "./bin/prettier.cjs" +} diff --git a/__tests__/index.test.tsx b/__tests__/index.test.tsx index b108223..0b318d3 100644 --- a/__tests__/index.test.tsx +++ b/__tests__/index.test.tsx @@ -5,8 +5,6 @@ import Home from '../pages/index' test('renders a heading', () => { render() - const heading = screen.getByRole('heading', { - name: /Octyne/i - }) + const heading = screen.getByRole('heading', { name: /Octyne/i }) expect(heading).toBeDefined() }) diff --git a/eslint.config.mjs b/eslint.config.mjs index c478c75..ced1aee 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,54 +1,81 @@ -import love from 'eslint-config-love' +import js from '@eslint/js' +import tseslint from 'typescript-eslint' +import nextPlugin from '@next/eslint-plugin-next' import standardJsx from 'eslint-config-standard-jsx' import standardReact from 'eslint-config-standard-react' import react from 'eslint-plugin-react' import reactHooks from 'eslint-plugin-react-hooks' +import importPlugin from 'eslint-plugin-import' +import pluginPromise from 'eslint-plugin-promise' +import nodePlugin from 'eslint-plugin-n' +import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended' -export default [ +export default tseslint.config( { - ignores: ['.pnp.cjs', '.pnp.loader.mjs', '.yarn', '.next'], + ignores: [ + '.pnp.cjs', + '.pnp.loader.mjs', + '.yarn', + '.next', + '.prettierrc.mjs', + '*.config.{mjs,js}', + ], }, + js.configs.recommended, + tseslint.configs.strictTypeChecked, + tseslint.configs.stylisticTypeChecked, + react.configs.flat.recommended, + pluginPromise.configs['flat/recommended'], + importPlugin.flatConfigs.recommended, // Could use TypeScript resolver + nodePlugin.configs['flat/recommended-module'], { - ...love, - files: [ - '__tests__/**/*.{js,ts,tsx}', - 'imports/**/*.{js,ts,tsx}', - 'pages/**/*.{js,ts,tsx}', - ], + plugins: { '@next/next': nextPlugin }, + rules: nextPlugin.configs.recommended.rules, + }, + { + plugins: { 'react-hooks': reactHooks }, + rules: reactHooks.configs.recommended.rules, }, - { ...react.configs.flat.recommended, settings: { react: { version: 'detect' } } }, - { plugins: { 'react-hooks': reactHooks }, rules: reactHooks.configs.recommended.rules }, { rules: standardJsx.rules }, { rules: standardReact.rules }, { + settings: { react: { version: 'detect' } }, + languageOptions: { + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, + }, rules: { - // Make TypeScript ESLint less strict. - '@typescript-eslint/strict-boolean-expressions': 'off', - '@typescript-eslint/triple-slash-reference': 'off', - '@typescript-eslint/restrict-template-expressions': 'off', '@typescript-eslint/no-confusing-void-expression': 'off', - 'multiline-ternary': 'off', // Temporary. - // Allow no-multi-str. - 'no-multi-str': 'off', - // Make ESLint Config Love less strict. Perhaps move to ESLint+TS-ESLint+import+promise+n? - '@typescript-eslint/max-params': 'off', - '@typescript-eslint/no-require-imports': 'off', + /* '@typescript-eslint/restrict-template-expressions': [ + 'error', + { + allowAny: false, + allowBoolean: false, + allowNullish: false, + allowNumber: true, + allowRegExp: false, + allowNever: false, + }, + ], */ + 'n/no-missing-import': 'off', + 'n/no-unsupported-features/node-builtins': 'off', + 'n/no-unsupported-features/es-syntax': 'off', + 'import/no-unresolved': 'off', + // TODO: Re-enable these! + '@typescript-eslint/use-unknown-in-catch-callback-variable': 'off', + '@typescript-eslint/restrict-template-expressions': 'off', + '@typescript-eslint/no-unnecessary-condition': 'off', + '@typescript-eslint/no-unsafe-member-access': 'off', + '@typescript-eslint/no-unsafe-assignment': 'off', '@typescript-eslint/no-unsafe-return': 'off', '@typescript-eslint/no-empty-function': 'off', '@typescript-eslint/no-explicit-any': 'off', - '@typescript-eslint/no-magic-numbers': 'off', - '@typescript-eslint/no-unsafe-argument': 'off', - '@typescript-eslint/no-unsafe-assignment': 'off', - '@typescript-eslint/no-unsafe-member-access': 'off', - '@typescript-eslint/no-unnecessary-condition': 'off', - '@typescript-eslint/no-unsafe-type-assertion': 'off', - '@typescript-eslint/class-methods-use-this': 'off', - '@typescript-eslint/prefer-destructuring': 'off', - '@typescript-eslint/use-unknown-in-catch-callback-variable': 'off', - 'complexity': 'off', - 'promise/avoid-new': 'off', - '@typescript-eslint/init-declarations': 'off', - '@typescript-eslint/no-loop-func': 'off', + 'promise/catch-or-return': 'off', + 'promise/always-return': 'off', + 'promise/no-nesting': 'off', }, }, -] + eslintPluginPrettierRecommended, +) diff --git a/imports/dashboard/console/consoleButtons.tsx b/imports/dashboard/console/consoleButtons.tsx index 68968f2..4ad13df 100644 --- a/imports/dashboard/console/consoleButtons.tsx +++ b/imports/dashboard/console/consoleButtons.tsx @@ -4,7 +4,9 @@ import Stop from '@mui/icons-material/Stop' import Close from '@mui/icons-material/Close' import PlayArrow from '@mui/icons-material/PlayArrow' -const ConsoleButtons = ({ stopStartServer }: { +const ConsoleButtons = ({ + stopStartServer, +}: { stopStartServer: (operation: 'START' | 'TERM' | 'KILL') => void }): React.JSX.Element => { const smallScreen = useMediaQuery(useTheme().breakpoints.only('xs')) @@ -56,26 +58,24 @@ const ConsoleButtons = ({ stopStartServer }: { ) - return smallScreen - ? ( - - {Buttons} - - ) - : ( -
- {Buttons} -
- ) + return smallScreen ? ( + + {Buttons} + + ) : ( +
+ {Buttons} +
+ ) } export default ConsoleButtons diff --git a/imports/dashboard/console/consoleView.tsx b/imports/dashboard/console/consoleView.tsx index 44e8dd5..f017cf3 100644 --- a/imports/dashboard/console/consoleView.tsx +++ b/imports/dashboard/console/consoleView.tsx @@ -2,39 +2,43 @@ import React, { useRef, useLayoutEffect } from 'react' import Typography from '@mui/material/Typography' import styled from '@emotion/styled' -let chrome = false -try { - if ( - Object.hasOwnProperty.call(window, 'chrome') && - !navigator.userAgent.includes('Trident') && - !navigator.userAgent.includes('Edge') // Chromium Edge uses Edg *sad noises* - ) chrome = true -} catch (e) {} +const chrome = + Object.hasOwnProperty.call(window, 'chrome') && + typeof navigator === 'object' && + typeof navigator.userAgent === 'string' && + !navigator.userAgent.includes('Trident') && + !navigator.userAgent.includes('Edge') // Chromium Edge uses Edg *sad noises* const ChromeConsoleViewContainer = styled.div({ height: '100%', width: '100%', overflow: 'auto', display: 'flex', - flexDirection: 'column-reverse' + flexDirection: 'column-reverse', }) -const ChromeConsoleView = (props: { console: Array<{ id: number, text: string }> }): React.JSX.Element => ( +const ChromeConsoleView = (props: { + console: { id: number; text: string }[] +}): React.JSX.Element => (
- {props.console.map((i) => ( - {i.text}
- )) /* Truncate to 650 lines due to performance issues afterwards. */} + {props.console.map(i => ( + + {i.text} +
+
+ ))}
) -const ConsoleView = (props: { console: Array<{ id: number, text: string }> }): React.JSX.Element => { +const ConsoleView = (props: { console: { id: number; text: string }[] }): React.JSX.Element => { const ref = useRef(null) - const isScrolledToBottom = ref.current !== null - ? ref.current.scrollHeight - ref.current.clientHeight <= ref.current.scrollTop + 1 - : false + const isScrolledToBottom = + ref.current !== null + ? ref.current.scrollHeight - ref.current.clientHeight <= ref.current.scrollTop + 1 + : false useLayoutEffect(() => { if (ref.current) ref.current.scrollTop = ref.current.scrollHeight - ref.current.clientHeight @@ -47,10 +51,17 @@ const ConsoleView = (props: { console: Array<{ id: number, text: string }> }): R return (
- - {props.console.map((i) => ( - {i.text}
- )) /* Truncate to 650 lines due to performance issues afterwards. */} + + {props.console.map(i => ( + + {i.text} +
+
+ ))}
diff --git a/imports/dashboard/dashboardLayout.tsx b/imports/dashboard/dashboardLayout.tsx index 6b16ba9..6c3a4b8 100644 --- a/imports/dashboard/dashboardLayout.tsx +++ b/imports/dashboard/dashboardLayout.tsx @@ -1,9 +1,18 @@ import React, { useState } from 'react' import styled from '@emotion/styled' import { - Typography, IconButton, Drawer, - List, ListItemButton, ListItemIcon, ListItemText, - Divider, useMediaQuery, useTheme, Toolbar, Tooltip + Typography, + IconButton, + Drawer, + List, + ListItemButton, + ListItemIcon, + ListItemText, + Divider, + useMediaQuery, + useTheme, + Toolbar, + Tooltip, } from '@mui/material' import Apps from '@mui/icons-material/Apps' import Login from '@mui/icons-material/Login' @@ -14,20 +23,24 @@ import CallToAction from '@mui/icons-material/CallToAction' import Settings from '@mui/icons-material/Settings' import Storage from '@mui/icons-material/Storage' -import { useRouter } from 'next/router' import Layout from '../layout' import config from '../config' import UnstyledLink from '../helpers/unstyledLink' +import useOctyneData from './useOctyneData' const DashboardContainer = styled.div({ padding: 20, flexDirection: 'column', display: 'flex', - flex: 1 + flex: 1, }) -const DrawerItem = (props: { icon: React.ReactElement, name: string, subUrl: string }): React.JSX.Element => { - const { server, node } = useRouter().query +const DrawerItem = (props: { + icon: React.ReactElement + name: string + subUrl: string +}): React.JSX.Element => { + const { server, node } = useOctyneData() const nodeUri = typeof node === 'string' ? `?node=${encodeURIComponent(node)}` : '' return ( @@ -46,12 +59,14 @@ const onLogout = (): void => { fetch(`${config.ip}/logout`, { headers: { Authorization: token ?? '' } }).catch(console.error) } -const DashboardLayout = (props: React.PropsWithChildren<{ loggedIn: boolean }>): React.JSX.Element => { +const DashboardLayout = ( + props: React.PropsWithChildren<{ loggedIn: boolean }>, +): React.JSX.Element => { const [openDrawer, setOpenDrawer] = useState(false) const drawerVariant = useMediaQuery(useTheme().breakpoints.only('xs')) ? 'temporary' : 'permanent' const appBarContent = ( <> - {(props.loggedIn && drawerVariant === 'temporary') && ( + {props.loggedIn && drawerVariant === 'temporary' && ( <> ):
)} - Octyne + + Octyne + {/* These are displayed unconditionally in case of individual node authentication failure. */} - + + + - + + + diff --git a/imports/dashboard/files/editor.tsx b/imports/dashboard/files/editor.tsx index 657961b..34ee8a9 100644 --- a/imports/dashboard/files/editor.tsx +++ b/imports/dashboard/files/editor.tsx @@ -19,27 +19,33 @@ const Editor = (props: { const saveFile = (): void => { setSaving(true) - Promise.resolve(props.onSave(name, content)).then(() => setSaving(false), console.error) + Promise.resolve(props.onSave(name, content)) + .then(() => setSaving(false)) + .catch(console.error) } return ( <>
- {props.name - ? {name} - : ( - setName(e.target.value)} - helperText={error + {props.name ? ( + + {name} + + ) : ( + setName(e.target.value)} + helperText={ + error ? 'This file already exists! Go back and open the file directly or delete it.' - : undefined} - /> - )} + : undefined + } + /> + )}
{props.name && ( @@ -69,7 +75,11 @@ const Editor = (props: { Save
- {saving && (
)} + {saving && ( +
+ +
+ )} ) } diff --git a/imports/dashboard/files/fileList.tsx b/imports/dashboard/files/fileList.tsx index 48128ba..7dd46f8 100644 --- a/imports/dashboard/files/fileList.tsx +++ b/imports/dashboard/files/fileList.tsx @@ -1,7 +1,14 @@ import React from 'react' import { - ListItem, ListItemButton, ListItemText, ListItemAvatar, Avatar, IconButton, Checkbox, - useMediaQuery, type Theme + ListItem, + ListItemButton, + ListItemText, + ListItemAvatar, + Avatar, + IconButton, + Checkbox, + useMediaQuery, + type Theme, } from '@mui/material' import AutoSizer from 'react-virtualized-auto-sizer' import { FixedSizeList, type ListChildComponentProps } from 'react-window' @@ -15,12 +22,13 @@ import { joinPath } from './fileUtils' const rtd = (num: number): number => Math.round(num * 100) / 100 const bytesToGb = (bytes: number): string => { if (bytes < 1024) return `${bytes} bytes` - else if (bytes < (1024 * 1024)) return `${rtd(bytes / 1024)} KB` - else if (bytes < (1024 * 1024 * 1024)) return `${rtd(bytes / (1024 * 1024))} MB` - else if (bytes < (1024 * 1024 * 1024 * 1024)) return `${rtd(bytes / (1024 * 1024 * 1024))} GB` + else if (bytes < 1024 * 1024) return `${rtd(bytes / 1024)} KB` + else if (bytes < 1024 * 1024 * 1024) return `${rtd(bytes / (1024 * 1024))} MB` + else if (bytes < 1024 * 1024 * 1024 * 1024) return `${rtd(bytes / (1024 * 1024 * 1024))} GB` else return `${rtd(bytes / (1024 * 1024 * 1024 * 1024))} TB` } -const tsts = (ts: number): string => new Date(ts * 1000).toISOString().substring(0, 19).replace('T', ' ') +const tsts = (ts: number): string => + new Date(ts * 1000).toISOString().substring(0, 19).replace('T', ' ') export interface File { name: string @@ -30,7 +38,16 @@ export interface File { mimeType: string } -const FileListItem = ({ file, style, disabled, filesSelected, onItemClick, onCheck, openMenu, url }: { +const FileListItem = ({ + file, + style, + disabled, + filesSelected, + onItemClick, + onCheck, + openMenu, + url, +}: { file: File url: string disabled: boolean @@ -48,7 +65,9 @@ const FileListItem = ({ file, style, disabled, filesSelected, onItemClick, onChe secondaryAction={
openMenu(file.name, e.currentTarget)} size='large' + disabled={disabled} + onClick={e => openMenu(file.name, e.currentTarget)} + size='large' > @@ -61,21 +80,27 @@ const FileListItem = ({ file, style, disabled, filesSelected, onItemClick, onChe
} > - + {/* style={{ paddingRight: 96 }} */} + {file.folder ? : } ) -const FileListItemRenderer = ({ index, data, style }: ListChildComponentProps): React.JSX.Element => { - const { files, path, disabled, filesSelected, setFilesSelected, openMenu, onClick } = data as FileItemData +const FileListItemRenderer = ({ + index, + data, + style, +}: ListChildComponentProps): React.JSX.Element => { + const { files, path, disabled, filesSelected, setFilesSelected, openMenu, onClick } = + data as FileItemData const router = useRouter() const file = files[index] const selectItem = (): void => { @@ -87,10 +112,10 @@ const FileListItemRenderer = ({ index, data, style }: ListChildComponentProps): let lastSelectedFileIdx = files.findLastIndex(e => filesSelected.includes(e.name)) if (lastSelectedFileIdx === -1) lastSelectedFileIdx = 0 // If none found, select first item. // Select all items between the current item and found item. If they're already selected, skip. - const filesToSelect = - files.slice(Math.min(lastSelectedFileIdx, index), Math.max(lastSelectedFileIdx, index) + 1) - .map(e => e.name) - .filter(e => !filesSelected.includes(e)) + const filesToSelect = files + .slice(Math.min(lastSelectedFileIdx, index), Math.max(lastSelectedFileIdx, index) + 1) + .map(e => e.name) + .filter(e => !filesSelected.includes(e)) setFilesSelected([...filesSelected, ...filesToSelect]) } const subpath = file.folder ? joinPath(path, file.name) : path @@ -105,17 +130,21 @@ const FileListItemRenderer = ({ index, data, style }: ListChildComponentProps): disabled={disabled} openMenu={openMenu} filesSelected={filesSelected} - onItemClick={(e) => e.ctrlKey && !e.shiftKey && !e.metaKey && !e.altKey - ? selectItem() - : !e.ctrlKey && e.shiftKey && !e.metaKey && !e.altKey ? shiftClickItem() : onClick(file)} - onCheck={(e) => e.shiftKey ? shiftClickItem() : selectItem()} + onItemClick={e => + e.ctrlKey && !e.shiftKey && !e.metaKey && !e.altKey + ? selectItem() + : !e.ctrlKey && e.shiftKey && !e.metaKey && !e.altKey + ? shiftClickItem() + : onClick(file) + } + onCheck={e => (e.shiftKey ? shiftClickItem() : selectItem())} url={`/dashboard/${router.query.server}/files${subpath}${params.size ? '?' : ''}${params}`} /> ) } -interface FileItemData { /* eslint-disable react/no-unused-prop-types -- false positive */ - files: File[] +interface FileItemData { + /* eslint-disable react/no-unused-prop-types -- false positive */ files: File[] path: string disabled: boolean filesSelected: string[] @@ -132,17 +161,27 @@ const FileList = (props: FileItemData): React.JSX.Element => { const px100sm = useMediaQuery('(min-width:328px)') const px120sm = useMediaQuery('(min-width:288px)') const px140sm = useMediaQuery('(min-width:280px)') - const itemSize = px60 ? 60 : ( - !smDisplay ? 80 : ( // Sidebar is hidden when smDisplay is true, use 60/80/100/120/140/160px. - px60sm ? 60 : (px80sm ? 80 : (px100sm ? 100 : (px120sm ? 120 : px140sm ? 140 : 160))) - ) - ) + const itemSize = px60 + ? 60 + : !smDisplay + ? 80 // Sidebar is hidden when smDisplay is true, use 60/80/100/120/140/160px. + : px60sm + ? 60 + : px80sm + ? 80 + : px100sm + ? 100 + : px120sm + ? 120 + : px140sm + ? 140 + : 160 const sortedList = props.files.sort((a, b) => { const aName = a.name.toLowerCase() const bName = b.name.toLowerCase() if (a.folder && !b.folder) return -1 else if (!a.folder && b.folder) return 1 - else return aName === bName ? 0 : (aName > bName ? 1 : -1) + else return aName === bName ? 0 : aName > bName ? 1 : -1 }) return (
@@ -159,9 +198,13 @@ const FileList = (props: FileItemData): React.JSX.Element => { > {FileListItemRenderer} - )} + )} - ) : } + ) : ( + + + + )}
) } diff --git a/imports/dashboard/files/fileManager.tsx b/imports/dashboard/files/fileManager.tsx index 750caa1..e53caaf 100644 --- a/imports/dashboard/files/fileManager.tsx +++ b/imports/dashboard/files/fileManager.tsx @@ -2,8 +2,18 @@ import React, { useState, useEffect, useCallback, useRef } from 'react' import { useRouter } from 'next/router' import { - Paper, Typography, CircularProgress, IconButton, Divider, Tooltip, Menu, MenuItem, Slide, - Snackbar, Button, TextField + Paper, + Typography, + CircularProgress, + IconButton, + Divider, + Tooltip, + Menu, + MenuItem, + Slide, + Snackbar, + Button, + TextField, } from '@mui/material' import Add from '@mui/icons-material/Add' import Replay from '@mui/icons-material/Replay' @@ -28,8 +38,8 @@ import MassActionDialog from './massActionDialog' import ModifyFileDialog from './modifyFileDialog' import FolderCreationDialog from './folderCreationDialog' -let euc: (uriComponent: string | number | boolean) => string -try { euc = encodeURIComponent } catch (e) { euc = e => e.toString() } +const euc: (uriComponent: string | number | boolean) => string = + typeof encodeURIComponent === 'function' ? encodeURIComponent : e => e.toString() const editorExts = ['properties', 'json', 'yaml', 'yml', 'xml', 'js', 'log', 'sh', 'txt'] const FileManager = (props: { @@ -49,48 +59,54 @@ const FileManager = (props: { const [search, setSearch] = useState(null) const [searchApplies, setSearchApplies] = useState(true) - const [overlay, setOverlay] = useState('') + const [overlay, setOverlay] = useState('') const [message, setMessage] = useState('') const [fetching, setFetching] = useState(true) - const [error, setError] = useState(null) + const [error, setError] = // prettier-ignore + useState(null) const [files, setFiles] = useState(null) const [filesSelected, setFilesSelected] = useState([]) - const [file, setFile] = useState<{ name: string, content: string } | null>(null) + const [file, setFile] = useState<{ name: string; content: string } | null>(null) const [download, setDownload] = useState('') const [folderPromptOpen, setFolderPromptOpen] = useState(false) const [massActionMenuOpen, setMassActionMenuOpen] = useState(null) - const [modifyFileDialogOpen, setModifyFileDialogOpen] = useState<'' | 'move' | 'copy' | 'rename'>('') - const [massActionDialogOpen, setMassActionDialogOpen] = useState<'move' | 'copy' | 'compress' | false>(false) + const [modifyFileDialogOpen, setModifyFileDialogOpen] = // prettier-ignore + useState<'' | 'move' | 'copy' | 'rename'>('') + const [massActionDialogOpen, setMassActionDialogOpen] = // prettier-ignore + useState<'move' | 'copy' | 'compress' | false>(false) const searchRef = useRef(null) // Update path when URL changes. Requires normalised path. - const updatePath = useCallback((newPath: string, file?: string, replace?: boolean) => { - const route = { - pathname: '/dashboard/[server]/files/[[...path]]', - query: { ...router.query } - } - const as = { - pathname: `/dashboard/${server}/files${newPath}`, - query: { ...router.query } - } - delete route.query.server - delete route.query.path - delete route.query.file - delete as.query.server - delete as.query.path - delete as.query.file - if (file !== undefined) { - route.query.file = file - as.query.file = file - } - (replace ? router.replace : router.push)(route, as, { shallow: true }) - // Apply search only when search has been focused once or if you are just downloading files. - .then(() => setSearchApplies(file !== router.query.file)) - .catch(console.error) - }, [router, server]) + const updatePath = useCallback( + (newPath: string, file?: string, replace?: boolean) => { + const route = { + pathname: '/dashboard/[server]/files/[[...path]]', + query: { ...router.query }, + } + const as = { + pathname: `/dashboard/${server}/files${newPath}`, + query: { ...router.query }, + } + delete route.query.server + delete route.query.path + delete route.query.file + delete as.query.server + delete as.query.path + delete as.query.file + if (file !== undefined) { + route.query.file = file + as.query.file = file + } + ;(replace ? router.replace : router.push)(route, as, { shallow: true }) + // Apply search only when search has been focused once or if you are just downloading files. + .then(() => setSearchApplies(file !== router.query.file)) + .catch(console.error) + }, + [router, server], + ) // Used to fetch files. const { setAuthenticated, setServerExists } = props @@ -98,24 +114,39 @@ const FileManager = (props: { ;(async () => { setFetching(true) // TODO: Make it show up after 1.0 seconds. setError(null) - const files = await ky.get(`server/${server}/files?path=${euc(path)}`) - .json<{ error?: string, contents: File[] }>() + const files = await ky + .get(`server/${server}/files?path=${euc(path)}`) + .json<{ error?: string; contents: File[] }>() if (files.error === 'This server does not exist!') setServerExists(false) - else if (files.error === 'You are not authenticated to access this resource!') setAuthenticated(false) - else if (files.error === 'The folder requested is outside the server!') setError('outsideServerDir') + else if (files.error === 'You are not authenticated to access this resource!') + setAuthenticated(false) + else if (files.error === 'The folder requested is outside the server!') + setError('outsideServerDir') else if (files.error === 'This folder does not exist!') setError('folderNotExist') else if (files.error === 'This is not a folder!') { - return updatePath(parentPath(path), path.substring(0, path.length - 1).split('/').pop(), true) + return updatePath( + parentPath(path), + path + .substring(0, path.length - 1) + .split('/') + .pop(), + true, + ) } else if (files) { setFiles(files.contents) setFilesSelected([]) } setFetching(false) - })().catch(e => { console.error(e); setMessage(`Failed to fetch files: ${e.message}`); setFetching(false) }) + })().catch(e => { + console.error(e) + setMessage(`Failed to fetch files: ${e.message}`) + setFetching(false) + }) }, [path, ky, server, updatePath, setAuthenticated, setServerExists]) const prevPath = useRef(path) - useEffect(() => { // Fetch files. + // Fetch files. + useEffect(() => { if (server && (path !== prevPath.current || files === null)) { fetchFiles() } @@ -129,7 +160,7 @@ const FileManager = (props: { e.preventDefault() if (searchRef.current !== document.activeElement) searchRef.current?.focus() else searchRef.current?.setSelectionRange(0, searchRef.current.value.length) - setSearch(search => typeof search === 'string' ? search : '') + setSearch(search => (typeof search === 'string' ? search : '')) } else if (e.code === 'Escape') { e.preventDefault() setSearch(null) @@ -139,24 +170,27 @@ const FileManager = (props: { return () => window.removeEventListener('keydown', eventListener) }, [file]) - const loadFileInEditor = useCallback(async (filename: string) => { - setFetching(true) - const req = await ky.get(`server/${server}/file?path=${euc(joinPath(path, filename))}`) - if (req.status !== 200) { - setMessage((await req.json<{ error: string }>()).error) + const loadFileInEditor = useCallback( + async (filename: string) => { + setFetching(true) + const req = await ky.get(`server/${server}/file?path=${euc(joinPath(path, filename))}`) + if (req.status !== 200) { + setMessage((await req.json<{ error: string }>()).error) + setFetching(false) + updatePath(path) // Remove file from path. + return + } + const content = await req.text() + setFile({ name: filename, content }) setFetching(false) - updatePath(path) // Remove file from path. - return - } - const content = await req.text() - setFile({ name: filename, content }) - setFetching(false) - }, [ky, path, server, updatePath]) + }, + [ky, path, server, updatePath], + ) // Load any file in path. useEffect(() => { // Remove any file if the path loses ?file, so going back closes the editor. - if (file?.name !== undefined && filename !== file?.name) setFile(null) + if (file?.name !== undefined && filename !== file.name) setFile(null) if (download && filename !== download) setDownload('') if (filename === '') return setFile({ name: '', content: '' }) // Create a new empty file. if (filename === undefined) return // No file defined, do nothing. @@ -186,7 +220,7 @@ const FileManager = (props: { setFetching(true) try { const endpoint = `server/${server}/folder?path=/${euc(joinPath(path, name))}` - const createFolder = await ky.post(endpoint).json<{ success: boolean, error: string }>() + const createFolder = await ky.post(endpoint).json<{ success: boolean; error: string }>() if (createFolder.success) fetchFiles() else setMessage(createFolder.error) setFetching(false) @@ -195,7 +229,10 @@ const FileManager = (props: { setFetching(false) } } - const handleModifyFile = async (newPath: string, action: 'move' | 'copy' | 'rename'): Promise => { + const handleModifyFile = async ( + newPath: string, + action: 'move' | 'copy' | 'rename', + ): Promise => { setModifyFileDialogOpen('') setMenuOpen('') setAnchorEl(null) @@ -207,9 +244,11 @@ const FileManager = (props: { } const target = action === 'rename' ? path + newPath : newPath try { - const editFile = await ky.patch(`server/${server}/file`, { - body: `${action === 'copy' ? 'cp' : 'mv'}\n${path}${menuOpen}\n${target}` - }).json<{ success: boolean, error: string }>() + const editFile = await ky + .patch(`server/${server}/file`, { + body: `${action === 'copy' ? 'cp' : 'mv'}\n${path}${menuOpen}\n${target}`, + }) + .json<{ success: boolean; error: string }>() if (editFile.success) fetchFiles() else setMessage(editFile.error) setFetching(false) @@ -230,7 +269,7 @@ const FileManager = (props: { fetchFiles() return } else if (res.status !== 404) { - const errors = await res.json<{ errors?: Array<{ index: number, message: string }> }>() + const errors = await res.json<{ errors?: { index: number; message: string }[] }>() if (errors.errors?.length === 1) { setMessage(`Error deleting files: ${errors.errors[0].message}`) setOverlay('') @@ -256,21 +295,32 @@ const FileManager = (props: { for (const file of filesSelected) { // setOverlay('Deleting ' + file) // Save the file. - ops.push(ky.delete(`server/${server}/file?path=${euc(path + file)}`).then(async r => { - if (r.status !== 200) { - setMessage(`Error deleting ${file}\n${(await r.json<{ error: string }>()).error}`) - } else { - const progress = (filesSelected.length - total) * 100 / filesSelected.length - setOverlay({ text: `Deleting ${--total} out of ${filesSelected.length} files.`, progress }) - } - if (localStorage.getItem('ecthelion:logAsyncMassActions')) console.log('Deleted ' + file) - }).catch(e => setMessage(`Error deleting ${file}\n${e}`))) + ops.push( + ky + .delete(`server/${server}/file?path=${euc(path + file)}`) + .then(async r => { + if (r.status !== 200) { + setMessage(`Error deleting ${file}\n${(await r.json<{ error: string }>()).error}`) + } else { + const progress = ((filesSelected.length - total) * 100) / filesSelected.length + setOverlay({ + text: `Deleting ${--total} out of ${filesSelected.length} files.`, + progress, + }) + } + if (localStorage.getItem('ecthelion:logAsyncMassActions')) + console.log('Deleted ' + file) + }) + .catch(e => setMessage(`Error deleting ${file}\n${e}`)), + ) } - Promise.allSettled(ops).then(() => { - setMessage('Deleted all files successfully!') - setOverlay('') - fetchFiles() - }).catch(console.error) // Should not be called, ideally. + Promise.allSettled(ops) + .then(() => { + setMessage('Deleted all files successfully!') + setOverlay('') + fetchFiles() + }) + .catch(console.error) // Should not be called, ideally. } const handleFilesUpload = (files: FileList): void => { if (overlay) return // TODO: Allow multiple file uploads/mass actions simultaneously in future. @@ -280,9 +330,13 @@ const FileManager = (props: { // Save the file. const formData = new FormData() formData.append('upload', file, file.name) - const r = await uploadFormData(`${ip}/server/${server}/file?path=${euc(path)}`, formData, progress => { - setOverlay({ text: `Uploading ${file.name} to ${path}`, progress: progress * 100 }) - }) + const r = await uploadFormData( + `${ip}/server/${server}/file?path=${euc(path)}`, + formData, + progress => { + setOverlay({ text: `Uploading ${file.name} to ${path}`, progress: progress * 100 }) + }, + ) if (r.status !== 200) { setMessage(`Error uploading ${file.name}\n${JSON.parse(r.body).error}`) } @@ -290,45 +344,62 @@ const FileManager = (props: { } setMessage('Uploaded all files successfully!') if (path === prevPath.current) fetchFiles() // prevPath is current path after useEffect call. - })().catch(e => { console.error(e); setOverlay(''); setMessage(`Failed to upload files: ${e.message}`) }) + })().catch(e => { + console.error(e) + setOverlay('') + setMessage(`Failed to upload files: ${e.message}`) + }) } // Single file logic. const handleDeleteMenuButton = (): void => { ;(async () => { setMenuOpen('') setFetching(true) - const a = await ky.delete(`server/${server}/file?path=${euc(path + menuOpen)}`) + const a = await ky + .delete(`server/${server}/file?path=${euc(path + menuOpen)}`) .json<{ error: string }>() if (a.error) setMessage(a.error) setFetching(false) setMenuOpen('') fetchFiles() - })().catch(e => { console.error(e); setMessage(`Failed to delete file: ${e.message}`) }) + })().catch(e => { + console.error(e) + setMessage(`Failed to delete file: ${e.message}`) + }) } const handleDownloadMenuButton = (): void => { ;(async () => { setMenuOpen('') const ticket = encodeURIComponent((await ky.get('ott').json<{ ticket: string }>()).ticket) window.location.href = `${ip}/server/${server}/file?ticket=${ticket}&path=${path}${menuOpen}` - })().catch(e => { console.error(e); setMessage(`Failed to download file: ${e.message}`) }) + })().catch(e => { + console.error(e) + setMessage(`Failed to download file: ${e.message}`) + }) } const handleDecompressMenuButton = (): void => { ;(async () => { setMenuOpen('') setFetching(true) - const a = await ky.post(`server/${server}/decompress?path=${euc(path + menuOpen)}`, { - body: path + menuOpen.replace(archiveRegex, '') - }) + const a = await ky + .post(`server/${server}/decompress?path=${euc(path + menuOpen)}`, { + body: path + menuOpen.replace(archiveRegex, ''), + }) .json<{ error: string }>() - if (a.error?.includes('ZIP file') && !menuOpen.endsWith('.zip')) { + if (a.error.includes('ZIP file') && !menuOpen.endsWith('.zip')) { setMessage('Archive failed to decompress! Update Octyne to v1.2+ to decompress this file.') } else if (a.error) setMessage(a.error) setFetching(false) setMenuOpen('') fetchFiles() - })().catch(e => { console.error(e); setMessage(`Failed to decompress file: ${e.message}`) }) + })().catch(e => { + console.error(e) + setMessage(`Failed to decompress file: ${e.message}`) + }) + } + const handleCloseDownload = (): void => { + updatePath(path) } - const handleCloseDownload = (): void => { updatePath(path) } const handleDownloadButton = (): void => { ;(async () => { handleCloseDownload() @@ -336,7 +407,10 @@ const FileManager = (props: { const ticket = encodeURIComponent((await ky.get('ott').json<{ ticket: string }>()).ticket) const loc = `${ip}/server/${server}/file?ticket=${ticket}&path=${euc(joinPath(path, download))}` window.location.href = loc - })().catch((e: any) => { console.error(e); setMessage(`Failed to download file: ${e.message}`) }) + })().catch((e: any) => { + console.error(e) + setMessage(`Failed to download file: ${e.message}`) + }) } const handleSaveFile = async (name: string, content: string): Promise => { try { @@ -346,28 +420,34 @@ const FileManager = (props: { const r = await ky.post(`server/${server}/file?path=${encodedPath}`, { body: formData }) if (r.status !== 200) setMessage((await r.json<{ error: string }>()).error) else setMessage('Saved successfully!') - } catch (e: any) { setMessage(`Error saving file! ${e}`); console.error(e) } + } catch (e: any) { + setMessage(`Error saving file! ${e}`) + console.error(e) + } } const selectedFile = menuOpen && files?.find(e => e.name === menuOpen) - const titleName = file?.name ? file.name + ' - ' : (path ? path + ' - ' : '') + const titleName = file?.name ? file.name + ' - ' : path ? path + ' - ' : '' const alternativeDisplay = !error ? ( - !files || !server ? : null + !files || !server ? ( + + ) : null ) : ( - {error === 'folderNotExist' - ? `The folder you are trying to access (${path}) does not exist.` - : error === 'outsideServerDir' - ? `The path you are trying to access (${path}) is outside the server folder!` - : `The path you are trying to access (${path}) is a file!`} + + {error === 'folderNotExist' + ? `The folder you are trying to access (${path}) does not exist.` + : error === 'outsideServerDir' + ? `The path you are trying to access (${path}) is outside the server folder!` + : `The path you are trying to access (${path}) is a file!`} {path !== '/' && ( ( + onClick={() => !fetching && updatePath(error === 'outsideServerDir' ? '/' : parentPath(path), undefined, true) - )} + } > {error === 'outsideServerDir' ? 'Go to root folder?' : 'Try going up one path?'} @@ -381,148 +461,168 @@ const FileManager = (props: { description='The files of a process running on Octyne.' url={`/dashboard/${server}/files`} /> - {!files || alternativeDisplay ? alternativeDisplay : ( - file !== null ? ( - - e.name)} - onSave={handleSaveFile} - onClose={() => { updatePath(path); fetchFiles() }} - onDownload={() => { - ;(async () => { - const ott = encodeURIComponent((await ky.get('ott').json<{ ticket: string }>()).ticket) - window.location.href = `${ip}/server/${server}/file?path=${path}${file.name}&ticket=${ott}` - })().catch(e => { console.error(e); setMessage(`Failed to download file: ${e.message}`) }) - }} - /> - - ) : ( - { - e.stopPropagation() - e.preventDefault() - e.dataTransfer.dropEffect = 'copy' - }} onDrop={e => { - e.stopPropagation() - e.preventDefault() - handleFilesUpload(e.dataTransfer.files) + {!files || alternativeDisplay ? ( + alternativeDisplay + ) : file !== null ? ( + + e.name)} + onSave={handleSaveFile} + onClose={() => { + updatePath(path) + fetchFiles() }} - > - Files - {server} -
- {path !== '/' && ( - updatePath(parentPath(path))}> - + onDownload={() => { + ;(async () => { + const ott = encodeURIComponent( + (await ky.get('ott').json<{ ticket: string }>()).ticket, + ) + window.location.href = `${ip}/server/${server}/file?path=${path}${file.name}&ticket=${ott}` + })().catch(e => { + console.error(e) + setMessage(`Failed to download file: ${e.message}`) + }) + }} + /> + + ) : ( + { + e.stopPropagation() + e.preventDefault() + e.dataTransfer.dropEffect = 'copy' + }} + onDrop={e => { + e.stopPropagation() + e.preventDefault() + handleFilesUpload(e.dataTransfer.files) + }} + > + + Files - {server} + +
+ {path !== '/' && ( + updatePath(parentPath(path))}> + + + )} +
+ {path} +
+ {filesSelected.length > 0 && ( + <> + + + setMassActionMenuOpen(e.currentTarget)} + > + + + + +
+ + )} + + + + + + + + + + setSearch(s => (typeof s === 'string' ? null : ''))}> + + + + +
+ + + setFolderPromptOpen(true)}> + + + + +
+ + + updatePath(path, '')}> + - )} -
- {path} -
- {filesSelected.length > 0 && ( - <> - - - setMassActionMenuOpen(e.currentTarget)}> - - - - -
- - )} - - - - - - - - - - setSearch(s => typeof s === 'string' ? null : '')}> - - - - -
- - - setFolderPromptOpen(true)}> - - - - -
- - - updatePath(path, '')}> - - - - -
- - {fetching && ( - <>
- )} -
-
- {typeof search === 'string' && ( - setSearch(e.target.value)} - onFocus={() => setSearchApplies(true)} - /> + + +
+ + {fetching && ( + <> +
+ + )} - {search === null && } -
- {/* List of files and folders. */} - ( - typeof search === 'string' - ? e.name.toLowerCase().includes(search.toLowerCase()) || !searchApplies - : true - ))} - disabled={fetching} - filesSelected={filesSelected} - setFilesSelected={setFilesSelected} - onClick={(file) => { - if (file.folder) updatePath(joinPath(path, file.name)) - else updatePath(path, file.name) - }} - openMenu={(fn, anchor) => { - setMenuOpen(fn) - setAnchorEl(anchor) - }} +
+
+ {typeof search === 'string' && ( + setSearch(e.target.value)} + onFocus={() => setSearchApplies(true)} /> - - ) + )} + {search === null && } +
+ {/* List of files and folders. */} + + typeof search === 'string' + ? e.name.toLowerCase().includes(search.toLowerCase()) || !searchApplies + : true, + )} + disabled={fetching} + filesSelected={filesSelected} + setFilesSelected={setFilesSelected} + onClick={file => { + if (file.folder) updatePath(joinPath(path, file.name)) + else updatePath(path, file.name) + }} + openMenu={(fn, anchor) => { + setMenuOpen(fn) + setAnchorEl(anchor) + }} + /> + )} {download && ( } + TransitionComponent={props => } onClose={handleCloseDownload} message={`Do you want to download '${download}'?`} action={[ , - + , ]} /> )} @@ -537,7 +637,7 @@ const FileManager = (props: { filename={menuOpen} operation={modifyFileDialogOpen} handleClose={() => setModifyFileDialogOpen('')} - handleEdit={async (path) => await handleModifyFile(path, modifyFileDialogOpen)} + handleEdit={async path => await handleModifyFile(path, modifyFileDialogOpen)} /> )} {massActionDialogOpen && ( @@ -557,11 +657,29 @@ const FileManager = (props: { /> )} {massActionMenuOpen && ( - setMassActionMenuOpen(null)}> - setMassActionDialogOpen('move')} disabled={!!overlay}>Move - setMassActionDialogOpen('copy')} disabled={!!overlay}>Copy - { handleFilesDelete().catch(() => {}) }} disabled={!!overlay}>Delete - setMassActionDialogOpen('compress')} disabled={!!overlay}>Compress + setMassActionMenuOpen(null)} + > + setMassActionDialogOpen('move')} disabled={!!overlay}> + Move + + setMassActionDialogOpen('copy')} disabled={!!overlay}> + Copy + + { + handleFilesDelete().catch(() => {}) + }} + disabled={!!overlay} + > + Delete + + setMassActionDialogOpen('compress')} disabled={!!overlay}> + Compress + )} {selectedFile && ( diff --git a/imports/dashboard/files/fileUtils.ts b/imports/dashboard/files/fileUtils.ts index cc2848f..a23808b 100644 --- a/imports/dashboard/files/fileUtils.ts +++ b/imports/dashboard/files/fileUtils.ts @@ -19,8 +19,8 @@ export const joinPath = (a: string, b: string): string => { export const uploadFormData = async ( url: string, formData: FormData, - onProgress: (progress: number) => void -): Promise<{ status: number, body: string }> => + onProgress: (progress: number) => void, +): Promise<{ status: number; body: string }> => await new Promise((resolve, reject) => { const xhr = new XMLHttpRequest() xhr.upload.addEventListener('progress', e => onProgress(e.loaded / e.total)) diff --git a/imports/dashboard/files/folderCreationDialog.tsx b/imports/dashboard/files/folderCreationDialog.tsx index d2f01ae..1390d55 100644 --- a/imports/dashboard/files/folderCreationDialog.tsx +++ b/imports/dashboard/files/folderCreationDialog.tsx @@ -1,10 +1,19 @@ import React, { useState } from 'react' import { - Dialog, DialogTitle, DialogContent, DialogContentText, DialogActions, Button, TextField + Dialog, + DialogTitle, + DialogContent, + DialogContentText, + DialogActions, + Button, + TextField, } from '@mui/material' -const FolderCreationDialog = ({ handleCreateFolder, handleClose }: { +const FolderCreationDialog = ({ + handleCreateFolder, + handleClose, +}: { handleCreateFolder: (name: string) => any handleClose: () => void }): React.JSX.Element => { @@ -27,7 +36,9 @@ const FolderCreationDialog = ({ handleCreateFolder, handleClose }: { /> - + diff --git a/imports/dashboard/files/massActionDialog.tsx b/imports/dashboard/files/massActionDialog.tsx index fc42f65..5c8247c 100644 --- a/imports/dashboard/files/massActionDialog.tsx +++ b/imports/dashboard/files/massActionDialog.tsx @@ -1,16 +1,33 @@ import React, { useState } from 'react' import type { KyInstance } from 'ky/distribution/types/ky' import { - Dialog, DialogTitle, DialogContent, DialogContentText, DialogActions, Button, TextField, - Select, InputLabel, FormControl, MenuItem + Dialog, + DialogTitle, + DialogContent, + DialogContentText, + DialogActions, + Button, + TextField, + Select, + InputLabel, + FormControl, + MenuItem, } from '@mui/material' const MassActionDialog = ({ - operation, reload, files, server, ky, handleClose, path, setOverlay, setMessage + operation, + reload, + files, + server, + ky, + handleClose, + path, + setOverlay, + setMessage, }: { reload: () => void operation: 'move' | 'copy' | 'compress' - setOverlay: (message: string | { text: string, progress: number }) => void + setOverlay: (message: string | { text: string; progress: number }) => void setMessage: (message: string) => void handleClose: () => void server: string @@ -18,65 +35,92 @@ const MassActionDialog = ({ path: string ky: KyInstance }): React.JSX.Element => { - const [archiveType, setArchiveType] = useState<'zip' | 'tar' | 'tar.gz' | 'tar.xz' | 'tar.zst'>('zip') + const [archiveType, setArchiveType] = useState<'zip' | 'tar' | 'tar.gz' | 'tar.xz' | 'tar.zst'>( + 'zip', + ) const [newPath, setNewPath] = useState('') const move = operation === 'move' ? 'Move' : operation === 'compress' ? 'Compress' : 'Copy' const moved = operation === 'move' ? 'Moved' : operation === 'compress' ? 'Compressed' : 'Copied' - const moving = operation === 'move' ? 'Moving' : operation === 'compress' ? 'Compressing ' : 'Copying' - const movingl = operation === 'move' ? 'moving' : operation === 'compress' ? 'compressing ' : 'copying' + const moving = + operation === 'move' ? 'Moving' : operation === 'compress' ? 'Compressing ' : 'Copying' + const movingl = moving.charAt(0).toLowerCase() + moving.slice(1) const handleCompressOperation = (): void => { setOverlay(`Compressing ${files.length} files on the server.`) - const archiveTypeParam = archiveType.startsWith('tar') ? '&archiveType=tar&compress=' + ( - archiveType === 'tar.gz' ? 'gzip' - : archiveType === 'tar.xz' ? 'xz' - : archiveType === 'tar.zst' ? 'zstd' - : 'false' - ) : '' - ky.post(`server/${server}/compress/v2\ + const archiveTypeParam = archiveType.startsWith('tar') + ? '&archiveType=tar&compress=' + + (archiveType === 'tar.gz' + ? 'gzip' + : archiveType === 'tar.xz' + ? 'xz' + : archiveType === 'tar.zst' + ? 'zstd' + : 'false') + : '' + ky.post( + `server/${server}/compress/v2\ ?async=true\ &path=${encodeURIComponent(path + newPath + '.' + archiveType)}${archiveTypeParam}\ -&basePath=${encodeURIComponent(path)}`, { json: files }).then(res => { - if (res.ok) { - // Poll the token every second until the compression is finished. - res.json<{ token: string }>().then(async ({ token }) => { - while (true) { - const res = await ky.get(`server/${server}/compress/v2?token=${token}`) - .json<{ finished: boolean, error: string }>() - if (res.finished || res.error) { - reload() +&basePath=${encodeURIComponent(path)}`, + { json: files }, + ) + .then(res => { + if (res.ok) { + // Poll the token every second until the compression is finished. + res + .json<{ token: string }>() + .then(async ({ token }) => { + while (true) { + const res = await ky + .get(`server/${server}/compress/v2?token=${token}`) + .json<{ finished: boolean; error: string }>() + if (res.finished || res.error) { + reload() + setOverlay('') + setMessage(res.error ?? 'Compressed all files successfully!') + break + } + await new Promise(resolve => setTimeout(resolve, 1000)) + } + }) + .catch(() => setMessage('Failed to compress the files!')) + } else if (res.status === 404 && archiveType !== 'zip') { + setOverlay('') + setMessage('Compressing `tar` archives requires Octyne v1.2 or newer!') + } else if (res.status === 404) { + // Fallback to v1 API without async compression and basePath. + const json = files.map(f => path + f) + ky.post(`server/${server}/compress?path=${encodeURIComponent(path + newPath + '.zip')}`, { + json, + }) + .then(res => { setOverlay('') - setMessage(res.error ?? 'Compressed all files successfully!') - break - } - await new Promise(resolve => setTimeout(resolve, 1000)) - } - }).catch(() => setMessage('Failed to compress the files!')) - } else if (res.status === 404 && archiveType !== 'zip') { - setOverlay('') - setMessage('Compressing `tar` archives requires Octyne v1.2 or newer!') - } else if (res.status === 404) { - // Fallback to v1 API without async compression and basePath. - const json = files.map(f => path + f) - ky.post(`server/${server}/compress?path=${encodeURIComponent(path + newPath + '.zip')}`, { json }) - .then(res => { - setOverlay('') - if (res.ok) { - reload() - setMessage('Compressed all files successfully!') - } else { - res.json<{ error: string }>() - .then(({ error }) => setMessage(error ?? 'Failed to compress the files!')) - .catch(() => setMessage('Failed to compress the files!')) - } - }).catch(() => { setOverlay(''); setMessage('Failed to compress the files!') }) - } else { + if (res.ok) { + reload() + setMessage('Compressed all files successfully!') + } else { + res + .json<{ error: string }>() + .then(({ error }) => setMessage(error ?? 'Failed to compress the files!')) + .catch(() => setMessage('Failed to compress the files!')) + } + }) + .catch(() => { + setOverlay('') + setMessage('Failed to compress the files!') + }) + } else { + setOverlay('') + res + .json<{ error: string }>() + .then(({ error }) => setMessage(error ?? 'Failed to compress the files!')) + .catch(() => setMessage('Failed to compress the files!')) + } + }) + .catch(() => { setOverlay('') - res.json<{ error: string }>() - .then(({ error }) => setMessage(error ?? 'Failed to compress the files!')) - .catch(() => setMessage('Failed to compress the files!')) - } - }).catch(() => { setOverlay(''); setMessage('Failed to compress the files!') }) + setMessage('Failed to compress the files!') + }) } const handleMoveCopyOperation = async (): Promise => { @@ -84,7 +128,7 @@ const MassActionDialog = ({ const operations = files.map(file => ({ operation: operation === 'move' ? 'mv' : 'cp', src: path + file, - dest: newPath.endsWith('/') ? newPath + file : newPath + '/' + file + dest: newPath.endsWith('/') ? newPath + file : newPath + '/' + file, })) try { const res = await ky.patch(`server/${server}/files?path=..`, { json: { operations } }) @@ -94,7 +138,7 @@ const MassActionDialog = ({ setOverlay('') return } else if (res.status !== 404) { - const errors = await res.json<{ errors?: Array<{ index: number, message: string }> }>() + const errors = await res.json<{ errors?: { index: number; message: string }[] }>() if (errors.errors?.length === 1) { setMessage(`Error ${movingl} files: ${errors.errors[0].message}`) setOverlay('') @@ -121,22 +165,28 @@ const MassActionDialog = ({ // setOverlay(file) const slash = newPath.endsWith('/') ? '' : '/' const body = `${operation === 'move' ? 'mv' : 'cp'}\n${path}${file}\n${newPath}${slash}${file}` - requests.push(ky.patch(`server/${server}/file?path=${encodeURIComponent(path + file)}`, { body }) - .then(async r => { - if (r.status !== 200) { - setMessage(`Error ${movingl} ${file}\n${(await r.json<{ error: string }>()).error}`) - } - const progress = (files.length - left) * 100 / files.length - setOverlay({ text: `${moving} ${--left} out of ${files.length} files.`, progress }) - if (localStorage.getItem('ecthelion:logAsyncMassActions')) console.log(moved + ' ' + file) - }) - .catch(e => setMessage(`Error ${movingl} ${file}\n${e}`))) + requests.push( + ky + .patch(`server/${server}/file?path=${encodeURIComponent(path + file)}`, { body }) + .then(async r => { + if (r.status !== 200) { + setMessage(`Error ${movingl} ${file}\n${(await r.json<{ error: string }>()).error}`) + } + const progress = ((files.length - left) * 100) / files.length + setOverlay({ text: `${moving} ${--left} out of ${files.length} files.`, progress }) + if (localStorage.getItem('ecthelion:logAsyncMassActions')) + console.log(moved + ' ' + file) + }) + .catch(e => setMessage(`Error ${movingl} ${file}\n${e}`)), + ) } - Promise.allSettled(requests).then(() => { - reload() - setOverlay('') - setMessage(moved + ' all files successfully!') - }).catch(console.error) // Should not be called, ideally. + Promise.allSettled(requests) + .then(() => { + reload() + setOverlay('') + setMessage(moved + ' all files successfully!') + }) + .catch(console.error) // Should not be called, ideally. } const handleOperation = (): void => { @@ -147,9 +197,10 @@ const MassActionDialog = ({ handleMoveCopyOperation().catch(console.error) // Should not be called, ideally. } } - const prompt = operation === 'compress' - ? 'Enter path to archive to create:' - : `Enter path of folder to ${operation} to:` + const prompt = + operation === 'compress' + ? 'Enter path to archive to create:' + : `Enter path of folder to ${operation} to:` return ( <> {/* Folder creation dialog. */} @@ -164,7 +215,12 @@ const MassActionDialog = ({ label='New Path' value={newPath} onChange={e => setNewPath(e.target.value)} - onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); handleOperation() } }} + onKeyDown={e => { + if (e.key === 'Enter') { + e.preventDefault() + handleOperation() + } + }} /> {operation === 'compress' && ( @@ -185,8 +241,12 @@ const MassActionDialog = ({ )} - - + + diff --git a/imports/dashboard/files/modifyFileDialog.tsx b/imports/dashboard/files/modifyFileDialog.tsx index 0cac89c..d98776f 100644 --- a/imports/dashboard/files/modifyFileDialog.tsx +++ b/imports/dashboard/files/modifyFileDialog.tsx @@ -1,19 +1,33 @@ import React, { useState } from 'react' import { - Dialog, DialogTitle, DialogContent, DialogContentText, DialogActions, Button, TextField + Dialog, + DialogTitle, + DialogContent, + DialogContentText, + DialogActions, + Button, + TextField, } from '@mui/material' -const ModifyFileDialog = ({ handleEdit, handleClose, operation, filename }: { +const ModifyFileDialog = ({ + handleEdit, + handleClose, + operation, + filename, +}: { handleEdit: (path: string) => any handleClose: () => void operation: 'move' | 'copy' | 'rename' filename: string }): React.JSX.Element => { const [path, setPath] = useState(operation === 'rename' ? filename : '') - const title = operation === 'copy' - ? 'Copy File/Folder' - : operation === 'move' ? 'Move File/Folder' : 'Rename File/Folder' + const title = + operation === 'copy' + ? 'Copy File/Folder' + : operation === 'move' + ? 'Move File/Folder' + : 'Rename File/Folder' const pathOrName = operation === 'rename' ? 'name' : 'path' return ( <> @@ -29,12 +43,21 @@ const ModifyFileDialog = ({ handleEdit, handleClose, operation, filename }: { label={`New ${pathOrName}`} value={path} onChange={e => setPath(e.target.value)} - onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); handleEdit(path) } }} + onKeyDown={e => { + if (e.key === 'Enter') { + e.preventDefault() + handleEdit(path) + } + }} /> - - + + diff --git a/imports/dashboard/files/overlay.tsx b/imports/dashboard/files/overlay.tsx index be13faa..72c7a96 100644 --- a/imports/dashboard/files/overlay.tsx +++ b/imports/dashboard/files/overlay.tsx @@ -5,19 +5,21 @@ import styled from '@emotion/styled' const OverlayContainer = styled.div({ display: 'flex', flexDirection: 'column', - position: 'fixed', /* Sit on top of the page content */ - width: '100%', /* Full width (cover the whole page) */ - height: '100%', /* Full height (cover the whole page) */ + position: 'fixed' /* Sit on top of the page content */, + width: '100%' /* Full width (cover the whole page) */, + height: '100%' /* Full height (cover the whole page) */, top: 0, left: 0, right: 0, bottom: 0, zIndex: 2000, - pointerEvents: 'none' + pointerEvents: 'none', }) // https://github.com/mui/material-ui/blob/v5.14.4/docs/data/material/components/progress/LinearWithValueLabel.tsx -function LinearProgressWithLabel (props: LinearProgressProps & { value: number }): React.JSX.Element { +function LinearProgressWithLabel( + props: LinearProgressProps & { value: number }, +): React.JSX.Element { return ( @@ -32,21 +34,26 @@ function LinearProgressWithLabel (props: LinearProgressProps & { value: number } ) } -const Overlay = (props: { display: string | { text: string, progress: number } }): React.JSX.Element => ( +const Overlay = (props: { + display: string | { text: string; progress: number } +}): React.JSX.Element => (
- {typeof props.display === 'string' - ? - : } + {typeof props.display === 'string' ? ( + + ) : ( + + )}
{typeof props.display === 'string' ? props.display : props.display.text} diff --git a/imports/dashboard/files/uploadButton.tsx b/imports/dashboard/files/uploadButton.tsx index 8f64940..4f9c77c 100644 --- a/imports/dashboard/files/uploadButton.tsx +++ b/imports/dashboard/files/uploadButton.tsx @@ -2,7 +2,10 @@ import React, { useState, useEffect } from 'react' import { IconButton, Tooltip } from '@mui/material' import CloudUpload from '@mui/icons-material/CloudUpload' -const UploadButton = ({ uploadFiles, disabled }: { +const UploadButton = ({ + uploadFiles, + disabled, +}: { uploadFiles: (files: FileList) => void disabled: boolean }): React.JSX.Element => { @@ -21,7 +24,9 @@ const UploadButton = ({ uploadFiles, disabled }: { id='icon-button-file' type='file' onChange={e => setFiles(e.target.files)} - onClick={e => { (e.target as HTMLInputElement).value = '' }} + onClick={e => { + ;(e.target as HTMLInputElement).value = '' + }} />