From 5231476c274e0569b0d96a1291325d3be5d8c7fb Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 5 Apr 2026 11:12:08 +0200 Subject: [PATCH 01/47] feat(transcription): add Flyway migrations for transcription blocks V18: transcription_blocks table with optimistic locking version column V19: transcription_block_versions for edit history capture V20: add block_id FK to document_comments for block-level threads Co-Authored-By: Claude Sonnet 4.6 --- .../migration/V18__add_transcription_blocks.sql | 16 ++++++++++++++++ .../V19__add_transcription_block_versions.sql | 9 +++++++++ .../migration/V20__add_block_id_to_comments.sql | 4 ++++ 3 files changed, 29 insertions(+) create mode 100644 backend/src/main/resources/db/migration/V18__add_transcription_blocks.sql create mode 100644 backend/src/main/resources/db/migration/V19__add_transcription_block_versions.sql create mode 100644 backend/src/main/resources/db/migration/V20__add_block_id_to_comments.sql diff --git a/backend/src/main/resources/db/migration/V18__add_transcription_blocks.sql b/backend/src/main/resources/db/migration/V18__add_transcription_blocks.sql new file mode 100644 index 00000000..03e5aaf8 --- /dev/null +++ b/backend/src/main/resources/db/migration/V18__add_transcription_blocks.sql @@ -0,0 +1,16 @@ +CREATE TABLE transcription_blocks ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + annotation_id UUID NOT NULL REFERENCES document_annotations(id) ON DELETE CASCADE, + document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE, + text TEXT NOT NULL DEFAULT '', + label VARCHAR(200), + sort_order INTEGER NOT NULL DEFAULT 0, + version INTEGER NOT NULL DEFAULT 0, + created_by UUID REFERENCES app_users(id) ON DELETE SET NULL, + updated_by UUID REFERENCES app_users(id) ON DELETE SET NULL, + created_at TIMESTAMP NOT NULL DEFAULT now(), + updated_at TIMESTAMP NOT NULL DEFAULT now() +); + +CREATE INDEX idx_tb_document_sort ON transcription_blocks(document_id, sort_order); +CREATE INDEX idx_tb_annotation ON transcription_blocks(annotation_id); diff --git a/backend/src/main/resources/db/migration/V19__add_transcription_block_versions.sql b/backend/src/main/resources/db/migration/V19__add_transcription_block_versions.sql new file mode 100644 index 00000000..54df152c --- /dev/null +++ b/backend/src/main/resources/db/migration/V19__add_transcription_block_versions.sql @@ -0,0 +1,9 @@ +CREATE TABLE transcription_block_versions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + block_id UUID NOT NULL REFERENCES transcription_blocks(id) ON DELETE CASCADE, + text TEXT NOT NULL, + changed_by UUID REFERENCES app_users(id) ON DELETE SET NULL, + changed_at TIMESTAMP NOT NULL DEFAULT now() +); + +CREATE INDEX idx_tbv_block ON transcription_block_versions(block_id, changed_at DESC); diff --git a/backend/src/main/resources/db/migration/V20__add_block_id_to_comments.sql b/backend/src/main/resources/db/migration/V20__add_block_id_to_comments.sql new file mode 100644 index 00000000..025091ec --- /dev/null +++ b/backend/src/main/resources/db/migration/V20__add_block_id_to_comments.sql @@ -0,0 +1,4 @@ +ALTER TABLE document_comments + ADD COLUMN block_id UUID REFERENCES transcription_blocks(id) ON DELETE CASCADE; + +CREATE INDEX idx_dc_block ON document_comments(block_id); -- 2.49.1 From a46b1a2e844089b41509308206369f8ae1a9a234 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 5 Apr 2026 11:13:13 +0200 Subject: [PATCH 02/47] feat(transcription): add backend entities, service, and controller TranscriptionBlock entity with @Version optimistic locking TranscriptionBlockVersion for edit history TranscriptionService facade: CRUD, reorder, version history TranscriptionBlockController: REST endpoints under /api/documents/{docId}/transcription-blocks DTOs: Create, Update, Reorder ErrorCode: TRANSCRIPTION_BLOCK_NOT_FOUND, TRANSCRIPTION_BLOCK_CONFLICT DocumentComment: add block_id field for block-level comment threads Co-Authored-By: Claude Sonnet 4.6 --- .../TranscriptionBlockController.java | 102 +++++++++++++ .../dto/CreateTranscriptionBlockDTO.java | 18 +++ .../dto/ReorderTranscriptionBlocksDTO.java | 15 ++ .../dto/UpdateTranscriptionBlockDTO.java | 13 ++ .../familienarchiv/exception/ErrorCode.java | 6 + .../familienarchiv/model/DocumentComment.java | 3 + .../model/TranscriptionBlock.java | 64 +++++++++ .../model/TranscriptionBlockVersion.java | 39 +++++ .../TranscriptionBlockRepository.java | 17 +++ .../TranscriptionBlockVersionRepository.java | 12 ++ .../service/TranscriptionService.java | 134 ++++++++++++++++++ 11 files changed, 423 insertions(+) create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/controller/TranscriptionBlockController.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/dto/CreateTranscriptionBlockDTO.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/dto/ReorderTranscriptionBlocksDTO.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/dto/UpdateTranscriptionBlockDTO.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/model/TranscriptionBlock.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/model/TranscriptionBlockVersion.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/repository/TranscriptionBlockRepository.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/repository/TranscriptionBlockVersionRepository.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/service/TranscriptionService.java diff --git a/backend/src/main/java/org/raddatz/familienarchiv/controller/TranscriptionBlockController.java b/backend/src/main/java/org/raddatz/familienarchiv/controller/TranscriptionBlockController.java new file mode 100644 index 00000000..a7af973d --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/TranscriptionBlockController.java @@ -0,0 +1,102 @@ +package org.raddatz.familienarchiv.controller; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.raddatz.familienarchiv.dto.CreateTranscriptionBlockDTO; +import org.raddatz.familienarchiv.dto.ReorderTranscriptionBlocksDTO; +import org.raddatz.familienarchiv.dto.UpdateTranscriptionBlockDTO; +import org.raddatz.familienarchiv.model.AppUser; +import org.raddatz.familienarchiv.model.TranscriptionBlock; +import org.raddatz.familienarchiv.model.TranscriptionBlockVersion; +import org.raddatz.familienarchiv.security.Permission; +import org.raddatz.familienarchiv.security.RequirePermission; +import org.raddatz.familienarchiv.service.TranscriptionService; +import org.raddatz.familienarchiv.service.UserService; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.UUID; + +@RestController +@RequestMapping("/api/documents/{documentId}/transcription-blocks") +@RequiredArgsConstructor +@Slf4j +public class TranscriptionBlockController { + + private final TranscriptionService transcriptionService; + private final UserService userService; + + @GetMapping + @RequirePermission(Permission.READ_ALL) + public List listBlocks(@PathVariable UUID documentId) { + return transcriptionService.listBlocks(documentId); + } + + @GetMapping("/{blockId}") + @RequirePermission(Permission.READ_ALL) + public TranscriptionBlock getBlock(@PathVariable UUID documentId, @PathVariable UUID blockId) { + return transcriptionService.getBlock(documentId, blockId); + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + @RequirePermission(Permission.WRITE_ALL) + public TranscriptionBlock createBlock( + @PathVariable UUID documentId, + @RequestBody CreateTranscriptionBlockDTO dto, + Authentication authentication) { + UUID userId = resolveUserId(authentication); + return transcriptionService.createBlock(documentId, dto, userId); + } + + @PutMapping("/{blockId}") + @RequirePermission(Permission.WRITE_ALL) + public TranscriptionBlock updateBlock( + @PathVariable UUID documentId, + @PathVariable UUID blockId, + @RequestBody UpdateTranscriptionBlockDTO dto, + Authentication authentication) { + UUID userId = resolveUserId(authentication); + return transcriptionService.updateBlock(documentId, blockId, dto, userId); + } + + @DeleteMapping("/{blockId}") + @ResponseStatus(HttpStatus.NO_CONTENT) + @RequirePermission(Permission.WRITE_ALL) + public void deleteBlock( + @PathVariable UUID documentId, + @PathVariable UUID blockId, + Authentication authentication) { + UUID userId = resolveUserId(authentication); + transcriptionService.deleteBlock(documentId, blockId, userId); + } + + @PutMapping("/reorder") + @RequirePermission(Permission.WRITE_ALL) + public List reorderBlocks( + @PathVariable UUID documentId, + @RequestBody ReorderTranscriptionBlocksDTO dto) { + return transcriptionService.reorderBlocks(documentId, dto); + } + + @GetMapping("/{blockId}/history") + @RequirePermission(Permission.READ_ALL) + public List getBlockHistory( + @PathVariable UUID documentId, + @PathVariable UUID blockId) { + return transcriptionService.getBlockHistory(documentId, blockId); + } + + private UUID resolveUserId(Authentication authentication) { + if (authentication == null || !authentication.isAuthenticated()) return null; + try { + AppUser user = userService.findByUsername(authentication.getName()); + return user != null ? user.getId() : null; + } catch (Exception e) { + log.warn("Could not resolve user for transcription: {}", e.getMessage()); + return null; + } + } +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/CreateTranscriptionBlockDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/CreateTranscriptionBlockDTO.java new file mode 100644 index 00000000..e1d932c0 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/CreateTranscriptionBlockDTO.java @@ -0,0 +1,18 @@ +package org.raddatz.familienarchiv.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class CreateTranscriptionBlockDTO { + private int pageNumber; + private double x; + private double y; + private double width; + private double height; + private String text; + private String label; +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/ReorderTranscriptionBlocksDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/ReorderTranscriptionBlocksDTO.java new file mode 100644 index 00000000..7a7e2efb --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/ReorderTranscriptionBlocksDTO.java @@ -0,0 +1,15 @@ +package org.raddatz.familienarchiv.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; +import java.util.UUID; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ReorderTranscriptionBlocksDTO { + private List blockIds; +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/UpdateTranscriptionBlockDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/UpdateTranscriptionBlockDTO.java new file mode 100644 index 00000000..f0577e6f --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/UpdateTranscriptionBlockDTO.java @@ -0,0 +1,13 @@ +package org.raddatz.familienarchiv.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class UpdateTranscriptionBlockDTO { + private String text; + private String label; +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java b/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java index e40b122c..5952dba5 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java @@ -50,6 +50,12 @@ public enum ErrorCode { /** The new annotation overlaps an existing one on the same page. 409 */ ANNOTATION_OVERLAP, + // --- Transcription Blocks --- + /** The transcription block with the given ID does not exist. 404 */ + TRANSCRIPTION_BLOCK_NOT_FOUND, + /** Optimistic locking conflict — block was modified by another user. 409 */ + TRANSCRIPTION_BLOCK_CONFLICT, + // --- Comments --- /** The comment with the given ID does not exist. 404 */ COMMENT_NOT_FOUND, diff --git a/backend/src/main/java/org/raddatz/familienarchiv/model/DocumentComment.java b/backend/src/main/java/org/raddatz/familienarchiv/model/DocumentComment.java index 26294bb8..d64941ae 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/model/DocumentComment.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/model/DocumentComment.java @@ -33,6 +33,9 @@ public class DocumentComment { @Column(name = "annotation_id") private UUID annotationId; + @Column(name = "block_id") + private UUID blockId; + @Column(name = "parent_id") private UUID parentId; diff --git a/backend/src/main/java/org/raddatz/familienarchiv/model/TranscriptionBlock.java b/backend/src/main/java/org/raddatz/familienarchiv/model/TranscriptionBlock.java new file mode 100644 index 00000000..6f1e008e --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/model/TranscriptionBlock.java @@ -0,0 +1,64 @@ +package org.raddatz.familienarchiv.model; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Entity +@Table(name = "transcription_blocks") +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class TranscriptionBlock { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private UUID id; + + @Column(name = "annotation_id", nullable = false) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private UUID annotationId; + + @Column(name = "document_id", nullable = false) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private UUID documentId; + + @Column(nullable = false, columnDefinition = "TEXT") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private String text; + + @Column(length = 200) + private String label; + + @Column(name = "sort_order", nullable = false) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private int sortOrder; + + @Version + @Column(nullable = false) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private int version; + + @Column(name = "created_by") + private UUID createdBy; + + @Column(name = "updated_by") + private UUID updatedBy; + + @Column(name = "created_at", nullable = false, updatable = false) + @CreationTimestamp + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + @UpdateTimestamp + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime updatedAt; +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/model/TranscriptionBlockVersion.java b/backend/src/main/java/org/raddatz/familienarchiv/model/TranscriptionBlockVersion.java new file mode 100644 index 00000000..9a923e04 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/model/TranscriptionBlockVersion.java @@ -0,0 +1,39 @@ +package org.raddatz.familienarchiv.model; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.CreationTimestamp; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Entity +@Table(name = "transcription_block_versions") +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class TranscriptionBlockVersion { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private UUID id; + + @Column(name = "block_id", nullable = false) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private UUID blockId; + + @Column(nullable = false, columnDefinition = "TEXT") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private String text; + + @Column(name = "changed_by") + private UUID changedBy; + + @Column(name = "changed_at", nullable = false, updatable = false) + @CreationTimestamp + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime changedAt; +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/repository/TranscriptionBlockRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/repository/TranscriptionBlockRepository.java new file mode 100644 index 00000000..2e6c3365 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/repository/TranscriptionBlockRepository.java @@ -0,0 +1,17 @@ +package org.raddatz.familienarchiv.repository; + +import org.raddatz.familienarchiv.model.TranscriptionBlock; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface TranscriptionBlockRepository extends JpaRepository { + + List findByDocumentIdOrderBySortOrderAsc(UUID documentId); + + Optional findByIdAndDocumentId(UUID id, UUID documentId); + + int countByDocumentId(UUID documentId); +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/repository/TranscriptionBlockVersionRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/repository/TranscriptionBlockVersionRepository.java new file mode 100644 index 00000000..b4d8399b --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/repository/TranscriptionBlockVersionRepository.java @@ -0,0 +1,12 @@ +package org.raddatz.familienarchiv.repository; + +import org.raddatz.familienarchiv.model.TranscriptionBlockVersion; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.UUID; + +public interface TranscriptionBlockVersionRepository extends JpaRepository { + + List findByBlockIdOrderByChangedAtDesc(UUID blockId); +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/TranscriptionService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/TranscriptionService.java new file mode 100644 index 00000000..37440cc2 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/TranscriptionService.java @@ -0,0 +1,134 @@ +package org.raddatz.familienarchiv.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.raddatz.familienarchiv.dto.CreateAnnotationDTO; +import org.raddatz.familienarchiv.dto.CreateTranscriptionBlockDTO; +import org.raddatz.familienarchiv.dto.ReorderTranscriptionBlocksDTO; +import org.raddatz.familienarchiv.dto.UpdateTranscriptionBlockDTO; +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.model.TranscriptionBlock; +import org.raddatz.familienarchiv.model.TranscriptionBlockVersion; +import org.raddatz.familienarchiv.repository.TranscriptionBlockRepository; +import org.raddatz.familienarchiv.repository.TranscriptionBlockVersionRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +@Slf4j +public class TranscriptionService { + + private static final String TRANSCRIPTION_COLOR = "#00C7B1"; + private static final int MAX_TEXT_LENGTH = 10_000; + + private final TranscriptionBlockRepository blockRepository; + private final TranscriptionBlockVersionRepository versionRepository; + private final AnnotationService annotationService; + private final DocumentService documentService; + + public List listBlocks(UUID documentId) { + return blockRepository.findByDocumentIdOrderBySortOrderAsc(documentId); + } + + public TranscriptionBlock getBlock(UUID documentId, UUID blockId) { + return blockRepository.findByIdAndDocumentId(blockId, documentId) + .orElseThrow(() -> DomainException.notFound( + ErrorCode.TRANSCRIPTION_BLOCK_NOT_FOUND, + "Transcription block not found: " + blockId)); + } + + @Transactional + public TranscriptionBlock createBlock(UUID documentId, CreateTranscriptionBlockDTO dto, UUID userId) { + Document doc = documentService.getDocumentById(documentId); + + CreateAnnotationDTO annotationDTO = new CreateAnnotationDTO( + dto.getPageNumber(), dto.getX(), dto.getY(), + dto.getWidth(), dto.getHeight(), TRANSCRIPTION_COLOR); + DocumentAnnotation annotation = annotationService.createAnnotation( + documentId, annotationDTO, userId, doc.getFileHash()); + + int nextOrder = blockRepository.countByDocumentId(documentId); + String text = sanitizeText(dto.getText()); + + TranscriptionBlock block = TranscriptionBlock.builder() + .annotationId(annotation.getId()) + .documentId(documentId) + .text(text) + .label(dto.getLabel()) + .sortOrder(nextOrder) + .createdBy(userId) + .updatedBy(userId) + .build(); + + TranscriptionBlock saved = blockRepository.save(block); + saveVersion(saved, userId); + return saved; + } + + @Transactional + public TranscriptionBlock updateBlock(UUID documentId, UUID blockId, + UpdateTranscriptionBlockDTO dto, UUID userId) { + TranscriptionBlock block = getBlock(documentId, blockId); + + String text = sanitizeText(dto.getText()); + block.setText(text); + if (dto.getLabel() != null) { + block.setLabel(dto.getLabel()); + } + block.setUpdatedBy(userId); + + TranscriptionBlock saved = blockRepository.save(block); + saveVersion(saved, userId); + return saved; + } + + @Transactional + public void deleteBlock(UUID documentId, UUID blockId, UUID userId) { + TranscriptionBlock block = getBlock(documentId, blockId); + // CASCADE deletes annotation, versions, and comments via DB constraints + blockRepository.delete(block); + annotationService.deleteAnnotation(documentId, block.getAnnotationId(), userId); + } + + @Transactional + public List reorderBlocks(UUID documentId, ReorderTranscriptionBlocksDTO dto) { + List blockIds = dto.getBlockIds(); + for (int i = 0; i < blockIds.size(); i++) { + TranscriptionBlock block = getBlock(documentId, blockIds.get(i)); + block.setSortOrder(i); + blockRepository.save(block); + } + return blockRepository.findByDocumentIdOrderBySortOrderAsc(documentId); + } + + public List getBlockHistory(UUID documentId, UUID blockId) { + getBlock(documentId, blockId); + return versionRepository.findByBlockIdOrderByChangedAtDesc(blockId); + } + + private void saveVersion(TranscriptionBlock block, UUID userId) { + TranscriptionBlockVersion version = TranscriptionBlockVersion.builder() + .blockId(block.getId()) + .text(block.getText()) + .changedBy(userId) + .build(); + versionRepository.save(version); + } + + private String sanitizeText(String text) { + if (text == null) return ""; + // Strip any HTML tags — textarea content should be plain text only + String cleaned = text.replaceAll("<[^>]*>", ""); + if (cleaned.length() > MAX_TEXT_LENGTH) { + cleaned = cleaned.substring(0, MAX_TEXT_LENGTH); + } + return cleaned; + } +} -- 2.49.1 From 234f83c40bd6458641b2f76b04a62859a47d78bd Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 5 Apr 2026 11:16:22 +0200 Subject: [PATCH 03/47] feat(i18n): add translation keys for metadata drawer and transcription Keys for #175: doc_details_toggle, section headings, field labels, empty states Keys for #176: transcription mode, block editing, save states, comments, drawing hints Error codes: TRANSCRIPTION_BLOCK_NOT_FOUND, TRANSCRIPTION_BLOCK_CONFLICT All three languages: de, en, es Co-Authored-By: Claude Sonnet 4.6 --- frontend/.prettierignore | 1 + frontend/messages/de.json | 30 +++++++++++++++++++++++++++++- frontend/messages/en.json | 30 +++++++++++++++++++++++++++++- frontend/messages/es.json | 30 +++++++++++++++++++++++++++++- frontend/src/lib/errors.ts | 6 ++++++ 5 files changed, 94 insertions(+), 3 deletions(-) diff --git a/frontend/.prettierignore b/frontend/.prettierignore index 852473db..4a03d881 100644 --- a/frontend/.prettierignore +++ b/frontend/.prettierignore @@ -17,6 +17,7 @@ bun.lockb /src/lib/generated/ /src/lib/paraglide/ /src/lib/paraglide_bak*/ +/src/paraglide/ # Test artifacts /test-results/ diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 53fd4114..72b07b37 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -423,5 +423,33 @@ "notification_empty_history_body": "Hier erscheinen Erwähnungen und Antworten auf deine Kommentare.", "notification_row_aria": "{actor} {type} auf \u201e{title}\u201c \u2014 {time} \u2014 {readState}", "notification_read_state_read": "gelesen", - "notification_read_state_unread": "ungelesen" + "notification_read_state_unread": "ungelesen", + "error_transcription_block_not_found": "Der Transkriptionsblock wurde nicht gefunden.", + "error_transcription_block_conflict": "Der Block wurde zwischenzeitlich von jemand anderem geändert. Bitte laden Sie die Seite neu.", + "doc_details_toggle": "Details", + "doc_details_section_details": "Details", + "doc_details_section_persons": "Personen", + "doc_details_section_tags": "Schlagwörter", + "doc_details_field_date": "Datum", + "doc_details_field_sender": "Absender", + "doc_details_field_receivers": "Empfänger", + "doc_details_field_status": "Status", + "doc_details_no_persons": "Keine Personen zugeordnet", + "doc_details_no_tags": "Keine Schlagwörter zugeordnet", + "doc_details_more_receivers": "+{count} weitere", + "transcription_mode_label": "Transkribieren", + "transcription_mode_stop": "Fertig", + "transcription_block_placeholder": "Text hier eingeben...", + "transcription_block_save_saving": "Speichere...", + "transcription_block_save_saved": "Gespeichert", + "transcription_block_save_error": "Nicht gespeichert", + "transcription_block_save_retry": "Erneut versuchen", + "transcription_block_comment_btn": "Kommentieren", + "transcription_block_quote_hint": "Text markieren für Zitat", + "transcription_block_delete_confirm": "Block und alle zugehörigen Kommentare wirklich löschen?", + "transcription_block_history_btn": "Verlauf", + "transcription_empty_cta": "Markiere einen Bereich auf dem Scan, um mit der Transkription zu beginnen", + "transcription_draw_tooltip": "Klicken und ziehen, um einen Textbereich zu markieren", + "transcription_quote_stale": "Zitat aus älterer Version", + "transcription_block_conflict": "Dieser Block wurde von jemand anderem geändert — bitte neu laden" } diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 3ad3cfdb..e9780c21 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -423,5 +423,33 @@ "notification_empty_history_body": "Mentions and replies to your comments will appear here.", "notification_row_aria": "{actor} {type} on \"{title}\" — {time} — {readState}", "notification_read_state_read": "read", - "notification_read_state_unread": "unread" + "notification_read_state_unread": "unread", + "error_transcription_block_not_found": "Transcription block not found.", + "error_transcription_block_conflict": "This block was modified by someone else. Please reload the page.", + "doc_details_toggle": "Details", + "doc_details_section_details": "Details", + "doc_details_section_persons": "Persons", + "doc_details_section_tags": "Tags", + "doc_details_field_date": "Date", + "doc_details_field_sender": "Sender", + "doc_details_field_receivers": "Receivers", + "doc_details_field_status": "Status", + "doc_details_no_persons": "No persons assigned", + "doc_details_no_tags": "No tags assigned", + "doc_details_more_receivers": "+{count} more", + "transcription_mode_label": "Transcribe", + "transcription_mode_stop": "Done", + "transcription_block_placeholder": "Type text here...", + "transcription_block_save_saving": "Saving...", + "transcription_block_save_saved": "Saved", + "transcription_block_save_error": "Not saved", + "transcription_block_save_retry": "Retry", + "transcription_block_comment_btn": "Comment", + "transcription_block_quote_hint": "Select text to quote", + "transcription_block_delete_confirm": "Really delete this block and all its comments?", + "transcription_block_history_btn": "History", + "transcription_empty_cta": "Mark a region on the scan to start transcribing", + "transcription_draw_tooltip": "Click and drag to mark a text region", + "transcription_quote_stale": "Quote from an older version", + "transcription_block_conflict": "This block was changed by someone else — please reload" } diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 8f8110c2..b199fb3e 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -423,5 +423,33 @@ "notification_empty_history_body": "Aquí aparecerán las menciones y respuestas a tus comentarios.", "notification_row_aria": "{actor} {type} en \"{title}\" — {time} — {readState}", "notification_read_state_read": "leído", - "notification_read_state_unread": "no leído" + "notification_read_state_unread": "no leído", + "error_transcription_block_not_found": "Bloque de transcripción no encontrado.", + "error_transcription_block_conflict": "Este bloque fue modificado por otra persona. Por favor, recargue la página.", + "doc_details_toggle": "Detalles", + "doc_details_section_details": "Detalles", + "doc_details_section_persons": "Personas", + "doc_details_section_tags": "Etiquetas", + "doc_details_field_date": "Fecha", + "doc_details_field_sender": "Remitente", + "doc_details_field_receivers": "Destinatarios", + "doc_details_field_status": "Estado", + "doc_details_no_persons": "No hay personas asignadas", + "doc_details_no_tags": "No hay etiquetas asignadas", + "doc_details_more_receivers": "+{count} más", + "transcription_mode_label": "Transcribir", + "transcription_mode_stop": "Listo", + "transcription_block_placeholder": "Escriba el texto aquí...", + "transcription_block_save_saving": "Guardando...", + "transcription_block_save_saved": "Guardado", + "transcription_block_save_error": "No guardado", + "transcription_block_save_retry": "Reintentar", + "transcription_block_comment_btn": "Comentar", + "transcription_block_quote_hint": "Seleccione texto para citar", + "transcription_block_delete_confirm": "¿Realmente eliminar este bloque y todos sus comentarios?", + "transcription_block_history_btn": "Historial", + "transcription_empty_cta": "Marque una región en el escaneo para comenzar a transcribir", + "transcription_draw_tooltip": "Haga clic y arrastre para marcar una región de texto", + "transcription_quote_stale": "Cita de una versión anterior", + "transcription_block_conflict": "Este bloque fue cambiado por otra persona — por favor recargue" } diff --git a/frontend/src/lib/errors.ts b/frontend/src/lib/errors.ts index d5964198..eaa402ed 100644 --- a/frontend/src/lib/errors.ts +++ b/frontend/src/lib/errors.ts @@ -18,6 +18,8 @@ export type ErrorCode = | 'INVALID_RESET_TOKEN' | 'ANNOTATION_NOT_FOUND' | 'ANNOTATION_OVERLAP' + | 'TRANSCRIPTION_BLOCK_NOT_FOUND' + | 'TRANSCRIPTION_BLOCK_CONFLICT' | 'COMMENT_NOT_FOUND' | 'UNAUTHORIZED' | 'FORBIDDEN' @@ -74,6 +76,10 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string { return m.error_annotation_not_found(); case 'ANNOTATION_OVERLAP': return m.error_annotation_overlap(); + case 'TRANSCRIPTION_BLOCK_NOT_FOUND': + return m.error_transcription_block_not_found(); + case 'TRANSCRIPTION_BLOCK_CONFLICT': + return m.error_transcription_block_conflict(); case 'COMMENT_NOT_FOUND': return m.error_comment_not_found(); case 'UNAUTHORIZED': -- 2.49.1 From 5211e0b9f707aa111fc17b83d3ed9e5879c2e4bf Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 5 Apr 2026 11:22:38 +0200 Subject: [PATCH 04/47] feat(topbar): add expandable metadata drawer with Details toggle (#175) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DocumentMetadataDrawer: 3-column grid (≥1024px), single-column mobile Shows document date, location, status, person cards, tag chips Person names link to /persons/{id}, tags link to filtered search Empty states for missing persons/tags, receiver cap with expand button - DocumentTopBar: "Details" toggle button with animated SVG chevron 44×44px tap target, aria-expanded, Svelte slide transition Semantic color tokens for dark mode compatibility - Remove DocumentBottomPanel from document detail page Bottom panel replaced by topbar drawer for metadata access Simplify +page.server.ts (remove comments loading) Update page.server.spec.ts for new load signature Co-Authored-By: Claude Sonnet 4.6 --- .../DocumentBottomPanel.svelte.spec.ts | 47 ------ .../components/DocumentMetadataDrawer.svelte | 146 ++++++++++++++++++ .../src/lib/components/DocumentTopBar.svelte | 43 ++++++ .../src/routes/documents/[id]/+page.server.ts | 18 +-- .../src/routes/documents/[id]/+page.svelte | 39 +---- .../routes/documents/[id]/page.server.spec.ts | 56 +------ 6 files changed, 197 insertions(+), 152 deletions(-) delete mode 100644 frontend/src/lib/components/DocumentBottomPanel.svelte.spec.ts create mode 100644 frontend/src/lib/components/DocumentMetadataDrawer.svelte diff --git a/frontend/src/lib/components/DocumentBottomPanel.svelte.spec.ts b/frontend/src/lib/components/DocumentBottomPanel.svelte.spec.ts deleted file mode 100644 index 975d36e6..00000000 --- a/frontend/src/lib/components/DocumentBottomPanel.svelte.spec.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { describe, it, expect, afterEach } from 'vitest'; -import { cleanup, render } from 'vitest-browser-svelte'; -import { page } from 'vitest/browser'; -import DocumentBottomPanel from './DocumentBottomPanel.svelte'; -import type { Comment } from '$lib/types'; - -afterEach(cleanup); - -function makeComment(id: string): Comment { - return { - id, - authorId: 'user-1', - authorName: 'Alice', - content: 'Hello', - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - replies: [] - }; -} - -const doc = { id: 'doc-1', title: 'Test' }; - -const baseProps = { - doc, - canComment: true, - currentUserId: 'user-1', - canAdmin: false, - height: 300, - activeTab: 'discussion' as const -}; - -describe('DocumentBottomPanel – discussion badge', () => { - it('always shows a badge on the Discussion tab', async () => { - render(DocumentBottomPanel, { ...baseProps, comments: [], open: true }); - await expect.element(page.getByTestId('discussion-count-badge')).toBeInTheDocument(); - await expect.element(page.getByTestId('discussion-count-badge')).toHaveTextContent('0'); - }); - - it('shows the correct count when comments exist', async () => { - render(DocumentBottomPanel, { - ...baseProps, - comments: [makeComment('c-1'), makeComment('c-2')], - open: true - }); - await expect.element(page.getByTestId('discussion-count-badge')).toHaveTextContent('2'); - }); -}); diff --git a/frontend/src/lib/components/DocumentMetadataDrawer.svelte b/frontend/src/lib/components/DocumentMetadataDrawer.svelte new file mode 100644 index 00000000..22e8b13a --- /dev/null +++ b/frontend/src/lib/components/DocumentMetadataDrawer.svelte @@ -0,0 +1,146 @@ + + +{#snippet personCard(person: Person)} + + + {getFullName(person)} + +{/snippet} + +
+
+ +
+

+ {m.doc_details_section_details()} +

+
+
+
{m.doc_details_field_date()}
+
{formattedDate}
+
+
+
{m.form_label_location()}
+
{displayLocation}
+
+
+
{m.doc_details_field_status()}
+
{statusLabel}
+
+
+
+ + +
+

+ {m.doc_details_section_persons()} +

+ {#if hasPersons} +
+ {#if sender} +
+

+ {m.doc_details_field_sender()} +

+ {@render personCard(sender)} +
+ {/if} + {#if receivers.length > 0} +
+

+ {m.doc_details_field_receivers()} +

+
+ {#each displayedReceivers as receiver (receiver.id)} + {@render personCard(receiver)} + {/each} +
+ {#if hiddenReceiverCount > 0 && !showAllReceivers} + + {/if} +
+ {/if} +
+ {:else} +

{m.doc_details_no_persons()}

+ {/if} +
+ + +
+

+ {m.doc_details_section_tags()} +

+ {#if hasTags} +
+ {#each tags as tag (tag.id)} + + {tag.name} + + {/each} +
+ {:else} +

{m.doc_details_no_tags()}

+ {/if} +
+
+
diff --git a/frontend/src/lib/components/DocumentTopBar.svelte b/frontend/src/lib/components/DocumentTopBar.svelte index 9c1bd419..5f63473d 100644 --- a/frontend/src/lib/components/DocumentTopBar.svelte +++ b/frontend/src/lib/components/DocumentTopBar.svelte @@ -1,12 +1,15 @@ + +
+
+ +
+ + {blockNumber} + + {#if label} + + {label} + + {/if} +
+ + + + + +
+
+ + {#if active} + + {m.transcription_block_quote_hint()} + + {/if} +
+ +
+ + {#if saveState === 'saving'} + + {m.transcription_block_save_saving()} + + {:else if saveState === 'saved'} + + {m.transcription_block_save_saved()} + + {:else if saveState === 'error'} + + {m.transcription_block_save_error()} + + + + {/if} + + + +
+
+
+
diff --git a/frontend/src/lib/components/TranscriptionEditView.svelte b/frontend/src/lib/components/TranscriptionEditView.svelte new file mode 100644 index 00000000..34067c61 --- /dev/null +++ b/frontend/src/lib/components/TranscriptionEditView.svelte @@ -0,0 +1,183 @@ + + +
+ {#if hasBlocks} +
+ {#each sortedBlocks as block, i (block.id)} +
+ handleTextChange(block.id, text)} + onFocus={() => handleFocus(block.id)} + onCommentClick={handleCommentClick} + onDeleteClick={() => handleDelete(block.id)} + onRetry={() => handleRetry(block.id)} + /> +
+ {/each} +
+ {:else} +
+ + + +

+ {m.transcription_empty_cta()} +

+
+ {/if} +
diff --git a/frontend/src/routes/documents/[id]/+page.svelte b/frontend/src/routes/documents/[id]/+page.svelte index dd51e27a..543c7178 100644 --- a/frontend/src/routes/documents/[id]/+page.svelte +++ b/frontend/src/routes/documents/[id]/+page.svelte @@ -4,6 +4,7 @@ import { page } from '$app/state'; import DocumentTopBar from '$lib/components/DocumentTopBar.svelte'; import DocumentViewer from '$lib/components/DocumentViewer.svelte'; import AnnotationSidePanel from '$lib/components/AnnotationSidePanel.svelte'; +import TranscriptionEditView from '$lib/components/TranscriptionEditView.svelte'; let { data } = $props(); @@ -11,6 +12,7 @@ const targetCommentId = $derived(page.url.searchParams.get('commentId')); const targetAnnotationId = $derived(page.url.searchParams.get('annotationId')); const doc = $derived(data.document); +const canWrite = $derived(data.canWrite ?? false); const canComment = $derived((data.canAnnotate || data.canWrite) ?? false); const canAdmin = $derived( (data.user?.groups as Array<{ permissions: string[] }> | undefined)?.some((g) => @@ -54,12 +56,79 @@ async function loadFile(id: string) { } } -// ── Annotation state (lifted from PdfViewer) ────────────────────────────────── +// ── Mode state (mutually exclusive) ────────────────────────────────────────── let annotateMode = $state(false); +let transcribeMode = $state(false); let activeAnnotationId = $state(null); let activeAnnotationPage = $state(null); +// Mode exclusivity: entering one mode exits the other +$effect(() => { + if (annotateMode && transcribeMode) { + transcribeMode = false; + } +}); + +// ── Transcription blocks ───────────────────────────────────────────────────── + +type TranscriptionBlockData = { + id: string; + annotationId: string; + documentId: string; + text: string; + label: string | null; + sortOrder: number; + version: number; +}; + +let transcriptionBlocks = $state([]); + +async function loadTranscriptionBlocks() { + if (!doc?.id) return; + try { + const res = await fetch(`/api/documents/${doc.id}/transcription-blocks`); + if (res.ok) { + transcriptionBlocks = await res.json(); + } + } catch (e) { + console.error('Failed to load transcription blocks:', e); + } +} + +async function saveBlock(blockId: string, text: string) { + const res = await fetch(`/api/documents/${doc.id}/transcription-blocks/${blockId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text }) + }); + if (!res.ok) throw new Error('Save failed'); + const updated = await res.json(); + transcriptionBlocks = transcriptionBlocks.map((b) => (b.id === blockId ? updated : b)); +} + +async function deleteBlock(blockId: string) { + const res = await fetch(`/api/documents/${doc.id}/transcription-blocks/${blockId}`, { + method: 'DELETE' + }); + if (!res.ok) throw new Error('Delete failed'); + transcriptionBlocks = transcriptionBlocks.filter((b) => b.id !== blockId); +} + +function handleBlockFocus(blockId: string) { + const block = transcriptionBlocks.find((b) => b.id === blockId); + if (block) { + activeAnnotationId = block.annotationId; + } +} + +// Load blocks when transcribe mode is entered +$effect(() => { + if (transcribeMode) { + loadTranscriptionBlocks(); + } +}); + // ── Navigation / init ───────────────────────────────────────────────────────── let navHeight = $state(0); @@ -80,7 +149,9 @@ onMount(() => { function onKeyDown(e: KeyboardEvent) { if (e.key === 'Escape') { - if (activeAnnotationId) { + if (transcribeMode) { + transcribeMode = false; + } else if (activeAnnotationId) { activeAnnotationId = null; activeAnnotationPage = null; } @@ -102,37 +173,54 @@ onMount(() => { > -
- { - activeAnnotationId = id; - }} - /> - { - activeAnnotationId = null; - activeAnnotationPage = null; - }} - /> +
+
+ { + activeAnnotationId = id; + }} + /> +
+ + {#if !transcribeMode} + { + activeAnnotationId = null; + activeAnnotationPage = null; + }} + /> + {/if} + + {#if transcribeMode} +
+ +
+ {/if}
-- 2.49.1 From 6463a32dfcec47e41f1211db00f12a945aa90578 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 5 Apr 2026 11:43:35 +0200 Subject: [PATCH 06/47] =?UTF-8?q?fix:=20address=20PR=20review=20feedback?= =?UTF-8?q?=20=E2=80=94=20security,=20architecture,=20dead=20code?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes from PR #178 review: Migration fixes: - V18/V19: fix FK references from app_users to users (correct table name) - V18: change annotation_id FK from ON DELETE CASCADE to ON DELETE RESTRICT (block is aggregate root, cascade flows from block, not annotation) Backend fixes: - TranscriptionService.deleteBlock(): remove userId param, delete block first then annotation directly via repository (no ownership check — block owns annotation) - TranscriptionService.sanitizeText(): remove flawed regex HTML stripping, textarea content is plain text by design — just enforce max length - TranscriptionBlockController.requireUserId(): throw DomainException.unauthorized() instead of silently returning null on auth failure - CreateTranscriptionBlockDTO: add @Min/@Positive validation on coordinates - Add @Slf4j logging to TranscriptionService for create/delete operations Frontend fixes: - Delete DocumentBottomPanel.svelte entirely (issue #175 requirement) - Remove redundant mode exclusivity $effect (handled at toggle call sites) - Remove dead handleCommentClick + onCommentClick prop (comments are future work) - Remove quote hint UI (depends on comment feature) Co-Authored-By: Claude Sonnet 4.6 --- .../TranscriptionBlockController.java | 27 ++- .../dto/CreateTranscriptionBlockDTO.java | 7 + .../service/TranscriptionService.java | 25 ++- .../V18__add_transcription_blocks.sql | 6 +- .../V19__add_transcription_block_versions.sql | 2 +- .../lib/components/DocumentBottomPanel.svelte | 193 ------------------ .../lib/components/TranscriptionBlock.svelte | 19 +- .../components/TranscriptionEditView.svelte | 5 - .../src/routes/documents/[id]/+page.svelte | 7 - 9 files changed, 41 insertions(+), 250 deletions(-) delete mode 100644 frontend/src/lib/components/DocumentBottomPanel.svelte diff --git a/backend/src/main/java/org/raddatz/familienarchiv/controller/TranscriptionBlockController.java b/backend/src/main/java/org/raddatz/familienarchiv/controller/TranscriptionBlockController.java index a7af973d..ed622826 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/controller/TranscriptionBlockController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/TranscriptionBlockController.java @@ -5,6 +5,7 @@ import lombok.extern.slf4j.Slf4j; import org.raddatz.familienarchiv.dto.CreateTranscriptionBlockDTO; import org.raddatz.familienarchiv.dto.ReorderTranscriptionBlocksDTO; import org.raddatz.familienarchiv.dto.UpdateTranscriptionBlockDTO; +import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.model.AppUser; import org.raddatz.familienarchiv.model.TranscriptionBlock; import org.raddatz.familienarchiv.model.TranscriptionBlockVersion; @@ -47,7 +48,7 @@ public class TranscriptionBlockController { @PathVariable UUID documentId, @RequestBody CreateTranscriptionBlockDTO dto, Authentication authentication) { - UUID userId = resolveUserId(authentication); + UUID userId = requireUserId(authentication); return transcriptionService.createBlock(documentId, dto, userId); } @@ -58,7 +59,7 @@ public class TranscriptionBlockController { @PathVariable UUID blockId, @RequestBody UpdateTranscriptionBlockDTO dto, Authentication authentication) { - UUID userId = resolveUserId(authentication); + UUID userId = requireUserId(authentication); return transcriptionService.updateBlock(documentId, blockId, dto, userId); } @@ -67,10 +68,8 @@ public class TranscriptionBlockController { @RequirePermission(Permission.WRITE_ALL) public void deleteBlock( @PathVariable UUID documentId, - @PathVariable UUID blockId, - Authentication authentication) { - UUID userId = resolveUserId(authentication); - transcriptionService.deleteBlock(documentId, blockId, userId); + @PathVariable UUID blockId) { + transcriptionService.deleteBlock(documentId, blockId); } @PutMapping("/reorder") @@ -89,14 +88,14 @@ public class TranscriptionBlockController { return transcriptionService.getBlockHistory(documentId, blockId); } - private UUID resolveUserId(Authentication authentication) { - if (authentication == null || !authentication.isAuthenticated()) return null; - try { - AppUser user = userService.findByUsername(authentication.getName()); - return user != null ? user.getId() : null; - } catch (Exception e) { - log.warn("Could not resolve user for transcription: {}", e.getMessage()); - return null; + private UUID requireUserId(Authentication authentication) { + if (authentication == null || !authentication.isAuthenticated()) { + throw DomainException.unauthorized("Authentication required"); } + AppUser user = userService.findByUsername(authentication.getName()); + if (user == null) { + throw DomainException.unauthorized("User not found"); + } + return user.getId(); } } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/CreateTranscriptionBlockDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/CreateTranscriptionBlockDTO.java index e1d932c0..90f46359 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/dto/CreateTranscriptionBlockDTO.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/CreateTranscriptionBlockDTO.java @@ -1,5 +1,7 @@ package org.raddatz.familienarchiv.dto; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Positive; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; @@ -8,10 +10,15 @@ import lombok.NoArgsConstructor; @NoArgsConstructor @AllArgsConstructor public class CreateTranscriptionBlockDTO { + @Min(0) private int pageNumber; + @Min(0) private double x; + @Min(0) private double y; + @Positive private double width; + @Positive private double height; private String text; private String label; diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/TranscriptionService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/TranscriptionService.java index 37440cc2..caf8604c 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/TranscriptionService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/TranscriptionService.java @@ -12,6 +12,7 @@ import org.raddatz.familienarchiv.model.Document; import org.raddatz.familienarchiv.model.DocumentAnnotation; import org.raddatz.familienarchiv.model.TranscriptionBlock; import org.raddatz.familienarchiv.model.TranscriptionBlockVersion; +import org.raddatz.familienarchiv.repository.AnnotationRepository; import org.raddatz.familienarchiv.repository.TranscriptionBlockRepository; import org.raddatz.familienarchiv.repository.TranscriptionBlockVersionRepository; import org.springframework.stereotype.Service; @@ -30,6 +31,7 @@ public class TranscriptionService { private final TranscriptionBlockRepository blockRepository; private final TranscriptionBlockVersionRepository versionRepository; + private final AnnotationRepository annotationRepository; private final AnnotationService annotationService; private final DocumentService documentService; @@ -69,6 +71,7 @@ public class TranscriptionService { TranscriptionBlock saved = blockRepository.save(block); saveVersion(saved, userId); + log.info("Created transcription block {} for document {}", saved.getId(), documentId); return saved; } @@ -90,11 +93,17 @@ public class TranscriptionService { } @Transactional - public void deleteBlock(UUID documentId, UUID blockId, UUID userId) { + public void deleteBlock(UUID documentId, UUID blockId) { TranscriptionBlock block = getBlock(documentId, blockId); - // CASCADE deletes annotation, versions, and comments via DB constraints + UUID annotationId = block.getAnnotationId(); + + // Block is the aggregate root — delete block first (cascades to versions + comments), + // then delete the dependent annotation directly (no ownership check needed) blockRepository.delete(block); - annotationService.deleteAnnotation(documentId, block.getAnnotationId(), userId); + blockRepository.flush(); + annotationRepository.deleteById(annotationId); + log.info("Deleted transcription block {} and annotation {} for document {}", + blockId, annotationId, documentId); } @Transactional @@ -122,13 +131,11 @@ public class TranscriptionService { versionRepository.save(version); } - private String sanitizeText(String text) { + String sanitizeText(String text) { if (text == null) return ""; - // Strip any HTML tags — textarea content should be plain text only - String cleaned = text.replaceAll("<[^>]*>", ""); - if (cleaned.length() > MAX_TEXT_LENGTH) { - cleaned = cleaned.substring(0, MAX_TEXT_LENGTH); + if (text.length() > MAX_TEXT_LENGTH) { + text = text.substring(0, MAX_TEXT_LENGTH); } - return cleaned; + return text; } } diff --git a/backend/src/main/resources/db/migration/V18__add_transcription_blocks.sql b/backend/src/main/resources/db/migration/V18__add_transcription_blocks.sql index 03e5aaf8..524a2649 100644 --- a/backend/src/main/resources/db/migration/V18__add_transcription_blocks.sql +++ b/backend/src/main/resources/db/migration/V18__add_transcription_blocks.sql @@ -1,13 +1,13 @@ CREATE TABLE transcription_blocks ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - annotation_id UUID NOT NULL REFERENCES document_annotations(id) ON DELETE CASCADE, + annotation_id UUID NOT NULL REFERENCES document_annotations(id) ON DELETE RESTRICT, document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE, text TEXT NOT NULL DEFAULT '', label VARCHAR(200), sort_order INTEGER NOT NULL DEFAULT 0, version INTEGER NOT NULL DEFAULT 0, - created_by UUID REFERENCES app_users(id) ON DELETE SET NULL, - updated_by UUID REFERENCES app_users(id) ON DELETE SET NULL, + created_by UUID REFERENCES users(id) ON DELETE SET NULL, + updated_by UUID REFERENCES users(id) ON DELETE SET NULL, created_at TIMESTAMP NOT NULL DEFAULT now(), updated_at TIMESTAMP NOT NULL DEFAULT now() ); diff --git a/backend/src/main/resources/db/migration/V19__add_transcription_block_versions.sql b/backend/src/main/resources/db/migration/V19__add_transcription_block_versions.sql index 54df152c..2c03ab2c 100644 --- a/backend/src/main/resources/db/migration/V19__add_transcription_block_versions.sql +++ b/backend/src/main/resources/db/migration/V19__add_transcription_block_versions.sql @@ -2,7 +2,7 @@ CREATE TABLE transcription_block_versions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), block_id UUID NOT NULL REFERENCES transcription_blocks(id) ON DELETE CASCADE, text TEXT NOT NULL, - changed_by UUID REFERENCES app_users(id) ON DELETE SET NULL, + changed_by UUID REFERENCES users(id) ON DELETE SET NULL, changed_at TIMESTAMP NOT NULL DEFAULT now() ); diff --git a/frontend/src/lib/components/DocumentBottomPanel.svelte b/frontend/src/lib/components/DocumentBottomPanel.svelte deleted file mode 100644 index 209bf76e..00000000 --- a/frontend/src/lib/components/DocumentBottomPanel.svelte +++ /dev/null @@ -1,193 +0,0 @@ - - -
- - - - -
- -
- {#each tabs as tab (tab.id)} - - {/each} -
- - {#if open} - - {/if} -
- - - {#if open} -
- {#if activeTab === 'metadata'} - - {:else if activeTab === 'transcription'} - - {:else if activeTab === 'discussion'} - - {:else if activeTab === 'history'} - - {/if} -
- {/if} -
diff --git a/frontend/src/lib/components/TranscriptionBlock.svelte b/frontend/src/lib/components/TranscriptionBlock.svelte index 6dfbe548..7f66eb6e 100644 --- a/frontend/src/lib/components/TranscriptionBlock.svelte +++ b/frontend/src/lib/components/TranscriptionBlock.svelte @@ -12,7 +12,6 @@ type Props = { saveState: SaveState; onTextChange: (text: string) => void; onFocus: () => void; - onCommentClick: () => void; onDeleteClick: () => void; onRetry: () => void; }; @@ -26,7 +25,6 @@ let { saveState, onTextChange, onFocus, - onCommentClick, onDeleteClick, onRetry }: Props = $props(); @@ -91,22 +89,7 @@ function handleDelete() { > -
-
- - {#if active} - - {m.transcription_block_quote_hint()} - - {/if} -
- +
{#if saveState === 'saving'} diff --git a/frontend/src/lib/components/TranscriptionEditView.svelte b/frontend/src/lib/components/TranscriptionEditView.svelte index 34067c61..77a2f742 100644 --- a/frontend/src/lib/components/TranscriptionEditView.svelte +++ b/frontend/src/lib/components/TranscriptionEditView.svelte @@ -119,10 +119,6 @@ function handleDelete(blockId: string) { onDeleteBlock(blockId); } -function handleCommentClick() { - // Placeholder for future comment functionality -} - $effect(() => { function onBeforeUnload() { flushAllPending(); @@ -153,7 +149,6 @@ $effect(() => { saveState={getSaveState(block.id)} onTextChange={(text) => handleTextChange(block.id, text)} onFocus={() => handleFocus(block.id)} - onCommentClick={handleCommentClick} onDeleteClick={() => handleDelete(block.id)} onRetry={() => handleRetry(block.id)} /> diff --git a/frontend/src/routes/documents/[id]/+page.svelte b/frontend/src/routes/documents/[id]/+page.svelte index 543c7178..c95dc4de 100644 --- a/frontend/src/routes/documents/[id]/+page.svelte +++ b/frontend/src/routes/documents/[id]/+page.svelte @@ -63,13 +63,6 @@ let transcribeMode = $state(false); let activeAnnotationId = $state(null); let activeAnnotationPage = $state(null); -// Mode exclusivity: entering one mode exits the other -$effect(() => { - if (annotateMode && transcribeMode) { - transcribeMode = false; - } -}); - // ── Transcription blocks ───────────────────────────────────────────────────── type TranscriptionBlockData = { -- 2.49.1 From 51c799e20ea03cfd2216cd0b0361e5c194249ac1 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 5 Apr 2026 11:46:16 +0200 Subject: [PATCH 07/47] test(transcription): add TranscriptionServiceTest with 13 unit tests Tests cover: getBlock (found, not found), createBlock (creates annotation + block + version), updateBlock (text + label), deleteBlock (deletes block + annotation, not found), reorderBlocks, getBlockHistory, sanitizeText (null, max length, plain text preservation), listBlocks Co-Authored-By: Claude Sonnet 4.6 --- .../service/TranscriptionServiceTest.java | 247 ++++++++++++++++++ 1 file changed, 247 insertions(+) create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/service/TranscriptionServiceTest.java diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/TranscriptionServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/TranscriptionServiceTest.java new file mode 100644 index 00000000..efc6de6a --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/TranscriptionServiceTest.java @@ -0,0 +1,247 @@ +package org.raddatz.familienarchiv.service; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.raddatz.familienarchiv.dto.CreateAnnotationDTO; +import org.raddatz.familienarchiv.dto.CreateTranscriptionBlockDTO; +import org.raddatz.familienarchiv.dto.ReorderTranscriptionBlocksDTO; +import org.raddatz.familienarchiv.dto.UpdateTranscriptionBlockDTO; +import org.raddatz.familienarchiv.exception.DomainException; +import org.raddatz.familienarchiv.model.Document; +import org.raddatz.familienarchiv.model.DocumentAnnotation; +import org.raddatz.familienarchiv.model.TranscriptionBlock; +import org.raddatz.familienarchiv.model.TranscriptionBlockVersion; +import org.raddatz.familienarchiv.repository.AnnotationRepository; +import org.raddatz.familienarchiv.repository.TranscriptionBlockRepository; +import org.raddatz.familienarchiv.repository.TranscriptionBlockVersionRepository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.http.HttpStatus.NOT_FOUND; + +@ExtendWith(MockitoExtension.class) +class TranscriptionServiceTest { + + @Mock TranscriptionBlockRepository blockRepository; + @Mock TranscriptionBlockVersionRepository versionRepository; + @Mock AnnotationRepository annotationRepository; + @Mock AnnotationService annotationService; + @Mock DocumentService documentService; + @InjectMocks TranscriptionService transcriptionService; + + // ─── getBlock ──────────────────────────────────────────────────────────────── + + @Test + void getBlock_throwsNotFound_whenBlockDoesNotExist() { + UUID docId = UUID.randomUUID(); + UUID blockId = UUID.randomUUID(); + when(blockRepository.findByIdAndDocumentId(blockId, docId)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> transcriptionService.getBlock(docId, blockId)) + .isInstanceOf(DomainException.class) + .satisfies(e -> assertThat(((DomainException) e).getStatus()).isEqualTo(NOT_FOUND)); + } + + @Test + void getBlock_returnsBlock_whenExists() { + UUID docId = UUID.randomUUID(); + UUID blockId = UUID.randomUUID(); + TranscriptionBlock block = TranscriptionBlock.builder() + .id(blockId).documentId(docId).text("hello").build(); + when(blockRepository.findByIdAndDocumentId(blockId, docId)).thenReturn(Optional.of(block)); + + TranscriptionBlock result = transcriptionService.getBlock(docId, blockId); + + assertThat(result).isEqualTo(block); + } + + // ─── createBlock ───────────────────────────────────────────────────────────── + + @Test + void createBlock_createsAnnotationAndBlockAndVersion() { + UUID docId = UUID.randomUUID(); + UUID userId = UUID.randomUUID(); + UUID annotId = UUID.randomUUID(); + + Document doc = Document.builder().id(docId).fileHash("hash123").build(); + when(documentService.getDocumentById(docId)).thenReturn(doc); + + DocumentAnnotation annotation = DocumentAnnotation.builder().id(annotId).build(); + when(annotationService.createAnnotation(eq(docId), any(CreateAnnotationDTO.class), eq(userId), eq("hash123"))) + .thenReturn(annotation); + + when(blockRepository.countByDocumentId(docId)).thenReturn(0); + when(blockRepository.save(any())).thenAnswer(inv -> { + TranscriptionBlock b = inv.getArgument(0); + b.setId(UUID.randomUUID()); + return b; + }); + + CreateTranscriptionBlockDTO dto = new CreateTranscriptionBlockDTO(1, 0.1, 0.2, 0.3, 0.4, "hello", null); + + TranscriptionBlock result = transcriptionService.createBlock(docId, dto, userId); + + assertThat(result.getAnnotationId()).isEqualTo(annotId); + assertThat(result.getText()).isEqualTo("hello"); + assertThat(result.getSortOrder()).isZero(); + assertThat(result.getCreatedBy()).isEqualTo(userId); + verify(versionRepository).save(any(TranscriptionBlockVersion.class)); + } + + // ─── updateBlock ───────────────────────────────────────────────────────────── + + @Test + void updateBlock_updatesTextAndSavesVersion() { + UUID docId = UUID.randomUUID(); + UUID blockId = UUID.randomUUID(); + UUID userId = UUID.randomUUID(); + + TranscriptionBlock block = TranscriptionBlock.builder() + .id(blockId).documentId(docId).text("old").build(); + when(blockRepository.findByIdAndDocumentId(blockId, docId)).thenReturn(Optional.of(block)); + when(blockRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + UpdateTranscriptionBlockDTO dto = new UpdateTranscriptionBlockDTO("new text", null); + + TranscriptionBlock result = transcriptionService.updateBlock(docId, blockId, dto, userId); + + assertThat(result.getText()).isEqualTo("new text"); + assertThat(result.getUpdatedBy()).isEqualTo(userId); + verify(versionRepository).save(any(TranscriptionBlockVersion.class)); + } + + @Test + void updateBlock_updatesLabel_whenProvided() { + UUID docId = UUID.randomUUID(); + UUID blockId = UUID.randomUUID(); + + TranscriptionBlock block = TranscriptionBlock.builder() + .id(blockId).documentId(docId).text("text").label("old label").build(); + when(blockRepository.findByIdAndDocumentId(blockId, docId)).thenReturn(Optional.of(block)); + when(blockRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + UpdateTranscriptionBlockDTO dto = new UpdateTranscriptionBlockDTO("text", "Anrede"); + + TranscriptionBlock result = transcriptionService.updateBlock(docId, blockId, dto, UUID.randomUUID()); + + assertThat(result.getLabel()).isEqualTo("Anrede"); + } + + // ─── deleteBlock ───────────────────────────────────────────────────────────── + + @Test + void deleteBlock_deletesBlockAndAnnotation() { + UUID docId = UUID.randomUUID(); + UUID blockId = UUID.randomUUID(); + UUID annotId = UUID.randomUUID(); + + TranscriptionBlock block = TranscriptionBlock.builder() + .id(blockId).documentId(docId).annotationId(annotId).build(); + when(blockRepository.findByIdAndDocumentId(blockId, docId)).thenReturn(Optional.of(block)); + + transcriptionService.deleteBlock(docId, blockId); + + verify(blockRepository).delete(block); + verify(blockRepository).flush(); + verify(annotationRepository).deleteById(annotId); + } + + @Test + void deleteBlock_throwsNotFound_whenBlockMissing() { + UUID docId = UUID.randomUUID(); + UUID blockId = UUID.randomUUID(); + when(blockRepository.findByIdAndDocumentId(blockId, docId)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> transcriptionService.deleteBlock(docId, blockId)) + .isInstanceOf(DomainException.class) + .satisfies(e -> assertThat(((DomainException) e).getStatus()).isEqualTo(NOT_FOUND)); + } + + // ─── reorderBlocks ─────────────────────────────────────────────────────────── + + @Test + void reorderBlocks_updatesSortOrder() { + UUID docId = UUID.randomUUID(); + UUID id1 = UUID.randomUUID(); + UUID id2 = UUID.randomUUID(); + + TranscriptionBlock block1 = TranscriptionBlock.builder() + .id(id1).documentId(docId).sortOrder(0).build(); + TranscriptionBlock block2 = TranscriptionBlock.builder() + .id(id2).documentId(docId).sortOrder(1).build(); + + when(blockRepository.findByIdAndDocumentId(id2, docId)).thenReturn(Optional.of(block2)); + when(blockRepository.findByIdAndDocumentId(id1, docId)).thenReturn(Optional.of(block1)); + when(blockRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + when(blockRepository.findByDocumentIdOrderBySortOrderAsc(docId)).thenReturn(List.of(block2, block1)); + + ReorderTranscriptionBlocksDTO dto = new ReorderTranscriptionBlocksDTO(List.of(id2, id1)); + + transcriptionService.reorderBlocks(docId, dto); + + assertThat(block2.getSortOrder()).isZero(); + assertThat(block1.getSortOrder()).isEqualTo(1); + } + + // ─── getBlockHistory ───────────────────────────────────────────────────────── + + @Test + void getBlockHistory_returnsVersionsForBlock() { + UUID docId = UUID.randomUUID(); + UUID blockId = UUID.randomUUID(); + + TranscriptionBlock block = TranscriptionBlock.builder() + .id(blockId).documentId(docId).build(); + when(blockRepository.findByIdAndDocumentId(blockId, docId)).thenReturn(Optional.of(block)); + + TranscriptionBlockVersion v = TranscriptionBlockVersion.builder() + .id(UUID.randomUUID()).blockId(blockId).text("ver1").build(); + when(versionRepository.findByBlockIdOrderByChangedAtDesc(blockId)).thenReturn(List.of(v)); + + List result = transcriptionService.getBlockHistory(docId, blockId); + + assertThat(result).containsExactly(v); + } + + // ─── sanitizeText ──────────────────────────────────────────────────────────── + + @Test + void sanitizeText_returnsEmptyString_forNull() { + assertThat(transcriptionService.sanitizeText(null)).isEmpty(); + } + + @Test + void sanitizeText_truncatesAtMaxLength() { + String longText = "a".repeat(15_000); + String result = transcriptionService.sanitizeText(longText); + assertThat(result).hasSize(10_000); + } + + @Test + void sanitizeText_preservesPlainText() { + assertThat(transcriptionService.sanitizeText("Liebe Mutter,")).isEqualTo("Liebe Mutter,"); + } + + // ─── listBlocks ────────────────────────────────────────────────────────────── + + @Test + void listBlocks_returnsBlocksOrderedBySortOrder() { + UUID docId = UUID.randomUUID(); + TranscriptionBlock b = TranscriptionBlock.builder() + .id(UUID.randomUUID()).documentId(docId).sortOrder(0).build(); + when(blockRepository.findByDocumentIdOrderBySortOrderAsc(docId)).thenReturn(List.of(b)); + + assertThat(transcriptionService.listBlocks(docId)).containsExactly(b); + } +} -- 2.49.1 From b21778b3d18c24706c3e2f7e60b5982d68a3e223 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 5 Apr 2026 20:22:35 +0200 Subject: [PATCH 08/47] refactor(types): extract TranscriptionBlockData to shared types Move duplicated type definition from TranscriptionEditView.svelte and +page.svelte into $lib/types.ts for single source of truth. Fixes @Felix: "Consider extracting the TranscriptionBlockData type" Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/components/TranscriptionEditView.svelte | 11 +---------- frontend/src/lib/types.ts | 10 ++++++++++ frontend/src/routes/documents/[id]/+page.svelte | 11 +---------- 3 files changed, 12 insertions(+), 20 deletions(-) diff --git a/frontend/src/lib/components/TranscriptionEditView.svelte b/frontend/src/lib/components/TranscriptionEditView.svelte index 77a2f742..08d2652f 100644 --- a/frontend/src/lib/components/TranscriptionEditView.svelte +++ b/frontend/src/lib/components/TranscriptionEditView.svelte @@ -2,16 +2,7 @@ import { m } from '$lib/paraglide/messages.js'; import { SvelteMap } from 'svelte/reactivity'; import TranscriptionBlock from './TranscriptionBlock.svelte'; - -type TranscriptionBlockData = { - id: string; - annotationId: string; - documentId: string; - text: string; - label: string | null; - sortOrder: number; - version: number; -}; +import type { TranscriptionBlockData } from '$lib/types'; type SaveState = 'idle' | 'saving' | 'saved' | 'error'; diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index a2144e40..490f5352 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -27,6 +27,16 @@ export type Comment = { export type DocumentPanelTab = 'metadata' | 'transcription' | 'discussion' | 'history'; +export type TranscriptionBlockData = { + id: string; + annotationId: string; + documentId: string; + text: string; + label: string | null; + sortOrder: number; + version: number; +}; + export type Annotation = { id: string; documentId: string; diff --git a/frontend/src/routes/documents/[id]/+page.svelte b/frontend/src/routes/documents/[id]/+page.svelte index c95dc4de..e71593e0 100644 --- a/frontend/src/routes/documents/[id]/+page.svelte +++ b/frontend/src/routes/documents/[id]/+page.svelte @@ -5,6 +5,7 @@ import DocumentTopBar from '$lib/components/DocumentTopBar.svelte'; import DocumentViewer from '$lib/components/DocumentViewer.svelte'; import AnnotationSidePanel from '$lib/components/AnnotationSidePanel.svelte'; import TranscriptionEditView from '$lib/components/TranscriptionEditView.svelte'; +import type { TranscriptionBlockData } from '$lib/types'; let { data } = $props(); @@ -65,16 +66,6 @@ let activeAnnotationPage = $state(null); // ── Transcription blocks ───────────────────────────────────────────────────── -type TranscriptionBlockData = { - id: string; - annotationId: string; - documentId: string; - text: string; - label: string | null; - sortOrder: number; - version: number; -}; - let transcriptionBlocks = $state([]); async function loadTranscriptionBlocks() { -- 2.49.1 From a3fbcf346b4743600567ad9c0dfa4c02c0d1ab73 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 5 Apr 2026 20:26:41 +0200 Subject: [PATCH 09/47] fix(ui): semantic turquoise tokens, badge styling, saved fade animation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add turquoise/turquoise-fg semantic color tokens to layout.css (light + dark mode), replacing all hardcoded #00C7B1 in components - Bump Details toggle from text-xs to text-sm for visual hierarchy - Block badge: navy → turquoise, overlapping top-left card border with absolute positioning to visually link PDF annotation badges - Saved indicator: smooth 300ms opacity fade before removal (new 'fading' state in SaveState type) - Transcribe buttons: use border-turquoise/bg-turquoise/text-turquoise-fg Fixes @Leonie concerns: toggle visual weight, semantic tokens, badge styling, saved fade animation Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/components/DocumentTopBar.svelte | 8 +++--- .../lib/components/TranscriptionBlock.svelte | 28 +++++++++++-------- .../components/TranscriptionEditView.svelte | 9 ++++-- frontend/src/routes/layout.css | 13 +++++++++ 4 files changed, 41 insertions(+), 17 deletions(-) diff --git a/frontend/src/lib/components/DocumentTopBar.svelte b/frontend/src/lib/components/DocumentTopBar.svelte index a58eb3bd..b7b06b89 100644 --- a/frontend/src/lib/components/DocumentTopBar.svelte +++ b/frontend/src/lib/components/DocumentTopBar.svelte @@ -111,7 +111,7 @@ let mobileMenuOpen = $state(false); aria-pressed={false} class={mobile ? 'flex w-full items-center gap-2 rounded px-3 py-2 text-left text-[16px] text-ink transition hover:bg-muted focus-visible:ring-2 focus-visible:ring-primary' - : 'hidden items-center gap-1.5 rounded border border-[#00C7B1] px-3 py-1.5 font-sans text-[16px] font-medium text-ink transition hover:bg-[#00C7B1] hover:text-white focus-visible:ring-2 focus-visible:ring-primary md:flex'} + : 'hidden items-center gap-1.5 rounded border border-turquoise px-3 py-1.5 font-sans text-[16px] font-medium text-ink transition hover:bg-turquoise hover:text-turquoise-fg focus-visible:ring-2 focus-visible:ring-primary md:flex'} > (detailsOpen = !detailsOpen)} aria-expanded={detailsOpen} aria-label={m.doc_details_toggle()} - class="ml-2 inline-flex min-h-[44px] shrink-0 items-center gap-1 rounded border px-2 py-1 font-sans text-xs font-semibold transition-colors {detailsOpen ? 'border-primary bg-primary text-primary-fg' : 'border-line text-ink-2 hover:bg-muted hover:text-ink'}" + class="ml-2 inline-flex min-h-[44px] shrink-0 items-center gap-1.5 rounded border px-3 py-1 font-sans text-sm font-semibold transition-colors {detailsOpen ? 'border-primary bg-primary text-primary-fg' : 'border-line text-ink-2 hover:bg-muted hover:text-ink'}" > {m.doc_details_toggle()} import { m } from '$lib/paraglide/messages.js'; -type SaveState = 'idle' | 'saving' | 'saved' | 'error'; +type SaveState = 'idle' | 'saving' | 'saved' | 'fading' | 'error'; type Props = { blockId: string; @@ -30,7 +30,7 @@ let { }: Props = $props(); let leftBorderClass = $derived( - saveState === 'error' ? 'border-l-2 border-error' : active ? 'border-l-2 border-[#00C7B1]' : '' + saveState === 'error' ? 'border-l-2 border-error' : active ? 'border-l-2 border-turquoise' : '' ); function autoresize(node: HTMLTextAreaElement) { @@ -61,15 +61,19 @@ function handleDelete() { } -
-
+
+ + + {blockNumber} + +
- - {blockNumber} - {#if label} {label} @@ -96,8 +100,10 @@ function handleDelete() { {m.transcription_block_save_saving()} - {:else if saveState === 'saved'} - + {:else if saveState === 'saved' || saveState === 'fading'} + {m.transcription_block_save_saved()} {:else if saveState === 'error'} diff --git a/frontend/src/lib/components/TranscriptionEditView.svelte b/frontend/src/lib/components/TranscriptionEditView.svelte index 08d2652f..f4f57c51 100644 --- a/frontend/src/lib/components/TranscriptionEditView.svelte +++ b/frontend/src/lib/components/TranscriptionEditView.svelte @@ -4,7 +4,7 @@ import { SvelteMap } from 'svelte/reactivity'; import TranscriptionBlock from './TranscriptionBlock.svelte'; import type { TranscriptionBlockData } from '$lib/types'; -type SaveState = 'idle' | 'saving' | 'saved' | 'error'; +type SaveState = 'idle' | 'saving' | 'saved' | 'fading' | 'error'; type Props = { blocks: TranscriptionBlockData[]; @@ -50,7 +50,12 @@ async function executeSave(blockId: string) { function scheduleSavedFade(blockId: string) { setTimeout(() => { if (getSaveState(blockId) === 'saved') { - setSaveState(blockId, 'idle'); + setSaveState(blockId, 'fading'); + setTimeout(() => { + if (getSaveState(blockId) === 'fading') { + setSaveState(blockId, 'idle'); + } + }, 300); } }, 2000); } diff --git a/frontend/src/routes/layout.css b/frontend/src/routes/layout.css index 4f7bfe0e..197aef4e 100644 --- a/frontend/src/routes/layout.css +++ b/frontend/src/routes/layout.css @@ -59,6 +59,10 @@ /* Header surface — independent from canvas/surface for per-mode control */ --color-header: var(--c-header); + /* Turquoise — transcription mode accent */ + --color-turquoise: var(--c-turquoise); + --color-turquoise-fg: var(--c-turquoise-fg); + /* Focus ring — keyboard focus indicator, mode-aware (navy in light, mint in dark) */ --color-focus-ring: var(--c-focus-ring); @@ -93,6 +97,9 @@ /* Header is brand-navy in light mode; same in dark mode for contrast compliance */ --c-header: #012851; + --c-turquoise: #00c7b1; + --c-turquoise-fg: #ffffff; + /* Focus ring: brand-navy in light mode — 14:1 on white, ~11:1 on sand */ --c-focus-ring: #012851; @@ -132,6 +139,9 @@ /* Header at brand-navy: 4.99:1 with ink-3 (WCAG AA ✓), visually above canvas */ --c-header: #012851; + --c-turquoise: #00c7b1; + --c-turquoise-fg: #012851; + /* Focus ring: brand-mint in dark mode — 9.2:1 on canvas, 7.1:1 on surface */ --c-focus-ring: #a1dcd8; @@ -167,6 +177,9 @@ /* Header at brand-navy: 4.99:1 with ink-3 (WCAG AA ✓), visually above canvas */ --c-header: #012851; + --c-turquoise: #00c7b1; + --c-turquoise-fg: #012851; + /* Focus ring: brand-mint in dark mode — 9.2:1 on canvas, 7.1:1 on surface */ --c-focus-ring: #a1dcd8; -- 2.49.1 From 052f70e871be56df0e3368713c38ca79c0e4a0e7 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 5 Apr 2026 20:28:37 +0200 Subject: [PATCH 10/47] fix(transcription): use navigator.sendBeacon for beforeunload save MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace async executeSave in beforeunload handler with navigator.sendBeacon — synchronous and reliable for page unload. Sends pending text as JSON blob to the block update endpoint. Fixes @Sara: "beforeunload handlers cannot reliably await async" Co-Authored-By: Claude Sonnet 4.6 --- .../lib/components/TranscriptionEditView.svelte | 15 +++++++++++++-- frontend/src/routes/documents/[id]/+page.svelte | 1 + 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/frontend/src/lib/components/TranscriptionEditView.svelte b/frontend/src/lib/components/TranscriptionEditView.svelte index f4f57c51..e30d10f5 100644 --- a/frontend/src/lib/components/TranscriptionEditView.svelte +++ b/frontend/src/lib/components/TranscriptionEditView.svelte @@ -7,13 +7,14 @@ import type { TranscriptionBlockData } from '$lib/types'; type SaveState = 'idle' | 'saving' | 'saved' | 'fading' | 'error'; type Props = { + documentId: string; blocks: TranscriptionBlockData[]; onBlockFocus: (blockId: string) => void; onSaveBlock: (blockId: string, text: string) => Promise; onDeleteBlock: (blockId: string) => Promise; }; -let { blocks, onBlockFocus, onSaveBlock, onDeleteBlock }: Props = $props(); +let { documentId, blocks, onBlockFocus, onSaveBlock, onDeleteBlock }: Props = $props(); let activeBlockId: string | null = $state(null); let saveStates = new SvelteMap(); @@ -115,9 +116,19 @@ function handleDelete(blockId: string) { onDeleteBlock(blockId); } +function flushViaBeacon() { + for (const [blockId, text] of pendingTexts) { + clearDebounce(blockId); + const url = `/api/documents/${documentId}/transcription-blocks/${blockId}`; + const body = JSON.stringify({ text }); + navigator.sendBeacon(url, new Blob([body], { type: 'application/json' })); + pendingTexts.delete(blockId); + } +} + $effect(() => { function onBeforeUnload() { - flushAllPending(); + flushViaBeacon(); } window.addEventListener('beforeunload', onBeforeUnload); diff --git a/frontend/src/routes/documents/[id]/+page.svelte b/frontend/src/routes/documents/[id]/+page.svelte index e71593e0..fac7d987 100644 --- a/frontend/src/routes/documents/[id]/+page.svelte +++ b/frontend/src/routes/documents/[id]/+page.svelte @@ -199,6 +199,7 @@ onMount(() => { {#if transcribeMode}
Date: Sun, 5 Apr 2026 20:29:41 +0200 Subject: [PATCH 11/47] fix(migration): add CHECK constraint on text length (defense in depth) V18: text column now has CHECK (length(text) <= 10000) to enforce the 10,000 character limit at the database level, complementing the application-level enforcement in TranscriptionService.sanitizeText(). Fixes @Nora: "DB constraint catches anything the application misses" Co-Authored-By: Claude Sonnet 4.6 --- .../resources/db/migration/V18__add_transcription_blocks.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/main/resources/db/migration/V18__add_transcription_blocks.sql b/backend/src/main/resources/db/migration/V18__add_transcription_blocks.sql index 524a2649..94fc30de 100644 --- a/backend/src/main/resources/db/migration/V18__add_transcription_blocks.sql +++ b/backend/src/main/resources/db/migration/V18__add_transcription_blocks.sql @@ -2,7 +2,7 @@ CREATE TABLE transcription_blocks ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), annotation_id UUID NOT NULL REFERENCES document_annotations(id) ON DELETE RESTRICT, document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE, - text TEXT NOT NULL DEFAULT '', + text TEXT NOT NULL DEFAULT '' CHECK (length(text) <= 10000), label VARCHAR(200), sort_order INTEGER NOT NULL DEFAULT 0, version INTEGER NOT NULL DEFAULT 0, -- 2.49.1 From 18c6bca2dd071667552edbf0d391b8f3229f898a Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 5 Apr 2026 20:32:44 +0200 Subject: [PATCH 12/47] refactor(transcription): split reorderBlocks for command-query separation TranscriptionService.reorderBlocks() now returns void (command). Controller calls listBlocks() separately after reorder (query). Updated test to match new void signature. Fixes @Felix: "reorderBlocks violates command-query separation" Co-Authored-By: Claude Sonnet 4.6 --- .../controller/TranscriptionBlockController.java | 3 ++- .../raddatz/familienarchiv/service/TranscriptionService.java | 3 +-- .../familienarchiv/service/TranscriptionServiceTest.java | 1 - 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/controller/TranscriptionBlockController.java b/backend/src/main/java/org/raddatz/familienarchiv/controller/TranscriptionBlockController.java index ed622826..227713d0 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/controller/TranscriptionBlockController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/TranscriptionBlockController.java @@ -77,7 +77,8 @@ public class TranscriptionBlockController { public List reorderBlocks( @PathVariable UUID documentId, @RequestBody ReorderTranscriptionBlocksDTO dto) { - return transcriptionService.reorderBlocks(documentId, dto); + transcriptionService.reorderBlocks(documentId, dto); + return transcriptionService.listBlocks(documentId); } @GetMapping("/{blockId}/history") diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/TranscriptionService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/TranscriptionService.java index caf8604c..2aff91bb 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/TranscriptionService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/TranscriptionService.java @@ -107,14 +107,13 @@ public class TranscriptionService { } @Transactional - public List reorderBlocks(UUID documentId, ReorderTranscriptionBlocksDTO dto) { + public void reorderBlocks(UUID documentId, ReorderTranscriptionBlocksDTO dto) { List blockIds = dto.getBlockIds(); for (int i = 0; i < blockIds.size(); i++) { TranscriptionBlock block = getBlock(documentId, blockIds.get(i)); block.setSortOrder(i); blockRepository.save(block); } - return blockRepository.findByDocumentIdOrderBySortOrderAsc(documentId); } public List getBlockHistory(UUID documentId, UUID blockId) { diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/TranscriptionServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/TranscriptionServiceTest.java index efc6de6a..ebe02d10 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/TranscriptionServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/TranscriptionServiceTest.java @@ -184,7 +184,6 @@ class TranscriptionServiceTest { when(blockRepository.findByIdAndDocumentId(id2, docId)).thenReturn(Optional.of(block2)); when(blockRepository.findByIdAndDocumentId(id1, docId)).thenReturn(Optional.of(block1)); when(blockRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); - when(blockRepository.findByDocumentIdOrderBySortOrderAsc(docId)).thenReturn(List.of(block2, block1)); ReorderTranscriptionBlocksDTO dto = new ReorderTranscriptionBlocksDTO(List.of(id2, id1)); -- 2.49.1 From aaffee28043ba499a16bd03bc526a051746f15b2 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 5 Apr 2026 20:38:53 +0200 Subject: [PATCH 13/47] test(frontend): add Vitest specs for DocumentMetadataDrawer and TranscriptionBlock DocumentMetadataDrawer (10 tests): - Renders formatted date, dash for null date - Renders location, dash for null location - Renders translated status label - Person cards as links to /persons/{id} - Receiver links, empty state for no persons - Tag chips as links, empty state for no tags TranscriptionBlock (12 tests): - Renders block number, text, optional label - Save states: idle (nothing), saving (pulse), saved (checkmark), error (retry) - Active turquoise border, error red border - onTextChange fires on typing, onFocus fires on click Fixes @Felix/@Sara: "Frontend component tests still missing" Co-Authored-By: Claude Sonnet 4.6 --- .../DocumentMetadataDrawer.svelte.spec.ts | 100 +++++++++++++++ .../TranscriptionBlock.svelte.spec.ts | 114 ++++++++++++++++++ 2 files changed, 214 insertions(+) create mode 100644 frontend/src/lib/components/DocumentMetadataDrawer.svelte.spec.ts create mode 100644 frontend/src/lib/components/TranscriptionBlock.svelte.spec.ts diff --git a/frontend/src/lib/components/DocumentMetadataDrawer.svelte.spec.ts b/frontend/src/lib/components/DocumentMetadataDrawer.svelte.spec.ts new file mode 100644 index 00000000..7265a9a9 --- /dev/null +++ b/frontend/src/lib/components/DocumentMetadataDrawer.svelte.spec.ts @@ -0,0 +1,100 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import DocumentMetadataDrawer from './DocumentMetadataDrawer.svelte'; + +afterEach(cleanup); + +const sender = { id: 's1', firstName: 'Karl', lastName: 'Müller' }; +const receivers = [ + { id: 'r1', firstName: 'Anna', lastName: 'Schmidt' }, + { id: 'r2', firstName: 'Hans', lastName: 'Weber' } +]; +const tags = [ + { id: 't1', name: 'Familienbrief' }, + { id: 't2', name: 'Kriegszeit' } +]; + +function renderDrawer(overrides: Record = {}) { + return render(DocumentMetadataDrawer, { + documentDate: '1942-03-15', + location: 'Berlin', + status: 'UPLOADED', + sender, + receivers, + tags, + ...overrides + }); +} + +// ─── Details column ────────────────────────────────────────────────────────── + +describe('DocumentMetadataDrawer — details column', () => { + it('renders formatted date', async () => { + renderDrawer(); + await expect.element(page.getByText('15. März 1942')).toBeInTheDocument(); + }); + + it('renders dash when date is null', async () => { + renderDrawer({ documentDate: null }); + const dds = page.getByText('—'); + await expect.element(dds.first()).toBeInTheDocument(); + }); + + it('renders location', async () => { + renderDrawer(); + await expect.element(page.getByText('Berlin')).toBeInTheDocument(); + }); + + it('renders dash when location is null', async () => { + renderDrawer({ location: null }); + const dashes = page.getByText('—'); + await expect.element(dashes.first()).toBeInTheDocument(); + }); + + it('renders translated status label', async () => { + renderDrawer(); + // "Hochgeladen" is the German translation of UPLOADED + await expect.element(page.getByText('Hochgeladen')).toBeInTheDocument(); + }); +}); + +// ─── Persons column ────────────────────────────────────────────────────────── + +describe('DocumentMetadataDrawer — persons column', () => { + it('renders sender name as link to person detail', async () => { + renderDrawer(); + const link = page.getByRole('link', { name: /Karl Müller/ }); + await expect.element(link).toBeInTheDocument(); + await expect.element(link).toHaveAttribute('href', '/persons/s1'); + }); + + it('renders receiver names as links', async () => { + renderDrawer(); + const anna = page.getByRole('link', { name: /Anna Schmidt/ }); + await expect.element(anna).toHaveAttribute('href', '/persons/r1'); + const hans = page.getByRole('link', { name: /Hans Weber/ }); + await expect.element(hans).toHaveAttribute('href', '/persons/r2'); + }); + + it('shows empty state when no sender and no receivers', async () => { + renderDrawer({ sender: null, receivers: [] }); + await expect.element(page.getByText('Keine Personen zugeordnet')).toBeInTheDocument(); + }); +}); + +// ─── Tags column ───────────────────────────────────────────────────────────── + +describe('DocumentMetadataDrawer — tags column', () => { + it('renders tag chips as links', async () => { + renderDrawer(); + const fb = page.getByRole('link', { name: 'Familienbrief' }); + await expect.element(fb).toBeInTheDocument(); + await expect.element(fb).toHaveAttribute('href', '/?tag=Familienbrief'); + }); + + it('shows empty state when no tags', async () => { + renderDrawer({ tags: [] }); + await expect.element(page.getByText('Keine Schlagwörter zugeordnet')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/lib/components/TranscriptionBlock.svelte.spec.ts b/frontend/src/lib/components/TranscriptionBlock.svelte.spec.ts new file mode 100644 index 00000000..e3158023 --- /dev/null +++ b/frontend/src/lib/components/TranscriptionBlock.svelte.spec.ts @@ -0,0 +1,114 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import TranscriptionBlock from './TranscriptionBlock.svelte'; + +afterEach(cleanup); + +function renderBlock(overrides: Record = {}) { + return render(TranscriptionBlock, { + blockId: 'block-1', + blockNumber: 3, + text: 'Liebe Mutter,', + label: null, + active: false, + saveState: 'idle' as const, + onTextChange: vi.fn(), + onFocus: vi.fn(), + onDeleteClick: vi.fn(), + onRetry: vi.fn(), + ...overrides + }); +} + +// ─── Rendering ─────────────────────────────────────────────────────────────── + +describe('TranscriptionBlock — rendering', () => { + it('renders block number in turquoise badge', async () => { + renderBlock(); + await expect.element(page.getByText('3')).toBeInTheDocument(); + }); + + it('renders text in textarea', async () => { + renderBlock(); + const textarea = page.getByRole('textbox'); + await expect.element(textarea).toHaveValue('Liebe Mutter,'); + }); + + it('renders optional label when provided', async () => { + renderBlock({ label: 'Anrede' }); + await expect.element(page.getByText('Anrede')).toBeInTheDocument(); + }); + + it('does not render label when null', async () => { + renderBlock({ label: null }); + const label = page.getByText('Anrede'); + await expect.element(label).not.toBeInTheDocument(); + }); +}); + +// ─── Save states ───────────────────────────────────────────────────────────── + +describe('TranscriptionBlock — save states', () => { + it('shows nothing in idle state', async () => { + renderBlock({ saveState: 'idle' }); + const saving = page.getByText('Speichere...'); + await expect.element(saving).not.toBeInTheDocument(); + }); + + it('shows "Speichere..." in saving state', async () => { + renderBlock({ saveState: 'saving' }); + await expect.element(page.getByText('Speichere...')).toBeInTheDocument(); + }); + + it('shows "Gespeichert" in saved state', async () => { + renderBlock({ saveState: 'saved' }); + await expect.element(page.getByText(/Gespeichert/)).toBeInTheDocument(); + }); + + it('shows error with retry button in error state', async () => { + const onRetry = vi.fn(); + renderBlock({ saveState: 'error', onRetry }); + await expect.element(page.getByText('Nicht gespeichert')).toBeInTheDocument(); + const retryBtn = page.getByText('Erneut versuchen'); + await expect.element(retryBtn).toBeInTheDocument(); + }); +}); + +// ─── Active state ──────────────────────────────────────────────────────────── + +describe('TranscriptionBlock — active border', () => { + it('has turquoise left border when active', async () => { + renderBlock({ active: true }); + await expect.element(page.getByRole('textbox')).toBeInTheDocument(); + const block = document.querySelector('[data-block-id="block-1"]')!; + expect(block.className).toContain('border-turquoise'); + }); + + it('has error left border when save failed', async () => { + renderBlock({ saveState: 'error' }); + await expect.element(page.getByRole('textbox')).toBeInTheDocument(); + const block = document.querySelector('[data-block-id="block-1"]')!; + expect(block.className).toContain('border-error'); + }); +}); + +// ─── Interactions ──────────────────────────────────────────────────────────── + +describe('TranscriptionBlock — interactions', () => { + it('calls onTextChange when typing in textarea', async () => { + const onTextChange = vi.fn(); + renderBlock({ onTextChange }); + const textarea = page.getByRole('textbox'); + await textarea.fill('Neue Zeile'); + expect(onTextChange).toHaveBeenCalled(); + }); + + it('calls onFocus when textarea is focused', async () => { + const onFocus = vi.fn(); + renderBlock({ onFocus }); + const textarea = page.getByRole('textbox'); + await textarea.click(); + expect(onFocus).toHaveBeenCalled(); + }); +}); -- 2.49.1 From 99e2e6e5c1653233eafc7fc27170cf93dbab9a71 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 5 Apr 2026 20:44:45 +0200 Subject: [PATCH 14/47] feat(transcription): enable drawing turquoise rectangles on PDF to create blocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AnnotationLayer: add dimColor prop — annotations matching dim color render at 30% opacity with pointer-events disabled (300ms transition) - PdfViewer: add transcribeMode prop, derived drawingEnabled/drawColor; in transcribe mode draws with turquoise (#00C7B1), routes draw events to onTranscriptionDraw callback instead of annotation endpoint - DocumentViewer: pass through transcribeMode + onTranscriptionDraw - Document detail page: createBlockFromDraw() POSTs to transcription blocks API on draw completion, adds created block to list - Mode-based dimming: yellow annotations dim in transcribe mode, turquoise annotations dim in annotate mode Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/components/AnnotationLayer.svelte | 12 +++++-- .../src/lib/components/DocumentViewer.svelte | 10 +++++- frontend/src/lib/components/PdfViewer.svelte | 25 +++++++++++--- .../src/routes/documents/[id]/+page.svelte | 33 +++++++++++++++++++ 4 files changed, 73 insertions(+), 7 deletions(-) diff --git a/frontend/src/lib/components/AnnotationLayer.svelte b/frontend/src/lib/components/AnnotationLayer.svelte index 3d79b61b..5d103b11 100644 --- a/frontend/src/lib/components/AnnotationLayer.svelte +++ b/frontend/src/lib/components/AnnotationLayer.svelte @@ -12,6 +12,7 @@ let { annotations = [], canAnnotate, color, + dimColor, onDraw, onDelete, commentCounts, @@ -20,12 +21,18 @@ let { annotations: Annotation[]; canAnnotate: boolean; color: string; + dimColor?: string; onDraw: (rect: { x: number; y: number; width: number; height: number }) => void; onDelete: (id: string) => void; commentCounts?: Record; onAnnotationClick?: (id: string) => void; } = $props(); +function isDimmed(annotation: Annotation): boolean { + if (!dimColor) return false; + return annotation.color.toLowerCase() === dimColor.toLowerCase(); +} + let drawStart = $state<{ x: number; y: number } | null>(null); let drawRect = $state(null); @@ -123,8 +130,9 @@ const containerStyle = $derived( height: {annotation.height * 100}%; background-color: {hexToRgba(annotation.color, hoveredId === annotation.id ? 0.5 : 0.3)}; box-shadow: {hoveredId === annotation.id ? `inset 0 0 0 2px ${hexToRgba(annotation.color, 0.8)}` : 'none'}; - pointer-events: auto; - transition: background-color 0.15s ease, box-shadow 0.15s ease; + opacity: {isDimmed(annotation) ? 0.3 : 1}; + pointer-events: {isDimmed(annotation) ? 'none' : 'auto'}; + transition: background-color 0.15s ease, box-shadow 0.15s ease, opacity 0.3s ease; {onAnnotationClick && !canAnnotate ? 'cursor: pointer;' : ''} " > diff --git a/frontend/src/lib/components/DocumentViewer.svelte b/frontend/src/lib/components/DocumentViewer.svelte index 0806d784..5f70772c 100644 --- a/frontend/src/lib/components/DocumentViewer.svelte +++ b/frontend/src/lib/components/DocumentViewer.svelte @@ -9,15 +9,19 @@ type Doc = { fileHash?: string | null; }; +type DrawRect = { x: number; y: number; width: number; height: number; pageNumber: number }; + type Props = { doc: Doc; fileUrl: string; isLoading: boolean; error: string; annotateMode: boolean; + transcribeMode?: boolean; activeAnnotationId: string | null; activeAnnotationPage: number | null; onAnnotationClick: (id: string) => void; + onTranscriptionDraw?: (rect: DrawRect) => void; }; let { @@ -26,9 +30,11 @@ let { isLoading, error, annotateMode = $bindable(), + transcribeMode = false, activeAnnotationId = $bindable(), activeAnnotationPage = $bindable(), - onAnnotationClick + onAnnotationClick, + onTranscriptionDraw }: Props = $props(); @@ -81,9 +87,11 @@ let { url={fileUrl} documentId={doc.id} bind:annotateMode={annotateMode} + transcribeMode={transcribeMode} bind:activeAnnotationId={activeAnnotationId} bind:activeAnnotationPage={activeAnnotationPage} onAnnotationClick={onAnnotationClick} + onTranscriptionDraw={onTranscriptionDraw} documentFileHash={doc.fileHash ?? null} /> {:else if fileUrl} diff --git a/frontend/src/lib/components/PdfViewer.svelte b/frontend/src/lib/components/PdfViewer.svelte index 704a8a07..fa07b050 100644 --- a/frontend/src/lib/components/PdfViewer.svelte +++ b/frontend/src/lib/components/PdfViewer.svelte @@ -6,21 +6,27 @@ import AnnotationLayer from './AnnotationLayer.svelte'; import type { Annotation } from '$lib/types'; import { m } from '$lib/paraglide/messages.js'; +type DrawRect = { x: number; y: number; width: number; height: number; pageNumber: number }; + let { url, documentId = '', annotateMode = $bindable(false), + transcribeMode = false, activeAnnotationId = $bindable(null), activeAnnotationPage = $bindable(null), onAnnotationClick, + onTranscriptionDraw, documentFileHash }: { url: string; documentId?: string; annotateMode?: boolean; + transcribeMode?: boolean; activeAnnotationId?: string | null; activeAnnotationPage?: number | null; onAnnotationClick?: (id: string) => void; + onTranscriptionDraw?: (rect: DrawRect) => void; documentFileHash?: string | null; } = $props(); @@ -49,6 +55,10 @@ let annotateColor = $state('#ffff00'); let commentCounts = new SvelteMap(); let showAnnotations = $state(true); +const TRANSCRIPTION_COLOR = '#00C7B1'; +const drawingEnabled = $derived(annotateMode || transcribeMode); +const drawColor = $derived(transcribeMode ? TRANSCRIPTION_COLOR : annotateColor); + const visibleAnnotations = $derived( annotations.filter((a) => !a.fileHash || !documentFileHash || a.fileHash === documentFileHash) ); @@ -194,8 +204,14 @@ async function loadAnnotations(docId: string) { } } -async function handleAnnotationDraw(rect: { x: number; y: number; width: number; height: number }) { +async function handleDraw(rect: { x: number; y: number; width: number; height: number }) { if (!documentId) return; + + if (transcribeMode) { + onTranscriptionDraw?.({ ...rect, pageNumber: currentPage }); + return; + } + try { const res = await fetch(`/api/documents/${documentId}/annotations`, { method: 'POST', @@ -486,9 +502,10 @@ function zoomOut() { {#if showAnnotations} a.pageNumber === currentPage)} - canAnnotate={annotateMode} - color={annotateColor} - onDraw={handleAnnotationDraw} + canAnnotate={drawingEnabled} + color={drawColor} + dimColor={transcribeMode ? '#ffff00' : annotateMode ? TRANSCRIPTION_COLOR : undefined} + onDraw={handleDraw} onDelete={handleAnnotationDelete} commentCounts={Object.fromEntries(commentCounts)} onAnnotationClick={handleAnnotationClick} diff --git a/frontend/src/routes/documents/[id]/+page.svelte b/frontend/src/routes/documents/[id]/+page.svelte index fac7d987..ff05d7f4 100644 --- a/frontend/src/routes/documents/[id]/+page.svelte +++ b/frontend/src/routes/documents/[id]/+page.svelte @@ -99,6 +99,37 @@ async function deleteBlock(blockId: string) { transcriptionBlocks = transcriptionBlocks.filter((b) => b.id !== blockId); } +async function createBlockFromDraw(rect: { + x: number; + y: number; + width: number; + height: number; + pageNumber: number; +}) { + try { + const res = await fetch(`/api/documents/${doc.id}/transcription-blocks`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + pageNumber: rect.pageNumber, + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height, + text: '', + label: null + }) + }); + if (res.ok) { + const created = (await res.json()) as TranscriptionBlockData; + transcriptionBlocks = [...transcriptionBlocks, created]; + activeAnnotationId = created.annotationId; + } + } catch (e) { + console.error('Failed to create transcription block:', e); + } +} + function handleBlockFocus(blockId: string) { const block = transcriptionBlocks.find((b) => b.id === blockId); if (block) { @@ -172,11 +203,13 @@ onMount(() => { isLoading={isLoading} error={fileError} bind:annotateMode={annotateMode} + transcribeMode={transcribeMode} bind:activeAnnotationId={activeAnnotationId} bind:activeAnnotationPage={activeAnnotationPage} onAnnotationClick={(id) => { activeAnnotationId = id; }} + onTranscriptionDraw={createBlockFromDraw} />
-- 2.49.1 From 7036f18b25caf282db41ef94892a2fd6e7b3e5ac Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 5 Apr 2026 20:47:21 +0200 Subject: [PATCH 15/47] test(annotations): add tests for dimColor and crosshair cursor - dims annotations matching dimColor (opacity 0.3, pointer-events none) - does not dim annotations that don't match dimColor - has crosshair cursor when canAnnotate is true Co-Authored-By: Claude Sonnet 4.6 --- .../components/AnnotationLayer.svelte.spec.ts | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/frontend/src/lib/components/AnnotationLayer.svelte.spec.ts b/frontend/src/lib/components/AnnotationLayer.svelte.spec.ts index e25f95c4..cc82e084 100644 --- a/frontend/src/lib/components/AnnotationLayer.svelte.spec.ts +++ b/frontend/src/lib/components/AnnotationLayer.svelte.spec.ts @@ -71,4 +71,52 @@ describe('AnnotationLayer', () => { expect(page.getByRole('button', { name: /annotation löschen/i }).query()).toBeNull(); }); + + it('dims annotations matching dimColor', async () => { + render(AnnotationLayer, { + annotations: [makeAnnotation('ann-1')], + canAnnotate: false, + color: '#00C7B1', + dimColor: '#ff0000', + onDraw: () => {}, + onDelete: () => {} + }); + + const el = page.getByTestId('annotation-ann-1'); + await expect.element(el).toBeInTheDocument(); + const style = el.element().style; + expect(style.opacity).toBe('0.3'); + expect(style.pointerEvents).toBe('none'); + }); + + it('does not dim annotations that do not match dimColor', async () => { + const ann = makeAnnotation('ann-1'); + ann.color = '#00C7B1'; + render(AnnotationLayer, { + annotations: [ann], + canAnnotate: false, + color: '#00C7B1', + dimColor: '#ff0000', + onDraw: () => {}, + onDelete: () => {} + }); + + const el = page.getByTestId('annotation-ann-1'); + await expect.element(el).toBeInTheDocument(); + const style = el.element().style; + expect(style.opacity).toBe('1'); + }); + + it('has crosshair cursor when canAnnotate is true', async () => { + render(AnnotationLayer, { + annotations: [], + canAnnotate: true, + color: '#00C7B1', + onDraw: () => {}, + onDelete: () => {} + }); + + const container = document.querySelector('[role="presentation"]')!; + expect(container.getAttribute('style')).toContain('cursor: crosshair'); + }); }); -- 2.49.1 From 3b2d905041f84afe72638749f016b570eedd9797 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 5 Apr 2026 20:49:01 +0200 Subject: [PATCH 16/47] fix(transcription): reload annotations after drawing block on PDF After onTranscriptionDraw callback completes, reload the annotation list from the backend so the turquoise rectangle overlay appears immediately on the PDF page. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/components/PdfViewer.svelte | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/components/PdfViewer.svelte b/frontend/src/lib/components/PdfViewer.svelte index fa07b050..03e655ac 100644 --- a/frontend/src/lib/components/PdfViewer.svelte +++ b/frontend/src/lib/components/PdfViewer.svelte @@ -208,7 +208,8 @@ async function handleDraw(rect: { x: number; y: number; width: number; height: n if (!documentId) return; if (transcribeMode) { - onTranscriptionDraw?.({ ...rect, pageNumber: currentPage }); + await onTranscriptionDraw?.({ ...rect, pageNumber: currentPage }); + await loadAnnotations(documentId); return; } -- 2.49.1 From da43cadb0a4b4cba68f9bf21c2d340974120cd7b Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 5 Apr 2026 21:01:02 +0200 Subject: [PATCH 17/47] feat(comments): add block-level comment endpoints with TDD RED/GREEN for CommentService: - getCommentsForBlock(blockId): returns root comments filtered by blockId - postBlockComment(documentId, blockId, content, mentions, author): creates comment with block_id set RED/GREEN for CommentController: - GET /api/documents/{docId}/transcription-blocks/{blockId}/comments - POST /api/documents/{docId}/transcription-blocks/{blockId}/comments - POST .../comments/{commentId}/replies (reuses existing replyToComment) 4 new tests: 2 service unit tests + 2 controller integration tests All 25 CommentServiceTest + 24 CommentControllerTest green Co-Authored-By: Claude Sonnet 4.6 --- .../controller/CommentController.java | 31 ++++++++++++++++ .../repository/CommentRepository.java | 2 ++ .../service/CommentService.java | 22 ++++++++++++ .../controller/CommentControllerTest.java | 26 ++++++++++++++ .../service/CommentServiceTest.java | 36 +++++++++++++++++++ 5 files changed, 117 insertions(+) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/controller/CommentController.java b/backend/src/main/java/org/raddatz/familienarchiv/controller/CommentController.java index c9f9fac8..cb6b6d70 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/controller/CommentController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/CommentController.java @@ -85,6 +85,37 @@ public class CommentController { return commentService.replyToComment(documentId, commentId, dto.getContent(), dto.getMentionedUserIds(), author); } + // ─── Block (transcription) comments ──────────────────────────────────────── + + @GetMapping("/api/documents/{documentId}/transcription-blocks/{blockId}/comments") + public List getBlockComments(@PathVariable UUID blockId) { + return commentService.getCommentsForBlock(blockId); + } + + @PostMapping("/api/documents/{documentId}/transcription-blocks/{blockId}/comments") + @ResponseStatus(HttpStatus.CREATED) + @RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL}) + public DocumentComment postBlockComment( + @PathVariable UUID documentId, + @PathVariable UUID blockId, + @RequestBody CreateCommentDTO dto, + Authentication authentication) { + AppUser author = resolveUser(authentication); + return commentService.postBlockComment(documentId, blockId, dto.getContent(), dto.getMentionedUserIds(), author); + } + + @PostMapping("/api/documents/{documentId}/transcription-blocks/{blockId}/comments/{commentId}/replies") + @ResponseStatus(HttpStatus.CREATED) + @RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL}) + public DocumentComment replyToBlockComment( + @PathVariable UUID documentId, + @PathVariable UUID commentId, + @RequestBody CreateCommentDTO dto, + Authentication authentication) { + AppUser author = resolveUser(authentication); + return commentService.replyToComment(documentId, commentId, dto.getContent(), dto.getMentionedUserIds(), author); + } + // ─── Edit and delete (shared) ───────────────────────────────────────────── @PatchMapping("/api/documents/{documentId}/comments/{commentId}") diff --git a/backend/src/main/java/org/raddatz/familienarchiv/repository/CommentRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/repository/CommentRepository.java index 80269305..9327a350 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/repository/CommentRepository.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/repository/CommentRepository.java @@ -13,4 +13,6 @@ public interface CommentRepository extends JpaRepository List findByAnnotationIdAndParentIdIsNull(UUID annotationId); List findByParentId(UUID parentId); + + List findByBlockIdAndParentIdIsNull(UUID blockId); } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/CommentService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/CommentService.java index 4d932c84..bfd4b6df 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/CommentService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/CommentService.java @@ -34,6 +34,28 @@ public class CommentService { return withRepliesAndMentions(roots); } + public List getCommentsForBlock(UUID blockId) { + List roots = commentRepository.findByBlockIdAndParentIdIsNull(blockId); + return withRepliesAndMentions(roots); + } + + @Transactional + public DocumentComment postBlockComment(UUID documentId, UUID blockId, String content, + List mentionedUserIds, AppUser author) { + DocumentComment comment = DocumentComment.builder() + .documentId(documentId) + .blockId(blockId) + .content(content) + .authorId(author.getId()) + .authorName(resolveAuthorName(author)) + .build(); + saveMentions(comment, mentionedUserIds); + DocumentComment saved = commentRepository.save(comment); + withMentionDTOs(saved); + notificationService.notifyMentions(mentionedUserIds, saved); + return saved; + } + @Transactional public DocumentComment postComment(UUID documentId, UUID annotationId, String content, List mentionedUserIds, AppUser author) { diff --git a/backend/src/test/java/org/raddatz/familienarchiv/controller/CommentControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/controller/CommentControllerTest.java index d9c2f31d..a556e676 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/controller/CommentControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/CommentControllerTest.java @@ -279,4 +279,30 @@ class CommentControllerTest { .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON)) .andExpect(status().isCreated()); } + + // ─── Block comment endpoints ───────────────────────────────────────────── + + @Test + @WithMockUser + void getBlockComments_returns200() throws Exception { + UUID blockId = UUID.randomUUID(); + when(commentService.getCommentsForBlock(blockId)).thenReturn(List.of()); + + mockMvc.perform(get("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments")) + .andExpect(status().isOk()); + } + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void postBlockComment_returns201() throws Exception { + UUID blockId = UUID.randomUUID(); + DocumentComment saved = DocumentComment.builder() + .id(UUID.randomUUID()).documentId(DOC_ID).blockId(blockId).content("Nice").build(); + when(commentService.postBlockComment(any(), any(), any(), any(), any())).thenReturn(saved); + + mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments") + .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.blockId").value(blockId.toString())); + } } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/CommentServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/CommentServiceTest.java index 8373f110..94851440 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/CommentServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/CommentServiceTest.java @@ -488,4 +488,40 @@ class CommentServiceTest { .build())) .build(); } + + // ─── Block-level comments ──────────────────────────────────────────────── + + @Test + void getCommentsForBlock_returnsRootCommentsFilteredByBlockId() { + UUID blockId = UUID.randomUUID(); + DocumentComment root = DocumentComment.builder() + .id(UUID.randomUUID()).blockId(blockId).content("Nice work").authorName("Felix") + .createdAt(LocalDateTime.now()).updatedAt(LocalDateTime.now()).build(); + when(commentRepository.findByBlockIdAndParentIdIsNull(blockId)).thenReturn(List.of(root)); + when(commentRepository.findByParentId(root.getId())).thenReturn(List.of()); + + List result = commentService.getCommentsForBlock(blockId); + + assertThat(result).hasSize(1); + assertThat(result.getFirst().getContent()).isEqualTo("Nice work"); + } + + @Test + void postBlockComment_setsBlockIdOnComment() { + UUID documentId = UUID.randomUUID(); + UUID blockId = UUID.randomUUID(); + AppUser author = AppUser.builder().id(UUID.randomUUID()).username("felix").firstName("Felix").lastName("Brandt").build(); + when(commentRepository.save(any())).thenAnswer(inv -> { + DocumentComment c = inv.getArgument(0); + c.setId(UUID.randomUUID()); + return c; + }); + + DocumentComment result = commentService.postBlockComment( + documentId, blockId, "Looks like Breslau", List.of(), author); + + assertThat(result.getBlockId()).isEqualTo(blockId); + assertThat(result.getDocumentId()).isEqualTo(documentId); + assertThat(result.getContent()).isEqualTo("Looks like Breslau"); + } } -- 2.49.1 From 8c26876345113a36cf43004d8669d474b6186ab0 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 5 Apr 2026 21:05:39 +0200 Subject: [PATCH 18/47] feat(transcription): add block-level comment threads with quote support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TranscriptionBlock.svelte: - "Kommentieren" button opens expandable comment thread per block - Text selection in textarea captured as quoted text (> "...") prefix - Quote hint "Text markieren für Zitat" shown when block is active/focused - Comment thread uses existing CommentThread with blockId prop CommentThread.svelte: - Add blockId prop for block-level comments URL routing - Add quotedText prop — pre-fills comment input with markdown blockquote - commentsBase now supports 3 URL patterns: document, annotation, block TranscriptionEditView.svelte: - Pass canComment + currentUserId through to block components 3 new frontend tests: - Kommentieren button present - Quote hint shown when active - Quote hint hidden when inactive Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/components/CommentThread.svelte | 19 ++++- .../lib/components/TranscriptionBlock.svelte | 76 ++++++++++++++++++- .../TranscriptionBlock.svelte.spec.ts | 20 +++++ .../components/TranscriptionEditView.svelte | 15 +++- .../src/routes/documents/[id]/+page.svelte | 2 + 5 files changed, 126 insertions(+), 6 deletions(-) diff --git a/frontend/src/lib/components/CommentThread.svelte b/frontend/src/lib/components/CommentThread.svelte index b741c632..65eff1c4 100644 --- a/frontend/src/lib/components/CommentThread.svelte +++ b/frontend/src/lib/components/CommentThread.svelte @@ -9,24 +9,28 @@ import type { MentionDTO } from '$lib/types'; type Props = { documentId: string; annotationId?: string | null; + blockId?: string | null; initialComments?: Comment[]; loadOnMount?: boolean; canComment: boolean; currentUserId: string | null; canAdmin: boolean; targetCommentId?: string | null; + quotedText?: string | null; onCountChange?: (count: number) => void; }; let { documentId, annotationId = null, + blockId = null, initialComments = [], loadOnMount = false, canComment, currentUserId, canAdmin, targetCommentId = null, + quotedText = null, onCountChange }: Props = $props(); @@ -43,11 +47,20 @@ let replyMentionCandidates: MentionDTO[] = $state([]); let editMentionCandidates: MentionDTO[] = $state([]); const commentsBase = $derived( - annotationId - ? `/api/documents/${documentId}/annotations/${annotationId}/comments` - : `/api/documents/${documentId}/comments` + blockId + ? `/api/documents/${documentId}/transcription-blocks/${blockId}/comments` + : annotationId + ? `/api/documents/${documentId}/annotations/${annotationId}/comments` + : `/api/documents/${documentId}/comments` ); +// Pre-fill comment box with quoted text when selection changes +$effect(() => { + if (quotedText && quotedText.trim()) { + newText = `> "${quotedText}"\n\n`; + } +}); + function timeAgo(iso: string): string { const diff = Date.now() - new Date(iso).getTime(); const minutes = Math.floor(diff / 60000); diff --git a/frontend/src/lib/components/TranscriptionBlock.svelte b/frontend/src/lib/components/TranscriptionBlock.svelte index 0b07068f..0b370561 100644 --- a/frontend/src/lib/components/TranscriptionBlock.svelte +++ b/frontend/src/lib/components/TranscriptionBlock.svelte @@ -1,15 +1,19 @@
-
+
+
+ + {#if active} + + {m.transcription_block_quote_hint()} + + {/if} +
+
{#if saveState === 'saving'} @@ -143,5 +185,35 @@ function handleDelete() {
+ + + {#if commentOpen} +
+
+ + {m.comment_section_title()} + + +
+ +
+ {/if}
diff --git a/frontend/src/lib/components/TranscriptionBlock.svelte.spec.ts b/frontend/src/lib/components/TranscriptionBlock.svelte.spec.ts index e3158023..7489ed52 100644 --- a/frontend/src/lib/components/TranscriptionBlock.svelte.spec.ts +++ b/frontend/src/lib/components/TranscriptionBlock.svelte.spec.ts @@ -8,11 +8,14 @@ afterEach(cleanup); function renderBlock(overrides: Record = {}) { return render(TranscriptionBlock, { blockId: 'block-1', + documentId: 'doc-1', blockNumber: 3, text: 'Liebe Mutter,', label: null, active: false, saveState: 'idle' as const, + canComment: true, + currentUserId: 'user-1', onTextChange: vi.fn(), onFocus: vi.fn(), onDeleteClick: vi.fn(), @@ -111,4 +114,21 @@ describe('TranscriptionBlock — interactions', () => { await textarea.click(); expect(onFocus).toHaveBeenCalled(); }); + + it('shows Kommentieren button that opens comment thread', async () => { + renderBlock(); + const btn = page.getByText('Kommentieren'); + await expect.element(btn).toBeInTheDocument(); + }); + + it('shows quote hint when block is active', async () => { + renderBlock({ active: true }); + await expect.element(page.getByText('Text markieren für Zitat')).toBeInTheDocument(); + }); + + it('hides quote hint when block is not active', async () => { + renderBlock({ active: false }); + const hint = page.getByText('Text markieren für Zitat'); + await expect.element(hint).not.toBeInTheDocument(); + }); }); diff --git a/frontend/src/lib/components/TranscriptionEditView.svelte b/frontend/src/lib/components/TranscriptionEditView.svelte index e30d10f5..65fba8f0 100644 --- a/frontend/src/lib/components/TranscriptionEditView.svelte +++ b/frontend/src/lib/components/TranscriptionEditView.svelte @@ -9,12 +9,22 @@ type SaveState = 'idle' | 'saving' | 'saved' | 'fading' | 'error'; type Props = { documentId: string; blocks: TranscriptionBlockData[]; + canComment: boolean; + currentUserId: string | null; onBlockFocus: (blockId: string) => void; onSaveBlock: (blockId: string, text: string) => Promise; onDeleteBlock: (blockId: string) => Promise; }; -let { documentId, blocks, onBlockFocus, onSaveBlock, onDeleteBlock }: Props = $props(); +let { + documentId, + blocks, + canComment, + currentUserId, + onBlockFocus, + onSaveBlock, + onDeleteBlock +}: Props = $props(); let activeBlockId: string | null = $state(null); let saveStates = new SvelteMap(); @@ -149,11 +159,14 @@ $effect(() => {
handleTextChange(block.id, text)} onFocus={() => handleFocus(block.id)} onDeleteClick={() => handleDelete(block.id)} diff --git a/frontend/src/routes/documents/[id]/+page.svelte b/frontend/src/routes/documents/[id]/+page.svelte index ff05d7f4..2b8d4aaf 100644 --- a/frontend/src/routes/documents/[id]/+page.svelte +++ b/frontend/src/routes/documents/[id]/+page.svelte @@ -234,6 +234,8 @@ onMount(() => { Date: Sun, 5 Apr 2026 21:17:27 +0200 Subject: [PATCH 19/47] =?UTF-8?q?refactor:=20remove=20legacy=20annotate=20?= =?UTF-8?q?mode=20=E2=80=94=20transcription=20replaces=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The yellow annotation+comment system is now redundant. Transcription blocks handle the same use case (mark region → discuss) but better, because they also produce a transcription. Removed: - annotateMode state and all wiring through page/topbar/viewer/pdfviewer - Annotate/Stop annotate buttons from DocumentTopBar - AnnotateHintStrip import and rendering - AnnotationSidePanel from document detail page - canAnnotate prop from DocumentTopBar - Color picker from PdfViewer - Comment count badges and loadCommentCounts from PdfViewer - Delete button from AnnotationLayer (blocks own annotation lifecycle) - dimColor prop from AnnotationLayer Simplified: - AnnotationLayer: only canDraw + color + onDraw + onAnnotationClick - PdfViewer: only draws in transcribeMode with turquoise - Clicking annotation in transcribe mode scrolls to corresponding block - canComment derived from canWrite (no longer needs canAnnotate) Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/components/AnnotationLayer.svelte | 101 +++-------------- .../components/AnnotationLayer.svelte.spec.ts | 103 ++++++------------ .../src/lib/components/DocumentTopBar.svelte | 82 +------------- .../src/lib/components/DocumentViewer.svelte | 6 - frontend/src/lib/components/PdfViewer.svelte | 96 +--------------- .../src/routes/documents/[id]/+page.svelte | 66 +++-------- 6 files changed, 76 insertions(+), 378 deletions(-) diff --git a/frontend/src/lib/components/AnnotationLayer.svelte b/frontend/src/lib/components/AnnotationLayer.svelte index 5d103b11..2db06063 100644 --- a/frontend/src/lib/components/AnnotationLayer.svelte +++ b/frontend/src/lib/components/AnnotationLayer.svelte @@ -10,29 +10,18 @@ type DrawRect = { let { annotations = [], - canAnnotate, + canDraw, color, - dimColor, onDraw, - onDelete, - commentCounts, onAnnotationClick }: { annotations: Annotation[]; - canAnnotate: boolean; + canDraw: boolean; color: string; - dimColor?: string; - onDraw: (rect: { x: number; y: number; width: number; height: number }) => void; - onDelete: (id: string) => void; - commentCounts?: Record; + onDraw: (rect: DrawRect) => void; onAnnotationClick?: (id: string) => void; } = $props(); -function isDimmed(annotation: Annotation): boolean { - if (!dimColor) return false; - return annotation.color.toLowerCase() === dimColor.toLowerCase(); -} - let drawStart = $state<{ x: number; y: number } | null>(null); let drawRect = $state(null); @@ -52,7 +41,7 @@ function getNormalizedCoords(event: PointerEvent, element: HTMLElement): { x: nu } function handlePointerDown(event: PointerEvent) { - if (!canAnnotate) return; + if (!canDraw) return; if ((event.target as HTMLElement).closest('[data-annotation]')) return; @@ -65,7 +54,7 @@ function handlePointerDown(event: PointerEvent) { } function handlePointerMove(event: PointerEvent) { - if (!canAnnotate || !drawStart) return; + if (!canDraw || !drawStart) return; const container = event.currentTarget as HTMLElement; const coords = getNormalizedCoords(event, container); @@ -79,7 +68,7 @@ function handlePointerMove(event: PointerEvent) { } function handlePointerUp(event: PointerEvent) { - if (!canAnnotate || !drawStart || !drawRect) return; + if (!canDraw || !drawStart || !drawRect) return; const container = event.currentTarget as HTMLElement; const coords = getNormalizedCoords(event, container); @@ -100,7 +89,7 @@ function handlePointerUp(event: PointerEvent) { let hoveredId = $state(null); const containerStyle = $derived( - `position: absolute; top: 0; left: 0; width: 100%; height: 100%;${canAnnotate ? ' cursor: crosshair; touch-action: none;' : ''}` + `position: absolute; top: 0; left: 0; width: 100%; height: 100%;${canDraw ? ' cursor: crosshair; touch-action: none;' : ''}` ); @@ -117,9 +106,11 @@ const containerStyle = $derived( data-annotation role="button" tabindex="0" - aria-label="Kommentare anzeigen" + aria-label="Block anzeigen" onclick={() => onAnnotationClick?.(annotation.id)} - onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') onAnnotationClick?.(annotation.id); }} + onkeydown={(e) => { + if (e.key === 'Enter' || e.key === ' ') onAnnotationClick?.(annotation.id); + }} onpointerenter={() => (hoveredId = annotation.id)} onpointerleave={() => (hoveredId = null)} style=" @@ -130,73 +121,11 @@ const containerStyle = $derived( height: {annotation.height * 100}%; background-color: {hexToRgba(annotation.color, hoveredId === annotation.id ? 0.5 : 0.3)}; box-shadow: {hoveredId === annotation.id ? `inset 0 0 0 2px ${hexToRgba(annotation.color, 0.8)}` : 'none'}; - opacity: {isDimmed(annotation) ? 0.3 : 1}; - pointer-events: {isDimmed(annotation) ? 'none' : 'auto'}; - transition: background-color 0.15s ease, box-shadow 0.15s ease, opacity 0.3s ease; - {onAnnotationClick && !canAnnotate ? 'cursor: pointer;' : ''} + pointer-events: auto; + cursor: pointer; + transition: background-color 0.15s ease, box-shadow 0.15s ease; " - > - {#if canAnnotate} - - {/if} - {#if (commentCounts?.[annotation.id] ?? 0) > 0} -
- {commentCounts?.[annotation.id]} -
- {/if} -
+ >
{/each} {#if drawRect && drawRect.width > 0} diff --git a/frontend/src/lib/components/AnnotationLayer.svelte.spec.ts b/frontend/src/lib/components/AnnotationLayer.svelte.spec.ts index cc82e084..78befeca 100644 --- a/frontend/src/lib/components/AnnotationLayer.svelte.spec.ts +++ b/frontend/src/lib/components/AnnotationLayer.svelte.spec.ts @@ -18,7 +18,7 @@ type Annotation = { createdAt: string; }; -function makeAnnotation(id = 'ann-1'): Annotation { +function makeAnnotation(id = 'ann-1', color = '#00C7B1'): Annotation { return { id, documentId: 'doc-1', @@ -27,7 +27,7 @@ function makeAnnotation(id = 'ann-1'): Annotation { y: 0.1, width: 0.3, height: 0.2, - color: '#ff0000', + color, createdAt: new Date().toISOString() }; } @@ -36,87 +36,48 @@ describe('AnnotationLayer', () => { it('renders a colored element for each annotation', async () => { render(AnnotationLayer, { annotations: [makeAnnotation('ann-1'), makeAnnotation('ann-2')], - canAnnotate: false, - color: '#ff0000', - onDraw: () => {}, - onDelete: () => {} + canDraw: false, + color: '#00C7B1', + onDraw: () => {} }); await expect.element(page.getByTestId('annotation-ann-1')).toBeInTheDocument(); await expect.element(page.getByTestId('annotation-ann-2')).toBeInTheDocument(); }); - it('shows a delete button for each annotation when canAnnotate is true', async () => { - render(AnnotationLayer, { - annotations: [makeAnnotation('ann-1')], - canAnnotate: true, - color: '#ff0000', - onDraw: () => {}, - onDelete: () => {} - }); - - await expect - .element(page.getByRole('button', { name: /annotation löschen/i })) - .toBeInTheDocument(); - }); - - it('does not show delete buttons when canAnnotate is false', async () => { - render(AnnotationLayer, { - annotations: [makeAnnotation('ann-1')], - canAnnotate: false, - color: '#ff0000', - onDraw: () => {}, - onDelete: () => {} - }); - - expect(page.getByRole('button', { name: /annotation löschen/i }).query()).toBeNull(); - }); - - it('dims annotations matching dimColor', async () => { - render(AnnotationLayer, { - annotations: [makeAnnotation('ann-1')], - canAnnotate: false, - color: '#00C7B1', - dimColor: '#ff0000', - onDraw: () => {}, - onDelete: () => {} - }); - - const el = page.getByTestId('annotation-ann-1'); - await expect.element(el).toBeInTheDocument(); - const style = el.element().style; - expect(style.opacity).toBe('0.3'); - expect(style.pointerEvents).toBe('none'); - }); - - it('does not dim annotations that do not match dimColor', async () => { - const ann = makeAnnotation('ann-1'); - ann.color = '#00C7B1'; - render(AnnotationLayer, { - annotations: [ann], - canAnnotate: false, - color: '#00C7B1', - dimColor: '#ff0000', - onDraw: () => {}, - onDelete: () => {} - }); - - const el = page.getByTestId('annotation-ann-1'); - await expect.element(el).toBeInTheDocument(); - const style = el.element().style; - expect(style.opacity).toBe('1'); - }); - - it('has crosshair cursor when canAnnotate is true', async () => { + it('has crosshair cursor when canDraw is true', async () => { render(AnnotationLayer, { annotations: [], - canAnnotate: true, + canDraw: true, color: '#00C7B1', - onDraw: () => {}, - onDelete: () => {} + onDraw: () => {} }); const container = document.querySelector('[role="presentation"]')!; expect(container.getAttribute('style')).toContain('cursor: crosshair'); }); + + it('does not have crosshair cursor when canDraw is false', async () => { + render(AnnotationLayer, { + annotations: [], + canDraw: false, + color: '#00C7B1', + onDraw: () => {} + }); + + const container = document.querySelector('[role="presentation"]')!; + expect(container.getAttribute('style')).not.toContain('cursor: crosshair'); + }); + + it('does not show delete buttons (annotations owned by blocks)', async () => { + render(AnnotationLayer, { + annotations: [makeAnnotation('ann-1')], + canDraw: true, + color: '#00C7B1', + onDraw: () => {} + }); + + await expect.element(page.getByTestId('annotation-ann-1')).toBeInTheDocument(); + expect(page.getByRole('button', { name: /löschen/i }).query()).toBeNull(); + }); }); diff --git a/frontend/src/lib/components/DocumentTopBar.svelte b/frontend/src/lib/components/DocumentTopBar.svelte index b7b06b89..40874939 100644 --- a/frontend/src/lib/components/DocumentTopBar.svelte +++ b/frontend/src/lib/components/DocumentTopBar.svelte @@ -4,7 +4,6 @@ import { slide } from 'svelte/transition'; import { formatDate } from '$lib/utils/personFormat'; import { clickOutside } from '$lib/actions/clickOutside'; import PersonChipRow from './PersonChipRow.svelte'; -import AnnotateHintStrip from './AnnotateHintStrip.svelte'; import OverflowPillButton from './OverflowPillButton.svelte'; import DocumentMetadataDrawer from './DocumentMetadataDrawer.svelte'; @@ -28,20 +27,11 @@ type Doc = { type Props = { doc: Doc; canWrite: boolean; - canAnnotate: boolean; fileUrl: string; - annotateMode: boolean; transcribeMode: boolean; }; -let { - doc, - canWrite, - canAnnotate, - fileUrl, - annotateMode = $bindable(), - transcribeMode = $bindable() -}: Props = $props(); +let { doc, canWrite, fileUrl, transcribeMode = $bindable() }: Props = $props(); let detailsOpen = $state(false); @@ -56,55 +46,10 @@ const longDate = $derived(doc.documentDate ? formatDate(doc.documentDate, 'long' let mobileMenuOpen = $state(false); -{#snippet annotateBtn(mobile: boolean)} - -{/snippet} - -{#snippet annotateStopBtn(mobile: boolean)} - -{/snippet} - {#snippet transcribeBtn(mobile: boolean)}
- - {#if annotateMode} - - {/if} {#if annotations.length > 0} -
-
- -
-
- - -
- - - - -
-
-

- {m.comment_panel_title()} -

- -
-
- -
-
-
diff --git a/frontend/src/lib/components/AnnotationSidePanel.svelte b/frontend/src/lib/components/AnnotationSidePanel.svelte deleted file mode 100644 index 28d292e4..00000000 --- a/frontend/src/lib/components/AnnotationSidePanel.svelte +++ /dev/null @@ -1,68 +0,0 @@ - - -
- -
- - {m.doc_panel_discussion_annotation_tab({ page: String(activeAnnotationPage ?? '?') })} - - -
- - -
- {#if activeAnnotationId} - {#key activeAnnotationId} - - {/key} - {/if} -
-
diff --git a/frontend/src/lib/components/AnnotationSidePanel.svelte.spec.ts b/frontend/src/lib/components/AnnotationSidePanel.svelte.spec.ts deleted file mode 100644 index 84745470..00000000 --- a/frontend/src/lib/components/AnnotationSidePanel.svelte.spec.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { describe, it, expect, vi, afterEach } from 'vitest'; -import { cleanup, render } from 'vitest-browser-svelte'; -import { page } from 'vitest/browser'; -import AnnotationSidePanel from './AnnotationSidePanel.svelte'; - -afterEach(() => { - cleanup(); - vi.restoreAllMocks(); -}); - -vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: async () => [] - }) -); - -const baseProps = { - documentId: 'doc-1', - activeAnnotationPage: 1, - canComment: true, - currentUserId: 'user-1', - canAdmin: false, - onClose: vi.fn() -}; - -describe('AnnotationSidePanel – visibility', () => { - it('is hidden (translated off-screen) when activeAnnotationId is null', async () => { - render(AnnotationSidePanel, { ...baseProps, activeAnnotationId: null }); - const panel = document.querySelector('[data-testid="annotation-side-panel"]'); - expect(panel?.classList.contains('translate-x-full')).toBe(true); - expect(panel?.classList.contains('translate-x-0')).toBe(false); - }); - - it('is visible when activeAnnotationId is set', async () => { - render(AnnotationSidePanel, { ...baseProps, activeAnnotationId: 'ann-1' }); - const panel = document.querySelector('[data-testid="annotation-side-panel"]'); - expect(panel?.classList.contains('translate-x-0')).toBe(true); - expect(panel?.classList.contains('translate-x-full')).toBe(false); - }); -}); - -describe('AnnotationSidePanel – close button', () => { - it('calls onClose when the close button is clicked', async () => { - const onClose = vi.fn(); - render(AnnotationSidePanel, { ...baseProps, activeAnnotationId: 'ann-1', onClose }); - await page.getByRole('button', { name: /schließen/i }).click(); - expect(onClose).toHaveBeenCalledOnce(); - }); -}); - -describe('AnnotationSidePanel – targetCommentId forwarding', () => { - it('renders CommentThread when annotation is active', async () => { - render(AnnotationSidePanel, { - ...baseProps, - activeAnnotationId: 'ann-1', - targetCommentId: 'comment-42' - }); - // CommentThread renders inside the panel when activeAnnotationId is set - const panel = document.querySelector('[data-testid="annotation-side-panel"]'); - expect(panel).not.toBeNull(); - expect(panel?.classList.contains('translate-x-0')).toBe(true); - }); - - it('does not render CommentThread when annotation is null', async () => { - render(AnnotationSidePanel, { - ...baseProps, - activeAnnotationId: null, - targetCommentId: 'comment-42' - }); - // Panel is hidden and no fetch should have been triggered for comments - const panel = document.querySelector('[data-testid="annotation-side-panel"]'); - expect(panel?.classList.contains('translate-x-full')).toBe(true); - }); -}); diff --git a/frontend/src/lib/components/CommentThread.svelte b/frontend/src/lib/components/CommentThread.svelte index 4a1a4b13..f8e5495d 100644 --- a/frontend/src/lib/components/CommentThread.svelte +++ b/frontend/src/lib/components/CommentThread.svelte @@ -1,7 +1,7 @@ - -{#snippet commentEntry(comment: Comment | CommentReply, threadId: string, showReplyButton: boolean)} - {#if editingId === comment.id} -
- saveEdit(comment.id)} - /> -
- - -
-
- {:else} -
-
-
- {comment.authorName} - {#if wasEdited(comment)} - {timeAgo(comment.updatedAt)} {m.comment_edited_label()} - {:else} - {timeAgo(comment.createdAt)} - {/if} -
-

- - {@html renderBody(comment.content, comment.mentionDTOs ?? [])} -

-
- {#if canModify(comment)} -
- - -
- {/if} -
- {#if showReplyButton && canComment} -
- -
- {/if} - {/if} -{/snippet} - -
- {#each comments as thread, ti (thread.id)} -
0 ? 'border-t border-line pt-4' : ''}> - -
0} +
+
+ - {@render commentEntry(thread, thread.id, thread.replies.length === 0)} -
+ + + {flatMessages.length} + {flatMessages.length === 1 ? 'Kommentar' : 'Kommentare'} +
- - {#each thread.replies as reply, ri (reply.id)} -
- {@render commentEntry(reply, thread.id, ri === thread.replies.length - 1)} -
- {/each} - - - {#if replyingTo === thread.id} -
- postReply(thread.id)} - /> -
- - +
+ {#each flatMessages as msg (msg.id)} + {@const parsed = extractQuote(msg.content)} +
+
+ {getInitials(msg.authorName)} +
+
+
+ {msg.authorName} + {#if wasEdited(msg)} + {timeAgo(msg.updatedAt)} {m.comment_edited_label()} + {:else} + {timeAgo(msg.createdAt)} + {/if} +
+ {#if parsed.quote} +
+ “{parsed.quote}” +
+ {/if} +

+ + {@html renderBody(parsed.body, msg.mentionDTOs ?? [])} +

- {/if} + {/each}
- {/each} +
+{/if} - - {#if canComment && showCompose} -
0 ? 'border-t border-line pt-4' : ''}> -
- -
- -
-
+{#if canComment && showCompose} +
+
+
- {/if} -
+ +
+{/if} diff --git a/frontend/src/lib/components/PanelDiscussion.svelte b/frontend/src/lib/components/PanelDiscussion.svelte deleted file mode 100644 index 40d9af39..00000000 --- a/frontend/src/lib/components/PanelDiscussion.svelte +++ /dev/null @@ -1,36 +0,0 @@ - - -
- -
diff --git a/frontend/src/lib/components/PanelHistory.svelte b/frontend/src/lib/components/PanelHistory.svelte deleted file mode 100644 index 2be6f7aa..00000000 --- a/frontend/src/lib/components/PanelHistory.svelte +++ /dev/null @@ -1,519 +0,0 @@ - - -
- {#if historyLoading} -

{m.history_loading()}

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

{m.history_empty()}

- {:else} - -
- -
- - {#if compareMode} -
-
- - -
-
- - -
- -
- - - {#if diffLoading} -

{m.history_loading()}

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

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

- {:else if entry.kind === 'scalar'} -
- {entry.oldVal || '—'} - - {entry.newVal || '—'} -
- {:else if entry.kind === 'relation'} -
- {#each entry.removed as item (item)} - {item} - {/each} - {#each entry.added as item (item)} - {item} - {/each} -
- {/if} -
- {/each} -
- {/if} - {:else} - -
    - {#each versions as v, i (v.id)} -
  • - - - - {#if selectedVersionId === v.id} - {#if diffLoading} -

    {m.history_loading()}

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

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

    - {:else if entry.kind === 'scalar'} -
    - {entry.oldVal || '—'} - - {entry.newVal || '—'} -
    - {:else if entry.kind === 'relation'} -
    - {#each entry.removed as item (item)} - {item} - {/each} - {#each entry.added as item (item)} - {item} - {/each} -
    - {/if} -
    - {/each} -
    - {/if} - {/if} -
  • - {/each} -
- {/if} - {/if} -
diff --git a/frontend/src/lib/components/PanelMetadata.svelte b/frontend/src/lib/components/PanelMetadata.svelte deleted file mode 100644 index f8c2719c..00000000 --- a/frontend/src/lib/components/PanelMetadata.svelte +++ /dev/null @@ -1,198 +0,0 @@ - - -
diff --git a/frontend/src/lib/components/PanelTranscription.svelte b/frontend/src/lib/components/PanelTranscription.svelte deleted file mode 100644 index d8bc79a8..00000000 --- a/frontend/src/lib/components/PanelTranscription.svelte +++ /dev/null @@ -1,38 +0,0 @@ - - -
-
- {#if !doc.summary && !doc.transcription} -

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

{doc.summary}

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

- {doc.transcription} -

-
- {/if} -
-
diff --git a/frontend/src/lib/components/TranscriptionBlock.svelte b/frontend/src/lib/components/TranscriptionBlock.svelte index dd7caf05..371401b4 100644 --- a/frontend/src/lib/components/TranscriptionBlock.svelte +++ b/frontend/src/lib/components/TranscriptionBlock.svelte @@ -13,7 +13,6 @@ type Props = { active: boolean; saveState: SaveState; canComment: boolean; - currentUserId: string | null; onTextChange: (text: string) => void; onFocus: () => void; onDeleteClick: () => void; @@ -29,7 +28,6 @@ let { active, saveState, canComment, - currentUserId, onTextChange, onFocus, onDeleteClick, @@ -204,8 +202,6 @@ function captureSelectionAndOpenComments() { blockId={blockId} loadOnMount={true} canComment={canComment} - currentUserId={currentUserId} - canAdmin={false} quotedText={selectedQuote} showCompose={commentOpen} /> diff --git a/frontend/src/lib/components/TranscriptionBlock.svelte.spec.ts b/frontend/src/lib/components/TranscriptionBlock.svelte.spec.ts index 7489ed52..6a166861 100644 --- a/frontend/src/lib/components/TranscriptionBlock.svelte.spec.ts +++ b/frontend/src/lib/components/TranscriptionBlock.svelte.spec.ts @@ -15,7 +15,6 @@ function renderBlock(overrides: Record = {}) { active: false, saveState: 'idle' as const, canComment: true, - currentUserId: 'user-1', onTextChange: vi.fn(), onFocus: vi.fn(), onDeleteClick: vi.fn(), diff --git a/frontend/src/lib/components/TranscriptionEditView.svelte b/frontend/src/lib/components/TranscriptionEditView.svelte index 4bb02688..cdcc4b67 100644 --- a/frontend/src/lib/components/TranscriptionEditView.svelte +++ b/frontend/src/lib/components/TranscriptionEditView.svelte @@ -10,21 +10,12 @@ type Props = { documentId: string; blocks: TranscriptionBlockData[]; canComment: boolean; - currentUserId: string | null; onBlockFocus: (blockId: string) => void; onSaveBlock: (blockId: string, text: string) => Promise; onDeleteBlock: (blockId: string) => Promise; }; -let { - documentId, - blocks, - canComment, - currentUserId, - onBlockFocus, - onSaveBlock, - onDeleteBlock -}: Props = $props(); +let { documentId, blocks, canComment, onBlockFocus, onSaveBlock, onDeleteBlock }: Props = $props(); let activeBlockId: string | null = $state(null); let saveStates = new SvelteMap(); @@ -165,7 +156,6 @@ $effect(() => { active={activeBlockId === block.id} saveState={getSaveState(block.id)} canComment={canComment} - currentUserId={currentUserId} onTextChange={(text) => handleTextChange(block.id, text)} onFocus={() => handleFocus(block.id)} onDeleteClick={() => handleDelete(block.id)} diff --git a/frontend/src/routes/documents/[id]/+page.svelte b/frontend/src/routes/documents/[id]/+page.svelte index 7864ce00..27dd403c 100644 --- a/frontend/src/routes/documents/[id]/+page.svelte +++ b/frontend/src/routes/documents/[id]/+page.svelte @@ -9,7 +9,6 @@ let { data } = $props(); const doc = $derived(data.document); const canWrite = $derived(data.canWrite ?? false); -const currentUserId = $derived((data.user?.id as string | undefined) ?? null); // ── File loading ────────────────────────────────────────────────────────────── @@ -218,7 +217,6 @@ onMount(() => { documentId={doc.id} blocks={transcriptionBlocks} canComment={canWrite} - currentUserId={currentUserId} onBlockFocus={handleBlockFocus} onSaveBlock={saveBlock} onDeleteBlock={deleteBlock} -- 2.49.1 From e384c87eefb2aa22689fe131a4b094a1e494bd29 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 5 Apr 2026 22:25:46 +0200 Subject: [PATCH 29/47] =?UTF-8?q?refactor(comments):=20streamline=20input?= =?UTF-8?q?=20=E2=80=94=20Enter=20to=20send,=20no=20buttons?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MentionEditor: Enter sends (Shift+Enter for newline), remove @ button - CommentThread: remove send button, full-width input, always show input when comments exist (no need to click Kommentieren first) - TranscriptionBlock: remove border-t above comment section (orange background provides enough visual separation) - Update placeholder in all languages to hint @mention and Enter to send Co-Authored-By: Claude Sonnet 4.6 --- frontend/messages/de.json | 2 +- frontend/messages/en.json | 2 +- frontend/messages/es.json | 2 +- .../src/lib/components/CommentThread.svelte | 29 +++++--------- .../src/lib/components/MentionEditor.svelte | 40 +------------------ .../lib/components/TranscriptionBlock.svelte | 2 +- 6 files changed, 16 insertions(+), 61 deletions(-) diff --git a/frontend/messages/de.json b/frontend/messages/de.json index e78993f7..0e9e45d4 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -315,7 +315,7 @@ "comp_expandable_show_less": "Weniger anzeigen", "error_comment_not_found": "Der Kommentar wurde nicht gefunden.", "comment_section_title": "Diskussion", - "comment_placeholder": "Kommentar schreiben…", + "comment_placeholder": "Kommentar schreiben… (@Name erwähnen · Enter senden)", "comment_btn_post": "Senden", "comment_btn_reply": "Antworten", "comment_edited_label": "(Bearbeitet)", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 63158327..6516439c 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -315,7 +315,7 @@ "comp_expandable_show_less": "Show less", "error_comment_not_found": "The comment could not be found.", "comment_section_title": "Discussion", - "comment_placeholder": "Write a comment…", + "comment_placeholder": "Write a comment… (@name to mention · Enter to send)", "comment_btn_post": "Send", "comment_btn_reply": "Reply", "comment_edited_label": "(Edited)", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index f5e7c4cf..628845ad 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -315,7 +315,7 @@ "comp_expandable_show_less": "Mostrar menos", "error_comment_not_found": "El comentario no pudo encontrarse.", "comment_section_title": "Discusión", - "comment_placeholder": "Escribe un comentario…", + "comment_placeholder": "Escribe un comentario… (@nombre para mencionar · Enter para enviar)", "comment_btn_post": "Enviar", "comment_btn_reply": "Responder", "comment_edited_label": "(Editado)", diff --git a/frontend/src/lib/components/CommentThread.svelte b/frontend/src/lib/components/CommentThread.svelte index f8e5495d..707b2409 100644 --- a/frontend/src/lib/components/CommentThread.svelte +++ b/frontend/src/lib/components/CommentThread.svelte @@ -196,24 +196,15 @@ onMount(() => {
{/if} -{#if canComment && showCompose} -
-
- -
- +{#if canComment && (showCompose || flatMessages.length > 0)} +
+
{/if} diff --git a/frontend/src/lib/components/MentionEditor.svelte b/frontend/src/lib/components/MentionEditor.svelte index a97c018f..52b1ac74 100644 --- a/frontend/src/lib/components/MentionEditor.svelte +++ b/frontend/src/lib/components/MentionEditor.svelte @@ -115,7 +115,8 @@ function closePopup() { } function handleKeydown(e: KeyboardEvent) { - if (e.ctrlKey && e.key === 'Enter') { + // Enter sends, Shift+Enter adds newline + if (e.key === 'Enter' && !e.shiftKey && query === null) { e.preventDefault(); onsubmit?.(); return; @@ -152,33 +153,6 @@ function handleKeydown(e: KeyboardEvent) { } } -async function handleAtButtonClick() { - if (!textarea) return; - const pos = textarea.selectionStart; - const before = value.slice(0, pos); - const after = value.slice(pos); - // Ensure @ is preceded by whitespace or is at the start - const needsSpace = before.length > 0 && !/\s$/.test(before); - const insertion = needsSpace ? ' @' : '@'; - value = before + insertion + after; - - await tick(); - if (!textarea) return; - const newPos = pos + insertion.length; - textarea.selectionStart = newPos; - textarea.selectionEnd = newPos; - textarea.focus(); - - // Trigger mention detection after inserting @ - const detected = detectMention(value, newPos); - if (detected !== null) { - mentionStart = newPos - 1; - query = detected; - highlightedIndex = 0; - scheduleSearch(detected); - } -} - onDestroy(() => clearTimeout(debounceTimer)); const popupOpen = $derived(query !== null); @@ -224,14 +198,4 @@ const popupOpen = $derived(query !== null); {/if}
{/if} - -
diff --git a/frontend/src/lib/components/TranscriptionBlock.svelte b/frontend/src/lib/components/TranscriptionBlock.svelte index 371401b4..3027c8d6 100644 --- a/frontend/src/lib/components/TranscriptionBlock.svelte +++ b/frontend/src/lib/components/TranscriptionBlock.svelte @@ -196,7 +196,7 @@ function captureSelectionAndOpenComments() {
-
+
Date: Sun, 5 Apr 2026 22:30:13 +0200 Subject: [PATCH 30/47] fix(transcription): auto-capture quote on text selection, smart comment button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Quote captured automatically on mouseup in textarea (no button needed) Selection is held in state and pre-fills the comment input - "Kommentieren" button only shown when zero comments exist When comments are present, the input is already visible — button is noise - Chat bubble icon added to Kommentieren button for visual consistency Co-Authored-By: Claude Sonnet 4.6 --- .../lib/components/TranscriptionBlock.svelte | 57 ++++++++++++------- .../TranscriptionBlock.svelte.spec.ts | 13 +---- 2 files changed, 36 insertions(+), 34 deletions(-) diff --git a/frontend/src/lib/components/TranscriptionBlock.svelte b/frontend/src/lib/components/TranscriptionBlock.svelte index 3027c8d6..55b068ad 100644 --- a/frontend/src/lib/components/TranscriptionBlock.svelte +++ b/frontend/src/lib/components/TranscriptionBlock.svelte @@ -36,9 +36,12 @@ let { let localText = $state(text); let commentOpen = $state(false); +let commentCount = $state(0); let selectedQuote = $state(null); let textareaEl = $state(null); +const hasComments = $derived(commentCount > 0); + // Sync from prop only when switching to a different block (not on save responses) let prevBlockId = $state(blockId); $effect(() => { @@ -83,17 +86,15 @@ function handleDelete() { } } -function captureSelectionAndOpenComments() { - if (textareaEl) { - const start = textareaEl.selectionStart; - const end = textareaEl.selectionEnd; - if (start !== end) { - selectedQuote = localText.substring(start, end); - } else { - selectedQuote = null; - } +function handleTextareaMouseUp() { + if (!textareaEl) return; + const start = textareaEl.selectionStart; + const end = textareaEl.selectionEnd; + if (start !== end) { + selectedQuote = localText.substring(start, end); + } else { + selectedQuote = null; } - commentOpen = true; } @@ -126,22 +127,33 @@ function captureSelectionAndOpenComments() { value={localText} oninput={handleInput} onfocus={onFocus} + onmouseup={handleTextareaMouseUp} >
-
- - {#if active} - - {m.transcription_block_quote_hint()} - +
+ {#if !hasComments} + {/if}
@@ -204,6 +216,7 @@ function captureSelectionAndOpenComments() { canComment={canComment} quotedText={selectedQuote} showCompose={commentOpen} + onCountChange={(count) => (commentCount = count)} />
diff --git a/frontend/src/lib/components/TranscriptionBlock.svelte.spec.ts b/frontend/src/lib/components/TranscriptionBlock.svelte.spec.ts index 6a166861..ddae063f 100644 --- a/frontend/src/lib/components/TranscriptionBlock.svelte.spec.ts +++ b/frontend/src/lib/components/TranscriptionBlock.svelte.spec.ts @@ -114,20 +114,9 @@ describe('TranscriptionBlock — interactions', () => { expect(onFocus).toHaveBeenCalled(); }); - it('shows Kommentieren button that opens comment thread', async () => { + it('shows Kommentieren button when no comments exist', async () => { renderBlock(); const btn = page.getByText('Kommentieren'); await expect.element(btn).toBeInTheDocument(); }); - - it('shows quote hint when block is active', async () => { - renderBlock({ active: true }); - await expect.element(page.getByText('Text markieren für Zitat')).toBeInTheDocument(); - }); - - it('hides quote hint when block is not active', async () => { - renderBlock({ active: false }); - const hint = page.getByText('Text markieren für Zitat'); - await expect.element(hint).not.toBeInTheDocument(); - }); }); -- 2.49.1 From 82d5a34f76caa1e369ee0701aaf1485f8d458923 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 5 Apr 2026 22:32:01 +0200 Subject: [PATCH 31/47] fix(comments): use semantic tokens for comment box dark mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace hardcoded Tailwind orange colors with semantic tokens: border-accent, bg-muted, text-ink-2 — adapts to light/dark mode via CSS custom properties instead of Tailwind dark: prefix. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/components/CommentThread.svelte | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/frontend/src/lib/components/CommentThread.svelte b/frontend/src/lib/components/CommentThread.svelte index 707b2409..bd9f66bd 100644 --- a/frontend/src/lib/components/CommentThread.svelte +++ b/frontend/src/lib/components/CommentThread.svelte @@ -137,12 +137,8 @@ onMount(() => { {#if flatMessages.length > 0} -
-
+
+
Date: Sun, 5 Apr 2026 22:33:42 +0200 Subject: [PATCH 32/47] fix(comments): increase text size for readability Bump comment body and quote from text-xs (12px) to text-sm (14px). Bump author name from text-xs to text-sm, timestamp from 10px to text-xs. Improves readability especially for 60+ target users. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/components/CommentThread.svelte | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/frontend/src/lib/components/CommentThread.svelte b/frontend/src/lib/components/CommentThread.svelte index bd9f66bd..00ca5ab3 100644 --- a/frontend/src/lib/components/CommentThread.svelte +++ b/frontend/src/lib/components/CommentThread.svelte @@ -138,7 +138,7 @@ onMount(() => { {#if flatMessages.length > 0}
-
+
{
- {msg.authorName} + {msg.authorName} {#if wasEdited(msg)} - {timeAgo(msg.updatedAt)} {m.comment_edited_label()} {:else} - {timeAgo(msg.createdAt)} + {timeAgo(msg.createdAt)} {/if}
{#if parsed.quote} -
+
“{parsed.quote}”
{/if} -

+

{@html renderBody(parsed.body, msg.mentionDTOs ?? [])}

-- 2.49.1 From e6432846a135e3d15c01605961b53713208497e8 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 5 Apr 2026 22:35:57 +0200 Subject: [PATCH 33/47] fix(topbar): use brand navy for transcribe button, not turquoise Transcribe button now uses border-primary/bg-primary/text-primary-fg matching the other action buttons (Bearbeiten). Turquoise is reserved for annotation overlays and block focus borders on the PDF. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/components/DocumentTopBar.svelte | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/lib/components/DocumentTopBar.svelte b/frontend/src/lib/components/DocumentTopBar.svelte index 40874939..7d610706 100644 --- a/frontend/src/lib/components/DocumentTopBar.svelte +++ b/frontend/src/lib/components/DocumentTopBar.svelte @@ -56,7 +56,7 @@ let mobileMenuOpen = $state(false); aria-pressed={false} class={mobile ? 'flex w-full items-center gap-2 rounded px-3 py-2 text-left text-[16px] text-ink transition hover:bg-muted focus-visible:ring-2 focus-visible:ring-primary' - : 'hidden items-center gap-1.5 rounded border border-turquoise px-3 py-1.5 font-sans text-[16px] font-medium text-ink transition hover:bg-turquoise hover:text-turquoise-fg focus-visible:ring-2 focus-visible:ring-primary md:flex'} + : 'hidden items-center gap-1.5 rounded border border-primary px-3 py-1.5 font-sans text-[16px] font-medium text-ink transition hover:bg-primary hover:text-primary-fg focus-visible:ring-2 focus-visible:ring-primary md:flex'} > Date: Sun, 5 Apr 2026 22:42:24 +0200 Subject: [PATCH 34/47] feat(comments): inline edit on click + trash icon for own comments Own comments: - Click the text to open inline edit (textarea replaces text) - Enter saves, Escape cancels - Small trash icon always visible in bottom-right corner - Hover on text shows cursor-text + subtle bg highlight Other people's comments: read-only, no edit/delete affordances. Re-add currentUserId prop chain for ownership check. Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/components/CommentThread.svelte | 109 +++++++++++++++++- .../lib/components/TranscriptionBlock.svelte | 3 + .../TranscriptionBlock.svelte.spec.ts | 1 + .../components/TranscriptionEditView.svelte | 12 +- .../src/routes/documents/[id]/+page.svelte | 2 + 5 files changed, 122 insertions(+), 5 deletions(-) diff --git a/frontend/src/lib/components/CommentThread.svelte b/frontend/src/lib/components/CommentThread.svelte index 00ca5ab3..8725bd09 100644 --- a/frontend/src/lib/components/CommentThread.svelte +++ b/frontend/src/lib/components/CommentThread.svelte @@ -13,6 +13,7 @@ type Props = { initialComments?: Comment[]; loadOnMount?: boolean; canComment: boolean; + currentUserId: string | null; quotedText?: string | null; showCompose?: boolean; onCountChange?: (count: number) => void; @@ -25,6 +26,7 @@ let { initialComments = [], loadOnMount = false, canComment, + currentUserId = null, quotedText = null, showCompose = true, onCountChange @@ -44,6 +46,8 @@ let comments: Comment[] = $state(untrack(() => [...initialComments])); let newText: string = $state(''); let posting: boolean = $state(false); let newMentionCandidates: MentionDTO[] = $state([]); +let editingId: string | null = $state(null); +let editText: string = $state(''); const commentsBase = $derived( blockId @@ -78,6 +82,10 @@ function wasEdited(c: { createdAt: string; updatedAt: string }): boolean { return c.updatedAt > c.createdAt; } +function isOwn(c: { authorId: string | null }): boolean { + return currentUserId !== null && c.authorId === currentUserId; +} + function getInitials(name: string): string { return name .split(/\s+/) @@ -126,6 +134,60 @@ async function postComment() { } } +function startEdit(msg: FlatMessage) { + editingId = msg.id; + editText = msg.content; +} + +async function saveEdit(commentId: string) { + const text = editText.trim(); + if (!text || posting) return; + posting = true; + try { + const res = await fetch(`/api/documents/${documentId}/comments/${commentId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ content: text }) + }); + if (res.ok) { + editingId = null; + editText = ''; + await reload(); + } + } finally { + posting = false; + } +} + +function cancelEdit() { + editingId = null; + editText = ''; +} + +function handleEditKeydown(e: KeyboardEvent, commentId: string) { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + saveEdit(commentId); + } else if (e.key === 'Escape') { + cancelEdit(); + } +} + +async function deleteComment(commentId: string) { + if (posting) return; + posting = true; + try { + const res = await fetch(`/api/documents/${documentId}/comments/${commentId}`, { + method: 'DELETE' + }); + if (res.ok) { + await reload(); + } + } finally { + posting = false; + } +} + onMount(() => { if (loadOnMount) { reload(); @@ -181,10 +243,49 @@ onMount(() => { “{parsed.quote}”
{/if} -

- - {@html renderBody(parsed.body, msg.mentionDTOs ?? [])} -

+ + {#if editingId === msg.id} + +
Enter speichern · Esc abbrechen
+ {:else} + + +
{ if (isOwn(msg)) startEdit(msg); }}> +

+ + {@html renderBody(parsed.body, msg.mentionDTOs ?? [])} +

+ {#if isOwn(msg)} + + {/if} +
+ {/if}
{/each} diff --git a/frontend/src/lib/components/TranscriptionBlock.svelte b/frontend/src/lib/components/TranscriptionBlock.svelte index 55b068ad..582486ef 100644 --- a/frontend/src/lib/components/TranscriptionBlock.svelte +++ b/frontend/src/lib/components/TranscriptionBlock.svelte @@ -13,6 +13,7 @@ type Props = { active: boolean; saveState: SaveState; canComment: boolean; + currentUserId: string | null; onTextChange: (text: string) => void; onFocus: () => void; onDeleteClick: () => void; @@ -28,6 +29,7 @@ let { active, saveState, canComment, + currentUserId, onTextChange, onFocus, onDeleteClick, @@ -214,6 +216,7 @@ function handleTextareaMouseUp() { blockId={blockId} loadOnMount={true} canComment={canComment} + currentUserId={currentUserId} quotedText={selectedQuote} showCompose={commentOpen} onCountChange={(count) => (commentCount = count)} diff --git a/frontend/src/lib/components/TranscriptionBlock.svelte.spec.ts b/frontend/src/lib/components/TranscriptionBlock.svelte.spec.ts index ddae063f..5e2af323 100644 --- a/frontend/src/lib/components/TranscriptionBlock.svelte.spec.ts +++ b/frontend/src/lib/components/TranscriptionBlock.svelte.spec.ts @@ -15,6 +15,7 @@ function renderBlock(overrides: Record = {}) { active: false, saveState: 'idle' as const, canComment: true, + currentUserId: 'user-1', onTextChange: vi.fn(), onFocus: vi.fn(), onDeleteClick: vi.fn(), diff --git a/frontend/src/lib/components/TranscriptionEditView.svelte b/frontend/src/lib/components/TranscriptionEditView.svelte index cdcc4b67..4bb02688 100644 --- a/frontend/src/lib/components/TranscriptionEditView.svelte +++ b/frontend/src/lib/components/TranscriptionEditView.svelte @@ -10,12 +10,21 @@ type Props = { documentId: string; blocks: TranscriptionBlockData[]; canComment: boolean; + currentUserId: string | null; onBlockFocus: (blockId: string) => void; onSaveBlock: (blockId: string, text: string) => Promise; onDeleteBlock: (blockId: string) => Promise; }; -let { documentId, blocks, canComment, onBlockFocus, onSaveBlock, onDeleteBlock }: Props = $props(); +let { + documentId, + blocks, + canComment, + currentUserId, + onBlockFocus, + onSaveBlock, + onDeleteBlock +}: Props = $props(); let activeBlockId: string | null = $state(null); let saveStates = new SvelteMap(); @@ -156,6 +165,7 @@ $effect(() => { active={activeBlockId === block.id} saveState={getSaveState(block.id)} canComment={canComment} + currentUserId={currentUserId} onTextChange={(text) => handleTextChange(block.id, text)} onFocus={() => handleFocus(block.id)} onDeleteClick={() => handleDelete(block.id)} diff --git a/frontend/src/routes/documents/[id]/+page.svelte b/frontend/src/routes/documents/[id]/+page.svelte index 27dd403c..7864ce00 100644 --- a/frontend/src/routes/documents/[id]/+page.svelte +++ b/frontend/src/routes/documents/[id]/+page.svelte @@ -9,6 +9,7 @@ let { data } = $props(); const doc = $derived(data.document); const canWrite = $derived(data.canWrite ?? false); +const currentUserId = $derived((data.user?.id as string | undefined) ?? null); // ── File loading ────────────────────────────────────────────────────────────── @@ -217,6 +218,7 @@ onMount(() => { documentId={doc.id} blocks={transcriptionBlocks} canComment={canWrite} + currentUserId={currentUserId} onBlockFocus={handleBlockFocus} onSaveBlock={saveBlock} onDeleteBlock={deleteBlock} -- 2.49.1 From cef1810700db033903651ecd9c108dde9f20e536 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 5 Apr 2026 22:43:37 +0200 Subject: [PATCH 35/47] fix(comments): stop Escape propagation in edit mode Pressing Escape while editing a comment now only cancels the edit, without propagating to the parent (which closes the transcribe panel). Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/components/CommentThread.svelte | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/lib/components/CommentThread.svelte b/frontend/src/lib/components/CommentThread.svelte index 8725bd09..c4b78747 100644 --- a/frontend/src/lib/components/CommentThread.svelte +++ b/frontend/src/lib/components/CommentThread.svelte @@ -169,6 +169,7 @@ function handleEditKeydown(e: KeyboardEvent, commentId: string) { e.preventDefault(); saveEdit(commentId); } else if (e.key === 'Escape') { + e.stopPropagation(); cancelEdit(); } } -- 2.49.1 From 6dc81ef2e39367c97a1baa39c31d772da5453cce Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 5 Apr 2026 22:46:41 +0200 Subject: [PATCH 36/47] fix(ui): match delete icon size + add cursor-pointer to interactive elements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Comment delete icon: h-3 w-3 → h-4 w-4 (matches block delete icon) - Add cursor-pointer to: comment delete button, Kommentieren button, block delete button, own-comment click-to-edit text - Add title tooltip on comment delete button Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/components/CommentThread.svelte | 5 +++-- frontend/src/lib/components/TranscriptionBlock.svelte | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/frontend/src/lib/components/CommentThread.svelte b/frontend/src/lib/components/CommentThread.svelte index c4b78747..2f836822 100644 --- a/frontend/src/lib/components/CommentThread.svelte +++ b/frontend/src/lib/components/CommentThread.svelte @@ -266,12 +266,13 @@ onMount(() => { {#if isOwn(msg)}
{/each} + + +
+ {m.transcription_next_block_cta({ number: sortedBlocks.length + 1 })} +
{:else}
-- 2.49.1 From 7d2d615e0c03319e62f38302956e6d00e8ba57c1 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 5 Apr 2026 23:00:52 +0200 Subject: [PATCH 38/47] feat(transcription): add drag-and-drop + arrow button reordering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TranscriptionBlock: - Desktop: grip handle (⠿) on left side, serves as drag handle - Mobile (<768px): ▲/▼ arrow buttons (44px tap targets) replace grip - isFirst/isLast disable boundary arrows - onMoveUp/onMoveDown callbacks for arrow button clicks TranscriptionEditView: - HTML5 drag-and-drop on block wrappers (only initiates from grip handle) - Dragged block shows 40% opacity - On drop: reorder array and call PUT /reorder endpoint - Arrow handlers: swap adjacent blocks and call reorder endpoint 5 new tests: - drag handle element present - move-up disabled when isFirst - move-down disabled when isLast - onMoveUp fires on click - onMoveDown fires on click Co-Authored-By: Claude Sonnet 4.6 --- .../lib/components/TranscriptionBlock.svelte | 51 ++++++++++- .../TranscriptionBlock.svelte.spec.ts | 38 ++++++++ .../components/TranscriptionEditView.svelte | 91 ++++++++++++++++++- 3 files changed, 176 insertions(+), 4 deletions(-) diff --git a/frontend/src/lib/components/TranscriptionBlock.svelte b/frontend/src/lib/components/TranscriptionBlock.svelte index 73caa2d1..3da162b5 100644 --- a/frontend/src/lib/components/TranscriptionBlock.svelte +++ b/frontend/src/lib/components/TranscriptionBlock.svelte @@ -18,6 +18,10 @@ type Props = { onFocus: () => void; onDeleteClick: () => void; onRetry: () => void; + onMoveUp?: () => void; + onMoveDown?: () => void; + isFirst?: boolean; + isLast?: boolean; }; let { @@ -33,7 +37,11 @@ let { onTextChange, onFocus, onDeleteClick, - onRetry + onRetry, + onMoveUp, + onMoveDown, + isFirst = false, + isLast = false }: Props = $props(); let localText = $state(text); @@ -101,7 +109,7 @@ function handleTextareaMouseUp() {
@@ -110,7 +118,44 @@ function handleTextareaMouseUp() { > {blockNumber} -
+ + +
+ + + + + + +
+ +
{#if label} diff --git a/frontend/src/lib/components/TranscriptionBlock.svelte.spec.ts b/frontend/src/lib/components/TranscriptionBlock.svelte.spec.ts index 5e2af323..1f305694 100644 --- a/frontend/src/lib/components/TranscriptionBlock.svelte.spec.ts +++ b/frontend/src/lib/components/TranscriptionBlock.svelte.spec.ts @@ -121,3 +121,41 @@ describe('TranscriptionBlock — interactions', () => { await expect.element(btn).toBeInTheDocument(); }); }); + +// ─── Reorder controls ──────────────────────────────────────────────────────── + +describe('TranscriptionBlock — reorder controls', () => { + it('shows a drag handle element', async () => { + renderBlock(); + const handle = document.querySelector('[data-drag-handle]'); + expect(handle).not.toBeNull(); + }); + + it('disables move-up button when isFirst', async () => { + renderBlock({ isFirst: true }); + const btn = page.getByRole('button', { name: 'Nach oben' }); + await expect.element(btn).toBeDisabled(); + }); + + it('disables move-down button when isLast', async () => { + renderBlock({ isLast: true }); + const btn = page.getByRole('button', { name: 'Nach unten' }); + await expect.element(btn).toBeDisabled(); + }); + + it('calls onMoveUp when up arrow clicked', async () => { + const onMoveUp = vi.fn(); + renderBlock({ onMoveUp, isFirst: false }); + const btn = page.getByRole('button', { name: 'Nach oben' }); + await btn.click(); + expect(onMoveUp).toHaveBeenCalled(); + }); + + it('calls onMoveDown when down arrow clicked', async () => { + const onMoveDown = vi.fn(); + renderBlock({ onMoveDown, isLast: false }); + const btn = page.getByRole('button', { name: 'Nach unten' }); + await btn.click(); + expect(onMoveDown).toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/lib/components/TranscriptionEditView.svelte b/frontend/src/lib/components/TranscriptionEditView.svelte index 74d13c94..edad6189 100644 --- a/frontend/src/lib/components/TranscriptionEditView.svelte +++ b/frontend/src/lib/components/TranscriptionEditView.svelte @@ -125,6 +125,82 @@ function handleDelete(blockId: string) { onDeleteBlock(blockId); } +async function reorder(newOrder: string[]) { + try { + const res = await fetch(`/api/documents/${documentId}/transcription-blocks/reorder`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ blockIds: newOrder }) + }); + if (!res.ok) return; + const updated = await res.json(); + // Update blocks with new sort orders from server + for (const b of updated) { + const existing = blocks.find((x) => x.id === b.id); + if (existing) existing.sortOrder = b.sortOrder; + } + } catch { + // ignore + } +} + +function handleMoveUp(blockId: string) { + const sorted = [...sortedBlocks]; + const idx = sorted.findIndex((b) => b.id === blockId); + if (idx <= 0) return; + [sorted[idx - 1], sorted[idx]] = [sorted[idx], sorted[idx - 1]]; + reorder(sorted.map((b) => b.id)); +} + +function handleMoveDown(blockId: string) { + const sorted = [...sortedBlocks]; + const idx = sorted.findIndex((b) => b.id === blockId); + if (idx < 0 || idx >= sorted.length - 1) return; + [sorted[idx], sorted[idx + 1]] = [sorted[idx + 1], sorted[idx]]; + reorder(sorted.map((b) => b.id)); +} + +// ── Drag and drop ──────────────────────────────────────────────────────── + +let draggedBlockId: string | null = $state(null); + +function handleDragStart(e: DragEvent, blockId: string) { + if (!(e.target as HTMLElement).closest('[data-drag-handle]')) { + e.preventDefault(); + return; + } + draggedBlockId = blockId; + e.dataTransfer!.effectAllowed = 'move'; +} + +function handleDragOver(e: DragEvent) { + e.preventDefault(); + e.dataTransfer!.dropEffect = 'move'; +} + +function handleDrop(e: DragEvent, targetBlockId: string) { + e.preventDefault(); + if (!draggedBlockId || draggedBlockId === targetBlockId) { + draggedBlockId = null; + return; + } + const sorted = [...sortedBlocks]; + const fromIdx = sorted.findIndex((b) => b.id === draggedBlockId); + const toIdx = sorted.findIndex((b) => b.id === targetBlockId); + if (fromIdx < 0 || toIdx < 0) { + draggedBlockId = null; + return; + } + const [moved] = sorted.splice(fromIdx, 1); + sorted.splice(toIdx, 0, moved); + draggedBlockId = null; + reorder(sorted.map((b) => b.id)); +} + +function handleDragEnd() { + draggedBlockId = null; +} + function flushViaBeacon() { for (const [blockId, text] of pendingTexts) { clearDebounce(blockId); @@ -155,7 +231,16 @@ $effect(() => { {#if hasBlocks}
{#each sortedBlocks as block, i (block.id)} -
+ +
handleDragStart(e, block.id)} + ondragover={handleDragOver} + ondrop={(e) => handleDrop(e, block.id)} + ondragend={handleDragEnd} + class={draggedBlockId === block.id ? 'opacity-40' : ''} + > { onFocus={() => handleFocus(block.id)} onDeleteClick={() => handleDelete(block.id)} onRetry={() => handleRetry(block.id)} + onMoveUp={() => handleMoveUp(block.id)} + onMoveDown={() => handleMoveDown(block.id)} + isFirst={i === 0} + isLast={i === sortedBlocks.length - 1} />
{/each} -- 2.49.1 From c22f2e41b197bd514c787fe4434c46d118ee0f12 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 5 Apr 2026 23:07:42 +0200 Subject: [PATCH 39/47] fix(transcription): replace broken HTML5 drag with pointer-based drag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HTML5 drag-and-drop didn't work — the grip handle couldn't initiate drag properly. Replace with pointer event-based drag: - Grip handle pointerdown starts drag, captures pointer - Pointermove tracks offset, shows floaty style (shadow, scale, ring) - Turquoise drop indicator line appears between blocks at cursor position - Pointerup finalizes: reorders array and calls PUT /reorder endpoint Visual feedback: - Dragged block: shadow-xl, ring-2 ring-turquoise/40, scale 1.02, opacity 0.9 - Drop indicator: turquoise h-1 rounded bar between blocks 6 new TranscriptionEditView tests: - renders blocks in sort order - shows next-block CTA - shows empty state - move-up disabled on first block - move-down disabled on last block - drag handle present on each block Co-Authored-By: Claude Sonnet 4.6 --- .../components/TranscriptionEditView.svelte | 99 ++++++++++++------- .../TranscriptionEditView.svelte.spec.ts | 78 +++++++++++++++ 2 files changed, 142 insertions(+), 35 deletions(-) create mode 100644 frontend/src/lib/components/TranscriptionEditView.svelte.spec.ts diff --git a/frontend/src/lib/components/TranscriptionEditView.svelte b/frontend/src/lib/components/TranscriptionEditView.svelte index edad6189..44a21288 100644 --- a/frontend/src/lib/components/TranscriptionEditView.svelte +++ b/frontend/src/lib/components/TranscriptionEditView.svelte @@ -160,45 +160,63 @@ function handleMoveDown(blockId: string) { reorder(sorted.map((b) => b.id)); } -// ── Drag and drop ──────────────────────────────────────────────────────── +// ── Pointer-based drag and drop ────────────────────────────────────────── let draggedBlockId: string | null = $state(null); +let dropTargetIdx: number | null = $state(null); +let dragOffsetY: number = $state(0); +let dragStartY = 0; +let capturedEl: HTMLElement | null = null; +let listEl: HTMLElement | null = null; -function handleDragStart(e: DragEvent, blockId: string) { - if (!(e.target as HTMLElement).closest('[data-drag-handle]')) { - e.preventDefault(); - return; - } +function handleGripDown(e: PointerEvent, blockId: string) { + if (!(e.target as HTMLElement).closest('[data-drag-handle]')) return; + e.preventDefault(); draggedBlockId = blockId; - e.dataTransfer!.effectAllowed = 'move'; + dragStartY = e.clientY; + dragOffsetY = 0; + capturedEl = (e.target as HTMLElement).closest('[data-block-wrapper]') as HTMLElement; + capturedEl?.setPointerCapture(e.pointerId); } -function handleDragOver(e: DragEvent) { - e.preventDefault(); - e.dataTransfer!.dropEffect = 'move'; -} +function handlePointerMove(e: PointerEvent) { + if (!draggedBlockId || !listEl) return; + dragOffsetY = e.clientY - dragStartY; -function handleDrop(e: DragEvent, targetBlockId: string) { - e.preventDefault(); - if (!draggedBlockId || draggedBlockId === targetBlockId) { - draggedBlockId = null; - return; + const wrappers = Array.from(listEl.querySelectorAll('[data-block-wrapper]')); + const dragIdx = sortedBlocks.findIndex((b) => b.id === draggedBlockId); + let target: number | null = null; + + for (let i = 0; i < wrappers.length; i++) { + const rect = wrappers[i].getBoundingClientRect(); + if (e.clientY < rect.top + rect.height / 2) { + target = i; + break; + } } - const sorted = [...sortedBlocks]; - const fromIdx = sorted.findIndex((b) => b.id === draggedBlockId); - const toIdx = sorted.findIndex((b) => b.id === targetBlockId); - if (fromIdx < 0 || toIdx < 0) { - draggedBlockId = null; - return; - } - const [moved] = sorted.splice(fromIdx, 1); - sorted.splice(toIdx, 0, moved); - draggedBlockId = null; - reorder(sorted.map((b) => b.id)); + if (target === null) target = wrappers.length; + if (target === dragIdx || target === dragIdx + 1) target = null; + dropTargetIdx = target; } -function handleDragEnd() { +function handlePointerUp() { + if (!draggedBlockId) return; + + if (dropTargetIdx !== null) { + const sorted = [...sortedBlocks]; + const fromIdx = sorted.findIndex((b) => b.id === draggedBlockId); + if (fromIdx >= 0) { + const [moved] = sorted.splice(fromIdx, 1); + const insertAt = dropTargetIdx > fromIdx ? dropTargetIdx - 1 : dropTargetIdx; + sorted.splice(insertAt, 0, moved); + reorder(sorted.map((b) => b.id)); + } + } + draggedBlockId = null; + dropTargetIdx = null; + dragOffsetY = 0; + capturedEl = null; } function flushViaBeacon() { @@ -229,17 +247,24 @@ $effect(() => {
{#if hasBlocks} -
+ +
{#each sortedBlocks as block, i (block.id)} + {#if dropTargetIdx === i} +
+ {/if}
handleDragStart(e, block.id)} - ondragover={handleDragOver} - ondrop={(e) => handleDrop(e, block.id)} - ondragend={handleDragEnd} - class={draggedBlockId === block.id ? 'opacity-40' : ''} + onpointerdown={(e) => handleGripDown(e, block.id)} + class="relative transition-all duration-150 {draggedBlockId === block.id ? 'z-10 rounded-lg shadow-xl ring-2 ring-turquoise/40' : ''}" + style={draggedBlockId === block.id ? `transform: translateY(${dragOffsetY}px) scale(1.02); opacity: 0.9;` : ''} > {
{/each} + {#if dropTargetIdx === sortedBlocks.length} +
+ {/if} +
= {}) { + return render(TranscriptionEditView, { + documentId: 'doc-1', + blocks: [block1, block2], + canComment: true, + currentUserId: 'user-1', + onBlockFocus: vi.fn(), + onSaveBlock: vi.fn(), + onDeleteBlock: vi.fn(), + ...overrides + }); +} + +describe('TranscriptionEditView — rendering', () => { + it('renders blocks in sort order', async () => { + renderView(); + const textareas = page.getByRole('textbox').all(); + expect(textareas.length).toBeGreaterThanOrEqual(2); + }); + + it('shows next-block CTA after block list', async () => { + renderView(); + await expect.element(page.getByText(/Block 3/)).toBeInTheDocument(); + }); + + it('shows empty state when no blocks', async () => { + renderView({ blocks: [] }); + await expect.element(page.getByText(/Markiere einen Bereich/)).toBeInTheDocument(); + }); +}); + +describe('TranscriptionEditView — reorder', () => { + it('renders move-up button disabled on first block', async () => { + renderView(); + const upButtons = page.getByRole('button', { name: 'Nach oben' }).all(); + // First block's up button should be disabled + await expect.element(upButtons[0]).toBeDisabled(); + }); + + it('renders move-down button disabled on last block', async () => { + renderView(); + const downButtons = page.getByRole('button', { name: 'Nach unten' }).all(); + // Last block's down button should be disabled + await expect.element(downButtons[downButtons.length - 1]).toBeDisabled(); + }); + + it('has a drag handle on each block', async () => { + renderView(); + const handles = document.querySelectorAll('[data-drag-handle]'); + expect(handles.length).toBe(2); + }); +}); -- 2.49.1 From b4212f5e8631e9b8a0a37008aa9d68dcb407a3e5 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 5 Apr 2026 23:13:27 +0200 Subject: [PATCH 40/47] feat(transcription): mobile stacked layout + cross-page scroll-sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mobile layout (< 768px): - Split view stacks vertically: PDF top (min 40vh), blocks below - Blocks panel gets border-top instead of border-left - PDF remains interactive for drawing in stacked mode Scroll-sync (block → PDF): - Clicking a block sets activeAnnotationId - PdfViewer effect watches activeAnnotationId, navigates to the annotation's page if different from current, then scrolls the annotation element into view (double-rAF for async render timing) - Works across pages: block on page 3 navigates PDF to page 3 Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/components/PdfViewer.svelte | 20 +++++++++++++++++++ .../src/routes/documents/[id]/+page.svelte | 10 +++++++--- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/frontend/src/lib/components/PdfViewer.svelte b/frontend/src/lib/components/PdfViewer.svelte index f47dd422..a061b939 100644 --- a/frontend/src/lib/components/PdfViewer.svelte +++ b/frontend/src/lib/components/PdfViewer.svelte @@ -217,6 +217,26 @@ $effect(() => { if (transcribeMode) showAnnotations = true; }); +// Scroll-sync: when activeAnnotationId changes, navigate to its page +$effect(() => { + if (!activeAnnotationId || !pdfDoc) return; + const ann = annotations.find((a) => a.id === activeAnnotationId); + if (!ann) return; + + if (ann.pageNumber !== currentPage) { + currentPage = ann.pageNumber; + } + + // After page renders, scroll the annotation into view (double-rAF for async render) + const targetId = activeAnnotationId; + requestAnimationFrame(() => { + requestAnimationFrame(() => { + const el = document.querySelector(`[data-testid="annotation-${targetId}"]`); + el?.scrollIntoView({ behavior: 'smooth', block: 'center' }); + }); + }); +}); + function prevPage() { if (currentPage > 1) currentPage -= 1; } diff --git a/frontend/src/routes/documents/[id]/+page.svelte b/frontend/src/routes/documents/[id]/+page.svelte index 7864ce00..f3d00e33 100644 --- a/frontend/src/routes/documents/[id]/+page.svelte +++ b/frontend/src/routes/documents/[id]/+page.svelte @@ -197,8 +197,10 @@ onMount(() => { bind:transcribeMode={transcribeMode} /> -
-
+
+
{
{#if transcribeMode} -
+
Date: Sun, 5 Apr 2026 23:26:02 +0200 Subject: [PATCH 41/47] feat(annotations): dim non-active annotations when a block is focused MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When activeAnnotationId is set, the active annotation stays at full opacity with a highlight box-shadow, while all other annotations fade to 30% opacity (300ms ease transition). When no block is focused, all annotations show at full opacity. Prop chain: activeAnnotationId flows from PdfViewer → AnnotationLayer. 2 new tests (RED/GREEN): - dims non-active annotations when activeAnnotationId is set - shows all at full opacity when no activeAnnotationId Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/components/AnnotationLayer.svelte | 9 ++++-- .../components/AnnotationLayer.svelte.spec.ts | 29 +++++++++++++++++++ frontend/src/lib/components/PdfViewer.svelte | 1 + 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/frontend/src/lib/components/AnnotationLayer.svelte b/frontend/src/lib/components/AnnotationLayer.svelte index ed8ce6d7..2afe3b47 100644 --- a/frontend/src/lib/components/AnnotationLayer.svelte +++ b/frontend/src/lib/components/AnnotationLayer.svelte @@ -13,6 +13,7 @@ let { canDraw, color, blockNumbers = {}, + activeAnnotationId = null, onDraw, onAnnotationClick }: { @@ -20,6 +21,7 @@ let { canDraw: boolean; color: string; blockNumbers?: Record; + activeAnnotationId?: string | null; onDraw: (rect: DrawRect) => void; onAnnotationClick?: (id: string) => void; } = $props(); @@ -121,11 +123,12 @@ const containerStyle = $derived( top: {annotation.y * 100}%; width: {annotation.width * 100}%; height: {annotation.height * 100}%; - background-color: {hexToRgba(annotation.color, hoveredId === annotation.id ? 0.5 : 0.3)}; - box-shadow: {hoveredId === annotation.id ? `inset 0 0 0 2px ${hexToRgba(annotation.color, 0.8)}` : 'none'}; + background-color: {hexToRgba(annotation.color, hoveredId === annotation.id || annotation.id === activeAnnotationId ? 0.5 : 0.3)}; + box-shadow: {annotation.id === activeAnnotationId ? `inset 0 0 0 2px ${hexToRgba(annotation.color, 0.8)}` : hoveredId === annotation.id ? `inset 0 0 0 2px ${hexToRgba(annotation.color, 0.8)}` : 'none'}; + opacity: {activeAnnotationId && annotation.id !== activeAnnotationId ? 0.3 : 1}; pointer-events: auto; cursor: pointer; - transition: background-color 0.15s ease, box-shadow 0.15s ease; + transition: background-color 0.15s ease, box-shadow 0.15s ease, opacity 0.3s ease; " > {#if blockNumbers[annotation.id]} diff --git a/frontend/src/lib/components/AnnotationLayer.svelte.spec.ts b/frontend/src/lib/components/AnnotationLayer.svelte.spec.ts index 78befeca..7a4b4e07 100644 --- a/frontend/src/lib/components/AnnotationLayer.svelte.spec.ts +++ b/frontend/src/lib/components/AnnotationLayer.svelte.spec.ts @@ -69,6 +69,35 @@ describe('AnnotationLayer', () => { expect(container.getAttribute('style')).not.toContain('cursor: crosshair'); }); + it('dims non-active annotations when activeAnnotationId is set', async () => { + render(AnnotationLayer, { + annotations: [makeAnnotation('ann-1'), makeAnnotation('ann-2')], + canDraw: false, + color: '#00C7B1', + activeAnnotationId: 'ann-1', + onDraw: () => {} + }); + + const active = page.getByTestId('annotation-ann-1').element(); + const dimmed = page.getByTestId('annotation-ann-2').element(); + expect(active.style.opacity).toBe('1'); + expect(dimmed.style.opacity).toBe('0.3'); + }); + + it('shows all annotations at full opacity when no activeAnnotationId', async () => { + render(AnnotationLayer, { + annotations: [makeAnnotation('ann-1'), makeAnnotation('ann-2')], + canDraw: false, + color: '#00C7B1', + onDraw: () => {} + }); + + const el1 = page.getByTestId('annotation-ann-1').element(); + const el2 = page.getByTestId('annotation-ann-2').element(); + expect(el1.style.opacity).toBe('1'); + expect(el2.style.opacity).toBe('1'); + }); + it('does not show delete buttons (annotations owned by blocks)', async () => { render(AnnotationLayer, { annotations: [makeAnnotation('ann-1')], diff --git a/frontend/src/lib/components/PdfViewer.svelte b/frontend/src/lib/components/PdfViewer.svelte index a061b939..f2b45011 100644 --- a/frontend/src/lib/components/PdfViewer.svelte +++ b/frontend/src/lib/components/PdfViewer.svelte @@ -447,6 +447,7 @@ function zoomOut() { canDraw={transcribeMode} color={TRANSCRIPTION_COLOR} blockNumbers={blockNumbers} + activeAnnotationId={activeAnnotationId} onDraw={handleDraw} onAnnotationClick={handleAnnotationClick} /> -- 2.49.1 From 676d3cb6a78c4eccf6bd8f2c9850e8545eb0944e Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 5 Apr 2026 23:27:52 +0200 Subject: [PATCH 42/47] fix(pdf): prevent scroll-sync effect from hijacking page navigation The scroll-sync $effect was re-triggering on every dependency change (including currentPage), forcing the PDF back to the annotation's page when the user clicked next/prev. Fix: track prevActiveAnnotationId and only scroll when the active annotation actually changes. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/components/PdfViewer.svelte | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/frontend/src/lib/components/PdfViewer.svelte b/frontend/src/lib/components/PdfViewer.svelte index f2b45011..e3984d7f 100644 --- a/frontend/src/lib/components/PdfViewer.svelte +++ b/frontend/src/lib/components/PdfViewer.svelte @@ -218,9 +218,16 @@ $effect(() => { }); // Scroll-sync: when activeAnnotationId changes, navigate to its page +let prevActiveAnnotationId: string | null = null; $effect(() => { - if (!activeAnnotationId || !pdfDoc) return; - const ann = annotations.find((a) => a.id === activeAnnotationId); + const id = activeAnnotationId; + if (!id || id === prevActiveAnnotationId || !pdfDoc) { + prevActiveAnnotationId = id; + return; + } + prevActiveAnnotationId = id; + + const ann = annotations.find((a) => a.id === id); if (!ann) return; if (ann.pageNumber !== currentPage) { @@ -228,10 +235,9 @@ $effect(() => { } // After page renders, scroll the annotation into view (double-rAF for async render) - const targetId = activeAnnotationId; requestAnimationFrame(() => { requestAnimationFrame(() => { - const el = document.querySelector(`[data-testid="annotation-${targetId}"]`); + const el = document.querySelector(`[data-testid="annotation-${id}"]`); el?.scrollIntoView({ behavior: 'smooth', block: 'center' }); }); }); -- 2.49.1 From ef11cbee4e103973e3c4808b519cd94b6d215b77 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 5 Apr 2026 23:36:06 +0200 Subject: [PATCH 43/47] feat(transcription): clicking annotation focuses corresponding block Pass activeAnnotationId to TranscriptionEditView. An $effect watches it and sets activeBlockId to the block matching the annotation, activating its turquoise focus border. 2 new tests (RED/GREEN): - activates block matching activeAnnotationId (turquoise border) - no block activated when activeAnnotationId is null Co-Authored-By: Claude Sonnet 4.6 --- .../lib/components/TranscriptionEditView.svelte | 9 +++++++++ .../TranscriptionEditView.svelte.spec.ts | 17 +++++++++++++++++ frontend/src/routes/documents/[id]/+page.svelte | 1 + 3 files changed, 27 insertions(+) diff --git a/frontend/src/lib/components/TranscriptionEditView.svelte b/frontend/src/lib/components/TranscriptionEditView.svelte index 44a21288..5d675a01 100644 --- a/frontend/src/lib/components/TranscriptionEditView.svelte +++ b/frontend/src/lib/components/TranscriptionEditView.svelte @@ -11,6 +11,7 @@ type Props = { blocks: TranscriptionBlockData[]; canComment: boolean; currentUserId: string | null; + activeAnnotationId?: string | null; onBlockFocus: (blockId: string) => void; onSaveBlock: (blockId: string, text: string) => Promise; onDeleteBlock: (blockId: string) => Promise; @@ -21,12 +22,20 @@ let { blocks, canComment, currentUserId, + activeAnnotationId = null, onBlockFocus, onSaveBlock, onDeleteBlock }: Props = $props(); let activeBlockId: string | null = $state(null); + +// Sync: when an annotation is clicked on the PDF, activate the corresponding block +$effect(() => { + if (!activeAnnotationId) return; + const block = blocks.find((b) => b.annotationId === activeAnnotationId); + if (block) activeBlockId = block.id; +}); let saveStates = new SvelteMap(); let debounceTimers = new SvelteMap>(); let pendingTexts = new SvelteMap(); diff --git a/frontend/src/lib/components/TranscriptionEditView.svelte.spec.ts b/frontend/src/lib/components/TranscriptionEditView.svelte.spec.ts index fd780fb9..b7f8621c 100644 --- a/frontend/src/lib/components/TranscriptionEditView.svelte.spec.ts +++ b/frontend/src/lib/components/TranscriptionEditView.svelte.spec.ts @@ -55,6 +55,23 @@ describe('TranscriptionEditView — rendering', () => { }); }); +describe('TranscriptionEditView — annotation sync', () => { + it('activates block matching activeAnnotationId', async () => { + renderView({ activeAnnotationId: 'a2' }); + // Block 2 (annotation a2) should have turquoise border + const block = document.querySelector('[data-block-id="b2"]')!; + expect(block.className).toContain('border-turquoise'); + }); + + it('does not activate any block when activeAnnotationId is null', async () => { + renderView({ activeAnnotationId: null }); + const block1 = document.querySelector('[data-block-id="b1"]')!; + const block2 = document.querySelector('[data-block-id="b2"]')!; + expect(block1.className).not.toContain('border-turquoise'); + expect(block2.className).not.toContain('border-turquoise'); + }); +}); + describe('TranscriptionEditView — reorder', () => { it('renders move-up button disabled on first block', async () => { renderView(); diff --git a/frontend/src/routes/documents/[id]/+page.svelte b/frontend/src/routes/documents/[id]/+page.svelte index f3d00e33..b7fd293c 100644 --- a/frontend/src/routes/documents/[id]/+page.svelte +++ b/frontend/src/routes/documents/[id]/+page.svelte @@ -223,6 +223,7 @@ onMount(() => { blocks={transcriptionBlocks} canComment={canWrite} currentUserId={currentUserId} + activeAnnotationId={activeAnnotationId} onBlockFocus={handleBlockFocus} onSaveBlock={saveBlock} onDeleteBlock={deleteBlock} -- 2.49.1 From f359c19e4c689b8d9c0a379b6423e04592f5678e Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 5 Apr 2026 23:40:23 +0200 Subject: [PATCH 44/47] fix: bump comment text to text-base + reload annotations on block delete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comment text: - Body and quote bumped from text-sm (14px) to text-base (16px) to visually match the font-sans author name at text-sm Annotation reload on delete: - Add annotationReloadKey prop through DocumentViewer → PdfViewer - Increment key after block delete in +page.svelte - PdfViewer reloads annotations when key changes - Annotation rectangle disappears immediately, not just after refresh Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/components/CommentThread.svelte | 4 ++-- frontend/src/lib/components/DocumentViewer.svelte | 3 +++ frontend/src/lib/components/PdfViewer.svelte | 4 +++- frontend/src/routes/documents/[id]/+page.svelte | 3 +++ 4 files changed, 11 insertions(+), 3 deletions(-) diff --git a/frontend/src/lib/components/CommentThread.svelte b/frontend/src/lib/components/CommentThread.svelte index 2f836822..73858028 100644 --- a/frontend/src/lib/components/CommentThread.svelte +++ b/frontend/src/lib/components/CommentThread.svelte @@ -240,7 +240,7 @@ onMount(() => { {/if}
{#if parsed.quote} -
+
“{parsed.quote}”
{/if} @@ -258,7 +258,7 @@ onMount(() => {
{ if (isOwn(msg)) startEdit(msg); }}>

{@html renderBody(parsed.body, msg.mentionDTOs ?? [])} diff --git a/frontend/src/lib/components/DocumentViewer.svelte b/frontend/src/lib/components/DocumentViewer.svelte index 275a27d4..6266f975 100644 --- a/frontend/src/lib/components/DocumentViewer.svelte +++ b/frontend/src/lib/components/DocumentViewer.svelte @@ -18,6 +18,7 @@ type Props = { error: string; transcribeMode?: boolean; blockNumbers?: Record; + annotationReloadKey?: number; activeAnnotationId: string | null; onAnnotationClick: (id: string) => void; onTranscriptionDraw?: (rect: DrawRect) => void; @@ -30,6 +31,7 @@ let { error, transcribeMode = false, blockNumbers = {}, + annotationReloadKey = 0, activeAnnotationId = $bindable(), onAnnotationClick, onTranscriptionDraw @@ -86,6 +88,7 @@ let { documentId={doc.id} transcribeMode={transcribeMode} blockNumbers={blockNumbers} + annotationReloadKey={annotationReloadKey} bind:activeAnnotationId={activeAnnotationId} onAnnotationClick={onAnnotationClick} onTranscriptionDraw={onTranscriptionDraw} diff --git a/frontend/src/lib/components/PdfViewer.svelte b/frontend/src/lib/components/PdfViewer.svelte index e3984d7f..a3f767f6 100644 --- a/frontend/src/lib/components/PdfViewer.svelte +++ b/frontend/src/lib/components/PdfViewer.svelte @@ -12,6 +12,7 @@ let { documentId = '', transcribeMode = false, blockNumbers = {}, + annotationReloadKey = 0, activeAnnotationId = $bindable(null), onAnnotationClick, onTranscriptionDraw, @@ -21,6 +22,7 @@ let { documentId?: string; transcribeMode?: boolean; blockNumbers?: Record; + annotationReloadKey?: number; activeAnnotationId?: string | null; onAnnotationClick?: (id: string) => void; onTranscriptionDraw?: (rect: DrawRect) => void; @@ -208,7 +210,7 @@ $effect(() => { }); $effect(() => { - if (documentId) { + if (documentId && annotationReloadKey >= 0) { loadAnnotations(documentId); } }); diff --git a/frontend/src/routes/documents/[id]/+page.svelte b/frontend/src/routes/documents/[id]/+page.svelte index b7fd293c..a3af67a0 100644 --- a/frontend/src/routes/documents/[id]/+page.svelte +++ b/frontend/src/routes/documents/[id]/+page.svelte @@ -54,6 +54,7 @@ let activeAnnotationId = $state(null); // ── Transcription blocks ───────────────────────────────────────────────────── let transcriptionBlocks = $state([]); +let annotationReloadKey = $state(0); const blockNumbers = $derived( Object.fromEntries( @@ -92,6 +93,7 @@ async function deleteBlock(blockId: string) { }); if (!res.ok) throw new Error('Delete failed'); transcriptionBlocks = transcriptionBlocks.filter((b) => b.id !== blockId); + annotationReloadKey++; } async function createBlockFromDraw(rect: { @@ -208,6 +210,7 @@ onMount(() => { error={fileError} transcribeMode={transcribeMode} blockNumbers={blockNumbers} + annotationReloadKey={annotationReloadKey} bind:activeAnnotationId={activeAnnotationId} onAnnotationClick={handleAnnotationClick} onTranscriptionDraw={createBlockFromDraw} -- 2.49.1 From e89d8a4ca933d7a20bbe6d74e8bf7f20eafe340a Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 6 Apr 2026 11:20:57 +0200 Subject: [PATCH 45/47] test: increase coverage --- .../TranscriptionBlockControllerTest.java | 359 ++++++++++++++++++ .../TranscriptionBlockRepositoryTest.java | 193 ++++++++++ frontend/e2e/annotations.spec.ts | 272 +++++++++++++ frontend/e2e/transcription.spec.ts | 296 +++++++++++++++ frontend/eslint.config.js | 1 + .../TranscriptionBlock.svelte.spec.ts | 56 +++ .../TranscriptionEditView.svelte.spec.ts | 129 +++++++ 7 files changed, 1306 insertions(+) create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/controller/TranscriptionBlockControllerTest.java create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/repository/TranscriptionBlockRepositoryTest.java create mode 100644 frontend/e2e/annotations.spec.ts create mode 100644 frontend/e2e/transcription.spec.ts diff --git a/backend/src/test/java/org/raddatz/familienarchiv/controller/TranscriptionBlockControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/controller/TranscriptionBlockControllerTest.java new file mode 100644 index 00000000..a891413e --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/TranscriptionBlockControllerTest.java @@ -0,0 +1,359 @@ +package org.raddatz.familienarchiv.controller; + +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.AppUser; +import org.raddatz.familienarchiv.model.TranscriptionBlock; +import org.raddatz.familienarchiv.model.TranscriptionBlockVersion; +import org.raddatz.familienarchiv.security.PermissionAspect; +import org.raddatz.familienarchiv.service.CustomUserDetailsService; +import org.raddatz.familienarchiv.service.TranscriptionService; +import org.raddatz.familienarchiv.service.UserService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.List; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@WebMvcTest(TranscriptionBlockController.class) +@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class}) +class TranscriptionBlockControllerTest { + + @Autowired MockMvc mockMvc; + + @MockitoBean TranscriptionService transcriptionService; + @MockitoBean UserService userService; + @MockitoBean CustomUserDetailsService customUserDetailsService; + + private static final UUID DOC_ID = UUID.randomUUID(); + private static final UUID BLOCK_ID = UUID.randomUUID(); + private static final String URL_BASE = "/api/documents/" + DOC_ID + "/transcription-blocks"; + private static final String URL_BLOCK = URL_BASE + "/" + BLOCK_ID; + private static final String URL_REORDER = URL_BASE + "/reorder"; + private static final String URL_HISTORY = URL_BLOCK + "/history"; + + private static final String CREATE_JSON = + "{\"pageNumber\":1,\"x\":0.1,\"y\":0.2,\"width\":0.3,\"height\":0.4,\"text\":\"Liebe Mutter,\"}"; + private static final String UPDATE_JSON = + "{\"text\":\"Neue Fassung\",\"label\":\"Anrede\"}"; + private static final String REORDER_JSON = + "{\"blockIds\":[\"" + UUID.randomUUID() + "\",\"" + UUID.randomUUID() + "\"]}"; + + private AppUser mockUser() { + return AppUser.builder().id(UUID.randomUUID()).username("user").build(); + } + + private TranscriptionBlock sampleBlock() { + return TranscriptionBlock.builder() + .id(BLOCK_ID).documentId(DOC_ID) + .annotationId(UUID.randomUUID()) + .text("Liebe Mutter,").sortOrder(0).build(); + } + + // ─── GET /api/documents/{id}/transcription-blocks ──────────────────────── + + @Test + void listBlocks_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(get(URL_BASE)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + void listBlocks_returns403_whenMissingReadAllPermission() throws Exception { + mockMvc.perform(get(URL_BASE)) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(authorities = "READ_ALL") + void listBlocks_returns200_withBlocks_whenAuthorised() throws Exception { + TranscriptionBlock b = sampleBlock(); + when(transcriptionService.listBlocks(DOC_ID)).thenReturn(List.of(b)); + + mockMvc.perform(get(URL_BASE)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].text").value("Liebe Mutter,")); + } + + @Test + @WithMockUser(authorities = "READ_ALL") + void listBlocks_returns200_withEmptyArray_whenNoBlocksExist() throws Exception { + when(transcriptionService.listBlocks(any())).thenReturn(List.of()); + + mockMvc.perform(get(URL_BASE)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()) + .andExpect(jsonPath("$").isEmpty()); + } + + // ─── GET /api/documents/{id}/transcription-blocks/{blockId} ───────────── + + @Test + void getBlock_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(get(URL_BLOCK)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + void getBlock_returns403_whenMissingReadAllPermission() throws Exception { + mockMvc.perform(get(URL_BLOCK)) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(authorities = "READ_ALL") + void getBlock_returns200_withBlockData_whenFound() throws Exception { + when(transcriptionService.getBlock(DOC_ID, BLOCK_ID)).thenReturn(sampleBlock()); + + mockMvc.perform(get(URL_BLOCK)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(BLOCK_ID.toString())) + .andExpect(jsonPath("$.text").value("Liebe Mutter,")) + .andExpect(jsonPath("$.sortOrder").value(0)); + } + + @Test + @WithMockUser(authorities = "READ_ALL") + void getBlock_returns404_whenBlockDoesNotExist() throws Exception { + when(transcriptionService.getBlock(any(), any())) + .thenThrow(DomainException.notFound(ErrorCode.TRANSCRIPTION_BLOCK_NOT_FOUND, "not found")); + + mockMvc.perform(get(URL_BLOCK)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("TRANSCRIPTION_BLOCK_NOT_FOUND")); + } + + // ─── POST /api/documents/{id}/transcription-blocks ─────────────────────── + + @Test + void createBlock_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(post(URL_BASE) + .contentType(MediaType.APPLICATION_JSON) + .content(CREATE_JSON)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + void createBlock_returns403_whenMissingWriteAllPermission() throws Exception { + mockMvc.perform(post(URL_BASE) + .contentType(MediaType.APPLICATION_JSON) + .content(CREATE_JSON)) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void createBlock_returns201_withSavedBlock_whenAuthorised() throws Exception { + when(userService.findByUsername(any())).thenReturn(mockUser()); + when(transcriptionService.createBlock(eq(DOC_ID), any(), any())).thenReturn(sampleBlock()); + + mockMvc.perform(post(URL_BASE) + .contentType(MediaType.APPLICATION_JSON) + .content(CREATE_JSON)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.text").value("Liebe Mutter,")) + .andExpect(jsonPath("$.documentId").value(DOC_ID.toString())); + } + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void createBlock_returns401_whenUserNotFoundInDatabase() throws Exception { + when(userService.findByUsername(any())).thenReturn(null); + + mockMvc.perform(post(URL_BASE) + .contentType(MediaType.APPLICATION_JSON) + .content(CREATE_JSON)) + .andExpect(status().isUnauthorized()); + } + + // ─── PUT /api/documents/{id}/transcription-blocks/{blockId} ───────────── + + @Test + void updateBlock_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(put(URL_BLOCK) + .contentType(MediaType.APPLICATION_JSON) + .content(UPDATE_JSON)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + void updateBlock_returns403_whenMissingWriteAllPermission() throws Exception { + mockMvc.perform(put(URL_BLOCK) + .contentType(MediaType.APPLICATION_JSON) + .content(UPDATE_JSON)) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void updateBlock_returns200_withUpdatedBlock_whenAuthorised() throws Exception { + TranscriptionBlock updated = sampleBlock(); + updated.setText("Neue Fassung"); + updated.setLabel("Anrede"); + + when(userService.findByUsername(any())).thenReturn(mockUser()); + when(transcriptionService.updateBlock(eq(DOC_ID), eq(BLOCK_ID), any(), any())) + .thenReturn(updated); + + mockMvc.perform(put(URL_BLOCK) + .contentType(MediaType.APPLICATION_JSON) + .content(UPDATE_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.text").value("Neue Fassung")) + .andExpect(jsonPath("$.label").value("Anrede")); + } + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void updateBlock_returns404_whenBlockDoesNotExist() throws Exception { + when(userService.findByUsername(any())).thenReturn(mockUser()); + when(transcriptionService.updateBlock(any(), any(), any(), any())) + .thenThrow(DomainException.notFound(ErrorCode.TRANSCRIPTION_BLOCK_NOT_FOUND, "not found")); + + mockMvc.perform(put(URL_BLOCK) + .contentType(MediaType.APPLICATION_JSON) + .content(UPDATE_JSON)) + .andExpect(status().isNotFound()); + } + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void updateBlock_returns401_whenUserNotFoundInDatabase() throws Exception { + when(userService.findByUsername(any())).thenReturn(null); + + mockMvc.perform(put(URL_BLOCK) + .contentType(MediaType.APPLICATION_JSON) + .content(UPDATE_JSON)) + .andExpect(status().isUnauthorized()); + } + + // ─── DELETE /api/documents/{id}/transcription-blocks/{blockId} ─────────── + + @Test + void deleteBlock_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(delete(URL_BLOCK)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + void deleteBlock_returns403_whenMissingWriteAllPermission() throws Exception { + mockMvc.perform(delete(URL_BLOCK)) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void deleteBlock_returns204_whenAuthorised() throws Exception { + mockMvc.perform(delete(URL_BLOCK)) + .andExpect(status().isNoContent()); + } + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void deleteBlock_returns404_whenBlockDoesNotExist() throws Exception { + org.mockito.Mockito.doThrow( + DomainException.notFound(ErrorCode.TRANSCRIPTION_BLOCK_NOT_FOUND, "not found")) + .when(transcriptionService).deleteBlock(any(), any()); + + mockMvc.perform(delete(URL_BLOCK)) + .andExpect(status().isNotFound()); + } + + // ─── PUT /api/documents/{id}/transcription-blocks/reorder ──────────────── + + @Test + void reorderBlocks_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(put(URL_REORDER) + .contentType(MediaType.APPLICATION_JSON) + .content(REORDER_JSON)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + void reorderBlocks_returns403_whenMissingWriteAllPermission() throws Exception { + mockMvc.perform(put(URL_REORDER) + .contentType(MediaType.APPLICATION_JSON) + .content(REORDER_JSON)) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void reorderBlocks_returns200_withReorderedBlocks_whenAuthorised() throws Exception { + when(transcriptionService.listBlocks(DOC_ID)).thenReturn(List.of(sampleBlock())); + + mockMvc.perform(put(URL_REORDER) + .contentType(MediaType.APPLICATION_JSON) + .content(REORDER_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()); + } + + // ─── GET /api/documents/{id}/transcription-blocks/{blockId}/history ────── + + @Test + void getBlockHistory_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(get(URL_HISTORY)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + void getBlockHistory_returns403_whenMissingReadAllPermission() throws Exception { + mockMvc.perform(get(URL_HISTORY)) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(authorities = "READ_ALL") + void getBlockHistory_returns200_withVersionList_whenAuthorised() throws Exception { + TranscriptionBlockVersion v = TranscriptionBlockVersion.builder() + .id(UUID.randomUUID()).blockId(BLOCK_ID).text("v1").build(); + when(transcriptionService.getBlockHistory(DOC_ID, BLOCK_ID)).thenReturn(List.of(v)); + + mockMvc.perform(get(URL_HISTORY)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].text").value("v1")) + .andExpect(jsonPath("$[0].blockId").value(BLOCK_ID.toString())); + } + + @Test + @WithMockUser(authorities = "READ_ALL") + void getBlockHistory_returns404_whenBlockDoesNotExist() throws Exception { + when(transcriptionService.getBlockHistory(any(), any())) + .thenThrow(DomainException.notFound(ErrorCode.TRANSCRIPTION_BLOCK_NOT_FOUND, "not found")); + + mockMvc.perform(get(URL_HISTORY)) + .andExpect(status().isNotFound()); + } + + @Test + @WithMockUser(authorities = "READ_ALL") + void getBlockHistory_returns200_withEmptyList_whenNoVersionsExist() throws Exception { + when(transcriptionService.getBlockHistory(any(), any())).thenReturn(List.of()); + + mockMvc.perform(get(URL_HISTORY)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isEmpty()); + } +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/repository/TranscriptionBlockRepositoryTest.java b/backend/src/test/java/org/raddatz/familienarchiv/repository/TranscriptionBlockRepositoryTest.java new file mode 100644 index 00000000..c948ceed --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/repository/TranscriptionBlockRepositoryTest.java @@ -0,0 +1,193 @@ +package org.raddatz.familienarchiv.repository; + +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.raddatz.familienarchiv.PostgresContainerConfig; +import org.raddatz.familienarchiv.config.FlywayConfig; +import org.raddatz.familienarchiv.model.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase; +import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Import({PostgresContainerConfig.class, FlywayConfig.class}) +class TranscriptionBlockRepositoryTest { + + @Autowired TranscriptionBlockRepository blockRepository; + @Autowired TranscriptionBlockVersionRepository versionRepository; + @Autowired DocumentRepository documentRepository; + @Autowired AnnotationRepository annotationRepository; + @Autowired EntityManager em; + + private UUID documentId; + private UUID annotationId; + + @BeforeEach + void setUp() { + Document doc = documentRepository.save(Document.builder() + .title("Testbrief") + .originalFilename("brief.pdf") + .status(DocumentStatus.UPLOADED) + .build()); + documentId = doc.getId(); + + DocumentAnnotation annotation = annotationRepository.save(DocumentAnnotation.builder() + .documentId(documentId) + .pageNumber(1) + .x(0.1).y(0.2).width(0.3).height(0.4) + .color("#00C7B1") + .build()); + annotationId = annotation.getId(); + } + + // ─── findByDocumentIdOrderBySortOrderAsc ───────────────────────────────── + + @Test + void findByDocumentIdOrderBySortOrderAsc_returnsBlocksInSortOrder() { + blockRepository.save(block("Block B", 1)); + blockRepository.save(block("Block A", 0)); + blockRepository.save(block("Block C", 2)); + + List result = blockRepository.findByDocumentIdOrderBySortOrderAsc(documentId); + + assertThat(result).hasSize(3); + assertThat(result.get(0).getText()).isEqualTo("Block A"); + assertThat(result.get(1).getText()).isEqualTo("Block B"); + assertThat(result.get(2).getText()).isEqualTo("Block C"); + } + + @Test + void findByDocumentIdOrderBySortOrderAsc_returnsEmptyList_whenNoBlocksForDocument() { + UUID otherId = UUID.randomUUID(); + List result = blockRepository.findByDocumentIdOrderBySortOrderAsc(otherId); + assertThat(result).isEmpty(); + } + + @Test + void findByDocumentIdOrderBySortOrderAsc_doesNotReturnBlocksFromOtherDocument() { + blockRepository.save(block("My block", 0)); + + Document other = documentRepository.save(Document.builder() + .title("Anderer Brief").originalFilename("other.pdf").status(DocumentStatus.PLACEHOLDER).build()); + + List result = blockRepository.findByDocumentIdOrderBySortOrderAsc(other.getId()); + assertThat(result).isEmpty(); + } + + // ─── findByIdAndDocumentId ──────────────────────────────────────────────── + + @Test + void findByIdAndDocumentId_returnsBlock_whenBothMatch() { + TranscriptionBlock saved = blockRepository.save(block("Liebe Tante,", 0)); + + Optional found = blockRepository.findByIdAndDocumentId(saved.getId(), documentId); + + assertThat(found).isPresent(); + assertThat(found.get().getText()).isEqualTo("Liebe Tante,"); + } + + @Test + void findByIdAndDocumentId_returnsEmpty_whenDocumentIdDoesNotMatch() { + TranscriptionBlock saved = blockRepository.save(block("Liebe Tante,", 0)); + + Optional found = blockRepository.findByIdAndDocumentId(saved.getId(), UUID.randomUUID()); + + assertThat(found).isEmpty(); + } + + @Test + void findByIdAndDocumentId_returnsEmpty_whenBlockIdDoesNotExist() { + Optional found = blockRepository.findByIdAndDocumentId(UUID.randomUUID(), documentId); + assertThat(found).isEmpty(); + } + + // ─── countByDocumentId ──────────────────────────────────────────────────── + + @Test + void countByDocumentId_returnsZero_whenNoBlocksExist() { + assertThat(blockRepository.countByDocumentId(documentId)).isZero(); + } + + @Test + void countByDocumentId_returnsCorrectCount_afterMultipleSaves() { + blockRepository.save(block("Block 1", 0)); + blockRepository.save(block("Block 2", 1)); + blockRepository.save(block("Block 3", 2)); + + assertThat(blockRepository.countByDocumentId(documentId)).isEqualTo(3); + } + + @Test + void countByDocumentId_doesNotCountBlocksFromOtherDocument() { + blockRepository.save(block("Block 1", 0)); + + UUID otherId = UUID.randomUUID(); + assertThat(blockRepository.countByDocumentId(otherId)).isZero(); + } + + // ─── version (optimistic lock) ──────────────────────────────────────────── + + @Test + void version_startsAtZero_andIncrementsOnEachSave() { + TranscriptionBlock saved = blockRepository.saveAndFlush(block("initial", 0)); + assertThat(saved.getVersion()).isZero(); + + saved.setText("updated"); + TranscriptionBlock updated = blockRepository.saveAndFlush(saved); + assertThat(updated.getVersion()).isEqualTo(1); + } + + // ─── cascade: deleting a block cascades to its versions ────────────────── + + @Test + @Transactional + void delete_cascadesToVersions() { + TranscriptionBlock block = blockRepository.saveAndFlush(block("text", 0)); + versionRepository.saveAndFlush(TranscriptionBlockVersion.builder() + .blockId(block.getId()).text("text").build()); + + assertThat(versionRepository.findByBlockIdOrderByChangedAtDesc(block.getId())).hasSize(1); + + blockRepository.delete(block); + blockRepository.flush(); + em.clear(); + + assertThat(versionRepository.findByBlockIdOrderByChangedAtDesc(block.getId())).isEmpty(); + } + + // ─── cascade: deleting a document cascades to its blocks ───────────────── + + @Test + @Transactional + void deleteDocument_cascadesToBlocks() { + blockRepository.saveAndFlush(block("text", 0)); + assertThat(blockRepository.findByDocumentIdOrderBySortOrderAsc(documentId)).hasSize(1); + + documentRepository.deleteById(documentId); + documentRepository.flush(); + em.clear(); + + assertThat(blockRepository.findByDocumentIdOrderBySortOrderAsc(documentId)).isEmpty(); + } + + // ─── helper ────────────────────────────────────────────────────────────── + + private TranscriptionBlock block(String text, int sortOrder) { + return TranscriptionBlock.builder() + .annotationId(annotationId) + .documentId(documentId) + .text(text) + .sortOrder(sortOrder) + .build(); + } +} diff --git a/frontend/e2e/annotations.spec.ts b/frontend/e2e/annotations.spec.ts new file mode 100644 index 00000000..e4b0bed5 --- /dev/null +++ b/frontend/e2e/annotations.spec.ts @@ -0,0 +1,272 @@ +import { test, expect, type Page } from '@playwright/test'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import fs from 'fs'; +import { AxeBuilder } from '@axe-core/playwright'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const PDF_FIXTURE = path.resolve(__dirname, 'fixtures/minimal.pdf'); + +/** + * E2E tests for the annotation overlay and transcribe-mode UI — issue #176. + * + * Strategy: + * - Transcription blocks are seeded via API in beforeAll — no canvas drawing in CI. + * - Browser tests verify transcribe-mode toggling, annotation overlay rendering, + * the visibility toggle, and scroll-sync between annotations and blocks. + */ + +let docHref: string; +let docId: string; +let annotAId: string; +let annotBId: string; +let blockAId: string; + +test.describe('Annotation overlay and transcribe mode', () => { + test.beforeAll(async ({ request }) => { + const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000'; + + // 1. Create a document and upload a PDF so the annotation layer is active. + const createRes = await request.post('/api/documents', { + multipart: { title: 'E2E Annotation Test', documentDate: '1945-05-08' } + }); + if (!createRes.ok()) throw new Error(`Create document failed: ${createRes.status()}`); + const doc = await createRes.json(); + docId = doc.id; + docHref = `${baseURL}/documents/${docId}`; + + const uploadRes = await request.put(`/api/documents/${docId}`, { + multipart: { + title: doc.title, + documentDate: '1945-05-08', + file: { + name: 'minimal.pdf', + mimeType: 'application/pdf', + buffer: fs.readFileSync(PDF_FIXTURE) + } + } + }); + if (!uploadRes.ok()) throw new Error(`Upload PDF failed: ${uploadRes.status()}`); + + // 2. Create two transcription blocks (each brings its own annotation). + const blockARes = await request.post(`/api/documents/${docId}/transcription-blocks`, { + data: { + pageNumber: 1, + x: 0.1, + y: 0.1, + width: 0.3, + height: 0.1, + text: 'Erste Zeile.', + label: 'Anrede' + } + }); + if (!blockARes.ok()) throw new Error(`Create block A failed: ${blockARes.status()}`); + const blockA = await blockARes.json(); + blockAId = blockA.id; + annotAId = blockA.annotationId; + + const blockBRes = await request.post(`/api/documents/${docId}/transcription-blocks`, { + data: { + pageNumber: 1, + x: 0.1, + y: 0.35, + width: 0.3, + height: 0.1, + text: 'Zweite Zeile.', + label: null + } + }); + if (!blockBRes.ok()) throw new Error(`Create block B failed: ${blockBRes.status()}`); + const blockB = await blockBRes.json(); + annotBId = blockB.annotationId; + }); + + /** + * Navigate to the document, enter transcribe mode, and wait until the PDF + * has fully rendered (page counter appears) and the annotation rect is visible. + * Centralises the timing gate used by multiple tests. + */ + async function openTranscribeMode(page: Page, annotationId: string) { + await page.goto(docHref); + await page.waitForSelector('[data-hydrated]'); + await page.getByRole('button', { name: 'Transkribieren' }).click(); + // Wait for the PDF to finish loading — the page counter only renders when totalPages > 0 + await page.locator('.tabular-nums').waitFor({ timeout: 15_000 }); + // Wait for annotation rect (annotations API) and at least one block textarea (blocks API) + // to be ready — these are two independent fetches. + await Promise.all([ + page.locator(`[data-testid="annotation-${annotationId}"]`).waitFor({ timeout: 10_000 }), + page.getByRole('textbox').first().waitFor({ timeout: 10_000 }) + ]); + } + + // ─── Transcribe mode toggle ──────────────────────────────────────────────── + + test('Transkribieren button is visible on a PDF document', async ({ page }) => { + test.setTimeout(30_000); + await page.goto(docHref); + await page.waitForSelector('[data-hydrated]'); + + await expect(page.getByRole('button', { name: 'Transkribieren' })).toBeVisible(); + await page.screenshot({ path: 'test-results/e2e/annotation-transcribe-btn.png' }); + }); + + test('clicking Transkribieren enters transcribe mode and shows the Fertig button', async ({ + page + }) => { + test.setTimeout(30_000); + await page.goto(docHref); + await page.waitForSelector('[data-hydrated]'); + + await page.getByRole('button', { name: 'Transkribieren' }).click(); + + await expect(page.getByRole('button', { name: 'Fertig' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Transkribieren' })).not.toBeVisible(); + + await page.screenshot({ path: 'test-results/e2e/annotation-transcribe-mode-active.png' }); + }); + + test('clicking Fertig exits transcribe mode and restores the Transkribieren button', async ({ + page + }) => { + test.setTimeout(30_000); + await page.goto(docHref); + await page.waitForSelector('[data-hydrated]'); + + await page.getByRole('button', { name: 'Transkribieren' }).click(); + await expect(page.getByRole('button', { name: 'Fertig' })).toBeVisible(); + + await page.getByRole('button', { name: 'Fertig' }).click(); + + await expect(page.getByRole('button', { name: 'Transkribieren' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Fertig' })).not.toBeVisible(); + }); + + test('pressing Escape exits transcribe mode', async ({ page }) => { + test.setTimeout(30_000); + await page.goto(docHref); + await page.waitForSelector('[data-hydrated]'); + + await page.getByRole('button', { name: 'Transkribieren' }).click(); + await expect(page.getByRole('button', { name: 'Fertig' })).toBeVisible(); + + await page.keyboard.press('Escape'); + + await expect(page.getByRole('button', { name: 'Transkribieren' })).toBeVisible(); + }); + + // ─── Annotation overlay rendering ───────────────────────────────────────── + + test('annotation rects are rendered on the PDF after entering transcribe mode', async ({ + page + }) => { + test.setTimeout(40_000); + await openTranscribeMode(page, annotAId); + + await expect(page.locator(`[data-testid="annotation-${annotAId}"]`)).toBeVisible(); + await expect(page.locator(`[data-testid="annotation-${annotBId}"]`)).toBeVisible(); + + await page.screenshot({ path: 'test-results/e2e/annotation-rects-rendered.png' }); + }); + + test('numbered badges appear on annotation rects', async ({ page }) => { + test.setTimeout(40_000); + await openTranscribeMode(page, annotAId); + + const annotA = page.locator(`[data-testid="annotation-${annotAId}"]`); + await expect(annotA.locator('div', { hasText: '1' })).toBeVisible(); + + await page.screenshot({ path: 'test-results/e2e/annotation-numbered-badges.png' }); + }); + + // ─── Annotation visibility toggle ───────────────────────────────────────── + + test('annotation visibility toggle button appears when annotations exist', async ({ page }) => { + test.setTimeout(40_000); + await openTranscribeMode(page, annotAId); + + await expect(page.getByRole('button', { name: 'Annotierungen verbergen' })).toBeVisible(); + }); + + test('clicking the visibility toggle hides annotation rects', async ({ page }) => { + test.setTimeout(40_000); + await openTranscribeMode(page, annotAId); + + await page.getByRole('button', { name: 'Annotierungen verbergen' }).click(); + + await expect(page.locator(`[data-testid="annotation-${annotAId}"]`)).not.toBeVisible(); + await expect(page.getByRole('button', { name: 'Annotierungen anzeigen' })).toBeVisible(); + + await page.screenshot({ path: 'test-results/e2e/annotation-hidden.png' }); + }); + + test('clicking the visibility toggle again restores annotation rects', async ({ page }) => { + test.setTimeout(40_000); + await openTranscribeMode(page, annotAId); + + await page.getByRole('button', { name: 'Annotierungen verbergen' }).click(); + await page.getByRole('button', { name: 'Annotierungen anzeigen' }).click(); + + await expect(page.locator(`[data-testid="annotation-${annotAId}"]`)).toBeVisible(); + }); + + // ─── Scroll-sync: annotation → block ────────────────────────────────────── + + test('clicking an annotation rect scrolls the matching block into view in the right panel', async ({ + page + }) => { + test.setTimeout(40_000); + await openTranscribeMode(page, annotAId); + + await page.locator(`[data-testid="annotation-${annotAId}"]`).click(); + + await expect(page.locator(`[data-block-id="${blockAId}"]`)).toBeVisible({ timeout: 5_000 }); + + await page.screenshot({ path: 'test-results/e2e/annotation-click-scroll-sync.png' }); + }); + + test('clicking annotation B activates the corresponding block in the panel', async ({ page }) => { + test.setTimeout(40_000); + await openTranscribeMode(page, annotBId); + + await page.locator(`[data-testid="annotation-${annotBId}"]`).click(); + + // Block B's annotation should become active (full opacity), A's should dim + await expect(page.locator(`[data-testid="annotation-${annotBId}"]`)).toHaveCSS('opacity', '1'); + await expect(page.locator(`[data-testid="annotation-${annotAId}"]`)).toHaveCSS( + 'opacity', + '0.3' + ); + }); + + // ─── Scroll-sync: block → annotation (dimming) ──────────────────────────── + + test('focusing a block dims all other annotation rects', async ({ page }) => { + test.setTimeout(40_000); + await openTranscribeMode(page, annotAId); + + // Focus block A's textarea to set it as active + await page.getByRole('textbox').first().click(); + + // Non-active annotation (B) must be dimmed + await expect(page.locator(`[data-testid="annotation-${annotBId}"]`)).toHaveCSS( + 'opacity', + '0.3' + ); + + // Active annotation (A) must be at full opacity + await expect(page.locator(`[data-testid="annotation-${annotAId}"]`)).toHaveCSS('opacity', '1'); + + await page.screenshot({ path: 'test-results/e2e/annotation-dimming.png' }); + }); + + // ─── Accessibility ───────────────────────────────────────────────────────── + + test('transcribe mode passes axe accessibility check', async ({ page }) => { + test.setTimeout(40_000); + await openTranscribeMode(page, annotAId); + + const results = await new AxeBuilder({ page }).analyze(); + expect(results.violations).toHaveLength(0); + }); +}); diff --git a/frontend/e2e/transcription.spec.ts b/frontend/e2e/transcription.spec.ts new file mode 100644 index 00000000..8b682905 --- /dev/null +++ b/frontend/e2e/transcription.spec.ts @@ -0,0 +1,296 @@ +import { test, expect } from '@playwright/test'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import fs from 'fs'; +import { AxeBuilder } from '@axe-core/playwright'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const PDF_FIXTURE = path.resolve(__dirname, 'fixtures/minimal.pdf'); + +/** + * E2E tests for the annotation-backed transcription system — issue #176. + * + * Strategy: + * - Transcription blocks are created via API in beforeAll (no need to draw on canvas in CI). + * - Browser tests verify rendering, editing, auto-save feedback, reordering, deletion, and a11y. + */ + +let docHref: string; +let docId: string; + +test.describe('Transcription panel', () => { + test.beforeAll(async ({ request }) => { + const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000'; + + // 1. Create a document with a PDF so the Transkription tab is meaningful. + const createRes = await request.post('/api/documents', { + multipart: { + title: 'E2E Transkription Test', + documentDate: '1945-05-08' + } + }); + if (!createRes.ok()) throw new Error(`Create document failed: ${createRes.status()}`); + const doc = await createRes.json(); + docId = doc.id; + docHref = `${baseURL}/documents/${docId}`; + + await request.put(`/api/documents/${docId}`, { + multipart: { + title: doc.title, + documentDate: '1945-05-08', + file: { + name: 'minimal.pdf', + mimeType: 'application/pdf', + buffer: fs.readFileSync(PDF_FIXTURE) + } + } + }); + + // 2. Create a document_annotation so we can attach blocks to it. + const annotARes = await request.post(`/api/documents/${docId}/annotations`, { + data: { pageNumber: 1, x: 0.1, y: 0.1, width: 0.2, height: 0.1, color: '#00C7B1' } + }); + if (!annotARes.ok()) throw new Error(`Create annotation A failed: ${annotARes.status()}`); + const annotA = await annotARes.json(); + + const annotBRes = await request.post(`/api/documents/${docId}/annotations`, { + data: { pageNumber: 1, x: 0.1, y: 0.3, width: 0.2, height: 0.1, color: '#00C7B1' } + }); + if (!annotBRes.ok()) throw new Error(`Create annotation B failed: ${annotBRes.status()}`); + const annotB = await annotBRes.json(); + + // 3. Create two transcription blocks via API. + const blockARes = await request.post(`/api/documents/${docId}/transcription-blocks`, { + data: { + pageNumber: 1, + x: annotA.x, + y: annotA.y, + width: annotA.width, + height: annotA.height, + text: 'Liebe Mutter,', + label: 'Anrede' + } + }); + if (!blockARes.ok()) throw new Error(`Create block A failed: ${blockARes.status()}`); + await blockARes.json(); + + const blockBRes = await request.post(`/api/documents/${docId}/transcription-blocks`, { + data: { + pageNumber: 1, + x: annotB.x, + y: annotB.y, + width: annotB.width, + height: annotB.height, + text: 'ich schreibe dir aus Breslau.', + label: null + } + }); + if (!blockBRes.ok()) throw new Error(`Create block B failed: ${blockBRes.status()}`); + await blockBRes.json(); + }); + + // ─── Tab visibility ──────────────────────────────────────────────────────── + + test('Transkription tab is visible in the bottom panel tab bar', async ({ page }) => { + test.setTimeout(30_000); + await page.goto(docHref); + await page.waitForSelector('[data-hydrated]'); + + await expect(page.getByRole('button', { name: 'Transkription' })).toBeVisible(); + await page.screenshot({ path: 'test-results/e2e/transcription-tab-visible.png' }); + }); + + // ─── Block rendering ────────────────────────────────────────────────────── + + test('blocks are rendered in sort order with correct text and label', async ({ page }) => { + test.setTimeout(30_000); + await page.goto(docHref); + await page.waitForSelector('[data-hydrated]'); + await page.getByRole('button', { name: 'Transkription' }).click(); + await page.waitForSelector('[data-testid="bottom-panel-content"]'); + + await expect(page.getByText('Liebe Mutter,')).toBeVisible(); + await expect(page.getByText('ich schreibe dir aus Breslau.')).toBeVisible(); + // Label for block A + await expect(page.getByText('Anrede')).toBeVisible(); + + await page.screenshot({ path: 'test-results/e2e/transcription-blocks-rendered.png' }); + }); + + test('block numbers are rendered in turquoise badge', async ({ page }) => { + test.setTimeout(30_000); + await page.goto(docHref); + await page.waitForSelector('[data-hydrated]'); + await page.getByRole('button', { name: 'Transkription' }).click(); + await page.waitForSelector('[data-testid="bottom-panel-content"]'); + + // Block 1 and 2 badges must be visible + await expect(page.getByText('1').first()).toBeVisible(); + await expect(page.getByText('2').first()).toBeVisible(); + }); + + test('next-block CTA shows Block 3 hint after two blocks', async ({ page }) => { + test.setTimeout(30_000); + await page.goto(docHref); + await page.waitForSelector('[data-hydrated]'); + await page.getByRole('button', { name: 'Transkription' }).click(); + await page.waitForSelector('[data-testid="bottom-panel-content"]'); + + await expect(page.getByText(/Block 3/)).toBeVisible(); + }); + + // ─── Text editing & auto-save feedback ──────────────────────────────────── + + test('editing a block shows "Speichere..." then "Gespeichert" indicator', async ({ page }) => { + test.setTimeout(30_000); + await page.goto(docHref); + await page.waitForSelector('[data-hydrated]'); + await page.getByRole('button', { name: 'Transkription' }).click(); + await page.waitForSelector('[data-testid="bottom-panel-content"]'); + + const firstTextarea = page.getByRole('textbox').first(); + await firstTextarea.click(); + await firstTextarea.fill('Liebe Mutter, ich bin wohlauf.'); + + // "Speichere..." should appear (debounce triggers after 1.5s) + await expect(page.getByText(/Speichere\.\.\./)).toBeVisible({ timeout: 5000 }); + // After save completes, "Gespeichert ✓" appears + await expect(page.getByText(/Gespeichert/)).toBeVisible({ timeout: 8000 }); + + await page.screenshot({ path: 'test-results/e2e/transcription-autosave.png' }); + }); + + test('edited text persists after page reload', async ({ page }) => { + test.setTimeout(40_000); + await page.goto(docHref); + await page.waitForSelector('[data-hydrated]'); + await page.getByRole('button', { name: 'Transkription' }).click(); + await page.waitForSelector('[data-testid="bottom-panel-content"]'); + + const firstTextarea = page.getByRole('textbox').first(); + await firstTextarea.fill('Persistierter Text'); + + // Wait for auto-save to complete + await expect(page.getByText(/Gespeichert/)).toBeVisible({ timeout: 8000 }); + + // Reload + await page.reload(); + await page.waitForSelector('[data-hydrated]'); + await page.getByRole('button', { name: 'Transkription' }).click(); + + await expect(page.getByText('Persistierter Text')).toBeVisible(); + }); + + // ─── Block reordering ───────────────────────────────────────────────────── + + test('move-up button is disabled on the first block', async ({ page }) => { + test.setTimeout(30_000); + await page.goto(docHref); + await page.waitForSelector('[data-hydrated]'); + await page.getByRole('button', { name: 'Transkription' }).click(); + await page.waitForSelector('[data-testid="bottom-panel-content"]'); + + const upButtons = page.getByRole('button', { name: 'Nach oben' }); + await expect(upButtons.first()).toBeDisabled(); + }); + + test('move-down button is disabled on the last block', async ({ page }) => { + test.setTimeout(30_000); + await page.goto(docHref); + await page.waitForSelector('[data-hydrated]'); + await page.getByRole('button', { name: 'Transkription' }).click(); + await page.waitForSelector('[data-testid="bottom-panel-content"]'); + + const downButtons = page.getByRole('button', { name: 'Nach unten' }); + await expect(downButtons.last()).toBeDisabled(); + }); + + test('clicking move-down on the first block swaps block order', async ({ page }) => { + test.setTimeout(30_000); + await page.goto(docHref); + await page.waitForSelector('[data-hydrated]'); + await page.getByRole('button', { name: 'Transkription' }).click(); + await page.waitForSelector('[data-testid="bottom-panel-content"]'); + + const textareas = page.getByRole('textbox'); + const before = await textareas.first().inputValue(); + + const downButtons = page.getByRole('button', { name: 'Nach unten' }); + await downButtons.first().click(); + + // After reorder, the block that was second should now appear first + const after = await textareas.first().inputValue(); + expect(after).not.toBe(before); + + await page.screenshot({ path: 'test-results/e2e/transcription-reorder.png' }); + }); + + // ─── Block deletion ─────────────────────────────────────────────────────── + + test('cancelling delete confirmation keeps the block', async ({ page }) => { + test.setTimeout(30_000); + await page.goto(docHref); + await page.waitForSelector('[data-hydrated]'); + await page.getByRole('button', { name: 'Transkription' }).click(); + await page.waitForSelector('[data-testid="bottom-panel-content"]'); + + // Dismiss the confirm dialog automatically + page.once('dialog', (dialog) => dialog.dismiss()); + + const deleteBtn = page.getByRole('button', { name: 'Löschen' }).first(); + await deleteBtn.click(); + + // Block should still be present + await expect(page.getByRole('textbox').first()).toBeVisible(); + }); + + // ─── Comment thread ─────────────────────────────────────────────────────── + + test('clicking Kommentieren button opens comment compose in the block', async ({ page }) => { + test.setTimeout(30_000); + await page.goto(docHref); + await page.waitForSelector('[data-hydrated]'); + await page.getByRole('button', { name: 'Transkription' }).click(); + await page.waitForSelector('[data-testid="bottom-panel-content"]'); + + await page.getByText('Kommentieren').first().click(); + + await expect(page.getByPlaceholder(/Kommentar/)).toBeVisible(); + + await page.screenshot({ path: 'test-results/e2e/transcription-comment-open.png' }); + }); + + // ─── Accessibility ──────────────────────────────────────────────────────── + + test('transcription panel passes axe accessibility check', async ({ page }) => { + test.setTimeout(30_000); + await page.goto(docHref); + await page.waitForSelector('[data-hydrated]'); + await page.getByRole('button', { name: 'Transkription' }).click(); + await page.waitForSelector('[data-testid="bottom-panel-content"]'); + + const results = await new AxeBuilder({ page }).analyze(); + expect(results.violations).toHaveLength(0); + }); + + // ─── Empty state ────────────────────────────────────────────────────────── + + test('shows empty state when document has no transcription blocks', async ({ page, request }) => { + test.setTimeout(30_000); + const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000'; + + const emptyDocRes = await request.post('/api/documents', { + multipart: { title: 'E2E Empty Transcription Test' } + }); + if (!emptyDocRes.ok()) throw new Error(`Create empty doc failed: ${emptyDocRes.status()}`); + const emptyDoc = await emptyDocRes.json(); + + await page.goto(`${baseURL}/documents/${emptyDoc.id}`); + await page.waitForSelector('[data-hydrated]'); + await page.getByRole('button', { name: 'Transkription' }).click(); + await page.waitForSelector('[data-testid="bottom-panel-content"]'); + + await expect(page.getByText(/Markiere einen Bereich/)).toBeVisible(); + await page.screenshot({ path: 'test-results/e2e/transcription-empty-state.png' }); + }); +}); diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 1b395dfb..b1d59d3c 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -12,6 +12,7 @@ const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url)); export default defineConfig( includeIgnoreFile(gitignorePath), + { ignores: ['src/paraglide/**'] }, js.configs.recommended, ...ts.configs.recommended, ...svelte.configs.recommended, diff --git a/frontend/src/lib/components/TranscriptionBlock.svelte.spec.ts b/frontend/src/lib/components/TranscriptionBlock.svelte.spec.ts index 1f305694..b439f327 100644 --- a/frontend/src/lib/components/TranscriptionBlock.svelte.spec.ts +++ b/frontend/src/lib/components/TranscriptionBlock.svelte.spec.ts @@ -159,3 +159,59 @@ describe('TranscriptionBlock — reorder controls', () => { expect(onMoveDown).toHaveBeenCalled(); }); }); + +// ─── Delete confirmation ────────────────────────────────────────────────────── + +describe('TranscriptionBlock — delete confirmation', () => { + it('does not call onDeleteClick when user cancels confirm dialog', async () => { + const onDeleteClick = vi.fn(); + vi.spyOn(window, 'confirm').mockReturnValue(false); + renderBlock({ onDeleteClick }); + + const deleteBtn = page.getByRole('button', { name: 'Löschen' }); + await deleteBtn.click(); + + expect(onDeleteClick).not.toHaveBeenCalled(); + vi.restoreAllMocks(); + }); + + it('calls onDeleteClick when user confirms deletion', async () => { + const onDeleteClick = vi.fn(); + vi.spyOn(window, 'confirm').mockReturnValue(true); + renderBlock({ onDeleteClick }); + + const deleteBtn = page.getByRole('button', { name: 'Löschen' }); + await deleteBtn.click(); + + expect(onDeleteClick).toHaveBeenCalledOnce(); + vi.restoreAllMocks(); + }); +}); + +// ─── Quote selection ───────────────────────────────────────────────────────── + +describe('TranscriptionBlock — quote selection', () => { + it('shows quote hint after text is selected in textarea', async () => { + renderBlock({ text: 'Breslau, den 12. August' }); + const textarea = page.getByRole('textbox'); + // Select all text via keyboard shortcut to trigger mouseup with selection + await textarea.click(); + await textarea.selectText(); + // Fire mouseup to trigger the selection handler + await textarea.dispatchEvent('mouseup'); + await expect.element(page.getByText(/Zitat/)).toBeInTheDocument(); + }); +}); + +// ─── Fading state ──────────────────────────────────────────────────────────── + +describe('TranscriptionBlock — fading save state', () => { + it('shows Gespeichert text in fading state (opacity-0 fade-out)', async () => { + renderBlock({ saveState: 'fading' }); + const indicator = page.getByText(/Gespeichert/); + await expect.element(indicator).toBeInTheDocument(); + // The fading class sets opacity-0 + const el = document.querySelector('.opacity-0'); + expect(el).not.toBeNull(); + }); +}); diff --git a/frontend/src/lib/components/TranscriptionEditView.svelte.spec.ts b/frontend/src/lib/components/TranscriptionEditView.svelte.spec.ts index b7f8621c..11a4d29b 100644 --- a/frontend/src/lib/components/TranscriptionEditView.svelte.spec.ts +++ b/frontend/src/lib/components/TranscriptionEditView.svelte.spec.ts @@ -93,3 +93,132 @@ describe('TranscriptionEditView — reorder', () => { expect(handles.length).toBe(2); }); }); + +// ─── Auto-save debounce ─────────────────────────────────────────────────────── + +describe('TranscriptionEditView — auto-save debounce', () => { + it('calls onSaveBlock after 1500ms debounce when text changes', async () => { + vi.useFakeTimers(); + const onSaveBlock = vi.fn().mockResolvedValue(undefined); + renderView({ onSaveBlock }); + + const textarea = page.getByRole('textbox').first(); + await textarea.fill('Neue Zeile'); + + // Not called immediately + expect(onSaveBlock).not.toHaveBeenCalled(); + + // Advance past debounce + vi.advanceTimersByTime(1500); + await vi.runAllTimersAsync(); + + expect(onSaveBlock).toHaveBeenCalledWith('b1', 'Neue Zeile'); + vi.useRealTimers(); + }); + + it('resets debounce timer on rapid successive changes', async () => { + vi.useFakeTimers(); + const onSaveBlock = vi.fn().mockResolvedValue(undefined); + renderView({ onSaveBlock }); + + const textarea = page.getByRole('textbox').first(); + await textarea.fill('First'); + vi.advanceTimersByTime(500); + + await textarea.fill('Second'); + vi.advanceTimersByTime(500); + + // 1000ms elapsed since first change — should not have saved yet + expect(onSaveBlock).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(1000); + await vi.runAllTimersAsync(); + + // Only one save with the final value + expect(onSaveBlock).toHaveBeenCalledTimes(1); + expect(onSaveBlock).toHaveBeenCalledWith('b1', 'Second'); + vi.useRealTimers(); + }); +}); + +// ─── Save state transitions ─────────────────────────────────────────────────── + +describe('TranscriptionEditView — save state indicators', () => { + it('shows saving indicator while onSaveBlock is in-flight', async () => { + vi.useFakeTimers(); + let resolveSave!: () => void; + const onSaveBlock = vi.fn().mockReturnValue(new Promise((r) => (resolveSave = r))); + renderView({ onSaveBlock }); + + await page.getByRole('textbox').first().fill('Hello'); + vi.advanceTimersByTime(1500); + await vi.runAllTimersAsync(); + + await expect.element(page.getByText('Speichere...')).toBeInTheDocument(); + + resolveSave(); + vi.useRealTimers(); + }); + + it('shows error state when onSaveBlock rejects', async () => { + vi.useFakeTimers(); + const onSaveBlock = vi.fn().mockRejectedValue(new Error('network')); + renderView({ onSaveBlock }); + + await page.getByRole('textbox').first().fill('Fails'); + vi.advanceTimersByTime(1500); + await vi.runAllTimersAsync(); + + await expect.element(page.getByText('Nicht gespeichert')).toBeInTheDocument(); + await expect.element(page.getByText('Erneut versuchen')).toBeInTheDocument(); + vi.useRealTimers(); + }); +}); + +// ─── Flush on blur ──────────────────────────────────────────────────────────── + +describe('TranscriptionEditView — flush on blur', () => { + it('flushes pending save immediately on textarea blur before debounce expires', async () => { + vi.useFakeTimers(); + const onSaveBlock = vi.fn().mockResolvedValue(undefined); + renderView({ onSaveBlock }); + + const textarea = page.getByRole('textbox').first(); + await textarea.fill('Blur text'); + + // Blur before 1500ms debounce fires + await textarea.blur(); + + await vi.runAllTimersAsync(); + expect(onSaveBlock).toHaveBeenCalledWith('b1', 'Blur text'); + vi.useRealTimers(); + }); +}); + +// ─── onDeleteBlock callback ─────────────────────────────────────────────────── + +describe('TranscriptionEditView — delete block', () => { + it('calls onDeleteBlock with correct blockId when delete is confirmed', async () => { + const onDeleteBlock = vi.fn().mockResolvedValue(undefined); + vi.spyOn(window, 'confirm').mockReturnValue(true); + renderView({ onDeleteBlock }); + + const deleteBtn = page.getByRole('button', { name: 'Löschen' }).first(); + await deleteBtn.click(); + + expect(onDeleteBlock).toHaveBeenCalledWith('b1'); + vi.restoreAllMocks(); + }); + + it('does not call onDeleteBlock when deletion is cancelled', async () => { + const onDeleteBlock = vi.fn(); + vi.spyOn(window, 'confirm').mockReturnValue(false); + renderView({ onDeleteBlock }); + + const deleteBtn = page.getByRole('button', { name: 'Löschen' }).first(); + await deleteBtn.click(); + + expect(onDeleteBlock).not.toHaveBeenCalled(); + vi.restoreAllMocks(); + }); +}); -- 2.49.1 From c18ad25514556026fcdcf7d56f52d5353ef99aad Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 6 Apr 2026 11:22:08 +0200 Subject: [PATCH 46/47] remove e2e from pipeline. takes too long --- .gitea/workflows/ci.yml | 132 +--------------------------------------- 1 file changed, 1 insertion(+), 131 deletions(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index f50e8e80..cc298b26 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -71,134 +71,4 @@ jobs: run: | chmod +x mvnw ./mvnw clean test - working-directory: backend - - # ─── E2E Tests ──────────────────────────────────────────────────────────────── - # Needs: PostgreSQL + MinIO (via docker-compose) + Spring Boot + SvelteKit dev server. - # Test data is seeded by DataInitializer on first startup (admin user + e2e profile data). - e2e-tests: - name: E2E Tests - runs-on: ubuntu-latest - - # These env vars are picked up by docker-compose (overrides .env file) - env: - DOCKER_API_VERSION: "1.43" - POSTGRES_USER: archive_user - POSTGRES_PASSWORD: ci_db_password - POSTGRES_DB: family_archive_db - MINIO_ROOT_USER: minio_admin - MINIO_ROOT_PASSWORD: ci_minio_password - MINIO_DEFAULT_BUCKETS: archive-documents - PORT_DB: 5433 - PORT_MINIO_API: 9100 - PORT_MINIO_CONSOLE: 9101 - PORT_BACKEND: 8080 - PORT_FRONTEND: 3000 - - steps: - - uses: actions/checkout@v4 - - # ── Infrastructure ────────────────────────────────────────────────────── - - name: Cleanup leftover containers from previous runs - run: docker compose -f docker-compose.yml -f docker-compose.ci.yml down --volumes --remove-orphans || true - - - name: Start DB and MinIO - run: docker compose -f docker-compose.yml -f docker-compose.ci.yml up -d db minio create-buckets - - - name: Wait for DB to be ready - run: | - timeout 30 bash -c \ - 'until docker compose -f docker-compose.yml -f docker-compose.ci.yml exec -T db pg_isready -U archive_user; do sleep 2; done' - - - name: Connect job container to compose network - run: docker network connect familienarchiv_archive-net $(cat /etc/hostname) - - # ── Backend ───────────────────────────────────────────────────────────── - - uses: actions/setup-java@v4 - with: - java-version: '21' - distribution: temurin - - - name: Cache Maven repository - uses: actions/cache@v4 - with: - path: ~/.m2/repository - key: maven-${{ hashFiles('backend/pom.xml') }} - restore-keys: maven- - - - name: Build backend (skip tests — covered by separate Java test job) - run: | - chmod +x mvnw - ./mvnw clean package -DskipTests - working-directory: backend - - - name: Start backend - run: | - java -jar backend/target/*.jar \ - --spring.profiles.active=e2e \ - --SPRING_DATASOURCE_URL=jdbc:postgresql://db:5432/family_archive_db \ - --SPRING_DATASOURCE_USERNAME=archive_user \ - --SPRING_DATASOURCE_PASSWORD=ci_db_password \ - --S3_ENDPOINT=http://minio:9000 \ - --S3_ACCESS_KEY=minio_admin \ - --S3_SECRET_KEY=ci_minio_password \ - --S3_BUCKET_NAME=archive-documents \ - --S3_REGION=us-east-1 \ - --APP_ADMIN_USERNAME=admin \ - --APP_ADMIN_PASSWORD=admin123 \ - & - echo "Waiting for backend..." - timeout 90 bash -c \ - 'until curl -sf http://localhost:8080/actuator/health | grep -q "UP"; do sleep 3; done' - echo "Backend is up." - - # ── Frontend ───────────────────────────────────────────────────────────── - - uses: actions/setup-node@v4 - with: - node-version: 20 - - - name: Cache node_modules - id: node-modules-cache - uses: actions/cache@v4 - with: - path: frontend/node_modules - key: node-modules-${{ hashFiles('frontend/package-lock.json') }} - - - name: Install frontend dependencies - if: steps.node-modules-cache.outputs.cache-hit != 'true' - run: npm ci - working-directory: frontend - - - name: Cache Playwright browsers - id: playwright-cache - uses: actions/cache@v4 - with: - path: ~/.cache/ms-playwright - key: playwright-chromium-${{ hashFiles('frontend/package-lock.json') }} - - - name: Install Playwright Chromium + system deps - if: steps.playwright-cache.outputs.cache-hit != 'true' - run: npx playwright install chromium --with-deps - working-directory: frontend - - - name: Install Playwright system deps (browser binary already cached) - if: steps.playwright-cache.outputs.cache-hit == 'true' - run: npx playwright install-deps chromium - working-directory: frontend - - # ── Tests ──────────────────────────────────────────────────────────────── - - name: Run E2E tests - run: npm run test:e2e - working-directory: frontend - env: - E2E_BASE_URL: http://localhost:3000 - E2E_USERNAME: admin - E2E_PASSWORD: admin123 - E2E_BACKEND_URL: http://localhost:8080 - - - name: Upload E2E results - if: always() - uses: actions/upload-artifact@v3 - with: - name: e2e-results - path: frontend/test-results/e2e/ + working-directory: backend \ No newline at end of file -- 2.49.1 From 8e48e67cb856352c73cf225c234a49f41a74511c Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 6 Apr 2026 11:24:57 +0200 Subject: [PATCH 47/47] fix(a11y): increase contrast --- frontend/src/lib/components/PdfViewer.svelte | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/lib/components/PdfViewer.svelte b/frontend/src/lib/components/PdfViewer.svelte index a3f767f6..2eac4308 100644 --- a/frontend/src/lib/components/PdfViewer.svelte +++ b/frontend/src/lib/components/PdfViewer.svelte @@ -325,7 +325,7 @@ function zoomOut() { {#if totalPages > 0} - + {currentPage} / {totalPages} {/if} @@ -394,7 +394,7 @@ function zoomOut() { onclick={() => (showAnnotations = !showAnnotations)} aria-label={showAnnotations ? m.pdf_annotations_hide() : m.pdf_annotations_show()} class="flex items-center gap-1.5 rounded px-2 py-1 font-sans text-xs transition {showAnnotations - ? 'text-ink-3 hover:bg-surface/10' + ? 'text-ink-2 hover:bg-surface/10' : 'bg-surface/10 text-accent'}" >