cut/move files #203
Massimo Melina committed
Dec 3, 2023 at 18:15 UTC
dbc031a268120a4adfb6ad4f0062937a6e5ae31b
16 files changed
+164
-13
admin/src/FileForm.ts
+1
-1
@@ -167,7 +167,7 @@ export default function FileForm({ file, anyMask, addToBar, statusApi }: FileFor
167
perm('can_see', "If you can't see, you may still download with a direct link"),
168
perm('can_archive', "Should this be included when user downloads as ZIP", { lg: isDir ? true : 12 }),
169
perm('can_list', "Permission to see content of folders", { contentText: "subfolders" }),
170
- perm('can_delete', [needSourceWarning, "Those who can delete can also rename"]),
170
+ perm('can_delete', [needSourceWarning, "Those who can delete can also rename and cut/move"]),
171
perm('can_upload', needSourceWarning, { contentText: "subfolders" }),
172
showSize && { k: 'size', comp: DisplayField, lg: 4, toField: formatBytes },
173
showTimestamps && { k: 'ctime', comp: DisplayField, md: 6, lg: showSize && 4, label: "Created", toField: formatTimestamp },
frontend/fontello-config.json
+18
@@ -233,6 +233,24 @@
233
"css": "link",
234
"code": 61582,
235
"src": "fontawesome"
236
+ },
237
+ {
238
+ "uid": "b429436ec5a518c78479d44ef18dbd60",
239
+ "css": "paste",
240
+ "code": 61674,
241
+ "src": "fontawesome"
242
+ },
243
+ {
244
+ "uid": "8772331a9fec983cdb5d72902a6f9e0e",
245
+ "css": "cut",
246
+ "code": 59417,
247
+ "src": "fontawesome"
248
+ },
249
+ {
250
+ "uid": "c8585e1e5b0467f28b70bce765d5840c",
251
+ "css": "copy",
252
+ "code": 61637,
253
+ "src": "fontawesome"
254
}
255
]
256
}
\ No newline at end of file
frontend/public/fontello.css
+4
-1
@@ -1,6 +1,6 @@
1
@font-face {
2
font-family: 'fontello';
3
- src: url('fontello.woff2?11723613') format('woff2');
3
+ src: url('fontello.woff2?39711600') format('woff2');
4
font-weight: normal;
5
font-style: normal;
6
}
@@ -64,6 +64,7 @@
64
.fa-left:before { content: '\e816'; } /* '' */
65
.fa-lock:before { content: '\e817'; } /* '' */
66
.fa-reload:before { content: '\e818'; } /* '' */
67
+.fa-cut:before { content: '\e819'; } /* '' */
68
.fa-spin6:before { content: '\e839'; } /* '' */
69
.fa-crown:before { content: '\e844'; } /* '' */
70
.fa-download:before { content: '\f02e'; } /* '' */
@@ -71,8 +72,10 @@
72
.fa-link:before { content: '\f08e'; } /* '' */
73
.fa-filter:before { content: '\f0b0'; } /* '' */
74
.fa-zoom:before { content: '\f0b2'; } /* '' */
75
+.copy:before { content: '\f0c5'; } /* '' */
76
.fa-menu:before { content: '\f0c9'; } /* '' */
77
.fa-comment:before { content: '\f0e5'; } /* '' */
78
+.fa-paste:before { content: '\f0ea'; } /* '' */
79
.fa-fullscreen:before { content: '\f108'; } /* '' */
80
.fa-quote:before { content: '\f10d'; } /* '' */
81
.fa-unlink:before { content: '\f127'; } /* '' */
frontend/public/fontello.woff2
Binary files a/frontend/public/fontello.woff2 and b/frontend/public/fontello.woff2 differ
frontend/src/BrowseFiles.ts
+2
@@ -14,6 +14,7 @@ import { acceptDropFiles, enqueue } from './upload'
14
import _ from 'lodash'
15
import { t, useI18N } from './i18n'
16
import { openFileMenu } from './fileMenu'
17
+import { ClipBar } from './clip'
18
19
export const MISSING_PERM = "Missing permission"
20
@@ -43,6 +44,7 @@ export function BrowseFiles() {
44
h(CustomCode, { name: 'afterHeader' }),
45
error ? h(ErrorMsg, { err: error }) : h(FilesList),
46
h(CustomCode, { name: 'afterList' }),
47
+ h(ClipBar),
48
)
49
)
50
}
frontend/src/clip.ts
new
+67
@@ -0,0 +1,67 @@
1
+import { createElement as h, Fragment } from 'react'
2
+import { DirList, state, useSnapState } from './state'
3
+import { MenuButton } from './menu'
4
+import { t, useI18N } from './i18n'
5
+import { alertDialog } from './dialog'
6
+import { useNavigate } from 'react-router-dom'
7
+import { dirname, HTTP_MESSAGES, xlate } from '../../src/cross'
8
+import { apiCall } from '@hfs/shared/api'
9
+import { reloadList, usePath } from './useFetchList'
10
+import _ from 'lodash'
11
+
12
+export function ClipBar() {
13
+ const { clip, props } = useSnapState()
14
+ const { t } = useI18N()
15
+ const go = useNavigate()
16
+ const here = usePath()
17
+ if (!clip.length)
18
+ return null
19
+ const there = dirname(clip[0].uri) + '/'
20
+ return h('div', { id: 'clipBar' },
21
+ h(MenuButton, { label: t`Cancel clipboard`, icon: 'close', onClick: cancel }),
22
+ h(MenuButton, { label: t('to_clipboard_source', "To folder"), icon: 'parent', onClick: goBack, disabled: here === there }),
23
+ h(MenuButton, { label: t`Paste`, icon: 'paste', onClick: paste, disabled: here === there || !props?.can_upload }),
24
+ h('div', { onClick: show, style: { flex: 1 } },
25
+ clip.length === 1 ? clip[0].name : t('n_items', { n: clip.length }, "{n,plural, one{# item} other{# items}}") ),
26
+ )
27
+
28
+ function cancel() {
29
+ cut([])
30
+ }
31
+
32
+ function goBack() {
33
+ go(there)
34
+ }
35
+
36
+ function show() {
37
+ alertDialog(h('div', {},
38
+ t('clipboard_list', "Items in clipboard:"),
39
+ clip.map(x => h('li', {}, x.name)),
40
+ ))
41
+ }
42
+
43
+ function paste() {
44
+ return apiCall('move_files', {
45
+ uri_from: clip.map(x => x.uri),
46
+ uri_to: here,
47
+ }).then(res => {
48
+ const bad = _.sumBy(res.errors, x => x ? 1 : 0)
49
+ alertDialog(h(Fragment, {},
50
+ t('good_bad', { bad, good: clip.length - bad }, "{good} moved, {bad} failed"),
51
+ h('ul', {}, res.errors.map(((e: any, i: number) => {
52
+ e = xlate(e, HTTP_MESSAGES)
53
+ return e && h('li', {}, clip[i].name + ': ' + e)
54
+ }))),
55
+ ), bad ? 'warning' : 'info')
56
+ cancel()
57
+ reloadList()
58
+ }, alertDialog)
59
+ }
60
+}
61
+
62
+
63
+export function cut(files: DirList) {
64
+ state.clip = files
65
+ if (files.length)
66
+ return alertDialog(t('after_cut', "Your selection is now in the clipboard.\nGo to destination folder to paste."), 'info')
67
+}
\ No newline at end of file
frontend/src/fileMenu.ts
+2
@@ -11,6 +11,7 @@ import { alertDialog, promptDialog } from './dialog'
11
import { apiCall, useApi } from '@hfs/shared/api'
12
import { navigate } from './App'
13
import { inputComment } from './upload'
14
+import { cut } from './clip'
15
16
interface FileMenuEntry {
17
id?: string
@@ -50,6 +51,7 @@ export function openFileMenu(entry: DirEntry, ev: MouseEvent, addToMenu: (FileMe
51
return x
52
}),
53
state.props?.can_delete && { id: 'rename', label: t`Rename`, icon: 'edit', onClick: () => rename(entry) },
54
+ state.props?.can_delete && { id: 'cut', label: t`Cut`, icon: 'cut', onClick: () => close(cut([entry])) },
55
isFolder && { id: 'list', label: t`Get list`, href: uri + '?get=list&folders=*', icon: 'list' },
56
]
57
const props = [
frontend/src/icons.ts
+2
@@ -39,6 +39,8 @@ const SYS_ICONS: Record<string, [string] | [string, string | false]> = { // fals
39
comment: ['💬'],
40
link: ['↗'],
41
info: ['ⓘ', false],
42
+ cut: ['✄'],
43
+ paste: ['📋'],
44
}
45
46
document.fonts.ready.then(async ()=> {
frontend/src/index.scss
+13
-3
@@ -104,7 +104,7 @@ a {
104
button {
105
background-color: var(--button-bg);
106
color: var(--button-text);
107
- padding: 0.5em 1em;
107
+ padding: .5em;
108
border: transparent;
109
text-decoration: none;
110
border-radius: 0.3em;
@@ -483,7 +483,8 @@ button label {
483
484
display: flex;
485
flex-direction: column;
486
- gap: 1em;
486
+ gap: .7em 1em;
487
+ justify-content: space-between;
488
a {
489
display: flex;
490
align-items: flex-start;
@@ -498,6 +499,14 @@ button label {
499
500
.unauthorized { text-align: center }
501
502
+#clipBar {
503
+ position: sticky; bottom: 0;
504
+ background: var(--bg);
505
+ box-shadow: 0 0 .3em .3em var(--bg);
506
+
507
+ display: flex; align-items: center; gap: 0.5em;
508
+}
509
+
510
.tiles-mode {
511
max-width: none;
512
margin: 0 1em;
@@ -682,11 +691,12 @@ button label {
691
border: 1px solid var(--ghost-contrast)
692
}
693
}
694
+ button { padding: .5em 1em; }
695
}
696
@media (max-width: 42em) {
697
:root { --ghost-contrast: #8883; } /* phones have different curve */
698
body, button, select { font-size: 14pt; }
689
- #menu-bar, #filter-bar {
699
+ #menu-bar, #filter-bar, #clipBar {
700
button label { display: none } /* icons only */
701
}
702
#filter-bar {
frontend/src/menu.ts
+10
-2
@@ -1,6 +1,6 @@
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, useSnapState } from './state'
3
+import { DirList, state, useSnapState } from './state'
4
import { ComponentPropsWithoutRef, createElement as h, Fragment, useEffect, useMemo, useState } from 'react'
5
import { alertDialog, confirmDialog, ConfirmOptions, promptDialog } from './dialog'
6
import { defaultPerms, err2msg, ErrorMsg, hIcon, onlyTruthy, prefix, useStateMounted, VfsPerms, working } from './misc'
@@ -15,6 +15,7 @@ import { useSnapshot } from 'valtio'
15
import { apiCall } from '@hfs/shared/api'
16
import { reloadList } from './useFetchList'
17
import { t, useI18N } from './i18n'
18
+import { cut } from './clip'
19
20
export function MenuPanel() {
21
const { showFilter, remoteSearch, stopSearch, stoppedSearch, selected, props } = useSnapState()
@@ -71,7 +72,14 @@ export function MenuPanel() {
72
className: changingButton === 'upload' ? 'show-sliding ' + (uploading ? 'ani-working' : '') : 'before-sliding',
73
onClick: showUpload,
74
}),
74
- h(MenuButton, getSearchProps()),
75
+ h(MenuButton, showFilter && can_delete ? {
76
+ id: 'cut-button',
77
+ icon: 'cut',
78
+ label: t`Cut`,
79
+ onClick() {
80
+ cut(onlyTruthy(Object.keys(selected).map(uri => _.find(state.list, { uri }))))
81
+ }
82
+ } : getSearchProps()),
83
h(MenuButton, {
84
id: 'options-button',
85
icon: 'settings',
frontend/src/state.ts
+2
@@ -12,6 +12,7 @@ export const state = proxy<typeof FRONTEND_OPTIONS & {
12
username: string,
13
list: DirList,
14
filteredList?: DirList,
15
+ clip: DirList
16
loading: boolean,
17
error?: string,
18
listReloader: number,
@@ -37,6 +38,7 @@ export const state = proxy<typeof FRONTEND_OPTIONS & {
38
iconsReady: false,
39
username: '',
40
list: [],
41
+ clip: [],
42
loading: false,
43
listReloader: 0,
44
patternFilter: '',
src/cross.ts
+4
@@ -193,6 +193,10 @@ export function basename(path: string) {
193
return path.slice(path.lastIndexOf('/') + 1 || path.lastIndexOf('\\') + 1)
194
}
195
196
+export function dirname(path: string) {
197
+ return path.slice(0, path.lastIndexOf('/', path.length - 1))
198
+}
199
+
200
export function tryJson(s?: string, except?: (s?: string) => unknown) {
201
try { return s && JSON.parse(s) }
202
catch { return except?.(s) }
src/frontEndApis.ts
+18
-1
@@ -12,7 +12,7 @@ import {
12
} from './const'
13
import { hasPermission, statusCodeForMissingPerm, urlToNode } from './vfs'
14
import { mkdir, rename, rm } from 'fs/promises'
15
-import { dirname, join } from 'path'
15
+import { basename, dirname, join } from 'path'
16
import { getUploadMeta } from './upload'
17
import { apiAssertTypes } from './misc'
18
import { getCommentFor, setCommentFor } from './comments'
@@ -116,6 +116,23 @@ export const frontEndApis: ApiHandlers = {
116
}
117
},
118
119
+ async move_files({ uri_from, uri_to }, ctx) {
120
+ apiAssertTypes({ array: { uri_from }, string: { uri_to } })
121
+ const destNode = await urlToNode(uri_to, ctx)
122
+ const code = !destNode ? HTTP_NOT_FOUND : statusCodeForMissingPerm(destNode, 'can_upload', ctx)
123
+ if (code) return new ApiError(code)
124
+ return {
125
+ errors: await Promise.all(uri_from.map(async (src: any) => {
126
+ if (typeof src !== 'string') return HTTP_BAD_REQUEST
127
+ const srcNode = await urlToNode(src, ctx)
128
+ if (!srcNode) return HTTP_NOT_FOUND
129
+ return statusCodeForMissingPerm(srcNode, 'can_delete', ctx)
130
+ || rename(srcNode.source!, join(destNode!.source!, basename(srcNode.source!)))
131
+ .catch(e => e.code || String(e))
132
+ }))
133
+ }
134
+ },
135
+
136
async comment({ uri, comment }, ctx) {
137
apiAssertTypes({ string: { uri, comment } })
138
const node = await urlToNode(uri, ctx)
src/langs/hfs-lang-en.json
+10
-2
@@ -1,7 +1,7 @@
1
{
2
"author": "YOUR NAME HERE",
3
"version": 1.0,
4
- "hfs_version": "0.50.0",
4
+ "hfs_version": "0.51.0",
5
"translate": {
6
"Select": "Select",
7
"n_files": "{n,plural,one{# file} other{# files}}",
@@ -140,6 +140,14 @@
140
"enter_comment": "Comment for this file",
141
"Comment": "Comment",
142
"upload_dd_hint": "You can upload files doing drag&drop on the files list",
143
- "Upload not available": "Upload not available"
143
+ "Upload not available": "Upload not available",
144
+ "Cut": "Cut",
145
+ "n_items": "{n,plural, one{# item} other{# items}}",
146
+ "good_bad": "{good} moved, {bad} failed",
147
+ "after_cut": "Your selection is now in the clipboard.\nGo to destination folder to paste.",
148
+ "Cancel clipboard": "Cancel clipboard",
149
+ "to_clipboard_source": "To folder",
150
+ "Paste": "Paste",
151
+ "clipboard_list": "Items in clipboard:"
152
}
153
}
src/langs/hfs-lang-it.json
+10
-2
@@ -1,7 +1,7 @@
1
{
2
"author": "Massimo Melina",
3
- "version": 1.8,
4
- "hfs_version": "0.50.0",
3
+ "version": 1.9,
4
+ "hfs_version": "0.51.0",
5
"translate": {
6
"Select": "Seleziona",
7
"n_files": "{n} file",
@@ -133,6 +133,14 @@
133
"Comment": "Comment",
134
"upload_dd_hint": "Puoi uploadare file trascinandoli sulla lista principale",
135
"Upload not available": "Upload non disponibile",
136
+ "Cut": "Taglia",
137
+ "n_items": "{n,plural, one{# elemento} other{# elementi}}",
138
+ "good_bad": "{good} spostati, {bad} falliti",
139
+ "after_cut": "Hai messo negli appunti, ora spostati fino alla destinazione e premi \"Incolla\".",
140
+ "Cancel clipboard": "Annulla appunti",
141
+ "to_clipboard_source": "Torna all'origine'",
142
+ "Paste": "Incolla",
143
+ "clipboard_list": "Elementi negli appunti",
144
145
"_PLUGINS SECTION": "",
146
"_PLUGIN thumbnails": "",
src/misc.ts
+1
-1
@@ -140,6 +140,6 @@ export function apiAssertTypes(paramsByType: { [type:string]: { [name:string]: a
140
for (const [types,params] of Object.entries(paramsByType))
141
for (const type of types.split('_'))
142
for (const [name,val] of Object.entries(params))
143
- if (typeof val !== type)
143
+ if (type === 'array' ? !Array.isArray(val) : typeof val !== type)
144
throw new ApiError(HTTP_BAD_REQUEST, 'bad ' + name)
145
}
\ No newline at end of file