270 lines
8.7 KiB
Svelte
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>
|