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

+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>