Backend: - Add ANNOTATE_ALL permission - Add ANNOTATION_NOT_FOUND and ANNOTATION_OVERLAP error codes - V10 migration: document_annotations table with page/rect/color/owner - DocumentAnnotation entity, AnnotationRepository, CreateAnnotationDTO - AnnotationService: overlap detection (rectangle intersection), ownership enforcement on delete - AnnotationController: GET (authenticated), POST/DELETE (ANNOTATE_ALL) - 15 new tests (AnnotationServiceTest, AnnotationControllerTest) — TDD red/green Frontend: - AnnotationLayer.svelte: pointer-event drawing, colored rect overlays, delete buttons - PdfViewer.svelte: annotate toggle, color picker, loads/saves/deletes annotations via API - Disabled annotate button with tooltip for users without ANNOTATE_ALL - canAnnotate exposed from layout server, passed to PdfViewer - errors.ts + de/en/es translations for new error codes - 3 new unit tests for AnnotationLayer — TDD red/green Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
891 lines
28 KiB
Svelte
891 lines
28 KiB
Svelte
<script lang="ts">
|
|
import { m } from '$lib/paraglide/messages.js';
|
|
import { formatDate } from '$lib/utils/date';
|
|
import { diffWords } from 'diff';
|
|
import ExpandableText from '$lib/components/ExpandableText.svelte';
|
|
import PdfViewer from '$lib/components/PdfViewer.svelte';
|
|
|
|
let { data } = $props();
|
|
|
|
const doc = $derived(data.document);
|
|
|
|
let fileUrl = $state('');
|
|
let isLoading = $state(false);
|
|
let error = $state('');
|
|
|
|
$effect(() => {
|
|
if (doc?.id && doc?.filePath) {
|
|
loadFile(doc.id);
|
|
}
|
|
});
|
|
|
|
async function loadFile(id: string) {
|
|
isLoading = true;
|
|
error = '';
|
|
fileUrl = '';
|
|
|
|
try {
|
|
const response = await fetch(`/api/documents/${id}/file`);
|
|
|
|
if (!response.ok) {
|
|
if (response.status === 401) throw new Error('Nicht eingeloggt');
|
|
throw new Error('Fehler beim Laden der Datei');
|
|
}
|
|
|
|
const blob = await response.blob();
|
|
fileUrl = URL.createObjectURL(blob);
|
|
} catch (e) {
|
|
console.error(e);
|
|
error = m.doc_file_error_preview();
|
|
} finally {
|
|
isLoading = false;
|
|
}
|
|
}
|
|
|
|
// ── History panel ────────────────────────────────────────────────────────────
|
|
|
|
type VersionSummary = {
|
|
id: string;
|
|
savedAt: string;
|
|
editorName: string;
|
|
changedFields: string[];
|
|
};
|
|
|
|
type SnapshotDoc = {
|
|
title?: string;
|
|
documentDate?: string;
|
|
location?: string;
|
|
documentLocation?: string;
|
|
transcription?: string;
|
|
summary?: string;
|
|
sender?: { id: string; firstName: string; lastName: string } | null;
|
|
receivers?: { id: string; firstName: string; lastName: string }[];
|
|
tags?: { id: string; name: string }[];
|
|
};
|
|
|
|
type DiffEntry =
|
|
| {
|
|
kind: 'text';
|
|
field: string;
|
|
label: string;
|
|
parts: { value: string; added?: boolean; removed?: boolean }[];
|
|
}
|
|
| { kind: 'scalar'; field: string; label: string; oldVal: string; newVal: string }
|
|
| { kind: 'relation'; field: string; label: string; removed: string[]; added: string[] };
|
|
|
|
let historyOpen = $state(false);
|
|
let historyLoaded = $state(false);
|
|
let historyLoading = $state(false);
|
|
let versions = $state<VersionSummary[]>([]);
|
|
|
|
let compareMode = $state(false);
|
|
let compareA = $state('');
|
|
let compareB = $state('');
|
|
|
|
let selectedVersionId = $state<string | null>(null);
|
|
let diffEntries = $state<DiffEntry[]>([]);
|
|
let diffLoading = $state(false);
|
|
let noDiff = $state(false);
|
|
|
|
const fieldLabels: Record<string, () => string> = {
|
|
title: m.history_field_title,
|
|
documentDate: m.history_field_document_date,
|
|
location: m.history_field_location,
|
|
documentLocation: m.history_field_document_location,
|
|
transcription: m.history_field_transcription,
|
|
summary: m.history_field_summary,
|
|
sender: m.history_field_sender,
|
|
receivers: m.history_field_receivers,
|
|
tags: m.history_field_tags
|
|
};
|
|
|
|
const TEXT_FIELDS = ['title', 'summary', 'transcription'] as const;
|
|
const SCALAR_FIELDS = ['documentDate', 'location', 'documentLocation'] as const;
|
|
|
|
function parseSnapshot(raw: string): SnapshotDoc {
|
|
try {
|
|
return JSON.parse(raw) as SnapshotDoc;
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
function personLabel(p: { firstName: string; lastName: string }): string {
|
|
return `${p.firstName} ${p.lastName}`.trim();
|
|
}
|
|
|
|
const DIFF_CONTEXT_WORDS = 4;
|
|
|
|
type DiffPart = { value: string; added?: boolean; removed?: boolean };
|
|
|
|
function trimContextParts(parts: DiffPart[]): DiffPart[] {
|
|
return parts.flatMap((part, i) => {
|
|
if (part.added || part.removed) return [part];
|
|
const tokens = part.value.split(/(\s+)/).filter(Boolean);
|
|
const wordCount = tokens.filter((t) => /\S/.test(t)).length;
|
|
if (wordCount <= DIFF_CONTEXT_WORDS * 2) return [part];
|
|
|
|
function keepFirst(n: number): string {
|
|
let count = 0;
|
|
const out: string[] = [];
|
|
for (const t of tokens) {
|
|
out.push(t);
|
|
if (/\S/.test(t) && ++count >= n) break;
|
|
}
|
|
return out.join('');
|
|
}
|
|
function keepLast(n: number): string {
|
|
let count = 0;
|
|
const out: string[] = [];
|
|
for (const t of [...tokens].reverse()) {
|
|
out.unshift(t);
|
|
if (/\S/.test(t) && ++count >= n) break;
|
|
}
|
|
return out.join('');
|
|
}
|
|
|
|
const isFirst = i === 0;
|
|
const isLast = i === parts.length - 1;
|
|
if (isFirst) return [{ value: '… ' + keepLast(DIFF_CONTEXT_WORDS) }];
|
|
if (isLast) return [{ value: keepFirst(DIFF_CONTEXT_WORDS) + ' …' }];
|
|
return [{ value: keepFirst(DIFF_CONTEXT_WORDS) + ' … ' + keepLast(DIFF_CONTEXT_WORDS) }];
|
|
});
|
|
}
|
|
|
|
function buildDiff(older: SnapshotDoc | null, newer: SnapshotDoc): DiffEntry[] {
|
|
const entries: DiffEntry[] = [];
|
|
|
|
for (const field of TEXT_FIELDS) {
|
|
const a = older?.[field] ?? '';
|
|
const b = newer[field] ?? '';
|
|
if (a === b) continue;
|
|
const parts = trimContextParts(diffWords(a, b));
|
|
entries.push({ kind: 'text', field, label: fieldLabels[field](), parts });
|
|
}
|
|
|
|
for (const field of SCALAR_FIELDS) {
|
|
const a = older?.[field] ?? '';
|
|
const b = newer[field] ?? '';
|
|
if (a === b) continue;
|
|
entries.push({ kind: 'scalar', field, label: fieldLabels[field](), oldVal: a, newVal: b });
|
|
}
|
|
|
|
// sender
|
|
const senderA = older?.sender ? personLabel(older.sender) : '';
|
|
const senderB = newer.sender ? personLabel(newer.sender) : '';
|
|
if (senderA !== senderB) {
|
|
const removed = senderA ? [senderA] : [];
|
|
const added = senderB ? [senderB] : [];
|
|
entries.push({
|
|
kind: 'relation',
|
|
field: 'sender',
|
|
label: fieldLabels['sender'](),
|
|
removed,
|
|
added
|
|
});
|
|
}
|
|
|
|
// receivers
|
|
const receiversA = new Set((older?.receivers ?? []).map(personLabel));
|
|
const receiversB = new Set((newer.receivers ?? []).map(personLabel));
|
|
const removedReceivers = [...receiversA].filter((r) => !receiversB.has(r));
|
|
const addedReceivers = [...receiversB].filter((r) => !receiversA.has(r));
|
|
if (removedReceivers.length > 0 || addedReceivers.length > 0) {
|
|
entries.push({
|
|
kind: 'relation',
|
|
field: 'receivers',
|
|
label: fieldLabels['receivers'](),
|
|
removed: removedReceivers,
|
|
added: addedReceivers
|
|
});
|
|
}
|
|
|
|
// tags
|
|
const tagsA = new Set((older?.tags ?? []).map((t) => t.name));
|
|
const tagsB = new Set((newer.tags ?? []).map((t) => t.name));
|
|
const removedTags = [...tagsA].filter((t) => !tagsB.has(t));
|
|
const addedTags = [...tagsB].filter((t) => !tagsA.has(t));
|
|
if (removedTags.length > 0 || addedTags.length > 0) {
|
|
entries.push({
|
|
kind: 'relation',
|
|
field: 'tags',
|
|
label: fieldLabels['tags'](),
|
|
removed: removedTags,
|
|
added: addedTags
|
|
});
|
|
}
|
|
|
|
return entries;
|
|
}
|
|
|
|
async function fetchSnapshot(versionId: string): Promise<SnapshotDoc> {
|
|
const res = await fetch(`/api/documents/${doc.id}/versions/${versionId}`);
|
|
if (!res.ok) throw new Error('Failed to fetch version');
|
|
const v = await res.json();
|
|
return parseSnapshot(v.snapshot);
|
|
}
|
|
|
|
async function toggleHistory() {
|
|
historyOpen = !historyOpen;
|
|
if (historyOpen && !historyLoaded) {
|
|
historyLoading = true;
|
|
try {
|
|
const res = await fetch(`/api/documents/${doc.id}/versions`);
|
|
if (res.ok) {
|
|
versions = await res.json();
|
|
}
|
|
historyLoaded = true;
|
|
} catch {
|
|
// ignore
|
|
} finally {
|
|
historyLoading = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
async function selectVersion(versionId: string) {
|
|
if (selectedVersionId === versionId) {
|
|
selectedVersionId = null;
|
|
diffEntries = [];
|
|
noDiff = false;
|
|
return;
|
|
}
|
|
selectedVersionId = versionId;
|
|
diffEntries = [];
|
|
noDiff = false;
|
|
diffLoading = true;
|
|
try {
|
|
const idx = versions.findIndex((v) => v.id === versionId);
|
|
const newerSnap = await fetchSnapshot(versionId);
|
|
const olderSnap = idx > 0 ? await fetchSnapshot(versions[idx - 1].id) : null;
|
|
const entries = buildDiff(olderSnap, newerSnap);
|
|
if (entries.length === 0) {
|
|
noDiff = true;
|
|
} else {
|
|
diffEntries = entries;
|
|
}
|
|
} catch {
|
|
// ignore
|
|
} finally {
|
|
diffLoading = false;
|
|
}
|
|
}
|
|
|
|
async function applyCompare() {
|
|
if (!compareA || !compareB || compareA === compareB) return;
|
|
selectedVersionId = null;
|
|
diffEntries = [];
|
|
noDiff = false;
|
|
diffLoading = true;
|
|
try {
|
|
const [snapA, snapB] = await Promise.all([fetchSnapshot(compareA), fetchSnapshot(compareB)]);
|
|
// compareA is the "older" baseline, compareB is "newer"
|
|
const entries = buildDiff(snapA, snapB);
|
|
if (entries.length === 0) {
|
|
noDiff = true;
|
|
} else {
|
|
diffEntries = entries;
|
|
}
|
|
} catch {
|
|
// ignore
|
|
} finally {
|
|
diffLoading = false;
|
|
}
|
|
}
|
|
|
|
function formatDateTime(iso: string): string {
|
|
try {
|
|
return new Intl.DateTimeFormat('de-DE', {
|
|
day: 'numeric',
|
|
month: 'short',
|
|
year: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit'
|
|
}).format(new Date(iso));
|
|
} catch {
|
|
return iso;
|
|
}
|
|
}
|
|
|
|
function versionLabel(v: VersionSummary, index: number): string {
|
|
return `Version ${index + 1} — ${v.editorName} — ${formatDateTime(v.savedAt)}`;
|
|
}
|
|
</script>
|
|
|
|
<div class="flex h-screen flex-col bg-white">
|
|
<!-- Top Bar -->
|
|
<div
|
|
class="z-10 flex items-center justify-between border-b border-brand-sand bg-white px-6 py-4 shadow-sm"
|
|
>
|
|
<div class="flex items-center gap-6 overflow-hidden">
|
|
<a
|
|
href="/"
|
|
class="group flex flex-shrink-0 items-center gap-2 font-sans text-sm font-medium text-gray-500 transition-colors hover:text-brand-navy"
|
|
>
|
|
<div
|
|
class="flex h-8 w-8 items-center justify-center rounded-full bg-brand-sand transition-colors group-hover:bg-brand-mint"
|
|
>
|
|
<img
|
|
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Arrow/Arrow-Left-MD.svg"
|
|
alt=""
|
|
aria-hidden="true"
|
|
class="h-4 w-4"
|
|
/>
|
|
</div>
|
|
<span>{m.btn_back()}</span>
|
|
</a>
|
|
|
|
<div class="flex items-center gap-4 overflow-hidden border-l border-gray-200 pl-6">
|
|
<h1 class="truncate font-serif text-xl text-brand-navy" title={doc.title}>
|
|
{doc.title || doc.originalFilename}
|
|
</h1>
|
|
<span
|
|
class="flex-shrink-0 rounded-full px-3 py-1 font-sans text-xs font-bold tracking-wide uppercase
|
|
{doc.status === 'UPLOADED'
|
|
? 'border border-brand-mint bg-brand-mint/30 text-brand-navy'
|
|
: 'border border-yellow-200 bg-yellow-100 text-yellow-800'}"
|
|
>
|
|
{doc.status}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="ml-4 flex flex-shrink-0 items-center gap-3 font-sans">
|
|
{#if data.canWrite}
|
|
<a
|
|
href="/documents/{doc.id}/edit"
|
|
class="flex items-center gap-2 rounded border border-brand-navy bg-transparent px-4 py-2 text-sm font-medium text-brand-navy transition hover:bg-brand-navy hover:text-white"
|
|
>
|
|
<img
|
|
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Edit-Content-MD.svg"
|
|
alt=""
|
|
aria-hidden="true"
|
|
class="h-4 w-4"
|
|
/>
|
|
{m.btn_edit()}
|
|
</a>
|
|
{/if}
|
|
|
|
{#if doc.filePath}
|
|
<a
|
|
href={fileUrl}
|
|
download={doc.originalFilename}
|
|
class="rounded border border-transparent bg-brand-sand/50 p-2 text-brand-navy transition hover:bg-brand-mint"
|
|
title={m.doc_download_title()}
|
|
>
|
|
<img
|
|
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Download-MD.svg"
|
|
alt=""
|
|
aria-hidden="true"
|
|
class="h-5 w-5"
|
|
/>
|
|
</a>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Content Area -->
|
|
<div class="flex flex-1 overflow-hidden">
|
|
<!-- LEFT SIDEBAR: METADATA -->
|
|
<aside
|
|
class="custom-scrollbar w-96 flex-shrink-0 overflow-y-auto border-r border-brand-sand bg-white p-8"
|
|
>
|
|
<div class="space-y-10">
|
|
<!-- 1. DETAILS GROUP -->
|
|
<div>
|
|
<h3
|
|
class="mb-4 border-b border-brand-sand pb-2 font-sans text-xs font-bold tracking-widest text-brand-navy uppercase"
|
|
>
|
|
{m.doc_section_details()}
|
|
</h3>
|
|
<div class="space-y-5">
|
|
<!-- Date -->
|
|
<div class="group flex items-start">
|
|
<span class="mt-0.5 w-8 text-brand-mint">
|
|
<img
|
|
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Calendar/Calendar-Add-MD.svg"
|
|
alt=""
|
|
aria-hidden="true"
|
|
class="h-5 w-5"
|
|
/>
|
|
</span>
|
|
<div>
|
|
<span class="block font-serif text-lg text-brand-navy">
|
|
{doc.documentDate ? formatDate(doc.documentDate) : '—'}
|
|
</span>
|
|
<span class="font-sans text-xs text-gray-500">{m.doc_label_document_date()}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Creation Location -->
|
|
<div class="group flex items-start">
|
|
<span class="mt-0.5 w-8 text-brand-mint">
|
|
<img
|
|
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Location-MD.svg"
|
|
alt=""
|
|
aria-hidden="true"
|
|
class="h-5 w-5"
|
|
/>
|
|
</span>
|
|
<div>
|
|
<span class="block font-serif text-lg text-brand-navy">
|
|
{doc.location ? doc.location : '—'}
|
|
</span>
|
|
<span class="font-sans text-xs text-gray-500"
|
|
>{m.doc_label_creation_location()}</span
|
|
>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Physical Archive Location -->
|
|
{#if doc.documentLocation}
|
|
<div class="group flex items-start">
|
|
<span class="mt-0.5 w-8 text-brand-mint">
|
|
<img
|
|
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Folder-MD.svg"
|
|
alt=""
|
|
aria-hidden="true"
|
|
class="h-5 w-5"
|
|
/>
|
|
</span>
|
|
<div>
|
|
<span class="block font-serif text-lg text-brand-navy">
|
|
{doc.documentLocation}
|
|
</span>
|
|
<span class="font-sans text-xs text-gray-500"
|
|
>{m.doc_label_archive_location_original()}</span
|
|
>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- TAGS / SCHLAGWORTE -->
|
|
{#if doc.tags && doc.tags.length > 0}
|
|
<div class="group flex items-start">
|
|
<span class="mt-0.5 w-8 text-brand-mint">
|
|
<img
|
|
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Bookmark/Bookmark-Outline-MD.svg"
|
|
alt=""
|
|
aria-hidden="true"
|
|
class="h-5 w-5"
|
|
/>
|
|
</span>
|
|
<div class="flex-1">
|
|
<div class="mb-1 flex flex-wrap gap-2">
|
|
{#each doc.tags as tag (tag.id)}
|
|
<a
|
|
href="/?tag={encodeURIComponent(tag.name)}"
|
|
class="inline-flex items-center rounded bg-brand-sand/50 px-2 py-0.5 text-xs font-bold tracking-wide text-brand-navy uppercase transition-colors hover:bg-brand-navy hover:text-white"
|
|
title={m.doc_tag_filter_title({ name: tag.name })}
|
|
>
|
|
{tag.name}
|
|
</a>
|
|
{/each}
|
|
</div>
|
|
<span class="font-sans text-xs text-gray-500">{m.form_label_tags()}</span>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 2. PERSONEN GROUP -->
|
|
<div>
|
|
<h3
|
|
class="mb-4 border-b border-brand-sand pb-2 font-sans text-xs font-bold tracking-widest text-brand-navy uppercase"
|
|
>
|
|
{m.doc_section_persons()}
|
|
</h3>
|
|
|
|
<div class="mb-6">
|
|
<span class="mb-2 block font-sans text-xs text-gray-400 uppercase"
|
|
>{m.form_label_sender()}</span
|
|
>
|
|
{#if doc.sender}
|
|
<a
|
|
href="/persons/{doc.sender.id}"
|
|
class="group block rounded border border-brand-sand bg-brand-sand/20 p-3 transition hover:border-brand-mint hover:bg-brand-mint/10"
|
|
>
|
|
<div class="flex items-center gap-3">
|
|
<div
|
|
class="flex h-8 w-8 items-center justify-center rounded-full bg-brand-navy font-serif text-sm text-white"
|
|
>
|
|
{doc.sender.firstName[0]}{doc.sender.lastName[0]}
|
|
</div>
|
|
<div>
|
|
<p
|
|
class="font-serif text-brand-navy decoration-brand-mint underline-offset-2 group-hover:underline"
|
|
>
|
|
{doc.sender.firstName}
|
|
{doc.sender.lastName}
|
|
</p>
|
|
{#if doc.sender.alias}
|
|
<p class="font-sans text-xs text-gray-500">{doc.sender.alias}</p>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</a>
|
|
{:else}
|
|
<span class="font-serif text-sm text-gray-400 italic"
|
|
>{m.doc_sender_not_specified()}</span
|
|
>
|
|
{/if}
|
|
</div>
|
|
|
|
<div>
|
|
<span class="mb-2 block font-sans text-xs text-gray-400 uppercase"
|
|
>{m.form_label_receivers()}</span
|
|
>
|
|
{#if doc.receivers && doc.receivers.length > 0}
|
|
<div class="space-y-2">
|
|
{#each doc.receivers as receiver (receiver.id)}
|
|
<div
|
|
class="group flex items-center justify-between rounded border border-brand-sand bg-white p-3 transition hover:border-brand-navy"
|
|
>
|
|
<a href="/persons/{receiver.id}" class="flex min-w-0 flex-1 items-center gap-3">
|
|
<div
|
|
class="flex h-6 w-6 items-center justify-center rounded-full bg-gray-100 font-serif text-xs text-gray-500"
|
|
>
|
|
{receiver.firstName[0]}{receiver.lastName[0]}
|
|
</div>
|
|
<span
|
|
class="truncate font-serif text-sm text-brand-navy group-hover:text-brand-navy"
|
|
>
|
|
{receiver.firstName}
|
|
{receiver.lastName}
|
|
</span>
|
|
</a>
|
|
|
|
{#if doc.sender}
|
|
<a
|
|
href="/conversations?senderId={doc.sender.id}&receiverId={receiver.id}"
|
|
class="text-gray-300 transition hover:text-brand-mint"
|
|
title={m.doc_conversation_title()}
|
|
>
|
|
<img
|
|
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Chat-MD.svg"
|
|
alt=""
|
|
aria-hidden="true"
|
|
class="h-5 w-5"
|
|
/>
|
|
</a>
|
|
{/if}
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{:else}
|
|
<span class="font-serif text-sm text-gray-400 italic">{m.doc_no_receivers()}</span>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 3. INHALT GROUP -->
|
|
{#if doc.summary || doc.transcription}
|
|
<div>
|
|
<h3
|
|
class="mb-4 border-b border-brand-sand pb-2 font-sans text-xs font-bold tracking-widest text-brand-navy uppercase"
|
|
>
|
|
{m.doc_section_content()}
|
|
</h3>
|
|
|
|
<div class="space-y-6">
|
|
{#if doc.summary}
|
|
<div>
|
|
<span class="mb-2 block font-sans text-xs text-gray-400 uppercase"
|
|
>{m.doc_label_summary()}</span
|
|
>
|
|
<ExpandableText text={doc.summary} />
|
|
</div>
|
|
{/if}
|
|
|
|
{#if doc.transcription}
|
|
<div>
|
|
<span class="mb-2 block font-sans text-xs text-gray-400 uppercase"
|
|
>{m.form_label_transcription()}</span
|
|
>
|
|
<ExpandableText text={doc.transcription} />
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- 4. HISTORY GROUP -->
|
|
<div>
|
|
<div class="flex items-center justify-between border-b border-brand-sand pb-2">
|
|
<h3 class="font-sans text-xs font-bold tracking-widest text-brand-navy uppercase">
|
|
{m.history_section_title()}
|
|
</h3>
|
|
<button
|
|
onclick={toggleHistory}
|
|
class="flex items-center gap-1 rounded p-1 text-gray-400 transition hover:bg-brand-sand/50 hover:text-brand-navy"
|
|
aria-expanded={historyOpen}
|
|
aria-label={m.history_section_title()}
|
|
>
|
|
<svg
|
|
class="h-4 w-4 transition-transform duration-200 {historyOpen ? 'rotate-180' : ''}"
|
|
viewBox="0 0 20 20"
|
|
fill="currentColor"
|
|
aria-hidden="true"
|
|
>
|
|
<path
|
|
fill-rule="evenodd"
|
|
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
|
|
clip-rule="evenodd"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
{#if historyOpen}
|
|
<div class="mt-4 space-y-3">
|
|
{#if historyLoading}
|
|
<p class="font-sans text-xs text-gray-400">{m.history_loading()}</p>
|
|
{:else if versions.length === 0}
|
|
<p class="font-serif text-sm text-gray-400 italic">{m.history_empty()}</p>
|
|
{:else}
|
|
<!-- Compare mode toggle -->
|
|
<div class="flex justify-end">
|
|
<button
|
|
onclick={() => {
|
|
compareMode = !compareMode;
|
|
diffEntries = [];
|
|
noDiff = false;
|
|
selectedVersionId = null;
|
|
}}
|
|
class="font-sans text-xs font-medium transition {compareMode
|
|
? 'text-brand-navy underline'
|
|
: 'text-gray-400 hover:text-brand-navy'}"
|
|
>
|
|
{m.history_compare_mode()}
|
|
</button>
|
|
</div>
|
|
|
|
{#if compareMode}
|
|
<!-- Compare selects -->
|
|
<div class="space-y-2">
|
|
<div>
|
|
<label
|
|
for="compare-a"
|
|
class="mb-1 block font-sans text-[10px] text-gray-400 uppercase"
|
|
>{m.history_compare_select_a()}</label
|
|
>
|
|
<select
|
|
id="compare-a"
|
|
bind:value={compareA}
|
|
class="w-full rounded border border-brand-sand bg-white px-2 py-1 font-sans text-xs text-brand-navy focus:ring-1 focus:ring-brand-mint focus:outline-none"
|
|
>
|
|
<option value="">—</option>
|
|
{#each versions as v, i (v.id)}
|
|
<option value={v.id}>{versionLabel(v, i)}</option>
|
|
{/each}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label
|
|
for="compare-b"
|
|
class="mb-1 block font-sans text-[10px] text-gray-400 uppercase"
|
|
>{m.history_compare_select_b()}</label
|
|
>
|
|
<select
|
|
id="compare-b"
|
|
bind:value={compareB}
|
|
class="w-full rounded border border-brand-sand bg-white px-2 py-1 font-sans text-xs text-brand-navy focus:ring-1 focus:ring-brand-mint focus:outline-none"
|
|
>
|
|
<option value="">—</option>
|
|
{#each versions as v, i (v.id)}
|
|
<option value={v.id}>{versionLabel(v, i)}</option>
|
|
{/each}
|
|
</select>
|
|
</div>
|
|
<button
|
|
onclick={applyCompare}
|
|
disabled={!compareA || !compareB || compareA === compareB}
|
|
class="w-full rounded bg-brand-navy px-3 py-1.5 font-sans text-xs font-medium text-white transition hover:bg-brand-navy/80 disabled:cursor-not-allowed disabled:opacity-40"
|
|
>
|
|
{m.history_compare_apply()}
|
|
</button>
|
|
</div>
|
|
{:else}
|
|
<!-- Version list -->
|
|
<ul class="divide-y divide-brand-sand">
|
|
{#each versions as v, i (v.id)}
|
|
<li>
|
|
<button
|
|
onclick={() => selectVersion(v.id)}
|
|
data-testid="history-version"
|
|
class="w-full py-2 text-left transition hover:bg-brand-sand/30 {selectedVersionId ===
|
|
v.id
|
|
? 'border-l-2 border-brand-mint pl-2'
|
|
: 'pl-0'}"
|
|
>
|
|
<div class="flex items-baseline justify-between gap-2">
|
|
<span class="font-sans text-xs font-medium text-brand-navy">
|
|
Version {i + 1}
|
|
</span>
|
|
<span class="font-sans text-[10px] text-gray-400">
|
|
{formatDateTime(v.savedAt)}
|
|
</span>
|
|
</div>
|
|
<span class="font-sans text-[11px] text-gray-500">{v.editorName}</span>
|
|
{#if v.changedFields && v.changedFields.length > 0}
|
|
<div class="mt-1 flex flex-wrap gap-1">
|
|
{#each v.changedFields as field (field)}
|
|
<span
|
|
class="rounded bg-brand-sand/50 px-1.5 py-0.5 font-sans text-[10px] tracking-wide text-gray-500 uppercase"
|
|
>
|
|
{fieldLabels[field] ? fieldLabels[field]() : field}
|
|
</span>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</button>
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
{/if}
|
|
|
|
<!-- Diff panel -->
|
|
{#if diffLoading}
|
|
<p class="font-sans text-xs text-gray-400">{m.history_loading()}</p>
|
|
{:else if noDiff}
|
|
<div
|
|
data-testid="history-diff"
|
|
class="rounded-sm border border-brand-sand bg-white p-4 font-serif text-sm text-gray-400 italic"
|
|
>
|
|
{m.history_diff_no_changes()}
|
|
</div>
|
|
{:else if diffEntries.length > 0}
|
|
<div
|
|
data-testid="history-diff"
|
|
class="space-y-4 rounded-sm border border-brand-sand bg-white p-4"
|
|
>
|
|
{#each diffEntries as entry (entry.field)}
|
|
<div>
|
|
<span
|
|
class="mb-1.5 block font-sans text-[10px] font-bold tracking-wide text-gray-400 uppercase"
|
|
>{entry.label}</span
|
|
>
|
|
{#if entry.kind === 'text'}
|
|
<p class="font-serif text-sm leading-relaxed">
|
|
{#each entry.parts as part, partIdx (partIdx)}
|
|
{#if part.added}
|
|
<span class="bg-green-50 text-green-700">{part.value}</span>
|
|
{:else if part.removed}
|
|
<span class="bg-red-50 text-red-600 line-through">{part.value}</span
|
|
>
|
|
{:else}
|
|
<span>{part.value}</span>
|
|
{/if}
|
|
{/each}
|
|
</p>
|
|
{:else if entry.kind === 'scalar'}
|
|
<div class="flex items-center gap-2 font-serif text-sm">
|
|
<span class="text-red-600 line-through">{entry.oldVal || '—'}</span>
|
|
<svg
|
|
class="h-3 w-3 flex-shrink-0 text-gray-400"
|
|
viewBox="0 0 20 20"
|
|
fill="currentColor"
|
|
aria-hidden="true"
|
|
>
|
|
<path
|
|
fill-rule="evenodd"
|
|
d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z"
|
|
clip-rule="evenodd"
|
|
/>
|
|
</svg>
|
|
<span class="text-green-700">{entry.newVal || '—'}</span>
|
|
</div>
|
|
{:else if entry.kind === 'relation'}
|
|
<div class="flex flex-wrap gap-1.5">
|
|
{#each entry.removed as item (item)}
|
|
<span
|
|
class="rounded bg-red-50 px-1.5 py-0.5 font-sans text-[11px] text-red-600 line-through"
|
|
>{item}</span
|
|
>
|
|
{/each}
|
|
{#each entry.added as item (item)}
|
|
<span
|
|
class="rounded bg-green-50 px-1.5 py-0.5 font-sans text-[11px] text-green-700"
|
|
>{item}</span
|
|
>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Footer -->
|
|
<div class="border-t border-brand-sand pt-4 font-sans text-[10px] text-gray-400">
|
|
<p class="truncate">ID: {doc.id}</p>
|
|
<p class="mt-1 truncate">{doc.originalFilename}</p>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
|
|
<!-- RIGHT: PREVIEW AREA -->
|
|
<main class="relative flex flex-1 flex-col items-center justify-center bg-[#2A2A2A]">
|
|
{#if isLoading}
|
|
<div class="flex flex-col items-center text-brand-mint">
|
|
<svg
|
|
class="mb-4 h-8 w-8 animate-spin"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"
|
|
></circle>
|
|
<path
|
|
class="opacity-75"
|
|
fill="currentColor"
|
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
></path>
|
|
</svg>
|
|
<span class="font-sans text-sm tracking-wide">{m.doc_loading()}</span>
|
|
</div>
|
|
{:else if error}
|
|
<div class="px-4 text-center text-gray-400">
|
|
<p class="mb-2 font-serif">{error}</p>
|
|
{#if doc.filePath}
|
|
<a
|
|
href={`/api/documents/${doc.id}/file`}
|
|
target="_blank"
|
|
class="text-sm underline hover:text-white"
|
|
>
|
|
{m.doc_download_link()}
|
|
</a>
|
|
{/if}
|
|
</div>
|
|
{:else if !doc.filePath}
|
|
<div class="flex flex-col items-center text-gray-400">
|
|
<div class="mb-6 rounded-full bg-white/5 p-8">
|
|
<img
|
|
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/PDF-Document-MD.svg"
|
|
alt=""
|
|
aria-hidden="true"
|
|
class="h-12 w-12 opacity-50 invert"
|
|
/>
|
|
</div>
|
|
<p class="font-sans text-sm tracking-wide uppercase">{m.doc_no_scan()}</p>
|
|
</div>
|
|
{:else if fileUrl && doc.contentType?.startsWith('application/pdf')}
|
|
<PdfViewer url={fileUrl} documentId={doc.id} canAnnotate={data.canAnnotate} />
|
|
{:else if fileUrl}
|
|
<div class="flex h-full w-full items-center justify-center overflow-auto p-8">
|
|
<img
|
|
src={fileUrl}
|
|
alt={m.doc_image_alt()}
|
|
class="max-h-full max-w-full object-contain shadow-2xl"
|
|
/>
|
|
</div>
|
|
{/if}
|
|
</main>
|
|
</div>
|
|
</div>
|