Compare commits
17 Commits
feat/38-do
...
feat/62-do
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c2bdbd777 | ||
|
|
34c66f80fc | ||
|
|
fd03e56c85 | ||
|
|
af57b4e530 | ||
|
|
aaa9286612 | ||
|
|
646674b06a | ||
|
|
1070e6e9ec | ||
|
|
3e5d296b09 | ||
|
|
ee49bac2ef | ||
|
|
48040dc7e4 | ||
|
|
83e5a1fde5 | ||
|
|
37f5c3d005 | ||
|
|
eb8bcdb426 | ||
|
|
05f3ce687f | ||
|
|
06e846f2f8 | ||
|
|
ea1c097ae0 | ||
|
|
b45ec744b2 |
@@ -148,7 +148,7 @@
|
||||
<activeByDefault>true</activeByDefault>
|
||||
</activation>
|
||||
<properties>
|
||||
<spring.profiles.active>dev</spring.profiles.active>
|
||||
<spring.profiles.active>dev,e2e</spring.profiles.active>
|
||||
</properties>
|
||||
</profile>
|
||||
<profile>
|
||||
|
||||
@@ -49,7 +49,7 @@ public class DataInitializer {
|
||||
// 1. Admin Gruppe erstellen
|
||||
UserGroup adminGroup = UserGroup.builder()
|
||||
.name("Administrators")
|
||||
.permissions(Set.of("ADMIN", "READ_ALL", "WRITE_ALL", "ADMIN_USER", "ADMIN_TAG", "ADMIN_PERMISSION"))
|
||||
.permissions(Set.of("ADMIN", "READ_ALL", "WRITE_ALL", "ANNOTATE_ALL", "ADMIN_USER", "ADMIN_TAG", "ADMIN_PERMISSION"))
|
||||
.build();
|
||||
groupRepository.save(adminGroup);
|
||||
|
||||
@@ -84,8 +84,24 @@ public class DataInitializer {
|
||||
TagRepository tagRepo,
|
||||
PasswordEncoder passwordEncoder) {
|
||||
return args -> {
|
||||
// Always ensure the read-only test user exists, even when seed data was already loaded.
|
||||
if (userRepository.findByUsername("reader").isEmpty()) {
|
||||
log.info("E2E seed: Erstelle 'reader'-Testbenutzer...");
|
||||
UserGroup leserGroup = groupRepository.findByName("Leser").orElseGet(() ->
|
||||
groupRepository.save(UserGroup.builder()
|
||||
.name("Leser")
|
||||
.permissions(Set.of("READ_ALL"))
|
||||
.build()));
|
||||
userRepository.save(AppUser.builder()
|
||||
.username("reader")
|
||||
.password(passwordEncoder.encode("reader123"))
|
||||
.groups(Set.of(leserGroup))
|
||||
.build());
|
||||
log.info("E2E seed: 'reader'-Testbenutzer erstellt.");
|
||||
}
|
||||
|
||||
if (personRepo.count() > 0) {
|
||||
log.info("E2E seed: Daten bereits vorhanden, überspringe.");
|
||||
log.info("E2E seed: Personendaten bereits vorhanden, überspringe Dokument-Seed.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -166,19 +182,6 @@ public class DataInitializer {
|
||||
.receivers(Set.of(otto))
|
||||
.build());
|
||||
|
||||
// ── Read-only user (for permissions E2E tests) ───────────────────
|
||||
// Username: reader / Password: reader123
|
||||
// Has only READ_ALL — used to assert write controls are absent.
|
||||
UserGroup leserGroup = groupRepository.save(UserGroup.builder()
|
||||
.name("Leser")
|
||||
.permissions(Set.of("READ_ALL"))
|
||||
.build());
|
||||
userRepository.save(AppUser.builder()
|
||||
.username("reader")
|
||||
.password(passwordEncoder.encode("reader123"))
|
||||
.groups(Set.of(leserGroup))
|
||||
.build());
|
||||
|
||||
log.info("E2E seed: {} Personen, {} Tags, {} Dokumente, {} Benutzer erstellt.",
|
||||
personRepo.count(), tagRepo.count(), docRepo.count(), userRepository.count());
|
||||
};
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package org.raddatz.familienarchiv.controller;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.raddatz.familienarchiv.dto.CreateAnnotationDTO;
|
||||
import org.raddatz.familienarchiv.model.AppUser;
|
||||
import org.raddatz.familienarchiv.model.DocumentAnnotation;
|
||||
import org.raddatz.familienarchiv.security.Permission;
|
||||
import org.raddatz.familienarchiv.security.RequirePermission;
|
||||
import org.raddatz.familienarchiv.service.AnnotationService;
|
||||
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}/annotations")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class AnnotationController {
|
||||
|
||||
private final AnnotationService annotationService;
|
||||
private final UserService userService;
|
||||
|
||||
@GetMapping
|
||||
public List<DocumentAnnotation> listAnnotations(@PathVariable UUID documentId) {
|
||||
return annotationService.listAnnotations(documentId);
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@ResponseStatus(HttpStatus.CREATED)
|
||||
@RequirePermission(Permission.ANNOTATE_ALL)
|
||||
public DocumentAnnotation createAnnotation(
|
||||
@PathVariable UUID documentId,
|
||||
@RequestBody CreateAnnotationDTO dto,
|
||||
Authentication authentication) {
|
||||
UUID userId = resolveUserId(authentication);
|
||||
return annotationService.createAnnotation(documentId, dto, userId);
|
||||
}
|
||||
|
||||
@DeleteMapping("/{annotationId}")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
@RequirePermission(Permission.ANNOTATE_ALL)
|
||||
public void deleteAnnotation(
|
||||
@PathVariable UUID documentId,
|
||||
@PathVariable UUID annotationId,
|
||||
Authentication authentication) {
|
||||
UUID userId = resolveUserId(authentication);
|
||||
annotationService.deleteAnnotation(documentId, annotationId, userId);
|
||||
}
|
||||
|
||||
// ─── private helpers ──────────────────────────────────────────────────────
|
||||
|
||||
private UUID resolveUserId(Authentication authentication) {
|
||||
if (authentication == null || !authentication.isAuthenticated()) return null;
|
||||
try {
|
||||
AppUser user = userService.findByUsername(authentication.getName());
|
||||
return user != null ? user.getId() : null;
|
||||
} catch (Exception e) {
|
||||
log.warn("Could not resolve user for annotation: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package org.raddatz.familienarchiv.controller;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.raddatz.familienarchiv.dto.CreateCommentDTO;
|
||||
import org.raddatz.familienarchiv.model.AppUser;
|
||||
import org.raddatz.familienarchiv.model.DocumentComment;
|
||||
import org.raddatz.familienarchiv.security.Permission;
|
||||
import org.raddatz.familienarchiv.security.RequirePermission;
|
||||
import org.raddatz.familienarchiv.service.CommentService;
|
||||
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
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class CommentController {
|
||||
|
||||
private final CommentService commentService;
|
||||
private final UserService userService;
|
||||
|
||||
// ─── General document comments ────────────────────────────────────────────
|
||||
|
||||
@GetMapping("/api/documents/{documentId}/comments")
|
||||
public List<DocumentComment> getDocumentComments(@PathVariable UUID documentId) {
|
||||
return commentService.getCommentsForDocument(documentId);
|
||||
}
|
||||
|
||||
@PostMapping("/api/documents/{documentId}/comments")
|
||||
@ResponseStatus(HttpStatus.CREATED)
|
||||
@RequirePermission(Permission.ANNOTATE_ALL)
|
||||
public DocumentComment postDocumentComment(
|
||||
@PathVariable UUID documentId,
|
||||
@RequestBody CreateCommentDTO dto,
|
||||
Authentication authentication) {
|
||||
AppUser author = resolveUser(authentication);
|
||||
return commentService.postComment(documentId, null, dto.getContent(), author);
|
||||
}
|
||||
|
||||
@PostMapping("/api/documents/{documentId}/comments/{commentId}/replies")
|
||||
@ResponseStatus(HttpStatus.CREATED)
|
||||
@RequirePermission(Permission.ANNOTATE_ALL)
|
||||
public DocumentComment replyToDocumentComment(
|
||||
@PathVariable UUID documentId,
|
||||
@PathVariable UUID commentId,
|
||||
@RequestBody CreateCommentDTO dto,
|
||||
Authentication authentication) {
|
||||
AppUser author = resolveUser(authentication);
|
||||
return commentService.replyToComment(documentId, commentId, dto.getContent(), author);
|
||||
}
|
||||
|
||||
// ─── Annotation comments ──────────────────────────────────────────────────
|
||||
|
||||
@GetMapping("/api/documents/{documentId}/annotations/{annotationId}/comments")
|
||||
public List<DocumentComment> getAnnotationComments(@PathVariable UUID annotationId) {
|
||||
return commentService.getCommentsForAnnotation(annotationId);
|
||||
}
|
||||
|
||||
@PostMapping("/api/documents/{documentId}/annotations/{annotationId}/comments")
|
||||
@ResponseStatus(HttpStatus.CREATED)
|
||||
@RequirePermission(Permission.ANNOTATE_ALL)
|
||||
public DocumentComment postAnnotationComment(
|
||||
@PathVariable UUID documentId,
|
||||
@PathVariable UUID annotationId,
|
||||
@RequestBody CreateCommentDTO dto,
|
||||
Authentication authentication) {
|
||||
AppUser author = resolveUser(authentication);
|
||||
return commentService.postComment(documentId, annotationId, dto.getContent(), author);
|
||||
}
|
||||
|
||||
@PostMapping("/api/documents/{documentId}/annotations/{annotationId}/comments/{commentId}/replies")
|
||||
@ResponseStatus(HttpStatus.CREATED)
|
||||
@RequirePermission(Permission.ANNOTATE_ALL)
|
||||
public DocumentComment replyToAnnotationComment(
|
||||
@PathVariable UUID documentId,
|
||||
@PathVariable UUID commentId,
|
||||
@RequestBody CreateCommentDTO dto,
|
||||
Authentication authentication) {
|
||||
AppUser author = resolveUser(authentication);
|
||||
return commentService.replyToComment(documentId, commentId, dto.getContent(), author);
|
||||
}
|
||||
|
||||
// ─── Edit and delete (shared) ─────────────────────────────────────────────
|
||||
|
||||
@PatchMapping("/api/documents/{documentId}/comments/{commentId}")
|
||||
@RequirePermission(Permission.ANNOTATE_ALL)
|
||||
public DocumentComment editComment(
|
||||
@PathVariable UUID documentId,
|
||||
@PathVariable UUID commentId,
|
||||
@RequestBody CreateCommentDTO dto,
|
||||
Authentication authentication) {
|
||||
AppUser currentUser = resolveUser(authentication);
|
||||
return commentService.editComment(documentId, commentId, dto.getContent(), currentUser);
|
||||
}
|
||||
|
||||
@DeleteMapping("/api/documents/{documentId}/comments/{commentId}")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
public void deleteComment(
|
||||
@PathVariable UUID documentId,
|
||||
@PathVariable UUID commentId,
|
||||
Authentication authentication) {
|
||||
AppUser currentUser = resolveUser(authentication);
|
||||
commentService.deleteComment(documentId, commentId, currentUser);
|
||||
}
|
||||
|
||||
// ─── private helpers ──────────────────────────────────────────────────────
|
||||
|
||||
private AppUser resolveUser(Authentication authentication) {
|
||||
if (authentication == null || !authentication.isAuthenticated()) return null;
|
||||
try {
|
||||
return userService.findByUsername(authentication.getName());
|
||||
} catch (Exception e) {
|
||||
log.warn("Could not resolve user for comment: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package org.raddatz.familienarchiv.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class CreateAnnotationDTO {
|
||||
private int pageNumber;
|
||||
private double x;
|
||||
private double y;
|
||||
private double width;
|
||||
private double height;
|
||||
private String color;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package org.raddatz.familienarchiv.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class CreateCommentDTO {
|
||||
private String content;
|
||||
}
|
||||
@@ -38,6 +38,16 @@ public enum ErrorCode {
|
||||
/** The password-reset token is missing, expired, or already used. 400 */
|
||||
INVALID_RESET_TOKEN,
|
||||
|
||||
// --- Annotations ---
|
||||
/** The annotation with the given ID does not exist. 404 */
|
||||
ANNOTATION_NOT_FOUND,
|
||||
/** The new annotation overlaps an existing one on the same page. 409 */
|
||||
ANNOTATION_OVERLAP,
|
||||
|
||||
// --- Comments ---
|
||||
/** The comment with the given ID does not exist. 404 */
|
||||
COMMENT_NOT_FOUND,
|
||||
|
||||
// --- Generic ---
|
||||
/** Request validation failed (missing or malformed fields). 400 */
|
||||
VALIDATION_ERROR,
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
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 = "document_annotations")
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class DocumentAnnotation {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.UUID)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private UUID id;
|
||||
|
||||
@Column(name = "document_id", nullable = false)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private UUID documentId;
|
||||
|
||||
@Column(name = "page_number", nullable = false)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private int pageNumber;
|
||||
|
||||
@Column(nullable = false)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private double x;
|
||||
|
||||
@Column(nullable = false)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private double y;
|
||||
|
||||
@Column(nullable = false)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private double width;
|
||||
|
||||
@Column(nullable = false)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private double height;
|
||||
|
||||
@Column(nullable = false)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String color;
|
||||
|
||||
@Column(name = "created_by")
|
||||
private UUID createdBy;
|
||||
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
@CreationTimestamp
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
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.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@Table(name = "document_comments")
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class DocumentComment {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.UUID)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private UUID id;
|
||||
|
||||
@Column(name = "document_id", nullable = false)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private UUID documentId;
|
||||
|
||||
@Column(name = "annotation_id")
|
||||
private UUID annotationId;
|
||||
|
||||
@Column(name = "parent_id")
|
||||
private UUID parentId;
|
||||
|
||||
@Column(name = "author_id")
|
||||
private UUID authorId;
|
||||
|
||||
@Column(name = "author_name", nullable = false)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String authorName;
|
||||
|
||||
@Column(nullable = false, columnDefinition = "TEXT")
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String content;
|
||||
|
||||
@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;
|
||||
|
||||
// Populated by the service — not stored in the database
|
||||
@Transient
|
||||
@Builder.Default
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private List<DocumentComment> replies = new ArrayList<>();
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package org.raddatz.familienarchiv.repository;
|
||||
|
||||
import org.raddatz.familienarchiv.model.DocumentAnnotation;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface AnnotationRepository extends JpaRepository<DocumentAnnotation, UUID> {
|
||||
|
||||
List<DocumentAnnotation> findByDocumentId(UUID documentId);
|
||||
|
||||
List<DocumentAnnotation> findByDocumentIdAndPageNumber(UUID documentId, int pageNumber);
|
||||
|
||||
Optional<DocumentAnnotation> findByIdAndDocumentId(UUID id, UUID documentId);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package org.raddatz.familienarchiv.repository;
|
||||
|
||||
import org.raddatz.familienarchiv.model.DocumentComment;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface CommentRepository extends JpaRepository<DocumentComment, UUID> {
|
||||
|
||||
List<DocumentComment> findByDocumentIdAndAnnotationIdIsNullAndParentIdIsNull(UUID documentId);
|
||||
|
||||
List<DocumentComment> findByAnnotationIdAndParentIdIsNull(UUID annotationId);
|
||||
|
||||
List<DocumentComment> findByParentId(UUID parentId);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package org.raddatz.familienarchiv.security;
|
||||
public enum Permission {
|
||||
READ_ALL,
|
||||
WRITE_ALL,
|
||||
ANNOTATE_ALL,
|
||||
ADMIN,
|
||||
ADMIN_USER,
|
||||
ADMIN_TAG,
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package org.raddatz.familienarchiv.service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.raddatz.familienarchiv.dto.CreateAnnotationDTO;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.model.DocumentAnnotation;
|
||||
import org.raddatz.familienarchiv.repository.AnnotationRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AnnotationService {
|
||||
|
||||
private final AnnotationRepository annotationRepository;
|
||||
|
||||
public List<DocumentAnnotation> listAnnotations(UUID documentId) {
|
||||
return annotationRepository.findByDocumentId(documentId);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public DocumentAnnotation createAnnotation(UUID documentId, CreateAnnotationDTO dto, UUID userId) {
|
||||
List<DocumentAnnotation> existing =
|
||||
annotationRepository.findByDocumentIdAndPageNumber(documentId, dto.getPageNumber());
|
||||
|
||||
boolean overlaps = existing.stream().anyMatch(a -> overlaps(a, dto));
|
||||
if (overlaps) {
|
||||
throw DomainException.conflict(
|
||||
ErrorCode.ANNOTATION_OVERLAP, "Annotation overlaps an existing one on this page");
|
||||
}
|
||||
|
||||
DocumentAnnotation annotation = DocumentAnnotation.builder()
|
||||
.documentId(documentId)
|
||||
.pageNumber(dto.getPageNumber())
|
||||
.x(dto.getX())
|
||||
.y(dto.getY())
|
||||
.width(dto.getWidth())
|
||||
.height(dto.getHeight())
|
||||
.color(dto.getColor())
|
||||
.createdBy(userId)
|
||||
.build();
|
||||
|
||||
return annotationRepository.save(annotation);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteAnnotation(UUID documentId, UUID annotationId, UUID userId) {
|
||||
DocumentAnnotation annotation = annotationRepository
|
||||
.findByIdAndDocumentId(annotationId, documentId)
|
||||
.orElseThrow(() -> DomainException.notFound(
|
||||
ErrorCode.ANNOTATION_NOT_FOUND, "Annotation not found: " + annotationId));
|
||||
|
||||
if (userId == null || !userId.equals(annotation.getCreatedBy())) {
|
||||
throw DomainException.forbidden("Only the annotation author can delete it");
|
||||
}
|
||||
|
||||
annotationRepository.delete(annotation);
|
||||
}
|
||||
|
||||
// ─── private helpers ──────────────────────────────────────────────────────
|
||||
|
||||
private boolean overlaps(DocumentAnnotation existing, CreateAnnotationDTO dto) {
|
||||
double ex2 = existing.getX() + existing.getWidth();
|
||||
double ey2 = existing.getY() + existing.getHeight();
|
||||
double dx2 = dto.getX() + dto.getWidth();
|
||||
double dy2 = dto.getY() + dto.getHeight();
|
||||
return existing.getX() < dx2 && ex2 > dto.getX()
|
||||
&& existing.getY() < dy2 && ey2 > dto.getY();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package org.raddatz.familienarchiv.service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.model.AppUser;
|
||||
import org.raddatz.familienarchiv.model.DocumentComment;
|
||||
import org.raddatz.familienarchiv.repository.CommentRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class CommentService {
|
||||
|
||||
private final CommentRepository commentRepository;
|
||||
|
||||
public List<DocumentComment> getCommentsForDocument(UUID documentId) {
|
||||
List<DocumentComment> roots =
|
||||
commentRepository.findByDocumentIdAndAnnotationIdIsNullAndParentIdIsNull(documentId);
|
||||
return withReplies(roots);
|
||||
}
|
||||
|
||||
public List<DocumentComment> getCommentsForAnnotation(UUID annotationId) {
|
||||
List<DocumentComment> roots = commentRepository.findByAnnotationIdAndParentIdIsNull(annotationId);
|
||||
return withReplies(roots);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public DocumentComment postComment(UUID documentId, UUID annotationId, String content, AppUser author) {
|
||||
DocumentComment comment = DocumentComment.builder()
|
||||
.documentId(documentId)
|
||||
.annotationId(annotationId)
|
||||
.content(content)
|
||||
.authorId(author.getId())
|
||||
.authorName(resolveAuthorName(author))
|
||||
.build();
|
||||
return commentRepository.save(comment);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public DocumentComment replyToComment(UUID documentId, UUID commentId, String content, AppUser author) {
|
||||
DocumentComment target = commentRepository.findById(commentId)
|
||||
.orElseThrow(() -> DomainException.notFound(
|
||||
ErrorCode.COMMENT_NOT_FOUND, "Comment not found: " + commentId));
|
||||
|
||||
UUID rootId = target.getParentId() != null ? target.getParentId() : target.getId();
|
||||
DocumentComment root = commentRepository.findById(rootId)
|
||||
.orElseThrow(() -> DomainException.notFound(
|
||||
ErrorCode.COMMENT_NOT_FOUND, "Comment not found: " + rootId));
|
||||
|
||||
DocumentComment reply = DocumentComment.builder()
|
||||
.documentId(documentId)
|
||||
.annotationId(root.getAnnotationId())
|
||||
.parentId(root.getId())
|
||||
.content(content)
|
||||
.authorId(author.getId())
|
||||
.authorName(resolveAuthorName(author))
|
||||
.build();
|
||||
return commentRepository.save(reply);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public DocumentComment editComment(UUID documentId, UUID commentId, String content, AppUser currentUser) {
|
||||
DocumentComment comment = findComment(documentId, commentId);
|
||||
if (!currentUser.getId().equals(comment.getAuthorId())) {
|
||||
throw DomainException.forbidden("Only the comment author can edit it");
|
||||
}
|
||||
comment.setContent(content);
|
||||
return commentRepository.save(comment);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteComment(UUID documentId, UUID commentId, AppUser currentUser) {
|
||||
DocumentComment comment = findComment(documentId, commentId);
|
||||
boolean isAuthor = currentUser.getId().equals(comment.getAuthorId());
|
||||
boolean isAdmin = currentUser.hasPermission("ADMIN");
|
||||
if (!isAuthor && !isAdmin) {
|
||||
throw DomainException.forbidden("Only the comment author or an admin can delete it");
|
||||
}
|
||||
commentRepository.delete(comment);
|
||||
}
|
||||
|
||||
// ─── private helpers ──────────────────────────────────────────────────────
|
||||
|
||||
private List<DocumentComment> withReplies(List<DocumentComment> roots) {
|
||||
roots.forEach(root -> root.setReplies(commentRepository.findByParentId(root.getId())));
|
||||
return roots;
|
||||
}
|
||||
|
||||
private DocumentComment findComment(UUID documentId, UUID commentId) {
|
||||
return commentRepository.findById(commentId)
|
||||
.filter(c -> documentId.equals(c.getDocumentId()))
|
||||
.orElseThrow(() -> DomainException.notFound(
|
||||
ErrorCode.COMMENT_NOT_FOUND, "Comment not found: " + commentId));
|
||||
}
|
||||
|
||||
private String resolveAuthorName(AppUser author) {
|
||||
String first = author.getFirstName();
|
||||
String last = author.getLastName();
|
||||
if ((first == null || first.isBlank()) && (last == null || last.isBlank())) {
|
||||
return author.getUsername();
|
||||
}
|
||||
return ((first != null ? first : "") + " " + (last != null ? last : "")).strip();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
CREATE TABLE document_annotations (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||
page_number INTEGER NOT NULL,
|
||||
x DOUBLE PRECISION NOT NULL,
|
||||
y DOUBLE PRECISION NOT NULL,
|
||||
width DOUBLE PRECISION NOT NULL,
|
||||
height DOUBLE PRECISION NOT NULL,
|
||||
color VARCHAR(20) NOT NULL,
|
||||
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX ON document_annotations (document_id, page_number);
|
||||
@@ -0,0 +1,7 @@
|
||||
-- Grant ANNOTATE_ALL to every group that already has ADMIN.
|
||||
-- New installs get it via DataInitializer; this covers existing deployments.
|
||||
INSERT INTO group_permissions (group_id, permission)
|
||||
SELECT g.id, 'ANNOTATE_ALL'
|
||||
FROM user_groups g
|
||||
WHERE g.id IN (SELECT group_id FROM group_permissions WHERE permission = 'ADMIN')
|
||||
AND g.id NOT IN (SELECT group_id FROM group_permissions WHERE permission = 'ANNOTATE_ALL');
|
||||
@@ -0,0 +1,15 @@
|
||||
CREATE TABLE document_comments (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||
annotation_id UUID REFERENCES document_annotations(id) ON DELETE CASCADE,
|
||||
parent_id UUID REFERENCES document_comments(id) ON DELETE CASCADE,
|
||||
author_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
author_name VARCHAR(200) NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_dc_document ON document_comments(document_id);
|
||||
CREATE INDEX idx_dc_annotation ON document_comments(annotation_id);
|
||||
CREATE INDEX idx_dc_parent ON document_comments(parent_id);
|
||||
@@ -0,0 +1,130 @@
|
||||
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.DocumentAnnotation;
|
||||
import org.raddatz.familienarchiv.security.PermissionAspect;
|
||||
import org.raddatz.familienarchiv.service.AnnotationService;
|
||||
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
|
||||
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.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
@WebMvcTest(AnnotationController.class)
|
||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||
class AnnotationControllerTest {
|
||||
|
||||
@Autowired MockMvc mockMvc;
|
||||
|
||||
@MockitoBean AnnotationService annotationService;
|
||||
@MockitoBean UserService userService;
|
||||
@MockitoBean CustomUserDetailsService customUserDetailsService;
|
||||
|
||||
private static final String ANNOTATION_JSON =
|
||||
"{\"pageNumber\":1,\"x\":0.1,\"y\":0.1,\"width\":0.2,\"height\":0.2,\"color\":\"#ff0000\"}";
|
||||
|
||||
// ─── GET /api/documents/{documentId}/annotations ──────────────────────────
|
||||
|
||||
@Test
|
||||
void listAnnotations_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(get("/api/documents/" + UUID.randomUUID() + "/annotations"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void listAnnotations_returns200_whenAuthenticated() throws Exception {
|
||||
when(annotationService.listAnnotations(any())).thenReturn(List.of());
|
||||
|
||||
mockMvc.perform(get("/api/documents/" + UUID.randomUUID() + "/annotations"))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
// ─── POST /api/documents/{documentId}/annotations ─────────────────────────
|
||||
|
||||
@Test
|
||||
void createAnnotation_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(ANNOTATION_JSON))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void createAnnotation_returns403_whenMissingAnnotatePermission() throws Exception {
|
||||
mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(ANNOTATION_JSON))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "ANNOTATE_ALL")
|
||||
void createAnnotation_returns201_whenHasAnnotatePermission() throws Exception {
|
||||
UUID docId = UUID.randomUUID();
|
||||
DocumentAnnotation saved = DocumentAnnotation.builder()
|
||||
.id(UUID.randomUUID()).documentId(docId).pageNumber(1)
|
||||
.x(0.1).y(0.1).width(0.2).height(0.2).color("#ff0000").build();
|
||||
when(annotationService.createAnnotation(any(), any(), any())).thenReturn(saved);
|
||||
|
||||
mockMvc.perform(post("/api/documents/" + docId + "/annotations")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(ANNOTATION_JSON))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.pageNumber").value(1));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "ANNOTATE_ALL")
|
||||
void createAnnotation_returns409_whenOverlap() throws Exception {
|
||||
when(annotationService.createAnnotation(any(), any(), any()))
|
||||
.thenThrow(DomainException.conflict(ErrorCode.ANNOTATION_OVERLAP, "Overlap"));
|
||||
|
||||
mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(ANNOTATION_JSON))
|
||||
.andExpect(status().isConflict());
|
||||
}
|
||||
|
||||
// ─── DELETE /api/documents/{documentId}/annotations/{annotationId} ─────────
|
||||
|
||||
@Test
|
||||
void deleteAnnotation_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void deleteAnnotation_returns403_whenMissingAnnotatePermission() throws Exception {
|
||||
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "ANNOTATE_ALL")
|
||||
void deleteAnnotation_returns204_whenHasAnnotatePermission() throws Exception {
|
||||
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()))
|
||||
.andExpect(status().isNoContent());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package org.raddatz.familienarchiv.controller;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.config.SecurityConfig;
|
||||
import org.raddatz.familienarchiv.model.DocumentComment;
|
||||
import org.raddatz.familienarchiv.security.PermissionAspect;
|
||||
import org.raddatz.familienarchiv.service.CommentService;
|
||||
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
|
||||
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.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
@WebMvcTest(CommentController.class)
|
||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||
class CommentControllerTest {
|
||||
|
||||
@Autowired MockMvc mockMvc;
|
||||
|
||||
@MockitoBean CommentService commentService;
|
||||
@MockitoBean UserService userService;
|
||||
@MockitoBean CustomUserDetailsService customUserDetailsService;
|
||||
|
||||
private static final String COMMENT_JSON = "{\"content\":\"Test comment\"}";
|
||||
private static final UUID DOC_ID = UUID.randomUUID();
|
||||
private static final UUID ANN_ID = UUID.randomUUID();
|
||||
private static final UUID COMMENT_ID = UUID.randomUUID();
|
||||
|
||||
// ─── GET /api/documents/{documentId}/comments ─────────────────────────────
|
||||
|
||||
@Test
|
||||
void getDocumentComments_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(get("/api/documents/" + DOC_ID + "/comments"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void getDocumentComments_returns200_whenAuthenticated() throws Exception {
|
||||
when(commentService.getCommentsForDocument(any())).thenReturn(List.of());
|
||||
mockMvc.perform(get("/api/documents/" + DOC_ID + "/comments"))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
// ─── POST /api/documents/{documentId}/comments ────────────────────────────
|
||||
|
||||
@Test
|
||||
void postDocumentComment_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments")
|
||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void postDocumentComment_returns403_whenMissingPermission() throws Exception {
|
||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments")
|
||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "ANNOTATE_ALL")
|
||||
void postDocumentComment_returns201_whenHasPermission() throws Exception {
|
||||
DocumentComment saved = DocumentComment.builder()
|
||||
.id(COMMENT_ID).documentId(DOC_ID).authorName("Hans").content("Test comment").build();
|
||||
when(commentService.postComment(any(), any(), any(), any())).thenReturn(saved);
|
||||
|
||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments")
|
||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.content").value("Test comment"));
|
||||
}
|
||||
|
||||
// ─── POST /api/documents/{documentId}/comments/{commentId}/replies ────────
|
||||
|
||||
@Test
|
||||
void replyToComment_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID + "/replies")
|
||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "ANNOTATE_ALL")
|
||||
void replyToComment_returns201_whenHasPermission() throws Exception {
|
||||
DocumentComment saved = DocumentComment.builder()
|
||||
.id(UUID.randomUUID()).documentId(DOC_ID).parentId(COMMENT_ID)
|
||||
.authorName("Anna").content("Test comment").build();
|
||||
when(commentService.replyToComment(any(), any(), any(), any())).thenReturn(saved);
|
||||
|
||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID + "/replies")
|
||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||
.andExpect(status().isCreated());
|
||||
}
|
||||
|
||||
// ─── PATCH /api/documents/{documentId}/comments/{commentId} ──────────────
|
||||
|
||||
@Test
|
||||
void editComment_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID)
|
||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "ANNOTATE_ALL")
|
||||
void editComment_returns200_whenHasPermission() throws Exception {
|
||||
DocumentComment updated = DocumentComment.builder()
|
||||
.id(COMMENT_ID).documentId(DOC_ID).authorName("Hans").content("Test comment").build();
|
||||
when(commentService.editComment(any(), any(), any(), any())).thenReturn(updated);
|
||||
|
||||
mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID)
|
||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
// ─── DELETE /api/documents/{documentId}/comments/{commentId} ─────────────
|
||||
|
||||
@Test
|
||||
void deleteComment_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(delete("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void deleteComment_returns204_whenAuthenticated() throws Exception {
|
||||
mockMvc.perform(delete("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID))
|
||||
.andExpect(status().isNoContent());
|
||||
}
|
||||
|
||||
// ─── GET /api/documents/{documentId}/annotations/{annId}/comments ─────────
|
||||
|
||||
@Test
|
||||
void getAnnotationComments_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(get("/api/documents/" + DOC_ID + "/annotations/" + ANN_ID + "/comments"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void getAnnotationComments_returns200_whenAuthenticated() throws Exception {
|
||||
when(commentService.getCommentsForAnnotation(any())).thenReturn(List.of());
|
||||
mockMvc.perform(get("/api/documents/" + DOC_ID + "/annotations/" + ANN_ID + "/comments"))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
// ─── POST /api/documents/{documentId}/annotations/{annId}/comments ────────
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void postAnnotationComment_returns403_whenMissingPermission() throws Exception {
|
||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/annotations/" + ANN_ID + "/comments")
|
||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "ANNOTATE_ALL")
|
||||
void postAnnotationComment_returns201_whenHasPermission() throws Exception {
|
||||
DocumentComment saved = DocumentComment.builder()
|
||||
.id(UUID.randomUUID()).documentId(DOC_ID).annotationId(ANN_ID)
|
||||
.authorName("Hans").content("Test comment").build();
|
||||
when(commentService.postComment(any(), any(), any(), any())).thenReturn(saved);
|
||||
|
||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/annotations/" + ANN_ID + "/comments")
|
||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||
.andExpect(status().isCreated());
|
||||
}
|
||||
|
||||
// ─── POST /api/documents/{documentId}/annotations/{annId}/comments/{commentId}/replies ─
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "ANNOTATE_ALL")
|
||||
void replyToAnnotationComment_returns201_whenHasPermission() throws Exception {
|
||||
DocumentComment saved = DocumentComment.builder()
|
||||
.id(UUID.randomUUID()).documentId(DOC_ID).annotationId(ANN_ID)
|
||||
.parentId(COMMENT_ID).authorName("Anna").content("Test comment").build();
|
||||
when(commentService.replyToComment(any(), any(), any(), any())).thenReturn(saved);
|
||||
|
||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/annotations/" + ANN_ID + "/comments/" + COMMENT_ID + "/replies")
|
||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||
.andExpect(status().isCreated());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
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.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.model.DocumentAnnotation;
|
||||
import org.raddatz.familienarchiv.repository.AnnotationRepository;
|
||||
|
||||
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.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.http.HttpStatus.CONFLICT;
|
||||
import static org.springframework.http.HttpStatus.FORBIDDEN;
|
||||
import static org.springframework.http.HttpStatus.NOT_FOUND;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class AnnotationServiceTest {
|
||||
|
||||
@Mock AnnotationRepository annotationRepository;
|
||||
@InjectMocks AnnotationService annotationService;
|
||||
|
||||
// ─── createAnnotation ─────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void createAnnotation_throwsConflict_whenAnnotationOverlapsExisting() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID userId = UUID.randomUUID();
|
||||
CreateAnnotationDTO dto = new CreateAnnotationDTO(1, 0.1, 0.1, 0.3, 0.3, "#ff0000");
|
||||
|
||||
DocumentAnnotation existing = DocumentAnnotation.builder()
|
||||
.id(UUID.randomUUID()).documentId(docId).pageNumber(1)
|
||||
.x(0.2).y(0.2).width(0.3).height(0.3).color("#00ff00").build();
|
||||
when(annotationRepository.findByDocumentIdAndPageNumber(docId, 1))
|
||||
.thenReturn(List.of(existing));
|
||||
|
||||
assertThatThrownBy(() -> annotationService.createAnnotation(docId, dto, userId))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(e -> assertThat(((DomainException) e).getStatus()).isEqualTo(CONFLICT));
|
||||
|
||||
verify(annotationRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void createAnnotation_savesAndReturns_whenNoOverlap() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID userId = UUID.randomUUID();
|
||||
CreateAnnotationDTO dto = new CreateAnnotationDTO(1, 0.0, 0.0, 0.05, 0.05, "#ff0000");
|
||||
|
||||
when(annotationRepository.findByDocumentIdAndPageNumber(docId, 1)).thenReturn(List.of());
|
||||
DocumentAnnotation saved = DocumentAnnotation.builder()
|
||||
.id(UUID.randomUUID()).documentId(docId).pageNumber(1)
|
||||
.x(0.0).y(0.0).width(0.05).height(0.05).color("#ff0000").createdBy(userId).build();
|
||||
when(annotationRepository.save(any())).thenReturn(saved);
|
||||
|
||||
DocumentAnnotation result = annotationService.createAnnotation(docId, dto, userId);
|
||||
|
||||
assertThat(result).isEqualTo(saved);
|
||||
verify(annotationRepository).save(any());
|
||||
}
|
||||
|
||||
// ─── deleteAnnotation ─────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void deleteAnnotation_throwsNotFound_whenMissing() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID annotId = UUID.randomUUID();
|
||||
when(annotationRepository.findByIdAndDocumentId(annotId, docId)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> annotationService.deleteAnnotation(docId, annotId, UUID.randomUUID()))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(e -> assertThat(((DomainException) e).getStatus()).isEqualTo(NOT_FOUND));
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteAnnotation_throwsForbidden_whenNotOwner() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID annotId = UUID.randomUUID();
|
||||
UUID ownerId = UUID.randomUUID();
|
||||
UUID otherId = UUID.randomUUID();
|
||||
|
||||
DocumentAnnotation annotation = DocumentAnnotation.builder()
|
||||
.id(annotId).documentId(docId).createdBy(ownerId).build();
|
||||
when(annotationRepository.findByIdAndDocumentId(annotId, docId))
|
||||
.thenReturn(Optional.of(annotation));
|
||||
|
||||
assertThatThrownBy(() -> annotationService.deleteAnnotation(docId, annotId, otherId))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(e -> assertThat(((DomainException) e).getStatus()).isEqualTo(FORBIDDEN));
|
||||
|
||||
verify(annotationRepository, never()).delete(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteAnnotation_succeeds_whenOwner() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID annotId = UUID.randomUUID();
|
||||
UUID ownerId = UUID.randomUUID();
|
||||
|
||||
DocumentAnnotation annotation = DocumentAnnotation.builder()
|
||||
.id(annotId).documentId(docId).createdBy(ownerId).build();
|
||||
when(annotationRepository.findByIdAndDocumentId(annotId, docId))
|
||||
.thenReturn(Optional.of(annotation));
|
||||
|
||||
annotationService.deleteAnnotation(docId, annotId, ownerId);
|
||||
|
||||
verify(annotationRepository).delete(annotation);
|
||||
}
|
||||
|
||||
// ─── listAnnotations ──────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void listAnnotations_returnsAllForDocument() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
DocumentAnnotation a = DocumentAnnotation.builder()
|
||||
.id(UUID.randomUUID()).documentId(docId).build();
|
||||
when(annotationRepository.findByDocumentId(docId)).thenReturn(List.of(a));
|
||||
|
||||
assertThat(annotationService.listAnnotations(docId)).containsExactly(a);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
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.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.model.AppUser;
|
||||
import org.raddatz.familienarchiv.model.DocumentComment;
|
||||
import org.raddatz.familienarchiv.model.UserGroup;
|
||||
import org.raddatz.familienarchiv.repository.CommentRepository;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
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.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.http.HttpStatus.FORBIDDEN;
|
||||
import static org.springframework.http.HttpStatus.NOT_FOUND;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class CommentServiceTest {
|
||||
|
||||
@Mock CommentRepository commentRepository;
|
||||
@InjectMocks CommentService commentService;
|
||||
|
||||
// ─── postComment ──────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void postComment_capturesAuthorNameAtWriteTime() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
AppUser author = AppUser.builder()
|
||||
.id(UUID.randomUUID()).username("hans").firstName("Hans").lastName("Müller").build();
|
||||
DocumentComment saved = DocumentComment.builder()
|
||||
.id(UUID.randomUUID()).documentId(docId).authorName("Hans Müller").content("Test").build();
|
||||
when(commentRepository.save(any())).thenReturn(saved);
|
||||
|
||||
DocumentComment result = commentService.postComment(docId, null, "Test", author);
|
||||
|
||||
assertThat(result.getAuthorName()).isEqualTo("Hans Müller");
|
||||
}
|
||||
|
||||
@Test
|
||||
void postComment_fallsBackToUsername_whenNamesAreBlank() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
AppUser author = AppUser.builder().id(UUID.randomUUID()).username("hans42").build();
|
||||
DocumentComment saved = DocumentComment.builder()
|
||||
.id(UUID.randomUUID()).documentId(docId).authorName("hans42").content("Test").build();
|
||||
when(commentRepository.save(any())).thenReturn(saved);
|
||||
|
||||
DocumentComment result = commentService.postComment(docId, null, "Test", author);
|
||||
|
||||
assertThat(result.getAuthorName()).isEqualTo("hans42");
|
||||
}
|
||||
|
||||
// ─── replyToComment ───────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void replyToComment_throwsNotFound_whenTargetCommentMissing() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID commentId = UUID.randomUUID();
|
||||
AppUser author = AppUser.builder().id(UUID.randomUUID()).username("anna").build();
|
||||
when(commentRepository.findById(commentId)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> commentService.replyToComment(docId, commentId, "Reply", author))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(e -> assertThat(((DomainException) e).getStatus()).isEqualTo(NOT_FOUND));
|
||||
|
||||
verify(commentRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void replyToComment_resolvesToRootParent_whenReplyingToAReply() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID rootId = UUID.randomUUID();
|
||||
UUID replyId = UUID.randomUUID();
|
||||
AppUser author = AppUser.builder().id(UUID.randomUUID()).username("anna").build();
|
||||
|
||||
DocumentComment root = DocumentComment.builder()
|
||||
.id(rootId).documentId(docId).parentId(null).content("Root").authorName("Hans").build();
|
||||
DocumentComment existingReply = DocumentComment.builder()
|
||||
.id(replyId).documentId(docId).parentId(rootId).content("Reply1").authorName("Anna").build();
|
||||
|
||||
when(commentRepository.findById(replyId)).thenReturn(Optional.of(existingReply));
|
||||
when(commentRepository.findById(rootId)).thenReturn(Optional.of(root));
|
||||
DocumentComment saved = DocumentComment.builder()
|
||||
.id(UUID.randomUUID()).documentId(docId).parentId(rootId).content("Reply2").authorName("anna").build();
|
||||
when(commentRepository.save(any())).thenReturn(saved);
|
||||
|
||||
DocumentComment result = commentService.replyToComment(docId, replyId, "Reply2", author);
|
||||
|
||||
assertThat(result.getParentId()).isEqualTo(rootId);
|
||||
}
|
||||
|
||||
@Test
|
||||
void replyToComment_usesDirectComment_whenReplyingToTopLevel() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID rootId = UUID.randomUUID();
|
||||
AppUser author = AppUser.builder().id(UUID.randomUUID()).username("anna").build();
|
||||
|
||||
DocumentComment root = DocumentComment.builder()
|
||||
.id(rootId).documentId(docId).parentId(null).content("Root").authorName("Hans").build();
|
||||
|
||||
when(commentRepository.findById(rootId)).thenReturn(Optional.of(root));
|
||||
DocumentComment saved = DocumentComment.builder()
|
||||
.id(UUID.randomUUID()).documentId(docId).parentId(rootId).content("Reply").authorName("anna").build();
|
||||
when(commentRepository.save(any())).thenReturn(saved);
|
||||
|
||||
DocumentComment result = commentService.replyToComment(docId, rootId, "Reply", author);
|
||||
|
||||
assertThat(result.getParentId()).isEqualTo(rootId);
|
||||
}
|
||||
|
||||
// ─── editComment ──────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void editComment_throwsForbidden_whenNotAuthor() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID commentId = UUID.randomUUID();
|
||||
UUID ownerId = UUID.randomUUID();
|
||||
AppUser other = AppUser.builder().id(UUID.randomUUID()).username("other").build();
|
||||
|
||||
DocumentComment comment = DocumentComment.builder()
|
||||
.id(commentId).documentId(docId).authorId(ownerId).content("Original").authorName("Hans").build();
|
||||
when(commentRepository.findById(commentId)).thenReturn(Optional.of(comment));
|
||||
|
||||
assertThatThrownBy(() -> commentService.editComment(docId, commentId, "Changed", other))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(e -> assertThat(((DomainException) e).getStatus()).isEqualTo(FORBIDDEN));
|
||||
|
||||
verify(commentRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void editComment_updatesContent_whenAuthor() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID commentId = UUID.randomUUID();
|
||||
UUID authorId = UUID.randomUUID();
|
||||
AppUser author = AppUser.builder().id(authorId).username("hans").build();
|
||||
LocalDateTime created = LocalDateTime.now().minusMinutes(5);
|
||||
|
||||
DocumentComment comment = DocumentComment.builder()
|
||||
.id(commentId).documentId(docId).authorId(authorId)
|
||||
.content("Original").authorName("Hans").createdAt(created).build();
|
||||
when(commentRepository.findById(commentId)).thenReturn(Optional.of(comment));
|
||||
when(commentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
DocumentComment result = commentService.editComment(docId, commentId, "Updated", author);
|
||||
|
||||
assertThat(result.getContent()).isEqualTo("Updated");
|
||||
assertThat(result.getCreatedAt()).isEqualTo(created);
|
||||
}
|
||||
|
||||
// ─── deleteComment ────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void deleteComment_throwsForbidden_whenNotAuthorAndNotAdmin() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID commentId = UUID.randomUUID();
|
||||
UUID ownerId = UUID.randomUUID();
|
||||
AppUser other = AppUser.builder().id(UUID.randomUUID()).username("other").build();
|
||||
|
||||
DocumentComment comment = DocumentComment.builder()
|
||||
.id(commentId).documentId(docId).authorId(ownerId).authorName("Hans").content("X").build();
|
||||
when(commentRepository.findById(commentId)).thenReturn(Optional.of(comment));
|
||||
|
||||
assertThatThrownBy(() -> commentService.deleteComment(docId, commentId, other))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(e -> assertThat(((DomainException) e).getStatus()).isEqualTo(FORBIDDEN));
|
||||
|
||||
verify(commentRepository, never()).delete(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteComment_succeeds_whenAuthor() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID commentId = UUID.randomUUID();
|
||||
UUID authorId = UUID.randomUUID();
|
||||
AppUser author = AppUser.builder().id(authorId).username("hans").build();
|
||||
|
||||
DocumentComment comment = DocumentComment.builder()
|
||||
.id(commentId).documentId(docId).authorId(authorId).authorName("Hans").content("X").build();
|
||||
when(commentRepository.findById(commentId)).thenReturn(Optional.of(comment));
|
||||
|
||||
commentService.deleteComment(docId, commentId, author);
|
||||
|
||||
verify(commentRepository).delete(comment);
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteComment_succeeds_whenAdmin() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID commentId = UUID.randomUUID();
|
||||
UUID ownerId = UUID.randomUUID();
|
||||
AppUser admin = buildAdmin();
|
||||
|
||||
DocumentComment comment = DocumentComment.builder()
|
||||
.id(commentId).documentId(docId).authorId(ownerId).authorName("Hans").content("X").build();
|
||||
when(commentRepository.findById(commentId)).thenReturn(Optional.of(comment));
|
||||
|
||||
commentService.deleteComment(docId, commentId, admin);
|
||||
|
||||
verify(commentRepository).delete(comment);
|
||||
}
|
||||
|
||||
// ─── getCommentsForDocument ───────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void getCommentsForDocument_returnsRootsWithRepliesAttached() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID rootId = UUID.randomUUID();
|
||||
|
||||
DocumentComment root = DocumentComment.builder()
|
||||
.id(rootId).documentId(docId).authorName("Hans").content("Root").build();
|
||||
DocumentComment reply = DocumentComment.builder()
|
||||
.id(UUID.randomUUID()).documentId(docId).parentId(rootId).authorName("Anna").content("Reply").build();
|
||||
|
||||
when(commentRepository.findByDocumentIdAndAnnotationIdIsNullAndParentIdIsNull(docId))
|
||||
.thenReturn(List.of(root));
|
||||
when(commentRepository.findByParentId(rootId)).thenReturn(List.of(reply));
|
||||
|
||||
List<DocumentComment> result = commentService.getCommentsForDocument(docId);
|
||||
|
||||
assertThat(result).hasSize(1);
|
||||
assertThat(result.get(0).getReplies()).containsExactly(reply);
|
||||
}
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
private AppUser buildAdmin() {
|
||||
return AppUser.builder()
|
||||
.id(UUID.randomUUID())
|
||||
.username("admin")
|
||||
.groups(Set.of(UserGroup.builder()
|
||||
.id(UUID.randomUUID())
|
||||
.name("admins")
|
||||
.permissions(Set.of("ADMIN"))
|
||||
.build()))
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -98,6 +98,7 @@ services:
|
||||
S3_SECRET_KEY: ${MINIO_ROOT_PASSWORD}
|
||||
S3_BUCKET_NAME: ${MINIO_DEFAULT_BUCKETS}
|
||||
S3_REGION: us-east-1
|
||||
SPRING_PROFILES_ACTIVE: dev,e2e
|
||||
APP_BASE_URL: ${APP_BASE_URL:-http://localhost:3000}
|
||||
# Defaults to the local Mailpit catcher — override in .env for production SMTP
|
||||
MAIL_HOST: ${MAIL_HOST:-mailpit}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"value": "de",
|
||||
"domain": "localhost",
|
||||
"path": "/",
|
||||
"expires": 1808565334.192108,
|
||||
"expires": 1808896929.897686,
|
||||
"httpOnly": false,
|
||||
"secure": false,
|
||||
"sameSite": "Lax"
|
||||
@@ -15,7 +15,7 @@
|
||||
"value": "Basic%20YWRtaW46YWRtaW4xMjM%3D",
|
||||
"domain": "localhost",
|
||||
"path": "/",
|
||||
"expires": 1774091734.449243,
|
||||
"expires": 1774423330.233039,
|
||||
"httpOnly": true,
|
||||
"secure": false,
|
||||
"sameSite": "Strict"
|
||||
|
||||
180
frontend/e2e/bottom-panel.spec.ts
Normal file
180
frontend/e2e/bottom-panel.spec.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import fs from 'fs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PDF_FIXTURE = path.resolve(__dirname, 'fixtures/minimal.pdf');
|
||||
|
||||
/**
|
||||
* Bottom panel E2E tests — issue #62.
|
||||
* Verifies the new document detail layout: full-viewport viewer + floating bottom panel.
|
||||
*/
|
||||
|
||||
let pdfDocHref: string;
|
||||
let noFileDocHref: string;
|
||||
|
||||
test.describe('Document bottom panel', () => {
|
||||
test.beforeAll(async ({ request }) => {
|
||||
const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000';
|
||||
|
||||
// Create a document with a PDF and a date for metadata tests.
|
||||
const createRes = await request.post('/api/documents', {
|
||||
multipart: { title: 'E2E Bottom Panel Test', documentDate: '1945-05-08' }
|
||||
});
|
||||
if (!createRes.ok()) throw new Error(`Create document failed: ${createRes.status()}`);
|
||||
const doc = await createRes.json();
|
||||
|
||||
const uploadRes = await request.put(`/api/documents/${doc.id}`, {
|
||||
multipart: {
|
||||
title: doc.title,
|
||||
documentDate: '1945-05-08',
|
||||
transcription: 'Dies ist eine vollständige Transkription des Dokuments für den E2E-Test.',
|
||||
file: {
|
||||
name: 'minimal.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
buffer: fs.readFileSync(PDF_FIXTURE)
|
||||
}
|
||||
}
|
||||
});
|
||||
if (!uploadRes.ok()) throw new Error(`Upload PDF failed: ${uploadRes.status()}`);
|
||||
pdfDocHref = `${baseURL}/documents/${doc.id}`;
|
||||
|
||||
// Create a document WITHOUT a file — panel should open to Metadaten by default.
|
||||
const noFileRes = await request.post('/api/documents', {
|
||||
multipart: { title: 'E2E Bottom Panel No-File Test' }
|
||||
});
|
||||
if (!noFileRes.ok()) throw new Error(`Create no-file document failed: ${noFileRes.status()}`);
|
||||
noFileDocHref = `${baseURL}/documents/${noFileRes.json().then ? (await noFileRes.json()).id : ''}`;
|
||||
const noFileDoc = await noFileRes.json();
|
||||
noFileDocHref = `${baseURL}/documents/${noFileDoc.id}`;
|
||||
});
|
||||
|
||||
test('bottom panel tab bar is visible and panel content is closed by default on a PDF document', async ({
|
||||
page
|
||||
}) => {
|
||||
test.setTimeout(30_000);
|
||||
// Clear localStorage to ensure no previous panel state.
|
||||
await page.goto(pdfDocHref);
|
||||
await page.evaluate(() => localStorage.clear());
|
||||
await page.reload();
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
|
||||
// Tab bar must always be visible.
|
||||
await expect(page.getByRole('button', { name: 'Metadaten' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Transkription' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Diskussion' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Verlauf' })).toBeVisible();
|
||||
|
||||
// Panel content must NOT be visible when closed.
|
||||
await expect(page.locator('[data-testid="bottom-panel-content"]')).not.toBeVisible();
|
||||
|
||||
await page.screenshot({ path: 'test-results/e2e/bottom-panel-closed-default.png' });
|
||||
});
|
||||
|
||||
test('clicking Metadaten tab opens the panel and shows metadata content', async ({ page }) => {
|
||||
test.setTimeout(30_000);
|
||||
await page.goto(pdfDocHref);
|
||||
await page.evaluate(() => localStorage.clear());
|
||||
await page.reload();
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
|
||||
await page.getByRole('button', { name: 'Metadaten' }).click();
|
||||
|
||||
// Panel content becomes visible.
|
||||
await expect(page.locator('[data-testid="bottom-panel-content"]')).toBeVisible();
|
||||
|
||||
// Metadata section heading should be present.
|
||||
await expect(page.getByText('Details', { exact: false })).toBeVisible();
|
||||
|
||||
await page.screenshot({ path: 'test-results/e2e/bottom-panel-metadata.png' });
|
||||
});
|
||||
|
||||
test('clicking Transkription tab shows transcription text', async ({ page }) => {
|
||||
test.setTimeout(30_000);
|
||||
await page.goto(pdfDocHref);
|
||||
await page.evaluate(() => localStorage.clear());
|
||||
await page.reload();
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
|
||||
await page.getByRole('button', { name: 'Transkription' }).click();
|
||||
|
||||
await expect(page.locator('[data-testid="bottom-panel-content"]')).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Dies ist eine vollständige Transkription', { exact: false })
|
||||
).toBeVisible();
|
||||
|
||||
await page.screenshot({ path: 'test-results/e2e/bottom-panel-transcription.png' });
|
||||
});
|
||||
|
||||
test('clicking Diskussion tab shows the comment input', async ({ page }) => {
|
||||
test.setTimeout(30_000);
|
||||
await page.goto(pdfDocHref);
|
||||
await page.evaluate(() => localStorage.clear());
|
||||
await page.reload();
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
|
||||
await page.getByRole('button', { name: 'Diskussion' }).click();
|
||||
|
||||
await expect(page.locator('[data-testid="bottom-panel-content"]')).toBeVisible();
|
||||
await expect(page.getByPlaceholder('Kommentar schreiben…')).toBeVisible();
|
||||
|
||||
await page.screenshot({ path: 'test-results/e2e/bottom-panel-discussion.png' });
|
||||
});
|
||||
|
||||
test('clicking × close button collapses the panel content', async ({ page }) => {
|
||||
test.setTimeout(30_000);
|
||||
await page.goto(pdfDocHref);
|
||||
await page.evaluate(() => localStorage.clear());
|
||||
await page.reload();
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
|
||||
// Open the panel first.
|
||||
await page.getByRole('button', { name: 'Metadaten' }).click();
|
||||
await expect(page.locator('[data-testid="bottom-panel-content"]')).toBeVisible();
|
||||
|
||||
// Close it.
|
||||
await page.locator('[data-testid="panel-close-btn"]').click();
|
||||
await expect(page.locator('[data-testid="bottom-panel-content"]')).not.toBeVisible();
|
||||
|
||||
// Tab bar still visible after closing.
|
||||
await expect(page.getByRole('button', { name: 'Metadaten' })).toBeVisible();
|
||||
|
||||
await page.screenshot({ path: 'test-results/e2e/bottom-panel-closed-after-x.png' });
|
||||
});
|
||||
|
||||
test('panel open state persists after page reload', async ({ page }) => {
|
||||
test.setTimeout(30_000);
|
||||
await page.goto(pdfDocHref);
|
||||
await page.evaluate(() => localStorage.clear());
|
||||
await page.reload();
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
|
||||
// Open the panel to Diskussion.
|
||||
await page.getByRole('button', { name: 'Diskussion' }).click();
|
||||
await expect(page.locator('[data-testid="bottom-panel-content"]')).toBeVisible();
|
||||
|
||||
// Reload — panel should re-open on the same tab.
|
||||
await page.reload();
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
|
||||
await expect(page.locator('[data-testid="bottom-panel-content"]')).toBeVisible();
|
||||
await expect(page.getByPlaceholder('Kommentar schreiben…')).toBeVisible();
|
||||
|
||||
await page.screenshot({ path: 'test-results/e2e/bottom-panel-persisted.png' });
|
||||
});
|
||||
|
||||
test('document without a file opens panel to Metadaten by default', async ({ page }) => {
|
||||
test.setTimeout(30_000);
|
||||
await page.goto(noFileDocHref);
|
||||
await page.evaluate(() => localStorage.clear());
|
||||
await page.reload();
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
|
||||
// Panel should be open to Metadaten by default when there is no file.
|
||||
await expect(page.locator('[data-testid="bottom-panel-content"]')).toBeVisible();
|
||||
await expect(page.getByText('Details', { exact: false })).toBeVisible();
|
||||
|
||||
await page.screenshot({ path: 'test-results/e2e/bottom-panel-no-file-default.png' });
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,9 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import fs from 'fs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
/**
|
||||
* Document management E2E tests.
|
||||
@@ -150,29 +154,38 @@ const PDF_FIXTURE = path.resolve(__dirname, 'fixtures/minimal.pdf');
|
||||
|
||||
test.describe('PDF viewer', () => {
|
||||
let pdfDocHref: string;
|
||||
let noFileDocHref: string;
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
// Create a document and upload the PDF fixture so later tests have a
|
||||
// real file attached. Runs once for the whole describe block.
|
||||
const ctx = await browser.newContext();
|
||||
const p = await ctx.newPage();
|
||||
test.beforeAll(async ({ request }) => {
|
||||
const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000';
|
||||
|
||||
await p.goto('/documents/new');
|
||||
await p.waitForSelector('[data-hydrated]');
|
||||
await p.getByLabel('Titel').fill('E2E PDF Viewer Test');
|
||||
await p.getByRole('button', { name: /Speichern/i }).click();
|
||||
await p.waitForURL(/\/documents\/[^/]+$/);
|
||||
// Create a document with a PDF file.
|
||||
const createRes = await request.post('/api/documents', {
|
||||
multipart: { title: 'E2E PDF Viewer Test' }
|
||||
});
|
||||
if (!createRes.ok()) throw new Error(`Create document failed: ${createRes.status()}`);
|
||||
const doc = await createRes.json();
|
||||
|
||||
// Upload the PDF on the edit page
|
||||
const href = p.url().replace(/\/$/, '');
|
||||
pdfDocHref = href;
|
||||
await p.goto(`${href}/edit`);
|
||||
await p.waitForSelector('[data-hydrated]');
|
||||
await p.locator('input[type="file"][name="file"]').setInputFiles(PDF_FIXTURE);
|
||||
await p.getByRole('button', { name: /Speichern/i }).click();
|
||||
await p.waitForURL(/\/documents\/[^/]+$/);
|
||||
const uploadRes = await request.put(`/api/documents/${doc.id}`, {
|
||||
multipart: {
|
||||
title: doc.title,
|
||||
file: {
|
||||
name: 'minimal.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
buffer: fs.readFileSync(PDF_FIXTURE)
|
||||
}
|
||||
}
|
||||
});
|
||||
if (!uploadRes.ok()) throw new Error(`Upload PDF failed: ${uploadRes.status()}`);
|
||||
pdfDocHref = `${baseURL}/documents/${doc.id}`;
|
||||
|
||||
await ctx.close();
|
||||
// Create a document WITHOUT a file — used to verify no canvas is rendered.
|
||||
const noFileRes = await request.post('/api/documents', {
|
||||
multipart: { title: 'E2E No-File Test' }
|
||||
});
|
||||
if (!noFileRes.ok()) throw new Error(`Create no-file document failed: ${noFileRes.status()}`);
|
||||
const noFileDoc = await noFileRes.json();
|
||||
noFileDocHref = `${baseURL}/documents/${noFileDoc.id}`;
|
||||
});
|
||||
|
||||
test('PDF renders in the custom viewer — canvas is present instead of iframe', async ({
|
||||
@@ -201,20 +214,164 @@ test.describe('PDF viewer', () => {
|
||||
await page.screenshot({ path: 'test-results/e2e/pdf-viewer-nav.png' });
|
||||
});
|
||||
|
||||
test('non-PDF attachment renders as an img element, not canvas', async ({ page }) => {
|
||||
// The seed document "Urlaubspostkarte Ostsee" has a .jpg original filename.
|
||||
// Navigate to it and confirm an <img> is used (no canvas, no iframe).
|
||||
await page.goto('/');
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
await page.goto('/?q=Urlaubspostkarte');
|
||||
const link = page.getByRole('link', { name: /Urlaubspostkarte/i }).first();
|
||||
const href = await link.getAttribute('href');
|
||||
await page.goto(href!);
|
||||
test('document without a file has no canvas', async ({ page }) => {
|
||||
// A document with no file attached must not render a PDF canvas.
|
||||
await page.goto(noFileDocHref);
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
|
||||
// No canvas — this is an image document
|
||||
// No canvas — this document has no file
|
||||
await expect(page.locator('canvas')).not.toBeAttached();
|
||||
|
||||
await page.screenshot({ path: 'test-results/e2e/pdf-viewer-image-fallback.png' });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── PDF Annotations (admin) ──────────────────────────────────────────────────
|
||||
|
||||
// Shared with the read-only user describe block below
|
||||
let sharedAnnotationDocId: string;
|
||||
|
||||
test.describe('PDF annotations — admin', () => {
|
||||
let annotationDocHref: string;
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
// Create a document with a PDF via API — much faster than UI automation.
|
||||
const createRes = await request.post('/api/documents', {
|
||||
multipart: { title: 'E2E Annotations Test' }
|
||||
});
|
||||
if (!createRes.ok()) throw new Error(`Create document failed: ${createRes.status()}`);
|
||||
const doc = await createRes.json();
|
||||
|
||||
const uploadRes = await request.put(`/api/documents/${doc.id}`, {
|
||||
multipart: {
|
||||
title: doc.title,
|
||||
file: {
|
||||
name: 'minimal.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
buffer: fs.readFileSync(PDF_FIXTURE)
|
||||
}
|
||||
}
|
||||
});
|
||||
if (!uploadRes.ok()) throw new Error(`Upload PDF failed: ${uploadRes.status()}`);
|
||||
|
||||
const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000';
|
||||
annotationDocHref = `${baseURL}/documents/${doc.id}`;
|
||||
sharedAnnotationDocId = doc.id;
|
||||
});
|
||||
|
||||
test('admin user sees an active Annotieren button on a PDF', async ({ page }) => {
|
||||
test.setTimeout(60_000);
|
||||
await page.goto(annotationDocHref);
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
await page.locator('canvas').first().waitFor({ state: 'visible', timeout: 20000 });
|
||||
|
||||
// Admin has ANNOTATE_ALL — button must be enabled
|
||||
const annotateBtn = page.getByRole('button', { name: /^annotieren$/i });
|
||||
await expect(annotateBtn).toBeVisible();
|
||||
await expect(annotateBtn).not.toBeDisabled();
|
||||
|
||||
await page.screenshot({ path: 'test-results/e2e/annotations-button-admin.png' });
|
||||
});
|
||||
|
||||
test('admin can draw an annotation and it appears on the page', async ({ page }) => {
|
||||
test.setTimeout(60_000);
|
||||
await page.goto(annotationDocHref);
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
await page.locator('canvas').first().waitFor({ state: 'visible', timeout: 20000 });
|
||||
|
||||
// Enable annotate mode
|
||||
await page.getByRole('button', { name: /^annotieren$/i }).click();
|
||||
|
||||
// Color picker must appear
|
||||
await expect(page.getByLabel(/farbe/i)).toBeVisible();
|
||||
|
||||
// Draw on the annotation layer overlay
|
||||
const annotationLayer = page.locator('[role="presentation"]').last();
|
||||
const box = await annotationLayer.boundingBox();
|
||||
if (!box) throw new Error('Annotation layer not found');
|
||||
|
||||
const startX = box.x + box.width * 0.3;
|
||||
const startY = box.y + box.height * 0.3;
|
||||
const endX = box.x + box.width * 0.55;
|
||||
const endY = box.y + box.height * 0.55;
|
||||
|
||||
await page.mouse.move(startX, startY);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(endX, endY);
|
||||
await page.mouse.up();
|
||||
|
||||
await expect(page.locator('[data-testid^="annotation-"]').first()).toBeVisible({
|
||||
timeout: 8000
|
||||
});
|
||||
|
||||
await page.screenshot({ path: 'test-results/e2e/annotation-drawn.png' });
|
||||
});
|
||||
|
||||
test('annotation persists after page reload', async ({ page }) => {
|
||||
test.setTimeout(60_000);
|
||||
await page.goto(annotationDocHref);
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
await page.locator('canvas').first().waitFor({ state: 'visible', timeout: 20000 });
|
||||
|
||||
// Annotation from the previous test must be loaded from the API
|
||||
await expect(page.locator('[data-testid^="annotation-"]').first()).toBeVisible({
|
||||
timeout: 8000
|
||||
});
|
||||
|
||||
await page.screenshot({ path: 'test-results/e2e/annotation-persisted.png' });
|
||||
});
|
||||
|
||||
test('admin can delete an annotation', async ({ page }) => {
|
||||
test.setTimeout(60_000);
|
||||
await page.goto(annotationDocHref);
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
await page.locator('canvas').first().waitFor({ state: 'visible', timeout: 20000 });
|
||||
|
||||
// Ensure annotation is visible before enabling annotate mode
|
||||
await expect(page.locator('[data-testid^="annotation-"]').first()).toBeVisible({
|
||||
timeout: 8000
|
||||
});
|
||||
|
||||
// Enable annotate mode to show delete buttons
|
||||
await page.getByRole('button', { name: /^annotieren$/i }).click();
|
||||
|
||||
const deleteBtn = page.getByRole('button', { name: /annotation löschen/i }).first();
|
||||
await expect(deleteBtn).toBeVisible({ timeout: 8000 });
|
||||
await deleteBtn.click();
|
||||
|
||||
await expect(page.locator('[data-testid^="annotation-"]')).toHaveCount(0, {
|
||||
timeout: 8000
|
||||
});
|
||||
|
||||
await page.screenshot({ path: 'test-results/e2e/annotation-deleted.png' });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── PDF Annotations (read-only user) ─────────────────────────────────────────
|
||||
|
||||
test.describe('PDF annotations — read-only user', () => {
|
||||
// Isolated session — does not share the admin storage state
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
test('read-only user sees a disabled Annotieren button', async ({ page }) => {
|
||||
test.setTimeout(60_000);
|
||||
await page.goto('/login');
|
||||
await page.getByLabel('Benutzername').fill('reader');
|
||||
await page.getByLabel('Passwort').fill('reader123');
|
||||
await page.getByRole('button', { name: 'Anmelden' }).click();
|
||||
await page.waitForURL('/');
|
||||
|
||||
// Navigate directly to the PDF document created by the admin beforeAll.
|
||||
const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000';
|
||||
await page.goto(`${baseURL}/documents/${sharedAnnotationDocId}`);
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
// Wait for the PDF canvas — once rendered, the controls bar (with disabled button) is shown.
|
||||
await page.locator('canvas').first().waitFor({ state: 'visible', timeout: 30000 });
|
||||
|
||||
const disabledBtn = page.getByRole('button', { name: /annotieren/i });
|
||||
await expect(disabledBtn).toBeVisible({ timeout: 5000 });
|
||||
await expect(disabledBtn).toBeDisabled();
|
||||
|
||||
await page.screenshot({ path: 'test-results/e2e/annotations-button-reader.png' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
{
|
||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||
"error_annotation_not_found": "Die Annotation wurde nicht gefunden.",
|
||||
"error_annotation_overlap": "Die Annotation überschneidet sich mit einer vorhandenen.",
|
||||
"error_document_not_found": "Das Dokument wurde nicht gefunden.",
|
||||
"error_document_no_file": "Diesem Dokument ist noch keine Datei zugeordnet.",
|
||||
"error_file_not_found": "Die Datei konnte im Speicher nicht gefunden werden.",
|
||||
@@ -240,5 +242,20 @@
|
||||
"admin_system_backfill_btn": "Jetzt auffüllen",
|
||||
"admin_system_backfill_success": "{count} Dokumente wurden aufgefüllt.",
|
||||
"comp_expandable_show_more": "Mehr anzeigen",
|
||||
"comp_expandable_show_less": "Weniger anzeigen"
|
||||
"comp_expandable_show_less": "Weniger anzeigen",
|
||||
"error_comment_not_found": "Der Kommentar wurde nicht gefunden.",
|
||||
"comment_section_title": "Diskussion",
|
||||
"comment_placeholder": "Kommentar schreiben…",
|
||||
"comment_btn_post": "Senden",
|
||||
"comment_btn_reply": "Antworten",
|
||||
"comment_edited_label": "· bearbeitet",
|
||||
"comment_panel_title": "Kommentare",
|
||||
"comment_panel_close": "Schließen",
|
||||
"doc_panel_tab_metadata": "Metadaten",
|
||||
"doc_panel_tab_transcription": "Transkription",
|
||||
"doc_panel_tab_discussion": "Diskussion",
|
||||
"doc_panel_tab_history": "Verlauf",
|
||||
"doc_panel_annotate": "Annotieren",
|
||||
"doc_panel_annotate_stop": "Fertig",
|
||||
"doc_panel_annotation_thread_title": "Annotation"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
{
|
||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||
"error_annotation_not_found": "Annotation not found.",
|
||||
"error_annotation_overlap": "The annotation overlaps an existing one.",
|
||||
"error_document_not_found": "Document not found.",
|
||||
"error_document_no_file": "No file is associated with this document.",
|
||||
"error_file_not_found": "The file could not be found in storage.",
|
||||
@@ -240,5 +242,20 @@
|
||||
"admin_system_backfill_btn": "Backfill now",
|
||||
"admin_system_backfill_success": "{count} documents were backfilled.",
|
||||
"comp_expandable_show_more": "Show more",
|
||||
"comp_expandable_show_less": "Show less"
|
||||
"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_btn_post": "Send",
|
||||
"comment_btn_reply": "Reply",
|
||||
"comment_edited_label": "· edited",
|
||||
"comment_panel_title": "Comments",
|
||||
"comment_panel_close": "Close",
|
||||
"doc_panel_tab_metadata": "Metadata",
|
||||
"doc_panel_tab_transcription": "Transcription",
|
||||
"doc_panel_tab_discussion": "Discussion",
|
||||
"doc_panel_tab_history": "History",
|
||||
"doc_panel_annotate": "Annotate",
|
||||
"doc_panel_annotate_stop": "Done",
|
||||
"doc_panel_annotation_thread_title": "Annotation"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
{
|
||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||
"error_annotation_not_found": "Anotación no encontrada.",
|
||||
"error_annotation_overlap": "La anotación se superpone con una existente.",
|
||||
"error_document_not_found": "Documento no encontrado.",
|
||||
"error_document_no_file": "No hay ningún archivo asociado a este documento.",
|
||||
"error_file_not_found": "El archivo no pudo encontrarse en el almacenamiento.",
|
||||
@@ -240,5 +242,20 @@
|
||||
"admin_system_backfill_btn": "Completar ahora",
|
||||
"admin_system_backfill_success": "{count} documentos fueron completados.",
|
||||
"comp_expandable_show_more": "Mostrar más",
|
||||
"comp_expandable_show_less": "Mostrar menos"
|
||||
"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_btn_post": "Enviar",
|
||||
"comment_btn_reply": "Responder",
|
||||
"comment_edited_label": "· editado",
|
||||
"comment_panel_title": "Comentarios",
|
||||
"comment_panel_close": "Cerrar",
|
||||
"doc_panel_tab_metadata": "Metadatos",
|
||||
"doc_panel_tab_transcription": "Transcripción",
|
||||
"doc_panel_tab_discussion": "Discusión",
|
||||
"doc_panel_tab_history": "Historial",
|
||||
"doc_panel_annotate": "Anotar",
|
||||
"doc_panel_annotate_stop": "Listo",
|
||||
"doc_panel_annotation_thread_title": "Anotación"
|
||||
}
|
||||
|
||||
90
frontend/src/lib/components/AnnotationCommentPanel.svelte
Normal file
90
frontend/src/lib/components/AnnotationCommentPanel.svelte
Normal file
@@ -0,0 +1,90 @@
|
||||
<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-brand-sand bg-white shadow-2xl sm:flex"
|
||||
>
|
||||
<div class="flex shrink-0 items-center justify-between border-b border-brand-sand px-4 py-3">
|
||||
<h3 class="font-sans text-xs font-bold tracking-widest text-brand-navy uppercase">
|
||||
{m.comment_panel_title()}
|
||||
</h3>
|
||||
<button
|
||||
onclick={onClose}
|
||||
aria-label={m.comment_panel_close()}
|
||||
class="rounded p-1 text-gray-400 transition-colors hover:bg-brand-sand/50 hover:text-brand-navy"
|
||||
>
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</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-white shadow-2xl">
|
||||
<div class="flex shrink-0 items-center justify-between border-b border-brand-sand px-4 py-3">
|
||||
<h3 class="font-sans text-xs font-bold tracking-widest text-brand-navy uppercase">
|
||||
{m.comment_panel_title()}
|
||||
</h3>
|
||||
<button
|
||||
onclick={onClose}
|
||||
aria-label={m.comment_panel_close()}
|
||||
class="rounded p-1 text-gray-400 transition-colors hover:bg-brand-sand/50 hover:text-brand-navy"
|
||||
>
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</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>
|
||||
218
frontend/src/lib/components/AnnotationLayer.svelte
Normal file
218
frontend/src/lib/components/AnnotationLayer.svelte
Normal file
@@ -0,0 +1,218 @@
|
||||
<script lang="ts">
|
||||
type Annotation = {
|
||||
id: string;
|
||||
documentId: string;
|
||||
pageNumber: number;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
color: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
type DrawRect = {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
let {
|
||||
annotations = [],
|
||||
canAnnotate,
|
||||
color,
|
||||
onDraw,
|
||||
onDelete,
|
||||
commentCounts,
|
||||
onAnnotationClick
|
||||
}: {
|
||||
annotations: Annotation[];
|
||||
canAnnotate: boolean;
|
||||
color: string;
|
||||
onDraw: (rect: { x: number; y: number; width: number; height: number }) => void;
|
||||
onDelete: (id: string) => void;
|
||||
commentCounts?: Record<string, number>;
|
||||
onAnnotationClick?: (id: string) => void;
|
||||
} = $props();
|
||||
|
||||
let drawStart = $state<{ x: number; y: number } | null>(null);
|
||||
let drawRect = $state<DrawRect | null>(null);
|
||||
|
||||
function hexToRgba(hex: string, alpha: number): string {
|
||||
const r = parseInt(hex.slice(1, 3), 16);
|
||||
const g = parseInt(hex.slice(3, 5), 16);
|
||||
const b = parseInt(hex.slice(5, 7), 16);
|
||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||
}
|
||||
|
||||
function getNormalizedCoords(event: PointerEvent, element: HTMLElement): { x: number; y: number } {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return {
|
||||
x: (event.clientX - rect.left) / rect.width,
|
||||
y: (event.clientY - rect.top) / rect.height
|
||||
};
|
||||
}
|
||||
|
||||
function handlePointerDown(event: PointerEvent) {
|
||||
if (!canAnnotate) return;
|
||||
|
||||
if ((event.target as HTMLElement).closest('[data-annotation]')) return;
|
||||
|
||||
const container = event.currentTarget as HTMLElement;
|
||||
container.setPointerCapture(event.pointerId);
|
||||
|
||||
const coords = getNormalizedCoords(event, container);
|
||||
drawStart = coords;
|
||||
drawRect = { x: coords.x, y: coords.y, width: 0, height: 0 };
|
||||
}
|
||||
|
||||
function handlePointerMove(event: PointerEvent) {
|
||||
if (!canAnnotate || !drawStart) return;
|
||||
|
||||
const container = event.currentTarget as HTMLElement;
|
||||
const coords = getNormalizedCoords(event, container);
|
||||
|
||||
const x = Math.min(drawStart.x, coords.x);
|
||||
const y = Math.min(drawStart.y, coords.y);
|
||||
const width = Math.abs(coords.x - drawStart.x);
|
||||
const height = Math.abs(coords.y - drawStart.y);
|
||||
|
||||
drawRect = { x, y, width, height };
|
||||
}
|
||||
|
||||
function handlePointerUp(event: PointerEvent) {
|
||||
if (!canAnnotate || !drawStart || !drawRect) return;
|
||||
|
||||
const container = event.currentTarget as HTMLElement;
|
||||
const coords = getNormalizedCoords(event, container);
|
||||
|
||||
const x = Math.min(drawStart.x, coords.x);
|
||||
const y = Math.min(drawStart.y, coords.y);
|
||||
const width = Math.abs(coords.x - drawStart.x);
|
||||
const height = Math.abs(coords.y - drawStart.y);
|
||||
|
||||
if (width > 0.01 && height > 0.01) {
|
||||
onDraw({ x, y, width, height });
|
||||
}
|
||||
|
||||
drawStart = null;
|
||||
drawRect = null;
|
||||
}
|
||||
|
||||
let hoveredId = $state<string | null>(null);
|
||||
|
||||
const containerStyle = $derived(
|
||||
`position: absolute; top: 0; left: 0; width: 100%; height: 100%;${canAnnotate ? ' cursor: crosshair;' : ''}`
|
||||
);
|
||||
</script>
|
||||
|
||||
<div
|
||||
style={containerStyle}
|
||||
role="presentation"
|
||||
onpointerdown={handlePointerDown}
|
||||
onpointermove={handlePointerMove}
|
||||
onpointerup={handlePointerUp}
|
||||
>
|
||||
{#each annotations as annotation (annotation.id)}
|
||||
<div
|
||||
data-testid="annotation-{annotation.id}"
|
||||
data-annotation
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label="Kommentare anzeigen"
|
||||
onclick={() => onAnnotationClick?.(annotation.id)}
|
||||
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') onAnnotationClick?.(annotation.id); }}
|
||||
onmouseenter={() => (hoveredId = annotation.id)}
|
||||
onmouseleave={() => (hoveredId = null)}
|
||||
style="
|
||||
position: absolute;
|
||||
left: {annotation.x * 100}%;
|
||||
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'};
|
||||
pointer-events: auto;
|
||||
transition: background-color 0.15s ease, box-shadow 0.15s ease;
|
||||
{onAnnotationClick && !canAnnotate ? 'cursor: pointer;' : ''}
|
||||
"
|
||||
>
|
||||
{#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);
|
||||
}}
|
||||
style="
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
right: -8px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background-color: #ef4444;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
pointer-events: auto;
|
||||
">×</button
|
||||
>
|
||||
{/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]}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if drawRect && drawRect.width > 0}
|
||||
<div
|
||||
style="
|
||||
position: absolute;
|
||||
left: {drawRect.x * 100}%;
|
||||
top: {drawRect.y * 100}%;
|
||||
width: {drawRect.width * 100}%;
|
||||
height: {drawRect.height * 100}%;
|
||||
border: 2px dashed {color};
|
||||
opacity: 0.3;
|
||||
pointer-events: none;
|
||||
"
|
||||
></div>
|
||||
{/if}
|
||||
</div>
|
||||
74
frontend/src/lib/components/AnnotationLayer.svelte.spec.ts
Normal file
74
frontend/src/lib/components/AnnotationLayer.svelte.spec.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
|
||||
import AnnotationLayer from './AnnotationLayer.svelte';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
type Annotation = {
|
||||
id: string;
|
||||
documentId: string;
|
||||
pageNumber: number;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
color: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
function makeAnnotation(id = 'ann-1'): Annotation {
|
||||
return {
|
||||
id,
|
||||
documentId: 'doc-1',
|
||||
pageNumber: 1,
|
||||
x: 0.1,
|
||||
y: 0.1,
|
||||
width: 0.3,
|
||||
height: 0.2,
|
||||
color: '#ff0000',
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
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: () => {}
|
||||
});
|
||||
|
||||
await expect.element(page.getByTestId('annotation-ann-1')).toBeInTheDocument();
|
||||
await expect.element(page.getByTestId('annotation-ann-2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a delete button for each annotation when canAnnotate is true', async () => {
|
||||
render(AnnotationLayer, {
|
||||
annotations: [makeAnnotation('ann-1')],
|
||||
canAnnotate: true,
|
||||
color: '#ff0000',
|
||||
onDraw: () => {},
|
||||
onDelete: () => {}
|
||||
});
|
||||
|
||||
await expect
|
||||
.element(page.getByRole('button', { name: /annotation löschen/i }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show delete buttons when canAnnotate is false', async () => {
|
||||
render(AnnotationLayer, {
|
||||
annotations: [makeAnnotation('ann-1')],
|
||||
canAnnotate: false,
|
||||
color: '#ff0000',
|
||||
onDraw: () => {},
|
||||
onDelete: () => {}
|
||||
});
|
||||
|
||||
expect(page.getByRole('button', { name: /annotation löschen/i }).query()).toBeNull();
|
||||
});
|
||||
});
|
||||
394
frontend/src/lib/components/CommentThread.svelte
Normal file
394
frontend/src/lib/components/CommentThread.svelte
Normal file
@@ -0,0 +1,394 @@
|
||||
<script lang="ts">
|
||||
import { onMount, untrack } from 'svelte';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
type CommentReply = {
|
||||
id: string;
|
||||
authorId: string | null;
|
||||
authorName: string;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
type Comment = {
|
||||
id: string;
|
||||
authorId: string | null;
|
||||
authorName: string;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
replies: CommentReply[];
|
||||
};
|
||||
|
||||
type Props = {
|
||||
documentId: string;
|
||||
annotationId?: string | null;
|
||||
initialComments?: Comment[];
|
||||
loadOnMount?: boolean;
|
||||
canComment: boolean;
|
||||
currentUserId: string | null;
|
||||
canAdmin: boolean;
|
||||
onCountChange?: (count: number) => void;
|
||||
};
|
||||
|
||||
let {
|
||||
documentId,
|
||||
annotationId = null,
|
||||
initialComments = [],
|
||||
loadOnMount = false,
|
||||
canComment,
|
||||
currentUserId,
|
||||
canAdmin,
|
||||
onCountChange
|
||||
}: Props = $props();
|
||||
|
||||
let comments: Comment[] = $state(untrack(() => [...initialComments]));
|
||||
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);
|
||||
|
||||
const commentsBase = $derived(
|
||||
annotationId
|
||||
? `/api/documents/${documentId}/annotations/${annotationId}/comments`
|
||||
: `/api/documents/${documentId}/comments`
|
||||
);
|
||||
|
||||
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'}`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `vor ${hours} Stunde${hours === 1 ? '' : 'n'}`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `vor ${days} Tag${days === 1 ? '' : 'en'}`;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
async function reload() {
|
||||
try {
|
||||
const res = await fetch(commentsBase);
|
||||
if (res.ok) {
|
||||
comments = await res.json();
|
||||
const total = comments.reduce((s, c) => s + 1 + c.replies.length, 0);
|
||||
onCountChange?.(total);
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
async function postComment() {
|
||||
const text = newText.trim();
|
||||
if (!text || posting) return;
|
||||
posting = true;
|
||||
try {
|
||||
const res = await fetch(commentsBase, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content: text })
|
||||
});
|
||||
if (res.ok) {
|
||||
newText = '';
|
||||
await reload();
|
||||
}
|
||||
} finally {
|
||||
posting = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function postReply(threadId: string) {
|
||||
const text = replyText.trim();
|
||||
if (!text || posting) return;
|
||||
posting = true;
|
||||
try {
|
||||
const res = await fetch(`${commentsBase}/${threadId}/replies`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content: text })
|
||||
});
|
||||
if (res.ok) {
|
||||
replyText = '';
|
||||
replyingTo = null;
|
||||
await reload();
|
||||
}
|
||||
} finally {
|
||||
posting = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveEdit(commentId: string) {
|
||||
const text = editText.trim();
|
||||
if (!text || posting) return;
|
||||
posting = true;
|
||||
try {
|
||||
const res = await fetch(`/api/documents/${documentId}/comments/${commentId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content: text })
|
||||
});
|
||||
if (res.ok) {
|
||||
editingId = null;
|
||||
await reload();
|
||||
}
|
||||
} finally {
|
||||
posting = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteComment(commentId: string) {
|
||||
if (posting) return;
|
||||
posting = true;
|
||||
try {
|
||||
const res = await fetch(`/api/documents/${documentId}/comments/${commentId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
if (res.ok) {
|
||||
await reload();
|
||||
}
|
||||
} finally {
|
||||
posting = false;
|
||||
}
|
||||
}
|
||||
|
||||
function startEdit(comment: Comment | CommentReply) {
|
||||
editingId = comment.id;
|
||||
editText = comment.content;
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
editingId = null;
|
||||
editText = '';
|
||||
}
|
||||
|
||||
function startReply(threadId: string) {
|
||||
replyingTo = threadId;
|
||||
replyText = '';
|
||||
}
|
||||
|
||||
function cancelReply() {
|
||||
replyingTo = null;
|
||||
replyText = '';
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (loadOnMount) {
|
||||
reload();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="space-y-4">
|
||||
{#each comments as thread, ti (thread.id)}
|
||||
<div class={ti > 0 ? 'border-t border-brand-sand pt-4' : ''}>
|
||||
<!-- Root comment -->
|
||||
<div>
|
||||
{#if editingId === thread.id}
|
||||
<div class="flex flex-col gap-2">
|
||||
<textarea
|
||||
class="w-full resize-none rounded border border-brand-sand px-3 py-2 font-serif text-sm text-brand-navy focus:ring-1 focus:ring-brand-mint focus:outline-none"
|
||||
rows={3}
|
||||
bind:value={editText}
|
||||
></textarea>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
class="rounded bg-brand-navy px-3 py-1.5 font-sans text-xs font-medium text-white hover:bg-brand-navy/80 disabled:opacity-40"
|
||||
disabled={posting}
|
||||
onclick={() => saveEdit(thread.id)}
|
||||
>
|
||||
{m.btn_save()}
|
||||
</button>
|
||||
<button
|
||||
class="font-sans text-xs text-gray-400 transition-colors hover:text-brand-navy"
|
||||
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-brand-navy"
|
||||
>{thread.authorName}</span
|
||||
>
|
||||
<span class="font-sans text-xs text-gray-400">{timeAgo(thread.createdAt)}</span>
|
||||
{#if wasEdited(thread)}
|
||||
<span class="font-sans text-xs text-gray-400">
|
||||
{m.comment_edited_label()}
|
||||
{timeAgo(thread.updatedAt)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="mt-1 font-serif text-sm leading-relaxed text-gray-700">{thread.content}</p>
|
||||
</div>
|
||||
{#if canModify(thread)}
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<button
|
||||
class="font-sans text-xs text-gray-400 transition-colors hover:text-brand-navy"
|
||||
onclick={() => startEdit(thread)}
|
||||
>
|
||||
{m.btn_edit()}
|
||||
</button>
|
||||
<button
|
||||
class="font-sans text-xs text-gray-400 transition-colors hover:text-brand-navy"
|
||||
onclick={() => deleteComment(thread.id)}
|
||||
>
|
||||
{m.btn_delete()}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- Reply button on root comment only if there are no replies -->
|
||||
{#if thread.replies.length === 0 && canComment}
|
||||
<div class="mt-1">
|
||||
<button
|
||||
class="font-sans text-xs font-medium text-brand-mint transition-colors hover:text-brand-navy"
|
||||
onclick={() => startReply(thread.id)}
|
||||
>
|
||||
{m.comment_btn_reply()}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Replies -->
|
||||
{#each thread.replies as reply, ri (reply.id)}
|
||||
<div class="mt-3 ml-6 border-l-2 border-brand-sand pl-4">
|
||||
{#if editingId === reply.id}
|
||||
<div class="flex flex-col gap-2">
|
||||
<textarea
|
||||
class="w-full resize-none rounded border border-brand-sand px-3 py-2 font-serif text-sm text-brand-navy focus:ring-1 focus:ring-brand-mint focus:outline-none"
|
||||
rows={3}
|
||||
bind:value={editText}
|
||||
></textarea>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
class="rounded bg-brand-navy px-3 py-1.5 font-sans text-xs font-medium text-white hover:bg-brand-navy/80 disabled:opacity-40"
|
||||
disabled={posting}
|
||||
onclick={() => saveEdit(reply.id)}
|
||||
>
|
||||
{m.btn_save()}
|
||||
</button>
|
||||
<button
|
||||
class="font-sans text-xs text-gray-400 transition-colors hover:text-brand-navy"
|
||||
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-brand-navy"
|
||||
>{reply.authorName}</span
|
||||
>
|
||||
<span class="font-sans text-xs text-gray-400">{timeAgo(reply.createdAt)}</span>
|
||||
{#if wasEdited(reply)}
|
||||
<span class="font-sans text-xs text-gray-400">
|
||||
{m.comment_edited_label()}
|
||||
{timeAgo(reply.updatedAt)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="mt-1 font-serif text-sm leading-relaxed text-gray-700">{reply.content}</p>
|
||||
</div>
|
||||
{#if canModify(reply)}
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<button
|
||||
class="font-sans text-xs text-gray-400 transition-colors hover:text-brand-navy"
|
||||
onclick={() => startEdit(reply)}
|
||||
>
|
||||
{m.btn_edit()}
|
||||
</button>
|
||||
<button
|
||||
class="font-sans text-xs text-gray-400 transition-colors hover:text-brand-navy"
|
||||
onclick={() => deleteComment(reply.id)}
|
||||
>
|
||||
{m.btn_delete()}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- Reply button only on the last reply -->
|
||||
{#if ri === thread.replies.length - 1 && canComment}
|
||||
<div class="mt-1">
|
||||
<button
|
||||
class="font-sans text-xs font-medium text-brand-mint transition-colors hover:text-brand-navy"
|
||||
onclick={() => startReply(thread.id)}
|
||||
>
|
||||
{m.comment_btn_reply()}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<!-- Reply textarea (shown when replyingTo === thread.id) -->
|
||||
{#if replyingTo === thread.id}
|
||||
<div class="mt-3 ml-6 flex flex-col gap-2">
|
||||
<textarea
|
||||
class="w-full resize-none rounded border border-brand-sand px-3 py-2 font-serif text-sm text-brand-navy focus:ring-1 focus:ring-brand-mint focus:outline-none"
|
||||
rows={3}
|
||||
placeholder={m.comment_placeholder()}
|
||||
bind:value={replyText}
|
||||
></textarea>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
class="rounded bg-brand-navy px-3 py-1.5 font-sans text-xs font-medium text-white hover:bg-brand-navy/80 disabled:opacity-40"
|
||||
disabled={posting}
|
||||
onclick={() => postReply(thread.id)}
|
||||
>
|
||||
{m.comment_btn_post()}
|
||||
</button>
|
||||
<button
|
||||
class="font-sans text-xs text-gray-400 transition-colors hover:text-brand-navy"
|
||||
onclick={cancelReply}
|
||||
>
|
||||
{m.btn_cancel()}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<!-- New top-level comment textarea -->
|
||||
{#if canComment}
|
||||
<div class={comments.length > 0 ? 'border-t border-brand-sand pt-4' : ''}>
|
||||
<div class="flex flex-col gap-2">
|
||||
<textarea
|
||||
class="w-full resize-none rounded border border-brand-sand px-3 py-2 font-serif text-sm text-brand-navy focus:ring-1 focus:ring-brand-mint focus:outline-none"
|
||||
rows={3}
|
||||
placeholder={m.comment_placeholder()}
|
||||
bind:value={newText}
|
||||
></textarea>
|
||||
<div>
|
||||
<button
|
||||
class="rounded bg-brand-navy px-3 py-1.5 font-sans text-xs font-medium text-white hover:bg-brand-navy/80 disabled:opacity-40"
|
||||
disabled={posting || !newText.trim()}
|
||||
onclick={postComment}
|
||||
>
|
||||
{m.comment_btn_post()}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
196
frontend/src/lib/components/DocumentBottomPanel.svelte
Normal file
196
frontend/src/lib/components/DocumentBottomPanel.svelte
Normal file
@@ -0,0 +1,196 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import PanelMetadata from './PanelMetadata.svelte';
|
||||
import PanelTranscription from './PanelTranscription.svelte';
|
||||
import PanelDiscussion from './PanelDiscussion.svelte';
|
||||
import PanelHistory from './PanelHistory.svelte';
|
||||
|
||||
type Tab = 'metadata' | 'transcription' | 'discussion' | 'history';
|
||||
|
||||
type CommentReply = {
|
||||
id: string;
|
||||
authorId: string | null;
|
||||
authorName: string;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
type Comment = {
|
||||
id: string;
|
||||
authorId: string | null;
|
||||
authorName: string;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
replies: CommentReply[];
|
||||
};
|
||||
|
||||
type Doc = {
|
||||
id: string;
|
||||
title?: string | null;
|
||||
documentDate?: string | null;
|
||||
location?: string | null;
|
||||
documentLocation?: string | null;
|
||||
tags?: { id: string; name: string }[] | null;
|
||||
sender?: { id: string; firstName: string; lastName: string; alias?: string | null } | null;
|
||||
receivers?: { id: string; firstName: string; lastName: string }[] | null;
|
||||
summary?: string | null;
|
||||
transcription?: string | null;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
doc: Doc;
|
||||
comments: Comment[];
|
||||
canComment: boolean;
|
||||
currentUserId: string | null;
|
||||
canAdmin: boolean;
|
||||
open: boolean;
|
||||
height: number;
|
||||
activeTab: Tab;
|
||||
activeAnnotationId: string | null;
|
||||
onAnnotationCommentCountChange?: (annotationId: string, count: number) => void;
|
||||
};
|
||||
|
||||
let {
|
||||
doc,
|
||||
comments,
|
||||
canComment,
|
||||
currentUserId,
|
||||
canAdmin,
|
||||
open = $bindable(),
|
||||
height = $bindable(),
|
||||
activeTab = $bindable(),
|
||||
activeAnnotationId,
|
||||
onAnnotationCommentCountChange
|
||||
}: Props = $props();
|
||||
|
||||
const MIN_HEIGHT = 52; // drag handle (8px) + tab bar (~44px)
|
||||
const DEFAULT_HEIGHT = 320;
|
||||
|
||||
let isDragging = $state(false);
|
||||
let dragStartY = 0;
|
||||
let dragStartHeight = 0;
|
||||
|
||||
function openTab(tab: Tab) {
|
||||
activeTab = tab;
|
||||
if (!open) {
|
||||
open = true;
|
||||
if (height <= MIN_HEIGHT) height = DEFAULT_HEIGHT;
|
||||
}
|
||||
}
|
||||
|
||||
function closePanel() {
|
||||
open = false;
|
||||
}
|
||||
|
||||
function onDragStart(e: PointerEvent) {
|
||||
isDragging = true;
|
||||
dragStartY = e.clientY;
|
||||
dragStartHeight = open ? height : MIN_HEIGHT;
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
}
|
||||
|
||||
function onDragMove(e: PointerEvent) {
|
||||
if (!isDragging) return;
|
||||
const delta = dragStartY - e.clientY; // positive = dragging up = bigger panel
|
||||
const newHeight = dragStartHeight + delta;
|
||||
const maxHeight = Math.floor(window.innerHeight * 0.8);
|
||||
|
||||
if (newHeight <= MIN_HEIGHT + 20) {
|
||||
// collapsed past threshold → close
|
||||
open = false;
|
||||
} else {
|
||||
open = true;
|
||||
height = Math.max(DEFAULT_HEIGHT / 4, Math.min(newHeight, maxHeight));
|
||||
}
|
||||
}
|
||||
|
||||
function onDragEnd() {
|
||||
isDragging = false;
|
||||
}
|
||||
|
||||
const tabs: { id: Tab; label: () => string }[] = [
|
||||
{ id: 'metadata', label: m.doc_panel_tab_metadata },
|
||||
{ id: 'transcription', label: m.doc_panel_tab_transcription },
|
||||
{ id: 'discussion', label: m.doc_panel_tab_discussion },
|
||||
{ id: 'history', label: m.doc_panel_tab_history }
|
||||
];
|
||||
|
||||
const panelHeight = $derived(open ? height : MIN_HEIGHT);
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="fixed right-0 bottom-0 left-0 z-30 flex flex-col border-t border-brand-sand bg-white shadow-[0_-4px_16px_rgba(0,0,0,0.08)]"
|
||||
style="height: {panelHeight}px"
|
||||
data-testid="bottom-panel"
|
||||
>
|
||||
<!-- Drag handle -->
|
||||
<div
|
||||
class="flex h-2 shrink-0 cursor-ns-resize items-center justify-center bg-white"
|
||||
style="touch-action: none"
|
||||
role="separator"
|
||||
aria-orientation="horizontal"
|
||||
aria-label="Panel resize"
|
||||
onpointerdown={onDragStart}
|
||||
onpointermove={onDragMove}
|
||||
onpointerup={onDragEnd}
|
||||
onpointercancel={onDragEnd}
|
||||
>
|
||||
<div class="h-1 w-12 rounded-full bg-gray-200"></div>
|
||||
</div>
|
||||
|
||||
<!-- Tab bar -->
|
||||
<div class="flex shrink-0 items-center border-b border-brand-sand bg-white px-4">
|
||||
{#each tabs as tab (tab.id)}
|
||||
<button
|
||||
onclick={() => openTab(tab.id)}
|
||||
class="mr-1 px-3 py-2.5 font-sans text-xs font-medium transition-colors {activeTab === tab.id && open
|
||||
? 'border-b-2 border-brand-navy text-brand-navy'
|
||||
: 'text-gray-400 hover:text-brand-navy'}"
|
||||
aria-pressed={activeTab === tab.id && open}
|
||||
>
|
||||
{tab.label()}
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
<!-- spacer -->
|
||||
<div class="flex-1"></div>
|
||||
|
||||
{#if open}
|
||||
<button
|
||||
onclick={closePanel}
|
||||
data-testid="panel-close-btn"
|
||||
aria-label="Panel schließen"
|
||||
class="rounded p-1.5 text-gray-400 transition-colors hover:bg-brand-sand/50 hover:text-brand-navy"
|
||||
>
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Tab content -->
|
||||
{#if open}
|
||||
<div class="flex-1 overflow-y-auto" data-testid="bottom-panel-content">
|
||||
{#if activeTab === 'metadata'}
|
||||
<PanelMetadata doc={doc} />
|
||||
{:else if activeTab === 'transcription'}
|
||||
<PanelTranscription doc={doc} />
|
||||
{:else if activeTab === 'discussion'}
|
||||
<PanelDiscussion
|
||||
documentId={doc.id}
|
||||
activeAnnotationId={activeAnnotationId}
|
||||
initialComments={comments}
|
||||
canComment={canComment}
|
||||
currentUserId={currentUserId}
|
||||
canAdmin={canAdmin}
|
||||
onAnnotationCommentCountChange={onAnnotationCommentCountChange}
|
||||
/>
|
||||
{:else if activeTab === 'history'}
|
||||
<PanelHistory documentId={doc.id} />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
142
frontend/src/lib/components/DocumentTopBar.svelte
Normal file
142
frontend/src/lib/components/DocumentTopBar.svelte
Normal file
@@ -0,0 +1,142 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
type Person = { id: string; firstName: string; lastName: string };
|
||||
|
||||
type Doc = {
|
||||
id: string;
|
||||
title?: string | null;
|
||||
originalFilename?: string | null;
|
||||
documentDate?: string | null;
|
||||
sender?: Person | null;
|
||||
receivers?: Person[] | null;
|
||||
filePath?: string | null;
|
||||
contentType?: string | null;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
doc: Doc;
|
||||
canWrite: boolean;
|
||||
canAnnotate: boolean;
|
||||
fileUrl: string;
|
||||
annotateMode: boolean;
|
||||
};
|
||||
|
||||
let { doc, canWrite, canAnnotate, fileUrl, annotateMode = $bindable() }: Props = $props();
|
||||
|
||||
const isPdf = $derived(!!doc.filePath && doc.contentType?.startsWith('application/pdf'));
|
||||
|
||||
const receiverDisplay = $derived.by(() => {
|
||||
const receivers = doc.receivers ?? [];
|
||||
if (receivers.length === 0) return null;
|
||||
const shown = receivers.slice(0, 2);
|
||||
const extra = receivers.length - shown.length;
|
||||
const names = shown.map((r) => `${r.firstName} ${r.lastName}`).join(', ');
|
||||
return extra > 0 ? `${names} +${extra}` : names;
|
||||
});
|
||||
|
||||
const compactMeta = $derived.by(() => {
|
||||
const parts: string[] = [];
|
||||
if (doc.documentDate) {
|
||||
parts.push(
|
||||
new Intl.DateTimeFormat('de-DE', {
|
||||
day: 'numeric',
|
||||
month: 'numeric',
|
||||
year: 'numeric'
|
||||
}).format(new Date(doc.documentDate + 'T12:00:00'))
|
||||
);
|
||||
}
|
||||
if (doc.sender) {
|
||||
const senderName = `${doc.sender.firstName} ${doc.sender.lastName}`;
|
||||
const receiver = receiverDisplay;
|
||||
parts.push(receiver ? `${senderName} → ${receiver}` : senderName);
|
||||
} else if (receiverDisplay) {
|
||||
parts.push(`→ ${receiverDisplay}`);
|
||||
}
|
||||
return parts.join(' · ');
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="z-20 flex shrink-0 items-center justify-between border-b border-brand-sand bg-white px-6 py-3 shadow-sm"
|
||||
>
|
||||
<!-- Left: back + title -->
|
||||
<div class="flex min-w-0 items-center gap-4 overflow-hidden">
|
||||
<a
|
||||
href="/"
|
||||
class="group flex shrink-0 items-center gap-2 font-sans text-sm font-medium text-gray-500 transition-colors hover:text-brand-navy"
|
||||
>
|
||||
<div
|
||||
class="flex h-8 w-8 items-center justify-center rounded-full bg-brand-sand transition-colors group-hover:bg-brand-mint"
|
||||
>
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Arrow/Arrow-Left-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</div>
|
||||
<span class="hidden sm:inline">{m.btn_back()}</span>
|
||||
</a>
|
||||
|
||||
<div class="min-w-0 border-l border-gray-200 pl-4">
|
||||
<h1
|
||||
class="truncate font-serif text-base leading-tight text-brand-navy"
|
||||
title={doc.title ?? doc.originalFilename ?? ''}
|
||||
>
|
||||
{doc.title || doc.originalFilename}
|
||||
</h1>
|
||||
{#if compactMeta}
|
||||
<p class="truncate font-sans text-xs text-gray-500" title={compactMeta}>
|
||||
{compactMeta}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: actions -->
|
||||
<div class="ml-4 flex shrink-0 items-center gap-2 font-sans">
|
||||
{#if canAnnotate && isPdf}
|
||||
<button
|
||||
onclick={() => (annotateMode = !annotateMode)}
|
||||
aria-label={annotateMode ? m.doc_panel_annotate_stop() : m.doc_panel_annotate()}
|
||||
class="rounded px-3 py-1.5 font-sans text-xs font-medium transition {annotateMode
|
||||
? 'bg-brand-navy text-white'
|
||||
: 'border border-brand-navy text-brand-navy hover:bg-brand-navy hover:text-white'}"
|
||||
>
|
||||
{annotateMode ? m.doc_panel_annotate_stop() : m.doc_panel_annotate()}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if canWrite}
|
||||
<a
|
||||
href="/documents/{doc.id}/edit"
|
||||
class="flex items-center gap-2 rounded border border-brand-navy bg-transparent px-3 py-1.5 text-xs font-medium text-brand-navy transition hover:bg-brand-navy hover:text-white"
|
||||
>
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Edit-Content-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
{m.btn_edit()}
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
{#if doc.filePath}
|
||||
<a
|
||||
href={fileUrl}
|
||||
download={doc.originalFilename}
|
||||
class="rounded border border-transparent bg-brand-sand/50 p-1.5 text-brand-navy transition hover:bg-brand-mint"
|
||||
title={m.doc_download_title()}
|
||||
>
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Download-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
93
frontend/src/lib/components/DocumentViewer.svelte
Normal file
93
frontend/src/lib/components/DocumentViewer.svelte
Normal file
@@ -0,0 +1,93 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import PdfViewer from './PdfViewer.svelte';
|
||||
|
||||
type Doc = {
|
||||
id: string;
|
||||
filePath?: string | null;
|
||||
contentType?: string | null;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
doc: Doc;
|
||||
fileUrl: string;
|
||||
isLoading: boolean;
|
||||
error: string;
|
||||
annotateMode: boolean;
|
||||
activeAnnotationId: string | null;
|
||||
onAnnotationClick: (id: string) => void;
|
||||
};
|
||||
|
||||
let {
|
||||
doc,
|
||||
fileUrl,
|
||||
isLoading,
|
||||
error,
|
||||
annotateMode = $bindable(),
|
||||
activeAnnotationId = $bindable(),
|
||||
onAnnotationClick
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="absolute inset-0 bg-[#2A2A2A]">
|
||||
{#if isLoading}
|
||||
<div class="flex h-full flex-col items-center justify-center text-brand-mint">
|
||||
<svg
|
||||
class="mb-4 h-8 w-8 animate-spin"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
<span class="font-sans text-sm tracking-wide">{m.doc_loading()}</span>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="flex h-full flex-col items-center justify-center px-4 text-center text-gray-400">
|
||||
<p class="mb-2 font-serif">{error}</p>
|
||||
{#if doc.filePath}
|
||||
<a
|
||||
href="/api/documents/{doc.id}/file"
|
||||
target="_blank"
|
||||
class="text-sm underline hover:text-white"
|
||||
>
|
||||
{m.doc_download_link()}
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if !doc.filePath}
|
||||
<div class="flex h-full flex-col items-center justify-center text-gray-400">
|
||||
<div class="mb-6 rounded-full bg-white/5 p-8">
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/PDF-Document-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-12 w-12 opacity-50 invert"
|
||||
/>
|
||||
</div>
|
||||
<p class="font-sans text-sm tracking-wide uppercase">{m.doc_no_scan()}</p>
|
||||
</div>
|
||||
{:else if fileUrl && doc.contentType?.startsWith('application/pdf')}
|
||||
<PdfViewer
|
||||
url={fileUrl}
|
||||
documentId={doc.id}
|
||||
bind:annotateMode={annotateMode}
|
||||
bind:activeAnnotationId={activeAnnotationId}
|
||||
onAnnotationClick={onAnnotationClick}
|
||||
/>
|
||||
{:else if fileUrl}
|
||||
<div class="flex h-full w-full items-center justify-center overflow-auto p-8">
|
||||
<img
|
||||
src={fileUrl}
|
||||
alt={m.doc_image_alt()}
|
||||
class="max-h-full max-w-full object-contain shadow-2xl"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
85
frontend/src/lib/components/PanelDiscussion.svelte
Normal file
85
frontend/src/lib/components/PanelDiscussion.svelte
Normal file
@@ -0,0 +1,85 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import CommentThread from './CommentThread.svelte';
|
||||
|
||||
type CommentReply = {
|
||||
id: string;
|
||||
authorId: string | null;
|
||||
authorName: string;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
type Comment = {
|
||||
id: string;
|
||||
authorId: string | null;
|
||||
authorName: string;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
replies: CommentReply[];
|
||||
};
|
||||
|
||||
type Props = {
|
||||
documentId: string;
|
||||
activeAnnotationId: string | null;
|
||||
initialComments: Comment[];
|
||||
canComment: boolean;
|
||||
currentUserId: string | null;
|
||||
canAdmin: boolean;
|
||||
onAnnotationCommentCountChange?: (annotationId: string, count: number) => void;
|
||||
};
|
||||
|
||||
let {
|
||||
documentId,
|
||||
activeAnnotationId,
|
||||
initialComments,
|
||||
canComment,
|
||||
currentUserId,
|
||||
canAdmin,
|
||||
onAnnotationCommentCountChange
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="space-y-8 p-6">
|
||||
<!-- Annotation thread (shown when an annotation is active) -->
|
||||
{#if activeAnnotationId}
|
||||
<div>
|
||||
<h4
|
||||
class="mb-3 border-b border-brand-sand pb-2 font-sans text-xs font-bold tracking-widest text-brand-navy uppercase"
|
||||
>
|
||||
{m.doc_panel_annotation_thread_title()}
|
||||
</h4>
|
||||
{#key activeAnnotationId}
|
||||
<CommentThread
|
||||
documentId={documentId}
|
||||
annotationId={activeAnnotationId}
|
||||
canComment={canComment}
|
||||
currentUserId={currentUserId}
|
||||
canAdmin={canAdmin}
|
||||
loadOnMount={true}
|
||||
onCountChange={(count) => onAnnotationCommentCountChange?.(activeAnnotationId, count)}
|
||||
/>
|
||||
{/key}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- General document discussion -->
|
||||
<div>
|
||||
{#if activeAnnotationId}
|
||||
<h4
|
||||
class="mb-3 border-b border-brand-sand pb-2 font-sans text-xs font-bold tracking-widest text-brand-navy uppercase"
|
||||
>
|
||||
{m.comment_section_title()}
|
||||
</h4>
|
||||
{/if}
|
||||
<CommentThread
|
||||
documentId={documentId}
|
||||
initialComments={initialComments}
|
||||
canComment={canComment}
|
||||
currentUserId={currentUserId}
|
||||
canAdmin={canAdmin}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
446
frontend/src/lib/components/PanelHistory.svelte
Normal file
446
frontend/src/lib/components/PanelHistory.svelte
Normal file
@@ -0,0 +1,446 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { diffWords } from 'diff';
|
||||
|
||||
let { documentId }: { documentId: string } = $props();
|
||||
|
||||
type VersionSummary = {
|
||||
id: string;
|
||||
savedAt: string;
|
||||
editorName: string;
|
||||
changedFields: string[];
|
||||
};
|
||||
|
||||
type SnapshotDoc = {
|
||||
title?: string;
|
||||
documentDate?: string;
|
||||
location?: string;
|
||||
documentLocation?: string;
|
||||
transcription?: string;
|
||||
summary?: string;
|
||||
sender?: { id: string; firstName: string; lastName: string } | null;
|
||||
receivers?: { id: string; firstName: string; lastName: string }[];
|
||||
tags?: { id: string; name: string }[];
|
||||
};
|
||||
|
||||
type DiffEntry =
|
||||
| {
|
||||
kind: 'text';
|
||||
field: string;
|
||||
label: string;
|
||||
parts: { value: string; added?: boolean; removed?: boolean }[];
|
||||
}
|
||||
| { kind: 'scalar'; field: string; label: string; oldVal: string; newVal: string }
|
||||
| { kind: 'relation'; field: string; label: string; removed: string[]; added: string[] };
|
||||
|
||||
let historyLoaded = $state(false);
|
||||
let historyLoading = $state(false);
|
||||
let versions = $state<VersionSummary[]>([]);
|
||||
|
||||
let compareMode = $state(false);
|
||||
let compareA = $state('');
|
||||
let compareB = $state('');
|
||||
|
||||
let selectedVersionId = $state<string | null>(null);
|
||||
let diffEntries = $state<DiffEntry[]>([]);
|
||||
let diffLoading = $state(false);
|
||||
let noDiff = $state(false);
|
||||
|
||||
const fieldLabels: Record<string, () => string> = {
|
||||
title: m.history_field_title,
|
||||
documentDate: m.history_field_document_date,
|
||||
location: m.history_field_location,
|
||||
documentLocation: m.history_field_document_location,
|
||||
transcription: m.history_field_transcription,
|
||||
summary: m.history_field_summary,
|
||||
sender: m.history_field_sender,
|
||||
receivers: m.history_field_receivers,
|
||||
tags: m.history_field_tags
|
||||
};
|
||||
|
||||
const TEXT_FIELDS = ['title', 'summary', 'transcription'] as const;
|
||||
const SCALAR_FIELDS = ['documentDate', 'location', 'documentLocation'] as const;
|
||||
|
||||
function parseSnapshot(raw: string): SnapshotDoc {
|
||||
try {
|
||||
return JSON.parse(raw) as SnapshotDoc;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function personLabel(p: { firstName: string; lastName: string }): string {
|
||||
return `${p.firstName} ${p.lastName}`.trim();
|
||||
}
|
||||
|
||||
const DIFF_CONTEXT_WORDS = 4;
|
||||
|
||||
type DiffPart = { value: string; added?: boolean; removed?: boolean };
|
||||
|
||||
function trimContextParts(parts: DiffPart[]): DiffPart[] {
|
||||
return parts.flatMap((part, i) => {
|
||||
if (part.added || part.removed) return [part];
|
||||
const tokens = part.value.split(/(\s+)/).filter(Boolean);
|
||||
const wordCount = tokens.filter((t) => /\S/.test(t)).length;
|
||||
if (wordCount <= DIFF_CONTEXT_WORDS * 2) return [part];
|
||||
|
||||
function keepFirst(n: number): string {
|
||||
let count = 0;
|
||||
const out: string[] = [];
|
||||
for (const t of tokens) {
|
||||
out.push(t);
|
||||
if (/\S/.test(t) && ++count >= n) break;
|
||||
}
|
||||
return out.join('');
|
||||
}
|
||||
function keepLast(n: number): string {
|
||||
let count = 0;
|
||||
const out: string[] = [];
|
||||
for (const t of [...tokens].reverse()) {
|
||||
out.unshift(t);
|
||||
if (/\S/.test(t) && ++count >= n) break;
|
||||
}
|
||||
return out.join('');
|
||||
}
|
||||
|
||||
const isFirst = i === 0;
|
||||
const isLast = i === parts.length - 1;
|
||||
if (isFirst) return [{ value: '… ' + keepLast(DIFF_CONTEXT_WORDS) }];
|
||||
if (isLast) return [{ value: keepFirst(DIFF_CONTEXT_WORDS) + ' …' }];
|
||||
return [{ value: keepFirst(DIFF_CONTEXT_WORDS) + ' … ' + keepLast(DIFF_CONTEXT_WORDS) }];
|
||||
});
|
||||
}
|
||||
|
||||
function buildDiff(older: SnapshotDoc | null, newer: SnapshotDoc): DiffEntry[] {
|
||||
const entries: DiffEntry[] = [];
|
||||
|
||||
for (const field of TEXT_FIELDS) {
|
||||
const a = older?.[field] ?? '';
|
||||
const b = newer[field] ?? '';
|
||||
if (a === b) continue;
|
||||
const parts = trimContextParts(diffWords(a, b));
|
||||
entries.push({ kind: 'text', field, label: fieldLabels[field](), parts });
|
||||
}
|
||||
|
||||
for (const field of SCALAR_FIELDS) {
|
||||
const a = older?.[field] ?? '';
|
||||
const b = newer[field] ?? '';
|
||||
if (a === b) continue;
|
||||
entries.push({ kind: 'scalar', field, label: fieldLabels[field](), oldVal: a, newVal: b });
|
||||
}
|
||||
|
||||
const senderA = older?.sender ? personLabel(older.sender) : '';
|
||||
const senderB = newer.sender ? personLabel(newer.sender) : '';
|
||||
if (senderA !== senderB) {
|
||||
entries.push({
|
||||
kind: 'relation',
|
||||
field: 'sender',
|
||||
label: fieldLabels['sender'](),
|
||||
removed: senderA ? [senderA] : [],
|
||||
added: senderB ? [senderB] : []
|
||||
});
|
||||
}
|
||||
|
||||
const receiversA = new Set((older?.receivers ?? []).map(personLabel));
|
||||
const receiversB = new Set((newer.receivers ?? []).map(personLabel));
|
||||
const removedReceivers = [...receiversA].filter((r) => !receiversB.has(r));
|
||||
const addedReceivers = [...receiversB].filter((r) => !receiversA.has(r));
|
||||
if (removedReceivers.length > 0 || addedReceivers.length > 0) {
|
||||
entries.push({
|
||||
kind: 'relation',
|
||||
field: 'receivers',
|
||||
label: fieldLabels['receivers'](),
|
||||
removed: removedReceivers,
|
||||
added: addedReceivers
|
||||
});
|
||||
}
|
||||
|
||||
const tagsA = new Set((older?.tags ?? []).map((t) => t.name));
|
||||
const tagsB = new Set((newer.tags ?? []).map((t) => t.name));
|
||||
const removedTags = [...tagsA].filter((t) => !tagsB.has(t));
|
||||
const addedTags = [...tagsB].filter((t) => !tagsA.has(t));
|
||||
if (removedTags.length > 0 || addedTags.length > 0) {
|
||||
entries.push({
|
||||
kind: 'relation',
|
||||
field: 'tags',
|
||||
label: fieldLabels['tags'](),
|
||||
removed: removedTags,
|
||||
added: addedTags
|
||||
});
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
async function fetchSnapshot(versionId: string): Promise<SnapshotDoc> {
|
||||
const res = await fetch(`/api/documents/${documentId}/versions/${versionId}`);
|
||||
if (!res.ok) throw new Error('Failed to fetch version');
|
||||
const v = await res.json();
|
||||
return parseSnapshot(v.snapshot);
|
||||
}
|
||||
|
||||
async function loadHistory() {
|
||||
if (historyLoaded) return;
|
||||
historyLoading = true;
|
||||
try {
|
||||
const res = await fetch(`/api/documents/${documentId}/versions`);
|
||||
if (res.ok) {
|
||||
versions = await res.json();
|
||||
}
|
||||
historyLoaded = true;
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
historyLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function selectVersion(versionId: string) {
|
||||
if (selectedVersionId === versionId) {
|
||||
selectedVersionId = null;
|
||||
diffEntries = [];
|
||||
noDiff = false;
|
||||
return;
|
||||
}
|
||||
selectedVersionId = versionId;
|
||||
diffEntries = [];
|
||||
noDiff = false;
|
||||
diffLoading = true;
|
||||
try {
|
||||
const idx = versions.findIndex((v) => v.id === versionId);
|
||||
const newerSnap = await fetchSnapshot(versionId);
|
||||
const olderSnap = idx > 0 ? await fetchSnapshot(versions[idx - 1].id) : null;
|
||||
const entries = buildDiff(olderSnap, newerSnap);
|
||||
if (entries.length === 0) {
|
||||
noDiff = true;
|
||||
} else {
|
||||
diffEntries = entries;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
diffLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function applyCompare() {
|
||||
if (!compareA || !compareB || compareA === compareB) return;
|
||||
selectedVersionId = null;
|
||||
diffEntries = [];
|
||||
noDiff = false;
|
||||
diffLoading = true;
|
||||
try {
|
||||
const [snapA, snapB] = await Promise.all([fetchSnapshot(compareA), fetchSnapshot(compareB)]);
|
||||
const entries = buildDiff(snapA, snapB);
|
||||
if (entries.length === 0) {
|
||||
noDiff = true;
|
||||
} else {
|
||||
diffEntries = entries;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
diffLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDateTime(iso: string): string {
|
||||
try {
|
||||
return new Intl.DateTimeFormat('de-DE', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}).format(new Date(iso));
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
function versionLabel(v: VersionSummary, index: number): string {
|
||||
return `Version ${index + 1} — ${v.editorName} — ${formatDateTime(v.savedAt)}`;
|
||||
}
|
||||
|
||||
// Load history when this panel mounts.
|
||||
$effect(() => {
|
||||
loadHistory();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="space-y-4 p-6">
|
||||
{#if historyLoading}
|
||||
<p class="font-sans text-xs text-gray-400">{m.history_loading()}</p>
|
||||
{:else if !historyLoaded}
|
||||
<!-- initial state before effect runs — show nothing -->
|
||||
{:else if versions.length === 0}
|
||||
<p class="font-serif text-sm text-gray-400 italic">{m.history_empty()}</p>
|
||||
{:else}
|
||||
<!-- Compare mode toggle -->
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
onclick={() => {
|
||||
compareMode = !compareMode;
|
||||
diffEntries = [];
|
||||
noDiff = false;
|
||||
selectedVersionId = null;
|
||||
}}
|
||||
class="font-sans text-xs font-medium transition {compareMode
|
||||
? 'text-brand-navy underline'
|
||||
: 'text-gray-400 hover:text-brand-navy'}"
|
||||
>
|
||||
{m.history_compare_mode()}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if compareMode}
|
||||
<div class="space-y-2">
|
||||
<div>
|
||||
<label for="compare-a" class="mb-1 block font-sans text-[10px] text-gray-400 uppercase"
|
||||
>{m.history_compare_select_a()}</label
|
||||
>
|
||||
<select
|
||||
id="compare-a"
|
||||
bind:value={compareA}
|
||||
class="w-full rounded border border-brand-sand bg-white px-2 py-1 font-sans text-xs text-brand-navy focus:ring-1 focus:ring-brand-mint focus:outline-none"
|
||||
>
|
||||
<option value="">—</option>
|
||||
{#each versions as v, i (v.id)}
|
||||
<option value={v.id}>{versionLabel(v, i)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="compare-b" class="mb-1 block font-sans text-[10px] text-gray-400 uppercase"
|
||||
>{m.history_compare_select_b()}</label
|
||||
>
|
||||
<select
|
||||
id="compare-b"
|
||||
bind:value={compareB}
|
||||
class="w-full rounded border border-brand-sand bg-white px-2 py-1 font-sans text-xs text-brand-navy focus:ring-1 focus:ring-brand-mint focus:outline-none"
|
||||
>
|
||||
<option value="">—</option>
|
||||
{#each versions as v, i (v.id)}
|
||||
<option value={v.id}>{versionLabel(v, i)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
onclick={applyCompare}
|
||||
disabled={!compareA || !compareB || compareA === compareB}
|
||||
class="w-full rounded bg-brand-navy px-3 py-1.5 font-sans text-xs font-medium text-white transition hover:bg-brand-navy/80 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{m.history_compare_apply()}
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Version list -->
|
||||
<ul class="divide-y divide-brand-sand">
|
||||
{#each versions as v, i (v.id)}
|
||||
<li>
|
||||
<button
|
||||
onclick={() => selectVersion(v.id)}
|
||||
data-testid="history-version"
|
||||
class="w-full py-2 text-left transition hover:bg-brand-sand/30 {selectedVersionId ===
|
||||
v.id
|
||||
? 'border-l-2 border-brand-mint pl-2'
|
||||
: 'pl-0'}"
|
||||
>
|
||||
<div class="flex items-baseline justify-between gap-2">
|
||||
<span class="font-sans text-xs font-medium text-brand-navy">
|
||||
Version {i + 1}
|
||||
</span>
|
||||
<span class="font-sans text-[10px] text-gray-400">
|
||||
{formatDateTime(v.savedAt)}
|
||||
</span>
|
||||
</div>
|
||||
<span class="font-sans text-[11px] text-gray-500">{v.editorName}</span>
|
||||
{#if v.changedFields && v.changedFields.length > 0}
|
||||
<div class="mt-1 flex flex-wrap gap-1">
|
||||
{#each v.changedFields as field (field)}
|
||||
<span
|
||||
class="rounded bg-brand-sand/50 px-1.5 py-0.5 font-sans text-[10px] tracking-wide text-gray-500 uppercase"
|
||||
>
|
||||
{fieldLabels[field] ? fieldLabels[field]() : field}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
<!-- Diff panel -->
|
||||
{#if diffLoading}
|
||||
<p class="font-sans text-xs text-gray-400">{m.history_loading()}</p>
|
||||
{:else if noDiff}
|
||||
<div
|
||||
data-testid="history-diff"
|
||||
class="rounded-sm border border-brand-sand bg-white p-4 font-serif text-sm text-gray-400 italic"
|
||||
>
|
||||
{m.history_diff_no_changes()}
|
||||
</div>
|
||||
{:else if diffEntries.length > 0}
|
||||
<div
|
||||
data-testid="history-diff"
|
||||
class="space-y-4 rounded-sm border border-brand-sand bg-white p-4"
|
||||
>
|
||||
{#each diffEntries as entry (entry.field)}
|
||||
<div>
|
||||
<span
|
||||
class="mb-1.5 block font-sans text-[10px] font-bold tracking-wide text-gray-400 uppercase"
|
||||
>{entry.label}</span
|
||||
>
|
||||
{#if entry.kind === 'text'}
|
||||
<p class="font-serif text-sm leading-relaxed">
|
||||
{#each entry.parts as part, partIdx (partIdx)}
|
||||
{#if part.added}
|
||||
<span class="bg-green-50 text-green-700">{part.value}</span>
|
||||
{:else if part.removed}
|
||||
<span class="bg-red-50 text-red-600 line-through">{part.value}</span>
|
||||
{:else}
|
||||
<span>{part.value}</span>
|
||||
{/if}
|
||||
{/each}
|
||||
</p>
|
||||
{:else if entry.kind === 'scalar'}
|
||||
<div class="flex items-center gap-2 font-serif text-sm">
|
||||
<span class="text-red-600 line-through">{entry.oldVal || '—'}</span>
|
||||
<svg
|
||||
class="h-3 w-3 flex-shrink-0 text-gray-400"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-green-700">{entry.newVal || '—'}</span>
|
||||
</div>
|
||||
{:else if entry.kind === 'relation'}
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{#each entry.removed as item (item)}
|
||||
<span
|
||||
class="rounded bg-red-50 px-1.5 py-0.5 font-sans text-[11px] text-red-600 line-through"
|
||||
>{item}</span
|
||||
>
|
||||
{/each}
|
||||
{#each entry.added as item (item)}
|
||||
<span
|
||||
class="rounded bg-green-50 px-1.5 py-0.5 font-sans text-[11px] text-green-700"
|
||||
>{item}</span
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
202
frontend/src/lib/components/PanelMetadata.svelte
Normal file
202
frontend/src/lib/components/PanelMetadata.svelte
Normal file
@@ -0,0 +1,202 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { formatDate } from '$lib/utils/date';
|
||||
|
||||
type Person = { id: string; firstName: string; lastName: string; alias?: string | null };
|
||||
type Tag = { id: string; name: string };
|
||||
|
||||
type Doc = {
|
||||
documentDate?: string | null;
|
||||
location?: string | null;
|
||||
documentLocation?: string | null;
|
||||
tags?: Tag[] | null;
|
||||
sender?: Person | null;
|
||||
receivers?: Person[] | null;
|
||||
};
|
||||
|
||||
let { doc }: { doc: Doc } = $props();
|
||||
</script>
|
||||
|
||||
<div class="space-y-10 p-6">
|
||||
<!-- DETAILS GROUP -->
|
||||
<div>
|
||||
<h3
|
||||
class="mb-4 border-b border-brand-sand pb-2 font-sans text-xs font-bold tracking-widest text-brand-navy uppercase"
|
||||
>
|
||||
{m.doc_section_details()}
|
||||
</h3>
|
||||
<div class="space-y-5">
|
||||
<!-- Date -->
|
||||
<div class="flex items-start">
|
||||
<span class="mt-0.5 w-8 text-brand-mint">
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Calendar/Calendar-Add-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
</span>
|
||||
<div>
|
||||
<span class="block font-serif text-lg text-brand-navy">
|
||||
{doc.documentDate ? formatDate(doc.documentDate) : '—'}
|
||||
</span>
|
||||
<span class="font-sans text-xs text-gray-500">{m.doc_label_document_date()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Creation Location -->
|
||||
<div class="flex items-start">
|
||||
<span class="mt-0.5 w-8 text-brand-mint">
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Location-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
</span>
|
||||
<div>
|
||||
<span class="block font-serif text-lg text-brand-navy">
|
||||
{doc.location ? doc.location : '—'}
|
||||
</span>
|
||||
<span class="font-sans text-xs text-gray-500">{m.doc_label_creation_location()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Physical Archive Location -->
|
||||
{#if doc.documentLocation}
|
||||
<div class="flex items-start">
|
||||
<span class="mt-0.5 w-8 text-brand-mint">
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Folder-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
</span>
|
||||
<div>
|
||||
<span class="block font-serif text-lg text-brand-navy">
|
||||
{doc.documentLocation}
|
||||
</span>
|
||||
<span class="font-sans text-xs text-gray-500"
|
||||
>{m.doc_label_archive_location_original()}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Tags -->
|
||||
{#if doc.tags && doc.tags.length > 0}
|
||||
<div class="flex items-start">
|
||||
<span class="mt-0.5 w-8 text-brand-mint">
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Bookmark/Bookmark-Outline-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
</span>
|
||||
<div class="flex-1">
|
||||
<div class="mb-1 flex flex-wrap gap-2">
|
||||
{#each doc.tags as tag (tag.id)}
|
||||
<a
|
||||
href="/?tag={encodeURIComponent(tag.name)}"
|
||||
class="inline-flex items-center rounded bg-brand-sand/50 px-2 py-0.5 text-xs font-bold tracking-wide text-brand-navy uppercase transition-colors hover:bg-brand-navy hover:text-white"
|
||||
title={m.doc_tag_filter_title({ name: tag.name })}
|
||||
>
|
||||
{tag.name}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
<span class="font-sans text-xs text-gray-500">{m.form_label_tags()}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PERSONEN GROUP -->
|
||||
<div>
|
||||
<h3
|
||||
class="mb-4 border-b border-brand-sand pb-2 font-sans text-xs font-bold tracking-widest text-brand-navy uppercase"
|
||||
>
|
||||
{m.doc_section_persons()}
|
||||
</h3>
|
||||
|
||||
<div class="mb-6">
|
||||
<span class="mb-2 block font-sans text-xs text-gray-400 uppercase"
|
||||
>{m.form_label_sender()}</span
|
||||
>
|
||||
{#if doc.sender}
|
||||
<a
|
||||
href="/persons/{doc.sender.id}"
|
||||
class="group block rounded border border-brand-sand bg-brand-sand/20 p-3 transition hover:border-brand-mint hover:bg-brand-mint/10"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="flex h-8 w-8 items-center justify-center rounded-full bg-brand-navy font-serif text-sm text-white"
|
||||
>
|
||||
{doc.sender.firstName[0]}{doc.sender.lastName[0]}
|
||||
</div>
|
||||
<div>
|
||||
<p
|
||||
class="font-serif text-brand-navy decoration-brand-mint underline-offset-2 group-hover:underline"
|
||||
>
|
||||
{doc.sender.firstName}
|
||||
{doc.sender.lastName}
|
||||
</p>
|
||||
{#if doc.sender.alias}
|
||||
<p class="font-sans text-xs text-gray-500">{doc.sender.alias}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{:else}
|
||||
<span class="font-serif text-sm text-gray-400 italic">{m.doc_sender_not_specified()}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span class="mb-2 block font-sans text-xs text-gray-400 uppercase"
|
||||
>{m.form_label_receivers()}</span
|
||||
>
|
||||
{#if doc.receivers && doc.receivers.length > 0}
|
||||
<div class="space-y-2">
|
||||
{#each doc.receivers as receiver (receiver.id)}
|
||||
<div
|
||||
class="group flex items-center justify-between rounded border border-brand-sand bg-white p-3 transition hover:border-brand-navy"
|
||||
>
|
||||
<a href="/persons/{receiver.id}" class="flex min-w-0 flex-1 items-center gap-3">
|
||||
<div
|
||||
class="flex h-6 w-6 items-center justify-center rounded-full bg-gray-100 font-serif text-xs text-gray-500"
|
||||
>
|
||||
{receiver.firstName[0]}{receiver.lastName[0]}
|
||||
</div>
|
||||
<span class="truncate font-serif text-sm text-brand-navy">
|
||||
{receiver.firstName}
|
||||
{receiver.lastName}
|
||||
</span>
|
||||
</a>
|
||||
|
||||
{#if doc.sender}
|
||||
<a
|
||||
href="/conversations?senderId={doc.sender.id}&receiverId={receiver.id}"
|
||||
class="text-gray-300 transition hover:text-brand-mint"
|
||||
title={m.doc_conversation_title()}
|
||||
>
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Chat-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<span class="font-serif text-sm text-gray-400 italic">{m.doc_no_receivers()}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
42
frontend/src/lib/components/PanelTranscription.svelte
Normal file
42
frontend/src/lib/components/PanelTranscription.svelte
Normal file
@@ -0,0 +1,42 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
type Doc = {
|
||||
summary?: string | null;
|
||||
transcription?: string | null;
|
||||
};
|
||||
|
||||
let { doc }: { doc: Doc } = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex justify-center px-6 py-8">
|
||||
<div class="w-full max-w-prose space-y-8">
|
||||
{#if !doc.summary && !doc.transcription}
|
||||
<p class="font-serif text-sm text-gray-400 italic">—</p>
|
||||
{/if}
|
||||
|
||||
{#if doc.summary}
|
||||
<div>
|
||||
<span
|
||||
class="mb-3 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
|
||||
>
|
||||
{m.doc_label_summary()}
|
||||
</span>
|
||||
<p class="font-serif text-base leading-relaxed text-brand-navy">{doc.summary}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if doc.transcription}
|
||||
<div>
|
||||
<span
|
||||
class="mb-3 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
|
||||
>
|
||||
{m.form_label_transcription()}
|
||||
</span>
|
||||
<p class="font-serif text-base leading-relaxed whitespace-pre-wrap text-brand-navy">
|
||||
{doc.transcription}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,8 +1,22 @@
|
||||
<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';
|
||||
|
||||
let { url }: { url: string } = $props();
|
||||
let {
|
||||
url,
|
||||
documentId = '',
|
||||
annotateMode = $bindable(false),
|
||||
activeAnnotationId = $bindable<string | null>(null),
|
||||
onAnnotationClick
|
||||
}: {
|
||||
url: string;
|
||||
documentId?: string;
|
||||
annotateMode?: boolean;
|
||||
activeAnnotationId?: string | null;
|
||||
onAnnotationClick?: (id: string) => void;
|
||||
} = $props();
|
||||
|
||||
let pdfDoc = $state<PDFDocumentProxy | null>(null);
|
||||
let currentPage = $state(1);
|
||||
@@ -24,6 +38,22 @@ let textLayerInstance: { cancel: () => void } | null = null;
|
||||
let pdfjsLib: typeof import('pdfjs-dist') | null = null;
|
||||
let pdfjsReady = $state(false);
|
||||
|
||||
type Annotation = {
|
||||
id: string;
|
||||
documentId: string;
|
||||
pageNumber: number;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
color: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
let annotations = $state<Annotation[]>([]);
|
||||
let annotateColor = $state('#ffff00');
|
||||
let commentCounts = new SvelteMap<string, number>();
|
||||
|
||||
onMount(async () => {
|
||||
// Dynamic import keeps pdfjs out of the SSR bundle entirely
|
||||
const [lib, { default: workerUrl }] = await Promise.all([
|
||||
@@ -134,6 +164,81 @@ 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;
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
function handleAnnotationClick(id: string) {
|
||||
activeAnnotationId = id;
|
||||
onAnnotationClick?.(id);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (pdfjsReady && url) {
|
||||
loadDocument(url);
|
||||
@@ -151,6 +256,12 @@ $effect(() => {
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (documentId) {
|
||||
loadAnnotations(documentId);
|
||||
}
|
||||
});
|
||||
|
||||
function prevPage() {
|
||||
if (currentPage > 1) currentPage -= 1;
|
||||
}
|
||||
@@ -274,6 +385,17 @@ function zoomOut() {
|
||||
</svg>
|
||||
</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}
|
||||
</div>
|
||||
|
||||
<!-- PDF canvas area -->
|
||||
@@ -297,6 +419,15 @@ function zoomOut() {
|
||||
class="textLayer"
|
||||
style="position: absolute; top: 0; left: 0; overflow: hidden; pointer-events: none; line-height: 1;"
|
||||
></div>
|
||||
<AnnotationLayer
|
||||
annotations={annotations.filter((a) => a.pageNumber === currentPage)}
|
||||
canAnnotate={annotateMode}
|
||||
color={annotateColor}
|
||||
onDraw={handleAnnotationDraw}
|
||||
onDelete={handleAnnotationDelete}
|
||||
commentCounts={Object.fromEntries(commentCounts)}
|
||||
onAnnotationClick={handleAnnotationClick}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -14,6 +14,9 @@ export type ErrorCode =
|
||||
| 'WRONG_CURRENT_PASSWORD'
|
||||
| 'IMPORT_ALREADY_RUNNING'
|
||||
| 'INVALID_RESET_TOKEN'
|
||||
| 'ANNOTATION_NOT_FOUND'
|
||||
| 'ANNOTATION_OVERLAP'
|
||||
| 'COMMENT_NOT_FOUND'
|
||||
| 'UNAUTHORIZED'
|
||||
| 'FORBIDDEN'
|
||||
| 'VALIDATION_ERROR'
|
||||
@@ -61,6 +64,12 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string {
|
||||
return m.error_import_already_running();
|
||||
case 'INVALID_RESET_TOKEN':
|
||||
return m.error_invalid_reset_token();
|
||||
case 'ANNOTATION_NOT_FOUND':
|
||||
return m.error_annotation_not_found();
|
||||
case 'ANNOTATION_OVERLAP':
|
||||
return m.error_annotation_overlap();
|
||||
case 'COMMENT_NOT_FOUND':
|
||||
return m.error_comment_not_found();
|
||||
case 'UNAUTHORIZED':
|
||||
return m.error_unauthorized();
|
||||
case 'FORBIDDEN':
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
|
||||
export const load: LayoutServerLoad = async ({ locals }) => {
|
||||
const groups: { permissions: string[] }[] = locals.user?.groups ?? [];
|
||||
return {
|
||||
user: locals.user,
|
||||
canWrite:
|
||||
locals.user?.groups?.some((g: { permissions: string[] }) =>
|
||||
g.permissions.includes('WRITE_ALL')
|
||||
) ?? false
|
||||
canWrite: groups.some((g) => g.permissions.includes('WRITE_ALL')),
|
||||
canAnnotate: groups.some((g) => g.permissions.includes('ANNOTATE_ALL'))
|
||||
};
|
||||
};
|
||||
|
||||
@@ -29,6 +29,7 @@ const makeUser = (overrides = {}) => ({
|
||||
const baseData = {
|
||||
user: undefined,
|
||||
canWrite: true,
|
||||
canAnnotate: false,
|
||||
users: [makeUser()],
|
||||
groups: [makeGroup()],
|
||||
tags: []
|
||||
|
||||
@@ -24,7 +24,13 @@ const makeUser = (overrides = {}) => ({
|
||||
...overrides
|
||||
});
|
||||
|
||||
const baseData = { user: undefined, canWrite: true, editUser: makeUser(), groups };
|
||||
const baseData = {
|
||||
user: undefined,
|
||||
canWrite: true,
|
||||
canAnnotate: false,
|
||||
editUser: makeUser(),
|
||||
groups
|
||||
};
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ const groups = [
|
||||
{ id: 'g2', name: 'Admins', permissions: ['ADMIN'] }
|
||||
];
|
||||
|
||||
const baseData = { user: undefined, canWrite: true, groups };
|
||||
const baseData = { user: undefined, canWrite: true, canAnnotate: false, groups };
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ afterEach(cleanup);
|
||||
const baseData = {
|
||||
user: undefined,
|
||||
canWrite: true,
|
||||
canAnnotate: false,
|
||||
documents: [],
|
||||
initialValues: { senderName: '', receiverName: '' },
|
||||
filters: { senderId: '', receiverId: '', from: '', to: '', dir: 'DESC' as const }
|
||||
|
||||
@@ -1,19 +1,33 @@
|
||||
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 result = await api.GET('/api/documents/{id}', { params: { path: { id } } });
|
||||
const [docResult, commentsRes] = await Promise.all([
|
||||
api.GET('/api/documents/{id}', { params: { path: { id } } }),
|
||||
fetch(`${base}/api/documents/${id}/comments`).catch(() => null)
|
||||
]);
|
||||
|
||||
if (result.response.status === 401) throw redirect(302, '/login');
|
||||
if (docResult.response.status === 401) throw redirect(302, '/login');
|
||||
|
||||
if (!result.response.ok) {
|
||||
const code = (result.error as unknown as { code?: string })?.code;
|
||||
throw error(result.response.status, getErrorMessage(code));
|
||||
if (!docResult.response.ok) {
|
||||
const code = (docResult.error as unknown as { code?: string })?.code;
|
||||
throw error(docResult.response.status, getErrorMessage(code));
|
||||
}
|
||||
|
||||
return { document: result.data! };
|
||||
let comments: unknown[] = [];
|
||||
if (commentsRes?.ok) {
|
||||
try {
|
||||
comments = await commentsRes.json();
|
||||
} catch {
|
||||
// ignore invalid response
|
||||
}
|
||||
}
|
||||
|
||||
return { document: docResult.data!, comments };
|
||||
}
|
||||
|
||||
@@ -1,17 +1,27 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { formatDate } from '$lib/utils/date';
|
||||
import { diffWords } from 'diff';
|
||||
import ExpandableText from '$lib/components/ExpandableText.svelte';
|
||||
import PdfViewer from '$lib/components/PdfViewer.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import DocumentTopBar from '$lib/components/DocumentTopBar.svelte';
|
||||
import DocumentViewer from '$lib/components/DocumentViewer.svelte';
|
||||
import DocumentBottomPanel from '$lib/components/DocumentBottomPanel.svelte';
|
||||
|
||||
type Tab = 'metadata' | 'transcription' | 'discussion' | 'history';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
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 currentUserId = $derived((data.user?.id as string | undefined) ?? null);
|
||||
|
||||
// ── File loading ──────────────────────────────────────────────────────────────
|
||||
|
||||
let fileUrl = $state('');
|
||||
let isLoading = $state(false);
|
||||
let error = $state('');
|
||||
let fileError = $state('');
|
||||
|
||||
$effect(() => {
|
||||
if (doc?.id && doc?.filePath) {
|
||||
@@ -21,7 +31,7 @@ $effect(() => {
|
||||
|
||||
async function loadFile(id: string) {
|
||||
isLoading = true;
|
||||
error = '';
|
||||
fileError = '';
|
||||
fileUrl = '';
|
||||
|
||||
try {
|
||||
@@ -36,855 +46,104 @@ async function loadFile(id: string) {
|
||||
fileUrl = URL.createObjectURL(blob);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
error = m.doc_file_error_preview();
|
||||
fileError = 'Vorschau konnte nicht geladen werden.';
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── History panel ────────────────────────────────────────────────────────────
|
||||
// ── Annotation state (lifted from PdfViewer) ──────────────────────────────────
|
||||
|
||||
type VersionSummary = {
|
||||
id: string;
|
||||
savedAt: string;
|
||||
editorName: string;
|
||||
changedFields: string[];
|
||||
};
|
||||
let annotateMode = $state(false);
|
||||
let activeAnnotationId = $state<string | null>(null);
|
||||
|
||||
type SnapshotDoc = {
|
||||
title?: string;
|
||||
documentDate?: string;
|
||||
location?: string;
|
||||
documentLocation?: string;
|
||||
transcription?: string;
|
||||
summary?: string;
|
||||
sender?: { id: string; firstName: string; lastName: string } | null;
|
||||
receivers?: { id: string; firstName: string; lastName: string }[];
|
||||
tags?: { id: string; name: string }[];
|
||||
};
|
||||
|
||||
type DiffEntry =
|
||||
| {
|
||||
kind: 'text';
|
||||
field: string;
|
||||
label: string;
|
||||
parts: { value: string; added?: boolean; removed?: boolean }[];
|
||||
}
|
||||
| { kind: 'scalar'; field: string; label: string; oldVal: string; newVal: string }
|
||||
| { kind: 'relation'; field: string; label: string; removed: string[]; added: string[] };
|
||||
|
||||
let historyOpen = $state(false);
|
||||
let historyLoaded = $state(false);
|
||||
let historyLoading = $state(false);
|
||||
let versions = $state<VersionSummary[]>([]);
|
||||
|
||||
let compareMode = $state(false);
|
||||
let compareA = $state('');
|
||||
let compareB = $state('');
|
||||
|
||||
let selectedVersionId = $state<string | null>(null);
|
||||
let diffEntries = $state<DiffEntry[]>([]);
|
||||
let diffLoading = $state(false);
|
||||
let noDiff = $state(false);
|
||||
|
||||
const fieldLabels: Record<string, () => string> = {
|
||||
title: m.history_field_title,
|
||||
documentDate: m.history_field_document_date,
|
||||
location: m.history_field_location,
|
||||
documentLocation: m.history_field_document_location,
|
||||
transcription: m.history_field_transcription,
|
||||
summary: m.history_field_summary,
|
||||
sender: m.history_field_sender,
|
||||
receivers: m.history_field_receivers,
|
||||
tags: m.history_field_tags
|
||||
};
|
||||
|
||||
const TEXT_FIELDS = ['title', 'summary', 'transcription'] as const;
|
||||
const SCALAR_FIELDS = ['documentDate', 'location', 'documentLocation'] as const;
|
||||
|
||||
function parseSnapshot(raw: string): SnapshotDoc {
|
||||
try {
|
||||
return JSON.parse(raw) as SnapshotDoc;
|
||||
} catch {
|
||||
return {};
|
||||
// When an annotation is clicked, open the Diskussion tab.
|
||||
$effect(() => {
|
||||
if (activeAnnotationId) {
|
||||
activeTab = 'discussion';
|
||||
panelOpen = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function personLabel(p: { firstName: string; lastName: string }): string {
|
||||
return `${p.firstName} ${p.lastName}`.trim();
|
||||
}
|
||||
// ── Bottom panel state ────────────────────────────────────────────────────────
|
||||
|
||||
const DIFF_CONTEXT_WORDS = 4;
|
||||
const LS_KEY_OPEN = 'doc-panel-open';
|
||||
const LS_KEY_HEIGHT = 'doc-panel-height';
|
||||
const LS_KEY_TAB = 'doc-panel-tab';
|
||||
|
||||
type DiffPart = { value: string; added?: boolean; removed?: boolean };
|
||||
let panelOpen = $state(false);
|
||||
let panelHeight = $state(320);
|
||||
let activeTab = $state<Tab>('metadata');
|
||||
let localStorageRestored = $state(false);
|
||||
|
||||
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];
|
||||
onMount(() => {
|
||||
const savedOpen = localStorage.getItem(LS_KEY_OPEN);
|
||||
const savedHeight = localStorage.getItem(LS_KEY_HEIGHT);
|
||||
const savedTab = localStorage.getItem(LS_KEY_TAB);
|
||||
|
||||
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 });
|
||||
if (savedTab && ['metadata', 'transcription', 'discussion', 'history'].includes(savedTab)) {
|
||||
activeTab = savedTab as Tab;
|
||||
}
|
||||
if (savedHeight) {
|
||||
const h = parseInt(savedHeight, 10);
|
||||
if (!isNaN(h) && h >= 80) panelHeight = h;
|
||||
}
|
||||
if (savedOpen !== null) {
|
||||
panelOpen = savedOpen === 'true';
|
||||
} else if (!doc.filePath) {
|
||||
// No previous state and no file → open to Metadaten by default
|
||||
panelOpen = true;
|
||||
activeTab = 'metadata';
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
localStorageRestored = true;
|
||||
});
|
||||
|
||||
// sender
|
||||
const senderA = older?.sender ? personLabel(older.sender) : '';
|
||||
const senderB = newer.sender ? personLabel(newer.sender) : '';
|
||||
if (senderA !== senderB) {
|
||||
const removed = senderA ? [senderA] : [];
|
||||
const added = senderB ? [senderB] : [];
|
||||
entries.push({
|
||||
kind: 'relation',
|
||||
field: 'sender',
|
||||
label: fieldLabels['sender'](),
|
||||
removed,
|
||||
added
|
||||
});
|
||||
}
|
||||
|
||||
// receivers
|
||||
const receiversA = new Set((older?.receivers ?? []).map(personLabel));
|
||||
const receiversB = new Set((newer.receivers ?? []).map(personLabel));
|
||||
const removedReceivers = [...receiversA].filter((r) => !receiversB.has(r));
|
||||
const addedReceivers = [...receiversB].filter((r) => !receiversA.has(r));
|
||||
if (removedReceivers.length > 0 || addedReceivers.length > 0) {
|
||||
entries.push({
|
||||
kind: 'relation',
|
||||
field: 'receivers',
|
||||
label: fieldLabels['receivers'](),
|
||||
removed: removedReceivers,
|
||||
added: addedReceivers
|
||||
});
|
||||
}
|
||||
|
||||
// tags
|
||||
const tagsA = new Set((older?.tags ?? []).map((t) => t.name));
|
||||
const tagsB = new Set((newer.tags ?? []).map((t) => t.name));
|
||||
const removedTags = [...tagsA].filter((t) => !tagsB.has(t));
|
||||
const addedTags = [...tagsB].filter((t) => !tagsA.has(t));
|
||||
if (removedTags.length > 0 || addedTags.length > 0) {
|
||||
entries.push({
|
||||
kind: 'relation',
|
||||
field: 'tags',
|
||||
label: fieldLabels['tags'](),
|
||||
removed: removedTags,
|
||||
added: addedTags
|
||||
});
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
async function fetchSnapshot(versionId: string): Promise<SnapshotDoc> {
|
||||
const res = await fetch(`/api/documents/${doc.id}/versions/${versionId}`);
|
||||
if (!res.ok) throw new Error('Failed to fetch version');
|
||||
const v = await res.json();
|
||||
return parseSnapshot(v.snapshot);
|
||||
}
|
||||
|
||||
async function toggleHistory() {
|
||||
historyOpen = !historyOpen;
|
||||
if (historyOpen && !historyLoaded) {
|
||||
historyLoading = true;
|
||||
try {
|
||||
const res = await fetch(`/api/documents/${doc.id}/versions`);
|
||||
if (res.ok) {
|
||||
versions = await res.json();
|
||||
}
|
||||
historyLoaded = true;
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
historyLoading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function selectVersion(versionId: string) {
|
||||
if (selectedVersionId === versionId) {
|
||||
selectedVersionId = null;
|
||||
diffEntries = [];
|
||||
noDiff = false;
|
||||
return;
|
||||
}
|
||||
selectedVersionId = versionId;
|
||||
diffEntries = [];
|
||||
noDiff = false;
|
||||
diffLoading = true;
|
||||
try {
|
||||
const idx = versions.findIndex((v) => v.id === versionId);
|
||||
const newerSnap = await fetchSnapshot(versionId);
|
||||
const olderSnap = idx > 0 ? await fetchSnapshot(versions[idx - 1].id) : null;
|
||||
const entries = buildDiff(olderSnap, newerSnap);
|
||||
if (entries.length === 0) {
|
||||
noDiff = true;
|
||||
} else {
|
||||
diffEntries = entries;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
diffLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function applyCompare() {
|
||||
if (!compareA || !compareB || compareA === compareB) return;
|
||||
selectedVersionId = null;
|
||||
diffEntries = [];
|
||||
noDiff = false;
|
||||
diffLoading = true;
|
||||
try {
|
||||
const [snapA, snapB] = await Promise.all([fetchSnapshot(compareA), fetchSnapshot(compareB)]);
|
||||
// compareA is the "older" baseline, compareB is "newer"
|
||||
const entries = buildDiff(snapA, snapB);
|
||||
if (entries.length === 0) {
|
||||
noDiff = true;
|
||||
} else {
|
||||
diffEntries = entries;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
diffLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDateTime(iso: string): string {
|
||||
try {
|
||||
return new Intl.DateTimeFormat('de-DE', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}).format(new Date(iso));
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
function versionLabel(v: VersionSummary, index: number): string {
|
||||
return `Version ${index + 1} — ${v.editorName} — ${formatDateTime(v.savedAt)}`;
|
||||
}
|
||||
// Persist panel state whenever it changes (after initial restore).
|
||||
$effect(() => {
|
||||
if (!localStorageRestored) return;
|
||||
localStorage.setItem(LS_KEY_OPEN, String(panelOpen));
|
||||
localStorage.setItem(LS_KEY_HEIGHT, String(panelHeight));
|
||||
localStorage.setItem(LS_KEY_TAB, activeTab);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex h-screen flex-col bg-white">
|
||||
<!-- Top Bar -->
|
||||
<div
|
||||
class="z-10 flex items-center justify-between border-b border-brand-sand bg-white px-6 py-4 shadow-sm"
|
||||
>
|
||||
<div class="flex items-center gap-6 overflow-hidden">
|
||||
<a
|
||||
href="/"
|
||||
class="group flex flex-shrink-0 items-center gap-2 font-sans text-sm font-medium text-gray-500 transition-colors hover:text-brand-navy"
|
||||
>
|
||||
<div
|
||||
class="flex h-8 w-8 items-center justify-center rounded-full bg-brand-sand transition-colors group-hover:bg-brand-mint"
|
||||
>
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Arrow/Arrow-Left-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</div>
|
||||
<span>{m.btn_back()}</span>
|
||||
</a>
|
||||
<svelte:head>
|
||||
<title>{doc.title || doc.originalFilename || 'Dokument'}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex items-center gap-4 overflow-hidden border-l border-gray-200 pl-6">
|
||||
<h1 class="truncate font-serif text-xl text-brand-navy" title={doc.title}>
|
||||
{doc.title || doc.originalFilename}
|
||||
</h1>
|
||||
<span
|
||||
class="flex-shrink-0 rounded-full px-3 py-1 font-sans text-xs font-bold tracking-wide uppercase
|
||||
{doc.status === 'UPLOADED'
|
||||
? 'border border-brand-mint bg-brand-mint/30 text-brand-navy'
|
||||
: 'border border-yellow-200 bg-yellow-100 text-yellow-800'}"
|
||||
>
|
||||
{doc.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex h-screen flex-col overflow-hidden bg-white" data-hydrated>
|
||||
<DocumentTopBar
|
||||
doc={doc}
|
||||
canWrite={data.canWrite ?? false}
|
||||
canAnnotate={data.canAnnotate ?? false}
|
||||
fileUrl={fileUrl}
|
||||
bind:annotateMode={annotateMode}
|
||||
/>
|
||||
|
||||
<div class="ml-4 flex flex-shrink-0 items-center gap-3 font-sans">
|
||||
{#if data.canWrite}
|
||||
<a
|
||||
href="/documents/{doc.id}/edit"
|
||||
class="flex items-center gap-2 rounded border border-brand-navy bg-transparent px-4 py-2 text-sm font-medium text-brand-navy transition hover:bg-brand-navy hover:text-white"
|
||||
>
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Edit-Content-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
{m.btn_edit()}
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
{#if doc.filePath}
|
||||
<a
|
||||
href={fileUrl}
|
||||
download={doc.originalFilename}
|
||||
class="rounded border border-transparent bg-brand-sand/50 p-2 text-brand-navy transition hover:bg-brand-mint"
|
||||
title={m.doc_download_title()}
|
||||
>
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Download-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content Area -->
|
||||
<div class="flex flex-1 overflow-hidden">
|
||||
<!-- LEFT SIDEBAR: METADATA -->
|
||||
<aside
|
||||
class="custom-scrollbar w-96 flex-shrink-0 overflow-y-auto border-r border-brand-sand bg-white p-8"
|
||||
>
|
||||
<div class="space-y-10">
|
||||
<!-- 1. DETAILS GROUP -->
|
||||
<div>
|
||||
<h3
|
||||
class="mb-4 border-b border-brand-sand pb-2 font-sans text-xs font-bold tracking-widest text-brand-navy uppercase"
|
||||
>
|
||||
{m.doc_section_details()}
|
||||
</h3>
|
||||
<div class="space-y-5">
|
||||
<!-- Date -->
|
||||
<div class="group flex items-start">
|
||||
<span class="mt-0.5 w-8 text-brand-mint">
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Calendar/Calendar-Add-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
</span>
|
||||
<div>
|
||||
<span class="block font-serif text-lg text-brand-navy">
|
||||
{doc.documentDate ? formatDate(doc.documentDate) : '—'}
|
||||
</span>
|
||||
<span class="font-sans text-xs text-gray-500">{m.doc_label_document_date()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Creation Location -->
|
||||
<div class="group flex items-start">
|
||||
<span class="mt-0.5 w-8 text-brand-mint">
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Location-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
</span>
|
||||
<div>
|
||||
<span class="block font-serif text-lg text-brand-navy">
|
||||
{doc.location ? doc.location : '—'}
|
||||
</span>
|
||||
<span class="font-sans text-xs text-gray-500"
|
||||
>{m.doc_label_creation_location()}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Physical Archive Location -->
|
||||
{#if doc.documentLocation}
|
||||
<div class="group flex items-start">
|
||||
<span class="mt-0.5 w-8 text-brand-mint">
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Folder-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
</span>
|
||||
<div>
|
||||
<span class="block font-serif text-lg text-brand-navy">
|
||||
{doc.documentLocation}
|
||||
</span>
|
||||
<span class="font-sans text-xs text-gray-500"
|
||||
>{m.doc_label_archive_location_original()}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- TAGS / SCHLAGWORTE -->
|
||||
{#if doc.tags && doc.tags.length > 0}
|
||||
<div class="group flex items-start">
|
||||
<span class="mt-0.5 w-8 text-brand-mint">
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Bookmark/Bookmark-Outline-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
</span>
|
||||
<div class="flex-1">
|
||||
<div class="mb-1 flex flex-wrap gap-2">
|
||||
{#each doc.tags as tag (tag.id)}
|
||||
<a
|
||||
href="/?tag={encodeURIComponent(tag.name)}"
|
||||
class="inline-flex items-center rounded bg-brand-sand/50 px-2 py-0.5 text-xs font-bold tracking-wide text-brand-navy uppercase transition-colors hover:bg-brand-navy hover:text-white"
|
||||
title={m.doc_tag_filter_title({ name: tag.name })}
|
||||
>
|
||||
{tag.name}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
<span class="font-sans text-xs text-gray-500">{m.form_label_tags()}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2. PERSONEN GROUP -->
|
||||
<div>
|
||||
<h3
|
||||
class="mb-4 border-b border-brand-sand pb-2 font-sans text-xs font-bold tracking-widest text-brand-navy uppercase"
|
||||
>
|
||||
{m.doc_section_persons()}
|
||||
</h3>
|
||||
|
||||
<div class="mb-6">
|
||||
<span class="mb-2 block font-sans text-xs text-gray-400 uppercase"
|
||||
>{m.form_label_sender()}</span
|
||||
>
|
||||
{#if doc.sender}
|
||||
<a
|
||||
href="/persons/{doc.sender.id}"
|
||||
class="group block rounded border border-brand-sand bg-brand-sand/20 p-3 transition hover:border-brand-mint hover:bg-brand-mint/10"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="flex h-8 w-8 items-center justify-center rounded-full bg-brand-navy font-serif text-sm text-white"
|
||||
>
|
||||
{doc.sender.firstName[0]}{doc.sender.lastName[0]}
|
||||
</div>
|
||||
<div>
|
||||
<p
|
||||
class="font-serif text-brand-navy decoration-brand-mint underline-offset-2 group-hover:underline"
|
||||
>
|
||||
{doc.sender.firstName}
|
||||
{doc.sender.lastName}
|
||||
</p>
|
||||
{#if doc.sender.alias}
|
||||
<p class="font-sans text-xs text-gray-500">{doc.sender.alias}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{:else}
|
||||
<span class="font-serif text-sm text-gray-400 italic"
|
||||
>{m.doc_sender_not_specified()}</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span class="mb-2 block font-sans text-xs text-gray-400 uppercase"
|
||||
>{m.form_label_receivers()}</span
|
||||
>
|
||||
{#if doc.receivers && doc.receivers.length > 0}
|
||||
<div class="space-y-2">
|
||||
{#each doc.receivers as receiver (receiver.id)}
|
||||
<div
|
||||
class="group flex items-center justify-between rounded border border-brand-sand bg-white p-3 transition hover:border-brand-navy"
|
||||
>
|
||||
<a href="/persons/{receiver.id}" class="flex min-w-0 flex-1 items-center gap-3">
|
||||
<div
|
||||
class="flex h-6 w-6 items-center justify-center rounded-full bg-gray-100 font-serif text-xs text-gray-500"
|
||||
>
|
||||
{receiver.firstName[0]}{receiver.lastName[0]}
|
||||
</div>
|
||||
<span
|
||||
class="truncate font-serif text-sm text-brand-navy group-hover:text-brand-navy"
|
||||
>
|
||||
{receiver.firstName}
|
||||
{receiver.lastName}
|
||||
</span>
|
||||
</a>
|
||||
|
||||
{#if doc.sender}
|
||||
<a
|
||||
href="/conversations?senderId={doc.sender.id}&receiverId={receiver.id}"
|
||||
class="text-gray-300 transition hover:text-brand-mint"
|
||||
title={m.doc_conversation_title()}
|
||||
>
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Chat-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<span class="font-serif text-sm text-gray-400 italic">{m.doc_no_receivers()}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 3. INHALT GROUP -->
|
||||
{#if doc.summary || doc.transcription}
|
||||
<div>
|
||||
<h3
|
||||
class="mb-4 border-b border-brand-sand pb-2 font-sans text-xs font-bold tracking-widest text-brand-navy uppercase"
|
||||
>
|
||||
{m.doc_section_content()}
|
||||
</h3>
|
||||
|
||||
<div class="space-y-6">
|
||||
{#if doc.summary}
|
||||
<div>
|
||||
<span class="mb-2 block font-sans text-xs text-gray-400 uppercase"
|
||||
>{m.doc_label_summary()}</span
|
||||
>
|
||||
<ExpandableText text={doc.summary} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if doc.transcription}
|
||||
<div>
|
||||
<span class="mb-2 block font-sans text-xs text-gray-400 uppercase"
|
||||
>{m.form_label_transcription()}</span
|
||||
>
|
||||
<ExpandableText text={doc.transcription} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- 4. HISTORY GROUP -->
|
||||
<div>
|
||||
<div class="flex items-center justify-between border-b border-brand-sand pb-2">
|
||||
<h3 class="font-sans text-xs font-bold tracking-widest text-brand-navy uppercase">
|
||||
{m.history_section_title()}
|
||||
</h3>
|
||||
<button
|
||||
onclick={toggleHistory}
|
||||
class="flex items-center gap-1 rounded p-1 text-gray-400 transition hover:bg-brand-sand/50 hover:text-brand-navy"
|
||||
aria-expanded={historyOpen}
|
||||
aria-label={m.history_section_title()}
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4 transition-transform duration-200 {historyOpen ? 'rotate-180' : ''}"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if historyOpen}
|
||||
<div class="mt-4 space-y-3">
|
||||
{#if historyLoading}
|
||||
<p class="font-sans text-xs text-gray-400">{m.history_loading()}</p>
|
||||
{:else if versions.length === 0}
|
||||
<p class="font-serif text-sm text-gray-400 italic">{m.history_empty()}</p>
|
||||
{:else}
|
||||
<!-- Compare mode toggle -->
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
onclick={() => {
|
||||
compareMode = !compareMode;
|
||||
diffEntries = [];
|
||||
noDiff = false;
|
||||
selectedVersionId = null;
|
||||
}}
|
||||
class="font-sans text-xs font-medium transition {compareMode
|
||||
? 'text-brand-navy underline'
|
||||
: 'text-gray-400 hover:text-brand-navy'}"
|
||||
>
|
||||
{m.history_compare_mode()}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if compareMode}
|
||||
<!-- Compare selects -->
|
||||
<div class="space-y-2">
|
||||
<div>
|
||||
<label
|
||||
for="compare-a"
|
||||
class="mb-1 block font-sans text-[10px] text-gray-400 uppercase"
|
||||
>{m.history_compare_select_a()}</label
|
||||
>
|
||||
<select
|
||||
id="compare-a"
|
||||
bind:value={compareA}
|
||||
class="w-full rounded border border-brand-sand bg-white px-2 py-1 font-sans text-xs text-brand-navy focus:ring-1 focus:ring-brand-mint focus:outline-none"
|
||||
>
|
||||
<option value="">—</option>
|
||||
{#each versions as v, i (v.id)}
|
||||
<option value={v.id}>{versionLabel(v, i)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="compare-b"
|
||||
class="mb-1 block font-sans text-[10px] text-gray-400 uppercase"
|
||||
>{m.history_compare_select_b()}</label
|
||||
>
|
||||
<select
|
||||
id="compare-b"
|
||||
bind:value={compareB}
|
||||
class="w-full rounded border border-brand-sand bg-white px-2 py-1 font-sans text-xs text-brand-navy focus:ring-1 focus:ring-brand-mint focus:outline-none"
|
||||
>
|
||||
<option value="">—</option>
|
||||
{#each versions as v, i (v.id)}
|
||||
<option value={v.id}>{versionLabel(v, i)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
onclick={applyCompare}
|
||||
disabled={!compareA || !compareB || compareA === compareB}
|
||||
class="w-full rounded bg-brand-navy px-3 py-1.5 font-sans text-xs font-medium text-white transition hover:bg-brand-navy/80 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{m.history_compare_apply()}
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Version list -->
|
||||
<ul class="divide-y divide-brand-sand">
|
||||
{#each versions as v, i (v.id)}
|
||||
<li>
|
||||
<button
|
||||
onclick={() => selectVersion(v.id)}
|
||||
data-testid="history-version"
|
||||
class="w-full py-2 text-left transition hover:bg-brand-sand/30 {selectedVersionId ===
|
||||
v.id
|
||||
? 'border-l-2 border-brand-mint pl-2'
|
||||
: 'pl-0'}"
|
||||
>
|
||||
<div class="flex items-baseline justify-between gap-2">
|
||||
<span class="font-sans text-xs font-medium text-brand-navy">
|
||||
Version {i + 1}
|
||||
</span>
|
||||
<span class="font-sans text-[10px] text-gray-400">
|
||||
{formatDateTime(v.savedAt)}
|
||||
</span>
|
||||
</div>
|
||||
<span class="font-sans text-[11px] text-gray-500">{v.editorName}</span>
|
||||
{#if v.changedFields && v.changedFields.length > 0}
|
||||
<div class="mt-1 flex flex-wrap gap-1">
|
||||
{#each v.changedFields as field (field)}
|
||||
<span
|
||||
class="rounded bg-brand-sand/50 px-1.5 py-0.5 font-sans text-[10px] tracking-wide text-gray-500 uppercase"
|
||||
>
|
||||
{fieldLabels[field] ? fieldLabels[field]() : field}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
<!-- Diff panel -->
|
||||
{#if diffLoading}
|
||||
<p class="font-sans text-xs text-gray-400">{m.history_loading()}</p>
|
||||
{:else if noDiff}
|
||||
<div
|
||||
data-testid="history-diff"
|
||||
class="rounded-sm border border-brand-sand bg-white p-4 font-serif text-sm text-gray-400 italic"
|
||||
>
|
||||
{m.history_diff_no_changes()}
|
||||
</div>
|
||||
{:else if diffEntries.length > 0}
|
||||
<div
|
||||
data-testid="history-diff"
|
||||
class="space-y-4 rounded-sm border border-brand-sand bg-white p-4"
|
||||
>
|
||||
{#each diffEntries as entry (entry.field)}
|
||||
<div>
|
||||
<span
|
||||
class="mb-1.5 block font-sans text-[10px] font-bold tracking-wide text-gray-400 uppercase"
|
||||
>{entry.label}</span
|
||||
>
|
||||
{#if entry.kind === 'text'}
|
||||
<p class="font-serif text-sm leading-relaxed">
|
||||
{#each entry.parts as part, partIdx (partIdx)}
|
||||
{#if part.added}
|
||||
<span class="bg-green-50 text-green-700">{part.value}</span>
|
||||
{:else if part.removed}
|
||||
<span class="bg-red-50 text-red-600 line-through">{part.value}</span
|
||||
>
|
||||
{:else}
|
||||
<span>{part.value}</span>
|
||||
{/if}
|
||||
{/each}
|
||||
</p>
|
||||
{:else if entry.kind === 'scalar'}
|
||||
<div class="flex items-center gap-2 font-serif text-sm">
|
||||
<span class="text-red-600 line-through">{entry.oldVal || '—'}</span>
|
||||
<svg
|
||||
class="h-3 w-3 flex-shrink-0 text-gray-400"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-green-700">{entry.newVal || '—'}</span>
|
||||
</div>
|
||||
{:else if entry.kind === 'relation'}
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{#each entry.removed as item (item)}
|
||||
<span
|
||||
class="rounded bg-red-50 px-1.5 py-0.5 font-sans text-[11px] text-red-600 line-through"
|
||||
>{item}</span
|
||||
>
|
||||
{/each}
|
||||
{#each entry.added as item (item)}
|
||||
<span
|
||||
class="rounded bg-green-50 px-1.5 py-0.5 font-sans text-[11px] text-green-700"
|
||||
>{item}</span
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="border-t border-brand-sand pt-4 font-sans text-[10px] text-gray-400">
|
||||
<p class="truncate">ID: {doc.id}</p>
|
||||
<p class="mt-1 truncate">{doc.originalFilename}</p>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- RIGHT: PREVIEW AREA -->
|
||||
<main class="relative flex flex-1 flex-col items-center justify-center bg-[#2A2A2A]">
|
||||
{#if isLoading}
|
||||
<div class="flex flex-col items-center text-brand-mint">
|
||||
<svg
|
||||
class="mb-4 h-8 w-8 animate-spin"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
<span class="font-sans text-sm tracking-wide">{m.doc_loading()}</span>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="px-4 text-center text-gray-400">
|
||||
<p class="mb-2 font-serif">{error}</p>
|
||||
{#if doc.filePath}
|
||||
<a
|
||||
href={`/api/documents/${doc.id}/file`}
|
||||
target="_blank"
|
||||
class="text-sm underline hover:text-white"
|
||||
>
|
||||
{m.doc_download_link()}
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if !doc.filePath}
|
||||
<div class="flex flex-col items-center text-gray-400">
|
||||
<div class="mb-6 rounded-full bg-white/5 p-8">
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/PDF-Document-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-12 w-12 opacity-50 invert"
|
||||
/>
|
||||
</div>
|
||||
<p class="font-sans text-sm tracking-wide uppercase">{m.doc_no_scan()}</p>
|
||||
</div>
|
||||
{:else if fileUrl && doc.contentType?.startsWith('application/pdf')}
|
||||
<PdfViewer url={fileUrl} />
|
||||
{:else if fileUrl}
|
||||
<div class="flex h-full w-full items-center justify-center overflow-auto p-8">
|
||||
<img
|
||||
src={fileUrl}
|
||||
alt={m.doc_image_alt()}
|
||||
class="max-h-full max-w-full object-contain shadow-2xl"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
<div class="relative flex-1 overflow-hidden">
|
||||
<DocumentViewer
|
||||
doc={doc}
|
||||
fileUrl={fileUrl}
|
||||
isLoading={isLoading}
|
||||
error={fileError}
|
||||
bind:annotateMode={annotateMode}
|
||||
bind:activeAnnotationId={activeAnnotationId}
|
||||
onAnnotationClick={(id) => {
|
||||
activeAnnotationId = id;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DocumentBottomPanel
|
||||
doc={doc}
|
||||
comments={(data.comments ?? []) as never[]}
|
||||
canComment={canComment}
|
||||
currentUserId={currentUserId}
|
||||
canAdmin={canAdmin}
|
||||
bind:open={panelOpen}
|
||||
bind:height={panelHeight}
|
||||
bind:activeTab={activeTab}
|
||||
activeAnnotationId={activeAnnotationId}
|
||||
/>
|
||||
|
||||
@@ -10,6 +10,7 @@ afterEach(cleanup);
|
||||
const baseData = {
|
||||
user: undefined,
|
||||
canWrite: true,
|
||||
canAnnotate: false,
|
||||
persons: [],
|
||||
initialSenderId: '',
|
||||
initialSenderName: '',
|
||||
|
||||
@@ -22,6 +22,7 @@ const makeData = (overrides = {}) => ({
|
||||
createdAt: ''
|
||||
},
|
||||
canWrite: true,
|
||||
canAnnotate: false,
|
||||
...overrides
|
||||
});
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ afterEach(cleanup);
|
||||
const emptyData = {
|
||||
user: undefined,
|
||||
canWrite: true,
|
||||
canAnnotate: false,
|
||||
filters: { q: '', from: '', to: '', senderId: '', receiverId: '', tags: [] },
|
||||
documents: [],
|
||||
initialValues: { senderName: '', receiverName: '' },
|
||||
|
||||
@@ -14,7 +14,7 @@ const makePerson = (overrides = {}) => ({
|
||||
...overrides
|
||||
});
|
||||
|
||||
const emptyData = { user: undefined, canWrite: true, q: '', persons: [] };
|
||||
const emptyData = { user: undefined, canWrite: true, canAnnotate: false, q: '', persons: [] };
|
||||
const dataWithPersons = { ...emptyData, persons: [makePerson()] };
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
Reference in New Issue
Block a user