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 86a31481..5d29f488 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -255,5 +255,15 @@ "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", + "doc_panel_discussion_annotation_tab": "Annotation · Seite {page}", + "pdf_annotations_show": "Annotierungen anzeigen", + "pdf_annotations_hide": "Annotierungen verbergen" } diff --git a/frontend/messages/en.json b/frontend/messages/en.json index b9060841..94bf692f 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -255,5 +255,15 @@ "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", + "doc_panel_discussion_annotation_tab": "Annotation · Page {page}", + "pdf_annotations_show": "Show annotations", + "pdf_annotations_hide": "Hide annotations" } diff --git a/frontend/messages/es.json b/frontend/messages/es.json index db645b43..e0a46eef 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -255,5 +255,15 @@ "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", + "doc_panel_discussion_annotation_tab": "Anotación · Página {page}", + "pdf_annotations_show": "Mostrar anotaciones", + "pdf_annotations_hide": "Ocultar anotaciones" } diff --git a/frontend/src/lib/components/AnnotationSidePanel.svelte b/frontend/src/lib/components/AnnotationSidePanel.svelte new file mode 100644 index 00000000..0b264414 --- /dev/null +++ b/frontend/src/lib/components/AnnotationSidePanel.svelte @@ -0,0 +1,65 @@ + + +
+ +
+ + {m.doc_panel_discussion_annotation_tab({ page: String(activeAnnotationPage ?? '?') })} + + +
+ + +
+ {#if activeAnnotationId} + {#key activeAnnotationId} + + {/key} + {/if} +
+
diff --git a/frontend/src/lib/components/DocumentBottomPanel.svelte b/frontend/src/lib/components/DocumentBottomPanel.svelte new file mode 100644 index 00000000..33de26e6 --- /dev/null +++ b/frontend/src/lib/components/DocumentBottomPanel.svelte @@ -0,0 +1,194 @@ + + +
+ + + + +
+ {#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..06bc3a17 --- /dev/null +++ b/frontend/src/lib/components/DocumentTopBar.svelte @@ -0,0 +1,149 @@ + + +
+ +
+ +
+ +
+ +
+ +
+

+ {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..525f2d1a --- /dev/null +++ b/frontend/src/lib/components/DocumentViewer.svelte @@ -0,0 +1,98 @@ + + +
+ {#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..22fb9d6b --- /dev/null +++ b/frontend/src/lib/components/PanelDiscussion.svelte @@ -0,0 +1,42 @@ + + +
+ +
diff --git a/frontend/src/lib/components/PanelHistory.svelte b/frontend/src/lib/components/PanelHistory.svelte new file mode 100644 index 00000000..2124be49 --- /dev/null +++ b/frontend/src/lib/components/PanelHistory.svelte @@ -0,0 +1,519 @@ + + +
+ {#if historyLoading} +

{m.history_loading()}

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

{m.history_empty()}

+ {:else} + +
+ +
+ + {#if compareMode} +
+
+ + +
+
+ + +
+ +
+ + + {#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} + {:else} + + + {/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 9d1a3381..bdb1914d 100644 --- a/frontend/src/lib/components/PdfViewer.svelte +++ b/frontend/src/lib/components/PdfViewer.svelte @@ -3,24 +3,23 @@ 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'; import { m } from '$lib/paraglide/messages.js'; let { url, documentId = '', - canAnnotate = false, - canComment, - currentUserId, - canAdmin, + annotateMode = $bindable(false), + activeAnnotationId = $bindable(null), + activeAnnotationPage = $bindable(null), + onAnnotationClick, documentFileHash }: { url: string; documentId?: string; - canAnnotate?: boolean; - canComment?: boolean; - currentUserId?: string | null; - canAdmin?: boolean; + annotateMode?: boolean; + activeAnnotationId?: string | null; + activeAnnotationPage?: number | null; + onAnnotationClick?: (id: string) => void; documentFileHash?: string | null; } = $props(); @@ -58,10 +57,9 @@ type Annotation = { }; let annotations = $state([]); -let annotateMode = $state(false); let annotateColor = $state('#ffff00'); let commentCounts = new SvelteMap(); -let activeAnnotationId = $state(null); +let showAnnotations = $state(true); const visibleAnnotations = $derived( annotations.filter((a) => !a.fileHash || !documentFileHash || a.fileHash === documentFileHash) @@ -227,6 +225,8 @@ async function handleAnnotationDraw(rect: { x: number; y: number; width: number; const created: Annotation = await res.json(); annotations = [...annotations, created]; activeAnnotationId = created.id; + activeAnnotationPage = created.pageNumber; + onAnnotationClick?.(created.id); } } catch { // ignore @@ -247,6 +247,13 @@ async function handleAnnotationDelete(annotationId: string) { } } +function handleAnnotationClick(id: string) { + activeAnnotationId = id; + const ann = annotations.find((a) => a.id === id); + activeAnnotationPage = ann?.pageNumber ?? null; + onAnnotationClick?.(id); +} + $effect(() => { if (pdfjsReady && url) { loadDocument(url); @@ -415,34 +422,52 @@ function zoomOut() { - - {#if canAnnotate} -
- - {#if annotateMode} - - {/if} -
- {:else} + + {#if annotateMode} + + {/if} + + {#if annotations.length > 0} {/if} @@ -468,34 +493,20 @@ function zoomOut() { class="textLayer" style="position: absolute; top: 0; left: 0; overflow: hidden; pointer-events: none; line-height: 1;" > - a.pageNumber === currentPage)} - canAnnotate={annotateMode} - color={annotateColor} - onDraw={handleAnnotationDraw} - onDelete={handleAnnotationDelete} - commentCounts={Object.fromEntries(commentCounts)} - onAnnotationClick={(id) => (activeAnnotationId = id)} - /> + {#if showAnnotations} + a.pageNumber === currentPage)} + canAnnotate={annotateMode} + color={annotateColor} + onDraw={handleAnnotationDraw} + onDelete={handleAnnotationDelete} + commentCounts={Object.fromEntries(commentCounts)} + onAnnotationClick={handleAnnotationClick} + /> + {/if} {/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 4a14ef90..9142083d 100644 --- a/frontend/src/routes/documents/[id]/+page.svelte +++ b/frontend/src/routes/documents/[id]/+page.svelte @@ -1,10 +1,11 @@ -
- -
-
- -
- -
- {m.btn_back()} -
+ + {doc.title || doc.originalFilename || 'Dokument'} + -
-

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

-
-
+
+ -
- {#if data.canWrite} - - - {m.btn_edit()} - - {/if} - - {#if doc.filePath} - - - - {/if} -
+
+ { + activeAnnotationId = id; + }} + /> + { + activeAnnotationId = null; + activeAnnotationPage = null; + }} + />
- -
- - - - -
- {#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} -
-
+