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();