Files
bea-edit/src/lib/components/ExhibitFileList.svelte
T
2026-10-28 16:09:53 +01:00

270 lines
8.7 KiB
Svelte

<script lang="ts">
import { Download, Eye, GripVertical, RotateCcw, Trash2 } from '@lucide/svelte';
import Button from '$lib/components/ui/button.svelte';
import {
batchStatusClass,
batchStatusLabel,
downloadBatchZip,
formatBytes,
shouldWarnAboutBatchMemory,
type BatchItem
} from '$lib/batch';
type Props = {
items: BatchItem[];
/** Resolved stamp label per row; frozen once the row was created. */
labels: Record<string, string>;
/** Renamed file per row, or `null` when renaming is switched off. */
fileNames: Record<string, string | null>;
selectedId: string | null;
/** Base file name of the bulk ZIP, e.g. `anlagen` → `anlagen_2026-10-06.zip`. */
zipBaseName: string;
/** Reordering and preview selection are blocked while a run is active. */
disabled?: boolean;
onSelect: (id: string) => void;
onMove: (from: number, to: number) => void;
onDownload: (item: BatchItem) => void;
onRemove: (id: string) => void;
onRetry: (id: string) => void;
};
let {
items,
labels,
fileNames,
selectedId,
zipBaseName,
disabled = false,
onSelect,
onMove,
onDownload,
onRemove,
onRetry
}: Props = $props();
let draggingId = $state<string | null>(null);
let dragOverId = $state<string | null>(null);
const doneItems = $derived(items.filter((item) => item.status === 'done'));
const totalResultBytes = $derived(
doneItems.reduce((sum, item) => sum + (item.resultBytes?.byteLength ?? 0), 0)
);
const processingIndex = $derived(items.findIndex((item) => item.status === 'processing'));
const showMemoryWarning = $derived(shouldWarnAboutBatchMemory(items));
const downloadZip = () => downloadBatchZip(doneItems, zipBaseName);
const moveUp = (index: number) => {
if (disabled || index === 0) return;
onMove(index, index - 1);
};
const moveDown = (index: number) => {
if (disabled || index === items.length - 1) return;
onMove(index, index + 1);
};
const handleDragStart = (event: DragEvent, item: BatchItem) => {
if (disabled) return;
draggingId = item.id;
event.dataTransfer?.setData('text/plain', item.id);
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move';
};
const handleDragOver = (event: DragEvent, item: BatchItem) => {
if (disabled || !draggingId || draggingId === item.id) return;
event.preventDefault();
dragOverId = item.id;
};
const handleDrop = (event: DragEvent, item: BatchItem) => {
event.preventDefault();
const sourceId = draggingId;
draggingId = null;
dragOverId = null;
if (disabled || !sourceId || sourceId === item.id) return;
const from = items.findIndex((entry) => entry.id === sourceId);
const to = items.findIndex((entry) => entry.id === item.id);
if (from === -1 || to === -1) return;
onMove(from, to);
};
const handleDragEnd = () => {
draggingId = null;
dragOverId = null;
};
</script>
<div class="space-y-3 rounded-2xl border border-border bg-card p-4">
<div class="flex flex-wrap items-center justify-between gap-2">
<h2 class="text-lg font-semibold text-foreground">Anlagen ({items.length})</h2>
{#if doneItems.length > 0}
<p class="text-sm text-primary">
Ergebnisse gesamt: <span class="font-semibold">{formatBytes(totalResultBytes)}</span>
</p>
{/if}
</div>
{#if processingIndex !== -1}
<p class="text-sm text-primary" role="status">
Datei {processingIndex + 1} von {items.length} wird verarbeitet…
</p>
{/if}
{#if showMemoryWarning}
<div class="rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800">
Bei sehr vielen oder sehr großen Dateien kann der Arbeitsspeicher knapp werden. Laden Sie
fertige Ergebnisse früh herunter und entfernen Sie sie aus der Liste.
</div>
{/if}
<ul class="divide-y divide-border">
{#each items as item, index (item.id)}
{@const label = labels[item.id] ?? ''}
{@const renamed = fileNames[item.id] ?? null}
<li
class="flex flex-wrap items-center gap-3 py-3 {dragOverId === item.id
? 'bg-accent/40'
: ''} {draggingId === item.id ? 'opacity-60' : ''}"
data-exhibit-item={item.file.name}
data-exhibit-index={index + 1}
data-exhibit-label={label}
draggable={!disabled}
ondragstart={(event) => handleDragStart(event, item)}
ondragover={(event) => handleDragOver(event, item)}
ondrop={(event) => handleDrop(event, item)}
ondragend={handleDragEnd}
>
<div class="flex shrink-0 items-center gap-1">
<span
class="cursor-grab p-1 text-muted-foreground {disabled ? 'opacity-40' : ''}"
aria-hidden="true"
title="Ziehen zum Verschieben"
>
<GripVertical class="h-4 w-4" />
</span>
<span
class="flex h-6 w-6 items-center justify-center rounded-full bg-accent text-xs font-bold text-primary"
title="Position in der Liste"
>
{index + 1}
</span>
<div class="flex flex-col">
<button
type="button"
class="rounded p-0.5 text-primary transition hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-40"
aria-label="{item.file.name} nach oben verschieben"
title="Nach oben"
disabled={disabled || index === 0}
onclick={() => moveUp(index)}
>
<svg
viewBox="0 0 24 24"
class="h-3 w-3"
fill="none"
stroke="currentColor"
stroke-width="3"
aria-hidden="true"><path d="M6 15l6-6 6 6" /></svg
>
</button>
<button
type="button"
class="rounded p-0.5 text-primary transition hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-40"
aria-label="{item.file.name} nach unten verschieben"
title="Nach unten"
disabled={disabled || index === items.length - 1}
onclick={() => moveDown(index)}
>
<svg
viewBox="0 0 24 24"
class="h-3 w-3"
fill="none"
stroke="currentColor"
stroke-width="3"
aria-hidden="true"><path d="M6 9l6 6 6-6" /></svg
>
</button>
</div>
</div>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-semibold text-foreground">
{label}
</p>
<p class="truncate text-xs text-primary">{item.file.name}</p>
{#if renamed}
<p class="truncate text-xs text-muted-foreground" data-exhibit-filename>
→ {renamed}
</p>
{/if}
{#if item.status === 'error' && item.errorMessage}
<p class="mt-0.5 text-xs text-red-700" role="alert">{item.errorMessage}</p>
{/if}
</div>
<span
class="shrink-0 rounded-full px-2.5 py-0.5 text-xs font-semibold {batchStatusClass(
item.status
)}"
>
{batchStatusLabel(item.status)}
</span>
<div class="flex shrink-0 items-center gap-1">
<button
type="button"
class="focus-visible:ring-primary-400 rounded-lg p-2 text-primary transition hover:bg-accent focus:outline-none focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-40 aria-pressed:bg-accent"
aria-label="Vorschau für {item.file.name}"
title="Vorschau für {item.file.name}"
aria-pressed={selectedId === item.id}
{disabled}
onclick={() => onSelect(item.id)}
>
<Eye class="h-4 w-4" />
</button>
{#if item.status === 'done' && item.resultBytes && item.resultName}
<button
type="button"
class="focus-visible:ring-primary-400 rounded-lg p-2 text-primary transition hover:bg-accent focus:outline-none focus-visible:ring-2"
aria-label="{item.file.name} herunterladen"
title="{item.file.name} herunterladen"
onclick={() => onDownload(item)}
>
<Download class="h-4 w-4" />
</button>
{/if}
{#if item.status === 'error'}
<button
type="button"
class="focus-visible:ring-primary-400 rounded-lg p-2 text-primary transition hover:bg-accent focus:outline-none focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-40"
aria-label="{item.file.name} erneut versuchen"
title="{item.file.name} erneut versuchen"
{disabled}
onclick={() => onRetry(item.id)}
>
<RotateCcw class="h-4 w-4" />
</button>
{/if}
<button
type="button"
class="focus-visible:ring-primary-400 rounded-lg p-2 text-primary transition hover:bg-accent focus:outline-none focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-40"
aria-label="{item.file.name} entfernen"
title="{item.file.name} entfernen"
disabled={item.status === 'processing'}
onclick={() => onRemove(item.id)}
>
<Trash2 class="h-4 w-4" />
</button>
</div>
</li>
{/each}
</ul>
{#if doneItems.length >= 2}
<div class="flex justify-end pt-1">
<Button onclick={downloadZip} icon={Download}>Alle herunterladen</Button>
</div>
{/if}
</div>