Nach einem Stapel-Upload blieb unten rechts für jede Datei eine Karte stehen, bis man sie einzeln wegklickte oder die Seite neu lud. Bei vielen Dateien war der Bildschirm zu. Fertige Meldungen räumen sich jetzt nach vier Sekunden selbst ab. Fehler bleiben: Sie tragen die einzige Auskunft, die es sonst nirgends gibt. Zusätzlich stehen höchstens vier Karten gleichzeitig, der Rest wird gezählt – der Bildschirm läuft ja schon während des Hochladens voll, nicht erst danach. Gescheiterte Dateien stehen dabei oben. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
127 lines
4.5 KiB
TypeScript
127 lines
4.5 KiB
TypeScript
import { useEffect, useRef } from 'react'
|
||
|
||
interface UploadItem {
|
||
id: string
|
||
name: string
|
||
progress: number // 0-100
|
||
status: 'uploading' | 'done' | 'error'
|
||
error?: string
|
||
}
|
||
|
||
interface Props {
|
||
items: UploadItem[]
|
||
onDismiss: (id: string) => void
|
||
}
|
||
|
||
export type { UploadItem }
|
||
|
||
/**
|
||
* Wie lange eine fertige Meldung stehen bleibt.
|
||
*
|
||
* Lang genug, um sie im Vorbeigehen zu lesen, kurz genug, dass beim Hochladen
|
||
* vieler Dateien nicht der halbe Bildschirm zuwächst.
|
||
*/
|
||
const dismissAfter = 4000
|
||
|
||
/**
|
||
* So viele Meldungen zeigen wir gleichzeitig, der Rest wird gezählt.
|
||
*
|
||
* Bei fünfzig Dateien auf einmal wäre die Liste sonst länger als das Fenster –
|
||
* und das gilt schon während des Hochladens, nicht erst danach.
|
||
*/
|
||
const maxVisible = 4
|
||
|
||
export default function UploadQueue({ items, onDismiss }: Props) {
|
||
// Der Aufrufer gibt bei jedem Rendern eine neue Funktion herein. Über eine
|
||
// Referenz gelesen, muss der Effekt darauf nicht erneut laufen – sonst
|
||
// begännen die Fristen bei jedem Fortschrittsschritt von vorn.
|
||
const dismiss = useRef(onDismiss)
|
||
dismiss.current = onDismiss
|
||
|
||
const timers = useRef(new Map<string, ReturnType<typeof setTimeout>>())
|
||
|
||
useEffect(() => {
|
||
for (const item of items) {
|
||
// Fehler bleiben stehen. Sie sind das Einzige, was hier eine Auskunft
|
||
// trägt, die man sonst nirgends wiederfindet – die verschwindet nicht
|
||
// von selbst.
|
||
if (item.status !== 'done') continue
|
||
if (timers.current.has(item.id)) continue
|
||
timers.current.set(item.id, setTimeout(() => {
|
||
timers.current.delete(item.id)
|
||
dismiss.current(item.id)
|
||
}, dismissAfter))
|
||
}
|
||
|
||
// Aufräumen, wenn eine Meldung von Hand weggeklickt wurde: Ihre Frist läuft
|
||
// sonst weiter und rührte später an eine Kennung, die es nicht mehr gibt.
|
||
const live = new Set(items.map(i => i.id))
|
||
for (const [id, timer] of timers.current) {
|
||
if (!live.has(id)) { clearTimeout(timer); timers.current.delete(id) }
|
||
}
|
||
}, [items])
|
||
|
||
// Beim Verlassen der Seite alle Fristen abräumen.
|
||
const all = timers.current
|
||
useEffect(() => () => { for (const t of all.values()) clearTimeout(t) }, [all])
|
||
|
||
if (items.length === 0) return null
|
||
|
||
// Fehler zuerst: Wenn mehr Meldungen anstehen als Platz ist, darf gerade die
|
||
// eine gescheiterte Datei nicht diejenige sein, die unter den Tisch fällt.
|
||
const failed = items.filter(i => i.status === 'error')
|
||
const rest = items.filter(i => i.status !== 'error')
|
||
const visible = [...failed, ...rest].slice(0, maxVisible)
|
||
const hidden = items.length - visible.length
|
||
|
||
return (
|
||
<div className="fixed bottom-4 right-4 w-72 space-y-2 z-40">
|
||
{visible.map(item => (
|
||
<div
|
||
key={item.id}
|
||
className="rounded-xl p-3 text-sm shadow-lg border"
|
||
style={{ background: 'var(--color-surface)', borderColor: 'var(--color-border)' }}>
|
||
<div className="flex items-center justify-between mb-1.5">
|
||
<span className="truncate font-medium text-xs" style={{ color: 'var(--color-text)', maxWidth: 180 }}>
|
||
{item.name}
|
||
</span>
|
||
{item.status !== 'uploading' && (
|
||
<button onClick={() => onDismiss(item.id)}
|
||
className="ml-2 opacity-50 hover:opacity-100 text-xs shrink-0"
|
||
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>
|
||
)}
|
||
{item.status === 'done' && (
|
||
<p style={{ color: 'var(--color-success)' }} className="text-xs">✓ Uploaded</p>
|
||
)}
|
||
{item.status === 'error' && (
|
||
<p style={{ color: 'var(--color-danger)' }} className="text-xs truncate">✕ {item.error}</p>
|
||
)}
|
||
</div>
|
||
))}
|
||
|
||
{hidden > 0 && (
|
||
<div
|
||
className="rounded-xl px-3 py-2 text-xs shadow-lg border text-center"
|
||
style={{
|
||
background: 'var(--color-surface)', borderColor: 'var(--color-border)',
|
||
color: 'var(--color-text-muted)',
|
||
}}>
|
||
+{hidden} more
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|