upload: "Copy links" button after finished #803
Massimo Melina committed
Nov 29, 2024 at 10:02 UTC
d8ee732c962121dcf2126393f3088db3bf3d2383
10 files changed
+70
-36
frontend/src/components.ts
+12
-3
@@ -4,7 +4,7 @@ import { Callback, getHFS, hfsEvent, hIcon, Html, isPrimitive, onlyTruthy, prefi
4
import {
5
ButtonHTMLAttributes, ChangeEvent, createElement as h, CSSProperties, forwardRef, Fragment,
6
HTMLAttributes, InputHTMLAttributes, isValidElement, MouseEventHandler, ReactNode, SelectHTMLAttributes,
7
- useMemo, useState, ComponentPropsWithoutRef, LabelHTMLAttributes
7
+ useMemo, useState, ComponentPropsWithoutRef, LabelHTMLAttributes, useRef
8
} from 'react'
9
import _ from 'lodash'
10
import { t } from './i18n'
@@ -116,10 +116,13 @@ export interface BtnProps extends ComponentPropsWithoutRef<"button"> {
116
onClick?: () => unknown
117
onClickAnimation?: boolean
118
asText?: boolean
119
+ successFeedback?: boolean
120
}
121
121
-export function Btn({ icon, label, tooltip, toggled, onClick, onClickAnimation, asText, ...rest }: BtnProps) {
122
+export function Btn({ icon, label, tooltip, toggled, onClick, onClickAnimation, asText, successFeedback, ...rest }: BtnProps) {
123
const [working, setWorking] = useState(false)
124
+ const [success, setSuccess] = useState(false)
125
+ const t = useRef<any>()
126
return h(asText ? 'a' : 'button', {
127
title: label + prefix(' - ', tooltip),
128
'aria-label': label,
@@ -131,9 +134,15 @@ export function Btn({ icon, label, tooltip, toggled, onClick, onClickAnimation,
134
if (onClickAnimation !== false)
135
setWorking(true)
136
Promise.resolve(onClick()).finally(() => setWorking(false))
137
+ .then(() => {
138
+ if (!successFeedback) return
139
+ setSuccess(true)
140
+ clearTimeout(t.current)
141
+ t.current = setTimeout(() => setSuccess(false), 1000)
142
+ })
143
},
144
...rest,
145
...asText ? { role: 'button', style: { cursor: 'pointer', ...rest.style } } : undefined,
137
- className: [rest.className, toggled && 'toggled', working && 'ani-working'].filter(Boolean).join(' '),
146
+ className: [rest.className, toggled && 'toggled', working && 'ani-working', success && 'success'].filter(Boolean).join(' '),
147
}, icon && hIcon(icon), h('span', { className: 'label' }, label) ) // don't use <label> as VoiceOver will get redundant
148
}
frontend/src/fileMenu.ts
+2
-2
@@ -1,7 +1,7 @@
1
import { t, useI18N } from './i18n'
2
import {
3
dontBotherWithKeys, formatBytes, getHFS, hfsEvent, hIcon, newDialog, prefix, with_, working,
4
- pathEncode, closeDialog, anyDialogOpen, Falsy
4
+ pathEncode, closeDialog, anyDialogOpen, Falsy, operationSuccessful
5
} from './misc'
6
import { createElement as h, Fragment, isValidElement, MouseEvent, ReactNode } from 'react'
7
import _ from 'lodash'
@@ -172,7 +172,7 @@ async function editComment(entry: DirEntry) {
172
if (res === undefined) return
173
await apiCall('comment', { uri: entry.uri, comment: res }, { modal: working })
174
updateEntry(entry, e => e.comment = res)
175
- toast(t`Operation successful`, 'success')
175
+ operationSuccessful()
176
}
177
178
function updateEntry(entry: DirEntry, cb: (e: DirEntry) => unknown) {
frontend/src/index.scss
+10
@@ -135,6 +135,16 @@ button.toggled {
135
color: #fff;
136
text-shadow: 0 0 3px #fff;
137
}
138
+button.success {
139
+ transition: all .3s;
140
+ background-color: var(--success);
141
+ @extend .highlightedText;
142
+}
143
+a[role=button].success {
144
+ transition: all .3s;
145
+ color: var(--success);
146
+ text-shadow: 0 0 3px var(--text-high-contrast);
147
+}
148
a.toggled {
149
color: #223;
150
text-shadow: 0 0 5px #223;
frontend/src/misc.ts
+4
@@ -106,3 +106,7 @@ Object.assign(getHFS(), {
106
}
107
}
108
})
109
+
110
+export function operationSuccessful() {
111
+ return toast(t`Operation successful`, 'success')
112
+}
\ No newline at end of file
frontend/src/upload.ts
+13
-3
@@ -3,8 +3,8 @@
3
import { createElement as h, DragEvent, Fragment, useMemo, useState, useEffect, CSSProperties } from 'react'
4
import { Btn, Flex, FlexV, iconBtn, Select } from './components'
5
import {
6
- basename, formatBytes, formatPerc, hIcon, useIsMobile, newDialog, selectFiles, working,
7
- HTTP_CONFLICT, formatSpeed, getHFS, onlyTruthy, cpuSpeedIndex, closeDialog, prefix,
6
+ basename, formatBytes, formatPerc, hIcon, useIsMobile, newDialog, selectFiles, working, copyTextToClipboard,
7
+ HTTP_CONFLICT, formatSpeed, getHFS, onlyTruthy, cpuSpeedIndex, closeDialog, prefix, operationSuccessful,
8
} from './misc'
9
import _ from 'lodash'
10
import { INTERNAL_Snapshot, ref, useSnapshot } from 'valtio'
@@ -209,7 +209,17 @@ export function UploadStatus({ snapshot, ...props }: { snapshot?: INTERNAL_Snaps
209
const s = [msgDone, msgSkipped, msgErrors].filter(Boolean).join(' – ')
210
if (!s) return null
211
return h('div', { style: { ...props } },
212
- s, ' – ', h(Btn, { label: t`Show details`, asText: true, onClick: showDetails }) )
212
+ s, ' – ', h(Btn, { label: t`Show details`, asText: true, onClick: showDetails }),
213
+ ' – ', h(Btn, {
214
+ label: t('copy_links', "Copy links"),
215
+ asText: true,
216
+ successFeedback: true,
217
+ onClick() {
218
+ copyTextToClipboard(done.map(x => location.origin + x.res.uri).join('\n'))
219
+ operationSuccessful()
220
+ }
221
+ }),
222
+ )
223
224
function showDetails() {
225
if (!uploadState.uploadDialogIsOpen)
frontend/src/uploadQueue.ts
+5
-9
@@ -1,7 +1,7 @@
1
import {
2
buildUrlQueryString, dirname, formatBytes, formatPerc, getHFS,
3
HTTP_CONFLICT, HTTP_MESSAGES, HTTP_PAYLOAD_TOO_LARGE,
4
- pathEncode, pendingPromise, prefix, randomId, with_
4
+ pathEncode, pendingPromise, prefix, randomId, tryJson, with_
5
} from '@hfs/shared'
6
import { state } from './state'
7
import { getNotifications } from '@hfs/shared/api'
@@ -16,7 +16,7 @@ import { UploadStatus } from './upload'
16
17
export interface ToUpload { file: File, comment?: string, name?: string, to?: string, error?: string }
18
export const uploadState = proxy<{
19
- done: ToUpload[]
19
+ done: (ToUpload & { res?: any })[]
20
doneByte: number
21
errors: ToUpload[]
22
skipped: ToUpload[]
@@ -111,7 +111,9 @@ export async function startUpload(toUpload: ToUpload, to: string, resume=0) {
111
offset += splitSize
112
if (offset < fullSize) return // continue looping
113
}
114
- done()
114
+ uploadState.done.push({ ...toUpload, res: tryJson(req.responseText) })
115
+ uploadState.doneByte += toUpload!.file.size
116
+ reloadOnClose = true
117
}
118
next()
119
}
@@ -192,12 +194,6 @@ export async function startUpload(toUpload: ToUpload, to: string, resume=0) {
194
closeLast = alertDialog(msg, 'error').close
195
}
196
195
- function done() {
196
- uploadState.done.push(toUpload)
197
- uploadState.doneByte += toUpload!.file.size
198
- reloadOnClose = true
199
- }
200
-
197
function next() {
198
stopLooping()
199
uploadState.uploading = undefined
shared/api.ts
+2
-2
@@ -37,8 +37,8 @@ export function apiCall<T=any>(cmd: string, params?: Dict, options: ApiCallOptio
37
controller.abort(aborted = 'timeout')
38
console.debug('API TIMEOUT', cmd, params??'')
39
}, ms)
40
- const l = location // rebuilding the whole url makes it resistant to url-with-credentials
41
- return Object.assign(fetch(`${l.protocol}//${l.host}${getPrefixUrl()}${API_URL}${cmd}`, {
40
+ // rebuilding the whole url makes it resistant to url-with-credentials
41
+ return Object.assign(fetch(`${location.origin}${getPrefixUrl()}${API_URL}${cmd}`, {
42
method: options.method || 'POST',
43
headers: { 'content-type': 'application/json', 'x-hfs-anti-csrf': '1' },
44
signal: controller.signal,
src/langs/hfs-lang-en.json
+2
-1
@@ -176,6 +176,7 @@
176
"Cancel": "Cancel",
177
"allow_session_ip_change": "Allow IP change during this session",
178
179
- "focus_hint": "By typing on your keyboard, you search and focus elements of the list."
179
+ "focus_hint": "By typing on your keyboard, you search and focus elements of the list.",
180
+ "copy_links": "Copy links"
181
}
182
}
src/serveGuiAndSharedFiles.ts
+10
-8
@@ -41,18 +41,19 @@ export const serveGuiAndSharedFiles: Koa.Middleware = async (ctx, next) => {
41
if (ctx.method === 'PUT') { // curl -T file url/
42
const decPath = decodeURIComponent(path)
43
let rest = basename(decPath)
44
- const folder = await urlToNode(dirname(path), ctx, vfs, v => rest = v+'/'+rest)
44
+ const folderUri = dirname(path)
45
+ const folder = await urlToNode(folderUri, ctx, vfs, v => rest = v+'/'+rest)
46
if (!folder)
47
return sendErrorPage(ctx, HTTP_NOT_FOUND)
48
ctx.state.uploadPath = decPath
48
- const dest = uploadWriter(folder, rest, ctx)
49
+ const dest = uploadWriter(folder, folderUri, rest, ctx)
50
if (dest) {
51
ctx.req.pipe(dest).on('error', err => {
52
ctx.status = HTTP_SERVER_ERROR
53
ctx.body = err.message || String(err)
54
})
54
- await dest.lockMiddleware // we need to wait more than just the stream
55
- ctx.body = {}
55
+ const uri = await dest.lockMiddleware // we need to wait more than just the stream
56
+ ctx.body = { uri }
57
}
58
return
59
}
@@ -64,9 +65,8 @@ export const serveGuiAndSharedFiles: Koa.Middleware = async (ctx, next) => {
65
if (ctx.method === 'POST') { // curl -F upload=@file url/
66
if (ctx.request.type !== 'multipart/form-data')
67
return ctx.status = HTTP_BAD_REQUEST
67
- ctx.body = {}
68
ctx.state.uploads = []
69
- let locks: Promise<any>[] = []
69
+ let locks: Promise<string>[] = []
70
const form = formidable({
71
maxFileSize: Infinity,
72
allowEmptyFiles: true,
@@ -74,17 +74,19 @@ export const serveGuiAndSharedFiles: Koa.Middleware = async (ctx, next) => {
74
const fn = (f as any).originalFilename
75
ctx.state.uploadPath = decodeURI(ctx.path) + fn
76
ctx.state.uploads!.push(fn)
77
- const ret = uploadWriter(node!, fn, ctx)
77
+ const ret = uploadWriter(node!, path, fn, ctx)
78
if (!ret)
79
return new Writable({ write(data,enc,cb) { cb() } }) // just discard data
80
locks.push(ret.lockMiddleware)
81
return ret
82
}
83
})
84
- return new Promise<any>(res => form.parse(ctx.req, err => {
84
+ const uris = await new Promise<string[]>(res => form.parse(ctx.req, async err => {
85
if (err) console.error(String(err))
86
res(Promise.all(locks))
87
}))
88
+ ctx.body = { uris }
89
+ return
90
}
91
if (ctx.method === 'DELETE') {
92
const res = await deleteNode(ctx, node, ctx.path)
src/upload.ts
+10
-8
@@ -5,7 +5,10 @@ import {
5
} from './const'
6
import { basename, dirname, extname, join } from 'path'
7
import fs from 'fs'
8
-import { Callback, dirTraversal, loadFileAttr, pendingPromise, storeFileAttr, try_, createStreamLimiter } from './misc'
8
+import {
9
+ Callback, dirTraversal, loadFileAttr, pendingPromise, storeFileAttr, try_, createStreamLimiter, pathEncode,
10
+ enforceFinal
11
+} from './misc'
12
import { notifyClient } from './frontEndApis'
13
import { defineConfig } from './config'
14
import { getDiskSpaceSync } from './util-os'
@@ -39,7 +42,7 @@ function setUploadMeta(path: string, ctx: Koa.Context) {
42
// stay sync because we use this function with formidable()
43
const diskSpaceCache: any = {}
44
const openFiles = new Set()
42
-export function uploadWriter(base: VfsNode, path: string, ctx: Koa.Context) {
45
+export function uploadWriter(base: VfsNode, baseUri: string, path: string, ctx: Koa.Context) {
46
let fullPath = ''
47
if (dirTraversal(path))
48
return fail(HTTP_FOOL)
@@ -129,7 +132,7 @@ export function uploadWriter(base: VfsNode, path: string, ctx: Koa.Context) {
132
cancelDeletion(tempName)
133
ctx.state.uploadDestinationPath = tempName
134
// allow plugins to mess with the write-stream, because the read-stream can be complicated in case of multipart
132
- const obj = { ctx, writeStream }
135
+ const obj = { ctx, writeStream, uri: '' }
136
const resEvent = events.emit('uploadStart', obj)
137
if (resEvent?.isDefaultPrevented()) return
138
@@ -143,7 +146,7 @@ export function uploadWriter(base: VfsNode, path: string, ctx: Koa.Context) {
146
Object.assign(obj, { fileStream })
147
trackProgress()
148
146
- const lockMiddleware = pendingPromise() // outside we need to know when all operations stopped
149
+ const lockMiddleware = pendingPromise<string>() // outside we need to know when all operations stopped
150
writeStream.once('close', async () => {
151
try {
152
await new Promise(res => fileStream.close(res)) // this only seem to be necessary on Windows
@@ -175,6 +178,7 @@ export function uploadWriter(base: VfsNode, path: string, ctx: Koa.Context) {
178
void setCommentFor(dest, String(ctx.query.comment))
179
if (resumable && !resuming) // this happens if user decided to not resume and the new upload finished before delayedDelete
180
rm(resumable).catch(console.warn)
181
+ obj.uri = enforceFinal('/', baseUri) + pathEncode(basename(dest))
182
events.emit('uploadFinished', obj)
183
if (resEvent) for (const cb of resEvent)
184
if (_.isFunction(cb))
@@ -187,12 +191,10 @@ export function uploadWriter(base: VfsNode, path: string, ctx: Koa.Context) {
191
}
192
finally {
193
releaseFile()
190
- lockMiddleware.resolve()
194
+ lockMiddleware.resolve(obj.uri)
195
}
196
})
193
- return Object.assign(obj.writeStream, {
194
- lockMiddleware
195
- })
197
+ return Object.assign(obj.writeStream, { lockMiddleware })
198
199
function trackProgress() {
200
let lastGot = 0