@samitouri / QOSami-HFS / commits / d8ee732c

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