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