admin/shared: on desktop, moved the add button in a more visible place
Massimo Melina committed
Sep 28, 2025 at 18:51 UTC
54f1ebdcaa73b8590608a80ff01e040792502ec4
6 files changed
+75
-66
admin/src/FileForm.ts
+5
-13
@@ -20,12 +20,11 @@ import FileField from './FileField'
20
import { alertDialog, toast, useDialogBarColors } from './dialog'
21
import yaml from 'yaml'
22
import {
23
- Add, Check, ContentCopy, ContentCut, ContentPaste, Delete, Edit, QrCode2, Save, RestartAlt
23
+ Check, ContentCopy, ContentCut, ContentPaste, Delete, Edit, QrCode2, Save, RestartAlt
24
} from '@mui/icons-material'
25
import { moveVfs } from './VfsTree'
26
import QrCreator from 'qr-creator';
27
-import MenuButton from './MenuButton'
28
-import addFiles, { addLink, addVirtual } from './addFiles'
27
+import { AddVfsBtn } from './VfsMenuBar'
28
import { SYS_ICONS } from '@hfs/frontend/src/sysIcons'
29
import { hIcon } from '@hfs/frontend/src/misc'
30
import { TextEditorField } from './TextEditor'
@@ -39,8 +38,9 @@ interface FileFormProps {
38
statusApi: UseApi
39
accounts: Account[]
40
saved: Callback
41
+ isSideBreakpoint: boolean
42
}
43
-export default function FileForm({ file, addToBar, statusApi, accounts, saved }: FileFormProps) {
43
+export default function FileForm({ file, addToBar, statusApi, accounts, saved, isSideBreakpoint }: FileFormProps) {
44
const { parent, children, isRoot, byMasks, ...rest } = file
45
const [values, setValues] = useState(rest)
46
useEffect(() => {
@@ -95,15 +95,7 @@ export default function FileForm({ file, addToBar, statusApi, accounts, saved }:
95
barSx: { gap: 2, width: '100%', ...barColors },
96
stickyBar: true,
97
addToBar: [
98
- h(MenuButton, {
99
- variant: 'outlined',
100
- startIcon: h(Add),
101
- items: [
102
- { children: "from disk", onClick: addFiles },
103
- { children: "virtual folder", onClick: addVirtual },
104
- { children: "web-link", onClick: addLink },
105
- ]
106
- }, "Add"),
98
+ isDir && !isSideBreakpoint && h(AddVfsBtn, { variant: 'outlined' }, "Add"),
99
h(IconBtn, {
100
icon: ContentCut,
101
disabled: isRoot || movingFile === file.id,
admin/src/MenuButton.ts
+4
-5
@@ -1,18 +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 { MouseEvent, createElement as h, Fragment, useCallback, useId, useState } from 'react'
4
-import { Button, ButtonProps, Menu, MenuItem } from '@mui/material'
4
+import { Menu, MenuItem } from '@mui/material'
5
+import { Btn, BtnProps } from './mui'
6
6
-interface Props extends ButtonProps { items: any[] }
7
-
8
-export default function MenuButton({ items, ...rest }: Props) {
7
+export default function MenuButton({ items, ...rest }: BtnProps & { items: any[] }) {
8
const [anchorEl, setAnchorEl] = useState<HTMLElement>()
9
const open = Boolean(anchorEl)
10
const onClose = useCallback(() => setAnchorEl(undefined), [])
11
const id = useId()
12
const menuId = useId()
13
return h(Fragment, {},
15
- h(Button, {
14
+ h(Btn, {
15
id,
16
'aria-controls': open ? menuId : undefined,
17
'aria-haspopup': true,
admin/src/VfsMenuBar.ts
+19
-2
@@ -1,8 +1,10 @@
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, ReactNode } from 'react'
4
-import { Alert, Box, List, ListItem, ListItemIcon, ListItemText } from '@mui/material'
5
-import { Storage } from '@mui/icons-material'
4
+import { Alert, Box, ButtonProps, List, ListItem, ListItemIcon, ListItemText } from '@mui/material'
5
+import { Add, Storage } from '@mui/icons-material'
6
+import addFiles, { addLink, addVirtual } from './addFiles'
7
+import MenuButton from './MenuButton'
8
import { osIcon } from './LogsPage'
9
import { reloadVfs } from './VfsPage'
10
import { prefix } from './misc'
@@ -16,9 +18,11 @@ import { getDiskSpaces } from '../../src/util-os'
18
export default function VfsMenuBar({ statusApi, add }: { add: ReactNode, statusApi: ApiObject }) {
19
return h(Flex, {
20
zIndex: 2,
21
+ gap: 1,
22
backgroundColor: 'background.paper',
23
width: 'fit-content',
24
},
25
+ h(AddVfsBtn),
26
reloadBtn(() => reloadVfs()),
27
h(Btn, {
28
icon: Storage,
@@ -40,6 +44,19 @@ export default function VfsMenuBar({ statusApi, add }: { add: ReactNode, statusA
44
)
45
}
46
47
+export function AddVfsBtn(props: Partial<ButtonProps>) {
48
+ return h(MenuButton, {
49
+ variant: 'contained',
50
+ icon: Add,
51
+ ...props,
52
+ items: [
53
+ { children: "virtual folder", onClick: addVirtual },
54
+ { children: "file or folder from disk", onClick: addFiles },
55
+ { children: "web-link", onClick: addLink },
56
+ ]
57
+ })
58
+}
59
+
60
function SystemIntegrationButton({ platform }: { platform: string | undefined }) {
61
const isWindows = platform === 'win32'
62
const { data: integrated, reload } = useApi(isWindows && 'windows_integrated')
admin/src/VfsPage.ts
+9
-9
@@ -9,10 +9,9 @@ import { CFG, matches, newDialog, normalizeHost, onlyTruthy, pathEncode, prefix,
9
import { Flex, useBreakpoint } from './mui'
10
import { reactJoin } from '@hfs/shared'
11
import _ from 'lodash'
12
-import { AlertProps } from '@mui/material/Alert/Alert'
12
import { Account } from './AccountsPage'
13
import FileForm from './FileForm'
15
-import { Delete } from '@mui/icons-material'
14
+import { Add, Delete } from '@mui/icons-material'
15
import { alertDialog, confirmDialog } from './dialog'
16
import { PageProps } from './App'
17
@@ -51,27 +50,28 @@ export default function VfsPage({ setTitleSide }: PageProps) {
50
closeDialogRef.current()
51
}, [movingFile])
52
54
- const anythingShared = !data?.root?.children?.length && !data?.root?.source
55
- const alert: AlertProps | false = useMemo(() => anythingShared ? {
53
+ const nothingShared = !data?.root?.children?.length && !data?.root?.source
54
+ const hintElement = useMemo(() => nothingShared ? h(Alert, {
55
severity: 'warning',
57
- children: "Add something to your shared files — click Add"
58
- } : urls?.length > 0 && {
56
+ children: h(Fragment, {}, "Add something to your virtual file system — click the ", h(Add), "button, or set a source for the Home folder"),
57
+ }) : urls?.length > 0 && h(Alert, {
58
severity: 'info',
59
children: [
60
"Your shared files can be browsed from ",
61
h('span', { className: 'hideInTests', key: 0 },
62
reactJoin(" or ", urls.slice(0,3).map(href => h(Link, { href, target: 'frontend' }, href))) )
63
]
65
- }, [anythingShared, urls])
64
+ }), [nothingShared, urls])
65
66
setTitleSide(useMemo(() => h(Box, { sx: { display: { xs: 'none', md: 'block' } } },
67
h(Alert, { severity: 'info' }, "If you rename or delete here, it's virtual, and only affects what is presented to the users"),
69
- alert && h(Alert, alert),
70
- ), [alert]))
68
+ hintElement,
69
+ ), [hintElement]))
70
71
const sideContent = accountsApi.element || !vfs ? null
72
: single ? h(FileForm, {
73
key: single.id,
74
+ isSideBreakpoint,
75
addToBar: isSideBreakpoint && h(Box, { flex: 1, textAlign: 'right', mr: 1, color: '#8883' }, vfsNodeIcon(single)),
76
statusApi,
77
saved: () => closeDialogRef.current(),
admin/src/VfsTree.ts
+5
-5
@@ -109,22 +109,22 @@ export default function VfsTree({ id2node, statusApi }:{ id2node: Map<string, Vf
109
const [expandAll, toggleBtn] = useToggleButton("Collapse all", "Expand all", exp => ({
110
icon: exp ? UnfoldLess : UnfoldMore,
111
sx: { rotate: exp ? 0 : '180deg' },
112
- }))
112
+ }), expanded.length === id2node.size)
113
useEffectOnce(() => { // this is also resetting the state at each mount
114
state.expanded = expandAll ? Array.from(id2node.keys())
115
- : state.expanded.length ? state.expanded // keep previous state
115
+ : state.expanded.length ? state.expanded // keep the previous state
116
: ['/', ...vfs?.children?.length === 1 ? [vfs.children[0].id] : []] // in case there's only one child, expand that too
117
- }, [expandAll, Boolean(vfs)]) // vfs is undefined on first render, we want to be called again as soon as it is loaded first time and not at reloads
117
+ }, [expandAll, Boolean(vfs)]) // vfs is undefined on the first render, we want to be called again as soon as it is loaded first time and not at reloads
118
useEffect(() => {
119
state.expanded = _.uniq(state.expanded.concat(state.selectedFiles.map(x => x.parent?.id || '')))
120
}, [state.vfs])
121
- // be sure selected element is visible
121
+ // be sure the selected element is visible
122
const treeId = 'vfs'
123
const first = selectedFiles[0]
124
useEffect(() => document.getElementById(`${treeId}-${first?.id}`)?.scrollIntoView({ block: 'center', behavior: 'instant' as any }),
125
[first])
126
return h(Flex, { flexDirection: 'column', alignItems: 'stretch', flex: 1 },
127
- h(Flex, { mb: 1, flexWrap: 'wrap', gap: [0, 2] },
127
+ h(Flex, { mb: 1, flexWrap: 'wrap', gap: [1, 2] },
128
h(Typography, { variant: 'h6' }, "Virtual File System"),
129
h(VfsMenuBar, { statusApi, add: toggleBtn }),
130
),
e2e/frontend.spec.ts
+33
-32
@@ -299,38 +299,39 @@ test('anew', async ({ page, browserName }) => {
299
await page.getByRole('button', { name: 'Options' }).click();
300
const page1Promise = page.waitForEvent('popup');
301
await page.getByRole('button', { name: 'Admin-panel' }).click();
302
- const page1 = await page1Promise;
303
- await page1.getByRole('link', { name: 'add some' }).click();
304
- await page1.getByRole('button', { name: 'Add' }).click();
305
- await page1.getByRole('menuitem', { name: 'from disk' }).click();
306
- await page1.getByRole('textbox', { name: /Filter results/ }).fill('data');
307
- await expect(page1.getByText('Filter results (1/')).toBeVisible();
308
- await page1.getByRole('checkbox').check();
309
- await page1.getByText('data.kv').click();
310
- await page1.getByRole('button', { name: 'Add' }).click();
311
- await page1.getByRole('menuitem', { name: 'from disk' }).click();
312
- await page1.getByRole('button', { name: 'Select this folder' }).click();
313
- await page1.getByRole('button', { name: 'Add' }).click();
314
- await page1.getByRole('menuitem', { name: 'virtual folder' }).click();
315
- await page1.getByRole('textbox').fill('folder1');
316
- await page1.getByRole('textbox').press('Enter');
317
- await page1.getByRole('dialog').locator('div').nth(1).click();
318
- await page1.locator('.MuiDialog-container').press('Escape')
319
- await page1.locator('#vfs').click();
320
- await page1.getByText('folder1', { exact: true }).click();
321
- await page1.getByRole('treeitem', { name: 'folder1' }).locator('path').first().click();
322
- await page1.getByText('folder1', { exact: true }).click();
323
- await page1.getByRole('button', { name: 'Cut' }).click();
324
- await page1.locator('div').filter({ hasText: 'InfoNow that this is marked' }).nth(1).click();
325
- await page1.getByRole('button', { name: '(Close)' }).click();
326
- await page1.getByText('Home folder').click();
327
- await page1.getByRole('button', { name: '(/work2/folder1/)' }).click();
328
- await page1.getByText('/Users/rejetto/code/hfs/tests/work2/data.kv').click();
329
- await page1.getByRole('button', { name: 'Cut' }).click();
330
- await page1.getByRole('button', { name: '(Close)' }).click();
331
- await page1.getByText('folder1').click();
332
- await page1.getByRole('button', { name: '(/data.kv)' }).click();
333
- await page1.locator('[id="vfs-/"] div').filter({ hasText: /work2\/data\.kv$/ }).nth(4).click();
302
+ const adminPage = await page1Promise;
303
+ await adminPage.getByRole('link', { name: 'add some' }).click();
304
+ const addBtn = adminPage.getByRole('button').nth(1)
305
+ await addBtn.click();
306
+ await adminPage.getByRole('menuitem', { name: 'from disk' }).click();
307
+ await adminPage.getByRole('textbox', { name: /Filter results/ }).fill('data');
308
+ await expect(adminPage.getByText('Filter results (1/')).toBeVisible();
309
+ await adminPage.getByRole('checkbox').check();
310
+ await adminPage.getByText('data.kv').click();
311
+ await addBtn.click();
312
+ await adminPage.getByRole('menuitem', { name: 'from disk' }).click();
313
+ await adminPage.getByRole('button', { name: 'Select this folder' }).click();
314
+ await addBtn.click();
315
+ await adminPage.getByRole('menuitem', { name: 'virtual folder' }).click();
316
+ await adminPage.getByRole('textbox').fill('folder1');
317
+ await adminPage.getByRole('textbox').press('Enter');
318
+ await adminPage.getByRole('dialog').locator('div').nth(1).click();
319
+ await adminPage.locator('.MuiDialog-container').press('Escape')
320
+ await adminPage.locator('#vfs').click();
321
+ await adminPage.getByText('folder1', { exact: true }).click();
322
+ await adminPage.getByRole('treeitem', { name: 'folder1' }).locator('path').first().click();
323
+ await adminPage.getByText('folder1', { exact: true }).click();
324
+ await adminPage.getByRole('button', { name: 'Cut' }).click();
325
+ await adminPage.locator('div').filter({ hasText: 'InfoNow that this is marked' }).nth(1).click();
326
+ await adminPage.getByRole('button', { name: '(Close)' }).click();
327
+ await adminPage.getByText('Home folder').click();
328
+ await adminPage.getByRole('button', { name: '(/work2/folder1/)' }).click(); // paste button
329
+ await adminPage.getByText('data.kv').click();
330
+ await adminPage.getByRole('button', { name: 'Cut' }).click();
331
+ await adminPage.getByRole('button', { name: '(Close)' }).click();
332
+ await adminPage.getByText('folder1').click();
333
+ await adminPage.getByRole('button', { name: '(/data.kv)' }).click();
334
+ await adminPage.locator('[id="vfs-/"] div').filter({ hasText: /work2\/data\.kv$/ }).nth(4).click();
335
await page.getByRole('button', { name: 'Close' }).click();
336
await page.getByRole('link', { name: 'home' }).click();
337
await page.getByRole('link', { name: 'Reload' }).click();