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