Compare commits

..

2 Commits

Author SHA1 Message Date
Marcel
7fbc33b32d feat(frontend): hide outdated annotations when file version changes
Some checks failed
CI / Unit & Component Tests (pull_request) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
CI / E2E Tests (pull_request) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Unit & Component Tests (push) Has started running
- Regenerate API types with fileHash on Document and DocumentAnnotation
- PdfViewer accepts documentFileHash prop; filters visibleAnnotations to
  those whose hash matches (or is null) and shows an amber notice banner
  when any annotations are hidden due to a hash mismatch
- Document detail page passes doc.fileHash to PdfViewer
- Add i18n key annotation_outdated_notice in de/en/es
- E2E: two new tests covering hide-on-reupload and restore-on-original-reupload
  scenarios; add minimal2.pdf fixture for a different-hash upload

Closes #55

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 17:09:26 +01:00
Marcel
93f57477cd feat(backend): hash uploaded files and store hash on documents and annotations
- Flyway V13: add file_hash column to documents and document_annotations
- FileService.uploadFile() now returns UploadResult(s3Key, fileHash) with SHA-256 hash computed from raw bytes
- Document and DocumentAnnotation models gain a fileHash field
- DocumentService propagates the hash at all three upload sites (storeDocument, createDocument, updateDocument)
- AnnotationService.createAnnotation() accepts and persists a fileHash
- AnnotationController resolves the document's hash and passes it through

Closes #55

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 17:08:55 +01:00
27 changed files with 1793 additions and 1565 deletions

View File

@@ -4,10 +4,12 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.raddatz.familienarchiv.dto.CreateAnnotationDTO;
import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.DocumentAnnotation;
import org.raddatz.familienarchiv.security.Permission;
import org.raddatz.familienarchiv.security.RequirePermission;
import org.raddatz.familienarchiv.service.AnnotationService;
import org.raddatz.familienarchiv.service.DocumentService;
import org.raddatz.familienarchiv.service.UserService;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.Authentication;
@@ -23,6 +25,7 @@ import java.util.UUID;
public class AnnotationController {
private final AnnotationService annotationService;
private final DocumentService documentService;
private final UserService userService;
@GetMapping
@@ -38,7 +41,8 @@ public class AnnotationController {
@RequestBody CreateAnnotationDTO dto,
Authentication authentication) {
UUID userId = resolveUserId(authentication);
return annotationService.createAnnotation(documentId, dto, userId);
Document doc = documentService.getDocumentById(documentId);
return annotationService.createAnnotation(documentId, dto, userId, doc.getFileHash());
}
@DeleteMapping("/{annotationId}")

View File

@@ -39,6 +39,10 @@ public class Document {
@Column(name = "content_type")
private String contentType;
// SHA-256 hash of the uploaded file — used to link annotations to a file version
@Column(name = "file_hash", length = 64)
private String fileHash;
// Originaler Dateiname beim Upload (z.B. "Brief_Oma_1940.pdf")
@Column(name = "original_filename", nullable = false)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)

View File

@@ -49,6 +49,9 @@ public class DocumentAnnotation {
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private String color;
@Column(name = "file_hash", length = 64)
private String fileHash;
@Column(name = "created_by")
private UUID createdBy;

View File

@@ -23,7 +23,7 @@ public class AnnotationService {
}
@Transactional
public DocumentAnnotation createAnnotation(UUID documentId, CreateAnnotationDTO dto, UUID userId) {
public DocumentAnnotation createAnnotation(UUID documentId, CreateAnnotationDTO dto, UUID userId, String fileHash) {
List<DocumentAnnotation> existing =
annotationRepository.findByDocumentIdAndPageNumber(documentId, dto.getPageNumber());
@@ -41,6 +41,7 @@ public class AnnotationService {
.width(dto.getWidth())
.height(dto.getHeight())
.color(dto.getColor())
.fileHash(fileHash)
.createdBy(userId)
.build();

View File

@@ -64,10 +64,11 @@ public class DocumentService {
}
// 2. Delegate Storage to FileService
String s3Key = fileService.uploadFile(file, originalFilename);
FileService.UploadResult upload = fileService.uploadFile(file, originalFilename);
// 3. Update Database
document.setFilePath(s3Key);
document.setFilePath(upload.s3Key());
document.setFileHash(upload.fileHash());
document.setContentType(file.getContentType());
if (document.getStatus() == DocumentStatus.PLACEHOLDER) {
document.setStatus(DocumentStatus.UPLOADED);
@@ -120,8 +121,9 @@ public class DocumentService {
// Datei
if (file != null && !file.isEmpty()) {
String s3Key = fileService.uploadFile(file, file.getOriginalFilename());
doc.setFilePath(s3Key);
FileService.UploadResult upload = fileService.uploadFile(file, file.getOriginalFilename());
doc.setFilePath(upload.s3Key());
doc.setFileHash(upload.fileHash());
doc.setContentType(file.getContentType());
doc.setStatus(DocumentStatus.UPLOADED);
}
@@ -170,12 +172,9 @@ public class DocumentService {
// 4. Datei austauschen (nur wenn eine neue ausgewählt wurde)
if (newFile != null && !newFile.isEmpty()) {
// Alte Datei könnte man hier theoretisch löschen (optional)
// Neue Datei hochladen
String s3Key = fileService.uploadFile(newFile, newFile.getOriginalFilename());
doc.setFilePath(s3Key);
FileService.UploadResult upload = fileService.uploadFile(newFile, newFile.getOriginalFilename());
doc.setFilePath(upload.s3Key());
doc.setFileHash(upload.fileHash());
doc.setOriginalFilename(newFile.getOriginalFilename());
doc.setContentType(newFile.getContentType());
doc.setStatus(DocumentStatus.UPLOADED);

View File

@@ -13,6 +13,8 @@ import org.springframework.web.multipart.MultipartFile;
import org.springframework.core.io.InputStreamResource;
import java.io.IOException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.UUID;
@Service
@@ -29,10 +31,14 @@ public class FileService {
}
/**
* Uploads a file to S3/MinIO and returns the generated object key.
* Uploads a file to S3/MinIO.
* Returns an {@link UploadResult} containing the S3 key and the SHA-256
* hash of the file content. The hash is used to link annotations to the
* specific file version they were created against.
*/
public String uploadFile(MultipartFile file, String originalFilename) throws IOException {
// Generate secure unique path: "documents/UUID_filename"
public UploadResult uploadFile(MultipartFile file, String originalFilename) throws IOException {
byte[] bytes = file.getBytes();
String fileHash = sha256Hex(bytes);
String s3Key = "documents/" + UUID.randomUUID() + "_" + originalFilename;
try {
@@ -42,11 +48,10 @@ public class FileService {
.contentType(file.getContentType())
.build();
s3Client.putObject(putObjectRequest,
RequestBody.fromInputStream(file.getInputStream(), file.getSize()));
s3Client.putObject(putObjectRequest, RequestBody.fromBytes(bytes));
log.info("Uploaded file to S3: {}", s3Key);
return s3Key;
log.info("Uploaded file to S3: {} (hash={})", s3Key, fileHash);
return new UploadResult(s3Key, fileHash);
} catch (S3Exception e) {
log.error("S3 Upload Error", e);
throw new IOException("Failed to upload file to storage", e);
@@ -58,32 +63,52 @@ public class FileService {
* Returns a wrapper containing the stream and content type.
*/
public S3FileDownload downloadFile(String s3Key) {
try {
GetObjectRequest getObjectRequest = GetObjectRequest.builder()
.bucket(bucketName)
.key(s3Key)
.build();
try {
GetObjectRequest getObjectRequest = GetObjectRequest.builder()
.bucket(bucketName)
.key(s3Key)
.build();
ResponseInputStream<GetObjectResponse> s3Object = s3Client.getObject(getObjectRequest);
ResponseInputStream<GetObjectResponse> s3Object = s3Client.getObject(getObjectRequest);
// Use whatever content type S3 has stored (set at upload time)
String contentType = s3Object.response().contentType();
if (contentType == null || contentType.isBlank()) {
contentType = "application/octet-stream";
String contentType = s3Object.response().contentType();
if (contentType == null || contentType.isBlank()) {
contentType = "application/octet-stream";
}
return new S3FileDownload(new InputStreamResource(s3Object), contentType);
} catch (NoSuchKeyException e) {
throw new StorageFileNotFoundException("File not found in storage: " + s3Key);
} catch (S3Exception e) {
throw new RuntimeException("Storage Error: " + e.getMessage());
}
return new S3FileDownload(new InputStreamResource(s3Object), contentType);
} catch (NoSuchKeyException e) {
throw new StorageFileNotFoundException("File not found in storage: " + s3Key);
} catch (S3Exception e) {
throw new RuntimeException("Storage Error: " + e.getMessage());
}
}
// Helper Record to carry the stream and metadata back to the controller
// ─── private helpers ──────────────────────────────────────────────────────
private static String sha256Hex(byte[] bytes) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(bytes);
StringBuilder sb = new StringBuilder(64);
for (byte b : hash) {
sb.append(String.format("%02x", b));
}
return sb.toString();
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 not available", e);
}
}
// ─── result types ─────────────────────────────────────────────────────────
/** Carries the S3 object key and the content hash back to the caller. */
public record UploadResult(String s3Key, String fileHash) {}
/** Carries the download stream and content type. */
public record S3FileDownload(InputStreamResource resource, String contentType) {}
// Custom Exception
public static class StorageFileNotFoundException extends RuntimeException {
public StorageFileNotFoundException(String message) { super(message); }
}

View File

@@ -0,0 +1,7 @@
-- Add content-based file hash to documents for annotation versioning
ALTER TABLE documents
ADD COLUMN file_hash VARCHAR(64);
-- Each annotation remembers which file version it was created against
ALTER TABLE document_annotations
ADD COLUMN file_hash VARCHAR(64);

View File

@@ -4,10 +4,12 @@ import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.config.SecurityConfig;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.DocumentAnnotation;
import org.raddatz.familienarchiv.security.PermissionAspect;
import org.raddatz.familienarchiv.service.AnnotationService;
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
import org.raddatz.familienarchiv.service.DocumentService;
import org.raddatz.familienarchiv.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
@@ -36,6 +38,7 @@ class AnnotationControllerTest {
@Autowired MockMvc mockMvc;
@MockitoBean AnnotationService annotationService;
@MockitoBean DocumentService documentService;
@MockitoBean UserService userService;
@MockitoBean CustomUserDetailsService customUserDetailsService;
@@ -85,7 +88,8 @@ class AnnotationControllerTest {
DocumentAnnotation saved = DocumentAnnotation.builder()
.id(UUID.randomUUID()).documentId(docId).pageNumber(1)
.x(0.1).y(0.1).width(0.2).height(0.2).color("#ff0000").build();
when(annotationService.createAnnotation(any(), any(), any())).thenReturn(saved);
when(documentService.getDocumentById(any())).thenReturn(Document.builder().build());
when(annotationService.createAnnotation(any(), any(), any(), any())).thenReturn(saved);
mockMvc.perform(post("/api/documents/" + docId + "/annotations")
.contentType(MediaType.APPLICATION_JSON)
@@ -97,7 +101,8 @@ class AnnotationControllerTest {
@Test
@WithMockUser(authorities = "ANNOTATE_ALL")
void createAnnotation_returns409_whenOverlap() throws Exception {
when(annotationService.createAnnotation(any(), any(), any()))
when(documentService.getDocumentById(any())).thenReturn(Document.builder().build());
when(annotationService.createAnnotation(any(), any(), any(), any()))
.thenThrow(DomainException.conflict(ErrorCode.ANNOTATION_OVERLAP, "Overlap"));
mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations")

View File

@@ -44,7 +44,7 @@ class AnnotationServiceTest {
when(annotationRepository.findByDocumentIdAndPageNumber(docId, 1))
.thenReturn(List.of(existing));
assertThatThrownBy(() -> annotationService.createAnnotation(docId, dto, userId))
assertThatThrownBy(() -> annotationService.createAnnotation(docId, dto, userId, null))
.isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getStatus()).isEqualTo(CONFLICT));
@@ -63,7 +63,7 @@ class AnnotationServiceTest {
.x(0.0).y(0.0).width(0.05).height(0.05).color("#ff0000").createdBy(userId).build();
when(annotationRepository.save(any())).thenReturn(saved);
DocumentAnnotation result = annotationService.createAnnotation(docId, dto, userId);
DocumentAnnotation result = annotationService.createAnnotation(docId, dto, userId, null);
assertThat(result).isEqualTo(saved);
verify(annotationRepository).save(any());
@@ -117,6 +117,35 @@ class AnnotationServiceTest {
verify(annotationRepository).delete(annotation);
}
@Test
void createAnnotation_setsFileHash_whenProvided() {
UUID docId = UUID.randomUUID();
UUID userId = UUID.randomUUID();
CreateAnnotationDTO dto = new CreateAnnotationDTO(1, 0.0, 0.0, 0.05, 0.05, "#ff0000");
String fileHash = "abc123";
when(annotationRepository.findByDocumentIdAndPageNumber(docId, 1)).thenReturn(List.of());
when(annotationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
DocumentAnnotation result = annotationService.createAnnotation(docId, dto, userId, fileHash);
assertThat(result.getFileHash()).isEqualTo(fileHash);
}
@Test
void createAnnotation_setsNullFileHash_whenNoneProvided() {
UUID docId = UUID.randomUUID();
UUID userId = UUID.randomUUID();
CreateAnnotationDTO dto = new CreateAnnotationDTO(1, 0.0, 0.0, 0.05, 0.05, "#ff0000");
when(annotationRepository.findByDocumentIdAndPageNumber(docId, 1)).thenReturn(List.of());
when(annotationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
DocumentAnnotation result = annotationService.createAnnotation(docId, dto, userId, null);
assertThat(result.getFileHash()).isNull();
}
// ─── listAnnotations ──────────────────────────────────────────────────────
@Test

View File

@@ -135,6 +135,48 @@ class DocumentServiceTest {
assertThat(documentService.getDocumentsByReceiver(receiverId)).containsExactly(doc);
}
// ─── file hash propagation ───────────────────────────────────────────────
@Test
void createDocument_setsFileHashFromUpload_whenFileProvided() throws Exception {
DocumentUpdateDTO dto = new DocumentUpdateDTO();
dto.setTitle("Doc");
org.springframework.mock.web.MockMultipartFile file =
new org.springframework.mock.web.MockMultipartFile("file", "scan.pdf", "application/pdf", new byte[]{1});
FileService.UploadResult uploadResult = new FileService.UploadResult("documents/uuid_scan.pdf", "deadbeef");
Document savedDoc = Document.builder().id(UUID.randomUUID()).title("Doc")
.originalFilename("scan.pdf").status(DocumentStatus.PLACEHOLDER).build();
when(documentRepository.save(any())).thenReturn(savedDoc);
when(documentRepository.findById(any())).thenReturn(Optional.of(savedDoc));
when(fileService.uploadFile(any(), any())).thenReturn(uploadResult);
documentService.createDocument(dto, file);
org.mockito.ArgumentCaptor<Document> captor = org.mockito.ArgumentCaptor.forClass(Document.class);
verify(documentRepository, atLeastOnce()).save(captor.capture());
assertThat(captor.getAllValues()).anySatisfy(d -> assertThat(d.getFileHash()).isEqualTo("deadbeef"));
}
@Test
void updateDocument_setsFileHashFromUpload_whenNewFileProvided() throws Exception {
UUID id = UUID.randomUUID();
Document existing = Document.builder()
.id(id).title("Alt").originalFilename("old.pdf")
.status(DocumentStatus.UPLOADED).build();
org.springframework.mock.web.MockMultipartFile newFile =
new org.springframework.mock.web.MockMultipartFile("file", "new.pdf", "application/pdf", new byte[]{2});
FileService.UploadResult uploadResult = new FileService.UploadResult("documents/uuid_new.pdf", "cafebabe");
when(documentRepository.findById(id)).thenReturn(Optional.of(existing));
when(fileService.uploadFile(any(), any())).thenReturn(uploadResult);
when(documentRepository.save(any())).thenReturn(existing);
documentService.updateDocument(id, new DocumentUpdateDTO(), newFile);
assertThat(existing.getFileHash()).isEqualTo("cafebabe");
}
// ─── versioning ───────────────────────────────────────────────────────────
@Test

View File

@@ -0,0 +1,85 @@
package org.raddatz.familienarchiv.service;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.mock.web.MockMultipartFile;
import software.amazon.awssdk.core.sync.RequestBody;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
import java.io.IOException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
class FileServiceTest {
private S3Client s3Client;
private FileService fileService;
@BeforeEach
void setUp() {
s3Client = mock(S3Client.class);
fileService = new FileService(s3Client, "test-bucket");
}
@Test
void uploadFile_returnsS3Key() throws IOException {
MockMultipartFile file = new MockMultipartFile(
"file", "test.pdf", "application/pdf", new byte[]{1, 2, 3});
FileService.UploadResult result = fileService.uploadFile(file, "test.pdf");
assertThat(result.s3Key()).startsWith("documents/");
assertThat(result.s3Key()).endsWith("_test.pdf");
verify(s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class));
}
@Test
void uploadFile_returnsCorrectSha256FileHash() throws IOException, NoSuchAlgorithmException {
byte[] content = "hello pdf content".getBytes();
MockMultipartFile file = new MockMultipartFile(
"file", "doc.pdf", "application/pdf", content);
FileService.UploadResult result = fileService.uploadFile(file, "doc.pdf");
// Compute expected hash independently
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hashBytes = digest.digest(content);
StringBuilder expected = new StringBuilder();
for (byte b : hashBytes) {
expected.append(String.format("%02x", b));
}
assertThat(result.fileHash()).isEqualTo(expected.toString());
}
@Test
void uploadFile_differentContents_produceDifferentHashes() throws IOException {
MockMultipartFile file1 = new MockMultipartFile(
"f", "a.pdf", "application/pdf", new byte[]{1, 2, 3});
MockMultipartFile file2 = new MockMultipartFile(
"f", "b.pdf", "application/pdf", new byte[]{4, 5, 6});
FileService.UploadResult r1 = fileService.uploadFile(file1, "a.pdf");
FileService.UploadResult r2 = fileService.uploadFile(file2, "b.pdf");
assertThat(r1.fileHash()).isNotEqualTo(r2.fileHash());
}
@Test
void uploadFile_sameContents_produceSameHash() throws IOException {
byte[] content = new byte[]{10, 20, 30};
MockMultipartFile file1 = new MockMultipartFile("f", "x.pdf", "application/pdf", content);
MockMultipartFile file2 = new MockMultipartFile("f", "y.pdf", "application/pdf", content);
FileService.UploadResult r1 = fileService.uploadFile(file1, "x.pdf");
FileService.UploadResult r2 = fileService.uploadFile(file2, "y.pdf");
assertThat(r1.fileHash()).isEqualTo(r2.fileHash());
}
}

View File

@@ -1,180 +0,0 @@
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

@@ -347,6 +347,135 @@ test.describe('PDF annotations — admin', () => {
});
});
// ─── PDF Annotations — file hash (version awareness) ─────────────────────────
test.describe('PDF annotations — file hash versioning', () => {
const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000';
const PDF_FIXTURE2 = path.resolve(__dirname, 'fixtures/minimal2.pdf');
test('annotations are hidden after a different file is uploaded', async ({ page, request }) => {
test.setTimeout(90_000);
// 1. Create document and upload original PDF
const createRes = await request.post('/api/documents', {
multipart: { title: 'E2E Hash Test — version' }
});
if (!createRes.ok()) throw new Error(`Create failed: ${createRes.status()}`);
const doc = await createRes.json();
const uploadRes = await request.put(`/api/documents/${doc.id}`, {
multipart: {
title: doc.title,
file: {
name: 'minimal.pdf',
mimeType: 'application/pdf',
buffer: fs.readFileSync(PDF_FIXTURE)
}
}
});
if (!uploadRes.ok()) throw new Error(`Upload failed: ${uploadRes.status()}`);
// 2. Create an annotation via API
const annotRes = await request.post(`/api/documents/${doc.id}/annotations`, {
data: { pageNumber: 1, x: 0.1, y: 0.1, width: 0.2, height: 0.2, color: '#ff0000' }
});
if (!annotRes.ok()) throw new Error(`Create annotation failed: ${annotRes.status()}`);
// 3. Verify annotation appears before re-upload
await page.goto(`${baseURL}/documents/${doc.id}`);
await page.waitForSelector('[data-hydrated]');
await page.locator('canvas').first().waitFor({ state: 'visible', timeout: 20000 });
await expect(page.locator('[data-testid^="annotation-"]').first()).toBeVisible({
timeout: 8000
});
// 4. Upload a different file (different hash)
const reuploadRes = await request.put(`/api/documents/${doc.id}`, {
multipart: {
title: doc.title,
file: {
name: 'minimal2.pdf',
mimeType: 'application/pdf',
buffer: fs.readFileSync(PDF_FIXTURE2)
}
}
});
if (!reuploadRes.ok()) throw new Error(`Re-upload failed: ${reuploadRes.status()}`);
// 5. Reload — annotation must be hidden and notice shown
await page.reload();
await page.waitForSelector('[data-hydrated]');
await page.locator('canvas').first().waitFor({ state: 'visible', timeout: 20000 });
await expect(page.locator('[data-testid^="annotation-"]')).toHaveCount(0, { timeout: 8000 });
await expect(page.locator('[data-testid="annotation-outdated-notice"]')).toBeVisible({
timeout: 5000
});
await page.screenshot({ path: 'test-results/e2e/annotation-hidden-after-reupload.png' });
});
test('annotations reappear after re-uploading the original file', async ({ page, request }) => {
test.setTimeout(90_000);
// 1. Create document and upload original PDF
const createRes = await request.post('/api/documents', {
multipart: { title: 'E2E Hash Test — restore' }
});
if (!createRes.ok()) throw new Error(`Create failed: ${createRes.status()}`);
const doc = await createRes.json();
const originalBytes = fs.readFileSync(PDF_FIXTURE);
const uploadRes = await request.put(`/api/documents/${doc.id}`, {
multipart: {
title: doc.title,
file: { name: 'minimal.pdf', mimeType: 'application/pdf', buffer: originalBytes }
}
});
if (!uploadRes.ok()) throw new Error(`Upload failed: ${uploadRes.status()}`);
// 2. Create annotation
const annotRes = await request.post(`/api/documents/${doc.id}/annotations`, {
data: { pageNumber: 1, x: 0.1, y: 0.1, width: 0.2, height: 0.2, color: '#0000ff' }
});
if (!annotRes.ok()) throw new Error(`Create annotation failed: ${annotRes.status()}`);
// 3. Replace with different file
const replaceRes = await request.put(`/api/documents/${doc.id}`, {
multipart: {
title: doc.title,
file: {
name: 'minimal2.pdf',
mimeType: 'application/pdf',
buffer: fs.readFileSync(PDF_FIXTURE2)
}
}
});
if (!replaceRes.ok()) throw new Error(`Replace failed: ${replaceRes.status()}`);
// 4. Re-upload original file (restoring the hash)
const restoreRes = await request.put(`/api/documents/${doc.id}`, {
multipart: {
title: doc.title,
file: { name: 'minimal.pdf', mimeType: 'application/pdf', buffer: originalBytes }
}
});
if (!restoreRes.ok()) throw new Error(`Restore failed: ${restoreRes.status()}`);
// 5. Verify annotation reappears and notice is gone
await page.goto(`${baseURL}/documents/${doc.id}`);
await page.waitForSelector('[data-hydrated]');
await page.locator('canvas').first().waitFor({ state: 'visible', timeout: 20000 });
await expect(page.locator('[data-testid^="annotation-"]').first()).toBeVisible({
timeout: 8000
});
await expect(page.locator('[data-testid="annotation-outdated-notice"]')).not.toBeVisible();
await page.screenshot({ path: 'test-results/e2e/annotation-restored.png' });
});
});
// ─── PDF Annotations (read-only user) ─────────────────────────────────────────
test.describe('PDF annotations — read-only user', () => {

View File

@@ -0,0 +1,21 @@
%PDF-1.4
1 0 obj
<</Type/Catalog/Pages 2 0 R>>
endobj
2 0 obj
<</Type/Pages/Kids[3 0 R]/Count 1>>
endobj
3 0 obj
<</Type/Page/MediaBox[0 0 3 3]/Parent 2 0 R>>
endobj
xref
0 4
0000000000 65535 f
0000000009 00000 n
0000000058 00000 n
0000000115 00000 n
trailer
<</Size 4/Root 1 0 R>>
startxref
190
%%EOF

View File

@@ -2,6 +2,7 @@
"$schema": "https://inlang.com/schema/inlang-message-format",
"error_annotation_not_found": "Die Annotation wurde nicht gefunden.",
"error_annotation_overlap": "Die Annotation überschneidet sich mit einer vorhandenen.",
"annotation_outdated_notice": "Einige Annotationen beziehen sich auf eine frühere Dateiversion und werden nicht angezeigt.",
"error_document_not_found": "Das Dokument wurde nicht gefunden.",
"error_document_no_file": "Diesem Dokument ist noch keine Datei zugeordnet.",
"error_file_not_found": "Die Datei konnte im Speicher nicht gefunden werden.",
@@ -250,12 +251,5 @@
"comment_btn_reply": "Antworten",
"comment_edited_label": "· bearbeitet",
"comment_panel_title": "Kommentare",
"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"
"comment_panel_close": "Schließen"
}

View File

@@ -2,6 +2,7 @@
"$schema": "https://inlang.com/schema/inlang-message-format",
"error_annotation_not_found": "Annotation not found.",
"error_annotation_overlap": "The annotation overlaps an existing one.",
"annotation_outdated_notice": "Some annotations refer to an earlier file version and are not shown.",
"error_document_not_found": "Document not found.",
"error_document_no_file": "No file is associated with this document.",
"error_file_not_found": "The file could not be found in storage.",
@@ -250,12 +251,5 @@
"comment_btn_reply": "Reply",
"comment_edited_label": "· edited",
"comment_panel_title": "Comments",
"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"
"comment_panel_close": "Close"
}

View File

@@ -2,6 +2,7 @@
"$schema": "https://inlang.com/schema/inlang-message-format",
"error_annotation_not_found": "Anotación no encontrada.",
"error_annotation_overlap": "La anotación se superpone con una existente.",
"annotation_outdated_notice": "Algunas anotaciones hacen referencia a una versión anterior del archivo y no se muestran.",
"error_document_not_found": "Documento no encontrado.",
"error_document_no_file": "No hay ningún archivo asociado a este documento.",
"error_file_not_found": "El archivo no pudo encontrarse en el almacenamiento.",
@@ -250,12 +251,5 @@
"comment_btn_reply": "Responder",
"comment_edited_label": "· editado",
"comment_panel_title": "Comentarios",
"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"
"comment_panel_close": "Cerrar"
}

View File

@@ -1,196 +0,0 @@
<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

@@ -1,142 +0,0 @@
<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

@@ -1,93 +0,0 @@
<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

@@ -1,85 +0,0 @@
<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

@@ -1,446 +0,0 @@
<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

@@ -1,202 +0,0 @@
<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

@@ -1,42 +0,0 @@
<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,19 +3,25 @@ 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 = '',
annotateMode = $bindable(false),
activeAnnotationId = $bindable<string | null>(null),
onAnnotationClick
canAnnotate = false,
canComment,
currentUserId,
canAdmin,
documentFileHash
}: {
url: string;
documentId?: string;
annotateMode?: boolean;
activeAnnotationId?: string | null;
onAnnotationClick?: (id: string) => void;
canAnnotate?: boolean;
canComment?: boolean;
currentUserId?: string | null;
canAdmin?: boolean;
documentFileHash?: string | null;
} = $props();
let pdfDoc = $state<PDFDocumentProxy | null>(null);
@@ -48,11 +54,19 @@ type Annotation = {
height: number;
color: string;
createdAt: string;
fileHash?: string | null;
};
let annotations = $state<Annotation[]>([]);
let annotateMode = $state(false);
let annotateColor = $state('#ffff00');
let commentCounts = new SvelteMap<string, number>();
let activeAnnotationId = $state<string | null>(null);
const visibleAnnotations = $derived(
annotations.filter((a) => !a.fileHash || !documentFileHash || a.fileHash === documentFileHash)
);
const outdatedCount = $derived(annotations.length - visibleAnnotations.length);
onMount(async () => {
// Dynamic import keeps pdfjs out of the SSR bundle entirely
@@ -213,7 +227,6 @@ 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
@@ -234,11 +247,6 @@ async function handleAnnotationDelete(annotationId: string) {
}
}
function handleAnnotationClick(id: string) {
activeAnnotationId = id;
onAnnotationClick?.(id);
}
$effect(() => {
if (pdfjsReady && url) {
loadDocument(url);
@@ -299,6 +307,27 @@ function zoomOut() {
</div>
{:else}
<div class="flex h-full w-full flex-col bg-[#2A2A2A]">
{#if outdatedCount > 0}
<div
class="flex shrink-0 items-center gap-2 border-b border-amber-500/30 bg-amber-500/10 px-4 py-2"
data-testid="annotation-outdated-notice"
>
<svg
class="h-4 w-4 shrink-0 text-amber-400"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
/>
</svg>
<span class="font-sans text-xs text-amber-300">{m.annotation_outdated_notice()}</span>
</div>
{/if}
<!-- Controls -->
<div
class="flex shrink-0 items-center justify-between gap-2 border-b border-white/10 px-4 py-2"
@@ -386,15 +415,35 @@ function zoomOut() {
</button>
</div>
<!-- Color picker (shown in annotate mode) -->
{#if annotateMode}
<input
type="color"
bind:value={annotateColor}
aria-label="Farbe wählen"
class="h-6 w-6 cursor-pointer rounded border-0 bg-transparent p-0"
title="Farbe wählen"
/>
<!-- 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>
{#if annotateMode}
<input
type="color"
bind:value={annotateColor}
aria-label="Farbe wählen"
class="h-6 w-6 cursor-pointer rounded border-0 bg-transparent p-0"
title="Farbe wählen"
/>
{/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>
@@ -420,17 +469,33 @@ function zoomOut() {
style="position: absolute; top: 0; left: 0; overflow: hidden; pointer-events: none; line-height: 1;"
></div>
<AnnotationLayer
annotations={annotations.filter((a) => a.pageNumber === currentPage)}
annotations={visibleAnnotations.filter((a) => a.pageNumber === currentPage)}
canAnnotate={annotateMode}
color={annotateColor}
onDraw={handleAnnotationDraw}
onDelete={handleAnnotationDelete}
commentCounts={Object.fromEntries(commentCounts)}
onAnnotationClick={handleAnnotationClick}
onAnnotationClick={(id) => (activeAnnotationId = id)}
/>
</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

@@ -180,6 +180,86 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/documents/{documentId}/comments": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getDocumentComments"];
put?: never;
post: operations["postDocumentComment"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/documents/{documentId}/comments/{commentId}/replies": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["replyToDocumentComment"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/documents/{documentId}/annotations": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["listAnnotations"];
put?: never;
post: operations["createAnnotation"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/documents/{documentId}/annotations/{annotationId}/comments": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getAnnotationComments"];
put?: never;
post: operations["postAnnotationComment"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/documents/{documentId}/annotations/{annotationId}/comments/{commentId}/replies": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["replyToAnnotationComment"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/auth/reset-password": {
parameters: {
query?: never;
@@ -260,6 +340,22 @@ export interface paths {
patch: operations["updateGroup"];
trace?: never;
};
"/api/documents/{documentId}/comments/{commentId}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
delete: operations["deleteComment"];
options?: never;
head?: never;
patch: operations["editComment"];
trace?: never;
};
"/api/tags": {
parameters: {
query?: never;
@@ -420,6 +516,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/documents/{documentId}/annotations/{annotationId}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
delete: operations["deleteAnnotation"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
}
export type webhooks = Record<string, never>;
export interface components {
@@ -510,6 +622,7 @@ export interface components {
title: string;
filePath?: string;
contentType?: string;
fileHash?: string;
originalFilename: string;
/** @enum {string} */
status: "PLACEHOLDER" | "UPLOADED" | "TRANSCRIBED" | "REVIEWED" | "ARCHIVED";
@@ -548,6 +661,63 @@ export interface components {
name?: string;
permissions?: string[];
};
CreateCommentDTO: {
content?: string;
};
DocumentComment: {
/** Format: uuid */
id: string;
/** Format: uuid */
documentId: string;
/** Format: uuid */
annotationId?: string;
/** Format: uuid */
parentId?: string;
/** Format: uuid */
authorId?: string;
authorName: string;
content: string;
/** Format: date-time */
createdAt: string;
/** Format: date-time */
updatedAt: string;
replies: components["schemas"]["DocumentComment"][];
};
CreateAnnotationDTO: {
/** Format: int32 */
pageNumber?: number;
/** Format: double */
x?: number;
/** Format: double */
y?: number;
/** Format: double */
width?: number;
/** Format: double */
height?: number;
color?: string;
};
DocumentAnnotation: {
/** Format: uuid */
id: string;
/** Format: uuid */
documentId: string;
/** Format: int32 */
pageNumber: number;
/** Format: double */
x: number;
/** Format: double */
y: number;
/** Format: double */
width: number;
/** Format: double */
height: number;
color: string;
fileHash?: string;
/** Format: uuid */
createdBy?: string;
/** Format: date-time */
createdAt: string;
};
ResetPasswordRequest: {
token?: string;
newPassword?: string;
@@ -1062,6 +1232,205 @@ export interface operations {
};
};
};
getDocumentComments: {
parameters: {
query?: never;
header?: never;
path: {
documentId: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["DocumentComment"][];
};
};
};
};
postDocumentComment: {
parameters: {
query?: never;
header?: never;
path: {
documentId: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["CreateCommentDTO"];
};
};
responses: {
/** @description Created */
201: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["DocumentComment"];
};
};
};
};
replyToDocumentComment: {
parameters: {
query?: never;
header?: never;
path: {
documentId: string;
commentId: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["CreateCommentDTO"];
};
};
responses: {
/** @description Created */
201: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["DocumentComment"];
};
};
};
};
listAnnotations: {
parameters: {
query?: never;
header?: never;
path: {
documentId: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["DocumentAnnotation"][];
};
};
};
};
createAnnotation: {
parameters: {
query?: never;
header?: never;
path: {
documentId: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["CreateAnnotationDTO"];
};
};
responses: {
/** @description Created */
201: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["DocumentAnnotation"];
};
};
};
};
getAnnotationComments: {
parameters: {
query?: never;
header?: never;
path: {
annotationId: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["DocumentComment"][];
};
};
};
};
postAnnotationComment: {
parameters: {
query?: never;
header?: never;
path: {
documentId: string;
annotationId: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["CreateCommentDTO"];
};
};
responses: {
/** @description Created */
201: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["DocumentComment"];
};
};
};
};
replyToAnnotationComment: {
parameters: {
query?: never;
header?: never;
path: {
documentId: string;
commentId: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["CreateCommentDTO"];
};
};
responses: {
/** @description Created */
201: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["DocumentComment"];
};
};
};
};
resetPassword: {
parameters: {
query?: never;
@@ -1192,6 +1561,54 @@ export interface operations {
};
};
};
deleteComment: {
parameters: {
query?: never;
header?: never;
path: {
documentId: string;
commentId: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description No Content */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
editComment: {
parameters: {
query?: never;
header?: never;
path: {
documentId: string;
commentId: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["CreateCommentDTO"];
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["DocumentComment"];
};
};
};
};
searchTags: {
parameters: {
query?: {
@@ -1422,4 +1839,25 @@ export interface operations {
};
};
};
deleteAnnotation: {
parameters: {
query?: never;
header?: never;
path: {
documentId: string;
annotationId: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description No Content */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
}

View File

@@ -1,10 +1,10 @@
<script lang="ts">
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';
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';
let { data } = $props();
@@ -17,11 +17,9 @@ const canAdmin = $derived(
);
const currentUserId = $derived((data.user?.id as string | undefined) ?? null);
// ── File loading ──────────────────────────────────────────────────────────────
let fileUrl = $state('');
let isLoading = $state(false);
let fileError = $state('');
let error = $state('');
$effect(() => {
if (doc?.id && doc?.filePath) {
@@ -31,7 +29,7 @@ $effect(() => {
async function loadFile(id: string) {
isLoading = true;
fileError = '';
error = '';
fileUrl = '';
try {
@@ -46,104 +44,881 @@ async function loadFile(id: string) {
fileUrl = URL.createObjectURL(blob);
} catch (e) {
console.error(e);
fileError = 'Vorschau konnte nicht geladen werden.';
error = m.doc_file_error_preview();
} finally {
isLoading = false;
}
}
// ── Annotation state (lifted from PdfViewer) ──────────────────────────────────
// ── History panel ────────────────────────────────────────────────────────────
let annotateMode = $state(false);
let activeAnnotationId = $state<string | null>(null);
type VersionSummary = {
id: string;
savedAt: string;
editorName: string;
changedFields: string[];
};
// When an annotation is clicked, open the Diskussion tab.
$effect(() => {
if (activeAnnotationId) {
activeTab = 'discussion';
panelOpen = true;
type SnapshotDoc = {
title?: string;
documentDate?: string;
location?: string;
documentLocation?: string;
transcription?: string;
summary?: string;
sender?: { id: string; firstName: string; lastName: string } | null;
receivers?: { id: string; firstName: string; lastName: string }[];
tags?: { id: string; name: string }[];
};
type DiffEntry =
| {
kind: 'text';
field: string;
label: string;
parts: { value: string; added?: boolean; removed?: boolean }[];
}
| { kind: 'scalar'; field: string; label: string; oldVal: string; newVal: string }
| { kind: 'relation'; field: string; label: string; removed: string[]; added: string[] };
let historyOpen = $state(false);
let historyLoaded = $state(false);
let historyLoading = $state(false);
let versions = $state<VersionSummary[]>([]);
let compareMode = $state(false);
let compareA = $state('');
let compareB = $state('');
let selectedVersionId = $state<string | null>(null);
let diffEntries = $state<DiffEntry[]>([]);
let diffLoading = $state(false);
let noDiff = $state(false);
const fieldLabels: Record<string, () => string> = {
title: m.history_field_title,
documentDate: m.history_field_document_date,
location: m.history_field_location,
documentLocation: m.history_field_document_location,
transcription: m.history_field_transcription,
summary: m.history_field_summary,
sender: m.history_field_sender,
receivers: m.history_field_receivers,
tags: m.history_field_tags
};
const TEXT_FIELDS = ['title', 'summary', 'transcription'] as const;
const SCALAR_FIELDS = ['documentDate', 'location', 'documentLocation'] as const;
function parseSnapshot(raw: string): SnapshotDoc {
try {
return JSON.parse(raw) as SnapshotDoc;
} catch {
return {};
}
});
}
// ── Bottom panel state ────────────────────────────────────────────────────────
function personLabel(p: { firstName: string; lastName: string }): string {
return `${p.firstName} ${p.lastName}`.trim();
}
const LS_KEY_OPEN = 'doc-panel-open';
const LS_KEY_HEIGHT = 'doc-panel-height';
const LS_KEY_TAB = 'doc-panel-tab';
const DIFF_CONTEXT_WORDS = 4;
let panelOpen = $state(false);
let panelHeight = $state(320);
let activeTab = $state<Tab>('metadata');
let localStorageRestored = $state(false);
type DiffPart = { value: string; added?: boolean; removed?: boolean };
onMount(() => {
const savedOpen = localStorage.getItem(LS_KEY_OPEN);
const savedHeight = localStorage.getItem(LS_KEY_HEIGHT);
const savedTab = localStorage.getItem(LS_KEY_TAB);
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];
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 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 });
}
localStorageRestored = true;
});
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 });
}
// 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);
});
// sender
const senderA = older?.sender ? personLabel(older.sender) : '';
const senderB = newer.sender ? personLabel(newer.sender) : '';
if (senderA !== senderB) {
const removed = senderA ? [senderA] : [];
const added = senderB ? [senderB] : [];
entries.push({
kind: 'relation',
field: 'sender',
label: fieldLabels['sender'](),
removed,
added
});
}
// receivers
const receiversA = new Set((older?.receivers ?? []).map(personLabel));
const receiversB = new Set((newer.receivers ?? []).map(personLabel));
const removedReceivers = [...receiversA].filter((r) => !receiversB.has(r));
const addedReceivers = [...receiversB].filter((r) => !receiversA.has(r));
if (removedReceivers.length > 0 || addedReceivers.length > 0) {
entries.push({
kind: 'relation',
field: 'receivers',
label: fieldLabels['receivers'](),
removed: removedReceivers,
added: addedReceivers
});
}
// tags
const tagsA = new Set((older?.tags ?? []).map((t) => t.name));
const tagsB = new Set((newer.tags ?? []).map((t) => t.name));
const removedTags = [...tagsA].filter((t) => !tagsB.has(t));
const addedTags = [...tagsB].filter((t) => !tagsA.has(t));
if (removedTags.length > 0 || addedTags.length > 0) {
entries.push({
kind: 'relation',
field: 'tags',
label: fieldLabels['tags'](),
removed: removedTags,
added: addedTags
});
}
return entries;
}
async function fetchSnapshot(versionId: string): Promise<SnapshotDoc> {
const res = await fetch(`/api/documents/${doc.id}/versions/${versionId}`);
if (!res.ok) throw new Error('Failed to fetch version');
const v = await res.json();
return parseSnapshot(v.snapshot);
}
async function toggleHistory() {
historyOpen = !historyOpen;
if (historyOpen && !historyLoaded) {
historyLoading = true;
try {
const res = await fetch(`/api/documents/${doc.id}/versions`);
if (res.ok) {
versions = await res.json();
}
historyLoaded = true;
} catch {
// ignore
} finally {
historyLoading = false;
}
}
}
async function selectVersion(versionId: string) {
if (selectedVersionId === versionId) {
selectedVersionId = null;
diffEntries = [];
noDiff = false;
return;
}
selectedVersionId = versionId;
diffEntries = [];
noDiff = false;
diffLoading = true;
try {
const idx = versions.findIndex((v) => v.id === versionId);
const newerSnap = await fetchSnapshot(versionId);
const olderSnap = idx > 0 ? await fetchSnapshot(versions[idx - 1].id) : null;
const entries = buildDiff(olderSnap, newerSnap);
if (entries.length === 0) {
noDiff = true;
} else {
diffEntries = entries;
}
} catch {
// ignore
} finally {
diffLoading = false;
}
}
async function applyCompare() {
if (!compareA || !compareB || compareA === compareB) return;
selectedVersionId = null;
diffEntries = [];
noDiff = false;
diffLoading = true;
try {
const [snapA, snapB] = await Promise.all([fetchSnapshot(compareA), fetchSnapshot(compareB)]);
// compareA is the "older" baseline, compareB is "newer"
const entries = buildDiff(snapA, snapB);
if (entries.length === 0) {
noDiff = true;
} else {
diffEntries = entries;
}
} catch {
// ignore
} finally {
diffLoading = false;
}
}
function formatDateTime(iso: string): string {
try {
return new Intl.DateTimeFormat('de-DE', {
day: 'numeric',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
}).format(new Date(iso));
} catch {
return iso;
}
}
function versionLabel(v: VersionSummary, index: number): string {
return `Version ${index + 1}${v.editorName}${formatDateTime(v.savedAt)}`;
}
</script>
<svelte:head>
<title>{doc.title || doc.originalFilename || 'Dokument'}</title>
</svelte:head>
<div class="flex h-screen flex-col bg-white">
<!-- Top Bar -->
<div
class="z-10 flex items-center justify-between border-b border-brand-sand bg-white px-6 py-4 shadow-sm"
>
<div class="flex items-center gap-6 overflow-hidden">
<a
href="/"
class="group flex flex-shrink-0 items-center gap-2 font-sans text-sm font-medium text-gray-500 transition-colors hover:text-brand-navy"
>
<div
class="flex h-8 w-8 items-center justify-center rounded-full bg-brand-sand transition-colors group-hover:bg-brand-mint"
>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Arrow/Arrow-Left-MD.svg"
alt=""
aria-hidden="true"
class="h-4 w-4"
/>
</div>
<span>{m.btn_back()}</span>
</a>
<div class="flex 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 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="relative flex-1 overflow-hidden">
<DocumentViewer
doc={doc}
fileUrl={fileUrl}
isLoading={isLoading}
error={fileError}
bind:annotateMode={annotateMode}
bind:activeAnnotationId={activeAnnotationId}
onAnnotationClick={(id) => {
activeAnnotationId = id;
}}
/>
<div class="ml-4 flex flex-shrink-0 items-center gap-3 font-sans">
{#if data.canWrite}
<a
href="/documents/{doc.id}/edit"
class="flex items-center gap-2 rounded border border-brand-navy bg-transparent px-4 py-2 text-sm font-medium text-brand-navy transition hover:bg-brand-navy hover:text-white"
>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Edit-Content-MD.svg"
alt=""
aria-hidden="true"
class="h-4 w-4"
/>
{m.btn_edit()}
</a>
{/if}
{#if doc.filePath}
<a
href={fileUrl}
download={doc.originalFilename}
class="rounded border border-transparent bg-brand-sand/50 p-2 text-brand-navy transition hover:bg-brand-mint"
title={m.doc_download_title()}
>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Download-MD.svg"
alt=""
aria-hidden="true"
class="h-5 w-5"
/>
</a>
{/if}
</div>
</div>
<!-- Content Area -->
<div class="flex flex-1 overflow-hidden">
<!-- LEFT SIDEBAR: METADATA -->
<aside
class="custom-scrollbar w-96 flex-shrink-0 overflow-y-auto border-r border-brand-sand bg-white p-8"
>
<div class="space-y-10">
<!-- 1. DETAILS GROUP -->
<div>
<h3
class="mb-4 border-b border-brand-sand pb-2 font-sans text-xs font-bold tracking-widest text-brand-navy uppercase"
>
{m.doc_section_details()}
</h3>
<div class="space-y-5">
<!-- Date -->
<div class="group flex items-start">
<span class="mt-0.5 w-8 text-brand-mint">
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Calendar/Calendar-Add-MD.svg"
alt=""
aria-hidden="true"
class="h-5 w-5"
/>
</span>
<div>
<span class="block font-serif text-lg text-brand-navy">
{doc.documentDate ? formatDate(doc.documentDate) : '—'}
</span>
<span class="font-sans text-xs text-gray-500">{m.doc_label_document_date()}</span>
</div>
</div>
<!-- Creation Location -->
<div class="group flex items-start">
<span class="mt-0.5 w-8 text-brand-mint">
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Location-MD.svg"
alt=""
aria-hidden="true"
class="h-5 w-5"
/>
</span>
<div>
<span class="block font-serif text-lg text-brand-navy">
{doc.location ? doc.location : '—'}
</span>
<span class="font-sans text-xs text-gray-500"
>{m.doc_label_creation_location()}</span
>
</div>
</div>
<!-- Physical Archive Location -->
{#if doc.documentLocation}
<div class="group flex items-start">
<span class="mt-0.5 w-8 text-brand-mint">
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Folder-MD.svg"
alt=""
aria-hidden="true"
class="h-5 w-5"
/>
</span>
<div>
<span class="block font-serif text-lg text-brand-navy">
{doc.documentLocation}
</span>
<span class="font-sans text-xs text-gray-500"
>{m.doc_label_archive_location_original()}</span
>
</div>
</div>
{/if}
<!-- TAGS / SCHLAGWORTE -->
{#if doc.tags && doc.tags.length > 0}
<div class="group flex items-start">
<span class="mt-0.5 w-8 text-brand-mint">
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Bookmark/Bookmark-Outline-MD.svg"
alt=""
aria-hidden="true"
class="h-5 w-5"
/>
</span>
<div class="flex-1">
<div class="mb-1 flex flex-wrap gap-2">
{#each doc.tags as tag (tag.id)}
<a
href="/?tag={encodeURIComponent(tag.name)}"
class="inline-flex items-center rounded bg-brand-sand/50 px-2 py-0.5 text-xs font-bold tracking-wide text-brand-navy uppercase transition-colors hover:bg-brand-navy hover:text-white"
title={m.doc_tag_filter_title({ name: tag.name })}
>
{tag.name}
</a>
{/each}
</div>
<span class="font-sans text-xs text-gray-500">{m.form_label_tags()}</span>
</div>
</div>
{/if}
</div>
</div>
<!-- 2. PERSONEN GROUP -->
<div>
<h3
class="mb-4 border-b border-brand-sand pb-2 font-sans text-xs font-bold tracking-widest text-brand-navy uppercase"
>
{m.doc_section_persons()}
</h3>
<div class="mb-6">
<span class="mb-2 block font-sans text-xs text-gray-400 uppercase"
>{m.form_label_sender()}</span
>
{#if doc.sender}
<a
href="/persons/{doc.sender.id}"
class="group block rounded border border-brand-sand bg-brand-sand/20 p-3 transition hover:border-brand-mint hover:bg-brand-mint/10"
>
<div class="flex items-center gap-3">
<div
class="flex h-8 w-8 items-center justify-center rounded-full bg-brand-navy font-serif text-sm text-white"
>
{doc.sender.firstName[0]}{doc.sender.lastName[0]}
</div>
<div>
<p
class="font-serif text-brand-navy decoration-brand-mint underline-offset-2 group-hover:underline"
>
{doc.sender.firstName}
{doc.sender.lastName}
</p>
{#if doc.sender.alias}
<p class="font-sans text-xs text-gray-500">{doc.sender.alias}</p>
{/if}
</div>
</div>
</a>
{:else}
<span class="font-serif text-sm text-gray-400 italic"
>{m.doc_sender_not_specified()}</span
>
{/if}
</div>
<div>
<span class="mb-2 block font-sans text-xs text-gray-400 uppercase"
>{m.form_label_receivers()}</span
>
{#if doc.receivers && doc.receivers.length > 0}
<div class="space-y-2">
{#each doc.receivers as receiver (receiver.id)}
<div
class="group flex items-center justify-between rounded border border-brand-sand bg-white p-3 transition hover:border-brand-navy"
>
<a href="/persons/{receiver.id}" class="flex min-w-0 flex-1 items-center gap-3">
<div
class="flex h-6 w-6 items-center justify-center rounded-full bg-gray-100 font-serif text-xs text-gray-500"
>
{receiver.firstName[0]}{receiver.lastName[0]}
</div>
<span
class="truncate font-serif text-sm text-brand-navy group-hover:text-brand-navy"
>
{receiver.firstName}
{receiver.lastName}
</span>
</a>
{#if doc.sender}
<a
href="/conversations?senderId={doc.sender.id}&receiverId={receiver.id}"
class="text-gray-300 transition hover:text-brand-mint"
title={m.doc_conversation_title()}
>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Chat-MD.svg"
alt=""
aria-hidden="true"
class="h-5 w-5"
/>
</a>
{/if}
</div>
{/each}
</div>
{:else}
<span class="font-serif text-sm text-gray-400 italic">{m.doc_no_receivers()}</span>
{/if}
</div>
</div>
<!-- 3. INHALT GROUP -->
{#if doc.summary || doc.transcription}
<div>
<h3
class="mb-4 border-b border-brand-sand pb-2 font-sans text-xs font-bold tracking-widest text-brand-navy uppercase"
>
{m.doc_section_content()}
</h3>
<div class="space-y-6">
{#if doc.summary}
<div>
<span class="mb-2 block font-sans text-xs text-gray-400 uppercase"
>{m.doc_label_summary()}</span
>
<ExpandableText text={doc.summary} />
</div>
{/if}
{#if doc.transcription}
<div>
<span class="mb-2 block font-sans text-xs text-gray-400 uppercase"
>{m.form_label_transcription()}</span
>
<ExpandableText text={doc.transcription} />
</div>
{/if}
</div>
</div>
{/if}
<!-- 4. HISTORY GROUP -->
<div>
<div class="flex items-center justify-between border-b border-brand-sand pb-2">
<h3 class="font-sans text-xs font-bold tracking-widest text-brand-navy uppercase">
{m.history_section_title()}
</h3>
<button
onclick={toggleHistory}
class="flex items-center gap-1 rounded p-1 text-gray-400 transition hover:bg-brand-sand/50 hover:text-brand-navy"
aria-expanded={historyOpen}
aria-label={m.history_section_title()}
>
<svg
class="h-4 w-4 transition-transform duration-200 {historyOpen ? 'rotate-180' : ''}"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
clip-rule="evenodd"
/>
</svg>
</button>
</div>
{#if historyOpen}
<div class="mt-4 space-y-3">
{#if historyLoading}
<p class="font-sans text-xs text-gray-400">{m.history_loading()}</p>
{:else if versions.length === 0}
<p class="font-serif text-sm text-gray-400 italic">{m.history_empty()}</p>
{:else}
<!-- Compare mode toggle -->
<div class="flex justify-end">
<button
onclick={() => {
compareMode = !compareMode;
diffEntries = [];
noDiff = false;
selectedVersionId = null;
}}
class="font-sans text-xs font-medium transition {compareMode
? 'text-brand-navy underline'
: 'text-gray-400 hover:text-brand-navy'}"
>
{m.history_compare_mode()}
</button>
</div>
{#if compareMode}
<!-- Compare selects -->
<div class="space-y-2">
<div>
<label
for="compare-a"
class="mb-1 block font-sans text-[10px] text-gray-400 uppercase"
>{m.history_compare_select_a()}</label
>
<select
id="compare-a"
bind:value={compareA}
class="w-full rounded border border-brand-sand bg-white px-2 py-1 font-sans text-xs text-brand-navy focus:ring-1 focus:ring-brand-mint focus:outline-none"
>
<option value=""></option>
{#each versions as v, i (v.id)}
<option value={v.id}>{versionLabel(v, i)}</option>
{/each}
</select>
</div>
<div>
<label
for="compare-b"
class="mb-1 block font-sans text-[10px] text-gray-400 uppercase"
>{m.history_compare_select_b()}</label
>
<select
id="compare-b"
bind:value={compareB}
class="w-full rounded border border-brand-sand bg-white px-2 py-1 font-sans text-xs text-brand-navy focus:ring-1 focus:ring-brand-mint focus:outline-none"
>
<option value=""></option>
{#each versions as v, i (v.id)}
<option value={v.id}>{versionLabel(v, i)}</option>
{/each}
</select>
</div>
<button
onclick={applyCompare}
disabled={!compareA || !compareB || compareA === compareB}
class="w-full rounded bg-brand-navy px-3 py-1.5 font-sans text-xs font-medium text-white transition hover:bg-brand-navy/80 disabled:cursor-not-allowed disabled:opacity-40"
>
{m.history_compare_apply()}
</button>
</div>
{:else}
<!-- Version list -->
<ul class="divide-y divide-brand-sand">
{#each versions as v, i (v.id)}
<li>
<button
onclick={() => selectVersion(v.id)}
data-testid="history-version"
class="w-full py-2 text-left transition hover:bg-brand-sand/30 {selectedVersionId ===
v.id
? 'border-l-2 border-brand-mint pl-2'
: 'pl-0'}"
>
<div class="flex items-baseline justify-between gap-2">
<span class="font-sans text-xs font-medium text-brand-navy">
Version {i + 1}
</span>
<span class="font-sans text-[10px] text-gray-400">
{formatDateTime(v.savedAt)}
</span>
</div>
<span class="font-sans text-[11px] text-gray-500">{v.editorName}</span>
{#if v.changedFields && v.changedFields.length > 0}
<div class="mt-1 flex flex-wrap gap-1">
{#each v.changedFields as field (field)}
<span
class="rounded bg-brand-sand/50 px-1.5 py-0.5 font-sans text-[10px] tracking-wide text-gray-500 uppercase"
>
{fieldLabels[field] ? fieldLabels[field]() : field}
</span>
{/each}
</div>
{/if}
</button>
</li>
{/each}
</ul>
{/if}
<!-- Diff panel -->
{#if diffLoading}
<p class="font-sans text-xs text-gray-400">{m.history_loading()}</p>
{:else if noDiff}
<div
data-testid="history-diff"
class="rounded-sm border border-brand-sand bg-white p-4 font-serif text-sm text-gray-400 italic"
>
{m.history_diff_no_changes()}
</div>
{:else if diffEntries.length > 0}
<div
data-testid="history-diff"
class="space-y-4 rounded-sm border border-brand-sand bg-white p-4"
>
{#each diffEntries as entry (entry.field)}
<div>
<span
class="mb-1.5 block font-sans text-[10px] font-bold tracking-wide text-gray-400 uppercase"
>{entry.label}</span
>
{#if entry.kind === 'text'}
<p class="font-serif text-sm leading-relaxed">
{#each entry.parts as part, partIdx (partIdx)}
{#if part.added}
<span class="bg-green-50 text-green-700">{part.value}</span>
{:else if part.removed}
<span class="bg-red-50 text-red-600 line-through">{part.value}</span
>
{:else}
<span>{part.value}</span>
{/if}
{/each}
</p>
{:else if entry.kind === 'scalar'}
<div class="flex items-center gap-2 font-serif text-sm">
<span class="text-red-600 line-through">{entry.oldVal || '—'}</span>
<svg
class="h-3 w-3 flex-shrink-0 text-gray-400"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z"
clip-rule="evenodd"
/>
</svg>
<span class="text-green-700">{entry.newVal || '—'}</span>
</div>
{:else if entry.kind === 'relation'}
<div class="flex flex-wrap gap-1.5">
{#each entry.removed as item (item)}
<span
class="rounded bg-red-50 px-1.5 py-0.5 font-sans text-[11px] text-red-600 line-through"
>{item}</span
>
{/each}
{#each entry.added as item (item)}
<span
class="rounded bg-green-50 px-1.5 py-0.5 font-sans text-[11px] text-green-700"
>{item}</span
>
{/each}
</div>
{/if}
</div>
{/each}
</div>
{/if}
{/if}
</div>
{/if}
</div>
<!-- 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 ?? []}
canComment={canComment}
currentUserId={currentUserId}
canAdmin={canAdmin}
/>
</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}
documentFileHash={doc.fileHash ?? null}
/>
{: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>
<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}
/>