upload: "show details" button
Massimo Melina committed
Apr 11, 2024 at 16:32 UTC
d7e64802b5379b1201456f1c9df41fc0be1f853a
3 files changed
+59
-32
frontend/src/components.ts
+7
-3
@@ -100,21 +100,25 @@ export interface BtnProps extends ComponentPropsWithoutRef<"button"> {
100
className?: string,
101
onClick?: () => unknown
102
onClickAnimation?: boolean
103
+ asText?: boolean
104
}
105
105
-export function Btn({ icon, label, tooltip, toggled, onClick, onClickAnimation, ...rest }: BtnProps) {
106
+export function Btn({ icon, label, tooltip, toggled, onClick, onClickAnimation, asText, ...rest }: BtnProps) {
107
const [working, setWorking] = useState(false)
107
- return h('button', {
108
+ return h(asText ? 'a' : 'button', {
109
title: label + prefix(' - ', tooltip),
110
'aria-label': label,
111
'aria-pressed': toggled,
111
- onClick() {
112
+ onClick(ev) {
113
+ if (asText)
114
+ ev.preventDefault()
115
if (!onClick) return
116
if (onClickAnimation !== false)
117
setWorking(true)
118
Promise.resolve(onClick()).finally(() => setWorking(false))
119
},
120
...rest,
121
+ ...asText ? { role: 'button', style: { cursor: 'pointer', ...rest.style } } : undefined,
122
className: [rest.className, toggled && 'toggled', working && 'ani-working'].filter(Boolean).join(' '),
123
}, icon && hIcon(icon), h('span', { className: 'label' }, label) ) // don't use <label> as VoiceOver will get redundant
124
}
frontend/src/index.scss
+6
-3
@@ -31,9 +31,9 @@
31
--good-contrast: #aaa;
32
--button-bg: #345;
33
--button-text: #999;
34
- --success: #383;
35
- --warning: #883;
36
- --error: #a33;
34
+ --success: #272;
35
+ --warning: #772;
36
+ --error: #a22;
37
38
color-scheme: dark;
39
.highlightedText { color: #fff; text-shadow: 0 0 3px #fff; }
@@ -400,6 +400,8 @@ ul.dir {
400
}
401
}
402
403
+ul { padding-left: 2em }
404
+
405
button .icon + .label {
406
cursor: inherit;
407
margin-left: .4em;
@@ -415,6 +417,7 @@ button .icon + .label {
417
.dialog-alert .dialog-content {
418
text-align: center;
419
p { text-align: left; display: inline-block; }
420
+ ul { text-align: left; } // bullet points should be aligned
421
}
422
423
.dialog {
frontend/src/upload.ts
+46
-26
@@ -1,13 +1,13 @@
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 { createElement as h, DragEvent, Fragment, useMemo, CSSProperties, useState, useEffect } from 'react'
4
-import { Flex, FlexV, iconBtn, Select } from './components'
4
+import { Btn, Flex, FlexV, iconBtn, Select } from './components'
5
import {
6
basename, closeDialog, formatBytes, formatPerc, hIcon, useIsMobile, newDialog, prefix, selectFiles, working,
7
HTTP_CONFLICT, HTTP_PAYLOAD_TOO_LARGE, formatSpeed, dirname, getHFS, onlyTruthy, with_, cpuSpeedIndex
8
} from './misc'
9
import _ from 'lodash'
10
-import { proxy, ref, subscribe, useSnapshot } from 'valtio'
10
+import { INTERNAL_Snapshot, proxy, ref, snapshot, subscribe, useSnapshot } from 'valtio'
11
import { alertDialog, confirmDialog, promptDialog, toast } from './dialog'
12
import { reloadList } from './useFetchList'
13
import { apiCall, getNotification } from '@hfs/shared/api'
@@ -20,10 +20,10 @@ const renameEnabled = getHFS().dontOverwriteUploading
20
21
interface ToUpload { file: File, comment?: string, name?: string, to?: string }
22
export const uploadState = proxy<{
23
- done: number
23
+ done: ToUpload[]
24
doneByte: number
25
- errors: number
26
- skipped: number
25
+ errors: ToUpload[]
26
+ skipped: ToUpload[]
27
adding: ToUpload[]
28
qs: { to: string, entries: ToUpload[] }[]
29
paused: boolean
@@ -41,10 +41,10 @@ export const uploadState = proxy<{
41
paused: false,
42
qs: [],
43
adding: [],
44
- skipped: 0,
45
- errors: 0,
44
+ skipped: [],
45
+ errors: [],
46
doneByte: 0,
47
- done: 0,
47
+ done: [],
48
policyForExisting: renameEnabled ? 'rename' : 'skip'
49
})
50
@@ -77,10 +77,10 @@ let everPaused = false
77
78
function resetCounters() {
79
Object.assign(uploadState, {
80
- errors: 0,
81
- done: 0,
80
+ errors: [],
81
+ done: [],
82
doneByte: 0,
83
- skipped: 0,
83
+ skipped: [],
84
})
85
}
86
@@ -316,7 +316,7 @@ async function startUpload(toUpload: ToUpload, to: string, resume=0) {
316
const status = overrideStatus || req.status
317
closeLast?.()
318
if (!status || status === HTTP_CONFLICT) // 0 = user-aborted, HTTP_CONFLICT = skipped because existing
319
- uploadState.skipped++
319
+ uploadState.skipped.push(toUpload)
320
else if (status >= 400)
321
error(status)
322
else
@@ -379,7 +379,7 @@ async function startUpload(toUpload: ToUpload, to: string, resume=0) {
379
}
380
381
function error(status: number) {
382
- if (uploadState.errors++) return
382
+ if (uploadState.errors.push(toUpload)) return
383
const ERRORS = {
384
[HTTP_PAYLOAD_TOO_LARGE]: t`file too large`,
385
}
@@ -390,7 +390,7 @@ async function startUpload(toUpload: ToUpload, to: string, resume=0) {
390
}
391
392
function done() {
393
- uploadState.done++
393
+ uploadState.done.push(toUpload)
394
uploadState.doneByte += toUpload!.file.size
395
reloadOnClose = true
396
}
@@ -406,21 +406,41 @@ async function startUpload(toUpload: ToUpload, to: string, resume=0) {
406
if (qs.length) return
407
setTimeout(reloadList, 500) // workaround: reloading too quickly can meet the new file still with its temp name
408
reloadOnClose = false
409
- const msg = h('div', {}, t(['upload_concluded', "Upload terminated"], "Upload concluded:"), h(UploadStatus) )
410
- if (!uploadDialogIsOpen)
411
- (uploadState.errors || uploadState.skipped ? alertDialog(msg, 'info') : toast(msg, 'success').closed)
412
- .finally(resetCounters)
409
+ if (uploadDialogIsOpen) return
410
+ // freeze and reset
411
+ const snap = snapshot(uploadState)
412
+ resetCounters()
413
+ const msg = h('div', {}, t(['upload_concluded', "Upload terminated"], "Upload concluded:"),
414
+ h(UploadStatus, { snapshot: snap, display: 'flex', flexDirection: 'column' }) )
415
+ if (uploadState.errors.length || uploadState.skipped.length)
416
+ alertDialog(msg, 'info')
417
+ else
418
+ toast(msg, 'success')
419
}
420
}
421
416
-function UploadStatus(props: CSSProperties) {
417
- const { done, doneByte, errors, skipped } = useSnapshot(uploadState)
418
- const s = [
419
- done && t('upload_finished', { n: done, size: formatBytes(doneByte) }, "{n} finished ({size})"),
420
- skipped && t('upload_skipped', { n: skipped }, "{n} skipped"),
421
- errors && t('upload_errors', { n: errors }, "{n} failed"),
422
- ].filter(Boolean).join(' – ')
423
- return !s ? null : h('div', { style: props }, s)
422
+function UploadStatus({ snapshot, ...props }: { snapshot?: INTERNAL_Snapshot<typeof uploadState> } & CSSProperties) {
423
+ const current = useSnapshot(uploadState)
424
+ const { done, doneByte, errors, skipped } = snapshot || current
425
+ const msgDone = done.length > 0 && t('upload_finished', { n: done.length, size: formatBytes(doneByte) }, "{n} finished ({size})")
426
+ const msgSkipped = skipped.length > 0 && t('upload_skipped', { n: skipped.length }, "{n} skipped")
427
+ const msgErrors = errors.length > 0 && t('upload_errors', { n: errors.length }, "{n} failed")
428
+ const s = [msgDone, msgSkipped, msgErrors].filter(Boolean).join(' – ')
429
+ if (!s) return null
430
+ return h('div', { style: { ...props } },
431
+ s, ' – ', h(Btn, { label: t`Show details`, asText: true, onClick: showDetails }) )
432
+
433
+ function showDetails() {
434
+ alertDialog(h('div', {},
435
+ ([
436
+ [msgDone, done],
437
+ [msgSkipped, skipped],
438
+ [msgErrors, errors]
439
+ ] as const).map(([msg, list]) =>
440
+ msg && h('div', {}, msg, h('ul', {},
441
+ list.map(x => h('li', {}, x.name || x.file.name)) )))
442
+ ))
443
+ }
444
}
445
446
function abortCurrentUpload() {