Compare commits
13 Commits
feat/issue
...
48d034dcb8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
48d034dcb8 | ||
|
|
c335ddd686 | ||
|
|
7830a749a0 | ||
|
|
5b7c37391c | ||
|
|
ce72b07197 | ||
|
|
505804c893 | ||
|
|
67421a4c0c | ||
|
|
0ea0df4f72 | ||
|
|
077f5c85df | ||
|
|
018e272a3b | ||
|
|
0c4a0ead7b | ||
|
|
82b12d4383 | ||
|
|
01758e8e00 |
@@ -18,7 +18,6 @@ import jakarta.validation.constraints.Min;
|
|||||||
import org.springframework.data.domain.PageRequest;
|
import org.springframework.data.domain.PageRequest;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.validation.annotation.Validated;
|
import org.springframework.validation.annotation.Validated;
|
||||||
import org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO;
|
|
||||||
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
|
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
|
||||||
import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
|
import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
|
||||||
import org.raddatz.familienarchiv.dto.TagOperator;
|
import org.raddatz.familienarchiv.dto.TagOperator;
|
||||||
@@ -194,7 +193,6 @@ public class DocumentController {
|
|||||||
@RequirePermission(Permission.WRITE_ALL)
|
@RequirePermission(Permission.WRITE_ALL)
|
||||||
public QuickUploadResult quickUpload(
|
public QuickUploadResult quickUpload(
|
||||||
@RequestPart(value = "files", required = false) List<MultipartFile> files,
|
@RequestPart(value = "files", required = false) List<MultipartFile> files,
|
||||||
@RequestPart(value = "metadata", required = false) DocumentBatchMetadataDTO metadata,
|
|
||||||
Authentication authentication) {
|
Authentication authentication) {
|
||||||
List<Document> created = new ArrayList<>();
|
List<Document> created = new ArrayList<>();
|
||||||
List<Document> updated = new ArrayList<>();
|
List<Document> updated = new ArrayList<>();
|
||||||
@@ -204,21 +202,14 @@ public class DocumentController {
|
|||||||
return new QuickUploadResult(created, updated, errors);
|
return new QuickUploadResult(created, updated, errors);
|
||||||
}
|
}
|
||||||
|
|
||||||
documentService.validateBatch(files.size(), metadata);
|
|
||||||
|
|
||||||
UUID actorId = requireUserId(authentication);
|
UUID actorId = requireUserId(authentication);
|
||||||
long totalBytes = files.stream().mapToLong(MultipartFile::getSize).sum();
|
for (MultipartFile file : files) {
|
||||||
|
|
||||||
for (int i = 0; i < files.size(); i++) {
|
|
||||||
MultipartFile file = files.get(i);
|
|
||||||
if (!ALLOWED_CONTENT_TYPES.contains(file.getContentType())) {
|
if (!ALLOWED_CONTENT_TYPES.contains(file.getContentType())) {
|
||||||
errors.add(new UploadError(file.getOriginalFilename(), "UNSUPPORTED_FILE_TYPE"));
|
errors.add(new UploadError(file.getOriginalFilename(), "UNSUPPORTED_FILE_TYPE"));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
DocumentService.StoreResult result = metadata != null
|
DocumentService.StoreResult result = documentService.storeDocument(file, actorId);
|
||||||
? documentService.storeDocumentWithBatchMetadata(file, metadata, i, actorId)
|
|
||||||
: documentService.storeDocument(file, actorId);
|
|
||||||
if (result.isNew()) {
|
if (result.isNew()) {
|
||||||
created.add(result.document());
|
created.add(result.document());
|
||||||
} else {
|
} else {
|
||||||
@@ -230,10 +221,6 @@ 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);
|
return new QuickUploadResult(created, updated, errors);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -109,8 +109,6 @@ public enum ErrorCode {
|
|||||||
// --- Generic ---
|
// --- Generic ---
|
||||||
/** Request validation failed (missing or malformed fields). 400 */
|
/** Request validation failed (missing or malformed fields). 400 */
|
||||||
VALIDATION_ERROR,
|
VALIDATION_ERROR,
|
||||||
/** Batch upload exceeds the maximum allowed file count per request. 400 */
|
|
||||||
BATCH_TOO_LARGE,
|
|
||||||
/** An unexpected server-side error occurred. 500 */
|
/** An unexpected server-side error occurred. 500 */
|
||||||
INTERNAL_ERROR,
|
INTERNAL_ERROR,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import org.raddatz.familienarchiv.audit.ActivityActorDTO;
|
|||||||
import org.raddatz.familienarchiv.audit.AuditKind;
|
import org.raddatz.familienarchiv.audit.AuditKind;
|
||||||
import org.raddatz.familienarchiv.audit.AuditLogQueryService;
|
import org.raddatz.familienarchiv.audit.AuditLogQueryService;
|
||||||
import org.raddatz.familienarchiv.audit.AuditService;
|
import org.raddatz.familienarchiv.audit.AuditService;
|
||||||
import org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO;
|
|
||||||
import org.raddatz.familienarchiv.dto.DocumentSearchItem;
|
import org.raddatz.familienarchiv.dto.DocumentSearchItem;
|
||||||
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
|
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
|
||||||
import org.raddatz.familienarchiv.dto.DocumentSort;
|
import org.raddatz.familienarchiv.dto.DocumentSort;
|
||||||
@@ -133,52 +132,6 @@ public class DocumentService {
|
|||||||
return new StoreResult(saved, isNew);
|
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
|
@Transactional
|
||||||
public Document createDocument(DocumentUpdateDTO dto, MultipartFile file) throws IOException {
|
public Document createDocument(DocumentUpdateDTO dto, MultipartFile file) throws IOException {
|
||||||
String filename = (file != null && !file.isEmpty())
|
String filename = (file != null && !file.isEmpty())
|
||||||
|
|||||||
@@ -23,8 +23,7 @@ spring:
|
|||||||
servlet:
|
servlet:
|
||||||
multipart:
|
multipart:
|
||||||
max-file-size: 50MB
|
max-file-size: 50MB
|
||||||
max-request-size: 500MB # supports 10-file chunk at max per-file size; see #317
|
max-request-size: 50MB
|
||||||
file-size-threshold: 2KB
|
|
||||||
|
|
||||||
mail:
|
mail:
|
||||||
host: ${MAIL_HOST:}
|
host: ${MAIL_HOST:}
|
||||||
|
|||||||
@@ -1,17 +1,15 @@
|
|||||||
package org.raddatz.familienarchiv.controller;
|
package org.raddatz.familienarchiv.controller;
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO;
|
|
||||||
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
|
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
|
||||||
import org.raddatz.familienarchiv.dto.DocumentVersionSummary;
|
import org.raddatz.familienarchiv.dto.DocumentVersionSummary;
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
import org.raddatz.familienarchiv.model.AppUser;
|
|
||||||
import org.raddatz.familienarchiv.model.Document;
|
import org.raddatz.familienarchiv.model.Document;
|
||||||
import org.raddatz.familienarchiv.model.DocumentStatus;
|
import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||||
import org.raddatz.familienarchiv.model.DocumentVersion;
|
import org.raddatz.familienarchiv.model.DocumentVersion;
|
||||||
import org.raddatz.familienarchiv.model.Person;
|
|
||||||
import org.raddatz.familienarchiv.security.PermissionAspect;
|
import org.raddatz.familienarchiv.security.PermissionAspect;
|
||||||
|
import org.raddatz.familienarchiv.model.AppUser;
|
||||||
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
|
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
|
||||||
import org.raddatz.familienarchiv.service.DocumentService;
|
import org.raddatz.familienarchiv.service.DocumentService;
|
||||||
import org.raddatz.familienarchiv.service.DocumentVersionService;
|
import org.raddatz.familienarchiv.service.DocumentVersionService;
|
||||||
@@ -768,165 +766,4 @@ class DocumentControllerTest {
|
|||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.editorName").value("Otto"));
|
.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"));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1813,108 +1813,4 @@ class DocumentServiceTest {
|
|||||||
|
|
||||||
verify(auditService).logAfterCommit(eq(AuditKind.FILE_UPLOADED), isNull(), eq(id), isNull());
|
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");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,21 +8,27 @@ test.describe('Help chip — Read/Edit panel header', () => {
|
|||||||
docId = await createEmptyDocument(request);
|
docId = await createEmptyDocument(request);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.afterAll(async ({ request }) => {
|
||||||
|
await request.delete(`/api/documents/${docId}`);
|
||||||
|
});
|
||||||
|
|
||||||
test('opens popover on click, closes on Esc, returns focus to chip', async ({ page }) => {
|
test('opens popover on click, closes on Esc, returns focus to chip', async ({ page }) => {
|
||||||
await page.goto(`/documents/${docId}`);
|
await page.goto(`/documents/${docId}`);
|
||||||
await page.getByRole('button', { name: 'Transkribieren' }).click();
|
await page.getByRole('button', { name: 'Transkribieren' }).click();
|
||||||
|
|
||||||
// Find and click the (?) help chip
|
// Use the accessible label of the HelpPopover trigger (transcription_mode_help_label)
|
||||||
const helpBtn = page.locator('button[aria-expanded]');
|
const helpBtn = page.getByRole('button', { name: 'Lese- und Bearbeitungsmodus' });
|
||||||
await expect(helpBtn).toBeVisible({ timeout: 5000 });
|
await expect(helpBtn).toBeVisible({ timeout: 5000 });
|
||||||
await helpBtn.click();
|
await helpBtn.click();
|
||||||
|
|
||||||
// Popover should open
|
// Popover should open (role="region", not tooltip — click-triggered panels are regions)
|
||||||
await expect(page.locator('[role="tooltip"]')).toBeVisible();
|
await expect(page.getByRole('region', { name: 'Lese- und Bearbeitungsmodus' })).toBeVisible();
|
||||||
|
|
||||||
// Press Esc
|
// Press Esc
|
||||||
await page.keyboard.press('Escape');
|
await page.keyboard.press('Escape');
|
||||||
await expect(page.locator('[role="tooltip"]')).not.toBeVisible();
|
await expect(
|
||||||
|
page.getByRole('region', { name: 'Lese- und Bearbeitungsmodus' })
|
||||||
|
).not.toBeVisible();
|
||||||
|
|
||||||
// Focus should have returned to the chip
|
// Focus should have returned to the chip
|
||||||
await expect(helpBtn).toBeFocused();
|
await expect(helpBtn).toBeFocused();
|
||||||
|
|||||||
@@ -1,10 +1,30 @@
|
|||||||
import type { APIRequestContext } from '@playwright/test';
|
import type { APIRequestContext } from '@playwright/test';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import fs from 'fs';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const PDF_FIXTURE = path.resolve(__dirname, '../fixtures/minimal.pdf');
|
||||||
|
|
||||||
export async function createEmptyDocument(request: APIRequestContext): Promise<string> {
|
export async function createEmptyDocument(request: APIRequestContext): Promise<string> {
|
||||||
const res = await request.post('/api/documents', {
|
const createRes = await request.post('/api/documents', {
|
||||||
multipart: { title: 'E2E Transcribe Coach Test' }
|
multipart: { title: 'E2E Transcribe Coach Test' }
|
||||||
});
|
});
|
||||||
if (!res.ok()) throw new Error(`Create document failed: ${res.status()}`);
|
if (!createRes.ok()) throw new Error(`Create document failed: ${createRes.status()}`);
|
||||||
const doc = await res.json();
|
const doc = await createRes.json();
|
||||||
return doc.id as string;
|
const docId = doc.id as string;
|
||||||
|
|
||||||
|
const uploadRes = await request.put(`/api/documents/${docId}`, {
|
||||||
|
multipart: {
|
||||||
|
title: doc.title,
|
||||||
|
file: {
|
||||||
|
name: 'minimal.pdf',
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
buffer: fs.readFileSync(PDF_FIXTURE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!uploadRes.ok()) throw new Error(`Upload PDF failed: ${uploadRes.status()}`);
|
||||||
|
|
||||||
|
return docId;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,6 +63,12 @@ test.describe('Richtlinien page — print media', () => {
|
|||||||
await expect(nav).toBeHidden();
|
await expect(nav).toBeHidden();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// .new-tab annotation spans must be hidden in print so "(öffnet in neuem Tab)"
|
||||||
|
// text does not clutter the printed output (the print CSS declares display:none)
|
||||||
|
for (const span of await page.locator('.new-tab').all()) {
|
||||||
|
await expect(span).toBeHidden();
|
||||||
|
}
|
||||||
|
|
||||||
await page.screenshot({ path: 'test-results/e2e/richtlinien-print.png', fullPage: true });
|
await page.screenshot({ path: 'test-results/e2e/richtlinien-print.png', fullPage: true });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,24 @@ import { test, expect } from '@playwright/test';
|
|||||||
import AxeBuilder from '@axe-core/playwright';
|
import AxeBuilder from '@axe-core/playwright';
|
||||||
import { createEmptyDocument } from './helpers/upload-empty-document.js';
|
import { createEmptyDocument } from './helpers/upload-empty-document.js';
|
||||||
|
|
||||||
|
async function createBlock(
|
||||||
|
request: Parameters<typeof createEmptyDocument>[0],
|
||||||
|
docId: string
|
||||||
|
): Promise<void> {
|
||||||
|
const res = await request.post(`/api/documents/${docId}/transcription-blocks`, {
|
||||||
|
data: {
|
||||||
|
pageNumber: 1,
|
||||||
|
x: 0.1,
|
||||||
|
y: 0.1,
|
||||||
|
width: 0.3,
|
||||||
|
height: 0.1,
|
||||||
|
text: 'Liebe Mutter,',
|
||||||
|
label: null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!res.ok()) throw new Error(`Create block failed: ${res.status()}`);
|
||||||
|
}
|
||||||
|
|
||||||
function buildAxe(page: Parameters<typeof AxeBuilder>[0]['page']) {
|
function buildAxe(page: Parameters<typeof AxeBuilder>[0]['page']) {
|
||||||
return new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa']);
|
return new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa']);
|
||||||
}
|
}
|
||||||
@@ -13,10 +31,13 @@ test.describe('Transcribe coach — empty state', () => {
|
|||||||
docId = await createEmptyDocument(request);
|
docId = await createEmptyDocument(request);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.afterAll(async ({ request }) => {
|
||||||
|
await request.delete(`/api/documents/${docId}`);
|
||||||
|
});
|
||||||
|
|
||||||
test('shows coach card (title, preamble, three step bodies, animation region)', async ({
|
test('shows coach card (title, preamble, three step bodies, animation region)', async ({
|
||||||
page
|
page
|
||||||
}) => {
|
}) => {
|
||||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
|
||||||
await page.goto(`/documents/${docId}`);
|
await page.goto(`/documents/${docId}`);
|
||||||
await page.getByRole('button', { name: 'Transkribieren' }).click();
|
await page.getByRole('button', { name: 'Transkribieren' }).click();
|
||||||
|
|
||||||
@@ -31,14 +52,12 @@ test.describe('Transcribe coach — empty state', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('training footer is NOT visible on empty doc', async ({ page }) => {
|
test('training footer is NOT visible on empty doc', async ({ page }) => {
|
||||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
|
||||||
await page.goto(`/documents/${docId}`);
|
await page.goto(`/documents/${docId}`);
|
||||||
await page.getByRole('button', { name: 'Transkribieren' }).click();
|
await page.getByRole('button', { name: 'Transkribieren' }).click();
|
||||||
await expect(page.getByText('Für Training vormerken')).not.toBeVisible({ timeout: 3000 });
|
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 }) => {
|
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.goto(`/documents/${docId}`);
|
||||||
await page.getByRole('button', { name: 'Transkribieren' }).click();
|
await page.getByRole('button', { name: 'Transkribieren' }).click();
|
||||||
await expect(page.getByRole('heading', { level: 2, name: /Erste Transkription/ })).toBeVisible({
|
await expect(page.getByRole('heading', { level: 2, name: /Erste Transkription/ })).toBeVisible({
|
||||||
@@ -50,10 +69,9 @@ test.describe('Transcribe coach — empty state', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('axe: panel empty state — dark theme — no WCAG 2.1 AA violations', async ({ page }) => {
|
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}`);
|
await page.goto(`/documents/${docId}`);
|
||||||
// Toggle dark theme
|
// Toggle dark theme
|
||||||
await page.getByRole('button', { name: /Farbmodus|theme/i }).click();
|
await page.getByRole('button', { name: /dark mode/i }).click();
|
||||||
await page.getByRole('button', { name: 'Transkribieren' }).click();
|
await page.getByRole('button', { name: 'Transkribieren' }).click();
|
||||||
await expect(page.getByRole('heading', { level: 2, name: /Erste Transkription/ })).toBeVisible({
|
await expect(page.getByRole('heading', { level: 2, name: /Erste Transkription/ })).toBeVisible({
|
||||||
timeout: 5000
|
timeout: 5000
|
||||||
@@ -63,3 +81,25 @@ test.describe('Transcribe coach — empty state', () => {
|
|||||||
expect(a11y.violations, JSON.stringify(a11y.violations, null, 2)).toHaveLength(0);
|
expect(a11y.violations, JSON.stringify(a11y.violations, null, 2)).toHaveLength(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.describe('Transcribe coach — with blocks', () => {
|
||||||
|
let docId: string;
|
||||||
|
|
||||||
|
test.beforeAll(async ({ request }) => {
|
||||||
|
docId = await createEmptyDocument(request);
|
||||||
|
await createBlock(request, docId);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async ({ request }) => {
|
||||||
|
await request.delete(`/api/documents/${docId}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('training footer IS visible when at least one block exists', async ({ page }) => {
|
||||||
|
await page.goto(`/documents/${docId}`);
|
||||||
|
await page.getByRole('button', { name: 'Transkribieren' }).click();
|
||||||
|
// Wait for blocks to finish loading — block count confirms mode settled to 'read'
|
||||||
|
await expect(page.getByText(/1 Abschnitt/)).toBeVisible({ timeout: 5000 });
|
||||||
|
await page.locator('[data-testid="mode-edit"]').click();
|
||||||
|
await expect(page.getByText('Für Training vormerken')).toBeVisible({ timeout: 5000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -515,7 +515,6 @@
|
|||||||
"scan_collapse": "Scan verkleinern",
|
"scan_collapse": "Scan verkleinern",
|
||||||
"transcription_empty_title": "Noch keine Transkription",
|
"transcription_empty_title": "Noch keine Transkription",
|
||||||
"transcription_empty_desc": "Zeichne Bereiche auf dem Scan und tippe den Text ab, um eine Transkription zu erstellen.",
|
"transcription_empty_desc": "Zeichne Bereiche auf dem Scan und tippe den Text ab, um eine Transkription zu erstellen.",
|
||||||
"transcription_empty_draw_hint": "Zeichnen Sie Bereiche auf dem Dokument, um mit der Transkription zu beginnen.",
|
|
||||||
"transcription_panel_close": "Panel schließen",
|
"transcription_panel_close": "Panel schließen",
|
||||||
"person_alias_heading": "Namensverlauf",
|
"person_alias_heading": "Namensverlauf",
|
||||||
"person_alias_empty": "Noch keine Namensaenderungen erfasst.",
|
"person_alias_empty": "Noch keine Namensaenderungen erfasst.",
|
||||||
@@ -850,29 +849,5 @@
|
|||||||
"richtlinien_klaer_umbrueche": "Originale Zeilenumbrüche",
|
"richtlinien_klaer_umbrueche": "Originale Zeilenumbrüche",
|
||||||
"richtlinien_klaer_caps": "Alte Groß-/Kleinschreibung",
|
"richtlinien_klaer_caps": "Alte Groß-/Kleinschreibung",
|
||||||
"richtlinien_closing_title": "Fehlt eine Regel?",
|
"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.",
|
"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"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -515,7 +515,6 @@
|
|||||||
"scan_collapse": "Collapse scan",
|
"scan_collapse": "Collapse scan",
|
||||||
"transcription_empty_title": "No transcription yet",
|
"transcription_empty_title": "No transcription yet",
|
||||||
"transcription_empty_desc": "Draw regions on the scan and type the text to create a transcription.",
|
"transcription_empty_desc": "Draw regions on the scan and type the text to create a transcription.",
|
||||||
"transcription_empty_draw_hint": "Draw regions on the document to start transcribing.",
|
|
||||||
"transcription_panel_close": "Close panel",
|
"transcription_panel_close": "Close panel",
|
||||||
"person_alias_heading": "Name history",
|
"person_alias_heading": "Name history",
|
||||||
"person_alias_empty": "No name changes recorded yet.",
|
"person_alias_empty": "No name changes recorded yet.",
|
||||||
@@ -850,29 +849,5 @@
|
|||||||
"richtlinien_klaer_umbrueche": "Original line breaks",
|
"richtlinien_klaer_umbrueche": "Original line breaks",
|
||||||
"richtlinien_klaer_caps": "Old capitalisation",
|
"richtlinien_klaer_caps": "Old capitalisation",
|
||||||
"richtlinien_closing_title": "Missing a rule?",
|
"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.",
|
"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"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -515,7 +515,6 @@
|
|||||||
"scan_collapse": "Reducir escaneo",
|
"scan_collapse": "Reducir escaneo",
|
||||||
"transcription_empty_title": "Sin transcripcion",
|
"transcription_empty_title": "Sin transcripcion",
|
||||||
"transcription_empty_desc": "Dibuja regiones en el escaneo y escribe el texto para crear una transcripcion.",
|
"transcription_empty_desc": "Dibuja regiones en el escaneo y escribe el texto para crear una transcripcion.",
|
||||||
"transcription_empty_draw_hint": "Dibuje regiones en el documento para comenzar a transcribir.",
|
|
||||||
"transcription_panel_close": "Cerrar panel",
|
"transcription_panel_close": "Cerrar panel",
|
||||||
"person_alias_heading": "Historial de nombres",
|
"person_alias_heading": "Historial de nombres",
|
||||||
"person_alias_empty": "Aun no se han registrado cambios de nombre.",
|
"person_alias_empty": "Aun no se han registrado cambios de nombre.",
|
||||||
@@ -850,29 +849,5 @@
|
|||||||
"richtlinien_klaer_umbrueche": "Saltos de línea originales",
|
"richtlinien_klaer_umbrueche": "Saltos de línea originales",
|
||||||
"richtlinien_klaer_caps": "Mayúsculas antiguas",
|
"richtlinien_klaer_caps": "Mayúsculas antiguas",
|
||||||
"richtlinien_closing_title": "¿Falta una regla?",
|
"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.",
|
"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"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
<script module>
|
||||||
|
// Module-level counter produces stable, predictable IDs across SSR + hydration.
|
||||||
|
// Math.random() would generate different values server-side vs client-side,
|
||||||
|
// causing a hydration mismatch on first render.
|
||||||
|
let _counter = 0;
|
||||||
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
@@ -11,8 +18,9 @@ type Props = {
|
|||||||
|
|
||||||
let { label, placement = 'bottom', children }: Props = $props();
|
let { label, placement = 'bottom', children }: Props = $props();
|
||||||
|
|
||||||
|
const popoverId = `help-popover-${_counter++}`;
|
||||||
|
|
||||||
let open = $state(false);
|
let open = $state(false);
|
||||||
const popoverId = `help-popover-${Math.random().toString(36).slice(2)}`;
|
|
||||||
let triggerEl: HTMLButtonElement | null = $state(null);
|
let triggerEl: HTMLButtonElement | null = $state(null);
|
||||||
|
|
||||||
function toggle() {
|
function toggle() {
|
||||||
@@ -58,6 +66,10 @@ const placementClass: Record<Placement, string> = {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="relative inline-block">
|
<div class="relative inline-block">
|
||||||
|
<!--
|
||||||
|
Outer button is 44×44px for WCAG 2.5.8 touch-target compliance (our transcriber
|
||||||
|
audience is 60+). The inner <span> carries the visual 20×20px circle.
|
||||||
|
-->
|
||||||
<button
|
<button
|
||||||
bind:this={triggerEl}
|
bind:this={triggerEl}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -65,15 +77,20 @@ const placementClass: Record<Placement, string> = {
|
|||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
aria-controls={popoverId}
|
aria-controls={popoverId}
|
||||||
onclick={toggle}
|
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"
|
class="group flex h-[44px] w-[44px] items-center justify-center rounded-full focus-visible:ring-2 focus-visible:ring-brand-navy"
|
||||||
>
|
>
|
||||||
?
|
<span
|
||||||
|
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 group-hover:border-brand-navy group-hover:text-brand-navy"
|
||||||
|
>
|
||||||
|
?
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{#if open}
|
{#if open}
|
||||||
<div
|
<div
|
||||||
id={popoverId}
|
id={popoverId}
|
||||||
role="tooltip"
|
role="region"
|
||||||
|
aria-label={label}
|
||||||
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]}"
|
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}
|
{#if children}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, it, expect, afterEach, vi } from 'vitest';
|
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||||
import { cleanup, render } from 'vitest-browser-svelte';
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
import { page } from 'vitest/browser';
|
import { page, userEvent } from 'vitest/browser';
|
||||||
import HelpPopover from './HelpPopover.svelte';
|
import HelpPopover from './HelpPopover.svelte';
|
||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
@@ -20,7 +20,7 @@ describe('HelpPopover — initial state', () => {
|
|||||||
renderPopover();
|
renderPopover();
|
||||||
const btn = page.getByRole('button', { name: /Help/ });
|
const btn = page.getByRole('button', { name: /Help/ });
|
||||||
await expect.element(btn).toHaveAttribute('aria-expanded', 'false');
|
await expect.element(btn).toHaveAttribute('aria-expanded', 'false');
|
||||||
expect(document.querySelector('[role="tooltip"]')).toBeNull();
|
expect(document.querySelector('[role="region"]')).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -30,37 +30,61 @@ describe('HelpPopover — open / close interactions', () => {
|
|||||||
await page.getByRole('button', { name: /Help/ }).click();
|
await page.getByRole('button', { name: /Help/ }).click();
|
||||||
const btn = page.getByRole('button', { name: /Help/ });
|
const btn = page.getByRole('button', { name: /Help/ });
|
||||||
await expect.element(btn).toHaveAttribute('aria-expanded', 'true');
|
await expect.element(btn).toHaveAttribute('aria-expanded', 'true');
|
||||||
expect(document.querySelector('[role="tooltip"]')).not.toBeNull();
|
expect(document.querySelector('[role="region"]')).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('closes on Esc key', async () => {
|
it('closes on Esc key', async () => {
|
||||||
renderPopover();
|
renderPopover();
|
||||||
await page.getByRole('button', { name: /Help/ }).click();
|
await page.getByRole('button', { name: /Help/ }).click();
|
||||||
expect(document.querySelector('[role="tooltip"]')).not.toBeNull();
|
expect(document.querySelector('[role="region"]')).not.toBeNull();
|
||||||
|
|
||||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
||||||
await vi.waitFor(() => expect(document.querySelector('[role="tooltip"]')).toBeNull());
|
await vi.waitFor(() => expect(document.querySelector('[role="region"]')).toBeNull());
|
||||||
});
|
});
|
||||||
|
|
||||||
it('closes on outside click', async () => {
|
it('closes on outside click', async () => {
|
||||||
renderPopover();
|
renderPopover();
|
||||||
await page.getByRole('button', { name: /Help/ }).click();
|
await page.getByRole('button', { name: /Help/ }).click();
|
||||||
expect(document.querySelector('[role="tooltip"]')).not.toBeNull();
|
expect(document.querySelector('[role="region"]')).not.toBeNull();
|
||||||
|
|
||||||
document.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }));
|
document.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }));
|
||||||
await vi.waitFor(() => expect(document.querySelector('[role="tooltip"]')).toBeNull());
|
await vi.waitFor(() => expect(document.querySelector('[role="region"]')).toBeNull());
|
||||||
});
|
});
|
||||||
|
|
||||||
it('opens on Enter key (button is keyboard-reachable, Enter fires click)', async () => {
|
it('opens on Enter key', async () => {
|
||||||
renderPopover();
|
renderPopover();
|
||||||
await page.getByRole('button', { name: /Help/ }).click();
|
(document.querySelector('button[aria-expanded]') as HTMLButtonElement).focus();
|
||||||
expect(document.querySelector('[role="tooltip"]')).not.toBeNull();
|
await userEvent.keyboard('{Enter}');
|
||||||
|
await vi.waitFor(() => expect(document.querySelector('[role="region"]')).not.toBeNull());
|
||||||
});
|
});
|
||||||
|
|
||||||
it('opens on Space key (button is keyboard-reachable, Space fires click)', async () => {
|
it('opens on Space key', async () => {
|
||||||
renderPopover();
|
renderPopover();
|
||||||
await page.getByRole('button', { name: /Help/ }).click();
|
(document.querySelector('button[aria-expanded]') as HTMLButtonElement).focus();
|
||||||
expect(document.querySelector('[role="tooltip"]')).not.toBeNull();
|
await userEvent.keyboard('{Space}');
|
||||||
|
await vi.waitFor(() => expect(document.querySelector('[role="region"]')).not.toBeNull());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('HelpPopover — hover-target', () => {
|
||||||
|
it('hover styles propagate from 44px button group to inner span, not from span itself', () => {
|
||||||
|
const { container } = renderPopover();
|
||||||
|
const btn = container.querySelector('button[aria-expanded]')!;
|
||||||
|
const span = btn.querySelector('span')!;
|
||||||
|
const btnClasses = btn.className.split(/\s+/);
|
||||||
|
const spanClasses = span.className.split(/\s+/);
|
||||||
|
expect(btnClasses).toContain('group');
|
||||||
|
expect(spanClasses).not.toContain('hover:border-brand-navy');
|
||||||
|
expect(spanClasses).toContain('group-hover:border-brand-navy');
|
||||||
|
expect(spanClasses).not.toContain('hover:text-brand-navy');
|
||||||
|
expect(spanClasses).toContain('group-hover:text-brand-navy');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('outer button has focus-visible ring for keyboard users', () => {
|
||||||
|
const { container } = renderPopover();
|
||||||
|
const btn = container.querySelector('button[aria-expanded]')!;
|
||||||
|
expect(btn.className).toContain('focus-visible:ring-2');
|
||||||
|
expect(btn.className).toContain('focus-visible:ring-brand-navy');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -74,4 +98,17 @@ describe('HelpPopover — aria wiring', () => {
|
|||||||
const popover = document.getElementById(controls!);
|
const popover = document.getElementById(controls!);
|
||||||
expect(popover).not.toBeNull();
|
expect(popover).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('two renders produce different, predictable IDs (no Math.random — SSR safe)', async () => {
|
||||||
|
const { container: c1 } = render(HelpPopover, { props: { label: 'A' } });
|
||||||
|
const { container: c2 } = render(HelpPopover, { props: { label: 'B' } });
|
||||||
|
const id1 = c1.querySelector('button[aria-controls]')?.getAttribute('aria-controls');
|
||||||
|
const id2 = c2.querySelector('button[aria-controls]')?.getAttribute('aria-controls');
|
||||||
|
expect(id1).toBeTruthy();
|
||||||
|
expect(id2).toBeTruthy();
|
||||||
|
expect(id1).not.toBe(id2);
|
||||||
|
// IDs must be deterministic (counter-based), not random hex
|
||||||
|
expect(id1).toMatch(/^help-popover-\d+$/);
|
||||||
|
expect(id2).toMatch(/^help-popover-\d+$/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ function removePerson(id: string | undefined) {
|
|||||||
|
|
||||||
<div class="relative" use:clickOutside onclickoutside={() => (showDropdown = false)}>
|
<div class="relative" use:clickOutside onclickoutside={() => (showDropdown = false)}>
|
||||||
<div
|
<div
|
||||||
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"
|
class="flex min-h-[42px] flex-wrap gap-2 rounded border border-line bg-surface p-2 focus-within:border-ink focus-within:ring-1 focus-within:ring-ink"
|
||||||
>
|
>
|
||||||
{#each selectedPersons as person (person.id)}
|
{#each selectedPersons as person (person.id)}
|
||||||
<span
|
<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'
|
? '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
|
: 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 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 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'}
|
: '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'}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{#if typeahead.isOpen && (typeahead.results.length > 0 || typeahead.loading)}
|
{#if typeahead.isOpen && (typeahead.results.length > 0 || typeahead.loading)}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ let { icon, title, body, beispielOutput, beispielLabel = 'Beispiel' }: Props = $
|
|||||||
<p class="font-serif text-sm leading-relaxed text-ink-2">{body}</p>
|
<p class="font-serif text-sm leading-relaxed text-ink-2">{body}</p>
|
||||||
|
|
||||||
{#if beispielOutput !== undefined}
|
{#if beispielOutput !== undefined}
|
||||||
<div class="border-brand-sand mt-4 rounded-sm border bg-[#FAF8F1] px-4 py-3">
|
<div class="border-brand-sand mt-4 rounded-sm border bg-parchment px-4 py-3">
|
||||||
<p class="font-sans text-xs font-semibold tracking-wider text-ink-3 uppercase">
|
<p class="font-sans text-xs font-semibold tracking-wider text-ink-3 uppercase">
|
||||||
{beispielLabel}
|
{beispielLabel}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import TranscribeDragDemo from './TranscribeDragDemo.svelte';
|
|||||||
|
|
||||||
<ol class="m-0 flex list-none flex-col gap-[18px] p-0">
|
<ol class="m-0 flex list-none flex-col gap-[18px] p-0">
|
||||||
<!-- Step 1 -->
|
<!-- Step 1 -->
|
||||||
<li class="grid gap-3.5" style="grid-template-columns: 34px 1fr; align-items: start;">
|
<li aria-label="Schritt 1 von 3" class="grid grid-cols-[34px_1fr] items-start gap-3.5">
|
||||||
<span
|
<span
|
||||||
aria-hidden="true"
|
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"
|
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"
|
||||||
@@ -27,7 +27,7 @@ import TranscribeDragDemo from './TranscribeDragDemo.svelte';
|
|||||||
</li>
|
</li>
|
||||||
|
|
||||||
<!-- Step 2 -->
|
<!-- Step 2 -->
|
||||||
<li class="grid gap-3.5" style="grid-template-columns: 34px 1fr; align-items: start;">
|
<li aria-label="Schritt 2 von 3" class="grid grid-cols-[34px_1fr] items-start gap-3.5">
|
||||||
<span
|
<span
|
||||||
aria-hidden="true"
|
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"
|
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"
|
||||||
@@ -40,7 +40,7 @@ import TranscribeDragDemo from './TranscribeDragDemo.svelte';
|
|||||||
</li>
|
</li>
|
||||||
|
|
||||||
<!-- Step 3 -->
|
<!-- Step 3 -->
|
||||||
<li class="grid gap-3.5" style="grid-template-columns: 34px 1fr; align-items: start;">
|
<li aria-label="Schritt 3 von 3" class="grid grid-cols-[34px_1fr] items-start gap-3.5">
|
||||||
<span
|
<span
|
||||||
aria-hidden="true"
|
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"
|
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,7 +1,19 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
const prefersReducedMotion = $derived(
|
// $derived from .matches is a one-shot snapshot — it doesn't react when the
|
||||||
|
// user toggles the OS setting at runtime. Use $state + addEventListener instead.
|
||||||
|
let prefersReducedMotion = $state(
|
||||||
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
const mql = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||||
|
const handler = (e: MediaQueryListEvent) => {
|
||||||
|
prefersReducedMotion = e.matches;
|
||||||
|
};
|
||||||
|
mql.addEventListener('change', handler);
|
||||||
|
return () => mql.removeEventListener('change', handler);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if prefersReducedMotion}
|
{#if prefersReducedMotion}
|
||||||
@@ -10,7 +22,7 @@ const prefersReducedMotion = $derived(
|
|||||||
role="img"
|
role="img"
|
||||||
aria-label="Eine gestrichelte Umrandung markiert eine Zeile Kurrentschrift auf dem Dokument."
|
aria-label="Eine gestrichelte Umrandung markiert eine Zeile Kurrentschrift auf dem Dokument."
|
||||||
viewBox="0 0 600 180"
|
viewBox="0 0 600 180"
|
||||||
class="border-brand-sand block w-full rounded-sm border bg-[#FAF8F1]"
|
class="border-brand-sand block w-full rounded-sm border bg-parchment"
|
||||||
>
|
>
|
||||||
<g
|
<g
|
||||||
stroke="#2a2a2a"
|
stroke="#2a2a2a"
|
||||||
@@ -61,7 +73,7 @@ const prefersReducedMotion = $derived(
|
|||||||
role="img"
|
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."
|
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"
|
viewBox="0 0 600 180"
|
||||||
class="border-brand-sand block w-full rounded-sm border bg-[#FAF8F1]"
|
class="border-brand-sand block w-full rounded-sm border bg-parchment"
|
||||||
>
|
>
|
||||||
<!-- Kurrent writing (static) -->
|
<!-- Kurrent writing (static) -->
|
||||||
<g
|
<g
|
||||||
|
|||||||
@@ -177,6 +177,6 @@ describe('TranscriptionPanelHeader', () => {
|
|||||||
|
|
||||||
const helpBtn = document.querySelector('button[aria-expanded]') as HTMLButtonElement;
|
const helpBtn = document.querySelector('button[aria-expanded]') as HTMLButtonElement;
|
||||||
helpBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
helpBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||||
await vi.waitFor(() => expect(document.querySelector('[role="tooltip"]')).not.toBeNull());
|
await vi.waitFor(() => expect(document.querySelector('[role="region"]')).not.toBeNull());
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,320 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,338 +0,0 @@
|
|||||||
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 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
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,7 +69,8 @@ $effect(() => {
|
|||||||
oninput={handleDateInput}
|
oninput={handleDateInput}
|
||||||
placeholder={m.form_placeholder_date()}
|
placeholder={m.form_placeholder_date()}
|
||||||
maxlength="10"
|
maxlength="10"
|
||||||
class="block w-full rounded border border-line px-2 py-3 text-sm shadow-sm
|
autofocus={!initialDateIso}
|
||||||
|
class="block w-full rounded border border-line p-2 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'}"
|
{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}
|
aria-describedby={dateInvalid ? 'date-error' : undefined}
|
||||||
/>
|
/>
|
||||||
@@ -88,6 +89,7 @@ $effect(() => {
|
|||||||
bind:value={senderId}
|
bind:value={senderId}
|
||||||
initialName={initialSenderName}
|
initialName={initialSenderName}
|
||||||
suggestedName={suggestedSenderName}
|
suggestedName={suggestedSenderName}
|
||||||
|
autofocus={!!initialDateIso}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -108,7 +110,7 @@ $effect(() => {
|
|||||||
name="location"
|
name="location"
|
||||||
value={initialLocation}
|
value={initialLocation}
|
||||||
placeholder={m.form_placeholder_location()}
|
placeholder={m.form_placeholder_location()}
|
||||||
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"
|
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"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ export type ErrorCode =
|
|||||||
| 'UNAUTHORIZED'
|
| 'UNAUTHORIZED'
|
||||||
| 'FORBIDDEN'
|
| 'FORBIDDEN'
|
||||||
| 'VALIDATION_ERROR'
|
| 'VALIDATION_ERROR'
|
||||||
| 'BATCH_TOO_LARGE'
|
|
||||||
| 'INTERNAL_ERROR';
|
| 'INTERNAL_ERROR';
|
||||||
|
|
||||||
export interface BackendError {
|
export interface BackendError {
|
||||||
@@ -140,8 +139,6 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string {
|
|||||||
return m.error_forbidden();
|
return m.error_forbidden();
|
||||||
case 'VALIDATION_ERROR':
|
case 'VALIDATION_ERROR':
|
||||||
return m.error_validation_error();
|
return m.error_validation_error();
|
||||||
case 'BATCH_TOO_LARGE':
|
|
||||||
return m.error_batch_too_large();
|
|
||||||
default:
|
default:
|
||||||
return m.error_internal_error();
|
return m.error_internal_error();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -548,22 +548,6 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/api/admin/backfill-versions": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1044,22 +1028,6 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/api/documents/{documentId}/transcription-blocks/{blockId}/history": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1236,22 +1204,6 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/api/admin/import-status": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1438,6 +1390,7 @@ export interface components {
|
|||||||
thumbnailAspect?: "PORTRAIT" | "LANDSCAPE";
|
thumbnailAspect?: "PORTRAIT" | "LANDSCAPE";
|
||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
pageCount?: number;
|
pageCount?: number;
|
||||||
|
thumbnailUrl?: string;
|
||||||
originalFilename: string;
|
originalFilename: string;
|
||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
status: "PLACEHOLDER" | "UPLOADED" | "TRANSCRIBED" | "REVIEWED" | "ARCHIVED";
|
status: "PLACEHOLDER" | "UPLOADED" | "TRANSCRIBED" | "REVIEWED" | "ARCHIVED";
|
||||||
@@ -1460,7 +1413,6 @@ export interface components {
|
|||||||
sender?: components["schemas"]["Person"];
|
sender?: components["schemas"]["Person"];
|
||||||
tags?: components["schemas"]["Tag"][];
|
tags?: components["schemas"]["Tag"][];
|
||||||
trainingLabels?: ("KURRENT_RECOGNITION" | "KURRENT_SEGMENTATION")[];
|
trainingLabels?: ("KURRENT_RECOGNITION" | "KURRENT_SEGMENTATION")[];
|
||||||
thumbnailUrl?: string;
|
|
||||||
};
|
};
|
||||||
UpdateTranscriptionBlockDTO: {
|
UpdateTranscriptionBlockDTO: {
|
||||||
text?: string;
|
text?: string;
|
||||||
@@ -1687,17 +1639,6 @@ export interface components {
|
|||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
};
|
};
|
||||||
DocumentBatchMetadataDTO: {
|
|
||||||
titles?: string[];
|
|
||||||
/** Format: uuid */
|
|
||||||
senderId?: string;
|
|
||||||
receiverIds?: string[];
|
|
||||||
/** Format: date */
|
|
||||||
documentDate?: string;
|
|
||||||
location?: string;
|
|
||||||
tagNames?: string[];
|
|
||||||
metadataComplete?: boolean;
|
|
||||||
};
|
|
||||||
QuickUploadResult: {
|
QuickUploadResult: {
|
||||||
created?: components["schemas"]["Document"][];
|
created?: components["schemas"]["Document"][];
|
||||||
updated?: components["schemas"]["Document"][];
|
updated?: components["schemas"]["Document"][];
|
||||||
@@ -1732,21 +1673,6 @@ export interface components {
|
|||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
startedAt?: string;
|
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: {
|
BackfillResult: {
|
||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
count: number;
|
count: number;
|
||||||
@@ -1911,10 +1837,10 @@ export interface components {
|
|||||||
timeout?: number;
|
timeout?: number;
|
||||||
};
|
};
|
||||||
PageNotificationDTO: {
|
PageNotificationDTO: {
|
||||||
/** Format: int64 */
|
|
||||||
totalElements?: number;
|
|
||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
totalPages?: number;
|
totalPages?: number;
|
||||||
|
/** Format: int64 */
|
||||||
|
totalElements?: number;
|
||||||
pageable?: components["schemas"]["PageableObject"];
|
pageable?: components["schemas"]["PageableObject"];
|
||||||
first?: boolean;
|
first?: boolean;
|
||||||
last?: boolean;
|
last?: boolean;
|
||||||
@@ -3226,7 +3152,6 @@ export interface operations {
|
|||||||
content: {
|
content: {
|
||||||
"multipart/form-data": {
|
"multipart/form-data": {
|
||||||
files?: string[];
|
files?: string[];
|
||||||
metadata?: components["schemas"]["DocumentBatchMetadataDTO"];
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -3330,26 +3255,6 @@ 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: {
|
backfillVersions: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -4070,28 +3975,6 @@ 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: {
|
getBlockHistory: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -4155,9 +4038,15 @@ export interface operations {
|
|||||||
dir?: string;
|
dir?: string;
|
||||||
/** @description Tag operator: AND (default) or OR */
|
/** @description Tag operator: AND (default) or OR */
|
||||||
tagOp?: string;
|
tagOp?: string;
|
||||||
/** @description Page number (0-indexed) */
|
/**
|
||||||
|
* @description Page number (0-indexed)
|
||||||
|
* @default 0
|
||||||
|
*/
|
||||||
page?: number;
|
page?: number;
|
||||||
/** @description Page size (max 100) */
|
/**
|
||||||
|
* @description Page size (max 100)
|
||||||
|
* @default 50
|
||||||
|
*/
|
||||||
size?: number;
|
size?: number;
|
||||||
};
|
};
|
||||||
header?: never;
|
header?: never;
|
||||||
@@ -4356,26 +4245,6 @@ 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: {
|
importStatus: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { parseFilename, stripExtension, bulkTitleFromFilename } from './filename';
|
import { parseFilename, stripExtension } from './filename';
|
||||||
|
|
||||||
describe('parseFilename', () => {
|
describe('parseFilename', () => {
|
||||||
describe('date-first patterns', () => {
|
describe('date-first patterns', () => {
|
||||||
@@ -86,24 +86,6 @@ 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', () => {
|
describe('stripExtension', () => {
|
||||||
it('removes the extension', () => {
|
it('removes the extension', () => {
|
||||||
expect(stripExtension('document.pdf')).toBe('document');
|
expect(stripExtension('document.pdf')).toBe('document');
|
||||||
|
|||||||
@@ -81,7 +81,3 @@ export function parseFilename(filename: string): FilenameParseResult {
|
|||||||
export function stripExtension(filename: string): string {
|
export function stripExtension(filename: string): string {
|
||||||
return filename.replace(/\.[^/.]+$/, '');
|
return filename.replace(/\.[^/.]+$/, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function bulkTitleFromFilename(filename: string): string {
|
|
||||||
return stripExtension(filename).replace(/[_-]+/g, ' ').trim();
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,11 +1,154 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import BulkDocumentEditLayout from '$lib/components/document/BulkDocumentEditLayout.svelte';
|
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';
|
||||||
|
|
||||||
let { data } = $props();
|
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;
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<BulkDocumentEditLayout
|
<div class="mx-auto max-w-4xl px-4 py-8">
|
||||||
initialSenderId={data.initialSenderId}
|
<!-- Heading -->
|
||||||
initialSenderName={data.initialSenderName}
|
<div class="mb-6">
|
||||||
initialReceivers={data.initialReceivers}
|
<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>
|
||||||
|
|||||||
@@ -21,14 +21,15 @@ const baseData = {
|
|||||||
|
|
||||||
describe('New document page – sender prefill', () => {
|
describe('New document page – sender prefill', () => {
|
||||||
it('shows an empty sender input when no senderId is in the URL', async () => {
|
it('shows an empty sender input when no senderId is in the URL', async () => {
|
||||||
render(Page, { data: baseData });
|
render(Page, { data: baseData, form: null });
|
||||||
const input = document.querySelector<HTMLInputElement>('#senderId-search');
|
const input = document.querySelector<HTMLInputElement>('#senderId-search');
|
||||||
expect(input?.value).toBe('');
|
expect(input?.value).toBe('');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows the sender name in the typeahead input when initialSenderName is set', async () => {
|
it('shows the sender name in the typeahead input when initialSenderName is set', async () => {
|
||||||
render(Page, {
|
render(Page, {
|
||||||
data: { ...baseData, initialSenderId: 'p1', initialSenderName: 'Hans Müller' }
|
data: { ...baseData, initialSenderId: 'p1', initialSenderName: 'Hans Müller' },
|
||||||
|
form: null
|
||||||
});
|
});
|
||||||
const input = document.querySelector<HTMLInputElement>('#senderId-search');
|
const input = document.querySelector<HTMLInputElement>('#senderId-search');
|
||||||
expect(input?.value).toBe('Hans Müller');
|
expect(input?.value).toBe('Hans Müller');
|
||||||
@@ -36,7 +37,8 @@ describe('New document page – sender prefill', () => {
|
|||||||
|
|
||||||
it('sets the hidden senderId input to the prefilled ID', async () => {
|
it('sets the hidden senderId input to the prefilled ID', async () => {
|
||||||
render(Page, {
|
render(Page, {
|
||||||
data: { ...baseData, initialSenderId: 'p1', initialSenderName: 'Hans Müller' }
|
data: { ...baseData, initialSenderId: 'p1', initialSenderName: 'Hans Müller' },
|
||||||
|
form: null
|
||||||
});
|
});
|
||||||
const hidden = document.querySelector<HTMLInputElement>(
|
const hidden = document.querySelector<HTMLInputElement>(
|
||||||
'input[type="hidden"][name="senderId"]'
|
'input[type="hidden"][name="senderId"]'
|
||||||
@@ -49,7 +51,7 @@ describe('New document page – sender prefill', () => {
|
|||||||
|
|
||||||
describe('New document page – receiver prefill', () => {
|
describe('New document page – receiver prefill', () => {
|
||||||
it('shows no receiver chips when initialReceivers is empty', async () => {
|
it('shows no receiver chips when initialReceivers is empty', async () => {
|
||||||
render(Page, { data: baseData });
|
render(Page, { data: baseData, form: null });
|
||||||
await expect.element(page.getByText('Anna Schmidt')).not.toBeInTheDocument();
|
await expect.element(page.getByText('Anna Schmidt')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -60,7 +62,7 @@ describe('New document page – receiver prefill', () => {
|
|||||||
{ id: 'p2', firstName: 'Anna', lastName: 'Schmidt', displayName: 'Anna Schmidt' }
|
{ id: 'p2', firstName: 'Anna', lastName: 'Schmidt', displayName: 'Anna Schmidt' }
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
render(Page, { data });
|
render(Page, { data, form: null });
|
||||||
await expect.element(page.getByText('Anna Schmidt')).toBeInTheDocument();
|
await expect.element(page.getByText('Anna Schmidt')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -71,7 +73,7 @@ describe('New document page – receiver prefill', () => {
|
|||||||
{ id: 'p2', firstName: 'Anna', lastName: 'Schmidt', displayName: 'Anna Schmidt' }
|
{ id: 'p2', firstName: 'Anna', lastName: 'Schmidt', displayName: 'Anna Schmidt' }
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
render(Page, { data });
|
render(Page, { data, form: null });
|
||||||
const hidden = document.querySelector<HTMLInputElement>('input[name="receiverIds"]');
|
const hidden = document.querySelector<HTMLInputElement>('input[name="receiverIds"]');
|
||||||
expect(hidden?.value).toBe('p2');
|
expect(hidden?.value).toBe('p2');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ const klaerungChips = [
|
|||||||
<title>{m.richtlinien_title()} — Familienarchiv</title>
|
<title>{m.richtlinien_title()} — Familienarchiv</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="mx-auto max-w-2xl px-4 py-10 font-serif">
|
<main class="mx-auto max-w-2xl px-4 py-10 font-serif">
|
||||||
<!-- Title -->
|
<!-- Title -->
|
||||||
<h1 class="mb-4 text-3xl font-bold text-ink">{m.richtlinien_title()}</h1>
|
<h1 class="mb-4 text-3xl font-bold text-ink">{m.richtlinien_title()}</h1>
|
||||||
|
|
||||||
@@ -102,10 +102,10 @@ const klaerungChips = [
|
|||||||
|
|
||||||
<!-- Closing card -->
|
<!-- Closing card -->
|
||||||
<div class="border-brand-sand rounded-sm border bg-white p-6 shadow-sm">
|
<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>
|
<h3 class="mb-2 font-serif text-lg font-bold text-ink">{m.richtlinien_closing_title()}</h3>
|
||||||
<p class="font-serif text-sm leading-relaxed text-ink-2">{m.richtlinien_closing_body()}</p>
|
<p class="font-serif text-sm leading-relaxed text-ink-2">{m.richtlinien_closing_body()}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</main>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@media print {
|
@media print {
|
||||||
|
|||||||
@@ -1 +1,3 @@
|
|||||||
|
// Safe: handleAuth in hooks.server.ts redirects unauthenticated requests
|
||||||
|
// before prerendered HTML is visible.
|
||||||
export const prerender = true;
|
export const prerender = true;
|
||||||
|
|||||||
@@ -66,6 +66,9 @@
|
|||||||
/* Focus ring — keyboard focus indicator, mode-aware (navy in light, mint in dark) */
|
/* Focus ring — keyboard focus indicator, mode-aware (navy in light, mint in dark) */
|
||||||
--color-focus-ring: var(--c-focus-ring);
|
--color-focus-ring: var(--c-focus-ring);
|
||||||
|
|
||||||
|
/* Parchment — warm background for code/example blocks inside cards */
|
||||||
|
--color-parchment: var(--c-parchment);
|
||||||
|
|
||||||
/* Danger — destructive action color */
|
/* Danger — destructive action color */
|
||||||
--color-danger: var(--c-danger);
|
--color-danger: var(--c-danger);
|
||||||
--color-danger-fg: var(--c-danger-fg);
|
--color-danger-fg: var(--c-danger-fg);
|
||||||
@@ -122,6 +125,9 @@
|
|||||||
--c-danger: #c0392b;
|
--c-danger: #c0392b;
|
||||||
--c-danger-fg: #ffffff;
|
--c-danger-fg: #ffffff;
|
||||||
|
|
||||||
|
/* Parchment — warm near-white for example blocks (light mode: cream #FAF8F1) */
|
||||||
|
--c-parchment: #faf8f1;
|
||||||
|
|
||||||
/* Tag color tokens — decorative dot colors on tag chips */
|
/* Tag color tokens — decorative dot colors on tag chips */
|
||||||
--c-tag-sage: #5a8a6a;
|
--c-tag-sage: #5a8a6a;
|
||||||
--c-tag-sienna: #a0522d;
|
--c-tag-sienna: #a0522d;
|
||||||
@@ -203,6 +209,9 @@
|
|||||||
--c-danger: #e55347;
|
--c-danger: #e55347;
|
||||||
--c-danger-fg: #ffffff;
|
--c-danger-fg: #ffffff;
|
||||||
|
|
||||||
|
/* Parchment — subtle surface shift for example blocks on dark navy */
|
||||||
|
--c-parchment: #041828;
|
||||||
|
|
||||||
/* Tag color tokens — lighter for visibility on dark backgrounds */
|
/* Tag color tokens — lighter for visibility on dark backgrounds */
|
||||||
--c-tag-sage: #7abf8a;
|
--c-tag-sage: #7abf8a;
|
||||||
--c-tag-sienna: #cc7050;
|
--c-tag-sienna: #cc7050;
|
||||||
@@ -267,6 +276,9 @@
|
|||||||
--c-danger: #e55347;
|
--c-danger: #e55347;
|
||||||
--c-danger-fg: #ffffff;
|
--c-danger-fg: #ffffff;
|
||||||
|
|
||||||
|
/* Parchment — subtle surface shift for example blocks on dark navy */
|
||||||
|
--c-parchment: #041828;
|
||||||
|
|
||||||
/* Tag color tokens — lighter for visibility on dark backgrounds */
|
/* Tag color tokens — lighter for visibility on dark backgrounds */
|
||||||
--c-tag-sage: #7abf8a;
|
--c-tag-sage: #7abf8a;
|
||||||
--c-tag-sienna: #cc7050;
|
--c-tag-sienna: #cc7050;
|
||||||
|
|||||||
Reference in New Issue
Block a user