feat(uploads): fertige Meldungen verschwinden von selbst

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>
This commit is contained in:
marcandClaude Opus 5 committed 2026-08-26 19:14:13 +02:00
1 parent 8d95088271
commit 451f359734
1 file changed
+70 -1
+70 -1
View File
@@ -1,3 +1,5 @@
import { useEffect, useRef } from 'react'
interface UploadItem {
id: string
name: string
@@ -13,12 +15,68 @@ interface Props {
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">
{items.map(item => (
{visible.map(item => (
<div
key={item.id}
className="rounded-xl p-3 text-sm shadow-lg border"
@@ -52,6 +110,17 @@ export default function UploadQueue({ items, onDismiss }: Props) {
)}
</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>
)
}