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)