feat(frontend): add floating bottom panel to document detail page
Some checks failed
CI / Unit & Component Tests (push) Successful in 4m47s
CI / Backend Unit Tests (push) Successful in 2m20s
CI / E2E Tests (push) Failing after 24m42s

Replaces the left sidebar layout with:
- Full-viewport PDF/image viewer (never resizes, position: absolute)
- Fixed floating bottom panel with tabs: Metadaten, Transkription,
  Diskussion, Verlauf
- Compact top bar with title, date · sender → receivers row, and
  Annotieren / Edit / Download actions
- Drag-to-resize panel with localStorage persistence of open/height/tab
- Panel opens automatically to Diskussion when an annotation is clicked
- Documents without a file default to showing the Metadaten tab

New components: DocumentTopBar, DocumentViewer, DocumentBottomPanel,
PanelMetadata, PanelTranscription, PanelDiscussion, PanelHistory

PdfViewer: annotateMode and activeAnnotationId lifted to bindable props;
AnnotationCommentPanel removed (discussion moves to the Diskussion tab).

Closes #62
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-03-24 22:35:28 +01:00
parent 34c66f80fc
commit 8c2bdbd777
13 changed files with 1521 additions and 923 deletions

View File

@@ -0,0 +1,180 @@
import { test, expect } from '@playwright/test';
import path from 'path';
import { fileURLToPath } from 'url';
import fs from 'fs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PDF_FIXTURE = path.resolve(__dirname, 'fixtures/minimal.pdf');
/**
* Bottom panel E2E tests — issue #62.
* Verifies the new document detail layout: full-viewport viewer + floating bottom panel.
*/
let pdfDocHref: string;
let noFileDocHref: string;
test.describe('Document bottom panel', () => {
test.beforeAll(async ({ request }) => {
const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000';
// Create a document with a PDF and a date for metadata tests.
const createRes = await request.post('/api/documents', {
multipart: { title: 'E2E Bottom Panel Test', documentDate: '1945-05-08' }
});
if (!createRes.ok()) throw new Error(`Create document failed: ${createRes.status()}`);
const doc = await createRes.json();
const uploadRes = await request.put(`/api/documents/${doc.id}`, {
multipart: {
title: doc.title,
documentDate: '1945-05-08',
transcription: 'Dies ist eine vollständige Transkription des Dokuments für den E2E-Test.',
file: {
name: 'minimal.pdf',
mimeType: 'application/pdf',
buffer: fs.readFileSync(PDF_FIXTURE)
}
}
});
if (!uploadRes.ok()) throw new Error(`Upload PDF failed: ${uploadRes.status()}`);
pdfDocHref = `${baseURL}/documents/${doc.id}`;
// Create a document WITHOUT a file — panel should open to Metadaten by default.
const noFileRes = await request.post('/api/documents', {
multipart: { title: 'E2E Bottom Panel No-File Test' }
});
if (!noFileRes.ok()) throw new Error(`Create no-file document failed: ${noFileRes.status()}`);
noFileDocHref = `${baseURL}/documents/${noFileRes.json().then ? (await noFileRes.json()).id : ''}`;
const noFileDoc = await noFileRes.json();
noFileDocHref = `${baseURL}/documents/${noFileDoc.id}`;
});
test('bottom panel tab bar is visible and panel content is closed by default on a PDF document', async ({
page
}) => {
test.setTimeout(30_000);
// Clear localStorage to ensure no previous panel state.
await page.goto(pdfDocHref);
await page.evaluate(() => localStorage.clear());
await page.reload();
await page.waitForSelector('[data-hydrated]');
// Tab bar must always be visible.
await expect(page.getByRole('button', { name: 'Metadaten' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Transkription' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Diskussion' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Verlauf' })).toBeVisible();
// Panel content must NOT be visible when closed.
await expect(page.locator('[data-testid="bottom-panel-content"]')).not.toBeVisible();
await page.screenshot({ path: 'test-results/e2e/bottom-panel-closed-default.png' });
});
test('clicking Metadaten tab opens the panel and shows metadata content', async ({ page }) => {
test.setTimeout(30_000);
await page.goto(pdfDocHref);
await page.evaluate(() => localStorage.clear());
await page.reload();
await page.waitForSelector('[data-hydrated]');
await page.getByRole('button', { name: 'Metadaten' }).click();
// Panel content becomes visible.
await expect(page.locator('[data-testid="bottom-panel-content"]')).toBeVisible();
// Metadata section heading should be present.
await expect(page.getByText('Details', { exact: false })).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/bottom-panel-metadata.png' });
});
test('clicking Transkription tab shows transcription text', async ({ page }) => {
test.setTimeout(30_000);
await page.goto(pdfDocHref);
await page.evaluate(() => localStorage.clear());
await page.reload();
await page.waitForSelector('[data-hydrated]');
await page.getByRole('button', { name: 'Transkription' }).click();
await expect(page.locator('[data-testid="bottom-panel-content"]')).toBeVisible();
await expect(
page.getByText('Dies ist eine vollständige Transkription', { exact: false })
).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/bottom-panel-transcription.png' });
});
test('clicking Diskussion tab shows the comment input', async ({ page }) => {
test.setTimeout(30_000);
await page.goto(pdfDocHref);
await page.evaluate(() => localStorage.clear());
await page.reload();
await page.waitForSelector('[data-hydrated]');
await page.getByRole('button', { name: 'Diskussion' }).click();
await expect(page.locator('[data-testid="bottom-panel-content"]')).toBeVisible();
await expect(page.getByPlaceholder('Kommentar schreiben…')).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/bottom-panel-discussion.png' });
});
test('clicking × close button collapses the panel content', async ({ page }) => {
test.setTimeout(30_000);
await page.goto(pdfDocHref);
await page.evaluate(() => localStorage.clear());
await page.reload();
await page.waitForSelector('[data-hydrated]');
// Open the panel first.
await page.getByRole('button', { name: 'Metadaten' }).click();
await expect(page.locator('[data-testid="bottom-panel-content"]')).toBeVisible();
// Close it.
await page.locator('[data-testid="panel-close-btn"]').click();
await expect(page.locator('[data-testid="bottom-panel-content"]')).not.toBeVisible();
// Tab bar still visible after closing.
await expect(page.getByRole('button', { name: 'Metadaten' })).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/bottom-panel-closed-after-x.png' });
});
test('panel open state persists after page reload', async ({ page }) => {
test.setTimeout(30_000);
await page.goto(pdfDocHref);
await page.evaluate(() => localStorage.clear());
await page.reload();
await page.waitForSelector('[data-hydrated]');
// Open the panel to Diskussion.
await page.getByRole('button', { name: 'Diskussion' }).click();
await expect(page.locator('[data-testid="bottom-panel-content"]')).toBeVisible();
// Reload — panel should re-open on the same tab.
await page.reload();
await page.waitForSelector('[data-hydrated]');
await expect(page.locator('[data-testid="bottom-panel-content"]')).toBeVisible();
await expect(page.getByPlaceholder('Kommentar schreiben…')).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/bottom-panel-persisted.png' });
});
test('document without a file opens panel to Metadaten by default', async ({ page }) => {
test.setTimeout(30_000);
await page.goto(noFileDocHref);
await page.evaluate(() => localStorage.clear());
await page.reload();
await page.waitForSelector('[data-hydrated]');
// Panel should be open to Metadaten by default when there is no file.
await expect(page.locator('[data-testid="bottom-panel-content"]')).toBeVisible();
await expect(page.getByText('Details', { exact: false })).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/bottom-panel-no-file-default.png' });
});
});

View File

@@ -250,5 +250,12 @@
"comment_btn_reply": "Antworten",
"comment_edited_label": "· bearbeitet",
"comment_panel_title": "Kommentare",
"comment_panel_close": "Schließen"
"comment_panel_close": "Schließen",
"doc_panel_tab_metadata": "Metadaten",
"doc_panel_tab_transcription": "Transkription",
"doc_panel_tab_discussion": "Diskussion",
"doc_panel_tab_history": "Verlauf",
"doc_panel_annotate": "Annotieren",
"doc_panel_annotate_stop": "Fertig",
"doc_panel_annotation_thread_title": "Annotation"
}

View File

@@ -250,5 +250,12 @@
"comment_btn_reply": "Reply",
"comment_edited_label": "· edited",
"comment_panel_title": "Comments",
"comment_panel_close": "Close"
"comment_panel_close": "Close",
"doc_panel_tab_metadata": "Metadata",
"doc_panel_tab_transcription": "Transcription",
"doc_panel_tab_discussion": "Discussion",
"doc_panel_tab_history": "History",
"doc_panel_annotate": "Annotate",
"doc_panel_annotate_stop": "Done",
"doc_panel_annotation_thread_title": "Annotation"
}

View File

@@ -250,5 +250,12 @@
"comment_btn_reply": "Responder",
"comment_edited_label": "· editado",
"comment_panel_title": "Comentarios",
"comment_panel_close": "Cerrar"
"comment_panel_close": "Cerrar",
"doc_panel_tab_metadata": "Metadatos",
"doc_panel_tab_transcription": "Transcripción",
"doc_panel_tab_discussion": "Discusión",
"doc_panel_tab_history": "Historial",
"doc_panel_annotate": "Anotar",
"doc_panel_annotate_stop": "Listo",
"doc_panel_annotation_thread_title": "Anotación"
}

View File

@@ -0,0 +1,196 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import PanelMetadata from './PanelMetadata.svelte';
import PanelTranscription from './PanelTranscription.svelte';
import PanelDiscussion from './PanelDiscussion.svelte';
import PanelHistory from './PanelHistory.svelte';
type Tab = 'metadata' | 'transcription' | 'discussion' | 'history';
type CommentReply = {
id: string;
authorId: string | null;
authorName: string;
content: string;
createdAt: string;
updatedAt: string;
};
type Comment = {
id: string;
authorId: string | null;
authorName: string;
content: string;
createdAt: string;
updatedAt: string;
replies: CommentReply[];
};
type Doc = {
id: string;
title?: string | null;
documentDate?: string | null;
location?: string | null;
documentLocation?: string | null;
tags?: { id: string; name: string }[] | null;
sender?: { id: string; firstName: string; lastName: string; alias?: string | null } | null;
receivers?: { id: string; firstName: string; lastName: string }[] | null;
summary?: string | null;
transcription?: string | null;
};
type Props = {
doc: Doc;
comments: Comment[];
canComment: boolean;
currentUserId: string | null;
canAdmin: boolean;
open: boolean;
height: number;
activeTab: Tab;
activeAnnotationId: string | null;
onAnnotationCommentCountChange?: (annotationId: string, count: number) => void;
};
let {
doc,
comments,
canComment,
currentUserId,
canAdmin,
open = $bindable(),
height = $bindable(),
activeTab = $bindable(),
activeAnnotationId,
onAnnotationCommentCountChange
}: Props = $props();
const MIN_HEIGHT = 52; // drag handle (8px) + tab bar (~44px)
const DEFAULT_HEIGHT = 320;
let isDragging = $state(false);
let dragStartY = 0;
let dragStartHeight = 0;
function openTab(tab: Tab) {
activeTab = tab;
if (!open) {
open = true;
if (height <= MIN_HEIGHT) height = DEFAULT_HEIGHT;
}
}
function closePanel() {
open = false;
}
function onDragStart(e: PointerEvent) {
isDragging = true;
dragStartY = e.clientY;
dragStartHeight = open ? height : MIN_HEIGHT;
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
}
function onDragMove(e: PointerEvent) {
if (!isDragging) return;
const delta = dragStartY - e.clientY; // positive = dragging up = bigger panel
const newHeight = dragStartHeight + delta;
const maxHeight = Math.floor(window.innerHeight * 0.8);
if (newHeight <= MIN_HEIGHT + 20) {
// collapsed past threshold → close
open = false;
} else {
open = true;
height = Math.max(DEFAULT_HEIGHT / 4, Math.min(newHeight, maxHeight));
}
}
function onDragEnd() {
isDragging = false;
}
const tabs: { id: Tab; label: () => string }[] = [
{ id: 'metadata', label: m.doc_panel_tab_metadata },
{ id: 'transcription', label: m.doc_panel_tab_transcription },
{ id: 'discussion', label: m.doc_panel_tab_discussion },
{ id: 'history', label: m.doc_panel_tab_history }
];
const panelHeight = $derived(open ? height : MIN_HEIGHT);
</script>
<div
class="fixed right-0 bottom-0 left-0 z-30 flex flex-col border-t border-brand-sand bg-white shadow-[0_-4px_16px_rgba(0,0,0,0.08)]"
style="height: {panelHeight}px"
data-testid="bottom-panel"
>
<!-- Drag handle -->
<div
class="flex h-2 shrink-0 cursor-ns-resize items-center justify-center bg-white"
style="touch-action: none"
role="separator"
aria-orientation="horizontal"
aria-label="Panel resize"
onpointerdown={onDragStart}
onpointermove={onDragMove}
onpointerup={onDragEnd}
onpointercancel={onDragEnd}
>
<div class="h-1 w-12 rounded-full bg-gray-200"></div>
</div>
<!-- Tab bar -->
<div class="flex shrink-0 items-center border-b border-brand-sand bg-white px-4">
{#each tabs as tab (tab.id)}
<button
onclick={() => openTab(tab.id)}
class="mr-1 px-3 py-2.5 font-sans text-xs font-medium transition-colors {activeTab === tab.id && open
? 'border-b-2 border-brand-navy text-brand-navy'
: 'text-gray-400 hover:text-brand-navy'}"
aria-pressed={activeTab === tab.id && open}
>
{tab.label()}
</button>
{/each}
<!-- spacer -->
<div class="flex-1"></div>
{#if open}
<button
onclick={closePanel}
data-testid="panel-close-btn"
aria-label="Panel schließen"
class="rounded p-1.5 text-gray-400 transition-colors hover:bg-brand-sand/50 hover:text-brand-navy"
>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
{/if}
</div>
<!-- Tab content -->
{#if open}
<div class="flex-1 overflow-y-auto" data-testid="bottom-panel-content">
{#if activeTab === 'metadata'}
<PanelMetadata doc={doc} />
{:else if activeTab === 'transcription'}
<PanelTranscription doc={doc} />
{:else if activeTab === 'discussion'}
<PanelDiscussion
documentId={doc.id}
activeAnnotationId={activeAnnotationId}
initialComments={comments}
canComment={canComment}
currentUserId={currentUserId}
canAdmin={canAdmin}
onAnnotationCommentCountChange={onAnnotationCommentCountChange}
/>
{:else if activeTab === 'history'}
<PanelHistory documentId={doc.id} />
{/if}
</div>
{/if}
</div>

View File

@@ -0,0 +1,142 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
type Person = { id: string; firstName: string; lastName: string };
type Doc = {
id: string;
title?: string | null;
originalFilename?: string | null;
documentDate?: string | null;
sender?: Person | null;
receivers?: Person[] | null;
filePath?: string | null;
contentType?: string | null;
};
type Props = {
doc: Doc;
canWrite: boolean;
canAnnotate: boolean;
fileUrl: string;
annotateMode: boolean;
};
let { doc, canWrite, canAnnotate, fileUrl, annotateMode = $bindable() }: Props = $props();
const isPdf = $derived(!!doc.filePath && doc.contentType?.startsWith('application/pdf'));
const receiverDisplay = $derived.by(() => {
const receivers = doc.receivers ?? [];
if (receivers.length === 0) return null;
const shown = receivers.slice(0, 2);
const extra = receivers.length - shown.length;
const names = shown.map((r) => `${r.firstName} ${r.lastName}`).join(', ');
return extra > 0 ? `${names} +${extra}` : names;
});
const compactMeta = $derived.by(() => {
const parts: string[] = [];
if (doc.documentDate) {
parts.push(
new Intl.DateTimeFormat('de-DE', {
day: 'numeric',
month: 'numeric',
year: 'numeric'
}).format(new Date(doc.documentDate + 'T12:00:00'))
);
}
if (doc.sender) {
const senderName = `${doc.sender.firstName} ${doc.sender.lastName}`;
const receiver = receiverDisplay;
parts.push(receiver ? `${senderName}${receiver}` : senderName);
} else if (receiverDisplay) {
parts.push(`→ ${receiverDisplay}`);
}
return parts.join(' · ');
});
</script>
<div
class="z-20 flex shrink-0 items-center justify-between border-b border-brand-sand bg-white px-6 py-3 shadow-sm"
>
<!-- Left: back + title -->
<div class="flex min-w-0 items-center gap-4 overflow-hidden">
<a
href="/"
class="group 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 class="hidden sm:inline">{m.btn_back()}</span>
</a>
<div class="min-w-0 border-l border-gray-200 pl-4">
<h1
class="truncate font-serif text-base leading-tight text-brand-navy"
title={doc.title ?? doc.originalFilename ?? ''}
>
{doc.title || doc.originalFilename}
</h1>
{#if compactMeta}
<p class="truncate font-sans text-xs text-gray-500" title={compactMeta}>
{compactMeta}
</p>
{/if}
</div>
</div>
<!-- Right: actions -->
<div class="ml-4 flex shrink-0 items-center gap-2 font-sans">
{#if canAnnotate && isPdf}
<button
onclick={() => (annotateMode = !annotateMode)}
aria-label={annotateMode ? m.doc_panel_annotate_stop() : m.doc_panel_annotate()}
class="rounded px-3 py-1.5 font-sans text-xs font-medium transition {annotateMode
? 'bg-brand-navy text-white'
: 'border border-brand-navy text-brand-navy hover:bg-brand-navy hover:text-white'}"
>
{annotateMode ? m.doc_panel_annotate_stop() : m.doc_panel_annotate()}
</button>
{/if}
{#if canWrite}
<a
href="/documents/{doc.id}/edit"
class="flex items-center gap-2 rounded border border-brand-navy bg-transparent px-3 py-1.5 text-xs 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-1.5 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>

View File

@@ -0,0 +1,93 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import PdfViewer from './PdfViewer.svelte';
type Doc = {
id: string;
filePath?: string | null;
contentType?: string | null;
};
type Props = {
doc: Doc;
fileUrl: string;
isLoading: boolean;
error: string;
annotateMode: boolean;
activeAnnotationId: string | null;
onAnnotationClick: (id: string) => void;
};
let {
doc,
fileUrl,
isLoading,
error,
annotateMode = $bindable(),
activeAnnotationId = $bindable(),
onAnnotationClick
}: Props = $props();
</script>
<div class="absolute inset-0 bg-[#2A2A2A]">
{#if isLoading}
<div class="flex h-full flex-col items-center justify-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="flex h-full flex-col items-center justify-center 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 h-full flex-col items-center justify-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}
bind:annotateMode={annotateMode}
bind:activeAnnotationId={activeAnnotationId}
onAnnotationClick={onAnnotationClick}
/>
{: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}
</div>

View File

@@ -0,0 +1,85 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import CommentThread from './CommentThread.svelte';
type CommentReply = {
id: string;
authorId: string | null;
authorName: string;
content: string;
createdAt: string;
updatedAt: string;
};
type Comment = {
id: string;
authorId: string | null;
authorName: string;
content: string;
createdAt: string;
updatedAt: string;
replies: CommentReply[];
};
type Props = {
documentId: string;
activeAnnotationId: string | null;
initialComments: Comment[];
canComment: boolean;
currentUserId: string | null;
canAdmin: boolean;
onAnnotationCommentCountChange?: (annotationId: string, count: number) => void;
};
let {
documentId,
activeAnnotationId,
initialComments,
canComment,
currentUserId,
canAdmin,
onAnnotationCommentCountChange
}: Props = $props();
</script>
<div class="space-y-8 p-6">
<!-- Annotation thread (shown when an annotation is active) -->
{#if activeAnnotationId}
<div>
<h4
class="mb-3 border-b border-brand-sand pb-2 font-sans text-xs font-bold tracking-widest text-brand-navy uppercase"
>
{m.doc_panel_annotation_thread_title()}
</h4>
{#key activeAnnotationId}
<CommentThread
documentId={documentId}
annotationId={activeAnnotationId}
canComment={canComment}
currentUserId={currentUserId}
canAdmin={canAdmin}
loadOnMount={true}
onCountChange={(count) => onAnnotationCommentCountChange?.(activeAnnotationId, count)}
/>
{/key}
</div>
{/if}
<!-- General document discussion -->
<div>
{#if activeAnnotationId}
<h4
class="mb-3 border-b border-brand-sand pb-2 font-sans text-xs font-bold tracking-widest text-brand-navy uppercase"
>
{m.comment_section_title()}
</h4>
{/if}
<CommentThread
documentId={documentId}
initialComments={initialComments}
canComment={canComment}
currentUserId={currentUserId}
canAdmin={canAdmin}
/>
</div>
</div>

View File

@@ -0,0 +1,446 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { diffWords } from 'diff';
let { documentId }: { documentId: string } = $props();
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 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 });
}
const senderA = older?.sender ? personLabel(older.sender) : '';
const senderB = newer.sender ? personLabel(newer.sender) : '';
if (senderA !== senderB) {
entries.push({
kind: 'relation',
field: 'sender',
label: fieldLabels['sender'](),
removed: senderA ? [senderA] : [],
added: senderB ? [senderB] : []
});
}
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
});
}
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/${documentId}/versions/${versionId}`);
if (!res.ok) throw new Error('Failed to fetch version');
const v = await res.json();
return parseSnapshot(v.snapshot);
}
async function loadHistory() {
if (historyLoaded) return;
historyLoading = true;
try {
const res = await fetch(`/api/documents/${documentId}/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)]);
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)}`;
}
// Load history when this panel mounts.
$effect(() => {
loadHistory();
});
</script>
<div class="space-y-4 p-6">
{#if historyLoading}
<p class="font-sans text-xs text-gray-400">{m.history_loading()}</p>
{:else if !historyLoaded}
<!-- initial state before effect runs — show nothing -->
{: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}
<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>

View File

@@ -0,0 +1,202 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { formatDate } from '$lib/utils/date';
type Person = { id: string; firstName: string; lastName: string; alias?: string | null };
type Tag = { id: string; name: string };
type Doc = {
documentDate?: string | null;
location?: string | null;
documentLocation?: string | null;
tags?: Tag[] | null;
sender?: Person | null;
receivers?: Person[] | null;
};
let { doc }: { doc: Doc } = $props();
</script>
<div class="space-y-10 p-6">
<!-- 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="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="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="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 -->
{#if doc.tags && doc.tags.length > 0}
<div class="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>
<!-- 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">
{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>
</div>

View File

@@ -0,0 +1,42 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
type Doc = {
summary?: string | null;
transcription?: string | null;
};
let { doc }: { doc: Doc } = $props();
</script>
<div class="flex justify-center px-6 py-8">
<div class="w-full max-w-prose space-y-8">
{#if !doc.summary && !doc.transcription}
<p class="font-serif text-sm text-gray-400 italic"></p>
{/if}
{#if doc.summary}
<div>
<span
class="mb-3 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
>
{m.doc_label_summary()}
</span>
<p class="font-serif text-base leading-relaxed text-brand-navy">{doc.summary}</p>
</div>
{/if}
{#if doc.transcription}
<div>
<span
class="mb-3 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
>
{m.form_label_transcription()}
</span>
<p class="font-serif text-base leading-relaxed whitespace-pre-wrap text-brand-navy">
{doc.transcription}
</p>
</div>
{/if}
</div>
</div>

View File

@@ -3,22 +3,19 @@ import { onMount } from 'svelte';
import { SvelteMap } from 'svelte/reactivity';
import type { PDFDocumentProxy, PDFPageProxy, RenderTask } from 'pdfjs-dist';
import AnnotationLayer from './AnnotationLayer.svelte';
import AnnotationCommentPanel from './AnnotationCommentPanel.svelte';
let {
url,
documentId = '',
canAnnotate = false,
canComment,
currentUserId,
canAdmin
annotateMode = $bindable(false),
activeAnnotationId = $bindable<string | null>(null),
onAnnotationClick
}: {
url: string;
documentId?: string;
canAnnotate?: boolean;
canComment?: boolean;
currentUserId?: string | null;
canAdmin?: boolean;
annotateMode?: boolean;
activeAnnotationId?: string | null;
onAnnotationClick?: (id: string) => void;
} = $props();
let pdfDoc = $state<PDFDocumentProxy | null>(null);
@@ -54,10 +51,8 @@ type Annotation = {
};
let annotations = $state<Annotation[]>([]);
let annotateMode = $state(false);
let annotateColor = $state('#ffff00');
let commentCounts = new SvelteMap<string, number>();
let activeAnnotationId = $state<string | null>(null);
onMount(async () => {
// Dynamic import keeps pdfjs out of the SSR bundle entirely
@@ -218,6 +213,7 @@ async function handleAnnotationDraw(rect: { x: number; y: number; width: number;
const created: Annotation = await res.json();
annotations = [...annotations, created];
activeAnnotationId = created.id;
onAnnotationClick?.(created.id);
}
} catch {
// ignore
@@ -238,6 +234,11 @@ async function handleAnnotationDelete(annotationId: string) {
}
}
function handleAnnotationClick(id: string) {
activeAnnotationId = id;
onAnnotationClick?.(id);
}
$effect(() => {
if (pdfjsReady && url) {
loadDocument(url);
@@ -385,16 +386,7 @@ function zoomOut() {
</button>
</div>
<!-- Annotate controls -->
{#if canAnnotate}
<div class="flex items-center gap-1">
<button
onclick={() => (annotateMode = !annotateMode)}
aria-label={annotateMode ? 'Annotieren beenden' : 'Annotieren'}
class="rounded px-2 py-1 font-sans text-xs text-gray-300 transition hover:bg-white/10 {annotateMode ? 'bg-white/20' : ''}"
>
{annotateMode ? 'Fertig' : 'Annotieren'}
</button>
<!-- Color picker (shown in annotate mode) -->
{#if annotateMode}
<input
type="color"
@@ -405,17 +397,6 @@ function zoomOut() {
/>
{/if}
</div>
{:else}
<button
disabled
title="Sie benötigen die Berechtigung ANNOTATE_ALL zum Annotieren"
class="cursor-not-allowed rounded px-2 py-1 font-sans text-xs text-gray-500"
aria-label="Annotieren (keine Berechtigung)"
>
Annotieren
</button>
{/if}
</div>
<!-- PDF canvas area -->
<div class="relative flex-1 overflow-auto">
@@ -445,27 +426,11 @@ function zoomOut() {
onDraw={handleAnnotationDraw}
onDelete={handleAnnotationDelete}
commentCounts={Object.fromEntries(commentCounts)}
onAnnotationClick={(id) => (activeAnnotationId = id)}
onAnnotationClick={handleAnnotationClick}
/>
</div>
</div>
{/if}
</div>
{#key activeAnnotationId}
{#if activeAnnotationId}
<AnnotationCommentPanel
documentId={documentId}
annotationId={activeAnnotationId}
canComment={canComment ?? false}
currentUserId={currentUserId ?? null}
canAdmin={canAdmin ?? false}
onClose={() => (activeAnnotationId = null)}
onCountChange={(count) => {
if (activeAnnotationId) commentCounts.set(activeAnnotationId, count);
}}
/>
{/if}
{/key}
</div>
{/if}

View File

@@ -1,10 +1,10 @@
<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';
import CommentThread from '$lib/components/CommentThread.svelte';
import { onMount } from 'svelte';
import DocumentTopBar from '$lib/components/DocumentTopBar.svelte';
import DocumentViewer from '$lib/components/DocumentViewer.svelte';
import DocumentBottomPanel from '$lib/components/DocumentBottomPanel.svelte';
type Tab = 'metadata' | 'transcription' | 'discussion' | 'history';
let { data } = $props();
@@ -17,9 +17,11 @@ const canAdmin = $derived(
);
const currentUserId = $derived((data.user?.id as string | undefined) ?? null);
// ── File loading ──────────────────────────────────────────────────────────────
let fileUrl = $state('');
let isLoading = $state(false);
let error = $state('');
let fileError = $state('');
$effect(() => {
if (doc?.id && doc?.filePath) {
@@ -29,7 +31,7 @@ $effect(() => {
async function loadFile(id: string) {
isLoading = true;
error = '';
fileError = '';
fileUrl = '';
try {
@@ -44,880 +46,104 @@ async function loadFile(id: string) {
fileUrl = URL.createObjectURL(blob);
} catch (e) {
console.error(e);
error = m.doc_file_error_preview();
fileError = 'Vorschau konnte nicht geladen werden.';
} finally {
isLoading = false;
}
}
// ── History panel ────────────────────────────────────────────────────────────
// ── Annotation state (lifted from PdfViewer) ──────────────────────────────────
type VersionSummary = {
id: string;
savedAt: string;
editorName: string;
changedFields: string[];
};
let annotateMode = $state(false);
let activeAnnotationId = $state<string | null>(null);
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 }[];
// When an annotation is clicked, open the Diskussion tab.
$effect(() => {
if (activeAnnotationId) {
activeTab = 'discussion';
panelOpen = true;
}
| { 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) }];
});
// ── Bottom panel state ────────────────────────────────────────────────────────
const LS_KEY_OPEN = 'doc-panel-open';
const LS_KEY_HEIGHT = 'doc-panel-height';
const LS_KEY_TAB = 'doc-panel-tab';
let panelOpen = $state(false);
let panelHeight = $state(320);
let activeTab = $state<Tab>('metadata');
let localStorageRestored = $state(false);
onMount(() => {
const savedOpen = localStorage.getItem(LS_KEY_OPEN);
const savedHeight = localStorage.getItem(LS_KEY_HEIGHT);
const savedTab = localStorage.getItem(LS_KEY_TAB);
if (savedTab && ['metadata', 'transcription', 'discussion', 'history'].includes(savedTab)) {
activeTab = savedTab as Tab;
}
if (savedHeight) {
const h = parseInt(savedHeight, 10);
if (!isNaN(h) && h >= 80) panelHeight = h;
}
if (savedOpen !== null) {
panelOpen = savedOpen === 'true';
} else if (!doc.filePath) {
// No previous state and no file → open to Metadaten by default
panelOpen = true;
activeTab = 'metadata';
}
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
localStorageRestored = true;
});
}
// 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
// Persist panel state whenever it changes (after initial restore).
$effect(() => {
if (!localStorageRestored) return;
localStorage.setItem(LS_KEY_OPEN, String(panelOpen));
localStorage.setItem(LS_KEY_HEIGHT, String(panelHeight));
localStorage.setItem(LS_KEY_TAB, activeTab);
});
}
// 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"
<svelte:head>
<title>{doc.title || doc.originalFilename || 'Dokument'}</title>
</svelte:head>
<div class="flex h-screen flex-col overflow-hidden bg-white" data-hydrated>
<DocumentTopBar
doc={doc}
canWrite={data.canWrite ?? false}
canAnnotate={data.canAnnotate ?? false}
fileUrl={fileUrl}
bind:annotateMode={annotateMode}
/>
</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;
<div class="relative flex-1 overflow-hidden">
<DocumentViewer
doc={doc}
fileUrl={fileUrl}
isLoading={isLoading}
error={fileError}
bind:annotateMode={annotateMode}
bind:activeAnnotationId={activeAnnotationId}
onAnnotationClick={(id) => {
activeAnnotationId = id;
}}
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>
<!-- 5. DISKUSSION -->
<div>
<div class="border-b border-brand-sand pb-2">
<h3 class="font-sans text-xs font-bold tracking-widest text-brand-navy uppercase">
{m.comment_section_title()}
</h3>
</div>
<div class="mt-4">
<CommentThread
documentId={doc.id}
initialComments={data.comments ?? []}
<DocumentBottomPanel
doc={doc}
comments={(data.comments ?? []) as never[]}
canComment={canComment}
currentUserId={currentUserId}
canAdmin={canAdmin}
bind:open={panelOpen}
bind:height={panelHeight}
bind:activeTab={activeTab}
activeAnnotationId={activeAnnotationId}
/>
</div>
</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}
canComment={canComment}
currentUserId={currentUserId}
canAdmin={canAdmin}
/>
{: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>