307 lines
8.3 KiB
Svelte
307 lines
8.3 KiB
Svelte
<script lang="ts">
|
|
import AttachmentPreview from '$lib/components/AttachmentPreview.svelte';
|
|
import Button from '$lib/components/ui/button.svelte';
|
|
import type { AttachmentLocation, SubDocument, ZipAttachment } from '$lib/zip-processing';
|
|
import { cn } from '$lib/utils';
|
|
|
|
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;
|
|
onDownload?: () => void;
|
|
isDownloading?: boolean;
|
|
downloadDisabled?: boolean;
|
|
};
|
|
|
|
let {
|
|
subDocument,
|
|
blockIndex,
|
|
thumbnailWidth = 200,
|
|
isBlockDragSource = false,
|
|
isBlockDropTarget = false,
|
|
onSubDocumentChange = () => {},
|
|
onRemove = () => {},
|
|
onAttachmentMove = () => {},
|
|
onBlockDragStart = () => {},
|
|
onBlockDragOver = () => {},
|
|
onBlockDrop = () => {},
|
|
onBlockDragEnd = () => {},
|
|
onDownload,
|
|
isDownloading = false,
|
|
downloadDisabled = false
|
|
}: 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>
|
|
|
|
{#if onDownload}
|
|
<Button
|
|
type="button"
|
|
variant="bordered"
|
|
disabled={downloadDisabled || isDownloading || attachments.length === 0}
|
|
onclick={onDownload}
|
|
draggable="false"
|
|
aria-label={`PDF für ${subDocumentName} herunterladen`}
|
|
>
|
|
{isDownloading ? 'PDF wird erstellt' : 'PDF herunterladen'}
|
|
</Button>
|
|
{/if}
|
|
|
|
<button
|
|
class="rounded-md border border-primary-200 bg-white px-2 py-1 text-[13px] font-medium text-primary-700 transition hover:border-red-300 hover:bg-red-50 hover:text-red-700"
|
|
type="button"
|
|
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>
|