admin/fs: expand/collapse button
Massimo Melina committed
Oct 31, 2024 at 11:03 UTC
af88b88c89eade3d625d683c5d3e0015041fa363
4 files changed
+88
-75
admin/src/VfsMenuBar.ts
+3
-2
@@ -1,6 +1,6 @@
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 } from 'react'
3
+import { createElement as h, ReactNode } from 'react'
4
import { Alert, Box, List, ListItem, ListItemIcon, ListItemText } from '@mui/material'
5
import { Storage } from '@mui/icons-material'
6
import { osIcon } from './LogsPage'
@@ -13,7 +13,7 @@ import { alertDialog, promptDialog } from './dialog'
13
import { formatDiskSpace } from './FilePicker'
14
import { getDiskSpaces } from '../../src/util-os'
15
16
-export default function VfsMenuBar({ statusApi }: { statusApi: ApiObject }) {
16
+export default function VfsMenuBar({ statusApi, add }: { add: ReactNode, statusApi: ApiObject }) {
17
return h(Flex, {
18
zIndex: 2,
19
backgroundColor: 'background.paper',
@@ -36,6 +36,7 @@ export default function VfsMenuBar({ statusApi }: { statusApi: ApiObject }) {
36
.then(() => false), // no success-animation for IconBtn
37
alertDialog)
38
}),
39
+ add,
40
h(SystemIntegrationButton, statusApi.data)
41
)
42
}
admin/src/VfsPage.ts
+1
-6
@@ -4,7 +4,6 @@ import { createElement as h, Fragment, useEffect, useMemo, useRef, useState } fr
4
import { apiCall, useApiEx } from './api'
5
import { Alert, Box, Button, Card, CardContent, Grid, Link, List, ListItem, ListItemText, Typography } from '@mui/material'
6
import { state, useSnapState } from './state'
7
-import VfsMenuBar from './VfsMenuBar'
7
import VfsTree, { vfsNodeIcon } from './VfsTree'
8
import { newDialog, onlyTruthy, prefix, VfsNodeAdminSend } from './misc'
9
import { Flex, useBreakpoint } from './mui'
@@ -145,11 +144,7 @@ export default function VfsPage() {
144
),
145
h(Grid, { container: true, rowSpacing: 1, columnSpacing: 2, top: 0, flex: '1 1 auto', height: 0 },
146
h(Grid, { item: true, xs: 12, [sideBreakpoint]: 6, lg: 6, xl: 5, ...scrollProps },
148
- h(Flex, { mb: 1, flexWrap: 'wrap', gap: [0, 2] },
149
- h(Typography, { variant: 'h6' }, "Virtual File System"),
150
- h(VfsMenuBar, { statusApi }),
151
- ),
152
- vfs && h(VfsTree, { id2node, statusApi }) ),
147
+ h(VfsTree, { id2node, statusApi }) ),
148
isSideBreakpoint && sideContent && h(Grid, { item: true, [sideBreakpoint]: true, maxWidth: '100%', ...scrollProps },
149
h(Card, { sx: { overflow: 'initial' } }, // overflow is incompatible with stickyBar
150
h(CardContent, {}, sideContent)) )
admin/src/VfsTree.ts
+75
-67
@@ -1,14 +1,17 @@
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, useEffect, useRef, useState } from 'react'
4
+import { createElement as h, ReactElement, useCallback, useEffect, 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 } from '@mui/icons-material'
8
-import { Box } from '@mui/material'
6
+import {
7
+ ChevronRight, ExpandMore, TheaterComedy, Folder, Home, Link, InsertDriveFileOutlined, Lock,
8
+ RemoveRedEye, Web, Upload, Cloud, Delete, HighlightOff, UnfoldMore, UnfoldLess
9
+} from '@mui/icons-material'
10
+import { Box, Typography } from '@mui/material'
11
import { reloadVfs, VfsNode } from './VfsPage'
10
-import { onlyTruthy, Who, with_ } from './misc'
11
-import { iconTooltip } from './mui'
12
+import { onlyTruthy, useEffectOnce, Who, with_ } from './misc'
13
+import { Flex, iconTooltip, useToggleButton } from './mui'
14
+import VfsMenuBar from './VfsMenuBar'
15
import { apiCall, ApiObject } from './api'
16
import { alertDialog, confirmDialog } from './dialog'
17
import _ from 'lodash'
@@ -18,39 +21,9 @@ export const FileIcon = InsertDriveFileOutlined
21
22
export default function VfsTree({ id2node, statusApi }:{ id2node: Map<string, VfsNode>, statusApi: ApiObject }) {
23
const { vfs, selectedFiles } = useSnapState()
21
- const [expanded, setExpanded] = useState(Array.from(id2node.keys()))
24
+ const [expanded, setExpanded] = useState<string[]>([])
25
const dragging = useRef<string>()
23
- const ref = useRef<HTMLUListElement>()
24
- if (!vfs)
25
- return null
26
- // be sure selected element is visible
27
- const treeId = 'vfs'
28
- const first = selectedFiles[0]
29
- useEffect(() => document.getElementById(`${treeId}-${first?.id}`)?.scrollIntoView({ block: 'center', behavior: 'instant' as any }),
30
- [first])
31
- return h(TreeView, {
32
- // @ts-ignore the type declared on the lib doesn't seem to be compatible with useRef()
33
- ref,
34
- expanded,
35
- selected: selectedFiles.map(x => x.id),
36
- multiSelect: true,
37
- id: treeId,
38
- sx: {
39
- overflowX: 'auto',
40
- maxWidth: ref.current && `calc(100vw - ${16 + ref.current.offsetLeft}px)`, // limit possible horizontal scrolling to this element
41
- '& ul': { borderLeft: '1px dashed #444', marginLeft: '15px', paddingLeft: '15px' },
42
- },
43
- onNodeSelect(ev, ids) {
44
- if (typeof ids === 'string') return // shut up ts
45
- state.selectedFiles = onlyTruthy(ids.map(id => id2node.get(id)))
46
- }
47
- }, recur(vfs as Readonly<VfsNode>))
48
-
49
- function isRestricted(who: Who | undefined) {
50
- return who !== undefined && who !== true
51
- }
52
-
53
- function recur(node: Readonly<VfsNode>): ReactElement {
26
+ const Branch = useCallback(function({ node }: { node: Readonly<VfsNode> }): ReactElement {
27
let { id, name, isRoot } = node
28
const folder = node.type === 'folder'
29
const ref = useRef<HTMLLIElement | null>()
@@ -61,36 +34,37 @@ export default function VfsTree({ id2node, statusApi }:{ id2node: Map<string, Vf
34
el?.addEventListener('focusin', (e: any) => e.stopImmediatePropagation())
35
ref.current = el
36
},
64
- label: h(Box, {
65
- draggable: !isRoot,
66
- onDragStart() {
67
- dragging.current = id
68
- },
69
- onDragOver(ev) {
70
- if (!folder) return
71
- const src = dragging.current
72
- if (src?.startsWith(id) && !src.slice(id.length + 1, -1).includes('/')) return // dragging node (src) must not be direct child of destination (id)
73
- ev.preventDefault()
37
+ label:
38
+ h(Box, {
39
+ draggable: !isRoot,
40
+ onDragStart() {
41
+ dragging.current = id
42
+ },
43
+ onDragOver(ev) {
44
+ if (!folder) return
45
+ const src = dragging.current
46
+ if (src?.startsWith(id) && !src.slice(id.length + 1, -1).includes('/')) return // dragging node (src) must not be direct child of destination (id)
47
+ ev.preventDefault()
48
+ },
49
+ async onDrop() {
50
+ const from = dragging.current
51
+ if (!from) return
52
+ if (await confirmDialog(`Moving ${from} under ${id}`))
53
+ moveVfs(from, id)
54
+ },
55
+ sx: {
56
+ display: 'flex',
57
+ gap: '.5em',
58
+ minHeight: '1.8em', pt: '.2em', // comfy, make single-line ones taller
59
+ }
60
},
75
- async onDrop() {
76
- const from = dragging.current
77
- if (!from) return
78
- if (await confirmDialog(`Moving ${from} under ${id}`))
79
- moveVfs(from, id)
80
- },
81
- sx: {
82
- display: 'flex',
83
- gap: '.5em',
84
- minHeight: '1.8em', pt: '.2em', // comfy, make single-line ones taller
85
- }
86
- },
61
h(Box, { display: 'flex', flex: 0, },
62
vfsNodeIcon(node),
63
// attributes
64
h(Box, { sx: {
91
- flex: 0, ml: '2px', my: '2px', '&>*': { fontSize: '87%', opacity: .6, mt: '-2px' },
92
- display: 'grid', gridAutoFlow: 'column', gridTemplateRows: 'auto auto', height: '1em',
93
- } },
65
+ flex: 0, ml: '2px', my: '2px', '&>*': { fontSize: '87%', opacity: .6, mt: '-2px' },
66
+ display: 'grid', gridAutoFlow: 'column', gridTemplateRows: 'auto auto', height: '1em',
67
+ } },
68
node.can_delete !== undefined && iconTooltip(Delete, "Delete permission"),
69
node.can_upload !== undefined && iconTooltip(Upload, "Upload permission"),
70
!isRoot && !node.source && !node.url && iconTooltip(Cloud, "Virtual (no source)"),
@@ -117,23 +91,57 @@ export default function VfsTree({ id2node, statusApi }:{ id2node: Map<string, Vf
91
key: name,
92
collapseIcon: h(ExpandMore, {
93
onClick(ev) {
120
- setExpanded( expanded.filter(x => x !== id) )
94
+ setExpanded(was => was?.filter(x => x !== id) )
95
ev.preventDefault()
96
ev.stopPropagation()
97
}
98
}),
99
expandIcon: h(ChevronRight, {
100
onClick(ev) {
127
- setExpanded( [...expanded, id] )
101
+ setExpanded(was => [...was!, id] )
102
ev.preventDefault()
103
ev.stopPropagation()
104
}
105
}),
106
nodeId: id
107
}, isRoot && !node.children?.length ? h(TreeItem, { nodeId: '?', label: h('i', {}, "nothing here") })
134
- : node.children?.map(recur))
135
- }
108
+ : node.children?.map(x => h(Branch, { node: x})) )
109
110
+ function isRestricted(who: Who | undefined) {
111
+ return who !== undefined && who !== true
112
+ }
113
+ }, [setExpanded])
114
+ const ref = useRef<HTMLUListElement>()
115
+ const [expandAll, toggleBtn] = useToggleButton("Collapse all", "Expand all", exp => ({ icon: exp ? UnfoldLess : UnfoldMore, sx: {} }))
116
+ useEffectOnce(() => setExpanded(expandAll ? Array.from(id2node.keys()) : ['/']), [expandAll])
117
+ // be sure selected element is visible
118
+ const treeId = 'vfs'
119
+ const first = selectedFiles[0]
120
+ useEffect(() => document.getElementById(`${treeId}-${first?.id}`)?.scrollIntoView({ block: 'center', behavior: 'instant' as any }),
121
+ [first])
122
+ return h(Flex, { flexDirection: 'column', alignItems: 'stretch' },
123
+ h(Flex, { mb: 1, flexWrap: 'wrap', gap: [0, 2] },
124
+ h(Typography, { variant: 'h6' }, "Virtual File System"),
125
+ h(VfsMenuBar, { statusApi, add: toggleBtn }),
126
+ ),
127
+ vfs && h(TreeView, {
128
+ // @ts-ignore the type declared on the lib doesn't seem to be compatible with useRef()
129
+ ref,
130
+ expanded,
131
+ selected: selectedFiles.map(x => x.id),
132
+ multiSelect: true,
133
+ id: treeId,
134
+ sx: {
135
+ overflowX: 'auto',
136
+ maxWidth: ref.current && `calc(100vw - ${16 + ref.current.offsetLeft}px)`, // limit possible horizontal scrolling to this element
137
+ '& ul': { borderLeft: '1px dashed #444', marginLeft: '15px', paddingLeft: '15px' },
138
+ },
139
+ onNodeSelect(ev, ids) {
140
+ if (typeof ids === 'string') return // shut up ts
141
+ state.selectedFiles = onlyTruthy(ids.map(id => id2node.get(id)))
142
+ }
143
+ }, h(Branch, { node: vfs as Readonly<VfsNode> }))
144
+ )
145
}
146
147
export function moveVfs(from: string, to: string) {
shared/react.ts
+9
@@ -137,6 +137,15 @@ export function useGetSize() {
137
}), [size, ref, propsRef])
138
}
139
140
+export function useEffectOnce(cb: Callback, deps: any[]) {
141
+ const state = useRef<any>()
142
+ useEffect(() => {
143
+ if (_.isEqual(deps, state.current)) return
144
+ state.current = deps
145
+ cb(...deps)
146
+ }, deps)
147
+}
148
+
149
type FunctionRef<T=HTMLElement> = (instance: (T | null)) => void
150
export function passRef<T=any>(el: T, ...refs: (MutableRefObject<T> | FunctionRef<T>)[]) {
151
for (const ref of refs)