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

+21 -22
View File
@@ -1,31 +1,30 @@
export function ripple(node: HTMLElement) {
function handleClick(event: MouseEvent) {
const circle = document.createElement("span");
function handleClick(event: MouseEvent) {
const circle = document.createElement('span');
const rect = node.getBoundingClientRect();
const size = Math.max(rect.width, rect.height);
const rect = node.getBoundingClientRect();
const size = Math.max(rect.width, rect.height);
circle.style.width = `${size}px`;
circle.style.height = `${size}px`;
circle.style.width = `${size}px`;
circle.style.height = `${size}px`;
circle.style.left = `${event.clientX - rect.left - size / 2}px`;
circle.style.top = `${event.clientY - rect.top - size / 2}px`;
circle.style.left = `${event.clientX - rect.left - size / 2}px`;
circle.style.top = `${event.clientY - rect.top - size / 2}px`;
circle.className =
"absolute rounded-full bg-white/30 animate-ripple pointer-events-none";
circle.className = 'absolute rounded-full bg-white/30 animate-ripple pointer-events-none';
node.appendChild(circle);
node.appendChild(circle);
circle.addEventListener("animationend", () => {
circle.remove();
});
}
circle.addEventListener('animationend', () => {
circle.remove();
});
}
node.addEventListener("click", handleClick);
node.addEventListener('click', handleClick);
return {
destroy() {
node.removeEventListener("click", handleClick);
}
};
}
return {
destroy() {
node.removeEventListener('click', handleClick);
}
};
}
+124
View File
@@ -0,0 +1,124 @@
<script lang="ts">
type Props = {
onFilesSelected: (files: File[]) => void;
onFilesRejected?: (files: File[]) => void;
accept?: string;
multiple?: boolean;
ariaLabel?: string;
label?: string;
sublabel?: string;
class?: string;
};
let {
onFilesSelected,
onFilesRejected,
accept = '.pdf,application/pdf',
multiple = false,
ariaLabel = 'PDF-Dateien hinzufügen',
label = 'Dateien hinzufügen',
sublabel = 'PDF hier ablegen oder zum Auswählen klicken',
class: className = ''
}: Props = $props();
let fileInput = $state<HTMLInputElement | null>(null);
let isDragging = $state(false);
let rejectionMessage = $state<string | null>(null);
let dragDepth = 0;
const matchesAccept = (file: File) =>
accept
.split(',')
.map((value) => value.trim().toLowerCase())
.filter(Boolean)
.some((filter) => {
if (filter.startsWith('.')) return file.name.toLowerCase().endsWith(filter);
if (filter.endsWith('/*')) return file.type.toLowerCase().startsWith(filter.slice(0, -1));
return file.type.toLowerCase() === filter;
});
const processFiles = (files: FileList | null | undefined) => {
if (!files) return;
const received = Array.from(files);
const accepted = received.filter(matchesAccept);
const rejected = received.filter((file) => !matchesAccept(file));
if (rejected.length > 0) {
rejectionMessage = `${rejected.map((file) => file.name).join(', ')}: Dateityp nicht unterstützt.`;
onFilesRejected?.(rejected);
}
if (accepted.length > 0) {
rejectionMessage = null;
onFilesSelected(multiple ? accepted : accepted.slice(0, 1));
}
};
const openFileDialog = () => fileInput?.click();
const handleFileSelection = (event: Event) => {
const input = event.currentTarget as HTMLInputElement;
processFiles(input.files);
input.value = '';
};
const handleDragEnter = (event: DragEvent) => {
event.preventDefault();
dragDepth += 1;
isDragging = true;
};
const handleDragOver = (event: DragEvent) => {
event.preventDefault();
if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy';
};
const handleDragLeave = (event: DragEvent) => {
event.preventDefault();
dragDepth = Math.max(0, dragDepth - 1);
if (dragDepth === 0) isDragging = false;
};
const handleDrop = (event: DragEvent) => {
event.preventDefault();
event.stopPropagation();
dragDepth = 0;
isDragging = false;
processFiles(event.dataTransfer?.files);
};
</script>
<input
bind:this={fileInput}
class="hidden"
type="file"
{accept}
{multiple}
onchange={handleFileSelection}
/>
<button
type="button"
class="flex w-full cursor-pointer flex-col items-center justify-center gap-2 rounded-2xl border-2 border-dashed border-primary-200 bg-primary-50/50 px-6 py-10 text-center transition focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-400 {isDragging
? 'border-primary-400 bg-primary-100/60'
: ''} {className}"
aria-label={ariaLabel}
data-dropzone-ready={fileInput ? 'true' : 'false'}
onclick={openFileDialog}
ondragenter={handleDragEnter}
ondragover={handleDragOver}
ondragleave={handleDragLeave}
ondrop={handleDrop}
>
<div class="flex flex-row items-baseline justify-center gap-2">
<span class="text-[18px] font-bold text-primary-900">{label}</span>
<span class="text-[20px] font-extrabold text-primary">+</span>
</div>
{#if sublabel}
<p class="text-[13px] text-primary-700">{sublabel}</p>
{/if}
</button>
{#if rejectionMessage}
<p class="mt-2 text-sm font-medium text-red-700" role="alert">{rejectionMessage}</p>
{/if}
+54
View File
@@ -0,0 +1,54 @@
<script lang="ts">
import type { PdfThumbnail } from '$lib/pdf-thumbnails';
import { cn } from '$lib/utils';
type Props = {
thumbnails: PdfThumbnail[];
selectedPages?: Set<number>;
onPageClick?: (pageNumber: number) => void;
thumbnailWidth?: number;
class?: string;
};
let {
thumbnails,
selectedPages = new Set<number>(),
onPageClick,
thumbnailWidth = 120,
class: className = ''
}: Props = $props();
</script>
<div
class={cn('grid gap-3', className)}
style="grid-template-columns: repeat(auto-fill, minmax({thumbnailWidth}px, 1fr));"
>
{#each thumbnails as thumb}
{@const isSelected = selectedPages.has(thumb.pageNumber)}
<button
type="button"
class={cn(
'group relative flex flex-col items-center gap-2 rounded-xl border-2 p-2 transition focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-400',
isSelected
? 'border-primary bg-primary/5'
: 'border-transparent hover:border-primary-200 hover:bg-primary-50/50'
)}
onclick={() => onPageClick?.(thumb.pageNumber)}
>
<img
src={thumb.imageUrl}
alt="Seite {thumb.pageNumber}"
class="w-full rounded border border-primary-100 bg-white object-contain"
style="aspect-ratio: {thumb.width} / {thumb.height};"
/>
<span class="text-[12px] font-medium text-primary-700">Seite {thumb.pageNumber}</span>
{#if isSelected}
<div
class="absolute top-1 right-1 flex h-5 w-5 items-center justify-center rounded-full bg-primary text-[10px] font-bold text-white"
>
✓
</div>
{/if}
</button>
{/each}
</div>
@@ -4,6 +4,7 @@
import SubDocumentEditor from '$lib/components/SubDocumentEditor.svelte';
import {
buildArchiveExport,
buildSubDocumentExport,
createSubDocument,
dissolveSubDocument,
moveAttachmentIntoSubDocument,
@@ -41,8 +42,11 @@
let downloadMode = $state<ExportMode>('single');
let isMerging = $state(false);
let downloadingSubDocumentId = $state<string | null>(null);
let mergeError = $state<string | null>(null);
const isExportActive = $derived(isMerging || downloadingSubDocumentId !== null);
$effect(() => {
archiveName = archive.name;
looseAttachments = archive.attachments.map((attachment) => ({ ...attachment }));
@@ -293,7 +297,7 @@
return;
}
if (isMerging) {
if (isExportActive) {
return;
}
@@ -313,6 +317,29 @@
isMerging = false;
}
};
const downloadSubDocument = async (subDocument: SubDocument) => {
if (isExportActive || subDocument.attachments.length === 0) {
return;
}
downloadingSubDocumentId = subDocument.id;
mergeError = null;
try {
const exportUnit = await buildSubDocumentExport(archiveName, subDocument);
downloadPdfBytes(exportUnit.bytes, exportUnit.name);
} catch (error) {
console.error(
`Failed to export sub-document "${subDocument.name}" from archive ${archive.name}`,
error
);
mergeError = `Teildokument „${subDocument.name}“ konnte nicht als PDF erstellt werden.`;
} finally {
downloadingSubDocumentId = null;
}
};
</script>
<svelte:window onkeydown={handleWindowKeydown} />
@@ -354,17 +381,21 @@
{#if hasSubDocuments}
<div
class="inline-flex items-center rounded-[16px] border border-primary-200 bg-white p-0.5"
class={cn(
'inline-flex items-center rounded-[16px] border border-primary-200 bg-white p-0.5',
downloadingSubDocumentId !== null ? 'opacity-60' : ''
)}
role="radiogroup"
aria-label="Export-Modus"
>
{#each [{ value: 'single', label: 'Eine PDF' }, { value: 'separate', label: 'Getrennt' }] as option (option.value)}
<label
class={cn(
'cursor-pointer rounded-[14px] px-3 py-1 text-[13px] font-semibold transition',
'rounded-[14px] px-3 py-1 text-[13px] font-semibold transition',
downloadMode === option.value
? 'bg-primary text-primary-foreground'
: 'text-primary-700 hover:bg-primary-50'
: 'text-primary-700 hover:bg-primary-50',
downloadingSubDocumentId !== null ? 'cursor-not-allowed' : 'cursor-pointer'
)}
>
<input
@@ -373,6 +404,7 @@
name={`download-mode-${archive.name}`}
value={option.value}
checked={downloadMode === option.value}
disabled={downloadingSubDocumentId !== null}
onchange={() => {
downloadMode = option.value as ExportMode;
}}
@@ -386,7 +418,7 @@
<Button
class="bg-primary"
type="button"
disabled={(looseAttachments.length === 0 && subDocuments.length === 0) || isMerging}
disabled={(looseAttachments.length === 0 && subDocuments.length === 0) || isExportActive}
onclick={downloadArchive}
>
{isMerging ? 'PDF wird erstellt' : 'PDF herunterladen'}
@@ -495,6 +527,9 @@
onBlockDragOver={handleBlockDragOver}
onBlockDrop={handleBlockDrop}
onBlockDragEnd={handleBlockDragEnd}
onDownload={() => downloadSubDocument(subDocument)}
isDownloading={downloadingSubDocumentId === subDocument.id}
downloadDisabled={isExportActive}
/>
{/each}
</div>
@@ -0,0 +1,23 @@
<script lang="ts">
import { AlertTriangle } from '@lucide/svelte';
import { isRasterizationTooLarge, shouldWarnAboutRasterization } from '$lib/rasterization-limits';
type Props = { file: File | null };
let { file }: Props = $props();
</script>
{#if shouldWarnAboutRasterization(file)}
<div
class="flex gap-3 rounded-lg border p-4 text-sm {isRasterizationTooLarge(file)
? 'border-red-200 bg-red-50 text-red-800'
: 'border-amber-200 bg-amber-50 text-amber-800'}"
role={isRasterizationTooLarge(file) ? 'alert' : undefined}
>
<AlertTriangle class="mt-0.5 h-5 w-5 shrink-0" />
<p>
{isRasterizationTooLarge(file)
? 'Diese Datei ist größer als 100 MB und kann aus Speichergründen nicht im Browser gerastert werden.'
: 'Diese große Datei benötigt beim Rasterisieren viel Arbeitsspeicher. Schließen Sie andere speicherintensive Tabs.'}
</p>
</div>
{/if}
+21 -1
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import AttachmentPreview from '$lib/components/AttachmentPreview.svelte';
import Button from '$lib/components/ui/button.svelte';
import type { AttachmentLocation, SubDocument, ZipAttachment } from '$lib/zip-processing';
import { cn } from '$lib/utils';
@@ -16,6 +17,9 @@
onBlockDragOver?: (blockIndex: number, event: DragEvent) => void;
onBlockDrop?: (blockIndex: number, event: DragEvent) => void;
onBlockDragEnd?: () => void;
onDownload?: () => void;
isDownloading?: boolean;
downloadDisabled?: boolean;
};
let {
@@ -30,7 +34,10 @@
onBlockDragStart = () => {},
onBlockDragOver = () => {},
onBlockDrop = () => {},
onBlockDragEnd = () => {}
onBlockDragEnd = () => {},
onDownload,
isDownloading = false,
downloadDisabled = false
}: Props = $props();
let subDocumentName = $state('');
@@ -200,6 +207,19 @@
{attachments.length} Datei{attachments.length === 1 ? '' : 'en'}
</span>
{#if onDownload}
<Button
type="button"
variant="bordered"
disabled={downloadDisabled || isDownloading || attachments.length === 0}
onclick={onDownload}
draggable="false"
aria-label={`PDF für ${subDocumentName} herunterladen`}
>
{isDownloading ? 'PDF wird erstellt' : 'PDF herunterladen'}
</Button>
{/if}
<button
class="rounded-md border border-primary-200 bg-white px-2 py-1 text-[13px] font-medium text-primary-700 transition hover:border-red-300 hover:bg-red-50 hover:text-red-700"
type="button"
+14 -70
View File
@@ -1,76 +1,20 @@
<script lang="ts">
type Props = {
onFilesSelected: (files: File[]) => void;
class?: string;
};
import FileDropzone from '$lib/components/FileDropzone.svelte';
let { onFilesSelected, class: className = "" }: Props = $props();
type Props = {
onFilesSelected: (files: File[]) => void;
class?: string;
};
let fileInput: HTMLInputElement | null = null;
let isDragging = $state(false);
const processFiles = (files: FileList | null | undefined) => {
if (!files) return;
const filtered = Array.from(files).filter((file) => {
const name = file.name.toLowerCase();
return (
name.endsWith('.zip') ||
file.type === 'application/zip' ||
file.type === 'application/x-zip-compressed'
);
});
if (filtered.length > 0) {
onFilesSelected(filtered);
}
};
const openFileDialog = () => fileInput?.click();
const handleFileSelection = (event: Event) => {
const input = event.currentTarget as HTMLInputElement;
processFiles(input.files);
input.value = ''; // Reset so same file can be uploaded twice
};
const handleDragOver = (event: DragEvent) => {
event.preventDefault();
isDragging = true;
};
const handleDragLeave = (event: DragEvent) => {
event.preventDefault();
isDragging = false;
};
const handleDrop = (event: DragEvent) => {
event.preventDefault();
isDragging = false;
processFiles(event.dataTransfer?.files);
};
let { onFilesSelected, class: className = '' }: Props = $props();
</script>
<input
bind:this={fileInput}
class="hidden"
type="file"
accept=".zip,application/zip,application/x-zip-compressed"
multiple
onchange={handleFileSelection}
<FileDropzone
{onFilesSelected}
accept=".zip,application/zip,application/x-zip-compressed"
multiple={true}
ariaLabel="ZIP-Dateien hinzufügen"
label="Dateien hinzufügen"
sublabel="ZIP-Dateien hier ablegen oder zum Auswählen klicken"
class="min-h-[calc(-180px+100vh)] border-0 bg-transparent {className}"
/>
<button
type="button"
class="flex min-h-[calc(-180px+100vh)] w-full grow cursor-pointer flex-col justify-center border-0 text-primary bg-transparent p-0 text-center appearance-none focus:outline-none transition-colors rounded-2xl {isDragging ? 'bg-gray-100 border-2 border-dashed border-primary-500' : ''} {className}"
aria-label="ZIP-Dateien hinzufügen"
onclick={openFileDialog}
ondragover={handleDragOver}
ondragleave={handleDragLeave}
ondrop={handleDrop}
>
<div class="flex flex-row items-baseline justify-center gap-2">
<span class="text-[20px] font-bold">Dateien hinzufügen</span>
<span class="text-[20px] font-extrabold">+</span>
</div>
</button>
+25
View File
@@ -0,0 +1,25 @@
export const downloadBlob = (
bytes: Uint8Array,
fileName: string,
mimeType: string = 'application/pdf'
) => {
const buffer = new Uint8Array(bytes.byteLength);
buffer.set(bytes);
const blob = new Blob([buffer.buffer as ArrayBuffer], { type: mimeType });
const objectUrl = URL.createObjectURL(blob);
const downloadLink = document.createElement('a');
downloadLink.href = objectUrl;
downloadLink.download = fileName;
downloadLink.rel = 'noopener';
downloadLink.click();
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
};
export const downloadBlobUrl = (blobUrl: string, fileName: string) => {
const downloadLink = document.createElement('a');
downloadLink.href = blobUrl;
downloadLink.download = fileName;
downloadLink.rel = 'noopener';
downloadLink.click();
};
+17
View File
@@ -0,0 +1,17 @@
import { PDFDocument } from 'pdf-lib';
export const mergePdfFiles = async (files: File[]): Promise<Uint8Array> => {
if (files.length < 2) throw new Error('At least two PDF files are required.');
const mergedDocument = await PDFDocument.create();
for (const file of files) {
const sourceDocument = await PDFDocument.load(new Uint8Array(await file.arrayBuffer()));
const copiedPages = await mergedDocument.copyPages(
sourceDocument,
sourceDocument.getPageIndices()
);
for (const page of copiedPages) mergedDocument.addPage(page);
}
return mergedDocument.save();
};
+71
View File
@@ -0,0 +1,71 @@
export type PdfThumbnail = {
pageNumber: number;
imageUrl: string;
width: number;
height: number;
};
export type PdfDocumentHandle = {
pageCount: number;
thumbnails: PdfThumbnail[];
destroy: () => void;
};
export async function loadPdfDocument(data: Uint8Array): Promise<PdfDocumentHandle> {
const pdfjsLib = await import('pdfjs-dist');
const { getDocument, GlobalWorkerOptions } = pdfjsLib;
GlobalWorkerOptions.workerSrc = await import('pdfjs-dist/build/pdf.worker.mjs?url').then(
(module) => module.default
);
const loadingTask = getDocument({ data: new Uint8Array(data) });
const thumbnails: PdfThumbnail[] = [];
try {
const pdfDocument = await loadingTask.promise;
for (let pageNumber = 1; pageNumber <= pdfDocument.numPages; pageNumber += 1) {
const page = await pdfDocument.getPage(pageNumber);
const viewport = page.getViewport({ scale: 0.5 });
const canvas = document.createElement('canvas');
canvas.width = Math.ceil(viewport.width);
canvas.height = Math.ceil(viewport.height);
const canvasContext = canvas.getContext('2d');
if (!canvasContext) throw new Error('Could not create thumbnail canvas context.');
await page.render({ canvas, canvasContext, viewport }).promise;
const blob = await new Promise<Blob>((resolve, reject) => {
canvas.toBlob(
(result) =>
result ? resolve(result) : reject(new Error('Could not encode PDF thumbnail.')),
'image/png'
);
});
thumbnails.push({
pageNumber,
imageUrl: URL.createObjectURL(blob),
width: viewport.width,
height: viewport.height
});
}
let destroyed = false;
return {
pageCount: pdfDocument.numPages,
thumbnails,
destroy: () => {
if (destroyed) return;
destroyed = true;
for (const thumbnail of thumbnails) URL.revokeObjectURL(thumbnail.imageUrl);
void loadingTask.destroy();
}
};
} catch (error) {
for (const thumbnail of thumbnails) URL.revokeObjectURL(thumbnail.imageUrl);
await loadingTask.destroy();
throw error;
}
}
+11
View File
@@ -0,0 +1,11 @@
let pendingZipFiles: File[] = [];
export const queueZipFiles = (files: File[]) => {
pendingZipFiles = [...pendingZipFiles, ...files];
};
export const takeQueuedZipFiles = () => {
const files = pendingZipFiles;
pendingZipFiles = [];
return files;
};
+8
View File
@@ -0,0 +1,8 @@
export const RASTERIZATION_WARNING_BYTES = 25 * 1024 * 1024;
export const RASTERIZATION_MAX_BYTES = 100 * 1024 * 1024;
export const isRasterizationTooLarge = (file: File | null) =>
file !== null && file.size > RASTERIZATION_MAX_BYTES;
export const shouldWarnAboutRasterization = (file: File | null) =>
file !== null && file.size > RASTERIZATION_WARNING_BYTES;
+51
View File
@@ -0,0 +1,51 @@
export type Tool = {
slug: string;
title: string;
description: string;
icon: string;
};
export const tools: Tool[] = [
{
slug: 'watermark',
title: 'Wasserzeichen',
description: 'Fügen Sie ein Wasserzeichen zu Ihrem PDF hinzu.',
icon: 'droplets'
},
{
slug: 'encrypt',
title: 'Passwort setzen',
description: 'Verschlüsseln Sie Ihr PDF mit einem Passwort.',
icon: 'lock'
},
{
slug: 'decrypt',
title: 'Passwort entfernen',
description: 'Entsperren Sie ein passwortgeschütztes PDF.',
icon: 'unlock'
},
{
slug: 'convert',
title: 'Konvertieren',
description: 'Konvertieren Sie ein PDF in Bilder.',
icon: 'image'
},
{
slug: 'compress',
title: 'Komprimieren',
description: 'Komprimieren Sie ein PDF für kleinere Dateigröße.',
icon: 'minimize'
},
{
slug: 'separate',
title: 'Seiten entfernen',
description: 'Entfernen Sie Seiten aus einem PDF.',
icon: 'scissors'
},
{
slug: 'merge',
title: 'PDFs zusammenfügen',
description: 'Fügen Sie mehrere PDFs zu einer Datei zusammen.',
icon: 'merge'
}
];
+4 -4
View File
@@ -1,13 +1,13 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type WithoutChild<T> = T extends { child?: any } ? Omit<T, "child"> : T;
export type WithoutChild<T> = T extends { child?: any } ? Omit<T, 'child'> : T;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type WithoutChildren<T> = T extends { children?: any } ? Omit<T, "children"> : T;
export type WithoutChildren<T> = T extends { children?: any } ? Omit<T, 'children'> : T;
export type WithoutChildrenOrChild<T> = WithoutChildren<WithoutChild<T>>;
export type WithElementRef<T, U extends HTMLElement = HTMLElement> = T & { ref?: U | null };
+20 -4
View File
@@ -572,6 +572,25 @@ export const dissolveSubDocument = (
const stripPdfExtension = (name: string) =>
name.toLowerCase().endsWith('.pdf') ? name.slice(0, -'.pdf'.length) : name;
/**
* Build a single PDF export unit for one sub-document. The resulting filename
* follows the same convention as the separate archive export:
* `{archiveName} - {subDocumentName}` (without duplicate `.pdf` suffixes).
*/
export const buildSubDocumentExport = async (
archiveName: string,
subDocument: SubDocument
): Promise<ExportUnit> => {
if (subDocument.attachments.length === 0) {
throw new Error('Cannot create a PDF from an empty sub-document.');
}
return {
name: `${stripPdfExtension(archiveName)} - ${stripPdfExtension(subDocument.name)}`,
bytes: await mergeAttachments(subDocument.attachments)
};
};
/**
* Build one or more PDF export units from the archive.
*
@@ -607,10 +626,7 @@ export const buildArchiveExport = async (
continue;
}
separateUnits.push({
name: `${stripPdfExtension(archive.name)} - ${stripPdfExtension(subDocument.name)}`,
bytes: await mergeAttachments(subDocument.attachments)
});
separateUnits.push(await buildSubDocumentExport(archive.name, subDocument));
}
if (archive.attachments.length > 0) {