@@ -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
|
||||
@@ -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}")
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ bun.lockb
|
||||
/src/lib/generated/
|
||||
/src/lib/paraglide/
|
||||
/src/lib/paraglide_bak*/
|
||||
/src/paraglide/
|
||||
|
||||
# Test artifacts
|
||||
/test-results/
|
||||
|
||||
272
frontend/e2e/annotations.spec.ts
Normal file
272
frontend/e2e/annotations.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
296
frontend/e2e/transcription.spec.ts
Normal file
296
frontend/e2e/transcription.spec.ts
Normal 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' });
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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">
|
||||
“{parsed.quote}”
|
||||
</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}
|
||||
|
||||
@@ -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>
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
146
frontend/src/lib/components/DocumentMetadataDrawer.svelte
Normal file
146
frontend/src/lib/components/DocumentMetadataDrawer.svelte
Normal 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>
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
|
||||
271
frontend/src/lib/components/TranscriptionBlock.svelte
Normal file
271
frontend/src/lib/components/TranscriptionBlock.svelte
Normal 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">✓</span>
|
||||
</span>
|
||||
{:else if saveState === 'error'}
|
||||
<span class="text-error text-xs">
|
||||
{m.transcription_block_save_error()}
|
||||
<span class="mx-1">—</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>
|
||||
217
frontend/src/lib/components/TranscriptionBlock.svelte.spec.ts
Normal file
217
frontend/src/lib/components/TranscriptionBlock.svelte.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
331
frontend/src/lib/components/TranscriptionEditView.svelte
Normal file
331
frontend/src/lib/components/TranscriptionEditView.svelte
Normal 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>
|
||||
224
frontend/src/lib/components/TranscriptionEditView.svelte.spec.ts
Normal file
224
frontend/src/lib/components/TranscriptionEditView.svelte.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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':
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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! };
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user