admin/fs: drag&drop to reorganize items
Massimo Melina committed
Feb 12, 2023 at 10:27 UTC
a9ddc849c5496e1d2c80d6a6400fcb40ba0b9e09
5 files changed
+56
-12
admin/src/VfsTree.ts
+28
-2
@@ -15,8 +15,10 @@ import {
15
Web
16
} from '@mui/icons-material'
17
import { Box } from '@mui/material'
18
-import { VfsNode, Who } from './VfsPage'
19
-import { iconTooltip, isWindowsDrive, onlyTruthy } from './misc'
18
+import { reloadVfs, VfsNode, Who } from './VfsPage'
19
+import { iconTooltip, isWindowsDrive, onlyTruthy, pathJoin, basename } from './misc'
20
+import { apiCall } from './api'
21
+import { alertDialog, confirmDialog } from './dialog'
22
23
export const FolderIcon = Folder
24
export const FileIcon = InsertDriveFileOutlined
@@ -25,14 +27,17 @@ export default function VfsTree({ id2node }:{ id2node: Map<string, VfsNode> }) {
27
const { vfs, selectedFiles } = useSnapState()
28
const [selected, setSelected] = useState<string[]>(selectedFiles.map(x => x.id)) // try to restore selection after reload
29
const [expanded, setExpanded] = useState(Array.from(id2node.keys()))
30
+ const dragging = useRef<string>()
31
const ref = useRef<HTMLElement>()
32
if (!vfs)
33
return null
34
+ const treeId = 'vfs'
35
return h(TreeView, {
36
ref,
37
expanded,
38
selected,
39
multiSelect: true,
40
+ id: treeId,
41
sx: {
42
overflowX: 'auto',
43
maxWidth: ref.current && `calc(100vw - ${16 + ref.current.offsetLeft}px)`, // limit possible horizontal scrolling to this element
@@ -56,7 +61,28 @@ export default function VfsTree({ id2node }:{ id2node: Map<string, VfsNode> }) {
61
if (folder && !isWindowsDrive(source) && source === name) // we need a way to show that the name we are displaying is a source in this ambiguous case, so we add a redundant ./
62
source = './' + source
63
return h(TreeItem, {
64
+ ref(el: any) { // workaround to permit drag&drop with mui5's tree
65
+ el?.addEventListener('focusin', (e: any) => e.stopImmediatePropagation())
66
+ },
67
label: h(Box, {
68
+ draggable: !isRoot,
69
+ onDragStart() {
70
+ dragging.current = id
71
+ },
72
+ onDragOver(ev) {
73
+ if (!folder) return
74
+ const src = dragging.current
75
+ if (src?.startsWith(id) && !src.slice(id.length + 1).includes('/')) return // src must be not me or my parent
76
+ ev.preventDefault()
77
+ },
78
+ async onDrop() {
79
+ const from = dragging.current
80
+ if (!from) return
81
+ if (await confirmDialog(`Moving ${from} under ${id}`))
82
+ apiCall('move_vfs', { from, parent: id }).then(() => {
83
+ reloadVfs([ pathJoin(id, basename(from)) ])
84
+ }, alertDialog)
85
+ },
86
sx: {
87
display: 'flex',
88
gap: '.5em',
admin/src/misc.ts
+2
-2
@@ -93,11 +93,11 @@ export function typedKeys<T extends {}>(o: T) {
93
return Object.keys(o) as (keyof T)[]
94
}
95
96
-export function dirname(s: string) {
96
+export function basename(s: string) {
97
let i = s.lastIndexOf('/')
98
if (i < 0)
99
i = s.lastIndexOf('\\')
100
- return i < 0 ? '' : s.slice(0, i)
100
+ return i < 0 ? s : s.slice(i)
101
}
102
103
export function isAbsolutePath(s: string) {
src/api.vfs.ts
+23
-4
@@ -61,6 +61,25 @@ const apis: ApiHandlers = {
61
}
62
},
63
64
+ async move_vfs({ from, parent }) {
65
+ if (from <= '/' || !parent)
66
+ return new ApiError(HTTP_BAD_REQUEST)
67
+ const fromNode = await urlToNodeOriginal(from)
68
+ if (!fromNode)
69
+ return new ApiError(HTTP_NOT_FOUND, 'from not found')
70
+ const parentNode = await urlToNodeOriginal(parent)
71
+ if (!parentNode)
72
+ return new ApiError(HTTP_NOT_FOUND, 'parent not found')
73
+ const name = getNodeName(fromNode)
74
+ if (parentNode.children?.find(x => name === getNodeName(x)))
75
+ return new ApiError(HTTP_CONFLICT, 'item with same name already present in destination')
76
+ const oldParent = await urlToNodeOriginal(dirname(from))
77
+ _.pull(oldParent!.children!, fromNode)
78
+ ;(parentNode.children ||= []).push(fromNode)
79
+ await saveVfs()
80
+ return {}
81
+ },
82
+
83
async set_vfs({ uri, props }) {
84
const n = await urlToNodeOriginal(uri)
85
if (!n)
@@ -76,12 +95,12 @@ const apis: ApiHandlers = {
95
return n
96
},
97
79
- async add_vfs({ under, source, name }) {
80
- const n = under ? await urlToNodeOriginal(under) : vfs
98
+ async add_vfs({ parent, source, name }) {
99
+ const n = parent ? await urlToNodeOriginal(parent) : vfs
100
if (!n)
82
- return new ApiError(HTTP_NOT_FOUND, 'invalid under')
101
+ return new ApiError(HTTP_NOT_FOUND, 'invalid parent')
102
if (n.isTemp || !await nodeIsDirectory(n))
84
- return new ApiError(HTTP_NOT_ACCEPTABLE, 'invalid under')
103
+ return new ApiError(HTTP_NOT_ACCEPTABLE, 'invalid parent')
104
if (isWindowsDrive(source))
105
source += '\\' // slash must be included, otherwise it will refer to the cwd of that drive
106
const a = n.children || (n.children = [])
src/middlewares.ts
+3
-3
@@ -8,7 +8,7 @@ import {
8
BUILD_TIMESTAMP,
9
DEV,
10
SESSION_DURATION,
11
- HTTP_FORBIDDEN, HTTP_NOT_FOUND,
11
+ HTTP_FORBIDDEN, HTTP_NOT_FOUND, HTTP_FOOL,
12
} from './const'
13
import { FRONTEND_URI } from './const'
14
import { cantReadStatusCode, hasPermission, nodeIsDirectory, urlToNode, vfs } from './vfs'
@@ -155,14 +155,14 @@ export const someSecurity: Koa.Middleware = async (ctx, next) => {
155
if (DEV && proxy && [process.env.FRONTEND_PROXY, process.env.ADMIN_PROXY].includes(ctx.get('X-Forwarded-port')))
156
proxy = ''
157
if (dirTraversal(decodeURI(ctx.path)))
158
- return ctx.status = 418
158
+ return ctx.status = HTTP_FOOL
159
if (applyBlock(ctx.socket, ctx.ip))
160
return
161
proxyDetected ||= proxy > ''
162
ctx.state.proxiedFor = proxy
163
}
164
catch {
165
- return ctx.status = 418
165
+ return ctx.status = HTTP_FOOL
166
}
167
return next()
168
}
todo.md
-1
@@ -21,7 +21,6 @@
21
- admin/fs: sort items
22
- admin/fs: render virtual folders differently
23
- admin/config: hide advanced settings
24
-- admin/fs: drag&drop to move items around
24
- admin/fs: support insert/delete key
25
- admin/fs: button "copy url to clipboard"
26
- admin/monitor: show some info on what folder is browsing