admin/fs: cut/paste buttons, to move files also on mobile
Massimo Melina committed
Oct 31, 2023 at 11:28 UTC
797f30f86b3df385d8be5646c40789e58f91a302
7 files changed
+52
-15
admin/src/FileForm.ts
+26
-4
@@ -1,8 +1,8 @@
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 } from './state'
3
+import { state, useSnapState } from './state'
4
import { createElement as h, ReactElement, ReactNode, useEffect, useMemo, useState } from 'react'
5
-import { Alert, Box, Collapse, FormHelperText, Link, MenuItem, MenuList, } from '@mui/material'
5
+import { Alert, Box, Collapse, FormHelperText, Link, MenuItem, MenuList } from '@mui/material'
6
import {
7
BoolField,
8
DisplayField,
@@ -24,7 +24,8 @@ import _ from 'lodash'
24
import FileField from './FileField'
25
import { alertDialog, toast, useDialogBarColors } from './dialog'
26
import yaml from 'yaml'
27
-import { Check, ContentCopy, Delete, Edit, Save } from '@mui/icons-material'
27
+import { Check, ContentCopy, ContentCut, ContentPaste, Delete, Edit, Save } from '@mui/icons-material'
28
+import { moveVfs } from './VfsTree'
29
30
interface Account { username: string }
31
@@ -62,6 +63,7 @@ export default function FileForm({ file, anyMask, addToBar, statusApi }: FileFor
63
const showAccept = file.accept! > '' || isDir && (file.can_upload ?? file.inherited?.can_upload)
64
const showWebsite = isDir
65
const barColors = useDialogBarColors()
66
+ const { movingFile } = useSnapState()
67
68
const { data, element } = useApiEx<{ list: Account[] }>('get_accounts')
69
if (element || !data)
@@ -82,6 +84,26 @@ export default function FileForm({ file, anyMask, addToBar, statusApi }: FileFor
84
barSx: { gap: 2, width: '100%', ...barColors },
85
stickyBar: true,
86
addToBar: [
87
+ h(IconBtn, {
88
+ icon: ContentCut,
89
+ disabled: isRoot || movingFile === file.id,
90
+ title: "You can also use drag & drop to move items",
91
+ onClick() {
92
+ state.movingFile = file.id
93
+ alertDialog(h(Box, {}, "Now that this is marked for moving, click on the destination folder, and then the paste button ", h(ContentPaste)), 'info')
94
+ },
95
+ }),
96
+ movingFile && h(IconBtn, {
97
+ icon: ContentPaste,
98
+ disabled: file.type !== 'folder'
99
+ || file.id.startsWith(movingFile) // can't move below myself
100
+ || file.id === movingFile.replace(/[^/]+\/?$/,''), // can't move to the same parent
101
+ title: movingFile,
102
+ onClick() {
103
+ state.movingFile = ''
104
+ return moveVfs(movingFile, file.id)
105
+ },
106
+ }),
107
!isRoot && h(IconBtn, {
108
icon: Delete,
109
title: "Delete",
@@ -330,4 +352,4 @@ export async function changeBaseUrl() {
352
}
353
})
354
})
333
-}
\ No newline at end of file
355
+}
admin/src/VfsPage.ts
+8
-2
@@ -18,7 +18,7 @@ let selectOnReload: string[] | undefined
18
19
export default function VfsPage() {
20
const [id2node] = useState(() => new Map<string, VfsNode>())
21
- const { vfs, selectedFiles } = useSnapState()
21
+ const { vfs, selectedFiles, movingFile } = useSnapState()
22
const { data, reload, element } = useApiEx('get_vfs')
23
useMemo(() => vfs || reload(), [vfs, reload])
24
const anyMask = useMemo(() =>
@@ -62,6 +62,11 @@ export default function VfsPage() {
62
)
63
)
64
65
+ // this will take care of closing the dialog, for user's convenience, after "cut" button is pressed
66
+ const [closeDialog, setCloseDialog] = useState(() => _.noop)
67
+ useEffect(() => {
68
+ if (movingFile === selectedFiles[0]?.id) closeDialog()
69
+ }, [movingFile, closeDialog])
70
useEffect(() => {
71
if (isSideBreakpoint || !sideContent) return
72
const { close } = newDialog({
@@ -69,8 +74,9 @@ export default function VfsPage() {
74
Content: () => sideContent,
75
onClose: selectNone,
76
})
77
+ setCloseDialog(() => close)
78
return close
73
- },[isSideBreakpoint, selectedFiles])
79
+ }, [isSideBreakpoint, selectedFiles])
80
81
useEffect(() => {
82
state.vfs = undefined
admin/src/VfsTree.ts
+8
-4
@@ -35,7 +35,7 @@ export default function VfsTree({ id2node }:{ id2node: Map<string, VfsNode> }) {
35
sx: {
36
overflowX: 'auto',
37
maxWidth: ref.current && `calc(100vw - ${16 + ref.current.offsetLeft}px)`, // limit possible horizontal scrolling to this element
38
- '& ul': { borderLeft: '1px dashed #444', marginLeft: '15px' },
38
+ '& ul': { borderLeft: '1px dashed #444', marginLeft: '15px', paddingLeft: '15px' },
39
},
40
onNodeSelect(ev, ids) {
41
if (typeof ids === 'string') return // shut up ts
@@ -72,9 +72,7 @@ export default function VfsTree({ id2node }:{ id2node: Map<string, VfsNode> }) {
72
const from = dragging.current
73
if (!from) return
74
if (await confirmDialog(`Moving ${from} under ${id}`))
75
- apiCall('move_vfs', { from, parent: id }).then(() => {
76
- reloadVfs([ id + from.slice(1 + from.lastIndexOf('/', from.length-2)) ])
77
- }, alertDialog)
75
+ moveVfs(from, id)
76
},
77
sx: {
78
display: 'flex',
@@ -135,3 +133,9 @@ export default function VfsTree({ id2node }:{ id2node: Map<string, VfsNode> }) {
133
}
134
135
}
136
+
137
+export function moveVfs(from: string, to: string) {
138
+ apiCall('move_vfs', { from, parent: to }).then(() => {
139
+ reloadVfs([ to + from.slice(1 + from.lastIndexOf('/', from.length-2)) ])
140
+ }, alertDialog)
141
+}
admin/src/state.ts
+2
@@ -11,6 +11,7 @@ export const state = proxy<{
11
title: string
12
config: Dict
13
vfs: VfsNode | undefined
14
+ movingFile: string
15
selectedFiles: VfsNode[]
16
loginRequired: boolean | number
17
username: string
@@ -19,6 +20,7 @@ export const state = proxy<{
20
title: '',
21
config: {},
22
selectedFiles: [],
23
+ movingFile: '',
24
vfs: undefined,
25
loginRequired: false,
26
username: '',
src/api.vfs.ts
+2
@@ -65,6 +65,8 @@ const apis: ApiHandlers = {
65
return new ApiError(HTTP_NOT_FOUND, 'from not found')
66
if (fromNode === vfs)
67
return new ApiError(HTTP_BAD_REQUEST, 'from is root')
68
+ if (parent.startsWith(from))
69
+ return new ApiError(HTTP_BAD_REQUEST, 'incompatible parent')
70
const parentNode = await urlToNodeOriginal(parent)
71
if (!parentNode)
72
return new ApiError(HTTP_NOT_FOUND, 'parent not found')
src/cross.ts
+2
-2
@@ -1,7 +1,7 @@
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
// all content here is shared between client and server
3
import _ from 'lodash'
4
-import { VfsNode } from './vfs'
4
+import { VfsNodeStored } from './vfs'
5
export * from './cross-const'
6
7
export const REPO_URL = 'https://github.com/rejetto/hfs/'
@@ -77,7 +77,7 @@ export type VfsNodeAdminSend = {
77
byMasks?: VfsPerms
78
inherited?: VfsPerms
79
children?: VfsNodeAdminSend[]
80
-} & Omit<VfsNode, 'type' | 'children'>
80
+} & Omit<VfsNodeStored, 'children'>
81
82
export const PERM_KEYS = typedKeys(defaultPerms)
83
src/vfs.ts
+4
-3
@@ -15,17 +15,18 @@ import { expandUsername, getCurrentUsername } from './perm'
15
16
type Masks = Record<string, VfsNode & { maskOnly?: 'files' | 'folders' }>
17
18
-export interface VfsNode extends VfsPerms {
18
+export interface VfsNodeStored extends VfsPerms {
19
name?: string
20
source?: string
21
children?: VfsNode[]
22
default?: string | false // we could have used empty string to override inherited default, but false is clearer, even reading the yaml, and works well with pickProps(), where empty strings are removed
23
- mime?: string | Record<string,string>
23
+ mime?: string | Record<string, string>
24
rename?: Record<string, string>
25
masks?: Masks // express fields for descendants that are not in the tree
26
accept?: string
27
propagate?: Record<keyof VfsPerms, boolean> // legacy pre-0.47
28
- // fields that are only filled at run-time
28
+}
29
+export interface VfsNode extends VfsNodeStored { // include fields that are only filled at run-time
30
isTemp?: true // this node doesn't belong to the tree and was created by necessity
31
original?: VfsNode // if this is a temp node but reflecting an existing node
32
parent?: VfsNode // available when original is available