feat(uploads): Fortschritt in MB, Tempo und Restzeit; Abbruchkreuz rot
Das Kreuz faerbt sich beim Darueberfahren rot -- aber nur, solange der Upload laeuft. Dasselbe Zeichen tut zwei Dinge: eine Meldung wegraeumen ist folgenlos, einen laufenden Vorgang fallen zu lassen nicht. Unter dem Balken steht jetzt "4.2 MB / 18.0 MB · 1.8 MB/s · 8 s left". Die Zahlen kamen schon immer vom XMLHttpRequest, sie wurden nur weggeworfen. Neu gerechnet wird nur das Tempo -- der Browser meldet Bytes, nicht deren Ableitung. Zwei Daempfungen, aus zwei Gruenden: * Das Tempo aus einem einzelnen Abstand springt zwischen dem Doppelten und der Haelfte. Gleitender Mittelwert (0,7/0,3) ueber Messpunkte von mindestens 0,2 s. * Die Anzeige wird hoechstens alle 150 ms nachgezogen. Der Browser meldet im 50-ms-Takt; bei drei Dateien waeren das sechzig Neuaufbauten der Liste je Sekunde fuer eine Zahl, die niemand so schnell liest. Jeder Teil erscheint erst, wenn er stimmt: Groesse sofort, Tempo nach dem zweiten Messpunkt, Restzeit erst mit beidem. Eine Schaetzung aus dem Nichts waere schlimmer als keine -- man richtet sich danach. formatDuration in src/format.ts, 5 Tests (37 gesamt). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
511213b40d
commit
e9f69f7d7f
4 files changed
+175
-14
No files matched your search
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { formatBytes, formatDuration } from '../format'
|
||||
|
||||
interface UploadItem {
|
||||
id: string
|
||||
@@ -6,6 +7,12 @@ interface UploadItem {
|
||||
progress: number // 0-100
|
||||
status: 'uploading' | 'done' | 'error' | 'canceled'
|
||||
error?: string
|
||||
/** Schon draußen, in Bytes. */
|
||||
loaded?: number
|
||||
/** Größe der Datei, in Bytes. */
|
||||
total?: number
|
||||
/** Bytes je Sekunde, geglättet. 0 heißt „noch nicht messbar". */
|
||||
speed?: number
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -98,21 +105,36 @@ export default function UploadQueue({ items, onDismiss, onCancel }: Props) {
|
||||
title={item.status === 'uploading' ? 'Cancel upload' : 'Dismiss'}
|
||||
aria-label={item.status === 'uploading' ? 'Cancel upload' : 'Dismiss'}
|
||||
className="ml-2 opacity-50 hover:opacity-100 text-xs shrink-0"
|
||||
style={{ color: 'var(--color-text-muted)' }}>✕</button>
|
||||
style={{ color: 'var(--color-text-muted)' }}
|
||||
// Beim Darüberfahren rot – **nur solange der Upload läuft**.
|
||||
// Dasselbe Zeichen tut hier zwei verschiedene Dinge: eine
|
||||
// Meldung wegräumen ist folgenlos, einen laufenden Vorgang
|
||||
// fallen zu lassen nicht. Die Farbe ist der einzige Hinweis
|
||||
// darauf, bevor man klickt.
|
||||
onMouseEnter={e => {
|
||||
if (item.status === 'uploading') e.currentTarget.style.color = 'var(--color-danger)'
|
||||
}}
|
||||
onMouseLeave={e => { e.currentTarget.style.color = 'var(--color-text-muted)' }}
|
||||
>✕</button>
|
||||
</div>
|
||||
|
||||
{item.status === 'uploading' && (
|
||||
<div className="h-1.5 rounded-full overflow-hidden" style={{ background: 'var(--color-surface-2)' }}>
|
||||
<div
|
||||
className="h-full rounded-full transition-all"
|
||||
style={{
|
||||
width: `${item.progress}%`,
|
||||
background: 'linear-gradient(90deg, var(--color-primary), var(--color-accent))',
|
||||
}} />
|
||||
</div>
|
||||
<>
|
||||
<div className="h-1.5 rounded-full overflow-hidden" style={{ background: 'var(--color-surface-2)' }}>
|
||||
<div
|
||||
className="h-full rounded-full transition-all"
|
||||
style={{
|
||||
width: `${item.progress}%`,
|
||||
background: 'linear-gradient(90deg, var(--color-primary), var(--color-accent))',
|
||||
}} />
|
||||
</div>
|
||||
<Details item={item} />
|
||||
</>
|
||||
)}
|
||||
{item.status === 'done' && (
|
||||
<p style={{ color: 'var(--color-success)' }} className="text-xs">✓ Uploaded</p>
|
||||
<p style={{ color: 'var(--color-success)' }} className="text-xs">
|
||||
✓ Uploaded{item.total ? ` · ${formatBytes(item.total)}` : ''}
|
||||
</p>
|
||||
)}
|
||||
{item.status === 'error' && (
|
||||
<p style={{ color: 'var(--color-danger)' }} className="text-xs truncate">✕ {item.error}</p>
|
||||
@@ -136,3 +158,40 @@ export default function UploadQueue({ items, onDismiss, onCancel }: Props) {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Zeile unter dem Balken: wie viel, wie schnell, wie lange noch.
|
||||
*
|
||||
* Ein Prozentwert allein sagt nichts über das Warten. Bei 40 % weiß man nicht,
|
||||
* ob noch zehn Sekunden oder zehn Minuten kommen – und genau das ist die
|
||||
* Frage, wegen der man auf den Balken sieht.
|
||||
*
|
||||
* Jeder Teil erscheint erst, wenn er stimmt: Die Größe kennt der Browser
|
||||
* sofort, das Tempo erst nach dem zweiten Messpunkt, die Restzeit erst mit
|
||||
* beidem. Eine Schätzung aus dem Nichts wäre schlimmer als keine – man richtet
|
||||
* sich danach.
|
||||
*/
|
||||
function Details({ item }: { item: UploadItem }) {
|
||||
const teile: string[] = []
|
||||
|
||||
if (item.total) {
|
||||
teile.push(`${formatBytes(item.loaded ?? 0)} / ${formatBytes(item.total)}`)
|
||||
}
|
||||
if (item.speed) {
|
||||
teile.push(`${formatBytes(Math.round(item.speed))}/s`)
|
||||
if (item.total) {
|
||||
const rest = formatDuration((item.total - (item.loaded ?? 0)) / item.speed)
|
||||
if (rest) teile.push(`${rest} left`)
|
||||
}
|
||||
}
|
||||
if (teile.length === 0) return null
|
||||
|
||||
return (
|
||||
<p
|
||||
className="text-xs mt-1 truncate"
|
||||
style={{ color: 'var(--color-text-muted)', fontVariantNumeric: 'tabular-nums' }}
|
||||
>
|
||||
{teile.join(' · ')}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user