fix: admin/fs: on mobile it was not possible to add an element under another
Massimo Melina committed
Jan 21, 2024 at 16:00 UTC
abeb8d9b98af01dcd10cd368286712b201f3beb7
5 files changed
+73
-68
admin/src/FileForm.ts
+15
-3
@@ -15,9 +15,11 @@ import _ from 'lodash'
15
import FileField from './FileField'
16
import { alertDialog, toast, useDialogBarColors } from './dialog'
17
import yaml from 'yaml'
18
-import { Check, ContentCopy, ContentCut, ContentPaste, Delete, Edit, QrCode2, Save } from '@mui/icons-material'
18
+import { Add, Check, ContentCopy, ContentCut, ContentPaste, Delete, Edit, QrCode2, Save } from '@mui/icons-material'
19
import { moveVfs } from './VfsTree'
20
import QrCreator from 'qr-creator';
21
+import MenuButton from './MenuButton'
22
+import addFiles, { addLink, addVirtual } from './addFiles'
23
24
interface Account { username: string }
25
@@ -84,6 +86,15 @@ export default function FileForm({ file, addToBar, statusApi }: FileFormProps) {
86
barSx: { gap: 2, width: '100%', ...barColors },
87
stickyBar: true,
88
addToBar: [
89
+ h(MenuButton, {
90
+ variant: 'outlined',
91
+ startIcon: h(Add),
92
+ items: [
93
+ { children: "from disk", onClick: addFiles },
94
+ { children: "virtual folder", onClick: addVirtual },
95
+ { children: "web-link", onClick: addLink },
96
+ ]
97
+ }, "Add"),
98
h(IconBtn, {
99
icon: ContentCut,
100
disabled: isRoot || movingFile === file.id,
@@ -105,11 +116,12 @@ export default function FileForm({ file, addToBar, statusApi }: FileFormProps) {
116
return moveVfs(movingFile, file.id)
117
},
118
}),
108
- !isRoot && h(IconBtn, {
119
+ h(IconBtn, {
120
icon: Delete,
121
title: "Delete",
122
confirm: "Delete?",
112
- onClick: () => apiCall('del_vfs', { uris: [file.id] }).then(() => reloadVfs()),
123
+ disabled: isRoot,
124
+ onClick: () => apiCall('del_vfs', { uris: [file.id] }).then(() => reloadVfs([])),
125
}),
126
...wantArray(addToBar)
127
],
admin/src/VfsMenuBar.ts
+1
-12
@@ -2,10 +2,8 @@
2
3
import { createElement as h } from 'react'
4
import { Alert, Box } from '@mui/material'
5
-import { Add, Microsoft } from '@mui/icons-material'
5
+import { Microsoft } from '@mui/icons-material'
6
import { reloadVfs } from './VfsPage'
7
-import addFiles, { addLink, addVirtual } from './addFiles'
8
-import MenuButton from './MenuButton'
7
import { CFG, newDialog } from './misc'
8
import { Btn, Flex, reloadBtn } from './mui'
9
import { apiCall, ApiObject, useApi } from './api'
@@ -26,15 +24,6 @@ export default function VfsMenuBar({ statusApi }: { statusApi: ApiObject }) {
24
backgroundColor: 'background.paper',
25
width: 'fit-content',
26
},
29
- h(MenuButton, {
30
- variant: 'contained',
31
- startIcon: h(Add),
32
- items: [
33
- { children: "from disk", onClick: addFiles },
34
- { children: "virtual folder", onClick: addVirtual },
35
- { children: "web-link", onClick: addLink },
36
- ]
37
- }, "Add"),
27
h(Btn, { variant: 'outlined', onClick: roots }, "Roots"),
28
reloadBtn(() => reloadVfs()),
29
isWindows && h(Btn, {
admin/src/VfsPage.ts
+52
-45
@@ -7,12 +7,12 @@ import { state, useSnapState } from './state'
7
import VfsMenuBar from './VfsMenuBar'
8
import VfsTree, { vfsNodeIcon } from './VfsTree'
9
import { newDialog, onlyTruthy, prefix, VfsNodeAdminSend } from './misc'
10
-import { Flex, IconBtn, useBreakpoint } from './mui'
10
+import { Flex, useBreakpoint } from './mui'
11
import { reactJoin } from '@hfs/shared'
12
import _ from 'lodash'
13
import { AlertProps } from '@mui/material/Alert/Alert'
14
import FileForm from './FileForm'
15
-import { Close, Delete } from '@mui/icons-material'
15
+import { Delete } from '@mui/icons-material'
16
import { alertDialog, confirmDialog } from './dialog'
17
18
let selectOnReload: string[] | undefined
@@ -31,49 +31,47 @@ export default function VfsPage() {
31
const ret = status?.urls.https || status?.urls.http
32
return b && !ret.includes(b) ? [b, ...ret] : ret
33
}, [status])
34
- const [hideForm, setHideForm] = useState(false)
35
-
36
- function closeForm() {
37
- setHideForm(true)
38
- }
34
+ const single = selectedFiles.length < 2 && (selectedFiles[0] as VfsNode || vfs)
35
+ useEffect(() => {
36
+ if (isSideBreakpoint && !selectedFiles.length && state.vfs)
37
+ state.selectedFiles = [state.vfs]
38
+ }, [isSideBreakpoint, selectedFiles, state.vfs])
39
40
- const sideContent = !selectedFiles.length || hideForm ? null
41
- : selectedFiles.length === 1 ? h(FileForm, {
42
- addToBar: isSideBreakpoint && [
43
- h(Box, { flex: 1 }),
44
- // not really useful, but users misled in thinking it's a dialog will find satisfaction in dismissing the form
45
- vfsNodeIcon(selectedFiles[0] as VfsNode),
46
- h(IconBtn, { icon: Close, title: "Close", onClick: closeForm })
47
- ],
48
- statusApi,
49
- file: selectedFiles[0] as VfsNode // it's actually Snapshot<VfsNode> but it's easier this way
50
- })
51
- : h(Fragment, {},
52
- h(Flex, {},
53
- h(Typography, {variant: 'h6'}, selectedFiles.length + ' selected'),
54
- h(Button, { onClick: deleteFiles, startIcon: h(Delete) }, "Remove"),
55
- ),
56
- h(List, { dense: true, disablePadding: true },
57
- selectedFiles.map(f => h(ListItem, { key: f.id },
58
- h(ListItemText, { primary: f.name, secondary: f.source }) ))
59
- )
40
+ const sideContent = !vfs ? null : single ? h(FileForm, {
41
+ addToBar: isSideBreakpoint && h(Box, { flex: 1, textAlign: 'right', mr: 1, color: '#8883' }, vfsNodeIcon(single)),
42
+ statusApi,
43
+ file: single // it's actually Snapshot<VfsNode> but it's easier this way
44
+ })
45
+ : h(Fragment, {},
46
+ h(Flex, {},
47
+ h(Typography, {variant: 'h6'}, selectedFiles.length + ' selected'),
48
+ h(Button, { onClick: deleteFiles, startIcon: h(Delete) }, "Remove"),
49
+ ),
50
+ h(List, { dense: true, disablePadding: true },
51
+ selectedFiles.map(f => h(ListItem, { key: f.id },
52
+ h(ListItemText, { primary: f.name, secondary: f.source }) ))
53
)
54
+ )
55
56
// this will take care of closing the dialog, for user's convenience, after "cut" button is pressed
57
const [closeDialog, setCloseDialog] = useState(() => _.noop)
58
useEffect(() => {
65
- if (movingFile === selectedFiles[0]?.id) closeDialog()
59
+ if (movingFile === selectedFiles[0]?.id)
60
+ closeDialog()
61
}, [movingFile, closeDialog])
62
+
63
useEffect(() => {
68
- if (isSideBreakpoint || !sideContent) return
64
+ if (isSideBreakpoint || !sideContent || !selectedFiles.length) return
65
const { close } = newDialog({
66
title: selectedFiles.length > 1 ? "Multiple selection" :
67
h(Flex, {}, vfsNodeIcon(selectedFiles[0] as VfsNode), selectedFiles[0].name || "Home"),
68
Content: () => sideContent,
73
- onClose: closeForm,
69
+ onClose() {
70
+ state.selectedFiles = []
71
+ },
72
})
73
setCloseDialog(() => close)
76
- return close
74
+ return close // auto-close dialog if we are switching to side-panel
75
}, [isSideBreakpoint, selectedFiles])
76
77
useEffect(() => {
@@ -87,10 +85,16 @@ export default function VfsPage() {
85
recur(root) // this must be done before state change that would cause Tree to render and expecting id2node
86
state.vfs = root
87
// refresh objects of selectedFiles
90
- const ids = selectOnReload || state.selectedFiles.map(x => x.id)
91
- selectOnReload = undefined
92
- state.selectedFiles = onlyTruthy(ids.map(id =>
93
- id2node.get(id)))
88
+ state.selectedFiles = consumeSelectOnReload()
89
+ || onlyTruthy(state.selectedFiles.map(x => id2node.get(x.id))) // refresh with new objects
90
+
91
+ function consumeSelectOnReload() {
92
+ if (selectOnReload)
93
+ closeDialog() // noop when side-paneling
94
+ const ret = selectOnReload && onlyTruthy(selectOnReload.map(id => id2node.get(id)))
95
+ selectOnReload = undefined
96
+ return ret
97
+ }
98
99
// calculate id and parent fields, and builds the map id2node
100
function recur(node: VfsNode, pre='/', parent: VfsNode|undefined=undefined) {
@@ -118,17 +122,20 @@ export default function VfsPage() {
122
reactJoin(" or ", urls.slice(0,3).map(href => h(Link, { href, target: 'frontend' }, href)))
123
]
124
}
121
- return h(Grid, { container:true, rowSpacing: 1, columnSpacing: 2 },
122
- h(Grid, { item: true, mb: 2, xs: 12 },
125
+ return h(Fragment, {},
126
+ h(Box, { mb: 2 },
127
h(Alert, { severity: 'info' }, "If you rename or delete here, it's virtual, and only affects what is presented to the users"),
124
- alert && h(Alert, alert) ),
125
- h(Grid, { item: true, [sideBreakpoint]: 7, lg: 6, xl: 5 },
126
- h(Typography, { variant: 'h6', mb:1, }, "Virtual File System"),
127
- h(VfsMenuBar, { statusApi }),
128
- vfs && h(VfsTree, { id2node, statusApi, onSelect: () => setHideForm(false) }) ),
129
- isSideBreakpoint && sideContent && h(Grid, { item: true, [sideBreakpoint]: true, maxWidth:'100%' },
130
- h(Card, { sx: { overflow: 'initial' } }, // overflow is incompatible with stickyBar
131
- h(CardContent, {}, sideContent) ))
128
+ alert && h(Alert, alert),
129
+ ),
130
+ h(Grid, { container: true, rowSpacing: 1, columnSpacing: 2, position: 'sticky', top: 0 },
131
+ h(Grid, { item: true, xs: 12, [sideBreakpoint]: 6, lg: 6, xl: 5 },
132
+ h(Typography, { variant: 'h6', mb: 1, }, "Virtual File System"),
133
+ h(VfsMenuBar, { statusApi }),
134
+ vfs && h(VfsTree, { id2node, statusApi }) ),
135
+ isSideBreakpoint && sideContent && h(Grid, { item: true, [sideBreakpoint]: true, maxWidth: '100%' },
136
+ h(Card, { sx: { overflow: 'initial' } }, // overflow is incompatible with stickyBar
137
+ h(CardContent, {}, sideContent)) )
138
+ )
139
)
140
}
141
admin/src/VfsTree.ts
+4
-7
@@ -4,8 +4,7 @@ import { state, useSnapState } from './state'
4
import { createElement as h, ReactElement, useRef, useState } from 'react'
5
import { TreeItem, TreeView } from '@mui/x-tree-view'
6
import { ChevronRight, ExpandMore, TheaterComedy, Folder, Home, Link, InsertDriveFileOutlined, Lock,
7
- RemoveRedEye, Web, Upload, Cloud, Delete, HighlightOff
8
-} from '@mui/icons-material'
7
+ RemoveRedEye, Web, Upload, Cloud, Delete, HighlightOff } from '@mui/icons-material'
8
import { Box } from '@mui/material'
9
import { reloadVfs, VfsNode } from './VfsPage'
10
import { Callback, onlyTruthy, Who, with_ } from './misc'
@@ -17,9 +16,8 @@ import _ from 'lodash'
16
export const FolderIcon = Folder
17
export const FileIcon = InsertDriveFileOutlined
18
20
-export default function VfsTree({ id2node, statusApi, onSelect }:{ id2node: Map<string, VfsNode>, statusApi: ApiObject, onSelect: Callback }) {
19
+export default function VfsTkree({ id2node, statusApi, onSelect }:{ id2node: Map<string, VfsNode>, statusApi: ApiObject, onSelect?: Callback<VfsNode[]> }) {
20
const { vfs, selectedFiles } = useSnapState()
22
- const [selected, setSelected] = useState<string[]>(selectedFiles.map(x => x.id)) // try to restore selection after reload
21
const [expanded, setExpanded] = useState(Array.from(id2node.keys()))
22
const dragging = useRef<string>()
23
const ref = useRef<HTMLUListElement>()
@@ -30,7 +28,7 @@ export default function VfsTree({ id2node, statusApi, onSelect }:{ id2node: Map<
28
// @ts-ignore the type declared on the lib doesn't seem to be compatible with useRef()
29
ref,
30
expanded,
33
- selected,
31
+ selected: selectedFiles.map(x => x.id),
32
multiSelect: true,
33
id: treeId,
34
sx: {
@@ -40,9 +38,8 @@ export default function VfsTree({ id2node, statusApi, onSelect }:{ id2node: Map<
38
},
39
onNodeSelect(ev, ids) {
40
if (typeof ids === 'string') return // shut up ts
43
- setSelected(ids)
41
state.selectedFiles = onlyTruthy(ids.map(id => id2node.get(id)))
45
- onSelect()
42
+ onSelect?.(state.selectedFiles)
43
}
44
}, recur(vfs as Readonly<VfsNode>))
45
admin/src/addFiles.ts
+1
-1
@@ -52,8 +52,8 @@ export async function addVirtual() {
52
if (!name) return
53
const { id: parent } = getFolderFromSelected()
54
const res = await apiCall('add_vfs', { parent, name })
55
- reloadVfs([ parent + encodeURI(res.name) + '/' ])
55
await alertDialog(`Folder "${res.name}" created`, 'success')
56
+ reloadVfs([ parent + encodeURI(res.name) + '/' ])
57
}
58
catch(e) {
59
await alertDialog(e as Error)