Compare commits
65 Commits
7007491d8c
...
feat/issue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50621f9a15 | ||
|
|
1fca1f80a2 | ||
|
|
46dae8a826 | ||
|
|
e5fe2fc5c6 | ||
|
|
0ab85d888b | ||
|
|
48c82aa07b | ||
|
|
1299f191e2 | ||
|
|
9aed929b67 | ||
|
|
cb9962f0c2 | ||
|
|
262c792654 | ||
|
|
60f1db1f99 | ||
|
|
8cf4f7c2e4 | ||
|
|
6b10daeeac | ||
|
|
74b473e3d7 | ||
|
|
f1b3e8c2d8 | ||
|
|
c78a1d69dc | ||
|
|
5131c8da31 | ||
|
|
eb106c9ca7 | ||
|
|
e742c36ef6 | ||
|
|
9ac01f7cc2 | ||
|
|
a2a7d547ee | ||
|
|
3c99030546 | ||
|
|
f75a960179 | ||
|
|
811baf78da | ||
|
|
43122c20cb | ||
|
|
f90d4b282e | ||
|
|
1eb833f333 | ||
|
|
b2264de949 | ||
|
|
dd6331c098 | ||
|
|
9d687ba9f9 | ||
|
|
1ea95f8fe0 | ||
|
|
65846911f3 | ||
|
|
75dd8cb08d | ||
|
|
db6a3225db | ||
|
|
8b05451f42 | ||
|
|
aa9c47ecc8 | ||
|
|
0e6efc9170 | ||
|
|
64dbce2a00 | ||
|
|
a1f9253712 | ||
|
|
3a6a70a1f7 | ||
|
|
edd96b05fe | ||
|
|
6d5fb9d8c8 | ||
|
|
1f1b7aeab5 | ||
|
|
22bba5cfcd | ||
|
|
4248d8af72 | ||
|
|
f86105a1be | ||
|
|
ae445a78ae | ||
|
|
c3fac5b0ad | ||
|
|
03b180fe88 | ||
|
|
b234db0472 | ||
|
|
7c3a8e7651 | ||
|
|
7fb9d74515 | ||
|
|
dff203d526 | ||
|
|
86584a53a8 | ||
|
|
1d5219eac4 | ||
|
|
6e021fb23a | ||
|
|
bdac5e42ad | ||
|
|
18b88672ec | ||
|
|
8fa061187e | ||
|
|
610915b2a2 | ||
|
|
78ac5d663d | ||
|
|
826c0827dc | ||
|
|
7a75ffed76 | ||
|
|
1299bd5938 | ||
|
|
8f28a99e00 |
@@ -13,6 +13,12 @@ import java.util.UUID;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO;
|
||||
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
|
||||
import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
|
||||
import org.raddatz.familienarchiv.dto.TagOperator;
|
||||
@@ -62,6 +68,7 @@ import lombok.extern.slf4j.Slf4j;
|
||||
@RequestMapping("/api/documents")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
@Validated
|
||||
public class DocumentController {
|
||||
|
||||
private final DocumentService documentService;
|
||||
@@ -187,6 +194,7 @@ public class DocumentController {
|
||||
@RequirePermission(Permission.WRITE_ALL)
|
||||
public QuickUploadResult quickUpload(
|
||||
@RequestPart(value = "files", required = false) List<MultipartFile> files,
|
||||
@RequestPart(value = "metadata", required = false) DocumentBatchMetadataDTO metadata,
|
||||
Authentication authentication) {
|
||||
List<Document> created = new ArrayList<>();
|
||||
List<Document> updated = new ArrayList<>();
|
||||
@@ -196,14 +204,21 @@ public class DocumentController {
|
||||
return new QuickUploadResult(created, updated, errors);
|
||||
}
|
||||
|
||||
documentService.validateBatch(files.size(), metadata);
|
||||
|
||||
UUID actorId = requireUserId(authentication);
|
||||
for (MultipartFile file : files) {
|
||||
long totalBytes = files.stream().mapToLong(MultipartFile::getSize).sum();
|
||||
|
||||
for (int i = 0; i < files.size(); i++) {
|
||||
MultipartFile file = files.get(i);
|
||||
if (!ALLOWED_CONTENT_TYPES.contains(file.getContentType())) {
|
||||
errors.add(new UploadError(file.getOriginalFilename(), "UNSUPPORTED_FILE_TYPE"));
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
DocumentService.StoreResult result = documentService.storeDocument(file, actorId);
|
||||
DocumentService.StoreResult result = metadata != null
|
||||
? documentService.storeDocumentWithBatchMetadata(file, metadata, i, actorId)
|
||||
: documentService.storeDocument(file, actorId);
|
||||
if (result.isNew()) {
|
||||
created.add(result.document());
|
||||
} else {
|
||||
@@ -215,6 +230,10 @@ public class DocumentController {
|
||||
}
|
||||
}
|
||||
|
||||
log.info("quickUpload actor={} files={} totalBytes={} withMetadata={} created={} updated={} errors={}",
|
||||
actorId, files.size(), totalBytes, metadata != null,
|
||||
created.size(), updated.size(), errors.size());
|
||||
|
||||
return new QuickUploadResult(created, updated, errors);
|
||||
}
|
||||
|
||||
@@ -252,14 +271,20 @@ public class DocumentController {
|
||||
@Parameter(description = "Filter by document status") @RequestParam(required = false) DocumentStatus status,
|
||||
@Parameter(description = "Sort field") @RequestParam(required = false) DocumentSort sort,
|
||||
@Parameter(description = "Sort direction: ASC or DESC") @RequestParam(required = false, defaultValue = "DESC") String dir,
|
||||
@Parameter(description = "Tag operator: AND (default) or OR") @RequestParam(required = false) String tagOp) {
|
||||
@Parameter(description = "Tag operator: AND (default) or OR") @RequestParam(required = false) String tagOp,
|
||||
// @Max on page guards against overflow when pageable.getOffset() is computed
|
||||
// as page * size — Integer.MAX_VALUE * 50 would wrap to a negative long, which
|
||||
// Hibernate cheerfully turns into an invalid SQL OFFSET.
|
||||
@Parameter(description = "Page number (0-indexed)") @RequestParam(defaultValue = "0") @Min(0) @Max(100_000) int page,
|
||||
@Parameter(description = "Page size (max 100)") @RequestParam(defaultValue = "50") @Min(1) @Max(100) int size) {
|
||||
if (!"ASC".equalsIgnoreCase(dir) && !"DESC".equalsIgnoreCase(dir)) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "dir must be ASC or DESC");
|
||||
}
|
||||
// tagOp is a raw String at the HTTP boundary; any value other than "OR" (case-insensitive)
|
||||
// defaults to AND, which matches the frontend default and keeps old clients working.
|
||||
TagOperator operator = "OR".equalsIgnoreCase(tagOp) ? TagOperator.OR : TagOperator.AND;
|
||||
return ResponseEntity.ok(documentService.searchDocuments(q, from, to, senderId, receiverId, tags, tagQ, status, sort, dir, operator));
|
||||
Pageable pageable = PageRequest.of(page, size);
|
||||
return ResponseEntity.ok(documentService.searchDocuments(q, from, to, senderId, receiverId, tags, tagQ, status, sort, dir, operator, pageable));
|
||||
}
|
||||
|
||||
// --- TRAINING LABELS ---
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package org.raddatz.familienarchiv.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@Data
|
||||
public class DocumentBatchMetadataDTO {
|
||||
private List<String> titles;
|
||||
private UUID senderId;
|
||||
private List<UUID> receiverIds;
|
||||
private LocalDate documentDate;
|
||||
private String location;
|
||||
private List<String> tagNames;
|
||||
private Boolean metadataComplete;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.raddatz.familienarchiv.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@@ -8,9 +9,30 @@ public record DocumentSearchResult(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
List<DocumentSearchItem> items,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
long total
|
||||
long totalElements,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
int pageNumber,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
int pageSize,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
int totalPages
|
||||
) {
|
||||
/**
|
||||
* Single-page convenience factory used by empty-result shortcuts and by tests that
|
||||
* don't care about paging. Treats the whole list as page 0 of itself.
|
||||
*/
|
||||
public static DocumentSearchResult of(List<DocumentSearchItem> items) {
|
||||
return new DocumentSearchResult(items, items.size());
|
||||
int size = items.size();
|
||||
return new DocumentSearchResult(items, size, 0, size, size == 0 ? 0 : 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Paged factory used by the service when it has a real Pageable + full match count
|
||||
* (e.g. from Spring's Page<T> or from an in-memory sort-then-slice).
|
||||
*/
|
||||
public static DocumentSearchResult paged(List<DocumentSearchItem> slice, Pageable pageable, long totalElements) {
|
||||
int pageSize = pageable.getPageSize();
|
||||
int totalPages = pageSize == 0 ? 0 : (int) ((totalElements + pageSize - 1) / pageSize);
|
||||
return new DocumentSearchResult(slice, totalElements, pageable.getPageNumber(), pageSize, totalPages);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,6 +109,8 @@ public enum ErrorCode {
|
||||
// --- Generic ---
|
||||
/** Request validation failed (missing or malformed fields). 400 */
|
||||
VALIDATION_ERROR,
|
||||
/** Batch upload exceeds the maximum allowed file count per request. 400 */
|
||||
BATCH_TOO_LARGE,
|
||||
/** An unexpected server-side error occurred. 500 */
|
||||
INTERNAL_ERROR,
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import org.raddatz.familienarchiv.audit.ActivityActorDTO;
|
||||
import org.raddatz.familienarchiv.audit.AuditKind;
|
||||
import org.raddatz.familienarchiv.audit.AuditLogQueryService;
|
||||
import org.raddatz.familienarchiv.audit.AuditService;
|
||||
import org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO;
|
||||
import org.raddatz.familienarchiv.dto.DocumentSearchItem;
|
||||
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
|
||||
import org.raddatz.familienarchiv.dto.DocumentSort;
|
||||
@@ -22,7 +23,9 @@ import org.raddatz.familienarchiv.model.TrainingLabel;
|
||||
import org.raddatz.familienarchiv.model.Person;
|
||||
import org.raddatz.familienarchiv.model.Tag;
|
||||
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
@@ -130,6 +133,52 @@ public class DocumentService {
|
||||
return new StoreResult(saved, isNew);
|
||||
}
|
||||
|
||||
public void validateBatch(int fileCount, DocumentBatchMetadataDTO metadata) {
|
||||
// 50-file hard cap keeps FormData requests at a manageable size and protects against runaway bulk uploads.
|
||||
if (fileCount > 50) {
|
||||
throw DomainException.badRequest(ErrorCode.BATCH_TOO_LARGE, "Batch exceeds maximum of 50 files per request");
|
||||
}
|
||||
if (metadata != null && metadata.getTitles() != null && metadata.getTitles().size() > fileCount) {
|
||||
throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR, "titles count must not exceed files count");
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public StoreResult storeDocumentWithBatchMetadata(
|
||||
MultipartFile file, DocumentBatchMetadataDTO metadata, int fileIndex, UUID actorId) throws IOException {
|
||||
StoreResult base = storeDocument(file, actorId);
|
||||
Document doc = applyBatchMetadata(base.document(), metadata, fileIndex);
|
||||
return new StoreResult(documentRepository.save(doc), base.isNew());
|
||||
}
|
||||
|
||||
private Document applyBatchMetadata(Document doc, DocumentBatchMetadataDTO metadata, int fileIndex) {
|
||||
if (metadata.getTitles() != null && fileIndex < metadata.getTitles().size()) {
|
||||
doc.setTitle(metadata.getTitles().get(fileIndex));
|
||||
}
|
||||
if (metadata.getSenderId() != null) {
|
||||
doc.setSender(personService.getById(metadata.getSenderId()));
|
||||
}
|
||||
if (metadata.getReceiverIds() != null && !metadata.getReceiverIds().isEmpty()) {
|
||||
doc.setReceivers(new HashSet<>(personService.getAllById(metadata.getReceiverIds())));
|
||||
}
|
||||
if (metadata.getDocumentDate() != null) {
|
||||
doc.setDocumentDate(metadata.getDocumentDate());
|
||||
}
|
||||
if (metadata.getLocation() != null) {
|
||||
doc.setLocation(metadata.getLocation());
|
||||
}
|
||||
if (metadata.getMetadataComplete() != null) {
|
||||
doc.setMetadataComplete(metadata.getMetadataComplete());
|
||||
}
|
||||
if (metadata.getTagNames() != null && !metadata.getTagNames().isEmpty()) {
|
||||
UUID docId = doc.getId();
|
||||
updateDocumentTags(docId, metadata.getTagNames());
|
||||
doc = documentRepository.findById(docId)
|
||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Not found after batch metadata: " + docId));
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Document createDocument(DocumentUpdateDTO dto, MultipartFile file) throws IOException {
|
||||
String filename = (file != null && !file.isEmpty())
|
||||
@@ -355,7 +404,7 @@ public class DocumentService {
|
||||
}
|
||||
|
||||
// 1. Allgemeine Suche (für das Suchfeld im Frontend)
|
||||
public DocumentSearchResult searchDocuments(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver, List<String> tags, String tagQ, DocumentStatus status, DocumentSort sort, String dir, TagOperator tagOperator) {
|
||||
public DocumentSearchResult searchDocuments(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver, List<String> tags, String tagQ, DocumentStatus status, DocumentSort sort, String dir, TagOperator tagOperator, Pageable pageable) {
|
||||
boolean hasText = StringUtils.hasText(text);
|
||||
List<UUID> rankedIds = null;
|
||||
|
||||
@@ -376,15 +425,18 @@ public class DocumentService {
|
||||
.and(hasTagPartial(tagQ))
|
||||
.and(hasStatus(status));
|
||||
|
||||
// SENDER and RECEIVER are sorted in-memory because JPA's Sort.by("sender.lastName")
|
||||
// generates an INNER JOIN that silently drops documents with null sender/receivers.
|
||||
// SENDER, RECEIVER and RELEVANCE sorts load the full match set and slice in memory.
|
||||
// JPA's Sort.by("sender.lastName") generates an INNER JOIN that silently drops
|
||||
// documents with null sender/receivers; RELEVANCE maps a DB order to an external
|
||||
// rank list. Cost scales linearly with match count — acceptable while documents
|
||||
// stays under ~10k rows. Past that, replace with SQL-level LEFT JOIN sort.
|
||||
if (sort == DocumentSort.RECEIVER) {
|
||||
List<Document> results = documentRepository.findAll(spec);
|
||||
return buildResult(sortByFirstReceiver(results, dir), text);
|
||||
List<Document> sorted = sortByFirstReceiver(documentRepository.findAll(spec), dir);
|
||||
return buildResultPaged(pageSlice(sorted, pageable), text, pageable, sorted.size());
|
||||
}
|
||||
if (sort == DocumentSort.SENDER) {
|
||||
List<Document> results = documentRepository.findAll(spec);
|
||||
return buildResult(sortBySender(results, dir), text);
|
||||
List<Document> sorted = sortBySender(documentRepository.findAll(spec), dir);
|
||||
return buildResultPaged(pageSlice(sorted, pageable), text, pageable, sorted.size());
|
||||
}
|
||||
|
||||
// RELEVANCE: default when text present and no explicit sort given
|
||||
@@ -397,15 +449,26 @@ public class DocumentService {
|
||||
.sorted(Comparator.comparingInt(
|
||||
doc -> rankMap.getOrDefault(doc.getId(), Integer.MAX_VALUE)))
|
||||
.toList();
|
||||
return buildResult(sorted, text);
|
||||
return buildResultPaged(pageSlice(sorted, pageable), text, pageable, sorted.size());
|
||||
}
|
||||
|
||||
Sort springSort = resolveSort(sort, dir);
|
||||
List<Document> results = documentRepository.findAll(spec, springSort);
|
||||
return buildResult(results, text);
|
||||
// Fast path — push sort + paging into the DB and enrich only the returned slice.
|
||||
PageRequest pageRequest = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), resolveSort(sort, dir));
|
||||
Page<Document> page = documentRepository.findAll(spec, pageRequest);
|
||||
return buildResultPaged(page.getContent(), text, pageable, page.getTotalElements());
|
||||
}
|
||||
|
||||
private DocumentSearchResult buildResult(List<Document> documents, String text) {
|
||||
private static <T> List<T> pageSlice(List<T> sorted, Pageable pageable) {
|
||||
int from = Math.min((int) pageable.getOffset(), sorted.size());
|
||||
int to = Math.min(from + pageable.getPageSize(), sorted.size());
|
||||
return sorted.subList(from, to);
|
||||
}
|
||||
|
||||
private DocumentSearchResult buildResultPaged(List<Document> slice, String text, Pageable pageable, long totalElements) {
|
||||
return DocumentSearchResult.paged(enrichItems(slice, text), pageable, totalElements);
|
||||
}
|
||||
|
||||
private List<DocumentSearchItem> enrichItems(List<Document> documents, String text) {
|
||||
List<Document> colorResolved = resolveDocumentTagColors(documents);
|
||||
Map<UUID, SearchMatchData> matchData = enrichWithMatchData(colorResolved, text);
|
||||
|
||||
@@ -413,14 +476,12 @@ public class DocumentService {
|
||||
Map<UUID, Integer> completionByDoc = fetchCompletionPercentages(docIds);
|
||||
Map<UUID, List<ActivityActorDTO>> contributorsByDoc = auditLogQueryService.findRecentContributorsPerDocument(docIds);
|
||||
|
||||
List<DocumentSearchItem> items = colorResolved.stream().map(doc -> new DocumentSearchItem(
|
||||
return colorResolved.stream().map(doc -> new DocumentSearchItem(
|
||||
doc,
|
||||
matchData.getOrDefault(doc.getId(), SearchMatchData.empty()),
|
||||
completionByDoc.getOrDefault(doc.getId(), 0),
|
||||
contributorsByDoc.getOrDefault(doc.getId(), List.of())
|
||||
)).toList();
|
||||
|
||||
return DocumentSearchResult.of(items);
|
||||
}
|
||||
|
||||
private Map<UUID, Integer> fetchCompletionPercentages(List<UUID> docIds) {
|
||||
|
||||
@@ -23,7 +23,8 @@ spring:
|
||||
servlet:
|
||||
multipart:
|
||||
max-file-size: 50MB
|
||||
max-request-size: 50MB
|
||||
max-request-size: 500MB # supports 10-file chunk at max per-file size; see #317
|
||||
file-size-threshold: 2KB
|
||||
|
||||
mail:
|
||||
host: ${MAIL_HOST:}
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
package org.raddatz.familienarchiv.controller;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO;
|
||||
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
|
||||
import org.raddatz.familienarchiv.dto.DocumentVersionSummary;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.model.AppUser;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||
import org.raddatz.familienarchiv.model.DocumentVersion;
|
||||
import org.raddatz.familienarchiv.model.Person;
|
||||
import org.raddatz.familienarchiv.security.PermissionAspect;
|
||||
import org.raddatz.familienarchiv.model.AppUser;
|
||||
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
|
||||
import org.raddatz.familienarchiv.service.DocumentService;
|
||||
import org.raddatz.familienarchiv.service.DocumentVersionService;
|
||||
@@ -69,7 +71,7 @@ class DocumentControllerTest {
|
||||
@Test
|
||||
@WithMockUser
|
||||
void search_returns200_whenAuthenticated() throws Exception {
|
||||
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
|
||||
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
|
||||
.thenReturn(DocumentSearchResult.of(List.of()));
|
||||
|
||||
mockMvc.perform(get("/api/documents/search"))
|
||||
@@ -79,13 +81,13 @@ class DocumentControllerTest {
|
||||
@Test
|
||||
@WithMockUser
|
||||
void search_withStatusParam_passesItToService() throws Exception {
|
||||
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), eq(DocumentStatus.REVIEWED), any(), any(), any()))
|
||||
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), eq(DocumentStatus.REVIEWED), any(), any(), any(), any()))
|
||||
.thenReturn(DocumentSearchResult.of(List.of()));
|
||||
|
||||
mockMvc.perform(get("/api/documents/search").param("status", "REVIEWED"))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
verify(documentService).searchDocuments(any(), any(), any(), any(), any(), any(), any(), eq(DocumentStatus.REVIEWED), any(), any(), any());
|
||||
verify(documentService).searchDocuments(any(), any(), any(), any(), any(), any(), any(), eq(DocumentStatus.REVIEWED), any(), any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -112,12 +114,12 @@ class DocumentControllerTest {
|
||||
@Test
|
||||
@WithMockUser
|
||||
void search_responseContainsTotalCount() throws Exception {
|
||||
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
|
||||
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
|
||||
.thenReturn(DocumentSearchResult.of(List.of()));
|
||||
|
||||
mockMvc.perform(get("/api/documents/search"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.total").value(0))
|
||||
.andExpect(jsonPath("$.totalElements").value(0))
|
||||
.andExpect(jsonPath("$.items").isArray());
|
||||
}
|
||||
|
||||
@@ -133,7 +135,7 @@ class DocumentControllerTest {
|
||||
.build();
|
||||
var matchData = new SearchMatchData(
|
||||
"Er schrieb einen langen Brief", List.of(), false, List.of(), List.of(), List.of(), null, List.of());
|
||||
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
|
||||
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
|
||||
.thenReturn(DocumentSearchResult.of(List.of(new DocumentSearchItem(doc, matchData, 0, List.of()))));
|
||||
|
||||
mockMvc.perform(get("/api/documents/search").param("q", "Brief"))
|
||||
@@ -143,6 +145,70 @@ class DocumentControllerTest {
|
||||
.value("Er schrieb einen langen Brief"));
|
||||
}
|
||||
|
||||
// ─── /api/documents/search pagination ─────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void search_responseExposesPagingFields() throws Exception {
|
||||
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
|
||||
.thenReturn(DocumentSearchResult.of(List.of()));
|
||||
|
||||
mockMvc.perform(get("/api/documents/search"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.pageNumber").exists())
|
||||
.andExpect(jsonPath("$.pageSize").exists())
|
||||
.andExpect(jsonPath("$.totalPages").exists())
|
||||
.andExpect(jsonPath("$.totalElements").exists());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void search_returns400_whenSizeExceedsMax() throws Exception {
|
||||
// Locks @Validated on the controller — removing it silently reopens the
|
||||
// DoS window where a client could request all 1500 docs + enrichment.
|
||||
mockMvc.perform(get("/api/documents/search").param("size", "101"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void search_returns400_whenSizeBelowMin() throws Exception {
|
||||
mockMvc.perform(get("/api/documents/search").param("size", "0"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void search_returns400_whenPageNegative() throws Exception {
|
||||
mockMvc.perform(get("/api/documents/search").param("page", "-1"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void search_returns400_whenPageAboveMax() throws Exception {
|
||||
// Guards against page * size overflow into negative SQL OFFSET
|
||||
mockMvc.perform(get("/api/documents/search").param("page", "200000"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void search_passesPageRequestToService() throws Exception {
|
||||
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
|
||||
.thenReturn(DocumentSearchResult.of(List.of()));
|
||||
|
||||
mockMvc.perform(get("/api/documents/search").param("page", "2").param("size", "25"))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
org.mockito.ArgumentCaptor<org.springframework.data.domain.Pageable> captor =
|
||||
org.mockito.ArgumentCaptor.forClass(org.springframework.data.domain.Pageable.class);
|
||||
verify(documentService).searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), captor.capture());
|
||||
org.springframework.data.domain.Pageable pageable = captor.getValue();
|
||||
org.assertj.core.api.Assertions.assertThat(pageable.getPageNumber()).isEqualTo(2);
|
||||
org.assertj.core.api.Assertions.assertThat(pageable.getPageSize()).isEqualTo(25);
|
||||
}
|
||||
|
||||
// ─── POST /api/documents ─────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@@ -702,4 +768,165 @@ class DocumentControllerTest {
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.editorName").value("Otto"));
|
||||
}
|
||||
|
||||
// ─── POST /api/documents/quick-upload — metadata part ────────────────────
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void quickUpload_withMetadata_appliesSharedFieldsToAllCreatedDocuments() throws Exception {
|
||||
UUID senderId = UUID.randomUUID();
|
||||
Person sender = Person.builder().id(senderId).lastName("Müller").build();
|
||||
|
||||
Document doc1 = Document.builder().id(UUID.randomUUID()).title("Brief 1").originalFilename("a.pdf").sender(sender).build();
|
||||
Document doc2 = Document.builder().id(UUID.randomUUID()).title("Brief 2").originalFilename("b.pdf").sender(sender).build();
|
||||
Document doc3 = Document.builder().id(UUID.randomUUID()).title("Brief 3").originalFilename("c.pdf").sender(sender).build();
|
||||
|
||||
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||
when(documentService.storeDocumentWithBatchMetadata(any(), any(), eq(0), any()))
|
||||
.thenReturn(new DocumentService.StoreResult(doc1, true));
|
||||
when(documentService.storeDocumentWithBatchMetadata(any(), any(), eq(1), any()))
|
||||
.thenReturn(new DocumentService.StoreResult(doc2, true));
|
||||
when(documentService.storeDocumentWithBatchMetadata(any(), any(), eq(2), any()))
|
||||
.thenReturn(new DocumentService.StoreResult(doc3, true));
|
||||
|
||||
org.springframework.mock.web.MockMultipartFile f1 =
|
||||
new org.springframework.mock.web.MockMultipartFile("files", "a.pdf", "application/pdf", new byte[]{1});
|
||||
org.springframework.mock.web.MockMultipartFile f2 =
|
||||
new org.springframework.mock.web.MockMultipartFile("files", "b.pdf", "application/pdf", new byte[]{1});
|
||||
org.springframework.mock.web.MockMultipartFile f3 =
|
||||
new org.springframework.mock.web.MockMultipartFile("files", "c.pdf", "application/pdf", new byte[]{1});
|
||||
org.springframework.mock.web.MockMultipartFile metadata =
|
||||
new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json",
|
||||
("{\"senderId\":\"" + senderId + "\"}").getBytes());
|
||||
|
||||
mockMvc.perform(multipart("/api/documents/quick-upload").file(f1).file(f2).file(f3).file(metadata))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.created.length()").value(3))
|
||||
.andExpect(jsonPath("$.created[0].sender.id").value(senderId.toString()))
|
||||
.andExpect(jsonPath("$.created[1].sender.id").value(senderId.toString()))
|
||||
.andExpect(jsonPath("$.created[2].sender.id").value(senderId.toString()))
|
||||
.andExpect(jsonPath("$.updated").isEmpty())
|
||||
.andExpect(jsonPath("$.errors").isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void quickUpload_withMetadata_appliesSharedFieldsToUpdatedDocuments() throws Exception {
|
||||
UUID senderId = UUID.randomUUID();
|
||||
Person sender = Person.builder().id(senderId).lastName("Müller").build();
|
||||
Document existing = Document.builder().id(UUID.randomUUID()).title("Alt").originalFilename("alt.pdf").sender(sender).build();
|
||||
|
||||
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||
when(documentService.storeDocumentWithBatchMetadata(any(), any(), eq(0), any()))
|
||||
.thenReturn(new DocumentService.StoreResult(existing, false));
|
||||
|
||||
org.springframework.mock.web.MockMultipartFile file =
|
||||
new org.springframework.mock.web.MockMultipartFile("files", "alt.pdf", "application/pdf", new byte[]{1});
|
||||
org.springframework.mock.web.MockMultipartFile metadata =
|
||||
new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json",
|
||||
("{\"senderId\":\"" + senderId + "\"}").getBytes());
|
||||
|
||||
mockMvc.perform(multipart("/api/documents/quick-upload").file(file).file(metadata))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.created").isEmpty())
|
||||
.andExpect(jsonPath("$.updated[0].sender.id").value(senderId.toString()))
|
||||
.andExpect(jsonPath("$.errors").isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void quickUpload_withMetadata_mapsTitlesByIndex() throws Exception {
|
||||
Document docA = Document.builder().id(UUID.randomUUID()).title("Alpha").originalFilename("a.pdf").build();
|
||||
Document docB = Document.builder().id(UUID.randomUUID()).title("Beta").originalFilename("b.pdf").build();
|
||||
Document docC = Document.builder().id(UUID.randomUUID()).title("Gamma").originalFilename("c.pdf").build();
|
||||
|
||||
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||
when(documentService.storeDocumentWithBatchMetadata(any(), any(), eq(0), any()))
|
||||
.thenReturn(new DocumentService.StoreResult(docA, true));
|
||||
when(documentService.storeDocumentWithBatchMetadata(any(), any(), eq(1), any()))
|
||||
.thenReturn(new DocumentService.StoreResult(docB, true));
|
||||
when(documentService.storeDocumentWithBatchMetadata(any(), any(), eq(2), any()))
|
||||
.thenReturn(new DocumentService.StoreResult(docC, true));
|
||||
|
||||
org.springframework.mock.web.MockMultipartFile f1 =
|
||||
new org.springframework.mock.web.MockMultipartFile("files", "a.pdf", "application/pdf", new byte[]{1});
|
||||
org.springframework.mock.web.MockMultipartFile f2 =
|
||||
new org.springframework.mock.web.MockMultipartFile("files", "b.pdf", "application/pdf", new byte[]{1});
|
||||
org.springframework.mock.web.MockMultipartFile f3 =
|
||||
new org.springframework.mock.web.MockMultipartFile("files", "c.pdf", "application/pdf", new byte[]{1});
|
||||
org.springframework.mock.web.MockMultipartFile metadata =
|
||||
new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json",
|
||||
"{\"titles\":[\"Alpha\",\"Beta\",\"Gamma\"]}".getBytes());
|
||||
|
||||
mockMvc.perform(multipart("/api/documents/quick-upload").file(f1).file(f2).file(f3).file(metadata))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.created[0].title").value("Alpha"))
|
||||
.andExpect(jsonPath("$.created[1].title").value("Beta"))
|
||||
.andExpect(jsonPath("$.created[2].title").value("Gamma"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void quickUpload_withMetadata_rejects400_whenTitlesSizeExceedsFilesSize() throws Exception {
|
||||
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||
org.mockito.Mockito.doThrow(
|
||||
org.raddatz.familienarchiv.exception.DomainException.badRequest(
|
||||
org.raddatz.familienarchiv.exception.ErrorCode.VALIDATION_ERROR, "titles count must not exceed files count"))
|
||||
.when(documentService).validateBatch(eq(2), any());
|
||||
|
||||
org.springframework.mock.web.MockMultipartFile f1 =
|
||||
new org.springframework.mock.web.MockMultipartFile("files", "a.pdf", "application/pdf", new byte[]{1});
|
||||
org.springframework.mock.web.MockMultipartFile f2 =
|
||||
new org.springframework.mock.web.MockMultipartFile("files", "b.pdf", "application/pdf", new byte[]{1});
|
||||
org.springframework.mock.web.MockMultipartFile metadata =
|
||||
new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json",
|
||||
"{\"titles\":[\"A\",\"B\",\"C\"]}".getBytes());
|
||||
|
||||
mockMvc.perform(multipart("/api/documents/quick-upload").file(f1).file(f2).file(metadata))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void quickUpload_withMetadata_tagNamesJsonArray_parsedCorrectly() throws Exception {
|
||||
Document doc = Document.builder().id(UUID.randomUUID()).title("brief").originalFilename("brief.pdf").build();
|
||||
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||
|
||||
org.mockito.ArgumentCaptor<DocumentBatchMetadataDTO> captor =
|
||||
org.mockito.ArgumentCaptor.forClass(DocumentBatchMetadataDTO.class);
|
||||
when(documentService.storeDocumentWithBatchMetadata(any(), captor.capture(), anyInt(), any()))
|
||||
.thenReturn(new DocumentService.StoreResult(doc, true));
|
||||
|
||||
org.springframework.mock.web.MockMultipartFile file =
|
||||
new org.springframework.mock.web.MockMultipartFile("files", "brief.pdf", "application/pdf", new byte[]{1});
|
||||
org.springframework.mock.web.MockMultipartFile metadata =
|
||||
new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json",
|
||||
"{\"tagNames\":[\"Briefwechsel\",\"Krieg\"]}".getBytes());
|
||||
|
||||
mockMvc.perform(multipart("/api/documents/quick-upload").file(file).file(metadata))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
org.assertj.core.api.Assertions.assertThat(captor.getValue().getTagNames())
|
||||
.containsExactly("Briefwechsel", "Krieg");
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void quickUpload_returns400_whenBatchExceedsCap() throws Exception {
|
||||
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||
org.mockito.Mockito.doThrow(
|
||||
org.raddatz.familienarchiv.exception.DomainException.badRequest(
|
||||
org.raddatz.familienarchiv.exception.ErrorCode.BATCH_TOO_LARGE, "Batch exceeds maximum of 50 files per request"))
|
||||
.when(documentService).validateBatch(eq(51), any());
|
||||
|
||||
var builder = multipart("/api/documents/quick-upload");
|
||||
for (int i = 0; i < 51; i++) {
|
||||
builder.file(new org.springframework.mock.web.MockMultipartFile(
|
||||
"files", "f" + i + ".pdf", "application/pdf", new byte[]{1}));
|
||||
}
|
||||
|
||||
mockMvc.perform(builder)
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value("BATCH_TOO_LARGE"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.audit.ActivityActorDTO;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
@@ -24,10 +25,43 @@ class DocumentSearchResultTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void of_total_equals_list_size() {
|
||||
DocumentSearchResult result = DocumentSearchResult.of(List.of(item(UUID.randomUUID()), item(UUID.randomUUID())));
|
||||
void of_totalElements_equals_list_size_for_unpaged_shortcut() {
|
||||
DocumentSearchResult result = DocumentSearchResult.of(
|
||||
List.of(item(UUID.randomUUID()), item(UUID.randomUUID())));
|
||||
|
||||
assertThat(result.total()).isEqualTo(2L);
|
||||
assertThat(result.totalElements()).isEqualTo(2L);
|
||||
assertThat(result.pageNumber()).isZero();
|
||||
assertThat(result.pageSize()).isEqualTo(2);
|
||||
assertThat(result.totalPages()).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void of_empty_shortcut_has_zero_totalPages() {
|
||||
DocumentSearchResult result = DocumentSearchResult.of(List.of());
|
||||
|
||||
assertThat(result.totalElements()).isZero();
|
||||
assertThat(result.totalPages()).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void paged_factory_populates_paging_fields_from_pageable_and_total() {
|
||||
List<DocumentSearchItem> slice = List.of(item(UUID.randomUUID()), item(UUID.randomUUID()));
|
||||
|
||||
DocumentSearchResult result = DocumentSearchResult.paged(slice, PageRequest.of(1, 50), 120L);
|
||||
|
||||
assertThat(result.items()).hasSize(2);
|
||||
assertThat(result.totalElements()).isEqualTo(120L);
|
||||
assertThat(result.pageNumber()).isEqualTo(1);
|
||||
assertThat(result.pageSize()).isEqualTo(50);
|
||||
assertThat(result.totalPages()).isEqualTo(3); // ceil(120 / 50)
|
||||
}
|
||||
|
||||
@Test
|
||||
void paged_factory_totalPages_rounds_up_on_remainder() {
|
||||
DocumentSearchResult result =
|
||||
DocumentSearchResult.paged(List.of(), PageRequest.of(0, 7), 30L);
|
||||
|
||||
assertThat(result.totalPages()).isEqualTo(5); // ceil(30 / 7)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -53,9 +87,18 @@ class DocumentSearchResultTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void total_component_is_annotated_as_required_in_openapi_schema() throws NoSuchFieldException {
|
||||
Schema schema = DocumentSearchResult.class.getDeclaredField("total").getAnnotation(Schema.class);
|
||||
void totalElements_component_is_annotated_as_required_in_openapi_schema() throws NoSuchFieldException {
|
||||
Schema schema = DocumentSearchResult.class.getDeclaredField("totalElements").getAnnotation(Schema.class);
|
||||
assertThat(schema).isNotNull();
|
||||
assertThat(schema.requiredMode()).isEqualTo(Schema.RequiredMode.REQUIRED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void paging_components_are_annotated_as_required_in_openapi_schema() throws NoSuchFieldException {
|
||||
for (String name : List.of("pageNumber", "pageSize", "totalPages")) {
|
||||
Schema schema = DocumentSearchResult.class.getDeclaredField(name).getAnnotation(Schema.class);
|
||||
assertThat(schema).as(name + " must have @Schema").isNotNull();
|
||||
assertThat(schema.requiredMode()).isEqualTo(Schema.RequiredMode.REQUIRED);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
package org.raddatz.familienarchiv.service;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
|
||||
import org.raddatz.familienarchiv.dto.DocumentSort;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.test.annotation.DirtiesContext;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* End-to-end paged search test with real PostgreSQL (Testcontainers). Covers the
|
||||
* Specification→Pageable→Page→DTO path that unit tests mock around. Seeds 120
|
||||
* UPLOADED documents and asserts the slice/total/totalPages arithmetic holds
|
||||
* against the actual JPA query.
|
||||
*
|
||||
* <p>Closes the integration-coverage gap Sara flagged on PR #316.
|
||||
*/
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
||||
@ActiveProfiles("test")
|
||||
@Import(PostgresContainerConfig.class)
|
||||
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
|
||||
class DocumentSearchPagedIntegrationTest {
|
||||
|
||||
private static final int FIXTURE_SIZE = 120;
|
||||
|
||||
@MockitoBean S3Client s3Client;
|
||||
@Autowired DocumentService documentService;
|
||||
@Autowired DocumentRepository documentRepository;
|
||||
|
||||
@BeforeEach
|
||||
void seed() {
|
||||
// Deterministic date spread so DATE-DESC order is predictable:
|
||||
// document #0 has the oldest date, document #119 has the newest.
|
||||
for (int i = 0; i < FIXTURE_SIZE; i++) {
|
||||
Document doc = Document.builder()
|
||||
.title("Dok-" + String.format("%03d", i))
|
||||
.originalFilename("dok-" + i + ".pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.documentDate(LocalDate.of(1900, 1, 1).plusDays(i))
|
||||
.build();
|
||||
documentRepository.save(doc);
|
||||
}
|
||||
assertThat(documentRepository.count()).isEqualTo(FIXTURE_SIZE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void search_firstPage_returnsExactlyPageSizeItems_andCorrectTotalElements() {
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
null, null, null, null, null, null, null, null,
|
||||
DocumentSort.DATE, "DESC", null,
|
||||
PageRequest.of(0, 50));
|
||||
|
||||
assertThat(result.items()).hasSize(50);
|
||||
assertThat(result.totalElements()).isEqualTo(FIXTURE_SIZE);
|
||||
assertThat(result.pageNumber()).isZero();
|
||||
assertThat(result.pageSize()).isEqualTo(50);
|
||||
assertThat(result.totalPages()).isEqualTo(3); // ceil(120 / 50)
|
||||
}
|
||||
|
||||
@Test
|
||||
void search_lastPartialPage_returnsRemainingItems() {
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
null, null, null, null, null, null, null, null,
|
||||
DocumentSort.DATE, "DESC", null,
|
||||
PageRequest.of(2, 50));
|
||||
|
||||
// Page 2 (offset 100) of 120 docs → exactly 20 items on the tail.
|
||||
assertThat(result.items()).hasSize(20);
|
||||
assertThat(result.totalElements()).isEqualTo(FIXTURE_SIZE);
|
||||
assertThat(result.pageNumber()).isEqualTo(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void search_pageBeyondLast_returnsEmptyContent_totalElementsStillCorrect() {
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
null, null, null, null, null, null, null, null,
|
||||
DocumentSort.DATE, "DESC", null,
|
||||
PageRequest.of(99, 50));
|
||||
|
||||
assertThat(result.items()).isEmpty();
|
||||
assertThat(result.totalElements()).isEqualTo(FIXTURE_SIZE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void search_senderSort_pageOne_slicesInMemory_withCorrectTotal() {
|
||||
// SENDER sort path fetches all + sorts + slices in-memory (see scaling
|
||||
// comment in DocumentService). Proves that the in-memory slice path
|
||||
// returns the correct total from a real repository fetch.
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
null, null, null, null, null, null, null, null,
|
||||
DocumentSort.SENDER, "asc", null,
|
||||
PageRequest.of(1, 50));
|
||||
|
||||
assertThat(result.items()).hasSize(50);
|
||||
assertThat(result.totalElements()).isEqualTo(FIXTURE_SIZE);
|
||||
assertThat(result.pageNumber()).isEqualTo(1);
|
||||
assertThat(result.totalPages()).isEqualTo(3);
|
||||
}
|
||||
|
||||
@Test
|
||||
void search_differentPagesReturnDisjointSlices() {
|
||||
DocumentSearchResult page0 = documentService.searchDocuments(
|
||||
null, null, null, null, null, null, null, null,
|
||||
DocumentSort.DATE, "DESC", null,
|
||||
PageRequest.of(0, 50));
|
||||
DocumentSearchResult page1 = documentService.searchDocuments(
|
||||
null, null, null, null, null, null, null, null,
|
||||
DocumentSort.DATE, "DESC", null,
|
||||
PageRequest.of(1, 50));
|
||||
|
||||
// No document id should appear on both pages — slicing must be exclusive.
|
||||
var idsOnPage0 = page0.items().stream()
|
||||
.map(item -> item.document().getId())
|
||||
.toList();
|
||||
var idsOnPage1 = page1.items().stream()
|
||||
.map(item -> item.document().getId())
|
||||
.toList();
|
||||
for (UUID id : idsOnPage0) {
|
||||
assertThat(idsOnPage1).doesNotContain(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,8 @@ import org.raddatz.familienarchiv.dto.DocumentSort;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.domain.PageImpl;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
|
||||
import java.time.LocalDate;
|
||||
@@ -25,6 +26,8 @@ import static org.mockito.Mockito.when;
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class DocumentServiceSortTest {
|
||||
|
||||
private static final Pageable UNPAGED = org.springframework.data.domain.PageRequest.of(0, 10_000);
|
||||
|
||||
@Mock DocumentRepository documentRepository;
|
||||
@Mock PersonService personService;
|
||||
@Mock FileService fileService;
|
||||
@@ -51,12 +54,12 @@ class DocumentServiceSortTest {
|
||||
|
||||
// FTS returns id1 first (higher rank), id2 second
|
||||
when(documentRepository.findRankedIdsByFts("Brief")).thenReturn(List.of(id1, id2));
|
||||
// findAll(spec, sort) — the correct date path — returns date-DESC order
|
||||
when(documentRepository.findAll(any(Specification.class), any(Sort.class)))
|
||||
.thenReturn(List.of(newer, older));
|
||||
// findAll(spec, pageable) — the correct date path — returns date-DESC order
|
||||
when(documentRepository.findAll(any(Specification.class), any(Pageable.class)))
|
||||
.thenReturn(new PageImpl<>(List.of(newer, older)));
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
"Brief", null, null, null, null, null, null, null, DocumentSort.DATE, "DESC", null);
|
||||
"Brief", null, null, null, null, null, null, null, DocumentSort.DATE, "DESC", null, UNPAGED);
|
||||
|
||||
// Expect: date order (newer 1960 first), NOT rank order (older 1940 first)
|
||||
assertThat(result.items()).hasSize(2);
|
||||
@@ -78,7 +81,7 @@ class DocumentServiceSortTest {
|
||||
.thenReturn(List.of(doc2, doc1)); // unordered from DB
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null);
|
||||
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null, UNPAGED);
|
||||
|
||||
// Expect: rank order restored (id1 first)
|
||||
assertThat(result.items().get(0).document().getId()).isEqualTo(id1);
|
||||
@@ -97,7 +100,7 @@ class DocumentServiceSortTest {
|
||||
.thenReturn(List.of(doc2, doc1));
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
"Brief", null, null, null, null, null, null, null, null, null, null);
|
||||
"Brief", null, null, null, null, null, null, null, null, null, null, UNPAGED);
|
||||
|
||||
assertThat(result.items().get(0).document().getId()).isEqualTo(id1);
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import org.raddatz.familienarchiv.model.Tag;
|
||||
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageImpl;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
@@ -46,6 +47,12 @@ import static org.mockito.Mockito.*;
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class DocumentServiceTest {
|
||||
|
||||
// Used by tests that don't care about paging. 10 000 is chosen large enough
|
||||
// to hold any fixture in this file but small enough that totalPages math
|
||||
// stays in int range. Swap to `PageRequest.of(0, 10_000)` elsewhere is a
|
||||
// red flag — use this constant.
|
||||
private static final Pageable UNPAGED = PageRequest.of(0, 10_000);
|
||||
|
||||
@Mock DocumentRepository documentRepository;
|
||||
@Mock PersonService personService;
|
||||
@Mock FileService fileService;
|
||||
@@ -1323,26 +1330,124 @@ class DocumentServiceTest {
|
||||
assertThat(result).isNull();
|
||||
}
|
||||
|
||||
// ─── searchDocuments — pagination ────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void searchDocuments_fastPath_usesFindAllWithPageable_notWithSort() {
|
||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
|
||||
.thenReturn(new PageImpl<>(List.of()));
|
||||
|
||||
documentService.searchDocuments(null, null, null, null, null, null, null, null,
|
||||
org.raddatz.familienarchiv.dto.DocumentSort.DATE, "DESC", null,
|
||||
org.springframework.data.domain.PageRequest.of(1, 50));
|
||||
|
||||
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class));
|
||||
verify(documentRepository, never()).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchDocuments_fastPath_propagatesPageableToDatabase() {
|
||||
ArgumentCaptor<Pageable> captor = ArgumentCaptor.forClass(Pageable.class);
|
||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
|
||||
.thenReturn(new PageImpl<>(List.of()));
|
||||
|
||||
documentService.searchDocuments(null, null, null, null, null, null, null, null,
|
||||
org.raddatz.familienarchiv.dto.DocumentSort.DATE, "DESC", null,
|
||||
org.springframework.data.domain.PageRequest.of(3, 25));
|
||||
|
||||
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), captor.capture());
|
||||
assertThat(captor.getValue().getPageNumber()).isEqualTo(3);
|
||||
assertThat(captor.getValue().getPageSize()).isEqualTo(25);
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchDocuments_fastPath_returnsPageableTotalsOnResult() {
|
||||
// The service MUST report the full match count from Page.getTotalElements(),
|
||||
// not the slice size — otherwise the frontend's "N Briefe gefunden" label is wrong.
|
||||
Document d = Document.builder().id(UUID.randomUUID()).title("T").build();
|
||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
|
||||
.thenReturn(new PageImpl<>(List.of(d), org.springframework.data.domain.PageRequest.of(0, 50), 120L));
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(null, null, null, null, null, null, null, null,
|
||||
org.raddatz.familienarchiv.dto.DocumentSort.DATE, "DESC", null,
|
||||
org.springframework.data.domain.PageRequest.of(0, 50));
|
||||
|
||||
assertThat(result.totalElements()).isEqualTo(120L);
|
||||
assertThat(result.pageNumber()).isZero();
|
||||
assertThat(result.pageSize()).isEqualTo(50);
|
||||
assertThat(result.totalPages()).isEqualTo(3); // ceil(120/50)
|
||||
assertThat(result.items()).hasSize(1); // only the slice is enriched
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchDocuments_senderSort_slicesInMemoryAndReportsFullTotal() {
|
||||
// Fixture: 120 docs with senders; request page 1, size 50 → expect 50 items
|
||||
// back with totalElements = 120.
|
||||
List<Document> all = new java.util.ArrayList<>();
|
||||
for (int i = 0; i < 120; i++) {
|
||||
Person p = Person.builder()
|
||||
.id(UUID.randomUUID())
|
||||
.firstName("F" + i)
|
||||
.lastName(String.format("L%03d", i))
|
||||
.build();
|
||||
all.add(Document.builder().id(UUID.randomUUID()).title("D" + i).sender(p).build());
|
||||
}
|
||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))
|
||||
.thenReturn(all);
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(null, null, null, null, null, null, null, null,
|
||||
org.raddatz.familienarchiv.dto.DocumentSort.SENDER, "asc", null,
|
||||
org.springframework.data.domain.PageRequest.of(1, 50));
|
||||
|
||||
assertThat(result.totalElements()).isEqualTo(120L);
|
||||
assertThat(result.pageNumber()).isEqualTo(1);
|
||||
assertThat(result.pageSize()).isEqualTo(50);
|
||||
assertThat(result.totalPages()).isEqualTo(3);
|
||||
assertThat(result.items()).hasSize(50);
|
||||
// Page 1 (offset 50) under ascending sender sort should start at L050
|
||||
assertThat(result.items().get(0).document().getSender().getLastName()).isEqualTo("L050");
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchDocuments_pageBeyondLast_returnsEmptyContentAndCorrectTotal() {
|
||||
// Guards the JPA edge case where page * size > totalElements.
|
||||
// Must not throw, must return empty content + correct totalElements.
|
||||
List<Document> all = new java.util.ArrayList<>();
|
||||
for (int i = 0; i < 30; i++) {
|
||||
Person p = Person.builder().id(UUID.randomUUID()).lastName(String.format("L%02d", i)).build();
|
||||
all.add(Document.builder().id(UUID.randomUUID()).title("D" + i).sender(p).build());
|
||||
}
|
||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))
|
||||
.thenReturn(all);
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(null, null, null, null, null, null, null, null,
|
||||
org.raddatz.familienarchiv.dto.DocumentSort.SENDER, "asc", null,
|
||||
org.springframework.data.domain.PageRequest.of(10, 50));
|
||||
|
||||
assertThat(result.items()).isEmpty();
|
||||
assertThat(result.totalElements()).isEqualTo(30L);
|
||||
}
|
||||
|
||||
// ─── searchDocuments — status filter ─────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void searchDocuments_passesStatusSpecificationToRepository() {
|
||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class)))
|
||||
.thenReturn(List.of());
|
||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
|
||||
.thenReturn(new PageImpl<>(List.of()));
|
||||
|
||||
documentService.searchDocuments(null, null, null, null, null, null, null, DocumentStatus.REVIEWED, null, null, null);
|
||||
documentService.searchDocuments(null, null, null, null, null, null, null, DocumentStatus.REVIEWED, null, null, null, UNPAGED);
|
||||
|
||||
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class));
|
||||
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchDocuments_withNullStatus_doesNotFilterByStatus() {
|
||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class)))
|
||||
.thenReturn(List.of());
|
||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
|
||||
.thenReturn(new PageImpl<>(List.of()));
|
||||
|
||||
documentService.searchDocuments(null, null, null, null, null, null, null, null, null, null, null);
|
||||
documentService.searchDocuments(null, null, null, null, null, null, null, null, null, null, null, UNPAGED);
|
||||
|
||||
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class));
|
||||
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class));
|
||||
}
|
||||
|
||||
// ─── getRecentActivity ────────────────────────────────────────────────────
|
||||
@@ -1418,7 +1523,7 @@ class DocumentServiceTest {
|
||||
.thenReturn(List.of(withSender, noSender));
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null);
|
||||
null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null, UNPAGED);
|
||||
|
||||
assertThat(result.items()).hasSize(2);
|
||||
assertThat(result.items()).extracting(item -> item.document().getTitle()).containsExactly("Has Sender", "No Sender");
|
||||
@@ -1438,7 +1543,7 @@ class DocumentServiceTest {
|
||||
.thenReturn(List.of(noReceivers, withReceiver));
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
null, null, null, null, null, null, null, null, DocumentSort.RECEIVER, "asc", null);
|
||||
null, null, null, null, null, null, null, null, DocumentSort.RECEIVER, "asc", null, UNPAGED);
|
||||
|
||||
assertThat(result.items()).extracting(item -> item.document().getTitle())
|
||||
.containsExactly("Has Receiver", "No Receivers");
|
||||
@@ -1460,7 +1565,7 @@ class DocumentServiceTest {
|
||||
.thenReturn(List.of(docNullName, docSmith));
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null);
|
||||
null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null, UNPAGED);
|
||||
|
||||
// null lastName should sort to end (treated as empty), not before "smith" (as "null")
|
||||
assertThat(result.items()).extracting(item -> item.document().getTitle())
|
||||
@@ -1482,7 +1587,7 @@ class DocumentServiceTest {
|
||||
when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows);
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null);
|
||||
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null, UNPAGED);
|
||||
|
||||
assertThat(result.items()).hasSize(1);
|
||||
SearchMatchData md = result.items().get(0).matchData();
|
||||
@@ -1492,11 +1597,12 @@ class DocumentServiceTest {
|
||||
|
||||
@Test
|
||||
void searchDocuments_withoutTextQuery_returnsEmptyMatchData() {
|
||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class)))
|
||||
.thenReturn(List.of());
|
||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
|
||||
.thenReturn(new PageImpl<>(List.of()));
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
null, null, null, null, null, null, null, null, null, null, null);
|
||||
null, null, null, null, null, null, null, null, null, null, null,
|
||||
UNPAGED);
|
||||
|
||||
assertThat(result.items()).isEmpty();
|
||||
}
|
||||
@@ -1515,7 +1621,7 @@ class DocumentServiceTest {
|
||||
when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows);
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null);
|
||||
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null, UNPAGED);
|
||||
|
||||
SearchMatchData md = result.items().get(0).matchData();
|
||||
assertThat(md.transcriptionSnippet()).isEqualTo("Hier ist der Brief aus Berlin");
|
||||
@@ -1707,4 +1813,108 @@ class DocumentServiceTest {
|
||||
|
||||
verify(auditService).logAfterCommit(eq(AuditKind.FILE_UPLOADED), isNull(), eq(id), isNull());
|
||||
}
|
||||
|
||||
// ─── storeDocumentWithBatchMetadata ──────────────────────────────────────
|
||||
|
||||
private MockMultipartFile pdfFile(String name) {
|
||||
return new MockMultipartFile("file", name, "application/pdf", new byte[]{1});
|
||||
}
|
||||
|
||||
private void stubStoreDocument(String filename) throws Exception {
|
||||
when(documentRepository.findFirstByOriginalFilename(filename)).thenReturn(Optional.empty());
|
||||
when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("key", "hash"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void storeDocumentWithBatchMetadata_appliesTitleByIndex() throws Exception {
|
||||
stubStoreDocument("scan01.pdf");
|
||||
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO meta = new org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO();
|
||||
meta.setTitles(List.of("Erster Brief", "Zweiter Brief"));
|
||||
|
||||
DocumentService.StoreResult result = documentService.storeDocumentWithBatchMetadata(pdfFile("scan01.pdf"), meta, 0, null);
|
||||
|
||||
assertThat(result.document().getTitle()).isEqualTo("Erster Brief");
|
||||
}
|
||||
|
||||
@Test
|
||||
void storeDocumentWithBatchMetadata_resolvesSenderViaPersonService() throws Exception {
|
||||
UUID senderId = UUID.randomUUID();
|
||||
stubStoreDocument("scan02.pdf");
|
||||
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
Person sender = Person.builder().id(senderId).firstName("Anna").build();
|
||||
when(personService.getById(senderId)).thenReturn(sender);
|
||||
|
||||
org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO meta = new org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO();
|
||||
meta.setSenderId(senderId);
|
||||
|
||||
DocumentService.StoreResult result = documentService.storeDocumentWithBatchMetadata(pdfFile("scan02.pdf"), meta, 0, null);
|
||||
|
||||
assertThat(result.document().getSender()).isEqualTo(sender);
|
||||
}
|
||||
|
||||
@Test
|
||||
void storeDocumentWithBatchMetadata_appliesTagsViaUpdateDocumentTags() throws Exception {
|
||||
UUID docId = UUID.randomUUID();
|
||||
when(documentRepository.findFirstByOriginalFilename("scan03.pdf")).thenReturn(Optional.empty());
|
||||
when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("key", "hash"));
|
||||
when(documentRepository.save(any())).thenAnswer(inv -> {
|
||||
Document d = inv.getArgument(0);
|
||||
if (d.getId() == null) d.setId(docId);
|
||||
return d;
|
||||
});
|
||||
when(documentRepository.findById(docId)).thenAnswer(inv -> {
|
||||
Document d = new Document();
|
||||
d.setId(docId);
|
||||
return Optional.of(d);
|
||||
});
|
||||
Tag tag = Tag.builder().id(UUID.randomUUID()).name("Familie").build();
|
||||
when(tagService.findOrCreate("Familie")).thenReturn(tag);
|
||||
|
||||
org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO meta = new org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO();
|
||||
meta.setTagNames(List.of("Familie"));
|
||||
|
||||
documentService.storeDocumentWithBatchMetadata(pdfFile("scan03.pdf"), meta, 0, null);
|
||||
|
||||
verify(tagService).findOrCreate("Familie");
|
||||
}
|
||||
|
||||
@Test
|
||||
void storeDocumentWithBatchMetadata_leavesTitle_whenIndexExceedsTitlesList() throws Exception {
|
||||
stubStoreDocument("scan04.pdf");
|
||||
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO meta = new org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO();
|
||||
meta.setTitles(List.of("Only One Title"));
|
||||
|
||||
DocumentService.StoreResult result = documentService.storeDocumentWithBatchMetadata(pdfFile("scan04.pdf"), meta, 5, null);
|
||||
|
||||
assertThat(result.document().getTitle()).isEqualTo("scan04");
|
||||
}
|
||||
|
||||
// ─── validateBatch ───────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void validateBatch_throwsBatchTooLarge_whenFileCountExceedsCap() {
|
||||
assertThatThrownBy(() -> documentService.validateBatch(51, null))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.hasMessageContaining("50");
|
||||
}
|
||||
|
||||
@Test
|
||||
void validateBatch_doesNotThrow_whenFileCountEqualsCapExactly() {
|
||||
documentService.validateBatch(50, null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void validateBatch_throwsValidationError_whenTitlesSizeExceedsFileCount() {
|
||||
org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO metadata =
|
||||
new org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO();
|
||||
metadata.setTitles(java.util.List.of("A", "B", "C"));
|
||||
|
||||
assertThatThrownBy(() -> documentService.validateBatch(2, metadata))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.hasMessageContaining("titles");
|
||||
}
|
||||
}
|
||||
|
||||
996
docs/specs/bulk-upload-concepts.html
Normal file
996
docs/specs/bulk-upload-concepts.html
Normal file
@@ -0,0 +1,996 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>Bulk Upload — 3 Concept Designs · Familienarchiv</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Merriweather:ital,wght@0,300;0,400;0,700;1,300&family=Montserrat:wght@400;500;600;700;800;900&display=swap" rel="stylesheet"/>
|
||||
<style>
|
||||
/* ── Reset ── */
|
||||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||||
body{font-family:'Montserrat',system-ui,sans-serif;background:#ECEAE4;color:#1A1A1A;line-height:1.5;font-size:13px}
|
||||
.doc{max-width:1300px;margin:0 auto;padding:48px 32px 120px}
|
||||
|
||||
/* ── Masthead ── */
|
||||
.mh{padding-bottom:24px;border-bottom:3px solid #002850;margin-bottom:60px}
|
||||
.mh .kicker{font-size:9px;font-weight:800;letter-spacing:2px;text-transform:uppercase;color:#A6DAD8}
|
||||
.mh h1{font-size:28px;font-weight:900;color:#002850;letter-spacing:-.4px;margin-top:6px}
|
||||
.mh p{font-size:13px;color:#555;max-width:780px;line-height:1.75;margin-top:10px}
|
||||
.mh .byline{font-size:9px;color:#999;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;margin-top:14px}
|
||||
.tag-row{display:flex;gap:6px;margin-top:10px;flex-wrap:wrap}
|
||||
.tag{background:#002850;color:#A6DAD8;padding:3px 9px;border-radius:2px;font-size:8.5px;font-weight:700;letter-spacing:.8px;text-transform:uppercase}
|
||||
.tag.amber{background:#7c4a00;color:#fde68a}
|
||||
.tag.green{background:#1e5e34;color:#d1fae5}
|
||||
.tag.gray{background:#4b5563;color:#e5e7eb}
|
||||
.tag.mint{background:#A6DAD8;color:#002850}
|
||||
|
||||
/* ── Goals card ── */
|
||||
.goals{background:#fff;border:1px solid #E4E2D7;border-radius:4px;padding:22px 26px;margin:0 0 60px;box-shadow:0 1px 3px rgba(0,0,0,.04)}
|
||||
.goals h2{font-size:11px;font-weight:800;letter-spacing:1.5px;text-transform:uppercase;color:#002850;margin-bottom:14px}
|
||||
.goals ul{list-style:none;display:grid;grid-template-columns:1fr 1fr;gap:10px 28px}
|
||||
.goals li{font-size:12.5px;color:#333;padding-left:20px;position:relative;line-height:1.55}
|
||||
.goals li::before{content:"→";position:absolute;left:0;top:0;color:#A6DAD8;font-weight:800}
|
||||
|
||||
/* ── Concept section ── */
|
||||
.concept{margin-bottom:88px;padding-bottom:88px;border-bottom:2px dashed #C8C4BE}
|
||||
.concept:last-of-type{border-bottom:none;margin-bottom:0;padding-bottom:0}
|
||||
.concept-header{display:flex;align-items:flex-start;gap:24px;margin-bottom:36px}
|
||||
.concept-num{font-size:84px;font-weight:900;color:#E0DDD6;line-height:1;flex-shrink:0;width:96px}
|
||||
.concept-label{font-size:8.5px;font-weight:800;text-transform:uppercase;letter-spacing:1.2px;color:#A6DAD8;margin-bottom:5px}
|
||||
.concept-title{font-family:'Merriweather',Georgia,serif;font-size:24px;font-weight:700;color:#002850;margin-bottom:10px}
|
||||
.concept-desc{font-size:13.5px;color:#555;max-width:740px;line-height:1.75}
|
||||
.concept-best{margin-top:14px;display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
||||
.best-label{background:#A6DAD8;color:#002850;padding:3px 9px;border-radius:2px;font-size:8.5px;font-weight:800;letter-spacing:.6px;text-transform:uppercase}
|
||||
.best-text{font-size:12px;font-weight:600;color:#444}
|
||||
.concept-tradeoff{margin-top:8px;font-size:12px;color:#888;font-style:italic;max-width:680px;line-height:1.7}
|
||||
|
||||
/* ── Browser chrome ── */
|
||||
.screen{max-width:980px;margin:0 auto}
|
||||
.screen.narrow{max-width:400px}
|
||||
.chrome{background:#F5F4EE;border:1.5px solid #C4C0BA;border-radius:8px;overflow:hidden;box-shadow:0 4px 20px rgba(0,0,0,.1)}
|
||||
.chrome-bar{height:22px;background:#E8E6E0;border-bottom:1px solid #C4C0BA;display:flex;align-items:center;padding:0 9px;gap:5px;flex-shrink:0}
|
||||
.chrome-dot{width:7px;height:7px;border-radius:50%;background:#BDB8B1}
|
||||
.chrome-url{flex:1;height:10px;background:#CCC8C2;border-radius:5px;margin-left:8px}
|
||||
.viewport-hint{font-size:7.5px;font-weight:800;color:#A6DAD8;letter-spacing:1px;text-transform:uppercase;padding:4px 9px;background:#002850;border-radius:2px;margin-left:8px}
|
||||
|
||||
/* ── App nav ── */
|
||||
.app-nav{height:32px;background:#002850;display:flex;align-items:center;padding:0 14px;gap:12px;flex-shrink:0}
|
||||
.app-logo{font-family:'Merriweather',Georgia,serif;font-size:8px;font-weight:700;color:#fff;border-bottom:2px solid #A6DAD8;padding-bottom:1px}
|
||||
.app-link{font-size:6px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;color:rgba(255,255,255,.45);white-space:nowrap}
|
||||
.app-link.on{color:rgba(255,255,255,.9)}
|
||||
.app-nav-r{margin-left:auto;display:flex;gap:8px;align-items:center}
|
||||
.app-avatar{width:18px;height:18px;border-radius:50%;background:rgba(255,255,255,.12);display:flex;align-items:center;justify-content:center;font-size:6px;font-weight:800;color:rgba(255,255,255,.5)}
|
||||
|
||||
/* ── Common form element styles ── */
|
||||
.f-label{font-size:6.5px;font-weight:700;color:#666;letter-spacing:.2px;text-transform:uppercase}
|
||||
.f-req{color:#C0392B}
|
||||
.f-input{height:20px;border:1px solid #D4D0CA;border-radius:2px;background:#fff;font-size:7.5px;padding:0 7px;color:#333;display:flex;align-items:center}
|
||||
.f-input.focus{border-color:#002850;box-shadow:0 0 0 2px rgba(0,40,80,.12)}
|
||||
.f-input.filled{color:#002850;font-weight:600;background:#FAFBFF}
|
||||
.f-input.suggested{border-color:#A6DAD8;background:#F0FAFA;color:#005858;font-weight:600}
|
||||
.f-input.empty{color:#BBB;font-style:italic}
|
||||
.f-input.tall{height:28px}
|
||||
|
||||
.f-tags{display:flex;gap:3px;flex-wrap:wrap;min-height:20px;border:1px solid #D4D0CA;border-radius:2px;padding:2px 4px;background:#fff;align-items:center}
|
||||
.f-chip{background:#002850;color:#A6DAD8;border-radius:2px;font-size:6px;font-weight:700;padding:1px 4px 1px 5px;display:flex;align-items:center;gap:2px}
|
||||
.f-chip-rm{color:rgba(166,218,216,.5);font-weight:400}
|
||||
|
||||
/* ── Action bar ── */
|
||||
.action-bar{height:46px;background:#F5F4EE;border-top:1px solid #E4E2D7;display:flex;align-items:center;padding:0 14px;gap:8px;flex-shrink:0}
|
||||
.btn-skip{font-size:7px;font-weight:700;color:#AAA;letter-spacing:.2px;cursor:pointer}
|
||||
.btn-spacer{flex:1}
|
||||
.btn-outline{height:24px;padding:0 12px;border:1px solid #C0BDB6;border-radius:2px;font-size:6.5px;font-weight:800;text-transform:uppercase;letter-spacing:.5px;color:#777;display:flex;align-items:center;cursor:pointer;background:#fff}
|
||||
.btn-primary{height:24px;padding:0 12px;border-radius:2px;font-size:6.5px;font-weight:800;text-transform:uppercase;letter-spacing:.5px;background:#002850;color:#fff;display:flex;align-items:center;cursor:pointer;gap:4px}
|
||||
.btn-primary.green{background:#1A7040}
|
||||
|
||||
/* ─────────────────────────────────────── */
|
||||
/* ── CONCEPT A — Stack (mobile-first) ── */
|
||||
/* ─────────────────────────────────────── */
|
||||
.ca-top-bar{height:34px;background:#F5F4EE;border-bottom:1px solid #E4E2D7;display:flex;align-items:center;padding:0 12px;gap:8px}
|
||||
.ca-back{font-size:7px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:#888}
|
||||
.ca-title{flex:1;text-align:center;font-family:'Merriweather',Georgia,serif;font-size:9px;color:#002850;font-weight:600}
|
||||
.ca-count{font-size:7px;font-weight:700;color:#002850;background:#A6DAD8;padding:2px 6px;border-radius:10px;letter-spacing:.3px}
|
||||
|
||||
.ca-body{background:#ECEAE4;padding:14px 12px;overflow-y:auto}
|
||||
|
||||
.ca-drop{background:#fff;border:2px dashed #A6DAD8;border-radius:4px;padding:14px;text-align:center;margin-bottom:14px}
|
||||
.ca-drop-icon{font-size:18px;color:#A6DAD8;margin-bottom:4px}
|
||||
.ca-drop-title{font-size:8.5px;font-weight:700;color:#002850;margin-bottom:2px}
|
||||
.ca-drop-sub{font-size:6.5px;color:#999}
|
||||
|
||||
.ca-shared-card{background:#fff;border:1px solid #E4E2D7;border-radius:3px;padding:12px 14px;margin-bottom:14px;box-shadow:0 1px 2px rgba(0,0,0,.03)}
|
||||
.ca-shared-head{display:flex;align-items:center;gap:6px;margin-bottom:11px}
|
||||
.ca-shared-badge{background:#A6DAD8;color:#002850;padding:2px 7px;border-radius:2px;font-size:6px;font-weight:800;letter-spacing:.4px;text-transform:uppercase}
|
||||
.ca-shared-title{font-family:'Merriweather',Georgia,serif;font-size:9.5px;color:#002850;font-weight:700}
|
||||
.ca-shared-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px 10px}
|
||||
.ca-shared-grid .full{grid-column:1/-1}
|
||||
.ca-shared-field{display:flex;flex-direction:column;gap:3px}
|
||||
|
||||
.ca-files-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;padding:0 2px}
|
||||
.ca-files-title{font-size:7px;font-weight:800;letter-spacing:1px;text-transform:uppercase;color:#B0ADA6}
|
||||
.ca-files-add{font-size:7px;font-weight:700;color:#002850;display:flex;align-items:center;gap:3px}
|
||||
|
||||
.ca-file{background:#fff;border:1px solid #E4E2D7;border-radius:3px;padding:9px 10px;margin-bottom:7px;display:flex;align-items:center;gap:10px}
|
||||
.ca-file.active{border-color:#002850;box-shadow:0 0 0 2px rgba(0,40,80,.08)}
|
||||
.ca-thumb{width:28px;height:36px;background:#FFFEF8;border:1px solid #E4E2D7;border-radius:1px;flex-shrink:0;display:flex;flex-direction:column;padding:3px;gap:1px}
|
||||
.ca-thumb .tl{height:2px;background:#C4BDB0;opacity:.6;border-radius:1px}
|
||||
.ca-thumb .tl.s{width:60%;opacity:.35}
|
||||
.ca-thumb .tl.m{width:82%}
|
||||
.ca-file-body{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}
|
||||
.ca-file-title{font-size:8px;color:#002850;font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.ca-file-title.placeholder{color:#888;font-weight:400;font-style:italic}
|
||||
.ca-file-meta{font-size:6.5px;color:#AAA}
|
||||
.ca-file-rm{font-size:10px;color:#B0ADA6;padding:0 4px;cursor:pointer}
|
||||
|
||||
/* ───────────────────────────────────────────── */
|
||||
/* ── CONCEPT B — Split-panel + file switcher ── */
|
||||
/* ───────────────────────────────────────────── */
|
||||
.cb-top-bar{height:38px;background:#F5F4EE;border-bottom:1px solid #E4E2D7;display:flex;align-items:center;padding:0 14px;gap:10px}
|
||||
.cb-back{font-size:7px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:#888}
|
||||
.cb-title{font-family:'Merriweather',Georgia,serif;font-size:9px;font-weight:700;color:#002850}
|
||||
.cb-count{background:#A6DAD8;color:#002850;padding:2px 7px;border-radius:10px;font-size:7px;font-weight:800;letter-spacing:.3px}
|
||||
.cb-discard{margin-left:auto;font-size:7px;font-weight:700;color:#C0392B;letter-spacing:.2px}
|
||||
|
||||
.cb-split{display:flex;min-height:440px}
|
||||
.cb-pdf{flex:55;background:#5E5C59;display:flex;flex-direction:column;border-right:1px solid #3A3836}
|
||||
.cb-pdf-toolbar{height:28px;background:#3A3836;display:flex;align-items:center;padding:0 10px;gap:8px}
|
||||
.cb-pdf-btn{width:16px;height:16px;border-radius:2px;background:rgba(255,255,255,.1);display:flex;align-items:center;justify-content:center;font-size:7px;color:rgba(255,255,255,.6)}
|
||||
.cb-pdf-page{font-size:6.5px;color:rgba(255,255,255,.4);margin-left:auto;font-weight:700;letter-spacing:.5px}
|
||||
.cb-pdf-view{flex:1;display:flex;justify-content:center;padding:14px;overflow:hidden}
|
||||
.cb-paper{background:#FFFEF8;box-shadow:0 2px 10px rgba(0,0,0,.3);border-radius:1px;padding:14px 16px;display:flex;flex-direction:column;gap:0;width:180px;flex-shrink:0}
|
||||
.pl{height:4px;background:#C4BDB0;border-radius:1px;opacity:.55;margin-bottom:3px}
|
||||
.pl.h{height:6px;opacity:.75;margin-bottom:5px}
|
||||
.pl.s{width:55%;opacity:.3}
|
||||
.pl.m{width:80%}
|
||||
.pl.sp{height:7px;background:transparent}
|
||||
.cb-filebar{background:#434140;border-top:1px solid #3A3836;display:flex;align-items:center;padding:0 8px;gap:3px;height:36px;flex-shrink:0}
|
||||
.cb-fb-arrow{width:18px;height:22px;border-radius:2px;background:rgba(255,255,255,.08);display:flex;align-items:center;justify-content:center;font-size:9px;color:rgba(255,255,255,.6)}
|
||||
.cb-fb-track{flex:1;display:flex;gap:3px;padding:0 3px;overflow:hidden}
|
||||
.cb-fb-item{padding:3px 6px;border-radius:2px;font-size:6px;font-weight:700;color:rgba(255,255,255,.55);background:rgba(255,255,255,.06);display:flex;align-items:center;gap:4px;white-space:nowrap}
|
||||
.cb-fb-item.on{background:#A6DAD8;color:#002850}
|
||||
.cb-fb-num{background:rgba(0,0,0,.15);border-radius:2px;padding:0 3px;font-size:5.5px;font-weight:800}
|
||||
.cb-fb-item.on .cb-fb-num{background:rgba(0,40,80,.25);color:#002850}
|
||||
|
||||
.cb-form{flex:45;background:#fff;display:flex;flex-direction:column}
|
||||
.cb-form-scroll{flex:1;overflow-y:auto;padding:14px}
|
||||
|
||||
.cb-only-card{background:#F0FAFA;border:1px solid #A6DAD8;border-radius:3px;padding:10px 12px;margin-bottom:12px}
|
||||
.cb-only-head{display:flex;align-items:center;gap:6px;margin-bottom:7px}
|
||||
.cb-only-badge{background:#005858;color:#A6DAD8;padding:2px 7px;border-radius:2px;font-size:6px;font-weight:800;letter-spacing:.4px;text-transform:uppercase}
|
||||
.cb-only-subtitle{font-size:6.5px;color:#005858;font-weight:600;letter-spacing:.3px}
|
||||
|
||||
.cb-shared-card{background:#F9F8F5;border:1px solid #E4E2D7;border-radius:3px;padding:10px 12px;margin-bottom:10px}
|
||||
.cb-shared-head{display:flex;align-items:center;gap:6px;margin-bottom:9px}
|
||||
.cb-shared-badge{background:#A6DAD8;color:#002850;padding:2px 7px;border-radius:2px;font-size:6px;font-weight:800;letter-spacing:.4px;text-transform:uppercase}
|
||||
.cb-shared-subtitle{font-size:6.5px;color:#002850;font-weight:600}
|
||||
.cb-row{display:grid;grid-template-columns:1fr 1fr;gap:7px;margin-bottom:7px}
|
||||
.cb-row.full{grid-template-columns:1fr}
|
||||
.cb-field{display:flex;flex-direction:column;gap:3px}
|
||||
|
||||
/* ─────────────────────────────────────── */
|
||||
/* ── CONCEPT C — Progressive accordion ── */
|
||||
/* ─────────────────────────────────────── */
|
||||
.cc-top-bar{height:34px;background:#F5F4EE;border-bottom:1px solid #E4E2D7;display:flex;align-items:center;padding:0 14px;gap:8px}
|
||||
|
||||
.cc-body{background:#ECEAE4;padding:14px;display:flex;flex-direction:column;gap:11px;max-height:540px;overflow-y:auto}
|
||||
|
||||
.cc-shared{background:#fff;border:1px solid #E4E2D7;border-radius:3px;padding:12px 14px;box-shadow:0 1px 2px rgba(0,0,0,.03);position:sticky;top:0;z-index:2}
|
||||
.cc-shared-head{display:flex;align-items:center;gap:7px;margin-bottom:11px}
|
||||
.cc-shared-badge{background:#A6DAD8;color:#002850;padding:2px 7px;border-radius:2px;font-size:6px;font-weight:800;letter-spacing:.4px;text-transform:uppercase}
|
||||
.cc-shared-title{font-family:'Merriweather',Georgia,serif;font-size:10px;color:#002850;font-weight:700}
|
||||
.cc-grid{display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px 10px}
|
||||
.cc-grid .span2{grid-column:span 2}
|
||||
|
||||
.cc-files-label{font-size:7px;font-weight:800;letter-spacing:1px;text-transform:uppercase;color:#B0ADA6;padding:0 2px;margin-top:6px}
|
||||
|
||||
.cc-file{background:#fff;border:1px solid #E4E2D7;border-radius:3px;overflow:hidden}
|
||||
.cc-file.open{border-color:#002850;box-shadow:0 2px 6px rgba(0,40,80,.08)}
|
||||
.cc-file-head{display:flex;align-items:center;gap:10px;padding:9px 12px;cursor:pointer}
|
||||
.cc-file-head.open{border-bottom:1px solid #E4E2D7;background:#F9F8F5}
|
||||
.cc-caret{font-size:9px;color:#A6DAD8;width:10px}
|
||||
.cc-file-thumb{width:22px;height:28px;background:#FFFEF8;border:1px solid #E4E2D7;border-radius:1px;padding:2px;display:flex;flex-direction:column;gap:1px;flex-shrink:0}
|
||||
.cc-file-thumb .tl{height:2px;background:#C4BDB0;opacity:.55;border-radius:1px}
|
||||
.cc-file-body{flex:1;min-width:0}
|
||||
.cc-file-titlerow{display:flex;align-items:center;gap:7px}
|
||||
.cc-file-title{font-size:8.5px;color:#002850;font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.cc-file-title.placeholder{color:#888;font-weight:400;font-style:italic}
|
||||
.cc-file-meta{font-size:6.5px;color:#AAA;margin-top:2px}
|
||||
.cc-file-rm{font-size:11px;color:#B0ADA6;padding:0 4px}
|
||||
|
||||
.cc-file-open{display:flex;background:#F5F4EE}
|
||||
.cc-preview{flex:45;background:#5E5C59;padding:12px;display:flex;justify-content:center}
|
||||
.cc-preview-paper{background:#FFFEF8;border-radius:1px;padding:8px 10px;width:110px;flex-shrink:0;display:flex;flex-direction:column;box-shadow:0 2px 6px rgba(0,0,0,.25)}
|
||||
.cc-file-form{flex:55;padding:12px 14px;background:#fff;display:flex;flex-direction:column;gap:7px}
|
||||
|
||||
/* ─────────── Decision matrix ─────────── */
|
||||
.decision{background:#fff;border:1px solid #E4E2D7;border-radius:4px;padding:28px 32px;margin:88px 0 60px;box-shadow:0 1px 3px rgba(0,0,0,.04)}
|
||||
.decision h2{font-size:11px;font-weight:800;letter-spacing:1.5px;text-transform:uppercase;color:#002850;margin-bottom:6px}
|
||||
.decision p.lead{font-size:13.5px;color:#555;line-height:1.7;margin-bottom:22px;max-width:820px}
|
||||
.dm{width:100%;border-collapse:collapse;margin-top:12px;font-size:12px}
|
||||
.dm th{text-align:left;font-size:9.5px;font-weight:800;letter-spacing:.5px;text-transform:uppercase;color:#002850;padding:9px 12px;background:#F9F8F5;border-bottom:2px solid #E4E2D7}
|
||||
.dm td{padding:13px 12px;border-bottom:1px solid #EFEDE7;vertical-align:top;line-height:1.6}
|
||||
.dm td:first-child{font-weight:700;color:#002850;width:18%;white-space:nowrap}
|
||||
.dm td.score{font-size:15px;text-align:center;width:12%}
|
||||
.dm td.ok{color:#1A7040}
|
||||
.dm td.mid{color:#A07100}
|
||||
.dm td.bad{color:#C0392B}
|
||||
|
||||
/* ─────────── Recommendation ─────────── */
|
||||
.reco{background:#002850;color:#fff;border-radius:6px;padding:36px 40px;margin:48px 0 64px;box-shadow:0 4px 20px rgba(0,40,80,.15)}
|
||||
.reco .kicker{font-size:9px;font-weight:800;letter-spacing:2px;text-transform:uppercase;color:#A6DAD8}
|
||||
.reco h2{font-family:'Merriweather',Georgia,serif;font-size:26px;font-weight:700;margin-top:6px}
|
||||
.reco .why{font-size:13.5px;line-height:1.85;color:rgba(255,255,255,.88);max-width:780px;margin-top:14px}
|
||||
.reco ul{list-style:none;margin-top:14px;display:grid;grid-template-columns:1fr 1fr;gap:9px 26px}
|
||||
.reco ul li{font-size:12.5px;color:rgba(255,255,255,.9);padding-left:22px;position:relative;line-height:1.6}
|
||||
.reco ul li::before{content:"✓";position:absolute;left:0;top:0;color:#A6DAD8;font-weight:800}
|
||||
|
||||
/* ─────────── Impl-ref ─────────── */
|
||||
.impl{background:#fff;border:1px solid #E4E2D7;border-radius:4px;padding:28px 32px;box-shadow:0 1px 3px rgba(0,0,0,.04)}
|
||||
.impl h2{font-size:11px;font-weight:800;letter-spacing:1.5px;text-transform:uppercase;color:#002850;margin-bottom:16px}
|
||||
.impl h3{font-family:'Merriweather',Georgia,serif;font-size:15px;color:#002850;margin:22px 0 10px}
|
||||
.impl-table{width:100%;border-collapse:collapse;margin-top:6px;font-size:12px}
|
||||
.impl-table th{text-align:left;font-size:9px;font-weight:800;letter-spacing:.6px;text-transform:uppercase;color:#002850;padding:8px 10px;background:#F9F8F5;border-bottom:2px solid #E4E2D7}
|
||||
.impl-table td{padding:10px;border-bottom:1px solid #EFEDE7;vertical-align:top;line-height:1.55}
|
||||
.impl-table td:first-child{font-weight:700;color:#002850;width:22%}
|
||||
.impl-table td code{font-family:'SF Mono','Menlo',monospace;font-size:11px;background:#F0EEE8;padding:1px 6px;border-radius:2px;color:#002850}
|
||||
.impl-table td.px{color:#777;font-size:11.5px;width:16%}
|
||||
.impl-table td.note{color:#888;font-size:11.5px;font-style:italic;width:22%}
|
||||
.impl h3.ix{margin-top:32px}
|
||||
|
||||
.notes{background:#F9F8F5;border-left:3px solid #A6DAD8;padding:16px 22px;border-radius:0 4px 4px 0;margin-top:26px}
|
||||
.notes .nh{font-size:9px;font-weight:800;letter-spacing:1px;text-transform:uppercase;color:#002850;margin-bottom:8px}
|
||||
.notes ul{list-style:none;display:flex;flex-direction:column;gap:6px}
|
||||
.notes li{font-size:12px;color:#333;padding-left:18px;position:relative;line-height:1.7}
|
||||
.notes li::before{content:"•";position:absolute;left:0;top:0;color:#A6DAD8;font-weight:800}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="doc">
|
||||
|
||||
<!-- ════════════════════════════════════════════ -->
|
||||
<!-- ═══════════════ MASTHEAD ══════════════ -->
|
||||
<!-- ════════════════════════════════════════════ -->
|
||||
<div class="mh">
|
||||
<div class="kicker">UX Spec · Bulk Upload</div>
|
||||
<h1>Uploading multiple documents in a single pass</h1>
|
||||
<p>
|
||||
Extends issue <strong>#294</strong> (new-document split-panel) with bulk uploads. When a user drops
|
||||
N files, every metadata field applies once to all of them — only the <em>title</em> is per-file,
|
||||
pre-filled from the filename and editable inline. A single save POST creates N documents.
|
||||
</p>
|
||||
<div class="byline">Prepared by Leonie Voss · 2026-04-24 · Draft 1 · References: #294, #305</div>
|
||||
<div class="tag-row">
|
||||
<span class="tag">feature</span>
|
||||
<span class="tag mint">ui</span>
|
||||
<span class="tag gray">a11y 320px+</span>
|
||||
<span class="tag green">backend ready</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Goals -->
|
||||
<div class="goals">
|
||||
<h2>Design goals</h2>
|
||||
<ul>
|
||||
<li><strong>One-pass feel</strong>: drop → fill shared fields → save. No wizard, no per-file detour.</li>
|
||||
<li><strong>Every field is shared except the title</strong>, which is always set (filename-derived).</li>
|
||||
<li><strong>No mode switch</strong>: 1 file and N files use the same screen — more files reveal more chrome.</li>
|
||||
<li><strong>Scales to 20+ files</strong> without the form losing scan-ability on mobile.</li>
|
||||
<li><strong>Reuses the #294 split-panel layout</strong> (DocumentEditLayout) — minimum new surface.</li>
|
||||
<li><strong>a11y-first</strong>: 44px targets, focus states, `aria-current` on active file, keyboard-navigable.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- ════════════════════════════════════════════ -->
|
||||
<!-- ═════════ CONCEPT A — STACK ═════════ -->
|
||||
<!-- ════════════════════════════════════════════ -->
|
||||
<section class="concept">
|
||||
<div class="concept-header">
|
||||
<div class="concept-num">A</div>
|
||||
<div>
|
||||
<div class="concept-label">Concept A</div>
|
||||
<div class="concept-title">Flat Stack — shared header · file cards · sticky save</div>
|
||||
<p class="concept-desc">
|
||||
A single vertical flow: drop zone on top, then a <em>Gilt für alle</em> metadata card,
|
||||
then stacked file cards (thumbnail · editable title · remove). No split panel, no tabs.
|
||||
Scrolling down reveals all files; the save bar sticks to the bottom.
|
||||
</p>
|
||||
<div class="concept-best">
|
||||
<span class="best-label">Best for</span>
|
||||
<span class="best-text">Small-screen workflows. Seniors who prefer linear flows over tabs.</span>
|
||||
</div>
|
||||
<div class="concept-tradeoff">
|
||||
Trade-off: no PDF preview until you click through to the document after save. Harder to verify
|
||||
you grabbed the right files before committing.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- mobile mockup -->
|
||||
<div class="screen narrow">
|
||||
<div class="chrome">
|
||||
<div class="chrome-bar">
|
||||
<div class="chrome-dot"></div><div class="chrome-dot"></div><div class="chrome-dot"></div>
|
||||
<div class="chrome-url"></div>
|
||||
<div class="viewport-hint">375 · mobile</div>
|
||||
</div>
|
||||
<div class="app-nav">
|
||||
<div class="app-logo">Familienarchiv</div>
|
||||
<div class="app-nav-r">
|
||||
<div class="app-avatar">MR</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ca-top-bar">
|
||||
<div class="ca-back">← Zurück</div>
|
||||
<div class="ca-title">Neue Dokumente</div>
|
||||
<div class="ca-count">5</div>
|
||||
</div>
|
||||
<div class="ca-body" style="height:500px">
|
||||
<!-- drop zone -->
|
||||
<div class="ca-drop">
|
||||
<div class="ca-drop-icon">⇪</div>
|
||||
<div class="ca-drop-title">Weitere Dateien hinzufügen</div>
|
||||
<div class="ca-drop-sub">PDF, JPEG, PNG, TIFF · max 50 MB</div>
|
||||
</div>
|
||||
|
||||
<!-- shared card -->
|
||||
<div class="ca-shared-card">
|
||||
<div class="ca-shared-head">
|
||||
<span class="ca-shared-badge">Gilt für alle 5</span>
|
||||
<span class="ca-shared-title">Angaben</span>
|
||||
</div>
|
||||
<div class="ca-shared-grid">
|
||||
<div class="ca-shared-field">
|
||||
<span class="f-label">Absender</span>
|
||||
<div class="f-input filled">Hans Müller</div>
|
||||
</div>
|
||||
<div class="ca-shared-field">
|
||||
<span class="f-label">Empfänger</span>
|
||||
<div class="f-input filled">Anna Schmidt</div>
|
||||
</div>
|
||||
<div class="ca-shared-field">
|
||||
<span class="f-label">Datum</span>
|
||||
<div class="f-input filled">1950-06</div>
|
||||
</div>
|
||||
<div class="ca-shared-field">
|
||||
<span class="f-label">Ort</span>
|
||||
<div class="f-input empty">Berlin</div>
|
||||
</div>
|
||||
<div class="ca-shared-field full">
|
||||
<span class="f-label">Tags</span>
|
||||
<div class="f-tags">
|
||||
<span class="f-chip">Familie <span class="f-chip-rm">×</span></span>
|
||||
<span class="f-chip">Krieg <span class="f-chip-rm">×</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- files list -->
|
||||
<div class="ca-files-head">
|
||||
<div class="ca-files-title">5 Dateien · Titel bearbeiten</div>
|
||||
</div>
|
||||
<div class="ca-file active">
|
||||
<div class="ca-thumb"><div class="tl h"></div><div class="tl"></div><div class="tl m"></div><div class="tl s"></div></div>
|
||||
<div class="ca-file-body">
|
||||
<div class="ca-file-title">Brief_1940_Hans</div>
|
||||
<div class="ca-file-meta">Brief_1940_Hans.pdf · 2.4 MB</div>
|
||||
</div>
|
||||
<div class="ca-file-rm">✕</div>
|
||||
</div>
|
||||
<div class="ca-file">
|
||||
<div class="ca-thumb"><div class="tl h"></div><div class="tl"></div><div class="tl"></div><div class="tl s"></div></div>
|
||||
<div class="ca-file-body">
|
||||
<div class="ca-file-title">Brief_1940_Anna</div>
|
||||
<div class="ca-file-meta">Brief_1940_Anna.pdf · 1.8 MB</div>
|
||||
</div>
|
||||
<div class="ca-file-rm">✕</div>
|
||||
</div>
|
||||
<div class="ca-file">
|
||||
<div class="ca-thumb"><div class="tl h"></div><div class="tl m"></div><div class="tl"></div><div class="tl"></div></div>
|
||||
<div class="ca-file-body">
|
||||
<div class="ca-file-title">Brief_1941_Clara</div>
|
||||
<div class="ca-file-meta">Brief_1941_Clara.pdf · 890 kB</div>
|
||||
</div>
|
||||
<div class="ca-file-rm">✕</div>
|
||||
</div>
|
||||
<div class="ca-file">
|
||||
<div class="ca-thumb"><div class="tl h"></div><div class="tl"></div><div class="tl s"></div><div class="tl m"></div></div>
|
||||
<div class="ca-file-body">
|
||||
<div class="ca-file-title placeholder">Postkarte_Venedig</div>
|
||||
<div class="ca-file-meta">Postkarte_Venedig.jpg · 1.1 MB</div>
|
||||
</div>
|
||||
<div class="ca-file-rm">✕</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="action-bar">
|
||||
<div class="btn-skip">Alle verwerfen</div>
|
||||
<div class="btn-spacer"></div>
|
||||
<div class="btn-outline">Als Platzhalter</div>
|
||||
<div class="btn-primary">5 speichern →</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ════════════════════════════════════════════ -->
|
||||
<!-- ═══ CONCEPT B — SPLIT-PANEL + SWITCHER ══ -->
|
||||
<!-- ════════════════════════════════════════════ -->
|
||||
<section class="concept">
|
||||
<div class="concept-header">
|
||||
<div class="concept-num">B</div>
|
||||
<div>
|
||||
<div class="concept-label">Concept B · RECOMMENDED</div>
|
||||
<div class="concept-title">Split-Panel with File Switcher</div>
|
||||
<p class="concept-desc">
|
||||
Reuses the <em>DocumentEditLayout</em> from issue #294 and adds a horizontal file-switcher strip
|
||||
under the PDF preview. Right column splits into two cards: <em>Gilt nur für diese Datei</em>
|
||||
(title only, mint accent) and <em>Gilt für alle N Dokumente</em> (everything else).
|
||||
When N=1 the switcher disappears and the screen is byte-identical to #294.
|
||||
</p>
|
||||
<div class="concept-best">
|
||||
<span class="best-label">Best for</span>
|
||||
<span class="best-text">The project's primary use case. Desktop + tablet, matches #294 DNA.</span>
|
||||
</div>
|
||||
<div class="concept-tradeoff">
|
||||
Trade-off: on mobile the split has to collapse into tabs ("Vorschau / Angaben"). We reuse the
|
||||
same responsive pattern that DocumentEditLayout already ships with.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- desktop mockup -->
|
||||
<div class="screen">
|
||||
<div class="chrome">
|
||||
<div class="chrome-bar">
|
||||
<div class="chrome-dot"></div><div class="chrome-dot"></div><div class="chrome-dot"></div>
|
||||
<div class="chrome-url"></div>
|
||||
<div class="viewport-hint">1280 · desktop</div>
|
||||
</div>
|
||||
<div class="app-nav">
|
||||
<div class="app-logo">Familienarchiv</div>
|
||||
<div class="app-link on">Dokumente</div>
|
||||
<div class="app-link">Personen</div>
|
||||
<div class="app-link">Briefwechsel</div>
|
||||
<div class="app-link">Chronik</div>
|
||||
<div class="app-nav-r">
|
||||
<div class="app-avatar">MR</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cb-top-bar">
|
||||
<div class="cb-back">← Dokumente</div>
|
||||
<div class="cb-title">Neue Dokumente</div>
|
||||
<div class="cb-count">5 werden erstellt</div>
|
||||
<div class="cb-discard">Alle verwerfen</div>
|
||||
</div>
|
||||
|
||||
<div class="cb-split">
|
||||
<!-- PDF side -->
|
||||
<div class="cb-pdf">
|
||||
<div class="cb-pdf-toolbar">
|
||||
<div class="cb-pdf-btn">◀</div>
|
||||
<div class="cb-pdf-btn">▶</div>
|
||||
<div class="cb-pdf-btn">+</div>
|
||||
<div class="cb-pdf-btn">−</div>
|
||||
<div class="cb-pdf-page">Seite 1 / 2 · Datei 1 von 5</div>
|
||||
</div>
|
||||
<div class="cb-pdf-view">
|
||||
<div class="cb-paper">
|
||||
<div class="pl h"></div><div class="pl h s"></div><div class="pl sp"></div>
|
||||
<div class="pl"></div><div class="pl m"></div><div class="pl"></div>
|
||||
<div class="pl s"></div><div class="pl sp"></div>
|
||||
<div class="pl"></div><div class="pl m"></div><div class="pl"></div>
|
||||
<div class="pl"></div><div class="pl s"></div><div class="pl sp"></div>
|
||||
<div class="pl"></div><div class="pl m"></div><div class="pl s"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- file switcher -->
|
||||
<div class="cb-filebar">
|
||||
<div class="cb-fb-arrow">‹</div>
|
||||
<div class="cb-fb-track">
|
||||
<div class="cb-fb-item on"><span class="cb-fb-num">1</span> Brief_1940_Hans.pdf</div>
|
||||
<div class="cb-fb-item"><span class="cb-fb-num">2</span> Brief_1940_Anna.pdf</div>
|
||||
<div class="cb-fb-item"><span class="cb-fb-num">3</span> Brief_1941_Clara.pdf</div>
|
||||
<div class="cb-fb-item"><span class="cb-fb-num">4</span> Postkarte_Venedig.jpg</div>
|
||||
<div class="cb-fb-item"><span class="cb-fb-num">5</span> Urkunde_1942.pdf</div>
|
||||
</div>
|
||||
<div class="cb-fb-arrow">›</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Form side -->
|
||||
<div class="cb-form">
|
||||
<div class="cb-form-scroll">
|
||||
<!-- PER-FILE card -->
|
||||
<div class="cb-only-card">
|
||||
<div class="cb-only-head">
|
||||
<span class="cb-only-badge">Nur diese Datei</span>
|
||||
<span class="cb-only-subtitle">1 / 5 · Brief_1940_Hans.pdf</span>
|
||||
</div>
|
||||
<div class="cb-row full">
|
||||
<div class="cb-field">
|
||||
<span class="f-label">Titel <span class="f-req">*</span></span>
|
||||
<div class="f-input filled tall">Brief an Anna, 1940</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SHARED card -->
|
||||
<div class="cb-shared-card">
|
||||
<div class="cb-shared-head">
|
||||
<span class="cb-shared-badge">Gilt für alle 5</span>
|
||||
<span class="cb-shared-subtitle">Gemeinsame Angaben</span>
|
||||
</div>
|
||||
<div class="cb-row">
|
||||
<div class="cb-field">
|
||||
<span class="f-label">Absender</span>
|
||||
<div class="f-input filled">Hans Müller</div>
|
||||
</div>
|
||||
<div class="cb-field">
|
||||
<span class="f-label">Empfänger</span>
|
||||
<div class="f-input filled">Anna Schmidt</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cb-row">
|
||||
<div class="cb-field">
|
||||
<span class="f-label">Datum</span>
|
||||
<div class="f-input filled">15.06.1950</div>
|
||||
</div>
|
||||
<div class="cb-field">
|
||||
<span class="f-label">Ort</span>
|
||||
<div class="f-input empty">z.B. Berlin</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cb-row full">
|
||||
<div class="cb-field">
|
||||
<span class="f-label">Tags</span>
|
||||
<div class="f-tags">
|
||||
<span class="f-chip">Familie <span class="f-chip-rm">×</span></span>
|
||||
<span class="f-chip">Krieg <span class="f-chip-rm">×</span></span>
|
||||
<span class="f-chip">Briefwechsel <span class="f-chip-rm">×</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cb-row">
|
||||
<div class="cb-field">
|
||||
<span class="f-label">Archivbox</span>
|
||||
<div class="f-input empty">z.B. B-12</div>
|
||||
</div>
|
||||
<div class="cb-field">
|
||||
<span class="f-label">Mappe</span>
|
||||
<div class="f-input empty">z.B. M-3</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="action-bar">
|
||||
<div class="btn-skip">Alle verwerfen</div>
|
||||
<div class="btn-spacer"></div>
|
||||
<div class="btn-outline">Als Platzhalter</div>
|
||||
<div class="btn-primary green">5 speichern →</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ════════════════════════════════════════════ -->
|
||||
<!-- ══ CONCEPT C — PROGRESSIVE ACCORDION ══ -->
|
||||
<!-- ════════════════════════════════════════════ -->
|
||||
<section class="concept">
|
||||
<div class="concept-header">
|
||||
<div class="concept-num">C</div>
|
||||
<div>
|
||||
<div class="concept-label">Concept C</div>
|
||||
<div class="concept-title">Progressive Accordion — shared sticky header · file cards expand inline</div>
|
||||
<p class="concept-desc">
|
||||
Shared metadata sticks at the top of the page. Below, each file is a collapsed card; clicking
|
||||
a card expands it to show the PDF preview + title field inline. Only one card is expanded at a
|
||||
time. Scales well to 20+ files — the list stays readable, you only look at the PDFs you want
|
||||
to verify.
|
||||
</p>
|
||||
<div class="concept-best">
|
||||
<span class="best-label">Best for</span>
|
||||
<span class="best-text">Large batches (10+ files) where you want to spot-check a few.</span>
|
||||
</div>
|
||||
<div class="concept-tradeoff">
|
||||
Trade-off: two different visual languages — cards collapsed vs. cards expanded with PDF. New
|
||||
pattern for the project; costs familiarity.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="screen">
|
||||
<div class="chrome">
|
||||
<div class="chrome-bar">
|
||||
<div class="chrome-dot"></div><div class="chrome-dot"></div><div class="chrome-dot"></div>
|
||||
<div class="chrome-url"></div>
|
||||
<div class="viewport-hint">1280 · desktop</div>
|
||||
</div>
|
||||
<div class="app-nav">
|
||||
<div class="app-logo">Familienarchiv</div>
|
||||
<div class="app-link on">Dokumente</div>
|
||||
<div class="app-link">Personen</div>
|
||||
<div class="app-nav-r"><div class="app-avatar">MR</div></div>
|
||||
</div>
|
||||
<div class="cc-top-bar">
|
||||
<div class="ca-back">← Zurück</div>
|
||||
<div class="ca-title">Neue Dokumente</div>
|
||||
<div class="ca-count">5</div>
|
||||
</div>
|
||||
|
||||
<div class="cc-body">
|
||||
<!-- sticky shared card -->
|
||||
<div class="cc-shared">
|
||||
<div class="cc-shared-head">
|
||||
<span class="cc-shared-badge">Gilt für alle 5</span>
|
||||
<span class="cc-shared-title">Gemeinsame Angaben</span>
|
||||
</div>
|
||||
<div class="cc-grid">
|
||||
<div class="cb-field"><span class="f-label">Absender</span><div class="f-input filled">Hans Müller</div></div>
|
||||
<div class="cb-field"><span class="f-label">Empfänger</span><div class="f-input filled">Anna Schmidt</div></div>
|
||||
<div class="cb-field"><span class="f-label">Datum</span><div class="f-input filled">15.06.1950</div></div>
|
||||
<div class="cb-field span2"><span class="f-label">Tags</span><div class="f-tags"><span class="f-chip">Familie <span class="f-chip-rm">×</span></span><span class="f-chip">Krieg <span class="f-chip-rm">×</span></span></div></div>
|
||||
<div class="cb-field"><span class="f-label">Ort</span><div class="f-input empty">z.B. Berlin</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cc-files-label">5 Dateien</div>
|
||||
|
||||
<!-- collapsed card -->
|
||||
<div class="cc-file">
|
||||
<div class="cc-file-head">
|
||||
<div class="cc-caret">▸</div>
|
||||
<div class="cc-file-thumb"><div class="tl"></div><div class="tl"></div><div class="tl"></div></div>
|
||||
<div class="cc-file-body">
|
||||
<div class="cc-file-titlerow">
|
||||
<div class="cc-file-title">Brief an Anna, 1940</div>
|
||||
</div>
|
||||
<div class="cc-file-meta">Brief_1940_Hans.pdf · 2.4 MB</div>
|
||||
</div>
|
||||
<div class="cc-file-rm">✕</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- expanded card -->
|
||||
<div class="cc-file open">
|
||||
<div class="cc-file-head open">
|
||||
<div class="cc-caret" style="color:#002850">▾</div>
|
||||
<div class="cc-file-thumb"><div class="tl"></div><div class="tl"></div><div class="tl"></div></div>
|
||||
<div class="cc-file-body">
|
||||
<div class="cc-file-titlerow">
|
||||
<div class="cc-file-title">Brief von Anna, Antwort</div>
|
||||
</div>
|
||||
<div class="cc-file-meta">Brief_1940_Anna.pdf · 1.8 MB</div>
|
||||
</div>
|
||||
<div class="cc-file-rm">✕</div>
|
||||
</div>
|
||||
<div class="cc-file-open">
|
||||
<div class="cc-preview">
|
||||
<div class="cc-preview-paper">
|
||||
<div class="pl h"></div><div class="pl h s"></div><div class="pl sp"></div>
|
||||
<div class="pl"></div><div class="pl m"></div><div class="pl s"></div>
|
||||
<div class="pl sp"></div>
|
||||
<div class="pl"></div><div class="pl"></div><div class="pl m"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cc-file-form">
|
||||
<div class="cb-only-head">
|
||||
<span class="cb-only-badge">Nur diese Datei</span>
|
||||
<span class="cb-only-subtitle">2 / 5</span>
|
||||
</div>
|
||||
<div class="cb-field">
|
||||
<span class="f-label">Titel <span class="f-req">*</span></span>
|
||||
<div class="f-input filled tall">Brief von Anna, Antwort</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- more collapsed -->
|
||||
<div class="cc-file">
|
||||
<div class="cc-file-head">
|
||||
<div class="cc-caret">▸</div>
|
||||
<div class="cc-file-thumb"><div class="tl"></div><div class="tl"></div><div class="tl"></div></div>
|
||||
<div class="cc-file-body">
|
||||
<div class="cc-file-titlerow">
|
||||
<div class="cc-file-title placeholder">Brief_1941_Clara</div>
|
||||
</div>
|
||||
<div class="cc-file-meta">Brief_1941_Clara.pdf · 890 kB</div>
|
||||
</div>
|
||||
<div class="cc-file-rm">✕</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cc-file">
|
||||
<div class="cc-file-head">
|
||||
<div class="cc-caret">▸</div>
|
||||
<div class="cc-file-thumb"><div class="tl"></div><div class="tl"></div><div class="tl"></div></div>
|
||||
<div class="cc-file-body">
|
||||
<div class="cc-file-titlerow">
|
||||
<div class="cc-file-title placeholder">Postkarte_Venedig</div>
|
||||
</div>
|
||||
<div class="cc-file-meta">Postkarte_Venedig.jpg · 1.1 MB</div>
|
||||
</div>
|
||||
<div class="cc-file-rm">✕</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cc-file">
|
||||
<div class="cc-file-head">
|
||||
<div class="cc-caret">▸</div>
|
||||
<div class="cc-file-thumb"><div class="tl"></div><div class="tl"></div><div class="tl"></div></div>
|
||||
<div class="cc-file-body">
|
||||
<div class="cc-file-titlerow">
|
||||
<div class="cc-file-title placeholder">Urkunde_1942</div>
|
||||
</div>
|
||||
<div class="cc-file-meta">Urkunde_1942.pdf · 3.1 MB</div>
|
||||
</div>
|
||||
<div class="cc-file-rm">✕</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="action-bar">
|
||||
<div class="btn-skip">Alle verwerfen</div>
|
||||
<div class="btn-spacer"></div>
|
||||
<div class="btn-outline">Als Platzhalter</div>
|
||||
<div class="btn-primary green">5 speichern →</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ════════════════════════════════════════════ -->
|
||||
<!-- ══════════ DECISION MATRIX ════════════ -->
|
||||
<!-- ════════════════════════════════════════════ -->
|
||||
<div class="decision">
|
||||
<h2>Decision matrix</h2>
|
||||
<p class="lead">
|
||||
All three concepts meet the core requirement (shared metadata + per-file title + one save).
|
||||
Graded against what matters for the senior audience, the responsive constraint, and the #294
|
||||
architectural commitment.
|
||||
</p>
|
||||
<table class="dm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Dimension</th>
|
||||
<th>A · Stack</th>
|
||||
<th>B · Split-Panel</th>
|
||||
<th>C · Accordion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Reuses #294 layout</td>
|
||||
<td class="score bad">✕</td>
|
||||
<td class="score ok">✓</td>
|
||||
<td class="score bad">✕</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Single-file mode unchanged</td>
|
||||
<td class="score mid">rewrite</td>
|
||||
<td class="score ok">identical</td>
|
||||
<td class="score bad">different</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>PDF visible before save</td>
|
||||
<td class="score bad">no</td>
|
||||
<td class="score ok">always</td>
|
||||
<td class="score mid">one at a time</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Works at 320px</td>
|
||||
<td class="score ok">native</td>
|
||||
<td class="score mid">via tab collapse</td>
|
||||
<td class="score ok">native</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Scales to 20 files</td>
|
||||
<td class="score mid">long scroll</td>
|
||||
<td class="score ok">switcher scrolls</td>
|
||||
<td class="score ok">collapsed list</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>New Svelte components</td>
|
||||
<td class="score bad">3 new</td>
|
||||
<td class="score ok">1 new (switcher)</td>
|
||||
<td class="score bad">4 new</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Familiar pattern</td>
|
||||
<td class="score ok">yes</td>
|
||||
<td class="score ok">yes (post-#294)</td>
|
||||
<td class="score mid">new to app</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- ════════════════════════════════════════════ -->
|
||||
<!-- ══════════ RECOMMENDATION ════════════ -->
|
||||
<!-- ════════════════════════════════════════════ -->
|
||||
<div class="reco">
|
||||
<div class="kicker">Recommendation</div>
|
||||
<h2>Ship Concept B</h2>
|
||||
<p class="why">
|
||||
Concept B treats bulk upload as a <em>polymorphic state</em> of the existing single-document
|
||||
layout rather than a separate screen. A user who drops one file gets exactly the #294 experience.
|
||||
A user who drops five gets the same screen plus a horizontal file-switcher and a two-card split
|
||||
(<em>Nur diese Datei</em> vs. <em>Gilt für alle</em>). Nothing about the single-file flow changes.
|
||||
</p>
|
||||
<ul>
|
||||
<li>Keeps the mental model: "one form, one save" regardless of file count.</li>
|
||||
<li>PDF preview is persistent — you can spot-check each scan before committing.</li>
|
||||
<li>The per-file title is visually promoted with a mint border so it reads as the one thing that differs per file.</li>
|
||||
<li>Reuses DocumentEditLayout: the delta is ~1 new component (<code>FileSwitcherStrip</code>) + two cards in the form.</li>
|
||||
<li>Single-file mode is byte-identical to #294 — no regression risk for existing users.</li>
|
||||
<li>Backend is already ready (<code>POST /api/documents/quick-upload</code> accepts N files in one multipart).</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- ════════════════════════════════════════════ -->
|
||||
<!-- ══════════ IMPL-REF · CONCEPT B ═══════ -->
|
||||
<!-- ════════════════════════════════════════════ -->
|
||||
<div class="impl">
|
||||
<h2>Implementation reference — Concept B</h2>
|
||||
|
||||
<h3>Top bar (when N > 1)</h3>
|
||||
<table class="impl-table">
|
||||
<tr><th>Element</th><th>Tailwind</th><th>Px / value</th><th>Note</th></tr>
|
||||
<tr>
|
||||
<td>Count pill "N werden erstellt"</td>
|
||||
<td><code>bg-accent text-primary rounded-full px-3 py-1 text-sm font-bold</code></td>
|
||||
<td class="px">14px · 700</td>
|
||||
<td class="note">brand-mint on brand-navy</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>"Alle verwerfen" link</td>
|
||||
<td><code>ml-auto text-sm font-bold text-red-600 hover:text-red-800 focus-visible:outline-2 focus-visible:outline-red-600</code></td>
|
||||
<td class="px">14px / 44px target</td>
|
||||
<td class="note">confirm dialog before wiping</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h3 class="ix">FileSwitcherStrip (new component)</h3>
|
||||
<table class="impl-table">
|
||||
<tr><th>Element</th><th>Tailwind</th><th>Px / value</th><th>Note</th></tr>
|
||||
<tr>
|
||||
<td>Strip container</td>
|
||||
<td><code>flex items-center gap-1 bg-ink/95 px-2 py-2 border-t border-ink/80</code></td>
|
||||
<td class="px">height 48px</td>
|
||||
<td class="note">under the PDF toolbar, on the dark panel</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Arrow buttons</td>
|
||||
<td><code>h-10 w-10 rounded-sm bg-white/8 text-surface/60 hover:bg-white/15 focus-visible:outline-2</code></td>
|
||||
<td class="px">40×40 (44 w/padding)</td>
|
||||
<td class="note"><code>aria-label="Vorherige Datei"</code> / "Nächste Datei"</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>File chip (inactive)</td>
|
||||
<td><code>px-3 py-2 rounded-sm bg-white/6 text-sm font-bold text-surface/55 whitespace-nowrap hover:bg-white/12</code></td>
|
||||
<td class="px">14px / h 40px</td>
|
||||
<td class="note">horizontal scroll container uses <code>snap-x snap-mandatory</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>File chip (active)</td>
|
||||
<td><code>... bg-accent text-primary</code> + <code>aria-current="true"</code></td>
|
||||
<td class="px">14px / h 40px</td>
|
||||
<td class="note">mint pill, primary text — 7.2:1 contrast passes AAA</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Chip number prefix</td>
|
||||
<td><code>bg-primary/25 rounded-sm px-1 mr-2 text-xs font-extrabold</code></td>
|
||||
<td class="px">12px / 800</td>
|
||||
<td class="note">"1", "2", … — for quick scanning</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h3 class="ix">"Nur diese Datei" card (per-file scope)</h3>
|
||||
<table class="impl-table">
|
||||
<tr><th>Element</th><th>Tailwind</th><th>Px / value</th><th>Note</th></tr>
|
||||
<tr>
|
||||
<td>Card container</td>
|
||||
<td><code>bg-accent/20 border border-accent rounded-sm p-4 mb-4</code></td>
|
||||
<td class="px">padding 16px</td>
|
||||
<td class="note">mint tint signals "different per file"</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Scope badge</td>
|
||||
<td><code>bg-primary/90 text-accent rounded-sm px-2 py-1 text-xs font-extrabold uppercase tracking-wide</code></td>
|
||||
<td class="px">12px · 800</td>
|
||||
<td class="note">Paraglide key: <code>bulk_only_this_file</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Title input</td>
|
||||
<td><code>h-11 text-base font-semibold text-ink bg-white border border-line rounded-sm px-3 focus-visible:border-ink focus-visible:ring-2 focus-visible:ring-ink/20</code></td>
|
||||
<td class="px">44px min-height · 16px</td>
|
||||
<td class="note">pre-filled from filename <em>without extension</em></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h3 class="ix">"Gilt für alle" card (shared scope)</h3>
|
||||
<table class="impl-table">
|
||||
<tr><th>Element</th><th>Tailwind</th><th>Px / value</th><th>Note</th></tr>
|
||||
<tr>
|
||||
<td>Card container</td>
|
||||
<td><code>bg-surface border border-line rounded-sm p-4 mb-3</code></td>
|
||||
<td class="px">padding 16px</td>
|
||||
<td class="note">neutral (no accent tint)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Scope badge</td>
|
||||
<td><code>bg-accent text-primary rounded-sm px-2 py-1 text-xs font-extrabold uppercase tracking-wide</code></td>
|
||||
<td class="px">12px · 800</td>
|
||||
<td class="note">Paraglide: <code>bulk_shared_count</code> ("Gilt für alle {count}")</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Field grid</td>
|
||||
<td><code>grid grid-cols-1 md:grid-cols-2 gap-3</code></td>
|
||||
<td class="px">12px gap</td>
|
||||
<td class="note">single column at 320px, two at ≥ 768px</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h3 class="ix">Save bar</h3>
|
||||
<table class="impl-table">
|
||||
<tr><th>Element</th><th>Tailwind</th><th>Px / value</th><th>Note</th></tr>
|
||||
<tr>
|
||||
<td>Primary save button</td>
|
||||
<td><code>h-11 px-5 bg-green-700 hover:bg-green-800 text-white font-extrabold rounded-sm text-sm focus-visible:ring-2 focus-visible:ring-green-900</code></td>
|
||||
<td class="px">44px min · 14px</td>
|
||||
<td class="note">label <code>{count} speichern →</code> (plural-aware Paraglide)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>"Als Platzhalter" (outline)</td>
|
||||
<td><code>h-11 px-4 border border-line bg-white text-ink-3 font-bold rounded-sm text-sm</code></td>
|
||||
<td class="px">44px</td>
|
||||
<td class="note">posts with <code>metadataComplete=false</code> for all</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h3 class="ix">Responsive collapse (≤ 767px)</h3>
|
||||
<table class="impl-table">
|
||||
<tr><th>Element</th><th>Tailwind</th><th>Px / value</th><th>Note</th></tr>
|
||||
<tr>
|
||||
<td>Panel mode switch</td>
|
||||
<td>reuses DocumentEditLayout's existing tab collapse — "Vorschau / Angaben" tabs</td>
|
||||
<td class="px">tab height 48px</td>
|
||||
<td class="note">already shipped with #294</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>File switcher stays on "Vorschau" tab</td>
|
||||
<td><code>snap-x snap-mandatory overflow-x-auto</code></td>
|
||||
<td class="px">h 44px</td>
|
||||
<td class="note">horizontal swipe; arrow buttons removed at mobile</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<div class="notes">
|
||||
<div class="nh">Interactions + behaviour</div>
|
||||
<ul>
|
||||
<li><strong>Drop a file after the initial batch</strong>: append to the end of the list and switch focus to the newly added file. No modal, no confirmation.</li>
|
||||
<li><strong>Remove a file</strong> (X on the chip) → confirm only if it's the currently-previewed one; otherwise silent. When count drops to 1 the switcher strip animates away (200ms); when it drops to 0 we redirect back to the drop-zone state.</li>
|
||||
<li><strong>Title auto-fill</strong>: <code>filename.replace(/\.(pdf|jpe?g|png|tiff?)$/i, '').replace(/[_-]+/g, ' ').trim()</code>. Marks the title input as <code>suggested</code> until the user edits it (mint left border, same treatment as #294's filename-derived fields).</li>
|
||||
<li><strong>Title field visibility</strong>: always rendered (never collapsed) even in single-file mode, so there's zero layout jump when N changes from 1 to 2.</li>
|
||||
<li><strong>Save flow</strong>: single POST to <code>/api/documents/quick-upload</code> with N files + JSON metadata object containing shared fields + titles array. Backend maps title[i] to files[i] by index. Response splits into <code>created[] / updated[] / errors[]</code> — show a summary toast + inline error markers per file for the <code>errors[]</code> list.</li>
|
||||
<li><strong>Keyboard navigation</strong>: <kbd>←</kbd>/<kbd>→</kbd> on the switcher strip moves file focus; <kbd>Tab</kbd> cycles through form fields inside whichever card is active; <kbd>Esc</kbd> on the discard button opens the confirm dialog.</li>
|
||||
<li><strong>Focus management on file switch</strong>: when the user clicks a different file, the title input of the new file receives focus automatically (so the main editable field is always reachable).</li>
|
||||
<li><strong>Progress indicator during save</strong>: replace the save button with a determinate progress bar showing "Lade Datei 3 von 5…" for batches that take > 500ms.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="notes" style="margin-top:14px;border-left-color:#C0392B">
|
||||
<div class="nh" style="color:#C0392B">Edge cases + a11y</div>
|
||||
<ul>
|
||||
<li><strong>Duplicate filenames in the batch</strong>: accept, but show a warning icon next to both — backend will create both with unique IDs.</li>
|
||||
<li><strong>Mixed content types</strong>: PDF + image in the same batch is fine; the preview panel renders whichever the active file is (DocumentEditLayout already handles both).</li>
|
||||
<li><strong>Large batches (> 20 files)</strong>: the switcher strip becomes scrollable; consider a "Jump to file…" combobox at > 30 files (out of scope for v1).</li>
|
||||
<li><strong>Upload failure per file</strong>: mark the chip red (<code>bg-red-600/20 text-red-800 border border-red-600</code>), show inline error in the chip's tooltip, don't block the rest of the batch from retrying.</li>
|
||||
<li><strong>Screen reader announcement</strong>: when file count changes, fire a polite live region announce — "5 Dateien bereit zum Speichern" via <code>role="status" aria-live="polite"</code>.</li>
|
||||
<li><strong>Colour-alone warning</strong>: active file chip uses color + <code>aria-current="true"</code> + a ▸ caret prefix so it's distinguishable for color-blind users.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
1684
docs/specs/bulk-upload-split-panel-spec.html
Normal file
1684
docs/specs/bulk-upload-split-panel-spec.html
Normal file
File diff suppressed because it is too large
Load Diff
1146
docs/specs/transkriptions-richtlinien-spec.html
Normal file
1146
docs/specs/transkriptions-richtlinien-spec.html
Normal file
File diff suppressed because it is too large
Load Diff
30
frontend/e2e/help-popover.spec.ts
Normal file
30
frontend/e2e/help-popover.spec.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { createEmptyDocument } from './helpers/upload-empty-document.js';
|
||||
|
||||
test.describe('Help chip — Read/Edit panel header', () => {
|
||||
let docId: string;
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
docId = await createEmptyDocument(request);
|
||||
});
|
||||
|
||||
test('opens popover on click, closes on Esc, returns focus to chip', async ({ page }) => {
|
||||
await page.goto(`/documents/${docId}`);
|
||||
await page.getByRole('button', { name: 'Transkribieren' }).click();
|
||||
|
||||
// Find and click the (?) help chip
|
||||
const helpBtn = page.locator('button[aria-expanded]');
|
||||
await expect(helpBtn).toBeVisible({ timeout: 5000 });
|
||||
await helpBtn.click();
|
||||
|
||||
// Popover should open
|
||||
await expect(page.locator('[role="tooltip"]')).toBeVisible();
|
||||
|
||||
// Press Esc
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.locator('[role="tooltip"]')).not.toBeVisible();
|
||||
|
||||
// Focus should have returned to the chip
|
||||
await expect(helpBtn).toBeFocused();
|
||||
});
|
||||
});
|
||||
10
frontend/e2e/helpers/upload-empty-document.ts
Normal file
10
frontend/e2e/helpers/upload-empty-document.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { APIRequestContext } from '@playwright/test';
|
||||
|
||||
export async function createEmptyDocument(request: APIRequestContext): Promise<string> {
|
||||
const res = await request.post('/api/documents', {
|
||||
multipart: { title: 'E2E Transcribe Coach Test' }
|
||||
});
|
||||
if (!res.ok()) throw new Error(`Create document failed: ${res.status()}`);
|
||||
const doc = await res.json();
|
||||
return doc.id as string;
|
||||
}
|
||||
68
frontend/e2e/richtlinien.spec.ts
Normal file
68
frontend/e2e/richtlinien.spec.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
|
||||
function buildAxe(page: Parameters<typeof AxeBuilder>[0]['page']) {
|
||||
return new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa']);
|
||||
}
|
||||
|
||||
test.describe('Richtlinien page — content', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/hilfe/transkription');
|
||||
});
|
||||
|
||||
test('renders h1 title, intro, five rules, four chips, closing card', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByRole('heading', { level: 1, name: /Transkriptions-Richtlinien/ })
|
||||
).toBeVisible();
|
||||
await expect(page.getByText(/Damit alle Briefe einheitlich/)).toBeVisible();
|
||||
await expect(page.getByText('Nicht lesbare Wörter')).toBeVisible();
|
||||
await expect(page.getByText('Durchgestrichene Wörter')).toBeVisible();
|
||||
await expect(page.getByText(/Das lange s/)).toBeVisible();
|
||||
await expect(page.getByText('Unsichere Namen')).toBeVisible();
|
||||
await expect(page.getByText(/Dialekt/)).toBeVisible();
|
||||
await expect(page.getByText('Abkürzungen')).toBeVisible();
|
||||
await expect(page.getByText('Datumsformate')).toBeVisible();
|
||||
await expect(page.getByText(/Fehlt eine Regel/)).toBeVisible();
|
||||
});
|
||||
|
||||
test('Wikipedia link opens in new tab with annotation', async ({ page }) => {
|
||||
const wikiLink = page.getByRole('link', { name: /Wikipedia/ });
|
||||
await expect(wikiLink).toHaveAttribute('target', '_blank');
|
||||
await expect(wikiLink).toHaveAttribute('rel', 'noopener noreferrer');
|
||||
await expect(wikiLink).toHaveAttribute('referrerpolicy', 'no-referrer');
|
||||
await expect(wikiLink).toContainText(/öffnet in neuem Tab/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Richtlinien page — accessibility', () => {
|
||||
for (const viewport of [320, 768, 1440]) {
|
||||
test(`axe: light theme at ${viewport}px — no WCAG 2.1 AA violations`, async ({ page }) => {
|
||||
await page.setViewportSize({ width: viewport, height: 800 });
|
||||
await page.goto('/hilfe/transkription');
|
||||
const a11y = await buildAxe(page).analyze();
|
||||
expect(a11y.violations, JSON.stringify(a11y.violations, null, 2)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test(`axe: dark theme at ${viewport}px — no WCAG 2.1 AA violations`, async ({ page }) => {
|
||||
await page.setViewportSize({ width: viewport, height: 800 });
|
||||
await page.goto('/hilfe/transkription');
|
||||
await page.getByRole('button', { name: /Farbmodus|theme/i }).click();
|
||||
const a11y = await buildAxe(page).analyze();
|
||||
expect(a11y.violations, JSON.stringify(a11y.violations, null, 2)).toHaveLength(0);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test.describe('Richtlinien page — print media', () => {
|
||||
test('print snapshot hides nav, annotation chip, and new-tab spans', async ({ page }) => {
|
||||
await page.emulateMedia({ media: 'print' });
|
||||
await page.goto('/hilfe/transkription');
|
||||
|
||||
const nav = page.locator('.app-nav');
|
||||
if ((await nav.count()) > 0) {
|
||||
await expect(nav).toBeHidden();
|
||||
}
|
||||
|
||||
await page.screenshot({ path: 'test-results/e2e/richtlinien-print.png', fullPage: true });
|
||||
});
|
||||
});
|
||||
65
frontend/e2e/transcribe-coach.spec.ts
Normal file
65
frontend/e2e/transcribe-coach.spec.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
import { createEmptyDocument } from './helpers/upload-empty-document.js';
|
||||
|
||||
function buildAxe(page: Parameters<typeof AxeBuilder>[0]['page']) {
|
||||
return new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa']);
|
||||
}
|
||||
|
||||
test.describe('Transcribe coach — empty state', () => {
|
||||
let docId: string;
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
docId = await createEmptyDocument(request);
|
||||
});
|
||||
|
||||
test('shows coach card (title, preamble, three step bodies, animation region)', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
await page.goto(`/documents/${docId}`);
|
||||
await page.getByRole('button', { name: 'Transkribieren' }).click();
|
||||
|
||||
await expect(page.getByRole('heading', { level: 2, name: /Erste Transkription/ })).toBeVisible({
|
||||
timeout: 5000
|
||||
});
|
||||
await expect(page.getByText(/Kurrent-Erkenner lernt noch/)).toBeVisible();
|
||||
await expect(page.getByText(/Rahmen ziehen/)).toBeVisible();
|
||||
await expect(page.getByText(/Text eingeben/)).toBeVisible();
|
||||
await expect(page.getByText(/Speichert automatisch/)).toBeVisible();
|
||||
await expect(page.getByRole('img', { name: /Rahmen ziehen|Animation/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('training footer is NOT visible on empty doc', async ({ page }) => {
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
await page.goto(`/documents/${docId}`);
|
||||
await page.getByRole('button', { name: 'Transkribieren' }).click();
|
||||
await expect(page.getByText('Für Training vormerken')).not.toBeVisible({ timeout: 3000 });
|
||||
});
|
||||
|
||||
test('axe: panel empty state — light theme — no WCAG 2.1 AA violations', async ({ page }) => {
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
await page.goto(`/documents/${docId}`);
|
||||
await page.getByRole('button', { name: 'Transkribieren' }).click();
|
||||
await expect(page.getByRole('heading', { level: 2, name: /Erste Transkription/ })).toBeVisible({
|
||||
timeout: 5000
|
||||
});
|
||||
|
||||
const a11y = await buildAxe(page).analyze();
|
||||
expect(a11y.violations, JSON.stringify(a11y.violations, null, 2)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('axe: panel empty state — dark theme — no WCAG 2.1 AA violations', async ({ page }) => {
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
await page.goto(`/documents/${docId}`);
|
||||
// Toggle dark theme
|
||||
await page.getByRole('button', { name: /Farbmodus|theme/i }).click();
|
||||
await page.getByRole('button', { name: 'Transkribieren' }).click();
|
||||
await expect(page.getByRole('heading', { level: 2, name: /Erste Transkription/ })).toBeVisible({
|
||||
timeout: 5000
|
||||
});
|
||||
|
||||
const a11y = await buildAxe(page).analyze();
|
||||
expect(a11y.violations, JSON.stringify(a11y.violations, null, 2)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -499,7 +499,7 @@
|
||||
"transcription_block_delete_confirm": "Block und alle zugehörigen Kommentare wirklich löschen?",
|
||||
"transcription_block_history_btn": "Verlauf",
|
||||
"transcription_empty_cta": "Markiere einen Bereich auf dem Scan, um mit der Transkription zu beginnen",
|
||||
"transcription_next_block_cta": "Markiere eine weitere Passage im Scan, um Block {number} anzulegen",
|
||||
"transcription_next_block_cta": "Einen Rahmen ziehen, um Block {number} anzulegen",
|
||||
"transcription_draw_tooltip": "Klicken und ziehen, um einen Textbereich zu markieren",
|
||||
"transcription_quote_stale": "Zitat aus älterer Version",
|
||||
"transcription_block_conflict": "Dieser Block wurde von jemand anderem geändert — bitte neu laden",
|
||||
@@ -806,5 +806,73 @@
|
||||
"chronik_load_more": "Mehr laden",
|
||||
"chronik_loading": "Lädt …",
|
||||
"chronik_load_more_announcement": "{count} weitere Einträge geladen",
|
||||
"chronik_view_all": "Alle Aktivitäten →"
|
||||
"chronik_view_all": "Alle Aktivitäten →",
|
||||
"pagination_prev": "Zurück",
|
||||
"pagination_next": "Weiter",
|
||||
"pagination_page_of": "Seite {page} von {total}",
|
||||
"pagination_nav_label": "Seitennavigation",
|
||||
|
||||
"common_opens_new_tab": "(öffnet in neuem Tab)",
|
||||
|
||||
"transcribe_coach_title": "Erste Transkription?",
|
||||
"transcribe_coach_preamble": "Unser Kurrent-Erkenner lernt noch. Jede Transkription, die Sie zum Training freigeben, bringt ihm die Schrift bei — so funktioniert's:",
|
||||
"transcribe_coach_step_1_title": "Rahmen ziehen.",
|
||||
"transcribe_coach_step_1_body": "Klicken und ziehen Sie mit der Maus einen Rahmen um den Text, den Sie transkribieren möchten.",
|
||||
"transcribe_coach_step_2_title": "Text eingeben.",
|
||||
"transcribe_coach_step_2_body": "Geben Sie den Text, den Sie im Rahmen sehen, in das neue Textfeld ein.",
|
||||
"transcribe_coach_step_3_title": "Speichert automatisch.",
|
||||
"transcribe_coach_footer_kurrent": "Hilfe zu Kurrent ↗",
|
||||
"transcribe_coach_footer_richtlinien": "Transkriptions-Richtlinien ↗",
|
||||
|
||||
"transcription_mode_help_label": "Lese- und Bearbeitungsmodus",
|
||||
"transcription_mode_help_body": "Lesen zeigt die Transkription als fließenden Text. Bearbeiten öffnet die Textfelder für jede Passage.",
|
||||
|
||||
"richtlinien_title": "Transkriptions-Richtlinien",
|
||||
"richtlinien_intro": "Damit alle Briefe einheitlich transkribiert werden — egal ob Tante Hedwig oder Cousin Paul tippt — hier unsere Regeln. Die Seite wächst mit: sobald wir eine neue Konvention beschließen, landet sie hier.",
|
||||
"richtlinien_wiki_text": "Das vollständige Kurrent- und Sütterlin-Alphabet brauchen Sie für diese Seite nicht — das erledigt Wikipedia. Hier sind unsere eigenen Regeln für das, was Wikipedia nicht beantwortet.",
|
||||
"richtlinien_wiki_link": "Wikipedia →",
|
||||
"richtlinien_rules_label": "Regeln für die Transkription",
|
||||
"richtlinien_rule_unleserlich_title": "Nicht lesbare Wörter",
|
||||
"richtlinien_rule_unleserlich_body": "Wenn Sie ein Wort beim besten Willen nicht entziffern können, schreiben Sie [unleserlich]. Jemand anderes schaut später nochmal drauf.",
|
||||
"richtlinien_rule_durchgestrichen_title": "Durchgestrichene Wörter",
|
||||
"richtlinien_rule_durchgestrichen_body": "Auch durchgestrichener Text gehört zum Brief. Schreiben Sie ihn in eckigen Klammern mit Präfix durchgestrichen:",
|
||||
"richtlinien_rule_langes_s_title": "Das lange s (ſ)",
|
||||
"richtlinien_rule_langes_s_body": "Das ſ ist nur eine alte Schriftform des Buchstabens s — kein eigener Laut. Schreiben Sie immer ein normales s.",
|
||||
"richtlinien_rule_name_title": "Unsichere Namen",
|
||||
"richtlinien_rule_name_body": "Wenn Sie einen Namen zu erkennen meinen, aber nicht sicher sind, ergänzen Sie ein Fragezeichen in eckigen Klammern.",
|
||||
"richtlinien_rule_dialekt_title": "Dialekt, Fremdwörter, fremde Zitate",
|
||||
"richtlinien_rule_dialekt_body": "Plattdeutsch, Französisch, lateinische Phrasen — wörtlich übernehmen, genau wie sie geschrieben stehen.",
|
||||
"richtlinien_beispiel_label": "Beispiel",
|
||||
"richtlinien_klaerung_label": "Noch in Klärung",
|
||||
"richtlinien_klaerung_intro": "Diese Fragen klären wir noch — stoßen Sie beim Transkribieren darauf, treffen Sie eine plausible Wahl und notieren Sie es in den Kommentaren:",
|
||||
"richtlinien_klaer_abkuerzungen": "Abkürzungen",
|
||||
"richtlinien_klaer_datumsformate": "Datumsformate",
|
||||
"richtlinien_klaer_umbrueche": "Originale Zeilenumbrüche",
|
||||
"richtlinien_klaer_caps": "Alte Groß-/Kleinschreibung",
|
||||
"richtlinien_closing_title": "Fehlt eine Regel?",
|
||||
"richtlinien_closing_body": "Stolpern Sie beim Transkribieren über eine Situation, die hier nicht steht — schreiben Sie einen Kommentar beim betreffenden Block. Wir sammeln sie und besprechen sie beim nächsten Familientreffen.",
|
||||
"error_batch_too_large": "Zu viele Dateien auf einmal — bitte in Blöcken hochladen.",
|
||||
"bulk_drop_hint": "Eine oder mehrere Dateien ablegen",
|
||||
"bulk_drop_sub": "PDF · bis zu 50 MB pro Datei",
|
||||
"bulk_count_pill": "{count} werden erstellt",
|
||||
"bulk_save_cta_one": "Speichern →",
|
||||
"bulk_save_cta": "{count} speichern →",
|
||||
"bulk_discard_all": "Alle verwerfen",
|
||||
"bulk_discard_confirm": "Alle Dateien und eingegebenen Daten verwerfen? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
"bulk_add_more": "Weitere hinzufügen",
|
||||
"bulk_scope_per_file_label": "Nur diese Datei",
|
||||
"bulk_scope_shared_label": "Gilt für alle {count}",
|
||||
"bulk_title_suggested_hint": "Vorschlag aus Dateiname — zum Bearbeiten anklicken",
|
||||
"bulk_switcher_prev": "Vorherige Datei",
|
||||
"bulk_switcher_next": "Nächste Datei",
|
||||
"bulk_file_error_chip_label": "Fehler beim Hochladen",
|
||||
"bulk_upload_progress": "{done} von {total} hochgeladen",
|
||||
"bulk_partial_success": "{created} erstellt, {failed} fehlgeschlagen",
|
||||
"bulk_all_failed": "Alle Uploads fehlgeschlagen",
|
||||
"bulk_drop_desc": "Für jede Datei wird ein eigenes Dokument erstellt. Der Titel wird aus dem Dateinamen vorausgefüllt — alle anderen Felder gelten für alle gemeinsam.",
|
||||
"bulk_select_files": "Dateien auswählen",
|
||||
"bulk_drop_zone_label": "Dateien ablegen",
|
||||
"bulk_remove_file": "Entfernen",
|
||||
"bulk_title_single": "Neues Dokument",
|
||||
"bulk_title_multi": "Neue Dokumente"
|
||||
}
|
||||
|
||||
@@ -499,7 +499,7 @@
|
||||
"transcription_block_delete_confirm": "Really delete this block and all its comments?",
|
||||
"transcription_block_history_btn": "History",
|
||||
"transcription_empty_cta": "Mark a region on the scan to start transcribing",
|
||||
"transcription_next_block_cta": "Mark another passage on the scan to create block {number}",
|
||||
"transcription_next_block_cta": "Draw a frame on the scan to create block {number}",
|
||||
"transcription_draw_tooltip": "Click and drag to mark a text region",
|
||||
"transcription_quote_stale": "Quote from an older version",
|
||||
"transcription_block_conflict": "This block was changed by someone else — please reload",
|
||||
@@ -806,5 +806,73 @@
|
||||
"chronik_load_more": "Load more",
|
||||
"chronik_loading": "Loading …",
|
||||
"chronik_load_more_announcement": "{count} more entries loaded",
|
||||
"chronik_view_all": "All activity →"
|
||||
"chronik_view_all": "All activity →",
|
||||
"pagination_prev": "Previous",
|
||||
"pagination_next": "Next",
|
||||
"pagination_page_of": "Page {page} of {total}",
|
||||
"pagination_nav_label": "Pagination",
|
||||
|
||||
"common_opens_new_tab": "(opens in new tab)",
|
||||
|
||||
"transcribe_coach_title": "First transcription?",
|
||||
"transcribe_coach_preamble": "Our Kurrent recogniser is still learning. Every transcription you release for training teaches it the handwriting — here's how it works:",
|
||||
"transcribe_coach_step_1_title": "Draw a frame.",
|
||||
"transcribe_coach_step_1_body": "Click and drag a frame around the text you want to transcribe.",
|
||||
"transcribe_coach_step_2_title": "Enter the text.",
|
||||
"transcribe_coach_step_2_body": "Type the text you see inside the frame into the new text field.",
|
||||
"transcribe_coach_step_3_title": "Saves automatically.",
|
||||
"transcribe_coach_footer_kurrent": "Kurrent help ↗",
|
||||
"transcribe_coach_footer_richtlinien": "Transcription guidelines ↗",
|
||||
|
||||
"transcription_mode_help_label": "Read and edit mode",
|
||||
"transcription_mode_help_body": "Read shows the transcription as flowing text. Edit opens the text fields for each passage.",
|
||||
|
||||
"richtlinien_title": "Transcription Guidelines",
|
||||
"richtlinien_intro": "So every letter is transcribed consistently — whether Tante Hedwig or Cousin Paul is typing — here are our rules. The page grows with us: as soon as we agree a new convention, it lands here.",
|
||||
"richtlinien_wiki_text": "You don't need the full Kurrent and Sütterlin alphabet on this page — that's what Wikipedia is for. Here are our own rules for everything Wikipedia can't answer.",
|
||||
"richtlinien_wiki_link": "Wikipedia →",
|
||||
"richtlinien_rules_label": "Transcription rules",
|
||||
"richtlinien_rule_unleserlich_title": "Illegible words",
|
||||
"richtlinien_rule_unleserlich_body": "If you can't decipher a word even after trying, write [unleserlich]. Someone else will take another look later.",
|
||||
"richtlinien_rule_durchgestrichen_title": "Struck-through words",
|
||||
"richtlinien_rule_durchgestrichen_body": "Struck-through text still belongs to the letter. Write it in square brackets with prefix durchgestrichen:",
|
||||
"richtlinien_rule_langes_s_title": "The long s (ſ)",
|
||||
"richtlinien_rule_langes_s_body": "The ſ is just an old written form of the letter s — not a separate sound. Always write a normal s.",
|
||||
"richtlinien_rule_name_title": "Uncertain names",
|
||||
"richtlinien_rule_name_body": "If you think you can read a name but aren't sure, add a question mark in square brackets.",
|
||||
"richtlinien_rule_dialekt_title": "Dialect, foreign words, foreign quotes",
|
||||
"richtlinien_rule_dialekt_body": "Low German, French, Latin phrases — copy them verbatim, exactly as written.",
|
||||
"richtlinien_beispiel_label": "Example",
|
||||
"richtlinien_klaerung_label": "Still to be decided",
|
||||
"richtlinien_klaerung_intro": "These questions are still open — if you hit one while transcribing, make a plausible choice and note it in the comments:",
|
||||
"richtlinien_klaer_abkuerzungen": "Abbreviations",
|
||||
"richtlinien_klaer_datumsformate": "Date formats",
|
||||
"richtlinien_klaer_umbrueche": "Original line breaks",
|
||||
"richtlinien_klaer_caps": "Old capitalisation",
|
||||
"richtlinien_closing_title": "Missing a rule?",
|
||||
"richtlinien_closing_body": "If you hit a situation while transcribing that isn't listed here — leave a comment on the relevant block. We collect them and discuss them at the next family gathering.",
|
||||
"error_batch_too_large": "Too many files at once — please upload in smaller batches.",
|
||||
"bulk_drop_hint": "Drop one or more files here",
|
||||
"bulk_drop_sub": "PDF · up to 50 MB per file",
|
||||
"bulk_count_pill": "{count} will be created",
|
||||
"bulk_save_cta_one": "Save →",
|
||||
"bulk_save_cta": "Save {count} →",
|
||||
"bulk_discard_all": "Discard all",
|
||||
"bulk_discard_confirm": "Discard all files and entered data? This action cannot be undone.",
|
||||
"bulk_add_more": "Add more",
|
||||
"bulk_scope_per_file_label": "This file only",
|
||||
"bulk_scope_shared_label": "Applies to all {count}",
|
||||
"bulk_title_suggested_hint": "Suggested from filename — click to edit",
|
||||
"bulk_switcher_prev": "Previous file",
|
||||
"bulk_switcher_next": "Next file",
|
||||
"bulk_file_error_chip_label": "Upload failed",
|
||||
"bulk_upload_progress": "{done} of {total} uploaded",
|
||||
"bulk_partial_success": "{created} created, {failed} failed",
|
||||
"bulk_all_failed": "All uploads failed",
|
||||
"bulk_drop_desc": "A separate document is created for each file. The title is pre-filled from the filename — all other fields apply to all documents.",
|
||||
"bulk_select_files": "Select files",
|
||||
"bulk_drop_zone_label": "Drop files here",
|
||||
"bulk_remove_file": "Remove",
|
||||
"bulk_title_single": "New Document",
|
||||
"bulk_title_multi": "New Documents"
|
||||
}
|
||||
|
||||
@@ -499,7 +499,7 @@
|
||||
"transcription_block_delete_confirm": "¿Realmente eliminar este bloque y todos sus comentarios?",
|
||||
"transcription_block_history_btn": "Historial",
|
||||
"transcription_empty_cta": "Marque una región en el escaneo para comenzar a transcribir",
|
||||
"transcription_next_block_cta": "Marque otro pasaje en el escaneo para crear el bloque {number}",
|
||||
"transcription_next_block_cta": "Dibuje un marco en el escáner para crear el bloque {number}",
|
||||
"transcription_draw_tooltip": "Haga clic y arrastre para marcar una región de texto",
|
||||
"transcription_quote_stale": "Cita de una versión anterior",
|
||||
"transcription_block_conflict": "Este bloque fue cambiado por otra persona — por favor recargue",
|
||||
@@ -806,5 +806,73 @@
|
||||
"chronik_load_more": "Cargar más",
|
||||
"chronik_loading": "Cargando …",
|
||||
"chronik_load_more_announcement": "{count} entradas más cargadas",
|
||||
"chronik_view_all": "Todas las actividades →"
|
||||
"chronik_view_all": "Todas las actividades →",
|
||||
"pagination_prev": "Anterior",
|
||||
"pagination_next": "Siguiente",
|
||||
"pagination_page_of": "Página {page} de {total}",
|
||||
"pagination_nav_label": "Paginación",
|
||||
|
||||
"common_opens_new_tab": "(abre en pestaña nueva)",
|
||||
|
||||
"transcribe_coach_title": "¿Primera transcripción?",
|
||||
"transcribe_coach_preamble": "Nuestro reconocedor de Kurrent aún está aprendiendo. Cada transcripción que libera para el entrenamiento le enseña la escritura — así funciona:",
|
||||
"transcribe_coach_step_1_title": "Dibujar un marco.",
|
||||
"transcribe_coach_step_1_body": "Haga clic y arrastre un marco alrededor del texto que desea transcribir.",
|
||||
"transcribe_coach_step_2_title": "Ingresar el texto.",
|
||||
"transcribe_coach_step_2_body": "Escriba el texto que ve dentro del marco en el nuevo campo de texto.",
|
||||
"transcribe_coach_step_3_title": "Se guarda automáticamente.",
|
||||
"transcribe_coach_footer_kurrent": "Ayuda sobre Kurrent ↗",
|
||||
"transcribe_coach_footer_richtlinien": "Normas de transcripción ↗",
|
||||
|
||||
"transcription_mode_help_label": "Modo lectura y edición",
|
||||
"transcription_mode_help_body": "Lectura muestra la transcripción como texto continuo. Edición abre los campos de texto para cada pasaje.",
|
||||
|
||||
"richtlinien_title": "Normas de transcripción",
|
||||
"richtlinien_intro": "Para que todas las cartas se transcriban de forma uniforme — ya sea la tía Hedwig o el primo Paul quien escriba — aquí están nuestras reglas. La página crece con nosotros.",
|
||||
"richtlinien_wiki_text": "No necesitas el alfabeto Kurrent completo aquí — eso lo hace Wikipedia. Aquí están nuestras propias reglas para lo que Wikipedia no responde.",
|
||||
"richtlinien_wiki_link": "Wikipedia →",
|
||||
"richtlinien_rules_label": "Reglas de transcripción",
|
||||
"richtlinien_rule_unleserlich_title": "Palabras ilegibles",
|
||||
"richtlinien_rule_unleserlich_body": "Si no puedes descifrar una palabra, escribe [unleserlich]. Otra persona lo revisará después.",
|
||||
"richtlinien_rule_durchgestrichen_title": "Palabras tachadas",
|
||||
"richtlinien_rule_durchgestrichen_body": "El texto tachado también pertenece a la carta. Escríbelo entre corchetes con el prefijo durchgestrichen:",
|
||||
"richtlinien_rule_langes_s_title": "La s larga (ſ)",
|
||||
"richtlinien_rule_langes_s_body": "La ſ es solo una forma antigua de la letra s. Escribe siempre una s normal.",
|
||||
"richtlinien_rule_name_title": "Nombres inciertos",
|
||||
"richtlinien_rule_name_body": "Si crees reconocer un nombre pero no estás seguro, añade un signo de interrogación entre corchetes.",
|
||||
"richtlinien_rule_dialekt_title": "Dialecto, palabras extranjeras, citas",
|
||||
"richtlinien_rule_dialekt_body": "Bajo alemán, francés, frases latinas — cópialas tal cual están escritas.",
|
||||
"richtlinien_beispiel_label": "Ejemplo",
|
||||
"richtlinien_klaerung_label": "Aún por decidir",
|
||||
"richtlinien_klaerung_intro": "Estas preguntas aún están abiertas — si encuentras alguna mientras transcribes, elige algo razonable y nótalo en los comentarios:",
|
||||
"richtlinien_klaer_abkuerzungen": "Abreviaturas",
|
||||
"richtlinien_klaer_datumsformate": "Formatos de fecha",
|
||||
"richtlinien_klaer_umbrueche": "Saltos de línea originales",
|
||||
"richtlinien_klaer_caps": "Mayúsculas antiguas",
|
||||
"richtlinien_closing_title": "¿Falta una regla?",
|
||||
"richtlinien_closing_body": "Si al transcribir encuentras una situación que no está aquí — deja un comentario en el bloque. Las recogemos y las discutimos en la próxima reunión familiar.",
|
||||
"error_batch_too_large": "Demasiados archivos a la vez — sube en lotes más pequeños.",
|
||||
"bulk_drop_hint": "Suelta uno o varios archivos aquí",
|
||||
"bulk_drop_sub": "PDF · hasta 50 MB por archivo",
|
||||
"bulk_count_pill": "Se crearán {count}",
|
||||
"bulk_save_cta_one": "Guardar →",
|
||||
"bulk_save_cta": "Guardar {count} →",
|
||||
"bulk_discard_all": "Descartar todo",
|
||||
"bulk_discard_confirm": "¿Descartar todos los archivos y datos introducidos? Esta acción no se puede deshacer.",
|
||||
"bulk_add_more": "Añadir más",
|
||||
"bulk_scope_per_file_label": "Solo este archivo",
|
||||
"bulk_scope_shared_label": "Para todos los {count}",
|
||||
"bulk_title_suggested_hint": "Sugerencia del nombre de archivo — haz clic para editar",
|
||||
"bulk_switcher_prev": "Archivo anterior",
|
||||
"bulk_switcher_next": "Archivo siguiente",
|
||||
"bulk_file_error_chip_label": "Error al subir",
|
||||
"bulk_upload_progress": "{done} de {total} subidos",
|
||||
"bulk_partial_success": "{created} creados, {failed} fallidos",
|
||||
"bulk_all_failed": "Todos los uploads fallaron",
|
||||
"bulk_drop_desc": "Se crea un documento separado por archivo. El título se rellena desde el nombre del archivo — el resto de campos se aplican a todos.",
|
||||
"bulk_select_files": "Seleccionar archivos",
|
||||
"bulk_drop_zone_label": "Soltar archivos aquí",
|
||||
"bulk_remove_file": "Eliminar",
|
||||
"bulk_title_single": "Nuevo Documento",
|
||||
"bulk_title_multi": "Nuevos Documentos"
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ export default defineConfig({
|
||||
use: {
|
||||
baseURL: process.env.E2E_BASE_URL ?? 'http://localhost:3000',
|
||||
locale: 'de-DE', // ensures Accept-Language: de is sent so locale detection defaults to German
|
||||
reducedMotion: 'reduce', // prevents SMIL/CSS animations from flaking tests
|
||||
screenshot: 'on', // always capture screenshots
|
||||
video: 'retain-on-failure',
|
||||
trace: 'retain-on-failure'
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { thumbnailUrl } from '$lib/thumbnails';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type Doc = {
|
||||
id: string;
|
||||
thumbnailKey?: string;
|
||||
thumbnailGeneratedAt?: string;
|
||||
thumbnailAspect?: 'PORTRAIT' | 'LANDSCAPE';
|
||||
pageCount?: number;
|
||||
};
|
||||
type Doc = Pick<
|
||||
components['schemas']['Document'],
|
||||
'id' | 'thumbnailUrl' | 'thumbnailAspect' | 'pageCount'
|
||||
>;
|
||||
|
||||
let { doc }: { doc: Doc } = $props();
|
||||
|
||||
const url = $derived(thumbnailUrl(doc));
|
||||
const url = $derived(doc.thumbnailUrl ?? null);
|
||||
const aspect = $derived(doc.thumbnailAspect ?? 'PORTRAIT');
|
||||
const pageCount = $derived(doc.pageCount ?? 1);
|
||||
const tileClass = $derived(aspect === 'LANDSCAPE' ? 'h-[120px] w-[168px]' : 'h-[168px] w-[120px]');
|
||||
|
||||
@@ -12,8 +12,7 @@ describe('ConversationThumbnail', () => {
|
||||
render(ConversationThumbnail, {
|
||||
doc: {
|
||||
id: '1111',
|
||||
thumbnailKey: 'thumbnails/1111.jpg',
|
||||
thumbnailGeneratedAt: '2026-04-10T09:00:00Z',
|
||||
thumbnailUrl: '/api/documents/1111/thumbnail?v=2026-04-10T09%3A00%3A00Z',
|
||||
thumbnailAspect: 'PORTRAIT',
|
||||
pageCount: 1
|
||||
}
|
||||
@@ -29,7 +28,7 @@ describe('ConversationThumbnail', () => {
|
||||
render(ConversationThumbnail, {
|
||||
doc: {
|
||||
id: 'p1',
|
||||
thumbnailKey: 'thumbnails/p1.jpg',
|
||||
thumbnailUrl: '/api/documents/p1/thumbnail?v=2026-04-10T09%3A00%3A00Z',
|
||||
thumbnailAspect: 'PORTRAIT',
|
||||
pageCount: 1
|
||||
}
|
||||
@@ -43,7 +42,7 @@ describe('ConversationThumbnail', () => {
|
||||
render(ConversationThumbnail, {
|
||||
doc: {
|
||||
id: 'l1',
|
||||
thumbnailKey: 'thumbnails/l1.jpg',
|
||||
thumbnailUrl: '/api/documents/l1/thumbnail?v=2026-04-10T09%3A00%3A00Z',
|
||||
thumbnailAspect: 'LANDSCAPE',
|
||||
pageCount: 1
|
||||
}
|
||||
@@ -57,7 +56,7 @@ describe('ConversationThumbnail', () => {
|
||||
render(ConversationThumbnail, {
|
||||
doc: {
|
||||
id: 'n1',
|
||||
thumbnailKey: 'thumbnails/n1.jpg'
|
||||
thumbnailUrl: '/api/documents/n1/thumbnail?v=2026-04-10T09%3A00%3A00Z'
|
||||
}
|
||||
});
|
||||
|
||||
@@ -69,7 +68,7 @@ describe('ConversationThumbnail', () => {
|
||||
render(ConversationThumbnail, {
|
||||
doc: {
|
||||
id: 'm1',
|
||||
thumbnailKey: 'thumbnails/m1.jpg',
|
||||
thumbnailUrl: '/api/documents/m1/thumbnail?v=2026-04-10T09%3A00%3A00Z',
|
||||
thumbnailAspect: 'PORTRAIT',
|
||||
pageCount: 4
|
||||
}
|
||||
@@ -87,7 +86,7 @@ describe('ConversationThumbnail', () => {
|
||||
render(ConversationThumbnail, {
|
||||
doc: {
|
||||
id: 's1',
|
||||
thumbnailKey: 'thumbnails/s1.jpg',
|
||||
thumbnailUrl: '/api/documents/s1/thumbnail?v=2026-04-10T09%3A00%3A00Z',
|
||||
thumbnailAspect: 'PORTRAIT',
|
||||
pageCount: 1
|
||||
}
|
||||
@@ -97,7 +96,7 @@ describe('ConversationThumbnail', () => {
|
||||
expect(badge).toBeNull();
|
||||
});
|
||||
|
||||
it('renders a skeleton placeholder when no thumbnailKey is set yet', () => {
|
||||
it('renders a skeleton placeholder when no thumbnailUrl is set yet', () => {
|
||||
render(ConversationThumbnail, {
|
||||
doc: {
|
||||
id: 'blank',
|
||||
|
||||
84
frontend/src/lib/components/HelpPopover.svelte
Normal file
84
frontend/src/lib/components/HelpPopover.svelte
Normal file
@@ -0,0 +1,84 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
type Placement = 'bottom' | 'top' | 'left' | 'right';
|
||||
|
||||
type Props = {
|
||||
label: string;
|
||||
placement?: Placement;
|
||||
children?: Snippet;
|
||||
};
|
||||
|
||||
let { label, placement = 'bottom', children }: Props = $props();
|
||||
|
||||
let open = $state(false);
|
||||
const popoverId = `help-popover-${Math.random().toString(36).slice(2)}`;
|
||||
let triggerEl: HTMLButtonElement | null = $state(null);
|
||||
|
||||
function toggle() {
|
||||
open = !open;
|
||||
}
|
||||
|
||||
function close() {
|
||||
open = false;
|
||||
triggerEl?.focus();
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!open) return;
|
||||
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
close();
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
if (triggerEl && (e.target === triggerEl || triggerEl.contains(e.target as Node))) return;
|
||||
const popoverEl = document.getElementById(popoverId);
|
||||
if (popoverEl && popoverEl.contains(e.target as Node)) return;
|
||||
open = false;
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
document.addEventListener('pointerdown', onPointerDown);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKeyDown);
|
||||
document.removeEventListener('pointerdown', onPointerDown);
|
||||
};
|
||||
});
|
||||
|
||||
const placementClass: Record<Placement, string> = {
|
||||
bottom: 'top-full mt-1.5 left-1/2 -translate-x-1/2',
|
||||
top: 'bottom-full mb-1.5 left-1/2 -translate-x-1/2',
|
||||
left: 'right-full mr-1.5 top-1/2 -translate-y-1/2',
|
||||
right: 'left-full ml-1.5 top-1/2 -translate-y-1/2'
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="relative inline-block">
|
||||
<button
|
||||
bind:this={triggerEl}
|
||||
type="button"
|
||||
aria-label={label}
|
||||
aria-expanded={open}
|
||||
aria-controls={popoverId}
|
||||
onclick={toggle}
|
||||
class="flex h-5 w-5 items-center justify-center rounded-full border border-line bg-muted font-sans text-[10px] font-bold text-ink-3 transition-colors hover:border-brand-navy hover:text-brand-navy"
|
||||
>
|
||||
?
|
||||
</button>
|
||||
|
||||
{#if open}
|
||||
<div
|
||||
id={popoverId}
|
||||
role="tooltip"
|
||||
class="absolute z-50 w-64 rounded-sm border border-line bg-white p-3 font-sans text-sm text-ink shadow-md {placementClass[placement]}"
|
||||
>
|
||||
{#if children}
|
||||
{@render children()}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
77
frontend/src/lib/components/HelpPopover.svelte.spec.ts
Normal file
77
frontend/src/lib/components/HelpPopover.svelte.spec.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import HelpPopover from './HelpPopover.svelte';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function renderPopover(label = 'Help') {
|
||||
return render(HelpPopover, { props: { label } });
|
||||
}
|
||||
|
||||
describe('HelpPopover — initial state', () => {
|
||||
it('renders a trigger button with the given label', async () => {
|
||||
renderPopover();
|
||||
const btn = page.getByRole('button', { name: /Help/ });
|
||||
await expect.element(btn).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('starts closed: aria-expanded is false, popover not in DOM', async () => {
|
||||
renderPopover();
|
||||
const btn = page.getByRole('button', { name: /Help/ });
|
||||
await expect.element(btn).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(document.querySelector('[role="tooltip"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('HelpPopover — open / close interactions', () => {
|
||||
it('opens on click: aria-expanded true, popover in DOM', async () => {
|
||||
renderPopover();
|
||||
await page.getByRole('button', { name: /Help/ }).click();
|
||||
const btn = page.getByRole('button', { name: /Help/ });
|
||||
await expect.element(btn).toHaveAttribute('aria-expanded', 'true');
|
||||
expect(document.querySelector('[role="tooltip"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('closes on Esc key', async () => {
|
||||
renderPopover();
|
||||
await page.getByRole('button', { name: /Help/ }).click();
|
||||
expect(document.querySelector('[role="tooltip"]')).not.toBeNull();
|
||||
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
||||
await vi.waitFor(() => expect(document.querySelector('[role="tooltip"]')).toBeNull());
|
||||
});
|
||||
|
||||
it('closes on outside click', async () => {
|
||||
renderPopover();
|
||||
await page.getByRole('button', { name: /Help/ }).click();
|
||||
expect(document.querySelector('[role="tooltip"]')).not.toBeNull();
|
||||
|
||||
document.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }));
|
||||
await vi.waitFor(() => expect(document.querySelector('[role="tooltip"]')).toBeNull());
|
||||
});
|
||||
|
||||
it('opens on Enter key (button is keyboard-reachable, Enter fires click)', async () => {
|
||||
renderPopover();
|
||||
await page.getByRole('button', { name: /Help/ }).click();
|
||||
expect(document.querySelector('[role="tooltip"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('opens on Space key (button is keyboard-reachable, Space fires click)', async () => {
|
||||
renderPopover();
|
||||
await page.getByRole('button', { name: /Help/ }).click();
|
||||
expect(document.querySelector('[role="tooltip"]')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('HelpPopover — aria wiring', () => {
|
||||
it('trigger aria-controls matches popover element id', async () => {
|
||||
renderPopover();
|
||||
await page.getByRole('button', { name: /Help/ }).click();
|
||||
const btn = document.querySelector('button[aria-expanded]') as HTMLButtonElement;
|
||||
const controls = btn.getAttribute('aria-controls');
|
||||
expect(controls).toBeTruthy();
|
||||
const popover = document.getElementById(controls!);
|
||||
expect(popover).not.toBeNull();
|
||||
});
|
||||
});
|
||||
80
frontend/src/lib/components/Pagination.svelte
Normal file
80
frontend/src/lib/components/Pagination.svelte
Normal file
@@ -0,0 +1,80 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
||||
interface Props {
|
||||
/** 0-indexed current page. */
|
||||
page: number;
|
||||
/** Total number of pages. `0` or `1` hides the control as trivially there's nothing to navigate. */
|
||||
totalPages: number;
|
||||
/** Given a 0-indexed page number, returns the href the link should point at. */
|
||||
makeHref: (page: number) => string;
|
||||
/** Optional override for the outer `<nav>`'s aria-label. */
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
const { page, totalPages, makeHref, ariaLabel }: Props = $props();
|
||||
|
||||
const hasPrev = $derived(page > 0);
|
||||
const hasNext = $derived(page < totalPages - 1);
|
||||
const controlBase =
|
||||
'inline-flex min-h-[44px] min-w-[44px] items-center justify-center gap-1.5 rounded-sm border border-line bg-white px-4 py-2 font-sans text-sm font-bold text-ink';
|
||||
const linkBase = `${controlBase} transition-colors hover:bg-surface focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-2 focus-visible:outline-none`;
|
||||
const disabledBase = `${controlBase} cursor-not-allowed opacity-40`;
|
||||
</script>
|
||||
|
||||
{#if totalPages > 1}
|
||||
<nav
|
||||
aria-label={ariaLabel ?? m.pagination_nav_label()}
|
||||
class="mt-6 flex flex-col items-center gap-3 sm:flex-row sm:justify-between"
|
||||
>
|
||||
<!--
|
||||
At the bounds we render a <span aria-hidden="true"> instead of an
|
||||
<a aria-disabled>. aria-disabled on a link is the documented pattern
|
||||
but screen readers still announce "Previous, link, disabled" — which
|
||||
is confusing on a pagination control where the disabled state is
|
||||
purely visual. Hiding the element from the AT tree entirely is the
|
||||
cleaner semantic.
|
||||
-->
|
||||
{#if hasPrev}
|
||||
<a
|
||||
data-testid="pagination-prev"
|
||||
aria-label={m.pagination_prev()}
|
||||
href={makeHref(page - 1)}
|
||||
class={linkBase}
|
||||
>
|
||||
<span aria-hidden="true">«</span>
|
||||
{m.pagination_prev()}
|
||||
</a>
|
||||
{:else}
|
||||
<span data-testid="pagination-prev" aria-hidden="true" class={disabledBase}>
|
||||
<span aria-hidden="true">«</span>
|
||||
{m.pagination_prev()}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<span
|
||||
data-testid="pagination-page-label"
|
||||
aria-current="page"
|
||||
class="font-sans text-sm text-ink-2"
|
||||
>
|
||||
{m.pagination_page_of({ page: page + 1, total: totalPages })}
|
||||
</span>
|
||||
|
||||
{#if hasNext}
|
||||
<a
|
||||
data-testid="pagination-next"
|
||||
aria-label={m.pagination_next()}
|
||||
href={makeHref(page + 1)}
|
||||
class={linkBase}
|
||||
>
|
||||
{m.pagination_next()}
|
||||
<span aria-hidden="true">»</span>
|
||||
</a>
|
||||
{:else}
|
||||
<span data-testid="pagination-next" aria-hidden="true" class={disabledBase}>
|
||||
{m.pagination_next()}
|
||||
<span aria-hidden="true">»</span>
|
||||
</span>
|
||||
{/if}
|
||||
</nav>
|
||||
{/if}
|
||||
86
frontend/src/lib/components/Pagination.svelte.spec.ts
Normal file
86
frontend/src/lib/components/Pagination.svelte.spec.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
|
||||
import Pagination from './Pagination.svelte';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const makeHref = (p: number) => `/documents?page=${p}`;
|
||||
|
||||
describe('Pagination', () => {
|
||||
it('renders the page-of-total label for the current page', async () => {
|
||||
render(Pagination, { page: 2, totalPages: 10, makeHref });
|
||||
|
||||
const label = page.getByTestId('pagination-page-label');
|
||||
await expect.element(label).toHaveTextContent(/3/); // page is 0-indexed, label is 1-indexed
|
||||
await expect.element(label).toHaveTextContent(/10/);
|
||||
});
|
||||
|
||||
it('marks the current page label with aria-current="page"', async () => {
|
||||
render(Pagination, { page: 0, totalPages: 3, makeHref });
|
||||
|
||||
const label = page.getByTestId('pagination-page-label');
|
||||
await expect.element(label).toHaveAttribute('aria-current', 'page');
|
||||
});
|
||||
|
||||
it('renders prev as a link pointing at page - 1 when not on first page', async () => {
|
||||
render(Pagination, { page: 4, totalPages: 10, makeHref });
|
||||
|
||||
const prev = page.getByTestId('pagination-prev');
|
||||
await expect.element(prev).toHaveAttribute('href', '/documents?page=3');
|
||||
});
|
||||
|
||||
it('renders disabled prev as an aria-hidden non-link so screen readers skip it', async () => {
|
||||
render(Pagination, { page: 0, totalPages: 3, makeHref });
|
||||
|
||||
const prev = page.getByTestId('pagination-prev');
|
||||
// Not a link — no href, no role=link
|
||||
await expect.element(prev).not.toHaveAttribute('href');
|
||||
// Hidden from assistive tech — AT shouldn't read "Previous, link, disabled"
|
||||
await expect.element(prev).toHaveAttribute('aria-hidden', 'true');
|
||||
});
|
||||
|
||||
it('renders next as a link pointing at page + 1 when not on last page', async () => {
|
||||
render(Pagination, { page: 0, totalPages: 3, makeHref });
|
||||
|
||||
const next = page.getByTestId('pagination-next');
|
||||
await expect.element(next).toHaveAttribute('href', '/documents?page=1');
|
||||
});
|
||||
|
||||
it('renders disabled next as an aria-hidden non-link on the last page', async () => {
|
||||
render(Pagination, { page: 2, totalPages: 3, makeHref });
|
||||
|
||||
const next = page.getByTestId('pagination-next');
|
||||
await expect.element(next).not.toHaveAttribute('href');
|
||||
await expect.element(next).toHaveAttribute('aria-hidden', 'true');
|
||||
});
|
||||
|
||||
it('calls makeHref with p-1 and p+1', async () => {
|
||||
const spy = vi.fn(makeHref);
|
||||
render(Pagination, { page: 3, totalPages: 10, makeHref: spy });
|
||||
|
||||
const calls = spy.mock.calls.map((c) => c[0]).sort((a, b) => a - b);
|
||||
expect(calls).toContain(2);
|
||||
expect(calls).toContain(4);
|
||||
});
|
||||
|
||||
it('renders decorative chevron inside aria-hidden span so screen readers skip it', async () => {
|
||||
render(Pagination, { page: 1, totalPages: 3, makeHref });
|
||||
|
||||
const prev = page.getByTestId('pagination-prev');
|
||||
await expect
|
||||
.element(prev.getByText('«', { exact: true }))
|
||||
.toHaveAttribute('aria-hidden', 'true');
|
||||
});
|
||||
|
||||
it('prev and next have min 44px touch targets', async () => {
|
||||
render(Pagination, { page: 1, totalPages: 3, makeHref });
|
||||
|
||||
const prev = page.getByTestId('pagination-prev');
|
||||
await expect.element(prev).toHaveClass(/min-h-\[44px\]/);
|
||||
await expect.element(prev).toHaveClass(/min-w-\[44px\]/);
|
||||
});
|
||||
});
|
||||
@@ -67,7 +67,7 @@ function removePerson(id: string | undefined) {
|
||||
|
||||
<div class="relative" use:clickOutside onclickoutside={() => (showDropdown = false)}>
|
||||
<div
|
||||
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"
|
||||
class="flex min-h-[38px] flex-wrap gap-2 rounded border border-line bg-surface p-2 shadow-sm focus-within:ring-2 focus-within:ring-focus-ring focus-within:outline-none"
|
||||
>
|
||||
{#each selectedPersons as person (person.id)}
|
||||
<span
|
||||
|
||||
@@ -134,7 +134,7 @@ function selectPerson(person: Person) {
|
||||
? 'mt-2 block h-14 w-full rounded-md border border-line bg-surface px-4 text-base text-ink shadow-sm placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'
|
||||
: compact
|
||||
? 'mt-1 block h-9 w-full rounded border border-line bg-surface px-2 text-sm text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'
|
||||
: 'mt-1 block w-full rounded-md border border-line bg-surface p-2 text-ink shadow-sm placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'}
|
||||
: 'mt-1 block w-full rounded border border-line bg-surface px-2 py-3 text-sm text-ink shadow-sm placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'}
|
||||
/>
|
||||
|
||||
{#if typeahead.isOpen && (typeahead.results.length > 0 || typeahead.loading)}
|
||||
|
||||
30
frontend/src/lib/components/RichtlinienRuleCard.svelte
Normal file
30
frontend/src/lib/components/RichtlinienRuleCard.svelte
Normal file
@@ -0,0 +1,30 @@
|
||||
<script lang="ts">
|
||||
type Props = {
|
||||
icon: string;
|
||||
title: string;
|
||||
body: string;
|
||||
beispielOutput?: string;
|
||||
beispielLabel?: string;
|
||||
};
|
||||
|
||||
let { icon, title, body, beispielOutput, beispielLabel = 'Beispiel' }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="border-brand-sand break-inside-avoid rounded-sm border bg-white p-5 shadow-sm">
|
||||
<div class="mb-3 flex items-center gap-2">
|
||||
<span aria-hidden="true" class="text-xl">{icon}</span>
|
||||
<h3 class="font-serif text-base font-bold text-ink">{title}</h3>
|
||||
</div>
|
||||
<p class="font-serif text-sm leading-relaxed text-ink-2">{body}</p>
|
||||
|
||||
{#if beispielOutput !== undefined}
|
||||
<div class="border-brand-sand mt-4 rounded-sm border bg-[#FAF8F1] px-4 py-3">
|
||||
<p class="font-sans text-xs font-semibold tracking-wider text-ink-3 uppercase">
|
||||
{beispielLabel}
|
||||
</p>
|
||||
<p class="mt-1 font-sans text-sm text-ink">
|
||||
→ <code class="font-mono">{beispielOutput}</code>
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import RichtlinienRuleCard from './RichtlinienRuleCard.svelte';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const defaultProps = {
|
||||
icon: '✍',
|
||||
title: 'Unleserliche Wörter',
|
||||
body: 'Schreiben Sie [unleserlich].',
|
||||
beispielOutput: '[unleserlich]'
|
||||
};
|
||||
|
||||
describe('RichtlinienRuleCard', () => {
|
||||
it('renders an h3 with the title', async () => {
|
||||
render(RichtlinienRuleCard, { props: defaultProps });
|
||||
await expect
|
||||
.element(page.getByRole('heading', { level: 3 }))
|
||||
.toHaveTextContent('Unleserliche Wörter');
|
||||
});
|
||||
|
||||
it('renders the body text', async () => {
|
||||
render(RichtlinienRuleCard, { props: defaultProps });
|
||||
await expect.element(page.getByText('Schreiben Sie [unleserlich].')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders icon in a span with aria-hidden="true"', async () => {
|
||||
render(RichtlinienRuleCard, { props: defaultProps });
|
||||
const iconSpan = document.querySelector('span[aria-hidden="true"]');
|
||||
expect(iconSpan).not.toBeNull();
|
||||
expect(iconSpan!.textContent).toContain('✍');
|
||||
});
|
||||
|
||||
it('renders beispielOutput in monospace with → arrow', async () => {
|
||||
render(RichtlinienRuleCard, { props: defaultProps });
|
||||
const mono = document.querySelector('code, [class*="font-mono"]');
|
||||
expect(mono).not.toBeNull();
|
||||
expect(mono!.textContent).toContain('[unleserlich]');
|
||||
await expect.element(page.getByText(/→/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render beispiel section when beispielOutput is absent', async () => {
|
||||
render(RichtlinienRuleCard, {
|
||||
props: { icon: '✍', title: 'Test', body: 'Body' }
|
||||
});
|
||||
expect(document.querySelector('code, [class*="font-mono"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
76
frontend/src/lib/components/TranscribeCoachEmptyState.svelte
Normal file
76
frontend/src/lib/components/TranscribeCoachEmptyState.svelte
Normal file
@@ -0,0 +1,76 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import TranscribeDragDemo from './TranscribeDragDemo.svelte';
|
||||
</script>
|
||||
|
||||
<div class="border-brand-sand rounded-sm border bg-white p-7 shadow-sm">
|
||||
<h2 class="mb-3 font-serif text-[22px] font-bold text-ink">
|
||||
{m.transcribe_coach_title()}
|
||||
</h2>
|
||||
<p class="mb-6 font-serif text-[15px] leading-relaxed text-ink-2">
|
||||
{m.transcribe_coach_preamble()}
|
||||
</p>
|
||||
|
||||
<ol class="m-0 flex list-none flex-col gap-[18px] p-0">
|
||||
<!-- Step 1 -->
|
||||
<li class="grid gap-3.5" style="grid-template-columns: 34px 1fr; align-items: start;">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full bg-ink font-sans text-sm font-bold text-white"
|
||||
>1</span
|
||||
>
|
||||
<div class="pt-0.5 font-serif text-base leading-snug text-ink">
|
||||
<strong>{m.transcribe_coach_step_1_title()}</strong>
|
||||
{m.transcribe_coach_step_1_body()}
|
||||
<TranscribeDragDemo />
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<!-- Step 2 -->
|
||||
<li class="grid gap-3.5" style="grid-template-columns: 34px 1fr; align-items: start;">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full bg-ink font-sans text-sm font-bold text-white"
|
||||
>2</span
|
||||
>
|
||||
<div class="pt-0.5 font-serif text-base leading-snug text-ink">
|
||||
<strong>{m.transcribe_coach_step_2_title()}</strong>
|
||||
{m.transcribe_coach_step_2_body()}
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<!-- Step 3 -->
|
||||
<li class="grid gap-3.5" style="grid-template-columns: 34px 1fr; align-items: start;">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full bg-ink font-sans text-sm font-bold text-white"
|
||||
>3</span
|
||||
>
|
||||
<div class="pt-0.5 font-serif text-base leading-snug text-ink">
|
||||
<strong>{m.transcribe_coach_step_3_title()}</strong>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<div class="border-brand-sand mt-6 flex flex-wrap gap-4 border-t pt-3.5 font-sans text-[13px]">
|
||||
<a
|
||||
href="https://de.wikipedia.org/wiki/Kurrent"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
referrerpolicy="no-referrer"
|
||||
class="text-ink underline decoration-brand-mint decoration-[1.5px] underline-offset-[3px]"
|
||||
>
|
||||
{m.transcribe_coach_footer_kurrent()}
|
||||
<span class="ml-1 text-[11px] text-ink-3">{m.common_opens_new_tab()}</span>
|
||||
</a>
|
||||
<a
|
||||
href="/hilfe/transkription"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-ink underline decoration-brand-mint decoration-[1.5px] underline-offset-[3px]"
|
||||
>
|
||||
{m.transcribe_coach_footer_richtlinien()}
|
||||
<span class="ml-1 text-[11px] text-ink-3">{m.common_opens_new_tab()}</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import TranscribeCoachEmptyState from './TranscribeCoachEmptyState.svelte';
|
||||
|
||||
vi.mock('$lib/paraglide/messages.js', () => ({
|
||||
m: {
|
||||
transcribe_coach_title: () => 'Erste Transkription?',
|
||||
transcribe_coach_preamble: () => 'Unser Kurrent-Erkenner lernt noch.',
|
||||
transcribe_coach_step_1_title: () => 'Rahmen ziehen.',
|
||||
transcribe_coach_step_1_body: () => 'Klicken und ziehen Sie mit der Maus einen Rahmen.',
|
||||
transcribe_coach_step_2_title: () => 'Text eingeben.',
|
||||
transcribe_coach_step_2_body: () => 'Geben Sie den Text ein.',
|
||||
transcribe_coach_step_3_title: () => 'Speichert automatisch.',
|
||||
transcribe_coach_footer_kurrent: () => 'Hilfe zu Kurrent ↗',
|
||||
transcribe_coach_footer_richtlinien: () => 'Transkriptions-Richtlinien ↗',
|
||||
common_opens_new_tab: () => '(öffnet in neuem Tab)'
|
||||
}
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('TranscribeCoachEmptyState', () => {
|
||||
it('renders the title and preamble', async () => {
|
||||
render(TranscribeCoachEmptyState);
|
||||
await expect
|
||||
.element(page.getByRole('heading', { level: 2 }))
|
||||
.toHaveTextContent('Erste Transkription?');
|
||||
await expect.element(page.getByText('Unser Kurrent-Erkenner lernt noch.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders three numbered steps', async () => {
|
||||
render(TranscribeCoachEmptyState);
|
||||
await expect.element(page.getByText('Rahmen ziehen.')).toBeInTheDocument();
|
||||
await expect
|
||||
.element(page.getByText('Klicken und ziehen Sie mit der Maus einen Rahmen.'))
|
||||
.toBeInTheDocument();
|
||||
await expect.element(page.getByText('Text eingeben.')).toBeInTheDocument();
|
||||
await expect.element(page.getByText('Geben Sie den Text ein.')).toBeInTheDocument();
|
||||
await expect.element(page.getByText('Speichert automatisch.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders footer links to Wikipedia Kurrent and Richtlinien page', async () => {
|
||||
render(TranscribeCoachEmptyState);
|
||||
const kurrentLink = page.getByRole('link', { name: /Hilfe zu Kurrent/ });
|
||||
await expect.element(kurrentLink).toBeInTheDocument();
|
||||
await expect.element(kurrentLink).toHaveAttribute('target', '_blank');
|
||||
await expect.element(kurrentLink).toHaveAttribute('rel', 'noopener noreferrer');
|
||||
await expect.element(kurrentLink).toHaveAttribute('referrerpolicy', 'no-referrer');
|
||||
|
||||
const richtlinienLink = page.getByRole('link', { name: /Transkriptions-Richtlinien/ });
|
||||
await expect.element(richtlinienLink).toBeInTheDocument();
|
||||
await expect.element(richtlinienLink).toHaveAttribute('target', '_blank');
|
||||
await expect.element(richtlinienLink).toHaveAttribute('rel', 'noopener noreferrer');
|
||||
});
|
||||
|
||||
it('renders visible "(öffnet in neuem Tab)" annotation on each footer link', async () => {
|
||||
render(TranscribeCoachEmptyState);
|
||||
const annotations = page.getByText('(öffnet in neuem Tab)');
|
||||
await expect.element(annotations.first()).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the drag demo animation region inside step 1', async () => {
|
||||
render(TranscribeCoachEmptyState);
|
||||
const demo = page.getByRole('img', { name: /Rahmen ziehen|Animation/i });
|
||||
await expect.element(demo).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
205
frontend/src/lib/components/TranscribeDragDemo.svelte
Normal file
205
frontend/src/lib/components/TranscribeDragDemo.svelte
Normal file
@@ -0,0 +1,205 @@
|
||||
<script lang="ts">
|
||||
const prefersReducedMotion = $derived(
|
||||
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
);
|
||||
</script>
|
||||
|
||||
{#if prefersReducedMotion}
|
||||
<!-- Static final frame for reduced-motion users -->
|
||||
<svg
|
||||
role="img"
|
||||
aria-label="Eine gestrichelte Umrandung markiert eine Zeile Kurrentschrift auf dem Dokument."
|
||||
viewBox="0 0 600 180"
|
||||
class="border-brand-sand block w-full rounded-sm border bg-[#FAF8F1]"
|
||||
>
|
||||
<g
|
||||
stroke="#2a2a2a"
|
||||
stroke-width="1.6"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M 70 100 q 4 -25 10 -8 q 6 22 14 -2 q 4 -20 10 -3 q 6 22 12 -6 q 4 -18 10 2" />
|
||||
<path d="M 145 100 q 5 -28 14 -2 q 5 -20 12 -4 q 6 22 14 -6 q 4 -15 10 4" />
|
||||
<path d="M 230 100 q 6 -26 14 -2 q 5 -22 12 1 q 5 25 14 -5 q 4 -18 10 3 q 5 -20 10 1" />
|
||||
<path d="M 340 100 q 5 -30 12 -4 q 6 -18 14 5 q 5 -24 12 0 q 6 24 14 -10" />
|
||||
<path d="M 440 100 q 6 -28 14 0 q 5 -22 12 -4 q 6 24 14 -1 q 4 -18 10 2" />
|
||||
</g>
|
||||
<line
|
||||
x1="60"
|
||||
y1="120"
|
||||
x2="540"
|
||||
y2="120"
|
||||
stroke="#D4D1C4"
|
||||
stroke-width="0.8"
|
||||
stroke-dasharray="2 3"
|
||||
/>
|
||||
<rect
|
||||
x="55"
|
||||
y="68"
|
||||
width="470"
|
||||
height="57"
|
||||
fill="rgba(166, 218, 216, 0.12)"
|
||||
stroke="#002850"
|
||||
stroke-width="2.2"
|
||||
/>
|
||||
<g transform="translate(515, 58)">
|
||||
<circle cx="0" cy="0" r="9" fill="#002850" />
|
||||
<path
|
||||
d="M -4 0 L -1 3 L 4 -3"
|
||||
stroke="white"
|
||||
stroke-width="2"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
{:else}
|
||||
<!-- Animated 5-second drawing loop -->
|
||||
<svg
|
||||
role="img"
|
||||
aria-label="Animation: Ein Cursor zieht einen gestrichelten Rahmen um eine Zeile Kurrentschrift. Beim Loslassen wird der Rahmen durchgehend und ein Häkchen erscheint."
|
||||
viewBox="0 0 600 180"
|
||||
class="border-brand-sand block w-full rounded-sm border bg-[#FAF8F1]"
|
||||
>
|
||||
<!-- Kurrent writing (static) -->
|
||||
<g
|
||||
stroke="#2a2a2a"
|
||||
stroke-width="1.6"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M 70 100 q 4 -25 10 -8 q 6 22 14 -2 q 4 -20 10 -3 q 6 22 12 -6 q 4 -18 10 2" />
|
||||
<path d="M 145 100 q 5 -28 14 -2 q 5 -20 12 -4 q 6 22 14 -6 q 4 -15 10 4" />
|
||||
<path d="M 230 100 q 6 -26 14 -2 q 5 -22 12 1 q 5 25 14 -5 q 4 -18 10 3 q 5 -20 10 1" />
|
||||
<path d="M 340 100 q 5 -30 12 -4 q 6 -18 14 5 q 5 -24 12 0 q 6 24 14 -10" />
|
||||
<path d="M 440 100 q 6 -28 14 0 q 5 -22 12 -4 q 6 24 14 -1 q 4 -18 10 2" />
|
||||
</g>
|
||||
<line
|
||||
x1="60"
|
||||
y1="120"
|
||||
x2="540"
|
||||
y2="120"
|
||||
stroke="#D4D1C4"
|
||||
stroke-width="0.8"
|
||||
stroke-dasharray="2 3"
|
||||
/>
|
||||
|
||||
<!-- Click ripple -->
|
||||
<circle cx="55" cy="68" r="0" fill="none" stroke="#A6DAD8" stroke-width="2.5" opacity="0">
|
||||
<animate
|
||||
attributeName="r"
|
||||
values="0;0;4;18;0;0"
|
||||
keyTimes="0;0.17;0.19;0.24;0.26;1"
|
||||
dur="5s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
<animate
|
||||
attributeName="opacity"
|
||||
values="0;0;1;0;0;0"
|
||||
keyTimes="0;0.17;0.19;0.24;0.26;1"
|
||||
dur="5s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</circle>
|
||||
|
||||
<!-- Growing selection rectangle -->
|
||||
<rect
|
||||
x="55"
|
||||
y="68"
|
||||
width="0"
|
||||
height="0"
|
||||
fill="rgba(166, 218, 216, 0.12)"
|
||||
stroke="#002850"
|
||||
stroke-width="2"
|
||||
stroke-dasharray="5 4"
|
||||
opacity="0"
|
||||
>
|
||||
<animate
|
||||
attributeName="opacity"
|
||||
values="0;0;1;1;1;1;0;0"
|
||||
keyTimes="0;0.18;0.20;0.88;0.92;0.94;0.98;1"
|
||||
dur="5s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
<animate
|
||||
attributeName="width"
|
||||
values="0;0;470;470;470;470;0"
|
||||
keyTimes="0;0.20;0.62;0.88;0.94;0.98;1"
|
||||
dur="5s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
<animate
|
||||
attributeName="height"
|
||||
values="0;0;57;57;57;57;0"
|
||||
keyTimes="0;0.20;0.62;0.88;0.94;0.98;1"
|
||||
dur="5s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
<animate
|
||||
attributeName="stroke-dasharray"
|
||||
values="5 4;5 4;5 4;1 0;1 0;5 4"
|
||||
keyTimes="0;0.60;0.64;0.68;0.94;1"
|
||||
dur="5s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
<animate
|
||||
attributeName="stroke-width"
|
||||
values="2;2;2;3.2;2.2;2;2"
|
||||
keyTimes="0;0.64;0.66;0.68;0.72;0.90;1"
|
||||
dur="5s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</rect>
|
||||
|
||||
<!-- Confirmation checkmark badge -->
|
||||
<g opacity="0" transform="translate(515, 58)">
|
||||
<circle cx="0" cy="0" r="9" fill="#002850" />
|
||||
<path
|
||||
d="M -4 0 L -1 3 L 4 -3"
|
||||
stroke="white"
|
||||
stroke-width="2"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<animate
|
||||
attributeName="opacity"
|
||||
values="0;0;1;1;0;0"
|
||||
keyTimes="0;0.66;0.70;0.86;0.92;1"
|
||||
dur="5s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</g>
|
||||
|
||||
<!-- Cursor arrow -->
|
||||
<g>
|
||||
<animateTransform
|
||||
attributeName="transform"
|
||||
type="translate"
|
||||
values="15,20; 55,68; 55,68; 525,125; 525,125; 15,20"
|
||||
keyTimes="0; 0.15; 0.20; 0.62; 0.92; 1"
|
||||
calcMode="spline"
|
||||
keySplines="0.4 0 0.2 1; 0 0 1 1; 0.4 0 0.2 1; 0 0 1 1; 0.4 0 0.2 1"
|
||||
dur="5s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
<animate
|
||||
attributeName="opacity"
|
||||
values="1;1;1;0;0;1"
|
||||
keyTimes="0;0.92;0.94;0.96;0.99;1"
|
||||
dur="5s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
<path
|
||||
d="M 0 0 L 0 16 L 4.5 12 L 7.5 18 L 10.5 16.6 L 7.8 10.6 L 13 9 Z"
|
||||
fill="#002850"
|
||||
stroke="white"
|
||||
stroke-width="0.8"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
{/if}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import TranscribeDragDemo from './TranscribeDragDemo.svelte';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe('TranscribeDragDemo', () => {
|
||||
it('renders an SVG with an aria-label describing the animation', async () => {
|
||||
render(TranscribeDragDemo);
|
||||
const svg = page.getByRole('img');
|
||||
await expect.element(svg).toBeInTheDocument();
|
||||
await expect.element(svg).toHaveAttribute('aria-label');
|
||||
});
|
||||
|
||||
it('contains a dashed-border rectangle animation element', async () => {
|
||||
const { container } = render(TranscribeDragDemo);
|
||||
const rect = container.querySelector('rect');
|
||||
expect(rect).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import TranscriptionBlock from './TranscriptionBlock.svelte';
|
||||
import OcrTrigger from './OcrTrigger.svelte';
|
||||
import TranscribeCoachEmptyState from './TranscribeCoachEmptyState.svelte';
|
||||
import type { TranscriptionBlockData } from '$lib/types';
|
||||
import { createBlockAutoSave } from '$lib/hooks/useBlockAutoSave.svelte';
|
||||
import { createBlockDragDrop } from '$lib/hooks/useBlockDragDrop.svelte';
|
||||
@@ -231,28 +232,12 @@ async function handleLabelToggle(label: string) {
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-1 flex-col items-center justify-center px-6 py-12 text-center">
|
||||
<svg
|
||||
class="mb-4 h-16 w-16 text-ink-3"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<p class="max-w-xs text-sm leading-relaxed text-ink-3">
|
||||
{m.transcription_empty_draw_hint()}
|
||||
</p>
|
||||
<div class="p-4">
|
||||
<TranscribeCoachEmptyState />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if canWrite}
|
||||
{#if canWrite && hasBlocks}
|
||||
<div class="border-t border-line px-4 py-3">
|
||||
<p class="mb-2 font-sans text-xs font-medium text-ink-2">Für Training vormerken</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
|
||||
@@ -61,9 +61,21 @@ describe('TranscriptionEditView — rendering', () => {
|
||||
await expect.element(page.getByText(/Block 3/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows empty state when no blocks', async () => {
|
||||
it('shows coach card when no blocks', async () => {
|
||||
renderView({ blocks: [] });
|
||||
await expect.element(page.getByText(/Zeichnen Sie Bereiche/)).toBeInTheDocument();
|
||||
await expect
|
||||
.element(page.getByRole('heading', { level: 2 }))
|
||||
.toHaveTextContent('Erste Transkription?');
|
||||
});
|
||||
|
||||
it('hides training footer when no blocks', async () => {
|
||||
renderView({ blocks: [], canWrite: true });
|
||||
await expect.element(page.getByText('Für Training vormerken')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows training footer when blocks exist', async () => {
|
||||
renderView({ blocks: [block1], canWrite: true });
|
||||
await expect.element(page.getByText('Für Training vormerken')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { getLocale } from '$lib/paraglide/runtime.js';
|
||||
import HelpPopover from './HelpPopover.svelte';
|
||||
|
||||
type Props = {
|
||||
mode: 'read' | 'edit';
|
||||
@@ -33,31 +34,36 @@ function handleReadClick() {
|
||||
<div
|
||||
class="flex h-[44px] items-center justify-between border-b border-line bg-surface px-3 font-sans"
|
||||
>
|
||||
<!-- Segmented toggle -->
|
||||
<div class="flex h-9 items-center rounded-full border border-line bg-muted p-0.5 md:h-7">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="mode-read"
|
||||
aria-disabled={!hasBlocks}
|
||||
onclick={handleReadClick}
|
||||
class="h-full rounded-full px-3 text-xs font-semibold transition-colors {mode === 'read'
|
||||
<!-- Segmented toggle + help chip -->
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div class="flex h-9 items-center rounded-full border border-line bg-muted p-0.5 md:h-7">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="mode-read"
|
||||
aria-disabled={!hasBlocks}
|
||||
onclick={handleReadClick}
|
||||
class="h-full rounded-full px-3 text-xs font-semibold transition-colors {mode === 'read'
|
||||
? 'bg-primary text-primary-fg'
|
||||
: 'text-ink-2 hover:text-ink'}"
|
||||
style:opacity={!hasBlocks ? '0.35' : undefined}
|
||||
>
|
||||
{m.mode_read()}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="mode-edit"
|
||||
onclick={() => onModeChange('edit')}
|
||||
class="h-full rounded-full px-3 text-xs font-semibold transition-colors {mode === 'edit'
|
||||
style:opacity={!hasBlocks ? '0.35' : undefined}
|
||||
>
|
||||
{m.mode_read()}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="mode-edit"
|
||||
onclick={() => onModeChange('edit')}
|
||||
class="h-full rounded-full px-3 text-xs font-semibold transition-colors {mode === 'edit'
|
||||
? 'bg-primary text-primary-fg'
|
||||
: 'text-ink-2 hover:text-ink'}"
|
||||
>
|
||||
<span class="md:hidden">{m.mode_edit_short()}</span>
|
||||
<span class="hidden md:inline">{m.mode_edit()}</span>
|
||||
</button>
|
||||
>
|
||||
<span class="md:hidden">{m.mode_edit_short()}</span>
|
||||
<span class="hidden md:inline">{m.mode_edit()}</span>
|
||||
</button>
|
||||
</div>
|
||||
<HelpPopover label={m.transcription_mode_help_label()}>
|
||||
<p class="text-xs leading-relaxed">{m.transcription_mode_help_body()}</p>
|
||||
</HelpPopover>
|
||||
</div>
|
||||
|
||||
<!-- Status line (hidden on mobile to save space) -->
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import TranscriptionPanelHeader from './TranscriptionPanelHeader.svelte';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('TranscriptionPanelHeader', () => {
|
||||
it('should render Lesen and Bearbeiten buttons', async () => {
|
||||
render(TranscriptionPanelHeader, {
|
||||
@@ -148,4 +150,33 @@ describe('TranscriptionPanelHeader', () => {
|
||||
expect(statusText).not.toBeNull();
|
||||
expect(statusText!.textContent).toContain('2026');
|
||||
});
|
||||
|
||||
it('renders a (?) help chip next to the Read/Edit toggle', async () => {
|
||||
render(TranscriptionPanelHeader, {
|
||||
mode: 'read',
|
||||
hasBlocks: true,
|
||||
blockCount: 3,
|
||||
lastEditedAt: null,
|
||||
onModeChange: () => {},
|
||||
onClose: () => {}
|
||||
});
|
||||
|
||||
const helpBtn = document.querySelector('button[aria-expanded]') as HTMLButtonElement;
|
||||
expect(helpBtn).not.toBeNull();
|
||||
});
|
||||
|
||||
it('opens a help popover with mode explanation when the chip is clicked', async () => {
|
||||
render(TranscriptionPanelHeader, {
|
||||
mode: 'read',
|
||||
hasBlocks: true,
|
||||
blockCount: 3,
|
||||
lastEditedAt: null,
|
||||
onModeChange: () => {},
|
||||
onClose: () => {}
|
||||
});
|
||||
|
||||
const helpBtn = document.querySelector('button[aria-expanded]') as HTMLButtonElement;
|
||||
helpBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
await vi.waitFor(() => expect(document.querySelector('[role="tooltip"]')).not.toBeNull());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
<script lang="ts">
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
import { goto } from '$app/navigation';
|
||||
import { onDestroy, untrack } from 'svelte';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { getConfirmService } from '$lib/services/confirm.svelte.js';
|
||||
import type { ConfirmService } from '$lib/services/confirm.svelte.js';
|
||||
import BulkDropZone from './BulkDropZone.svelte';
|
||||
import FileSwitcherStrip from './FileSwitcherStrip.svelte';
|
||||
import type { FileEntry } from './FileSwitcherStrip.svelte';
|
||||
import ScopeCard from './ScopeCard.svelte';
|
||||
import UploadSaveBar from './UploadSaveBar.svelte';
|
||||
import WhoWhenSection from './WhoWhenSection.svelte';
|
||||
import DescriptionSection from './DescriptionSection.svelte';
|
||||
import PdfViewer from '$lib/components/PdfViewer.svelte';
|
||||
import { bulkTitleFromFilename } from '$lib/utils/filename';
|
||||
import type { Tag } from '$lib/components/TagInput.svelte';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type Person = components['schemas']['Person'];
|
||||
|
||||
// Optional — not available in unit tests that don't provide CONFIRM_KEY context.
|
||||
let _confirmService: ConfirmService | null;
|
||||
try {
|
||||
_confirmService = getConfirmService();
|
||||
} catch {
|
||||
_confirmService = null;
|
||||
}
|
||||
|
||||
let {
|
||||
initialSenderId = '',
|
||||
initialSenderName = '',
|
||||
initialReceivers = []
|
||||
}: {
|
||||
initialSenderId?: string;
|
||||
initialSenderName?: string;
|
||||
initialReceivers?: Person[];
|
||||
} = $props();
|
||||
|
||||
// --- File state ---
|
||||
let files = new SvelteMap<string, FileEntry>();
|
||||
let activeId = $state<string | null>(null);
|
||||
let chunkProgress = $state<{ done: number; total: number } | undefined>(undefined);
|
||||
let saving = $state(false);
|
||||
|
||||
// --- Shared metadata ---
|
||||
let senderId = $state(untrack(() => initialSenderId));
|
||||
let selectedReceivers = $state<Person[]>(untrack(() => initialReceivers));
|
||||
let dateIso = $state('');
|
||||
let tags = $state<Tag[]>([]);
|
||||
|
||||
// --- Derived ---
|
||||
const isMulti = $derived(files.size >= 2);
|
||||
const activeFile = $derived(activeId ? files.get(activeId) : null);
|
||||
|
||||
// --- File management ---
|
||||
function addFiles(newFiles: File[]) {
|
||||
for (const file of newFiles) {
|
||||
const id = crypto.randomUUID();
|
||||
const title = bulkTitleFromFilename(file.name);
|
||||
const previewUrl = URL.createObjectURL(file);
|
||||
files.set(id, { id, file, title, status: 'idle', previewUrl });
|
||||
if (!activeId) activeId = id;
|
||||
}
|
||||
}
|
||||
|
||||
function removeFile(id: string) {
|
||||
const entry = files.get(id);
|
||||
if (entry?.previewUrl) URL.revokeObjectURL(entry.previewUrl);
|
||||
files.delete(id);
|
||||
if (activeId === id) {
|
||||
activeId = files.keys().next().value ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
function setTitle(id: string, title: string) {
|
||||
const entry = files.get(id);
|
||||
if (entry) files.set(id, { ...entry, title });
|
||||
}
|
||||
|
||||
function discardAll() {
|
||||
for (const entry of files.values()) {
|
||||
if (entry.previewUrl) URL.revokeObjectURL(entry.previewUrl);
|
||||
}
|
||||
files.clear();
|
||||
activeId = null;
|
||||
chunkProgress = undefined;
|
||||
}
|
||||
|
||||
async function handleDiscard() {
|
||||
if (_confirmService) {
|
||||
const ok = await _confirmService.confirm({
|
||||
title: m.bulk_discard_all(),
|
||||
body: m.bulk_discard_confirm(),
|
||||
destructive: true
|
||||
});
|
||||
if (!ok) return;
|
||||
}
|
||||
discardAll();
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
for (const entry of files.values()) {
|
||||
if (entry.previewUrl) URL.revokeObjectURL(entry.previewUrl);
|
||||
}
|
||||
});
|
||||
|
||||
// --- Save ---
|
||||
async function save() {
|
||||
if (saving) return;
|
||||
saving = true;
|
||||
const entries = Array.from(files.values());
|
||||
// 10 files per request keeps multipart bodies well under typical reverse-proxy limits (e.g. nginx default 1 MB client_max_body_size per PDF).
|
||||
const chunkSize = 10;
|
||||
const chunks: FileEntry[][] = [];
|
||||
for (let i = 0; i < entries.length; i += chunkSize) {
|
||||
chunks.push(entries.slice(i, i + chunkSize));
|
||||
}
|
||||
chunkProgress = { done: 0, total: chunks.length };
|
||||
|
||||
let hadErrors = false;
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
const chunk = chunks[i];
|
||||
const formData = new FormData();
|
||||
chunk.forEach((entry) => formData.append('files', entry.file));
|
||||
const metadata = {
|
||||
titles: chunk.map((e) => e.title),
|
||||
senderId: senderId || null,
|
||||
receiverIds: selectedReceivers.map((r) => r.id),
|
||||
documentDate: dateIso || null,
|
||||
tagNames: tags.map((t) => t.name)
|
||||
};
|
||||
formData.append('metadata', new Blob([JSON.stringify(metadata)], { type: 'application/json' }));
|
||||
// Raw fetch is intentional: SvelteKit form actions can't stream chunked
|
||||
// FormData with per-chunk progress. Session cookie is sent automatically
|
||||
// by the browser for same-origin requests.
|
||||
try {
|
||||
const res = await fetch('/api/documents/quick-upload', { method: 'POST', body: formData });
|
||||
const body = await res.json().catch(() => ({ errors: [] }));
|
||||
const errorFilenames = new Set<string>(
|
||||
(body.errors ?? []).map((err: { filename: string }) => err.filename)
|
||||
);
|
||||
if (!res.ok || errorFilenames.size > 0) {
|
||||
hadErrors = true;
|
||||
for (const entry of chunk) {
|
||||
// When backend names specific files, mark only those; otherwise mark all.
|
||||
const isError = errorFilenames.size > 0 ? errorFilenames.has(entry.file.name) : true;
|
||||
if (isError) {
|
||||
const e = files.get(entry.id);
|
||||
if (e) files.set(entry.id, { ...e, status: 'error' });
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
hadErrors = true;
|
||||
for (const entry of chunk) {
|
||||
const e = files.get(entry.id);
|
||||
if (e) files.set(entry.id, { ...e, status: 'error' });
|
||||
}
|
||||
}
|
||||
chunkProgress = { done: i + 1, total: chunks.length };
|
||||
}
|
||||
saving = false;
|
||||
if (!hadErrors) goto('/documents');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="fixed inset-x-0 bottom-0 flex flex-col" style="top: var(--header-height)">
|
||||
<!-- Topbar -->
|
||||
<div class="flex shrink-0 items-center gap-3 border-b border-line bg-surface px-6 py-3">
|
||||
<a
|
||||
href="/documents"
|
||||
class="flex items-center gap-1.5 text-xs font-bold tracking-widest text-ink-3 uppercase hover:text-ink"
|
||||
>
|
||||
<svg
|
||||
class="h-3.5 w-3.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 19l-7-7m0 0l7-7m-7 7h18"
|
||||
/>
|
||||
</svg>
|
||||
{m.btn_back_to_overview()}
|
||||
</a>
|
||||
<span class="text-ink-3" aria-hidden="true">·</span>
|
||||
<span class="font-serif text-sm font-bold text-ink">
|
||||
{isMulti ? m.bulk_title_multi() : m.bulk_title_single()}
|
||||
</span>
|
||||
{#if isMulti}
|
||||
<span class="ml-auto flex items-center gap-3">
|
||||
<span class="rounded-[2px] bg-accent px-2 py-0.5 text-xs font-bold text-primary">
|
||||
{m.bulk_count_pill({ count: files.size })}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="discard-all-btn"
|
||||
onclick={handleDiscard}
|
||||
class="text-xs font-medium text-red-600/70 hover:text-red-700"
|
||||
>
|
||||
{m.bulk_discard_all()}
|
||||
</button>
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Split panel -->
|
||||
<div class="flex flex-1 overflow-hidden">
|
||||
<!-- Left: PDF preview / drop zone (55%) -->
|
||||
<div class="relative flex flex-[55] flex-col overflow-hidden border-r border-line bg-pdf-bg">
|
||||
{#if files.size === 0}
|
||||
<!-- N=0: centred drop-zone box fills the panel -->
|
||||
<BulkDropZone onFilesAdded={addFiles} />
|
||||
{:else}
|
||||
<!-- N≥1: real PDF preview via local blob URL -->
|
||||
<div class="relative flex-1 overflow-hidden">
|
||||
{#if activeFile}
|
||||
<PdfViewer url={activeFile.previewUrl} />
|
||||
{/if}
|
||||
</div>
|
||||
{#if isMulti}
|
||||
<!-- File switcher strip pinned to bottom of left panel -->
|
||||
<FileSwitcherStrip
|
||||
files={Array.from(files.values())}
|
||||
activeId={activeId ?? ''}
|
||||
onSelect={(id) => (activeId = id)}
|
||||
onRemove={removeFile}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Right: metadata form (45%) -->
|
||||
<div class="flex flex-[45] flex-col overflow-hidden">
|
||||
<!-- Scrollable form area — greyed out and non-interactive when no files selected -->
|
||||
<div
|
||||
class="flex-1 space-y-4 overflow-y-auto p-4 transition-opacity"
|
||||
class:opacity-60={files.size === 0}
|
||||
class:pointer-events-none={files.size === 0}
|
||||
>
|
||||
{#if isMulti}
|
||||
<!-- N≥2: per-file card (title) + shared card (metadata) -->
|
||||
<ScopeCard variant="per-file">
|
||||
{#if activeFile}
|
||||
<label class="block">
|
||||
<span class="mb-1 block text-xs font-medium tracking-widest text-ink-2 uppercase">
|
||||
{m.form_label_title()} <span class="text-danger">*</span>
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={activeFile.title}
|
||||
oninput={(e) =>
|
||||
setTitle(activeId!, (e.currentTarget as HTMLInputElement).value)}
|
||||
class="block w-full rounded-sm border border-line bg-surface p-2 text-sm focus:border-accent focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
{/if}
|
||||
</ScopeCard>
|
||||
|
||||
<ScopeCard variant="shared" count={files.size}>
|
||||
<WhoWhenSection
|
||||
bind:senderId={senderId}
|
||||
bind:selectedReceivers={selectedReceivers}
|
||||
bind:dateIso={dateIso}
|
||||
initialSenderName={initialSenderName}
|
||||
/>
|
||||
<DescriptionSection bind:tags={tags} hideTitle />
|
||||
</ScopeCard>
|
||||
{:else}
|
||||
<!-- N=0 (disabled placeholder) or N=1 (active): title + shared form -->
|
||||
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
|
||||
<label class="block">
|
||||
<span class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{m.form_label_title()} <span class="text-danger">*</span>
|
||||
</span>
|
||||
{#if activeFile}
|
||||
<input
|
||||
type="text"
|
||||
value={activeFile.title}
|
||||
oninput={(e) =>
|
||||
setTitle(activeId!, (e.currentTarget as HTMLInputElement).value)}
|
||||
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
{:else}
|
||||
<input
|
||||
type="text"
|
||||
disabled
|
||||
placeholder="—"
|
||||
class="block w-full rounded border border-line p-2 text-sm text-ink-3 shadow-sm"
|
||||
/>
|
||||
{/if}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<WhoWhenSection
|
||||
bind:senderId={senderId}
|
||||
bind:selectedReceivers={selectedReceivers}
|
||||
bind:dateIso={dateIso}
|
||||
initialSenderName={initialSenderName}
|
||||
/>
|
||||
<DescriptionSection bind:tags={tags} hideTitle />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Action bar: always visible at bottom of right panel -->
|
||||
<UploadSaveBar
|
||||
fileCount={files.size}
|
||||
chunkProgress={chunkProgress}
|
||||
onSave={save}
|
||||
onDiscard={handleDiscard}
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,338 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { goto } from '$app/navigation';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page, userEvent } from 'vitest/browser';
|
||||
import BulkDocumentEditLayout from './BulkDocumentEditLayout.svelte';
|
||||
import { createConfirmService, CONFIRM_KEY } from '$lib/services/confirm.svelte.js';
|
||||
|
||||
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function makeFile(name: string): File {
|
||||
return new File(['content'], name, { type: 'application/pdf' });
|
||||
}
|
||||
|
||||
async function addFilesViaInput(container: HTMLElement, files: File[]): Promise<void> {
|
||||
const input = container.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
if (!input) throw new Error('No file input found — is BulkDropZone visible?');
|
||||
await userEvent.upload(input, files);
|
||||
}
|
||||
|
||||
describe('BulkDocumentEditLayout', () => {
|
||||
it('N=0: shows BulkDropZone', async () => {
|
||||
render(BulkDocumentEditLayout, {});
|
||||
await expect.element(page.getByTestId('bulk-drop-zone')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('N=1: file-switcher-strip and per-file scope card are absent', async () => {
|
||||
const { container } = render(BulkDocumentEditLayout, {});
|
||||
await addFilesViaInput(container, [makeFile('doc.pdf')]);
|
||||
expect(container.querySelector('[data-testid="file-switcher-strip"]')).toBeNull();
|
||||
expect(container.querySelector('[data-variant="per-file"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('N=5: file-switcher-strip and per-file scope card are both present', async () => {
|
||||
const { container } = render(BulkDocumentEditLayout, {});
|
||||
await addFilesViaInput(container, [
|
||||
makeFile('a.pdf'),
|
||||
makeFile('b.pdf'),
|
||||
makeFile('c.pdf'),
|
||||
makeFile('d.pdf'),
|
||||
makeFile('e.pdf')
|
||||
]);
|
||||
expect(container.querySelector('[data-testid="file-switcher-strip"]')).not.toBeNull();
|
||||
expect(container.querySelector('[data-variant="per-file"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('removing middle file preserves order of remaining files', async () => {
|
||||
const { container } = render(BulkDocumentEditLayout, {});
|
||||
await addFilesViaInput(container, [
|
||||
makeFile('file0.pdf'),
|
||||
makeFile('file1.pdf'),
|
||||
makeFile('file2.pdf')
|
||||
]);
|
||||
|
||||
// Remove the chip for file1 via its remove button (identified by data-remove-id)
|
||||
const removeButtons = container.querySelectorAll<HTMLButtonElement>(
|
||||
'[data-testid="file-switcher-strip"] button[data-remove-id]'
|
||||
);
|
||||
expect(removeButtons.length).toBe(3);
|
||||
removeButtons[1].click(); // remove file1
|
||||
|
||||
// Wait for Svelte to flush the DOM update
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
const chips = container.querySelectorAll(
|
||||
'[data-testid="file-switcher-strip"] [data-chip-id]'
|
||||
);
|
||||
expect(chips.length).toBe(2);
|
||||
expect(chips[0].textContent?.trim()).toContain('file0');
|
||||
expect(chips[1].textContent?.trim()).toContain('file2');
|
||||
},
|
||||
{ timeout: 1000 }
|
||||
);
|
||||
});
|
||||
|
||||
it('save calls fetch twice for 12 files (2 chunks of 10)', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ created: [], updated: [], errors: [] })
|
||||
});
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
const { container } = render(BulkDocumentEditLayout, {});
|
||||
const files = Array.from({ length: 12 }, (_, i) => makeFile(`f${i}.pdf`));
|
||||
await addFilesViaInput(container, files);
|
||||
|
||||
const saveBtn = container.querySelector(
|
||||
'button[data-testid="bulk-save-btn"]'
|
||||
) as HTMLButtonElement;
|
||||
expect(saveBtn).not.toBeNull();
|
||||
saveBtn.click();
|
||||
|
||||
// Wait for async save to complete
|
||||
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(2), { timeout: 3000 });
|
||||
});
|
||||
|
||||
it('save marks file as error when server returns non-ok response', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
json: async () => ({ errors: [{ filename: 'f0.pdf', code: 'FILE_UPLOAD_FAILED' }] })
|
||||
});
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
const { container } = render(BulkDocumentEditLayout, {});
|
||||
await addFilesViaInput(container, [makeFile('f0.pdf')]);
|
||||
|
||||
const saveBtn = container.querySelector(
|
||||
'button[data-testid="bulk-save-btn"]'
|
||||
) as HTMLButtonElement;
|
||||
saveBtn.click();
|
||||
|
||||
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1), { timeout: 3000 });
|
||||
});
|
||||
|
||||
it('save() includes tagNames in metadata payload', async () => {
|
||||
let capturedFormData: FormData | undefined;
|
||||
const mockFetch = vi.fn().mockImplementation(async (_url: string, init: RequestInit) => {
|
||||
capturedFormData = init?.body as FormData;
|
||||
return { ok: true, json: async () => ({ created: [], updated: [], errors: [] }) };
|
||||
});
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
const { container } = render(BulkDocumentEditLayout, {});
|
||||
await addFilesViaInput(container, [makeFile('doc.pdf')]);
|
||||
|
||||
const saveBtn = container.querySelector(
|
||||
'button[data-testid="bulk-save-btn"]'
|
||||
) as HTMLButtonElement;
|
||||
saveBtn.click();
|
||||
|
||||
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1), { timeout: 3000 });
|
||||
|
||||
expect(capturedFormData).toBeDefined();
|
||||
const metadataBlob = capturedFormData!.get('metadata') as Blob;
|
||||
const metadataJson = JSON.parse(await metadataBlob.text());
|
||||
expect(metadataJson).toHaveProperty('tagNames');
|
||||
});
|
||||
|
||||
it('save() navigates to /documents when all chunks succeed', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ created: [], updated: [], errors: [] })
|
||||
});
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
const { container } = render(BulkDocumentEditLayout, {});
|
||||
await addFilesViaInput(container, [makeFile('doc.pdf')]);
|
||||
|
||||
const saveBtn = container.querySelector(
|
||||
'button[data-testid="bulk-save-btn"]'
|
||||
) as HTMLButtonElement;
|
||||
saveBtn.click();
|
||||
|
||||
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1), { timeout: 3000 });
|
||||
await vi.waitFor(() => expect(goto).toHaveBeenCalledWith('/documents'), { timeout: 3000 });
|
||||
});
|
||||
|
||||
it('save() does not navigate when chunk returns non-ok response', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
json: async () => ({ errors: [{ filename: 'f0.pdf', code: 'FILE_UPLOAD_FAILED' }] })
|
||||
});
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
const { container } = render(BulkDocumentEditLayout, {});
|
||||
await addFilesViaInput(container, [makeFile('f0.pdf')]);
|
||||
|
||||
const saveBtn = container.querySelector(
|
||||
'button[data-testid="bulk-save-btn"]'
|
||||
) as HTMLButtonElement;
|
||||
saveBtn.click();
|
||||
|
||||
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1), { timeout: 3000 });
|
||||
expect(goto).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('save marks only the file whose filename matches the backend error, not adjacent files', async () => {
|
||||
// backend returns error keyed to b.pdf — only b.pdf chip should get data-status="error"
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
json: async () => ({ errors: [{ filename: 'b.pdf', code: 'FILE_UPLOAD_FAILED' }] })
|
||||
});
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
const { container } = render(BulkDocumentEditLayout, {});
|
||||
await addFilesViaInput(container, [makeFile('a.pdf'), makeFile('b.pdf'), makeFile('c.pdf')]);
|
||||
|
||||
const saveBtn = container.querySelector(
|
||||
'button[data-testid="bulk-save-btn"]'
|
||||
) as HTMLButtonElement;
|
||||
saveBtn.click();
|
||||
|
||||
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1), { timeout: 3000 });
|
||||
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
const errorChips = container.querySelectorAll('[data-chip-id][data-status="error"]');
|
||||
expect(errorChips.length).toBe(1);
|
||||
expect(errorChips[0].textContent).toContain('b');
|
||||
},
|
||||
{ timeout: 1000 }
|
||||
);
|
||||
});
|
||||
|
||||
it('save() marks only the failed file when server returns HTTP 200 with a partial errors array', async () => {
|
||||
// Backend can return 200 OK while reporting individual file failures
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
created: [{ id: '1' }],
|
||||
updated: [],
|
||||
errors: [{ filename: 'b.pdf', code: 'FILE_UPLOAD_FAILED' }]
|
||||
})
|
||||
});
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
const { container } = render(BulkDocumentEditLayout, {});
|
||||
await addFilesViaInput(container, [makeFile('a.pdf'), makeFile('b.pdf'), makeFile('c.pdf')]);
|
||||
|
||||
const saveBtn = container.querySelector(
|
||||
'button[data-testid="bulk-save-btn"]'
|
||||
) as HTMLButtonElement;
|
||||
saveBtn.click();
|
||||
|
||||
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1), { timeout: 3000 });
|
||||
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
const errorChips = container.querySelectorAll('[data-chip-id][data-status="error"]');
|
||||
expect(errorChips.length).toBe(1);
|
||||
expect(errorChips[0].textContent).toContain('b');
|
||||
},
|
||||
{ timeout: 1000 }
|
||||
);
|
||||
// Navigation should be suppressed because hadErrors is true
|
||||
expect(goto).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('save() marks all chunk files as errored when fetch throws a network error', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network error')));
|
||||
|
||||
const { container } = render(BulkDocumentEditLayout, {});
|
||||
await addFilesViaInput(container, [makeFile('a.pdf'), makeFile('b.pdf')]);
|
||||
|
||||
const saveBtn = container.querySelector(
|
||||
'button[data-testid="bulk-save-btn"]'
|
||||
) as HTMLButtonElement;
|
||||
saveBtn.click();
|
||||
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
const errorChips = container.querySelectorAll('[data-chip-id][data-status="error"]');
|
||||
expect(errorChips.length).toBe(2);
|
||||
},
|
||||
{ timeout: 3000 }
|
||||
);
|
||||
expect(goto).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('save() does not call fetch a second time when already saving', async () => {
|
||||
let resolveFirst: (() => void) | undefined;
|
||||
const mockFetch = vi.fn().mockImplementation(
|
||||
() =>
|
||||
new Promise<Response>((resolve) => {
|
||||
resolveFirst = () =>
|
||||
resolve({
|
||||
ok: true,
|
||||
json: async () => ({ created: [], updated: [], errors: [] })
|
||||
} as Response);
|
||||
})
|
||||
);
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
const { container } = render(BulkDocumentEditLayout, {});
|
||||
await addFilesViaInput(container, [makeFile('a.pdf')]);
|
||||
|
||||
const saveBtn = container.querySelector(
|
||||
'button[data-testid="bulk-save-btn"]'
|
||||
) as HTMLButtonElement;
|
||||
saveBtn.click(); // first click — fetch is in-flight
|
||||
saveBtn.click(); // second click — should be a no-op
|
||||
|
||||
resolveFirst?.();
|
||||
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1), { timeout: 3000 });
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('discard-all does not clear files when the user cancels the confirm dialog', async () => {
|
||||
const service = createConfirmService();
|
||||
const { container } = render(BulkDocumentEditLayout, {
|
||||
context: new Map([[CONFIRM_KEY, service]])
|
||||
});
|
||||
await addFilesViaInput(container, [makeFile('a.pdf'), makeFile('b.pdf')]);
|
||||
|
||||
const discardBtn = container.querySelector(
|
||||
'button[data-testid="discard-all-btn"]'
|
||||
) as HTMLButtonElement;
|
||||
discardBtn.click();
|
||||
|
||||
// The confirm dialog should open (service.options not null)
|
||||
await vi.waitFor(() => expect(service.options).not.toBeNull(), { timeout: 1000 });
|
||||
|
||||
// Cancel — files should remain
|
||||
service.settle(false);
|
||||
await vi.waitFor(
|
||||
() => expect(container.querySelector('[data-testid="file-switcher-strip"]')).not.toBeNull(),
|
||||
{ timeout: 1000 }
|
||||
);
|
||||
});
|
||||
|
||||
it('discard-all resets to N=0 state and shows drop zone', async () => {
|
||||
const { container } = render(BulkDocumentEditLayout, {});
|
||||
await addFilesViaInput(container, [makeFile('a.pdf'), makeFile('b.pdf')]);
|
||||
|
||||
// Confirm N=2 state — switcher is visible
|
||||
expect(container.querySelector('[data-testid="file-switcher-strip"]')).not.toBeNull();
|
||||
|
||||
// Click the topbar discard-all button (only visible in isMulti state)
|
||||
const discardBtn = container.querySelector(
|
||||
'button[data-testid="discard-all-btn"]'
|
||||
) as HTMLButtonElement;
|
||||
expect(discardBtn).not.toBeNull();
|
||||
discardBtn.click();
|
||||
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
expect(container.querySelector('[data-testid="bulk-drop-zone"]')).not.toBeNull();
|
||||
expect(container.querySelector('[data-testid="file-switcher-strip"]')).toBeNull();
|
||||
},
|
||||
{ timeout: 1000 }
|
||||
);
|
||||
});
|
||||
});
|
||||
80
frontend/src/lib/components/document/BulkDropZone.svelte
Normal file
80
frontend/src/lib/components/document/BulkDropZone.svelte
Normal file
@@ -0,0 +1,80 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
let {
|
||||
onFilesAdded
|
||||
}: {
|
||||
onFilesAdded: (files: File[]) => void;
|
||||
} = $props();
|
||||
|
||||
let isDragging = $state(false);
|
||||
</script>
|
||||
|
||||
<div
|
||||
role="region"
|
||||
aria-label={m.bulk_drop_zone_label()}
|
||||
aria-describedby="bulk-drop-desc"
|
||||
data-testid="bulk-drop-zone"
|
||||
class="flex flex-1 flex-col items-center justify-center p-6"
|
||||
ondragover={(e) => {
|
||||
e.preventDefault();
|
||||
isDragging = true;
|
||||
}}
|
||||
ondragleave={() => (isDragging = false)}
|
||||
ondrop={(e) => {
|
||||
e.preventDefault();
|
||||
isDragging = false;
|
||||
if (e.dataTransfer && e.dataTransfer.files.length > 0) {
|
||||
onFilesAdded(Array.from(e.dataTransfer.files));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
class={[
|
||||
'flex w-full max-w-xl flex-col items-center gap-5 rounded-md border-2 border-dashed px-12 py-16 text-center transition-colors',
|
||||
isDragging ? 'border-accent bg-accent/10' : 'border-accent/50 bg-white/[0.04]'
|
||||
].join(' ')}
|
||||
>
|
||||
<!-- Circular mint icon -->
|
||||
<div class="flex h-20 w-20 items-center justify-center rounded-full bg-accent text-primary">
|
||||
<svg
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 32 32"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<polygon
|
||||
fill="currentColor"
|
||||
points="6 12.5 16 2 26 12.5 24.5714286 14 16.999 6.049 17 30 15 30 14.999 6.051 7.42857143 14"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Serif title -->
|
||||
<p class="font-serif text-base font-bold text-ink">{m.bulk_drop_hint()}</p>
|
||||
|
||||
<!-- Sub description -->
|
||||
<p id="bulk-drop-desc" class="text-sm leading-relaxed text-ink-2">{m.bulk_drop_desc()}</p>
|
||||
|
||||
<!-- CTA button -->
|
||||
<label
|
||||
class="flex min-h-[44px] cursor-pointer items-center rounded-sm bg-primary px-6 py-2 text-xs font-bold tracking-widest text-primary-fg uppercase transition-opacity hover:opacity-90"
|
||||
>
|
||||
{m.bulk_select_files()}
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
accept="application/pdf"
|
||||
class="sr-only"
|
||||
onchange={(e) => {
|
||||
const files = Array.from(e.currentTarget.files ?? []);
|
||||
if (files.length > 0) onFilesAdded(files);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<!-- Format hint -->
|
||||
<p class="text-xs text-ink-3">{m.bulk_drop_sub()}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page, userEvent } from 'vitest/browser';
|
||||
import BulkDropZone from './BulkDropZone.svelte';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('BulkDropZone', () => {
|
||||
it('file input has multiple attribute', async () => {
|
||||
const { container } = render(BulkDropZone, { onFilesAdded: vi.fn() });
|
||||
const input = container.querySelector('input[type="file"]');
|
||||
expect(input).not.toBeNull();
|
||||
expect(input?.hasAttribute('multiple')).toBe(true);
|
||||
});
|
||||
|
||||
it('fires onFilesAdded with selected files when 3 files are picked via input', async () => {
|
||||
const onFilesAdded = vi.fn();
|
||||
render(BulkDropZone, { onFilesAdded });
|
||||
|
||||
const files = [
|
||||
new File(['a'], 'a.pdf', { type: 'application/pdf' }),
|
||||
new File(['b'], 'b.pdf', { type: 'application/pdf' }),
|
||||
new File(['c'], 'c.pdf', { type: 'application/pdf' })
|
||||
];
|
||||
|
||||
const input = page.getByRole('button', { name: /Dateien auswählen/i });
|
||||
await userEvent.upload(input, files);
|
||||
|
||||
expect(onFilesAdded).toHaveBeenCalledOnce();
|
||||
const received: File[] = onFilesAdded.mock.calls[0][0];
|
||||
expect(received).toHaveLength(3);
|
||||
expect(received.map((f) => f.name)).toEqual(['a.pdf', 'b.pdf', 'c.pdf']);
|
||||
});
|
||||
|
||||
it('shows drop hint text', async () => {
|
||||
render(BulkDropZone, { onFilesAdded: vi.fn() });
|
||||
await expect.element(page.getByText(/hier ablegen/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
150
frontend/src/lib/components/document/FileSwitcherStrip.svelte
Normal file
150
frontend/src/lib/components/document/FileSwitcherStrip.svelte
Normal file
@@ -0,0 +1,150 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
export interface FileEntry {
|
||||
id: string;
|
||||
file: File;
|
||||
title: string;
|
||||
status: 'idle' | 'error';
|
||||
previewUrl: string;
|
||||
}
|
||||
|
||||
let {
|
||||
files,
|
||||
activeId,
|
||||
onSelect,
|
||||
onRemove
|
||||
}: {
|
||||
files: FileEntry[];
|
||||
activeId: string;
|
||||
onSelect: (id: string) => void;
|
||||
onRemove: (id: string) => void;
|
||||
} = $props();
|
||||
|
||||
let trackEl = $state<HTMLDivElement | null>(null);
|
||||
let listEl = $state<HTMLUListElement | null>(null);
|
||||
|
||||
const activeAnnouncement = $derived(files.find((f) => f.id === activeId)?.title ?? '');
|
||||
|
||||
function scrollPrev() {
|
||||
trackEl?.scrollBy({ left: -120, behavior: 'smooth' });
|
||||
}
|
||||
function scrollNext() {
|
||||
trackEl?.scrollBy({ left: 120, behavior: 'smooth' });
|
||||
}
|
||||
|
||||
async function handleRemove(entry: FileEntry, index: number) {
|
||||
const targetId = index > 0 ? files[index - 1].id : (files[index + 1]?.id ?? null);
|
||||
onRemove(entry.id);
|
||||
if (targetId) {
|
||||
await tick();
|
||||
(listEl?.querySelector<HTMLElement>(`[data-chip-id="${targetId}"]`) ?? null)?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!listEl) return;
|
||||
const node = listEl;
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
const buttons = Array.from(node.querySelectorAll<HTMLElement>('[data-chip-id]'));
|
||||
if (buttons.length === 0) return;
|
||||
|
||||
const focusedIndex = buttons.indexOf(document.activeElement as HTMLElement);
|
||||
if (focusedIndex === -1) return;
|
||||
|
||||
if (event.key === 'ArrowRight' || event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
const nextIndex = (focusedIndex + 1) % buttons.length;
|
||||
buttons[nextIndex].focus();
|
||||
} else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
const prevIndex = (focusedIndex - 1 + buttons.length) % buttons.length;
|
||||
buttons[prevIndex].focus();
|
||||
}
|
||||
}
|
||||
|
||||
node.addEventListener('keydown', handleKeyDown);
|
||||
return () => node.removeEventListener('keydown', handleKeyDown);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div aria-live="polite" aria-atomic="true" class="sr-only">{activeAnnouncement}</div>
|
||||
<div
|
||||
data-testid="file-switcher-strip"
|
||||
class="flex h-11 shrink-0 items-center gap-1 border-t border-line bg-pdf-ctrl px-2"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={m.bulk_switcher_prev()}
|
||||
onclick={scrollPrev}
|
||||
class="flex h-[44px] w-[44px] shrink-0 items-center justify-center rounded-sm text-sm text-ink-3 hover:bg-black/10 hover:text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>‹</button
|
||||
>
|
||||
|
||||
<!-- Gradient fade overlays signal hidden overflow to pointer-only users -->
|
||||
<div class="relative flex flex-1 overflow-hidden">
|
||||
<div
|
||||
class="pointer-events-none absolute inset-y-0 left-0 z-10 w-6 bg-gradient-to-r from-pdf-ctrl to-transparent"
|
||||
></div>
|
||||
<div
|
||||
class="pointer-events-none absolute inset-y-0 right-0 z-10 w-6 bg-gradient-to-l from-pdf-ctrl to-transparent"
|
||||
></div>
|
||||
<div bind:this={trackEl} class="flex flex-1 gap-1 overflow-x-auto" style="scrollbar-width:none">
|
||||
<ul bind:this={listEl} role="list" class="flex flex-row gap-1 py-1">
|
||||
{#each files as entry, i (entry.id)}
|
||||
<li role="listitem" class="inline-flex shrink-0 items-center">
|
||||
<button
|
||||
type="button"
|
||||
tabindex="0"
|
||||
aria-current={entry.id === activeId ? 'true' : undefined}
|
||||
data-status={entry.status}
|
||||
data-chip-id={entry.id}
|
||||
onclick={() => onSelect(entry.id)}
|
||||
class={[
|
||||
'inline-flex cursor-pointer items-center gap-1 rounded-[2px] px-1.5 py-0.5 text-xs font-bold transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent',
|
||||
entry.id === activeId
|
||||
? 'bg-accent text-primary'
|
||||
: 'bg-black/[0.06] text-ink-2 hover:bg-black/10',
|
||||
entry.status === 'error'
|
||||
? '!border !border-dashed !border-red-400 !bg-red-50/80 !text-red-700'
|
||||
: ''
|
||||
].join(' ')}
|
||||
>
|
||||
<span
|
||||
class={[
|
||||
'rounded-[2px] px-0.5 text-[11px] font-extrabold opacity-85',
|
||||
entry.id === activeId ? 'bg-black/20' : 'bg-black/10'
|
||||
].join(' ')}
|
||||
>{i + 1}</span
|
||||
>
|
||||
<span class="max-w-[8rem] truncate" title={entry.title}>{entry.title}</span>
|
||||
{#if entry.status === 'error'}
|
||||
<span class="sr-only">{m.bulk_file_error_chip_label()}</span>
|
||||
<span aria-hidden="true" class="ml-0.5 font-extrabold text-red-600">!</span>
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={m.bulk_remove_file()}
|
||||
data-remove-id={entry.id}
|
||||
onclick={() => handleRemove(entry, i)}
|
||||
class="ml-0.5 flex h-[44px] w-[44px] items-center justify-center text-base text-ink-3 hover:text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-label={m.bulk_switcher_next()}
|
||||
onclick={scrollNext}
|
||||
class="flex h-[44px] w-[44px] shrink-0 items-center justify-center rounded-sm text-sm text-ink-3 hover:bg-black/10 hover:text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>›</button
|
||||
>
|
||||
</div>
|
||||
@@ -0,0 +1,145 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page, userEvent } from 'vitest/browser';
|
||||
import FileSwitcherStrip from './FileSwitcherStrip.svelte';
|
||||
import type { FileEntry } from './FileSwitcherStrip.svelte';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function makeFiles(n: number): FileEntry[] {
|
||||
return Array.from({ length: n }, (_, i) => ({
|
||||
id: `id-${i}`,
|
||||
file: new File([''], `file${i}.pdf`),
|
||||
title: `File ${i}`,
|
||||
status: 'idle' as const,
|
||||
previewUrl: ''
|
||||
}));
|
||||
}
|
||||
|
||||
describe('FileSwitcherStrip', () => {
|
||||
it('renders N chips for N files', async () => {
|
||||
const files = makeFiles(4);
|
||||
render(FileSwitcherStrip, {
|
||||
files,
|
||||
activeId: files[0].id,
|
||||
onSelect: vi.fn(),
|
||||
onRemove: vi.fn()
|
||||
});
|
||||
const chips = page.getByRole('listitem');
|
||||
await expect.element(chips.nth(0)).toBeInTheDocument();
|
||||
await expect.element(chips.nth(3)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('active chip has aria-current="true"', async () => {
|
||||
const files = makeFiles(3);
|
||||
const { container } = render(FileSwitcherStrip, {
|
||||
files,
|
||||
activeId: files[1].id,
|
||||
onSelect: vi.fn(),
|
||||
onRemove: vi.fn()
|
||||
});
|
||||
const activeBtn = container.querySelector('[aria-current="true"]');
|
||||
expect(activeBtn).not.toBeNull();
|
||||
expect(activeBtn?.textContent).toContain('File 1');
|
||||
});
|
||||
|
||||
it('clicking a chip fires onSelect with its id', async () => {
|
||||
const files = makeFiles(3);
|
||||
const onSelect = vi.fn();
|
||||
const { container } = render(FileSwitcherStrip, {
|
||||
files,
|
||||
activeId: files[0].id,
|
||||
onSelect,
|
||||
onRemove: vi.fn()
|
||||
});
|
||||
const chip = container.querySelector('[data-chip-id="id-2"]') as HTMLElement;
|
||||
expect(chip).not.toBeNull();
|
||||
chip.click();
|
||||
expect(onSelect).toHaveBeenCalledWith('id-2');
|
||||
});
|
||||
|
||||
it('error chip has aria-label containing warning indicator', async () => {
|
||||
const files: FileEntry[] = [
|
||||
{
|
||||
id: 'e1',
|
||||
file: new File([''], 'bad.pdf'),
|
||||
title: 'Bad file',
|
||||
status: 'error',
|
||||
previewUrl: ''
|
||||
}
|
||||
];
|
||||
const { container } = render(FileSwitcherStrip, {
|
||||
files,
|
||||
activeId: 'e1',
|
||||
onSelect: vi.fn(),
|
||||
onRemove: vi.fn()
|
||||
});
|
||||
const errBtn = container.querySelector('[data-status="error"]');
|
||||
expect(errBtn).not.toBeNull();
|
||||
});
|
||||
|
||||
it('error chip contains a screen-reader-only error label', async () => {
|
||||
const files: FileEntry[] = [
|
||||
{
|
||||
id: 'e1',
|
||||
file: new File([''], 'bad.pdf'),
|
||||
title: 'Bad file',
|
||||
status: 'error',
|
||||
previewUrl: ''
|
||||
}
|
||||
];
|
||||
const { container } = render(FileSwitcherStrip, {
|
||||
files,
|
||||
activeId: 'e1',
|
||||
onSelect: vi.fn(),
|
||||
onRemove: vi.fn()
|
||||
});
|
||||
const errBtn = container.querySelector('[data-status="error"]');
|
||||
const srOnly = errBtn?.querySelector('.sr-only');
|
||||
expect(srOnly).not.toBeNull();
|
||||
});
|
||||
|
||||
it('focus moves to the previous chip after the middle chip is removed', async () => {
|
||||
const files = makeFiles(3); // id-0, id-1, id-2
|
||||
const onRemove = vi.fn();
|
||||
const { container } = render(FileSwitcherStrip, {
|
||||
files,
|
||||
activeId: files[1].id,
|
||||
onSelect: vi.fn(),
|
||||
onRemove
|
||||
});
|
||||
|
||||
const removeBtn = container.querySelector('[data-remove-id="id-1"]') as HTMLButtonElement;
|
||||
expect(removeBtn).not.toBeNull();
|
||||
removeBtn.click();
|
||||
expect(onRemove).toHaveBeenCalledWith('id-1');
|
||||
|
||||
// After removal, focus should be on the chip for id-0 (the previous chip)
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
const prevChip = container.querySelector('[data-chip-id="id-0"]') as HTMLElement | null;
|
||||
expect(prevChip).not.toBeNull();
|
||||
expect(document.activeElement).toBe(prevChip);
|
||||
},
|
||||
{ timeout: 1000 }
|
||||
);
|
||||
});
|
||||
|
||||
it('ArrowRight moves focus to next chip without leaving strip', async () => {
|
||||
const files = makeFiles(3);
|
||||
const { container } = render(FileSwitcherStrip, {
|
||||
files,
|
||||
activeId: files[0].id,
|
||||
onSelect: vi.fn(),
|
||||
onRemove: vi.fn()
|
||||
});
|
||||
const firstBtn = container.querySelectorAll('[data-chip-id]')[0] as HTMLElement;
|
||||
firstBtn.focus();
|
||||
await userEvent.keyboard('{ArrowRight}');
|
||||
const focused = document.activeElement;
|
||||
expect(focused).not.toBe(firstBtn);
|
||||
// The new focused element should still be inside the strip
|
||||
const strip = container.querySelector('[data-testid="file-switcher-strip"]');
|
||||
expect(strip?.contains(focused)).toBe(true);
|
||||
});
|
||||
});
|
||||
40
frontend/src/lib/components/document/ScopeCard.svelte
Normal file
40
frontend/src/lib/components/document/ScopeCard.svelte
Normal file
@@ -0,0 +1,40 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
let {
|
||||
variant,
|
||||
count = 0,
|
||||
children
|
||||
}: {
|
||||
variant: 'per-file' | 'shared';
|
||||
count?: number;
|
||||
children?: import('svelte').Snippet;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
data-testid="scope-card"
|
||||
data-variant={variant}
|
||||
class="mb-3 rounded-sm border p-4
|
||||
{variant === 'per-file'
|
||||
? 'border-accent bg-accent-bg'
|
||||
: 'border-line bg-surface'}"
|
||||
>
|
||||
{#if variant === 'shared'}
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<span class="text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{m.bulk_scope_shared_label({ count })}
|
||||
</span>
|
||||
<span
|
||||
class="inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-accent px-1.5 text-xs font-bold text-primary"
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="mb-3 text-xs font-bold tracking-widest text-primary uppercase">
|
||||
{m.bulk_scope_per_file_label()}
|
||||
</p>
|
||||
{/if}
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import ScopeCard from './ScopeCard.svelte';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('ScopeCard', () => {
|
||||
it('per-file variant has accent background class', async () => {
|
||||
const { container } = render(ScopeCard, { variant: 'per-file', count: 1 });
|
||||
const card = container.querySelector('[data-testid="scope-card"]');
|
||||
expect(card?.className).toMatch(/bg-accent-bg/);
|
||||
});
|
||||
|
||||
it('shared variant does not have accent background', async () => {
|
||||
const { container } = render(ScopeCard, { variant: 'shared', count: 3 });
|
||||
const card = container.querySelector('[data-testid="scope-card"]');
|
||||
expect(card?.className).not.toMatch(/bg-accent-bg/);
|
||||
});
|
||||
|
||||
it('shared variant renders count badge with file count', async () => {
|
||||
render(ScopeCard, { variant: 'shared', count: 5 });
|
||||
await expect.element(page.getByText('5', { exact: true })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('per-file variant renders slot content', async () => {
|
||||
// ScopeCard is a container — verify it renders children
|
||||
render(ScopeCard, { variant: 'per-file', count: 1 });
|
||||
const card = await page.getByTestId('scope-card');
|
||||
await expect.element(card).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
49
frontend/src/lib/components/document/UploadSaveBar.svelte
Normal file
49
frontend/src/lib/components/document/UploadSaveBar.svelte
Normal file
@@ -0,0 +1,49 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
let {
|
||||
fileCount,
|
||||
chunkProgress,
|
||||
onSave,
|
||||
onDiscard,
|
||||
disabled = false
|
||||
}: {
|
||||
fileCount: number;
|
||||
chunkProgress?: { done: number; total: number };
|
||||
onSave: () => void;
|
||||
onDiscard: () => void | Promise<void>;
|
||||
disabled?: boolean;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div class="shrink-0 border-t border-line bg-surface px-4 py-3">
|
||||
{#if chunkProgress}
|
||||
<progress
|
||||
value={chunkProgress.done}
|
||||
max={chunkProgress.total}
|
||||
aria-valuenow={chunkProgress.done}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={chunkProgress.total}
|
||||
aria-label={m.bulk_upload_progress({ done: chunkProgress.done, total: chunkProgress.total })}
|
||||
class="[&::-webkit-progress-bar]:bg-brand-sand mb-3 h-1 w-full rounded-full [&::-webkit-progress-bar]:rounded-full [&::-webkit-progress-value]:rounded-full [&::-webkit-progress-value]:bg-accent"
|
||||
></progress>
|
||||
{/if}
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onclick={onDiscard}
|
||||
class="flex min-h-[44px] items-center px-2 text-sm text-red-600/70 hover:text-red-700"
|
||||
>
|
||||
{m.bulk_discard_all()}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="bulk-save-btn"
|
||||
disabled={fileCount === 0 || disabled}
|
||||
onclick={onSave}
|
||||
class="min-h-[44px] rounded-sm bg-primary px-6 text-sm font-bold tracking-widest text-primary-fg uppercase transition-opacity hover:opacity-90 disabled:opacity-40"
|
||||
>
|
||||
{fileCount === 1 ? m.bulk_save_cta_one() : m.bulk_save_cta({ count: fileCount })}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import UploadSaveBar from './UploadSaveBar.svelte';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('UploadSaveBar', () => {
|
||||
it('shows plural label for multiple files', async () => {
|
||||
render(UploadSaveBar, { fileCount: 5, onSave: vi.fn(), onDiscard: vi.fn() });
|
||||
// "5 speichern →" or similar plural form
|
||||
await expect.element(page.getByText(/5/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows singular label for one file', async () => {
|
||||
render(UploadSaveBar, { fileCount: 1, onSave: vi.fn(), onDiscard: vi.fn() });
|
||||
// "Speichern →" singular form
|
||||
await expect.element(page.getByText(/Speichern/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('progress bar is visible when chunkProgress is provided', async () => {
|
||||
const { container } = render(UploadSaveBar, {
|
||||
fileCount: 3,
|
||||
chunkProgress: { done: 1, total: 3 },
|
||||
onSave: vi.fn(),
|
||||
onDiscard: vi.fn()
|
||||
});
|
||||
const progress = container.querySelector('progress');
|
||||
expect(progress).not.toBeNull();
|
||||
expect(progress?.getAttribute('value')).toBe('1');
|
||||
expect(progress?.getAttribute('max')).toBe('3');
|
||||
});
|
||||
|
||||
it('progress bar is not rendered when no chunkProgress', async () => {
|
||||
const { container } = render(UploadSaveBar, {
|
||||
fileCount: 2,
|
||||
onSave: vi.fn(),
|
||||
onDiscard: vi.fn()
|
||||
});
|
||||
const progress = container.querySelector('progress');
|
||||
expect(progress).toBeNull();
|
||||
});
|
||||
|
||||
it('discard link is rendered', async () => {
|
||||
render(UploadSaveBar, { fileCount: 2, onSave: vi.fn(), onDiscard: vi.fn() });
|
||||
await expect.element(page.getByText(/verwerfen/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -69,8 +69,7 @@ $effect(() => {
|
||||
oninput={handleDateInput}
|
||||
placeholder={m.form_placeholder_date()}
|
||||
maxlength="10"
|
||||
autofocus={!initialDateIso}
|
||||
class="block w-full rounded border border-line p-2 text-sm shadow-sm
|
||||
class="block w-full rounded border border-line px-2 py-3 text-sm shadow-sm
|
||||
{dateInvalid ? 'border-red-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500' : 'focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'}"
|
||||
aria-describedby={dateInvalid ? 'date-error' : undefined}
|
||||
/>
|
||||
@@ -89,7 +88,6 @@ $effect(() => {
|
||||
bind:value={senderId}
|
||||
initialName={initialSenderName}
|
||||
suggestedName={suggestedSenderName}
|
||||
autofocus={!!initialDateIso}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -110,7 +108,7 @@ $effect(() => {
|
||||
name="location"
|
||||
value={initialLocation}
|
||||
placeholder={m.form_placeholder_location()}
|
||||
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
class="block w-full rounded border border-line px-2 py-3 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -41,6 +41,7 @@ export type ErrorCode =
|
||||
| 'UNAUTHORIZED'
|
||||
| 'FORBIDDEN'
|
||||
| 'VALIDATION_ERROR'
|
||||
| 'BATCH_TOO_LARGE'
|
||||
| 'INTERNAL_ERROR';
|
||||
|
||||
export interface BackendError {
|
||||
@@ -139,6 +140,8 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string {
|
||||
return m.error_forbidden();
|
||||
case 'VALIDATION_ERROR':
|
||||
return m.error_validation_error();
|
||||
case 'BATCH_TOO_LARGE':
|
||||
return m.error_batch_too_large();
|
||||
default:
|
||||
return m.error_internal_error();
|
||||
}
|
||||
|
||||
@@ -548,6 +548,22 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/admin/generate-thumbnails": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post: operations["generateThumbnails"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/admin/backfill-versions": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1028,6 +1044,22 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/documents/{id}/thumbnail": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["getDocumentThumbnail"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/documents/{documentId}/transcription-blocks/{blockId}/history": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1204,6 +1236,22 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/admin/thumbnail-status": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["thumbnailStatus"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/admin/import-status": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1390,7 +1438,6 @@ export interface components {
|
||||
thumbnailAspect?: "PORTRAIT" | "LANDSCAPE";
|
||||
/** Format: int32 */
|
||||
pageCount?: number;
|
||||
thumbnailUrl?: string;
|
||||
originalFilename: string;
|
||||
/** @enum {string} */
|
||||
status: "PLACEHOLDER" | "UPLOADED" | "TRANSCRIBED" | "REVIEWED" | "ARCHIVED";
|
||||
@@ -1413,6 +1460,7 @@ export interface components {
|
||||
sender?: components["schemas"]["Person"];
|
||||
tags?: components["schemas"]["Tag"][];
|
||||
trainingLabels?: ("KURRENT_RECOGNITION" | "KURRENT_SEGMENTATION")[];
|
||||
thumbnailUrl?: string;
|
||||
};
|
||||
UpdateTranscriptionBlockDTO: {
|
||||
text?: string;
|
||||
@@ -1639,6 +1687,17 @@ export interface components {
|
||||
/** Format: date-time */
|
||||
createdAt: string;
|
||||
};
|
||||
DocumentBatchMetadataDTO: {
|
||||
titles?: string[];
|
||||
/** Format: uuid */
|
||||
senderId?: string;
|
||||
receiverIds?: string[];
|
||||
/** Format: date */
|
||||
documentDate?: string;
|
||||
location?: string;
|
||||
tagNames?: string[];
|
||||
metadataComplete?: boolean;
|
||||
};
|
||||
QuickUploadResult: {
|
||||
created?: components["schemas"]["Document"][];
|
||||
updated?: components["schemas"]["Document"][];
|
||||
@@ -1673,6 +1732,21 @@ export interface components {
|
||||
/** Format: date-time */
|
||||
startedAt?: string;
|
||||
};
|
||||
BackfillStatus: {
|
||||
/** @enum {string} */
|
||||
state?: "IDLE" | "RUNNING" | "DONE" | "FAILED";
|
||||
message?: string;
|
||||
/** Format: int32 */
|
||||
total?: number;
|
||||
/** Format: int32 */
|
||||
processed?: number;
|
||||
/** Format: int32 */
|
||||
skipped?: number;
|
||||
/** Format: int32 */
|
||||
failed?: number;
|
||||
/** Format: date-time */
|
||||
startedAt?: string;
|
||||
};
|
||||
BackfillResult: {
|
||||
/** Format: int32 */
|
||||
count: number;
|
||||
@@ -1837,10 +1911,10 @@ export interface components {
|
||||
timeout?: number;
|
||||
};
|
||||
PageNotificationDTO: {
|
||||
/** Format: int32 */
|
||||
totalPages?: number;
|
||||
/** Format: int64 */
|
||||
totalElements?: number;
|
||||
/** Format: int32 */
|
||||
totalPages?: number;
|
||||
pageable?: components["schemas"]["PageableObject"];
|
||||
first?: boolean;
|
||||
last?: boolean;
|
||||
@@ -1921,7 +1995,13 @@ export interface components {
|
||||
DocumentSearchResult: {
|
||||
items: components["schemas"]["DocumentSearchItem"][];
|
||||
/** Format: int64 */
|
||||
total: number;
|
||||
totalElements: number;
|
||||
/** Format: int32 */
|
||||
pageNumber: number;
|
||||
/** Format: int32 */
|
||||
pageSize: number;
|
||||
/** Format: int32 */
|
||||
totalPages: number;
|
||||
};
|
||||
MatchOffset: {
|
||||
/** Format: int32 */
|
||||
@@ -3146,6 +3226,7 @@ export interface operations {
|
||||
content: {
|
||||
"multipart/form-data": {
|
||||
files?: string[];
|
||||
metadata?: components["schemas"]["DocumentBatchMetadataDTO"];
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -3249,6 +3330,26 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
generateThumbnails: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["BackfillStatus"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
backfillVersions: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -3969,6 +4070,28 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
getDocumentThumbnail: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": string;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
getBlockHistory: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -4032,6 +4155,10 @@ export interface operations {
|
||||
dir?: string;
|
||||
/** @description Tag operator: AND (default) or OR */
|
||||
tagOp?: string;
|
||||
/** @description Page number (0-indexed) */
|
||||
page?: number;
|
||||
/** @description Page size (max 100) */
|
||||
size?: number;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
@@ -4229,6 +4356,26 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
thumbnailStatus: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["BackfillStatus"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
importStatus: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseFilename, stripExtension } from './filename';
|
||||
import { parseFilename, stripExtension, bulkTitleFromFilename } from './filename';
|
||||
|
||||
describe('parseFilename', () => {
|
||||
describe('date-first patterns', () => {
|
||||
@@ -86,6 +86,24 @@ describe('parseFilename', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('bulkTitleFromFilename', () => {
|
||||
it('replaces underscores with spaces', () => {
|
||||
expect(bulkTitleFromFilename('hello_world.pdf')).toBe('hello world');
|
||||
});
|
||||
|
||||
it('replaces hyphens with spaces', () => {
|
||||
expect(bulkTitleFromFilename('2024-01-01_Max.pdf')).toBe('2024 01 01 Max');
|
||||
});
|
||||
|
||||
it('collapses multiple separators', () => {
|
||||
expect(bulkTitleFromFilename('foo__bar--baz.pdf')).toBe('foo bar baz');
|
||||
});
|
||||
|
||||
it('strips extension', () => {
|
||||
expect(bulkTitleFromFilename('document.pdf')).toBe('document');
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripExtension', () => {
|
||||
it('removes the extension', () => {
|
||||
expect(stripExtension('document.pdf')).toBe('document');
|
||||
|
||||
@@ -81,3 +81,7 @@ export function parseFilename(filename: string): FilenameParseResult {
|
||||
export function stripExtension(filename: string): string {
|
||||
return filename.replace(/\.[^/.]+$/, '');
|
||||
}
|
||||
|
||||
export function bulkTitleFromFilename(filename: string): string {
|
||||
return stripExtension(filename).replace(/[_-]+/g, ' ').trim();
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ type ValidSort = (typeof VALID_SORTS)[number];
|
||||
const VALID_DIRS = ['asc', 'desc'] as const;
|
||||
type ValidDir = (typeof VALID_DIRS)[number];
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
export async function load({ url, fetch }) {
|
||||
const q = url.searchParams.get('q') || '';
|
||||
const from = url.searchParams.get('from') || '';
|
||||
@@ -27,6 +29,7 @@ export async function load({ url, fetch }) {
|
||||
: 'desc';
|
||||
const tagQ = url.searchParams.get('tagQ') || '';
|
||||
const tagOp = url.searchParams.get('tagOp') === 'OR' ? 'OR' : 'AND';
|
||||
const page = Math.max(0, Number(url.searchParams.get('page') ?? '0') || 0);
|
||||
|
||||
const api = createApiClient(fetch);
|
||||
|
||||
@@ -44,14 +47,19 @@ export async function load({ url, fetch }) {
|
||||
tagQ: tagQ && !tags.length ? tagQ : undefined,
|
||||
tagOp: tagOp === 'OR' ? 'OR' : undefined,
|
||||
sort,
|
||||
dir: dir || undefined
|
||||
dir: dir || undefined,
|
||||
page,
|
||||
size: PAGE_SIZE
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
return {
|
||||
items: [] as DocumentSearchItem[],
|
||||
total: 0,
|
||||
totalElements: 0,
|
||||
pageNumber: 0,
|
||||
pageSize: PAGE_SIZE,
|
||||
totalPages: 0,
|
||||
q,
|
||||
from,
|
||||
to,
|
||||
@@ -77,7 +85,10 @@ export async function load({ url, fetch }) {
|
||||
|
||||
return {
|
||||
items: (result.data?.items ?? []) as DocumentSearchItem[],
|
||||
total: result.data?.total ?? 0,
|
||||
totalElements: result.data?.totalElements ?? 0,
|
||||
pageNumber: result.data?.pageNumber ?? page,
|
||||
pageSize: result.data?.pageSize ?? PAGE_SIZE,
|
||||
totalPages: result.data?.totalPages ?? 0,
|
||||
q,
|
||||
from,
|
||||
to,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { untrack } from 'svelte';
|
||||
import { SvelteURLSearchParams } from 'svelte/reactivity';
|
||||
import SearchFilterBar from '../SearchFilterBar.svelte';
|
||||
import DocumentList from '../DocumentList.svelte';
|
||||
import Pagination from '$lib/components/Pagination.svelte';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
||||
let { data } = $props();
|
||||
@@ -35,21 +36,88 @@ let showAdvanced = $state(untrack(hasAdvancedFilters));
|
||||
|
||||
let searchTimer: ReturnType<typeof setTimeout>;
|
||||
|
||||
function triggerSearch() {
|
||||
type FilterSnapshot = {
|
||||
q: string;
|
||||
from: string;
|
||||
to: string;
|
||||
senderId: string;
|
||||
receiverId: string;
|
||||
tags: string[];
|
||||
sort: string;
|
||||
dir: string;
|
||||
tagQ: string;
|
||||
tagOp: 'AND' | 'OR';
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds a URLSearchParams from a filter snapshot. Single source of truth for
|
||||
* which params the `/documents` URL understands — add a filter here and both
|
||||
* filter-change nav (triggerSearch) and page nav (buildPageHref) will pick it
|
||||
* up. `page` is appended only when > 0 so the default page 0 stays out of the
|
||||
* URL, keeping the filter-change-resets-to-page-0 behaviour implicit.
|
||||
*/
|
||||
function buildSearchParams(filters: FilterSnapshot, targetPage?: number): SvelteURLSearchParams {
|
||||
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);
|
||||
tagNames.forEach((tag) => params.append('tag', tag.name));
|
||||
if (sort) params.set('sort', sort);
|
||||
if (dir) params.set('dir', dir);
|
||||
if (tagQ) params.set('tagQ', tagQ);
|
||||
if (tagOperator === 'OR') params.set('tagOp', 'OR');
|
||||
if (filters.q) params.set('q', filters.q);
|
||||
if (filters.from) params.set('from', filters.from);
|
||||
if (filters.to) params.set('to', filters.to);
|
||||
if (filters.senderId) params.set('senderId', filters.senderId);
|
||||
if (filters.receiverId) params.set('receiverId', filters.receiverId);
|
||||
filters.tags.forEach((tag) => params.append('tag', tag));
|
||||
if (filters.sort) params.set('sort', filters.sort);
|
||||
if (filters.dir) params.set('dir', filters.dir);
|
||||
if (filters.tagQ) params.set('tagQ', filters.tagQ);
|
||||
if (filters.tagOp === 'OR') params.set('tagOp', 'OR');
|
||||
if (targetPage !== undefined && targetPage > 0) params.set('page', String(targetPage));
|
||||
return params;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuilds the URL from the CURRENT local filter state. `page` is intentionally
|
||||
* not carried over — any filter change implicitly resets back to page 0.
|
||||
*/
|
||||
function triggerSearch() {
|
||||
const params = buildSearchParams({
|
||||
q,
|
||||
from,
|
||||
to,
|
||||
senderId,
|
||||
receiverId,
|
||||
tags: tagNames.map((t) => t.name),
|
||||
sort,
|
||||
dir,
|
||||
tagQ,
|
||||
tagOp: tagOperator
|
||||
});
|
||||
goto(`/documents?${params.toString()}`, { keepFocus: true, noScroll: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the href for a Pagination prev/next link. Preserves every filter
|
||||
* param from server `data` and updates `page`. Uses a normal <a href> (not
|
||||
* goto) so SvelteKit's default scroll restoration brings the user to the top
|
||||
* of the new slice — the expected behaviour for page navigation.
|
||||
*/
|
||||
function buildPageHref(targetPage: number): string {
|
||||
const params = buildSearchParams(
|
||||
{
|
||||
q: data.q || '',
|
||||
from: data.from || '',
|
||||
to: data.to || '',
|
||||
senderId: data.senderId || '',
|
||||
receiverId: data.receiverId || '',
|
||||
tags: data.tags || [],
|
||||
sort: data.sort || '',
|
||||
dir: data.dir || '',
|
||||
tagQ: data.tagQ || '',
|
||||
tagOp: (data.tagOp as 'AND' | 'OR') || 'AND'
|
||||
},
|
||||
targetPage
|
||||
);
|
||||
const qs = params.toString();
|
||||
return qs ? `/documents?${qs}` : '/documents';
|
||||
}
|
||||
|
||||
function handleTextSearch() {
|
||||
clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(() => triggerSearch(), 500);
|
||||
@@ -115,10 +183,12 @@ $effect(() => {
|
||||
|
||||
<DocumentList
|
||||
items={data.items}
|
||||
total={data.total}
|
||||
total={data.totalElements}
|
||||
q={data.q}
|
||||
canWrite={data.canWrite}
|
||||
error={data.error}
|
||||
sort={sort}
|
||||
/>
|
||||
|
||||
<Pagination page={data.pageNumber} totalPages={data.totalPages} makeHref={buildPageHref} />
|
||||
</main>
|
||||
|
||||
@@ -1,154 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { untrack } from 'svelte';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import WhoWhenSection from '$lib/components/document/WhoWhenSection.svelte';
|
||||
import DescriptionSection from '$lib/components/document/DescriptionSection.svelte';
|
||||
import TranscriptionSection from '$lib/components/document/TranscriptionSection.svelte';
|
||||
import FileSectionNew from './FileSectionNew.svelte';
|
||||
import { type FilenameParseResult } from '$lib/utils/filename';
|
||||
import BulkDocumentEditLayout from '$lib/components/document/BulkDocumentEditLayout.svelte';
|
||||
|
||||
let { data, form } = $props();
|
||||
|
||||
let tags: { name: string; id?: string; color?: string; parentId?: string }[] = $state([]);
|
||||
let senderId = $state(untrack(() => data.initialSenderId));
|
||||
let selectedReceivers: { id: string; firstName?: string; lastName: string; displayName: string }[] =
|
||||
$state(untrack(() => data.initialReceivers));
|
||||
|
||||
let parsedSuggestion = $state<FilenameParseResult>({});
|
||||
|
||||
// Title is derived from the filename suggestion unless the user has typed something
|
||||
let titleDirty = $state(false);
|
||||
let titleOverride = $state('');
|
||||
let titleValue = $derived(
|
||||
titleDirty ? titleOverride : (parsedSuggestion.suggestedTitle ?? titleOverride)
|
||||
);
|
||||
|
||||
// Details panel: starts open when prefill data is present or a form error occurred.
|
||||
// Auto-opens when filename parsing finds a date/sender, but never force-closes — user
|
||||
// can always collapse the section manually.
|
||||
let detailsOpen = $state(
|
||||
!!(
|
||||
untrack(() => data.initialSenderId) ||
|
||||
untrack(() => data.initialReceivers).length > 0 ||
|
||||
untrack(() => form)?.error
|
||||
)
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
if (parsedSuggestion.dateIso || senderId || selectedReceivers.length > 0) {
|
||||
detailsOpen = true;
|
||||
}
|
||||
});
|
||||
let { data } = $props();
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-4xl px-4 py-8">
|
||||
<!-- Heading -->
|
||||
<div class="mb-6">
|
||||
<a
|
||||
href="/"
|
||||
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"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 19l-7-7m0 0l7-7m-7 7h18"
|
||||
/>
|
||||
</svg>
|
||||
{m.btn_back_to_overview()}
|
||||
</a>
|
||||
<h1 class="font-serif text-3xl text-ink">{m.doc_new_heading()}</h1>
|
||||
</div>
|
||||
|
||||
{#if form?.error}
|
||||
<div class="mb-6 rounded border border-red-200 bg-red-50 p-4 text-red-700">{form.error}</div>
|
||||
{/if}
|
||||
|
||||
<form method="POST" enctype="multipart/form-data" use:enhance class="space-y-6 pb-20">
|
||||
<!-- File upload — prominent, at the top -->
|
||||
<FileSectionNew onfileParsed={(r) => (parsedSuggestion = r)} />
|
||||
|
||||
<!-- Standalone title card -->
|
||||
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
|
||||
<label for="new-title" class="mb-1 block text-sm font-medium text-ink-2"
|
||||
>{m.form_label_title()}</label
|
||||
>
|
||||
<input
|
||||
id="new-title"
|
||||
type="text"
|
||||
name="title"
|
||||
value={titleValue}
|
||||
oninput={(e) => {
|
||||
titleOverride = (e.target as HTMLInputElement).value;
|
||||
titleDirty = true;
|
||||
}}
|
||||
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
placeholder="Titel eingeben…"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Collapsible further details -->
|
||||
<details
|
||||
bind:open={detailsOpen}
|
||||
class="group rounded-sm border border-line bg-surface shadow-sm"
|
||||
>
|
||||
<summary class="cursor-pointer list-none px-6 py-4">
|
||||
<span class="text-xs font-bold tracking-widest text-ink-3 uppercase"
|
||||
>{m.doc_more_details()}</span
|
||||
>
|
||||
</summary>
|
||||
<div class="space-y-6 px-0 pb-6">
|
||||
<WhoWhenSection
|
||||
bind:senderId={senderId}
|
||||
bind:selectedReceivers={selectedReceivers}
|
||||
initialSenderName={data.initialSenderName}
|
||||
suggestedDateIso={parsedSuggestion.dateIso ?? ''}
|
||||
suggestedSenderName={parsedSuggestion.personName ?? ''}
|
||||
/>
|
||||
<DescriptionSection bind:tags={tags} hideTitle={true} />
|
||||
<TranscriptionSection />
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Sticky Save Bar -->
|
||||
<div
|
||||
class="sticky bottom-0 z-10 -mx-4 border-t border-line bg-surface px-4 py-3 shadow-[0_-2px_8px_rgba(0,0,0,0.06)] sm:px-6 sm:py-4"
|
||||
>
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<a
|
||||
href="/"
|
||||
class="order-last text-center text-sm font-medium text-ink-2 transition-colors hover:text-ink sm:order-first sm:text-left"
|
||||
>
|
||||
{m.btn_cancel()}
|
||||
</a>
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
name="metadataComplete"
|
||||
value="false"
|
||||
formaction="?/save"
|
||||
class="w-full rounded-sm border border-line px-5 py-2.5 font-sans text-xs font-bold tracking-widest text-ink-2 uppercase transition-colors hover:bg-muted sm:w-auto sm:py-2"
|
||||
>
|
||||
{m.btn_save()}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
name="metadataComplete"
|
||||
value="true"
|
||||
formaction="?/saveReviewed"
|
||||
class="w-full rounded-sm bg-primary px-5 py-2.5 font-sans text-xs font-bold tracking-widest text-primary-fg uppercase transition-colors hover:bg-primary/90 sm:w-auto sm:py-2"
|
||||
>
|
||||
{m.btn_save_and_mark_reviewed()}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<BulkDocumentEditLayout
|
||||
initialSenderId={data.initialSenderId}
|
||||
initialSenderName={data.initialSenderName}
|
||||
initialReceivers={data.initialReceivers}
|
||||
/>
|
||||
|
||||
@@ -21,15 +21,14 @@ const baseData = {
|
||||
|
||||
describe('New document page – sender prefill', () => {
|
||||
it('shows an empty sender input when no senderId is in the URL', async () => {
|
||||
render(Page, { data: baseData, form: null });
|
||||
render(Page, { data: baseData });
|
||||
const input = document.querySelector<HTMLInputElement>('#senderId-search');
|
||||
expect(input?.value).toBe('');
|
||||
});
|
||||
|
||||
it('shows the sender name in the typeahead input when initialSenderName is set', async () => {
|
||||
render(Page, {
|
||||
data: { ...baseData, initialSenderId: 'p1', initialSenderName: 'Hans Müller' },
|
||||
form: null
|
||||
data: { ...baseData, initialSenderId: 'p1', initialSenderName: 'Hans Müller' }
|
||||
});
|
||||
const input = document.querySelector<HTMLInputElement>('#senderId-search');
|
||||
expect(input?.value).toBe('Hans Müller');
|
||||
@@ -37,8 +36,7 @@ describe('New document page – sender prefill', () => {
|
||||
|
||||
it('sets the hidden senderId input to the prefilled ID', async () => {
|
||||
render(Page, {
|
||||
data: { ...baseData, initialSenderId: 'p1', initialSenderName: 'Hans Müller' },
|
||||
form: null
|
||||
data: { ...baseData, initialSenderId: 'p1', initialSenderName: 'Hans Müller' }
|
||||
});
|
||||
const hidden = document.querySelector<HTMLInputElement>(
|
||||
'input[type="hidden"][name="senderId"]'
|
||||
@@ -51,7 +49,7 @@ describe('New document page – sender prefill', () => {
|
||||
|
||||
describe('New document page – receiver prefill', () => {
|
||||
it('shows no receiver chips when initialReceivers is empty', async () => {
|
||||
render(Page, { data: baseData, form: null });
|
||||
render(Page, { data: baseData });
|
||||
await expect.element(page.getByText('Anna Schmidt')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -62,7 +60,7 @@ describe('New document page – receiver prefill', () => {
|
||||
{ id: 'p2', firstName: 'Anna', lastName: 'Schmidt', displayName: 'Anna Schmidt' }
|
||||
]
|
||||
};
|
||||
render(Page, { data, form: null });
|
||||
render(Page, { data });
|
||||
await expect.element(page.getByText('Anna Schmidt')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -73,7 +71,7 @@ describe('New document page – receiver prefill', () => {
|
||||
{ id: 'p2', firstName: 'Anna', lastName: 'Schmidt', displayName: 'Anna Schmidt' }
|
||||
]
|
||||
};
|
||||
render(Page, { data, form: null });
|
||||
render(Page, { data });
|
||||
const hidden = document.querySelector<HTMLInputElement>('input[name="receiverIds"]');
|
||||
expect(hidden?.value).toBe('p2');
|
||||
});
|
||||
|
||||
@@ -25,7 +25,7 @@ describe('documents page load — search params', () => {
|
||||
it('passes q, from, to to the search API', async () => {
|
||||
const mockGet = vi.fn().mockResolvedValue({
|
||||
response: { ok: true, status: 200 },
|
||||
data: { items: [], total: 0 }
|
||||
data: { items: [], totalElements: 0, pageNumber: 0, pageSize: 50, totalPages: 0 }
|
||||
});
|
||||
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||
typeof createApiClient
|
||||
@@ -49,7 +49,7 @@ describe('documents page load — search params', () => {
|
||||
it('passes senderId and receiverId to the search API', async () => {
|
||||
const mockGet = vi.fn().mockResolvedValue({
|
||||
response: { ok: true, status: 200 },
|
||||
data: { items: [], total: 0 }
|
||||
data: { items: [], totalElements: 0, pageNumber: 0, pageSize: 50, totalPages: 0 }
|
||||
});
|
||||
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||
typeof createApiClient
|
||||
@@ -73,7 +73,7 @@ describe('documents page load — search params', () => {
|
||||
it('passes sort, dir, tagQ to the search API', async () => {
|
||||
const mockGet = vi.fn().mockResolvedValue({
|
||||
response: { ok: true, status: 200 },
|
||||
data: { items: [], total: 0 }
|
||||
data: { items: [], totalElements: 0, pageNumber: 0, pageSize: 50, totalPages: 0 }
|
||||
});
|
||||
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||
typeof createApiClient
|
||||
@@ -103,7 +103,7 @@ describe('documents page load — search params', () => {
|
||||
};
|
||||
const mockGet = vi.fn().mockResolvedValue({
|
||||
response: { ok: true, status: 200 },
|
||||
data: { items: [item], total: 42 }
|
||||
data: { items: [item], totalElements: 42, pageNumber: 0, pageSize: 50, totalPages: 1 }
|
||||
});
|
||||
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||
typeof createApiClient
|
||||
@@ -115,13 +115,13 @@ describe('documents page load — search params', () => {
|
||||
});
|
||||
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.total).toBe(42);
|
||||
expect(result.totalElements).toBe(42);
|
||||
});
|
||||
|
||||
it('returns filter values in the result for pre-filling the UI', async () => {
|
||||
const mockGet = vi.fn().mockResolvedValue({
|
||||
response: { ok: true, status: 200 },
|
||||
data: { items: [], total: 0 }
|
||||
data: { items: [], totalElements: 0, pageNumber: 0, pageSize: 50, totalPages: 0 }
|
||||
});
|
||||
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||
typeof createApiClient
|
||||
|
||||
@@ -118,4 +118,20 @@ describe('documents page — URL building', () => {
|
||||
expect.objectContaining({ keepFocus: true, noScroll: true })
|
||||
);
|
||||
});
|
||||
|
||||
it('filter change does not carry the current page — goto URL drops page param', async () => {
|
||||
const { goto } = await import('$app/navigation');
|
||||
vi.mocked(goto).mockClear();
|
||||
|
||||
// User is mid-way through results at page 5; change the search text.
|
||||
render(Page, { data: makeData({ q: 'old', pageNumber: 5 }) });
|
||||
|
||||
const input = page.getByRole('textbox', { name: SEARCH_LABEL });
|
||||
await input.fill('Brief');
|
||||
vi.advanceTimersByTime(500);
|
||||
|
||||
const [url] = vi.mocked(goto).mock.calls[0];
|
||||
expect(url).toContain('q=Brief');
|
||||
expect(url).not.toContain('page=');
|
||||
});
|
||||
});
|
||||
|
||||
124
frontend/src/routes/hilfe/transkription/+page.svelte
Normal file
124
frontend/src/routes/hilfe/transkription/+page.svelte
Normal file
@@ -0,0 +1,124 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import RichtlinienRuleCard from '$lib/components/RichtlinienRuleCard.svelte';
|
||||
|
||||
const rules = [
|
||||
{
|
||||
icon: '❓',
|
||||
title: m.richtlinien_rule_unleserlich_title(),
|
||||
body: m.richtlinien_rule_unleserlich_body(),
|
||||
beispielOutput: '[unleserlich]'
|
||||
},
|
||||
{
|
||||
icon: '✗',
|
||||
title: m.richtlinien_rule_durchgestrichen_title(),
|
||||
body: m.richtlinien_rule_durchgestrichen_body(),
|
||||
beispielOutput: '[durchgestrichen: der Text]'
|
||||
},
|
||||
{
|
||||
icon: 'ſ',
|
||||
title: m.richtlinien_rule_langes_s_title(),
|
||||
body: m.richtlinien_rule_langes_s_body(),
|
||||
beispielOutput: 's'
|
||||
},
|
||||
{
|
||||
icon: '?',
|
||||
title: m.richtlinien_rule_name_title(),
|
||||
body: m.richtlinien_rule_name_body(),
|
||||
beispielOutput: '[Müller?]'
|
||||
},
|
||||
{
|
||||
icon: '💬',
|
||||
title: m.richtlinien_rule_dialekt_title(),
|
||||
body: m.richtlinien_rule_dialekt_body()
|
||||
}
|
||||
];
|
||||
|
||||
const klaerungChips = [
|
||||
m.richtlinien_klaer_abkuerzungen(),
|
||||
m.richtlinien_klaer_datumsformate(),
|
||||
m.richtlinien_klaer_umbrueche(),
|
||||
m.richtlinien_klaer_caps()
|
||||
];
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{m.richtlinien_title()} — Familienarchiv</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-2xl px-4 py-10 font-serif">
|
||||
<!-- Title -->
|
||||
<h1 class="mb-4 text-3xl font-bold text-ink">{m.richtlinien_title()}</h1>
|
||||
|
||||
<!-- Intro -->
|
||||
<p class="mb-8 text-base leading-relaxed text-ink-2">{m.richtlinien_intro()}</p>
|
||||
|
||||
<!-- Wikipedia info card -->
|
||||
<div class="border-brand-sand mb-10 rounded-sm border bg-white p-5 shadow-sm">
|
||||
<p class="mb-3 font-sans text-sm text-ink-2">{m.richtlinien_wiki_text()}</p>
|
||||
<a
|
||||
href="https://de.wikipedia.org/wiki/Kurrent"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
referrerpolicy="no-referrer"
|
||||
class="inline-flex items-center gap-1 font-sans text-sm font-medium text-ink underline decoration-brand-mint decoration-[1.5px] underline-offset-[3px]"
|
||||
>
|
||||
{m.richtlinien_wiki_link()}
|
||||
<span class="new-tab ml-1 text-[11px] text-ink-3">({m.common_opens_new_tab()})</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Rules section -->
|
||||
<h2 class="mb-5 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{m.richtlinien_rules_label()}
|
||||
</h2>
|
||||
<div class="mb-10 flex flex-col gap-4">
|
||||
{#each rules as rule (rule.title)}
|
||||
<RichtlinienRuleCard
|
||||
icon={rule.icon}
|
||||
title={rule.title}
|
||||
body={rule.body}
|
||||
beispielOutput={rule.beispielOutput}
|
||||
beispielLabel={m.richtlinien_beispiel_label()}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Noch in Klärung -->
|
||||
<h2 class="mb-3 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{m.richtlinien_klaerung_label()}
|
||||
</h2>
|
||||
<p class="mb-4 font-serif text-sm leading-relaxed text-ink-2">
|
||||
{m.richtlinien_klaerung_intro()}
|
||||
</p>
|
||||
<div class="mb-10 flex flex-wrap gap-2">
|
||||
{#each klaerungChips as chip (chip)}
|
||||
<span
|
||||
class="border-brand-sand rounded-full border bg-white px-3 py-1 font-sans text-xs text-ink-2"
|
||||
>{chip}</span
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Closing card -->
|
||||
<div class="border-brand-sand rounded-sm border bg-white p-6 shadow-sm">
|
||||
<h2 class="mb-2 font-serif text-lg font-bold text-ink">{m.richtlinien_closing_title()}</h2>
|
||||
<p class="font-serif text-sm leading-relaxed text-ink-2">{m.richtlinien_closing_body()}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@media print {
|
||||
:global(.app-nav) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.new-tab {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@page {
|
||||
margin: 1.5cm;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
1
frontend/src/routes/hilfe/transkription/+page.ts
Normal file
1
frontend/src/routes/hilfe/transkription/+page.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const prerender = true;
|
||||
72
frontend/src/routes/hilfe/transkription/page.svelte.spec.ts
Normal file
72
frontend/src/routes/hilfe/transkription/page.svelte.spec.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import Page from './+page.svelte';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('Richtlinien page — structure', () => {
|
||||
it('renders h1 with richtlinien title', async () => {
|
||||
render(Page);
|
||||
await expect
|
||||
.element(page.getByRole('heading', { level: 1 }))
|
||||
.toHaveTextContent('Transkriptions-Richtlinien');
|
||||
});
|
||||
|
||||
it('renders intro paragraph', async () => {
|
||||
render(Page);
|
||||
await expect.element(page.getByText(/Damit alle Briefe einheitlich/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Wikipedia external link with security attributes and new-tab annotation', async () => {
|
||||
render(Page);
|
||||
const wikiLink = page.getByRole('link', { name: /Wikipedia/ });
|
||||
await expect.element(wikiLink).toBeInTheDocument();
|
||||
await expect.element(wikiLink).toHaveAttribute('target', '_blank');
|
||||
await expect.element(wikiLink).toHaveAttribute('rel', 'noopener noreferrer');
|
||||
await expect.element(wikiLink).toHaveAttribute('referrerpolicy', 'no-referrer');
|
||||
// visible annotation (not sr-only)
|
||||
const link = document.querySelector('a[href*="wikipedia"]') as HTMLAnchorElement;
|
||||
expect(link.textContent).toContain('öffnet in neuem Tab');
|
||||
});
|
||||
|
||||
it('renders Regeln h2 section', async () => {
|
||||
render(Page);
|
||||
await expect
|
||||
.element(page.getByRole('heading', { level: 2, name: /Regeln für die Transkription/ }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Noch in Klärung h2 section', async () => {
|
||||
render(Page);
|
||||
await expect
|
||||
.element(page.getByRole('heading', { level: 2, name: /Noch in Klärung/ }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders closing invitation card', async () => {
|
||||
render(Page);
|
||||
await expect.element(page.getByText(/Fehlt eine Regel/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Richtlinien page — rule cards', () => {
|
||||
it('renders five rule card titles', async () => {
|
||||
render(Page);
|
||||
await expect.element(page.getByText('Nicht lesbare Wörter')).toBeInTheDocument();
|
||||
await expect.element(page.getByText('Durchgestrichene Wörter')).toBeInTheDocument();
|
||||
await expect.element(page.getByText(/Das lange s/)).toBeInTheDocument();
|
||||
await expect.element(page.getByText('Unsichere Namen')).toBeInTheDocument();
|
||||
await expect.element(page.getByText(/Dialekt/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Richtlinien page — Noch in Klärung chips', () => {
|
||||
it('renders four clarification chips', async () => {
|
||||
render(Page);
|
||||
await expect.element(page.getByText('Abkürzungen')).toBeInTheDocument();
|
||||
await expect.element(page.getByText('Datumsformate')).toBeInTheDocument();
|
||||
await expect.element(page.getByText(/Zeilenumbrüche/)).toBeInTheDocument();
|
||||
await expect.element(page.getByText(/Groß-\/Kleinschreibung/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user