177 lines
5.8 KiB
Svelte
177 lines
5.8 KiB
Svelte
<script lang="ts">
|
|
import { Download, RotateCcw, Trash2 } from '@lucide/svelte';
|
|
import { zipSync } from 'fflate';
|
|
import Button from '$lib/components/ui/button.svelte';
|
|
import { downloadBlob } from '$lib/download';
|
|
import { formatBytes, type BatchItem } from '$lib/batch';
|
|
import { RASTERIZATION_WARNING_BYTES } from '$lib/rasterization-limits';
|
|
|
|
type Props = {
|
|
items: BatchItem[];
|
|
/** Base file name of the bulk ZIP, e.g. `komprimiert` → `komprimiert_2026-02-12.zip`. */
|
|
zipBaseName: string;
|
|
onDownload: (item: BatchItem) => void;
|
|
onRemove: (id: string) => void;
|
|
onRetry: (id: string) => void;
|
|
};
|
|
|
|
let { items, zipBaseName, onDownload, onRemove, onRetry }: Props = $props();
|
|
|
|
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(
|
|
items.length > 20 || items.some((item) => item.file.size > RASTERIZATION_WARNING_BYTES)
|
|
);
|
|
|
|
const uniqueEntryName = (name: string, usedNames: Set<string>) => {
|
|
if (!usedNames.has(name)) {
|
|
usedNames.add(name);
|
|
return name;
|
|
}
|
|
const dotIndex = name.lastIndexOf('.');
|
|
const base = dotIndex > 0 ? name.slice(0, dotIndex) : name;
|
|
const extension = dotIndex > 0 ? name.slice(dotIndex) : '';
|
|
let counter = 2;
|
|
let candidate = `${base}_2${extension}`;
|
|
while (usedNames.has(candidate)) {
|
|
counter += 1;
|
|
candidate = `${base}_${counter}${extension}`;
|
|
}
|
|
usedNames.add(candidate);
|
|
return candidate;
|
|
};
|
|
|
|
const downloadZip = () => {
|
|
const entries: Record<string, Uint8Array> = {};
|
|
const usedNames = new Set<string>();
|
|
for (const item of doneItems) {
|
|
if (!item.resultBytes || !item.resultName) continue;
|
|
entries[uniqueEntryName(item.resultName, usedNames)] = item.resultBytes;
|
|
}
|
|
if (Object.keys(entries).length === 0) return;
|
|
const dateStamp = new Date().toISOString().slice(0, 10);
|
|
downloadBlob(zipSync(entries), `${zipBaseName}_${dateStamp}.zip`, 'application/zip');
|
|
};
|
|
|
|
const statusChipClass = (status: BatchItem['status']) => {
|
|
switch (status) {
|
|
case 'processing':
|
|
return 'bg-amber-100 text-amber-800';
|
|
case 'done':
|
|
return 'bg-emerald-100 text-emerald-800';
|
|
case 'error':
|
|
return 'bg-red-100 text-red-800';
|
|
default:
|
|
return 'bg-accent text-primary';
|
|
}
|
|
};
|
|
|
|
const statusLabel = (status: BatchItem['status']) => {
|
|
switch (status) {
|
|
case 'processing':
|
|
return 'Verarbeite…';
|
|
case 'done':
|
|
return 'Fertig ✓';
|
|
case 'error':
|
|
return 'Fehler';
|
|
default:
|
|
return 'Wartet';
|
|
}
|
|
};
|
|
</script>
|
|
|
|
<div class="rounded-2xl border border-border bg-card p-4 space-y-3">
|
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
|
<h2 class="text-lg font-semibold text-foreground">Dateien ({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 (item.id)}
|
|
<li class="flex flex-wrap items-center gap-3 py-3" data-batch-item={item.file.name}>
|
|
<div class="min-w-0 flex-1">
|
|
<p class="truncate text-sm font-medium text-foreground">{item.file.name}</p>
|
|
<p class="text-xs text-primary">
|
|
{formatBytes(item.file.size)}
|
|
{#if item.status === 'done' && item.resultBytes}
|
|
→ {formatBytes(item.resultBytes.length)}
|
|
{/if}
|
|
</p>
|
|
{#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 {statusChipClass(
|
|
item.status
|
|
)}"
|
|
>
|
|
{statusLabel(item.status)}
|
|
</span>
|
|
|
|
<div class="flex shrink-0 items-center gap-1">
|
|
{#if item.status === 'done' && item.resultBytes && item.resultName}
|
|
<button
|
|
type="button"
|
|
class="rounded-lg p-2 text-primary transition hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-400"
|
|
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="rounded-lg p-2 text-primary transition hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-400"
|
|
aria-label="{item.file.name} erneut versuchen"
|
|
title="{item.file.name} erneut versuchen"
|
|
onclick={() => onRetry(item.id)}
|
|
>
|
|
<RotateCcw class="h-4 w-4" />
|
|
</button>
|
|
{/if}
|
|
<button
|
|
type="button"
|
|
class="rounded-lg p-2 text-primary transition hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-400 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>
|