rotate + hover on start-page
Build and Push Docker Image / build (push) Successful in 1m26s

This commit is contained in:
2026-08-29 12:04:29 +02:00
parent ea21fc000f
commit 09cc5c06fb
7 changed files with 402 additions and 58 deletions
+11 -4
View File
@@ -103,9 +103,9 @@
<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-300 bg-white 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}"
class="flex w-full cursor-pointer flex-col items-center justify-center gap-2 rounded-2xl border-2 border-dashed px-6 py-10 text-center transition-all duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-400 {isDragging
? 'scale-[1.02] border-primary bg-primary-100/70 shadow-lg ring-4 ring-primary/25'
: 'border-primary-300 bg-white hover:border-primary-400 hover:bg-primary-50/60'} {className}"
aria-label={ariaLabel}
data-dropzone-ready={fileInput ? 'true' : 'false'}
onclick={openFileDialog}
@@ -116,7 +116,11 @@
>
{#if icon}
{@const IconComponent = icon}
<IconComponent class="h-10 w-10 text-primary" />
<IconComponent
class="h-10 w-10 text-primary transition-transform duration-150 {isDragging
? 'scale-125'
: ''}"
/>
{/if}
<div class="flex flex-row items-baseline justify-center gap-2">
<span class="text-[18px] font-bold text-primary-900">{label}</span>
@@ -125,6 +129,9 @@
{#if sublabel}
<p class="text-[13px] text-primary-700">{sublabel}</p>
{/if}
{#if isDragging}
<p class="text-[13px] font-bold text-primary">Jetzt loslassen zum Hinzufügen</p>
{/if}
</button>
{#if rejectionMessage}
+81 -26
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import type { PdfThumbnail } from '$lib/pdf-thumbnails';
import { cn } from '$lib/utils';
@@ -8,6 +9,8 @@
onPageClick?: (pageNumber: number) => void;
thumbnailWidth?: number;
class?: string;
pageActions?: Snippet<[pageNumber: number]>;
cardClass?: (pageNumber: number) => string;
};
let {
@@ -15,7 +18,9 @@
selectedPages = new Set<number>(),
onPageClick,
thumbnailWidth = 120,
class: className = ''
class: className = '',
pageActions,
cardClass
}: Props = $props();
</script>
@@ -25,30 +30,80 @@
>
{#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>
{#if onPageClick}
<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)}
>
{#if cardClass}
<div
class={cn(
'flex aspect-square w-full items-center justify-center',
cardClass(thumb.pageNumber)
)}
>
<img
src={thumb.imageUrl}
alt="Seite {thumb.pageNumber}"
class="max-h-full max-w-full rounded border border-primary-100 bg-white object-contain"
/>
</div>
{:else}
<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};"
/>
{/if}
<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>
{:else}
<div
class={cn(
'group relative flex flex-col items-center gap-2 rounded-xl border-2 border-transparent p-2',
isSelected && 'border-primary bg-primary/5'
)}
>
{#if cardClass}
<div
class={cn(
'flex aspect-square w-full items-center justify-center',
cardClass(thumb.pageNumber)
)}
>
<img
src={thumb.imageUrl}
alt="Seite {thumb.pageNumber}"
class="max-h-full max-w-full rounded border border-primary-100 bg-white object-contain"
/>
</div>
{:else}
<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};"
/>
{/if}
<span class="text-[12px] font-medium text-primary-700">Seite {thumb.pageNumber}</span>
{#if pageActions}
{@render pageActions(thumb.pageNumber)}
{/if}
</div>
{/if}
{/each}
</div>
+6
View File
@@ -48,6 +48,12 @@ export const tools: Tool[] = [
description: 'Entfernen Sie Seiten aus einem PDF.',
icon: 'scissors'
},
{
slug: 'rotate',
title: 'Seiten drehen',
description: 'Drehen Sie einzelne oder alle Seiten eines PDFs.',
icon: 'rotate-cw'
},
{
slug: 'merge',
title: 'PDFs zusammenfügen',
+4 -27
View File
@@ -9,6 +9,7 @@
Scissors,
Merge,
Stamp,
RotateCw,
FileText
} from '@lucide/svelte';
import { goto } from '$app/navigation';
@@ -25,11 +26,10 @@
minimize: Minimize2,
scissors: Scissors,
merge: Merge,
stamp: Stamp
stamp: Stamp,
'rotate-cw': RotateCw
};
let isDraggingFiles = $state(false);
const isZipFile = (file: File) =>
file.name.toLowerCase().endsWith('.zip') ||
file.type === 'application/zip' ||
@@ -46,17 +46,11 @@
const handleWindowDragOver = (event: DragEvent) => {
if (!event.dataTransfer?.types.includes('Files')) return;
event.preventDefault();
isDraggingFiles = true;
};
const handleWindowDragLeave = (event: DragEvent) => {
if (event.relatedTarget === null) isDraggingFiles = false;
};
const handleWindowDrop = (event: DragEvent) => {
if (!event.dataTransfer?.files.length) return;
event.preventDefault();
isDraggingFiles = false;
openBeaFiles(Array.from(event.dataTransfer.files));
};
</script>
@@ -65,24 +59,7 @@
<title>beA-Edit — Werkzeugkasten für das besondere elektronische Anwaltspostfach</title>
</svelte:head>
<svelte:window
ondragover={handleWindowDragOver}
ondragleave={handleWindowDragLeave}
ondrop={handleWindowDrop}
/>
{#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 class="px-4">
<Archive class="mx-auto h-12 w-12" />
<p class="mt-4 text-xl font-bold">beA-ZIP hier ablegen</p>
<p class="mt-1 text-sm">Archiv aus dem besonderen elektronischen Anwaltspostfach</p>
</div>
</div>
{/if}
<svelte:window ondragover={handleWindowDragOver} ondrop={handleWindowDrop} />
<div class="flex flex-col gap-10 px-4 py-10">
<!-- Hero -->
+219
View File
@@ -0,0 +1,219 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import { degrees, PDFDocument } from 'pdf-lib';
import { RotateCcw, RotateCw } from '@lucide/svelte';
import FileDropzone from '$lib/components/FileDropzone.svelte';
import PdfPageGrid from '$lib/components/PdfPageGrid.svelte';
import Button from '$lib/components/ui/button.svelte';
import { downloadBlob } from '$lib/download';
import { loadPdfDocument, type PdfDocumentHandle } from '$lib/pdf-thumbnails';
let pdfFile: File | null = $state(null);
let pdfHandle: PdfDocumentHandle | null = $state(null);
let rotations = $state(new Map<number, number>());
let isProcessing = $state(false);
let error = $state<string | null>(null);
let loadingError = $state<string | null>(null);
const rotatedCount = $derived(rotations.size);
const hasRotations = $derived(rotatedCount > 0);
const isEncryptedPdfError = (loadError: unknown) => {
const message =
loadError instanceof Error ? `${loadError.name} ${loadError.message}` : String(loadError);
return /password|encrypted|encryption|verschl/i.test(message);
};
const loadErrorMessage = (loadError: unknown) =>
isEncryptedPdfError(loadError)
? 'Dieses PDF ist passwortgeschützt. Entfernen Sie zuerst unter „Passwort entfernen“ den Schutz.'
: 'PDF konnte nicht geladen werden. Prüfen Sie, ob die Datei gültig ist.';
const handleFileSelected = async (files: File[]) => {
const file = files[0];
if (!file) return;
pdfHandle?.destroy();
pdfHandle = null;
pdfFile = file;
rotations = new Map();
loadingError = null;
error = null;
try {
const bytes = new Uint8Array(await file.arrayBuffer());
pdfHandle = await loadPdfDocument(bytes);
} catch (err) {
console.error('Failed to load PDF:', err);
loadingError = loadErrorMessage(err);
pdfHandle = null;
}
};
const rotatePage = (pageNumber: number, delta: number) => {
const next = new Map(rotations);
const current = next.get(pageNumber) ?? 0;
const value = (((current + delta) % 360) + 360) % 360;
if (value === 0) {
next.delete(pageNumber);
} else {
next.set(pageNumber, value);
}
rotations = next;
};
const rotateAll = (delta: number) => {
if (!pdfHandle) return;
for (let pageNumber = 1; pageNumber <= pdfHandle.pageCount; pageNumber += 1) {
rotatePage(pageNumber, delta);
}
};
const resetRotations = () => {
rotations = new Map();
};
const previewClass = (pageNumber: number) => {
const delta = rotations.get(pageNumber) ?? 0;
switch (delta) {
case 90:
return 'rotate-90';
case 180:
return 'rotate-180';
case 270:
return 'rotate-[270deg]';
default:
return '';
}
};
const rotateAndDownload = async () => {
if (!pdfFile || !pdfHandle || !hasRotations) return;
isProcessing = true;
error = null;
try {
const bytes = new Uint8Array(await pdfFile.arrayBuffer());
const pdfDoc = await PDFDocument.load(bytes);
for (const [index, page] of pdfDoc.getPages().entries()) {
const delta = rotations.get(index + 1);
if (!delta) continue;
const current = page.getRotation().angle;
page.setRotation(degrees((((current + delta) % 360) + 360) % 360));
}
const resultBytes = await pdfDoc.save();
const baseName = pdfFile.name.replace(/\.pdf$/i, '');
downloadBlob(resultBytes, `${baseName}_gedreht.pdf`);
} catch (err) {
console.error('Failed to rotate pages:', err);
error = isEncryptedPdfError(err)
? 'Dieses PDF ist passwortgeschützt. Entfernen Sie zuerst unter „Passwort entfernen“ den Schutz.'
: 'Fehler beim Drehen der Seiten.';
} finally {
isProcessing = false;
}
};
onDestroy(() => {
pdfHandle?.destroy();
});
</script>
<div class="mx-auto max-w-4xl space-y-6">
<div>
<h1 class="text-2xl font-bold text-primary-900">Seiten drehen</h1>
<p class="mt-2 text-sm text-primary-700">
Drehen Sie einzelne oder alle Seiten eines PDFs — verlustfrei und ohne Qualitätsverlust.
</p>
</div>
{#if !pdfHandle}
<FileDropzone onFilesSelected={handleFileSelected} />
{#if loadingError}
<div class="mt-4 rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800">
{loadingError}
</div>
{/if}
{:else}
<div class="space-y-4">
<FileDropzone
onFilesSelected={handleFileSelected}
label="Anderes PDF ablegen"
sublabel={pdfFile?.name ?? ''}
class="py-4"
/>
<div class="flex flex-wrap items-center justify-between gap-3">
<h2 class="text-lg font-semibold text-primary-900">
{pdfFile?.name}{pdfHandle.pageCount} Seite{pdfHandle.pageCount === 1 ? '' : 'n'}
</h2>
<div class="flex flex-wrap gap-2">
<Button variant="tertiary" class="min-w-0 px-3" onclick={() => rotateAll(90)}>
Alle 90° rechts
</Button>
<Button variant="tertiary" class="min-w-0 px-3" onclick={() => rotateAll(270)}>
Alle 90° links
</Button>
<Button variant="tertiary" class="min-w-0 px-3" onclick={() => rotateAll(180)}>
Alle 180°
</Button>
<Button
variant="tertiary"
class="min-w-0 px-3"
onclick={resetRotations}
disabled={!hasRotations}
>
Zurücksetzen
</Button>
</div>
</div>
<PdfPageGrid thumbnails={pdfHandle.thumbnails} thumbnailWidth={160} cardClass={previewClass}>
{#snippet pageActions(pageNumber)}
{#if (rotations.get(pageNumber) ?? 0) !== 0}
<span
class="absolute top-1 right-1 rounded-full bg-primary px-1.5 py-0.5 text-[10px] font-bold text-white"
>
{rotations.get(pageNumber)}°
</span>
{/if}
<div class="flex gap-1">
<button
type="button"
class="flex h-7 w-7 items-center justify-center rounded-lg border border-primary-200 bg-white text-primary transition hover:bg-primary-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-400"
aria-label="Seite {pageNumber} um 90° nach links drehen"
onclick={() => rotatePage(pageNumber, 270)}
>
<RotateCcw class="h-4 w-4" />
</button>
<button
type="button"
class="flex h-7 w-7 items-center justify-center rounded-lg border border-primary-200 bg-white text-primary transition hover:bg-primary-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-400"
aria-label="Seite {pageNumber} um 90° nach rechts drehen"
onclick={() => rotatePage(pageNumber, 90)}
>
<RotateCw class="h-4 w-4" />
</button>
</div>
{/snippet}
</PdfPageGrid>
{#if error}
<div class="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800">
{error}
</div>
{/if}
<div class="flex items-center justify-between">
<p class="text-sm text-primary-700">
{rotatedCount} von {pdfHandle.pageCount} Seite{pdfHandle.pageCount === 1 ? '' : 'n'}
gedreht
</p>
<Button onclick={rotateAndDownload} disabled={isProcessing || !hasRotations}>
{isProcessing ? 'Wird verarbeitet...' : 'Drehen & herunterladen'}
</Button>
</div>
</div>
{/if}
</div>