Merge branch 'features/shadcn' into develop

# Conflicts:
#	.idea/workspace.xml
#	agents.md
This commit is contained in:
2026-07-22 22:18:59 +02:00
43 changed files with 6196 additions and 752 deletions
+31
View File
@@ -0,0 +1,31 @@
export function ripple(node: HTMLElement) {
function handleClick(event: MouseEvent) {
const circle = document.createElement("span");
const rect = node.getBoundingClientRect();
const size = Math.max(rect.width, rect.height);
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.className =
"absolute rounded-full bg-white/30 animate-ripple pointer-events-none";
node.appendChild(circle);
circle.addEventListener("animationend", () => {
circle.remove();
});
}
node.addEventListener("click", handleClick);
return {
destroy() {
node.removeEventListener("click", handleClick);
}
};
}
+129
View File
@@ -0,0 +1,129 @@
<script lang="ts">
import { onMount } from "svelte";
import type {ZipAttachment} from "$lib/zip-processing";
type Props = {
attachment: ZipAttachment;
class?: string;
};
let { attachment, class: className = "" }: Props = $props();
let imageUrl = $state<string | null>(null);
let previewError = $state<string | null>(null);
onMount(() => {
let objectUrl: string | null = null;
let cancelled = false;
const revokeObjectUrl = () => {
if (objectUrl) {
URL.revokeObjectURL(objectUrl);
objectUrl = null;
}
};
const setBlobImage = (blob: Blob) => {
revokeObjectUrl();
objectUrl = URL.createObjectURL(blob);
imageUrl = objectUrl;
};
(async () => {
const pdfjsLib = await import("pdfjs-dist");
const { getDocument, GlobalWorkerOptions } = pdfjsLib;
GlobalWorkerOptions.workerSrc = await import("pdfjs-dist/build/pdf.worker.mjs?url").then((m) => m.default);
const loadPreview = async () => {
previewError = null;
imageUrl = null;
if (attachment.kind === "image") {
const imageBuffer = attachment.data.buffer.slice(
attachment.data.byteOffset,
attachment.data.byteOffset + attachment.data.byteLength,
) as ArrayBuffer;
setBlobImage(new Blob([imageBuffer]));
return;
}
try {
// create a copy, getDocument seems to manipulate the data -> creating pdfs fails
const buffer = new Uint8Array(attachment.data);
const loadingTask = getDocument({ data: buffer });
const pdfDocument = await loadingTask.promise;
const page = await pdfDocument.getPage(1);
if (cancelled) {
void loadingTask.destroy();
return;
}
const viewport = page.getViewport({ scale: 1 });
const canvasElement = document.createElement("canvas");
canvasElement.width = viewport.width;
canvasElement.height = viewport.height;
const canvasContext = canvasElement.getContext("2d");
if (!canvasContext) {
throw new Error("Could not create thumbnail canvas context.");
}
await page.render({ canvas: canvasElement, canvasContext, viewport }).promise;
if (cancelled) {
void loadingTask.destroy();
return;
}
const blob = await new Promise<Blob>((resolve, reject) => {
canvasElement.toBlob((result) => {
if (result) {
resolve(result);
return;
}
reject(new Error("Could not create thumbnail blob."));
}, "image/png");
});
setBlobImage(blob);
await loadingTask.destroy();
} catch (error) {
console.error(`Failed to render preview for ${attachment.path}`, error);
previewError = "PDF-Vorschau konnte nicht geladen werden.";
}
};
void loadPreview();
})();
return () => {
cancelled = true;
revokeObjectUrl();
};
});
</script>
<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">
{previewError}
</div>
{:else if imageUrl}
<img
alt={attachment.name}
class="block rounded-md border border-primary-100 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">
Vorschau wird erstellt
</div>
{/if}
</div>
-38
View File
@@ -1,38 +0,0 @@
<script lang="ts">
// Define the types for your props
type ButtonType = 'button' | 'submit' | 'reset'
type ButtonVariant = 'primary' | 'secondary'
// Define the props object including the Snippet for children
let {
type = 'button',
disabled = false,
variant = 'primary',
children
}: {
type?: ButtonType;
disabled?: boolean;
variant?: ButtonVariant
children: import('svelte').Snippet
} = $props();
const getBgColor = (variant: ButtonVariant) => {
return variant === 'primary' ? '#122f62' : 'transparent'
}
const getTextColor = (variant: ButtonVariant) => {
return variant === 'primary' ? 'white': 'primary-900'
}
let bgColor = $derived(getBgColor(variant))
let textColor = $derived(getTextColor(variant))
</script>
<button
type="{type}"
class="rounded-container not-disabled:bg-[{bgColor}] disabled:bg-[#0000001f] not-disabled:text-{textColor} disabled:text-[#00000042] whitespace-nowrap text-center text-[14px] leading-9 font-bold px-4 py-0 focus:outline-none focus:ring-2 focus:ring-primary-300 shadow-sm"
disabled={disabled}
>
{@render children()}
</button>
-23
View File
@@ -1,23 +0,0 @@
<script lang="ts">
import type {Snippet} from "svelte";
type Props = {
title?: Snippet
content?: Snippet
footer?: Snippet
class?: string
}
let {title, content, footer, class: className = ""}: Props = $props()
</script>
<div class="bg-white rounded-2xl shadow-sm py-2 px-2 {className}">
{#if title}
<header>{@render title()}</header>
{/if}
{#if content}
<main class="flex grow items-center">{@render content()}</main>
{/if}
{#if footer}
<footer>{@render footer()}</footer>
{/if}
</div>
@@ -0,0 +1,502 @@
<script lang="ts">
import AttachmentPreview from '$lib/components/AttachmentPreview.svelte';
import Button from '$lib/components/ui/button.svelte';
import SubDocumentEditor from '$lib/components/SubDocumentEditor.svelte';
import {
buildArchiveExport,
createSubDocument,
dissolveSubDocument,
moveAttachmentIntoSubDocument,
moveSubDocument,
type AttachmentLocation,
type ExportMode,
type ProcessedZipArchive,
type SubDocument,
type ZipAttachment
} from '$lib/zip-processing';
import { cn } from '$lib/utils';
type Props = {
archive: ProcessedZipArchive;
thumbnailWidth: number;
onArchiveChange?: (archive: ProcessedZipArchive) => void;
};
let { archive, thumbnailWidth = 200, onArchiveChange = () => {} }: Props = $props();
const ATTACHMENT_MIME = 'application/x-juri-merger-attachment';
let archiveName = $state('');
let looseAttachments = $state<ZipAttachment[]>([]);
let subDocuments = $state<SubDocument[]>([]);
let selectionMode = $state(false);
let selectedPaths = $state<Set<string>>(new Set());
let dragIndex = $state<number | null>(null);
let dropIndex = $state<number | null>(null);
let blockDragIndex = $state<number | null>(null);
let blockDropIndex = $state<number | null>(null);
let downloadMode = $state<ExportMode>('single');
let isMerging = $state(false);
let mergeError = $state<string | null>(null);
$effect(() => {
archiveName = archive.name;
looseAttachments = archive.attachments.map((attachment) => ({ ...attachment }));
subDocuments = archive.subDocuments.map((subDocument) => ({
...subDocument,
attachments: subDocument.attachments.map((attachment) => ({ ...attachment }))
}));
});
const toArrayBuffer = (bytes: Uint8Array) => {
const copy = new Uint8Array(bytes.byteLength);
copy.set(bytes);
return copy.buffer;
};
const normalizeFileName = (name: string) =>
name.toLowerCase().endsWith('.pdf') ? name : `${name}.pdf`;
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 = normalizeFileName(fileName);
downloadLink.rel = 'noopener';
downloadLink.click();
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
};
const composedArchive = (): ProcessedZipArchive => ({
...archive,
name: archiveName,
attachments: looseAttachments,
subDocuments
});
const emitArchiveChange = (nextArchive: ProcessedZipArchive) => {
archiveName = nextArchive.name;
looseAttachments = nextArchive.attachments;
subDocuments = nextArchive.subDocuments;
onArchiveChange(nextArchive);
};
const commit = () => {
onArchiveChange(composedArchive());
};
const hasSubDocuments = $derived(subDocuments.length > 0);
const toggleSelection = (path: string) => {
const next = new Set(selectedPaths);
if (next.has(path)) {
next.delete(path);
} else {
next.add(path);
}
selectedPaths = next;
};
const toggleSelectionMode = () => {
selectionMode = !selectionMode;
selectedPaths = new Set();
};
const exitSelectionMode = () => {
selectionMode = false;
selectedPaths = new Set();
};
const handleWindowKeydown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && selectionMode) {
exitSelectionMode();
}
};
const createSubDocumentFromSelection = () => {
if (selectedPaths.size === 0) {
return;
}
const selectedAttachments = looseAttachments.filter((attachment) =>
selectedPaths.has(attachment.path)
);
const remainingAttachments = looseAttachments.filter(
(attachment) => !selectedPaths.has(attachment.path)
);
if (selectedAttachments.length === 0) {
return;
}
const subDocumentNumber = subDocuments.length + 1;
const nextSubDocument = createSubDocument(
`Teildokument ${subDocumentNumber}`,
selectedAttachments
);
looseAttachments = remainingAttachments;
subDocuments = [...subDocuments, nextSubDocument];
selectedPaths = new Set();
commit();
};
const dissolveSubDocumentBlock = (subDocumentId: string) => {
const nextArchive = dissolveSubDocument(composedArchive(), subDocumentId);
emitArchiveChange(nextArchive);
};
const handleSubDocumentChange = (next: SubDocument) => {
subDocuments = subDocuments.map((subDocument) =>
subDocument.id === next.id ? next : subDocument
);
commit();
};
const handleAttachmentMove = (source: AttachmentLocation, target: AttachmentLocation) => {
const nextArchive = moveAttachmentIntoSubDocument(composedArchive(), source, target);
emitArchiveChange(nextArchive);
};
const handleBlockDragStart = (blockIndex: number, _event: DragEvent) => {
blockDragIndex = blockIndex;
blockDropIndex = blockIndex;
};
const handleBlockDragOver = (blockIndex: number, _event: DragEvent) => {
blockDropIndex = blockIndex;
};
const handleBlockDrop = (blockIndex: number, _event: DragEvent) => {
if (blockDragIndex === null) {
return;
}
const fromIndex = blockDragIndex;
const nextArchive = moveSubDocument(composedArchive(), fromIndex, blockIndex);
emitArchiveChange(nextArchive);
blockDragIndex = null;
blockDropIndex = null;
};
const handleBlockDragEnd = () => {
blockDragIndex = null;
blockDropIndex = null;
};
const moveLooseAttachment = (fromIndex: number, toIndex: number) => {
if (
fromIndex === toIndex ||
fromIndex < 0 ||
toIndex < 0 ||
fromIndex >= looseAttachments.length ||
toIndex >= looseAttachments.length
) {
return;
}
const nextAttachments = [...looseAttachments];
const [movedAttachment] = nextAttachments.splice(fromIndex, 1);
nextAttachments.splice(toIndex, 0, movedAttachment);
looseAttachments = nextAttachments;
commit();
};
const handleLooseDragStart = (index: number, event: DragEvent) => {
if (selectionMode) {
event.preventDefault();
return;
}
dragIndex = index;
dropIndex = index;
const source: AttachmentLocation = { kind: 'loose', index };
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData(ATTACHMENT_MIME, JSON.stringify(source));
event.dataTransfer.setData('text/plain', String(index));
}
};
const parseAttachmentPayload = (event: DragEvent): AttachmentLocation | null => {
const raw = event.dataTransfer?.getData(ATTACHMENT_MIME);
if (!raw) {
return null;
}
try {
const parsed = JSON.parse(raw) as AttachmentLocation;
if (parsed.kind === 'loose' || parsed.kind === 'subDocument') {
return parsed;
}
return null;
} catch {
return null;
}
};
const handleLooseDragOver = (index: number, event: DragEvent) => {
if (event.dataTransfer?.types.includes(ATTACHMENT_MIME)) {
event.preventDefault();
dropIndex = index;
}
};
const handleLooseDrop = (index: number, event: DragEvent) => {
const source = parseAttachmentPayload(event);
if (!source) {
return;
}
event.preventDefault();
if (source.kind === 'loose') {
moveLooseAttachment(source.index, index);
} else {
const nextArchive = moveAttachmentIntoSubDocument(composedArchive(), source, {
kind: 'loose',
index
});
emitArchiveChange(nextArchive);
}
dragIndex = null;
dropIndex = null;
};
const handleLooseDragEnd = () => {
dragIndex = null;
dropIndex = null;
};
const downloadArchive = async () => {
if (looseAttachments.length === 0 && subDocuments.length === 0) {
return;
}
if (isMerging) {
return;
}
isMerging = true;
mergeError = null;
try {
const exportUnits = await buildArchiveExport(composedArchive(), downloadMode);
for (const unit of exportUnits) {
downloadPdfBytes(unit.bytes, unit.name);
}
} catch (error) {
console.error(`Failed to export archive ${archive.name}`, error);
mergeError = 'PDF konnte nicht erstellt werden.';
} finally {
isMerging = false;
}
};
</script>
<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"
>
<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"
type="text"
value={archiveName}
draggable="false"
oninput={(event) => {
archiveName = (event.currentTarget as HTMLInputElement).value;
commit();
}}
/>
</label>
<p class="mt-1 text-[13px] text-primary">
{looseAttachments.length} lose Datei{looseAttachments.length === 1 ? '' : 'en'}
{#if hasSubDocuments}
· {subDocuments.length} Teildokument{subDocuments.length === 1 ? '' : 'e'}
{/if}
</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<Button
type="button"
variant={selectionMode ? 'secondary' : 'bordered'}
onclick={toggleSelectionMode}
>
{selectionMode ? 'Fertig' : 'Auswählen'}
</Button>
{#if hasSubDocuments}
<div
class="inline-flex items-center rounded-[16px] border border-primary-200 bg-white p-0.5"
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',
downloadMode === option.value
? 'bg-primary text-primary-foreground'
: 'text-primary-700 hover:bg-primary-50'
)}
>
<input
class="sr-only"
type="radio"
name={`download-mode-${archive.name}`}
value={option.value}
checked={downloadMode === option.value}
onchange={() => {
downloadMode = option.value as ExportMode;
}}
/>
{option.label}
</label>
{/each}
</div>
{/if}
<Button
class="bg-primary"
type="button"
disabled={(looseAttachments.length === 0 && subDocuments.length === 0) || isMerging}
onclick={downloadArchive}
>
{isMerging ? 'PDF wird erstellt' : 'PDF herunterladen'}
</Button>
</div>
</header>
{#if mergeError}
<div class="rounded-container bg-red-50 px-4 py-3 text-[14px] font-medium text-red-900">
{mergeError}
</div>
{/if}
{#if selectionMode && selectedPaths.size > 0}
<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"
>
<span>{selectedPaths.size} Datei{selectedPaths.size === 1 ? '' : 'en'} ausgewählt</span>
<Button type="button" onclick={createSubDocumentFromSelection}>Teildokument erstellen</Button>
</div>
{/if}
<ul class="flex gap-3 overflow-x-auto pb-1">
{#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',
dragIndex === index ? 'opacity-60' : '',
selectedPaths.has(attachment.path) ? 'border-primary-500 ring-2 ring-primary-300' : ''
)}
style={`width: ${thumbnailWidth}px;`}
draggable={selectionMode ? 'false' : 'true'}
ondragstart={(event) => handleLooseDragStart(index, event)}
ondragover={(event) => handleLooseDragOver(index, event)}
ondrop={(event) => handleLooseDrop(index, event)}
ondragend={handleLooseDragEnd}
>
{#if selectionMode}
<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">
<input
class="h-4 w-4 accent-primary-700"
type="checkbox"
checked={selectedPaths.has(attachment.path)}
onchange={() => toggleSelection(attachment.path)}
/>
<span class="truncate"
>{selectedPaths.has(attachment.path) ? 'Ausgewählt' : 'Auswählen'}</span
>
</div>
<div
class="truncate text-[13px] font-medium leading-tight text-primary-900"
title={attachment.name}
>
{attachment.name}
</div>
<AttachmentPreview {attachment} />
</label>
{:else}
<div class="flex min-w-0 flex-1 flex-row items-baseline gap-2 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"
draggable="false"
type="button"
aria-label="Datei verschieben"
>
::
</button>
<div
class="truncate text-[13px] font-medium leading-tight text-primary-900"
title={attachment.name}
>
{attachment.name}
</div>
</div>
<AttachmentPreview {attachment} />
{/if}
</li>
{/each}
</ul>
{#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"
>
Keine Dateien in diesem Archiv. Lade ein anderes ZIP, um weiterzuarbeiten.
</div>
{/if}
{#if hasSubDocuments}
<div class="flex flex-col gap-3">
{#each subDocuments as subDocument, blockIndex (subDocument.id)}
<SubDocumentEditor
{subDocument}
{blockIndex}
{thumbnailWidth}
isBlockDragSource={blockDragIndex === blockIndex}
isBlockDropTarget={blockDropIndex === blockIndex && blockDragIndex !== blockIndex}
onSubDocumentChange={handleSubDocumentChange}
onRemove={() => dissolveSubDocumentBlock(subDocument.id)}
onAttachmentMove={handleAttachmentMove}
onBlockDragStart={handleBlockDragStart}
onBlockDragOver={handleBlockDragOver}
onBlockDrop={handleBlockDrop}
onBlockDragEnd={handleBlockDragEnd}
/>
{/each}
</div>
{/if}
</section>
+286
View File
@@ -0,0 +1,286 @@
<script lang="ts">
import AttachmentPreview from '$lib/components/AttachmentPreview.svelte';
import type { AttachmentLocation, SubDocument, ZipAttachment } from '$lib/zip-processing';
import { cn } from '$lib/utils';
type Props = {
subDocument: SubDocument;
blockIndex: number;
thumbnailWidth: number;
isBlockDragSource?: boolean;
isBlockDropTarget?: boolean;
onSubDocumentChange?: (next: SubDocument) => void;
onRemove?: () => void;
onAttachmentMove?: (source: AttachmentLocation, target: AttachmentLocation) => void;
onBlockDragStart?: (blockIndex: number, event: DragEvent) => void;
onBlockDragOver?: (blockIndex: number, event: DragEvent) => void;
onBlockDrop?: (blockIndex: number, event: DragEvent) => void;
onBlockDragEnd?: () => void;
};
let {
subDocument,
blockIndex,
thumbnailWidth = 200,
isBlockDragSource = false,
isBlockDropTarget = false,
onSubDocumentChange = () => {},
onRemove = () => {},
onAttachmentMove = () => {},
onBlockDragStart = () => {},
onBlockDragOver = () => {},
onBlockDrop = () => {},
onBlockDragEnd = () => {}
}: Props = $props();
let subDocumentName = $state('');
let dragIndex = $state<number | null>(null);
let dropIndex = $state<number | null>(null);
$effect(() => {
subDocumentName = subDocument.name;
});
const emitChange = (overrides: Partial<SubDocument> = {}) => {
onSubDocumentChange({
...subDocument,
name: subDocumentName,
attachments: subDocument.attachments.map((attachment) => ({ ...attachment })),
...overrides
});
};
const ATTACHMENT_MIME = 'application/x-juri-merger-attachment';
const BLOCK_MIME = 'application/x-juri-merger-block';
const parseAttachmentPayload = (event: DragEvent): AttachmentLocation | null => {
const raw = event.dataTransfer?.getData(ATTACHMENT_MIME);
if (!raw) {
return null;
}
try {
const parsed = JSON.parse(raw) as AttachmentLocation;
if (parsed.kind === 'loose' || parsed.kind === 'subDocument') {
return parsed;
}
return null;
} catch {
return null;
}
};
const handleCardDragStart = (index: number, event: DragEvent) => {
dragIndex = index;
dropIndex = index;
const source: AttachmentLocation = {
kind: 'subDocument',
id: subDocument.id,
index
};
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData(ATTACHMENT_MIME, JSON.stringify(source));
// keep a text/plain fallback so the drag is not silently cancelled
event.dataTransfer.setData('text/plain', String(index));
}
};
const handleCardDragOver = (index: number, event: DragEvent) => {
if (event.dataTransfer?.types.includes(ATTACHMENT_MIME)) {
event.preventDefault();
dropIndex = index;
}
};
const handleCardDrop = (index: number, event: DragEvent) => {
const source = parseAttachmentPayload(event);
if (!source) {
return;
}
event.preventDefault();
const target: AttachmentLocation = {
kind: 'subDocument',
id: subDocument.id,
index
};
onAttachmentMove(source, target);
dragIndex = null;
dropIndex = null;
};
const handleCardDragEnd = () => {
dragIndex = null;
dropIndex = null;
};
const handleNameInput = (event: Event) => {
subDocumentName = (event.currentTarget as HTMLInputElement).value;
emitChange();
};
const handleHeaderDragStart = (event: DragEvent) => {
// do not start a block drag when the user grabbed the remove button or name input
if (event.defaultPrevented) {
return;
}
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData(BLOCK_MIME, String(blockIndex));
event.dataTransfer.setData('text/plain', String(blockIndex));
}
onBlockDragStart(blockIndex, event);
};
const handleHeaderDragOver = (event: DragEvent) => {
if (event.dataTransfer?.types.includes(BLOCK_MIME)) {
event.preventDefault();
onBlockDragOver(blockIndex, event);
}
};
const handleHeaderDrop = (event: DragEvent) => {
const raw = event.dataTransfer?.getData(BLOCK_MIME);
if (raw === undefined || raw === '') {
return;
}
event.preventDefault();
onBlockDrop(blockIndex, event);
};
const attachments: ZipAttachment[] = $derived(subDocument.attachments);
</script>
<section
class={cn(
'flex flex-col gap-3 rounded-xl border bg-primary-50/40 px-3 py-3 transition',
isBlockDropTarget ? 'border-primary-400 ring-1 ring-primary-200' : 'border-primary-100',
isBlockDragSource ? 'opacity-60' : ''
)}
>
<header class="flex flex-wrap items-center gap-2 text-primary">
<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"
draggable="true"
type="button"
aria-label="Teildokument verschieben"
ondragstart={handleHeaderDragStart}
ondragover={handleHeaderDragOver}
ondrop={handleHeaderDrop}
ondragend={onBlockDragEnd}
>
::
</button>
<label class="block min-w-0 flex-1">
<span class="sr-only">Teildokumentname</span>
<input
class="w-full max-w-md rounded-md border border-primary-200 bg-white px-3 py-1.5 text-[15px] font-semibold text-primary-900 outline-none transition focus:border-primary-400 focus:ring-2 focus:ring-primary-200"
type="text"
value={subDocumentName}
oninput={handleNameInput}
draggable="false"
/>
</label>
<span class="text-[13px] text-primary-700">
{attachments.length} Datei{attachments.length === 1 ? '' : 'en'}
</span>
<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"
onclick={onRemove}
draggable="false"
ondragstart={(event) => event.preventDefault()}
>
Auflösen
</button>
</header>
{#if attachments.length === 0}
<ul
class="flex items-center justify-center rounded-md border border-dashed border-primary-200 bg-white/60 px-4 py-6 text-[13px] text-primary-600"
ondragover={(event) => {
if (event.dataTransfer?.types.includes(ATTACHMENT_MIME)) {
event.preventDefault();
}
}}
ondrop={(event) => {
const source = parseAttachmentPayload(event);
if (!source) {
return;
}
event.preventDefault();
const target: AttachmentLocation = {
kind: 'subDocument',
id: subDocument.id,
index: 0
};
onAttachmentMove(source, target);
dragIndex = null;
dropIndex = null;
}}
>
<li class="w-full text-center">
Dieses Teildokument ist leer. Ziehe Dateien hierher, um sie hinzuzufügen.
</li>
</ul>
{:else}
<ul class="flex gap-3 overflow-x-auto pb-1">
{#each attachments 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',
dropIndex === index
? 'border-primary-400 ring-1 ring-primary-200'
: 'border-primary-100',
dragIndex === index ? 'opacity-60' : ''
)}
style={`width: ${thumbnailWidth}px;`}
draggable="true"
ondragstart={(event) => handleCardDragStart(index, event)}
ondragover={(event) => handleCardDragOver(index, event)}
ondrop={(event) => handleCardDrop(index, event)}
ondragend={handleCardDragEnd}
>
<div class="flex min-w-0 flex-1 flex-row items-baseline gap-2 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"
draggable="false"
type="button"
aria-label="Datei verschieben"
>
::
</button>
<div
class="truncate text-[13px] font-medium leading-tight text-primary-900"
title={attachment.name}
>
{attachment.name}
</div>
</div>
<AttachmentPreview {attachment} />
</li>
{/each}
</ul>
{/if}
</section>
+3 -4
View File
@@ -62,7 +62,7 @@
<button
type="button"
class="flex min-h-[calc(-180px+100vh)] w-full grow cursor-pointer flex-col justify-center border-0 bg-transparent p-0 text-center text-inherit appearance-none focus:outline-none transition-colors rounded-2xl {isDragging ? 'bg-gray-100 border-2 border-dashed border-primary-500' : ''} {className}"
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}
@@ -70,8 +70,7 @@
ondrop={handleDrop}
>
<div class="flex flex-row items-baseline justify-center gap-2">
<!-- Changed h1 to span for better semantic HTML inside a button -->
<span class="text-[20px] font-bold text-primary-900">Dateien hinzufügen</span>
<span class="text-[20px] font-extrabold text-primary-900">+</span>
<span class="text-[20px] font-bold">Dateien hinzufügen</span>
<span class="text-[20px] font-extrabold">+</span>
</div>
</button>
@@ -0,0 +1,8 @@
<script>
</script>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.5618 6.94745L14.1001 3.31245C15.0133 2.37424 16.5206 2.37424 17.4338 3.31245L19.0544 4.97737C19.9334 5.88045 19.9334 7.31929 19.0544 8.22237L14.3368 13.069C13.4235 14.0072 11.9162 14.0072 11.003 13.069L10.5537 12.6075" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12.8717 16.4126L7.66659 21.76L4.37911 18.3826C3.50007 17.4796 3.50008 16.0407 4.37911 15.1376L9.09675 10.291C10.01 9.35276 11.5173 9.35276 12.4305 10.291L12.8798 10.7525" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/>
</svg>
+10
View File
@@ -0,0 +1,10 @@
<script>
</script>
<svg width="23" height="24" viewBox="0 0 23 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M18.6875 19.7575C18.6875 21.0807 17.6148 22.1533 16.2917 22.1533H4.3125V5.99287C4.3125 5.39212 4.54755 4.81522 4.96738 4.38552L7.17097 2.13015C7.60374 1.68721 8.19683 1.4375 8.81609 1.4375H16.3875C17.6578 1.4375 18.6875 2.46725 18.6875 3.7375V19.7575Z" stroke="currentColor" stroke-width="1.86875"/>
<path d="M7.06787 12.5889H15.6929" stroke="currentColor" stroke-width="1.725" stroke-linecap="round"/>
<path d="M7.06787 8.71875H15.6929" stroke="currentColor" stroke-width="1.725" stroke-linecap="round"/>
<path d="M7.06787 16.464H12.8179" stroke="currentColor" stroke-width="1.725" stroke-linecap="round"/>
</svg>
+10
View File
@@ -0,0 +1,10 @@
<script>
</script>
<svg width="21" height="15" viewBox="0 0 21 15" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 13.2188C0 12.5457 0.545653 12 1.21875 12H16.8256C17.339 12 17.6536 12.5629 17.3845 13.0002L16.5 14.4375H1.21875C0.545653 14.4375 0 13.8918 0 13.2188Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 7.21875C0 6.54565 0.545653 6 1.21875 6H18.2738C18.7979 6 19.1106 6.58416 18.8198 7.02027L17.875 8.4375H1.21875C0.545653 8.4375 0 7.89185 0 7.21875Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 1.21875C0 0.545653 0.545653 0 1.21875 0H19.721C20.2555 0 20.5658 0.60479 20.2541 1.03898L19.25 2.4375H1.21875C0.545653 2.4375 0 1.89185 0 1.21875Z" fill="currentColor"/>
</svg>
@@ -0,0 +1,9 @@
<script>
</script>
<svg width="23" height="24" viewBox="0 0 23 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M20.4129 7.29267L14.1203 1C14.1203 1 11.8437 8.76535 4.14746 10.9744L11.7731 18.6C13.9805 10.9022 21.7475 8.62562 21.7475 8.62562L15.4548 2.33295" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/>
<path d="M7.28204 14.2833L8.8 15.8656L1.51796 23.0833L1.36219 22.921C0.606308 22.1331 0.622023 20.8845 1.3975 20.1159L7.28204 14.2833Z" fill="currentColor"/>
</svg>
@@ -0,0 +1,15 @@
<script>
</script>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.9914 12.6763V15.4458V20.4H7.98481H3.6001V16.046V4.27311C3.6001 3.32208 4.49438 2.6246 5.41678 2.85624L11.8863 4.48089C12.5359 4.64402 12.9914 5.228 12.9914 5.89777V12.6763Z" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
<path d="M13.6491 8.40002L20.5221 10.2682C21.1583 10.4411 21.5998 11.0186 21.5998 11.6779V12.6424V15.424V18.9392C21.5998 19.746 20.9457 20.4 20.1389 20.4H17.4409H12.2085" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
<path d="M4.5 8.3H7.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<path d="M4.5 12.1H7.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<path d="M4.5 15.9H7.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<path d="M14 13.1H16" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<path d="M14 16.9H16" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
@@ -0,0 +1,4 @@
<svg width="16" height="22" viewBox="0 0 16 22" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.27193 9.97498L13.4818 11.6926C14.3848 12.0611 14.9751 12.9394 14.9751 13.9148V15.4118V18.475C14.9751 19.8557 13.8558 20.975 12.4751 20.975H7.51156H0.975098V16.0858V13.8946C0.975098 12.9294 1.55327 12.0581 2.44261 11.6831L6.49358 9.97498" stroke="currentColor" stroke-width="1.95" stroke-linecap="round"/>
<circle cx="7.4751" cy="5.47498" r="4.5" stroke="currentColor" stroke-width="1.95"/>
</svg>

After

Width:  |  Height:  |  Size: 513 B

@@ -0,0 +1,12 @@
<script>
</script>
<svg width="22" height="23" viewBox="0 0 22 23" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21 17.5834C21.5523 17.5834 22 18.0311 22 18.5834V19.5834H1C0.447716 19.5834 0 19.1357 0 18.5834C0 18.0311 0.447715 17.5834 1 17.5834H21Z" fill="#122F62"/>
<path d="M21 10.5834C21.5523 10.5834 22 11.0311 22 11.5834V12.5834H1C0.447716 12.5834 0 12.1357 0 11.5834C0 11.0311 0.447715 10.5834 1 10.5834H21Z" fill="#122F62"/>
<path d="M21 3.08337C21.5523 3.08337 22 3.53109 22 4.08337V5.08337H1C0.447716 5.08337 0 4.63566 0 4.08337C0 3.53109 0.447715 3.08337 1 3.08337H21Z" fill="#122F62"/>
<circle cx="6.5" cy="3.75" r="2.75" fill="white" stroke="#122F62" stroke-width="2"/>
<circle cx="15.5835" cy="11.0834" r="2.75" fill="white" stroke="#122F62" stroke-width="2"/>
<circle cx="6.5" cy="18.4167" r="2.75" fill="white" stroke="#122F62" stroke-width="2"/>
</svg>
+4
View File
@@ -0,0 +1,4 @@
<svg width="24" height="23" viewBox="0 0 24 23" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.4142 5.48509C10.7066 5.19265 11.101 5.0248 11.5145 5.01676L17.3775 4.90268C18.2946 4.88483 19.039 5.64016 19.0077 6.55694L18.8111 12.3183C18.7973 12.7235 18.6301 13.1084 18.3434 13.3951L10.5219 21.2166C9.89704 21.8414 8.88398 21.8414 8.25914 21.2166L1.47091 14.4284L10.4142 5.48509Z" stroke="currentColor" stroke-width="2.08"/>
<circle cx="13.7192" cy="9.90002" r="1.5" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 556 B

+10
View File
@@ -0,0 +1,10 @@
<script>
</script>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 7V19C18 20.1046 17.1046 21 16 21H6V7" stroke="currentColor" stroke-width="2"/>
<path d="M8 4C8 3.44772 8.44772 3 9 3H15C15.5523 3 16 3.44772 16 4V7H8V4Z" stroke="currentColor" stroke-width="1.8"/>
<path d="M3 7.5H21" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
@@ -0,0 +1,7 @@
<script>
</script>
<svg width="19" height="19" viewBox="0 0 19 19" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.49023 1H12.6572C13.5748 1 14.375 1.62449 14.5977 2.51465L17.0977 12.5146C17.4132 13.7769 16.4584 15 15.1572 15H1.20508L3.52441 2.63184C3.70178 1.68589 4.5278 1 5.49023 1Z" stroke="currentColor" stroke-width="2"/>
<rect x="5" y="17" width="8" height="2" rx="1" fill="currentColor"/>
</svg>
+89
View File
@@ -0,0 +1,89 @@
<script lang="ts" module>
import {ripple} from "$lib/actions/ripple";
import {cn, type WithElementRef} from "$lib/utils.js";
import type {HTMLAnchorAttributes, HTMLButtonAttributes} from "svelte/elements";
import {type VariantProps, tv} from "tailwind-variants";
import type {Component} from "svelte";
export const buttonVariants = tv({
base: "relative overflow-hidden font-bold w-fit min-w-28 hover:not-disabled:cursor-pointer disabled:bg-[#E6E7EA] disabled:text-[#0B1F4366] inline-flex items-center justify-center gap-4",
variants: {
variant: {
primary: "bg-primary text-primary-foreground",
secondary: "bg-linear-to-r from-secondary to-[#FF6F6D] text-primary-foreground",
bordered: "text-primary border-[#E6E7EA] border-2 bg-white hover:bg-[#122F621A]",
tertiary: "text-primary hover:bg-[#122F621A]",
success: "text-white bg-[#0FAD80]"
},
size: {
standard: "px-6 h-8 rounded-[16px]",
large: "px-8 h-12 rounded-[24px]"
},
},
defaultVariants: {
variant: "primary",
size: "standard",
},
});
export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
export type ButtonSize = VariantProps<typeof buttonVariants>["size"];
export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
WithElementRef<HTMLAnchorAttributes> & {
variant?: ButtonVariant;
size?: ButtonSize;
icon?: Component;
};
</script>
<script lang="ts">
let {
class: className,
variant = "primary",
size = "standard",
icon,
ref = $bindable(null),
href = undefined,
type = "button",
disabled,
children,
...restProps
}: ButtonProps = $props();
</script>
{#if href}
<a
use:ripple
bind:this={ref}
data-slot="button"
class={cn("", buttonVariants({ variant, size }), className)}
href={disabled ? undefined : href}
aria-disabled={disabled}
role={disabled ? "link" : undefined}
tabindex={disabled ? -1 : undefined}
{...restProps}
>
{@render children?.()}
{#if icon}
{@const Icon = icon}
<Icon/>
{/if}
</a>
{:else}
<button
use:ripple
bind:this={ref}
data-slot="button"
class={cn("inline-flex items-center justify-center gap-4", buttonVariants({ variant, size }), className)}
{type}
{disabled}
{...restProps}
>
{@render children?.()}
{#if icon}
{@const Icon = icon}
<Icon/>
{/if}
</button>
{/if}
@@ -0,0 +1,23 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-action"
class={cn(
"cn-card-action col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-content"
class={cn("px-4 group-data-[size=sm]/card:px-3", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLParagraphElement>> = $props();
</script>
<p
bind:this={ref}
data-slot="card-description"
class={cn("text-muted-foreground text-sm", className)}
{...restProps}
>
{@render children?.()}
</p>
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-footer"
class={cn("bg-muted/50 rounded-b-xl border-t p-4 group-data-[size=sm]/card:p-3 flex items-center", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,23 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-header"
class={cn(
"gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3 group/card-header @container/card-header grid auto-rows-min items-start has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto]",
className
)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-title"
class={cn("text-base leading-snug font-medium group-data-[size=sm]/card:text-sm", className)}
{...restProps}
>
{@render children?.()}
</div>
+22
View File
@@ -0,0 +1,22 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
size = "default",
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & { size?: "default" | "sm" } = $props();
</script>
<div
bind:this={ref}
data-slot="card"
data-size={size}
class={cn("ring-foreground/10 bg-card text-card-foreground gap-4 overflow-hidden rounded-xl py-4 text-sm ring-1 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl group/card flex flex-col", className)}
{...restProps}
>
{@render children?.()}
</div>
+25
View File
@@ -0,0 +1,25 @@
import Root from "./card.svelte";
import Content from "./card-content.svelte";
import Description from "./card-description.svelte";
import Footer from "./card-footer.svelte";
import Header from "./card-header.svelte";
import Title from "./card-title.svelte";
import Action from "./card-action.svelte";
export {
Root,
Content,
Description,
Footer,
Header,
Title,
Action,
//
Root as Card,
Content as CardContent,
Description as CardDescription,
Footer as CardFooter,
Header as CardHeader,
Title as CardTitle,
Action as CardAction,
};
-108
View File
@@ -1,108 +0,0 @@
[data-theme='nominandum'] {
--text-scaling: 1.067;
--base-font-color: var(--color-surface-900);
--base-font-color-dark: var(--color-surface-50);
--base-font-family: Nunito, Helvetica Neue, sans-serif;
--base-font-size: inherit;
--base-line-height: 1.5;
--base-font-weight: 400;
--heading-font-color: var(--color-primary-900);
--heading-font-color-dark: var(--color-primary-900);
--heading-font-family: Nunito, Helvetica Neue, sans-serif;
--heading-font-weight: 400;
--anchor-font-color: var(--color-primary-600);
--anchor-font-color-dark: var(--color-primary-300);
--anchor-text-decoration: none;
--anchor-text-decoration-hover: underline;
--spacing: 0.25rem;
--radius-base: 0.5rem;
--radius-container: 24px;
--default-border-width: 1px;
--default-divide-width: 1px;
--default-ring-width: 2px;
--body-background-color: #EEF4F6;
--body-background-color-dark: #0f172a;
/* Primary: Material Blue */
/* Very Light / Ghostly */
--color-primary-50: oklch(96.8% 0.010 253deg);
--color-primary-100: oklch(92.8% 0.020 253deg);
--color-primary-200: oklch(86.5% 0.040 253deg);
--color-primary-300: oklch(77.5% 0.060 253deg);
--color-primary-400: oklch(68.5% 0.080 253deg);
/* Mid-range / Muted Tones */
--color-primary-500: oklch(59.5% 0.090 253deg);
--color-primary-600: oklch(52.5% 0.095 253deg);
--color-primary-700: oklch(45.5% 0.095 253deg);
/* Dark Range (Matching your base color) */
--color-primary-800: oklch(38.5% 0.090 253deg);
--color-primary-900: oklch(31.5% 0.090 253deg); /* YOUR BASE COLOR */
--color-primary-950: oklch(23.5% 0.070 253deg);
--color-primary-contrast-dark: var(--color-primary-950);
--color-primary-contrast-light: white;
--color-primary-contrast-50: var(--color-primary-contrast-dark);
--color-primary-contrast-100: var(--color-primary-contrast-dark);
--color-primary-contrast-200: var(--color-primary-contrast-dark);
--color-primary-contrast-300: var(--color-primary-contrast-dark);
--color-primary-contrast-400: white;
--color-primary-contrast-500: white;
--color-primary-contrast-600: white;
--color-primary-contrast-700: white;
--color-primary-contrast-800: white;
--color-primary-contrast-900: white;
--color-primary-contrast-950: white;
/* Secondary: dezentes Blau-Grau */
--color-secondary-50: oklch(97% 0.01 250deg);
--color-secondary-100: oklch(93.5% 0.015 250deg);
--color-secondary-200: oklch(88.5% 0.022 250deg);
--color-secondary-300: oklch(80% 0.035 250deg);
--color-secondary-400: oklch(68% 0.045 250deg);
--color-secondary-500: oklch(56% 0.055 250deg);
--color-secondary-600: oklch(48% 0.05 250deg);
--color-secondary-700: oklch(40% 0.045 250deg);
--color-secondary-800: oklch(32% 0.04 250deg);
--color-secondary-900: oklch(24% 0.035 250deg);
--color-secondary-950: oklch(17% 0.03 250deg);
--color-secondary-contrast-dark: var(--color-secondary-950);
--color-secondary-contrast-light: white;
/* Tertiary: Akzent Cyan */
--color-tertiary-50: oklch(97% 0.025 220deg);
--color-tertiary-100: oklch(93% 0.045 220deg);
--color-tertiary-200: oklch(87% 0.07 220deg);
--color-tertiary-300: oklch(78% 0.105 220deg);
--color-tertiary-400: oklch(69% 0.135 220deg);
--color-tertiary-500: oklch(60% 0.15 220deg);
--color-tertiary-600: oklch(52% 0.135 220deg);
--color-tertiary-700: oklch(44% 0.115 220deg);
--color-tertiary-800: oklch(36% 0.09 220deg);
--color-tertiary-900: oklch(29% 0.065 220deg);
--color-tertiary-950: oklch(21% 0.045 220deg);
/* Surface: Material-nahe kühle Grautöne */
--color-surface-50: oklch(98.5% 0.004 255deg);
--color-surface-100: oklch(96.5% 0.006 255deg);
--color-surface-200: oklch(92.5% 0.01 255deg);
--color-surface-300: oklch(86.5% 0.015 255deg);
--color-surface-400: oklch(74% 0.02 255deg);
--color-surface-500: oklch(62% 0.025 255deg);
--color-surface-600: oklch(50% 0.025 255deg);
--color-surface-700: oklch(39% 0.025 255deg);
--color-surface-800: oklch(29% 0.025 255deg);
--color-surface-900: oklch(21% 0.025 255deg);
--color-surface-950: oklch(14% 0.025 255deg);
--color-surface-contrast-dark: var(--color-surface-950);
--color-surface-contrast-light: white;
}
+93
View File
@@ -0,0 +1,93 @@
export type XJustizMetadata = {
sender: string;
receiver: string;
documentNames: string[];
};
const XML_TEXT_DECODER = new TextDecoder();
const matchesElementName = (element: Element, requestedName: string) => {
const requestedLocalName = requestedName.split('.').pop() ?? requestedName;
return (
element.localName === requestedName ||
element.localName === requestedLocalName ||
element.tagName === requestedName ||
element.tagName === requestedLocalName ||
element.tagName.endsWith(`:${requestedLocalName}`)
);
};
const getDirectChildByLocalName = (element: Element, localName: string) =>
Array.from(element.children).find((child) => matchesElementName(child, localName)) ?? null;
const getFirstDescendantByLocalName = (root: Document | Element, localName: string) =>
Array.from(root.querySelectorAll('*')).find((element) =>
matchesElementName(element, localName)
) ?? null;
const getTextContentByPath = (root: Element, path: string[]) => {
let current: Element | null = root;
for (const localName of path) {
current = current ? getDirectChildByLocalName(current, localName) : null;
if (!current) {
return null;
}
}
const textContent = current.textContent?.trim();
return textContent ? textContent : null;
};
const getTextContentByLocalName = (root: Document | Element, localName: string) =>
getFirstDescendantByLocalName(root, localName)?.textContent?.trim() ?? null;
const parseXmlDocument = (xmlBytes: Uint8Array) => {
const parser = new DOMParser();
const document = parser.parseFromString(XML_TEXT_DECODER.decode(xmlBytes), 'application/xml');
if (document.querySelector('parsererror')) {
return null;
}
return document;
};
export const parseXJustizMetadata = (xmlBytes: Uint8Array): XJustizMetadata | null => {
const document = parseXmlDocument(xmlBytes);
if (!document) {
return null;
}
const sender = getTextContentByLocalName(document, 'aktenzeichen.absender') ?? '';
const receiver = getTextContentByLocalName(document, 'aktenzeichen.empfaenger') ?? '';
const schriftgutobjekte = getFirstDescendantByLocalName(document, 'schriftgutobjekte');
if (!schriftgutobjekte) {
return {
sender,
receiver,
documentNames: []
};
}
const dokumentNodes = Array.from(schriftgutobjekte.children).filter((child) =>
matchesElementName(child, 'dokument')
);
const documentNames = dokumentNodes
.map((dokument) =>
getTextContentByPath(dokument, ['xjustiz.fachspezifischeDaten', 'datei', 'dateiname'])
)
.filter((name): name is string => Boolean(name));
return {
sender,
receiver,
documentNames
};
};
+13
View File
@@ -0,0 +1,13 @@
import { unzip } from 'fflate';
export const unzipArchive = (data: Uint8Array) =>
new Promise<Record<string, Uint8Array>>((resolve, reject) => {
unzip(data, (error, files) => {
if (error) {
reject(error);
return;
}
resolve(files);
});
});
+13
View File
@@ -0,0 +1,13 @@
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;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
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 };
+628
View File
@@ -0,0 +1,628 @@
import { PDFDocument } from 'pdf-lib';
import { parseXJustizMetadata, type XJustizMetadata } from './services/xml-reading.service';
import { unzipArchive } from './services/zip-inflating.service';
export type ZipAttachmentKind = 'pdf' | 'image';
export type ZipAttachment = {
name: string;
path: string;
kind: ZipAttachmentKind;
data: Uint8Array;
};
export type SubDocument = {
id: string;
name: string;
attachments: ZipAttachment[];
};
export type ProcessedZipArchive = {
name: string;
attachments: ZipAttachment[];
subDocuments: SubDocument[];
};
export type ExportMode = 'single' | 'separate';
export type ExportUnit = {
name: string;
bytes: Uint8Array;
};
/**
* Drag source/target descriptors shared between the archive editor and the
* sub-document editor so HTML5 `dataTransfer` payloads stay consistent.
*/
export type AttachmentLocation =
| { kind: 'loose'; index: number }
| { kind: 'subDocument'; id: string; index: number };
const IMAGE_EXTENSIONS = new Set([
'.avif',
'.bmp',
'.gif',
'.heic',
'.jpeg',
'.jpg',
'.jp2',
'.png',
'.tif',
'.tiff',
'.webp'
]);
const ZIP_META_FILE_NAME = 'xjustiz_nachricht.xml';
const getBaseName = (path: string) => path.split('/').pop() ?? path;
const getCurrentDateString = (date: Date) =>
`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
const createArchiveName = (date: Date, metadata: XJustizMetadata | null) => {
const datePrefix = getCurrentDateString(date);
if (!metadata) {
return `${datePrefix}_unbekannt_unbekannt.pdf`;
}
return `${datePrefix}_${metadata.sender}_${metadata.receiver}.pdf`;
};
const getExtension = (path: string) => {
const baseName = getBaseName(path).toLowerCase();
const lastDotIndex = baseName.lastIndexOf('.');
return lastDotIndex === -1 ? '' : baseName.slice(lastDotIndex);
};
const isPdf = (path: string) => getExtension(path) === '.pdf';
const isImage = (path: string) => IMAGE_EXTENSIONS.has(getExtension(path));
const isZipArchive = (path: string) => getExtension(path) === '.zip';
const normalizeKey = (value: string) => value.replaceAll('\\', '/').toLowerCase().trim();
const isMacMetadataEntry = (path: string) => {
const normalizedPath = normalizeKey(path);
const baseName = getBaseName(normalizedPath);
return (
normalizedPath.startsWith('__macosx/') ||
normalizedPath.includes('/__macosx/') ||
baseName === '.ds_store' ||
baseName.startsWith('._')
);
};
const orderAttachmentsByDocumentNames = (attachments: ZipAttachment[], documentNames: string[]) => {
const buckets = new Map<string, ZipAttachment[]>();
for (const attachment of attachments) {
const keys = new Set([normalizeKey(attachment.path), normalizeKey(attachment.name)]);
for (const key of keys) {
const bucket = buckets.get(key) ?? [];
bucket.push(attachment);
buckets.set(key, bucket);
}
}
const usedAttachments = new Set<ZipAttachment>();
const orderedAttachments: ZipAttachment[] = [];
const takeAttachment = (key: string) => {
const bucket = buckets.get(key);
if (!bucket) {
return null;
}
const nextAttachment = bucket.find((attachment) => !usedAttachments.has(attachment)) ?? null;
if (nextAttachment) {
usedAttachments.add(nextAttachment);
}
return nextAttachment;
};
for (const documentName of documentNames) {
const normalizedName = normalizeKey(documentName);
const normalizedBaseName = normalizeKey(getBaseName(documentName));
const matchedAttachment = takeAttachment(normalizedName) ?? takeAttachment(normalizedBaseName);
if (matchedAttachment) {
orderedAttachments.push(matchedAttachment);
}
}
for (const attachment of attachments) {
if (!usedAttachments.has(attachment)) {
orderedAttachments.push(attachment);
}
}
return orderedAttachments;
};
const shouldSkipZipEntry = (path: string) => isMacMetadataEntry(path);
const isMetadataFile = (path: string) => getBaseName(path).toLowerCase() === ZIP_META_FILE_NAME;
const createAttachment = (path: string, data: Uint8Array): ZipAttachment => ({
name: getBaseName(path),
path,
kind: isPdf(path) ? 'pdf' : 'image',
data
});
const extractZipEntries = async (archiveBytes: Uint8Array): Promise<ProcessedZipArchive[]> => {
const files = await unzipArchive(archiveBytes);
const attachments: ZipAttachment[] = [];
const nestedArchives: ProcessedZipArchive[] = [];
let xjustizNachrichtXml: Uint8Array | null = null;
for (const [path, data] of Object.entries(files)) {
if (shouldSkipZipEntry(path)) {
continue;
}
if (isMetadataFile(path)) {
xjustizNachrichtXml = data;
continue;
}
if (isZipArchive(path)) {
const childArchives = await extractZipEntries(data);
nestedArchives.push(...childArchives);
continue;
}
if (isPdf(path) || isImage(path)) {
attachments.push(createAttachment(path, data));
}
}
const metadata = xjustizNachrichtXml ? parseXJustizMetadata(xjustizNachrichtXml) : null;
const documentNames = metadata?.documentNames ?? [];
const orderedAttachments = orderAttachmentsByDocumentNames(attachments, documentNames);
const archiveName = createArchiveName(new Date(), metadata);
const archives: ProcessedZipArchive[] = [];
if (orderedAttachments.length > 0) {
archives.push({
name: archiveName,
attachments: orderedAttachments,
subDocuments: []
});
}
return [...archives, ...nestedArchives];
};
export const extractZipArchives = async (file: File): Promise<ProcessedZipArchive[]> => {
const archiveBytes = new Uint8Array(await file.arrayBuffer());
return extractZipEntries(archiveBytes);
};
const loadImageElement = (src: string) =>
new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image();
image.decoding = 'async';
image.onload = () => resolve(image);
image.onerror = () => reject(new Error(`Unable to decode image asset: ${src}`));
image.src = src;
});
const toArrayBuffer = (bytes: Uint8Array) =>
(() => {
const copy = new Uint8Array(bytes.byteLength);
copy.set(bytes);
return copy.buffer;
})();
const convertImageBytesToPng = async (imageBytes: Uint8Array) => {
if (typeof document === 'undefined') {
throw new Error('Image conversion requires a browser environment.');
}
const objectUrl = URL.createObjectURL(new Blob([toArrayBuffer(imageBytes)]));
try {
const image = await loadImageElement(objectUrl);
const canvas = document.createElement('canvas');
canvas.width = image.naturalWidth || image.width;
canvas.height = image.naturalHeight || image.height;
const context = canvas.getContext('2d');
if (!context) {
throw new Error('Could not create a canvas context for image conversion.');
}
context.drawImage(image, 0, 0);
const pngBlob = await new Promise<Blob>((resolve, reject) => {
canvas.toBlob((blob) => {
if (blob) {
resolve(blob);
return;
}
reject(new Error('Could not encode image asset as PNG.'));
}, 'image/png');
});
return new Uint8Array(await pngBlob.arrayBuffer());
} finally {
URL.revokeObjectURL(objectUrl);
}
};
const embedAttachmentImage = async (pdfDocument: PDFDocument, attachment: ZipAttachment) => {
const extension = getExtension(attachment.path);
const imageData = attachment.data;
if (extension === '.jpg' || extension === '.jpeg') {
return pdfDocument.embedJpg(imageData);
}
if (extension === '.png') {
return pdfDocument.embedPng(imageData);
}
const pngBytes = await convertImageBytesToPng(imageData);
return pdfDocument.embedPng(pngBytes);
};
const mergeAttachmentIntoPdf = async (mergedPdf: PDFDocument, attachment: ZipAttachment) => {
if (attachment.kind === 'pdf') {
const sourcePdf = await PDFDocument.load(attachment.data, { ignoreEncryption: true });
const copiedPages = await mergedPdf.copyPages(sourcePdf, sourcePdf.getPageIndices());
for (const page of copiedPages) {
mergedPdf.addPage(page);
}
return;
}
try {
const embeddedImage = await embedAttachmentImage(mergedPdf, attachment);
const page = mergedPdf.addPage([embeddedImage.width, embeddedImage.height]);
page.drawImage(embeddedImage, {
x: 0,
y: 0,
width: page.getWidth(),
height: page.getHeight()
});
} catch (error) {
throw error;
}
};
export const mergeAttachments = async (attachments: ZipAttachment[]): Promise<Uint8Array> => {
if (attachments.length === 0) {
throw new Error('Cannot create a PDF from an empty attachment list.');
}
const mergedPdf = await PDFDocument.create();
for (const attachment of attachments) {
await mergeAttachmentIntoPdf(mergedPdf, attachment);
}
return mergedPdf.save();
};
export const mergeProcessedZipArchive = async (archive: ProcessedZipArchive) => {
if (archive.attachments.length === 0 && archive.subDocuments.length === 0) {
throw new Error('Cannot create a PDF from an empty archive.');
}
return mergeAttachments(flattenArchiveForMerge(archive));
};
/**
* Flatten the archive into a single ordered attachment list, preserving
* sub-document order first and appending loose attachments at the end.
*/
export const flattenArchiveForMerge = (archive: ProcessedZipArchive): ZipAttachment[] => {
const ordered: ZipAttachment[] = [];
for (const subDocument of archive.subDocuments) {
ordered.push(...subDocument.attachments);
}
ordered.push(...archive.attachments);
return ordered;
};
/**
* Create a new sub-document with a stable id from a list of attachments.
*/
export const createSubDocument = (name: string, attachments: ZipAttachment[]): SubDocument => ({
id: crypto.randomUUID(),
name,
attachments: attachments.map((attachment) => ({ ...attachment }))
});
/**
* Reorder sub-documents inside an archive. Matches the semantics of a single
* `splice(fromIndex, 1)` -> `splice(toIndex, 0, moved)` move.
*/
export const moveSubDocument = (
archive: ProcessedZipArchive,
fromIndex: number,
toIndex: number
): ProcessedZipArchive => {
if (
fromIndex === toIndex ||
fromIndex < 0 ||
toIndex < 0 ||
fromIndex >= archive.subDocuments.length ||
toIndex >= archive.subDocuments.length
) {
return archive;
}
const nextSubDocuments = [...archive.subDocuments];
const [movedSubDocument] = nextSubDocuments.splice(fromIndex, 1);
nextSubDocuments.splice(toIndex, 0, movedSubDocument);
return { ...archive, subDocuments: nextSubDocuments };
};
const removeAt = <T>(list: T[], index: number): T | null => {
if (index < 0 || index >= list.length) {
return null;
}
const [item] = list.splice(index, 1);
return item ?? null;
};
const insertAt = <T>(list: T[], index: number, item: T) => {
const clampedIndex = Math.max(0, Math.min(index, list.length));
list.splice(clampedIndex, 0, item);
};
/**
* Reorder an attachment within its current location (loose list or a single
* sub-document's attachment list). Used by the per-list drag handlers.
*/
export const moveAttachmentInPlace = (
archive: ProcessedZipArchive,
location: AttachmentLocation,
toIndex: number
): ProcessedZipArchive => {
if (location.kind === 'loose') {
const nextAttachments = [...archive.attachments];
const moved = removeAt(nextAttachments, location.index);
if (!moved) {
return archive;
}
insertAt(nextAttachments, toIndex, moved);
return { ...archive, attachments: nextAttachments };
}
let changed = false;
const nextSubDocuments = archive.subDocuments.map((subDocument) => {
if (subDocument.id !== location.id) {
return subDocument;
}
const nextAttachments = [...subDocument.attachments];
const moved = removeAt(nextAttachments, location.index);
if (!moved) {
return subDocument;
}
insertAt(nextAttachments, toIndex, moved);
changed = true;
return { ...subDocument, attachments: nextAttachments };
});
return changed ? { ...archive, subDocuments: nextSubDocuments } : archive;
};
/**
* Move an attachment from a source location to a target location. Either side
* may be the loose list or any sub-document. The attachment moves (not copies),
* so the total attachment count is invariant. When source and target point to
* the same list, this degenerates to an in-place reorder (explicitly delegated
* to `moveAttachmentInPlace` so the indices are normalized correctly).
*/
export const moveAttachmentIntoSubDocument = (
archive: ProcessedZipArchive,
source: AttachmentLocation,
target: AttachmentLocation
): ProcessedZipArchive => {
const sameList =
source.kind === 'loose' && target.kind === 'loose'
? true
: source.kind === 'subDocument' && target.kind === 'subDocument'
? source.id === target.id
: false;
if (sameList) {
return moveAttachmentInPlace(archive, source, target.index);
}
const nextSubDocuments = archive.subDocuments.map((subDocument) => ({
...subDocument,
attachments: [...subDocument.attachments]
}));
const looseAttachments = [...archive.attachments];
const removeFromList = (location: AttachmentLocation): ZipAttachment | null => {
if (location.kind === 'loose') {
return removeAt(looseAttachments, location.index);
}
const target_ = nextSubDocuments.find((item) => item.id === location.id);
return target_ ? removeAt(target_.attachments, location.index) : null;
};
const insertIntoList = (attachment: ZipAttachment, location: AttachmentLocation) => {
if (location.kind === 'loose') {
insertAt(looseAttachments, location.index, attachment);
return;
}
const target_ = nextSubDocuments.find((item) => item.id === location.id);
if (!target_) {
// fall back to loose list end if the referenced sub-document vanished
looseAttachments.push(attachment);
return;
}
insertAt(target_.attachments, location.index, attachment);
};
const movedAttachment = removeFromList(source);
if (!movedAttachment) {
return archive;
}
insertIntoList(movedAttachment, target);
return {
...archive,
attachments: looseAttachments,
subDocuments: nextSubDocuments
};
};
/**
* Remove a single attachment from a sub-document and return it to the end of
* the loose list. Useful as a targeted "move back to loose" action.
*/
export const removeAttachmentFromSubDocument = (
archive: ProcessedZipArchive,
subDocumentId: string,
attachmentIndex: number
): ProcessedZipArchive => {
const nextSubDocuments = archive.subDocuments.map((subDocument) => ({
...subDocument,
attachments: [...subDocument.attachments]
}));
const nextLooseAttachments = [...archive.attachments];
const target_ = nextSubDocuments.find((item) => item.id === subDocumentId);
if (!target_) {
return archive;
}
const moved = removeAt(target_.attachments, attachmentIndex);
if (!moved) {
return archive;
}
nextLooseAttachments.push(moved);
return {
...archive,
attachments: nextLooseAttachments,
subDocuments: nextSubDocuments
};
};
/**
* Remove an entire sub-document and return its attachments to the end of the
* loose list. The sub-document ids of the remaining sub-documents are
* preserved.
*/
export const dissolveSubDocument = (
archive: ProcessedZipArchive,
subDocumentId: string
): ProcessedZipArchive => {
const dissolved = archive.subDocuments.find((item) => item.id === subDocumentId);
if (!dissolved) {
return archive;
}
return {
...archive,
attachments: [...archive.attachments, ...dissolved.attachments],
subDocuments: archive.subDocuments.filter((item) => item.id !== subDocumentId)
};
};
const stripPdfExtension = (name: string) =>
name.toLowerCase().endsWith('.pdf') ? name.slice(0, -'.pdf'.length) : name;
/**
* Build one or more PDF export units from the archive.
*
* - `mode: 'single'` mirrors today's behaviour: one merged PDF covering
* sub-documents first, then loose attachments, named after the archive.
* - `mode: 'separate'` produces one PDF per sub-document (named
* `{archiveName} - {subDocumentName}.pdf`) plus a single merged PDF for any
* loose attachments (named after the archive). Empty groups are skipped.
*/
export const buildArchiveExport = async (
archive: ProcessedZipArchive,
mode: ExportMode
): Promise<ExportUnit[]> => {
if (mode === 'single') {
const attachments = flattenArchiveForMerge(archive);
if (attachments.length === 0) {
throw new Error('Cannot create a PDF from an empty archive.');
}
return [
{
name: stripPdfExtension(archive.name),
bytes: await mergeAttachments(attachments)
}
];
}
const separateUnits: ExportUnit[] = [];
for (const subDocument of archive.subDocuments) {
if (subDocument.attachments.length === 0) {
continue;
}
separateUnits.push({
name: `${stripPdfExtension(archive.name)} - ${stripPdfExtension(subDocument.name)}`,
bytes: await mergeAttachments(subDocument.attachments)
});
}
if (archive.attachments.length > 0) {
separateUnits.push({
name: stripPdfExtension(archive.name),
bytes: await mergeAttachments(archive.attachments)
});
}
if (separateUnits.length === 0) {
throw new Error('Cannot create a PDF from an empty archive.');
}
return separateUnits;
};
+192 -16
View File
@@ -1,35 +1,211 @@
<script lang="ts">
import Card from "$lib/components/Card.svelte";
import ZipDropzone from "$lib/components/ZipDropzone.svelte";
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";
const THUMBNAIL_WIDTH_STORAGE_KEY = 'thumbnailWidth';
let selectedZipFiles: File[] = $state([]);
let processedZipFiles: ProcessedZipArchive[] = $state([]);
let pendingZipCount = $state(0);
let thumbnailWidth = $state(200);
let thumbnailWidthHydrated = $state(false);
let archiveGeneration = 0;
const clampThumbnailWidth = (value: number) => Math.min(400, Math.max(100, value));
onMount(() => {
const storedValue = localStorage.getItem(THUMBNAIL_WIDTH_STORAGE_KEY);
if (storedValue !== null) {
const parsedValue = Number(storedValue);
if (Number.isFinite(parsedValue)) {
thumbnailWidth = clampThumbnailWidth(parsedValue);
}
}
thumbnailWidthHydrated = true;
});
$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;
};
</script>
{#snippet content()}
{#if selectedZipFiles.length === 0}
<ZipDropzone onFilesSelected={handleFilesSelected} />
<Card.Root>
<Card.Content>
<ZipDropzone onFilesSelected={handleFilesSelected}/>
</Card.Content>
</Card.Root>
{:else}
<div class="flex min-h-[calc(-180px+100vh)] w-full grow flex-col justify-center gap-4 px-4 py-6">
<div class="flex flex-row items-baseline justify-center gap-2">
<h2 class="h1 text-[20px] font-bold text-primary-900">ZIP-Dateien geladen</h2>
</div>
<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>
<ul class="mx-auto w-full max-w-3xl space-y-2">
{#each selectedZipFiles as file}
<li class="rounded-container bg-primary-50 px-4 py-3 text-[14px] font-medium text-primary-900">
{file.name}
</li>
{/each}
</ul>
<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>
<Button
class="flex-1"
type="button"
disabled={selectedZipFiles.length === 0}
variant="secondary"
onclick={deleteAllZipArchives}
>
Alle ZIP-Archive löschen
</Button>
</div>
<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>
<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}
{#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}
<section class="px-4">
<h1 class="h1 text-[20px] font-bold py-6 text-primary-900">beA-Edit</h1>
<Card {content} class="h-full"/>
<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>
+155 -10
View File
@@ -1,16 +1,161 @@
@import 'tailwindcss';
@import '@skeletonlabs/skeleton/themes/cerberus';
@import '@skeletonlabs/skeleton';
@import '@skeletonlabs/skeleton-svelte';
@import '../lib/nom-theme.css';
/*@import '../globals.css';*/
@import "tw-animate-css";
@import "shadcn-svelte/tailwind.css";
@import "@fontsource-variable/geist";
@custom-variant dark (&:is(.dark *));
@plugin '@tailwindcss/forms';
@plugin '@tailwindcss/typography';
@font-face {
font-family: 'Nunito';
font-style: normal;
font-weight: 100 900;
src: url('$lib/assets/Nunito-VariableFont_wght.ttf') format('truetype');
}
@font-face{
font-family: 'Nunito';
font-style: normal;
font-weight: 100 900;
src: url('$lib/assets/Nunito-VariableFont_wght.ttf') format("truetype");
}
:root {
--background: #EEF4F6;
--foreground: #122F62;
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: #122f62;
--primary-foreground: oklch(0.985 0 0);
--secondary: #EA544B;
--secondary-end: #FF6F6D;
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: #d6dce5;
--accent-foreground: oklch(0.205 0 0);
--destructive: #ff0027;
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 16px;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: #212121;
--foreground: #FFFFFF;
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: #92b2eb;
--primary-foreground: oklch(0.205 0 0);
--secondary: linear-gradient(90deg, #EA544B 0%, #FF6F6D 100%);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: #ff0027;
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@theme inline {
--font-sans: 'Nunito', sans-serif;
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
}
/* ripple */
@keyframes ripple {
from {
transform: scale(0);
opacity: 0.8;
}
to {
transform: scale(4);
opacity: 0;
}
}
.animate-ripple {
animation: ripple 600ms ease-out;
}