Merge branch 'features/shadcn' into develop
# Conflicts: # .idea/workspace.xml # agents.md
This commit is contained in:
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 |
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user