adds features
Build and Push Docker Image / build (push) Successful in 1m28s

This commit is contained in:
rehlert committed 2026-08-27 07:38:35 +02:00
1 parent 891026ccbb
commit adaae41e44
33 files changed
+2771 -396

No files matched your search

+132 -194
View File
@@ -1,211 +1,149 @@
<script lang="ts">
import {onMount} from 'svelte';
import Button from '$lib/components/ui/button.svelte';
import ProcessedZipArchiveEditor from '$lib/components/ProcessedZipArchiveEditor.svelte';
import ZipDropzone from '$lib/components/ZipDropzone.svelte';
import {
extractZipArchives,
mergeProcessedZipArchive,
type ProcessedZipArchive
} from '$lib/zip-processing';
import * as Card from "$lib/components/ui/card/index";
import {
Archive,
Droplets,
Lock,
Unlock,
Image,
Minimize2,
Scissors,
Merge,
FileText
} from '@lucide/svelte';
import { goto } from '$app/navigation';
import FileDropzone from '$lib/components/FileDropzone.svelte';
import * as Card from '$lib/components/ui/card/index';
import { queueZipFiles } from '$lib/pending-files';
import { tools } from '$lib/tools';
const THUMBNAIL_WIDTH_STORAGE_KEY = 'thumbnailWidth';
const iconMap: Record<string, typeof FileText> = {
droplets: Droplets,
lock: Lock,
unlock: Unlock,
image: Image,
minimize: Minimize2,
scissors: Scissors,
merge: Merge
};
let selectedZipFiles: File[] = $state([]);
let processedZipFiles: ProcessedZipArchive[] = $state([]);
let pendingZipCount = $state(0);
let thumbnailWidth = $state(200);
let thumbnailWidthHydrated = $state(false);
let archiveGeneration = 0;
let isDraggingFiles = $state(false);
const clampThumbnailWidth = (value: number) => Math.min(400, Math.max(100, value));
const isZipFile = (file: File) =>
file.name.toLowerCase().endsWith('.zip') ||
file.type === 'application/zip' ||
file.type === 'application/x-zip-compressed';
onMount(() => {
const storedValue = localStorage.getItem(THUMBNAIL_WIDTH_STORAGE_KEY);
const openBeaFiles = (files: File[]) => {
const zipFiles = files.filter(isZipFile);
if (zipFiles.length === 0) return;
if (storedValue !== null) {
const parsedValue = Number(storedValue);
queueZipFiles(zipFiles);
void goto('/tools/bea');
};
if (Number.isFinite(parsedValue)) {
thumbnailWidth = clampThumbnailWidth(parsedValue);
}
}
const handleWindowDragOver = (event: DragEvent) => {
if (!event.dataTransfer?.types.includes('Files')) return;
event.preventDefault();
isDraggingFiles = true;
};
thumbnailWidthHydrated = true;
});
const handleWindowDragLeave = (event: DragEvent) => {
if (event.relatedTarget === null) isDraggingFiles = false;
};
$effect(() => {
if (!thumbnailWidthHydrated) {
return;
}
localStorage.setItem(THUMBNAIL_WIDTH_STORAGE_KEY, String(thumbnailWidth));
});
const toArrayBuffer = (bytes: Uint8Array) => {
const copy = new Uint8Array(bytes.byteLength);
copy.set(bytes);
return copy.buffer;
};
const downloadPdfBytes = (bytes: Uint8Array, fileName: string) => {
const pdfBlob = new Blob([toArrayBuffer(bytes)], {type: 'application/pdf'});
const objectUrl = URL.createObjectURL(pdfBlob);
const downloadLink = document.createElement('a');
downloadLink.href = objectUrl;
downloadLink.download = fileName.toLowerCase().endsWith('.pdf') ? fileName : `${fileName}.pdf`;
downloadLink.rel = 'noopener';
downloadLink.click();
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
};
const handleFilesSelected = (files: File[]) => {
selectedZipFiles = [...selectedZipFiles, ...files];
for (const file of files) {
void processZipFile(file, archiveGeneration);
}
};
const processZipFile = async (file: File, generation: number) => {
pendingZipCount += 1;
try {
const processedZipArchives = await extractZipArchives(file);
if (generation !== archiveGeneration) {
return;
}
processedZipFiles = [...processedZipFiles, ...processedZipArchives];
} catch (error) {
console.error(`Failed to process ${file.name}`, error);
} finally {
if (generation === archiveGeneration) {
pendingZipCount -= 1;
}
}
};
const updateProcessedZipFile = (index: number, archive: ProcessedZipArchive) => {
processedZipFiles = processedZipFiles.map((existingArchive, existingIndex) =>
existingIndex === index ? archive : existingArchive
);
};
const exportAllZipArchivesAsPdf = async () => {
for (const archive of processedZipFiles) {
try {
const mergedBytes = await mergeProcessedZipArchive(archive);
downloadPdfBytes(mergedBytes, archive.name);
} catch (error) {
console.error(`Failed to export archive ${archive.name}`, error);
}
}
};
const deleteAllZipArchives = () => {
archiveGeneration += 1;
selectedZipFiles = [];
processedZipFiles = [];
pendingZipCount = 0;
};
const handleWindowDrop = (event: DragEvent) => {
if (!event.dataTransfer?.files.length) return;
event.preventDefault();
isDraggingFiles = false;
openBeaFiles(Array.from(event.dataTransfer.files));
};
</script>
{#snippet content()}
{#if selectedZipFiles.length === 0}
<Card.Root>
<Card.Content>
<ZipDropzone onFilesSelected={handleFilesSelected}/>
</Card.Content>
</Card.Root>
{:else}
<details class="fixed bottom-4 right-4 z-20">
<summary
class="flex h-12 w-12 cursor-pointer list-none items-center justify-center rounded-full border border-primary-200 bg-white text-lg font-semibold text-primary-800 shadow-lg transition hover:border-primary-300 hover:bg-primary-50"
>
<span class="sr-only">Settings</span>
<span>⚙️</span>
</summary>
<svelte:head>
<title>juri-merger — PDF-Werkzeuge</title>
</svelte:head>
<div
class="absolute bottom-14 right-0 w-72 rounded-xl border border-primary-100 bg-white p-4 shadow-lg"
>
<div class="flex flex-col gap-3">
<div class="flex gap-2">
<Button
class="flex-1"
type="button"
disabled={processedZipFiles.length === 0}
onclick={exportAllZipArchivesAsPdf}
>
Alle ZIP-Archive als PDF exportieren
</Button>
<svelte:window
ondragover={handleWindowDragOver}
ondragleave={handleWindowDragLeave}
ondrop={handleWindowDrop}
/>
<Button
class="flex-1"
type="button"
disabled={selectedZipFiles.length === 0}
variant="secondary"
onclick={deleteAllZipArchives}
>
Alle ZIP-Archive löschen
</Button>
</div>
{#if isDraggingFiles}
<div
class="pointer-events-none fixed inset-3 z-50 grid place-items-center rounded-3xl border-4 border-dashed border-primary bg-background/95 text-center shadow-2xl"
aria-hidden="true"
>
<div>
<Archive class="mx-auto h-12 w-12" />
<p class="mt-4 text-xl font-bold">ZIP-Archiv hier ablegen</p>
</div>
</div>
{/if}
<label class="flex flex-col gap-2">
<div
class="flex items-center justify-between gap-3 text-sm font-medium text-primary-900"
>
<span>Thumbnail Breite</span>
<span class="tabular-nums text-primary-700">{thumbnailWidth}px</span>
</div>
<div class="flex flex-col gap-10 px-4 py-10">
<!-- Hero -->
<div class="flex flex-col items-center gap-4 text-center">
<h1 class="text-[28px] font-bold text-primary-900">PDF-Werkzeuge</h1>
<p class="max-w-lg text-[15px] text-primary-700">
Alle Werkzeuge laufen zu 100 % in Ihrem Browser — keine Dateien werden hochgeladen.
</p>
</div>
<input
class="w-full accent-primary-700"
type="range"
min="100"
max="400"
step="1"
bind:value={thumbnailWidth}
/>
</label>
</div>
</div>
</details>
<div
class="flex min-h-[calc(-180px+100vh)] w-full grow flex-col justify-center gap-4 px-4 py-6"
>
<div class="mx-auto flex w-full flex-col gap-3">
{#if pendingZipCount > 0}
<div
class="rounded-container bg-primary-50 px-4 py-3 text-[14px] font-medium text-primary-900"
>
Noch {pendingZipCount} ZIP-Datei{pendingZipCount === 1 ? '' : 'en'} in Bearbeitung.
</div>
{/if}
<!-- Hero tile: beA workflow -->
<a href="/tools/bea" class="block w-full max-w-2xl mx-auto">
<Card.Root class="transition hover:shadow-md cursor-pointer">
<Card.Content class="flex flex-col items-center gap-4 py-10">
<div
class="flex h-16 w-16 items-center justify-center rounded-2xl bg-primary text-primary-foreground"
>
<Archive class="h-8 w-8" />
</div>
<div class="text-center">
<h2 class="text-[18px] font-bold text-primary-900">ZIP-Archiv bearbeiten</h2>
<p class="mt-1 text-[14px] text-primary-700">
beA-Archive verarbeiten, Teildokumente erstellen und als PDF exportieren.
</p>
</div>
</Card.Content>
</Card.Root>
</a>
{#if processedZipFiles.length > 0}
<div class="flex flex-col gap-4">
{#each processedZipFiles as file, index}
<ProcessedZipArchiveEditor
archive={file}
{thumbnailWidth}
onArchiveChange={(updatedArchive) => updateProcessedZipFile(index, updatedArchive)}
/>
{/each}
</div>
{/if}
</div>
</div>
{/if}
{/snippet}
<!-- ZIP drop entry point -->
<div class="mx-auto w-full max-w-2xl">
<FileDropzone
onFilesSelected={openBeaFiles}
accept=".zip,application/zip,application/x-zip-compressed"
multiple={true}
ariaLabel="ZIP-Dateien hinzufügen"
label="ZIP-Datei hier ablegen"
sublabel="oder zum Auswählen klicken"
class="py-6"
/>
</div>
<section class="px-4">
<div class="flex flex-wrap items-center justify-between gap-3 py-6">
<h1 class="h1 text-[20px] font-bold text-primary-900">beA-Edit</h1>
</div>
{@render content()}
</section>
<!-- Tool grid -->
<div class="mx-auto w-full max-w-4xl">
<h2 class="mb-4 text-[18px] font-bold text-primary-900">Werkzeuge</h2>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each tools as tool}
{@const Icon = iconMap[tool.icon] ?? FileText}
<a href="/tools/{tool.slug}" class="block">
<Card.Root class="h-full transition hover:shadow-md cursor-pointer">
<Card.Content class="flex flex-col gap-3 py-6">
<div
class="flex h-10 w-10 items-center justify-center rounded-xl bg-primary/10 text-primary"
>
<Icon class="h-5 w-5" />
</div>
<div>
<h3 class="text-[15px] font-bold text-primary-900">{tool.title}</h3>
<p class="mt-1 text-[13px] leading-snug text-primary-700">{tool.description}</p>
</div>
</Card.Content>
</Card.Root>
</a>
{/each}
</div>
</div>
</div>