fix: admin/fs: bad mobile ux

Massimo Melina committed Feb 2, 2023 at 14:07 UTC b6523ae1c845ddeda481f34d7fabcecb592ea377
8 files changed +158 -74
admin/package.json
+1
@@ -24,6 +24,7 @@
24 "tssrp6a": "^3.0.0",
25 "valtio": "^1.2.9",
26 "immer": "^9.0.15",
27 + "usehooks-ts": "^2.9.1",
28 "web-vitals": "^2.1.4"
29 },
30 "devDependencies": {
admin/src/FileForm.ts
+21 -16
@@ -1,21 +1,22 @@
1 // This file is part of HFS - Copyright 2021-2023, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt
2
3 import { state } from './state'
4 -import { createElement as h, useEffect, useMemo, useState } from 'react'
5 -import { Alert, Button } from '@mui/material'
4 +import { createElement as h, ReactNode, useEffect, useMemo, useState } from 'react'
5 +import { Alert } from '@mui/material'
6 import { BoolField, DisplayField, Field, FieldProps, Form, MultiSelectField, SelectField } from '@hfs/mui-grid-form'
7 import { apiCall, useApiEx } from './api'
8 -import { formatBytes, isEqualLax, modifiedSx, onlyTruthy } from './misc'
8 +import { formatBytes, IconBtn, isEqualLax, modifiedSx, onlyTruthy } from './misc'
9 import { reloadVfs, VfsNode, VfsPerms, Who } from './VfsPage'
10 import md from './md'
11 import _ from 'lodash'
12 import FileField from './FileField'
13 -import { alertDialog } from './dialog'
13 +import { alertDialog, useDialogBarColors } from './dialog'
14 import yaml from 'yaml'
15 +import { Delete } from '@mui/icons-material'
16
17 interface Account { username: string }
18
18 -export default function FileForm({ file, defaultPerms }: { file: VfsNode, defaultPerms: VfsPerms }) {
19 +export default function FileForm({ file, defaultPerms, addToBar }: { file: VfsNode, defaultPerms: VfsPerms, addToBar?: ReactNode }) {
20 const { parent, children, isRoot, ...rest } = file
21 const [values, setValues] = useState(rest)
22 useEffect(() => {
@@ -37,6 +38,7 @@ export default function FileForm({ file, defaultPerms }: { file: VfsNode, defaul
38 }, [parent])
39 const showCanSee = (values.can_read ?? inheritedPerms.can_read) === true
40 const showTimestamps = hasSource && Boolean(values.ctime)
41 + const barColors = useDialogBarColors()
42
43 const { data, element } = useApiEx<{ list: Account[] }>('get_accounts')
44 if (element || !data)
@@ -48,13 +50,16 @@ export default function FileForm({ file, defaultPerms }: { file: VfsNode, defaul
50 set(v, k) {
51 setValues({ ...values, [k]: v })
52 },
53 + barSx: { gap: 2, width: '100%', ...barColors },
54 + stickyBar: true,
55 addToBar: [
52 - h(Button, { // not really useful, but users misled in thinking it's a dialog will find satisfaction in dismissing the form
53 - sx: { ml: 2 },
54 - onClick(){
55 - state.selectedFiles = []
56 - }
57 - }, "Close")
56 + !isRoot && h(IconBtn, {
57 + icon: Delete,
58 + title: "Delete",
59 + confirm: "Delete?",
60 + onClick: () => apiCall('del_vfs', { uris: [file.id] }).then(() => reloadVfs()),
61 + }),
62 + addToBar
63 ],
64 onError: alertDialog,
65 save: {
@@ -81,14 +86,14 @@ export default function FileForm({ file, defaultPerms }: { file: VfsNode, defaul
86 perm('can_read', "Who can download", "Note: who can't download won't see it in the list"),
87 showCanSee && perm('can_see', "Who can see", "You can hide and keep it downloadable if you have a direct link"),
88 isDir && perm('can_upload', "Who can upload", hasSource ? '' : "Works only on folders with source"),
84 - hasSource && !realFolder && { k: 'size', comp: DisplayField, toField: formatBytes },
85 - showTimestamps && { k: 'ctime', comp: DisplayField, lg: 6, label: 'Created', toField: formatTimestamp },
86 - showTimestamps && { k: 'mtime', comp: DisplayField, lg: 6, label: 'Modified', toField: formatTimestamp },
89 + hasSource && !realFolder && { k: 'size', comp: DisplayField, lg: 4, toField: formatBytes },
90 + showTimestamps && { k: 'ctime', comp: DisplayField, md: 6, lg: 4, label: 'Created', toField: formatTimestamp },
91 + showTimestamps && { k: 'mtime', comp: DisplayField, md: 6, lg: 4, label: 'Modified', toField: formatTimestamp },
92 file.website && { k: 'default', comp: BoolField, label:"Serve index.html",
93 toField: Boolean, fromField: (v:boolean) => v ? 'index.html' : null,
94 helperText: md("This folder may be a website because contains `index.html`. Enabling this will show the website instead of the list of files.")
95 },
91 - isDir && { k: 'masks', multiline: true, xl: true,
96 + isDir && { k: 'masks', multiline: true, xl: true, lg: 6,
97 toField: yaml.stringify, fromField: v => v ? yaml.parse(v) : undefined,
98 sx: { '& textarea': { fontFamily: 'monospace' } },
99 helperText: "Special field, leave empty unless you know what you are doing. YAML syntax." }
@@ -96,7 +101,7 @@ export default function FileForm({ file, defaultPerms }: { file: VfsNode, defaul
101 })
102
103 function perm(perm: keyof typeof inheritedPerms, label: string, helperText='', props={}) {
99 - return { k: perm, xl: 6, comp: WhoField, parent, accounts, label, inherit: inheritedPerms[perm], helperText, ...props }
104 + return { k: perm, lg: 6, comp: WhoField, parent, accounts, label, inherit: inheritedPerms[perm], helperText, ...props }
105 }
106 }
107
admin/src/VfsMenuBar.ts
+2 -26
@@ -1,18 +1,14 @@
1 // This file is part of HFS - Copyright 2021-2023, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt
2
3 -import { state, useSnapState } from './state'
3 import { createElement as h } from 'react'
5 -import { Box, Button } from '@mui/material'
6 -import { Add, Delete, Refresh } from '@mui/icons-material'
7 -import { alertDialog, confirmDialog } from './dialog'
8 -import { apiCall } from './api'
4 +import { Box } from '@mui/material'
5 +import { Add, Refresh } from '@mui/icons-material'
6 import { reloadVfs } from './VfsPage'
7 import addFiles, { addVirtual } from './addFiles'
8 import MenuButton from './MenuButton'
9 import { IconBtn } from './misc'
10
11 export default function VfsMenuBar() {
15 - const { selectedFiles } = useSnapState()
12 return h(Box, {
13 display: 'flex',
14 gap: 2,
@@ -33,26 +29,6 @@ export default function VfsMenuBar() {
29 { children: "virtual folder", onClick: addVirtual }
30 ]
31 }, "Add"),
36 - h(Button, { onClick: removeFiles, disabled: !selectedFiles.length, startIcon: h(Delete) }, "Remove"),
32 h(IconBtn, { icon: Refresh, title: "Reload", onClick(){ reloadVfs() } }),
33 )
34 }
40 -
41 -async function removeFiles() {
42 - const f = state.selectedFiles
43 - if (!f.length) return
44 - if (await confirmDialog(`Remove ${f.length} item(s)?`)) {
45 - try {
46 - const uris = f.map(x => x.id)
47 - const { errors } = await apiCall('del_vfs', { uris })
48 - const urisThatFailed = uris.filter((uri, idx) => errors[idx])
49 - if (urisThatFailed.length)
50 - return alertDialog("Following elements couldn't be removed: " + urisThatFailed.join(', '), 'error')
51 - reloadVfs()
52 - }
53 - catch(e) {
54 - await alertDialog(e as Error)
55 - }
56 - }
57 -
58 -}
admin/src/VfsPage.ts
+72 -15
@@ -1,16 +1,27 @@
1 // This file is part of HFS - Copyright 2021-2023, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt
2
3 -import { createElement as h, useEffect, useMemo, useState } from 'react'
4 -import { useApi, useApiEx } from './api'
5 -import { Alert, Grid, Link, List, ListItem, ListItemText, Typography } from '@mui/material'
3 +import { createElement as h, Fragment, useEffect, useMemo, useState } from 'react'
4 +import { apiCall, useApi, useApiEx } from './api'
5 +import {
6 + Alert,
7 + Button,
8 + Card, CardContent,
9 + Grid,
10 + Link,
11 + List, ListItem, ListItemText,
12 + Typography
13 +} from '@mui/material'
14 import { state, useSnapState } from './state'
15 import VfsMenuBar from './VfsMenuBar'
16 import VfsTree from './VfsTree'
9 -import { onlyTruthy, prefix } from './misc'
17 +import { IconBtn, newDialog, onlyTruthy, prefix, useBreakpoint } from './misc'
18 import { reactJoin } from '@hfs/shared'
19 import _ from 'lodash'
20 import { AlertProps } from '@mui/material/Alert/Alert'
21 import FileForm from './FileForm'
22 +import { Close, Delete } from '@mui/icons-material'
23 +import { alertDialog, confirmDialog } from './dialog'
24 +import { Flex } from '@hfs/frontend/src/components'
25
26 let selectOnReload: string[] | undefined
27
@@ -19,6 +30,43 @@ export default function VfsPage() {
30 const { vfs, selectedFiles } = useSnapState()
31 const { data, reload, element } = useApiEx('get_vfs')
32 useMemo(() => vfs || reload(), [vfs, reload])
33 + const sideBreakpoint = 'md'
34 + const isSideBreakpoint = useBreakpoint(sideBreakpoint)
35 +
36 + const sideContent = !selectedFiles.length ? null
37 + : selectedFiles.length === 1 ? h(FileForm, {
38 + addToBar: isSideBreakpoint && h(IconBtn, { // not really useful, but users misled in thinking it's a dialog will find satisfaction in dismissing the form
39 + icon: Close,
40 + title: "Close",
41 + onClick(){
42 + state.selectedFiles = []
43 + }
44 + }),
45 + defaultPerms: data?.defaultPerms as VfsPerms,
46 + file: selectedFiles[0] as VfsNode // it's actually Snapshot<VfsNode> but it's easier this way
47 + })
48 + : h(Fragment, {},
49 + h(Flex, { alignItems: 'center' },
50 + h(Typography, {variant: 'h6'}, selectedFiles.length + ' selected'),
51 + h(Button, { onClick: removeFiles, startIcon: h(Delete) }, "Remove"),
52 + ),
53 + h(List, { dense: true, disablePadding: true },
54 + selectedFiles.map(f => h(ListItem, { key: f.id },
55 + h(ListItemText, { primary: f.name, secondary: f.source }) ))
56 + )
57 + )
58 +
59 + useEffect(() => {
60 + if (isSideBreakpoint || !sideContent) return
61 + return newDialog({
62 + title: selectedFiles[0].name,
63 + Content: () => sideContent,
64 + onClose() {
65 + state.selectedFiles = []
66 + },
67 + })
68 + },[isSideBreakpoint, selectedFiles])
69 +
70 useEffect(() => {
71 state.vfs = undefined
72 if (!data) return
@@ -71,20 +119,12 @@ export default function VfsPage() {
119 }
120 return h(Grid, { container:true, rowSpacing: 1, maxWidth: '80em', columnSpacing: 2 },
121 alert && h(Grid, { item: true, mb: 2, xs: 12 }, h(Alert, alert)),
74 - h(Grid, { item:true, sm: 6, lg: 5 },
122 + h(Grid, { item:true, [sideBreakpoint]: 6, lg: 5 },
123 h(Typography, { variant: 'h6', mb:1, }, "Virtual File System"),
124 h(VfsMenuBar),
125 vfs && h(VfsTree, { id2node })),
78 - h(Grid, { item:true, sm: 6, lg: 7, maxWidth:'100%' },
79 - selectedFiles.length === 0 ? null
80 - : selectedFiles.length === 1 ? h(FileForm, {
81 - defaultPerms: data?.defaultPerms as VfsPerms,
82 - file: selectedFiles[0] as VfsNode // it's actually Snapshot<VfsNode> but it's easier this way
83 - })
84 - : h(List, {},
85 - selectedFiles.length + ' selected',
86 - selectedFiles.map(f => h(ListItem, { key: f.name },
87 - h(ListItemText, { primary: f.name, secondary: f.source }) ))))
126 + isSideBreakpoint && sideContent && h(Grid, { item:true, [sideBreakpoint]: 6, lg: 7, maxWidth:'100%' },
127 + h(Card, {}, h(CardContent, {}, sideContent) ))
128 )
129 }
130
@@ -93,6 +133,23 @@ export function reloadVfs(pleaseSelect?: string[]) {
133 state.vfs = undefined
134 }
135
136 +export async function removeFiles() {
137 + const f = state.selectedFiles
138 + if (!f.length) return
139 + if (!await confirmDialog(`Remove ${f.length} item(s)?`)) return
140 + try {
141 + const uris = f.map(x => x.id)
142 + const { errors } = await apiCall('del_vfs', { uris })
143 + const urisThatFailed = uris.filter((uri, idx) => errors[idx])
144 + if (urisThatFailed.length)
145 + return alertDialog("Following elements couldn't be removed: " + urisThatFailed.join(', '), 'error')
146 + reloadVfs()
147 + }
148 + catch(e) {
149 + await alertDialog(e as Error)
150 + }
151 +}
152 +
153 export interface VfsPerms {
154 can_see?: Who
155 can_read?: Who
admin/src/VfsTree.ts
+4 -1
@@ -1,7 +1,7 @@
1 // This file is part of HFS - Copyright 2021-2023, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt
2
3 import { state, useSnapState } from './state'
4 -import { createElement as h, ReactElement, useState } from 'react'
4 +import { createElement as h, ReactElement, useRef, useState } from 'react'
5 import { TreeItem, TreeView } from '@mui/lab'
6 import {
7 ChevronRight,
@@ -25,14 +25,17 @@ export default function VfsTree({ id2node }:{ id2node: Map<string, VfsNode> }) {
25 const { vfs, selectedFiles } = useSnapState()
26 const [selected, setSelected] = useState<string[]>(selectedFiles.map(x => x.id)) // try to restore selection after reload
27 const [expanded, setExpanded] = useState(Array.from(id2node.keys()))
28 + const ref = useRef<HTMLElement>()
29 if (!vfs)
30 return null
31 return h(TreeView, {
32 + ref,
33 expanded,
34 selected,
35 multiSelect: true,
36 sx: {
37 overflowX: 'auto',
38 + maxWidth: ref.current && `calc(100vw - ${16 + ref.current.offsetLeft}px)`, // limit possible horizontal scrolling to this element
39 '& ul': { borderLeft: '1px dashed #444', marginLeft: '15px' },
40 },
41 onNodeSelect(ev, ids) {
admin/src/dialog.ts
+38 -8
@@ -21,34 +21,64 @@ import {
21 import { Check, Close, Error as ErrorIcon, Forward, Info, Warning } from '@mui/icons-material'
22 import { newDialog, closeDialog, dialogsDefaults, DialogOptions } from '@hfs/shared'
23 import { Form, FormProps } from '@hfs/mui-grid-form'
24 -import { useBreakpoint } from './misc'
24 +import { IconBtn } from './misc'
25 import { Flex } from '@hfs/frontend/src/components'
26 +import { useDark } from './theme'
27 +import { useWindowSize } from 'usehooks-ts'
28 export * from '@hfs/shared/dialogs'
29
30 dialogsDefaults.Container = function Container(d:DialogOptions) {
29 - useEffect(()=>{
30 - ref.current?.focus()
31 - }, [])
31 const ref = useRef<HTMLElement>()
32 + const { width, height } = useWindowSize()
33 + const mobile = Math.min(width, height) < 500
34 + useEffect(()=> {
35 + const h = setTimeout(() => {
36 + const el = ref.current
37 + if (!el) return
38 + el.focus()
39 + if (mobile) return
40 + const input = el.querySelector('[autofocus]') || el.querySelector('input,textarea')
41 + if (input && input instanceof HTMLElement)
42 + input.focus()
43 + })
44 + return () => clearTimeout(h)
45 + }, [ref.current])
46 d = { ...dialogsDefaults, ...d }
47 const { sx, root, ...rest } = d.dialogProps||{}
35 - const p = d.padding ? 2 : 0
48 + dialogsDefaults.dialogProps = { fullScreen: mobile, sx: { overflow:'initial' } }
49 return h(MuiDialog, {
50 open: true,
51 maxWidth: 'lg',
39 - fullScreen: !useBreakpoint('sm'),
52 + fullScreen: mobile,
53 ...rest,
54 ...root,
55 onClose: ()=> closeDialog(),
56 },
44 - d.title && h(DialogTitle, {}, d.title),
57 + d.title && h(DialogTitle, {
58 + sx: {
59 + position: 'sticky', top: 0, py: 1, pr: 1, zIndex: 2, boxShadow: '0 0 8px #0004',
60 + display: 'flex', alignItems: 'center',
61 + ...useDialogBarColors()
62 + }
63 + },
64 + h(Box, { flex:1, minWidth: 40 }, d.title),
65 + h(IconBtn, { icon: Close, tooltip: "close", onClick: () => closeDialog() }),
66 + ),
67 h(DialogContent, {
68 ref,
47 - sx: { ...sx, px: p, pb: p, display: 'flex', flexDirection: 'column', justifyContent: 'center', }
69 + sx: {
70 + p: d.padding ? 2 : 0, pt: '16px !important', overflow: 'initial',
71 + display: 'flex', flexDirection: 'column', justifyContent: 'center',
72 + ...sx,
73 + }
74 }, h(d.Content) )
75 )
76 }
77
78 +export function useDialogBarColors() {
79 + return useDark() ? { bgcolor: '#2d2d2d' } : { bgcolor:'#aaa', color: '#444', }
80 +}
81 +
82 type AlertType = 'error' | 'warning' | 'info' | 'success'
83
84 const type2ico = {
admin/src/theme.ts
+5 -1
@@ -3,9 +3,13 @@
3 import { createTheme, useMediaQuery } from '@mui/material'
4 import { useMemo } from 'react'
5
6 +export function useDark() {
7 + return useMediaQuery('(prefers-color-scheme: dark)')
8 +}
9 +
10 const EMPTY = {}
11 export function useMyTheme() {
8 - const lightMode = useMediaQuery('(prefers-color-scheme: dark)') ? null : EMPTY
12 + const lightMode = useDark() ? null : EMPTY
13 return useMemo(() => createTheme({
14 palette: lightMode || {
15 mode: 'dark',
package-lock.json
+15 -7
@@ -57,6 +57,7 @@
57 "@types/unzipper": "^0.10.5",
58 "axios": "^0.24.0",
59 "axios-cookiejar-support": "^4.0.1",
60 + "cross-env": "^7.0.3",
61 "koa-better-http-proxy": "^0.2.9",
62 "mocha": "^9.1.3",
63 "nm-prune": "^5.0.0",
@@ -86,6 +87,7 @@
87 "react-router-dom": "^6.2.1",
88 "react-window": "^1.8.6",
89 "tssrp6a": "^3.0.0",
90 + "usehooks-ts": "^2.9.1",
91 "valtio": "^1.2.9",
92 "web-vitals": "^2.1.4"
93 },
@@ -6672,11 +6674,16 @@
6674 }
6675 },
6676 "node_modules/usehooks-ts": {
6675 - "version": "2.6.0",
6676 - "resolved": "https://registry.npmjs.org/usehooks-ts/-/usehooks-ts-2.6.0.tgz",
6677 - "integrity": "sha512-Kj/4oc2nOxRDGTDb2v1ZulF7+tpeXFuqI6cUesM0Vic7TPPDlFORxKh4ivsYg+NTvX/YbM+lhqqkfFTiIt23eg==",
6677 + "version": "2.9.1",
6678 + "resolved": "https://registry.npmjs.org/usehooks-ts/-/usehooks-ts-2.9.1.tgz",
6679 + "integrity": "sha512-2FAuSIGHlY+apM9FVlj8/oNhd+1y+Uwv5QNkMQz1oSfdHk4PXo1qoCw9I5M7j0vpH8CSWFJwXbVPeYDjLCx9PA==",
6680 + "engines": {
6681 + "node": ">=16.15.0",
6682 + "npm": ">=8"
6683 + },
6684 "peerDependencies": {
6679 - "react": ">=16.9.0"
6685 + "react": "^16.8.0 || ^17.0.0 || ^18.0.0",
6686 + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
6687 }
6688 },
6689 "node_modules/util-deprecate": {
@@ -7911,6 +7918,7 @@
7918 "react-router-dom": "^6.2.1",
7919 "react-window": "^1.8.6",
7920 "tssrp6a": "^3.0.0",
7921 + "usehooks-ts": "^2.9.1",
7922 "valtio": "^1.2.9",
7923 "vite": "^4.0.4",
7924 "vite-plugin-babel-import": "github:rejetto/vite-plugin-babel-import",
@@ -11970,9 +11978,9 @@
11978 "requires": {}
11979 },
11980 "usehooks-ts": {
11973 - "version": "2.6.0",
11974 - "resolved": "https://registry.npmjs.org/usehooks-ts/-/usehooks-ts-2.6.0.tgz",
11975 - "integrity": "sha512-Kj/4oc2nOxRDGTDb2v1ZulF7+tpeXFuqI6cUesM0Vic7TPPDlFORxKh4ivsYg+NTvX/YbM+lhqqkfFTiIt23eg==",
11981 + "version": "2.9.1",
11982 + "resolved": "https://registry.npmjs.org/usehooks-ts/-/usehooks-ts-2.9.1.tgz",
11983 + "integrity": "sha512-2FAuSIGHlY+apM9FVlj8/oNhd+1y+Uwv5QNkMQz1oSfdHk4PXo1qoCw9I5M7j0vpH8CSWFJwXbVPeYDjLCx9PA==",
11984 "requires": {}
11985 },
11986 "util-deprecate": {