feat: Expandable metadata drawer + transcription system (#175, #176) #178

Merged
marcel merged 47 commits from feat/issue-175-176-metadata-drawer-transcription into main 2026-04-06 11:31:11 +02:00
60 changed files with 3949 additions and 2108 deletions

View File

@@ -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

View File

@@ -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<DocumentComment> 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}")

View File

@@ -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.exception.DomainException;
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<TranscriptionBlock> 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 = requireUserId(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 = requireUserId(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) {
transcriptionService.deleteBlock(documentId, blockId);
}
@PutMapping("/reorder")
@RequirePermission(Permission.WRITE_ALL)
public List<TranscriptionBlock> reorderBlocks(
@PathVariable UUID documentId,
@RequestBody ReorderTranscriptionBlocksDTO dto) {
transcriptionService.reorderBlocks(documentId, dto);
return transcriptionService.listBlocks(documentId);
}
@GetMapping("/{blockId}/history")
@RequirePermission(Permission.READ_ALL)
public List<TranscriptionBlockVersion> getBlockHistory(
@PathVariable UUID documentId,
@PathVariable UUID blockId) {
return transcriptionService.getBlockHistory(documentId, blockId);
}
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();
}
}

View File

@@ -0,0 +1,25 @@
package org.raddatz.familienarchiv.dto;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Positive;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@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;
}

View File

@@ -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<UUID> blockIds;
}

View File

@@ -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;
}

View File

@@ -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,

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -13,4 +13,6 @@ public interface CommentRepository extends JpaRepository<DocumentComment, UUID>
List<DocumentComment> findByAnnotationIdAndParentIdIsNull(UUID annotationId);
List<DocumentComment> findByParentId(UUID parentId);
List<DocumentComment> findByBlockIdAndParentIdIsNull(UUID blockId);
}

View File

@@ -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<TranscriptionBlock, UUID> {
List<TranscriptionBlock> findByDocumentIdOrderBySortOrderAsc(UUID documentId);
Optional<TranscriptionBlock> findByIdAndDocumentId(UUID id, UUID documentId);
int countByDocumentId(UUID documentId);
}

View File

@@ -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<TranscriptionBlockVersion, UUID> {
List<TranscriptionBlockVersion> findByBlockIdOrderByChangedAtDesc(UUID blockId);
}

View File

@@ -34,6 +34,28 @@ public class CommentService {
return withRepliesAndMentions(roots);
}
public List<DocumentComment> getCommentsForBlock(UUID blockId) {
List<DocumentComment> roots = commentRepository.findByBlockIdAndParentIdIsNull(blockId);
return withRepliesAndMentions(roots);
}
@Transactional
public DocumentComment postBlockComment(UUID documentId, UUID blockId, String content,
List<UUID> 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<UUID> mentionedUserIds, AppUser author) {

View File

@@ -0,0 +1,140 @@
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.AnnotationRepository;
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 AnnotationRepository annotationRepository;
private final AnnotationService annotationService;
private final DocumentService documentService;
public List<TranscriptionBlock> 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);
log.info("Created transcription block {} for document {}", saved.getId(), documentId);
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) {
TranscriptionBlock block = getBlock(documentId, blockId);
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);
blockRepository.flush();
annotationRepository.deleteById(annotationId);
log.info("Deleted transcription block {} and annotation {} for document {}",
blockId, annotationId, documentId);
}
@Transactional
public void reorderBlocks(UUID documentId, ReorderTranscriptionBlocksDTO dto) {
List<UUID> blockIds = dto.getBlockIds();
for (int i = 0; i < blockIds.size(); i++) {
TranscriptionBlock block = getBlock(documentId, blockIds.get(i));
block.setSortOrder(i);
blockRepository.save(block);
}
}
public List<TranscriptionBlockVersion> 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);
}
String sanitizeText(String text) {
if (text == null) return "";
if (text.length() > MAX_TEXT_LENGTH) {
text = text.substring(0, MAX_TEXT_LENGTH);
}
return text;
}
}

View File

@@ -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 RESTRICT,
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
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,
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()
);
CREATE INDEX idx_tb_document_sort ON transcription_blocks(document_id, sort_order);
CREATE INDEX idx_tb_annotation ON transcription_blocks(annotation_id);

View File

@@ -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 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);

View File

@@ -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);

View File

@@ -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()));
}
}

View File

@@ -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());
}
}

View File

@@ -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<TranscriptionBlock> 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<TranscriptionBlock> 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<TranscriptionBlock> result = blockRepository.findByDocumentIdOrderBySortOrderAsc(other.getId());
assertThat(result).isEmpty();
}
// ─── findByIdAndDocumentId ────────────────────────────────────────────────
@Test
void findByIdAndDocumentId_returnsBlock_whenBothMatch() {
TranscriptionBlock saved = blockRepository.save(block("Liebe Tante,", 0));
Optional<TranscriptionBlock> 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<TranscriptionBlock> found = blockRepository.findByIdAndDocumentId(saved.getId(), UUID.randomUUID());
assertThat(found).isEmpty();
}
@Test
void findByIdAndDocumentId_returnsEmpty_whenBlockIdDoesNotExist() {
Optional<TranscriptionBlock> 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();
}
}

View File

@@ -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<DocumentComment> 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");
}
}

View File

@@ -0,0 +1,246 @@
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));
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<TranscriptionBlockVersion> 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);
}
}

View File

@@ -17,6 +17,7 @@ bun.lockb
/src/lib/generated/
/src/lib/paraglide/
/src/lib/paraglide_bak*/
/src/paraglide/
# Test artifacts
/test-results/

View File

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

View File

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

View File

@@ -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,

View File

@@ -315,10 +315,14 @@
"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",
"comment_edited_label": "(Bearbeitet)",
"comment_time_just_now": "gerade eben",
"comment_time_minutes": "vor {count} Minute(n)",
"comment_time_hours": "vor {count} Stunde(n)",
"comment_time_days": "vor {count} Tag(en)",
"comment_panel_title": "Kommentare",
"comment_panel_close": "Schließen",
"doc_panel_tab_metadata": "Metadaten",
@@ -423,5 +427,34 @@
"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_next_block_cta": "Markiere eine weitere Passage im Scan, um Block {number} anzulegen",
"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"
}

View File

@@ -315,10 +315,14 @@
"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",
"comment_edited_label": "(Edited)",
"comment_time_just_now": "just now",
"comment_time_minutes": "{count} minute(s) ago",
"comment_time_hours": "{count} hour(s) ago",
"comment_time_days": "{count} day(s) ago",
"comment_panel_title": "Comments",
"comment_panel_close": "Close",
"doc_panel_tab_metadata": "Metadata",
@@ -423,5 +427,34 @@
"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_next_block_cta": "Mark another passage on the scan to create block {number}",
"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"
}

View File

@@ -315,10 +315,14 @@
"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",
"comment_edited_label": "(Editado)",
"comment_time_just_now": "justo ahora",
"comment_time_minutes": "hace {count} minuto(s)",
"comment_time_hours": "hace {count} hora(s)",
"comment_time_days": "hace {count} día(s)",
"comment_panel_title": "Comentarios",
"comment_panel_close": "Cerrar",
"doc_panel_tab_metadata": "Metadatos",
@@ -423,5 +427,34 @@
"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_next_block_cta": "Marque otro pasaje en el escaneo para crear el bloque {number}",
"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"
}

View File

@@ -1,22 +0,0 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
type Props = {
annotateMode: boolean;
};
let { annotateMode }: Props = $props();
</script>
{#if annotateMode}
<div
data-testid="annotate-hint-strip"
class="hidden h-[29px] items-center gap-2 border-t border-dashed px-3.5 md:flex"
style="background: rgba(1,40,81,0.05); border-color: rgba(1,40,81,0.20)"
>
<span class="text-[16px] font-bold tracking-wide text-primary uppercase"
>{m.doc_panel_annotate()}</span
>
<span class="text-[16px] text-ink-2">{m.doc_panel_annotate_hint()}</span>
</div>
{/if}

View File

@@ -1,27 +0,0 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import AnnotateHintStrip from './AnnotateHintStrip.svelte';
afterEach(cleanup);
describe('AnnotateHintStrip', () => {
it('is absent from the DOM when annotateMode is false', async () => {
render(AnnotateHintStrip, { annotateMode: false });
const strip = page.getByTestId('annotate-hint-strip');
await expect.element(strip).not.toBeInTheDocument();
});
it('is present in the DOM when annotateMode is true', async () => {
render(AnnotateHintStrip, { annotateMode: true });
const strip = page.getByTestId('annotate-hint-strip');
await expect.element(strip).toBeInTheDocument();
});
it('has hidden md:flex class to hide below 768px', async () => {
render(AnnotateHintStrip, { annotateMode: true });
const strip = page.getByTestId('annotate-hint-strip');
await expect.element(strip).toHaveClass('hidden');
await expect.element(strip).toHaveClass('md:flex');
});
});

View File

@@ -1,90 +0,0 @@
<script lang="ts">
import CommentThread from './CommentThread.svelte';
import { m } from '$lib/paraglide/messages.js';
type Props = {
documentId: string;
annotationId: string;
canComment: boolean;
currentUserId: string | null;
canAdmin: boolean;
onClose: () => void;
onCountChange?: (count: number) => void;
};
let {
documentId,
annotationId,
canComment,
currentUserId,
canAdmin,
onClose,
onCountChange
}: Props = $props();
</script>
<!-- Desktop / tablet panel (≥ sm): absolute overlay on the right side -->
<div
class="absolute top-0 right-0 z-50 hidden h-full w-80 flex-col border-l border-line bg-surface shadow-2xl sm:flex"
>
<div class="flex shrink-0 items-center justify-between border-b border-line px-4 py-3">
<h3 class="font-sans text-xs font-bold tracking-widest text-ink uppercase">
{m.comment_panel_title()}
</h3>
<button
onclick={onClose}
aria-label={m.comment_panel_close()}
class="rounded p-1 text-ink-3 transition-colors hover:bg-muted hover:text-ink"
>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="flex-1 overflow-y-auto p-4">
<CommentThread
documentId={documentId}
annotationId={annotationId}
canComment={canComment}
currentUserId={currentUserId}
canAdmin={canAdmin}
loadOnMount={true}
onCountChange={onCountChange}
/>
</div>
</div>
<!-- Mobile modal (< sm): fixed full-screen with slide-up sheet -->
<div class="fixed inset-0 z-50 flex flex-col sm:hidden">
<!-- Semi-transparent backdrop -->
<div class="flex-1 bg-black/40" onclick={onClose} role="presentation"></div>
<!-- Slide-up panel -->
<div class="flex max-h-[80vh] flex-col rounded-t-2xl bg-surface shadow-2xl">
<div class="flex shrink-0 items-center justify-between border-b border-line px-4 py-3">
<h3 class="font-sans text-xs font-bold tracking-widest text-ink uppercase">
{m.comment_panel_title()}
</h3>
<button
onclick={onClose}
aria-label={m.comment_panel_close()}
class="rounded p-1 text-ink-3 transition-colors hover:bg-muted hover:text-ink"
>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="flex-1 overflow-y-auto p-4">
<CommentThread
documentId={documentId}
annotationId={annotationId}
canComment={canComment}
currentUserId={currentUserId}
canAdmin={canAdmin}
loadOnMount={true}
onCountChange={onCountChange}
/>
</div>
</div>
</div>

View File

@@ -10,19 +10,19 @@ type DrawRect = {
let {
annotations = [],
canAnnotate,
canDraw,
color,
blockNumbers = {},
activeAnnotationId = null,
onDraw,
onDelete,
commentCounts,
onAnnotationClick
}: {
annotations: Annotation[];
canAnnotate: boolean;
canDraw: boolean;
color: string;
onDraw: (rect: { x: number; y: number; width: number; height: number }) => void;
onDelete: (id: string) => void;
commentCounts?: Record<string, number>;
blockNumbers?: Record<string, number>;
activeAnnotationId?: string | null;
onDraw: (rect: DrawRect) => void;
onAnnotationClick?: (id: string) => void;
} = $props();
@@ -45,7 +45,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;
@@ -58,7 +58,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);
@@ -72,7 +72,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);
@@ -93,7 +93,7 @@ function handlePointerUp(event: PointerEvent) {
let hoveredId = $state<string | null>(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;' : ''}`
);
</script>
@@ -110,9 +110,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="
@@ -121,71 +123,36 @@ 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;
transition: background-color 0.15s ease, box-shadow 0.15s ease;
{onAnnotationClick && !canAnnotate ? 'cursor: pointer;' : ''}
cursor: pointer;
transition: background-color 0.15s ease, box-shadow 0.15s ease, opacity 0.3s ease;
"
>
{#if canAnnotate}
<button
aria-label="Annotation löschen"
onclick={(e) => {
e.stopPropagation();
const count = commentCounts?.[annotation.id] ?? 0;
if (count > 0) {
const msg =
count === 1
? 'Diese Annotation hat 1 Kommentar. Beim Löschen wird er ebenfalls entfernt. Fortfahren?'
: `Diese Annotation hat ${count} Kommentare. Beim Löschen werden sie ebenfalls entfernt. Fortfahren?`;
if (!window.confirm(msg)) return;
}
onDelete(annotation.id);
}}
{#if blockNumbers[annotation.id]}
<div
style="
position: absolute;
top: -8px;
right: -8px;
width: 16px;
height: 16px;
background-color: #ef4444;
color: white;
border: none;
left: -8px;
width: 20px;
height: 20px;
border-radius: 50%;
cursor: pointer;
background-color: {annotation.color};
color: white;
font-size: 11px;
font-family: sans-serif;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
line-height: 1;
padding: 0;
pointer-events: auto;
">×</button
pointer-events: none;
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
"
>
{/if}
{#if (commentCounts?.[annotation.id] ?? 0) > 0}
<div
style="
position: absolute;
bottom: -10px;
right: -10px;
background-color: #002850;
color: white;
font-size: 11px;
font-family: sans-serif;
font-weight: 600;
padding: 2px 6px;
border-radius: 999px;
min-width: 20px;
text-align: center;
white-space: nowrap;
pointer-events: none;
line-height: 18px;
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
"
>
{commentCounts?.[annotation.id]}
{blockNumbers[annotation.id]}
</div>
{/if}
</div>

View File

@@ -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,39 +36,77 @@ 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 () => {
it('has crosshair cursor when canDraw is true', async () => {
render(AnnotationLayer, {
annotations: [makeAnnotation('ann-1')],
canAnnotate: true,
color: '#ff0000',
onDraw: () => {},
onDelete: () => {}
annotations: [],
canDraw: true,
color: '#00C7B1',
onDraw: () => {}
});
await expect
.element(page.getByRole('button', { name: /annotation löschen/i }))
.toBeInTheDocument();
const container = document.querySelector('[role="presentation"]')!;
expect(container.getAttribute('style')).toContain('cursor: crosshair');
});
it('does not show delete buttons when canAnnotate is false', async () => {
it('does not have crosshair cursor when canDraw is false', async () => {
render(AnnotationLayer, {
annotations: [makeAnnotation('ann-1')],
canAnnotate: false,
color: '#ff0000',
onDraw: () => {},
onDelete: () => {}
annotations: [],
canDraw: false,
color: '#00C7B1',
onDraw: () => {}
});
expect(page.getByRole('button', { name: /annotation löschen/i }).query()).toBeNull();
const container = document.querySelector('[role="presentation"]')!;
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')],
canDraw: true,
color: '#00C7B1',
onDraw: () => {}
});
await expect.element(page.getByTestId('annotation-ann-1')).toBeInTheDocument();
expect(page.getByRole('button', { name: /löschen/i }).query()).toBeNull();
});
});

View File

@@ -1,68 +0,0 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import CommentThread from './CommentThread.svelte';
type Props = {
documentId: string;
activeAnnotationId: string | null;
activeAnnotationPage: number | null;
canComment: boolean;
currentUserId: string | null;
canAdmin: boolean;
targetCommentId?: string | null;
onClose: () => void;
};
let {
documentId,
activeAnnotationId,
activeAnnotationPage,
canComment,
currentUserId,
canAdmin,
targetCommentId = null,
onClose
}: Props = $props();
const visible = $derived(activeAnnotationId !== null);
</script>
<div
class="absolute inset-y-0 right-0 z-10 flex w-80 flex-col border-l border-line bg-surface shadow-[-4px_0_16px_rgba(0,0,0,0.08)] transition-transform duration-200 {visible
? 'translate-x-0'
: 'pointer-events-none translate-x-full'}"
data-testid="annotation-side-panel"
>
<!-- Header -->
<div class="flex shrink-0 items-center justify-between border-b border-line px-4 py-3">
<span class="font-sans text-xs font-medium text-ink">
{m.doc_panel_discussion_annotation_tab({ page: String(activeAnnotationPage ?? '?') })}
</span>
<button
onclick={onClose}
aria-label={m.comment_panel_close()}
class="rounded p-1 text-ink-3 transition-colors hover:bg-muted hover:text-ink"
>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Comment thread -->
<div class="flex-1 overflow-y-auto p-4">
{#if activeAnnotationId}
{#key activeAnnotationId}
<CommentThread
documentId={documentId}
annotationId={activeAnnotationId}
canComment={canComment}
currentUserId={currentUserId}
canAdmin={canAdmin}
targetCommentId={targetCommentId}
loadOnMount={true}
/>
{/key}
{/if}
</div>
</div>

View File

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

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { onMount, tick, untrack } from 'svelte';
import { onMount, untrack } from 'svelte';
import { m } from '$lib/paraglide/messages.js';
import type { Comment, CommentReply } from '$lib/types';
import type { Comment } from '$lib/types';
import MentionEditor from '$lib/components/MentionEditor.svelte';
import { renderBody, extractContent } from '$lib/utils/mention';
import type { MentionDTO } from '$lib/types';
@@ -9,62 +9,95 @@ 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;
showCompose?: boolean;
onCountChange?: (count: number) => void;
};
let {
documentId,
annotationId = null,
blockId = null,
initialComments = [],
loadOnMount = false,
canComment,
currentUserId,
canAdmin,
targetCommentId = null,
currentUserId = null,
quotedText = null,
showCompose = true,
onCountChange
}: Props = $props();
type FlatMessage = {
id: string;
authorId: string | null;
authorName: string;
content: string;
createdAt: string;
updatedAt: string;
mentionDTOs?: MentionDTO[];
};
let comments: Comment[] = $state(untrack(() => [...initialComments]));
let highlightedCommentId: string | null = $state(untrack(() => targetCommentId ?? null));
let newText: string = $state('');
let replyingTo: string | null = $state(null);
let replyText: string = $state('');
let editingId: string | null = $state(null);
let editText: string = $state('');
let posting: boolean = $state(false);
let newMentionCandidates: MentionDTO[] = $state([]);
let replyMentionCandidates: MentionDTO[] = $state([]);
let editMentionCandidates: MentionDTO[] = $state([]);
let editingId: string | null = $state(null);
let editText: string = $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`
);
const flatMessages = $derived(
comments.flatMap((thread) => [thread as FlatMessage, ...(thread.replies as FlatMessage[])])
);
$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);
if (minutes < 1) return 'gerade eben';
if (minutes < 60) return `vor ${minutes} Minute${minutes === 1 ? '' : 'n'}`;
if (minutes < 1) return m.comment_time_just_now();
if (minutes < 60) return m.comment_time_minutes({ count: minutes });
const hours = Math.floor(minutes / 60);
if (hours < 24) return `vor ${hours} Stunde${hours === 1 ? '' : 'n'}`;
if (hours < 24) return m.comment_time_hours({ count: hours });
const days = Math.floor(hours / 24);
return `vor ${days} Tag${days === 1 ? '' : 'en'}`;
return m.comment_time_days({ count: days });
}
function wasEdited(c: { createdAt: string; updatedAt: string }): boolean {
return c.updatedAt > c.createdAt;
}
function canModify(c: { authorId: string | null }): boolean {
return (currentUserId != null && c.authorId === currentUserId) || canAdmin;
function isOwn(c: { authorId: string | null }): boolean {
return currentUserId !== null && c.authorId === currentUserId;
}
function getInitials(name: string): string {
return name
.split(/\s+/)
.slice(0, 2)
.map((w) => w.charAt(0).toUpperCase())
.join('');
}
function extractQuote(content: string): { quote: string | null; body: string } {
const match = content.match(/^>\s*"(.+?)"\s*\n\n?([\s\S]*)$/);
if (match) return { quote: match[1], body: match[2] };
return { quote: null, body: content };
}
async function reload() {
@@ -101,26 +134,9 @@ async function postComment() {
}
}
async function postReply(threadId: string) {
const text = replyText.trim();
if (!text || posting) return;
posting = true;
try {
const { content, mentionedUserIds } = extractContent(text, replyMentionCandidates);
const res = await fetch(`${commentsBase}/${threadId}/replies`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content, mentionedUserIds })
});
if (res.ok) {
replyText = '';
replyMentionCandidates = [];
replyingTo = null;
await reload();
}
} finally {
posting = false;
}
function startEdit(msg: FlatMessage) {
editingId = msg.id;
editText = msg.content;
}
async function saveEdit(commentId: string) {
@@ -128,15 +144,14 @@ async function saveEdit(commentId: string) {
if (!text || posting) return;
posting = true;
try {
const { content, mentionedUserIds } = extractContent(text, editMentionCandidates);
const res = await fetch(`/api/documents/${documentId}/comments/${commentId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content, mentionedUserIds })
body: JSON.stringify({ content: text })
});
if (res.ok) {
editingId = null;
editMentionCandidates = [];
editText = '';
await reload();
}
} finally {
@@ -144,6 +159,21 @@ async function saveEdit(commentId: string) {
}
}
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') {
e.stopPropagation();
cancelEdit();
}
}
async function deleteComment(commentId: string) {
if (posting) return;
posting = true;
@@ -159,230 +189,121 @@ async function deleteComment(commentId: string) {
}
}
function startEdit(comment: Comment | CommentReply) {
editingId = comment.id;
editText = comment.content;
editMentionCandidates = [];
}
function cancelEdit() {
editingId = null;
editText = '';
}
function startReply(threadId: string) {
replyingTo = threadId;
replyText = '';
}
function cancelReply() {
replyingTo = null;
replyText = '';
}
onMount(async () => {
onMount(() => {
if (loadOnMount) {
reload();
} else {
const total = initialComments.reduce((s, c) => s + 1 + c.replies.length, 0);
onCountChange?.(total);
}
if (targetCommentId) {
await tick();
requestAnimationFrame(() => {
const el = document.querySelector(`[data-comment-id="${targetCommentId}"]`);
el?.scrollIntoView({ behavior: 'smooth', block: 'center' });
});
// Remove highlight on first user interaction
const clearHighlight = () => {
highlightedCommentId = null;
document.removeEventListener('click', clearHighlight, true);
document.removeEventListener('keydown', clearHighlight, true);
document.removeEventListener('scroll', clearHighlight, true);
};
document.addEventListener('click', clearHighlight, true);
document.addEventListener('keydown', clearHighlight, true);
document.addEventListener('scroll', clearHighlight, true);
}
});
</script>
<!--
Renders a single comment or reply entry.
showReplyButton: whether the "Reply" button appears (only on last item in a thread).
-->
{#snippet commentEntry(comment: Comment | CommentReply, threadId: string, showReplyButton: boolean)}
{#if editingId === comment.id}
<div class="flex flex-col gap-2">
<MentionEditor
bind:value={editText}
bind:mentionCandidates={editMentionCandidates}
rows={3}
disabled={posting}
onsubmit={() => saveEdit(comment.id)}
/>
<div class="flex items-center gap-3">
<button
class="rounded bg-primary px-3 py-1.5 font-sans text-xs font-medium text-primary-fg hover:bg-primary/80 disabled:opacity-40"
disabled={posting}
onclick={() => saveEdit(comment.id)}
>
{m.btn_save()}
</button>
<button
class="font-sans text-xs text-ink-3 transition-colors hover:text-ink"
onclick={cancelEdit}
>
{m.btn_cancel()}
</button>
</div>
</div>
{:else}
<div class="flex items-start justify-between gap-2">
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<span class="font-sans text-xs font-semibold text-ink">{comment.authorName}</span>
<span class="font-sans text-xs text-ink-3">{timeAgo(comment.createdAt)}</span>
{#if wasEdited(comment)}
<span class="font-sans text-xs text-ink-3">
{m.comment_edited_label()}
{timeAgo(comment.updatedAt)}
</span>
{/if}
</div>
<p class="mt-1 font-serif text-sm leading-relaxed text-ink-2">
<!-- eslint-disable-next-line svelte/no-at-html-tags -- renderBody escapes all HTML before injecting mention links -->
{@html renderBody(comment.content, comment.mentionDTOs ?? [])}
</p>
</div>
{#if canModify(comment)}
<div class="flex shrink-0 items-center gap-2">
<button
class="font-sans text-xs text-ink-3 transition-colors hover:text-ink"
onclick={() => startEdit(comment)}
>
{m.btn_edit()}
</button>
<button
class="font-sans text-xs text-ink-3 transition-colors hover:text-ink"
onclick={() => deleteComment(comment.id)}
>
{m.btn_delete()}
</button>
</div>
{/if}
</div>
{#if showReplyButton && canComment}
<div class="mt-1">
<button
class="font-sans text-xs font-medium text-primary transition-colors hover:text-ink-2"
onclick={() => startReply(threadId)}
>
{m.comment_btn_reply()}
</button>
</div>
{/if}
{/if}
{/snippet}
<div class="space-y-4">
{#if comments.length === 0}
<div class="flex flex-col items-center gap-3 py-8 text-center">
{#if flatMessages.length > 0}
<div class="rounded border-l-2 border-accent bg-muted p-2">
<div class="mb-2 flex items-center gap-1.5 font-sans text-sm font-semibold text-ink-2">
<svg
class="h-10 w-10 text-ink-3"
class="h-3.5 w-3.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M2.25 12.76c0 1.6 1.123 2.994 2.707 3.227 1.087.16 2.185.283 3.293.369V21l4.076-4.076a1.526 1.526 0 0 1 1.037-.443 48.282 48.282 0 0 0 5.68-.494c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z"
d="M2.25 12.76c0 1.6 1.123 2.994 2.707 3.227 1.087.16 2.185.283 3.293.369V21l4.076-4.076a1.526 1.526 0 011.037-.443 48.282 48.282 0 005.68-.494c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z"
/>
</svg>
<p class="font-sans text-sm text-ink-3">{m.comment_empty_hint()}</p>
{flatMessages.length}
{flatMessages.length === 1 ? 'Kommentar' : 'Kommentare'}
</div>
{/if}
{#each comments as thread, ti (thread.id)}
<div class={ti > 0 ? 'border-t border-line pt-4' : ''}>
<!-- Root comment -->
<div
data-comment-id={thread.id}
class={highlightedCommentId === thread.id
? 'rounded outline-2 outline-offset-1 outline-accent transition-shadow outline-dotted'
: ''}
>
{@render commentEntry(thread, thread.id, thread.replies.length === 0)}
</div>
<!-- Replies -->
{#each thread.replies as reply, ri (reply.id)}
<div
data-comment-id={reply.id}
class="mt-3 ml-6 border-l-2 border-line pl-4 {highlightedCommentId === reply.id
? 'rounded outline-2 outline-offset-1 outline-accent transition-shadow outline-dotted'
: ''}"
>
{@render commentEntry(reply, thread.id, ri === thread.replies.length - 1)}
</div>
{/each}
<div class="space-y-2">
{#each flatMessages as msg (msg.id)}
{@const parsed = extractQuote(msg.content)}
<div class="flex gap-2">
<div
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary text-[10px] font-bold text-primary-fg"
>
{getInitials(msg.authorName)}
</div>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-1.5">
<span class="font-sans text-sm font-semibold text-ink">{msg.authorName}</span>
{#if wasEdited(msg)}
<span class="font-sans text-xs text-ink-3"
>{timeAgo(msg.updatedAt)} {m.comment_edited_label()}</span
>
{:else}
<span class="font-sans text-xs text-ink-3">{timeAgo(msg.createdAt)}</span>
{/if}
</div>
{#if parsed.quote}
<div class="my-1 border-l-2 border-line pl-2 font-serif text-base text-ink-3 italic">
&ldquo;{parsed.quote}&rdquo;
</div>
{/if}
<!-- Reply compose box -->
{#if replyingTo === thread.id}
<div class="mt-3 ml-6 flex flex-col gap-2">
<MentionEditor
bind:value={replyText}
bind:mentionCandidates={replyMentionCandidates}
rows={3}
placeholder={m.comment_placeholder()}
disabled={posting}
onsubmit={() => postReply(thread.id)}
/>
<div class="flex items-center gap-3">
<button
class="rounded bg-primary px-3 py-1.5 font-sans text-xs font-medium text-primary-fg hover:bg-primary/80 disabled:opacity-40"
disabled={posting}
onclick={() => postReply(thread.id)}
>
{m.comment_btn_post()}
</button>
<button
class="font-sans text-xs text-ink-3 transition-colors hover:text-ink"
onclick={cancelReply}
>
{m.btn_cancel()}
</button>
{#if editingId === msg.id}
<textarea
class="mt-1 w-full resize-none rounded border border-line bg-surface px-2 py-1 font-serif text-sm leading-relaxed text-ink outline-none focus:border-primary"
rows={2}
bind:value={editText}
onkeydown={(e) => handleEditKeydown(e, msg.id)}
></textarea>
<div class="mt-1 font-sans text-xs text-ink-3">Enter speichern · Esc abbrechen</div>
{:else}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="relative" onclick={() => { if (isOwn(msg)) startEdit(msg); }}>
<p
class="font-serif text-base leading-relaxed text-ink-2 {isOwn(msg) ? '-mx-1 cursor-text rounded px-1 transition-colors hover:bg-surface' : ''}"
>
<!-- eslint-disable-next-line svelte/no-at-html-tags -- renderBody escapes all HTML before injecting mention links -->
{@html renderBody(parsed.body, msg.mentionDTOs ?? [])}
</p>
{#if isOwn(msg)}
<button
type="button"
class="hover:text-error absolute -right-1 -bottom-1 cursor-pointer rounded p-0.5 text-ink-3 transition-colors"
title={m.btn_delete()}
aria-label={m.btn_delete()}
onclick={(e) => { e.stopPropagation(); deleteComment(msg.id); }}
>
<svg
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
{/if}
</div>
{/if}
</div>
</div>
{/if}
{/each}
</div>
{/each}
</div>
{/if}
<!-- New top-level comment -->
{#if canComment}
<div class={comments.length > 0 ? 'border-t border-line pt-4' : ''}>
<div class="flex flex-col gap-2">
<MentionEditor
bind:value={newText}
bind:mentionCandidates={newMentionCandidates}
rows={3}
placeholder={m.comment_placeholder()}
disabled={posting}
onsubmit={postComment}
/>
<div>
<button
class="rounded bg-primary px-3 py-1.5 font-sans text-xs font-medium text-primary-fg hover:bg-primary/80 disabled:opacity-40"
disabled={posting || !newText.trim()}
onclick={postComment}
>
{m.comment_btn_post()}
</button>
</div>
</div>
</div>
{/if}
</div>
{#if canComment && (showCompose || flatMessages.length > 0)}
<div class="mt-2">
<MentionEditor
bind:value={newText}
bind:mentionCandidates={newMentionCandidates}
rows={1}
placeholder={m.comment_placeholder()}
disabled={posting}
onsubmit={postComment}
/>
</div>
{/if}

View File

@@ -1,193 +0,0 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import PanelMetadata from './PanelMetadata.svelte';
import PanelTranscription from './PanelTranscription.svelte';
import PanelDiscussion from './PanelDiscussion.svelte';
import PanelHistory from './PanelHistory.svelte';
import type { Comment, DocumentPanelTab } from '$lib/types';
type Doc = {
id: string;
title?: string | null;
documentDate?: string | null;
location?: string | null;
documentLocation?: string | null;
tags?: { id: string; name: string }[] | null;
sender?: { id: string; firstName: string; lastName: string; alias?: string | null } | null;
receivers?: { id: string; firstName: string; lastName: string }[] | null;
summary?: string | null;
transcription?: string | null;
};
type Props = {
doc: Doc;
comments: Comment[];
canComment: boolean;
currentUserId: string | null;
canAdmin: boolean;
open: boolean;
height: number;
activeTab: DocumentPanelTab;
targetCommentId?: string | null;
};
let {
doc,
comments,
canComment,
currentUserId,
canAdmin,
open = $bindable(),
height = $bindable(),
activeTab = $bindable(),
targetCommentId = null
}: Props = $props();
const MIN_HEIGHT = 52; // drag handle (8px) + tab bar (~44px)
let isDragging = $state(false);
let dragStartY = 0;
let dragStartHeight = 0;
function fullHeight() {
const topbar = document.querySelector('[data-topbar]');
return window.innerHeight - (topbar?.getBoundingClientRect().bottom ?? 0);
}
function openTab(tab: DocumentPanelTab) {
activeTab = tab;
if (!open) {
open = true;
if (height <= MIN_HEIGHT) height = fullHeight();
}
}
function closePanel() {
open = false;
}
function onDragStart(e: PointerEvent) {
isDragging = true;
dragStartY = e.clientY;
dragStartHeight = open ? height : MIN_HEIGHT;
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
}
function onDragMove(e: PointerEvent) {
if (!isDragging) return;
const delta = dragStartY - e.clientY; // positive = dragging up = bigger panel
const newHeight = dragStartHeight + delta;
const maxHeight = fullHeight();
if (newHeight <= MIN_HEIGHT + 20) {
// collapsed past threshold → close
open = false;
} else {
open = true;
height = Math.max(80, Math.min(newHeight, maxHeight));
}
}
function onDragEnd() {
isDragging = false;
}
const tabs: { id: DocumentPanelTab; label: () => string }[] = [
{ id: 'metadata', label: m.doc_panel_tab_metadata },
{ id: 'transcription', label: m.doc_panel_tab_transcription },
{ id: 'discussion', label: m.doc_panel_tab_discussion },
{ id: 'history', label: m.doc_panel_tab_history }
];
const panelHeight = $derived(open ? height : MIN_HEIGHT);
let discussionCount = $state((() => comments.reduce((s, c) => s + 1 + c.replies.length, 0))());
function handleCountChange(count: number) {
discussionCount = count;
}
</script>
<div
class="z-30 flex shrink-0 flex-col border-t border-line bg-surface shadow-[0_-4px_16px_rgba(0,0,0,0.08)]"
style="height: {panelHeight}px"
data-testid="bottom-panel"
>
<!-- Drag handle -->
<div
class="flex h-2 shrink-0 cursor-ns-resize items-center justify-center bg-surface"
style="touch-action: none"
role="separator"
aria-orientation="horizontal"
aria-label="Panel resize"
onpointerdown={onDragStart}
onpointermove={onDragMove}
onpointerup={onDragEnd}
onpointercancel={onDragEnd}
>
<div class="h-1 w-12 rounded-full bg-line"></div>
</div>
<!-- Tab bar -->
<div class="flex shrink-0 items-center border-b border-line bg-surface">
<!-- Scrollable tabs area — hides scrollbar visually -->
<div
class="flex flex-1 items-center overflow-x-auto px-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
>
{#each tabs as tab (tab.id)}
<button
onclick={() => openTab(tab.id)}
class="mr-1 shrink-0 px-3 py-2.5 font-sans text-xs font-medium transition-colors {activeTab === tab.id && open
? 'border-b-2 border-primary text-ink'
: 'text-ink-3 hover:text-ink'}"
aria-pressed={activeTab === tab.id && open}
>
{tab.label()}
{#if tab.id === 'discussion'}
<span
data-testid="discussion-count-badge"
class="ml-1.5 inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 font-sans text-[10px] font-bold text-primary-fg"
>{discussionCount}</span
>
{/if}
</button>
{/each}
</div>
{#if open}
<button
onclick={closePanel}
data-testid="panel-close-btn"
aria-label="Panel schließen"
class="mr-2 shrink-0 rounded p-1.5 text-ink-3 transition-colors hover:bg-muted hover:text-ink"
>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
{/if}
</div>
<!-- Tab content -->
{#if open}
<div class="flex-1 overflow-y-auto" data-testid="bottom-panel-content">
{#if activeTab === 'metadata'}
<PanelMetadata doc={doc} />
{:else if activeTab === 'transcription'}
<PanelTranscription doc={doc} />
{:else if activeTab === 'discussion'}
<PanelDiscussion
documentId={doc.id}
initialComments={comments}
canComment={canComment}
currentUserId={currentUserId}
canAdmin={canAdmin}
targetCommentId={targetCommentId}
onCountChange={handleCountChange}
/>
{:else if activeTab === 'history'}
<PanelHistory documentId={doc.id} />
{/if}
</div>
{/if}
</div>

View File

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

View File

@@ -0,0 +1,146 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { formatDate } from '$lib/utils/date';
import { formatDocumentStatus } from '$lib/utils/documentStatusLabel';
import { personAvatarColor } from '$lib/utils/personFormat';
type Person = { id: string; firstName: string; lastName: string };
type Tag = { id: string; name: string };
type Props = {
documentDate: string | null;
location: string | null;
status: string;
sender: Person | null;
receivers: Person[];
tags: Tag[];
};
let { documentDate, location, status, sender, receivers, tags }: Props = $props();
const VISIBLE_RECEIVER_LIMIT = 5;
const formattedDate = $derived(documentDate ? formatDate(documentDate) : '—');
const displayLocation = $derived(location ?? '—');
const statusLabel = $derived(formatDocumentStatus(status));
const visibleReceivers = $derived(receivers.slice(0, VISIBLE_RECEIVER_LIMIT));
const hiddenReceiverCount = $derived(Math.max(0, receivers.length - VISIBLE_RECEIVER_LIMIT));
const hasPersons = $derived(sender !== null || receivers.length > 0);
const hasTags = $derived(tags.length > 0);
let showAllReceivers = $state(false);
const displayedReceivers = $derived(showAllReceivers ? receivers : visibleReceivers);
function getInitials(person: Person): string {
return `${person.firstName.charAt(0)}${person.lastName.charAt(0)}`.toUpperCase();
}
function getFullName(person: Person): string {
return `${person.firstName} ${person.lastName}`;
}
</script>
{#snippet personCard(person: Person)}
<a
href="/persons/{person.id}"
class="group flex items-center gap-2.5 rounded px-2 py-1.5 transition-colors hover:bg-muted"
>
<span
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-bold text-white"
style="background-color: {personAvatarColor(person.id)}"
aria-hidden="true"
>
{getInitials(person)}
</span>
<span class="font-serif text-sm text-ink">{getFullName(person)}</span>
</a>
{/snippet}
<div class="border-b border-line p-6">
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
<!-- Column 1: Details -->
<div>
<h2 class="mb-4 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.doc_details_section_details()}
</h2>
<dl class="space-y-3 font-serif text-sm">
<div>
<dt class="font-sans text-xs font-medium text-ink-3">{m.doc_details_field_date()}</dt>
<dd class="text-ink">{formattedDate}</dd>
</div>
<div>
<dt class="font-sans text-xs font-medium text-ink-3">{m.form_label_location()}</dt>
<dd class="text-ink">{displayLocation}</dd>
</div>
<div>
<dt class="font-sans text-xs font-medium text-ink-3">{m.doc_details_field_status()}</dt>
<dd class="text-ink">{statusLabel}</dd>
</div>
</dl>
</div>
<!-- Column 2: Personen -->
<div>
<h2 class="mb-4 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.doc_details_section_persons()}
</h2>
{#if hasPersons}
<div class="space-y-3">
{#if sender}
<div>
<p class="mb-1 font-sans text-xs font-medium text-ink-3">
{m.doc_details_field_sender()}
</p>
{@render personCard(sender)}
</div>
{/if}
{#if receivers.length > 0}
<div>
<p class="mb-1 font-sans text-xs font-medium text-ink-3">
{m.doc_details_field_receivers()}
</p>
<div class="space-y-0.5">
{#each displayedReceivers as receiver (receiver.id)}
{@render personCard(receiver)}
{/each}
</div>
{#if hiddenReceiverCount > 0 && !showAllReceivers}
<button
type="button"
onclick={() => (showAllReceivers = true)}
class="mt-1 px-2 font-sans text-xs font-medium text-ink-2 transition-colors hover:text-ink"
>
{m.doc_details_more_receivers({ count: hiddenReceiverCount })}
</button>
{/if}
</div>
{/if}
</div>
{:else}
<p class="font-serif text-sm text-ink-3">{m.doc_details_no_persons()}</p>
{/if}
</div>
<!-- Column 3: Schlagwoerter -->
<div>
<h2 class="mb-4 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.doc_details_section_tags()}
</h2>
{#if hasTags}
<div class="flex flex-wrap gap-2">
{#each tags as tag (tag.id)}
<a
href="/?tag={encodeURIComponent(tag.name)}"
class="rounded bg-muted px-2 py-0.5 text-xs font-bold tracking-wide text-ink uppercase transition-colors hover:bg-accent"
>
{tag.name}
</a>
{/each}
</div>
{:else}
<p class="font-serif text-sm text-ink-3">{m.doc_details_no_tags()}</p>
{/if}
</div>
</div>
</div>

View File

@@ -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<string, unknown> = {}) {
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();
});
});

View File

@@ -1,12 +1,14 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
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';
type Person = { id: string; firstName: string; lastName: string };
type Tag = { id: string; name: string };
type Doc = {
id: string;
@@ -17,17 +19,21 @@ type Doc = {
receivers?: Person[] | null;
filePath?: string | null;
contentType?: string | null;
location?: string | null;
status?: string | null;
tags?: Tag[] | null;
};
type Props = {
doc: Doc;
canWrite: boolean;
canAnnotate: boolean;
fileUrl: string;
annotateMode: boolean;
transcribeMode: boolean;
};
let { doc, canWrite, canAnnotate, fileUrl, annotateMode = $bindable() }: Props = $props();
let { doc, canWrite, fileUrl, transcribeMode = $bindable() }: Props = $props();
let detailsOpen = $state(false);
const isPdf = $derived(!!doc.filePath && doc.contentType?.startsWith('application/pdf'));
const receivers = $derived(doc.receivers ?? []);
@@ -40,47 +46,61 @@ const longDate = $derived(doc.documentDate ? formatDate(doc.documentDate, 'long'
let mobileMenuOpen = $state(false);
</script>
{#snippet annotateBtn(mobile: boolean)}
{#snippet transcribeBtn(mobile: boolean)}
<button
onclick={() => {
annotateMode = true;
transcribeMode = true;
if (mobile) mobileMenuOpen = false;
}}
aria-label={m.doc_panel_annotate()}
aria-label={m.transcription_mode_label()}
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-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'}
>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Note/Note-Add-MD.svg"
alt=""
aria-hidden="true"
<svg
class="h-5 w-5 shrink-0"
/>
{m.doc_panel_annotate()}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
/>
</svg>
{m.transcription_mode_label()}
</button>
{/snippet}
{#snippet annotateStopBtn(mobile: boolean)}
{#snippet transcribeStopBtn(mobile: boolean)}
<button
onclick={() => {
annotateMode = false;
transcribeMode = false;
if (mobile) mobileMenuOpen = false;
}}
aria-label={m.doc_panel_annotate_stop()}
aria-label={m.transcription_mode_stop()}
aria-pressed={true}
class={mobile
? 'flex w-full items-center gap-2 rounded bg-primary px-3 py-2 text-left text-[16px] text-primary-fg transition focus-visible:ring-2 focus-visible:ring-primary'
: 'flex items-center gap-1.5 rounded bg-primary px-3 py-1.5 font-sans text-[16px] font-medium text-primary-fg transition focus-visible:ring-2 focus-visible:ring-primary'}
>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Note/Note-Add-MD.svg"
alt=""
aria-hidden="true"
class="h-5 w-5 shrink-0 invert"
/>
{m.doc_panel_annotate_stop()}
<svg
class="h-5 w-5 shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
/>
</svg>
{m.transcription_mode_stop()}
</button>
{/snippet}
@@ -155,20 +175,41 @@ let mobileMenuOpen = $state(false);
<OverflowPillButton extraCount={extraCount} persons={overflowPersons} />
{/if}
<!-- Details toggle -->
<button
type="button"
onclick={() => (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.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()}
<svg
class="h-3.5 w-3.5 transition-transform duration-200 {detailsOpen ? 'rotate-180' : ''}"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
aria-hidden="true"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
<!-- Divider between metadata and actions -->
<div class="mx-3 hidden h-6 w-px shrink-0 bg-line md:block"></div>
<!-- Action buttons -->
<div class="flex shrink-0 items-center gap-1.5 pr-3 font-sans">
{#if canAnnotate && isPdf && !annotateMode}
{@render annotateBtn(false)}
{#if canWrite && isPdf && !transcribeMode}
{@render transcribeBtn(false)}
{/if}
{#if canAnnotate && isPdf && annotateMode}
{@render annotateStopBtn(false)}
{#if transcribeMode}
{@render transcribeStopBtn(false)}
{/if}
{#if canWrite && !annotateMode}
{#if canWrite && !transcribeMode}
<a
href="/documents/{doc.id}/edit"
aria-label={m.btn_edit()}
@@ -184,12 +225,12 @@ let mobileMenuOpen = $state(false);
</a>
{/if}
{#if doc.filePath && !annotateMode}
{#if doc.filePath && !transcribeMode}
{@render downloadLink(false)}
{/if}
<!-- Kebab menu — mobile only, contains actions hidden below md -->
{#if (canAnnotate && isPdf) || doc.filePath}
{#if (canWrite && isPdf) || doc.filePath}
<div
role="group"
class="relative md:hidden"
@@ -217,8 +258,8 @@ let mobileMenuOpen = $state(false);
role="menu"
class="absolute top-full right-0 z-50 mt-1 min-w-[200px] rounded-md border border-line bg-surface p-2 shadow-lg"
>
{#if canAnnotate && isPdf && !annotateMode}
{@render annotateBtn(true)}
{#if canWrite && isPdf && !transcribeMode}
{@render transcribeBtn(true)}
{/if}
{#if doc.filePath}
@@ -231,6 +272,17 @@ let mobileMenuOpen = $state(false);
</div>
</div>
<!-- Hint strip — only when annotateMode, only at ≥768px -->
<AnnotateHintStrip annotateMode={annotateMode} />
<!-- Metadata drawer -->
{#if detailsOpen}
<div transition:slide={{ duration: 200 }}>
<DocumentMetadataDrawer
documentDate={doc.documentDate ?? null}
location={doc.location ?? null}
status={doc.status ?? 'PLACEHOLDER'}
sender={doc.sender ?? null}
receivers={doc.receivers ? [...doc.receivers] : []}
tags={doc.tags ? [...doc.tags] : []}
/>
</div>
{/if}
</div>

View File

@@ -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;
blockNumbers?: Record<string, number>;
annotationReloadKey?: number;
activeAnnotationId: string | null;
activeAnnotationPage: number | null;
onAnnotationClick: (id: string) => void;
onTranscriptionDraw?: (rect: DrawRect) => void;
};
let {
@@ -25,10 +29,12 @@ let {
fileUrl,
isLoading,
error,
annotateMode = $bindable(),
transcribeMode = false,
blockNumbers = {},
annotationReloadKey = 0,
activeAnnotationId = $bindable(),
activeAnnotationPage = $bindable(),
onAnnotationClick
onAnnotationClick,
onTranscriptionDraw
}: Props = $props();
</script>
@@ -80,10 +86,12 @@ let {
<PdfViewer
url={fileUrl}
documentId={doc.id}
bind:annotateMode={annotateMode}
transcribeMode={transcribeMode}
blockNumbers={blockNumbers}
annotationReloadKey={annotationReloadKey}
bind:activeAnnotationId={activeAnnotationId}
bind:activeAnnotationPage={activeAnnotationPage}
onAnnotationClick={onAnnotationClick}
onTranscriptionDraw={onTranscriptionDraw}
documentFileHash={doc.fileHash ?? null}
/>
{:else if fileUrl}

View File

@@ -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}
</div>
{/if}
<button
type="button"
aria-label={m.mention_btn_label()}
disabled={disabled}
class="mt-1 rounded border border-line px-2 py-0.5 font-sans text-xs font-medium text-ink-3 transition-colors hover:border-ink hover:text-ink disabled:opacity-40"
onclick={handleAtButtonClick}
>
@
</button>
</div>

View File

@@ -1,36 +0,0 @@
<script lang="ts">
import CommentThread from './CommentThread.svelte';
import type { Comment } from '$lib/types';
type Props = {
documentId: string;
initialComments: Comment[];
canComment: boolean;
currentUserId: string | null;
canAdmin: boolean;
targetCommentId?: string | null;
onCountChange?: (count: number) => void;
};
let {
documentId,
initialComments,
canComment,
currentUserId,
canAdmin,
targetCommentId = null,
onCountChange
}: Props = $props();
</script>
<div class="flex-1 overflow-y-auto p-6">
<CommentThread
documentId={documentId}
initialComments={initialComments}
canComment={canComment}
currentUserId={currentUserId}
canAdmin={canAdmin}
targetCommentId={targetCommentId}
onCountChange={onCountChange}
/>
</div>

View File

@@ -1,519 +0,0 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { diffWords } from 'diff';
let { documentId }: { documentId: string } = $props();
type VersionSummary = {
id: string;
savedAt: string;
editorName: string;
changedFields: string[];
};
type SnapshotDoc = {
title?: string;
documentDate?: string;
location?: string;
documentLocation?: string;
transcription?: string;
summary?: string;
sender?: { id: string; firstName: string; lastName: string } | null;
receivers?: { id: string; firstName: string; lastName: string }[];
tags?: { id: string; name: string }[];
};
type DiffEntry =
| {
kind: 'text';
field: string;
label: string;
parts: { value: string; added?: boolean; removed?: boolean }[];
}
| { kind: 'scalar'; field: string; label: string; oldVal: string; newVal: string }
| { kind: 'relation'; field: string; label: string; removed: string[]; added: string[] };
let historyLoaded = $state(false);
let historyLoading = $state(false);
let versions = $state<VersionSummary[]>([]);
let compareMode = $state(false);
let compareA = $state('');
let compareB = $state('');
let selectedVersionId = $state<string | null>(null);
let diffEntries = $state<DiffEntry[]>([]);
let diffLoading = $state(false);
let noDiff = $state(false);
const fieldLabels: Record<string, () => string> = {
title: m.history_field_title,
documentDate: m.history_field_document_date,
location: m.history_field_location,
documentLocation: m.history_field_document_location,
transcription: m.history_field_transcription,
summary: m.history_field_summary,
sender: m.history_field_sender,
receivers: m.history_field_receivers,
tags: m.history_field_tags
};
const TEXT_FIELDS = ['title', 'summary', 'transcription'] as const;
const SCALAR_FIELDS = ['documentDate', 'location', 'documentLocation'] as const;
function parseSnapshot(raw: string): SnapshotDoc {
try {
return JSON.parse(raw) as SnapshotDoc;
} catch {
return {};
}
}
function personLabel(p: { firstName: string; lastName: string }): string {
return `${p.firstName} ${p.lastName}`.trim();
}
const DIFF_CONTEXT_WORDS = 4;
type DiffPart = { value: string; added?: boolean; removed?: boolean };
function trimContextParts(parts: DiffPart[]): DiffPart[] {
return parts.flatMap((part, i) => {
if (part.added || part.removed) return [part];
const tokens = part.value.split(/(\s+)/).filter(Boolean);
const wordCount = tokens.filter((t) => /\S/.test(t)).length;
if (wordCount <= DIFF_CONTEXT_WORDS * 2) return [part];
function keepFirst(n: number): string {
let count = 0;
const out: string[] = [];
for (const t of tokens) {
out.push(t);
if (/\S/.test(t) && ++count >= n) break;
}
return out.join('');
}
function keepLast(n: number): string {
let count = 0;
const out: string[] = [];
for (const t of [...tokens].reverse()) {
out.unshift(t);
if (/\S/.test(t) && ++count >= n) break;
}
return out.join('');
}
const isFirst = i === 0;
const isLast = i === parts.length - 1;
if (isFirst) return [{ value: '… ' + keepLast(DIFF_CONTEXT_WORDS) }];
if (isLast) return [{ value: keepFirst(DIFF_CONTEXT_WORDS) + ' …' }];
return [{ value: keepFirst(DIFF_CONTEXT_WORDS) + ' … ' + keepLast(DIFF_CONTEXT_WORDS) }];
});
}
function buildDiff(older: SnapshotDoc | null, newer: SnapshotDoc): DiffEntry[] {
const entries: DiffEntry[] = [];
for (const field of TEXT_FIELDS) {
const a = older?.[field] ?? '';
const b = newer[field] ?? '';
if (a === b) continue;
const parts = trimContextParts(diffWords(a, b));
entries.push({ kind: 'text', field, label: fieldLabels[field](), parts });
}
for (const field of SCALAR_FIELDS) {
const a = older?.[field] ?? '';
const b = newer[field] ?? '';
if (a === b) continue;
entries.push({ kind: 'scalar', field, label: fieldLabels[field](), oldVal: a, newVal: b });
}
const senderA = older?.sender ? personLabel(older.sender) : '';
const senderB = newer.sender ? personLabel(newer.sender) : '';
if (senderA !== senderB) {
entries.push({
kind: 'relation',
field: 'sender',
label: fieldLabels['sender'](),
removed: senderA ? [senderA] : [],
added: senderB ? [senderB] : []
});
}
const receiversA = new Set((older?.receivers ?? []).map(personLabel));
const receiversB = new Set((newer.receivers ?? []).map(personLabel));
const removedReceivers = [...receiversA].filter((r) => !receiversB.has(r));
const addedReceivers = [...receiversB].filter((r) => !receiversA.has(r));
if (removedReceivers.length > 0 || addedReceivers.length > 0) {
entries.push({
kind: 'relation',
field: 'receivers',
label: fieldLabels['receivers'](),
removed: removedReceivers,
added: addedReceivers
});
}
const tagsA = new Set((older?.tags ?? []).map((t) => t.name));
const tagsB = new Set((newer.tags ?? []).map((t) => t.name));
const removedTags = [...tagsA].filter((t) => !tagsB.has(t));
const addedTags = [...tagsB].filter((t) => !tagsA.has(t));
if (removedTags.length > 0 || addedTags.length > 0) {
entries.push({
kind: 'relation',
field: 'tags',
label: fieldLabels['tags'](),
removed: removedTags,
added: addedTags
});
}
return entries;
}
async function fetchSnapshot(versionId: string): Promise<SnapshotDoc> {
const res = await fetch(`/api/documents/${documentId}/versions/${versionId}`);
if (!res.ok) throw new Error('Failed to fetch version');
const v = await res.json();
return parseSnapshot(v.snapshot);
}
async function loadHistory() {
if (historyLoaded) return;
historyLoading = true;
try {
const res = await fetch(`/api/documents/${documentId}/versions`);
if (res.ok) {
versions = await res.json();
}
historyLoaded = true;
} catch {
// ignore
} finally {
historyLoading = false;
}
}
async function selectVersion(versionId: string) {
if (selectedVersionId === versionId) {
selectedVersionId = null;
diffEntries = [];
noDiff = false;
return;
}
selectedVersionId = versionId;
diffEntries = [];
noDiff = false;
diffLoading = true;
try {
const idx = versions.findIndex((v) => v.id === versionId);
const newerSnap = await fetchSnapshot(versionId);
const olderSnap = idx > 0 ? await fetchSnapshot(versions[idx - 1].id) : null;
const entries = buildDiff(olderSnap, newerSnap);
if (entries.length === 0) {
noDiff = true;
} else {
diffEntries = entries;
}
} catch {
// ignore
} finally {
diffLoading = false;
}
}
async function applyCompare() {
if (!compareA || !compareB || compareA === compareB) return;
selectedVersionId = null;
diffEntries = [];
noDiff = false;
diffLoading = true;
try {
const [snapA, snapB] = await Promise.all([fetchSnapshot(compareA), fetchSnapshot(compareB)]);
const entries = buildDiff(snapA, snapB);
if (entries.length === 0) {
noDiff = true;
} else {
diffEntries = entries;
}
} catch {
// ignore
} finally {
diffLoading = false;
}
}
function formatDateTime(iso: string): string {
try {
return new Intl.DateTimeFormat('de-DE', {
day: 'numeric',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
}).format(new Date(iso));
} catch {
return iso;
}
}
function versionLabel(v: VersionSummary, index: number): string {
return `Version ${index + 1}${v.editorName}${formatDateTime(v.savedAt)}`;
}
// Load history when this panel mounts.
$effect(() => {
loadHistory();
});
</script>
<div class="space-y-4 p-6">
{#if historyLoading}
<p class="font-sans text-xs text-ink-3">{m.history_loading()}</p>
{:else if !historyLoaded}
<!-- initial state before effect runs — show nothing -->
{:else if versions.length === 0}
<p class="font-serif text-sm text-ink-3 italic">{m.history_empty()}</p>
{:else}
<!-- Compare mode toggle -->
<div class="flex justify-end">
<button
onclick={() => {
compareMode = !compareMode;
diffEntries = [];
noDiff = false;
selectedVersionId = null;
}}
class="font-sans text-xs font-medium transition {compareMode
? 'text-ink underline'
: 'text-ink-3 hover:text-ink'}"
>
{m.history_compare_mode()}
</button>
</div>
{#if compareMode}
<div class="space-y-2">
<div>
<label for="compare-a" class="mb-1 block font-sans text-[10px] text-ink-3 uppercase"
>{m.history_compare_select_a()}</label
>
<select
id="compare-a"
bind:value={compareA}
class="w-full rounded border border-line bg-surface px-2 py-1 font-sans text-xs text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>
<option value=""></option>
{#each versions as v, i (v.id)}
<option value={v.id}>{versionLabel(v, i)}</option>
{/each}
</select>
</div>
<div>
<label for="compare-b" class="mb-1 block font-sans text-[10px] text-ink-3 uppercase"
>{m.history_compare_select_b()}</label
>
<select
id="compare-b"
bind:value={compareB}
class="w-full rounded border border-line bg-surface px-2 py-1 font-sans text-xs text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>
<option value=""></option>
{#each versions as v, i (v.id)}
<option value={v.id}>{versionLabel(v, i)}</option>
{/each}
</select>
</div>
<button
onclick={applyCompare}
disabled={!compareA || !compareB || compareA === compareB}
class="w-full rounded bg-primary px-3 py-1.5 font-sans text-xs font-medium text-primary-fg transition hover:bg-primary/80 disabled:cursor-not-allowed disabled:opacity-40"
>
{m.history_compare_apply()}
</button>
</div>
<!-- Diff panel for compare mode -->
{#if diffLoading}
<p class="font-sans text-xs text-ink-3">{m.history_loading()}</p>
{:else if noDiff}
<div
data-testid="history-diff"
class="rounded-sm border border-line bg-surface p-4 font-serif text-sm text-ink-3 italic"
>
{m.history_diff_no_changes()}
</div>
{:else if diffEntries.length > 0}
<div
data-testid="history-diff"
class="space-y-4 rounded-sm border border-line bg-surface p-4"
>
{#each diffEntries as entry (entry.field)}
<div>
<span
class="mb-1.5 block font-sans text-[10px] font-bold tracking-wide text-ink-3 uppercase"
>{entry.label}</span
>
{#if entry.kind === 'text'}
<p class="font-serif text-sm leading-relaxed">
{#each entry.parts as part, partIdx (partIdx)}
{#if part.added}
<span class="bg-green-50 text-green-700">{part.value}</span>
{:else if part.removed}
<span class="bg-red-50 text-red-600 line-through">{part.value}</span>
{:else}
<span>{part.value}</span>
{/if}
{/each}
</p>
{:else if entry.kind === 'scalar'}
<div class="flex items-center gap-2 font-serif text-sm">
<span class="text-red-600 line-through">{entry.oldVal || '—'}</span>
<svg
class="h-3 w-3 flex-shrink-0 text-ink-3"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z"
clip-rule="evenodd"
/>
</svg>
<span class="text-green-700">{entry.newVal || '—'}</span>
</div>
{:else if entry.kind === 'relation'}
<div class="flex flex-wrap gap-1.5">
{#each entry.removed as item (item)}
<span
class="rounded bg-red-50 px-1.5 py-0.5 font-sans text-[11px] text-red-600 line-through"
>{item}</span
>
{/each}
{#each entry.added as item (item)}
<span
class="rounded bg-green-50 px-1.5 py-0.5 font-sans text-[11px] text-green-700"
>{item}</span
>
{/each}
</div>
{/if}
</div>
{/each}
</div>
{/if}
{:else}
<!-- Version list with inline diff below each selected item -->
<ul class="divide-y divide-line">
{#each versions as v, i (v.id)}
<li>
<button
onclick={() => selectVersion(v.id)}
data-testid="history-version"
class="w-full py-2 text-left transition hover:bg-muted {selectedVersionId ===
v.id
? 'border-l-2 border-accent pl-2'
: 'pl-0'}"
>
<div class="flex items-baseline justify-between gap-2">
<span class="font-sans text-xs font-medium text-ink">
Version {i + 1}
</span>
<span class="font-sans text-[10px] text-ink-3">
{formatDateTime(v.savedAt)}
</span>
</div>
<span class="font-sans text-[11px] text-ink-2">{v.editorName}</span>
{#if v.changedFields && v.changedFields.length > 0}
<div class="mt-1 flex flex-wrap gap-1">
{#each v.changedFields as field (field)}
<span
class="rounded bg-muted px-1.5 py-0.5 font-sans text-[10px] tracking-wide text-ink-2 uppercase"
>
{fieldLabels[field] ? fieldLabels[field]() : field}
</span>
{/each}
</div>
{/if}
</button>
<!-- Diff shown inline below the selected version -->
{#if selectedVersionId === v.id}
{#if diffLoading}
<p class="pb-3 pl-2 font-sans text-xs text-ink-3">{m.history_loading()}</p>
{:else if noDiff}
<div
data-testid="history-diff"
class="mb-2 rounded-sm border border-line bg-surface p-4 font-serif text-sm text-ink-3 italic"
>
{m.history_diff_no_changes()}
</div>
{:else if diffEntries.length > 0}
<div
data-testid="history-diff"
class="mb-2 space-y-4 rounded-sm border border-line bg-surface p-4"
>
{#each diffEntries as entry (entry.field)}
<div>
<span
class="mb-1.5 block font-sans text-[10px] font-bold tracking-wide text-ink-3 uppercase"
>{entry.label}</span
>
{#if entry.kind === 'text'}
<p class="font-serif text-sm leading-relaxed">
{#each entry.parts as part, partIdx (partIdx)}
{#if part.added}
<span class="bg-green-50 text-green-700">{part.value}</span>
{:else if part.removed}
<span class="bg-red-50 text-red-600 line-through">{part.value}</span>
{:else}
<span>{part.value}</span>
{/if}
{/each}
</p>
{:else if entry.kind === 'scalar'}
<div class="flex items-center gap-2 font-serif text-sm">
<span class="text-red-600 line-through">{entry.oldVal || '—'}</span>
<svg
class="h-3 w-3 flex-shrink-0 text-ink-3"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z"
clip-rule="evenodd"
/>
</svg>
<span class="text-green-700">{entry.newVal || '—'}</span>
</div>
{:else if entry.kind === 'relation'}
<div class="flex flex-wrap gap-1.5">
{#each entry.removed as item (item)}
<span
class="rounded bg-red-50 px-1.5 py-0.5 font-sans text-[11px] text-red-600 line-through"
>{item}</span
>
{/each}
{#each entry.added as item (item)}
<span
class="rounded bg-green-50 px-1.5 py-0.5 font-sans text-[11px] text-green-700"
>{item}</span
>
{/each}
</div>
{/if}
</div>
{/each}
</div>
{/if}
{/if}
</li>
{/each}
</ul>
{/if}
{/if}
</div>

View File

@@ -1,198 +0,0 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { formatDate } from '$lib/utils/date';
type Person = { id: string; firstName: string; lastName: string; alias?: string | null };
type Tag = { id: string; name: string };
type Doc = {
documentDate?: string | null;
location?: string | null;
documentLocation?: string | null;
tags?: Tag[] | null;
sender?: Person | null;
receivers?: Person[] | null;
};
let { doc }: { doc: Doc } = $props();
</script>
<div class="space-y-10 p-6">
<!-- DETAILS GROUP -->
<div>
<h3
class="mb-4 border-b border-line pb-2 font-sans text-xs font-bold tracking-widest text-ink uppercase"
>
{m.doc_section_details()}
</h3>
<div class="space-y-5">
<!-- Date -->
<div class="flex items-start">
<span class="mt-0.5 w-8 text-accent">
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Calendar/Calendar-Add-MD.svg"
alt=""
aria-hidden="true"
class="h-5 w-5"
/>
</span>
<div>
<span class="block font-serif text-lg text-ink">
{doc.documentDate ? formatDate(doc.documentDate) : '—'}
</span>
<span class="font-sans text-xs text-ink-2">{m.doc_label_document_date()}</span>
</div>
</div>
<!-- Creation Location -->
<div class="flex items-start">
<span class="mt-0.5 w-8 text-accent">
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Location-MD.svg"
alt=""
aria-hidden="true"
class="h-5 w-5"
/>
</span>
<div>
<span class="block font-serif text-lg text-ink">
{doc.location ? doc.location : '—'}
</span>
<span class="font-sans text-xs text-ink-2">{m.doc_label_creation_location()}</span>
</div>
</div>
<!-- Physical Archive Location -->
{#if doc.documentLocation}
<div class="flex items-start">
<span class="mt-0.5 w-8 text-accent">
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Folder-MD.svg"
alt=""
aria-hidden="true"
class="h-5 w-5"
/>
</span>
<div>
<span class="block font-serif text-lg text-ink">
{doc.documentLocation}
</span>
<span class="font-sans text-xs text-ink-2"
>{m.doc_label_archive_location_original()}</span
>
</div>
</div>
{/if}
<!-- Tags -->
{#if doc.tags && doc.tags.length > 0}
<div class="flex items-start">
<span class="mt-0.5 w-8 text-accent">
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Bookmark/Bookmark-Outline-MD.svg"
alt=""
aria-hidden="true"
class="h-5 w-5"
/>
</span>
<div class="flex-1">
<div class="mb-1 flex flex-wrap gap-2">
{#each doc.tags as tag (tag.id)}
<a
href="/?tag={encodeURIComponent(tag.name)}"
class="inline-flex items-center rounded bg-muted px-2 py-0.5 text-xs font-bold tracking-wide text-ink uppercase transition-colors hover:bg-primary hover:text-primary-fg"
title={m.doc_tag_filter_title({ name: tag.name })}
>
{tag.name}
</a>
{/each}
</div>
<span class="font-sans text-xs text-ink-2">{m.form_label_tags()}</span>
</div>
</div>
{/if}
</div>
</div>
<!-- PERSONEN GROUP -->
<div>
<h3
class="mb-4 border-b border-line pb-2 font-sans text-xs font-bold tracking-widest text-ink uppercase"
>
{m.doc_section_persons()}
</h3>
<div class="mb-6">
<span class="mb-2 block font-sans text-xs text-ink-3 uppercase">{m.form_label_sender()}</span>
{#if doc.sender}
<a
href="/persons/{doc.sender.id}"
class="group block rounded border border-line bg-muted p-3 transition hover:border-accent hover:bg-accent/10"
>
<div class="flex items-center gap-3">
<div
class="flex h-8 w-8 items-center justify-center rounded-full bg-primary font-serif text-sm text-primary-fg"
>
{doc.sender.firstName[0]}{doc.sender.lastName[0]}
</div>
<div>
<p class="font-serif text-ink group-hover:underline">
{doc.sender.firstName}
{doc.sender.lastName}
</p>
{#if doc.sender.alias}
<p class="font-sans text-xs text-ink-2">{doc.sender.alias}</p>
{/if}
</div>
</div>
</a>
{:else}
<span class="font-serif text-sm text-ink-3 italic">{m.doc_sender_not_specified()}</span>
{/if}
</div>
<div>
<span class="mb-2 block font-sans text-xs text-ink-3 uppercase"
>{m.form_label_receivers()}</span
>
{#if doc.receivers && doc.receivers.length > 0}
<div class="space-y-2">
{#each doc.receivers as receiver (receiver.id)}
<div
class="group flex items-center justify-between rounded border border-line bg-surface p-3 transition hover:border-primary"
>
<a href="/persons/{receiver.id}" class="flex min-w-0 flex-1 items-center gap-3">
<div
class="flex h-6 w-6 items-center justify-center rounded-full bg-muted font-serif text-xs text-ink-2"
>
{receiver.firstName[0]}{receiver.lastName[0]}
</div>
<span class="truncate font-serif text-sm text-ink">
{receiver.firstName}
{receiver.lastName}
</span>
</a>
{#if doc.sender}
<a
href="/korrespondenz?senderId={doc.sender.id}&receiverId={receiver.id}"
class="text-ink-3 transition hover:text-accent"
title={m.doc_conversation_title()}
>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Chat-MD.svg"
alt=""
aria-hidden="true"
class="h-5 w-5"
/>
</a>
{/if}
</div>
{/each}
</div>
{:else}
<span class="font-serif text-sm text-ink-3 italic">{m.doc_no_receivers()}</span>
{/if}
</div>
</div>
</div>

View File

@@ -1,38 +0,0 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
type Doc = {
summary?: string | null;
transcription?: string | null;
};
let { doc }: { doc: Doc } = $props();
</script>
<div class="flex justify-center px-6 py-8">
<div class="w-full max-w-prose space-y-8">
{#if !doc.summary && !doc.transcription}
<p class="font-serif text-sm text-ink-3 italic"></p>
{/if}
{#if doc.summary}
<div>
<span class="mb-3 block font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.doc_label_summary()}
</span>
<p class="font-serif text-base leading-relaxed text-ink">{doc.summary}</p>
</div>
{/if}
{#if doc.transcription}
<div>
<span class="mb-3 block font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.form_label_transcription()}
</span>
<p class="font-serif text-base leading-relaxed whitespace-pre-wrap text-ink">
{doc.transcription}
</p>
</div>
{/if}
</div>
</div>

View File

@@ -1,26 +1,31 @@
<script lang="ts">
import { onMount } from 'svelte';
import { SvelteMap } from 'svelte/reactivity';
import type { PDFDocumentProxy, PDFPageProxy, RenderTask } from 'pdfjs-dist';
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,
blockNumbers = {},
annotationReloadKey = 0,
activeAnnotationId = $bindable<string | null>(null),
activeAnnotationPage = $bindable<number | null>(null),
onAnnotationClick,
onTranscriptionDraw,
documentFileHash
}: {
url: string;
documentId?: string;
annotateMode?: boolean;
transcribeMode?: boolean;
blockNumbers?: Record<string, number>;
annotationReloadKey?: number;
activeAnnotationId?: string | null;
activeAnnotationPage?: number | null;
onAnnotationClick?: (id: string) => void;
onTranscriptionDraw?: (rect: DrawRect) => void;
documentFileHash?: string | null;
} = $props();
@@ -45,10 +50,10 @@ let pdfjsLib: typeof import('pdfjs-dist') | null = null;
let pdfjsReady = $state(false);
let annotations = $state<Annotation[]>([]);
let annotateColor = $state('#ffff00');
let commentCounts = new SvelteMap<string, number>();
let showAnnotations = $state(true);
const TRANSCRIPTION_COLOR = '#00C7B1';
const visibleAnnotations = $derived(
annotations.filter((a) => !a.fileHash || !documentFileHash || a.fileHash === documentFileHash)
);
@@ -164,81 +169,26 @@ async function prerender(doc: PDFDocumentProxy, pageNum: number) {
}
}
async function loadCommentCounts(docId: string, anns: Annotation[]) {
await Promise.all(
anns.map(async (a) => {
try {
const res = await fetch(`/api/documents/${docId}/annotations/${a.id}/comments`);
if (res.ok) {
const threads = (await res.json()) as Array<{ replies: unknown[] }>;
const total = threads.reduce((sum, t) => sum + 1 + t.replies.length, 0);
commentCounts.set(a.id, total);
}
} catch {
// ignore
}
})
);
}
async function loadAnnotations(docId: string) {
if (!docId) return;
try {
const res = await fetch(`/api/documents/${docId}/annotations`);
if (res.ok) {
annotations = await res.json();
await loadCommentCounts(docId, annotations);
}
} catch {
// ignore
}
}
async function handleAnnotationDraw(rect: { x: number; y: number; width: number; height: number }) {
if (!documentId) return;
try {
const res = await fetch(`/api/documents/${documentId}/annotations`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
pageNumber: currentPage,
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
color: annotateColor
})
});
if (res.ok) {
const created: Annotation = await res.json();
annotations = [...annotations, created];
activeAnnotationId = created.id;
activeAnnotationPage = created.pageNumber;
onAnnotationClick?.(created.id);
}
} catch {
// ignore
}
}
async function handleAnnotationDelete(annotationId: string) {
if (!documentId) return;
try {
const res = await fetch(`/api/documents/${documentId}/annotations/${annotationId}`, {
method: 'DELETE'
});
if (res.ok) {
annotations = annotations.filter((a) => a.id !== annotationId);
}
} catch {
// ignore
}
async function handleDraw(rect: { x: number; y: number; width: number; height: number }) {
if (!documentId || !transcribeMode) return;
await onTranscriptionDraw?.({ ...rect, pageNumber: currentPage });
await loadAnnotations(documentId);
}
function handleAnnotationClick(id: string) {
activeAnnotationId = id;
const ann = annotations.find((a) => a.id === id);
activeAnnotationPage = ann?.pageNumber ?? null;
onAnnotationClick?.(id);
}
@@ -260,13 +210,39 @@ $effect(() => {
});
$effect(() => {
if (documentId) {
if (documentId && annotationReloadKey >= 0) {
loadAnnotations(documentId);
}
});
$effect(() => {
if (annotateMode) showAnnotations = true;
if (transcribeMode) showAnnotations = true;
});
// Scroll-sync: when activeAnnotationId changes, navigate to its page
let prevActiveAnnotationId: string | null = null;
$effect(() => {
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) {
currentPage = ann.pageNumber;
}
// After page renders, scroll the annotation into view (double-rAF for async render)
requestAnimationFrame(() => {
requestAnimationFrame(() => {
const el = document.querySelector(`[data-testid="annotation-${id}"]`);
el?.scrollIntoView({ behavior: 'smooth', block: 'center' });
});
});
});
function prevPage() {
@@ -349,7 +325,7 @@ function zoomOut() {
</button>
{#if totalPages > 0}
<span class="font-sans text-xs text-ink-3 tabular-nums">
<span class="font-sans text-xs text-ink-2 tabular-nums">
{currentPage} / {totalPages}
</span>
{/if}
@@ -412,23 +388,13 @@ function zoomOut() {
</button>
</div>
<!-- Color picker (shown in annotate mode) -->
{#if annotateMode}
<input
type="color"
bind:value={annotateColor}
aria-label="Farbe wählen"
class="h-6 w-6 cursor-pointer rounded border-0 bg-transparent p-0"
title="Farbe wählen"
/>
{/if}
<!-- Annotation visibility toggle (shown when annotations exist) -->
{#if annotations.length > 0}
<button
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'}"
>
<svg
@@ -486,11 +452,11 @@ function zoomOut() {
{#if showAnnotations}
<AnnotationLayer
annotations={visibleAnnotations.filter((a) => a.pageNumber === currentPage)}
canAnnotate={annotateMode}
color={annotateColor}
onDraw={handleAnnotationDraw}
onDelete={handleAnnotationDelete}
commentCounts={Object.fromEntries(commentCounts)}
canDraw={transcribeMode}
color={TRANSCRIPTION_COLOR}
blockNumbers={blockNumbers}
activeAnnotationId={activeAnnotationId}
onDraw={handleDraw}
onAnnotationClick={handleAnnotationClick}
/>
{/if}

View File

@@ -0,0 +1,271 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import CommentThread from './CommentThread.svelte';
type SaveState = 'idle' | 'saving' | 'saved' | 'fading' | 'error';
type Props = {
blockId: string;
documentId: string;
blockNumber: number;
text: string;
label: string | null;
active: boolean;
saveState: SaveState;
canComment: boolean;
currentUserId: string | null;
onTextChange: (text: string) => void;
onFocus: () => void;
onDeleteClick: () => void;
onRetry: () => void;
onMoveUp?: () => void;
onMoveDown?: () => void;
isFirst?: boolean;
isLast?: boolean;
};
let {
blockId,
documentId,
blockNumber,
text,
label = null,
active,
saveState,
canComment,
currentUserId,
onTextChange,
onFocus,
onDeleteClick,
onRetry,
onMoveUp,
onMoveDown,
isFirst = false,
isLast = false
}: Props = $props();
let localText = $state(text);
let commentOpen = $state(false);
let commentCount = $state(0);
let selectedQuote = $state<string | null>(null);
let textareaEl = $state<HTMLTextAreaElement | null>(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(() => {
if (blockId !== prevBlockId) {
localText = text;
prevBlockId = blockId;
}
});
let leftBorderClass = $derived(
saveState === 'error' ? 'border-l-2 border-error' : active ? 'border-l-2 border-turquoise' : ''
);
function autoresize(node: HTMLTextAreaElement) {
textareaEl = node;
function resize() {
node.style.height = 'auto';
node.style.height = `${node.scrollHeight}px`;
}
resize();
return {
update() {
resize();
},
destroy() {
textareaEl = null;
}
};
}
function handleInput(event: Event) {
const target = event.target as HTMLTextAreaElement;
localText = target.value;
onTextChange(target.value);
}
function handleDelete() {
if (confirm(m.transcription_block_delete_confirm())) {
onDeleteClick();
}
}
function handleTextareaMouseUp() {
if (!textareaEl) return;
const start = textareaEl.selectionStart;
const end = textareaEl.selectionEnd;
if (start !== end) {
selectedQuote = localText.substring(start, end);
} else {
selectedQuote = null;
}
}
</script>
<div
class="relative flex overflow-visible rounded border border-line {leftBorderClass}"
data-block-id={blockId}
>
<!-- Turquoise numbered badge — overlaps top-left of card -->
<span
class="absolute -top-2 -left-2 z-10 flex h-6 w-6 items-center justify-center rounded-full bg-turquoise text-xs font-bold text-turquoise-fg shadow-sm"
>
{blockNumber}
</span>
<!-- Drag handle (desktop) / Arrow buttons (mobile) -->
<div class="flex shrink-0 flex-col items-center justify-center border-r border-line px-1">
<!-- Mobile: arrow buttons -->
<button
type="button"
class="flex h-7 w-7 cursor-pointer items-center justify-center rounded text-ink-3 transition-colors hover:bg-muted hover:text-ink disabled:opacity-20 md:hidden"
disabled={isFirst}
aria-label="Nach oben"
onclick={() => onMoveUp?.()}
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 15l7-7 7 7" />
</svg>
</button>
<!-- Desktop: grip handle (drag target) -->
<div
class="hidden cursor-grab text-ink-3 transition-colors select-none hover:text-ink active:cursor-grabbing md:block"
data-drag-handle
aria-label="Ziehen zum Sortieren"
>
</div>
<!-- Mobile: arrow down -->
<button
type="button"
class="flex h-7 w-7 cursor-pointer items-center justify-center rounded text-ink-3 transition-colors hover:bg-muted hover:text-ink disabled:opacity-20 md:hidden"
disabled={isLast}
aria-label="Nach unten"
onclick={() => onMoveDown?.()}
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
</div>
<div class="min-w-0 flex-1 p-4 pl-3">
<!-- Header -->
<div class="mb-2 flex items-center gap-2">
{#if label}
<span class="text-xs font-medium tracking-wide text-ink-2 uppercase">
{label}
</span>
{/if}
</div>
<!-- Textarea -->
<textarea
use:autoresize={localText}
class="w-full resize-none border-none bg-transparent font-serif text-base leading-relaxed text-ink outline-none placeholder:text-ink-3"
placeholder={m.transcription_block_placeholder()}
rows={1}
value={localText}
oninput={handleInput}
onfocus={onFocus}
onmouseup={handleTextareaMouseUp}
></textarea>
<!-- Footer -->
<div class="flex items-center justify-between border-t border-line pt-2">
<div>
{#if !hasComments}
<button
type="button"
class="flex cursor-pointer items-center gap-1 text-xs font-medium text-ink-2 transition-colors hover:text-ink"
onclick={() => (commentOpen = true)}
>
<svg
class="h-3 w-3"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M2.25 12.76c0 1.6 1.123 2.994 2.707 3.227 1.087.16 2.185.283 3.293.369V21l4.076-4.076a1.526 1.526 0 011.037-.443 48.282 48.282 0 005.68-.494c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z"
/>
</svg>
{m.transcription_block_comment_btn()}
</button>
{/if}
</div>
<div class="flex items-center gap-2">
<!-- Save state indicator -->
{#if saveState === 'saving'}
<span class="animate-pulse text-xs text-ink-3">
{m.transcription_block_save_saving()}
</span>
{:else if saveState === 'saved' || saveState === 'fading'}
<span
class="text-xs text-green-600 transition-opacity duration-300 {saveState === 'fading' ? 'opacity-0' : 'opacity-100'}"
>
{m.transcription_block_save_saved()} <span class="inline-block">&#10003;</span>
</span>
{:else if saveState === 'error'}
<span class="text-error text-xs">
{m.transcription_block_save_error()}
<span class="mx-1">&mdash;</span>
<button
type="button"
class="underline transition-colors hover:text-ink"
onclick={onRetry}
>
{m.transcription_block_save_retry()}
</button>
</span>
{/if}
<!-- Delete button -->
<button
type="button"
class="hover:text-error cursor-pointer text-ink-3 transition-colors"
aria-label={m.btn_delete()}
onclick={handleDelete}
>
<svg
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
</div>
<!-- Comment thread — list always visible, compose toggled by Kommentieren -->
<div class="mt-3">
<CommentThread
documentId={documentId}
blockId={blockId}
loadOnMount={true}
canComment={canComment}
currentUserId={currentUserId}
quotedText={selectedQuote}
showCompose={commentOpen}
onCountChange={(count) => (commentCount = count)}
/>
</div>
</div>
</div>

View File

@@ -0,0 +1,217 @@
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<string, unknown> = {}) {
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(),
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();
});
it('shows Kommentieren button when no comments exist', async () => {
renderBlock();
const btn = page.getByText('Kommentieren');
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();
});
});
// ─── 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();
});
});

View File

@@ -0,0 +1,331 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { SvelteMap } from 'svelte/reactivity';
import TranscriptionBlock from './TranscriptionBlock.svelte';
import type { TranscriptionBlockData } from '$lib/types';
type SaveState = 'idle' | 'saving' | 'saved' | 'fading' | 'error';
type Props = {
documentId: string;
blocks: TranscriptionBlockData[];
canComment: boolean;
currentUserId: string | null;
activeAnnotationId?: string | null;
onBlockFocus: (blockId: string) => void;
onSaveBlock: (blockId: string, text: string) => Promise<void>;
onDeleteBlock: (blockId: string) => Promise<void>;
};
let {
documentId,
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<string, SaveState>();
let debounceTimers = new SvelteMap<string, ReturnType<typeof setTimeout>>();
let pendingTexts = new SvelteMap<string, string>();
let sortedBlocks = $derived([...blocks].sort((a, b) => a.sortOrder - b.sortOrder));
let hasBlocks = $derived(blocks.length > 0);
function getSaveState(blockId: string): SaveState {
return saveStates.get(blockId) ?? 'idle';
}
function setSaveState(blockId: string, state: SaveState) {
saveStates.set(blockId, state);
}
async function executeSave(blockId: string) {
const text = pendingTexts.get(blockId);
if (text === undefined) return;
pendingTexts.delete(blockId);
setSaveState(blockId, 'saving');
try {
await onSaveBlock(blockId, text);
setSaveState(blockId, 'saved');
scheduleSavedFade(blockId);
} catch {
setSaveState(blockId, 'error');
}
}
function scheduleSavedFade(blockId: string) {
setTimeout(() => {
if (getSaveState(blockId) === 'saved') {
setSaveState(blockId, 'fading');
setTimeout(() => {
if (getSaveState(blockId) === 'fading') {
setSaveState(blockId, 'idle');
}
}, 300);
}
}, 2000);
}
function scheduleDebounce(blockId: string) {
clearDebounce(blockId);
const timer = setTimeout(() => {
debounceTimers.delete(blockId);
executeSave(blockId);
}, 1500);
debounceTimers.set(blockId, timer);
}
function clearDebounce(blockId: string) {
const existing = debounceTimers.get(blockId);
if (existing !== undefined) {
clearTimeout(existing);
debounceTimers.delete(blockId);
}
}
function flushAllPending() {
for (const [blockId] of debounceTimers) {
clearDebounce(blockId);
executeSave(blockId);
}
}
function handleTextChange(blockId: string, text: string) {
pendingTexts.set(blockId, text);
scheduleDebounce(blockId);
}
function handleFocus(blockId: string) {
activeBlockId = blockId;
onBlockFocus(blockId);
}
function handleBlur() {
flushAllPending();
}
async function handleRetry(blockId: string) {
const block = blocks.find((b) => b.id === blockId);
if (!block) return;
const pending = pendingTexts.get(blockId);
const text = pending ?? block.text;
pendingTexts.set(blockId, text);
await executeSave(blockId);
}
function handleDelete(blockId: string) {
clearDebounce(blockId);
pendingTexts.delete(blockId);
saveStates.delete(blockId);
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));
}
// ── 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 handleGripDown(e: PointerEvent, blockId: string) {
if (!(e.target as HTMLElement).closest('[data-drag-handle]')) return;
e.preventDefault();
draggedBlockId = blockId;
dragStartY = e.clientY;
dragOffsetY = 0;
capturedEl = (e.target as HTMLElement).closest('[data-block-wrapper]') as HTMLElement;
capturedEl?.setPointerCapture(e.pointerId);
}
function handlePointerMove(e: PointerEvent) {
if (!draggedBlockId || !listEl) return;
dragOffsetY = e.clientY - dragStartY;
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;
}
}
if (target === null) target = wrappers.length;
if (target === dragIdx || target === dragIdx + 1) target = null;
dropTargetIdx = target;
}
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() {
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() {
flushViaBeacon();
}
window.addEventListener('beforeunload', onBeforeUnload);
return () => {
window.removeEventListener('beforeunload', onBeforeUnload);
for (const timer of debounceTimers.values()) {
clearTimeout(timer);
}
};
});
</script>
<div class="flex h-full flex-col overflow-y-auto bg-surface p-4">
{#if hasBlocks}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="flex flex-col gap-3"
bind:this={listEl}
onpointermove={handlePointerMove}
onpointerup={handlePointerUp}
>
{#each sortedBlocks as block, i (block.id)}
{#if dropTargetIdx === i}
<div class="h-1 rounded-full bg-turquoise transition-all"></div>
{/if}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
data-block-wrapper
onblur={handleBlur}
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;` : ''}
>
<TranscriptionBlock
blockId={block.id}
documentId={documentId}
blockNumber={i + 1}
text={block.text}
label={block.label}
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)}
onRetry={() => handleRetry(block.id)}
onMoveUp={() => handleMoveUp(block.id)}
onMoveDown={() => handleMoveDown(block.id)}
isFirst={i === 0}
isLast={i === sortedBlocks.length - 1}
/>
</div>
{/each}
{#if dropTargetIdx === sortedBlocks.length}
<div class="h-1 rounded-full bg-turquoise transition-all"></div>
{/if}
<!-- Next block CTA — dashed outline hint -->
<div
class="flex items-center justify-center rounded border border-dashed border-line px-4 py-5 text-center font-sans text-sm text-ink-3"
>
{m.transcription_next_block_cta({ number: sortedBlocks.length + 1 })}
</div>
</div>
{:else}
<div class="flex flex-1 flex-col items-center justify-center px-6 py-12 text-center">
<svg
class="mb-4 h-16 w-16 text-ink-3"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
/>
</svg>
<p class="max-w-xs text-sm leading-relaxed text-ink-3">
{m.transcription_empty_cta()}
</p>
</div>
{/if}
</div>

View File

@@ -0,0 +1,224 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import TranscriptionEditView from './TranscriptionEditView.svelte';
afterEach(cleanup);
const block1 = {
id: 'b1',
annotationId: 'a1',
documentId: 'doc-1',
text: 'Block eins',
label: null,
sortOrder: 0,
version: 0
};
const block2 = {
id: 'b2',
annotationId: 'a2',
documentId: 'doc-1',
text: 'Block zwei',
label: null,
sortOrder: 1,
version: 0
};
function renderView(overrides: Record<string, unknown> = {}) {
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 — 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();
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);
});
});
// ─── 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<void>((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();
});
});

View File

@@ -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':

View File

@@ -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;

View File

@@ -1,17 +1,12 @@
import { error, redirect } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';
import { createApiClient } from '$lib/api.server';
import { getErrorMessage } from '$lib/errors';
export async function load({ params, fetch }) {
const { id } = params;
const api = createApiClient(fetch);
const base = env.API_INTERNAL_URL || 'http://localhost:8080';
const [docResult, commentsRes] = await Promise.all([
api.GET('/api/documents/{id}', { params: { path: { id } } }),
fetch(`${base}/api/documents/${id}/comments`).catch(() => null)
]);
const docResult = await api.GET('/api/documents/{id}', { params: { path: { id } } });
if (docResult.response.status === 401) throw redirect(302, '/login');
@@ -20,14 +15,5 @@ export async function load({ params, fetch }) {
throw error(docResult.response.status, getErrorMessage(code));
}
let comments: unknown[] = [];
if (commentsRes?.ok) {
try {
comments = await commentsRes.json();
} catch {
// ignore invalid response
}
}
return { document: docResult.data!, comments };
return { document: docResult.data! };
}

View File

@@ -1,24 +1,14 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/state';
import DocumentTopBar from '$lib/components/DocumentTopBar.svelte';
import DocumentViewer from '$lib/components/DocumentViewer.svelte';
import DocumentBottomPanel from '$lib/components/DocumentBottomPanel.svelte';
import AnnotationSidePanel from '$lib/components/AnnotationSidePanel.svelte';
import type { DocumentPanelTab } from '$lib/types';
import TranscriptionEditView from '$lib/components/TranscriptionEditView.svelte';
import type { TranscriptionBlockData } from '$lib/types';
let { data } = $props();
const targetCommentId = $derived(page.url.searchParams.get('commentId'));
const targetAnnotationId = $derived(page.url.searchParams.get('annotationId'));
const doc = $derived(data.document);
const canComment = $derived((data.canAnnotate || data.canWrite) ?? false);
const canAdmin = $derived(
(data.user?.groups as Array<{ permissions: string[] }> | undefined)?.some((g) =>
g.permissions.includes('ADMIN')
) ?? false
);
const canWrite = $derived(data.canWrite ?? false);
const currentUserId = $derived((data.user?.id as string | undefined) ?? null);
// ── File loading ──────────────────────────────────────────────────────────────
@@ -56,44 +46,126 @@ async function loadFile(id: string) {
}
}
// ── Annotation state (lifted from PdfViewer) ──────────────────────────────────
// ── Mode state ───────────────────────────────────────────────────────────────
let annotateMode = $state(false);
let transcribeMode = $state(false);
let activeAnnotationId = $state<string | null>(null);
let activeAnnotationPage = $state<number | null>(null);
// Close the panel when entering annotate mode so the PDF is fully visible.
// ── Transcription blocks ─────────────────────────────────────────────────────
let transcriptionBlocks = $state<TranscriptionBlockData[]>([]);
let annotationReloadKey = $state(0);
const blockNumbers = $derived(
Object.fromEntries(
[...transcriptionBlocks]
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((b, i) => [b.annotationId, i + 1])
)
);
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);
annotationReloadKey++;
}
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) {
activeAnnotationId = block.annotationId;
}
}
async function handleAnnotationClick(annotationId: string) {
activeAnnotationId = annotationId;
if (!transcribeMode) {
transcribeMode = true;
await loadTranscriptionBlocks();
}
// Wait for DOM to render the blocks, then scroll to the matching one
requestAnimationFrame(() => {
const block = transcriptionBlocks.find((b) => b.annotationId === annotationId);
if (block) {
const el = document.querySelector(`[data-block-id="${block.id}"]`);
el?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
});
}
// Load blocks when transcribe mode is entered
$effect(() => {
if (annotateMode) panelOpen = false;
if (transcribeMode) {
loadTranscriptionBlocks();
}
});
// ── Bottom panel state ────────────────────────────────────────────────────────
// ── Navigation / init ─────────────────────────────────────────────────────────
let panelOpen = $state(false);
let panelHeight = $state(0); // set to full height on mount
let navHeight = $state(0);
let activeTab = $state<DocumentPanelTab>('metadata');
onMount(() => {
navHeight = document.querySelector('header')?.getBoundingClientRect().height ?? 0;
const topbar = document.querySelector('[data-topbar]');
panelHeight = window.innerHeight - navHeight - (topbar?.getBoundingClientRect().height ?? 0);
if (targetAnnotationId) {
// Deep-link into an annotation comment: open the side panel
activeAnnotationId = targetAnnotationId;
} else if (targetCommentId) {
// Deep-link into a document-level comment: open discussion tab
panelOpen = true;
activeTab = 'discussion';
} else if (!doc?.filePath) {
// No file yet — open to metadata so the panel is immediately useful.
panelOpen = true;
activeTab = 'metadata';
}
// Track last-visited document for the dashboard resume strip
if (doc?.id) {
localStorage.setItem(
'familienarchiv.lastVisited',
@@ -102,13 +174,8 @@ onMount(() => {
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') {
if (activeAnnotationId) {
activeAnnotationId = null;
activeAnnotationPage = null;
} else if (panelOpen) {
panelOpen = false;
}
if (e.key === 'Escape' && transcribeMode) {
transcribeMode = false;
}
}
document.addEventListener('keydown', onKeyDown);
@@ -127,49 +194,44 @@ onMount(() => {
>
<DocumentTopBar
doc={doc}
canWrite={data.canWrite ?? false}
canAnnotate={data.canAnnotate ?? false}
canWrite={canWrite}
fileUrl={fileUrl}
bind:annotateMode={annotateMode}
bind:transcribeMode={transcribeMode}
/>
<div class="relative flex-1 overflow-hidden">
<DocumentViewer
doc={doc}
fileUrl={fileUrl}
isLoading={isLoading}
error={fileError}
bind:annotateMode={annotateMode}
bind:activeAnnotationId={activeAnnotationId}
bind:activeAnnotationPage={activeAnnotationPage}
onAnnotationClick={(id) => {
activeAnnotationId = id;
}}
/>
<AnnotationSidePanel
documentId={doc.id}
activeAnnotationId={activeAnnotationId}
activeAnnotationPage={activeAnnotationPage}
canComment={canComment}
currentUserId={currentUserId}
canAdmin={canAdmin}
targetCommentId={targetAnnotationId ? targetCommentId : null}
onClose={() => {
activeAnnotationId = null;
activeAnnotationPage = null;
}}
/>
<div class="relative flex-1 overflow-hidden {transcribeMode ? 'flex flex-col md:flex-row' : ''}">
<div
class={transcribeMode ? 'relative min-h-[40vh] flex-1 overflow-hidden md:min-h-0' : 'absolute inset-0'}
>
<DocumentViewer
doc={doc}
fileUrl={fileUrl}
isLoading={isLoading}
error={fileError}
transcribeMode={transcribeMode}
blockNumbers={blockNumbers}
annotationReloadKey={annotationReloadKey}
bind:activeAnnotationId={activeAnnotationId}
onAnnotationClick={handleAnnotationClick}
onTranscriptionDraw={createBlockFromDraw}
/>
</div>
{#if transcribeMode}
<div
class="shrink-0 border-t border-line md:w-[400px] md:border-t-0 md:border-l lg:w-[480px]"
>
<TranscriptionEditView
documentId={doc.id}
blocks={transcriptionBlocks}
canComment={canWrite}
currentUserId={currentUserId}
activeAnnotationId={activeAnnotationId}
onBlockFocus={handleBlockFocus}
onSaveBlock={saveBlock}
onDeleteBlock={deleteBlock}
/>
</div>
{/if}
</div>
<DocumentBottomPanel
doc={doc}
comments={(data.comments ?? []) as never[]}
canComment={canComment}
currentUserId={currentUserId}
canAdmin={canAdmin}
targetCommentId={targetCommentId}
bind:open={panelOpen}
bind:height={panelHeight}
bind:activeTab={activeTab}
/>
</div>

View File

@@ -8,17 +8,10 @@ import { createApiClient } from '$lib/api.server';
beforeEach(() => vi.clearAllMocks());
function makeCommentsResponse(comments: unknown[]) {
return {
ok: true,
json: vi.fn().mockResolvedValue(comments)
};
}
// ─── happy path ───────────────────────────────────────────────────────────────
describe('document detail load — happy path', () => {
it('returns document and comments on success', async () => {
it('returns document on success', async () => {
vi.mocked(createApiClient).mockReturnValue({
GET: vi.fn().mockResolvedValue({
response: { ok: true, status: 200 },
@@ -26,7 +19,7 @@ describe('document detail load — happy path', () => {
})
} as ReturnType<typeof createApiClient>);
const mockFetch = vi.fn().mockResolvedValue(makeCommentsResponse([{ id: 'c1', body: 'Hi' }]));
const mockFetch = vi.fn();
const result = await load({
params: { id: '123' },
@@ -34,45 +27,6 @@ describe('document detail load — happy path', () => {
});
expect(result.document.title).toBe('Testbrief');
expect(result.comments).toHaveLength(1);
});
it('returns empty comments when the comments fetch fails', async () => {
vi.mocked(createApiClient).mockReturnValue({
GET: vi.fn().mockResolvedValue({
response: { ok: true, status: 200 },
data: { id: '123', title: 'Testbrief' }
})
} as ReturnType<typeof createApiClient>);
// fetch throws a network error for the comments endpoint
const mockFetch = vi.fn().mockRejectedValue(new Error('Network error'));
const result = await load({
params: { id: '123' },
fetch: mockFetch as unknown as typeof fetch
});
expect(result.document.title).toBe('Testbrief');
expect(result.comments).toEqual([]);
});
it('returns empty comments when the comments response is not ok', async () => {
vi.mocked(createApiClient).mockReturnValue({
GET: vi.fn().mockResolvedValue({
response: { ok: true, status: 200 },
data: { id: '123', title: 'Testbrief' }
})
} as ReturnType<typeof createApiClient>);
const mockFetch = vi.fn().mockResolvedValue({ ok: false });
const result = await load({
params: { id: '123' },
fetch: mockFetch as unknown as typeof fetch
});
expect(result.comments).toEqual([]);
});
});
@@ -87,7 +41,7 @@ describe('document detail load — error paths', () => {
})
} as ReturnType<typeof createApiClient>);
const mockFetch = vi.fn().mockResolvedValue({ ok: false });
const mockFetch = vi.fn();
await expect(
load({ params: { id: 'missing' }, fetch: mockFetch as unknown as typeof fetch })
@@ -102,7 +56,7 @@ describe('document detail load — error paths', () => {
})
} as ReturnType<typeof createApiClient>);
const mockFetch = vi.fn().mockResolvedValue({ ok: false });
const mockFetch = vi.fn();
await expect(
load({ params: { id: 'secret' }, fetch: mockFetch as unknown as typeof fetch })
@@ -117,7 +71,7 @@ describe('document detail load — error paths', () => {
})
} as ReturnType<typeof createApiClient>);
const mockFetch = vi.fn().mockResolvedValue({ ok: false });
const mockFetch = vi.fn();
await expect(
load({ params: { id: 'any' }, fetch: mockFetch as unknown as typeof fetch })

View File

@@ -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;