Compare commits
98 Commits
feat/38-do
...
feature/68
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53b482c5f2 | ||
|
|
fa9577052d | ||
|
|
a7eaa40852 | ||
|
|
c5e28ac18e | ||
|
|
d6f4ea05d9 | ||
|
|
065dd8fabd | ||
|
|
a967483cd9 | ||
|
|
5d0a2a2c9c | ||
|
|
0f0d74eb2f | ||
|
|
20f6de4424 | ||
|
|
bf82ebfe1d | ||
|
|
c6984e49ee | ||
|
|
150bc2f171 | ||
|
|
41c311249b | ||
|
|
2efa790243 | ||
|
|
648bdffe4f | ||
|
|
99e3163c0e | ||
|
|
f0940524e7 | ||
|
|
a302f96560 | ||
|
|
654e736f8a | ||
|
|
078bc1c886 | ||
|
|
8555193a79 | ||
|
|
aab9e9a4b0 | ||
|
|
0ce18e1eed | ||
|
|
2bfbf45eba | ||
|
|
40f01a7712 | ||
|
|
0db68da00c | ||
|
|
e831de4f85 | ||
|
|
90e94b350a | ||
|
|
1facf9cd60 | ||
|
|
25014cce2d | ||
|
|
6f71682454 | ||
|
|
af59ed4de4 | ||
|
|
d46764ef4f | ||
|
|
d40d4b21e1 | ||
|
|
1ea84e4dc8 | ||
|
|
d078ad8224 | ||
|
|
9d5c57b49b | ||
|
|
0795e4099f | ||
|
|
1413058ae7 | ||
|
|
91a29d501d | ||
|
|
963807ff05 | ||
|
|
6a663cefe6 | ||
|
|
db103ca1ab | ||
|
|
3ec680b812 | ||
|
|
50e3f948c7 | ||
|
|
bbfef9a22d | ||
|
|
332b5b3c40 | ||
|
|
29a71f4421 | ||
|
|
eade2aa48a | ||
|
|
bda3cdf9af | ||
|
|
1765ffce01 | ||
|
|
399fa36f60 | ||
|
|
51a0eb76de | ||
|
|
162c58e8c5 | ||
|
|
e4539ed0f0 | ||
|
|
caba89dacc | ||
|
|
e83ba9b681 | ||
|
|
93befbd8da | ||
|
|
9aa98b4fb6 | ||
|
|
dd360ade8b | ||
|
|
f71712ab4b | ||
|
|
10783fdb55 | ||
|
|
5ea5590c89 | ||
|
|
142f296255 | ||
|
|
c19f7b3b1a | ||
|
|
db9d8ed457 | ||
|
|
65457a5650 | ||
|
|
1eb2659ba0 | ||
|
|
f18649fb79 | ||
|
|
a392e85f43 | ||
|
|
c9b4e6dad4 | ||
|
|
8519fbb48a | ||
|
|
ee85ce4668 | ||
|
|
ecfd80bf9a | ||
|
|
8c2bdbd777 | ||
|
|
63013cc86a | ||
|
|
9e2419a48e | ||
|
|
00195dc8db | ||
|
|
0ec86220d3 | ||
|
|
7fbc33b32d | ||
|
|
93f57477cd | ||
|
|
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,32 @@ public class DataInitializer {
|
||||
TagRepository tagRepo,
|
||||
PasswordEncoder passwordEncoder) {
|
||||
return args -> {
|
||||
// Always reset the admin password to the configured value so a failed password-reset
|
||||
// test from a previous run can never leave the account locked out.
|
||||
userRepository.findByUsername(adminUsername).ifPresent(admin -> {
|
||||
admin.setPassword(passwordEncoder.encode(adminPassword));
|
||||
userRepository.save(admin);
|
||||
log.info("E2E seed: Admin-Passwort auf konfigurierten Wert zurückgesetzt.");
|
||||
});
|
||||
|
||||
// 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 +190,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());
|
||||
};
|
||||
|
||||
@@ -41,4 +41,10 @@ public class AdminController {
|
||||
documentService.getDocumentsWithoutVersions());
|
||||
return ResponseEntity.ok(new BackfillResult(count));
|
||||
}
|
||||
|
||||
@PostMapping("/backfill-file-hashes")
|
||||
public ResponseEntity<BackfillResult> backfillFileHashes() {
|
||||
int count = documentService.backfillFileHashes();
|
||||
return ResponseEntity.ok(new BackfillResult(count));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
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.Document;
|
||||
import org.raddatz.familienarchiv.model.DocumentAnnotation;
|
||||
import org.raddatz.familienarchiv.security.Permission;
|
||||
import org.raddatz.familienarchiv.security.RequirePermission;
|
||||
import org.raddatz.familienarchiv.service.AnnotationService;
|
||||
import org.raddatz.familienarchiv.service.DocumentService;
|
||||
import org.raddatz.familienarchiv.service.UserService;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.core.Authentication;
|
||||
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 DocumentService documentService;
|
||||
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);
|
||||
Document doc = documentService.getDocumentById(documentId);
|
||||
return annotationService.createAnnotation(documentId, dto, userId, doc.getFileHash());
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,11 @@ package org.raddatz.familienarchiv.controller;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
|
||||
@@ -23,6 +27,7 @@ import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.core.io.InputStreamResource;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
@@ -103,6 +108,73 @@ public class DocumentController {
|
||||
}
|
||||
}
|
||||
|
||||
// --- DELETE ---
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
@RequirePermission(Permission.WRITE_ALL)
|
||||
public ResponseEntity<Void> deleteDocument(@PathVariable UUID id) {
|
||||
documentService.deleteDocument(id);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
// --- QUICK UPLOAD ---
|
||||
|
||||
private static final Set<String> ALLOWED_CONTENT_TYPES = Set.of(
|
||||
"application/pdf", "image/jpeg", "image/png", "image/tiff");
|
||||
|
||||
public record UploadError(String filename, String code) {}
|
||||
public record QuickUploadResult(List<Document> created, List<Document> updated, List<UploadError> errors) {}
|
||||
|
||||
@PostMapping(value = "/quick-upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@RequirePermission(Permission.WRITE_ALL)
|
||||
public QuickUploadResult quickUpload(
|
||||
@RequestPart(value = "files", required = false) List<MultipartFile> files) {
|
||||
List<Document> created = new ArrayList<>();
|
||||
List<Document> updated = new ArrayList<>();
|
||||
List<UploadError> errors = new ArrayList<>();
|
||||
|
||||
if (files == null || files.isEmpty()) {
|
||||
return new QuickUploadResult(created, updated, errors);
|
||||
}
|
||||
|
||||
for (MultipartFile file : files) {
|
||||
if (!ALLOWED_CONTENT_TYPES.contains(file.getContentType())) {
|
||||
errors.add(new UploadError(file.getOriginalFilename(), "UNSUPPORTED_FILE_TYPE"));
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
DocumentService.StoreResult result = documentService.storeDocument(file);
|
||||
if (result.isNew()) {
|
||||
created.add(result.document());
|
||||
} else {
|
||||
updated.add(result.document());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
errors.add(new UploadError(file.getOriginalFilename(), "FILE_UPLOAD_FAILED"));
|
||||
log.warn("Quick upload failed for file {}: {}", file.getOriginalFilename(), e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return new QuickUploadResult(created, updated, errors);
|
||||
}
|
||||
|
||||
@GetMapping("/incomplete-count")
|
||||
public Map<String, Long> getIncompleteCount() {
|
||||
return Map.of("count", documentService.getIncompleteCount());
|
||||
}
|
||||
|
||||
@GetMapping("/incomplete")
|
||||
public List<Document> getIncomplete() {
|
||||
return documentService.findIncompleteDocuments();
|
||||
}
|
||||
|
||||
@GetMapping("/incomplete/next")
|
||||
public ResponseEntity<Document> getNextIncomplete(@RequestParam UUID excludeId) {
|
||||
return documentService.findNextIncompleteDocument(excludeId)
|
||||
.map(ResponseEntity::ok)
|
||||
.orElse(ResponseEntity.noContent().build());
|
||||
}
|
||||
|
||||
@GetMapping("/search")
|
||||
public ResponseEntity<List<Document>> search(
|
||||
@RequestParam(required = false) String q,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -17,4 +17,5 @@ public class DocumentUpdateDTO {
|
||||
private UUID senderId;
|
||||
private List<UUID> receiverIds;
|
||||
private String tags;
|
||||
private Boolean metadataComplete;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ public enum ErrorCode {
|
||||
FILE_NOT_FOUND,
|
||||
/** An error occurred while uploading a file to object storage. 500 */
|
||||
FILE_UPLOAD_FAILED,
|
||||
/** The uploaded file's content type is not supported (PDF/JPEG/PNG/TIFF only). 400 */
|
||||
UNSUPPORTED_FILE_TYPE,
|
||||
|
||||
// --- Users ---
|
||||
/** A user with the given ID or username does not exist. 404 */
|
||||
@@ -38,6 +40,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,
|
||||
|
||||
@@ -39,6 +39,10 @@ public class Document {
|
||||
@Column(name = "content_type")
|
||||
private String contentType;
|
||||
|
||||
// SHA-256 hash of the uploaded file — used to link annotations to a file version
|
||||
@Column(name = "file_hash", length = 64)
|
||||
private String fileHash;
|
||||
|
||||
// Originaler Dateiname beim Upload (z.B. "Brief_Oma_1940.pdf")
|
||||
@Column(name = "original_filename", nullable = false)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@@ -82,6 +86,11 @@ public class Document {
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@Column(name = "metadata_complete", nullable = false)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@Builder.Default
|
||||
private boolean metadataComplete = false;
|
||||
|
||||
@ManyToMany(fetch = FetchType.EAGER)
|
||||
@JoinTable(name = "document_receivers", joinColumns = @JoinColumn(name = "document_id"), inverseJoinColumns = @JoinColumn(name = "person_id"))
|
||||
@Builder.Default
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
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 = "file_hash", length = 64)
|
||||
private String fileHash;
|
||||
|
||||
@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,19 @@
|
||||
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);
|
||||
|
||||
List<DocumentAnnotation> findByDocumentIdAndFileHashIsNull(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);
|
||||
}
|
||||
@@ -21,6 +21,9 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
|
||||
// Wichtig für den Abgleich beim Excel-Import & Datei-Upload
|
||||
Optional<Document> findByOriginalFilename(String originalFilename);
|
||||
|
||||
// Wie oben, gibt aber nur das erste Ergebnis zurück — sicher wenn doppelte Dateinamen existieren
|
||||
Optional<Document> findFirstByOriginalFilename(String originalFilename);
|
||||
|
||||
// Findet alle Dokumente mit einem bestimmten Status
|
||||
// z.B. um alle offenen "PLACEHOLDER" zu finden
|
||||
List<Document> findByStatus(DocumentStatus status);
|
||||
@@ -37,6 +40,14 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
|
||||
@Query("SELECT d FROM Document d WHERE d.id NOT IN (SELECT DISTINCT dv.documentId FROM DocumentVersion dv)")
|
||||
List<Document> findDocumentsWithoutVersions();
|
||||
|
||||
List<Document> findByFileHashIsNullAndFilePathIsNotNull();
|
||||
|
||||
long countByMetadataCompleteFalse();
|
||||
|
||||
List<Document> findByMetadataCompleteFalse(Sort sort);
|
||||
|
||||
Optional<Document> findFirstByMetadataCompleteFalseAndIdNot(UUID id, Sort sort);
|
||||
|
||||
@Query("SELECT DISTINCT d FROM Document d " +
|
||||
"JOIN d.receivers r " +
|
||||
"WHERE " +
|
||||
|
||||
@@ -28,6 +28,9 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
||||
// Lookup by full alias string, used during ODS mass import
|
||||
Optional<Person> findByAliasIgnoreCase(String alias);
|
||||
|
||||
// Exact first+last name match, used for filename-based sender lookup
|
||||
Optional<Person> findByFirstNameIgnoreCaseAndLastNameIgnoreCase(String firstName, String lastName);
|
||||
|
||||
// --- Correspondent queries ---
|
||||
|
||||
@Query(value = """
|
||||
|
||||
@@ -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,83 @@
|
||||
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, String fileHash) {
|
||||
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())
|
||||
.fileHash(fileHash)
|
||||
.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);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void backfillAnnotationFileHashForDocument(UUID documentId, String fileHash) {
|
||||
annotationRepository.findByDocumentIdAndFileHashIsNull(documentId).forEach(a -> {
|
||||
a.setFileHash(fileHash);
|
||||
annotationRepository.save(a);
|
||||
});
|
||||
}
|
||||
|
||||
// ─── 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();
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,8 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
@@ -38,42 +40,55 @@ public class DocumentService {
|
||||
private final FileService fileService;
|
||||
private final TagService tagService;
|
||||
private final DocumentVersionService documentVersionService;
|
||||
private final AnnotationService annotationService;
|
||||
|
||||
public record StoreResult(Document document, boolean isNew) {}
|
||||
|
||||
/**
|
||||
* Lädt eine Datei hoch.
|
||||
* - Prüft, ob ein Eintrag (aus Excel) schon existiert.
|
||||
* - Wenn JA: Aktualisiert Status und verknüpft Datei.
|
||||
* - Wenn NEIN: Erstellt neuen Eintrag (wartet auf Metadaten).
|
||||
* - Wenn JA: Aktualisiert Status und verknüpft Datei — isNew = false.
|
||||
* - Wenn NEIN: Erstellt neuen Eintrag — isNew = true.
|
||||
*/
|
||||
@Transactional
|
||||
public Document storeDocument(MultipartFile file) throws IOException {
|
||||
public StoreResult storeDocument(MultipartFile file) throws IOException {
|
||||
String originalFilename = file.getOriginalFilename();
|
||||
|
||||
// 1. Check for existing record
|
||||
Optional<Document> existingDoc = documentRepository.findByOriginalFilename(originalFilename);
|
||||
// 1. Check for existing record (findFirst to survive duplicate filenames in the DB)
|
||||
Optional<Document> existingDoc = documentRepository.findFirstByOriginalFilename(originalFilename);
|
||||
boolean isNew = existingDoc.isEmpty();
|
||||
Document document;
|
||||
|
||||
if (existingDoc.isPresent()) {
|
||||
document = existingDoc.get();
|
||||
} else {
|
||||
// New uploads from the drop zone always start as incomplete
|
||||
ParsedFilename parsed = parseFilenameData(originalFilename);
|
||||
Person sender = (parsed != null)
|
||||
? personService.findByName(parsed.firstName(), parsed.lastName()).orElse(null)
|
||||
: null;
|
||||
document = Document.builder()
|
||||
.originalFilename(originalFilename)
|
||||
.title(originalFilename)
|
||||
.title(parsed != null ? parsed.title() : stripExtension(originalFilename))
|
||||
.documentDate(parsed != null ? parsed.date() : null)
|
||||
.sender(sender)
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.metadataComplete(false)
|
||||
.build();
|
||||
}
|
||||
|
||||
// 2. Delegate Storage to FileService
|
||||
String s3Key = fileService.uploadFile(file, originalFilename);
|
||||
FileService.UploadResult upload = fileService.uploadFile(file, originalFilename);
|
||||
|
||||
// 3. Update Database
|
||||
document.setFilePath(s3Key);
|
||||
document.setFilePath(upload.s3Key());
|
||||
document.setFileHash(upload.fileHash());
|
||||
document.setContentType(file.getContentType());
|
||||
if (document.getStatus() == DocumentStatus.PLACEHOLDER) {
|
||||
document.setStatus(DocumentStatus.UPLOADED);
|
||||
}
|
||||
|
||||
return documentRepository.save(document);
|
||||
return new StoreResult(documentRepository.save(document), isNew);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -82,15 +97,31 @@ public class DocumentService {
|
||||
? file.getOriginalFilename()
|
||||
: (dto.getTitle() != null ? dto.getTitle() : "Unbenanntes Dokument");
|
||||
|
||||
// If the caller explicitly sets metadataComplete, use it.
|
||||
// Otherwise apply heuristic: complete if at least one key field is present.
|
||||
boolean metadataComplete;
|
||||
if (dto.getMetadataComplete() != null) {
|
||||
metadataComplete = dto.getMetadataComplete();
|
||||
} else {
|
||||
metadataComplete = dto.getDocumentDate() != null
|
||||
|| dto.getSenderId() != null
|
||||
|| (dto.getReceiverIds() != null && !dto.getReceiverIds().isEmpty());
|
||||
}
|
||||
|
||||
String titleToUse = (dto.getTitle() != null && !dto.getTitle().isBlank())
|
||||
? dto.getTitle()
|
||||
: titleFromFilename(filename);
|
||||
|
||||
Document doc = Document.builder()
|
||||
.originalFilename(filename)
|
||||
.title(dto.getTitle())
|
||||
.title(titleToUse)
|
||||
.documentDate(dto.getDocumentDate())
|
||||
.location(dto.getLocation())
|
||||
.documentLocation(dto.getDocumentLocation())
|
||||
.transcription(dto.getTranscription())
|
||||
.summary(dto.getSummary())
|
||||
.status(DocumentStatus.PLACEHOLDER)
|
||||
.metadataComplete(metadataComplete)
|
||||
.build();
|
||||
|
||||
doc = documentRepository.save(doc);
|
||||
@@ -120,8 +151,9 @@ public class DocumentService {
|
||||
|
||||
// Datei
|
||||
if (file != null && !file.isEmpty()) {
|
||||
String s3Key = fileService.uploadFile(file, file.getOriginalFilename());
|
||||
doc.setFilePath(s3Key);
|
||||
FileService.UploadResult upload = fileService.uploadFile(file, file.getOriginalFilename());
|
||||
doc.setFilePath(upload.s3Key());
|
||||
doc.setFileHash(upload.fileHash());
|
||||
doc.setContentType(file.getContentType());
|
||||
doc.setStatus(DocumentStatus.UPLOADED);
|
||||
}
|
||||
@@ -168,14 +200,16 @@ public class DocumentService {
|
||||
doc.getReceivers().clear(); // Alle entfernen
|
||||
}
|
||||
|
||||
// 3b. metadataComplete — only update when explicitly set in the DTO
|
||||
if (dto.getMetadataComplete() != null) {
|
||||
doc.setMetadataComplete(dto.getMetadataComplete());
|
||||
}
|
||||
|
||||
// 4. Datei austauschen (nur wenn eine neue ausgewählt wurde)
|
||||
if (newFile != null && !newFile.isEmpty()) {
|
||||
// Alte Datei könnte man hier theoretisch löschen (optional)
|
||||
|
||||
// Neue Datei hochladen
|
||||
String s3Key = fileService.uploadFile(newFile, newFile.getOriginalFilename());
|
||||
|
||||
doc.setFilePath(s3Key);
|
||||
FileService.UploadResult upload = fileService.uploadFile(newFile, newFile.getOriginalFilename());
|
||||
doc.setFilePath(upload.s3Key());
|
||||
doc.setFileHash(upload.fileHash());
|
||||
doc.setOriginalFilename(newFile.getOriginalFilename());
|
||||
doc.setContentType(newFile.getContentType());
|
||||
doc.setStatus(DocumentStatus.UPLOADED);
|
||||
@@ -232,8 +266,8 @@ public class DocumentService {
|
||||
.and(hasReceiver(receiver))
|
||||
.and(hasTags(tags));
|
||||
|
||||
// Immer sortiert nach Datum
|
||||
return documentRepository.findAll(spec, Sort.by(Sort.Direction.ASC, "documentDate"));
|
||||
// Neueste zuerst (nach Erstellungsdatum)
|
||||
return documentRepository.findAll(spec, Sort.by(Sort.Direction.DESC, "createdAt"));
|
||||
}
|
||||
|
||||
// 2. SPEZIALITÄT: Der Schriftwechsel
|
||||
@@ -275,6 +309,27 @@ public class DocumentService {
|
||||
return documentRepository.findConversation(senderId, receiverId, dateFrom, dateTo, sort);
|
||||
}
|
||||
|
||||
public long getIncompleteCount() {
|
||||
return documentRepository.countByMetadataCompleteFalse();
|
||||
}
|
||||
|
||||
public List<Document> findIncompleteDocuments() {
|
||||
return documentRepository.findByMetadataCompleteFalse(Sort.by(Sort.Direction.DESC, "createdAt"));
|
||||
}
|
||||
|
||||
public Optional<Document> findNextIncompleteDocument(UUID currentId) {
|
||||
return documentRepository.findFirstByMetadataCompleteFalseAndIdNot(
|
||||
currentId, Sort.by(Sort.Direction.DESC, "createdAt"));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteDocument(UUID id) {
|
||||
if (!documentRepository.existsById(id)) {
|
||||
throw DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id);
|
||||
}
|
||||
documentRepository.deleteById(id);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteTagCascading(UUID tagId) {
|
||||
documentRepository.findByTags_Id(tagId).forEach(doc -> {
|
||||
@@ -283,4 +338,120 @@ public class DocumentService {
|
||||
});
|
||||
tagService.delete(tagId);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public int backfillFileHashes() {
|
||||
List<Document> docs = documentRepository.findByFileHashIsNullAndFilePathIsNotNull();
|
||||
int count = 0;
|
||||
for (Document doc : docs) {
|
||||
try {
|
||||
byte[] bytes = fileService.downloadFileBytes(doc.getFilePath());
|
||||
String hash = sha256Hex(bytes);
|
||||
doc.setFileHash(hash);
|
||||
documentRepository.save(doc);
|
||||
annotationService.backfillAnnotationFileHashForDocument(doc.getId(), hash);
|
||||
count++;
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to backfill hash for document {}: {}", doc.getId(), e.getMessage());
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
// ─── private helpers ──────────────────────────────────────────────────────
|
||||
|
||||
private static String stripExtension(String filename) {
|
||||
if (filename == null) return null;
|
||||
int dot = filename.lastIndexOf('.');
|
||||
return dot > 0 ? filename.substring(0, dot) : filename;
|
||||
}
|
||||
|
||||
private record ParsedFilename(LocalDate date, String firstName, String lastName) {
|
||||
String title() {
|
||||
String dateDisplay = String.format("%02d.%02d.%d",
|
||||
date.getDayOfMonth(), date.getMonthValue(), date.getYear());
|
||||
return firstName + " " + lastName + " (" + dateDisplay + ")";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a structured filename into its date and name components.
|
||||
*
|
||||
* Algorithm: split stem on "_", identify the date token (first or last segment),
|
||||
* treat the outermost remaining segment as firstName, rest as lastName parts.
|
||||
* Compound last names (e.g. "de_Gruyter") are supported naturally.
|
||||
* Returns null for unrecognised filenames.
|
||||
*
|
||||
* Examples:
|
||||
* 18881025_de_Gruyter_Walter.pdf → date=1888-10-25, firstName=Walter, lastName=de Gruyter
|
||||
* 1965-03-12_Mueller_Hans.pdf → date=1965-03-12, firstName=Hans, lastName=Mueller
|
||||
* Mueller_Hans_19650312.pdf → date=1965-03-12, firstName=Hans, lastName=Mueller
|
||||
*/
|
||||
private static ParsedFilename parseFilenameData(String filename) {
|
||||
if (filename == null) return null;
|
||||
int dot = filename.lastIndexOf('.');
|
||||
if (dot < 0) return null;
|
||||
String stem = filename.substring(0, dot);
|
||||
|
||||
String[] parts = stem.split("_", -1);
|
||||
if (parts.length < 3) return null;
|
||||
|
||||
String dateIso;
|
||||
String[] nameParts;
|
||||
|
||||
String dateFromFirst = tryParseDate(parts[0]);
|
||||
if (dateFromFirst != null) {
|
||||
dateIso = dateFromFirst;
|
||||
nameParts = Arrays.copyOfRange(parts, 1, parts.length);
|
||||
} else {
|
||||
String dateFromLast = tryParseDate(parts[parts.length - 1]);
|
||||
if (dateFromLast == null) return null;
|
||||
dateIso = dateFromLast;
|
||||
nameParts = Arrays.copyOfRange(parts, 0, parts.length - 1);
|
||||
}
|
||||
|
||||
if (nameParts.length < 2) return null;
|
||||
for (String p : nameParts) {
|
||||
if (!p.matches("\\p{L}+")) return null;
|
||||
}
|
||||
|
||||
String firstName = nameParts[nameParts.length - 1];
|
||||
String lastName = String.join(" ", Arrays.copyOfRange(nameParts, 0, nameParts.length - 1));
|
||||
return new ParsedFilename(LocalDate.parse(dateIso), firstName, lastName);
|
||||
}
|
||||
|
||||
// Used by tests and as a public utility; delegates to parseFilenameData.
|
||||
static String titleFromFilename(String filename) {
|
||||
if (filename == null) return null;
|
||||
ParsedFilename parsed = parseFilenameData(filename);
|
||||
return parsed != null ? parsed.title() : stripExtension(filename);
|
||||
}
|
||||
|
||||
private static String tryParseDate(String s) {
|
||||
if (s.matches("\\d{4}-\\d{2}-\\d{2}")) {
|
||||
int m = Integer.parseInt(s.substring(5, 7));
|
||||
int d = Integer.parseInt(s.substring(8, 10));
|
||||
if (m >= 1 && m <= 12 && d >= 1 && d <= 31) return s;
|
||||
} else if (s.matches("\\d{8}")) {
|
||||
int m = Integer.parseInt(s.substring(4, 6));
|
||||
int d = Integer.parseInt(s.substring(6, 8));
|
||||
if (m >= 1 && m <= 12 && d >= 1 && d <= 31)
|
||||
return s.substring(0, 4) + "-" + s.substring(4, 6) + "-" + s.substring(6, 8);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String sha256Hex(byte[] bytes) {
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
byte[] hash = digest.digest(bytes);
|
||||
StringBuilder sb = new StringBuilder(64);
|
||||
for (byte b : hash) {
|
||||
sb.append(String.format("%02x", b));
|
||||
}
|
||||
return sb.toString();
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new IllegalStateException("SHA-256 not available", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,9 @@ import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.core.io.InputStreamResource;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
@@ -29,10 +32,14 @@ public class FileService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads a file to S3/MinIO and returns the generated object key.
|
||||
* Uploads a file to S3/MinIO.
|
||||
* Returns an {@link UploadResult} containing the S3 key and the SHA-256
|
||||
* hash of the file content. The hash is used to link annotations to the
|
||||
* specific file version they were created against.
|
||||
*/
|
||||
public String uploadFile(MultipartFile file, String originalFilename) throws IOException {
|
||||
// Generate secure unique path: "documents/UUID_filename"
|
||||
public UploadResult uploadFile(MultipartFile file, String originalFilename) throws IOException {
|
||||
byte[] bytes = file.getBytes();
|
||||
String fileHash = sha256Hex(bytes);
|
||||
String s3Key = "documents/" + UUID.randomUUID() + "_" + originalFilename;
|
||||
|
||||
try {
|
||||
@@ -42,11 +49,10 @@ public class FileService {
|
||||
.contentType(file.getContentType())
|
||||
.build();
|
||||
|
||||
s3Client.putObject(putObjectRequest,
|
||||
RequestBody.fromInputStream(file.getInputStream(), file.getSize()));
|
||||
s3Client.putObject(putObjectRequest, RequestBody.fromBytes(bytes));
|
||||
|
||||
log.info("Uploaded file to S3: {}", s3Key);
|
||||
return s3Key;
|
||||
log.info("Uploaded file to S3: {} (hash={})", s3Key, fileHash);
|
||||
return new UploadResult(s3Key, fileHash);
|
||||
} catch (S3Exception e) {
|
||||
log.error("S3 Upload Error", e);
|
||||
throw new IOException("Failed to upload file to storage", e);
|
||||
@@ -58,32 +64,72 @@ public class FileService {
|
||||
* Returns a wrapper containing the stream and content type.
|
||||
*/
|
||||
public S3FileDownload downloadFile(String s3Key) {
|
||||
try {
|
||||
GetObjectRequest getObjectRequest = GetObjectRequest.builder()
|
||||
.bucket(bucketName)
|
||||
.key(s3Key)
|
||||
.build();
|
||||
try {
|
||||
GetObjectRequest getObjectRequest = GetObjectRequest.builder()
|
||||
.bucket(bucketName)
|
||||
.key(s3Key)
|
||||
.build();
|
||||
|
||||
ResponseInputStream<GetObjectResponse> s3Object = s3Client.getObject(getObjectRequest);
|
||||
ResponseInputStream<GetObjectResponse> s3Object = s3Client.getObject(getObjectRequest);
|
||||
|
||||
// Use whatever content type S3 has stored (set at upload time)
|
||||
String contentType = s3Object.response().contentType();
|
||||
if (contentType == null || contentType.isBlank()) {
|
||||
contentType = "application/octet-stream";
|
||||
String contentType = s3Object.response().contentType();
|
||||
if (contentType == null || contentType.isBlank()) {
|
||||
contentType = "application/octet-stream";
|
||||
}
|
||||
|
||||
return new S3FileDownload(new InputStreamResource(s3Object), contentType);
|
||||
|
||||
} catch (NoSuchKeyException e) {
|
||||
throw new StorageFileNotFoundException("File not found in storage: " + s3Key);
|
||||
} catch (S3Exception e) {
|
||||
throw new RuntimeException("Storage Error: " + e.getMessage());
|
||||
}
|
||||
|
||||
return new S3FileDownload(new InputStreamResource(s3Object), contentType);
|
||||
|
||||
} catch (NoSuchKeyException e) {
|
||||
throw new StorageFileNotFoundException("File not found in storage: " + s3Key);
|
||||
} catch (S3Exception e) {
|
||||
throw new RuntimeException("Storage Error: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
// Helper Record to carry the stream and metadata back to the controller
|
||||
|
||||
/**
|
||||
* Downloads a file from S3/MinIO and returns its raw bytes.
|
||||
* Used for hash backfill — callers are responsible for not calling this on large files unnecessarily.
|
||||
*/
|
||||
public byte[] downloadFileBytes(String s3Key) throws IOException {
|
||||
try {
|
||||
GetObjectRequest getObjectRequest = GetObjectRequest.builder()
|
||||
.bucket(bucketName)
|
||||
.key(s3Key)
|
||||
.build();
|
||||
try (InputStream in = s3Client.getObject(getObjectRequest)) {
|
||||
return in.readAllBytes();
|
||||
}
|
||||
} catch (NoSuchKeyException e) {
|
||||
throw new StorageFileNotFoundException("File not found in storage: " + s3Key);
|
||||
} catch (S3Exception e) {
|
||||
throw new IOException("Failed to download file from storage: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── private helpers ──────────────────────────────────────────────────────
|
||||
|
||||
private static String sha256Hex(byte[] bytes) {
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
byte[] hash = digest.digest(bytes);
|
||||
StringBuilder sb = new StringBuilder(64);
|
||||
for (byte b : hash) {
|
||||
sb.append(String.format("%02x", b));
|
||||
}
|
||||
return sb.toString();
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new IllegalStateException("SHA-256 not available", e);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── result types ─────────────────────────────────────────────────────────
|
||||
|
||||
/** Carries the S3 object key and the content hash back to the caller. */
|
||||
public record UploadResult(String s3Key, String fileHash) {}
|
||||
|
||||
/** Carries the download stream and content type. */
|
||||
public record S3FileDownload(InputStreamResource resource, String contentType) {}
|
||||
|
||||
// Custom Exception
|
||||
public static class StorageFileNotFoundException extends RuntimeException {
|
||||
public StorageFileNotFoundException(String message) { super(message); }
|
||||
}
|
||||
|
||||
@@ -312,6 +312,9 @@ public class MassImportService {
|
||||
.originalFilename(originalFilename)
|
||||
.build());
|
||||
|
||||
// Heuristic: mark as complete if at least one key field is present in the spreadsheet row
|
||||
boolean metadataComplete = date != null || !senderRaw.isBlank() || !receiversRaw.isBlank();
|
||||
|
||||
doc.setTitle(buildTitle(index, date, location));
|
||||
doc.setFilePath(s3Key);
|
||||
doc.setContentType(contentType);
|
||||
@@ -325,6 +328,7 @@ public class MassImportService {
|
||||
doc.setSender(sender);
|
||||
doc.getReceivers().addAll(receivers);
|
||||
if (tag != null) doc.getTags().add(tag);
|
||||
doc.setMetadataComplete(metadataComplete);
|
||||
|
||||
documentRepository.save(doc);
|
||||
log.info("Importiert{}: {}", file.isEmpty() ? " (nur Metadaten)" : "", originalFilename);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.raddatz.familienarchiv.service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.raddatz.familienarchiv.dto.PersonUpdateDTO;
|
||||
@@ -42,6 +43,10 @@ public class PersonService {
|
||||
return personRepository.findAllById(ids);
|
||||
}
|
||||
|
||||
public Optional<Person> findByName(String firstName, String lastName) {
|
||||
return personRepository.findByFirstNameIgnoreCaseAndLastNameIgnoreCase(firstName, lastName);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Person findOrCreateByAlias(String rawName) {
|
||||
String alias = rawName.trim();
|
||||
|
||||
@@ -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,7 @@
|
||||
-- Add content-based file hash to documents for annotation versioning
|
||||
ALTER TABLE documents
|
||||
ADD COLUMN file_hash VARCHAR(64);
|
||||
|
||||
-- Each annotation remembers which file version it was created against
|
||||
ALTER TABLE document_annotations
|
||||
ADD COLUMN file_hash VARCHAR(64);
|
||||
@@ -0,0 +1,12 @@
|
||||
-- Add ON DELETE CASCADE to document_tags and document_receivers so that
|
||||
-- deleting a document automatically removes its tag and receiver associations.
|
||||
|
||||
ALTER TABLE public.document_tags
|
||||
DROP CONSTRAINT fkc99c5qjulwx9gru07yrhicgd2,
|
||||
ADD CONSTRAINT fkc99c5qjulwx9gru07yrhicgd2
|
||||
FOREIGN KEY (document_id) REFERENCES public.documents(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE public.document_receivers
|
||||
DROP CONSTRAINT fks7t60twjgfmpeqcuc3g0fvjpm,
|
||||
ADD CONSTRAINT fks7t60twjgfmpeqcuc3g0fvjpm
|
||||
FOREIGN KEY (document_id) REFERENCES public.documents(id) ON DELETE CASCADE;
|
||||
@@ -0,0 +1,6 @@
|
||||
-- Add metadata_complete flag to documents.
|
||||
-- Existing rows default to true (already reviewed before this feature existed).
|
||||
-- New documents created via Java will receive false from the entity default.
|
||||
|
||||
ALTER TABLE documents
|
||||
ADD COLUMN metadata_complete BOOLEAN NOT NULL DEFAULT TRUE;
|
||||
@@ -58,4 +58,29 @@ class AdminControllerTest {
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.count").value(1));
|
||||
}
|
||||
|
||||
// ─── POST /api/admin/backfill-file-hashes ──────────────────────────────────
|
||||
|
||||
@Test
|
||||
void backfillFileHashes_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(post("/api/admin/backfill-file-hashes"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(roles = "USER")
|
||||
void backfillFileHashes_returns403_whenNotAdmin() throws Exception {
|
||||
mockMvc.perform(post("/api/admin/backfill-file-hashes"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "ADMIN")
|
||||
void backfillFileHashes_returns200_withCount_whenAdmin() throws Exception {
|
||||
when(documentService.backfillFileHashes()).thenReturn(3);
|
||||
|
||||
mockMvc.perform(post("/api/admin/backfill-file-hashes"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.count").value(3));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
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.Document;
|
||||
import org.raddatz.familienarchiv.model.DocumentAnnotation;
|
||||
import org.raddatz.familienarchiv.security.PermissionAspect;
|
||||
import org.raddatz.familienarchiv.service.AnnotationService;
|
||||
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
|
||||
import org.raddatz.familienarchiv.service.DocumentService;
|
||||
import org.raddatz.familienarchiv.service.UserService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
|
||||
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 DocumentService documentService;
|
||||
@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(documentService.getDocumentById(any())).thenReturn(Document.builder().build());
|
||||
when(annotationService.createAnnotation(any(), any(), any(), any())).thenReturn(saved);
|
||||
|
||||
mockMvc.perform(post("/api/documents/" + docId + "/annotations")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(ANNOTATION_JSON))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.pageNumber").value(1));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "ANNOTATE_ALL")
|
||||
void createAnnotation_returns409_whenOverlap() throws Exception {
|
||||
when(documentService.getDocumentById(any())).thenReturn(Document.builder().build());
|
||||
when(annotationService.createAnnotation(any(), any(), any(), any()))
|
||||
.thenThrow(DomainException.conflict(ErrorCode.ANNOTATION_OVERLAP, "Overlap"));
|
||||
|
||||
mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations")
|
||||
.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());
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import org.springframework.test.web.servlet.MockMvc;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
@@ -121,6 +122,169 @@ class DocumentControllerTest {
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
// ─── DELETE /api/documents/{id} ──────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void deleteDocument_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders
|
||||
.delete("/api/documents/" + UUID.randomUUID()))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void deleteDocument_returns403_whenMissingWritePermission() throws Exception {
|
||||
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders
|
||||
.delete("/api/documents/" + UUID.randomUUID()))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void deleteDocument_returns204_whenHasWritePermission() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders
|
||||
.delete("/api/documents/" + id))
|
||||
.andExpect(status().isNoContent());
|
||||
}
|
||||
|
||||
// ─── POST /api/documents/quick-upload ────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void quickUpload_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(multipart("/api/documents/quick-upload"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void quickUpload_returns403_whenMissingWritePermission() throws Exception {
|
||||
mockMvc.perform(multipart("/api/documents/quick-upload"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void quickUpload_returns200_withValidPdfFile() throws Exception {
|
||||
Document doc = Document.builder()
|
||||
.id(UUID.randomUUID()).title("scan001").originalFilename("scan001.pdf").build();
|
||||
when(documentService.storeDocument(any()))
|
||||
.thenReturn(new DocumentService.StoreResult(doc, true));
|
||||
|
||||
org.springframework.mock.web.MockMultipartFile file =
|
||||
new org.springframework.mock.web.MockMultipartFile("files", "scan001.pdf", "application/pdf", new byte[]{1});
|
||||
|
||||
mockMvc.perform(multipart("/api/documents/quick-upload").file(file))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.created[0].title").value("scan001"))
|
||||
.andExpect(jsonPath("$.updated").isEmpty())
|
||||
.andExpect(jsonPath("$.errors").isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void quickUpload_placesDocumentInUpdated_whenFilenameAlreadyExists() throws Exception {
|
||||
Document existing = Document.builder()
|
||||
.id(UUID.randomUUID()).title("Alter Brief").originalFilename("scan001.pdf").build();
|
||||
when(documentService.storeDocument(any()))
|
||||
.thenReturn(new DocumentService.StoreResult(existing, false));
|
||||
|
||||
org.springframework.mock.web.MockMultipartFile file =
|
||||
new org.springframework.mock.web.MockMultipartFile("files", "scan001.pdf", "application/pdf", new byte[]{1});
|
||||
|
||||
mockMvc.perform(multipart("/api/documents/quick-upload").file(file))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.created").isEmpty())
|
||||
.andExpect(jsonPath("$.updated[0].title").value("Alter Brief"))
|
||||
.andExpect(jsonPath("$.errors").isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void quickUpload_skipsUnsupportedFileType_andReturnsError() throws Exception {
|
||||
org.springframework.mock.web.MockMultipartFile file =
|
||||
new org.springframework.mock.web.MockMultipartFile("files", "report.docx",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document", new byte[]{1});
|
||||
|
||||
mockMvc.perform(multipart("/api/documents/quick-upload").file(file))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.created").isEmpty())
|
||||
.andExpect(jsonPath("$.errors[0].filename").value("report.docx"))
|
||||
.andExpect(jsonPath("$.errors[0].code").value("UNSUPPORTED_FILE_TYPE"));
|
||||
}
|
||||
|
||||
// ─── GET /api/documents/incomplete-count ─────────────────────────────────
|
||||
|
||||
@Test
|
||||
void getIncompleteCount_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(get("/api/documents/incomplete-count"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void getIncompleteCount_returns200_withCount() throws Exception {
|
||||
when(documentService.getIncompleteCount()).thenReturn(3L);
|
||||
|
||||
mockMvc.perform(get("/api/documents/incomplete-count"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.count").value(3));
|
||||
}
|
||||
|
||||
// ─── GET /api/documents/incomplete ───────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void getIncomplete_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(get("/api/documents/incomplete"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void getIncomplete_returns200_withList() throws Exception {
|
||||
Document doc = Document.builder()
|
||||
.id(UUID.randomUUID()).title("Unvollständig").originalFilename("scan.pdf").build();
|
||||
when(documentService.findIncompleteDocuments()).thenReturn(List.of(doc));
|
||||
|
||||
mockMvc.perform(get("/api/documents/incomplete"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$[0].title").value("Unvollständig"));
|
||||
}
|
||||
|
||||
// ─── GET /api/documents/incomplete/next ──────────────────────────────────
|
||||
|
||||
@Test
|
||||
void getNextIncomplete_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(get("/api/documents/incomplete/next")
|
||||
.param("excludeId", UUID.randomUUID().toString()))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void getNextIncomplete_returns200_whenNextExists() throws Exception {
|
||||
UUID excludeId = UUID.randomUUID();
|
||||
Document next = Document.builder()
|
||||
.id(UUID.randomUUID()).title("Nächster").originalFilename("next.pdf").build();
|
||||
when(documentService.findNextIncompleteDocument(excludeId)).thenReturn(Optional.of(next));
|
||||
|
||||
mockMvc.perform(get("/api/documents/incomplete/next")
|
||||
.param("excludeId", excludeId.toString()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.title").value("Nächster"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void getNextIncomplete_returns204_whenNoneRemain() throws Exception {
|
||||
UUID excludeId = UUID.randomUUID();
|
||||
when(documentService.findNextIncompleteDocument(excludeId)).thenReturn(Optional.empty());
|
||||
|
||||
mockMvc.perform(get("/api/documents/incomplete/next")
|
||||
.param("excludeId", excludeId.toString()))
|
||||
.andExpect(status().isNoContent());
|
||||
}
|
||||
|
||||
// ─── GET /api/documents/{id}/versions ────────────────────────────────────
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
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, null))
|
||||
.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, null);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createAnnotation_setsFileHash_whenProvided() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID userId = UUID.randomUUID();
|
||||
CreateAnnotationDTO dto = new CreateAnnotationDTO(1, 0.0, 0.0, 0.05, 0.05, "#ff0000");
|
||||
String fileHash = "abc123";
|
||||
|
||||
when(annotationRepository.findByDocumentIdAndPageNumber(docId, 1)).thenReturn(List.of());
|
||||
when(annotationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
DocumentAnnotation result = annotationService.createAnnotation(docId, dto, userId, fileHash);
|
||||
|
||||
assertThat(result.getFileHash()).isEqualTo(fileHash);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createAnnotation_setsNullFileHash_whenNoneProvided() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID userId = UUID.randomUUID();
|
||||
CreateAnnotationDTO dto = new CreateAnnotationDTO(1, 0.0, 0.0, 0.05, 0.05, "#ff0000");
|
||||
|
||||
when(annotationRepository.findByDocumentIdAndPageNumber(docId, 1)).thenReturn(List.of());
|
||||
when(annotationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
DocumentAnnotation result = annotationService.createAnnotation(docId, dto, userId, null);
|
||||
|
||||
assertThat(result.getFileHash()).isNull();
|
||||
}
|
||||
|
||||
// ─── listAnnotations ──────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
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);
|
||||
}
|
||||
|
||||
// ─── backfillAnnotationFileHashForDocument ────────────────────────────────
|
||||
|
||||
@Test
|
||||
void backfillAnnotationFileHashForDocument_setsHashOnAnnotationsWithNullHash() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
String hash = "abc123";
|
||||
DocumentAnnotation a = DocumentAnnotation.builder()
|
||||
.id(UUID.randomUUID()).documentId(docId).build();
|
||||
when(annotationRepository.findByDocumentIdAndFileHashIsNull(docId)).thenReturn(List.of(a));
|
||||
|
||||
annotationService.backfillAnnotationFileHashForDocument(docId, hash);
|
||||
|
||||
assertThat(a.getFileHash()).isEqualTo(hash);
|
||||
verify(annotationRepository).save(a);
|
||||
}
|
||||
|
||||
@Test
|
||||
void backfillAnnotationFileHashForDocument_doesNothingWhenNoAnnotations() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
when(annotationRepository.findByDocumentIdAndFileHashIsNull(docId)).thenReturn(List.of());
|
||||
|
||||
annotationService.backfillAnnotationFileHashForDocument(docId, "hash");
|
||||
|
||||
verify(annotationRepository, never()).save(any());
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package org.raddatz.familienarchiv.service;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
@@ -9,9 +10,13 @@ import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||
import org.raddatz.familienarchiv.model.Person;
|
||||
import org.raddatz.familienarchiv.model.Tag;
|
||||
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
@@ -21,6 +26,7 @@ import java.util.UUID;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@@ -31,8 +37,32 @@ class DocumentServiceTest {
|
||||
@Mock FileService fileService;
|
||||
@Mock TagService tagService;
|
||||
@Mock DocumentVersionService documentVersionService;
|
||||
@Mock AnnotationService annotationService;
|
||||
@InjectMocks DocumentService documentService;
|
||||
|
||||
// ─── deleteDocument ───────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void deleteDocument_deletesById_whenExists() {
|
||||
UUID id = UUID.randomUUID();
|
||||
when(documentRepository.existsById(id)).thenReturn(true);
|
||||
|
||||
documentService.deleteDocument(id);
|
||||
|
||||
verify(documentRepository).deleteById(id);
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteDocument_throwsNotFound_whenMissing() {
|
||||
UUID id = UUID.randomUUID();
|
||||
when(documentRepository.existsById(id)).thenReturn(false);
|
||||
|
||||
assertThatThrownBy(() -> documentService.deleteDocument(id))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.hasMessageContaining(id.toString());
|
||||
verify(documentRepository, never()).deleteById(any());
|
||||
}
|
||||
|
||||
// ─── getDocumentById ──────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@@ -135,6 +165,48 @@ class DocumentServiceTest {
|
||||
assertThat(documentService.getDocumentsByReceiver(receiverId)).containsExactly(doc);
|
||||
}
|
||||
|
||||
// ─── file hash propagation ───────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void createDocument_setsFileHashFromUpload_whenFileProvided() throws Exception {
|
||||
DocumentUpdateDTO dto = new DocumentUpdateDTO();
|
||||
dto.setTitle("Doc");
|
||||
org.springframework.mock.web.MockMultipartFile file =
|
||||
new org.springframework.mock.web.MockMultipartFile("file", "scan.pdf", "application/pdf", new byte[]{1});
|
||||
FileService.UploadResult uploadResult = new FileService.UploadResult("documents/uuid_scan.pdf", "deadbeef");
|
||||
|
||||
Document savedDoc = Document.builder().id(UUID.randomUUID()).title("Doc")
|
||||
.originalFilename("scan.pdf").status(DocumentStatus.PLACEHOLDER).build();
|
||||
when(documentRepository.save(any())).thenReturn(savedDoc);
|
||||
when(documentRepository.findById(any())).thenReturn(Optional.of(savedDoc));
|
||||
when(fileService.uploadFile(any(), any())).thenReturn(uploadResult);
|
||||
|
||||
documentService.createDocument(dto, file);
|
||||
|
||||
org.mockito.ArgumentCaptor<Document> captor = org.mockito.ArgumentCaptor.forClass(Document.class);
|
||||
verify(documentRepository, atLeastOnce()).save(captor.capture());
|
||||
assertThat(captor.getAllValues()).anySatisfy(d -> assertThat(d.getFileHash()).isEqualTo("deadbeef"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateDocument_setsFileHashFromUpload_whenNewFileProvided() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
Document existing = Document.builder()
|
||||
.id(id).title("Alt").originalFilename("old.pdf")
|
||||
.status(DocumentStatus.UPLOADED).build();
|
||||
org.springframework.mock.web.MockMultipartFile newFile =
|
||||
new org.springframework.mock.web.MockMultipartFile("file", "new.pdf", "application/pdf", new byte[]{2});
|
||||
FileService.UploadResult uploadResult = new FileService.UploadResult("documents/uuid_new.pdf", "cafebabe");
|
||||
|
||||
when(documentRepository.findById(id)).thenReturn(Optional.of(existing));
|
||||
when(fileService.uploadFile(any(), any())).thenReturn(uploadResult);
|
||||
when(documentRepository.save(any())).thenReturn(existing);
|
||||
|
||||
documentService.updateDocument(id, new DocumentUpdateDTO(), newFile);
|
||||
|
||||
assertThat(existing.getFileHash()).isEqualTo("cafebabe");
|
||||
}
|
||||
|
||||
// ─── versioning ───────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@@ -167,4 +239,435 @@ class DocumentServiceTest {
|
||||
|
||||
verify(documentVersionService).recordVersion(any(Document.class));
|
||||
}
|
||||
|
||||
// ─── storeDocument ───────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void storeDocument_setsTitle_withoutFileExtension_forNewDocument() throws Exception {
|
||||
org.springframework.mock.web.MockMultipartFile file =
|
||||
new org.springframework.mock.web.MockMultipartFile("file", "scan001.pdf", "application/pdf", new byte[]{1});
|
||||
FileService.UploadResult uploadResult = new FileService.UploadResult("documents/uuid_scan001.pdf", "abc123");
|
||||
Document saved = Document.builder().id(UUID.randomUUID()).title("scan001").originalFilename("scan001.pdf").build();
|
||||
|
||||
when(documentRepository.findFirstByOriginalFilename("scan001.pdf")).thenReturn(Optional.empty());
|
||||
when(documentRepository.save(any())).thenReturn(saved);
|
||||
when(fileService.uploadFile(any(), any())).thenReturn(uploadResult);
|
||||
|
||||
org.mockito.ArgumentCaptor<Document> captor = org.mockito.ArgumentCaptor.forClass(Document.class);
|
||||
documentService.storeDocument(file);
|
||||
|
||||
verify(documentRepository).save(captor.capture());
|
||||
assertThat(captor.getValue().getTitle()).isEqualTo("scan001");
|
||||
}
|
||||
|
||||
@Test
|
||||
void storeDocument_preservesExistingTitle_whenPlaceholderAlreadyExists() throws Exception {
|
||||
org.springframework.mock.web.MockMultipartFile file =
|
||||
new org.springframework.mock.web.MockMultipartFile("file", "scan001.pdf", "application/pdf", new byte[]{1});
|
||||
FileService.UploadResult uploadResult = new FileService.UploadResult("documents/uuid_scan001.pdf", "abc123");
|
||||
Document placeholder = Document.builder()
|
||||
.id(UUID.randomUUID()).title("Brief an Oma").originalFilename("scan001.pdf")
|
||||
.status(DocumentStatus.PLACEHOLDER).build();
|
||||
|
||||
when(documentRepository.findFirstByOriginalFilename("scan001.pdf")).thenReturn(Optional.of(placeholder));
|
||||
when(documentRepository.save(any())).thenReturn(placeholder);
|
||||
when(fileService.uploadFile(any(), any())).thenReturn(uploadResult);
|
||||
|
||||
documentService.storeDocument(file);
|
||||
|
||||
assertThat(placeholder.getTitle()).isEqualTo("Brief an Oma");
|
||||
}
|
||||
|
||||
@Test
|
||||
void storeDocument_marksResultAsNew_whenNoExistingDocument() throws Exception {
|
||||
org.springframework.mock.web.MockMultipartFile file =
|
||||
new org.springframework.mock.web.MockMultipartFile("file", "new.pdf", "application/pdf", new byte[]{1});
|
||||
Document saved = Document.builder().id(UUID.randomUUID()).originalFilename("new.pdf").build();
|
||||
|
||||
when(documentRepository.findFirstByOriginalFilename("new.pdf")).thenReturn(Optional.empty());
|
||||
when(documentRepository.save(any())).thenReturn(saved);
|
||||
when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("documents/new.pdf", "hash"));
|
||||
|
||||
DocumentService.StoreResult result = documentService.storeDocument(file);
|
||||
|
||||
assertThat(result.isNew()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void storeDocument_marksResultAsNotNew_whenDocumentWithSameFilenameExists() throws Exception {
|
||||
org.springframework.mock.web.MockMultipartFile file =
|
||||
new org.springframework.mock.web.MockMultipartFile("file", "existing.pdf", "application/pdf", new byte[]{1});
|
||||
Document existing = Document.builder().id(UUID.randomUUID()).originalFilename("existing.pdf")
|
||||
.status(DocumentStatus.UPLOADED).build();
|
||||
|
||||
when(documentRepository.findFirstByOriginalFilename("existing.pdf")).thenReturn(Optional.of(existing));
|
||||
when(documentRepository.save(any())).thenReturn(existing);
|
||||
when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("documents/existing.pdf", "hash"));
|
||||
|
||||
DocumentService.StoreResult result = documentService.storeDocument(file);
|
||||
|
||||
assertThat(result.isNew()).isFalse();
|
||||
}
|
||||
|
||||
// ─── backfillFileHashes ───────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void backfillFileHashes_skipsDocumentsWithNoFilePath() throws Exception {
|
||||
Document noFile = Document.builder().id(UUID.randomUUID()).build();
|
||||
when(documentRepository.findByFileHashIsNullAndFilePathIsNotNull()).thenReturn(List.of());
|
||||
|
||||
int count = documentService.backfillFileHashes();
|
||||
|
||||
assertThat(count).isZero();
|
||||
verify(fileService, never()).downloadFileBytes(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void backfillFileHashes_computesHashAndSavesDocument() throws Exception {
|
||||
UUID docId = UUID.randomUUID();
|
||||
Document doc = Document.builder().id(docId).filePath("documents/scan.pdf").build();
|
||||
when(documentRepository.findByFileHashIsNullAndFilePathIsNotNull()).thenReturn(List.of(doc));
|
||||
when(fileService.downloadFileBytes("documents/scan.pdf")).thenReturn(new byte[]{1, 2, 3});
|
||||
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
documentService.backfillFileHashes();
|
||||
|
||||
assertThat(doc.getFileHash()).isNotNull().hasSize(64);
|
||||
verify(documentRepository).save(doc);
|
||||
}
|
||||
|
||||
@Test
|
||||
void backfillFileHashes_propagatesHashToAnnotations() throws Exception {
|
||||
UUID docId = UUID.randomUUID();
|
||||
Document doc = Document.builder().id(docId).filePath("documents/scan.pdf").build();
|
||||
when(documentRepository.findByFileHashIsNullAndFilePathIsNotNull()).thenReturn(List.of(doc));
|
||||
when(fileService.downloadFileBytes("documents/scan.pdf")).thenReturn(new byte[]{1, 2, 3});
|
||||
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
documentService.backfillFileHashes();
|
||||
|
||||
verify(annotationService).backfillAnnotationFileHashForDocument(eq(docId), any());
|
||||
}
|
||||
|
||||
// ─── getIncompleteCount ───────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void getIncompleteCount_delegatesToRepository() {
|
||||
when(documentRepository.countByMetadataCompleteFalse()).thenReturn(5L);
|
||||
assertThat(documentService.getIncompleteCount()).isEqualTo(5L);
|
||||
}
|
||||
|
||||
// ─── findIncompleteDocuments ──────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void findIncompleteDocuments_returnsDocumentsOrderedByCreatedAtDesc() {
|
||||
Document doc = Document.builder().id(UUID.randomUUID()).title("Test").build();
|
||||
when(documentRepository.findByMetadataCompleteFalse(any(Sort.class))).thenReturn(List.of(doc));
|
||||
|
||||
assertThat(documentService.findIncompleteDocuments()).containsExactly(doc);
|
||||
verify(documentRepository).findByMetadataCompleteFalse(Sort.by(Sort.Direction.DESC, "createdAt"));
|
||||
}
|
||||
|
||||
// ─── findNextIncompleteDocument ───────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void findNextIncompleteDocument_returnsNext_whenAnotherIncompleteExists() {
|
||||
UUID currentId = UUID.randomUUID();
|
||||
Document next = Document.builder().id(UUID.randomUUID()).title("Next").build();
|
||||
when(documentRepository.findFirstByMetadataCompleteFalseAndIdNot(eq(currentId), any(Sort.class)))
|
||||
.thenReturn(Optional.of(next));
|
||||
|
||||
assertThat(documentService.findNextIncompleteDocument(currentId)).contains(next);
|
||||
}
|
||||
|
||||
@Test
|
||||
void findNextIncompleteDocument_returnsEmpty_whenNoMoreIncomplete() {
|
||||
UUID currentId = UUID.randomUUID();
|
||||
when(documentRepository.findFirstByMetadataCompleteFalseAndIdNot(eq(currentId), any(Sort.class)))
|
||||
.thenReturn(Optional.empty());
|
||||
|
||||
assertThat(documentService.findNextIncompleteDocument(currentId)).isEmpty();
|
||||
}
|
||||
|
||||
// ─── storeDocument metadataComplete ──────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void storeDocument_setsMetadataCompleteFalse_forNewDocument() throws Exception {
|
||||
MockMultipartFile file = new MockMultipartFile("file", "scan.pdf", "application/pdf", new byte[]{1});
|
||||
Document saved = Document.builder().id(UUID.randomUUID()).originalFilename("scan.pdf").build();
|
||||
when(documentRepository.findFirstByOriginalFilename("scan.pdf")).thenReturn(Optional.empty());
|
||||
when(documentRepository.save(any())).thenReturn(saved);
|
||||
when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("path", "hash"));
|
||||
|
||||
ArgumentCaptor<Document> captor = ArgumentCaptor.forClass(Document.class);
|
||||
documentService.storeDocument(file);
|
||||
|
||||
verify(documentRepository).save(captor.capture());
|
||||
assertThat(captor.getValue().isMetadataComplete()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void storeDocument_doesNotChangeMetadataComplete_forExistingDocument() throws Exception {
|
||||
MockMultipartFile file = new MockMultipartFile("file", "scan.pdf", "application/pdf", new byte[]{1});
|
||||
Document existing = Document.builder().id(UUID.randomUUID()).originalFilename("scan.pdf")
|
||||
.status(DocumentStatus.PLACEHOLDER).metadataComplete(true).build();
|
||||
when(documentRepository.findFirstByOriginalFilename("scan.pdf")).thenReturn(Optional.of(existing));
|
||||
when(documentRepository.save(any())).thenReturn(existing);
|
||||
when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("path", "hash"));
|
||||
|
||||
documentService.storeDocument(file);
|
||||
|
||||
assertThat(existing.isMetadataComplete()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void storeDocument_parsesDateFromFilename_forNewDocument() throws Exception {
|
||||
MockMultipartFile file = new MockMultipartFile("file", "19650312_Mueller_Hans.pdf", "application/pdf", new byte[]{1});
|
||||
when(documentRepository.findFirstByOriginalFilename(any())).thenReturn(Optional.empty());
|
||||
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("path", "hash"));
|
||||
when(personService.findByName(any(), any())).thenReturn(Optional.empty());
|
||||
|
||||
ArgumentCaptor<Document> captor = ArgumentCaptor.forClass(Document.class);
|
||||
documentService.storeDocument(file);
|
||||
|
||||
verify(documentRepository).save(captor.capture());
|
||||
assertThat(captor.getValue().getDocumentDate()).isEqualTo(java.time.LocalDate.of(1965, 3, 12));
|
||||
assertThat(captor.getValue().getTitle()).isEqualTo("Hans Mueller (12.03.1965)");
|
||||
}
|
||||
|
||||
@Test
|
||||
void storeDocument_setsSender_whenPersonExistsForParsedName() throws Exception {
|
||||
MockMultipartFile file = new MockMultipartFile("file", "18881025_de_Gruyter_Walter.pdf", "application/pdf", new byte[]{1});
|
||||
Person walter = Person.builder().id(UUID.randomUUID()).firstName("Walter").lastName("de Gruyter").build();
|
||||
when(documentRepository.findFirstByOriginalFilename(any())).thenReturn(Optional.empty());
|
||||
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("path", "hash"));
|
||||
when(personService.findByName("Walter", "de Gruyter")).thenReturn(Optional.of(walter));
|
||||
|
||||
ArgumentCaptor<Document> captor = ArgumentCaptor.forClass(Document.class);
|
||||
documentService.storeDocument(file);
|
||||
|
||||
verify(documentRepository).save(captor.capture());
|
||||
assertThat(captor.getValue().getSender()).isEqualTo(walter);
|
||||
}
|
||||
|
||||
@Test
|
||||
void storeDocument_leavesSenderNull_whenPersonNotFound() throws Exception {
|
||||
MockMultipartFile file = new MockMultipartFile("file", "19650312_Mueller_Hans.pdf", "application/pdf", new byte[]{1});
|
||||
when(documentRepository.findFirstByOriginalFilename(any())).thenReturn(Optional.empty());
|
||||
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("path", "hash"));
|
||||
when(personService.findByName(any(), any())).thenReturn(Optional.empty());
|
||||
|
||||
ArgumentCaptor<Document> captor = ArgumentCaptor.forClass(Document.class);
|
||||
documentService.storeDocument(file);
|
||||
|
||||
verify(documentRepository).save(captor.capture());
|
||||
assertThat(captor.getValue().getSender()).isNull();
|
||||
}
|
||||
|
||||
// ─── createDocument title fallback ────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void createDocument_usesTitleFromFilename_whenDtoTitleIsNull() throws Exception {
|
||||
DocumentUpdateDTO dto = new DocumentUpdateDTO();
|
||||
// dto.title is null
|
||||
MockMultipartFile file = new MockMultipartFile("file", "Brief_1965.pdf", "application/pdf", new byte[]{1});
|
||||
Document saved = Document.builder().id(UUID.randomUUID()).title("Brief_1965")
|
||||
.originalFilename("Brief_1965.pdf").status(DocumentStatus.PLACEHOLDER).build();
|
||||
when(documentRepository.save(any())).thenReturn(saved);
|
||||
when(documentRepository.findById(any())).thenReturn(Optional.of(saved));
|
||||
when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("path", "hash"));
|
||||
|
||||
ArgumentCaptor<Document> captor = ArgumentCaptor.forClass(Document.class);
|
||||
documentService.createDocument(dto, file);
|
||||
|
||||
verify(documentRepository, atLeastOnce()).save(captor.capture());
|
||||
assertThat(captor.getAllValues().get(0).getTitle()).isEqualTo("Brief_1965");
|
||||
}
|
||||
|
||||
@Test
|
||||
void createDocument_usesTitleFromFilename_whenDtoTitleIsBlank() throws Exception {
|
||||
DocumentUpdateDTO dto = new DocumentUpdateDTO();
|
||||
dto.setTitle(" ");
|
||||
MockMultipartFile file = new MockMultipartFile("file", "Rechnung_1980.pdf", "application/pdf", new byte[]{1});
|
||||
Document saved = Document.builder().id(UUID.randomUUID()).title("Rechnung_1980")
|
||||
.originalFilename("Rechnung_1980.pdf").status(DocumentStatus.PLACEHOLDER).build();
|
||||
when(documentRepository.save(any())).thenReturn(saved);
|
||||
when(documentRepository.findById(any())).thenReturn(Optional.of(saved));
|
||||
when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("path", "hash"));
|
||||
|
||||
ArgumentCaptor<Document> captor = ArgumentCaptor.forClass(Document.class);
|
||||
documentService.createDocument(dto, file);
|
||||
|
||||
verify(documentRepository, atLeastOnce()).save(captor.capture());
|
||||
assertThat(captor.getAllValues().get(0).getTitle()).isEqualTo("Rechnung_1980");
|
||||
}
|
||||
|
||||
@Test
|
||||
void createDocument_keepsDtoTitle_whenProvided() throws Exception {
|
||||
DocumentUpdateDTO dto = new DocumentUpdateDTO();
|
||||
dto.setTitle("Mein Titel");
|
||||
MockMultipartFile file = new MockMultipartFile("file", "scan.pdf", "application/pdf", new byte[]{1});
|
||||
Document saved = Document.builder().id(UUID.randomUUID()).title("Mein Titel")
|
||||
.originalFilename("scan.pdf").status(DocumentStatus.PLACEHOLDER).build();
|
||||
when(documentRepository.save(any())).thenReturn(saved);
|
||||
when(documentRepository.findById(any())).thenReturn(Optional.of(saved));
|
||||
when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("path", "hash"));
|
||||
|
||||
ArgumentCaptor<Document> captor = ArgumentCaptor.forClass(Document.class);
|
||||
documentService.createDocument(dto, file);
|
||||
|
||||
verify(documentRepository, atLeastOnce()).save(captor.capture());
|
||||
assertThat(captor.getAllValues().get(0).getTitle()).isEqualTo("Mein Titel");
|
||||
}
|
||||
|
||||
// ─── createDocument metadataComplete ─────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void createDocument_setsMetadataCompleteFromDto_whenExplicitlyProvided() throws Exception {
|
||||
DocumentUpdateDTO dto = new DocumentUpdateDTO();
|
||||
dto.setTitle("Doc");
|
||||
dto.setMetadataComplete(true);
|
||||
Document saved = Document.builder().id(UUID.randomUUID()).title("Doc")
|
||||
.originalFilename("Doc").status(DocumentStatus.PLACEHOLDER).build();
|
||||
when(documentRepository.save(any())).thenReturn(saved);
|
||||
when(documentRepository.findById(any())).thenReturn(Optional.of(saved));
|
||||
|
||||
ArgumentCaptor<Document> captor = ArgumentCaptor.forClass(Document.class);
|
||||
documentService.createDocument(dto, null);
|
||||
|
||||
verify(documentRepository, atLeastOnce()).save(captor.capture());
|
||||
assertThat(captor.getAllValues().get(0).isMetadataComplete()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void createDocument_setsMetadataCompleteFalse_whenAllKeyFieldsMissingAndNoExplicitFlag() throws Exception {
|
||||
DocumentUpdateDTO dto = new DocumentUpdateDTO();
|
||||
dto.setTitle("Doc");
|
||||
// no documentDate, no senderId, no receiverIds, no metadataComplete flag
|
||||
Document saved = Document.builder().id(UUID.randomUUID()).title("Doc")
|
||||
.originalFilename("Doc").status(DocumentStatus.PLACEHOLDER).build();
|
||||
when(documentRepository.save(any())).thenReturn(saved);
|
||||
when(documentRepository.findById(any())).thenReturn(Optional.of(saved));
|
||||
|
||||
ArgumentCaptor<Document> captor = ArgumentCaptor.forClass(Document.class);
|
||||
documentService.createDocument(dto, null);
|
||||
|
||||
verify(documentRepository, atLeastOnce()).save(captor.capture());
|
||||
assertThat(captor.getAllValues().get(0).isMetadataComplete()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void createDocument_setsMetadataCompleteTrue_whenDatePresentAndNoExplicitFlag() throws Exception {
|
||||
DocumentUpdateDTO dto = new DocumentUpdateDTO();
|
||||
dto.setTitle("Doc");
|
||||
dto.setDocumentDate(LocalDate.of(2020, 1, 1));
|
||||
Document saved = Document.builder().id(UUID.randomUUID()).title("Doc")
|
||||
.originalFilename("Doc").status(DocumentStatus.PLACEHOLDER).build();
|
||||
when(documentRepository.save(any())).thenReturn(saved);
|
||||
when(documentRepository.findById(any())).thenReturn(Optional.of(saved));
|
||||
|
||||
ArgumentCaptor<Document> captor = ArgumentCaptor.forClass(Document.class);
|
||||
documentService.createDocument(dto, null);
|
||||
|
||||
verify(documentRepository, atLeastOnce()).save(captor.capture());
|
||||
assertThat(captor.getAllValues().get(0).isMetadataComplete()).isTrue();
|
||||
}
|
||||
|
||||
// ─── updateDocument metadataComplete ─────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void updateDocument_setsMetadataComplete_whenDtoHasValue() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
Document existing = Document.builder().id(id).title("Doc").originalFilename("doc.pdf")
|
||||
.status(DocumentStatus.PLACEHOLDER).metadataComplete(false).build();
|
||||
when(documentRepository.findById(id)).thenReturn(Optional.of(existing));
|
||||
when(documentRepository.save(any())).thenReturn(existing);
|
||||
|
||||
DocumentUpdateDTO dto = new DocumentUpdateDTO();
|
||||
dto.setMetadataComplete(true);
|
||||
documentService.updateDocument(id, dto, null);
|
||||
|
||||
assertThat(existing.isMetadataComplete()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateDocument_doesNotChangeMetadataComplete_whenDtoHasNull() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
Document existing = Document.builder().id(id).title("Doc").originalFilename("doc.pdf")
|
||||
.status(DocumentStatus.PLACEHOLDER).metadataComplete(false).build();
|
||||
when(documentRepository.findById(id)).thenReturn(Optional.of(existing));
|
||||
when(documentRepository.save(any())).thenReturn(existing);
|
||||
|
||||
DocumentUpdateDTO dto = new DocumentUpdateDTO();
|
||||
// metadataComplete not set → null
|
||||
documentService.updateDocument(id, dto, null);
|
||||
|
||||
assertThat(existing.isMetadataComplete()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void backfillFileHashes_returnsCountOfUpdatedDocuments() throws Exception {
|
||||
UUID id1 = UUID.randomUUID();
|
||||
UUID id2 = UUID.randomUUID();
|
||||
Document doc1 = Document.builder().id(id1).filePath("documents/a.pdf").build();
|
||||
Document doc2 = Document.builder().id(id2).filePath("documents/b.pdf").build();
|
||||
when(documentRepository.findByFileHashIsNullAndFilePathIsNotNull()).thenReturn(List.of(doc1, doc2));
|
||||
when(fileService.downloadFileBytes(any())).thenReturn(new byte[]{1});
|
||||
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
int count = documentService.backfillFileHashes();
|
||||
|
||||
assertThat(count).isEqualTo(2);
|
||||
}
|
||||
|
||||
// ─── titleFromFilename ────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void titleFromFilename_dateIso_name() {
|
||||
assertThat(DocumentService.titleFromFilename("1965-03-12_Mueller_Hans.pdf"))
|
||||
.isEqualTo("Hans Mueller (12.03.1965)");
|
||||
}
|
||||
|
||||
@Test
|
||||
void titleFromFilename_dateCompact_name() {
|
||||
assertThat(DocumentService.titleFromFilename("19650312_Mueller_Hans.pdf"))
|
||||
.isEqualTo("Hans Mueller (12.03.1965)");
|
||||
}
|
||||
|
||||
@Test
|
||||
void titleFromFilename_name_dateIso() {
|
||||
assertThat(DocumentService.titleFromFilename("Mueller_Hans_1965-03-12.pdf"))
|
||||
.isEqualTo("Hans Mueller (12.03.1965)");
|
||||
}
|
||||
|
||||
@Test
|
||||
void titleFromFilename_name_dateCompact() {
|
||||
assertThat(DocumentService.titleFromFilename("Mueller_Hans_19650312.pdf"))
|
||||
.isEqualTo("Hans Mueller (12.03.1965)");
|
||||
}
|
||||
|
||||
@Test
|
||||
void titleFromFilename_compound_lastName_dateFirst() {
|
||||
assertThat(DocumentService.titleFromFilename("18881025_de_Gruyter_Walter.pdf"))
|
||||
.isEqualTo("Walter de Gruyter (25.10.1888)");
|
||||
}
|
||||
|
||||
@Test
|
||||
void titleFromFilename_compound_lastName_dateLast() {
|
||||
assertThat(DocumentService.titleFromFilename("de_Gruyter_Walter_18881025.pdf"))
|
||||
.isEqualTo("Walter de Gruyter (25.10.1888)");
|
||||
}
|
||||
|
||||
@Test
|
||||
void titleFromFilename_fallsBackToStripExtension() {
|
||||
assertThat(DocumentService.titleFromFilename("scan_001.pdf")).isEqualTo("scan_001");
|
||||
}
|
||||
|
||||
@Test
|
||||
void titleFromFilename_null_returnsNull() {
|
||||
assertThat(DocumentService.titleFromFilename(null)).isNull();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package org.raddatz.familienarchiv.service;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import software.amazon.awssdk.core.sync.RequestBody;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class FileServiceTest {
|
||||
|
||||
private S3Client s3Client;
|
||||
private FileService fileService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
s3Client = mock(S3Client.class);
|
||||
fileService = new FileService(s3Client, "test-bucket");
|
||||
}
|
||||
|
||||
@Test
|
||||
void uploadFile_returnsS3Key() throws IOException {
|
||||
MockMultipartFile file = new MockMultipartFile(
|
||||
"file", "test.pdf", "application/pdf", new byte[]{1, 2, 3});
|
||||
|
||||
FileService.UploadResult result = fileService.uploadFile(file, "test.pdf");
|
||||
|
||||
assertThat(result.s3Key()).startsWith("documents/");
|
||||
assertThat(result.s3Key()).endsWith("_test.pdf");
|
||||
verify(s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void uploadFile_returnsCorrectSha256FileHash() throws IOException, NoSuchAlgorithmException {
|
||||
byte[] content = "hello pdf content".getBytes();
|
||||
MockMultipartFile file = new MockMultipartFile(
|
||||
"file", "doc.pdf", "application/pdf", content);
|
||||
|
||||
FileService.UploadResult result = fileService.uploadFile(file, "doc.pdf");
|
||||
|
||||
// Compute expected hash independently
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
byte[] hashBytes = digest.digest(content);
|
||||
StringBuilder expected = new StringBuilder();
|
||||
for (byte b : hashBytes) {
|
||||
expected.append(String.format("%02x", b));
|
||||
}
|
||||
|
||||
assertThat(result.fileHash()).isEqualTo(expected.toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void uploadFile_differentContents_produceDifferentHashes() throws IOException {
|
||||
MockMultipartFile file1 = new MockMultipartFile(
|
||||
"f", "a.pdf", "application/pdf", new byte[]{1, 2, 3});
|
||||
MockMultipartFile file2 = new MockMultipartFile(
|
||||
"f", "b.pdf", "application/pdf", new byte[]{4, 5, 6});
|
||||
|
||||
FileService.UploadResult r1 = fileService.uploadFile(file1, "a.pdf");
|
||||
FileService.UploadResult r2 = fileService.uploadFile(file2, "b.pdf");
|
||||
|
||||
assertThat(r1.fileHash()).isNotEqualTo(r2.fileHash());
|
||||
}
|
||||
|
||||
@Test
|
||||
void uploadFile_sameContents_produceSameHash() throws IOException {
|
||||
byte[] content = new byte[]{10, 20, 30};
|
||||
MockMultipartFile file1 = new MockMultipartFile("f", "x.pdf", "application/pdf", content);
|
||||
MockMultipartFile file2 = new MockMultipartFile("f", "y.pdf", "application/pdf", content);
|
||||
|
||||
FileService.UploadResult r1 = fileService.uploadFile(file1, "x.pdf");
|
||||
FileService.UploadResult r2 = fileService.uploadFile(file2, "y.pdf");
|
||||
|
||||
assertThat(r1.fileHash()).isEqualTo(r2.fileHash());
|
||||
}
|
||||
}
|
||||
@@ -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,19 +180,19 @@ test.describe('Admin — tag management', () => {
|
||||
// Wait for the tags list to render after the tab switch
|
||||
await page.waitForSelector('ul > li');
|
||||
|
||||
// Hover over the "Familie" row to reveal the opacity-0 action buttons
|
||||
const familieRow = page
|
||||
// Hover over the "Fest" row to reveal the opacity-0 action buttons
|
||||
const festRow = page
|
||||
.locator('ul > li')
|
||||
.filter({ has: page.locator('span', { hasText: /^Familie$/ }) });
|
||||
await familieRow.hover();
|
||||
await familieRow.getByRole('button', { name: 'Schlagwort bearbeiten' }).click();
|
||||
.filter({ has: page.locator('span', { hasText: /^Fest$/ }) });
|
||||
await festRow.hover();
|
||||
await festRow.getByRole('button', { name: 'Schlagwort bearbeiten' }).click();
|
||||
|
||||
// After clicking edit, {#if editingTagId} replaces the span with a form —
|
||||
// the familieRow filter no longer matches, so we find the input directly.
|
||||
await page.locator('input[name="name"]').fill('Familie (E2E)');
|
||||
// the festRow filter no longer matches, so we find the input directly.
|
||||
await page.locator('input[name="name"]').fill('Fest (E2E)');
|
||||
await page.getByRole('button', { name: 'Speichern' }).click();
|
||||
|
||||
await expect(page.getByText('Familie (E2E)')).toBeVisible();
|
||||
await expect(page.getByText('Fest (E2E)')).toBeVisible();
|
||||
await page.screenshot({ path: 'test-results/e2e/admin-tag-renamed.png' });
|
||||
});
|
||||
|
||||
@@ -205,14 +205,46 @@ test.describe('Admin — tag management', () => {
|
||||
|
||||
const renamedRow = page
|
||||
.locator('ul > li')
|
||||
.filter({ has: page.locator('span', { hasText: /^Familie \(E2E\)$/ }) });
|
||||
.filter({ has: page.locator('span', { hasText: /^Fest \(E2E\)$/ }) });
|
||||
await renamedRow.hover();
|
||||
await renamedRow.getByRole('button', { name: 'Schlagwort bearbeiten' }).click();
|
||||
|
||||
await page.locator('input[name="name"]').fill('Familie');
|
||||
await page.locator('input[name="name"]').fill('Fest');
|
||||
await page.getByRole('button', { name: 'Speichern' }).click();
|
||||
|
||||
await expect(page.getByText('Familie')).toBeVisible();
|
||||
await expect(page.getByText('Fest')).toBeVisible();
|
||||
await page.screenshot({ path: 'test-results/e2e/admin-tag-restored.png' });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── System tab — backfill file hashes ────────────────────────────────────────
|
||||
|
||||
test.describe('Admin system tab — backfill file hashes', () => {
|
||||
test('admin triggers file hash backfill and sees success message', async ({ request, page }) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
// Create a document via API so there is at least one without a hash
|
||||
const createRes = await request.post('/api/documents', {
|
||||
multipart: { title: 'E2E Backfill Hash Test' }
|
||||
});
|
||||
if (!createRes.ok()) throw new Error(`Create failed: ${createRes.status()}`);
|
||||
|
||||
await page.goto('/admin');
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
|
||||
// Navigate to System tab
|
||||
await page.getByRole('button', { name: /system/i }).click();
|
||||
|
||||
// Click the backfill hashes button
|
||||
const btn = page.getByRole('button', { name: /datei-hashes berechnen/i });
|
||||
await expect(btn).toBeVisible();
|
||||
await btn.click();
|
||||
|
||||
// Success message must appear (count >= 0)
|
||||
await expect(page.locator('text=/\\d+ Dokumente wurden aktualisiert/i')).toBeVisible({
|
||||
timeout: 15000
|
||||
});
|
||||
|
||||
await page.screenshot({ path: 'test-results/e2e/admin-backfill-hashes.png' });
|
||||
});
|
||||
});
|
||||
|
||||
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.
|
||||
@@ -21,7 +25,7 @@ test.describe('Document list', () => {
|
||||
|
||||
test('navigation bar shows active state for Dokumente', async ({ page }) => {
|
||||
const navLink = page.getByRole('navigation').getByRole('link', { name: 'Dokumente' });
|
||||
await expect(navLink).toHaveClass(/text-brand-navy/);
|
||||
await expect(navLink).toHaveClass(/bg-nav-active/);
|
||||
});
|
||||
|
||||
test('text search filters the document list', async ({ page }) => {
|
||||
@@ -73,12 +77,49 @@ test.describe('Document detail', () => {
|
||||
});
|
||||
|
||||
test.describe('New document', () => {
|
||||
test('renders the upload form', async ({ page }) => {
|
||||
test('renders the upload form with file input first', async ({ page }) => {
|
||||
await page.goto('/documents/new');
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
await expect(page.getByRole('heading', { name: /Neues Dokument/i })).toBeVisible();
|
||||
await expect(page.getByLabel('Titel')).toBeVisible();
|
||||
// File input comes before the title field in DOM order
|
||||
const fileInput = page.locator('input[type="file"]');
|
||||
const titleInput = page.getByLabel('Titel');
|
||||
await expect(fileInput).toBeVisible();
|
||||
await expect(titleInput).toBeVisible();
|
||||
const fileBox = await fileInput.boundingBox();
|
||||
const titleBox = await titleInput.boundingBox();
|
||||
expect(fileBox!.y).toBeLessThan(titleBox!.y);
|
||||
await page.screenshot({ path: 'test-results/e2e/document-new.png' });
|
||||
});
|
||||
|
||||
test('title field is pre-filled from filename when a file is selected', async ({ page }) => {
|
||||
await page.goto('/documents/new');
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
const PDF_FIXTURE = path.resolve(__dirname, 'fixtures/minimal.pdf');
|
||||
const fileInput = page.locator('input[type="file"]');
|
||||
await fileInput.setInputFiles({
|
||||
name: 'Brief_1965.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
buffer: fs.readFileSync(PDF_FIXTURE)
|
||||
});
|
||||
await expect(page.getByLabel('Titel')).toHaveValue('Brief_1965');
|
||||
await page.screenshot({ path: 'test-results/e2e/document-new-filename-prefill.png' });
|
||||
});
|
||||
|
||||
test('typed title is not overwritten when a file is selected', async ({ page }) => {
|
||||
await page.goto('/documents/new');
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
await page.getByLabel('Titel').fill('Weihnachtsbrief 1965');
|
||||
const PDF_FIXTURE = path.resolve(__dirname, 'fixtures/minimal.pdf');
|
||||
const fileInput = page.locator('input[type="file"]');
|
||||
await fileInput.setInputFiles({
|
||||
name: 'Brief_1965.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
buffer: fs.readFileSync(PDF_FIXTURE)
|
||||
});
|
||||
await expect(page.getByLabel('Titel')).toHaveValue('Weihnachtsbrief 1965');
|
||||
await page.screenshot({ path: 'test-results/e2e/document-new-title-not-overwritten.png' });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Document creation', () => {
|
||||
@@ -87,12 +128,27 @@ test.describe('Document creation', () => {
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
|
||||
await page.getByLabel('Titel').fill('E2E Testbrief');
|
||||
await page.getByRole('button', { name: /Speichern/i }).click();
|
||||
await page.getByRole('button', { name: 'Speichern', exact: true }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/documents\/[^/]+$/);
|
||||
await expect(page.getByRole('heading', { name: 'E2E Testbrief' })).toBeVisible();
|
||||
await page.screenshot({ path: 'test-results/e2e/document-create.png' });
|
||||
});
|
||||
|
||||
test('user saves a document with only a file — title comes from filename', async ({ page }) => {
|
||||
await page.goto('/documents/new');
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
const PDF_FIXTURE = path.resolve(__dirname, 'fixtures/minimal.pdf');
|
||||
await page.locator('input[type="file"]').setInputFiles({
|
||||
name: 'Brief_1965.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
buffer: fs.readFileSync(PDF_FIXTURE)
|
||||
});
|
||||
await page.getByRole('button', { name: 'Speichern', exact: true }).click();
|
||||
await expect(page).toHaveURL(/\/documents\/[^/]+$/);
|
||||
await expect(page.getByRole('heading', { name: 'Brief_1965' })).toBeVisible();
|
||||
await page.screenshot({ path: 'test-results/e2e/document-create-file-only.png' });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Document editing', () => {
|
||||
@@ -108,10 +164,10 @@ test.describe('Document editing', () => {
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
|
||||
await page.getByLabel('Titel').fill('E2E Testbrief (überarbeitet)');
|
||||
await page.getByRole('button', { name: /Speichern/i }).click();
|
||||
await page.getByRole('button', { name: 'Speichern', exact: true }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/documents\/[^/]+$/);
|
||||
await expect(page.getByText('E2E Testbrief (überarbeitet)')).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: 'E2E Testbrief (überarbeitet)' })).toBeVisible();
|
||||
await page.screenshot({ path: 'test-results/e2e/document-edit-save.png' });
|
||||
});
|
||||
});
|
||||
@@ -150,29 +206,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 +266,298 @@ 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 at least one annotation is visible before enabling annotate mode
|
||||
await expect(page.locator('[data-testid^="annotation-"]').first()).toBeVisible({
|
||||
timeout: 8000
|
||||
});
|
||||
// Record count now — the draw test may have created more than one annotation
|
||||
const countBefore = await page.locator('[data-testid^="annotation-"]').count();
|
||||
|
||||
// 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(countBefore - 1, {
|
||||
timeout: 8000
|
||||
});
|
||||
|
||||
await page.screenshot({ path: 'test-results/e2e/annotation-deleted.png' });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── PDF Annotations — file hash (version awareness) ─────────────────────────
|
||||
|
||||
test.describe('PDF annotations — file hash versioning', () => {
|
||||
const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000';
|
||||
const PDF_FIXTURE2 = path.resolve(__dirname, 'fixtures/minimal2.pdf');
|
||||
|
||||
test('annotations are hidden after a different file is uploaded', async ({ page, request }) => {
|
||||
test.setTimeout(90_000);
|
||||
|
||||
// 1. Create document and upload original PDF
|
||||
const createRes = await request.post('/api/documents', {
|
||||
multipart: { title: 'E2E Hash Test — version' }
|
||||
});
|
||||
if (!createRes.ok()) throw new Error(`Create failed: ${createRes.status()}`);
|
||||
const doc = await createRes.json();
|
||||
|
||||
const uploadRes = await request.put(`/api/documents/${doc.id}`, {
|
||||
multipart: {
|
||||
title: doc.title,
|
||||
file: {
|
||||
name: 'minimal.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
buffer: fs.readFileSync(PDF_FIXTURE)
|
||||
}
|
||||
}
|
||||
});
|
||||
if (!uploadRes.ok()) throw new Error(`Upload failed: ${uploadRes.status()}`);
|
||||
|
||||
// 2. Create an annotation via API
|
||||
const annotRes = await request.post(`/api/documents/${doc.id}/annotations`, {
|
||||
data: { pageNumber: 1, x: 0.1, y: 0.1, width: 0.2, height: 0.2, color: '#ff0000' }
|
||||
});
|
||||
if (!annotRes.ok()) throw new Error(`Create annotation failed: ${annotRes.status()}`);
|
||||
|
||||
// 3. Verify annotation appears before re-upload
|
||||
await page.goto(`${baseURL}/documents/${doc.id}`);
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
await page.locator('canvas').first().waitFor({ state: 'visible', timeout: 20000 });
|
||||
await expect(page.locator('[data-testid^="annotation-"]').first()).toBeVisible({
|
||||
timeout: 8000
|
||||
});
|
||||
|
||||
// 4. Upload a different file (different hash)
|
||||
const reuploadRes = await request.put(`/api/documents/${doc.id}`, {
|
||||
multipart: {
|
||||
title: doc.title,
|
||||
file: {
|
||||
name: 'minimal2.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
buffer: fs.readFileSync(PDF_FIXTURE2)
|
||||
}
|
||||
}
|
||||
});
|
||||
if (!reuploadRes.ok()) throw new Error(`Re-upload failed: ${reuploadRes.status()}`);
|
||||
|
||||
// 5. Reload — annotation must be hidden and notice shown
|
||||
await page.reload();
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
await page.locator('canvas').first().waitFor({ state: 'visible', timeout: 20000 });
|
||||
|
||||
// Use :not() to exclude the outdated-notice and side-panel elements whose testid also starts with "annotation-"
|
||||
await expect(
|
||||
page.locator(
|
||||
'[data-testid^="annotation-"]:not([data-testid="annotation-outdated-notice"]):not([data-testid="annotation-side-panel"])'
|
||||
)
|
||||
).toHaveCount(0, { timeout: 8000 });
|
||||
await expect(page.locator('[data-testid="annotation-outdated-notice"]')).toBeVisible({
|
||||
timeout: 5000
|
||||
});
|
||||
|
||||
await page.screenshot({ path: 'test-results/e2e/annotation-hidden-after-reupload.png' });
|
||||
});
|
||||
|
||||
test('annotations reappear after re-uploading the original file', async ({ page, request }) => {
|
||||
test.setTimeout(90_000);
|
||||
|
||||
// 1. Create document and upload original PDF
|
||||
const createRes = await request.post('/api/documents', {
|
||||
multipart: { title: 'E2E Hash Test — restore' }
|
||||
});
|
||||
if (!createRes.ok()) throw new Error(`Create failed: ${createRes.status()}`);
|
||||
const doc = await createRes.json();
|
||||
|
||||
const originalBytes = fs.readFileSync(PDF_FIXTURE);
|
||||
const uploadRes = await request.put(`/api/documents/${doc.id}`, {
|
||||
multipart: {
|
||||
title: doc.title,
|
||||
file: { name: 'minimal.pdf', mimeType: 'application/pdf', buffer: originalBytes }
|
||||
}
|
||||
});
|
||||
if (!uploadRes.ok()) throw new Error(`Upload failed: ${uploadRes.status()}`);
|
||||
|
||||
// 2. Create annotation
|
||||
const annotRes = await request.post(`/api/documents/${doc.id}/annotations`, {
|
||||
data: { pageNumber: 1, x: 0.1, y: 0.1, width: 0.2, height: 0.2, color: '#0000ff' }
|
||||
});
|
||||
if (!annotRes.ok()) throw new Error(`Create annotation failed: ${annotRes.status()}`);
|
||||
|
||||
// 3. Replace with different file
|
||||
const replaceRes = await request.put(`/api/documents/${doc.id}`, {
|
||||
multipart: {
|
||||
title: doc.title,
|
||||
file: {
|
||||
name: 'minimal2.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
buffer: fs.readFileSync(PDF_FIXTURE2)
|
||||
}
|
||||
}
|
||||
});
|
||||
if (!replaceRes.ok()) throw new Error(`Replace failed: ${replaceRes.status()}`);
|
||||
|
||||
// 4. Re-upload original file (restoring the hash)
|
||||
const restoreRes = await request.put(`/api/documents/${doc.id}`, {
|
||||
multipart: {
|
||||
title: doc.title,
|
||||
file: { name: 'minimal.pdf', mimeType: 'application/pdf', buffer: originalBytes }
|
||||
}
|
||||
});
|
||||
if (!restoreRes.ok()) throw new Error(`Restore failed: ${restoreRes.status()}`);
|
||||
|
||||
// 5. Verify annotation reappears and notice is gone
|
||||
await page.goto(`${baseURL}/documents/${doc.id}`);
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
await page.locator('canvas').first().waitFor({ state: 'visible', timeout: 20000 });
|
||||
|
||||
await expect(page.locator('[data-testid^="annotation-"]').first()).toBeVisible({
|
||||
timeout: 8000
|
||||
});
|
||||
await expect(page.locator('[data-testid="annotation-outdated-notice"]')).not.toBeVisible();
|
||||
|
||||
await page.screenshot({ path: 'test-results/e2e/annotation-restored.png' });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── PDF Annotations (read-only user) ─────────────────────────────────────────
|
||||
|
||||
test.describe('PDF annotations — read-only user', () => {
|
||||
// Isolated session — does not share the admin storage state
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
test('read-only user does not see the 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]');
|
||||
|
||||
// Reader users do not have ANNOTATE_ALL permission — the button must not be shown at all.
|
||||
const annotateBtn = page.getByRole('button', { name: /annotieren/i });
|
||||
await expect(annotateBtn).not.toBeVisible({ timeout: 5000 });
|
||||
|
||||
await page.screenshot({ path: 'test-results/e2e/annotations-button-reader.png' });
|
||||
});
|
||||
});
|
||||
|
||||
21
frontend/e2e/fixtures/minimal2.pdf
Normal file
21
frontend/e2e/fixtures/minimal2.pdf
Normal file
@@ -0,0 +1,21 @@
|
||||
%PDF-1.4
|
||||
1 0 obj
|
||||
<</Type/Catalog/Pages 2 0 R>>
|
||||
endobj
|
||||
2 0 obj
|
||||
<</Type/Pages/Kids[3 0 R]/Count 1>>
|
||||
endobj
|
||||
3 0 obj
|
||||
<</Type/Page/MediaBox[0 0 3 3]/Parent 2 0 R>>
|
||||
endobj
|
||||
xref
|
||||
0 4
|
||||
0000000000 65535 f
|
||||
0000000009 00000 n
|
||||
0000000058 00000 n
|
||||
0000000115 00000 n
|
||||
trailer
|
||||
<</Size 4/Root 1 0 R>>
|
||||
startxref
|
||||
190
|
||||
%%EOF
|
||||
@@ -25,7 +25,7 @@ test.describe('Document history panel', () => {
|
||||
await page.goto('/documents/new');
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
await page.getByLabel('Titel').fill('E2E History Test Dokument');
|
||||
await page.getByRole('button', { name: /Speichern/i }).click();
|
||||
await page.getByRole('button', { name: 'Speichern', exact: true }).click();
|
||||
// Wait for redirect to the new document's UUID-based URL (not /documents/new)
|
||||
await page.waitForURL(/\/documents\/[0-9a-f-]{36}$/);
|
||||
docPath = new URL(page.url()).pathname;
|
||||
@@ -34,7 +34,7 @@ test.describe('Document history panel', () => {
|
||||
await page.goto(`${docPath}/edit`);
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
await page.getByLabel('Titel').fill('E2E History Test Dokument (bearbeitet)');
|
||||
await page.getByRole('button', { name: /Speichern/i }).click();
|
||||
await page.getByRole('button', { name: 'Speichern', exact: true }).click();
|
||||
await page.waitForURL(/\/documents\/[0-9a-f-]{36}$/);
|
||||
|
||||
await context.close();
|
||||
|
||||
@@ -212,7 +212,7 @@ test.describe('Conversations', () => {
|
||||
test('nav link is active on the conversations page', async ({ page }) => {
|
||||
await page.goto('/conversations');
|
||||
const navLink = page.getByRole('link', { name: 'Konversationen' });
|
||||
await expect(navLink).toHaveClass(/text-brand-navy/);
|
||||
await expect(navLink).toHaveClass(/bg-nav-active/);
|
||||
});
|
||||
|
||||
test('sort toggle changes the button label', async ({ page }) => {
|
||||
|
||||
73
frontend/e2e/theme.spec.ts
Normal file
73
frontend/e2e/theme.spec.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Theme toggle', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Clear any saved theme preference before each test
|
||||
await page.goto('/');
|
||||
await page.evaluate(() => localStorage.removeItem('theme'));
|
||||
});
|
||||
|
||||
test('toggle button is visible in the header', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(
|
||||
page.getByRole('banner').getByRole('button', { name: /dark mode|light mode/i })
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('clicking the toggle switches to dark mode', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
|
||||
const html = page.locator('html');
|
||||
await expect(html).not.toHaveAttribute('data-theme', 'dark');
|
||||
|
||||
await page
|
||||
.getByRole('banner')
|
||||
.getByRole('button', { name: /dark mode/i })
|
||||
.click();
|
||||
|
||||
await expect(html).toHaveAttribute('data-theme', 'dark');
|
||||
});
|
||||
|
||||
test('clicking the toggle again switches back to light mode', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
|
||||
await page
|
||||
.getByRole('banner')
|
||||
.getByRole('button', { name: /dark mode/i })
|
||||
.click();
|
||||
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
|
||||
|
||||
await page
|
||||
.getByRole('banner')
|
||||
.getByRole('button', { name: /light mode/i })
|
||||
.click();
|
||||
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light');
|
||||
});
|
||||
|
||||
test('theme persists after page reload', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
|
||||
await page
|
||||
.getByRole('banner')
|
||||
.getByRole('button', { name: /dark mode/i })
|
||||
.click();
|
||||
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
|
||||
|
||||
await page.reload();
|
||||
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
|
||||
});
|
||||
|
||||
test('saved theme is applied before first paint (no flash)', async ({ page }) => {
|
||||
// Set dark theme in localStorage before navigating
|
||||
await page.goto('/');
|
||||
await page.evaluate(() => localStorage.setItem('theme', 'dark'));
|
||||
|
||||
// Intercept the initial HTML to verify data-theme is set immediately
|
||||
await page.goto('/');
|
||||
const theme = await page.evaluate(() => document.documentElement.getAttribute('data-theme'));
|
||||
expect(theme).toBe('dark');
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,13 @@
|
||||
{
|
||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||
"error_annotation_not_found": "Die Annotation wurde nicht gefunden.",
|
||||
"error_annotation_overlap": "Die Annotation überschneidet sich mit einer vorhandenen.",
|
||||
"annotation_outdated_notice": "Einige Annotationen beziehen sich auf eine frühere Dateiversion und werden nicht angezeigt.",
|
||||
"error_document_not_found": "Das Dokument wurde nicht gefunden.",
|
||||
"error_document_no_file": "Diesem Dokument ist noch keine Datei zugeordnet.",
|
||||
"error_file_not_found": "Die Datei konnte im Speicher nicht gefunden werden.",
|
||||
"error_file_upload_failed": "Die Datei konnte nicht hochgeladen werden.",
|
||||
"error_unsupported_file_type": "Dieses Dateiformat wird nicht unterstützt.",
|
||||
"error_user_not_found": "Der Benutzer wurde nicht gefunden.",
|
||||
"error_import_already_running": "Ein Import läuft bereits. Bitte warten Sie, bis dieser abgeschlossen ist.",
|
||||
"error_unauthorized": "Sie sind nicht angemeldet.",
|
||||
@@ -20,6 +24,7 @@
|
||||
"btn_edit": "Bearbeiten",
|
||||
"btn_create": "Erstellen",
|
||||
"btn_delete": "Löschen",
|
||||
"doc_delete_confirm": "Dokument wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
"btn_back_to_overview": "Zurück zur Übersicht",
|
||||
"btn_back": "Zurück",
|
||||
"btn_back_to_document": "Zurück zum Dokument",
|
||||
@@ -34,7 +39,7 @@
|
||||
"form_placeholder_location": "z.B. Berlin, Wien…",
|
||||
"form_label_sender": "Absender",
|
||||
"form_label_receivers": "Empfänger",
|
||||
"form_label_title": "Titel *",
|
||||
"form_label_title": "Titel",
|
||||
"form_label_tags": "Schlagworte",
|
||||
"form_label_content": "Inhalt",
|
||||
"form_placeholder_content": "Kurze Beschreibung des Inhalts…",
|
||||
@@ -70,6 +75,7 @@
|
||||
"doc_file_replace_label": "Neue Datei hochladen",
|
||||
"doc_file_replace_note": "(ersetzt die aktuelle Datei)",
|
||||
"doc_current_file_label": "Aktuelle Datei:",
|
||||
"doc_more_details": "Weitere Details",
|
||||
"doc_new_heading": "Neues Dokument",
|
||||
"doc_edit_heading": "Bearbeiten",
|
||||
"doc_section_details": "Details",
|
||||
@@ -239,6 +245,54 @@
|
||||
"admin_system_backfill_description": "Erstellt einen initialen Verlaufseintrag für alle Dokumente, die noch keinen Verlauf haben (z.B. importierte Dokumente). Dadurch werden beim nächsten Bearbeiten nur die tatsächlich geänderten Felder hervorgehoben.",
|
||||
"admin_system_backfill_btn": "Jetzt auffüllen",
|
||||
"admin_system_backfill_success": "{count} Dokumente wurden aufgefüllt.",
|
||||
"admin_system_backfill_hashes_heading": "Datei-Hashes berechnen",
|
||||
"admin_system_backfill_hashes_description": "Berechnet den SHA-256-Hash für alle bereits hochgeladenen Dokumente, die noch keinen Hash haben. Dadurch werden Annotationen korrekt mit ihrer Dateiversion verknüpft und wieder angezeigt.",
|
||||
"admin_system_backfill_hashes_btn": "Datei-Hashes berechnen",
|
||||
"admin_system_backfill_hashes_success": "{count} Dokumente wurden aktualisiert.",
|
||||
"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",
|
||||
"doc_panel_discussion_annotation_tab": "Annotation · Seite {page}",
|
||||
"pdf_annotations_show": "Annotierungen anzeigen",
|
||||
"pdf_annotations_hide": "Annotierungen verbergen",
|
||||
"upload_drop_hint": "Einzeln oder mehrere Dateien auf einmal hochladen",
|
||||
"upload_accepted_types": "PDF, JPEG, PNG, TIFF",
|
||||
"upload_filename_hint": "Tipp: 2024-03-15_Mueller_Hans.pdf → Datum und Absender werden vorausgefüllt",
|
||||
"upload_success": "{count} Dokument(e) erstellt",
|
||||
"upload_duplicate": "{filename} existiert bereits —",
|
||||
"upload_duplicate_link": "Zum Dokument",
|
||||
"upload_invalid_type": "{filename}: Dateiformat nicht unterstützt",
|
||||
"upload_error": "Fehler beim Hochladen von {filename}",
|
||||
"enrich_list_back": "Zurück zur Übersicht",
|
||||
"enrich_list_count": "Dokumente",
|
||||
"btn_save_and_mark_reviewed": "Speichern & abschließen",
|
||||
"btn_mark_for_review": "Zur Überprüfung markieren",
|
||||
"enrich_needs_metadata_title": "Dokumente ohne Metadaten",
|
||||
"enrich_needs_metadata_count": "{count} Dokument(e) warten auf Metadaten",
|
||||
"enrich_needs_metadata_cta": "Jetzt vervollständigen",
|
||||
"enrich_list_heading": "Dokumente ohne Metadaten",
|
||||
"enrich_list_empty_heading": "Alle Dokumente vollständig",
|
||||
"enrich_list_empty_body": "Es gibt keine Dokumente, die noch Metadaten benötigen.",
|
||||
"enrich_list_start": "Überprüfung starten",
|
||||
"enrich_progress": "{count} verbleibend",
|
||||
"enrich_skip": "Überspringen",
|
||||
"enrich_done_heading": "Alles erledigt!",
|
||||
"enrich_done_body": "Alle Dokumente wurden bearbeitet.",
|
||||
"enrich_back_to_list": "Zurück zur Liste",
|
||||
"comment_empty_hint": "Noch keine Kommentare – starte die Diskussion!",
|
||||
"comment_start_discussion": "Diskussion starten →"
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
{
|
||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||
"error_annotation_not_found": "Annotation not found.",
|
||||
"error_annotation_overlap": "The annotation overlaps an existing one.",
|
||||
"annotation_outdated_notice": "Some annotations refer to an earlier file version and are not shown.",
|
||||
"error_document_not_found": "Document not found.",
|
||||
"error_document_no_file": "No file is associated with this document.",
|
||||
"error_file_not_found": "The file could not be found in storage.",
|
||||
"error_file_upload_failed": "The file could not be uploaded.",
|
||||
"error_unsupported_file_type": "This file format is not supported.",
|
||||
"error_user_not_found": "User not found.",
|
||||
"error_import_already_running": "An import is already running. Please wait for it to finish.",
|
||||
"error_unauthorized": "You are not logged in.",
|
||||
@@ -20,6 +24,7 @@
|
||||
"btn_edit": "Edit",
|
||||
"btn_create": "Create",
|
||||
"btn_delete": "Delete",
|
||||
"doc_delete_confirm": "Really delete this document? This action cannot be undone.",
|
||||
"btn_back_to_overview": "Back to overview",
|
||||
"btn_back": "Back",
|
||||
"btn_back_to_document": "Back to document",
|
||||
@@ -34,7 +39,7 @@
|
||||
"form_placeholder_location": "e.g. Berlin, Vienna…",
|
||||
"form_label_sender": "Sender",
|
||||
"form_label_receivers": "Recipients",
|
||||
"form_label_title": "Title *",
|
||||
"form_label_title": "Title",
|
||||
"form_label_tags": "Tags",
|
||||
"form_label_content": "Content",
|
||||
"form_placeholder_content": "Brief description of the content…",
|
||||
@@ -70,6 +75,7 @@
|
||||
"doc_file_replace_label": "Upload new file",
|
||||
"doc_file_replace_note": "(replaces the current file)",
|
||||
"doc_current_file_label": "Current file:",
|
||||
"doc_more_details": "More details",
|
||||
"doc_new_heading": "New document",
|
||||
"doc_edit_heading": "Edit",
|
||||
"doc_section_details": "Details",
|
||||
@@ -239,6 +245,54 @@
|
||||
"admin_system_backfill_description": "Creates an initial history entry for all documents that do not have one yet (e.g. imported documents). This ensures that future edits only highlight actually changed fields.",
|
||||
"admin_system_backfill_btn": "Backfill now",
|
||||
"admin_system_backfill_success": "{count} documents were backfilled.",
|
||||
"admin_system_backfill_hashes_heading": "Compute file hashes",
|
||||
"admin_system_backfill_hashes_description": "Computes the SHA-256 hash for all previously uploaded documents that do not have one yet. This ensures annotations are correctly linked to their file version and shown again.",
|
||||
"admin_system_backfill_hashes_btn": "Compute file hashes",
|
||||
"admin_system_backfill_hashes_success": "{count} documents were updated.",
|
||||
"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",
|
||||
"doc_panel_discussion_annotation_tab": "Annotation · Page {page}",
|
||||
"pdf_annotations_show": "Show annotations",
|
||||
"pdf_annotations_hide": "Hide annotations",
|
||||
"upload_drop_hint": "Drop one or multiple files at once",
|
||||
"upload_accepted_types": "PDF, JPEG, PNG, TIFF",
|
||||
"upload_filename_hint": "Tip: 2024-03-15_Mueller_Hans.pdf → date and sender pre-filled",
|
||||
"upload_success": "{count} document(s) created",
|
||||
"upload_duplicate": "{filename} already exists —",
|
||||
"upload_duplicate_link": "View document",
|
||||
"upload_invalid_type": "{filename}: unsupported file format",
|
||||
"upload_error": "Error uploading {filename}",
|
||||
"enrich_list_back": "Back to overview",
|
||||
"enrich_list_count": "documents",
|
||||
"btn_save_and_mark_reviewed": "Save & mark as reviewed",
|
||||
"btn_mark_for_review": "Mark for review",
|
||||
"enrich_needs_metadata_title": "Documents without metadata",
|
||||
"enrich_needs_metadata_count": "{count} document(s) waiting for metadata",
|
||||
"enrich_needs_metadata_cta": "Complete now",
|
||||
"enrich_list_heading": "Documents without metadata",
|
||||
"enrich_list_empty_heading": "All documents complete",
|
||||
"enrich_list_empty_body": "There are no documents that still need metadata.",
|
||||
"enrich_list_start": "Start reviewing",
|
||||
"enrich_progress": "{count} remaining",
|
||||
"enrich_skip": "Skip",
|
||||
"enrich_done_heading": "All done!",
|
||||
"enrich_done_body": "All documents have been processed.",
|
||||
"enrich_back_to_list": "Back to list",
|
||||
"comment_empty_hint": "No comments yet – start the discussion!",
|
||||
"comment_start_discussion": "Start discussion →"
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
{
|
||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||
"error_annotation_not_found": "Anotación no encontrada.",
|
||||
"error_annotation_overlap": "La anotación se superpone con una existente.",
|
||||
"annotation_outdated_notice": "Algunas anotaciones hacen referencia a una versión anterior del archivo y no se muestran.",
|
||||
"error_document_not_found": "Documento no encontrado.",
|
||||
"error_document_no_file": "No hay ningún archivo asociado a este documento.",
|
||||
"error_file_not_found": "El archivo no pudo encontrarse en el almacenamiento.",
|
||||
"error_file_upload_failed": "No se pudo subir el archivo.",
|
||||
"error_unsupported_file_type": "Este formato de archivo no está admitido.",
|
||||
"error_user_not_found": "Usuario no encontrado.",
|
||||
"error_import_already_running": "Ya hay una importación en curso. Por favor, espere a que finalice.",
|
||||
"error_unauthorized": "No ha iniciado sesión.",
|
||||
@@ -20,6 +24,7 @@
|
||||
"btn_edit": "Editar",
|
||||
"btn_create": "Crear",
|
||||
"btn_delete": "Eliminar",
|
||||
"doc_delete_confirm": "¿Realmente eliminar este documento? Esta acción no se puede deshacer.",
|
||||
"btn_back_to_overview": "Volver al resumen",
|
||||
"btn_back": "Volver",
|
||||
"btn_back_to_document": "Volver al documento",
|
||||
@@ -34,7 +39,7 @@
|
||||
"form_placeholder_location": "p.ej. Berlín, Viena…",
|
||||
"form_label_sender": "Remitente",
|
||||
"form_label_receivers": "Destinatarios",
|
||||
"form_label_title": "Título *",
|
||||
"form_label_title": "Título",
|
||||
"form_label_tags": "Etiquetas",
|
||||
"form_label_content": "Contenido",
|
||||
"form_placeholder_content": "Breve descripción del contenido…",
|
||||
@@ -70,6 +75,7 @@
|
||||
"doc_file_replace_label": "Subir nuevo archivo",
|
||||
"doc_file_replace_note": "(reemplaza el archivo actual)",
|
||||
"doc_current_file_label": "Archivo actual:",
|
||||
"doc_more_details": "Más detalles",
|
||||
"doc_new_heading": "Nuevo documento",
|
||||
"doc_edit_heading": "Editar",
|
||||
"doc_section_details": "Detalles",
|
||||
@@ -239,6 +245,54 @@
|
||||
"admin_system_backfill_description": "Crea una entrada de historial inicial para todos los documentos que aún no tienen ninguna (p.ej. documentos importados). Así, en la próxima edición solo se resaltarán los campos realmente modificados.",
|
||||
"admin_system_backfill_btn": "Completar ahora",
|
||||
"admin_system_backfill_success": "{count} documentos fueron completados.",
|
||||
"admin_system_backfill_hashes_heading": "Calcular hashes de archivo",
|
||||
"admin_system_backfill_hashes_description": "Calcula el hash SHA-256 para todos los documentos ya subidos que aún no tienen uno. Así las anotaciones se vinculan correctamente a su versión del archivo y vuelven a mostrarse.",
|
||||
"admin_system_backfill_hashes_btn": "Calcular hashes de archivo",
|
||||
"admin_system_backfill_hashes_success": "{count} documentos fueron actualizados.",
|
||||
"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",
|
||||
"doc_panel_discussion_annotation_tab": "Anotación · Página {page}",
|
||||
"pdf_annotations_show": "Mostrar anotaciones",
|
||||
"pdf_annotations_hide": "Ocultar anotaciones",
|
||||
"upload_drop_hint": "Uno o varios archivos a la vez",
|
||||
"upload_accepted_types": "PDF, JPEG, PNG, TIFF",
|
||||
"upload_filename_hint": "Consejo: 2024-03-15_Mueller_Hans.pdf → fecha y remitente prellenados",
|
||||
"upload_success": "{count} documento(s) creado(s)",
|
||||
"upload_duplicate": "{filename} ya existe —",
|
||||
"upload_duplicate_link": "Ver documento",
|
||||
"upload_invalid_type": "{filename}: formato de archivo no admitido",
|
||||
"upload_error": "Error al subir {filename}",
|
||||
"enrich_list_back": "Volver a la vista general",
|
||||
"enrich_list_count": "documentos",
|
||||
"btn_save_and_mark_reviewed": "Guardar y marcar como revisado",
|
||||
"btn_mark_for_review": "Marcar para revisión",
|
||||
"enrich_needs_metadata_title": "Documentos sin metadatos",
|
||||
"enrich_needs_metadata_count": "{count} documento(s) esperando metadatos",
|
||||
"enrich_needs_metadata_cta": "Completar ahora",
|
||||
"enrich_list_heading": "Documentos sin metadatos",
|
||||
"enrich_list_empty_heading": "Todos los documentos completos",
|
||||
"enrich_list_empty_body": "No hay documentos que necesiten metadatos.",
|
||||
"enrich_list_start": "Comenzar revisión",
|
||||
"enrich_progress": "{count} restante(s)",
|
||||
"enrich_skip": "Omitir",
|
||||
"enrich_done_heading": "¡Todo listo!",
|
||||
"enrich_done_body": "Todos los documentos han sido procesados.",
|
||||
"enrich_back_to_list": "Volver a la lista",
|
||||
"comment_empty_hint": "Aún no hay comentarios – ¡inicia la discusión!",
|
||||
"comment_start_discussion": "Iniciar discusión →"
|
||||
}
|
||||
|
||||
@@ -3,6 +3,12 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<script>
|
||||
(function () {
|
||||
var t = localStorage.getItem('theme');
|
||||
if (t === 'dark' || t === 'light') document.documentElement.setAttribute('data-theme', t);
|
||||
})();
|
||||
</script>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
|
||||
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-line bg-surface shadow-2xl sm:flex"
|
||||
>
|
||||
<div class="flex shrink-0 items-center justify-between border-b border-line px-4 py-3">
|
||||
<h3 class="font-sans text-xs font-bold tracking-widest text-ink uppercase">
|
||||
{m.comment_panel_title()}
|
||||
</h3>
|
||||
<button
|
||||
onclick={onClose}
|
||||
aria-label={m.comment_panel_close()}
|
||||
class="rounded p-1 text-ink-3 transition-colors hover:bg-muted hover:text-ink"
|
||||
>
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto p-4">
|
||||
<CommentThread
|
||||
documentId={documentId}
|
||||
annotationId={annotationId}
|
||||
canComment={canComment}
|
||||
currentUserId={currentUserId}
|
||||
canAdmin={canAdmin}
|
||||
loadOnMount={true}
|
||||
onCountChange={onCountChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile modal (< sm): fixed full-screen with slide-up sheet -->
|
||||
<div class="fixed inset-0 z-50 flex flex-col sm:hidden">
|
||||
<!-- Semi-transparent backdrop -->
|
||||
<div class="flex-1 bg-black/40" onclick={onClose} role="presentation"></div>
|
||||
|
||||
<!-- Slide-up panel -->
|
||||
<div class="flex max-h-[80vh] flex-col rounded-t-2xl bg-surface shadow-2xl">
|
||||
<div class="flex shrink-0 items-center justify-between border-b border-line px-4 py-3">
|
||||
<h3 class="font-sans text-xs font-bold tracking-widest text-ink uppercase">
|
||||
{m.comment_panel_title()}
|
||||
</h3>
|
||||
<button
|
||||
onclick={onClose}
|
||||
aria-label={m.comment_panel_close()}
|
||||
class="rounded p-1 text-ink-3 transition-colors hover:bg-muted hover:text-ink"
|
||||
>
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto p-4">
|
||||
<CommentThread
|
||||
documentId={documentId}
|
||||
annotationId={annotationId}
|
||||
canComment={canComment}
|
||||
currentUserId={currentUserId}
|
||||
canAdmin={canAdmin}
|
||||
loadOnMount={true}
|
||||
onCountChange={onCountChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
208
frontend/src/lib/components/AnnotationLayer.svelte
Normal file
208
frontend/src/lib/components/AnnotationLayer.svelte
Normal file
@@ -0,0 +1,208 @@
|
||||
<script lang="ts">
|
||||
import type { Annotation } from '$lib/types';
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
65
frontend/src/lib/components/AnnotationSidePanel.svelte
Normal file
65
frontend/src/lib/components/AnnotationSidePanel.svelte
Normal file
@@ -0,0 +1,65 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import CommentThread from './CommentThread.svelte';
|
||||
|
||||
type Props = {
|
||||
documentId: string;
|
||||
activeAnnotationId: string | null;
|
||||
activeAnnotationPage: number | null;
|
||||
canComment: boolean;
|
||||
currentUserId: string | null;
|
||||
canAdmin: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
let {
|
||||
documentId,
|
||||
activeAnnotationId,
|
||||
activeAnnotationPage,
|
||||
canComment,
|
||||
currentUserId,
|
||||
canAdmin,
|
||||
onClose
|
||||
}: Props = $props();
|
||||
|
||||
const visible = $derived(activeAnnotationId !== null);
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="absolute inset-y-0 right-0 z-10 flex w-80 flex-col border-l border-line bg-surface shadow-[-4px_0_16px_rgba(0,0,0,0.08)] transition-transform duration-200 {visible
|
||||
? 'translate-x-0'
|
||||
: 'pointer-events-none translate-x-full'}"
|
||||
data-testid="annotation-side-panel"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex shrink-0 items-center justify-between border-b border-line px-4 py-3">
|
||||
<span class="font-sans text-xs font-medium text-ink">
|
||||
{m.doc_panel_discussion_annotation_tab({ page: String(activeAnnotationPage ?? '?') })}
|
||||
</span>
|
||||
<button
|
||||
onclick={onClose}
|
||||
aria-label={m.comment_panel_close()}
|
||||
class="rounded p-1 text-ink-3 transition-colors hover:bg-muted hover:text-ink"
|
||||
>
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Comment thread -->
|
||||
<div class="flex-1 overflow-y-auto p-4">
|
||||
{#if activeAnnotationId}
|
||||
{#key activeAnnotationId}
|
||||
<CommentThread
|
||||
documentId={documentId}
|
||||
annotationId={activeAnnotationId}
|
||||
canComment={canComment}
|
||||
currentUserId={currentUserId}
|
||||
canAdmin={canAdmin}
|
||||
loadOnMount={true}
|
||||
/>
|
||||
{/key}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
334
frontend/src/lib/components/CommentThread.svelte
Normal file
334
frontend/src/lib/components/CommentThread.svelte
Normal file
@@ -0,0 +1,334 @@
|
||||
<script lang="ts">
|
||||
import { onMount, untrack } from 'svelte';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import type { Comment, CommentReply } from '$lib/types';
|
||||
|
||||
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();
|
||||
} else {
|
||||
const total = initialComments.reduce((s, c) => s + 1 + c.replies.length, 0);
|
||||
onCountChange?.(total);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<!--
|
||||
Renders a single comment or reply entry.
|
||||
showReplyButton: whether the "Reply" button appears (only on last item in a thread).
|
||||
-->
|
||||
{#snippet commentEntry(comment: Comment | CommentReply, threadId: string, showReplyButton: boolean)}
|
||||
{#if editingId === comment.id}
|
||||
<div class="flex flex-col gap-2">
|
||||
<textarea
|
||||
class="w-full resize-none rounded border border-line px-3 py-2 font-serif text-sm text-ink focus:ring-1 focus:ring-accent focus:outline-none"
|
||||
rows={3}
|
||||
bind:value={editText}
|
||||
></textarea>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
class="rounded bg-primary px-3 py-1.5 font-sans text-xs font-medium text-white hover:bg-primary/80 disabled:opacity-40"
|
||||
disabled={posting}
|
||||
onclick={() => saveEdit(comment.id)}
|
||||
>
|
||||
{m.btn_save()}
|
||||
</button>
|
||||
<button
|
||||
class="font-sans text-xs text-ink-3 transition-colors hover:text-ink"
|
||||
onclick={cancelEdit}
|
||||
>
|
||||
{m.btn_cancel()}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="font-sans text-xs font-semibold text-ink">{comment.authorName}</span>
|
||||
<span class="font-sans text-xs text-ink-3">{timeAgo(comment.createdAt)}</span>
|
||||
{#if wasEdited(comment)}
|
||||
<span class="font-sans text-xs text-ink-3">
|
||||
{m.comment_edited_label()}
|
||||
{timeAgo(comment.updatedAt)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="mt-1 font-serif text-sm leading-relaxed text-ink-2">{comment.content}</p>
|
||||
</div>
|
||||
{#if canModify(comment)}
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<button
|
||||
class="font-sans text-xs text-ink-3 transition-colors hover:text-ink"
|
||||
onclick={() => startEdit(comment)}
|
||||
>
|
||||
{m.btn_edit()}
|
||||
</button>
|
||||
<button
|
||||
class="font-sans text-xs text-ink-3 transition-colors hover:text-ink"
|
||||
onclick={() => deleteComment(comment.id)}
|
||||
>
|
||||
{m.btn_delete()}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if showReplyButton && canComment}
|
||||
<div class="mt-1">
|
||||
<button
|
||||
class="font-sans text-xs font-medium text-accent transition-colors hover:text-ink"
|
||||
onclick={() => startReply(threadId)}
|
||||
>
|
||||
{m.comment_btn_reply()}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<div class="space-y-4">
|
||||
{#if comments.length === 0}
|
||||
<div class="flex flex-col items-center gap-3 py-8 text-center">
|
||||
<svg
|
||||
class="h-10 w-10 text-ink-3"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M2.25 12.76c0 1.6 1.123 2.994 2.707 3.227 1.087.16 2.185.283 3.293.369V21l4.076-4.076a1.526 1.526 0 0 1 1.037-.443 48.282 48.282 0 0 0 5.68-.494c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z"
|
||||
/>
|
||||
</svg>
|
||||
<p class="font-sans text-sm text-ink-3">{m.comment_empty_hint()}</p>
|
||||
</div>
|
||||
{/if}
|
||||
{#each comments as thread, ti (thread.id)}
|
||||
<div class={ti > 0 ? 'border-t border-line pt-4' : ''}>
|
||||
<!-- Root comment -->
|
||||
<div>
|
||||
{@render commentEntry(thread, thread.id, thread.replies.length === 0)}
|
||||
</div>
|
||||
|
||||
<!-- Replies -->
|
||||
{#each thread.replies as reply, ri (reply.id)}
|
||||
<div class="mt-3 ml-6 border-l-2 border-line pl-4">
|
||||
{@render commentEntry(reply, thread.id, ri === thread.replies.length - 1)}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<!-- Reply compose box -->
|
||||
{#if replyingTo === thread.id}
|
||||
<div class="mt-3 ml-6 flex flex-col gap-2">
|
||||
<textarea
|
||||
class="w-full resize-none rounded border border-line px-3 py-2 font-serif text-sm text-ink focus:ring-1 focus:ring-accent focus:outline-none"
|
||||
rows={3}
|
||||
placeholder={m.comment_placeholder()}
|
||||
bind:value={replyText}
|
||||
></textarea>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
class="rounded bg-primary px-3 py-1.5 font-sans text-xs font-medium text-white hover:bg-primary/80 disabled:opacity-40"
|
||||
disabled={posting}
|
||||
onclick={() => postReply(thread.id)}
|
||||
>
|
||||
{m.comment_btn_post()}
|
||||
</button>
|
||||
<button
|
||||
class="font-sans text-xs text-ink-3 transition-colors hover:text-ink"
|
||||
onclick={cancelReply}
|
||||
>
|
||||
{m.btn_cancel()}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<!-- New top-level comment -->
|
||||
{#if canComment}
|
||||
<div class={comments.length > 0 ? 'border-t border-line pt-4' : ''}>
|
||||
<div class="flex flex-col gap-2">
|
||||
<textarea
|
||||
class="w-full resize-none rounded border border-line px-3 py-2 font-serif text-sm text-ink focus:ring-1 focus:ring-accent focus:outline-none"
|
||||
rows={3}
|
||||
placeholder={m.comment_placeholder()}
|
||||
bind:value={newText}
|
||||
></textarea>
|
||||
<div>
|
||||
<button
|
||||
class="rounded bg-primary px-3 py-1.5 font-sans text-xs font-medium text-white hover:bg-primary/80 disabled:opacity-40"
|
||||
disabled={posting || !newText.trim()}
|
||||
onclick={postComment}
|
||||
>
|
||||
{m.comment_btn_post()}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
70
frontend/src/lib/components/CommentThread.svelte.spec.ts
Normal file
70
frontend/src/lib/components/CommentThread.svelte.spec.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import CommentThread from './CommentThread.svelte';
|
||||
import type { Comment } from '$lib/types';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function makeComment(id: string, content = 'Hello'): Comment {
|
||||
return {
|
||||
id,
|
||||
authorId: 'user-1',
|
||||
authorName: 'Alice',
|
||||
content,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
replies: []
|
||||
};
|
||||
}
|
||||
|
||||
const baseProps = {
|
||||
documentId: 'doc-1',
|
||||
canComment: true,
|
||||
currentUserId: 'user-1',
|
||||
canAdmin: false
|
||||
};
|
||||
|
||||
describe('CommentThread – empty state', () => {
|
||||
it('shows empty state hint when there are no comments', async () => {
|
||||
render(CommentThread, { ...baseProps, initialComments: [] });
|
||||
await expect
|
||||
.element(page.getByText('Noch keine Kommentare – starte die Diskussion!'))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show empty state hint when comments exist', async () => {
|
||||
render(CommentThread, { ...baseProps, initialComments: [makeComment('c-1')] });
|
||||
await expect
|
||||
.element(page.getByText('Noch keine Kommentare – starte die Diskussion!'))
|
||||
.not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('CommentThread – onCountChange', () => {
|
||||
it('calls onCountChange with initial SSR count on mount', async () => {
|
||||
const onCountChange = vi.fn();
|
||||
render(CommentThread, {
|
||||
...baseProps,
|
||||
initialComments: [makeComment('c-1'), makeComment('c-2')],
|
||||
onCountChange
|
||||
});
|
||||
expect(onCountChange).toHaveBeenCalledWith(2);
|
||||
});
|
||||
|
||||
it('calls onCountChange with 0 when no initial comments', async () => {
|
||||
const onCountChange = vi.fn();
|
||||
render(CommentThread, { ...baseProps, initialComments: [], onCountChange });
|
||||
expect(onCountChange).toHaveBeenCalledWith(0);
|
||||
});
|
||||
|
||||
it('counts replies in the total', async () => {
|
||||
const onCountChange = vi.fn();
|
||||
const comment = { ...makeComment('c-1'), replies: [makeComment('r-1') as never] };
|
||||
render(CommentThread, { ...baseProps, initialComments: [comment], onCountChange });
|
||||
expect(onCountChange).toHaveBeenCalledWith(2);
|
||||
});
|
||||
});
|
||||
188
frontend/src/lib/components/DocumentBottomPanel.svelte
Normal file
188
frontend/src/lib/components/DocumentBottomPanel.svelte
Normal file
@@ -0,0 +1,188 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import PanelMetadata from './PanelMetadata.svelte';
|
||||
import PanelTranscription from './PanelTranscription.svelte';
|
||||
import PanelDiscussion from './PanelDiscussion.svelte';
|
||||
import PanelHistory from './PanelHistory.svelte';
|
||||
import type { Comment, DocumentPanelTab } from '$lib/types';
|
||||
|
||||
type Doc = {
|
||||
id: string;
|
||||
title?: string | null;
|
||||
documentDate?: string | null;
|
||||
location?: string | null;
|
||||
documentLocation?: string | null;
|
||||
tags?: { id: string; name: string }[] | null;
|
||||
sender?: { id: string; firstName: string; lastName: string; alias?: string | null } | null;
|
||||
receivers?: { id: string; firstName: string; lastName: string }[] | null;
|
||||
summary?: string | null;
|
||||
transcription?: string | null;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
doc: Doc;
|
||||
comments: Comment[];
|
||||
canComment: boolean;
|
||||
currentUserId: string | null;
|
||||
canAdmin: boolean;
|
||||
open: boolean;
|
||||
height: number;
|
||||
activeTab: DocumentPanelTab;
|
||||
};
|
||||
|
||||
let {
|
||||
doc,
|
||||
comments,
|
||||
canComment,
|
||||
currentUserId,
|
||||
canAdmin,
|
||||
open = $bindable(),
|
||||
height = $bindable(),
|
||||
activeTab = $bindable()
|
||||
}: Props = $props();
|
||||
|
||||
const MIN_HEIGHT = 52; // drag handle (8px) + tab bar (~44px)
|
||||
|
||||
let isDragging = $state(false);
|
||||
let dragStartY = 0;
|
||||
let dragStartHeight = 0;
|
||||
|
||||
function fullHeight() {
|
||||
const topbar = document.querySelector('[data-topbar]');
|
||||
return window.innerHeight - (topbar?.getBoundingClientRect().bottom ?? 0);
|
||||
}
|
||||
|
||||
function openTab(tab: DocumentPanelTab) {
|
||||
activeTab = tab;
|
||||
if (!open) {
|
||||
open = true;
|
||||
if (height <= MIN_HEIGHT) height = fullHeight();
|
||||
}
|
||||
}
|
||||
|
||||
function closePanel() {
|
||||
open = false;
|
||||
}
|
||||
|
||||
function onDragStart(e: PointerEvent) {
|
||||
isDragging = true;
|
||||
dragStartY = e.clientY;
|
||||
dragStartHeight = open ? height : MIN_HEIGHT;
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
}
|
||||
|
||||
function onDragMove(e: PointerEvent) {
|
||||
if (!isDragging) return;
|
||||
const delta = dragStartY - e.clientY; // positive = dragging up = bigger panel
|
||||
const newHeight = dragStartHeight + delta;
|
||||
const maxHeight = fullHeight();
|
||||
|
||||
if (newHeight <= MIN_HEIGHT + 20) {
|
||||
// collapsed past threshold → close
|
||||
open = false;
|
||||
} else {
|
||||
open = true;
|
||||
height = Math.max(80, Math.min(newHeight, maxHeight));
|
||||
}
|
||||
}
|
||||
|
||||
function onDragEnd() {
|
||||
isDragging = false;
|
||||
}
|
||||
|
||||
const tabs: { id: DocumentPanelTab; label: () => string }[] = [
|
||||
{ id: 'metadata', label: m.doc_panel_tab_metadata },
|
||||
{ id: 'transcription', label: m.doc_panel_tab_transcription },
|
||||
{ id: 'discussion', label: m.doc_panel_tab_discussion },
|
||||
{ id: 'history', label: m.doc_panel_tab_history }
|
||||
];
|
||||
|
||||
const panelHeight = $derived(open ? height : MIN_HEIGHT);
|
||||
|
||||
let discussionCount = $state((() => comments.reduce((s, c) => s + 1 + c.replies.length, 0))());
|
||||
|
||||
function handleCountChange(count: number) {
|
||||
discussionCount = count;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="fixed right-0 bottom-0 left-0 z-30 flex flex-col border-t border-line bg-surface shadow-[0_-4px_16px_rgba(0,0,0,0.08)]"
|
||||
style="height: {panelHeight}px"
|
||||
data-testid="bottom-panel"
|
||||
>
|
||||
<!-- Drag handle -->
|
||||
<div
|
||||
class="flex h-2 shrink-0 cursor-ns-resize items-center justify-center bg-surface"
|
||||
style="touch-action: none"
|
||||
role="separator"
|
||||
aria-orientation="horizontal"
|
||||
aria-label="Panel resize"
|
||||
onpointerdown={onDragStart}
|
||||
onpointermove={onDragMove}
|
||||
onpointerup={onDragEnd}
|
||||
onpointercancel={onDragEnd}
|
||||
>
|
||||
<div class="h-1 w-12 rounded-full bg-line"></div>
|
||||
</div>
|
||||
|
||||
<!-- Tab bar -->
|
||||
<div class="flex shrink-0 items-center border-b border-line bg-surface 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-primary text-ink'
|
||||
: 'text-ink-3 hover:text-ink'}"
|
||||
aria-pressed={activeTab === tab.id && open}
|
||||
>
|
||||
{tab.label()}
|
||||
{#if tab.id === 'discussion'}
|
||||
<span
|
||||
data-testid="discussion-count-badge"
|
||||
class="ml-1.5 inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 font-sans text-[10px] font-bold text-primary-fg"
|
||||
>{discussionCount}</span
|
||||
>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
<!-- 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-ink-3 transition-colors hover:bg-muted hover:text-ink"
|
||||
>
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Tab content -->
|
||||
{#if open}
|
||||
<div class="flex-1 overflow-y-auto" data-testid="bottom-panel-content">
|
||||
{#if activeTab === 'metadata'}
|
||||
<PanelMetadata doc={doc} />
|
||||
{:else if activeTab === 'transcription'}
|
||||
<PanelTranscription doc={doc} />
|
||||
{:else if activeTab === 'discussion'}
|
||||
<PanelDiscussion
|
||||
documentId={doc.id}
|
||||
initialComments={comments}
|
||||
canComment={canComment}
|
||||
currentUserId={currentUserId}
|
||||
canAdmin={canAdmin}
|
||||
onCountChange={handleCountChange}
|
||||
/>
|
||||
{:else if activeTab === 'history'}
|
||||
<PanelHistory documentId={doc.id} />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import DocumentBottomPanel from './DocumentBottomPanel.svelte';
|
||||
import type { Comment } from '$lib/types';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function makeComment(id: string): Comment {
|
||||
return {
|
||||
id,
|
||||
authorId: 'user-1',
|
||||
authorName: 'Alice',
|
||||
content: 'Hello',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
replies: []
|
||||
};
|
||||
}
|
||||
|
||||
const doc = { id: 'doc-1', title: 'Test' };
|
||||
|
||||
const baseProps = {
|
||||
doc,
|
||||
canComment: true,
|
||||
currentUserId: 'user-1',
|
||||
canAdmin: false,
|
||||
height: 300,
|
||||
activeTab: 'discussion' as const
|
||||
};
|
||||
|
||||
describe('DocumentBottomPanel – discussion badge', () => {
|
||||
it('always shows a badge on the Discussion tab', async () => {
|
||||
render(DocumentBottomPanel, { ...baseProps, comments: [], open: true });
|
||||
await expect.element(page.getByTestId('discussion-count-badge')).toBeInTheDocument();
|
||||
await expect.element(page.getByTestId('discussion-count-badge')).toHaveTextContent('0');
|
||||
});
|
||||
|
||||
it('shows the correct count when comments exist', async () => {
|
||||
render(DocumentBottomPanel, {
|
||||
...baseProps,
|
||||
comments: [makeComment('c-1'), makeComment('c-2')],
|
||||
open: true
|
||||
});
|
||||
await expect.element(page.getByTestId('discussion-count-badge')).toHaveTextContent('2');
|
||||
});
|
||||
});
|
||||
149
frontend/src/lib/components/DocumentTopBar.svelte
Normal file
149
frontend/src/lib/components/DocumentTopBar.svelte
Normal file
@@ -0,0 +1,149 @@
|
||||
<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-line bg-surface px-6 py-3 shadow-sm"
|
||||
data-topbar
|
||||
>
|
||||
<!-- 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-ink-2 transition-colors hover:text-ink"
|
||||
>
|
||||
<div
|
||||
class="flex h-8 w-8 items-center justify-center rounded-full bg-canvas transition-colors group-hover:bg-accent"
|
||||
>
|
||||
<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-line pl-4">
|
||||
<h1
|
||||
class="truncate font-serif text-base leading-tight text-ink"
|
||||
title={doc.title ?? doc.originalFilename ?? ''}
|
||||
>
|
||||
{doc.title || doc.originalFilename}
|
||||
</h1>
|
||||
{#if compactMeta}
|
||||
<p class="truncate font-sans text-xs text-ink-2" 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="flex items-center gap-1.5 rounded px-3 py-1.5 font-sans text-xs font-medium transition {annotateMode
|
||||
? 'bg-primary text-white'
|
||||
: 'border border-primary text-ink hover:bg-primary hover:text-white'}"
|
||||
>
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Note/Note-Add-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-4 w-4 {annotateMode ? 'invert' : ''}"
|
||||
/>
|
||||
{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-primary bg-transparent px-3 py-1.5 text-xs font-medium text-ink transition hover:bg-primary 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-muted p-1.5 text-ink transition hover:bg-accent"
|
||||
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>
|
||||
98
frontend/src/lib/components/DocumentViewer.svelte
Normal file
98
frontend/src/lib/components/DocumentViewer.svelte
Normal file
@@ -0,0 +1,98 @@
|
||||
<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;
|
||||
fileHash?: string | null;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
doc: Doc;
|
||||
fileUrl: string;
|
||||
isLoading: boolean;
|
||||
error: string;
|
||||
annotateMode: boolean;
|
||||
activeAnnotationId: string | null;
|
||||
activeAnnotationPage: number | null;
|
||||
onAnnotationClick: (id: string) => void;
|
||||
};
|
||||
|
||||
let {
|
||||
doc,
|
||||
fileUrl,
|
||||
isLoading,
|
||||
error,
|
||||
annotateMode = $bindable(),
|
||||
activeAnnotationId = $bindable(),
|
||||
activeAnnotationPage = $bindable(),
|
||||
onAnnotationClick
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="absolute inset-0 bg-pdf-bg">
|
||||
{#if isLoading}
|
||||
<div class="flex h-full flex-col items-center justify-center text-accent">
|
||||
<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-ink-3">
|
||||
<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-ink-3">
|
||||
<div class="mb-6 rounded-full bg-surface/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}
|
||||
bind:activeAnnotationPage={activeAnnotationPage}
|
||||
onAnnotationClick={onAnnotationClick}
|
||||
documentFileHash={doc.fileHash ?? null}
|
||||
/>
|
||||
{:else if fileUrl}
|
||||
<div class="flex h-full w-full items-center justify-center overflow-auto p-8">
|
||||
<img
|
||||
src={fileUrl}
|
||||
alt={m.doc_image_alt()}
|
||||
class="max-h-full max-w-full object-contain shadow-2xl"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -18,14 +18,14 @@ $effect(() => {
|
||||
<div
|
||||
bind:this={el}
|
||||
style={!expanded ? `overflow: hidden; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: ${maxLines}` : ''}
|
||||
class="rounded border border-brand-sand bg-brand-sand/30 p-5 font-serif text-sm leading-relaxed whitespace-pre-wrap text-brand-navy"
|
||||
class="rounded border border-line bg-muted p-5 font-serif text-sm leading-relaxed whitespace-pre-wrap text-ink"
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
{#if isClamped || expanded}
|
||||
<button
|
||||
onclick={() => (expanded = !expanded)}
|
||||
class="mt-2 font-sans text-xs text-gray-400 transition hover:text-brand-navy"
|
||||
class="mt-2 font-sans text-xs text-ink-3 transition hover:text-ink"
|
||||
>
|
||||
{expanded ? m.comp_expandable_show_less() : m.comp_expandable_show_more()}
|
||||
</button>
|
||||
|
||||
27
frontend/src/lib/components/PanelDiscussion.svelte
Normal file
27
frontend/src/lib/components/PanelDiscussion.svelte
Normal file
@@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
import CommentThread from './CommentThread.svelte';
|
||||
import type { Comment } from '$lib/types';
|
||||
|
||||
type Props = {
|
||||
documentId: string;
|
||||
initialComments: Comment[];
|
||||
canComment: boolean;
|
||||
currentUserId: string | null;
|
||||
canAdmin: boolean;
|
||||
onCountChange?: (count: number) => void;
|
||||
};
|
||||
|
||||
let { documentId, initialComments, canComment, currentUserId, canAdmin, onCountChange }: Props =
|
||||
$props();
|
||||
</script>
|
||||
|
||||
<div class="flex-1 overflow-y-auto p-6">
|
||||
<CommentThread
|
||||
documentId={documentId}
|
||||
initialComments={initialComments}
|
||||
canComment={canComment}
|
||||
currentUserId={currentUserId}
|
||||
canAdmin={canAdmin}
|
||||
onCountChange={onCountChange}
|
||||
/>
|
||||
</div>
|
||||
519
frontend/src/lib/components/PanelHistory.svelte
Normal file
519
frontend/src/lib/components/PanelHistory.svelte
Normal file
@@ -0,0 +1,519 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { diffWords } from 'diff';
|
||||
|
||||
let { documentId }: { documentId: string } = $props();
|
||||
|
||||
type VersionSummary = {
|
||||
id: string;
|
||||
savedAt: string;
|
||||
editorName: string;
|
||||
changedFields: string[];
|
||||
};
|
||||
|
||||
type SnapshotDoc = {
|
||||
title?: string;
|
||||
documentDate?: string;
|
||||
location?: string;
|
||||
documentLocation?: string;
|
||||
transcription?: string;
|
||||
summary?: string;
|
||||
sender?: { id: string; firstName: string; lastName: string } | null;
|
||||
receivers?: { id: string; firstName: string; lastName: string }[];
|
||||
tags?: { id: string; name: string }[];
|
||||
};
|
||||
|
||||
type DiffEntry =
|
||||
| {
|
||||
kind: 'text';
|
||||
field: string;
|
||||
label: string;
|
||||
parts: { value: string; added?: boolean; removed?: boolean }[];
|
||||
}
|
||||
| { kind: 'scalar'; field: string; label: string; oldVal: string; newVal: string }
|
||||
| { kind: 'relation'; field: string; label: string; removed: string[]; added: string[] };
|
||||
|
||||
let historyLoaded = $state(false);
|
||||
let historyLoading = $state(false);
|
||||
let versions = $state<VersionSummary[]>([]);
|
||||
|
||||
let compareMode = $state(false);
|
||||
let compareA = $state('');
|
||||
let compareB = $state('');
|
||||
|
||||
let selectedVersionId = $state<string | null>(null);
|
||||
let diffEntries = $state<DiffEntry[]>([]);
|
||||
let diffLoading = $state(false);
|
||||
let noDiff = $state(false);
|
||||
|
||||
const fieldLabels: Record<string, () => string> = {
|
||||
title: m.history_field_title,
|
||||
documentDate: m.history_field_document_date,
|
||||
location: m.history_field_location,
|
||||
documentLocation: m.history_field_document_location,
|
||||
transcription: m.history_field_transcription,
|
||||
summary: m.history_field_summary,
|
||||
sender: m.history_field_sender,
|
||||
receivers: m.history_field_receivers,
|
||||
tags: m.history_field_tags
|
||||
};
|
||||
|
||||
const TEXT_FIELDS = ['title', 'summary', 'transcription'] as const;
|
||||
const SCALAR_FIELDS = ['documentDate', 'location', 'documentLocation'] as const;
|
||||
|
||||
function parseSnapshot(raw: string): SnapshotDoc {
|
||||
try {
|
||||
return JSON.parse(raw) as SnapshotDoc;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function personLabel(p: { firstName: string; lastName: string }): string {
|
||||
return `${p.firstName} ${p.lastName}`.trim();
|
||||
}
|
||||
|
||||
const DIFF_CONTEXT_WORDS = 4;
|
||||
|
||||
type DiffPart = { value: string; added?: boolean; removed?: boolean };
|
||||
|
||||
function trimContextParts(parts: DiffPart[]): DiffPart[] {
|
||||
return parts.flatMap((part, i) => {
|
||||
if (part.added || part.removed) return [part];
|
||||
const tokens = part.value.split(/(\s+)/).filter(Boolean);
|
||||
const wordCount = tokens.filter((t) => /\S/.test(t)).length;
|
||||
if (wordCount <= DIFF_CONTEXT_WORDS * 2) return [part];
|
||||
|
||||
function keepFirst(n: number): string {
|
||||
let count = 0;
|
||||
const out: string[] = [];
|
||||
for (const t of tokens) {
|
||||
out.push(t);
|
||||
if (/\S/.test(t) && ++count >= n) break;
|
||||
}
|
||||
return out.join('');
|
||||
}
|
||||
function keepLast(n: number): string {
|
||||
let count = 0;
|
||||
const out: string[] = [];
|
||||
for (const t of [...tokens].reverse()) {
|
||||
out.unshift(t);
|
||||
if (/\S/.test(t) && ++count >= n) break;
|
||||
}
|
||||
return out.join('');
|
||||
}
|
||||
|
||||
const isFirst = i === 0;
|
||||
const isLast = i === parts.length - 1;
|
||||
if (isFirst) return [{ value: '… ' + keepLast(DIFF_CONTEXT_WORDS) }];
|
||||
if (isLast) return [{ value: keepFirst(DIFF_CONTEXT_WORDS) + ' …' }];
|
||||
return [{ value: keepFirst(DIFF_CONTEXT_WORDS) + ' … ' + keepLast(DIFF_CONTEXT_WORDS) }];
|
||||
});
|
||||
}
|
||||
|
||||
function buildDiff(older: SnapshotDoc | null, newer: SnapshotDoc): DiffEntry[] {
|
||||
const entries: DiffEntry[] = [];
|
||||
|
||||
for (const field of TEXT_FIELDS) {
|
||||
const a = older?.[field] ?? '';
|
||||
const b = newer[field] ?? '';
|
||||
if (a === b) continue;
|
||||
const parts = trimContextParts(diffWords(a, b));
|
||||
entries.push({ kind: 'text', field, label: fieldLabels[field](), parts });
|
||||
}
|
||||
|
||||
for (const field of SCALAR_FIELDS) {
|
||||
const a = older?.[field] ?? '';
|
||||
const b = newer[field] ?? '';
|
||||
if (a === b) continue;
|
||||
entries.push({ kind: 'scalar', field, label: fieldLabels[field](), oldVal: a, newVal: b });
|
||||
}
|
||||
|
||||
const senderA = older?.sender ? personLabel(older.sender) : '';
|
||||
const senderB = newer.sender ? personLabel(newer.sender) : '';
|
||||
if (senderA !== senderB) {
|
||||
entries.push({
|
||||
kind: 'relation',
|
||||
field: 'sender',
|
||||
label: fieldLabels['sender'](),
|
||||
removed: senderA ? [senderA] : [],
|
||||
added: senderB ? [senderB] : []
|
||||
});
|
||||
}
|
||||
|
||||
const receiversA = new Set((older?.receivers ?? []).map(personLabel));
|
||||
const receiversB = new Set((newer.receivers ?? []).map(personLabel));
|
||||
const removedReceivers = [...receiversA].filter((r) => !receiversB.has(r));
|
||||
const addedReceivers = [...receiversB].filter((r) => !receiversA.has(r));
|
||||
if (removedReceivers.length > 0 || addedReceivers.length > 0) {
|
||||
entries.push({
|
||||
kind: 'relation',
|
||||
field: 'receivers',
|
||||
label: fieldLabels['receivers'](),
|
||||
removed: removedReceivers,
|
||||
added: addedReceivers
|
||||
});
|
||||
}
|
||||
|
||||
const tagsA = new Set((older?.tags ?? []).map((t) => t.name));
|
||||
const tagsB = new Set((newer.tags ?? []).map((t) => t.name));
|
||||
const removedTags = [...tagsA].filter((t) => !tagsB.has(t));
|
||||
const addedTags = [...tagsB].filter((t) => !tagsA.has(t));
|
||||
if (removedTags.length > 0 || addedTags.length > 0) {
|
||||
entries.push({
|
||||
kind: 'relation',
|
||||
field: 'tags',
|
||||
label: fieldLabels['tags'](),
|
||||
removed: removedTags,
|
||||
added: addedTags
|
||||
});
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
async function fetchSnapshot(versionId: string): Promise<SnapshotDoc> {
|
||||
const res = await fetch(`/api/documents/${documentId}/versions/${versionId}`);
|
||||
if (!res.ok) throw new Error('Failed to fetch version');
|
||||
const v = await res.json();
|
||||
return parseSnapshot(v.snapshot);
|
||||
}
|
||||
|
||||
async function loadHistory() {
|
||||
if (historyLoaded) return;
|
||||
historyLoading = true;
|
||||
try {
|
||||
const res = await fetch(`/api/documents/${documentId}/versions`);
|
||||
if (res.ok) {
|
||||
versions = await res.json();
|
||||
}
|
||||
historyLoaded = true;
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
historyLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function selectVersion(versionId: string) {
|
||||
if (selectedVersionId === versionId) {
|
||||
selectedVersionId = null;
|
||||
diffEntries = [];
|
||||
noDiff = false;
|
||||
return;
|
||||
}
|
||||
selectedVersionId = versionId;
|
||||
diffEntries = [];
|
||||
noDiff = false;
|
||||
diffLoading = true;
|
||||
try {
|
||||
const idx = versions.findIndex((v) => v.id === versionId);
|
||||
const newerSnap = await fetchSnapshot(versionId);
|
||||
const olderSnap = idx > 0 ? await fetchSnapshot(versions[idx - 1].id) : null;
|
||||
const entries = buildDiff(olderSnap, newerSnap);
|
||||
if (entries.length === 0) {
|
||||
noDiff = true;
|
||||
} else {
|
||||
diffEntries = entries;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
diffLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function applyCompare() {
|
||||
if (!compareA || !compareB || compareA === compareB) return;
|
||||
selectedVersionId = null;
|
||||
diffEntries = [];
|
||||
noDiff = false;
|
||||
diffLoading = true;
|
||||
try {
|
||||
const [snapA, snapB] = await Promise.all([fetchSnapshot(compareA), fetchSnapshot(compareB)]);
|
||||
const entries = buildDiff(snapA, snapB);
|
||||
if (entries.length === 0) {
|
||||
noDiff = true;
|
||||
} else {
|
||||
diffEntries = entries;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
diffLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDateTime(iso: string): string {
|
||||
try {
|
||||
return new Intl.DateTimeFormat('de-DE', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}).format(new Date(iso));
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
function versionLabel(v: VersionSummary, index: number): string {
|
||||
return `Version ${index + 1} — ${v.editorName} — ${formatDateTime(v.savedAt)}`;
|
||||
}
|
||||
|
||||
// Load history when this panel mounts.
|
||||
$effect(() => {
|
||||
loadHistory();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="space-y-4 p-6">
|
||||
{#if historyLoading}
|
||||
<p class="font-sans text-xs text-ink-3">{m.history_loading()}</p>
|
||||
{:else if !historyLoaded}
|
||||
<!-- initial state before effect runs — show nothing -->
|
||||
{:else if versions.length === 0}
|
||||
<p class="font-serif text-sm text-ink-3 italic">{m.history_empty()}</p>
|
||||
{:else}
|
||||
<!-- Compare mode toggle -->
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
onclick={() => {
|
||||
compareMode = !compareMode;
|
||||
diffEntries = [];
|
||||
noDiff = false;
|
||||
selectedVersionId = null;
|
||||
}}
|
||||
class="font-sans text-xs font-medium transition {compareMode
|
||||
? 'text-ink underline'
|
||||
: 'text-ink-3 hover:text-ink'}"
|
||||
>
|
||||
{m.history_compare_mode()}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if compareMode}
|
||||
<div class="space-y-2">
|
||||
<div>
|
||||
<label for="compare-a" class="mb-1 block font-sans text-[10px] text-ink-3 uppercase"
|
||||
>{m.history_compare_select_a()}</label
|
||||
>
|
||||
<select
|
||||
id="compare-a"
|
||||
bind:value={compareA}
|
||||
class="w-full rounded border border-line bg-surface px-2 py-1 font-sans text-xs text-ink focus:ring-1 focus:ring-accent 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-ink-3 uppercase"
|
||||
>{m.history_compare_select_b()}</label
|
||||
>
|
||||
<select
|
||||
id="compare-b"
|
||||
bind:value={compareB}
|
||||
class="w-full rounded border border-line bg-surface px-2 py-1 font-sans text-xs text-ink focus:ring-1 focus:ring-accent 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-primary px-3 py-1.5 font-sans text-xs font-medium text-white transition hover:bg-primary/80 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{m.history_compare_apply()}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Diff panel for compare mode -->
|
||||
{#if diffLoading}
|
||||
<p class="font-sans text-xs text-ink-3">{m.history_loading()}</p>
|
||||
{:else if noDiff}
|
||||
<div
|
||||
data-testid="history-diff"
|
||||
class="rounded-sm border border-line bg-surface p-4 font-serif text-sm text-ink-3 italic"
|
||||
>
|
||||
{m.history_diff_no_changes()}
|
||||
</div>
|
||||
{:else if diffEntries.length > 0}
|
||||
<div
|
||||
data-testid="history-diff"
|
||||
class="space-y-4 rounded-sm border border-line bg-surface p-4"
|
||||
>
|
||||
{#each diffEntries as entry (entry.field)}
|
||||
<div>
|
||||
<span
|
||||
class="mb-1.5 block font-sans text-[10px] font-bold tracking-wide text-ink-3 uppercase"
|
||||
>{entry.label}</span
|
||||
>
|
||||
{#if entry.kind === 'text'}
|
||||
<p class="font-serif text-sm leading-relaxed">
|
||||
{#each entry.parts as part, partIdx (partIdx)}
|
||||
{#if part.added}
|
||||
<span class="bg-green-50 text-green-700">{part.value}</span>
|
||||
{:else if part.removed}
|
||||
<span class="bg-red-50 text-red-600 line-through">{part.value}</span>
|
||||
{:else}
|
||||
<span>{part.value}</span>
|
||||
{/if}
|
||||
{/each}
|
||||
</p>
|
||||
{:else if entry.kind === 'scalar'}
|
||||
<div class="flex items-center gap-2 font-serif text-sm">
|
||||
<span class="text-red-600 line-through">{entry.oldVal || '—'}</span>
|
||||
<svg
|
||||
class="h-3 w-3 flex-shrink-0 text-ink-3"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-green-700">{entry.newVal || '—'}</span>
|
||||
</div>
|
||||
{:else if entry.kind === 'relation'}
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{#each entry.removed as item (item)}
|
||||
<span
|
||||
class="rounded bg-red-50 px-1.5 py-0.5 font-sans text-[11px] text-red-600 line-through"
|
||||
>{item}</span
|
||||
>
|
||||
{/each}
|
||||
{#each entry.added as item (item)}
|
||||
<span
|
||||
class="rounded bg-green-50 px-1.5 py-0.5 font-sans text-[11px] text-green-700"
|
||||
>{item}</span
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<!-- Version list with inline diff below each selected item -->
|
||||
<ul class="divide-brand-sand divide-y">
|
||||
{#each versions as v, i (v.id)}
|
||||
<li>
|
||||
<button
|
||||
onclick={() => selectVersion(v.id)}
|
||||
data-testid="history-version"
|
||||
class="w-full py-2 text-left transition hover:bg-muted {selectedVersionId ===
|
||||
v.id
|
||||
? 'border-l-2 border-accent pl-2'
|
||||
: 'pl-0'}"
|
||||
>
|
||||
<div class="flex items-baseline justify-between gap-2">
|
||||
<span class="font-sans text-xs font-medium text-ink">
|
||||
Version {i + 1}
|
||||
</span>
|
||||
<span class="font-sans text-[10px] text-ink-3">
|
||||
{formatDateTime(v.savedAt)}
|
||||
</span>
|
||||
</div>
|
||||
<span class="font-sans text-[11px] text-ink-2">{v.editorName}</span>
|
||||
{#if v.changedFields && v.changedFields.length > 0}
|
||||
<div class="mt-1 flex flex-wrap gap-1">
|
||||
{#each v.changedFields as field (field)}
|
||||
<span
|
||||
class="rounded bg-muted px-1.5 py-0.5 font-sans text-[10px] tracking-wide text-ink-2 uppercase"
|
||||
>
|
||||
{fieldLabels[field] ? fieldLabels[field]() : field}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Diff shown inline below the selected version -->
|
||||
{#if selectedVersionId === v.id}
|
||||
{#if diffLoading}
|
||||
<p class="pb-3 pl-2 font-sans text-xs text-ink-3">{m.history_loading()}</p>
|
||||
{:else if noDiff}
|
||||
<div
|
||||
data-testid="history-diff"
|
||||
class="mb-2 rounded-sm border border-line bg-surface p-4 font-serif text-sm text-ink-3 italic"
|
||||
>
|
||||
{m.history_diff_no_changes()}
|
||||
</div>
|
||||
{:else if diffEntries.length > 0}
|
||||
<div
|
||||
data-testid="history-diff"
|
||||
class="mb-2 space-y-4 rounded-sm border border-line bg-surface p-4"
|
||||
>
|
||||
{#each diffEntries as entry (entry.field)}
|
||||
<div>
|
||||
<span
|
||||
class="mb-1.5 block font-sans text-[10px] font-bold tracking-wide text-ink-3 uppercase"
|
||||
>{entry.label}</span
|
||||
>
|
||||
{#if entry.kind === 'text'}
|
||||
<p class="font-serif text-sm leading-relaxed">
|
||||
{#each entry.parts as part, partIdx (partIdx)}
|
||||
{#if part.added}
|
||||
<span class="bg-green-50 text-green-700">{part.value}</span>
|
||||
{:else if part.removed}
|
||||
<span class="bg-red-50 text-red-600 line-through">{part.value}</span>
|
||||
{:else}
|
||||
<span>{part.value}</span>
|
||||
{/if}
|
||||
{/each}
|
||||
</p>
|
||||
{:else if entry.kind === 'scalar'}
|
||||
<div class="flex items-center gap-2 font-serif text-sm">
|
||||
<span class="text-red-600 line-through">{entry.oldVal || '—'}</span>
|
||||
<svg
|
||||
class="h-3 w-3 flex-shrink-0 text-ink-3"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-green-700">{entry.newVal || '—'}</span>
|
||||
</div>
|
||||
{:else if entry.kind === 'relation'}
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{#each entry.removed as item (item)}
|
||||
<span
|
||||
class="rounded bg-red-50 px-1.5 py-0.5 font-sans text-[11px] text-red-600 line-through"
|
||||
>{item}</span
|
||||
>
|
||||
{/each}
|
||||
{#each entry.added as item (item)}
|
||||
<span
|
||||
class="rounded bg-green-50 px-1.5 py-0.5 font-sans text-[11px] text-green-700"
|
||||
>{item}</span
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
200
frontend/src/lib/components/PanelMetadata.svelte
Normal file
200
frontend/src/lib/components/PanelMetadata.svelte
Normal file
@@ -0,0 +1,200 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { formatDate } from '$lib/utils/date';
|
||||
|
||||
type Person = { id: string; firstName: string; lastName: string; alias?: string | null };
|
||||
type Tag = { id: string; name: string };
|
||||
|
||||
type Doc = {
|
||||
documentDate?: string | null;
|
||||
location?: string | null;
|
||||
documentLocation?: string | null;
|
||||
tags?: Tag[] | null;
|
||||
sender?: Person | null;
|
||||
receivers?: Person[] | null;
|
||||
};
|
||||
|
||||
let { doc }: { doc: Doc } = $props();
|
||||
</script>
|
||||
|
||||
<div class="space-y-10 p-6">
|
||||
<!-- DETAILS GROUP -->
|
||||
<div>
|
||||
<h3
|
||||
class="mb-4 border-b border-line pb-2 font-sans text-xs font-bold tracking-widest text-ink uppercase"
|
||||
>
|
||||
{m.doc_section_details()}
|
||||
</h3>
|
||||
<div class="space-y-5">
|
||||
<!-- Date -->
|
||||
<div class="flex items-start">
|
||||
<span class="mt-0.5 w-8 text-accent">
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Calendar/Calendar-Add-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
</span>
|
||||
<div>
|
||||
<span class="block font-serif text-lg text-ink">
|
||||
{doc.documentDate ? formatDate(doc.documentDate) : '—'}
|
||||
</span>
|
||||
<span class="font-sans text-xs text-ink-2">{m.doc_label_document_date()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Creation Location -->
|
||||
<div class="flex items-start">
|
||||
<span class="mt-0.5 w-8 text-accent">
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Location-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
</span>
|
||||
<div>
|
||||
<span class="block font-serif text-lg text-ink">
|
||||
{doc.location ? doc.location : '—'}
|
||||
</span>
|
||||
<span class="font-sans text-xs text-ink-2">{m.doc_label_creation_location()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Physical Archive Location -->
|
||||
{#if doc.documentLocation}
|
||||
<div class="flex items-start">
|
||||
<span class="mt-0.5 w-8 text-accent">
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Folder-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
</span>
|
||||
<div>
|
||||
<span class="block font-serif text-lg text-ink">
|
||||
{doc.documentLocation}
|
||||
</span>
|
||||
<span class="font-sans text-xs text-ink-2"
|
||||
>{m.doc_label_archive_location_original()}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Tags -->
|
||||
{#if doc.tags && doc.tags.length > 0}
|
||||
<div class="flex items-start">
|
||||
<span class="mt-0.5 w-8 text-accent">
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Bookmark/Bookmark-Outline-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
</span>
|
||||
<div class="flex-1">
|
||||
<div class="mb-1 flex flex-wrap gap-2">
|
||||
{#each doc.tags as tag (tag.id)}
|
||||
<a
|
||||
href="/?tag={encodeURIComponent(tag.name)}"
|
||||
class="inline-flex items-center rounded bg-muted px-2 py-0.5 text-xs font-bold tracking-wide text-ink uppercase transition-colors hover:bg-primary hover:text-white"
|
||||
title={m.doc_tag_filter_title({ name: tag.name })}
|
||||
>
|
||||
{tag.name}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
<span class="font-sans text-xs text-ink-2">{m.form_label_tags()}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PERSONEN GROUP -->
|
||||
<div>
|
||||
<h3
|
||||
class="mb-4 border-b border-line pb-2 font-sans text-xs font-bold tracking-widest text-ink uppercase"
|
||||
>
|
||||
{m.doc_section_persons()}
|
||||
</h3>
|
||||
|
||||
<div class="mb-6">
|
||||
<span class="mb-2 block font-sans text-xs text-ink-3 uppercase">{m.form_label_sender()}</span>
|
||||
{#if doc.sender}
|
||||
<a
|
||||
href="/persons/{doc.sender.id}"
|
||||
class="group block rounded border border-line bg-muted p-3 transition hover:border-accent hover:bg-accent/10"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="flex h-8 w-8 items-center justify-center rounded-full bg-primary font-serif text-sm text-white"
|
||||
>
|
||||
{doc.sender.firstName[0]}{doc.sender.lastName[0]}
|
||||
</div>
|
||||
<div>
|
||||
<p
|
||||
class="font-serif text-ink 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-ink-2">{doc.sender.alias}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{:else}
|
||||
<span class="font-serif text-sm text-ink-3 italic">{m.doc_sender_not_specified()}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span class="mb-2 block font-sans text-xs text-ink-3 uppercase"
|
||||
>{m.form_label_receivers()}</span
|
||||
>
|
||||
{#if doc.receivers && doc.receivers.length > 0}
|
||||
<div class="space-y-2">
|
||||
{#each doc.receivers as receiver (receiver.id)}
|
||||
<div
|
||||
class="group flex items-center justify-between rounded border border-line bg-surface p-3 transition hover:border-primary"
|
||||
>
|
||||
<a href="/persons/{receiver.id}" class="flex min-w-0 flex-1 items-center gap-3">
|
||||
<div
|
||||
class="flex h-6 w-6 items-center justify-center rounded-full bg-muted font-serif text-xs text-ink-2"
|
||||
>
|
||||
{receiver.firstName[0]}{receiver.lastName[0]}
|
||||
</div>
|
||||
<span class="truncate font-serif text-sm text-ink">
|
||||
{receiver.firstName}
|
||||
{receiver.lastName}
|
||||
</span>
|
||||
</a>
|
||||
|
||||
{#if doc.sender}
|
||||
<a
|
||||
href="/conversations?senderId={doc.sender.id}&receiverId={receiver.id}"
|
||||
class="text-ink-3 transition hover:text-accent"
|
||||
title={m.doc_conversation_title()}
|
||||
>
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Chat-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<span class="font-serif text-sm text-ink-3 italic">{m.doc_no_receivers()}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
38
frontend/src/lib/components/PanelTranscription.svelte
Normal file
38
frontend/src/lib/components/PanelTranscription.svelte
Normal file
@@ -0,0 +1,38 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
type Doc = {
|
||||
summary?: string | null;
|
||||
transcription?: string | null;
|
||||
};
|
||||
|
||||
let { doc }: { doc: Doc } = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex justify-center px-6 py-8">
|
||||
<div class="w-full max-w-prose space-y-8">
|
||||
{#if !doc.summary && !doc.transcription}
|
||||
<p class="font-serif text-sm text-ink-3 italic">—</p>
|
||||
{/if}
|
||||
|
||||
{#if doc.summary}
|
||||
<div>
|
||||
<span class="mb-3 block font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{m.doc_label_summary()}
|
||||
</span>
|
||||
<p class="font-serif text-base leading-relaxed text-ink">{doc.summary}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if doc.transcription}
|
||||
<div>
|
||||
<span class="mb-3 block font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{m.form_label_transcription()}
|
||||
</span>
|
||||
<p class="font-serif text-base leading-relaxed whitespace-pre-wrap text-ink">
|
||||
{doc.transcription}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,8 +1,28 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
import type { PDFDocumentProxy, PDFPageProxy, RenderTask } from 'pdfjs-dist';
|
||||
import AnnotationLayer from './AnnotationLayer.svelte';
|
||||
import type { Annotation } from '$lib/types';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
let { url }: { url: string } = $props();
|
||||
let {
|
||||
url,
|
||||
documentId = '',
|
||||
annotateMode = $bindable(false),
|
||||
activeAnnotationId = $bindable<string | null>(null),
|
||||
activeAnnotationPage = $bindable<number | null>(null),
|
||||
onAnnotationClick,
|
||||
documentFileHash
|
||||
}: {
|
||||
url: string;
|
||||
documentId?: string;
|
||||
annotateMode?: boolean;
|
||||
activeAnnotationId?: string | null;
|
||||
activeAnnotationPage?: number | null;
|
||||
onAnnotationClick?: (id: string) => void;
|
||||
documentFileHash?: string | null;
|
||||
} = $props();
|
||||
|
||||
let pdfDoc = $state<PDFDocumentProxy | null>(null);
|
||||
let currentPage = $state(1);
|
||||
@@ -24,6 +44,16 @@ let textLayerInstance: { cancel: () => void } | null = null;
|
||||
let pdfjsLib: typeof import('pdfjs-dist') | null = null;
|
||||
let pdfjsReady = $state(false);
|
||||
|
||||
let annotations = $state<Annotation[]>([]);
|
||||
let annotateColor = $state('#ffff00');
|
||||
let commentCounts = new SvelteMap<string, number>();
|
||||
let showAnnotations = $state(true);
|
||||
|
||||
const visibleAnnotations = $derived(
|
||||
annotations.filter((a) => !a.fileHash || !documentFileHash || a.fileHash === documentFileHash)
|
||||
);
|
||||
const outdatedCount = $derived(annotations.length - visibleAnnotations.length);
|
||||
|
||||
onMount(async () => {
|
||||
// Dynamic import keeps pdfjs out of the SSR bundle entirely
|
||||
const [lib, { default: workerUrl }] = await Promise.all([
|
||||
@@ -134,6 +164,84 @@ async function prerender(doc: PDFDocumentProxy, pageNum: number) {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCommentCounts(docId: string, anns: Annotation[]) {
|
||||
await Promise.all(
|
||||
anns.map(async (a) => {
|
||||
try {
|
||||
const res = await fetch(`/api/documents/${docId}/annotations/${a.id}/comments`);
|
||||
if (res.ok) {
|
||||
const threads = (await res.json()) as Array<{ replies: unknown[] }>;
|
||||
const total = threads.reduce((sum, t) => sum + 1 + t.replies.length, 0);
|
||||
commentCounts.set(a.id, total);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function loadAnnotations(docId: string) {
|
||||
if (!docId) return;
|
||||
try {
|
||||
const res = await fetch(`/api/documents/${docId}/annotations`);
|
||||
if (res.ok) {
|
||||
annotations = await res.json();
|
||||
await loadCommentCounts(docId, annotations);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAnnotationDraw(rect: { x: number; y: number; width: number; height: number }) {
|
||||
if (!documentId) return;
|
||||
try {
|
||||
const res = await fetch(`/api/documents/${documentId}/annotations`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
pageNumber: currentPage,
|
||||
x: rect.x,
|
||||
y: rect.y,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
color: annotateColor
|
||||
})
|
||||
});
|
||||
if (res.ok) {
|
||||
const created: Annotation = await res.json();
|
||||
annotations = [...annotations, created];
|
||||
activeAnnotationId = created.id;
|
||||
activeAnnotationPage = created.pageNumber;
|
||||
onAnnotationClick?.(created.id);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAnnotationDelete(annotationId: string) {
|
||||
if (!documentId) return;
|
||||
try {
|
||||
const res = await fetch(`/api/documents/${documentId}/annotations/${annotationId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
if (res.ok) {
|
||||
annotations = annotations.filter((a) => a.id !== annotationId);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function handleAnnotationClick(id: string) {
|
||||
activeAnnotationId = id;
|
||||
const ann = annotations.find((a) => a.id === id);
|
||||
activeAnnotationPage = ann?.pageNumber ?? null;
|
||||
onAnnotationClick?.(id);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (pdfjsReady && url) {
|
||||
loadDocument(url);
|
||||
@@ -151,6 +259,16 @@ $effect(() => {
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (documentId) {
|
||||
loadAnnotations(documentId);
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (annotateMode) showAnnotations = true;
|
||||
});
|
||||
|
||||
function prevPage() {
|
||||
if (currentPage > 1) currentPage -= 1;
|
||||
}
|
||||
@@ -169,28 +287,47 @@ function zoomOut() {
|
||||
</script>
|
||||
|
||||
{#if !url}
|
||||
<div class="flex h-full w-full items-center justify-center bg-[#2A2A2A] text-gray-400">
|
||||
<div class="flex h-full w-full items-center justify-center bg-pdf-bg text-ink-3">
|
||||
<p class="font-sans text-sm">Keine Datei vorhanden</p>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div
|
||||
class="flex h-full w-full flex-col items-center justify-center gap-3 bg-[#2A2A2A] text-gray-300"
|
||||
>
|
||||
<div class="flex h-full w-full flex-col items-center justify-center gap-3 bg-pdf-bg text-ink-3">
|
||||
<p class="font-sans text-sm text-red-400">Fehler beim Laden der PDF</p>
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="font-sans text-xs text-brand-mint underline hover:opacity-80"
|
||||
class="font-sans text-xs text-accent underline hover:opacity-80"
|
||||
>
|
||||
Direkt öffnen
|
||||
</a>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex h-full w-full flex-col bg-[#2A2A2A]">
|
||||
<div class="flex h-full w-full flex-col bg-pdf-bg">
|
||||
{#if outdatedCount > 0}
|
||||
<div
|
||||
class="flex shrink-0 items-center gap-2 border-b border-amber-500/30 bg-amber-500/10 px-4 py-2"
|
||||
data-testid="annotation-outdated-notice"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4 shrink-0 text-amber-400"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="font-sans text-xs text-amber-300">{m.annotation_outdated_notice()}</span>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Controls -->
|
||||
<div
|
||||
class="flex shrink-0 items-center justify-between gap-2 border-b border-white/10 px-4 py-2"
|
||||
class="flex shrink-0 items-center justify-between gap-2 border-b border-pdf-ctrl px-4 py-2"
|
||||
>
|
||||
<!-- Page navigation -->
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -198,7 +335,7 @@ function zoomOut() {
|
||||
onclick={prevPage}
|
||||
disabled={currentPage <= 1}
|
||||
aria-label="Zurück"
|
||||
class="rounded p-1 text-gray-300 transition hover:bg-white/10 disabled:opacity-40"
|
||||
class="rounded p-1 text-ink-3 transition hover:bg-surface/10 disabled:opacity-40"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
@@ -212,7 +349,7 @@ function zoomOut() {
|
||||
</button>
|
||||
|
||||
{#if totalPages > 0}
|
||||
<span class="font-sans text-xs text-gray-300 tabular-nums">
|
||||
<span class="font-sans text-xs text-ink-3 tabular-nums">
|
||||
{currentPage} / {totalPages}
|
||||
</span>
|
||||
{/if}
|
||||
@@ -221,7 +358,7 @@ function zoomOut() {
|
||||
onclick={nextPage}
|
||||
disabled={!pdfDoc || currentPage >= totalPages}
|
||||
aria-label="Weiter"
|
||||
class="rounded p-1 text-gray-300 transition hover:bg-white/10 disabled:opacity-40"
|
||||
class="rounded p-1 text-ink-3 transition hover:bg-surface/10 disabled:opacity-40"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
@@ -240,7 +377,7 @@ function zoomOut() {
|
||||
<button
|
||||
onclick={zoomOut}
|
||||
aria-label="Verkleinern"
|
||||
class="rounded p-1 text-gray-300 transition hover:bg-white/10"
|
||||
class="rounded p-1 text-ink-3 transition hover:bg-surface/10"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
@@ -258,7 +395,7 @@ function zoomOut() {
|
||||
<button
|
||||
onclick={zoomIn}
|
||||
aria-label="Vergrößern"
|
||||
class="rounded p-1 text-gray-300 transition hover:bg-white/10"
|
||||
class="rounded p-1 text-ink-3 transition hover:bg-surface/10"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
@@ -274,6 +411,55 @@ 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}
|
||||
<!-- Annotation visibility toggle (shown when annotations exist) -->
|
||||
{#if annotations.length > 0}
|
||||
<button
|
||||
onclick={() => (showAnnotations = !showAnnotations)}
|
||||
aria-label={showAnnotations ? m.pdf_annotations_hide() : m.pdf_annotations_show()}
|
||||
class="flex items-center gap-1.5 rounded px-2 py-1 font-sans text-xs transition {showAnnotations
|
||||
? 'text-ink-3 hover:bg-surface/10'
|
||||
: 'bg-surface/10 text-accent'}"
|
||||
>
|
||||
<svg
|
||||
class="h-3.5 w-3.5 shrink-0"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
{#if showAnnotations}
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||
/>
|
||||
{:else}
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"
|
||||
/>
|
||||
{/if}
|
||||
</svg>
|
||||
{showAnnotations ? m.pdf_annotations_hide() : m.pdf_annotations_show()}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- PDF canvas area -->
|
||||
@@ -297,6 +483,17 @@ function zoomOut() {
|
||||
class="textLayer"
|
||||
style="position: absolute; top: 0; left: 0; overflow: hidden; pointer-events: none; line-height: 1;"
|
||||
></div>
|
||||
{#if showAnnotations}
|
||||
<AnnotationLayer
|
||||
annotations={visibleAnnotations.filter((a) => a.pageNumber === currentPage)}
|
||||
canAnnotate={annotateMode}
|
||||
color={annotateColor}
|
||||
onDraw={handleAnnotationDraw}
|
||||
onDelete={handleAnnotationDelete}
|
||||
commentCounts={Object.fromEntries(commentCounts)}
|
||||
onAnnotationClick={handleAnnotationClick}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -80,18 +80,18 @@ function clickOutside(node: HTMLElement) {
|
||||
|
||||
<div class="relative" use:clickOutside>
|
||||
<div
|
||||
class="flex min-h-[42px] flex-wrap gap-2 rounded border border-gray-300 bg-white p-2 focus-within:border-brand-navy focus-within:ring-1 focus-within:ring-brand-navy"
|
||||
class="flex min-h-[42px] flex-wrap gap-2 rounded border border-line bg-surface p-2 focus-within:border-ink focus-within:ring-1 focus-within:ring-ink"
|
||||
>
|
||||
{#each selectedPersons as person (person.id)}
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded bg-brand-sand/40 px-2 py-1 text-sm font-medium text-brand-navy"
|
||||
class="inline-flex items-center gap-1 rounded bg-muted px-2 py-1 text-sm font-medium text-ink"
|
||||
>
|
||||
{person.firstName}
|
||||
{person.lastName}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => removePerson(person.id)}
|
||||
class="ml-0.5 text-brand-navy/50 hover:text-red-500 focus:outline-none"
|
||||
class="ml-0.5 text-ink/50 hover:text-red-500 focus:outline-none"
|
||||
aria-label={m.comp_multiselect_remove()}
|
||||
>
|
||||
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -121,14 +121,14 @@ function clickOutside(node: HTMLElement) {
|
||||
{#if showDropdown && (results.length > 0 || loading)}
|
||||
<div
|
||||
style={dropdownStyle}
|
||||
class="ring-opacity-5 z-50 max-h-60 overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black sm:text-sm"
|
||||
class="ring-opacity-5 z-50 max-h-60 overflow-auto rounded-md bg-surface py-1 text-base shadow-lg ring-1 ring-black sm:text-sm"
|
||||
>
|
||||
{#if loading}
|
||||
<div class="p-2 text-sm text-gray-500">{m.comp_multiselect_loading()}</div>
|
||||
<div class="p-2 text-sm text-ink-2">{m.comp_multiselect_loading()}</div>
|
||||
{:else}
|
||||
{#each results as person (person.id)}
|
||||
<div
|
||||
class="cursor-pointer py-2 pr-9 pl-3 text-gray-900 select-none hover:bg-brand-sand/30"
|
||||
class="cursor-pointer py-2 pr-9 pl-3 text-ink select-none hover:bg-muted"
|
||||
onclick={() => selectPerson(person)}
|
||||
onkeydown={(e) => e.key === 'Enter' && selectPerson(person)}
|
||||
role="button"
|
||||
|
||||
@@ -9,6 +9,7 @@ interface Props {
|
||||
label: string;
|
||||
value?: string;
|
||||
initialName?: string;
|
||||
suggestedName?: string;
|
||||
restrictToCorrespondentsOf?: string;
|
||||
onchange?: (value: string) => void;
|
||||
}
|
||||
@@ -18,12 +19,20 @@ let {
|
||||
label,
|
||||
value = $bindable(''),
|
||||
initialName = '',
|
||||
suggestedName = '',
|
||||
restrictToCorrespondentsOf,
|
||||
onchange
|
||||
}: Props = $props();
|
||||
|
||||
let searchTerm = $state(initialName);
|
||||
|
||||
$effect(() => {
|
||||
const suggested = suggestedName;
|
||||
if (suggested && !untrack(() => value)) {
|
||||
searchTerm = suggested;
|
||||
}
|
||||
});
|
||||
|
||||
let results: Person[] = $state([]);
|
||||
let showDropdown = $state(false);
|
||||
let loading = $state(false);
|
||||
@@ -111,7 +120,7 @@ function clickOutside(node: HTMLElement) {
|
||||
</script>
|
||||
|
||||
<div class="relative" use:clickOutside>
|
||||
<label for={name} class="block text-sm font-medium text-gray-700">{label}</label>
|
||||
<label for={name} class="block text-sm font-medium text-ink-2">{label}</label>
|
||||
|
||||
<input type="hidden" name={name} bind:value={value} />
|
||||
|
||||
@@ -123,19 +132,19 @@ function clickOutside(node: HTMLElement) {
|
||||
oninput={handleInput}
|
||||
onfocus={handleFocus}
|
||||
placeholder={m.comp_typeahead_placeholder()}
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 p-2 shadow-sm focus:border-blue-500 focus:ring-blue-500"
|
||||
class="mt-1 block w-full rounded-md border border-line p-2 shadow-sm focus:border-accent focus:ring-accent"
|
||||
/>
|
||||
|
||||
{#if showDropdown && (results.length > 0 || loading)}
|
||||
<div
|
||||
class="ring-opacity-5 absolute top-full left-0 z-50 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black focus:outline-none sm:text-sm"
|
||||
class="ring-opacity-5 absolute top-full left-0 z-50 mt-1 max-h-60 w-full overflow-auto rounded-md bg-surface py-1 text-base shadow-lg ring-1 ring-black focus:outline-none sm:text-sm"
|
||||
>
|
||||
{#if loading}
|
||||
<div class="p-2 text-sm text-gray-500">{m.comp_typeahead_loading()}</div>
|
||||
<div class="p-2 text-sm text-ink-2">{m.comp_typeahead_loading()}</div>
|
||||
{:else}
|
||||
{#each results as person (person.id)}
|
||||
<div
|
||||
class="relative cursor-pointer py-2 pr-9 pl-3 text-gray-900 select-none hover:bg-blue-100"
|
||||
class="relative cursor-pointer py-2 pr-9 pl-3 text-ink select-none hover:bg-accent-bg"
|
||||
onclick={() => selectPerson(person)}
|
||||
onkeydown={(e) => e.key === 'Enter' && selectPerson(person)}
|
||||
role="button"
|
||||
|
||||
@@ -85,19 +85,17 @@ function clickOutside(node: HTMLElement) {
|
||||
<div class="w-full" use:clickOutside>
|
||||
<!-- Tag Container -->
|
||||
<div
|
||||
class="flex min-h-[42px] flex-wrap gap-2 rounded border border-gray-300 bg-white p-2 focus-within:border-brand-navy focus-within:ring-1 focus-within:ring-brand-navy"
|
||||
class="flex min-h-[42px] flex-wrap gap-2 rounded border border-line bg-surface p-2 focus-within:border-ink focus-within:ring-1 focus-within:ring-ink"
|
||||
>
|
||||
<!-- Render Selected Tags -->
|
||||
{#each tags as tag, i (i)}
|
||||
<span
|
||||
class="flex items-center gap-1 rounded bg-brand-sand/30 px-2 py-1 text-sm font-medium text-brand-navy"
|
||||
>
|
||||
<span class="flex items-center gap-1 rounded bg-muted px-2 py-1 text-sm font-medium text-ink">
|
||||
{tag}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => removeTag(i)}
|
||||
aria-label={m.comp_taginput_remove()}
|
||||
class="text-brand-navy/50 hover:text-red-500 focus:outline-none"
|
||||
class="text-ink/50 hover:text-red-500 focus:outline-none"
|
||||
>
|
||||
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
><path
|
||||
@@ -130,16 +128,16 @@ function clickOutside(node: HTMLElement) {
|
||||
<!-- Typeahead Dropdown -->
|
||||
{#if showSuggestions && suggestions.length > 0}
|
||||
<ul
|
||||
class="absolute top-full left-0 z-50 mt-1 max-h-48 w-full overflow-y-auto rounded border border-gray-200 bg-white shadow-lg"
|
||||
class="absolute top-full left-0 z-50 mt-1 max-h-48 w-full overflow-y-auto rounded border border-line bg-surface shadow-lg"
|
||||
>
|
||||
{#each suggestions as suggestion, i (i)}
|
||||
<li
|
||||
role="option"
|
||||
aria-selected={i === activeIndex}
|
||||
tabindex="0"
|
||||
class="cursor-pointer px-3 py-2 text-sm hover:bg-brand-sand/20 {i === activeIndex
|
||||
? 'bg-brand-sand/20 font-bold text-brand-navy'
|
||||
: 'text-gray-700'}"
|
||||
class="cursor-pointer px-3 py-2 text-sm hover:bg-muted {i === activeIndex
|
||||
? 'bg-muted font-bold text-ink'
|
||||
: 'text-ink-2'}"
|
||||
onclick={() => addTag(suggestion)}
|
||||
onkeydown={(e) => e.key === 'Enter' && addTag(suggestion)}
|
||||
>
|
||||
@@ -151,6 +149,6 @@ function clickOutside(node: HTMLElement) {
|
||||
</div>
|
||||
</div>
|
||||
{#if allowCreation}
|
||||
<p class="mt-1 text-xs text-gray-400">{m.comp_taginput_create_hint()}</p>
|
||||
<p class="mt-1 text-xs text-ink-3">{m.comp_taginput_create_hint()}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
69
frontend/src/lib/components/ThemeToggle.svelte
Normal file
69
frontend/src/lib/components/ThemeToggle.svelte
Normal file
@@ -0,0 +1,69 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
type Theme = 'light' | 'dark';
|
||||
|
||||
function systemPrefersDark(): boolean {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
}
|
||||
|
||||
function resolveInitialTheme(): Theme {
|
||||
const saved = localStorage.getItem('theme');
|
||||
if (saved === 'light' || saved === 'dark') return saved;
|
||||
return systemPrefersDark() ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
let theme = $state<Theme>('light');
|
||||
|
||||
onMount(() => {
|
||||
theme = resolveInitialTheme();
|
||||
});
|
||||
|
||||
function toggle() {
|
||||
theme = theme === 'dark' ? 'light' : 'dark';
|
||||
localStorage.setItem('theme', theme);
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
}
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={toggle}
|
||||
aria-label={theme === 'dark' ? 'light mode' : 'dark mode'}
|
||||
title={theme === 'dark' ? 'light mode' : 'dark mode'}
|
||||
class="rounded p-1.5 text-ink-2 transition-colors hover:bg-muted hover:text-ink"
|
||||
>
|
||||
{#if theme === 'dark'}
|
||||
<!-- Sun icon — click to go light -->
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
d="M12 2v2M12 20v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M2 12h2M20 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
<!-- Moon icon — click to go dark -->
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
@@ -0,0 +1,95 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import TagInput from '$lib/components/TagInput.svelte';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
let {
|
||||
tags = $bindable<string[]>([]),
|
||||
initialTitle = '',
|
||||
initialDocumentLocation = '',
|
||||
initialSummary = '',
|
||||
titleRequired = false,
|
||||
suggestedTitle = '',
|
||||
hideTitle = false
|
||||
}: {
|
||||
tags?: string[];
|
||||
initialTitle?: string;
|
||||
initialDocumentLocation?: string;
|
||||
initialSummary?: string;
|
||||
titleRequired?: boolean;
|
||||
suggestedTitle?: string;
|
||||
hideTitle?: boolean;
|
||||
} = $props();
|
||||
|
||||
let titleDirty = $state(false);
|
||||
let titleOverride = $state(untrack(() => initialTitle));
|
||||
let titleValue = $derived(titleDirty ? titleOverride : suggestedTitle || titleOverride);
|
||||
</script>
|
||||
|
||||
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
|
||||
<h2 class="mb-5 text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{m.doc_section_description()}
|
||||
</h2>
|
||||
|
||||
<div class="space-y-5">
|
||||
{#if !hideTitle}
|
||||
<!-- Titel -->
|
||||
<div>
|
||||
<label for="title" class="mb-1 block text-sm font-medium text-ink-2"
|
||||
>{m.form_label_title()}{#if titleRequired}
|
||||
*{/if}</label
|
||||
>
|
||||
<input
|
||||
id="title"
|
||||
type="text"
|
||||
name="title"
|
||||
value={titleValue}
|
||||
oninput={(e) => {
|
||||
titleOverride = (e.target as HTMLInputElement).value;
|
||||
titleDirty = true;
|
||||
}}
|
||||
required={titleRequired}
|
||||
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:border-ink focus:ring-ink"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Aufbewahrungsort -->
|
||||
<div>
|
||||
<label for="documentLocation" class="mb-1 block text-sm font-medium text-ink-2"
|
||||
>{m.form_label_archive_location()}</label
|
||||
>
|
||||
<input
|
||||
id="documentLocation"
|
||||
type="text"
|
||||
name="documentLocation"
|
||||
value={initialDocumentLocation}
|
||||
placeholder={m.form_placeholder_archive_location()}
|
||||
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:border-ink focus:ring-ink"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-ink-3">{m.form_helper_archive_location()}</p>
|
||||
</div>
|
||||
|
||||
<!-- Schlagworte -->
|
||||
<div>
|
||||
<p class="mb-1 block text-sm font-medium text-ink-2">{m.form_label_tags()}</p>
|
||||
<TagInput bind:tags={tags} />
|
||||
<input type="hidden" name="tags" value={tags.join(',')} />
|
||||
</div>
|
||||
|
||||
<!-- Inhalt -->
|
||||
<div>
|
||||
<label for="summary" class="mb-1 block text-sm font-medium text-ink-2"
|
||||
>{m.form_label_content()}</label
|
||||
>
|
||||
<textarea
|
||||
id="summary"
|
||||
name="summary"
|
||||
rows="5"
|
||||
placeholder={m.form_placeholder_content()}
|
||||
class="block w-full rounded border border-line p-2 font-serif text-sm shadow-sm focus:border-ink focus:ring-ink"
|
||||
>{initialSummary}</textarea
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
let { initialTranscription = '' }: { initialTranscription?: string } = $props();
|
||||
</script>
|
||||
|
||||
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
|
||||
<h2 class="mb-5 text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{m.form_label_transcription()}
|
||||
</h2>
|
||||
<textarea
|
||||
id="transcription"
|
||||
name="transcription"
|
||||
rows="12"
|
||||
placeholder={m.form_placeholder_transcription()}
|
||||
class="block w-full rounded border border-line p-2 font-serif text-sm shadow-sm focus:border-ink focus:ring-ink"
|
||||
>{initialTranscription}</textarea
|
||||
>
|
||||
</div>
|
||||
115
frontend/src/lib/components/document/WhoWhenSection.svelte
Normal file
115
frontend/src/lib/components/document/WhoWhenSection.svelte
Normal file
@@ -0,0 +1,115 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
|
||||
import PersonMultiSelect from '$lib/components/PersonMultiSelect.svelte';
|
||||
import { isoToGerman, handleGermanDateInput } from '$lib/utils/date';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
interface Person {
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
}
|
||||
|
||||
let {
|
||||
senderId = $bindable(''),
|
||||
selectedReceivers = $bindable<Person[]>([]),
|
||||
initialDateIso = '',
|
||||
initialLocation = '',
|
||||
initialSenderName = '',
|
||||
suggestedDateIso = '',
|
||||
suggestedSenderName = ''
|
||||
}: {
|
||||
senderId?: string;
|
||||
selectedReceivers?: Person[];
|
||||
initialDateIso?: string;
|
||||
initialLocation?: string;
|
||||
initialSenderName?: string;
|
||||
suggestedDateIso?: string;
|
||||
suggestedSenderName?: string;
|
||||
} = $props();
|
||||
|
||||
let dateDisplay = $state(untrack(() => isoToGerman(initialDateIso)));
|
||||
let dateIso = $state(untrack(() => initialDateIso));
|
||||
let dateDirty = $state(false);
|
||||
|
||||
const dateInvalid = $derived(dateDirty && dateDisplay.length > 0 && dateIso === '');
|
||||
|
||||
function handleDateInput(e: Event) {
|
||||
const result = handleGermanDateInput(e);
|
||||
dateDisplay = result.display;
|
||||
dateIso = result.iso;
|
||||
dateDirty = true;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const suggested = suggestedDateIso;
|
||||
if (suggested && !untrack(() => dateDirty)) {
|
||||
dateDisplay = isoToGerman(suggested);
|
||||
dateIso = suggested;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
|
||||
<h2 class="mb-5 text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{m.doc_section_who_when()}
|
||||
</h2>
|
||||
|
||||
<div class="grid grid-cols-1 gap-5 md:grid-cols-2">
|
||||
<!-- Datum -->
|
||||
<div>
|
||||
<label for="documentDate" class="mb-1 block text-sm font-medium text-ink-2"
|
||||
>{m.form_label_date()}</label
|
||||
>
|
||||
<input
|
||||
id="documentDate"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
value={dateDisplay}
|
||||
oninput={handleDateInput}
|
||||
placeholder={m.form_placeholder_date()}
|
||||
maxlength="10"
|
||||
class="block w-full rounded border border-line p-2 text-sm shadow-sm
|
||||
{dateInvalid ? 'border-red-400 focus:border-red-500 focus:ring-red-500' : 'focus:border-ink focus:ring-ink'}"
|
||||
aria-describedby={dateInvalid ? 'date-error' : undefined}
|
||||
/>
|
||||
<input type="hidden" name="documentDate" value={dateIso} />
|
||||
{#if dateInvalid}
|
||||
<p id="date-error" class="mt-1 text-xs text-red-600">{m.form_date_error()}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Ort -->
|
||||
<div>
|
||||
<label for="location" class="mb-1 block text-sm font-medium text-ink-2"
|
||||
>{m.form_label_location()}</label
|
||||
>
|
||||
<input
|
||||
id="location"
|
||||
type="text"
|
||||
name="location"
|
||||
value={initialLocation}
|
||||
placeholder={m.form_placeholder_location()}
|
||||
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:border-ink focus:ring-ink"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Absender -->
|
||||
<div>
|
||||
<PersonTypeahead
|
||||
name="senderId"
|
||||
label={m.form_label_sender()}
|
||||
bind:value={senderId}
|
||||
initialName={initialSenderName}
|
||||
suggestedName={suggestedSenderName}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Empfänger -->
|
||||
<div>
|
||||
<p class="mb-1 block text-sm font-medium text-ink-2">{m.form_label_receivers()}</p>
|
||||
<PersonMultiSelect bind:selectedPersons={selectedReceivers} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
24
frontend/src/lib/components/user/UserGroupsSection.svelte
Normal file
24
frontend/src/lib/components/user/UserGroupsSection.svelte
Normal file
@@ -0,0 +1,24 @@
|
||||
<script lang="ts">
|
||||
let {
|
||||
groups,
|
||||
selectedGroupIds = []
|
||||
}: {
|
||||
groups: { id: string; name: string }[];
|
||||
selectedGroupIds?: string[];
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
{#each groups as group (group.id)}
|
||||
<label class="inline-flex items-center gap-2 text-sm text-ink-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="groupIds"
|
||||
value={group.id}
|
||||
checked={selectedGroupIds.includes(group.id)}
|
||||
class="rounded border-line text-ink focus:ring-accent"
|
||||
/>
|
||||
{group.name}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
31
frontend/src/lib/components/user/UserPasswordSection.svelte
Normal file
31
frontend/src/lib/components/user/UserPasswordSection.svelte
Normal file
@@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
let { required = false }: { required?: boolean } = $props();
|
||||
</script>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<label class="block">
|
||||
<span class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{m.profile_label_new_password()}
|
||||
</span>
|
||||
<input
|
||||
type="password"
|
||||
name="newPassword"
|
||||
required={required}
|
||||
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:border-ink focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{m.profile_label_new_password_confirm()}
|
||||
</span>
|
||||
<input
|
||||
type="password"
|
||||
name="confirmPassword"
|
||||
required={required}
|
||||
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:border-ink focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
95
frontend/src/lib/components/user/UserProfileSection.svelte
Normal file
95
frontend/src/lib/components/user/UserProfileSection.svelte
Normal file
@@ -0,0 +1,95 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { isoToGerman, handleGermanDateInput } from '$lib/utils/date';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
let {
|
||||
firstName = '',
|
||||
lastName = '',
|
||||
birthDate = '',
|
||||
email = '',
|
||||
contact = ''
|
||||
}: {
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
birthDate?: string;
|
||||
email?: string;
|
||||
contact?: string;
|
||||
} = $props();
|
||||
|
||||
let birthDateDisplay = $state(untrack(() => isoToGerman(birthDate)));
|
||||
let birthDateIso = $state(untrack(() => birthDate));
|
||||
|
||||
function handleBirthDateInput(e: Event) {
|
||||
const result = handleGermanDateInput(e);
|
||||
birthDateDisplay = result.display;
|
||||
birthDateIso = result.iso;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<label class="block">
|
||||
<span class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{m.profile_label_first_name()}
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
name="firstName"
|
||||
value={firstName}
|
||||
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:border-ink focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{m.profile_label_last_name()}
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
name="lastName"
|
||||
value={lastName}
|
||||
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:border-ink focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="block">
|
||||
<span class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{m.profile_label_birth_date()}
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="TT.MM.JJJJ"
|
||||
value={birthDateDisplay}
|
||||
oninput={handleBirthDateInput}
|
||||
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:border-ink focus:outline-none"
|
||||
/>
|
||||
<input type="hidden" name="birthDate" value={birthDateIso} />
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{m.profile_label_email()}
|
||||
</span>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
value={email}
|
||||
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:border-ink focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{m.profile_label_contact()}
|
||||
</span>
|
||||
<textarea
|
||||
name="contact"
|
||||
rows="3"
|
||||
placeholder={m.profile_contact_placeholder()}
|
||||
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:border-ink focus:outline-none"
|
||||
>{contact}</textarea
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
@@ -9,11 +9,15 @@ export type ErrorCode =
|
||||
| 'DOCUMENT_NO_FILE'
|
||||
| 'FILE_NOT_FOUND'
|
||||
| 'FILE_UPLOAD_FAILED'
|
||||
| 'UNSUPPORTED_FILE_TYPE'
|
||||
| 'USER_NOT_FOUND'
|
||||
| 'EMAIL_ALREADY_IN_USE'
|
||||
| 'WRONG_CURRENT_PASSWORD'
|
||||
| 'IMPORT_ALREADY_RUNNING'
|
||||
| 'INVALID_RESET_TOKEN'
|
||||
| 'ANNOTATION_NOT_FOUND'
|
||||
| 'ANNOTATION_OVERLAP'
|
||||
| 'COMMENT_NOT_FOUND'
|
||||
| 'UNAUTHORIZED'
|
||||
| 'FORBIDDEN'
|
||||
| 'VALIDATION_ERROR'
|
||||
@@ -51,6 +55,8 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string {
|
||||
return m.error_file_not_found();
|
||||
case 'FILE_UPLOAD_FAILED':
|
||||
return m.error_file_upload_failed();
|
||||
case 'UNSUPPORTED_FILE_TYPE':
|
||||
return m.error_unsupported_file_type();
|
||||
case 'USER_NOT_FOUND':
|
||||
return m.error_user_not_found();
|
||||
case 'EMAIL_ALREADY_IN_USE':
|
||||
@@ -61,6 +67,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':
|
||||
|
||||
@@ -180,6 +180,86 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/documents/{documentId}/comments": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["getDocumentComments"];
|
||||
put?: never;
|
||||
post: operations["postDocumentComment"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/documents/{documentId}/comments/{commentId}/replies": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post: operations["replyToDocumentComment"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/documents/{documentId}/annotations": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["listAnnotations"];
|
||||
put?: never;
|
||||
post: operations["createAnnotation"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/documents/{documentId}/annotations/{annotationId}/comments": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["getAnnotationComments"];
|
||||
put?: never;
|
||||
post: operations["postAnnotationComment"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/documents/{documentId}/annotations/{annotationId}/comments/{commentId}/replies": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post: operations["replyToAnnotationComment"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/auth/reset-password": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -260,6 +340,22 @@ export interface paths {
|
||||
patch: operations["updateGroup"];
|
||||
trace?: never;
|
||||
};
|
||||
"/api/documents/{documentId}/comments/{commentId}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete: operations["deleteComment"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch: operations["editComment"];
|
||||
trace?: never;
|
||||
};
|
||||
"/api/tags": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -372,6 +468,54 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/documents/incomplete-count": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["getIncompleteCount"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/documents/incomplete": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["getIncomplete"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/documents/incomplete/next": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["getNextIncomplete"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/documents/search": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -420,6 +564,22 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/documents/{documentId}/annotations/{annotationId}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete: operations["deleteAnnotation"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
}
|
||||
export type webhooks = Record<string, never>;
|
||||
export interface components {
|
||||
@@ -510,6 +670,7 @@ export interface components {
|
||||
title: string;
|
||||
filePath?: string;
|
||||
contentType?: string;
|
||||
fileHash?: string;
|
||||
originalFilename: string;
|
||||
/** @enum {string} */
|
||||
status: "PLACEHOLDER" | "UPLOADED" | "TRANSCRIBED" | "REVIEWED" | "ARCHIVED";
|
||||
@@ -548,6 +709,63 @@ export interface components {
|
||||
name?: string;
|
||||
permissions?: string[];
|
||||
};
|
||||
CreateCommentDTO: {
|
||||
content?: string;
|
||||
};
|
||||
DocumentComment: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
/** Format: uuid */
|
||||
documentId: string;
|
||||
/** Format: uuid */
|
||||
annotationId?: string;
|
||||
/** Format: uuid */
|
||||
parentId?: string;
|
||||
/** Format: uuid */
|
||||
authorId?: string;
|
||||
authorName: string;
|
||||
content: string;
|
||||
/** Format: date-time */
|
||||
createdAt: string;
|
||||
/** Format: date-time */
|
||||
updatedAt: string;
|
||||
replies: components["schemas"]["DocumentComment"][];
|
||||
};
|
||||
CreateAnnotationDTO: {
|
||||
/** Format: int32 */
|
||||
pageNumber?: number;
|
||||
/** Format: double */
|
||||
x?: number;
|
||||
/** Format: double */
|
||||
y?: number;
|
||||
/** Format: double */
|
||||
width?: number;
|
||||
/** Format: double */
|
||||
height?: number;
|
||||
color?: string;
|
||||
};
|
||||
DocumentAnnotation: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
/** Format: uuid */
|
||||
documentId: string;
|
||||
/** Format: int32 */
|
||||
pageNumber: number;
|
||||
/** Format: double */
|
||||
x: number;
|
||||
/** Format: double */
|
||||
y: number;
|
||||
/** Format: double */
|
||||
width: number;
|
||||
/** Format: double */
|
||||
height: number;
|
||||
color: string;
|
||||
fileHash?: string;
|
||||
/** Format: uuid */
|
||||
createdBy?: string;
|
||||
/** Format: date-time */
|
||||
createdAt: string;
|
||||
};
|
||||
ResetPasswordRequest: {
|
||||
token?: string;
|
||||
newPassword?: string;
|
||||
@@ -1062,6 +1280,205 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
getDocumentComments: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
documentId: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["DocumentComment"][];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
postDocumentComment: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
documentId: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["CreateCommentDTO"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Created */
|
||||
201: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["DocumentComment"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
replyToDocumentComment: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
documentId: string;
|
||||
commentId: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["CreateCommentDTO"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Created */
|
||||
201: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["DocumentComment"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
listAnnotations: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
documentId: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["DocumentAnnotation"][];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
createAnnotation: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
documentId: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["CreateAnnotationDTO"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Created */
|
||||
201: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["DocumentAnnotation"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
getAnnotationComments: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
annotationId: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["DocumentComment"][];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
postAnnotationComment: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
documentId: string;
|
||||
annotationId: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["CreateCommentDTO"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Created */
|
||||
201: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["DocumentComment"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
replyToAnnotationComment: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
documentId: string;
|
||||
commentId: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["CreateCommentDTO"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Created */
|
||||
201: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["DocumentComment"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
resetPassword: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1192,6 +1609,54 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
deleteComment: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
documentId: string;
|
||||
commentId: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description No Content */
|
||||
204: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
editComment: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
documentId: string;
|
||||
commentId: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["CreateCommentDTO"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["DocumentComment"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
searchTags: {
|
||||
parameters: {
|
||||
query?: {
|
||||
@@ -1402,6 +1867,77 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
getIncompleteCount: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": {
|
||||
count: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
getIncomplete: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["Document"][];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
getNextIncomplete: {
|
||||
parameters: {
|
||||
query: {
|
||||
excludeId: string;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["Document"];
|
||||
};
|
||||
};
|
||||
/** @description No Content */
|
||||
204: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
importStatus: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1422,4 +1958,25 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
deleteAnnotation: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
documentId: string;
|
||||
annotationId: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description No Content */
|
||||
204: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
33
frontend/src/lib/types.ts
Normal file
33
frontend/src/lib/types.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
export type CommentReply = {
|
||||
id: string;
|
||||
authorId: string | null;
|
||||
authorName: string;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type Comment = {
|
||||
id: string;
|
||||
authorId: string | null;
|
||||
authorName: string;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
replies: CommentReply[];
|
||||
};
|
||||
|
||||
export type DocumentPanelTab = 'metadata' | 'transcription' | 'discussion' | 'history';
|
||||
|
||||
export type Annotation = {
|
||||
id: string;
|
||||
documentId: string;
|
||||
pageNumber: number;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
color: string;
|
||||
createdAt: string;
|
||||
fileHash?: string | null;
|
||||
};
|
||||
@@ -1,20 +1 @@
|
||||
/**
|
||||
* Converts an ISO date string (YYYY-MM-DD) to German display format (DD.MM.YYYY).
|
||||
* Returns an empty string for invalid or empty input.
|
||||
*/
|
||||
export function isoToGerman(iso: string): string {
|
||||
if (!iso || !/^\d{4}-\d{2}-\d{2}$/.test(iso)) return '';
|
||||
const [y, m, d] = iso.split('-');
|
||||
return `${d}.${m}.${y}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a German date string (DD.MM.YYYY) to ISO format (YYYY-MM-DD).
|
||||
* Returns an empty string for invalid or empty input.
|
||||
*/
|
||||
export function germanToIso(german: string): string {
|
||||
const match = german.match(/^(\d{2})\.(\d{2})\.(\d{4})$/);
|
||||
if (!match) return '';
|
||||
const [, d, m, y] = match;
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
export { isoToGerman, germanToIso } from '$lib/utils/date';
|
||||
|
||||
@@ -9,3 +9,44 @@ export function formatDate(isoDate: string): string {
|
||||
year: 'numeric'
|
||||
}).format(new Date(isoDate + 'T12:00:00'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an ISO date string (YYYY-MM-DD) to German display format (DD.MM.YYYY).
|
||||
* Returns an empty string for invalid or empty input.
|
||||
*/
|
||||
export function isoToGerman(iso: string): string {
|
||||
if (!iso || !/^\d{4}-\d{2}-\d{2}$/.test(iso)) return '';
|
||||
const [y, m, d] = iso.split('-');
|
||||
return `${d}.${m}.${y}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a German date string (DD.MM.YYYY) to ISO format (YYYY-MM-DD).
|
||||
* Returns an empty string for invalid or empty input.
|
||||
*/
|
||||
export function germanToIso(german: string): string {
|
||||
const match = german.match(/^(\d{2})\.(\d{2})\.(\d{4})$/);
|
||||
if (!match) return '';
|
||||
const [, d, m, y] = match;
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles a date input event for German-format date fields (DD.MM.YYYY).
|
||||
* Strips non-digits, formats with dots, mutates the input's displayed value,
|
||||
* and returns the display string and its ISO equivalent.
|
||||
*/
|
||||
export function handleGermanDateInput(e: Event): { display: string; iso: string } {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const digits = input.value.replace(/\D/g, '').slice(0, 8);
|
||||
let display: string;
|
||||
if (digits.length <= 2) {
|
||||
display = digits;
|
||||
} else if (digits.length <= 4) {
|
||||
display = `${digits.slice(0, 2)}.${digits.slice(2)}`;
|
||||
} else {
|
||||
display = `${digits.slice(0, 2)}.${digits.slice(2, 4)}.${digits.slice(4)}`;
|
||||
}
|
||||
input.value = display;
|
||||
return { display, iso: germanToIso(display) };
|
||||
}
|
||||
|
||||
101
frontend/src/lib/utils/filename.spec.ts
Normal file
101
frontend/src/lib/utils/filename.spec.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseFilename, stripExtension } from './filename';
|
||||
|
||||
describe('parseFilename', () => {
|
||||
describe('date-first patterns', () => {
|
||||
it('YYYY-MM-DD_Lastname_Firstname', () => {
|
||||
expect(parseFilename('1965-03-12_Mueller_Hans.pdf')).toEqual({
|
||||
dateIso: '1965-03-12',
|
||||
personName: 'Hans Mueller',
|
||||
suggestedTitle: 'Hans Mueller (12.03.1965)'
|
||||
});
|
||||
});
|
||||
|
||||
it('YYYYMMDD_Lastname_Firstname', () => {
|
||||
expect(parseFilename('19650312_Mueller_Hans.pdf')).toEqual({
|
||||
dateIso: '1965-03-12',
|
||||
personName: 'Hans Mueller',
|
||||
suggestedTitle: 'Hans Mueller (12.03.1965)'
|
||||
});
|
||||
});
|
||||
|
||||
it('YYYYMMDD_compound_lastname_Firstname', () => {
|
||||
expect(parseFilename('18881025_de_Gruyter_Walter.pdf')).toEqual({
|
||||
dateIso: '1888-10-25',
|
||||
personName: 'Walter de Gruyter',
|
||||
suggestedTitle: 'Walter de Gruyter (25.10.1888)'
|
||||
});
|
||||
});
|
||||
|
||||
it('handles umlauts in names', () => {
|
||||
const result = parseFilename('2024-01-15_Müller_Jürgen.pdf');
|
||||
expect(result.personName).toBe('Jürgen Müller');
|
||||
});
|
||||
});
|
||||
|
||||
describe('date-last patterns', () => {
|
||||
it('Lastname_Firstname_YYYY-MM-DD', () => {
|
||||
expect(parseFilename('Mueller_Hans_1965-03-12.pdf')).toEqual({
|
||||
dateIso: '1965-03-12',
|
||||
personName: 'Hans Mueller',
|
||||
suggestedTitle: 'Hans Mueller (12.03.1965)'
|
||||
});
|
||||
});
|
||||
|
||||
it('Lastname_Firstname_YYYYMMDD', () => {
|
||||
expect(parseFilename('Mueller_Hans_19650312.pdf')).toEqual({
|
||||
dateIso: '1965-03-12',
|
||||
personName: 'Hans Mueller',
|
||||
suggestedTitle: 'Hans Mueller (12.03.1965)'
|
||||
});
|
||||
});
|
||||
|
||||
it('compound_lastname_Firstname_YYYYMMDD', () => {
|
||||
expect(parseFilename('de_Gruyter_Walter_18881025.pdf')).toEqual({
|
||||
dateIso: '1888-10-25',
|
||||
personName: 'Walter de Gruyter',
|
||||
suggestedTitle: 'Walter de Gruyter (25.10.1888)'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('non-matching filenames', () => {
|
||||
it('returns empty for date-only filename', () => {
|
||||
expect(parseFilename('1965-03-12.pdf')).toEqual({});
|
||||
});
|
||||
|
||||
it('returns empty for two segments with no date', () => {
|
||||
expect(parseFilename('Mueller_Hans.pdf')).toEqual({});
|
||||
});
|
||||
|
||||
it('returns empty for unstructured filename', () => {
|
||||
expect(parseFilename('scan_001.pdf')).toEqual({});
|
||||
});
|
||||
|
||||
it('returns empty for three name segments with no date', () => {
|
||||
expect(parseFilename('Mueller_Hans_Juergen.pdf')).toEqual({});
|
||||
});
|
||||
|
||||
it('returns empty for filename without extension', () => {
|
||||
expect(parseFilename('1965-03-12_Mueller_Hans')).toEqual({});
|
||||
});
|
||||
|
||||
it('rejects implausible date (month 13)', () => {
|
||||
expect(parseFilename('19651345_Mueller_Hans.pdf')).toEqual({});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripExtension', () => {
|
||||
it('removes the extension', () => {
|
||||
expect(stripExtension('document.pdf')).toBe('document');
|
||||
});
|
||||
|
||||
it('removes only the last extension', () => {
|
||||
expect(stripExtension('archive.tar.gz')).toBe('archive.tar');
|
||||
});
|
||||
|
||||
it('leaves names without extension unchanged', () => {
|
||||
expect(stripExtension('nodotfile')).toBe('nodotfile');
|
||||
});
|
||||
});
|
||||
83
frontend/src/lib/utils/filename.ts
Normal file
83
frontend/src/lib/utils/filename.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { isoToGerman } from './date';
|
||||
|
||||
export interface FilenameParseResult {
|
||||
/** ISO format: YYYY-MM-DD */
|
||||
dateIso?: string;
|
||||
/** "Firstname Lastname" — order reversed from filename convention */
|
||||
personName?: string;
|
||||
/** Ready-to-use title, e.g. "Hans Mueller (12.03.1965)" */
|
||||
suggestedTitle?: string;
|
||||
}
|
||||
|
||||
// A date token is either YYYY-MM-DD or YYYYMMDD with a plausible month/day range.
|
||||
function tryParseDate(s: string): string | undefined {
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(s)) {
|
||||
const m = parseInt(s.slice(5, 7));
|
||||
const d = parseInt(s.slice(8, 10));
|
||||
if (m >= 1 && m <= 12 && d >= 1 && d <= 31) return s;
|
||||
} else if (/^\d{8}$/.test(s)) {
|
||||
const m = parseInt(s.slice(4, 6));
|
||||
const d = parseInt(s.slice(6, 8));
|
||||
if (m >= 1 && m <= 12 && d >= 1 && d <= 31)
|
||||
return `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}`;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const NAME_PART = /^\p{L}+$/u;
|
||||
|
||||
/**
|
||||
* Parses a structured filename and extracts a date and person name.
|
||||
*
|
||||
* Supported conventions (date-first or date-last, compound last names supported):
|
||||
* YYYY-MM-DD_Lastname_Firstname.ext
|
||||
* YYYYMMDD_Lastname_Firstname.ext
|
||||
* YYYYMMDD_de_Gruyter_Walter.ext ← compound last name: lastName="de Gruyter"
|
||||
* Lastname_Firstname_YYYY-MM-DD.ext
|
||||
* Lastname_Firstname_YYYYMMDD.ext
|
||||
* de_Gruyter_Walter_YYYYMMDD.ext ← compound last name: lastName="de Gruyter"
|
||||
*
|
||||
* Algorithm: split on "_", identify the date token (first or last segment),
|
||||
* treat the outermost remaining segment as firstName, rest as lastName parts.
|
||||
* Returns {} for anything that doesn't match cleanly.
|
||||
*/
|
||||
export function parseFilename(filename: string): FilenameParseResult {
|
||||
const dot = filename.lastIndexOf('.');
|
||||
if (dot < 0) return {}; // no extension — not a real file
|
||||
const stem = filename.slice(0, dot);
|
||||
const parts = stem.split('_');
|
||||
|
||||
// Minimum: date + at least one lastName segment + firstName = 3 parts
|
||||
if (parts.length < 3) return {};
|
||||
|
||||
let dateIso: string;
|
||||
let nameParts: string[];
|
||||
|
||||
const dateFromFirst = tryParseDate(parts[0]);
|
||||
if (dateFromFirst) {
|
||||
dateIso = dateFromFirst;
|
||||
nameParts = parts.slice(1);
|
||||
} else {
|
||||
const dateFromLast = tryParseDate(parts[parts.length - 1]);
|
||||
if (!dateFromLast) return {};
|
||||
dateIso = dateFromLast;
|
||||
nameParts = parts.slice(0, -1);
|
||||
}
|
||||
|
||||
// Need at least lastName + firstName after removing the date
|
||||
if (nameParts.length < 2) return {};
|
||||
|
||||
// All name segments must be pure letters (covers umlauts via \p{L})
|
||||
if (!nameParts.every((p) => NAME_PART.test(p))) return {};
|
||||
|
||||
const firstName = nameParts[nameParts.length - 1];
|
||||
const lastName = nameParts.slice(0, -1).join(' ');
|
||||
const personName = `${firstName} ${lastName}`;
|
||||
const suggestedTitle = `${personName} (${isoToGerman(dateIso)})`;
|
||||
|
||||
return { dateIso, personName, suggestedTitle };
|
||||
}
|
||||
|
||||
export function stripExtension(filename: string): string {
|
||||
return filename.replace(/\.[^/.]+$/, '');
|
||||
}
|
||||
@@ -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'))
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<script lang="ts">
|
||||
import './layout.css';
|
||||
import { enhance } from '$app/forms';
|
||||
import { page } from '$app/state';
|
||||
import { onMount } from 'svelte';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { setLocale, getLocale } from '$lib/paraglide/runtime';
|
||||
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
|
||||
import AppNav from './AppNav.svelte';
|
||||
import UserMenu from './UserMenu.svelte';
|
||||
|
||||
let { children, data } = $props();
|
||||
|
||||
@@ -23,7 +24,9 @@ onMount(() => {
|
||||
hydrated = true;
|
||||
});
|
||||
|
||||
let userMenuOpen = $state(false);
|
||||
const isAuthPage = $derived(
|
||||
['/login', '/forgot-password', '/reset-password'].some((p) => page.url.pathname.startsWith(p))
|
||||
);
|
||||
|
||||
const userInitials = $derived.by(() => {
|
||||
const first = data?.user?.firstName?.[0];
|
||||
@@ -31,166 +34,49 @@ const userInitials = $derived.by(() => {
|
||||
if (first && last) return (first + last).toUpperCase();
|
||||
return null;
|
||||
});
|
||||
|
||||
function clickOutside(node: HTMLElement) {
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
if (node && !node.contains(event.target as Node) && !event.defaultPrevented) {
|
||||
userMenuOpen = false;
|
||||
}
|
||||
};
|
||||
document.addEventListener('click', handleClick, true);
|
||||
return () => {
|
||||
document.removeEventListener('click', handleClick, true);
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen bg-white" data-hydrated={hydrated || undefined}>
|
||||
{#if !['/login', '/forgot-password', '/reset-password'].some((p) => page.url.pathname.startsWith(p))}
|
||||
<header class="sticky top-0 z-50 border-b border-gray-100 bg-white">
|
||||
<div class="min-h-screen bg-canvas" data-hydrated={hydrated || undefined}>
|
||||
{#if !isAuthPage}
|
||||
<header class="sticky top-0 z-50 border-b border-line-2 bg-surface">
|
||||
<!-- De Gruyter Brill purple accent strip -->
|
||||
<div class="h-1 bg-brand-purple"></div>
|
||||
|
||||
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex h-16 justify-between">
|
||||
<!-- Logo & Nav -->
|
||||
<div class="flex">
|
||||
<div class="mr-10 flex flex-shrink-0 items-center">
|
||||
<a href="/" class="flex items-center" aria-label="Familienarchiv">
|
||||
<span class="font-sans text-xl font-bold tracking-widest text-brand-navy uppercase"
|
||||
>Familienarchiv</span
|
||||
>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<nav class="hidden items-center sm:flex sm:space-x-1">
|
||||
<a
|
||||
href="/"
|
||||
class="inline-flex items-center px-3 py-1.5 font-sans text-xs font-bold tracking-widest uppercase transition-colors
|
||||
{page.url.pathname === '/' || page.url.pathname.startsWith('/documents')
|
||||
? 'rounded bg-brand-purple/15 text-brand-navy'
|
||||
: 'rounded text-gray-500 hover:bg-brand-sand/60 hover:text-brand-navy'}"
|
||||
>
|
||||
{m.nav_documents()}
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/persons"
|
||||
class="inline-flex items-center px-3 py-1.5 font-sans text-xs font-bold tracking-widest uppercase transition-colors
|
||||
{page.url.pathname.startsWith('/persons')
|
||||
? 'rounded bg-brand-purple/15 text-brand-navy'
|
||||
: 'rounded text-gray-500 hover:bg-brand-sand/60 hover:text-brand-navy'}"
|
||||
>
|
||||
{m.nav_persons()}
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/conversations"
|
||||
class="inline-flex items-center px-3 py-1.5 font-sans text-xs font-bold tracking-widest uppercase transition-colors
|
||||
{page.url.pathname.startsWith('/conversations')
|
||||
? 'rounded bg-brand-purple/15 text-brand-navy'
|
||||
: 'rounded text-gray-500 hover:bg-brand-sand/60 hover:text-brand-navy'}"
|
||||
>
|
||||
{m.nav_conversations()}
|
||||
</a>
|
||||
{#if isAdmin}
|
||||
<a
|
||||
href="/admin"
|
||||
class="inline-flex items-center px-3 py-1.5 font-sans text-xs font-bold tracking-widest uppercase transition-colors
|
||||
{page.url.pathname.startsWith('/admin')
|
||||
? 'rounded bg-brand-purple/15 text-brand-navy'
|
||||
: 'rounded text-gray-500 hover:bg-brand-sand/60 hover:text-brand-navy'}"
|
||||
>
|
||||
{m.nav_admin()}
|
||||
</a>
|
||||
{/if}
|
||||
</nav>
|
||||
</div>
|
||||
<AppNav isAdmin={isAdmin} />
|
||||
|
||||
<!-- Right Side -->
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Language selector -->
|
||||
<div class="flex items-center gap-1 border-r border-gray-200 pr-3">
|
||||
<div class="flex items-center gap-1 border-r border-line pr-3">
|
||||
{#each locales as locale (locale)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => setLocale(localeMap[locale])}
|
||||
class="px-1.5 py-1 font-sans text-xs tracking-widest transition-colors
|
||||
{activeLocale === locale
|
||||
? 'font-bold text-brand-navy'
|
||||
: 'font-normal text-gray-400 hover:text-brand-navy'}"
|
||||
? 'font-bold text-ink'
|
||||
: 'font-normal text-ink-3 hover:text-ink'}"
|
||||
>
|
||||
{locale}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- User menu -->
|
||||
<div
|
||||
class="relative"
|
||||
{@attach clickOutside}
|
||||
onkeydown={(e) => { if (e.key === 'Escape') userMenuOpen = false; }}
|
||||
role="none"
|
||||
>
|
||||
{#if userInitials}
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={userMenuOpen}
|
||||
aria-haspopup="true"
|
||||
onclick={() => (userMenuOpen = !userMenuOpen)}
|
||||
class="flex h-8 w-8 items-center justify-center rounded-full bg-brand-navy font-sans text-xs font-bold text-white transition-opacity hover:opacity-80"
|
||||
>
|
||||
{userInitials}
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={m.nav_profile()}
|
||||
aria-expanded={userMenuOpen}
|
||||
aria-haspopup="true"
|
||||
onclick={() => (userMenuOpen = !userMenuOpen)}
|
||||
class="inline-flex items-center gap-1.5 px-3 py-2 font-sans text-xs font-bold tracking-widest text-gray-400 uppercase transition-colors hover:text-brand-navy"
|
||||
>
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Small-16px/SVG/Action/Account-SM.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-4 w-4 opacity-50"
|
||||
/>
|
||||
</button>
|
||||
{/if}
|
||||
<!-- Theme toggle -->
|
||||
<ThemeToggle />
|
||||
|
||||
{#if userMenuOpen}
|
||||
<div
|
||||
class="absolute top-full right-0 z-50 mt-1 min-w-[10rem] rounded-sm border border-brand-sand bg-white shadow-md"
|
||||
>
|
||||
<a
|
||||
href="/profile"
|
||||
onclick={() => (userMenuOpen = false)}
|
||||
class="block px-4 py-2.5 font-sans text-xs font-bold tracking-widest text-gray-600 uppercase transition-colors hover:bg-brand-sand/40 hover:text-brand-navy"
|
||||
>
|
||||
{m.nav_profile()}
|
||||
</a>
|
||||
<div class="border-t border-brand-sand">
|
||||
<form action="/logout" method="POST" use:enhance>
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full px-4 py-2.5 text-left font-sans text-xs font-bold tracking-widest text-gray-400 uppercase transition-colors hover:bg-brand-sand/40 hover:text-brand-navy"
|
||||
>
|
||||
{m.nav_logout()}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- User menu -->
|
||||
<UserMenu userInitials={userInitials} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
{/if}
|
||||
|
||||
<main class="py-6">
|
||||
<main class={isAuthPage ? '' : 'py-6'}>
|
||||
{@render children()}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -12,7 +12,7 @@ export async function load({ url, fetch }) {
|
||||
const api = createApiClient(fetch);
|
||||
|
||||
try {
|
||||
const [docsResult, personsResult] = await Promise.all([
|
||||
const [docsResult, personsResult, incompleteCountResult] = await Promise.all([
|
||||
api.GET('/api/documents/search', {
|
||||
params: {
|
||||
query: {
|
||||
@@ -25,7 +25,8 @@ export async function load({ url, fetch }) {
|
||||
}
|
||||
}
|
||||
}),
|
||||
api.GET('/api/persons')
|
||||
api.GET('/api/persons'),
|
||||
api.GET('/api/documents/incomplete-count')
|
||||
]);
|
||||
|
||||
if (docsResult.response.status === 401 || personsResult.response.status === 401) {
|
||||
@@ -39,8 +40,13 @@ export async function load({ url, fetch }) {
|
||||
const senderObj = allPersons.find((p) => p.id === senderId);
|
||||
const receiverObj = allPersons.find((p) => p.id === receiverId);
|
||||
|
||||
const incompleteCount = incompleteCountResult.response.ok
|
||||
? (incompleteCountResult.data?.count ?? 0)
|
||||
: 0;
|
||||
|
||||
return {
|
||||
documents,
|
||||
incompleteCount,
|
||||
initialValues: {
|
||||
senderName: senderObj ? `${senderObj.firstName} ${senderObj.lastName}` : '',
|
||||
receiverName: receiverObj ? `${receiverObj.firstName} ${receiverObj.lastName}` : ''
|
||||
@@ -53,6 +59,7 @@ export async function load({ url, fetch }) {
|
||||
console.error('Error loading data:', e);
|
||||
return {
|
||||
documents: [],
|
||||
incompleteCount: 0,
|
||||
initialValues: { senderName: '', receiverName: '' },
|
||||
filters: { q, from, to, senderId, receiverId, tags },
|
||||
error: 'Daten konnten nicht geladen werden.' as string | null
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
<script lang="ts">
|
||||
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import TagInput from '$lib/components/TagInput.svelte';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { untrack } from 'svelte';
|
||||
import { SvelteURLSearchParams } from 'svelte/reactivity';
|
||||
import SearchFilterBar from './SearchFilterBar.svelte';
|
||||
import DropZone from './DropZone.svelte';
|
||||
import DocumentList from './DocumentList.svelte';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { formatDate } from '$lib/utils/date';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
@@ -18,8 +17,6 @@ let senderId = $state(untrack(() => data.filters?.senderId || ''));
|
||||
let receiverId = $state(untrack(() => data.filters?.receiverId || ''));
|
||||
let tagNames = $state<string[]>(untrack(() => data.filters?.tags || []));
|
||||
|
||||
let searchTimer: ReturnType<typeof setTimeout>;
|
||||
|
||||
const hasAdvancedFilters = (filters: typeof data.filters) =>
|
||||
(filters?.tags?.length ?? 0) > 0 ||
|
||||
!!filters?.senderId ||
|
||||
@@ -29,27 +26,22 @@ const hasAdvancedFilters = (filters: typeof data.filters) =>
|
||||
|
||||
let showAdvanced = $state(untrack(() => hasAdvancedFilters(data.filters)));
|
||||
|
||||
let searchTimer: ReturnType<typeof setTimeout>;
|
||||
|
||||
function triggerSearch() {
|
||||
const params = new SvelteURLSearchParams();
|
||||
|
||||
if (q) params.set('q', q);
|
||||
if (from) params.set('from', from);
|
||||
if (to) params.set('to', to);
|
||||
if (senderId) params.set('senderId', senderId);
|
||||
if (receiverId) params.set('receiverId', receiverId);
|
||||
if (tagNames) tagNames.forEach((tag) => params.append('tag', tag));
|
||||
|
||||
goto(`/?${params.toString()}`, {
|
||||
keepFocus: true,
|
||||
noScroll: true
|
||||
});
|
||||
goto(`/?${params.toString()}`, { keepFocus: true, noScroll: true });
|
||||
}
|
||||
|
||||
function handleTextSearch() {
|
||||
clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(() => {
|
||||
triggerSearch();
|
||||
}, 500);
|
||||
searchTimer = setTimeout(() => triggerSearch(), 500);
|
||||
}
|
||||
|
||||
// Trigger search when tags change
|
||||
@@ -75,303 +67,54 @@ $effect(() => {
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Outer Container: Matches the 'Sand' background of the layout -->
|
||||
<main class="mx-auto max-w-7xl py-8 font-sans sm:px-6 lg:px-8">
|
||||
<!-- SEARCH & FILTER CARD -->
|
||||
<div class="mb-8 rounded-sm border border-brand-sand bg-white p-6 shadow-sm">
|
||||
<!-- ROW 1: Main Search (One Line) -->
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- Full Text Search -->
|
||||
<div class="relative flex-1">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={q}
|
||||
oninput={handleTextSearch}
|
||||
onfocus={() => (qFocused = true)}
|
||||
onblur={() => (qFocused = false)}
|
||||
placeholder={m.docs_search_placeholder()}
|
||||
class="block w-full border-gray-300 py-2.5 pr-10 pl-3 placeholder-gray-400 shadow-sm focus:border-brand-navy focus:ring-brand-navy"
|
||||
/>
|
||||
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Mag-Glass-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-4 w-4 opacity-40"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<SearchFilterBar
|
||||
bind:q={q}
|
||||
bind:from={from}
|
||||
bind:to={to}
|
||||
bind:senderId={senderId}
|
||||
bind:receiverId={receiverId}
|
||||
bind:tagNames={tagNames}
|
||||
bind:showAdvanced={showAdvanced}
|
||||
initialSenderName={data.initialValues?.senderName}
|
||||
initialReceiverName={data.initialValues?.receiverName}
|
||||
onSearch={handleTextSearch}
|
||||
onfocus={() => (qFocused = true)}
|
||||
onblur={() => (qFocused = false)}
|
||||
/>
|
||||
|
||||
<!-- Toggle Advanced Button -->
|
||||
<button
|
||||
onclick={() => (showAdvanced = !showAdvanced)}
|
||||
class="flex items-center gap-2 border border-gray-300 bg-gray-50 px-4 py-2.5 text-sm font-bold tracking-wide text-gray-600 uppercase transition hover:bg-gray-100 hover:text-brand-navy"
|
||||
>
|
||||
{#if data.canWrite}
|
||||
<DropZone />
|
||||
{/if}
|
||||
|
||||
{#if data.incompleteCount > 0}
|
||||
<a
|
||||
href="/enrich"
|
||||
class="mb-6 flex items-center justify-between rounded-sm border border-accent/40 bg-accent-bg px-6 py-4 transition-colors hover:bg-accent/20"
|
||||
>
|
||||
<div class="flex items-center gap-4">
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Small-16px/SVG/Action/Chevron/Chevron-Down-SM.svg"
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Info/Block/Info-Block-Border-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-4 w-4 transform transition-transform duration-200 {showAdvanced ? 'rotate-180' : ''}"
|
||||
class="h-6 w-6 opacity-60"
|
||||
/>
|
||||
{m.docs_btn_filter()}
|
||||
</button>
|
||||
|
||||
<!-- Reset Button -->
|
||||
<a
|
||||
href="/"
|
||||
class="flex items-center justify-center border border-transparent px-3 py-2.5 text-gray-400 transition hover:text-red-500"
|
||||
title={m.docs_btn_reset_title()}
|
||||
>
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Close-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-5 w-5 opacity-40"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- ROW 2: Advanced Filters (Collapsible) -->
|
||||
{#if showAdvanced}
|
||||
<div
|
||||
transition:slide
|
||||
class="mt-6 grid grid-cols-1 gap-6 border-t border-gray-100 pt-6 md:grid-cols-12"
|
||||
>
|
||||
<!-- Tag Filter -->
|
||||
<div class="md:col-span-12">
|
||||
<p class="mb-2 block text-xs font-bold tracking-widest text-gray-500 uppercase">
|
||||
{m.docs_filter_label_tags()}
|
||||
<div>
|
||||
<p class="font-sans text-xs font-bold tracking-widest text-ink uppercase">
|
||||
{m.enrich_needs_metadata_title()}
|
||||
</p>
|
||||
<p class="mt-0.5 font-serif text-sm text-ink-2">
|
||||
{m.enrich_needs_metadata_count({ count: data.incompleteCount })}
|
||||
</p>
|
||||
<TagInput bind:tags={tagNames} allowCreation={false} />
|
||||
</div>
|
||||
|
||||
<!-- Sender -->
|
||||
<div class="md:col-span-3">
|
||||
<div
|
||||
class="[&_input]:border-gray-300 [&_input]:py-2.5 [&_label]:mb-2 [&_label]:text-xs [&_label]:font-bold [&_label]:tracking-widest [&_label]:text-gray-500 [&_label]:uppercase"
|
||||
>
|
||||
<PersonTypeahead
|
||||
name="senderId"
|
||||
label={m.docs_filter_label_sender()}
|
||||
bind:value={senderId}
|
||||
initialName={data.initialValues?.senderName}
|
||||
onchange={triggerSearch}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Receiver -->
|
||||
<div class="md:col-span-3">
|
||||
<div
|
||||
class="[&_input]:border-gray-300 [&_input]:py-2.5 [&_label]:mb-2 [&_label]:text-xs [&_label]:font-bold [&_label]:tracking-widest [&_label]:text-gray-500 [&_label]:uppercase"
|
||||
>
|
||||
<PersonTypeahead
|
||||
name="receiverId"
|
||||
label={m.docs_filter_label_receivers()}
|
||||
bind:value={receiverId}
|
||||
initialName={data.initialValues?.receiverName}
|
||||
onchange={triggerSearch}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dates -->
|
||||
<div class="grid grid-cols-2 gap-4 md:col-span-6">
|
||||
<div>
|
||||
<label
|
||||
for="from"
|
||||
class="mb-2 block text-xs font-bold tracking-widest text-gray-500 uppercase"
|
||||
>{m.docs_filter_label_from()}</label
|
||||
>
|
||||
<input
|
||||
type="date"
|
||||
id="from"
|
||||
bind:value={from}
|
||||
onchange={triggerSearch}
|
||||
class="block w-full border-gray-300 py-2.5 text-sm shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="to"
|
||||
class="mb-2 block text-xs font-bold tracking-widest text-gray-500 uppercase"
|
||||
>{m.docs_filter_label_to()}</label
|
||||
>
|
||||
<input
|
||||
type="date"
|
||||
id="to"
|
||||
bind:value={to}
|
||||
onchange={triggerSearch}
|
||||
class="block w-full border-gray-300 py-2.5 text-sm shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- DOCUMENT LIST HEADER -->
|
||||
<div class="mb-2 flex justify-end">
|
||||
{#if data.canWrite}
|
||||
<a
|
||||
href="/documents/new"
|
||||
class="inline-flex items-center gap-1 text-sm font-medium text-brand-navy/60 transition-colors hover:text-brand-navy"
|
||||
<span
|
||||
class="font-sans text-xs font-bold tracking-widest text-ink uppercase transition-colors hover:text-ink-2"
|
||||
>
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Add/Add-General-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
{m.docs_btn_new()}
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
{m.enrich_needs_metadata_cta()} →
|
||||
</span>
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
<!-- DOCUMENT LIST -->
|
||||
<div class="border border-brand-sand bg-white shadow-sm">
|
||||
{#if data.error}
|
||||
<div class="bg-red-50 p-8 text-center text-red-600">
|
||||
{data.error}
|
||||
</div>
|
||||
{:else if data.documents && data.documents.length > 0}
|
||||
<ul class="divide-y divide-gray-100">
|
||||
{#each data.documents as doc (doc.id)}
|
||||
<li class="group transition-colors duration-200 hover:bg-brand-sand/10">
|
||||
<!-- LINK TO DETAIL PAGE -->
|
||||
<a href="/documents/{doc.id}" class="block p-6">
|
||||
<div class="flex flex-col gap-6 sm:flex-row">
|
||||
<!-- Main Info -->
|
||||
<div class="flex-1">
|
||||
<div class="mb-2 flex items-baseline justify-between">
|
||||
<!-- Title: Serif & Brand Navy -->
|
||||
<h3
|
||||
class="font-serif text-xl font-medium text-brand-navy decoration-brand-mint decoration-2 underline-offset-4 group-hover:underline"
|
||||
>
|
||||
{doc.title || doc.originalFilename}
|
||||
</h3>
|
||||
|
||||
<!-- Status Badge -->
|
||||
<span
|
||||
class="ml-3 inline-flex items-center rounded-full border px-2.5 py-0.5 text-[10px] font-bold tracking-wide uppercase
|
||||
{doc.status === 'UPLOADED'
|
||||
? 'border-brand-mint/50 bg-brand-mint/20 text-brand-navy'
|
||||
: 'border-yellow-200 bg-yellow-50 text-yellow-700'}"
|
||||
>
|
||||
{doc.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Metadata Row -->
|
||||
<div class="mb-4 flex flex-wrap gap-6 font-sans text-sm text-gray-500">
|
||||
<div class="flex items-center">
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Calendar/Calendar-Add-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="mr-1.5 h-4 w-4"
|
||||
/>
|
||||
{doc.documentDate ? formatDate(doc.documentDate) : '—'}
|
||||
</div>
|
||||
{#if doc.location}
|
||||
<div class="flex items-center">
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Location-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="mr-1.5 h-4 w-4"
|
||||
/>
|
||||
{doc.location}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Sender/Receiver Info -->
|
||||
<div class="grid grid-cols-1 gap-4 font-serif text-sm sm:grid-cols-2">
|
||||
<div class="flex items-baseline">
|
||||
<span
|
||||
class="w-10 font-sans text-xs font-bold tracking-wide text-gray-400 uppercase"
|
||||
>{m.docs_list_from()}</span
|
||||
>
|
||||
{#if doc.sender}
|
||||
<span class="text-gray-900"
|
||||
>{doc.sender.firstName} {doc.sender.lastName}</span
|
||||
>
|
||||
{:else}
|
||||
<span class="text-gray-400 italic">{m.docs_list_unknown()}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-baseline">
|
||||
<span
|
||||
class="w-10 font-sans text-xs font-bold tracking-wide text-gray-400 uppercase"
|
||||
>{m.docs_list_to()}</span
|
||||
>
|
||||
{#if doc.receivers && doc.receivers.length > 0}
|
||||
<span class="text-gray-900">
|
||||
{doc.receivers.map((p) => p.firstName + ' ' + p.lastName).join(', ')}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-gray-400 italic">{m.docs_list_unknown()}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tags Display -->
|
||||
{#if doc.tags && doc.tags.length > 0}
|
||||
<div class="mt-4 flex flex-wrap gap-2 pt-3">
|
||||
{#each doc.tags as tag (tag.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="relative z-10 inline-flex cursor-pointer items-center rounded bg-brand-sand/30 px-2 py-1 text-[10px] font-bold tracking-widest text-brand-navy uppercase transition-colors hover:bg-brand-navy hover:text-white"
|
||||
onclick={(e) => { e.preventDefault(); e.stopPropagation(); goto(`/?tag=${encodeURIComponent(tag.name)}`); }}
|
||||
>
|
||||
{tag.name}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Arrow Icon -->
|
||||
<div
|
||||
class="hidden items-center text-gray-300 transition-colors group-hover:text-brand-mint sm:flex"
|
||||
>
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Arrow/Arrow-Right-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-6 w-6"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<!-- Empty State -->
|
||||
<div class="p-16 text-center">
|
||||
<div
|
||||
class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-brand-sand/30"
|
||||
>
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Mag-Glass-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-6 w-6"
|
||||
/>
|
||||
</div>
|
||||
<h3 class="font-serif text-lg font-medium text-brand-navy">{m.docs_empty_heading()}</h3>
|
||||
<p class="mt-1 font-sans text-sm text-gray-500">
|
||||
{m.docs_empty_text()}
|
||||
</p>
|
||||
<button
|
||||
onclick={() => goto('/')}
|
||||
class="mt-6 text-sm font-bold tracking-wide text-brand-mint uppercase transition hover:text-brand-navy"
|
||||
>
|
||||
{m.docs_empty_btn_clear()}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<DocumentList documents={data.documents ?? []} canWrite={data.canWrite} error={data.error} />
|
||||
</main>
|
||||
|
||||
59
frontend/src/routes/AppNav.svelte
Normal file
59
frontend/src/routes/AppNav.svelte
Normal file
@@ -0,0 +1,59 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
let { isAdmin = false }: { isAdmin?: boolean } = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex">
|
||||
<div class="mr-10 flex flex-shrink-0 items-center">
|
||||
<a href="/" class="flex items-center" aria-label="Familienarchiv">
|
||||
<span class="font-sans text-xl font-bold tracking-widest text-ink uppercase"
|
||||
>Familienarchiv</span
|
||||
>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<nav class="hidden items-center sm:flex sm:space-x-1">
|
||||
<a
|
||||
href="/"
|
||||
class="inline-flex items-center px-3 py-1.5 font-sans text-xs font-bold tracking-widest uppercase transition-colors
|
||||
{page.url.pathname === '/' || page.url.pathname.startsWith('/documents')
|
||||
? 'rounded bg-nav-active text-ink'
|
||||
: 'rounded text-ink-2 hover:bg-muted hover:text-ink'}"
|
||||
>
|
||||
{m.nav_documents()}
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/persons"
|
||||
class="inline-flex items-center px-3 py-1.5 font-sans text-xs font-bold tracking-widest uppercase transition-colors
|
||||
{page.url.pathname.startsWith('/persons')
|
||||
? 'rounded bg-nav-active text-ink'
|
||||
: 'rounded text-ink-2 hover:bg-muted hover:text-ink'}"
|
||||
>
|
||||
{m.nav_persons()}
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/conversations"
|
||||
class="inline-flex items-center px-3 py-1.5 font-sans text-xs font-bold tracking-widest uppercase transition-colors
|
||||
{page.url.pathname.startsWith('/conversations')
|
||||
? 'rounded bg-nav-active text-ink'
|
||||
: 'rounded text-ink-2 hover:bg-muted hover:text-ink'}"
|
||||
>
|
||||
{m.nav_conversations()}
|
||||
</a>
|
||||
{#if isAdmin}
|
||||
<a
|
||||
href="/admin"
|
||||
class="inline-flex items-center px-3 py-1.5 font-sans text-xs font-bold tracking-widest uppercase transition-colors
|
||||
{page.url.pathname.startsWith('/admin')
|
||||
? 'rounded bg-nav-active text-ink'
|
||||
: 'rounded text-ink-2 hover:bg-muted hover:text-ink'}"
|
||||
>
|
||||
{m.nav_admin()}
|
||||
</a>
|
||||
{/if}
|
||||
</nav>
|
||||
</div>
|
||||
177
frontend/src/routes/DocumentList.svelte
Normal file
177
frontend/src/routes/DocumentList.svelte
Normal file
@@ -0,0 +1,177 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { formatDate } from '$lib/utils/date';
|
||||
|
||||
let {
|
||||
documents,
|
||||
canWrite,
|
||||
error
|
||||
}: {
|
||||
documents: {
|
||||
id: string;
|
||||
title?: string | null;
|
||||
originalFilename: string;
|
||||
documentDate?: string | null;
|
||||
location?: string | null;
|
||||
sender?: { firstName: string; lastName: string } | null;
|
||||
receivers?: { firstName: string; lastName: string }[];
|
||||
tags?: { id: string; name: string }[];
|
||||
}[];
|
||||
canWrite: boolean;
|
||||
error?: string | null;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<!-- DOCUMENT LIST HEADER -->
|
||||
<div class="mb-2 flex justify-end">
|
||||
{#if canWrite}
|
||||
<a
|
||||
href="/documents/new"
|
||||
class="inline-flex items-center gap-1 text-sm font-medium text-ink/60 transition-colors hover:text-ink"
|
||||
>
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Add/Add-General-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
{m.docs_btn_new()}
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- DOCUMENT LIST -->
|
||||
<div class="border border-line bg-surface shadow-sm">
|
||||
{#if error}
|
||||
<div class="bg-red-50 p-8 text-center text-red-600">
|
||||
{error}
|
||||
</div>
|
||||
{:else if documents.length > 0}
|
||||
<ul class="divide-y divide-line-2">
|
||||
{#each documents as doc (doc.id)}
|
||||
<li class="group transition-colors duration-200 hover:bg-muted/50">
|
||||
<a href="/documents/{doc.id}" class="block p-6">
|
||||
<div class="flex flex-col gap-6 sm:flex-row">
|
||||
<!-- Main Info -->
|
||||
<div class="flex-1">
|
||||
<div class="mb-2 flex items-baseline justify-between">
|
||||
<h3
|
||||
class="font-serif text-xl font-medium text-ink decoration-brand-mint decoration-2 underline-offset-4 group-hover:underline"
|
||||
>
|
||||
{doc.title || doc.originalFilename}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<!-- Metadata Row -->
|
||||
<div class="mb-4 flex flex-wrap gap-6 font-sans text-sm text-ink-2">
|
||||
<div class="flex items-center">
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Calendar/Calendar-Add-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="mr-1.5 h-4 w-4"
|
||||
/>
|
||||
{doc.documentDate ? formatDate(doc.documentDate) : '—'}
|
||||
</div>
|
||||
{#if doc.location}
|
||||
<div class="flex items-center">
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Location-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="mr-1.5 h-4 w-4"
|
||||
/>
|
||||
{doc.location}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Sender/Receiver Info -->
|
||||
<div class="grid grid-cols-1 gap-4 font-serif text-sm sm:grid-cols-2">
|
||||
<div class="flex items-baseline">
|
||||
<span
|
||||
class="w-10 font-sans text-xs font-bold tracking-wide text-ink-3 uppercase"
|
||||
>{m.docs_list_from()}</span
|
||||
>
|
||||
{#if doc.sender}
|
||||
<span class="text-ink">{doc.sender.firstName} {doc.sender.lastName}</span>
|
||||
{:else}
|
||||
<span class="text-ink-3 italic">{m.docs_list_unknown()}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-baseline">
|
||||
<span
|
||||
class="w-10 font-sans text-xs font-bold tracking-wide text-ink-3 uppercase"
|
||||
>{m.docs_list_to()}</span
|
||||
>
|
||||
{#if doc.receivers && doc.receivers.length > 0}
|
||||
<span class="text-ink">
|
||||
{doc.receivers.map((p) => p.firstName + ' ' + p.lastName).join(', ')}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-ink-3 italic">{m.docs_list_unknown()}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tags Display -->
|
||||
{#if doc.tags && doc.tags.length > 0}
|
||||
<div class="mt-4 flex flex-wrap gap-2 pt-3">
|
||||
{#each doc.tags as tag (tag.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="relative z-10 inline-flex cursor-pointer items-center rounded bg-muted px-2 py-1 text-[10px] font-bold tracking-widest text-ink uppercase transition-colors hover:bg-primary hover:text-white"
|
||||
onclick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
goto(`/?tag=${encodeURIComponent(tag.name)}`);
|
||||
}}
|
||||
>
|
||||
{tag.name}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Arrow Icon -->
|
||||
<div
|
||||
class="hidden items-center text-ink-3 transition-colors group-hover:text-accent sm:flex"
|
||||
>
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Arrow/Arrow-Right-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-6 w-6"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<!-- Empty State -->
|
||||
<div class="p-16 text-center">
|
||||
<div class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-muted">
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Mag-Glass-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-6 w-6"
|
||||
/>
|
||||
</div>
|
||||
<h3 class="font-serif text-lg font-medium text-ink">{m.docs_empty_heading()}</h3>
|
||||
<p class="mt-1 font-sans text-sm text-ink-2">
|
||||
{m.docs_empty_text()}
|
||||
</p>
|
||||
<button
|
||||
onclick={() => goto('/')}
|
||||
class="mt-6 text-sm font-bold tracking-wide text-accent uppercase transition hover:text-ink"
|
||||
>
|
||||
{m.docs_empty_btn_clear()}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
207
frontend/src/routes/DropZone.svelte
Normal file
207
frontend/src/routes/DropZone.svelte
Normal file
@@ -0,0 +1,207 @@
|
||||
<script lang="ts">
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { getErrorMessage } from '$lib/errors';
|
||||
|
||||
const ACCEPTED_TYPES = ['application/pdf', 'image/jpeg', 'image/png', 'image/tiff'];
|
||||
|
||||
let isDragging = $state(false);
|
||||
let windowDragging = $state(false);
|
||||
let dragCounter = 0;
|
||||
let isUploading = $state(false);
|
||||
let uploadProgress = $state(0);
|
||||
let uploadMessages = $state<{ text: string; isError: boolean; link?: string }[]>([]);
|
||||
let fileInput: HTMLInputElement;
|
||||
|
||||
function handleDragOver(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
isDragging = true;
|
||||
}
|
||||
|
||||
function handleDragLeave() {
|
||||
isDragging = false;
|
||||
}
|
||||
|
||||
async function handleDrop(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
isDragging = false;
|
||||
windowDragging = false;
|
||||
dragCounter = 0;
|
||||
const files = Array.from(e.dataTransfer?.files ?? []);
|
||||
await uploadFiles(files);
|
||||
}
|
||||
|
||||
async function handleFileSelect(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const files = Array.from(input.files ?? []);
|
||||
input.value = '';
|
||||
await uploadFiles(files);
|
||||
}
|
||||
|
||||
async function uploadFiles(files: File[]) {
|
||||
if (files.length === 0) return;
|
||||
|
||||
const messages: { text: string; isError: boolean; link?: string }[] = [];
|
||||
|
||||
const valid: File[] = [];
|
||||
for (const file of files) {
|
||||
if (!ACCEPTED_TYPES.includes(file.type)) {
|
||||
messages.push({ text: m.upload_invalid_type({ filename: file.name }), isError: true });
|
||||
} else {
|
||||
valid.push(file);
|
||||
}
|
||||
}
|
||||
|
||||
if (valid.length === 0) {
|
||||
uploadMessages = messages;
|
||||
return;
|
||||
}
|
||||
|
||||
isUploading = true;
|
||||
uploadProgress = 0;
|
||||
try {
|
||||
const formData = new FormData();
|
||||
for (const file of valid) {
|
||||
formData.append('files', file);
|
||||
}
|
||||
|
||||
const { ok, body } = await new Promise<{ ok: boolean; body: string }>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', '/api/documents/quick-upload');
|
||||
xhr.upload.addEventListener('progress', (e) => {
|
||||
if (e.lengthComputable) uploadProgress = Math.round((e.loaded / e.total) * 100);
|
||||
});
|
||||
xhr.addEventListener('load', () => resolve({ ok: xhr.status < 300, body: xhr.responseText }));
|
||||
xhr.addEventListener('error', () => reject(new Error('Network error')));
|
||||
xhr.send(formData);
|
||||
});
|
||||
|
||||
if (ok) {
|
||||
const result = JSON.parse(body);
|
||||
if (result.created?.length > 0) {
|
||||
messages.push({ text: m.upload_success({ count: result.created.length }), isError: false });
|
||||
}
|
||||
for (const doc of result.updated ?? []) {
|
||||
messages.push({
|
||||
text: m.upload_duplicate({ filename: doc.originalFilename }),
|
||||
isError: false,
|
||||
link: `/documents/${doc.id}`
|
||||
});
|
||||
}
|
||||
for (const err of result.errors ?? []) {
|
||||
messages.push({
|
||||
text: `${err.filename}: ${getErrorMessage(err.code)}`,
|
||||
isError: true
|
||||
});
|
||||
}
|
||||
await invalidateAll();
|
||||
} else {
|
||||
for (const file of valid) {
|
||||
messages.push({ text: m.upload_error({ filename: file.name }), isError: true });
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
isUploading = false;
|
||||
uploadProgress = 0;
|
||||
uploadMessages = messages;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
function onWindowDragEnter(e: DragEvent) {
|
||||
if (!e.dataTransfer?.types.includes('Files')) return;
|
||||
dragCounter++;
|
||||
windowDragging = true;
|
||||
}
|
||||
|
||||
function onWindowDragLeave() {
|
||||
dragCounter--;
|
||||
if (dragCounter <= 0) {
|
||||
dragCounter = 0;
|
||||
windowDragging = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onWindowDrop() {
|
||||
dragCounter = 0;
|
||||
windowDragging = false;
|
||||
}
|
||||
|
||||
window.addEventListener('dragenter', onWindowDragEnter);
|
||||
window.addEventListener('dragleave', onWindowDragLeave);
|
||||
window.addEventListener('drop', onWindowDrop);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('dragenter', onWindowDragEnter);
|
||||
window.removeEventListener('dragleave', onWindowDragLeave);
|
||||
window.removeEventListener('drop', onWindowDrop);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
class="mb-4 flex cursor-pointer flex-col items-center justify-center gap-2 border border-dashed px-6 transition-all duration-200 {isDragging
|
||||
? 'border-primary bg-accent-bg py-10 text-primary'
|
||||
: windowDragging
|
||||
? 'border-primary/60 bg-accent-bg/50 py-10 text-primary/80'
|
||||
: 'border-ink/20 py-6 text-ink-3 hover:border-primary hover:text-primary'}"
|
||||
ondragover={handleDragOver}
|
||||
ondragleave={handleDragLeave}
|
||||
ondrop={handleDrop}
|
||||
onclick={() => fileInput.click()}
|
||||
onkeydown={(e) => e.key === 'Enter' && fileInput.click()}
|
||||
>
|
||||
{#if isUploading}
|
||||
<div class="flex w-48 flex-col items-center gap-1">
|
||||
<div class="h-1.5 w-full overflow-hidden rounded-full bg-ink/10">
|
||||
<div
|
||||
class="h-full rounded-full bg-primary transition-all duration-200"
|
||||
style="width: {uploadProgress}%"
|
||||
></div>
|
||||
</div>
|
||||
<span class="font-sans text-xs text-ink-3">{uploadProgress}%</span>
|
||||
</div>
|
||||
{:else}
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Copy-Item-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-8 w-8 opacity-40"
|
||||
/>
|
||||
<div class="flex flex-col items-center gap-0.5 text-center">
|
||||
<span class="font-sans text-sm text-ink-2">{m.upload_drop_hint()}</span>
|
||||
<span class="font-sans text-xs text-ink-3">{m.upload_accepted_types()}</span>
|
||||
<span class="font-sans text-xs text-ink-3 italic">{m.upload_filename_hint()}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if uploadMessages.length > 0}
|
||||
<div class="mb-4 flex flex-col gap-1">
|
||||
{#each uploadMessages as msg, i (i)}
|
||||
<p
|
||||
class="font-sans text-sm {msg.isError
|
||||
? 'text-red-600'
|
||||
: msg.link
|
||||
? 'text-amber-700'
|
||||
: 'text-green-700'}"
|
||||
>
|
||||
{msg.text}
|
||||
{#if msg.link}
|
||||
<a href={msg.link} class="underline hover:no-underline">{m.upload_duplicate_link()}</a>
|
||||
{/if}
|
||||
</p>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<input
|
||||
bind:this={fileInput}
|
||||
type="file"
|
||||
multiple
|
||||
accept=".pdf,.jpg,.jpeg,.png,.tif,.tiff"
|
||||
class="sr-only"
|
||||
onchange={handleFileSelect}
|
||||
/>
|
||||
164
frontend/src/routes/SearchFilterBar.svelte
Normal file
164
frontend/src/routes/SearchFilterBar.svelte
Normal file
@@ -0,0 +1,164 @@
|
||||
<script lang="ts">
|
||||
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
|
||||
import TagInput from '$lib/components/TagInput.svelte';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
let {
|
||||
q = $bindable(''),
|
||||
from = $bindable(''),
|
||||
to = $bindable(''),
|
||||
senderId = $bindable(''),
|
||||
receiverId = $bindable(''),
|
||||
tagNames = $bindable<string[]>([]),
|
||||
showAdvanced = $bindable(false),
|
||||
initialSenderName = '',
|
||||
initialReceiverName = '',
|
||||
onSearch,
|
||||
onfocus,
|
||||
onblur
|
||||
}: {
|
||||
q?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
senderId?: string;
|
||||
receiverId?: string;
|
||||
tagNames?: string[];
|
||||
showAdvanced?: boolean;
|
||||
initialSenderName?: string;
|
||||
initialReceiverName?: string;
|
||||
onSearch: () => void;
|
||||
onfocus?: () => void;
|
||||
onblur?: () => void;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div class="mb-8 rounded-sm border border-line bg-surface p-6 shadow-sm">
|
||||
<!-- ROW 1: Main Search (One Line) -->
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- Full Text Search -->
|
||||
<div class="relative flex-1">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={q}
|
||||
oninput={onSearch}
|
||||
onfocus={onfocus}
|
||||
onblur={onblur}
|
||||
placeholder={m.docs_search_placeholder()}
|
||||
class="block w-full border-line py-2.5 pr-10 pl-3 placeholder-ink-3 shadow-sm focus:border-ink focus:ring-ink"
|
||||
/>
|
||||
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Mag-Glass-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-4 w-4 opacity-40"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toggle Advanced Button -->
|
||||
<button
|
||||
onclick={() => (showAdvanced = !showAdvanced)}
|
||||
class="flex items-center gap-2 border border-line bg-muted px-4 py-2.5 text-sm font-bold tracking-wide text-ink-2 uppercase transition hover:bg-muted hover:text-ink"
|
||||
>
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Small-16px/SVG/Action/Chevron/Chevron-Down-SM.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-4 w-4 transform transition-transform duration-200 {showAdvanced ? 'rotate-180' : ''}"
|
||||
/>
|
||||
{m.docs_btn_filter()}
|
||||
</button>
|
||||
|
||||
<!-- Reset Button -->
|
||||
<a
|
||||
href="/"
|
||||
class="flex items-center justify-center border border-transparent px-3 py-2.5 text-ink-3 transition hover:text-red-500"
|
||||
title={m.docs_btn_reset_title()}
|
||||
>
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Close-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-5 w-5 opacity-40"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- ROW 2: Advanced Filters (Collapsible) -->
|
||||
{#if showAdvanced}
|
||||
<div
|
||||
transition:slide
|
||||
class="mt-6 grid grid-cols-1 gap-6 border-t border-line-2 pt-6 md:grid-cols-12"
|
||||
>
|
||||
<!-- Tag Filter -->
|
||||
<div class="md:col-span-12">
|
||||
<p class="mb-2 block text-xs font-bold tracking-widest text-ink-2 uppercase">
|
||||
{m.docs_filter_label_tags()}
|
||||
</p>
|
||||
<TagInput bind:tags={tagNames} allowCreation={false} />
|
||||
</div>
|
||||
|
||||
<!-- Sender -->
|
||||
<div class="md:col-span-3">
|
||||
<div
|
||||
class="[&_input]:border-line [&_input]:py-2.5 [&_label]:mb-2 [&_label]:text-xs [&_label]:font-bold [&_label]:tracking-widest [&_label]:text-ink-2 [&_label]:uppercase"
|
||||
>
|
||||
<PersonTypeahead
|
||||
name="senderId"
|
||||
label={m.docs_filter_label_sender()}
|
||||
bind:value={senderId}
|
||||
initialName={initialSenderName}
|
||||
onchange={onSearch}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Receiver -->
|
||||
<div class="md:col-span-3">
|
||||
<div
|
||||
class="[&_input]:border-line [&_input]:py-2.5 [&_label]:mb-2 [&_label]:text-xs [&_label]:font-bold [&_label]:tracking-widest [&_label]:text-ink-2 [&_label]:uppercase"
|
||||
>
|
||||
<PersonTypeahead
|
||||
name="receiverId"
|
||||
label={m.docs_filter_label_receivers()}
|
||||
bind:value={receiverId}
|
||||
initialName={initialReceiverName}
|
||||
onchange={onSearch}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dates -->
|
||||
<div class="grid grid-cols-2 gap-4 md:col-span-6">
|
||||
<div>
|
||||
<label
|
||||
for="from"
|
||||
class="mb-2 block text-xs font-bold tracking-widest text-ink-2 uppercase"
|
||||
>{m.docs_filter_label_from()}</label
|
||||
>
|
||||
<input
|
||||
type="date"
|
||||
id="from"
|
||||
bind:value={from}
|
||||
onchange={onSearch}
|
||||
class="block w-full border-line py-2.5 text-sm shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="to" class="mb-2 block text-xs font-bold tracking-widest text-ink-2 uppercase"
|
||||
>{m.docs_filter_label_to()}</label
|
||||
>
|
||||
<input
|
||||
type="date"
|
||||
id="to"
|
||||
bind:value={to}
|
||||
onchange={onSearch}
|
||||
class="block w-full border-line py-2.5 text-sm shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
81
frontend/src/routes/UserMenu.svelte
Normal file
81
frontend/src/routes/UserMenu.svelte
Normal file
@@ -0,0 +1,81 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
let { userInitials }: { userInitials: string | null } = $props();
|
||||
|
||||
let userMenuOpen = $state(false);
|
||||
|
||||
function clickOutside(node: HTMLElement) {
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
if (node && !node.contains(event.target as Node) && !event.defaultPrevented) {
|
||||
userMenuOpen = false;
|
||||
}
|
||||
};
|
||||
document.addEventListener('click', handleClick, true);
|
||||
return () => {
|
||||
document.removeEventListener('click', handleClick, true);
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="relative"
|
||||
{@attach clickOutside}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Escape') userMenuOpen = false;
|
||||
}}
|
||||
role="none"
|
||||
>
|
||||
{#if userInitials}
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={userMenuOpen}
|
||||
aria-haspopup="true"
|
||||
onclick={() => (userMenuOpen = !userMenuOpen)}
|
||||
class="flex h-8 w-8 items-center justify-center rounded-full bg-primary font-sans text-xs font-bold text-white transition-opacity hover:opacity-80"
|
||||
>
|
||||
{userInitials}
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={m.nav_profile()}
|
||||
aria-expanded={userMenuOpen}
|
||||
aria-haspopup="true"
|
||||
onclick={() => (userMenuOpen = !userMenuOpen)}
|
||||
class="inline-flex items-center gap-1.5 px-3 py-2 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase transition-colors hover:text-ink"
|
||||
>
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Small-16px/SVG/Action/Account-SM.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-4 w-4 opacity-50"
|
||||
/>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if userMenuOpen}
|
||||
<div
|
||||
class="absolute top-full right-0 z-50 mt-1 min-w-[10rem] rounded-sm border border-line bg-overlay shadow-md"
|
||||
>
|
||||
<a
|
||||
href="/profile"
|
||||
onclick={() => (userMenuOpen = false)}
|
||||
class="block px-4 py-2.5 font-sans text-xs font-bold tracking-widest text-ink-2 uppercase transition-colors hover:bg-muted hover:text-ink"
|
||||
>
|
||||
{m.nav_profile()}
|
||||
</a>
|
||||
<div class="border-t border-line">
|
||||
<form action="/logout" method="POST" use:enhance>
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full px-4 py-2.5 text-left font-sans text-xs font-bold tracking-widest text-ink-3 uppercase transition-colors hover:bg-muted hover:text-ink"
|
||||
>
|
||||
{m.nav_logout()}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,539 +1,74 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import UsersTab from './UsersTab.svelte';
|
||||
import TagsTab from './TagsTab.svelte';
|
||||
import GroupsTab from './GroupsTab.svelte';
|
||||
import SystemTab from './SystemTab.svelte';
|
||||
|
||||
let { data, form } = $props();
|
||||
|
||||
let activeTab = $state('users');
|
||||
let editingTagId: string | null = $state(null);
|
||||
let editingTagName = $state('');
|
||||
let editingGroupId: string | null = $state(null);
|
||||
let backfillResult: number | null = $state(null);
|
||||
let backfillLoading = $state(false);
|
||||
|
||||
const availablePermissions = ['WRITE_ALL', 'ADMIN', 'ADMIN_USER', 'ADMIN_TAG', 'ADMIN_PERMISSION'];
|
||||
|
||||
function startEditTag(tag: { id: string; name: string }) {
|
||||
editingTagId = tag.id;
|
||||
editingTagName = tag.name;
|
||||
}
|
||||
|
||||
function cancelEditTag() {
|
||||
editingTagId = null;
|
||||
editingTagName = '';
|
||||
}
|
||||
|
||||
function startEditGroup(id: string) {
|
||||
editingGroupId = id;
|
||||
}
|
||||
|
||||
function cancelEditGroup() {
|
||||
editingGroupId = null;
|
||||
}
|
||||
|
||||
async function backfillVersions() {
|
||||
backfillLoading = true;
|
||||
backfillResult = null;
|
||||
try {
|
||||
const res = await fetch('/api/admin/backfill-versions', { method: 'POST' });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
backfillResult = data.count;
|
||||
}
|
||||
} finally {
|
||||
backfillLoading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-7xl py-8 font-sans sm:px-6 lg:px-8">
|
||||
<div class="mb-8 flex items-center justify-between">
|
||||
<h1 class="font-serif text-3xl text-brand-navy">{m.admin_heading()}</h1>
|
||||
<h1 class="font-serif text-3xl text-ink">{m.admin_heading()}</h1>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="flex rounded-lg border border-gray-200 bg-white p-1 shadow-sm">
|
||||
<div class="flex rounded-lg border border-line bg-surface p-1 shadow-sm">
|
||||
<button
|
||||
class="rounded-md px-4 py-2 text-sm font-bold tracking-wide uppercase transition {activeTab ===
|
||||
'users'
|
||||
? 'bg-brand-navy text-white'
|
||||
: 'text-gray-500 hover:text-brand-navy'}"
|
||||
? 'bg-primary text-white'
|
||||
: 'text-ink-2 hover:text-ink'}"
|
||||
onclick={() => (activeTab = 'users')}>{m.admin_tab_users()}</button
|
||||
>
|
||||
<button
|
||||
class="rounded-md px-4 py-2 text-sm font-bold tracking-wide uppercase transition {activeTab ===
|
||||
'groups'
|
||||
? 'bg-brand-navy text-white'
|
||||
: 'text-gray-500 hover:text-brand-navy'}"
|
||||
? 'bg-primary text-white'
|
||||
: 'text-ink-2 hover:text-ink'}"
|
||||
onclick={() => (activeTab = 'groups')}>{m.admin_tab_groups()}</button
|
||||
>
|
||||
<button
|
||||
class="rounded-md px-4 py-2 text-sm font-bold tracking-wide uppercase transition {activeTab ===
|
||||
'tags'
|
||||
? 'bg-brand-navy text-white'
|
||||
: 'text-gray-500 hover:text-brand-navy'}"
|
||||
? 'bg-primary text-white'
|
||||
: 'text-ink-2 hover:text-ink'}"
|
||||
onclick={() => (activeTab = 'tags')}>{m.admin_tab_tags()}</button
|
||||
>
|
||||
<button
|
||||
class="rounded-md px-4 py-2 text-sm font-bold tracking-wide uppercase transition {activeTab ===
|
||||
'system'
|
||||
? 'bg-brand-navy text-white'
|
||||
: 'text-gray-500 hover:text-brand-navy'}"
|
||||
? 'bg-primary text-white'
|
||||
: 'text-ink-2 hover:text-ink'}"
|
||||
onclick={() => (activeTab = 'system')}>{m.admin_tab_system()}</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if form?.message}
|
||||
<div class="mb-6 rounded border border-brand-mint/50 bg-brand-mint/20 p-4 text-brand-navy">
|
||||
<div class="mb-6 rounded border border-accent/50 bg-accent/20 p-4 text-ink">
|
||||
{form.message}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if activeTab === 'users'}
|
||||
<div class="overflow-hidden rounded-lg border border-brand-sand bg-white shadow-sm" in:slide>
|
||||
<div class="flex items-center justify-between border-b border-gray-100 p-6">
|
||||
<h2 class="text-lg font-bold text-gray-700">{m.admin_section_users()}</h2>
|
||||
<a
|
||||
href="/admin/users/new"
|
||||
class="inline-flex items-center gap-1 rounded-sm bg-brand-navy px-4 py-2 font-sans text-xs font-bold tracking-widest text-white uppercase transition-opacity hover:opacity-80"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
{m.admin_btn_new_user()}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-bold tracking-wider text-gray-500 uppercase"
|
||||
>{m.admin_col_login()}</th
|
||||
>
|
||||
<th class="px-6 py-3 text-left text-xs font-bold tracking-wider text-gray-500 uppercase"
|
||||
>{m.admin_col_full_name()}</th
|
||||
>
|
||||
<th class="px-6 py-3 text-left text-xs font-bold tracking-wider text-gray-500 uppercase"
|
||||
>{m.admin_col_groups()}</th
|
||||
>
|
||||
<th
|
||||
class="px-6 py-3 text-right text-xs font-bold tracking-wider text-gray-500 uppercase"
|
||||
>{m.admin_col_actions()}</th
|
||||
>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 bg-white">
|
||||
{#each data.users as user (user.id)}
|
||||
<tr class="group/row hover:bg-gray-50">
|
||||
<td class="px-6 py-4 text-sm font-medium whitespace-nowrap text-gray-900">
|
||||
{user.username}
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-500">
|
||||
{#if user.firstName || user.lastName}
|
||||
{user.firstName ?? ''} {user.lastName ?? ''}
|
||||
{:else}
|
||||
<span class="text-gray-300 italic">–</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-500">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#if user.groups && user.groups.length > 0}
|
||||
{#each user.groups as group (group.id)}
|
||||
<span
|
||||
class="rounded-full border border-blue-100 bg-blue-50 px-2 py-0.5 text-[10px] font-bold text-blue-700 uppercase"
|
||||
>
|
||||
{group.name}
|
||||
</span>
|
||||
{/each}
|
||||
{:else}
|
||||
<span class="text-xs text-gray-400 italic">{m.admin_no_groups()}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-right whitespace-nowrap">
|
||||
<div class="flex items-center justify-end gap-4">
|
||||
<a
|
||||
href="/admin/users/{user.id}"
|
||||
class="text-sm font-bold tracking-wide text-brand-mint uppercase hover:text-brand-navy"
|
||||
>
|
||||
{m.btn_edit()}
|
||||
</a>
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action="?/deleteUser"
|
||||
use:enhance={({ cancel }) => {
|
||||
if (!confirm(m.admin_user_delete_confirm({ username: user.username }))) {
|
||||
cancel();
|
||||
}
|
||||
return async ({ update }) => {
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
class="flex items-center"
|
||||
>
|
||||
<input type="hidden" name="id" value={user.id} />
|
||||
<button
|
||||
class="p-1 text-gray-300 transition-colors hover:text-red-600"
|
||||
title={m.admin_btn_delete_user_title()}
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
<div in:slide>
|
||||
<UsersTab users={data.users} />
|
||||
</div>
|
||||
{:else if activeTab === 'tags'}
|
||||
<div class="overflow-hidden rounded-lg border border-brand-sand bg-white shadow-sm" in:slide>
|
||||
<div class="border-b border-gray-100 bg-yellow-50/50 p-6">
|
||||
<h2 class="text-lg font-bold text-gray-700">{m.admin_section_tags()}</h2>
|
||||
<p class="mt-1 text-xs text-yellow-800">
|
||||
{m.admin_tags_warning()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ul class="max-h-[600px] divide-y divide-gray-100 overflow-y-auto">
|
||||
{#each data.tags as tag (tag.id)}
|
||||
<li class="group flex items-center justify-between px-6 py-3 hover:bg-gray-50">
|
||||
{#if editingTagId === tag.id}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/updateTag"
|
||||
use:enhance={() =>
|
||||
async ({ update }) => {
|
||||
await update();
|
||||
cancelEditTag();
|
||||
}}
|
||||
class="flex flex-1 items-center gap-2"
|
||||
>
|
||||
<input type="hidden" name="id" value={tag.id} />
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
bind:value={editingTagName}
|
||||
class="flex-1 rounded border-brand-mint px-2 py-1 text-sm ring-1 ring-brand-mint"
|
||||
/>
|
||||
<button aria-label={m.btn_save()} class="text-green-600 hover:text-green-800"
|
||||
><svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
><path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
/></svg
|
||||
></button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onclick={cancelEditTag}
|
||||
aria-label={m.btn_cancel()}
|
||||
class="text-gray-400 hover:text-gray-600"
|
||||
><svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
><path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/></svg
|
||||
></button
|
||||
>
|
||||
</form>
|
||||
{:else}
|
||||
<span class="rounded bg-brand-sand/30 px-2 py-1 text-sm font-medium text-brand-navy">
|
||||
{tag.name}
|
||||
</span>
|
||||
<div
|
||||
class="flex items-center gap-2 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
>
|
||||
<button
|
||||
onclick={() => startEditTag(tag)}
|
||||
aria-label={m.admin_btn_edit_tag_label()}
|
||||
class="p-1 text-gray-400 hover:text-brand-navy"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
><path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
|
||||
/></svg
|
||||
>
|
||||
</button>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/deleteTag"
|
||||
use:enhance={({ cancel }) => {
|
||||
if (
|
||||
!confirm(m.admin_tag_delete_confirm())
|
||||
) {
|
||||
cancel();
|
||||
}
|
||||
return async ({ update }) => {
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
class="inline"
|
||||
>
|
||||
<input type="hidden" name="id" value={tag.id} />
|
||||
<button
|
||||
aria-label={m.admin_btn_delete_tag_label()}
|
||||
class="p-1 text-gray-400 hover:text-red-600"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
><path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/></svg
|
||||
>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<div in:slide>
|
||||
<TagsTab tags={data.tags} />
|
||||
</div>
|
||||
{:else if activeTab === 'groups'}
|
||||
<div class="overflow-hidden rounded-lg border border-brand-sand bg-white shadow-sm" in:slide>
|
||||
<div class="flex items-center justify-between border-b border-gray-100 p-6">
|
||||
<h2 class="text-lg font-bold text-gray-700">{m.admin_section_groups()}</h2>
|
||||
</div>
|
||||
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-bold tracking-wider text-gray-500 uppercase"
|
||||
>{m.admin_col_name()}</th
|
||||
>
|
||||
<th class="px-6 py-3 text-left text-xs font-bold tracking-wider text-gray-500 uppercase"
|
||||
>{m.admin_col_permissions()}</th
|
||||
>
|
||||
<th
|
||||
class="px-6 py-3 text-right text-xs font-bold tracking-wider text-gray-500 uppercase"
|
||||
>{m.admin_col_actions()}</th
|
||||
>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 bg-white">
|
||||
{#each data.groups as group (group.id)}
|
||||
<tr class="group/row hover:bg-gray-50">
|
||||
{#if editingGroupId === group.id}
|
||||
<!-- EDIT MODE -->
|
||||
<td colspan="3" class="px-6 py-4">
|
||||
<form
|
||||
method="POST"
|
||||
action="?/updateGroup"
|
||||
use:enhance={() =>
|
||||
async ({ update }) => {
|
||||
await update();
|
||||
cancelEditGroup();
|
||||
}}
|
||||
class="flex w-full flex-col items-start gap-4 sm:flex-row"
|
||||
>
|
||||
<input type="hidden" name="id" value={group.id} />
|
||||
|
||||
<div class="w-full sm:w-1/3">
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
value={group.name}
|
||||
class="w-full rounded border-brand-mint text-sm"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex h-full flex-1 flex-wrap items-center gap-4 pt-2">
|
||||
{#each availablePermissions as perm (perm)}
|
||||
<label
|
||||
class="inline-flex items-center text-xs font-bold text-gray-600 uppercase"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="permissions"
|
||||
value={perm}
|
||||
checked={group.permissions.includes(perm)}
|
||||
class="mr-2 rounded border-gray-300 text-brand-navy focus:ring-brand-mint"
|
||||
/>
|
||||
{perm.replace('_', ' ')}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 self-start sm:self-center">
|
||||
<button
|
||||
type="submit"
|
||||
aria-label={m.btn_save()}
|
||||
class="p-1 text-green-600 hover:text-green-800"
|
||||
>
|
||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
><path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
/></svg
|
||||
>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={cancelEditGroup}
|
||||
aria-label={m.btn_cancel()}
|
||||
class="p-1 text-gray-400 hover:text-red-500"
|
||||
>
|
||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
><path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/></svg
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</td>
|
||||
{:else}
|
||||
<!-- VIEW MODE -->
|
||||
<td class="px-6 py-4 text-sm font-bold whitespace-nowrap text-brand-navy">
|
||||
{group.name}
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-500">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#each group.permissions as perm (perm)}
|
||||
<span
|
||||
class="rounded-full px-2 py-0.5 text-[10px] font-bold uppercase
|
||||
{perm === 'ADMIN'
|
||||
? 'border-red-100 bg-red-50 text-red-700'
|
||||
: 'border-gray-200 bg-gray-100 text-gray-600'}"
|
||||
>
|
||||
{perm}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-right whitespace-nowrap">
|
||||
<div class="flex items-center justify-end gap-3">
|
||||
<button
|
||||
onclick={() => startEditGroup(group.id)}
|
||||
class="text-sm font-bold tracking-wide text-brand-mint uppercase hover:text-brand-navy"
|
||||
>
|
||||
{m.btn_edit()}
|
||||
</button>
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action="?/deleteGroup"
|
||||
use:enhance={({ cancel }) => {
|
||||
if (!confirm(m.admin_group_delete_confirm())) {
|
||||
cancel();
|
||||
}
|
||||
return async ({ update }) => {
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="id" value={group.id} />
|
||||
<button
|
||||
class="p-1 text-gray-300 transition-colors hover:text-red-600"
|
||||
title={m.btn_delete()}
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
{/if}
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- CREATE GROUP FORM -->
|
||||
<div class="border-t border-gray-200 bg-gray-50 p-6">
|
||||
<h3 class="mb-4 text-xs font-bold tracking-wide text-gray-500 uppercase">
|
||||
{m.admin_section_new_group()}
|
||||
</h3>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/createGroup"
|
||||
use:enhance
|
||||
class="flex flex-col items-start gap-4 md:flex-row md:items-center"
|
||||
>
|
||||
<div class="w-full flex-1">
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
placeholder={m.admin_group_name_placeholder()}
|
||||
required
|
||||
class="w-full rounded border-gray-300 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
{#each availablePermissions as perm (perm)}
|
||||
<label class="inline-flex items-center text-xs font-bold text-gray-600 uppercase">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="permissions"
|
||||
value={perm}
|
||||
class="mr-2 rounded border-gray-300 text-brand-navy focus:ring-brand-mint"
|
||||
/>
|
||||
{perm.replace('_', ' ')}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full rounded bg-brand-navy px-6 py-2 text-sm font-bold text-white uppercase hover:bg-brand-mint hover:text-brand-navy md:w-auto"
|
||||
>
|
||||
{m.btn_create()}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<div in:slide>
|
||||
<GroupsTab groups={data.groups} />
|
||||
</div>
|
||||
{:else if activeTab === 'system'}
|
||||
<div class="rounded-sm border border-brand-sand bg-white p-6 shadow-sm">
|
||||
<h2 class="mb-1 text-lg font-bold text-gray-700">{m.admin_system_backfill_heading()}</h2>
|
||||
<p class="mb-4 text-sm text-gray-500">{m.admin_system_backfill_description()}</p>
|
||||
<button
|
||||
onclick={backfillVersions}
|
||||
disabled={backfillLoading}
|
||||
class="rounded bg-brand-navy px-6 py-2 text-sm font-bold text-white uppercase transition hover:bg-brand-mint hover:text-brand-navy disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{backfillLoading ? '…' : m.admin_system_backfill_btn()}
|
||||
</button>
|
||||
{#if backfillResult !== null}
|
||||
<p class="mt-4 text-sm font-medium text-brand-navy">
|
||||
{m.admin_system_backfill_success({ count: backfillResult })}
|
||||
</p>
|
||||
{/if}
|
||||
<div in:slide>
|
||||
<SystemTab />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
221
frontend/src/routes/admin/GroupsTab.svelte
Normal file
221
frontend/src/routes/admin/GroupsTab.svelte
Normal file
@@ -0,0 +1,221 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
let { groups }: { groups: { id: string; name: string; permissions: string[] }[] } = $props();
|
||||
|
||||
const availablePermissions = ['WRITE_ALL', 'ADMIN', 'ADMIN_USER', 'ADMIN_TAG', 'ADMIN_PERMISSION'];
|
||||
|
||||
let editingGroupId: string | null = $state(null);
|
||||
|
||||
function startEditGroup(id: string) {
|
||||
editingGroupId = id;
|
||||
}
|
||||
|
||||
function cancelEditGroup() {
|
||||
editingGroupId = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="overflow-hidden rounded-lg border border-line bg-surface shadow-sm">
|
||||
<div class="flex items-center justify-between border-b border-line-2 p-6">
|
||||
<h2 class="text-lg font-bold text-ink-2">{m.admin_section_groups()}</h2>
|
||||
</div>
|
||||
|
||||
<table class="min-w-full divide-y divide-line">
|
||||
<thead class="bg-muted">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-bold tracking-wider text-ink-2 uppercase"
|
||||
>{m.admin_col_name()}</th
|
||||
>
|
||||
<th class="px-6 py-3 text-left text-xs font-bold tracking-wider text-ink-2 uppercase"
|
||||
>{m.admin_col_permissions()}</th
|
||||
>
|
||||
<th class="px-6 py-3 text-right text-xs font-bold tracking-wider text-ink-2 uppercase"
|
||||
>{m.admin_col_actions()}</th
|
||||
>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-line bg-surface">
|
||||
{#each groups as group (group.id)}
|
||||
<tr class="group/row hover:bg-muted">
|
||||
{#if editingGroupId === group.id}
|
||||
<!-- EDIT MODE -->
|
||||
<td colspan="3" class="px-6 py-4">
|
||||
<form
|
||||
method="POST"
|
||||
action="?/updateGroup"
|
||||
use:enhance={() =>
|
||||
async ({ update }) => {
|
||||
await update();
|
||||
cancelEditGroup();
|
||||
}}
|
||||
class="flex w-full flex-col items-start gap-4 sm:flex-row"
|
||||
>
|
||||
<input type="hidden" name="id" value={group.id} />
|
||||
|
||||
<div class="w-full sm:w-1/3">
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
value={group.name}
|
||||
class="w-full rounded border-accent text-sm"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex h-full flex-1 flex-wrap items-center gap-4 pt-2">
|
||||
{#each availablePermissions as perm (perm)}
|
||||
<label class="inline-flex items-center text-xs font-bold text-ink-2 uppercase">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="permissions"
|
||||
value={perm}
|
||||
checked={group.permissions.includes(perm)}
|
||||
class="mr-2 rounded border-line text-ink focus:ring-accent"
|
||||
/>
|
||||
{perm.replace('_', ' ')}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 self-start sm:self-center">
|
||||
<button
|
||||
type="submit"
|
||||
aria-label={m.btn_save()}
|
||||
class="p-1 text-green-600 hover:text-green-800"
|
||||
>
|
||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
><path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
/></svg
|
||||
>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={cancelEditGroup}
|
||||
aria-label={m.btn_cancel()}
|
||||
class="p-1 text-ink-3 hover:text-red-500"
|
||||
>
|
||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
><path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/></svg
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</td>
|
||||
{:else}
|
||||
<!-- VIEW MODE -->
|
||||
<td class="px-6 py-4 text-sm font-bold whitespace-nowrap text-ink">
|
||||
{group.name}
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm text-ink-2">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#each group.permissions as perm (perm)}
|
||||
<span
|
||||
class="rounded-full px-2 py-0.5 text-[10px] font-bold uppercase
|
||||
{perm === 'ADMIN'
|
||||
? 'border-red-100 bg-red-50 text-red-700'
|
||||
: 'border-line bg-muted text-ink-2'}"
|
||||
>
|
||||
{perm}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-right whitespace-nowrap">
|
||||
<div class="flex items-center justify-end gap-3">
|
||||
<button
|
||||
onclick={() => startEditGroup(group.id)}
|
||||
class="text-sm font-bold tracking-wide text-accent uppercase hover:text-ink"
|
||||
>
|
||||
{m.btn_edit()}
|
||||
</button>
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action="?/deleteGroup"
|
||||
use:enhance={({ cancel }) => {
|
||||
if (!confirm(m.admin_group_delete_confirm())) {
|
||||
cancel();
|
||||
}
|
||||
return async ({ update }) => {
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="id" value={group.id} />
|
||||
<button
|
||||
class="p-1 text-ink-3 transition-colors hover:text-red-600"
|
||||
title={m.btn_delete()}
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
{/if}
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- CREATE GROUP FORM -->
|
||||
<div class="border-t border-line bg-muted p-6">
|
||||
<h3 class="mb-4 text-xs font-bold tracking-wide text-ink-2 uppercase">
|
||||
{m.admin_section_new_group()}
|
||||
</h3>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/createGroup"
|
||||
use:enhance
|
||||
class="flex flex-col items-start gap-4 md:flex-row md:items-center"
|
||||
>
|
||||
<div class="w-full flex-1">
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
placeholder={m.admin_group_name_placeholder()}
|
||||
required
|
||||
class="w-full rounded border-line text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
{#each availablePermissions as perm (perm)}
|
||||
<label class="inline-flex items-center text-xs font-bold text-ink-2 uppercase">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="permissions"
|
||||
value={perm}
|
||||
class="mr-2 rounded border-line text-ink focus:ring-accent"
|
||||
/>
|
||||
{perm.replace('_', ' ')}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full rounded bg-primary px-6 py-2 text-sm font-bold text-white uppercase hover:bg-accent hover:text-ink md:w-auto"
|
||||
>
|
||||
{m.btn_create()}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
72
frontend/src/routes/admin/SystemTab.svelte
Normal file
72
frontend/src/routes/admin/SystemTab.svelte
Normal file
@@ -0,0 +1,72 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
let backfillResult: number | null = $state(null);
|
||||
let backfillLoading = $state(false);
|
||||
let backfillHashesResult: number | null = $state(null);
|
||||
let backfillHashesLoading = $state(false);
|
||||
|
||||
async function backfillVersions() {
|
||||
backfillLoading = true;
|
||||
backfillResult = null;
|
||||
try {
|
||||
const res = await fetch('/api/admin/backfill-versions', { method: 'POST' });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
backfillResult = data.count;
|
||||
}
|
||||
} finally {
|
||||
backfillLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function backfillFileHashes() {
|
||||
backfillHashesLoading = true;
|
||||
backfillHashesResult = null;
|
||||
try {
|
||||
const res = await fetch('/api/admin/backfill-file-hashes', { method: 'POST' });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
backfillHashesResult = data.count;
|
||||
}
|
||||
} finally {
|
||||
backfillHashesLoading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
|
||||
<h2 class="mb-1 text-lg font-bold text-ink-2">{m.admin_system_backfill_heading()}</h2>
|
||||
<p class="mb-4 text-sm text-ink-2">{m.admin_system_backfill_description()}</p>
|
||||
<button
|
||||
onclick={backfillVersions}
|
||||
disabled={backfillLoading}
|
||||
class="rounded bg-primary px-6 py-2 text-sm font-bold text-white uppercase transition hover:bg-accent hover:text-ink disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{backfillLoading ? '…' : m.admin_system_backfill_btn()}
|
||||
</button>
|
||||
{#if backfillResult !== null}
|
||||
<p class="mt-4 text-sm font-medium text-ink">
|
||||
{m.admin_system_backfill_success({ count: backfillResult })}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="mt-4 rounded-sm border border-line bg-surface p-6 shadow-sm">
|
||||
<h2 class="mb-1 text-lg font-bold text-ink-2">
|
||||
{m.admin_system_backfill_hashes_heading()}
|
||||
</h2>
|
||||
<p class="mb-4 text-sm text-ink-2">{m.admin_system_backfill_hashes_description()}</p>
|
||||
<button
|
||||
onclick={backfillFileHashes}
|
||||
disabled={backfillHashesLoading}
|
||||
class="rounded bg-primary px-6 py-2 text-sm font-bold text-white uppercase transition hover:bg-accent hover:text-ink disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{backfillHashesLoading ? '…' : m.admin_system_backfill_hashes_btn()}
|
||||
</button>
|
||||
{#if backfillHashesResult !== null}
|
||||
<p class="mt-4 text-sm font-medium text-ink">
|
||||
{m.admin_system_backfill_hashes_success({ count: backfillHashesResult })}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
127
frontend/src/routes/admin/TagsTab.svelte
Normal file
127
frontend/src/routes/admin/TagsTab.svelte
Normal file
@@ -0,0 +1,127 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
let { tags }: { tags: { id: string; name: string }[] } = $props();
|
||||
|
||||
let editingTagId: string | null = $state(null);
|
||||
let editingTagName = $state('');
|
||||
|
||||
function startEditTag(tag: { id: string; name: string }) {
|
||||
editingTagId = tag.id;
|
||||
editingTagName = tag.name;
|
||||
}
|
||||
|
||||
function cancelEditTag() {
|
||||
editingTagId = null;
|
||||
editingTagName = '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="overflow-hidden rounded-lg border border-line bg-surface shadow-sm">
|
||||
<div class="border-b border-line-2 bg-yellow-50/50 p-6">
|
||||
<h2 class="text-lg font-bold text-ink-2">{m.admin_section_tags()}</h2>
|
||||
<p class="mt-1 text-xs text-yellow-800">
|
||||
{m.admin_tags_warning()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ul class="max-h-[600px] divide-y divide-line-2 overflow-y-auto">
|
||||
{#each tags as tag (tag.id)}
|
||||
<li class="group flex items-center justify-between px-6 py-3 hover:bg-muted">
|
||||
{#if editingTagId === tag.id}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/updateTag"
|
||||
use:enhance={() =>
|
||||
async ({ update }) => {
|
||||
await update();
|
||||
cancelEditTag();
|
||||
}}
|
||||
class="flex flex-1 items-center gap-2"
|
||||
>
|
||||
<input type="hidden" name="id" value={tag.id} />
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
bind:value={editingTagName}
|
||||
class="flex-1 rounded border-accent px-2 py-1 text-sm ring-1 ring-accent"
|
||||
/>
|
||||
<button aria-label={m.btn_save()} class="text-green-600 hover:text-green-800"
|
||||
><svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
><path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
/></svg
|
||||
></button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onclick={cancelEditTag}
|
||||
aria-label={m.btn_cancel()}
|
||||
class="text-ink-3 hover:text-ink-2"
|
||||
><svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
><path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/></svg
|
||||
></button
|
||||
>
|
||||
</form>
|
||||
{:else}
|
||||
<span class="rounded bg-muted px-2 py-1 text-sm font-medium text-ink">
|
||||
{tag.name}
|
||||
</span>
|
||||
<div class="flex items-center gap-2 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<button
|
||||
onclick={() => startEditTag(tag)}
|
||||
aria-label={m.admin_btn_edit_tag_label()}
|
||||
class="p-1 text-ink-3 hover:text-ink"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
><path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
|
||||
/></svg
|
||||
>
|
||||
</button>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/deleteTag"
|
||||
use:enhance={({ cancel }) => {
|
||||
if (!confirm(m.admin_tag_delete_confirm())) {
|
||||
cancel();
|
||||
}
|
||||
return async ({ update }) => {
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
class="inline"
|
||||
>
|
||||
<input type="hidden" name="id" value={tag.id} />
|
||||
<button
|
||||
aria-label={m.admin_btn_delete_tag_label()}
|
||||
class="p-1 text-ink-3 hover:text-red-600"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
><path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/></svg
|
||||
>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
120
frontend/src/routes/admin/UsersTab.svelte
Normal file
120
frontend/src/routes/admin/UsersTab.svelte
Normal file
@@ -0,0 +1,120 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
let {
|
||||
users
|
||||
}: {
|
||||
users: {
|
||||
id: string;
|
||||
username: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
groups?: { id: string; name: string }[];
|
||||
}[];
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div class="overflow-hidden rounded-lg border border-line bg-surface shadow-sm">
|
||||
<div class="flex items-center justify-between border-b border-line-2 p-6">
|
||||
<h2 class="text-lg font-bold text-ink-2">{m.admin_section_users()}</h2>
|
||||
<a
|
||||
href="/admin/users/new"
|
||||
class="inline-flex items-center gap-1 rounded-sm bg-primary px-4 py-2 font-sans text-xs font-bold tracking-widest text-white uppercase transition-opacity hover:opacity-80"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
{m.admin_btn_new_user()}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<table class="min-w-full divide-y divide-line">
|
||||
<thead class="bg-muted">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-bold tracking-wider text-ink-2 uppercase"
|
||||
>{m.admin_col_login()}</th
|
||||
>
|
||||
<th class="px-6 py-3 text-left text-xs font-bold tracking-wider text-ink-2 uppercase"
|
||||
>{m.admin_col_full_name()}</th
|
||||
>
|
||||
<th class="px-6 py-3 text-left text-xs font-bold tracking-wider text-ink-2 uppercase"
|
||||
>{m.admin_col_groups()}</th
|
||||
>
|
||||
<th class="px-6 py-3 text-right text-xs font-bold tracking-wider text-ink-2 uppercase"
|
||||
>{m.admin_col_actions()}</th
|
||||
>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-line bg-surface">
|
||||
{#each users as user (user.id)}
|
||||
<tr class="group/row hover:bg-muted">
|
||||
<td class="px-6 py-4 text-sm font-medium whitespace-nowrap text-ink">
|
||||
{user.username}
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm whitespace-nowrap text-ink-2">
|
||||
{#if user.firstName || user.lastName}
|
||||
{user.firstName ?? ''} {user.lastName ?? ''}
|
||||
{:else}
|
||||
<span class="text-ink-3 italic">–</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm text-ink-2">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#if user.groups && user.groups.length > 0}
|
||||
{#each user.groups as group (group.id)}
|
||||
<span
|
||||
class="rounded-full border border-blue-100 bg-blue-50 px-2 py-0.5 text-[10px] font-bold text-blue-700 uppercase"
|
||||
>
|
||||
{group.name}
|
||||
</span>
|
||||
{/each}
|
||||
{:else}
|
||||
<span class="text-xs text-ink-3 italic">{m.admin_no_groups()}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-right whitespace-nowrap">
|
||||
<div class="flex items-center justify-end gap-4">
|
||||
<a
|
||||
href="/admin/users/{user.id}"
|
||||
class="text-sm font-bold tracking-wide text-accent uppercase hover:text-ink"
|
||||
>
|
||||
{m.btn_edit()}
|
||||
</a>
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action="?/deleteUser"
|
||||
use:enhance={({ cancel }) => {
|
||||
if (!confirm(m.admin_user_delete_confirm({ username: user.username }))) {
|
||||
cancel();
|
||||
}
|
||||
return async ({ update }) => {
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
class="flex items-center"
|
||||
>
|
||||
<input type="hidden" name="id" value={user.id} />
|
||||
<button
|
||||
class="p-1 text-ink-3 transition-colors hover:text-red-600"
|
||||
title={m.admin_btn_delete_user_title()}
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -29,6 +29,7 @@ const makeUser = (overrides = {}) => ({
|
||||
const baseData = {
|
||||
user: undefined,
|
||||
canWrite: true,
|
||||
canAnnotate: false,
|
||||
users: [makeUser()],
|
||||
groups: [makeGroup()],
|
||||
tags: []
|
||||
|
||||
@@ -1,47 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { untrack } from 'svelte';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import UserProfileSection from '$lib/components/user/UserProfileSection.svelte';
|
||||
import UserGroupsSection from '$lib/components/user/UserGroupsSection.svelte';
|
||||
import UserPasswordSection from '$lib/components/user/UserPasswordSection.svelte';
|
||||
|
||||
let { data, form } = $props();
|
||||
|
||||
function isoToGerman(iso: string | undefined): string {
|
||||
if (!iso) return '';
|
||||
const match = iso.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||
if (!match) return '';
|
||||
return `${match[3]}.${match[2]}.${match[1]}`;
|
||||
}
|
||||
|
||||
function germanToIso(german: string): string {
|
||||
const match = german.match(/^(\d{2})\.(\d{2})\.(\d{4})$/);
|
||||
if (!match) return '';
|
||||
return `${match[3]}-${match[2]}-${match[1]}`;
|
||||
}
|
||||
|
||||
let birthDateDisplay = $state(untrack(() => isoToGerman(data.editUser?.birthDate)));
|
||||
let birthDateIso = $state(untrack(() => data.editUser?.birthDate ?? ''));
|
||||
|
||||
function handleBirthDateInput(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const digits = input.value.replace(/\D/g, '').slice(0, 8);
|
||||
let formatted: string;
|
||||
if (digits.length <= 2) {
|
||||
formatted = digits;
|
||||
} else if (digits.length <= 4) {
|
||||
formatted = `${digits.slice(0, 2)}.${digits.slice(2)}`;
|
||||
} else {
|
||||
formatted = `${digits.slice(0, 2)}.${digits.slice(2, 4)}.${digits.slice(4)}`;
|
||||
}
|
||||
input.value = formatted;
|
||||
birthDateDisplay = formatted;
|
||||
birthDateIso = germanToIso(formatted);
|
||||
}
|
||||
const selectedGroupIds = $derived(data.editUser.groups?.map((g: { id: string }) => g.id) ?? []);
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-3xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<a
|
||||
href="/admin"
|
||||
class="group mb-4 inline-flex items-center text-xs font-bold tracking-widest text-gray-500 uppercase transition-colors hover:text-brand-navy"
|
||||
class="group mb-4 inline-flex items-center text-xs font-bold tracking-widest text-ink-2 uppercase transition-colors hover:text-ink"
|
||||
>
|
||||
<svg
|
||||
class="mr-2 h-4 w-4 transform transition-transform group-hover:-translate-x-1"
|
||||
@@ -55,7 +27,7 @@ function handleBirthDateInput(e: Event) {
|
||||
{m.btn_back_to_overview()}
|
||||
</a>
|
||||
|
||||
<h1 class="mb-6 font-serif text-3xl font-bold text-brand-navy">
|
||||
<h1 class="mb-6 font-serif text-3xl font-bold text-ink">
|
||||
{m.admin_user_edit_heading({ username: data.editUser.username })}
|
||||
</h1>
|
||||
|
||||
@@ -72,159 +44,48 @@ function handleBirthDateInput(e: Event) {
|
||||
|
||||
<form method="POST" use:enhance class="space-y-6">
|
||||
<!-- Profile card -->
|
||||
<div class="rounded-sm border border-brand-sand bg-white p-6 shadow-sm">
|
||||
<h2 class="mb-5 text-xs font-bold tracking-widest text-gray-400 uppercase">
|
||||
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
|
||||
<h2 class="mb-5 text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{m.profile_section_personal()}
|
||||
</h2>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<label class="block">
|
||||
<span
|
||||
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
|
||||
>
|
||||
{m.profile_label_first_name()}
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
name="firstName"
|
||||
value={data.editUser.firstName ?? ''}
|
||||
class="w-full rounded-sm border border-brand-sand px-3 py-2 font-serif text-sm focus:border-brand-navy focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span
|
||||
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
|
||||
>
|
||||
{m.profile_label_last_name()}
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
name="lastName"
|
||||
value={data.editUser.lastName ?? ''}
|
||||
class="w-full rounded-sm border border-brand-sand px-3 py-2 font-serif text-sm focus:border-brand-navy focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="block">
|
||||
<span
|
||||
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
|
||||
>
|
||||
{m.profile_label_birth_date()}
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="TT.MM.JJJJ"
|
||||
value={birthDateDisplay}
|
||||
oninput={handleBirthDateInput}
|
||||
class="w-full rounded-sm border border-brand-sand px-3 py-2 font-serif text-sm focus:border-brand-navy focus:outline-none"
|
||||
/>
|
||||
<input type="hidden" name="birthDate" value={birthDateIso} />
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span
|
||||
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
|
||||
>
|
||||
{m.profile_label_email()}
|
||||
</span>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
value={data.editUser.email ?? ''}
|
||||
class="w-full rounded-sm border border-brand-sand px-3 py-2 font-serif text-sm focus:border-brand-navy focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span
|
||||
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
|
||||
>
|
||||
{m.profile_label_contact()}
|
||||
</span>
|
||||
<textarea
|
||||
name="contact"
|
||||
rows="3"
|
||||
placeholder={m.profile_contact_placeholder()}
|
||||
class="w-full rounded-sm border border-brand-sand px-3 py-2 font-serif text-sm focus:border-brand-navy focus:outline-none"
|
||||
>{data.editUser.contact ?? ''}</textarea
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
<UserProfileSection
|
||||
firstName={data.editUser.firstName ?? ''}
|
||||
lastName={data.editUser.lastName ?? ''}
|
||||
birthDate={data.editUser.birthDate ?? ''}
|
||||
email={data.editUser.email ?? ''}
|
||||
contact={data.editUser.contact ?? ''}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Groups card -->
|
||||
<div class="rounded-sm border border-brand-sand bg-white p-6 shadow-sm">
|
||||
<h2 class="mb-5 text-xs font-bold tracking-widest text-gray-400 uppercase">
|
||||
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
|
||||
<h2 class="mb-5 text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{m.admin_col_groups()}
|
||||
</h2>
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
{#each data.groups as group (group.id)}
|
||||
<label class="inline-flex items-center gap-2 text-sm text-gray-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="groupIds"
|
||||
value={group.id}
|
||||
checked={data.editUser.groups?.some((g: { id: string }) => g.id === group.id)}
|
||||
class="rounded border-gray-300 text-brand-navy focus:ring-brand-mint"
|
||||
/>
|
||||
{group.name}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
<UserGroupsSection groups={data.groups} selectedGroupIds={selectedGroupIds} />
|
||||
</div>
|
||||
|
||||
<!-- Password card -->
|
||||
<div class="rounded-sm border border-brand-sand bg-white p-6 shadow-sm">
|
||||
<h2 class="mb-5 text-xs font-bold tracking-widest text-gray-400 uppercase">
|
||||
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
|
||||
<h2 class="mb-5 text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{m.admin_label_new_password_optional()}
|
||||
</h2>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<label class="block">
|
||||
<span
|
||||
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
|
||||
>
|
||||
{m.profile_label_new_password()}
|
||||
</span>
|
||||
<input
|
||||
type="password"
|
||||
name="newPassword"
|
||||
class="w-full rounded-sm border border-brand-sand px-3 py-2 font-serif text-sm focus:border-brand-navy focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span
|
||||
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
|
||||
>
|
||||
{m.profile_label_new_password_confirm()}
|
||||
</span>
|
||||
<input
|
||||
type="password"
|
||||
name="confirmPassword"
|
||||
class="w-full rounded-sm border border-brand-sand px-3 py-2 font-serif text-sm focus:border-brand-navy focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<UserPasswordSection />
|
||||
</div>
|
||||
|
||||
<!-- Save bar -->
|
||||
<div
|
||||
class="sticky bottom-0 z-10 -mx-4 flex items-center justify-between border-t border-brand-sand bg-white px-6 py-4 shadow-[0_-2px_8px_rgba(0,0,0,0.06)]"
|
||||
class="sticky bottom-0 z-10 -mx-4 flex items-center justify-between border-t border-line bg-surface px-6 py-4 shadow-[0_-2px_8px_rgba(0,0,0,0.06)]"
|
||||
>
|
||||
<a
|
||||
href="/admin"
|
||||
class="font-sans text-xs font-bold tracking-widest text-gray-500 uppercase hover:text-brand-navy"
|
||||
class="font-sans text-xs font-bold tracking-widest text-ink-2 uppercase hover:text-ink"
|
||||
>
|
||||
{m.btn_cancel()}
|
||||
</a>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-sm bg-brand-navy px-5 py-2 font-sans text-xs font-bold tracking-widest text-white uppercase transition-opacity hover:opacity-80"
|
||||
class="rounded-sm bg-primary px-5 py-2 font-sans text-xs font-bold tracking-widest text-white uppercase transition-opacity hover:opacity-80"
|
||||
>
|
||||
{m.btn_save()}
|
||||
</button>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user