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' });
});
});