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