refactoring, numbering plan

This commit is contained in:
rehlert committed 2026-10-28 16:09:53 +01:00
1 parent b5279434b2
commit 3b04039aa9
47 files changed
+3485 -953

No files matched your search

+5 -4
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { pdfLoadErrorMessage } from '$lib/pdf-errors';
import type { ZipAttachment } from '$lib/zip-processing';
@@ -98,7 +99,7 @@
await loadingTask.destroy();
} catch (error) {
console.error(`Failed to render preview for ${attachment.path}`, error);
previewError = 'PDF-Vorschau konnte nicht geladen werden.';
previewError = pdfLoadErrorMessage(error, 'PDF-Vorschau konnte nicht geladen werden.');
}
};
@@ -115,19 +116,19 @@
<div class={className}>
{#if previewError}
<div
class="flex h-full min-h-0 items-center justify-center rounded-md border border-dashed border-primary-200 bg-primary-50 px-3 py-4 text-center text-[12px] text-primary-800"
class="border-primary-200 bg-primary-50 text-primary-800 flex h-full min-h-0 items-center justify-center rounded-md border border-dashed px-3 py-4 text-center text-[12px]"
>
{previewError}
</div>
{:else if imageUrl}
<img
alt={attachment.name}
class="block rounded-md border border-primary-100 bg-white object-contain"
class="border-primary-100 block rounded-md border bg-white object-contain"
src={imageUrl}
/>
{:else}
<div
class="flex items-center justify-center rounded-md border border-dashed border-primary-200 bg-primary-50 px-3 py-4 text-center text-[12px] text-primary-800"
class="border-primary-200 bg-primary-50 text-primary-800 flex items-center justify-center rounded-md border border-dashed px-3 py-4 text-center text-[12px]"
>
Vorschau wird erstellt
</div>
+16 -68
View File
@@ -1,10 +1,14 @@
<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';
import {
batchStatusClass,
batchStatusLabel,
downloadBatchZip,
formatBytes,
shouldWarnAboutBatchMemory,
type BatchItem
} from '$lib/batch';
type Props = {
items: BatchItem[];
@@ -22,68 +26,12 @@
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 showMemoryWarning = $derived(shouldWarnAboutBatchMemory(items));
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';
}
};
const downloadZip = () => downloadBatchZip(doneItems, zipBaseName);
</script>
<div class="rounded-2xl border border-border bg-card p-4 space-y-3">
<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">Dateien ({items.length})</h2>
{#if doneItems.length > 0}
@@ -123,18 +71,18 @@
</div>
<span
class="shrink-0 rounded-full px-2.5 py-0.5 text-xs font-semibold {statusChipClass(
class="shrink-0 rounded-full px-2.5 py-0.5 text-xs font-semibold {batchStatusClass(
item.status
)}"
>
{statusLabel(item.status)}
{batchStatusLabel(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"
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)}
@@ -145,7 +93,7 @@
{#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"
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} erneut versuchen"
title="{item.file.name} erneut versuchen"
onclick={() => onRetry(item.id)}
@@ -155,7 +103,7 @@
{/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"
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'}
+269
View File
@@ -0,0 +1,269 @@
<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>
+164
View File
@@ -0,0 +1,164 @@
<script lang="ts">
import { ChevronLeft, ChevronRight } from '@lucide/svelte';
import Button from '$lib/components/ui/button.svelte';
import { MIN_FONT_SIZE, PAGE_NUMBER_COLOR, PAGE_NUMBER_MARGIN } from '$lib/pdf-pagination';
import { getPreviewPositionStyle, type OverlayPosition } from '$lib/pdf-overlay';
import { getTextFitScale } from '$lib/pdf-preview-text';
import { PDF_PREVIEW_SCALE, type PdfPageImage } from '$lib/pdf-thumbnails';
type Props = {
/** Rendered page bitmap, or `null` while the first render is pending. */
preview: PdfPageImage | null;
/** Message shown instead of the bitmap when rendering failed. */
error: string | null;
/** Label the export will stamp on this page. */
label: string;
fontSize: number;
position: OverlayPosition;
currentPage: number;
pageCount: number;
onRetry: () => void;
onShowPage: (pageNumber: number) => void;
};
let {
preview,
error,
label,
fontSize,
position,
currentPage,
pageCount,
onRetry,
onShowPage
}: Props = $props();
const pageSize = $derived(
preview
? { width: preview.width / PDF_PREVIEW_SCALE, height: preview.height / PDF_PREVIEW_SCALE }
: null
);
/**
* The export shrinks a label that does not fit (down to `MIN_FONT_SIZE`), so
* the preview applies the same factor instead of clipping.
*/
const fit = $derived.by(() => {
if (!pageSize) return { scale: 1, shrunk: false, tooLong: false };
const raw = getTextFitScale(
label,
fontSize,
pageSize.width - PAGE_NUMBER_MARGIN * 2,
pageSize.height - PAGE_NUMBER_MARGIN * 2
);
const minScale = MIN_FONT_SIZE / fontSize;
return { scale: Math.max(raw, minScale), shrunk: raw < 0.999, tooLong: raw < minScale };
});
const previewFontSize = $derived(
preview ? (fontSize * fit.scale * PDF_PREVIEW_SCALE * 100) / preview.width : 0
);
const shrunkFontSize = $derived(Math.max(MIN_FONT_SIZE, Math.round(fontSize * fit.scale)));
</script>
<section
class="flex flex-col gap-4 rounded-2xl border border-border bg-card p-5"
aria-labelledby="pagination-preview-title"
>
<div class="flex items-start justify-between gap-3">
<div>
<h2 id="pagination-preview-title" class="text-lg font-semibold text-foreground">Vorschau</h2>
<p class="mt-1 text-xs text-muted-foreground">Annäherung an die Ausgabe im PDF</p>
</div>
<span class="text-xs font-semibold text-primary">Seite {currentPage} von {pageCount}</span>
</div>
{#if error}
<div
class="flex flex-col items-center gap-3 rounded-lg border border-border bg-muted/30 p-6 text-center"
role="alert"
>
<p class="text-sm text-foreground">{error}</p>
<Button variant="tertiary" onclick={onRetry}>Erneut versuchen</Button>
</div>
{:else if preview}
<div
class="relative mx-auto w-full max-w-lg overflow-hidden rounded-lg border border-border bg-white shadow-sm"
style="container-type: inline-size; aspect-ratio: {preview.width} / {preview.height};"
>
<img
src={preview.imageUrl}
alt="Vorschau von Seite {currentPage}"
class="block h-full w-full"
/>
<div
class="absolute max-w-[92%] leading-tight whitespace-nowrap"
style="{getPreviewPositionStyle(
position
)} font-size: {previewFontSize}cqw; color: {PAGE_NUMBER_COLOR};"
data-testid="pagination-preview"
data-position={position}
>
{label}
</div>
</div>
{:else}
<div
class="flex aspect-[1/1.414] items-center justify-center rounded-lg border border-border bg-muted/30 text-xs text-muted-foreground"
>
Vorschau wird geladen …
</div>
{/if}
{#if fit.tooLong}
<p class="text-xs text-destructive" role="status">
Der Text ist für diese Seite zu lang. Kürzen Sie den Zusatztext oder wählen Sie ein kürzeres
Zahlenformat.
</p>
{:else if fit.shrunk}
<p class="text-xs text-muted-foreground" role="status">
Auf dieser Seite wird der Text auf {shrunkFontSize} pt verkleinert.
</p>
{/if}
{#if pageCount > 1}
<div class="flex flex-wrap items-center justify-center gap-3">
<Button
variant="tertiary"
class="min-w-0 px-3"
onclick={() => onShowPage(currentPage - 1)}
disabled={currentPage === 1}
aria-label="Vorherige Seite"
>
<ChevronLeft aria-hidden="true" />
</Button>
<Button
variant="tertiary"
class="min-w-0 px-3"
onclick={() => onShowPage(currentPage + 1)}
disabled={currentPage === pageCount}
aria-label="Nächste Seite"
>
<ChevronRight aria-hidden="true" />
</Button>
<label class="flex items-center gap-2 text-sm text-foreground" for="pagination-page">
<span class="text-xs text-muted-foreground">Seite</span>
<input
id="pagination-page"
type="number"
inputmode="numeric"
step="1"
min="1"
max={pageCount}
value={currentPage}
oninput={(event) => onShowPage(Number(event.currentTarget.value))}
onblur={(event) => (event.currentTarget.value = String(currentPage))}
aria-label="Springe zu Seite"
class="w-20 rounded-lg border border-input bg-background px-2 py-1 text-sm"
/>
</label>
</div>
{/if}
</section>
+21 -20
View File
@@ -3,6 +3,7 @@
import { X } from '@lucide/svelte';
import Button from '$lib/components/ui/button.svelte';
import PdfPageGrid from '$lib/components/PdfPageGrid.svelte';
import { pdfLoadErrorMessage, pdfProcessingErrorMessage } from '$lib/pdf-errors';
import { loadPdfDocument, type PdfDocumentHandle } from '$lib/pdf-thumbnails';
import {
createAttachmentFromPages,
@@ -172,7 +173,7 @@
onApply(await buildResult(groups));
} catch (error) {
console.error(`Failed to split PDF attachment ${source.path}`, error);
splitError = 'Das PDF konnte nicht geteilt werden.';
splitError = pdfProcessingErrorMessage(error, 'Das PDF konnte nicht geteilt werden.');
} finally {
isProcessing = false;
}
@@ -209,7 +210,7 @@
console.error(`Failed to load PDF attachment ${source.path}`, error);
if (!cancelled) {
loadError = 'PDF konnte nicht geladen werden.';
loadError = pdfLoadErrorMessage(error);
}
}
})();
@@ -237,16 +238,16 @@
data-split-dialog
tabindex="-1"
>
<header class="flex items-center justify-between gap-3 border-b border-primary-100 px-5 py-4">
<header class="border-primary-100 flex items-center justify-between gap-3 border-b px-5 py-4">
<div class="min-w-0">
<h2 class="text-[16px] font-bold text-primary-900">PDF teilen</h2>
<p class="truncate text-[13px] text-primary-700" title={source.name}>
<h2 class="text-primary-900 text-[16px] font-bold">PDF teilen</h2>
<p class="text-primary-700 truncate text-[13px]" title={source.name}>
{source.name} — {pageCount} Seite{pageCount === 1 ? '' : 'n'}
</p>
</div>
<button
type="button"
class="flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-full border border-primary-200 bg-white text-primary-700 transition hover:border-primary-400 hover:bg-primary-50"
class="border-primary-200 text-primary-700 hover:border-primary-400 hover:bg-primary-50 flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-full border bg-white transition"
aria-label="Teilen abbrechen"
onclick={onClose}
>
@@ -256,7 +257,7 @@
<div class="flex-1 space-y-4 overflow-y-auto px-5 py-4">
<div
class="inline-flex flex-wrap items-center rounded-[16px] border border-primary-200 bg-white p-0.5"
class="border-primary-200 inline-flex flex-wrap items-center rounded-[16px] border bg-white p-0.5"
role="radiogroup"
aria-label="Art des Teilens"
>
@@ -283,10 +284,10 @@
</div>
{#if mode === 'everyN'}
<label class="flex flex-wrap items-center gap-2 text-[13px] font-semibold text-primary-900">
<label class="text-primary-900 flex flex-wrap items-center gap-2 text-[13px] font-semibold">
Seiten pro Teildokument
<input
class="w-20 rounded-md border border-primary-200 bg-white px-2 py-1 text-[13px] font-medium text-primary-900 outline-none transition focus:border-primary-400 focus:ring-2 focus:ring-primary-200"
class="border-primary-200 text-primary-900 focus:border-primary-400 focus:ring-primary-200 w-20 rounded-md border bg-white px-2 py-1 text-[13px] font-medium transition outline-none focus:ring-2"
type="number"
min={2}
value={chunkSize}
@@ -298,7 +299,7 @@
{/if}
{#if mode !== 'manual' && pageCount > 0}
<p class="text-[13px] font-medium text-primary-700" aria-live="polite">
<p class="text-primary-700 text-[13px] font-medium" aria-live="polite">
Es werden {bulkChunks.length}
Teildokument{bulkChunks.length === 1 ? '' : 'e'} erstellt.
</p>
@@ -329,7 +330,7 @@
</PdfPageGrid>
{:else}
<div
class="flex items-center justify-center rounded-md border border-dashed border-primary-200 bg-primary-50 px-4 py-10 text-[13px] text-primary-700"
class="border-primary-200 bg-primary-50 text-primary-700 flex items-center justify-center rounded-md border border-dashed px-4 py-10 text-[13px]"
>
Vorschau wird erstellt
</div>
@@ -340,7 +341,7 @@
<label class="flex min-w-0 flex-1 items-center gap-2">
<span class="sr-only">Name des Teildokuments</span>
<input
class="w-full min-w-40 rounded-md border border-primary-200 bg-white px-3 py-1.5 text-[13px] font-semibold text-primary-900 outline-none transition focus:border-primary-400 focus:ring-2 focus:ring-primary-200"
class="border-primary-200 text-primary-900 focus:border-primary-400 focus:ring-primary-200 w-full min-w-40 rounded-md border bg-white px-3 py-1.5 text-[13px] font-semibold transition outline-none focus:ring-2"
type="text"
placeholder={selectedPages.size > 0
? defaultGroupName([...selectedPages])
@@ -359,7 +360,7 @@
Als Teildokument übernehmen
</Button>
</div>
<p class="text-[13px] text-primary-700" aria-live="polite">
<p class="text-primary-700 text-[13px]" aria-live="polite">
{selectedPages.size} von {pageCount} Seiten ausgewählt
</p>
{/if}
@@ -368,17 +369,17 @@
<ul class="flex flex-col gap-2">
{#each assignedGroups as group, index (index)}
<li
class="flex flex-wrap items-center gap-2 rounded-xl border border-primary-200 bg-primary-50 px-3 py-1.5"
class="border-primary-200 bg-primary-50 flex flex-wrap items-center gap-2 rounded-xl border px-3 py-1.5"
>
<span class="min-w-0 flex-1 truncate text-[13px] font-semibold text-primary-900">
<span class="text-primary-900 min-w-0 flex-1 truncate text-[13px] font-semibold">
{group.name}
</span>
<span class="text-[12px] font-medium text-primary-700">
<span class="text-primary-700 text-[12px] font-medium">
{describePages(group.pages)}
</span>
<button
type="button"
class="flex h-6 w-6 cursor-pointer items-center justify-center rounded-full text-primary-600 transition hover:bg-white hover:text-red-700"
class="text-primary-600 flex h-6 w-6 cursor-pointer items-center justify-center rounded-full transition hover:bg-white hover:text-red-700"
aria-label={`Zuordnung „${group.name}“ aufheben`}
onclick={() => removeGroup(index)}
>
@@ -395,9 +396,9 @@
</div>
{/if}
<label class="flex items-center gap-2 text-[13px] font-medium text-primary-900">
<label class="text-primary-900 flex items-center gap-2 text-[13px] font-medium">
<input
class="h-4 w-4 accent-primary-700"
class="accent-primary-700 h-4 w-4"
type="checkbox"
checked={keepSource}
onchange={(event) => {
@@ -409,7 +410,7 @@
</div>
<footer
class="flex flex-wrap items-center justify-end gap-2 border-t border-primary-100 px-5 py-4"
class="border-primary-100 flex flex-wrap items-center justify-end gap-2 border-t px-5 py-4"
>
<Button type="button" variant="bordered" disabled={isProcessing} onclick={onClose}>
Abbrechen
@@ -5,6 +5,7 @@
import Button from '$lib/components/ui/button.svelte';
import * as AlertDialog from '$lib/components/ui/alert-dialog/index.js';
import PdfSplitDialog from '$lib/components/PdfSplitDialog.svelte';
import { pdfProcessingErrorMessage } from '$lib/pdf-errors';
import SubDocumentEditor from '$lib/components/SubDocumentEditor.svelte';
import {
applyPdfSplit,
@@ -447,7 +448,7 @@
}
} catch (error) {
console.error(`Failed to export archive ${archive.name}`, error);
mergeError = 'PDF konnte nicht erstellt werden.';
mergeError = pdfProcessingErrorMessage(error, 'PDF konnte nicht erstellt werden.');
} finally {
isMerging = false;
}
@@ -470,7 +471,10 @@
`Failed to export sub-document "${subDocument.name}" from archive ${archive.name}`,
error
);
mergeError = `Teildokument „${subDocument.name}“ konnte nicht als PDF erstellt werden.`;
mergeError = pdfProcessingErrorMessage(
error,
`Teildokument „${subDocument.name}“ konnte nicht als PDF erstellt werden.`
);
} finally {
downloadingSubDocumentId = null;
}
@@ -480,14 +484,14 @@
<svelte:window onkeydown={handleWindowKeydown} />
<section
class="flex flex-col gap-4 rounded-2xl border border-primary-100 bg-white px-4 py-4 shadow-sm"
class="border-primary-100 flex flex-col gap-4 rounded-2xl border bg-white px-4 py-4 shadow-sm"
>
<header class="flex flex-wrap items-baseline justify-between gap-2 text-primary">
<div class="min-w-0 flex-1">
<label class="block min-w-0">
<span class="sr-only">Archivname</span>
<input
class="w-full max-w-xl rounded-md border border-primary-200 bg-white px-3 py-2 text-[16px] font-bold text-primary-900 outline-none transition focus:border-primary-400 focus:ring-2 focus:ring-primary-200"
class="border-primary-200 text-primary-900 focus:border-primary-400 focus:ring-primary-200 w-full max-w-xl rounded-md border bg-white px-3 py-2 text-[16px] font-bold transition outline-none focus:ring-2"
type="text"
value={archiveName}
draggable="false"
@@ -532,7 +536,7 @@
{#if hasSubDocuments}
<div
class={cn(
'inline-flex items-center rounded-[16px] border border-primary-200 bg-white p-0.5',
'border-primary-200 inline-flex items-center rounded-[16px] border bg-white p-0.5',
downloadingSubDocumentId !== null ? 'opacity-60' : ''
)}
role="radiogroup"
@@ -584,7 +588,7 @@
{#if groupingMode}
<div
class="flex flex-wrap items-center gap-3 rounded-xl border border-primary-200 bg-primary-50 px-4 py-2 text-[13px] font-medium text-primary-900"
class="border-primary-200 bg-primary-50 text-primary-900 flex flex-wrap items-center gap-3 rounded-xl border px-4 py-2 text-[13px] font-medium"
>
<span aria-live="polite">
{selectedCount} Datei{selectedCount === 1 ? '' : 'en'} ausgewählt
@@ -634,10 +638,10 @@
{#each looseAttachments as attachment, index (attachment.path)}
<li
class={cn(
'flex flex-col shrink-0 items-stretch gap-2 rounded-xl border bg-accent p-2 text-primary transition',
dropIndex === index ? 'border-primary-400 ring-1 ring-primary-200' : 'border-primary-100',
'flex shrink-0 flex-col items-stretch gap-2 rounded-xl border bg-accent p-2 text-primary transition',
dropIndex === index ? 'border-primary-400 ring-primary-200 ring-1' : 'border-primary-100',
dragIndex === index ? 'opacity-60' : '',
selectedPaths.has(attachment.path) ? 'border-primary-500 ring-2 ring-primary-300' : ''
selectedPaths.has(attachment.path) ? 'border-primary-500 ring-primary-300 ring-2' : ''
)}
style={`width: ${thumbnailWidth}px;`}
draggable={groupingMode ? 'false' : 'true'}
@@ -648,9 +652,9 @@
>
{#if groupingMode}
<label class="flex cursor-pointer flex-col gap-2">
<div class="flex items-center gap-2 px-1 text-[13px] font-medium text-primary-900">
<div class="text-primary-900 flex items-center gap-2 px-1 text-[13px] font-medium">
<input
class="h-4 w-4 accent-primary-700"
class="accent-primary-700 h-4 w-4"
type="checkbox"
checked={selectedPaths.has(attachment.path)}
onchange={() => toggleSelection(attachment.path)}
@@ -661,7 +665,7 @@
</div>
<div
class="truncate text-[13px] font-medium leading-tight text-primary-900"
class="text-primary-900 truncate text-[13px] leading-tight font-medium"
title={attachment.name}
>
{attachment.name}
@@ -671,7 +675,7 @@
{:else}
<div class="flex min-w-0 flex-1 flex-row items-center gap-1 py-1">
<button
class="w-fit cursor-grab rounded-md border border-primary-200 bg-white px-2 py-1 text-sm font-semibold leading-none text-primary-700 active:cursor-grabbing"
class="border-primary-200 text-primary-700 w-fit cursor-grab rounded-md border bg-white px-2 py-1 text-sm leading-none font-semibold active:cursor-grabbing"
draggable="false"
type="button"
aria-label="Datei verschieben"
@@ -680,7 +684,7 @@
</button>
<div
class="min-w-0 flex-1 truncate text-[13px] font-medium leading-tight text-primary-900"
class="text-primary-900 min-w-0 flex-1 truncate text-[13px] leading-tight font-medium"
title={attachment.name}
>
{attachment.name}
@@ -703,7 +707,7 @@
{#if looseAttachments.length === 0 && subDocuments.length === 0}
<div
class="rounded-md border border-dashed border-primary-200 bg-primary-50 px-4 py-4 text-[13px] text-primary-700"
class="border-primary-200 bg-primary-50 text-primary-700 rounded-md border border-dashed px-4 py-4 text-[13px]"
>
Keine Dateien in diesem Archiv. Lade ein anderes ZIP, um weiterzuarbeiten.
</div>
@@ -758,7 +762,7 @@
}}
>
<AlertDialog.Content class="max-w-sm">
<AlertDialog.Title class="text-base font-bold text-primary-900">
<AlertDialog.Title class="text-primary-900 text-base font-bold">
Datei entfernen?
</AlertDialog.Title>
<AlertDialog.Description>
+73
View File
@@ -0,0 +1,73 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import FileDropzone from '$lib/components/FileDropzone.svelte';
import ToolError from '$lib/components/ToolError.svelte';
type Props = {
title: string;
description: string;
file: File | null;
/** `0` while no PDF is loaded; switches the shell to the bare dropzone. */
pageCount: number;
loadingError: string | null;
error?: string | null;
onFilesSelected: (files: File[]) => void;
/** Extra toolbar buttons next to the file name. */
actions?: Snippet;
/** Main content, e.g. the page grid. */
children: Snippet;
/** Status line plus primary action, laid out `space-between`. */
footer: Snippet;
};
let {
title,
description,
file,
pageCount,
loadingError,
error,
onFilesSelected,
actions,
children,
footer
}: Props = $props();
</script>
<div class="mx-auto max-w-4xl space-y-6">
<div>
<h1 class="text-primary-900 text-2xl font-bold">{title}</h1>
<p class="text-primary-700 mt-2 text-sm">{description}</p>
</div>
{#if pageCount === 0}
<FileDropzone {onFilesSelected} />
<ToolError message={loadingError} class="mt-4" />
{:else}
<div class="space-y-4">
<FileDropzone
{onFilesSelected}
label="Anderes PDF ablegen"
sublabel={file?.name ?? ''}
class="py-4"
/>
<div class="flex flex-wrap items-center justify-between gap-3">
<h2 class="text-primary-900 text-lg font-semibold">
{file?.name} — {pageCount} Seite{pageCount === 1 ? '' : 'n'}
</h2>
<div class="flex flex-wrap gap-2">
{@render actions?.()}
</div>
</div>
{@render children()}
<ToolError message={error} />
<div class="flex items-center justify-between">
{@render footer()}
</div>
</div>
{/if}
</div>
+20
View File
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn } from '$lib/utils';
type Props = {
/** German message; nothing is rendered when the value is empty. */
message?: string | null;
class?: string;
};
let { message, class: className = '' }: Props = $props();
</script>
{#if message}
<div
class={cn('rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800', className)}
role="alert"
>
{message}
</div>
{/if}