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": {