Files
Aether/frontend/src/components/UploadQueue.tsx
T
marcandClaude Opus 5 451f359734 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>
2026-08-26 19:14:13 +02:00

127 lines
4.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}