From 8c2bdbd777da23befced8630708900d4a70eedd0 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 24 Mar 2026 22:35:28 +0100 Subject: [PATCH] feat(frontend): add floating bottom panel to document detail page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- frontend/e2e/bottom-panel.spec.ts | 180 ++++ frontend/messages/de.json | 9 +- frontend/messages/en.json | 9 +- frontend/messages/es.json | 9 +- .../lib/components/DocumentBottomPanel.svelte | 196 ++++ .../src/lib/components/DocumentTopBar.svelte | 142 +++ .../src/lib/components/DocumentViewer.svelte | 93 ++ .../src/lib/components/PanelDiscussion.svelte | 85 ++ .../src/lib/components/PanelHistory.svelte | 446 ++++++++ .../src/lib/components/PanelMetadata.svelte | 202 ++++ .../lib/components/PanelTranscription.svelte | 42 + frontend/src/lib/components/PdfViewer.svelte | 79 +- .../src/routes/documents/[id]/+page.svelte | 952 ++---------------- 13 files changed, 1521 insertions(+), 923 deletions(-) create mode 100644 frontend/e2e/bottom-panel.spec.ts create mode 100644 frontend/src/lib/components/DocumentBottomPanel.svelte create mode 100644 frontend/src/lib/components/DocumentTopBar.svelte create mode 100644 frontend/src/lib/components/DocumentViewer.svelte create mode 100644 frontend/src/lib/components/PanelDiscussion.svelte create mode 100644 frontend/src/lib/components/PanelHistory.svelte create mode 100644 frontend/src/lib/components/PanelMetadata.svelte create mode 100644 frontend/src/lib/components/PanelTranscription.svelte diff --git a/frontend/e2e/bottom-panel.spec.ts b/frontend/e2e/bottom-panel.spec.ts new file mode 100644 index 00000000..55c2d074 --- /dev/null +++ b/frontend/e2e/bottom-panel.spec.ts @@ -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' }); + }); +}); diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 8d8a86c9..992698cb 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -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" } diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 8b9fbdf5..e6124604 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -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" } diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 01d37915..1a672c48 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -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" } diff --git a/frontend/src/lib/components/DocumentBottomPanel.svelte b/frontend/src/lib/components/DocumentBottomPanel.svelte new file mode 100644 index 00000000..1a0725f0 --- /dev/null +++ b/frontend/src/lib/components/DocumentBottomPanel.svelte @@ -0,0 +1,196 @@ + + +
+ + + + +
+ {#each tabs as tab (tab.id)} + + {/each} + + +
+ + {#if open} + + {/if} +
+ + + {#if open} +
+ {#if activeTab === 'metadata'} + + {:else if activeTab === 'transcription'} + + {:else if activeTab === 'discussion'} + + {:else if activeTab === 'history'} + + {/if} +
+ {/if} +
diff --git a/frontend/src/lib/components/DocumentTopBar.svelte b/frontend/src/lib/components/DocumentTopBar.svelte new file mode 100644 index 00000000..fdca043d --- /dev/null +++ b/frontend/src/lib/components/DocumentTopBar.svelte @@ -0,0 +1,142 @@ + + +
+ +
+ +
+ +
+ +
+ +
+

+ {doc.title || doc.originalFilename} +

+ {#if compactMeta} +

+ {compactMeta} +

+ {/if} +
+
+ + +
+ {#if canAnnotate && isPdf} + + {/if} + + {#if canWrite} + + + {m.btn_edit()} + + {/if} + + {#if doc.filePath} + + + + {/if} +
+
diff --git a/frontend/src/lib/components/DocumentViewer.svelte b/frontend/src/lib/components/DocumentViewer.svelte new file mode 100644 index 00000000..bf13e0d8 --- /dev/null +++ b/frontend/src/lib/components/DocumentViewer.svelte @@ -0,0 +1,93 @@ + + +
+ {#if isLoading} +
+ + + + + {m.doc_loading()} +
+ {:else if error} +
+

{error}

+ {#if doc.filePath} + + {m.doc_download_link()} + + {/if} +
+ {:else if !doc.filePath} +
+
+ +
+

{m.doc_no_scan()}

+
+ {:else if fileUrl && doc.contentType?.startsWith('application/pdf')} + + {:else if fileUrl} +
+ {m.doc_image_alt()} +
+ {/if} +
diff --git a/frontend/src/lib/components/PanelDiscussion.svelte b/frontend/src/lib/components/PanelDiscussion.svelte new file mode 100644 index 00000000..89810ca8 --- /dev/null +++ b/frontend/src/lib/components/PanelDiscussion.svelte @@ -0,0 +1,85 @@ + + +
+ + {#if activeAnnotationId} +
+

+ {m.doc_panel_annotation_thread_title()} +

+ {#key activeAnnotationId} + onAnnotationCommentCountChange?.(activeAnnotationId, count)} + /> + {/key} +
+ {/if} + + +
+ {#if activeAnnotationId} +

+ {m.comment_section_title()} +

+ {/if} + +
+
diff --git a/frontend/src/lib/components/PanelHistory.svelte b/frontend/src/lib/components/PanelHistory.svelte new file mode 100644 index 00000000..766c268b --- /dev/null +++ b/frontend/src/lib/components/PanelHistory.svelte @@ -0,0 +1,446 @@ + + +
+ {#if historyLoading} +

{m.history_loading()}

+ {:else if !historyLoaded} + + {:else if versions.length === 0} +

{m.history_empty()}

+ {:else} + +
+ +
+ + {#if compareMode} +
+
+ + +
+
+ + +
+ +
+ {:else} + +
    + {#each versions as v, i (v.id)} +
  • + +
  • + {/each} +
+ {/if} + + + {#if diffLoading} +

{m.history_loading()}

+ {:else if noDiff} +
+ {m.history_diff_no_changes()} +
+ {:else if diffEntries.length > 0} +
+ {#each diffEntries as entry (entry.field)} +
+ {entry.label} + {#if entry.kind === 'text'} +

+ {#each entry.parts as part, partIdx (partIdx)} + {#if part.added} + {part.value} + {:else if part.removed} + {part.value} + {:else} + {part.value} + {/if} + {/each} +

+ {:else if entry.kind === 'scalar'} +
+ {entry.oldVal || '—'} + + {entry.newVal || '—'} +
+ {:else if entry.kind === 'relation'} +
+ {#each entry.removed as item (item)} + {item} + {/each} + {#each entry.added as item (item)} + {item} + {/each} +
+ {/if} +
+ {/each} +
+ {/if} + {/if} +
diff --git a/frontend/src/lib/components/PanelMetadata.svelte b/frontend/src/lib/components/PanelMetadata.svelte new file mode 100644 index 00000000..25a8219c --- /dev/null +++ b/frontend/src/lib/components/PanelMetadata.svelte @@ -0,0 +1,202 @@ + + +
+ +
+

+ {m.doc_section_details()} +

+
+ +
+ + + +
+ + {doc.documentDate ? formatDate(doc.documentDate) : '—'} + + {m.doc_label_document_date()} +
+
+ + +
+ + + +
+ + {doc.location ? doc.location : '—'} + + {m.doc_label_creation_location()} +
+
+ + + {#if doc.documentLocation} +
+ + + +
+ + {doc.documentLocation} + + {m.doc_label_archive_location_original()} +
+
+ {/if} + + + {#if doc.tags && doc.tags.length > 0} +
+ + + +
+
+ {#each doc.tags as tag (tag.id)} + + {tag.name} + + {/each} +
+ {m.form_label_tags()} +
+
+ {/if} +
+
+ + +
+

+ {m.doc_section_persons()} +

+ + + +
+ {m.form_label_receivers()} + {#if doc.receivers && doc.receivers.length > 0} +
+ {#each doc.receivers as receiver (receiver.id)} + + {/each} +
+ {:else} + {m.doc_no_receivers()} + {/if} +
+
+
diff --git a/frontend/src/lib/components/PanelTranscription.svelte b/frontend/src/lib/components/PanelTranscription.svelte new file mode 100644 index 00000000..f2129ca3 --- /dev/null +++ b/frontend/src/lib/components/PanelTranscription.svelte @@ -0,0 +1,42 @@ + + +
+
+ {#if !doc.summary && !doc.transcription} +

+ {/if} + + {#if doc.summary} +
+ + {m.doc_label_summary()} + +

{doc.summary}

+
+ {/if} + + {#if doc.transcription} +
+ + {m.form_label_transcription()} + +

+ {doc.transcription} +

+
+ {/if} +
+
diff --git a/frontend/src/lib/components/PdfViewer.svelte b/frontend/src/lib/components/PdfViewer.svelte index 1a9ff68a..2ac3e6b2 100644 --- a/frontend/src/lib/components/PdfViewer.svelte +++ b/frontend/src/lib/components/PdfViewer.svelte @@ -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(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(null); @@ -54,10 +51,8 @@ type Annotation = { }; let annotations = $state([]); -let annotateMode = $state(false); let annotateColor = $state('#ffff00'); let commentCounts = new SvelteMap(); -let activeAnnotationId = $state(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,35 +386,15 @@ function zoomOut() { - - {#if canAnnotate} -
- - {#if annotateMode} - - {/if} -
- {:else} - + + {#if annotateMode} + {/if} @@ -445,27 +426,11 @@ function zoomOut() { onDraw={handleAnnotationDraw} onDelete={handleAnnotationDelete} commentCounts={Object.fromEntries(commentCounts)} - onAnnotationClick={(id) => (activeAnnotationId = id)} + onAnnotationClick={handleAnnotationClick} /> {/if} - - {#key activeAnnotationId} - {#if activeAnnotationId} - (activeAnnotationId = null)} - onCountChange={(count) => { - if (activeAnnotationId) commentCounts.set(activeAnnotationId, count); - }} - /> - {/if} - {/key} {/if} diff --git a/frontend/src/routes/documents/[id]/+page.svelte b/frontend/src/routes/documents/[id]/+page.svelte index 2ec4747c..d2f96141 100644 --- a/frontend/src/routes/documents/[id]/+page.svelte +++ b/frontend/src/routes/documents/[id]/+page.svelte @@ -1,10 +1,10 @@ -
- -
-
- -
- -
- {m.btn_back()} -
+ + {doc.title || doc.originalFilename || 'Dokument'} + -
-

- {doc.title || doc.originalFilename} -

- - {doc.status} - -
-
+
+ -
- {#if data.canWrite} - - - {m.btn_edit()} - - {/if} - - {#if doc.filePath} - - - - {/if} -
-
- - -
- - - - -
- {#if isLoading} -
- - - - - {m.doc_loading()} -
- {:else if error} -
-

{error}

- {#if doc.filePath} - - {m.doc_download_link()} - - {/if} -
- {:else if !doc.filePath} -
-
- -
-

{m.doc_no_scan()}

-
- {:else if fileUrl && doc.contentType?.startsWith('application/pdf')} - - {:else if fileUrl} -
- {m.doc_image_alt()} -
- {/if} -
+
+ { + activeAnnotationId = id; + }} + />
+ +