feat(geschichten): blog-like family memory stories (closes #381) #382
4
.gitignore
vendored
4
.gitignore
vendored
@@ -13,3 +13,7 @@ scripts/large-data.sql
|
|||||||
.vitest-attachments
|
.vitest-attachments
|
||||||
**/test-results/
|
**/test-results/
|
||||||
.worktrees/
|
.worktrees/
|
||||||
|
.superpowers/
|
||||||
|
|
||||||
|
# Repo uses npm; yarn.lock is ignored to avoid double-lockfile drift.
|
||||||
|
frontend/yarn.lock
|
||||||
|
|||||||
@@ -177,6 +177,13 @@
|
|||||||
<artifactId>imageio-tiff</artifactId>
|
<artifactId>imageio-tiff</artifactId>
|
||||||
<version>3.12.0</version>
|
<version>3.12.0</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<!-- HTML sanitization for Geschichten rich-text body (defense-in-depth alongside Tiptap on the client) -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.googlecode.owasp-java-html-sanitizer</groupId>
|
||||||
|
<artifactId>owasp-java-html-sanitizer</artifactId>
|
||||||
|
<version>20240325.1</version>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package org.raddatz.familienarchiv.controller;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.raddatz.familienarchiv.dto.GeschichteUpdateDTO;
|
||||||
|
import org.raddatz.familienarchiv.model.Geschichte;
|
||||||
|
import org.raddatz.familienarchiv.model.GeschichteStatus;
|
||||||
|
import org.raddatz.familienarchiv.security.Permission;
|
||||||
|
import org.raddatz.familienarchiv.security.RequirePermission;
|
||||||
|
import org.raddatz.familienarchiv.service.GeschichteService;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PatchMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/geschichten")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class GeschichteController {
|
||||||
|
|
||||||
|
private final GeschichteService geschichteService;
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public List<Geschichte> list(
|
||||||
|
@RequestParam(required = false) GeschichteStatus status,
|
||||||
|
@RequestParam(name = "personId", required = false) List<UUID> personIds,
|
||||||
|
@RequestParam(required = false) UUID documentId,
|
||||||
|
@RequestParam(required = false, defaultValue = "50") int limit) {
|
||||||
|
return geschichteService.list(
|
||||||
|
status,
|
||||||
|
personIds == null ? List.of() : personIds,
|
||||||
|
documentId,
|
||||||
|
limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
public Geschichte getById(@PathVariable UUID id) {
|
||||||
|
return geschichteService.getById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
@RequirePermission(Permission.BLOG_WRITE)
|
||||||
|
public ResponseEntity<Geschichte> create(@RequestBody GeschichteUpdateDTO dto) {
|
||||||
|
Geschichte created = geschichteService.create(dto);
|
||||||
|
return ResponseEntity.status(HttpStatus.CREATED).body(created);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PatchMapping("/{id}")
|
||||||
|
@RequirePermission(Permission.BLOG_WRITE)
|
||||||
|
public Geschichte update(@PathVariable UUID id, @RequestBody GeschichteUpdateDTO dto) {
|
||||||
|
return geschichteService.update(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
@RequirePermission(Permission.BLOG_WRITE)
|
||||||
|
public ResponseEntity<Void> delete(@PathVariable UUID id) {
|
||||||
|
geschichteService.delete(id);
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package org.raddatz.familienarchiv.dto;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import org.raddatz.familienarchiv.model.GeschichteStatus;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Used for both create and update of a Geschichte. All fields are optional;
|
||||||
|
* the service applies whatever is non-null. {@code body} is rich-text HTML and
|
||||||
|
* is sanitised against an allow-list before persistence.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class GeschichteUpdateDTO {
|
||||||
|
private String title;
|
||||||
|
private String body;
|
||||||
|
private GeschichteStatus status;
|
||||||
|
private List<UUID> personIds;
|
||||||
|
private List<UUID> documentIds;
|
||||||
|
}
|
||||||
@@ -103,6 +103,10 @@ public enum ErrorCode {
|
|||||||
/** A relationship with the same (person, relatedPerson, type) already exists. 409 */
|
/** A relationship with the same (person, relatedPerson, type) already exists. 409 */
|
||||||
DUPLICATE_RELATIONSHIP,
|
DUPLICATE_RELATIONSHIP,
|
||||||
|
|
||||||
|
// --- Geschichten (Stories) ---
|
||||||
|
/** A Geschichte (story) with the given ID does not exist, or is a DRAFT and the caller lacks BLOG_WRITE. 404 */
|
||||||
|
GESCHICHTE_NOT_FOUND,
|
||||||
|
|
||||||
// --- Tags ---
|
// --- Tags ---
|
||||||
/** A tag with the given ID does not exist. 404 */
|
/** A tag with the given ID does not exist. 404 */
|
||||||
TAG_NOT_FOUND,
|
TAG_NOT_FOUND,
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package org.raddatz.familienarchiv.model;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.*;
|
||||||
|
import org.hibernate.annotations.CreationTimestamp;
|
||||||
|
import org.hibernate.annotations.UpdateTimestamp;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "geschichten")
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Builder
|
||||||
|
public class Geschichte {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.UUID)
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
private UUID id;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
private String title;
|
||||||
|
|
||||||
|
@Column(columnDefinition = "TEXT")
|
||||||
|
private String body;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(nullable = false)
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
@Builder.Default
|
||||||
|
private GeschichteStatus status = GeschichteStatus.DRAFT;
|
||||||
|
|
||||||
|
@ManyToOne
|
||||||
|
@JoinColumn(name = "author_id")
|
||||||
|
private AppUser author;
|
||||||
|
|
||||||
|
@ManyToMany(fetch = FetchType.EAGER)
|
||||||
|
@JoinTable(name = "geschichten_persons",
|
||||||
|
joinColumns = @JoinColumn(name = "geschichte_id"),
|
||||||
|
inverseJoinColumns = @JoinColumn(name = "person_id"))
|
||||||
|
@Builder.Default
|
||||||
|
private Set<Person> persons = new HashSet<>();
|
||||||
|
|
||||||
|
@ManyToMany(fetch = FetchType.EAGER)
|
||||||
|
@JoinTable(name = "geschichten_documents",
|
||||||
|
joinColumns = @JoinColumn(name = "geschichte_id"),
|
||||||
|
inverseJoinColumns = @JoinColumn(name = "document_id"))
|
||||||
|
@Builder.Default
|
||||||
|
private Set<Document> documents = new HashSet<>();
|
||||||
|
|
||||||
|
@CreationTimestamp
|
||||||
|
@Column(updatable = false)
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@UpdateTimestamp
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
@Column(name = "published_at")
|
||||||
|
private LocalDateTime publishedAt;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package org.raddatz.familienarchiv.model;
|
||||||
|
|
||||||
|
public enum GeschichteStatus {
|
||||||
|
DRAFT,
|
||||||
|
PUBLISHED
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package org.raddatz.familienarchiv.repository;
|
||||||
|
|
||||||
|
import org.raddatz.familienarchiv.model.Geschichte;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface GeschichteRepository extends JpaRepository<Geschichte, UUID>, JpaSpecificationExecutor<Geschichte> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package org.raddatz.familienarchiv.repository;
|
||||||
|
|
||||||
|
import jakarta.persistence.criteria.CriteriaBuilder;
|
||||||
|
import jakarta.persistence.criteria.CriteriaQuery;
|
||||||
|
import jakarta.persistence.criteria.Join;
|
||||||
|
import jakarta.persistence.criteria.Predicate;
|
||||||
|
import jakarta.persistence.criteria.Root;
|
||||||
|
import jakarta.persistence.criteria.Subquery;
|
||||||
|
import org.raddatz.familienarchiv.model.Document;
|
||||||
|
import org.raddatz.familienarchiv.model.Geschichte;
|
||||||
|
import org.raddatz.familienarchiv.model.GeschichteStatus;
|
||||||
|
import org.raddatz.familienarchiv.model.Person;
|
||||||
|
import org.springframework.data.jpa.domain.Specification;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public final class GeschichteSpecifications {
|
||||||
|
|
||||||
|
private GeschichteSpecifications() {}
|
||||||
|
|
||||||
|
public static Specification<Geschichte> hasStatus(GeschichteStatus status) {
|
||||||
|
return (root, query, cb) -> status == null ? null : cb.equal(root.get("status"), status);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds {@code ORDER BY COALESCE(publishedAt, updatedAt) DESC} to the query without contributing
|
||||||
|
* a predicate. Combined into the spec chain via {@code .and(...)}; the {@code conjunction}
|
||||||
|
* acts as a no-op WHERE clause.
|
||||||
|
*/
|
||||||
|
public static Specification<Geschichte> orderByDisplayDateDesc() {
|
||||||
|
return (root, query, cb) -> {
|
||||||
|
// Skip ordering on count queries — JPA forbids orderBy on COUNT projections.
|
||||||
|
if (query != null
|
||||||
|
&& Long.class != query.getResultType()
|
||||||
|
&& long.class != query.getResultType()) {
|
||||||
|
query.orderBy(cb.desc(cb.coalesce(root.get("publishedAt"), root.get("updatedAt"))));
|
||||||
|
}
|
||||||
|
return cb.conjunction();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Specification<Geschichte> hasDocument(UUID documentId) {
|
||||||
|
return (root, query, cb) -> {
|
||||||
|
if (documentId == null) return null;
|
||||||
|
return cb.exists(documentSubquery(root, query, cb, documentId));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AND-filter across persons: the Geschichte must be associated with EVERY id in {@code personIds}.
|
||||||
|
*
|
||||||
|
* <p>Implemented as one EXISTS subquery per id (canonical Criteria-API idiom for AND across a
|
||||||
|
* many-to-many join). Mirrors {@link DocumentSpecifications#hasTags} which uses the same shape.
|
||||||
|
* Empty / null input returns {@code null} (i.e. no constraint added).
|
||||||
|
*/
|
||||||
|
public static Specification<Geschichte> hasAllPersons(Collection<UUID> personIds) {
|
||||||
|
return (root, query, cb) -> {
|
||||||
|
if (personIds == null || personIds.isEmpty()) return null;
|
||||||
|
List<Predicate> predicates = new ArrayList<>(personIds.size());
|
||||||
|
for (UUID id : personIds) {
|
||||||
|
predicates.add(cb.exists(personSubquery(root, query, cb, id)));
|
||||||
|
}
|
||||||
|
return cb.and(predicates.toArray(new Predicate[0]));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Subquery<UUID> personSubquery(
|
||||||
|
Root<Geschichte> root, CriteriaQuery<?> query, CriteriaBuilder cb, UUID personId) {
|
||||||
|
Subquery<UUID> sub = query.subquery(UUID.class);
|
||||||
|
Root<Geschichte> subRoot = sub.from(Geschichte.class);
|
||||||
|
Join<Geschichte, Person> persons = subRoot.join("persons");
|
||||||
|
sub.select(subRoot.get("id"))
|
||||||
|
.where(cb.equal(subRoot.get("id"), root.get("id")),
|
||||||
|
cb.equal(persons.get("id"), personId));
|
||||||
|
return sub;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Subquery<UUID> documentSubquery(
|
||||||
|
Root<Geschichte> root, CriteriaQuery<?> query, CriteriaBuilder cb, UUID documentId) {
|
||||||
|
Subquery<UUID> sub = query.subquery(UUID.class);
|
||||||
|
Root<Geschichte> subRoot = sub.from(Geschichte.class);
|
||||||
|
Join<Geschichte, Document> documents = subRoot.join("documents");
|
||||||
|
sub.select(subRoot.get("id"))
|
||||||
|
.where(cb.equal(subRoot.get("id"), root.get("id")),
|
||||||
|
cb.equal(documents.get("id"), documentId));
|
||||||
|
return sub;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ public enum Permission {
|
|||||||
READ_ALL,
|
READ_ALL,
|
||||||
WRITE_ALL,
|
WRITE_ALL,
|
||||||
ANNOTATE_ALL,
|
ANNOTATE_ALL,
|
||||||
|
BLOG_WRITE,
|
||||||
ADMIN,
|
ADMIN,
|
||||||
ADMIN_USER,
|
ADMIN_USER,
|
||||||
ADMIN_TAG,
|
ADMIN_TAG,
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
package org.raddatz.familienarchiv.service;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.owasp.html.HtmlPolicyBuilder;
|
||||||
|
import org.owasp.html.PolicyFactory;
|
||||||
|
import org.raddatz.familienarchiv.dto.GeschichteUpdateDTO;
|
||||||
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
|
import org.raddatz.familienarchiv.model.AppUser;
|
||||||
|
import org.raddatz.familienarchiv.model.Document;
|
||||||
|
import org.raddatz.familienarchiv.model.Geschichte;
|
||||||
|
import org.raddatz.familienarchiv.model.GeschichteStatus;
|
||||||
|
import org.raddatz.familienarchiv.model.Person;
|
||||||
|
import org.raddatz.familienarchiv.repository.GeschichteRepository;
|
||||||
|
import org.raddatz.familienarchiv.repository.GeschichteSpecifications;
|
||||||
|
import org.raddatz.familienarchiv.security.Permission;
|
||||||
|
import org.springframework.data.domain.Sort;
|
||||||
|
import org.springframework.data.jpa.domain.Specification;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
|
public class GeschichteService {
|
||||||
|
|
||||||
|
private final GeschichteRepository geschichteRepository;
|
||||||
|
private final PersonService personService;
|
||||||
|
private final DocumentService documentService;
|
||||||
|
private final UserService userService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allow-list policy for Geschichte body HTML. Tiptap on the writer side
|
||||||
|
* already constrains the marks/nodes, but the backend re-sanitises every
|
||||||
|
* save so that an attacker calling the API directly cannot inject more.
|
||||||
|
*/
|
||||||
|
private static final PolicyFactory BODY_SANITIZER = new HtmlPolicyBuilder()
|
||||||
|
.allowElements("p", "br", "strong", "em", "h2", "h3", "ul", "ol", "li")
|
||||||
|
.toFactory();
|
||||||
|
|
||||||
|
private static final int DEFAULT_LIMIT = 50;
|
||||||
|
private static final int MAX_LIMIT = 200;
|
||||||
|
|
||||||
|
// ─── Read API ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public Geschichte getById(UUID id) {
|
||||||
|
Geschichte g = geschichteRepository.findById(id)
|
||||||
|
.orElseThrow(() -> DomainException.notFound(
|
||||||
|
ErrorCode.GESCHICHTE_NOT_FOUND, "Geschichte not found: " + id));
|
||||||
|
if (g.getStatus() == GeschichteStatus.DRAFT && !currentUserHasBlogWrite()) {
|
||||||
|
// Use NOT_FOUND, not FORBIDDEN — don't leak DRAFT existence.
|
||||||
|
throw DomainException.notFound(
|
||||||
|
ErrorCode.GESCHICHTE_NOT_FOUND, "Geschichte not found: " + id);
|
||||||
|
}
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lists Geschichten with optional filters. {@code personIds} uses AND semantics: the story
|
||||||
|
* must be associated with every person id supplied. An empty or null list applies no
|
||||||
|
* person filter. Result is ordered by {@code COALESCE(publishedAt, updatedAt) DESC}.
|
||||||
|
*/
|
||||||
|
public List<Geschichte> list(GeschichteStatus status, List<UUID> personIds, UUID documentId, int limit) {
|
||||||
|
GeschichteStatus effective = currentUserHasBlogWrite() ? status : GeschichteStatus.PUBLISHED;
|
||||||
|
int safeLimit = limit <= 0 ? DEFAULT_LIMIT : Math.min(limit, MAX_LIMIT);
|
||||||
|
|
||||||
|
Specification<Geschichte> spec = Specification.allOf(
|
||||||
|
GeschichteSpecifications.hasStatus(effective),
|
||||||
|
GeschichteSpecifications.hasAllPersons(personIds),
|
||||||
|
GeschichteSpecifications.hasDocument(documentId),
|
||||||
|
GeschichteSpecifications.orderByDisplayDateDesc()
|
||||||
|
);
|
||||||
|
return geschichteRepository.findAll(spec, Sort.unsorted())
|
||||||
|
.stream()
|
||||||
|
.limit(safeLimit)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Write API ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public Geschichte create(GeschichteUpdateDTO dto) {
|
||||||
|
requireTitle(dto.getTitle());
|
||||||
|
Geschichte g = Geschichte.builder()
|
||||||
|
.title(dto.getTitle().trim())
|
||||||
|
.body(sanitize(dto.getBody()))
|
||||||
|
.status(GeschichteStatus.DRAFT)
|
||||||
|
.author(currentUser())
|
||||||
|
.persons(resolvePersons(dto.getPersonIds()))
|
||||||
|
.documents(resolveDocuments(dto.getDocumentIds()))
|
||||||
|
.build();
|
||||||
|
if (dto.getStatus() == GeschichteStatus.PUBLISHED) {
|
||||||
|
g.setStatus(GeschichteStatus.PUBLISHED);
|
||||||
|
g.setPublishedAt(LocalDateTime.now());
|
||||||
|
}
|
||||||
|
return geschichteRepository.save(g);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public Geschichte update(UUID id, GeschichteUpdateDTO dto) {
|
||||||
|
Geschichte g = geschichteRepository.findById(id)
|
||||||
|
.orElseThrow(() -> DomainException.notFound(
|
||||||
|
ErrorCode.GESCHICHTE_NOT_FOUND, "Geschichte not found: " + id));
|
||||||
|
if (dto.getTitle() != null) {
|
||||||
|
requireTitle(dto.getTitle());
|
||||||
|
g.setTitle(dto.getTitle().trim());
|
||||||
|
}
|
||||||
|
if (dto.getBody() != null) {
|
||||||
|
g.setBody(sanitize(dto.getBody()));
|
||||||
|
}
|
||||||
|
if (dto.getPersonIds() != null) {
|
||||||
|
g.setPersons(resolvePersons(dto.getPersonIds()));
|
||||||
|
}
|
||||||
|
if (dto.getDocumentIds() != null) {
|
||||||
|
g.setDocuments(resolveDocuments(dto.getDocumentIds()));
|
||||||
|
}
|
||||||
|
if (dto.getStatus() != null && dto.getStatus() != g.getStatus()) {
|
||||||
|
applyStatusTransition(g, dto.getStatus());
|
||||||
|
}
|
||||||
|
return geschichteRepository.save(g);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void delete(UUID id) {
|
||||||
|
if (!geschichteRepository.existsById(id)) {
|
||||||
|
throw DomainException.notFound(
|
||||||
|
ErrorCode.GESCHICHTE_NOT_FOUND, "Geschichte not found: " + id);
|
||||||
|
}
|
||||||
|
geschichteRepository.deleteById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── private helpers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private void applyStatusTransition(Geschichte g, GeschichteStatus next) {
|
||||||
|
g.setStatus(next);
|
||||||
|
if (next == GeschichteStatus.PUBLISHED) {
|
||||||
|
g.setPublishedAt(LocalDateTime.now());
|
||||||
|
} else {
|
||||||
|
g.setPublishedAt(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void requireTitle(String title) {
|
||||||
|
if (title == null || title.trim().isEmpty()) {
|
||||||
|
throw DomainException.badRequest(
|
||||||
|
ErrorCode.VALIDATION_ERROR, "Title is required");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String sanitize(String body) {
|
||||||
|
if (body == null) return null;
|
||||||
|
return BODY_SANITIZER.sanitize(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Set<Person> resolvePersons(List<UUID> ids) {
|
||||||
|
if (ids == null || ids.isEmpty()) return new HashSet<>();
|
||||||
|
return new LinkedHashSet<>(personService.getAllById(ids));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Set<Document> resolveDocuments(List<UUID> ids) {
|
||||||
|
if (ids == null || ids.isEmpty()) return new HashSet<>();
|
||||||
|
Set<Document> out = new LinkedHashSet<>();
|
||||||
|
for (UUID id : ids) {
|
||||||
|
out.add(documentService.getDocumentById(id));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private AppUser currentUser() {
|
||||||
|
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||||
|
if (auth == null || !auth.isAuthenticated()) {
|
||||||
|
throw DomainException.unauthorized("Authentication required");
|
||||||
|
}
|
||||||
|
return userService.findByEmail(auth.getName());
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean currentUserHasBlogWrite() {
|
||||||
|
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||||
|
if (auth == null || !auth.isAuthenticated()) return false;
|
||||||
|
return auth.getAuthorities().stream()
|
||||||
|
.anyMatch(a -> Permission.BLOG_WRITE.name().equals(a.getAuthority()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
-- Geschichten: blog-like family memory stories linked to persons and documents (issue #381).
|
||||||
|
-- BLOG_WRITE permission gates authoring; DRAFT stories are never returned to readers.
|
||||||
|
|
||||||
|
CREATE TABLE geschichten (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
body TEXT,
|
||||||
|
status VARCHAR(32) NOT NULL,
|
||||||
|
author_id UUID REFERENCES users (id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL,
|
||||||
|
updated_at TIMESTAMP NOT NULL,
|
||||||
|
published_at TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE geschichten_persons (
|
||||||
|
geschichte_id UUID NOT NULL REFERENCES geschichten (id) ON DELETE CASCADE,
|
||||||
|
person_id UUID NOT NULL REFERENCES persons (id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (geschichte_id, person_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE geschichten_documents (
|
||||||
|
geschichte_id UUID NOT NULL REFERENCES geschichten (id) ON DELETE CASCADE,
|
||||||
|
document_id UUID NOT NULL REFERENCES documents (id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (geschichte_id, document_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Index page query: WHERE status = 'PUBLISHED' ORDER BY published_at DESC.
|
||||||
|
CREATE INDEX idx_geschichten_published
|
||||||
|
ON geschichten (published_at DESC)
|
||||||
|
WHERE status = 'PUBLISHED';
|
||||||
|
|
||||||
|
-- Reverse-lookup indexes for the ?personId / ?documentId filters.
|
||||||
|
CREATE INDEX idx_geschichten_persons_person ON geschichten_persons (person_id);
|
||||||
|
CREATE INDEX idx_geschichten_documents_document ON geschichten_documents (document_id);
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- Grant BLOG_WRITE to every existing group that already holds WRITE_ALL.
|
||||||
|
-- Without this, the Geschichten feature ships dark to production: no group
|
||||||
|
-- has BLOG_WRITE, so the editor controls are invisible and "+ Neue Geschichte"
|
||||||
|
-- is never rendered. The natural mapping is "groups that can already write
|
||||||
|
-- documents and tags can also author family stories." Admins can revoke or
|
||||||
|
-- re-assign via the group editor afterwards.
|
||||||
|
|
||||||
|
INSERT INTO group_permissions (group_id, permission)
|
||||||
|
SELECT DISTINCT gp.group_id, 'BLOG_WRITE'
|
||||||
|
FROM group_permissions gp
|
||||||
|
WHERE gp.permission = 'WRITE_ALL'
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM group_permissions existing
|
||||||
|
WHERE existing.group_id = gp.group_id
|
||||||
|
AND existing.permission = 'BLOG_WRITE'
|
||||||
|
);
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
package org.raddatz.familienarchiv.controller;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.raddatz.familienarchiv.config.SecurityConfig;
|
||||||
|
import org.raddatz.familienarchiv.dto.GeschichteUpdateDTO;
|
||||||
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
|
import org.raddatz.familienarchiv.model.Geschichte;
|
||||||
|
import org.raddatz.familienarchiv.model.GeschichteStatus;
|
||||||
|
import org.raddatz.familienarchiv.security.PermissionAspect;
|
||||||
|
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
|
||||||
|
import org.raddatz.familienarchiv.service.GeschichteService;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
|
||||||
|
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
|
||||||
|
import org.springframework.context.annotation.Import;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.security.test.context.support.WithMockUser;
|
||||||
|
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||||
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyInt;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
|
@WebMvcTest(GeschichteController.class)
|
||||||
|
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||||
|
class GeschichteControllerTest {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
MockMvc mockMvc;
|
||||||
|
|
||||||
|
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
|
||||||
|
@MockitoBean
|
||||||
|
GeschichteService geschichteService;
|
||||||
|
|
||||||
|
@MockitoBean
|
||||||
|
CustomUserDetailsService customUserDetailsService;
|
||||||
|
|
||||||
|
// ─── GET /api/geschichten ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void list_returns401_whenUnauthenticated() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/geschichten"))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
|
void list_returns200_forReader() throws Exception {
|
||||||
|
when(geschichteService.list(any(), any(), any(), anyInt()))
|
||||||
|
.thenReturn(List.of(published(UUID.randomUUID(), "Story A")));
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/geschichten"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$[0].title").value("Story A"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
|
void list_passesSinglePersonIdFilterToServiceAsListOfOne() throws Exception {
|
||||||
|
UUID personId = UUID.randomUUID();
|
||||||
|
when(geschichteService.list(any(), eq(List.of(personId)), any(), anyInt()))
|
||||||
|
.thenReturn(List.of());
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/geschichten").param("personId", personId.toString()))
|
||||||
|
.andExpect(status().isOk());
|
||||||
|
|
||||||
|
verify(geschichteService).list(any(), eq(List.of(personId)), any(), anyInt());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
|
void list_passesRepeatedPersonIdParamsAsListForAndFilter() throws Exception {
|
||||||
|
UUID a = UUID.randomUUID();
|
||||||
|
UUID b = UUID.randomUUID();
|
||||||
|
when(geschichteService.list(any(), eq(List.of(a, b)), any(), anyInt()))
|
||||||
|
.thenReturn(List.of());
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/geschichten")
|
||||||
|
.param("personId", a.toString())
|
||||||
|
.param("personId", b.toString()))
|
||||||
|
.andExpect(status().isOk());
|
||||||
|
|
||||||
|
verify(geschichteService).list(any(), eq(List.of(a, b)), any(), anyInt());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── GET /api/geschichten/{id} ───────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
|
void getById_returns200_whenFound() throws Exception {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
when(geschichteService.getById(id)).thenReturn(published(id, "Hello"));
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/geschichten/{id}", id))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.id").value(id.toString()))
|
||||||
|
.andExpect(jsonPath("$.title").value("Hello"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
|
void getById_returns404_whenServiceThrowsNotFound() throws Exception {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
when(geschichteService.getById(id))
|
||||||
|
.thenThrow(DomainException.notFound(ErrorCode.GESCHICHTE_NOT_FOUND, "x"));
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/geschichten/{id}", id))
|
||||||
|
.andExpect(status().isNotFound())
|
||||||
|
.andExpect(jsonPath("$.code").value("GESCHICHTE_NOT_FOUND"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── POST /api/geschichten ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void create_returns401_whenUnauthenticated() throws Exception {
|
||||||
|
mockMvc.perform(post("/api/geschichten")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"title\":\"x\"}"))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
|
void create_returns403_whenLackingBlogWrite() throws Exception {
|
||||||
|
mockMvc.perform(post("/api/geschichten")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"title\":\"x\"}"))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "BLOG_WRITE")
|
||||||
|
void create_returns201_withBlogWrite() throws Exception {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
when(geschichteService.create(any(GeschichteUpdateDTO.class)))
|
||||||
|
.thenReturn(draft(id, "New"));
|
||||||
|
|
||||||
|
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||||
|
dto.setTitle("New");
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/geschichten")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(objectMapper.writeValueAsString(dto)))
|
||||||
|
.andExpect(status().isCreated())
|
||||||
|
.andExpect(jsonPath("$.id").value(id.toString()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── PATCH /api/geschichten/{id} ─────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
|
void update_returns403_whenLackingBlogWrite() throws Exception {
|
||||||
|
mockMvc.perform(patch("/api/geschichten/{id}", UUID.randomUUID())
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{}"))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "BLOG_WRITE")
|
||||||
|
void update_returns200_withBlogWrite() throws Exception {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
when(geschichteService.update(eq(id), any(GeschichteUpdateDTO.class)))
|
||||||
|
.thenReturn(published(id, "Updated"));
|
||||||
|
|
||||||
|
mockMvc.perform(patch("/api/geschichten/{id}", id)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"status\":\"PUBLISHED\"}"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.status").value("PUBLISHED"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── DELETE /api/geschichten/{id} ────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
|
void delete_returns403_whenLackingBlogWrite() throws Exception {
|
||||||
|
mockMvc.perform(delete("/api/geschichten/{id}", UUID.randomUUID()))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "BLOG_WRITE")
|
||||||
|
void delete_returns204_withBlogWrite() throws Exception {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
|
||||||
|
mockMvc.perform(delete("/api/geschichten/{id}", id))
|
||||||
|
.andExpect(status().isNoContent());
|
||||||
|
|
||||||
|
verify(geschichteService).delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private Geschichte published(UUID id, String title) {
|
||||||
|
return Geschichte.builder()
|
||||||
|
.id(id)
|
||||||
|
.title(title)
|
||||||
|
.body("<p>x</p>")
|
||||||
|
.status(GeschichteStatus.PUBLISHED)
|
||||||
|
.publishedAt(LocalDateTime.now())
|
||||||
|
.createdAt(LocalDateTime.now())
|
||||||
|
.updatedAt(LocalDateTime.now())
|
||||||
|
.persons(new HashSet<>())
|
||||||
|
.documents(new HashSet<>())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Geschichte draft(UUID id, String title) {
|
||||||
|
return Geschichte.builder()
|
||||||
|
.id(id)
|
||||||
|
.title(title)
|
||||||
|
.status(GeschichteStatus.DRAFT)
|
||||||
|
.createdAt(LocalDateTime.now())
|
||||||
|
.updatedAt(LocalDateTime.now())
|
||||||
|
.persons(new HashSet<>())
|
||||||
|
.documents(new HashSet<>())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
package org.raddatz.familienarchiv.service;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||||
|
import org.raddatz.familienarchiv.dto.GeschichteUpdateDTO;
|
||||||
|
import org.raddatz.familienarchiv.model.AppUser;
|
||||||
|
import org.raddatz.familienarchiv.model.Geschichte;
|
||||||
|
import org.raddatz.familienarchiv.model.GeschichteStatus;
|
||||||
|
import org.raddatz.familienarchiv.model.Person;
|
||||||
|
import org.raddatz.familienarchiv.repository.AppUserRepository;
|
||||||
|
import org.raddatz.familienarchiv.repository.GeschichteRepository;
|
||||||
|
import org.raddatz.familienarchiv.repository.PersonRepository;
|
||||||
|
import org.raddatz.familienarchiv.security.Permission;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.context.annotation.Import;
|
||||||
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||||
|
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
import org.springframework.test.annotation.DirtiesContext;
|
||||||
|
import org.springframework.test.context.ActiveProfiles;
|
||||||
|
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||||
|
import software.amazon.awssdk.services.s3.S3Client;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
||||||
|
@ActiveProfiles("test")
|
||||||
|
@Import(PostgresContainerConfig.class)
|
||||||
|
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
|
||||||
|
class GeschichteServiceIntegrationTest {
|
||||||
|
|
||||||
|
@MockitoBean
|
||||||
|
S3Client s3Client;
|
||||||
|
|
||||||
|
@Autowired GeschichteService geschichteService;
|
||||||
|
@Autowired GeschichteRepository geschichteRepository;
|
||||||
|
@Autowired PersonRepository personRepository;
|
||||||
|
@Autowired AppUserRepository appUserRepository;
|
||||||
|
|
||||||
|
AppUser writer;
|
||||||
|
AppUser reader;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void seed() {
|
||||||
|
writer = appUserRepository.save(AppUser.builder()
|
||||||
|
.email("writer-int@test")
|
||||||
|
.password("hash")
|
||||||
|
.build());
|
||||||
|
reader = appUserRepository.save(AppUser.builder()
|
||||||
|
.email("reader-int@test")
|
||||||
|
.password("hash")
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void clear() {
|
||||||
|
SecurityContextHolder.clearContext();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void create_then_publish_then_read_then_delete_full_lifecycle() {
|
||||||
|
// Create as writer
|
||||||
|
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||||
|
|
||||||
|
Person franz = personRepository.save(Person.builder().firstName("Franz").lastName("Raddatz").build());
|
||||||
|
|
||||||
|
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||||
|
dto.setTitle("Erinnerung an Opa Franz");
|
||||||
|
dto.setBody("<p>Ich erinnere mich, wie er <strong>jeden Sonntag</strong> sang.</p>"
|
||||||
|
+ "<script>alert('xss')</script>");
|
||||||
|
dto.setPersonIds(List.of(franz.getId()));
|
||||||
|
|
||||||
|
Geschichte created = geschichteService.create(dto);
|
||||||
|
|
||||||
|
assertThat(created.getId()).isNotNull();
|
||||||
|
assertThat(created.getStatus()).isEqualTo(GeschichteStatus.DRAFT);
|
||||||
|
assertThat(created.getBody())
|
||||||
|
.contains("<strong>jeden Sonntag</strong>")
|
||||||
|
.doesNotContain("<script>");
|
||||||
|
|
||||||
|
// Reader cannot see DRAFT in list
|
||||||
|
authenticateAs(reader, Permission.READ_ALL);
|
||||||
|
assertThat(geschichteService.list(null, List.of(), null, 50)).isEmpty();
|
||||||
|
|
||||||
|
// Reader cannot fetch DRAFT by id (404 via GESCHICHTE_NOT_FOUND)
|
||||||
|
UUID draftId = created.getId();
|
||||||
|
org.assertj.core.api.Assertions.assertThatThrownBy(() -> geschichteService.getById(draftId))
|
||||||
|
.hasMessageContaining("not found");
|
||||||
|
|
||||||
|
// Publish as writer
|
||||||
|
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||||
|
GeschichteUpdateDTO publishDto = new GeschichteUpdateDTO();
|
||||||
|
publishDto.setStatus(GeschichteStatus.PUBLISHED);
|
||||||
|
Geschichte publishedGesch = geschichteService.update(draftId, publishDto);
|
||||||
|
assertThat(publishedGesch.getPublishedAt()).isNotNull();
|
||||||
|
|
||||||
|
// Reader can now see and fetch it
|
||||||
|
authenticateAs(reader, Permission.READ_ALL);
|
||||||
|
assertThat(geschichteService.list(null, List.of(), null, 50)).hasSize(1);
|
||||||
|
assertThat(geschichteService.list(null, List.of(franz.getId()), null, 50)).hasSize(1);
|
||||||
|
Geschichte fetched = geschichteService.getById(draftId);
|
||||||
|
assertThat(fetched.getTitle()).isEqualTo("Erinnerung an Opa Franz");
|
||||||
|
assertThat(fetched.getPersons()).extracting(Person::getId).containsExactly(franz.getId());
|
||||||
|
|
||||||
|
// Delete as writer; join rows go with it
|
||||||
|
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||||
|
geschichteService.delete(draftId);
|
||||||
|
assertThat(geschichteRepository.findById(draftId)).isEmpty();
|
||||||
|
|
||||||
|
// The Person itself is untouched (cascade only flows from Geschichte to join table)
|
||||||
|
assertThat(personRepository.findById(franz.getId())).isPresent();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void list_filters_with_AND_semantics_when_multiple_personIds_given() {
|
||||||
|
// Three published stories, persons overlap so we can prove AND-not-OR:
|
||||||
|
// story_AB: about A and B
|
||||||
|
// story_AC: about A and C
|
||||||
|
// story_A: about A only
|
||||||
|
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||||
|
|
||||||
|
Person a = personRepository.save(Person.builder().firstName("Anna").lastName("A").build());
|
||||||
|
Person b = personRepository.save(Person.builder().firstName("Bertha").lastName("B").build());
|
||||||
|
Person c = personRepository.save(Person.builder().firstName("Carl").lastName("C").build());
|
||||||
|
|
||||||
|
UUID storyAB = publishedStoryWithPersons("Anna & Bertha", List.of(a.getId(), b.getId()));
|
||||||
|
UUID storyAC = publishedStoryWithPersons("Anna & Carl", List.of(a.getId(), c.getId()));
|
||||||
|
UUID storyA = publishedStoryWithPersons("Anna alone", List.of(a.getId()));
|
||||||
|
|
||||||
|
authenticateAs(reader, Permission.READ_ALL);
|
||||||
|
|
||||||
|
// No filter → all three
|
||||||
|
assertThat(geschichteService.list(null, List.of(), null, 50))
|
||||||
|
.extracting(Geschichte::getId)
|
||||||
|
.containsExactlyInAnyOrder(storyAB, storyAC, storyA);
|
||||||
|
|
||||||
|
// Single filter (Anna) → all three
|
||||||
|
assertThat(geschichteService.list(null, List.of(a.getId()), null, 50))
|
||||||
|
.extracting(Geschichte::getId)
|
||||||
|
.containsExactlyInAnyOrder(storyAB, storyAC, storyA);
|
||||||
|
|
||||||
|
// AND: Anna AND Bertha → only the AB story (NOT story_A, NOT story_AC)
|
||||||
|
assertThat(geschichteService.list(null, List.of(a.getId(), b.getId()), null, 50))
|
||||||
|
.extracting(Geschichte::getId)
|
||||||
|
.containsExactly(storyAB);
|
||||||
|
|
||||||
|
// AND: Bertha AND Carl → none (no story has both)
|
||||||
|
assertThat(geschichteService.list(null, List.of(b.getId(), c.getId()), null, 50))
|
||||||
|
.isEmpty();
|
||||||
|
|
||||||
|
// AND: Anna AND Bertha AND Carl → none
|
||||||
|
assertThat(geschichteService.list(null, List.of(a.getId(), b.getId(), c.getId()), null, 50))
|
||||||
|
.isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
private UUID publishedStoryWithPersons(String title, List<UUID> personIds) {
|
||||||
|
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||||
|
dto.setTitle(title);
|
||||||
|
dto.setBody("<p>body</p>");
|
||||||
|
dto.setPersonIds(personIds);
|
||||||
|
dto.setStatus(GeschichteStatus.PUBLISHED);
|
||||||
|
return geschichteService.create(dto).getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void authenticateAs(AppUser user, Permission... permissions) {
|
||||||
|
var authorities = java.util.Arrays.stream(permissions)
|
||||||
|
.map(p -> new SimpleGrantedAuthority(p.name()))
|
||||||
|
.toList();
|
||||||
|
SecurityContextHolder.getContext().setAuthentication(
|
||||||
|
new UsernamePasswordAuthenticationToken(user.getEmail(), null, authorities));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,441 @@
|
|||||||
|
package org.raddatz.familienarchiv.service;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.InjectMocks;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.raddatz.familienarchiv.dto.GeschichteUpdateDTO;
|
||||||
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
|
import org.raddatz.familienarchiv.model.AppUser;
|
||||||
|
import org.raddatz.familienarchiv.model.Document;
|
||||||
|
import org.raddatz.familienarchiv.model.Geschichte;
|
||||||
|
import org.raddatz.familienarchiv.model.GeschichteStatus;
|
||||||
|
import org.raddatz.familienarchiv.model.Person;
|
||||||
|
import org.raddatz.familienarchiv.repository.GeschichteRepository;
|
||||||
|
import org.raddatz.familienarchiv.security.Permission;
|
||||||
|
import org.springframework.data.domain.Sort;
|
||||||
|
import org.springframework.data.jpa.domain.Specification;
|
||||||
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||||
|
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class GeschichteServiceTest {
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
GeschichteRepository geschichteRepository;
|
||||||
|
@Mock
|
||||||
|
PersonService personService;
|
||||||
|
@Mock
|
||||||
|
DocumentService documentService;
|
||||||
|
@Mock
|
||||||
|
UserService userService;
|
||||||
|
|
||||||
|
@InjectMocks
|
||||||
|
GeschichteService geschichteService;
|
||||||
|
|
||||||
|
AppUser writer;
|
||||||
|
AppUser reader;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
SecurityContextHolder.clearContext();
|
||||||
|
writer = AppUser.builder().id(UUID.randomUUID()).email("writer@test").build();
|
||||||
|
reader = AppUser.builder().id(UUID.randomUUID()).email("reader@test").build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void tearDown() {
|
||||||
|
SecurityContextHolder.clearContext();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── getById ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getById_throws_NOT_FOUND_for_draft_when_user_lacks_BLOG_WRITE() {
|
||||||
|
authenticateAs(reader, Permission.READ_ALL);
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
Geschichte draft = draft(id);
|
||||||
|
when(geschichteRepository.findById(id)).thenReturn(Optional.of(draft));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> geschichteService.getById(id))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.extracting("code")
|
||||||
|
.isEqualTo(ErrorCode.GESCHICHTE_NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getById_returns_draft_when_user_has_BLOG_WRITE() {
|
||||||
|
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
Geschichte draft = draft(id);
|
||||||
|
when(geschichteRepository.findById(id)).thenReturn(Optional.of(draft));
|
||||||
|
|
||||||
|
Geschichte result = geschichteService.getById(id);
|
||||||
|
|
||||||
|
assertThat(result).isSameAs(draft);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getById_returns_published_to_anyone_authenticated() {
|
||||||
|
authenticateAs(reader, Permission.READ_ALL);
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
Geschichte published = published(id);
|
||||||
|
when(geschichteRepository.findById(id)).thenReturn(Optional.of(published));
|
||||||
|
|
||||||
|
Geschichte result = geschichteService.getById(id);
|
||||||
|
|
||||||
|
assertThat(result).isSameAs(published);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getById_throws_NOT_FOUND_when_id_unknown() {
|
||||||
|
authenticateAs(reader, Permission.READ_ALL);
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
when(geschichteRepository.findById(id)).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> geschichteService.getById(id))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.extracting("code")
|
||||||
|
.isEqualTo(ErrorCode.GESCHICHTE_NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── list ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void list_forces_PUBLISHED_status_for_reader_without_BLOG_WRITE() {
|
||||||
|
authenticateAs(reader, Permission.READ_ALL);
|
||||||
|
when(geschichteRepository.findAll(any(Specification.class), any(Sort.class)))
|
||||||
|
.thenReturn(List.of(published(UUID.randomUUID())));
|
||||||
|
|
||||||
|
geschichteService.list(/*status*/ null, /*personIds*/ List.of(), /*documentId*/ null, /*limit*/ 50);
|
||||||
|
|
||||||
|
// Status pinning lives inside the Specification; we assert end-to-end behaviour
|
||||||
|
// in GeschichteServiceIntegrationTest. Here we just confirm the service routes
|
||||||
|
// through the spec-aware repository method.
|
||||||
|
verify(geschichteRepository).findAll(any(Specification.class), any(Sort.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void list_passes_null_status_through_for_BLOG_WRITER_so_drafts_are_visible() {
|
||||||
|
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||||
|
when(geschichteRepository.findAll(any(Specification.class), any(Sort.class)))
|
||||||
|
.thenReturn(List.of(draft(UUID.randomUUID()), published(UUID.randomUUID())));
|
||||||
|
|
||||||
|
List<Geschichte> out = geschichteService.list(null, List.of(), null, 50);
|
||||||
|
|
||||||
|
assertThat(out).hasSize(2);
|
||||||
|
verify(geschichteRepository).findAll(any(Specification.class), any(Sort.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void list_invokes_repository_findAll_when_filtering_by_single_personId() {
|
||||||
|
authenticateAs(reader, Permission.READ_ALL);
|
||||||
|
UUID personId = UUID.randomUUID();
|
||||||
|
when(geschichteRepository.findAll(any(Specification.class), any(Sort.class)))
|
||||||
|
.thenReturn(List.of());
|
||||||
|
|
||||||
|
geschichteService.list(null, List.of(personId), null, 50);
|
||||||
|
|
||||||
|
verify(geschichteRepository).findAll(any(Specification.class), any(Sort.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void list_invokes_repository_findAll_when_filtering_by_multiple_personIds() {
|
||||||
|
authenticateAs(reader, Permission.READ_ALL);
|
||||||
|
UUID a = UUID.randomUUID();
|
||||||
|
UUID b = UUID.randomUUID();
|
||||||
|
when(geschichteRepository.findAll(any(Specification.class), any(Sort.class)))
|
||||||
|
.thenReturn(List.of());
|
||||||
|
|
||||||
|
geschichteService.list(null, List.of(a, b), null, 50);
|
||||||
|
|
||||||
|
verify(geschichteRepository).findAll(any(Specification.class), any(Sort.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void list_filters_by_documentId() {
|
||||||
|
authenticateAs(reader, Permission.READ_ALL);
|
||||||
|
UUID documentId = UUID.randomUUID();
|
||||||
|
when(geschichteRepository.findAll(any(Specification.class), any(Sort.class)))
|
||||||
|
.thenReturn(List.of());
|
||||||
|
|
||||||
|
geschichteService.list(null, List.of(), documentId, 50);
|
||||||
|
|
||||||
|
verify(geschichteRepository).findAll(any(Specification.class), any(Sort.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void list_caps_limit_at_max_via_pageable_when_caller_passes_huge_value() {
|
||||||
|
authenticateAs(reader, Permission.READ_ALL);
|
||||||
|
when(geschichteRepository.findAll(any(Specification.class), any(Sort.class)))
|
||||||
|
.thenReturn(List.of(published(UUID.randomUUID())));
|
||||||
|
|
||||||
|
// 9999 should be clamped — service trims to MAX_LIMIT (200) before/after the query
|
||||||
|
List<Geschichte> out = geschichteService.list(null, List.of(), null, 9999);
|
||||||
|
|
||||||
|
assertThat(out).hasSizeLessThanOrEqualTo(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── create ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void create_sets_status_to_DRAFT_by_default() {
|
||||||
|
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||||
|
when(userService.findByEmail(writer.getEmail())).thenReturn(writer);
|
||||||
|
when(geschichteRepository.save(any(Geschichte.class)))
|
||||||
|
.thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||||
|
dto.setTitle("My Story");
|
||||||
|
dto.setBody("<p>plain text</p>");
|
||||||
|
|
||||||
|
Geschichte saved = geschichteService.create(dto);
|
||||||
|
|
||||||
|
assertThat(saved.getStatus()).isEqualTo(GeschichteStatus.DRAFT);
|
||||||
|
assertThat(saved.getPublishedAt()).isNull();
|
||||||
|
assertThat(saved.getAuthor()).isSameAs(writer);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void create_sanitizes_body_HTML_dropping_disallowed_tags() {
|
||||||
|
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||||
|
when(userService.findByEmail(writer.getEmail())).thenReturn(writer);
|
||||||
|
when(geschichteRepository.save(any(Geschichte.class)))
|
||||||
|
.thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||||
|
dto.setTitle("XSS attempt");
|
||||||
|
dto.setBody("<p>safe</p><script>alert(1)</script><img src=x onerror=alert(2)>");
|
||||||
|
|
||||||
|
Geschichte saved = geschichteService.create(dto);
|
||||||
|
|
||||||
|
assertThat(saved.getBody())
|
||||||
|
.contains("<p>safe</p>")
|
||||||
|
.doesNotContain("<script>")
|
||||||
|
.doesNotContain("onerror")
|
||||||
|
.doesNotContain("<img");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void create_keeps_allowed_tags_strong_em_h2_h3_ul_ol_li() {
|
||||||
|
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||||
|
when(userService.findByEmail(writer.getEmail())).thenReturn(writer);
|
||||||
|
when(geschichteRepository.save(any(Geschichte.class)))
|
||||||
|
.thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||||
|
dto.setTitle("Rich");
|
||||||
|
dto.setBody("<h2>Heading</h2><p>Some <strong>bold</strong> and <em>italic</em>.</p>"
|
||||||
|
+ "<ul><li>one</li></ul><ol><li>first</li></ol>");
|
||||||
|
|
||||||
|
Geschichte saved = geschichteService.create(dto);
|
||||||
|
|
||||||
|
assertThat(saved.getBody())
|
||||||
|
.contains("<h2>Heading</h2>")
|
||||||
|
.contains("<strong>bold</strong>")
|
||||||
|
.contains("<em>italic</em>")
|
||||||
|
.contains("<ul>")
|
||||||
|
.contains("<ol>")
|
||||||
|
.contains("<li>one</li>");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void create_resolves_personIds_via_PersonService() {
|
||||||
|
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||||
|
when(userService.findByEmail(writer.getEmail())).thenReturn(writer);
|
||||||
|
UUID personId = UUID.randomUUID();
|
||||||
|
Person person = Person.builder().id(personId).build();
|
||||||
|
when(personService.getAllById(List.of(personId))).thenReturn(List.of(person));
|
||||||
|
when(geschichteRepository.save(any(Geschichte.class)))
|
||||||
|
.thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||||
|
dto.setTitle("Linked");
|
||||||
|
dto.setPersonIds(List.of(personId));
|
||||||
|
|
||||||
|
Geschichte saved = geschichteService.create(dto);
|
||||||
|
|
||||||
|
assertThat(saved.getPersons()).containsExactly(person);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void create_resolves_documentIds_via_DocumentService() {
|
||||||
|
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||||
|
when(userService.findByEmail(writer.getEmail())).thenReturn(writer);
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
Document doc = Document.builder().id(docId).build();
|
||||||
|
when(documentService.getDocumentById(docId)).thenReturn(doc);
|
||||||
|
when(geschichteRepository.save(any(Geschichte.class)))
|
||||||
|
.thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||||
|
dto.setTitle("Linked doc");
|
||||||
|
dto.setDocumentIds(List.of(docId));
|
||||||
|
|
||||||
|
Geschichte saved = geschichteService.create(dto);
|
||||||
|
|
||||||
|
assertThat(saved.getDocuments()).containsExactly(doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void create_throws_BAD_REQUEST_when_title_blank() {
|
||||||
|
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||||
|
|
||||||
|
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||||
|
dto.setTitle(" ");
|
||||||
|
dto.setBody("<p>x</p>");
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> geschichteService.create(dto))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.extracting("code")
|
||||||
|
.isEqualTo(ErrorCode.VALIDATION_ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── update ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void update_sets_publishedAt_when_status_transitions_to_PUBLISHED() {
|
||||||
|
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
Geschichte existing = draft(id);
|
||||||
|
existing.setPublishedAt(null);
|
||||||
|
when(geschichteRepository.findById(id)).thenReturn(Optional.of(existing));
|
||||||
|
when(geschichteRepository.save(any(Geschichte.class)))
|
||||||
|
.thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||||
|
dto.setStatus(GeschichteStatus.PUBLISHED);
|
||||||
|
|
||||||
|
Geschichte saved = geschichteService.update(id, dto);
|
||||||
|
|
||||||
|
assertThat(saved.getStatus()).isEqualTo(GeschichteStatus.PUBLISHED);
|
||||||
|
assertThat(saved.getPublishedAt()).isNotNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void update_clears_publishedAt_when_status_transitions_back_to_DRAFT() {
|
||||||
|
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
Geschichte existing = published(id);
|
||||||
|
existing.setPublishedAt(LocalDateTime.now().minusDays(1));
|
||||||
|
when(geschichteRepository.findById(id)).thenReturn(Optional.of(existing));
|
||||||
|
when(geschichteRepository.save(any(Geschichte.class)))
|
||||||
|
.thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||||
|
dto.setStatus(GeschichteStatus.DRAFT);
|
||||||
|
|
||||||
|
Geschichte saved = geschichteService.update(id, dto);
|
||||||
|
|
||||||
|
assertThat(saved.getStatus()).isEqualTo(GeschichteStatus.DRAFT);
|
||||||
|
assertThat(saved.getPublishedAt()).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void update_sanitizes_body_on_save() {
|
||||||
|
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
when(geschichteRepository.findById(id)).thenReturn(Optional.of(draft(id)));
|
||||||
|
when(geschichteRepository.save(any(Geschichte.class)))
|
||||||
|
.thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||||
|
dto.setBody("<p>ok</p><script>alert(1)</script>");
|
||||||
|
|
||||||
|
Geschichte saved = geschichteService.update(id, dto);
|
||||||
|
|
||||||
|
assertThat(saved.getBody()).doesNotContain("<script>").contains("<p>ok</p>");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void update_throws_NOT_FOUND_when_geschichte_unknown() {
|
||||||
|
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
when(geschichteRepository.findById(id)).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> geschichteService.update(id, new GeschichteUpdateDTO()))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.extracting("code")
|
||||||
|
.isEqualTo(ErrorCode.GESCHICHTE_NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── delete ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void delete_calls_repository_deleteById() {
|
||||||
|
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
when(geschichteRepository.existsById(id)).thenReturn(true);
|
||||||
|
|
||||||
|
geschichteService.delete(id);
|
||||||
|
|
||||||
|
verify(geschichteRepository).deleteById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void delete_throws_NOT_FOUND_when_unknown() {
|
||||||
|
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
when(geschichteRepository.existsById(id)).thenReturn(false);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> geschichteService.delete(id))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.extracting("code")
|
||||||
|
.isEqualTo(ErrorCode.GESCHICHTE_NOT_FOUND);
|
||||||
|
verify(geschichteRepository, never()).deleteById(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private void authenticateAs(AppUser user, Permission... permissions) {
|
||||||
|
var authorities = List.of(permissions).stream()
|
||||||
|
.map(p -> new SimpleGrantedAuthority(p.name()))
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
SecurityContextHolder.getContext().setAuthentication(
|
||||||
|
new UsernamePasswordAuthenticationToken(user.getEmail(), null, authorities));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Geschichte draft(UUID id) {
|
||||||
|
return Geschichte.builder()
|
||||||
|
.id(id)
|
||||||
|
.title("Draft")
|
||||||
|
.body("<p>body</p>")
|
||||||
|
.status(GeschichteStatus.DRAFT)
|
||||||
|
.persons(new HashSet<>())
|
||||||
|
.documents(new HashSet<>())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Geschichte published(UUID id) {
|
||||||
|
return Geschichte.builder()
|
||||||
|
.id(id)
|
||||||
|
.title("Published")
|
||||||
|
.body("<p>body</p>")
|
||||||
|
.status(GeschichteStatus.PUBLISHED)
|
||||||
|
.publishedAt(LocalDateTime.now().minusHours(1))
|
||||||
|
.persons(new HashSet<>())
|
||||||
|
.documents(new HashSet<>())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,5 +23,6 @@ bun.lockb
|
|||||||
|
|
||||||
# Test artifacts
|
# Test artifacts
|
||||||
/test-results/
|
/test-results/
|
||||||
|
/test-results.locked/
|
||||||
/e2e/.auth/
|
/e2e/.auth/
|
||||||
/coverage/
|
/coverage/
|
||||||
|
|||||||
151
frontend/e2e/geschichten.spec.ts
Normal file
151
frontend/e2e/geschichten.spec.ts
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
import AxeBuilder from '@axe-core/playwright';
|
||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal Geschichten coverage. The deeper a11y / visual-regression suite is
|
||||||
|
* tracked separately; this file proves the core writer + reader journey works
|
||||||
|
* end-to-end against the real stack.
|
||||||
|
*
|
||||||
|
* Pre-requisite: V59 has granted BLOG_WRITE to the Administrators group, so
|
||||||
|
* the seeded admin user can author. The auth.setup project handles login.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const stamp = () => new Date().toISOString().replace(/[^0-9]/g, '');
|
||||||
|
|
||||||
|
test.describe('Geschichten — writer + reader journey', () => {
|
||||||
|
test('admin can create a draft, publish it, and see it on the index', async ({ page }) => {
|
||||||
|
const title = `E2E story ${stamp()}`;
|
||||||
|
|
||||||
|
// Land on the index — empty state or pre-existing demo data is fine
|
||||||
|
await page.goto('/geschichten');
|
||||||
|
await page.waitForSelector('[data-hydrated]');
|
||||||
|
await expect(page.getByRole('heading', { name: 'Geschichten', level: 1 })).toBeVisible();
|
||||||
|
|
||||||
|
// Click "Neue Geschichte" — visible because admin has BLOG_WRITE
|
||||||
|
await page.getByRole('link', { name: 'Neue Geschichte' }).click();
|
||||||
|
await page.waitForURL('/geschichten/new');
|
||||||
|
|
||||||
|
// Fill in title — the body editor is Tiptap and harder to script reliably
|
||||||
|
await page.getByPlaceholder('Titel der Geschichte').fill(title);
|
||||||
|
|
||||||
|
// Save as draft and verify we land on the detail page
|
||||||
|
await page.getByRole('button', { name: 'Entwurf speichern' }).click();
|
||||||
|
await page.waitForURL(/\/geschichten\/[^/]+$/);
|
||||||
|
|
||||||
|
// Capture the new id from the URL
|
||||||
|
const detailUrl = page.url();
|
||||||
|
const id = detailUrl.split('/').pop();
|
||||||
|
expect(id).toBeTruthy();
|
||||||
|
|
||||||
|
// Publish from the edit page
|
||||||
|
await page.getByRole('link', { name: 'Bearbeiten' }).click();
|
||||||
|
await page.waitForURL(/\/edit$/);
|
||||||
|
await page.getByRole('button', { name: 'Veröffentlichen' }).click();
|
||||||
|
await page.waitForURL(detailUrl);
|
||||||
|
|
||||||
|
// Index now shows the published story
|
||||||
|
await page.goto('/geschichten');
|
||||||
|
await expect(page.getByRole('link', { name: title })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reader is taken to a story detail when clicking a card', async ({ page }) => {
|
||||||
|
await page.goto('/geschichten');
|
||||||
|
await page.waitForSelector('[data-hydrated]');
|
||||||
|
|
||||||
|
// Use the first story link in the list (demo data exists; if not, the
|
||||||
|
// previous test seeded one). The link wraps the whole card.
|
||||||
|
const firstStory = page.locator('a[href^="/geschichten/"]').filter({ hasText: /.+/ }).first();
|
||||||
|
await expect(firstStory).toBeVisible();
|
||||||
|
await firstStory.click();
|
||||||
|
|
||||||
|
await page.waitForURL(/\/geschichten\/[^/]+$/);
|
||||||
|
await expect(page.locator('article')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('multi-person filter: chips, URL params, and AND removal work end-to-end', async ({
|
||||||
|
page
|
||||||
|
}) => {
|
||||||
|
await page.goto('/geschichten');
|
||||||
|
await page.waitForSelector('[data-hydrated]');
|
||||||
|
|
||||||
|
// We need two distinct persons to filter by, but we don't want to couple this
|
||||||
|
// test to specific seed names. Strategy: type a single broadly-occurring vowel
|
||||||
|
// ("e" is present in the vast majority of German names), open the listbox,
|
||||||
|
// and pick whichever option matches the predicate.
|
||||||
|
//
|
||||||
|
// option DOM ids encode the person id as `${listboxId}-option-${personId}`,
|
||||||
|
// so we can identify the *first different* option without knowing the seed.
|
||||||
|
const PROBE = 'e';
|
||||||
|
|
||||||
|
async function openPicker() {
|
||||||
|
await page.getByRole('button', { name: /Person wählen/ }).click();
|
||||||
|
const input = page.getByRole('combobox', { name: /Person wählen/ });
|
||||||
|
await input.fill(PROBE);
|
||||||
|
// Wait for the listbox to be populated.
|
||||||
|
await expect(page.getByRole('option').first()).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pickFirstOption(): Promise<string> {
|
||||||
|
const opt = page.getByRole('option').first();
|
||||||
|
const optId = (await opt.getAttribute('id')) ?? '';
|
||||||
|
const personId = optId.split('-option-')[1] ?? '';
|
||||||
|
expect(personId).not.toEqual('');
|
||||||
|
await opt.click();
|
||||||
|
return personId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pickFirstOptionDifferentFrom(excludeId: string): Promise<string> {
|
||||||
|
// Iterate through visible options and return the first whose person id != excludeId.
|
||||||
|
const optionCount = await page.getByRole('option').count();
|
||||||
|
for (let i = 0; i < optionCount; i++) {
|
||||||
|
const candidate = page.getByRole('option').nth(i);
|
||||||
|
const optId = (await candidate.getAttribute('id')) ?? '';
|
||||||
|
const personId = optId.split('-option-')[1] ?? '';
|
||||||
|
if (personId && personId !== excludeId) {
|
||||||
|
await candidate.click();
|
||||||
|
return personId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
`Expected at least two distinct persons matching "${PROBE}" in the seed, found only one.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await openPicker();
|
||||||
|
const firstId = await pickFirstOption();
|
||||||
|
await page.waitForURL(/personId=/);
|
||||||
|
const firstIds = new URL(page.url()).searchParams.getAll('personId');
|
||||||
|
expect(firstIds).toEqual([firstId]);
|
||||||
|
|
||||||
|
await openPicker();
|
||||||
|
const secondId = await pickFirstOptionDifferentFrom(firstId);
|
||||||
|
await page.waitForURL((url) => url.searchParams.getAll('personId').length === 2);
|
||||||
|
const secondIds = new URL(page.url()).searchParams.getAll('personId');
|
||||||
|
expect(secondIds).toEqual([firstId, secondId]);
|
||||||
|
expect(secondId).not.toEqual(firstId);
|
||||||
|
|
||||||
|
// Two chips visible — find them by their remove-aria-label pattern
|
||||||
|
const chipButtons = page.getByRole('button', { name: /aus Filter entfernen/ });
|
||||||
|
await expect(chipButtons).toHaveCount(2);
|
||||||
|
|
||||||
|
// Remove the first chip — URL drops to one param, only the second id remains
|
||||||
|
await chipButtons.first().click();
|
||||||
|
await page.waitForURL((url) => url.searchParams.getAll('personId').length === 1);
|
||||||
|
const finalIds = new URL(page.url()).searchParams.getAll('personId');
|
||||||
|
expect(finalIds).toEqual([secondId]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AxeBuilder finds no critical violations on the index', async ({ page }) => {
|
||||||
|
await page.goto('/geschichten');
|
||||||
|
await page.waitForSelector('[data-hydrated]');
|
||||||
|
const results = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa']).analyze();
|
||||||
|
|
||||||
|
// Filter to non-deferred severity. We don't gate the whole PR on a clean
|
||||||
|
// AxeBuilder run yet — Sara's review tracks the broader a11y backlog —
|
||||||
|
// but any "serious" or "critical" finding from this scan would block merge.
|
||||||
|
const blocking = results.violations.filter(
|
||||||
|
(v) => v.impact === 'serious' || v.impact === 'critical'
|
||||||
|
);
|
||||||
|
expect(blocking, JSON.stringify(blocking, null, 2)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -228,6 +228,7 @@
|
|||||||
"admin_perm_read_all": "Nur lesen",
|
"admin_perm_read_all": "Nur lesen",
|
||||||
"admin_perm_annotate_all": "Lesen & Annotieren",
|
"admin_perm_annotate_all": "Lesen & Annotieren",
|
||||||
"admin_perm_write_all": "Lesen & Schreiben",
|
"admin_perm_write_all": "Lesen & Schreiben",
|
||||||
|
"admin_perm_blog_write": "Geschichten schreiben",
|
||||||
"admin_perm_admin": "Vollzugriff (Admin)",
|
"admin_perm_admin": "Vollzugriff (Admin)",
|
||||||
"admin_perm_admin_user": "Benutzer verwalten",
|
"admin_perm_admin_user": "Benutzer verwalten",
|
||||||
"admin_perm_admin_tag": "Schlagworte verwalten",
|
"admin_perm_admin_tag": "Schlagworte verwalten",
|
||||||
@@ -919,6 +920,59 @@
|
|||||||
"bulk_edit_count_pill": "{count} werden bearbeitet",
|
"bulk_edit_count_pill": "{count} werden bearbeitet",
|
||||||
|
|
||||||
"nav_stammbaum": "Stammbaum",
|
"nav_stammbaum": "Stammbaum",
|
||||||
|
"nav_geschichten": "Geschichten",
|
||||||
|
|
||||||
|
"error_geschichte_not_found": "Die Geschichte wurde nicht gefunden.",
|
||||||
|
|
||||||
|
"geschichten_index_title": "Geschichten",
|
||||||
|
"geschichten_new_button": "Neue Geschichte",
|
||||||
|
"geschichten_filter_all_pill": "Alle",
|
||||||
|
"geschichten_filter_choose_person": "Person wählen",
|
||||||
|
"geschichten_filter_aria_label": "Person filtern",
|
||||||
|
"geschichten_filter_remove_chip": "{name} aus Filter entfernen",
|
||||||
|
"geschichten_filter_and_hint": "Es werden nur Geschichten gezeigt, in denen alle ausgewählten Personen vorkommen.",
|
||||||
|
"geschichten_empty_for_person": "Keine Geschichten für {name} gefunden.",
|
||||||
|
"geschichten_empty_for_persons": "Keine Geschichten für {names} gefunden.",
|
||||||
|
"geschichten_empty_no_filter": "Es gibt noch keine veröffentlichten Geschichten.",
|
||||||
|
"geschichten_back_to_index": "Zurück zu Geschichten",
|
||||||
|
"geschichten_published_on": "veröffentlicht am {date}",
|
||||||
|
"geschichten_persons_section": "Personen in dieser Geschichte",
|
||||||
|
"geschichten_documents_section": "Erwähnte Dokumente",
|
||||||
|
"geschichten_card_heading": "Geschichten",
|
||||||
|
"geschichten_card_write_action": "+ Geschichte schreiben",
|
||||||
|
"geschichten_card_attach_action": "+ Geschichte anhängen",
|
||||||
|
"geschichten_card_show_all_for_person": "Alle Geschichten zu {name}",
|
||||||
|
"geschichten_card_show_all": "Alle anzeigen",
|
||||||
|
|
||||||
|
"geschichte_editor_title_placeholder": "Titel der Geschichte",
|
||||||
|
"geschichte_editor_body_placeholder": "Schreibe hier deine Geschichte…",
|
||||||
|
"geschichte_editor_status_draft": "ENTWURF",
|
||||||
|
"geschichte_editor_status_published": "VERÖFFENTLICHT",
|
||||||
|
"geschichte_editor_status_draft_hint": "Noch nicht öffentlich sichtbar.",
|
||||||
|
"geschichte_editor_status_published_hint": "Öffentlich sichtbar für alle Leser.",
|
||||||
|
"geschichte_editor_save_hint_draft": "Alle Änderungen werden als Entwurf gespeichert.",
|
||||||
|
"geschichte_editor_save_hint_published": "Änderungen sind sofort live.",
|
||||||
|
"geschichte_editor_save_draft": "Entwurf speichern",
|
||||||
|
"geschichte_editor_publish": "Veröffentlichen",
|
||||||
|
"geschichte_editor_save": "Speichern",
|
||||||
|
"geschichte_editor_unpublish": "Zurück zu Entwurf",
|
||||||
|
"geschichte_editor_title_required": "Bitte gib einen Titel ein.",
|
||||||
|
"geschichte_editor_unsaved_changes": "Du hast ungespeicherte Änderungen — wirklich verlassen?",
|
||||||
|
"geschichte_editor_personen_heading": "Personen",
|
||||||
|
"geschichte_editor_personen_hint": "Welche historischen Personen kommen in dieser Geschichte vor?",
|
||||||
|
"geschichte_editor_dokumente_heading": "Dokumente",
|
||||||
|
"geschichte_editor_dokumente_hint": "Welche Briefe oder Dokumente sind Teil dieser Geschichte?",
|
||||||
|
"geschichte_editor_search_person": "Person suchen…",
|
||||||
|
"geschichte_editor_search_document": "Dokument suchen…",
|
||||||
|
"geschichte_editor_toolbar_bold": "Fett (Strg+B)",
|
||||||
|
"geschichte_editor_toolbar_italic": "Kursiv (Strg+I)",
|
||||||
|
"geschichte_editor_toolbar_h2": "Überschrift",
|
||||||
|
"geschichte_editor_toolbar_h3": "Unterüberschrift",
|
||||||
|
"geschichte_editor_toolbar_ul": "Aufzählung",
|
||||||
|
"geschichte_editor_toolbar_ol": "Nummerierte Liste",
|
||||||
|
|
||||||
|
"geschichte_delete_confirm_title": "Geschichte löschen?",
|
||||||
|
"geschichte_delete_confirm_body": "Diese Aktion kann nicht rückgängig gemacht werden. Die Geschichte wird dauerhaft gelöscht und aus allen verlinkten Personen- und Dokumentseiten entfernt.",
|
||||||
|
|
||||||
"error_relationship_not_found": "Die Beziehung wurde nicht gefunden.",
|
"error_relationship_not_found": "Die Beziehung wurde nicht gefunden.",
|
||||||
"error_circular_relationship": "Diese Beziehung würde einen Kreis erzeugen.",
|
"error_circular_relationship": "Diese Beziehung würde einen Kreis erzeugen.",
|
||||||
|
|||||||
@@ -228,6 +228,7 @@
|
|||||||
"admin_perm_read_all": "Read only",
|
"admin_perm_read_all": "Read only",
|
||||||
"admin_perm_annotate_all": "Read & Annotate",
|
"admin_perm_annotate_all": "Read & Annotate",
|
||||||
"admin_perm_write_all": "Read & Write",
|
"admin_perm_write_all": "Read & Write",
|
||||||
|
"admin_perm_blog_write": "Write stories",
|
||||||
"admin_perm_admin": "Full access (Admin)",
|
"admin_perm_admin": "Full access (Admin)",
|
||||||
"admin_perm_admin_user": "Manage users",
|
"admin_perm_admin_user": "Manage users",
|
||||||
"admin_perm_admin_tag": "Manage tags",
|
"admin_perm_admin_tag": "Manage tags",
|
||||||
@@ -919,6 +920,59 @@
|
|||||||
"bulk_edit_count_pill": "{count} will be edited",
|
"bulk_edit_count_pill": "{count} will be edited",
|
||||||
|
|
||||||
"nav_stammbaum": "Family tree",
|
"nav_stammbaum": "Family tree",
|
||||||
|
"nav_geschichten": "Stories",
|
||||||
|
|
||||||
|
"error_geschichte_not_found": "The story was not found.",
|
||||||
|
|
||||||
|
"geschichten_index_title": "Stories",
|
||||||
|
"geschichten_new_button": "New story",
|
||||||
|
"geschichten_filter_all_pill": "All",
|
||||||
|
"geschichten_filter_choose_person": "Choose person",
|
||||||
|
"geschichten_filter_aria_label": "Filter by person",
|
||||||
|
"geschichten_filter_remove_chip": "Remove {name} from filter",
|
||||||
|
"geschichten_filter_and_hint": "Only stories that include every selected person are shown.",
|
||||||
|
"geschichten_empty_for_person": "No stories found for {name}.",
|
||||||
|
"geschichten_empty_for_persons": "No stories found for {names}.",
|
||||||
|
"geschichten_empty_no_filter": "There are no published stories yet.",
|
||||||
|
"geschichten_back_to_index": "Back to stories",
|
||||||
|
"geschichten_published_on": "published on {date}",
|
||||||
|
"geschichten_persons_section": "People in this story",
|
||||||
|
"geschichten_documents_section": "Referenced documents",
|
||||||
|
"geschichten_card_heading": "Stories",
|
||||||
|
"geschichten_card_write_action": "+ Write a story",
|
||||||
|
"geschichten_card_attach_action": "+ Attach a story",
|
||||||
|
"geschichten_card_show_all_for_person": "All stories about {name}",
|
||||||
|
"geschichten_card_show_all": "Show all",
|
||||||
|
|
||||||
|
"geschichte_editor_title_placeholder": "Story title",
|
||||||
|
"geschichte_editor_body_placeholder": "Write your story here…",
|
||||||
|
"geschichte_editor_status_draft": "DRAFT",
|
||||||
|
"geschichte_editor_status_published": "PUBLISHED",
|
||||||
|
"geschichte_editor_status_draft_hint": "Not yet visible to readers.",
|
||||||
|
"geschichte_editor_status_published_hint": "Visible to all readers.",
|
||||||
|
"geschichte_editor_save_hint_draft": "All changes are saved as a draft.",
|
||||||
|
"geschichte_editor_save_hint_published": "Changes go live immediately.",
|
||||||
|
"geschichte_editor_save_draft": "Save draft",
|
||||||
|
"geschichte_editor_publish": "Publish",
|
||||||
|
"geschichte_editor_save": "Save",
|
||||||
|
"geschichte_editor_unpublish": "Back to draft",
|
||||||
|
"geschichte_editor_title_required": "Please enter a title.",
|
||||||
|
"geschichte_editor_unsaved_changes": "You have unsaved changes — leave anyway?",
|
||||||
|
"geschichte_editor_personen_heading": "People",
|
||||||
|
"geschichte_editor_personen_hint": "Which historical persons appear in this story?",
|
||||||
|
"geschichte_editor_dokumente_heading": "Documents",
|
||||||
|
"geschichte_editor_dokumente_hint": "Which letters or documents are part of this story?",
|
||||||
|
"geschichte_editor_search_person": "Search person…",
|
||||||
|
"geschichte_editor_search_document": "Search document…",
|
||||||
|
"geschichte_editor_toolbar_bold": "Bold (Ctrl+B)",
|
||||||
|
"geschichte_editor_toolbar_italic": "Italic (Ctrl+I)",
|
||||||
|
"geschichte_editor_toolbar_h2": "Heading",
|
||||||
|
"geschichte_editor_toolbar_h3": "Subheading",
|
||||||
|
"geschichte_editor_toolbar_ul": "Bulleted list",
|
||||||
|
"geschichte_editor_toolbar_ol": "Numbered list",
|
||||||
|
|
||||||
|
"geschichte_delete_confirm_title": "Delete story?",
|
||||||
|
"geschichte_delete_confirm_body": "This action cannot be undone. The story will be permanently deleted and removed from all linked person and document pages.",
|
||||||
|
|
||||||
"error_relationship_not_found": "Relationship not found.",
|
"error_relationship_not_found": "Relationship not found.",
|
||||||
"error_circular_relationship": "This relationship would form a cycle.",
|
"error_circular_relationship": "This relationship would form a cycle.",
|
||||||
|
|||||||
@@ -228,6 +228,7 @@
|
|||||||
"admin_perm_read_all": "Solo lectura",
|
"admin_perm_read_all": "Solo lectura",
|
||||||
"admin_perm_annotate_all": "Leer y anotar",
|
"admin_perm_annotate_all": "Leer y anotar",
|
||||||
"admin_perm_write_all": "Leer y escribir",
|
"admin_perm_write_all": "Leer y escribir",
|
||||||
|
"admin_perm_blog_write": "Escribir historias",
|
||||||
"admin_perm_admin": "Acceso completo (Admin)",
|
"admin_perm_admin": "Acceso completo (Admin)",
|
||||||
"admin_perm_admin_user": "Gestionar usuarios",
|
"admin_perm_admin_user": "Gestionar usuarios",
|
||||||
"admin_perm_admin_tag": "Gestionar etiquetas",
|
"admin_perm_admin_tag": "Gestionar etiquetas",
|
||||||
@@ -919,6 +920,59 @@
|
|||||||
"bulk_edit_count_pill": "Se editarán {count}",
|
"bulk_edit_count_pill": "Se editarán {count}",
|
||||||
|
|
||||||
"nav_stammbaum": "Árbol genealógico",
|
"nav_stammbaum": "Árbol genealógico",
|
||||||
|
"nav_geschichten": "Historias",
|
||||||
|
|
||||||
|
"error_geschichte_not_found": "No se encontró la historia.",
|
||||||
|
|
||||||
|
"geschichten_index_title": "Historias",
|
||||||
|
"geschichten_new_button": "Nueva historia",
|
||||||
|
"geschichten_filter_all_pill": "Todas",
|
||||||
|
"geschichten_filter_choose_person": "Elegir persona",
|
||||||
|
"geschichten_filter_aria_label": "Filtrar por persona",
|
||||||
|
"geschichten_filter_remove_chip": "Quitar {name} del filtro",
|
||||||
|
"geschichten_filter_and_hint": "Solo se muestran las historias que incluyen a todas las personas seleccionadas.",
|
||||||
|
"geschichten_empty_for_person": "No hay historias para {name}.",
|
||||||
|
"geschichten_empty_for_persons": "No hay historias para {names}.",
|
||||||
|
"geschichten_empty_no_filter": "Aún no hay historias publicadas.",
|
||||||
|
"geschichten_back_to_index": "Volver a Historias",
|
||||||
|
"geschichten_published_on": "publicada el {date}",
|
||||||
|
"geschichten_persons_section": "Personas en esta historia",
|
||||||
|
"geschichten_documents_section": "Documentos mencionados",
|
||||||
|
"geschichten_card_heading": "Historias",
|
||||||
|
"geschichten_card_write_action": "+ Escribir historia",
|
||||||
|
"geschichten_card_attach_action": "+ Adjuntar historia",
|
||||||
|
"geschichten_card_show_all_for_person": "Todas las historias sobre {name}",
|
||||||
|
"geschichten_card_show_all": "Mostrar todas",
|
||||||
|
|
||||||
|
"geschichte_editor_title_placeholder": "Título de la historia",
|
||||||
|
"geschichte_editor_body_placeholder": "Escribe tu historia aquí…",
|
||||||
|
"geschichte_editor_status_draft": "BORRADOR",
|
||||||
|
"geschichte_editor_status_published": "PUBLICADA",
|
||||||
|
"geschichte_editor_status_draft_hint": "Aún no visible para lectores.",
|
||||||
|
"geschichte_editor_status_published_hint": "Visible para todos los lectores.",
|
||||||
|
"geschichte_editor_save_hint_draft": "Los cambios se guardan como borrador.",
|
||||||
|
"geschichte_editor_save_hint_published": "Los cambios se publican inmediatamente.",
|
||||||
|
"geschichte_editor_save_draft": "Guardar borrador",
|
||||||
|
"geschichte_editor_publish": "Publicar",
|
||||||
|
"geschichte_editor_save": "Guardar",
|
||||||
|
"geschichte_editor_unpublish": "Volver a borrador",
|
||||||
|
"geschichte_editor_title_required": "Por favor ingresa un título.",
|
||||||
|
"geschichte_editor_unsaved_changes": "Tienes cambios no guardados — ¿salir igualmente?",
|
||||||
|
"geschichte_editor_personen_heading": "Personas",
|
||||||
|
"geschichte_editor_personen_hint": "¿Qué personas históricas aparecen en esta historia?",
|
||||||
|
"geschichte_editor_dokumente_heading": "Documentos",
|
||||||
|
"geschichte_editor_dokumente_hint": "¿Qué cartas o documentos forman parte de esta historia?",
|
||||||
|
"geschichte_editor_search_person": "Buscar persona…",
|
||||||
|
"geschichte_editor_search_document": "Buscar documento…",
|
||||||
|
"geschichte_editor_toolbar_bold": "Negrita (Ctrl+B)",
|
||||||
|
"geschichte_editor_toolbar_italic": "Cursiva (Ctrl+I)",
|
||||||
|
"geschichte_editor_toolbar_h2": "Encabezado",
|
||||||
|
"geschichte_editor_toolbar_h3": "Subencabezado",
|
||||||
|
"geschichte_editor_toolbar_ul": "Lista con viñetas",
|
||||||
|
"geschichte_editor_toolbar_ol": "Lista numerada",
|
||||||
|
|
||||||
|
"geschichte_delete_confirm_title": "¿Eliminar historia?",
|
||||||
|
"geschichte_delete_confirm_body": "Esta acción no se puede deshacer. La historia se eliminará permanentemente y se quitará de todas las páginas de personas y documentos vinculados.",
|
||||||
|
|
||||||
"error_relationship_not_found": "La relación no fue encontrada.",
|
"error_relationship_not_found": "La relación no fue encontrada.",
|
||||||
"error_circular_relationship": "Esta relación crearía un ciclo.",
|
"error_circular_relationship": "Esta relación crearía un ciclo.",
|
||||||
|
|||||||
504
frontend/package-lock.json
generated
504
frontend/package-lock.json
generated
@@ -12,6 +12,7 @@
|
|||||||
"@tiptap/extension-mention": "3.22.5",
|
"@tiptap/extension-mention": "3.22.5",
|
||||||
"@tiptap/starter-kit": "3.22.5",
|
"@tiptap/starter-kit": "3.22.5",
|
||||||
"diff": "^8.0.3",
|
"diff": "^8.0.3",
|
||||||
|
"isomorphic-dompurify": "^3.12.0",
|
||||||
"openapi-fetch": "^0.13.5",
|
"openapi-fetch": "^0.13.5",
|
||||||
"pdfjs-dist": "^5.5.207"
|
"pdfjs-dist": "^5.5.207"
|
||||||
},
|
},
|
||||||
@@ -51,6 +52,53 @@
|
|||||||
"vitest-browser-svelte": "^2.0.1"
|
"vitest-browser-svelte": "^2.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@asamuzakjp/css-color": {
|
||||||
|
"version": "5.1.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz",
|
||||||
|
"integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@asamuzakjp/generational-cache": "^1.0.1",
|
||||||
|
"@csstools/css-calc": "^3.2.0",
|
||||||
|
"@csstools/css-color-parser": "^4.1.0",
|
||||||
|
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||||
|
"@csstools/css-tokenizer": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@asamuzakjp/dom-selector": {
|
||||||
|
"version": "7.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz",
|
||||||
|
"integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@asamuzakjp/generational-cache": "^1.0.1",
|
||||||
|
"@asamuzakjp/nwsapi": "^2.3.9",
|
||||||
|
"bidi-js": "^1.0.3",
|
||||||
|
"css-tree": "^3.2.1",
|
||||||
|
"is-potential-custom-element-name": "^1.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@asamuzakjp/generational-cache": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@asamuzakjp/nwsapi": {
|
||||||
|
"version": "2.3.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
|
||||||
|
"integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@axe-core/playwright": {
|
"node_modules/@axe-core/playwright": {
|
||||||
"version": "4.11.1",
|
"version": "4.11.1",
|
||||||
"resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.11.1.tgz",
|
"resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.11.1.tgz",
|
||||||
@@ -146,6 +194,152 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@bramus/specificity": {
|
||||||
|
"version": "2.4.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
|
||||||
|
"integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"css-tree": "^3.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"specificity": "bin/cli.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@csstools/color-helpers": {
|
||||||
|
"version": "6.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz",
|
||||||
|
"integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/csstools"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/csstools"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT-0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.19.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@csstools/css-calc": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/csstools"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/csstools"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.19.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||||
|
"@csstools/css-tokenizer": "^4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@csstools/css-color-parser": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/csstools"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/csstools"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@csstools/color-helpers": "^6.0.2",
|
||||||
|
"@csstools/css-calc": "^3.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.19.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||||
|
"@csstools/css-tokenizer": "^4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@csstools/css-parser-algorithms": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/csstools"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/csstools"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.19.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@csstools/css-tokenizer": "^4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@csstools/css-syntax-patches-for-csstree": {
|
||||||
|
"version": "1.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.3.tgz",
|
||||||
|
"integrity": "sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/csstools"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/csstools"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT-0",
|
||||||
|
"peerDependencies": {
|
||||||
|
"css-tree": "^3.2.1"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"css-tree": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@csstools/css-tokenizer": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/csstools"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/csstools"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.19.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@esbuild/aix-ppc64": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.27.4",
|
"version": "0.27.4",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz",
|
||||||
@@ -766,6 +960,23 @@
|
|||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@exodus/bytes": {
|
||||||
|
"version": "1.15.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz",
|
||||||
|
"integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@noble/hashes": "^1.8.0 || ^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@noble/hashes": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@humanfs/core": {
|
"node_modules/@humanfs/core": {
|
||||||
"version": "0.19.1",
|
"version": "0.19.1",
|
||||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
||||||
@@ -2655,7 +2866,7 @@
|
|||||||
"version": "2.0.7",
|
"version": "2.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
@@ -3307,6 +3518,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/bidi-js": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"require-from-string": "^2.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "1.1.12",
|
"version": "1.1.12",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||||
@@ -3495,6 +3715,19 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/css-tree": {
|
||||||
|
"version": "3.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
|
||||||
|
"integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"mdn-data": "2.27.1",
|
||||||
|
"source-map-js": "^1.2.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cssesc": {
|
"node_modules/cssesc": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||||
@@ -3508,6 +3741,19 @@
|
|||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/data-urls": {
|
||||||
|
"version": "7.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
|
||||||
|
"integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"whatwg-mimetype": "^5.0.0",
|
||||||
|
"whatwg-url": "^16.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
@@ -3526,6 +3772,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/decimal.js": {
|
||||||
|
"version": "10.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
|
||||||
|
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/dedent": {
|
"node_modules/dedent": {
|
||||||
"version": "1.5.1",
|
"version": "1.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.1.tgz",
|
||||||
@@ -3584,6 +3836,15 @@
|
|||||||
"node": ">=0.3.1"
|
"node": ">=0.3.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dompurify": {
|
||||||
|
"version": "3.4.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.2.tgz",
|
||||||
|
"integrity": "sha512-lHeS9SA/IKeIFFyYciHBr2n0v1VMPlSj843HdLOwjb2OxNwdq9Xykxqhk+FE42MzAdHvInbAolSE4mhahPpjXA==",
|
||||||
|
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@types/trusted-types": "^2.0.7"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/enhanced-resolve": {
|
"node_modules/enhanced-resolve": {
|
||||||
"version": "5.20.0",
|
"version": "5.20.0",
|
||||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz",
|
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz",
|
||||||
@@ -3598,6 +3859,18 @@
|
|||||||
"node": ">=10.13.0"
|
"node": ">=10.13.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/entities": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.19.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/es-module-lexer": {
|
"node_modules/es-module-lexer": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz",
|
||||||
@@ -4084,6 +4357,18 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/html-encoding-sniffer": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@exodus/bytes": "^1.6.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/html-escaper": {
|
"node_modules/html-escaper": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
|
||||||
@@ -4211,6 +4496,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/is-potential-custom-element-name": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/is-reference": {
|
"node_modules/is-reference": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz",
|
||||||
@@ -4228,6 +4519,19 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/isomorphic-dompurify": {
|
||||||
|
"version": "3.12.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/isomorphic-dompurify/-/isomorphic-dompurify-3.12.0.tgz",
|
||||||
|
"integrity": "sha512-8n+j+6ypTHvriJwFOQ2qusQ6bzGjZVcR3jbe1pBpLcGI1dn4WIl0ctLBngqE5QttquQBAlKXwJeTMw+X7x7qKw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"dompurify": "^3.4.2",
|
||||||
|
"jsdom": "^29.1.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || ^22.13.0 || >=24.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/istanbul-lib-coverage": {
|
"node_modules/istanbul-lib-coverage": {
|
||||||
"version": "3.2.2",
|
"version": "3.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
|
||||||
@@ -4314,6 +4618,46 @@
|
|||||||
"js-yaml": "bin/js-yaml.js"
|
"js-yaml": "bin/js-yaml.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jsdom": {
|
||||||
|
"version": "29.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz",
|
||||||
|
"integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@asamuzakjp/css-color": "^5.1.11",
|
||||||
|
"@asamuzakjp/dom-selector": "^7.1.1",
|
||||||
|
"@bramus/specificity": "^2.4.2",
|
||||||
|
"@csstools/css-syntax-patches-for-csstree": "^1.1.3",
|
||||||
|
"@exodus/bytes": "^1.15.0",
|
||||||
|
"css-tree": "^3.2.1",
|
||||||
|
"data-urls": "^7.0.0",
|
||||||
|
"decimal.js": "^10.6.0",
|
||||||
|
"html-encoding-sniffer": "^6.0.0",
|
||||||
|
"is-potential-custom-element-name": "^1.0.1",
|
||||||
|
"lru-cache": "^11.3.5",
|
||||||
|
"parse5": "^8.0.1",
|
||||||
|
"saxes": "^6.0.0",
|
||||||
|
"symbol-tree": "^3.2.4",
|
||||||
|
"tough-cookie": "^6.0.1",
|
||||||
|
"undici": "^7.25.0",
|
||||||
|
"w3c-xmlserializer": "^5.0.0",
|
||||||
|
"webidl-conversions": "^8.0.1",
|
||||||
|
"whatwg-mimetype": "^5.0.0",
|
||||||
|
"whatwg-url": "^16.0.1",
|
||||||
|
"xml-name-validator": "^5.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || ^22.13.0 || >=24.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"canvas": "^3.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"canvas": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/json-buffer": {
|
"node_modules/json-buffer": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
|
||||||
@@ -4706,6 +5050,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/lru-cache": {
|
||||||
|
"version": "11.3.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz",
|
||||||
|
"integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==",
|
||||||
|
"license": "BlueOak-1.0.0",
|
||||||
|
"engines": {
|
||||||
|
"node": "20 || >=22"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/magic-string": {
|
"node_modules/magic-string": {
|
||||||
"version": "0.30.21",
|
"version": "0.30.21",
|
||||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||||
@@ -4744,6 +5097,12 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/mdn-data": {
|
||||||
|
"version": "2.27.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
|
||||||
|
"integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
|
||||||
|
"license": "CC0-1.0"
|
||||||
|
},
|
||||||
"node_modules/mini-svg-data-uri": {
|
"node_modules/mini-svg-data-uri": {
|
||||||
"version": "1.4.4",
|
"version": "1.4.4",
|
||||||
"resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz",
|
"resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz",
|
||||||
@@ -4974,6 +5333,18 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/parse5": {
|
||||||
|
"version": "8.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
|
||||||
|
"integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"entities": "^8.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/path-exists": {
|
"node_modules/path-exists": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||||
@@ -5479,7 +5850,6 @@
|
|||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||||
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
@@ -5503,7 +5873,6 @@
|
|||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||||
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@@ -5604,6 +5973,18 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/saxes": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"xmlchars": "^2.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=v12.22.7"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/semver": {
|
"node_modules/semver": {
|
||||||
"version": "7.7.4",
|
"version": "7.7.4",
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||||
@@ -5673,7 +6054,6 @@
|
|||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||||
"dev": true,
|
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@@ -5851,6 +6231,12 @@
|
|||||||
"@types/estree": "^1.0.6"
|
"@types/estree": "^1.0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/symbol-tree": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tailwindcss": {
|
"node_modules/tailwindcss": {
|
||||||
"version": "4.2.1",
|
"version": "4.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz",
|
||||||
@@ -5916,6 +6302,24 @@
|
|||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tldts": {
|
||||||
|
"version": "7.0.30",
|
||||||
|
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.30.tgz",
|
||||||
|
"integrity": "sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tldts-core": "^7.0.30"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"tldts": "bin/cli.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tldts-core": {
|
||||||
|
"version": "7.0.30",
|
||||||
|
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.30.tgz",
|
||||||
|
"integrity": "sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/totalist": {
|
"node_modules/totalist": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
|
||||||
@@ -5926,6 +6330,30 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tough-cookie": {
|
||||||
|
"version": "6.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz",
|
||||||
|
"integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"tldts": "^7.0.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tr46": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"punycode": "^2.3.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ts-api-utils": {
|
"node_modules/ts-api-utils": {
|
||||||
"version": "2.4.0",
|
"version": "2.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz",
|
||||||
@@ -6003,6 +6431,15 @@
|
|||||||
"typescript": ">=4.8.4 <6.0.0"
|
"typescript": ">=4.8.4 <6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/undici": {
|
||||||
|
"version": "7.25.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz",
|
||||||
|
"integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.18.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/undici-types": {
|
"node_modules/undici-types": {
|
||||||
"version": "7.16.0",
|
"version": "7.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
||||||
@@ -6314,6 +6751,27 @@
|
|||||||
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/w3c-xmlserializer": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"xml-name-validator": "^5.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/webidl-conversions": {
|
||||||
|
"version": "8.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
|
||||||
|
"integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/webpack-virtual-modules": {
|
"node_modules/webpack-virtual-modules": {
|
||||||
"version": "0.6.2",
|
"version": "0.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
|
||||||
@@ -6321,6 +6779,29 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/whatwg-mimetype": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/whatwg-url": {
|
||||||
|
"version": "16.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
|
||||||
|
"integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@exodus/bytes": "^1.11.0",
|
||||||
|
"tr46": "^6.0.0",
|
||||||
|
"webidl-conversions": "^8.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/which": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
@@ -6386,6 +6867,21 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/xml-name-validator": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/xmlchars": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/yaml-ast-parser": {
|
"node_modules/yaml-ast-parser": {
|
||||||
"version": "0.0.43",
|
"version": "0.0.43",
|
||||||
"resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz",
|
"resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz",
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
"@tiptap/extension-mention": "3.22.5",
|
"@tiptap/extension-mention": "3.22.5",
|
||||||
"@tiptap/starter-kit": "3.22.5",
|
"@tiptap/starter-kit": "3.22.5",
|
||||||
"diff": "^8.0.3",
|
"diff": "^8.0.3",
|
||||||
|
"isomorphic-dompurify": "^3.12.0",
|
||||||
"openapi-fetch": "^0.13.5",
|
"openapi-fetch": "^0.13.5",
|
||||||
"pdfjs-dist": "^5.5.207"
|
"pdfjs-dist": "^5.5.207"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ import RelationshipPill from '$lib/components/RelationshipPill.svelte';
|
|||||||
|
|
||||||
type Person = { id: string; firstName?: string | null; lastName: string; displayName: string };
|
type Person = { id: string; firstName?: string | null; lastName: string; displayName: string };
|
||||||
type Tag = { id: string; name: string };
|
type Tag = { id: string; name: string };
|
||||||
|
type GeschichteSummary = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
publishedAt?: string;
|
||||||
|
author?: { firstName?: string; lastName?: string; email: string };
|
||||||
|
};
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
documentDate: string | null;
|
documentDate: string | null;
|
||||||
@@ -16,6 +22,9 @@ type Props = {
|
|||||||
receivers: Person[];
|
receivers: Person[];
|
||||||
tags: Tag[];
|
tags: Tag[];
|
||||||
inferredRelationship?: { labelFromA: string; labelFromB: string } | null;
|
inferredRelationship?: { labelFromA: string; labelFromB: string } | null;
|
||||||
|
geschichten?: GeschichteSummary[];
|
||||||
|
documentId?: string;
|
||||||
|
canBlogWrite?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -25,10 +34,30 @@ let {
|
|||||||
sender,
|
sender,
|
||||||
receivers,
|
receivers,
|
||||||
tags,
|
tags,
|
||||||
inferredRelationship = null
|
inferredRelationship = null,
|
||||||
|
geschichten = [],
|
||||||
|
documentId,
|
||||||
|
canBlogWrite = false
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
const VISIBLE_RECEIVER_LIMIT = 5;
|
const VISIBLE_RECEIVER_LIMIT = 5;
|
||||||
|
const VISIBLE_GESCHICHTEN_LIMIT = 3;
|
||||||
|
const showGeschichtenColumn = $derived(geschichten.length > 0 || canBlogWrite);
|
||||||
|
const visibleGeschichten = $derived(geschichten.slice(0, VISIBLE_GESCHICHTEN_LIMIT));
|
||||||
|
const hasGeschichtenOverflow = $derived(geschichten.length >= VISIBLE_GESCHICHTEN_LIMIT);
|
||||||
|
const gridClass = $derived(showGeschichtenColumn ? 'lg:grid-cols-4' : 'lg:grid-cols-3');
|
||||||
|
|
||||||
|
function formatGeschichteAuthor(g: GeschichteSummary): string {
|
||||||
|
const a = g.author;
|
||||||
|
if (!a) return '';
|
||||||
|
const full = [a.firstName, a.lastName].filter(Boolean).join(' ').trim();
|
||||||
|
return full || a.email || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatGeschichteDate(g: GeschichteSummary): string {
|
||||||
|
if (!g.publishedAt) return '';
|
||||||
|
return formatDate(g.publishedAt.slice(0, 10), 'short');
|
||||||
|
}
|
||||||
|
|
||||||
const formattedDate = $derived(documentDate ? formatDate(documentDate) : '—');
|
const formattedDate = $derived(documentDate ? formatDate(documentDate) : '—');
|
||||||
const displayLocation = $derived(location ?? '—');
|
const displayLocation = $derived(location ?? '—');
|
||||||
@@ -67,7 +96,7 @@ function getFullName(person: Person): string {
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
<div class="border-b border-line p-6">
|
<div class="border-b border-line p-6">
|
||||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
<div class="grid grid-cols-1 gap-6 {gridClass}">
|
||||||
<!-- Column 1: Details -->
|
<!-- Column 1: Details -->
|
||||||
<div>
|
<div>
|
||||||
<h2 class="mb-4 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
<h2 class="mb-4 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||||
@@ -159,5 +188,51 @@ function getFullName(person: Person): string {
|
|||||||
<p class="font-serif text-sm text-ink-3">{m.doc_details_no_tags()}</p>
|
<p class="font-serif text-sm text-ink-3">{m.doc_details_no_tags()}</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Column 4: Geschichten (visible when stories exist or user can author) -->
|
||||||
|
{#if showGeschichtenColumn}
|
||||||
|
<div>
|
||||||
|
<div class="mb-4 flex items-center justify-between">
|
||||||
|
<h2 class="font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||||
|
{m.geschichten_card_heading()}
|
||||||
|
</h2>
|
||||||
|
{#if canBlogWrite && documentId}
|
||||||
|
<a
|
||||||
|
href="/geschichten/new?documentId={documentId}"
|
||||||
|
class="font-sans text-xs font-medium text-ink-2 hover:text-ink"
|
||||||
|
>
|
||||||
|
{m.geschichten_card_attach_action()}
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if geschichten.length === 0}
|
||||||
|
<p class="font-serif text-sm text-ink-3">—</p>
|
||||||
|
{:else}
|
||||||
|
<ul class="space-y-2 font-serif text-sm">
|
||||||
|
{#each visibleGeschichten as g (g.id)}
|
||||||
|
<li>
|
||||||
|
<a href="/geschichten/{g.id}" class="block text-ink hover:underline">
|
||||||
|
{g.title}
|
||||||
|
</a>
|
||||||
|
<p class="font-sans text-xs text-ink-3">
|
||||||
|
{formatGeschichteAuthor(g)}
|
||||||
|
{#if formatGeschichteDate(g)}· {formatGeschichteDate(g)}{/if}
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{#if hasGeschichtenOverflow && documentId}
|
||||||
|
<a
|
||||||
|
href="/geschichten?documentId={documentId}"
|
||||||
|
class="mt-3 inline-flex font-sans text-xs font-medium text-ink hover:underline"
|
||||||
|
>
|
||||||
|
{m.geschichten_card_show_all()} →
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
147
frontend/src/lib/components/DocumentMultiSelect.svelte
Normal file
147
frontend/src/lib/components/DocumentMultiSelect.svelte
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { components } from '$lib/generated/api';
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import { clickOutside } from '$lib/actions/clickOutside';
|
||||||
|
import { formatDate } from '$lib/utils/date';
|
||||||
|
|
||||||
|
type Document = components['schemas']['Document'];
|
||||||
|
type DocumentSearchItem = components['schemas']['DocumentSearchItem'];
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
selectedDocuments?: Document[];
|
||||||
|
placeholder?: string;
|
||||||
|
hiddenInputName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
selectedDocuments = $bindable([]),
|
||||||
|
placeholder = m.geschichte_editor_search_document(),
|
||||||
|
hiddenInputName = 'documentIds'
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let searchTerm = $state('');
|
||||||
|
let results: Document[] = $state([]);
|
||||||
|
let showDropdown = $state(false);
|
||||||
|
let loading = $state(false);
|
||||||
|
let debounceTimer: ReturnType<typeof setTimeout>;
|
||||||
|
let inputEl: HTMLInputElement;
|
||||||
|
let dropdownStyle = $state('');
|
||||||
|
|
||||||
|
function updateDropdownPosition() {
|
||||||
|
if (!inputEl) return;
|
||||||
|
const rect = inputEl.getBoundingClientRect();
|
||||||
|
dropdownStyle = `position:fixed;top:${rect.bottom + 4}px;left:${rect.left}px;width:${rect.width}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleInput() {
|
||||||
|
showDropdown = true;
|
||||||
|
clearTimeout(debounceTimer);
|
||||||
|
debounceTimer = setTimeout(async () => {
|
||||||
|
if (searchTerm.length < 1) {
|
||||||
|
results = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loading = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/documents/search?q=${encodeURIComponent(searchTerm)}&size=10`);
|
||||||
|
if (res.ok) {
|
||||||
|
const body: { items: DocumentSearchItem[] } = await res.json();
|
||||||
|
const docs = body.items.map((it) => it.document);
|
||||||
|
results = docs.filter((d) => !selectedDocuments.some((s) => s.id === d.id));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
results = [];
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectDocument(doc: Document) {
|
||||||
|
selectedDocuments = [...selectedDocuments, doc];
|
||||||
|
searchTerm = '';
|
||||||
|
showDropdown = false;
|
||||||
|
results = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeDocument(id: string | undefined) {
|
||||||
|
selectedDocuments = selectedDocuments.filter((d) => d.id !== id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDocLabel(doc: Document): string {
|
||||||
|
if (doc.documentDate) return `${doc.title} · ${formatDate(doc.documentDate, 'short')}`;
|
||||||
|
return doc.title;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onscroll={updateDropdownPosition} onresize={updateDropdownPosition} />
|
||||||
|
|
||||||
|
{#each selectedDocuments as doc (doc.id)}
|
||||||
|
<input type="hidden" name={hiddenInputName} value={doc.id} />
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<div class="relative" use:clickOutside onclickoutside={() => (showDropdown = false)}>
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
{#each selectedDocuments as doc (doc.id)}
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center gap-1 rounded bg-muted px-2 py-1 text-sm font-medium text-ink"
|
||||||
|
>
|
||||||
|
{formatDocLabel(doc)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => removeDocument(doc.id)}
|
||||||
|
class="ml-0.5 text-ink/50 hover:text-red-500 focus:outline-none"
|
||||||
|
aria-label={m.comp_multiselect_remove()}
|
||||||
|
>
|
||||||
|
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<input
|
||||||
|
bind:this={inputEl}
|
||||||
|
type="text"
|
||||||
|
autocomplete="off"
|
||||||
|
bind:value={searchTerm}
|
||||||
|
oninput={handleInput}
|
||||||
|
onfocus={() => {
|
||||||
|
updateDropdownPosition();
|
||||||
|
showDropdown = true;
|
||||||
|
}}
|
||||||
|
placeholder={placeholder}
|
||||||
|
class="min-w-[120px] flex-1 border-none bg-transparent p-1 text-sm outline-none focus:ring-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if showDropdown && (results.length > 0 || loading)}
|
||||||
|
<div
|
||||||
|
style={dropdownStyle}
|
||||||
|
class="ring-opacity-5 z-50 max-h-60 overflow-auto rounded-md bg-surface py-1 text-base shadow-lg ring-1 ring-black sm:text-sm"
|
||||||
|
>
|
||||||
|
{#if loading}
|
||||||
|
<div class="p-2 text-sm text-ink-2">{m.comp_multiselect_loading()}</div>
|
||||||
|
{:else}
|
||||||
|
{#each results as doc (doc.id)}
|
||||||
|
<div
|
||||||
|
class="cursor-pointer py-2 pr-9 pl-3 text-ink select-none hover:bg-muted"
|
||||||
|
onclick={() => selectDocument(doc)}
|
||||||
|
onkeydown={(e) => e.key === 'Enter' && selectDocument(doc)}
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
{formatDocLabel(doc)}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
126
frontend/src/lib/components/DocumentMultiSelect.svelte.spec.ts
Normal file
126
frontend/src/lib/components/DocumentMultiSelect.svelte.spec.ts
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { page, userEvent } from 'vitest/browser';
|
||||||
|
import DocumentMultiSelect from './DocumentMultiSelect.svelte';
|
||||||
|
|
||||||
|
const waitForDebounce = () => new Promise((r) => setTimeout(r, 350));
|
||||||
|
|
||||||
|
const docFactory = (id: string, title: string, date = '1880-01-01') => ({
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
documentDate: date,
|
||||||
|
originalFilename: `${title}.pdf`,
|
||||||
|
status: 'UPLOADED',
|
||||||
|
metadataComplete: false,
|
||||||
|
scriptType: 'UNKNOWN' as const,
|
||||||
|
createdAt: '2024-01-01T00:00:00',
|
||||||
|
updatedAt: '2024-01-01T00:00:00'
|
||||||
|
});
|
||||||
|
|
||||||
|
function mockSearchResponse(items: ReturnType<typeof docFactory>[]) {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: vi.fn().mockResolvedValue({ items: items.map((document) => ({ document })) })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DocumentMultiSelect — rendering', () => {
|
||||||
|
it('renders an empty chip-input by default', async () => {
|
||||||
|
render(DocumentMultiSelect);
|
||||||
|
await expect.element(page.getByPlaceholder('Dokument suchen…')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders pre-selected documents as chips with their date', async () => {
|
||||||
|
render(DocumentMultiSelect, {
|
||||||
|
selectedDocuments: [docFactory('d1', 'Brief vom 1. Mai', '1882-05-01')]
|
||||||
|
});
|
||||||
|
await expect.element(page.getByText(/Brief vom 1\. Mai/)).toBeInTheDocument();
|
||||||
|
await expect.element(page.getByText(/01\.05\.1882/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits a hidden documentIds input for each pre-selected document', async () => {
|
||||||
|
render(DocumentMultiSelect, {
|
||||||
|
selectedDocuments: [docFactory('d1', 'A'), docFactory('d2', 'B')]
|
||||||
|
});
|
||||||
|
const inputs = document.querySelectorAll<HTMLInputElement>(
|
||||||
|
'input[type="hidden"][name="documentIds"]'
|
||||||
|
);
|
||||||
|
expect(inputs).toHaveLength(2);
|
||||||
|
expect([inputs[0].value, inputs[1].value].sort()).toEqual(['d1', 'd2']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DocumentMultiSelect — search and select', () => {
|
||||||
|
it('queries /api/documents/search after debounce and shows results', async () => {
|
||||||
|
mockSearchResponse([docFactory('d1', 'Brief von Eugenie')]);
|
||||||
|
render(DocumentMultiSelect);
|
||||||
|
|
||||||
|
await userEvent.fill(page.getByPlaceholder('Dokument suchen…'), 'Eug');
|
||||||
|
await waitForDebounce();
|
||||||
|
|
||||||
|
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||||
|
expect.stringMatching(/^\/api\/documents\/search\?q=Eug/)
|
||||||
|
);
|
||||||
|
await expect.element(page.getByText(/Brief von Eugenie/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds a chip when a search result is clicked', async () => {
|
||||||
|
mockSearchResponse([docFactory('d1', 'Brief von Eugenie')]);
|
||||||
|
render(DocumentMultiSelect);
|
||||||
|
|
||||||
|
await userEvent.fill(page.getByPlaceholder('Dokument suchen…'), 'Eug');
|
||||||
|
await waitForDebounce();
|
||||||
|
await userEvent.click(page.getByText(/Brief von Eugenie/));
|
||||||
|
|
||||||
|
// After selection the search field clears and the chip is rendered
|
||||||
|
const hidden = document.querySelector<HTMLInputElement>(
|
||||||
|
'input[type="hidden"][name="documentIds"]'
|
||||||
|
);
|
||||||
|
expect(hidden?.value).toBe('d1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides already-selected documents from new search results', async () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: vi.fn().mockResolvedValue({
|
||||||
|
items: [
|
||||||
|
{ document: docFactory('d1', 'Already attached') },
|
||||||
|
{ document: docFactory('d2', 'Not attached') }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
render(DocumentMultiSelect, {
|
||||||
|
selectedDocuments: [docFactory('d1', 'Already attached')]
|
||||||
|
});
|
||||||
|
|
||||||
|
await userEvent.fill(page.getByPlaceholder('Dokument suchen…'), 'attached');
|
||||||
|
await waitForDebounce();
|
||||||
|
|
||||||
|
// "Not attached" appears in the dropdown; "Already attached" only as the chip.
|
||||||
|
const matches = await page.getByText(/Already attached/).all();
|
||||||
|
expect(matches.length).toBe(1); // chip only, not in dropdown
|
||||||
|
await expect.element(page.getByText(/Not attached/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DocumentMultiSelect — remove', () => {
|
||||||
|
it('removes a chip when its × button is clicked', async () => {
|
||||||
|
render(DocumentMultiSelect, {
|
||||||
|
selectedDocuments: [docFactory('d1', 'Brief A')]
|
||||||
|
});
|
||||||
|
await userEvent.click(page.getByLabelText('Entfernen'));
|
||||||
|
expect(
|
||||||
|
document.querySelector<HTMLInputElement>('input[type="hidden"][name="documentIds"]')
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -25,12 +25,21 @@ type Doc = {
|
|||||||
tags?: Tag[] | null;
|
tags?: Tag[] | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type GeschichteSummary = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
publishedAt?: string;
|
||||||
|
author?: { firstName?: string; lastName?: string; email: string };
|
||||||
|
};
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
doc: Doc;
|
doc: Doc;
|
||||||
canWrite: boolean;
|
canWrite: boolean;
|
||||||
fileUrl: string;
|
fileUrl: string;
|
||||||
transcribeMode: boolean;
|
transcribeMode: boolean;
|
||||||
inferredRelationship?: { labelFromA: string; labelFromB: string } | null;
|
inferredRelationship?: { labelFromA: string; labelFromB: string } | null;
|
||||||
|
geschichten?: GeschichteSummary[];
|
||||||
|
canBlogWrite?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -38,7 +47,9 @@ let {
|
|||||||
canWrite,
|
canWrite,
|
||||||
fileUrl,
|
fileUrl,
|
||||||
transcribeMode = $bindable(),
|
transcribeMode = $bindable(),
|
||||||
inferredRelationship = null
|
inferredRelationship = null,
|
||||||
|
geschichten = [],
|
||||||
|
canBlogWrite = false
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let detailsOpen = $state(false);
|
let detailsOpen = $state(false);
|
||||||
@@ -283,6 +294,9 @@ let mobileMenuOpen = $state(false);
|
|||||||
receivers={doc.receivers ? [...doc.receivers] : []}
|
receivers={doc.receivers ? [...doc.receivers] : []}
|
||||||
tags={doc.tags ? [...doc.tags] : []}
|
tags={doc.tags ? [...doc.tags] : []}
|
||||||
inferredRelationship={inferredRelationship}
|
inferredRelationship={inferredRelationship}
|
||||||
|
geschichten={geschichten}
|
||||||
|
documentId={doc.id}
|
||||||
|
canBlogWrite={canBlogWrite}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
329
frontend/src/lib/components/GeschichteEditor.svelte
Normal file
329
frontend/src/lib/components/GeschichteEditor.svelte
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onDestroy, onMount } from 'svelte';
|
||||||
|
import { beforeNavigate } from '$app/navigation';
|
||||||
|
import { Editor } from '@tiptap/core';
|
||||||
|
import StarterKit from '@tiptap/starter-kit';
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import type { components } from '$lib/generated/api';
|
||||||
|
import PersonMultiSelect from './PersonMultiSelect.svelte';
|
||||||
|
import DocumentMultiSelect from './DocumentMultiSelect.svelte';
|
||||||
|
|
||||||
|
type Geschichte = components['schemas']['Geschichte'];
|
||||||
|
type Person = components['schemas']['Person'];
|
||||||
|
type Document = components['schemas']['Document'];
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
geschichte?: Geschichte | null;
|
||||||
|
initialPersons?: Person[];
|
||||||
|
initialDocuments?: Document[];
|
||||||
|
onSubmit: (payload: {
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
status: 'DRAFT' | 'PUBLISHED';
|
||||||
|
personIds: string[];
|
||||||
|
documentIds: string[];
|
||||||
|
}) => Promise<void>;
|
||||||
|
submitting?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
geschichte = null,
|
||||||
|
initialPersons = [],
|
||||||
|
initialDocuments = [],
|
||||||
|
onSubmit,
|
||||||
|
submitting = false
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
// Initial-state snapshot from incoming props. The editor owns these values
|
||||||
|
// after mount; the parent should re-mount the component with a different
|
||||||
|
// `geschichte` to reset (consistent with how form components in this codebase
|
||||||
|
// behave — see DocumentEdit page).
|
||||||
|
let title = $state(geschichte?.title ?? '');
|
||||||
|
let body = $state(geschichte?.body ?? '');
|
||||||
|
let status: 'DRAFT' | 'PUBLISHED' = $state(geschichte?.status ?? 'DRAFT');
|
||||||
|
let selectedPersons: Person[] = $state(
|
||||||
|
geschichte?.persons ? Array.from(geschichte.persons) : initialPersons
|
||||||
|
);
|
||||||
|
let selectedDocuments: Document[] = $state(
|
||||||
|
geschichte?.documents ? Array.from(geschichte.documents) : initialDocuments
|
||||||
|
);
|
||||||
|
|
||||||
|
let dirty = $state(false);
|
||||||
|
let titleTouched = $state(false);
|
||||||
|
|
||||||
|
const titleEmpty = $derived(title.trim().length === 0);
|
||||||
|
const isDraft = $derived(status === 'DRAFT');
|
||||||
|
const showTitleError = $derived(titleEmpty && titleTouched);
|
||||||
|
|
||||||
|
let editorEl: HTMLDivElement;
|
||||||
|
let editor: Editor | null = null;
|
||||||
|
let toolbarVersion = $state(0);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
editor = new Editor({
|
||||||
|
element: editorEl,
|
||||||
|
content: body,
|
||||||
|
extensions: [
|
||||||
|
StarterKit.configure({
|
||||||
|
heading: { levels: [2, 3] },
|
||||||
|
code: false,
|
||||||
|
codeBlock: false,
|
||||||
|
blockquote: false,
|
||||||
|
strike: false,
|
||||||
|
horizontalRule: false,
|
||||||
|
hardBreak: false
|
||||||
|
})
|
||||||
|
],
|
||||||
|
editorProps: {
|
||||||
|
attributes: {
|
||||||
|
role: 'textbox',
|
||||||
|
'aria-multiline': 'true',
|
||||||
|
'aria-label': m.geschichte_editor_body_placeholder(),
|
||||||
|
class:
|
||||||
|
'prose max-w-none focus:outline-none min-h-[260px] font-serif text-base leading-relaxed'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onUpdate({ editor: ed }) {
|
||||||
|
body = ed.getHTML();
|
||||||
|
dirty = true;
|
||||||
|
},
|
||||||
|
onSelectionUpdate() {
|
||||||
|
toolbarVersion++;
|
||||||
|
},
|
||||||
|
onTransaction() {
|
||||||
|
toolbarVersion++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
editor?.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeNavigate(({ cancel }) => {
|
||||||
|
if (dirty && !submitting) {
|
||||||
|
const ok = window.confirm(m.geschichte_editor_unsaved_changes());
|
||||||
|
if (!ok) cancel();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleTitleBlur() {
|
||||||
|
titleTouched = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTitleInput() {
|
||||||
|
dirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save(nextStatus: 'DRAFT' | 'PUBLISHED') {
|
||||||
|
titleTouched = true;
|
||||||
|
if (titleEmpty) return;
|
||||||
|
await onSubmit({
|
||||||
|
title: title.trim(),
|
||||||
|
body,
|
||||||
|
status: nextStatus,
|
||||||
|
personIds: selectedPersons.map((p) => p.id!).filter(Boolean),
|
||||||
|
documentIds: selectedDocuments.map((d) => d.id!).filter(Boolean)
|
||||||
|
});
|
||||||
|
dirty = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isActive(name: string, attrs?: Record<string, unknown>): boolean {
|
||||||
|
void toolbarVersion;
|
||||||
|
return editor?.isActive(name, attrs) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function exec(action: () => void) {
|
||||||
|
if (!editor) return;
|
||||||
|
action();
|
||||||
|
editor.commands.focus();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-6 lg:grid-cols-[2fr_1fr]">
|
||||||
|
<!-- Editor column -->
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<!-- Title -->
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={title}
|
||||||
|
oninput={handleTitleInput}
|
||||||
|
onblur={handleTitleBlur}
|
||||||
|
placeholder={m.geschichte_editor_title_placeholder()}
|
||||||
|
aria-invalid={showTitleError}
|
||||||
|
aria-describedby={showTitleError ? 'title-error' : undefined}
|
||||||
|
class="block w-full rounded border border-line bg-surface px-3 py-3 font-serif text-2xl font-bold text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||||
|
/>
|
||||||
|
{#if showTitleError}
|
||||||
|
<p id="title-error" class="mt-1 text-sm text-danger" role="alert">
|
||||||
|
{m.geschichte_editor_title_required()}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Toolbar -->
|
||||||
|
<div
|
||||||
|
role="toolbar"
|
||||||
|
aria-label="Formatierung"
|
||||||
|
class="flex flex-wrap items-center gap-1 rounded border border-line bg-surface p-1"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => exec(() => editor!.chain().focus().toggleBold().run())}
|
||||||
|
aria-label={m.geschichte_editor_toolbar_bold()}
|
||||||
|
aria-pressed={isActive('bold')}
|
||||||
|
title={m.geschichte_editor_toolbar_bold()}
|
||||||
|
class="inline-flex h-11 min-w-[44px] items-center justify-center rounded px-2 font-bold text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring aria-pressed:bg-accent-bg"
|
||||||
|
>
|
||||||
|
B
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => exec(() => editor!.chain().focus().toggleItalic().run())}
|
||||||
|
aria-label={m.geschichte_editor_toolbar_italic()}
|
||||||
|
aria-pressed={isActive('italic')}
|
||||||
|
title={m.geschichte_editor_toolbar_italic()}
|
||||||
|
class="inline-flex h-11 min-w-[44px] items-center justify-center rounded px-2 text-ink italic hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring aria-pressed:bg-accent-bg"
|
||||||
|
>
|
||||||
|
I
|
||||||
|
</button>
|
||||||
|
<span class="mx-1 h-6 w-px bg-line" aria-hidden="true"></span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => exec(() => editor!.chain().focus().toggleHeading({ level: 2 }).run())}
|
||||||
|
aria-label={m.geschichte_editor_toolbar_h2()}
|
||||||
|
aria-pressed={isActive('heading', { level: 2 })}
|
||||||
|
title={m.geschichte_editor_toolbar_h2()}
|
||||||
|
class="inline-flex h-11 min-w-[44px] items-center justify-center rounded px-2 text-sm font-bold text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring aria-pressed:bg-accent-bg"
|
||||||
|
>
|
||||||
|
H2
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => exec(() => editor!.chain().focus().toggleHeading({ level: 3 }).run())}
|
||||||
|
aria-label={m.geschichte_editor_toolbar_h3()}
|
||||||
|
aria-pressed={isActive('heading', { level: 3 })}
|
||||||
|
title={m.geschichte_editor_toolbar_h3()}
|
||||||
|
class="inline-flex h-11 min-w-[44px] items-center justify-center rounded px-2 text-sm font-bold text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring aria-pressed:bg-accent-bg"
|
||||||
|
>
|
||||||
|
H3
|
||||||
|
</button>
|
||||||
|
<span class="mx-1 h-6 w-px bg-line" aria-hidden="true"></span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => exec(() => editor!.chain().focus().toggleBulletList().run())}
|
||||||
|
aria-label={m.geschichte_editor_toolbar_ul()}
|
||||||
|
aria-pressed={isActive('bulletList')}
|
||||||
|
title={m.geschichte_editor_toolbar_ul()}
|
||||||
|
class="inline-flex h-11 min-w-[44px] items-center justify-center rounded px-2 text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring aria-pressed:bg-accent-bg"
|
||||||
|
>
|
||||||
|
•
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => exec(() => editor!.chain().focus().toggleOrderedList().run())}
|
||||||
|
aria-label={m.geschichte_editor_toolbar_ol()}
|
||||||
|
aria-pressed={isActive('orderedList')}
|
||||||
|
title={m.geschichte_editor_toolbar_ol()}
|
||||||
|
class="inline-flex h-11 min-w-[44px] items-center justify-center rounded px-2 text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring aria-pressed:bg-accent-bg"
|
||||||
|
>
|
||||||
|
1.
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Editor surface -->
|
||||||
|
<div
|
||||||
|
class="rounded border border-line bg-surface p-4 shadow-sm focus-within:ring-2 focus-within:ring-focus-ring"
|
||||||
|
>
|
||||||
|
<div bind:this={editorEl} class="min-h-[260px]"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<aside class="flex flex-col gap-6">
|
||||||
|
<section class="rounded border border-line bg-surface p-4 shadow-sm">
|
||||||
|
<h2 class="mb-1 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">Status</h2>
|
||||||
|
<p class="mb-3">
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center rounded px-2 py-1 font-sans text-xs font-bold tracking-widest uppercase {isDraft
|
||||||
|
? 'bg-muted text-ink-2'
|
||||||
|
: 'bg-accent-bg text-ink'}"
|
||||||
|
>
|
||||||
|
{isDraft
|
||||||
|
? m.geschichte_editor_status_draft()
|
||||||
|
: m.geschichte_editor_status_published()}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<p class="font-sans text-xs text-ink-3">
|
||||||
|
{isDraft
|
||||||
|
? m.geschichte_editor_status_draft_hint()
|
||||||
|
: m.geschichte_editor_status_published_hint()}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="rounded border border-line bg-surface p-4 shadow-sm">
|
||||||
|
<h2 class="mb-2 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||||
|
{m.geschichte_editor_personen_heading()}
|
||||||
|
</h2>
|
||||||
|
<p class="mb-3 font-sans text-xs text-ink-3">{m.geschichte_editor_personen_hint()}</p>
|
||||||
|
<PersonMultiSelect bind:selectedPersons={selectedPersons} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="rounded border border-line bg-surface p-4 shadow-sm">
|
||||||
|
<h2 class="mb-2 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||||
|
{m.geschichte_editor_dokumente_heading()}
|
||||||
|
</h2>
|
||||||
|
<p class="mb-3 font-sans text-xs text-ink-3">{m.geschichte_editor_dokumente_hint()}</p>
|
||||||
|
<DocumentMultiSelect bind:selectedDocuments={selectedDocuments} />
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Save bar -->
|
||||||
|
<div
|
||||||
|
class="sticky bottom-0 z-10 -mx-4 mt-6 flex flex-col gap-3 border-t border-line bg-surface px-6 py-4 shadow-[0_-2px_8px_rgba(0,0,0,0.06)] sm:flex-row sm:items-center sm:justify-between"
|
||||||
|
>
|
||||||
|
<p class="font-sans text-xs text-ink-3">
|
||||||
|
{isDraft
|
||||||
|
? m.geschichte_editor_save_hint_draft()
|
||||||
|
: m.geschichte_editor_save_hint_published()}
|
||||||
|
</p>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
{#if isDraft}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => save('DRAFT')}
|
||||||
|
disabled={submitting || titleEmpty}
|
||||||
|
class="inline-flex h-11 items-center rounded border border-line bg-surface px-4 font-sans text-sm font-medium text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{m.geschichte_editor_save_draft()}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => save('PUBLISHED')}
|
||||||
|
disabled={submitting || titleEmpty}
|
||||||
|
class="inline-flex h-11 items-center rounded bg-primary px-4 font-sans text-sm font-medium text-primary-fg hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{m.geschichte_editor_publish()}
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => save('DRAFT')}
|
||||||
|
disabled={submitting}
|
||||||
|
class="inline-flex h-11 items-center rounded border border-line bg-surface px-4 font-sans text-sm font-medium text-amber-700 hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{m.geschichte_editor_unpublish()}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => save('PUBLISHED')}
|
||||||
|
disabled={submitting || titleEmpty}
|
||||||
|
class="inline-flex h-11 items-center rounded bg-primary px-4 font-sans text-sm font-medium text-primary-fg hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{m.geschichte_editor_save()}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
150
frontend/src/lib/components/GeschichteEditor.svelte.spec.ts
Normal file
150
frontend/src/lib/components/GeschichteEditor.svelte.spec.ts
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { page, userEvent } from 'vitest/browser';
|
||||||
|
import GeschichteEditor from './GeschichteEditor.svelte';
|
||||||
|
|
||||||
|
const personFactory = (id: string, displayName: string) => ({
|
||||||
|
id,
|
||||||
|
firstName: displayName.split(' ')[0],
|
||||||
|
lastName: displayName.split(' ').slice(1).join(' ') || displayName,
|
||||||
|
displayName,
|
||||||
|
personType: 'PERSON' as const
|
||||||
|
});
|
||||||
|
|
||||||
|
const docFactory = (id: string, title: string, date = '1882-01-01') => ({
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
documentDate: date,
|
||||||
|
originalFilename: `${title}.pdf`,
|
||||||
|
status: 'UPLOADED' as const,
|
||||||
|
metadataComplete: false,
|
||||||
|
scriptType: 'UNKNOWN' as const,
|
||||||
|
createdAt: '2024-01-01T00:00:00',
|
||||||
|
updatedAt: '2024-01-01T00:00:00'
|
||||||
|
});
|
||||||
|
|
||||||
|
const draftFactory = (overrides: Record<string, unknown> = {}) => ({
|
||||||
|
id: 'g1',
|
||||||
|
title: 'Existing draft',
|
||||||
|
body: '<p>Hello world</p>',
|
||||||
|
status: 'DRAFT' as const,
|
||||||
|
persons: [],
|
||||||
|
documents: [],
|
||||||
|
createdAt: '2024-01-01T00:00:00',
|
||||||
|
updatedAt: '2024-01-01T00:00:00',
|
||||||
|
...overrides
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => cleanup());
|
||||||
|
|
||||||
|
describe('GeschichteEditor — title-required guard', () => {
|
||||||
|
it('disables both DRAFT save buttons when the title is empty', async () => {
|
||||||
|
const onSubmit = vi.fn().mockResolvedValue(undefined);
|
||||||
|
render(GeschichteEditor, { onSubmit });
|
||||||
|
|
||||||
|
const draft = await page.getByRole('button', { name: 'Entwurf speichern' }).element();
|
||||||
|
const publish = await page.getByRole('button', { name: 'Veröffentlichen' }).element();
|
||||||
|
expect(draft).toHaveProperty('disabled', true);
|
||||||
|
expect(publish).toHaveProperty('disabled', true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the inline error after the title field is blurred while empty', async () => {
|
||||||
|
const onSubmit = vi.fn();
|
||||||
|
render(GeschichteEditor, { onSubmit });
|
||||||
|
|
||||||
|
await userEvent.click(page.getByPlaceholder('Titel der Geschichte'));
|
||||||
|
await userEvent.tab(); // blur
|
||||||
|
await expect.element(page.getByText('Bitte gib einen Titel ein.')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GeschichteEditor — save bar adapts to status', () => {
|
||||||
|
it('renders DRAFT mode buttons when no geschichte prop is supplied', async () => {
|
||||||
|
render(GeschichteEditor, { onSubmit: vi.fn() });
|
||||||
|
await expect
|
||||||
|
.element(page.getByRole('button', { name: 'Entwurf speichern' }))
|
||||||
|
.toBeInTheDocument();
|
||||||
|
await expect.element(page.getByRole('button', { name: 'Veröffentlichen' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders PUBLISHED mode buttons when geschichte.status is PUBLISHED', async () => {
|
||||||
|
render(GeschichteEditor, {
|
||||||
|
geschichte: draftFactory({ status: 'PUBLISHED', publishedAt: '2024-04-01T12:00:00' }),
|
||||||
|
onSubmit: vi.fn()
|
||||||
|
});
|
||||||
|
await expect.element(page.getByRole('button', { name: 'Speichern' })).toBeInTheDocument();
|
||||||
|
await expect
|
||||||
|
.element(page.getByRole('button', { name: 'Zurück zu Entwurf' }))
|
||||||
|
.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GeschichteEditor — pre-fill', () => {
|
||||||
|
it('renders initial persons as chips', async () => {
|
||||||
|
render(GeschichteEditor, {
|
||||||
|
initialPersons: [personFactory('p1', 'Franz Raddatz')],
|
||||||
|
onSubmit: vi.fn()
|
||||||
|
});
|
||||||
|
await expect.element(page.getByText('Franz Raddatz')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders initial documents as chips', async () => {
|
||||||
|
render(GeschichteEditor, {
|
||||||
|
initialDocuments: [docFactory('d1', 'Brief von Eugenie')],
|
||||||
|
onSubmit: vi.fn()
|
||||||
|
});
|
||||||
|
await expect.element(page.getByText(/Brief von Eugenie/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('populates the title input from a geschichte prop', async () => {
|
||||||
|
render(GeschichteEditor, {
|
||||||
|
geschichte: draftFactory({ title: 'My existing story' }),
|
||||||
|
onSubmit: vi.fn()
|
||||||
|
});
|
||||||
|
const input = await page.getByPlaceholder('Titel der Geschichte').element();
|
||||||
|
expect((input as HTMLInputElement).value).toBe('My existing story');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GeschichteEditor — onSubmit payload', () => {
|
||||||
|
it('passes the trimmed title and DRAFT status when "Entwurf speichern" is clicked', async () => {
|
||||||
|
const onSubmit = vi.fn().mockResolvedValue(undefined);
|
||||||
|
render(GeschichteEditor, { onSubmit });
|
||||||
|
|
||||||
|
await userEvent.fill(page.getByPlaceholder('Titel der Geschichte'), ' My title ');
|
||||||
|
await userEvent.click(page.getByRole('button', { name: 'Entwurf speichern' }));
|
||||||
|
|
||||||
|
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||||
|
const payload = onSubmit.mock.calls[0][0];
|
||||||
|
expect(payload.title).toBe('My title');
|
||||||
|
expect(payload.status).toBe('DRAFT');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes status=PUBLISHED when "Veröffentlichen" is clicked', async () => {
|
||||||
|
const onSubmit = vi.fn().mockResolvedValue(undefined);
|
||||||
|
render(GeschichteEditor, { onSubmit });
|
||||||
|
|
||||||
|
await userEvent.fill(page.getByPlaceholder('Titel der Geschichte'), 'Story');
|
||||||
|
await userEvent.click(page.getByRole('button', { name: 'Veröffentlichen' }));
|
||||||
|
|
||||||
|
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onSubmit.mock.calls[0][0].status).toBe('PUBLISHED');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes the personIds and documentIds from initial props through onSubmit', async () => {
|
||||||
|
const onSubmit = vi.fn().mockResolvedValue(undefined);
|
||||||
|
render(GeschichteEditor, {
|
||||||
|
initialPersons: [personFactory('p1', 'Franz Raddatz')],
|
||||||
|
initialDocuments: [docFactory('d1', 'Brief A')],
|
||||||
|
onSubmit
|
||||||
|
});
|
||||||
|
|
||||||
|
await userEvent.fill(page.getByPlaceholder('Titel der Geschichte'), 'Story');
|
||||||
|
await userEvent.click(page.getByRole('button', { name: 'Entwurf speichern' }));
|
||||||
|
|
||||||
|
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||||
|
const payload = onSubmit.mock.calls[0][0];
|
||||||
|
expect(payload.personIds).toEqual(['p1']);
|
||||||
|
expect(payload.documentIds).toEqual(['d1']);
|
||||||
|
});
|
||||||
|
});
|
||||||
89
frontend/src/lib/components/GeschichtenCard.svelte
Normal file
89
frontend/src/lib/components/GeschichtenCard.svelte
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import type { components } from '$lib/generated/api';
|
||||||
|
import { plainExcerpt } from '$lib/utils/extractText';
|
||||||
|
import { formatDate } from '$lib/utils/date';
|
||||||
|
|
||||||
|
type Geschichte = components['schemas']['Geschichte'];
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
geschichten: Geschichte[];
|
||||||
|
personId: string;
|
||||||
|
personName: string;
|
||||||
|
canWrite: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { geschichten, personId, personName, canWrite }: Props = $props();
|
||||||
|
|
||||||
|
const visible = $derived(geschichten.slice(0, 3));
|
||||||
|
const hasOverflow = $derived(geschichten.length >= 3);
|
||||||
|
|
||||||
|
function formatPublishedDate(g: Geschichte): string | null {
|
||||||
|
if (!g.publishedAt) return null;
|
||||||
|
return formatDate(g.publishedAt.slice(0, 10), 'short');
|
||||||
|
}
|
||||||
|
|
||||||
|
function authorName(g: Geschichte): string {
|
||||||
|
const a = g.author;
|
||||||
|
if (!a) return '';
|
||||||
|
const full = [a.firstName, a.lastName].filter(Boolean).join(' ').trim();
|
||||||
|
return full || a.email || '';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if geschichten.length > 0}
|
||||||
|
<section
|
||||||
|
aria-labelledby="geschichten-card-heading"
|
||||||
|
class="rounded-sm border border-line bg-surface p-6 shadow-sm"
|
||||||
|
>
|
||||||
|
<header class="mb-5 flex items-center justify-between">
|
||||||
|
<h2
|
||||||
|
id="geschichten-card-heading"
|
||||||
|
class="font-sans text-xs font-bold tracking-widest text-ink-2 uppercase"
|
||||||
|
>
|
||||||
|
{m.geschichten_card_heading()}
|
||||||
|
</h2>
|
||||||
|
{#if canWrite}
|
||||||
|
<a
|
||||||
|
href="/geschichten/new?personId={personId}"
|
||||||
|
class="inline-flex items-center font-sans text-sm font-medium text-ink-2 hover:text-ink"
|
||||||
|
>
|
||||||
|
{m.geschichten_card_write_action()}
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<ul class="-mx-2">
|
||||||
|
{#each visible as g (g.id)}
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="/geschichten/{g.id}"
|
||||||
|
class="group flex flex-col gap-1 border-b border-line px-2 py-3 transition-colors last:border-b-0 hover:bg-muted"
|
||||||
|
>
|
||||||
|
<span class="font-serif text-base font-bold text-ink group-hover:underline">
|
||||||
|
{g.title}
|
||||||
|
</span>
|
||||||
|
<span class="font-sans text-xs text-ink-3">
|
||||||
|
{authorName(g)}
|
||||||
|
{#if formatPublishedDate(g)}· {formatPublishedDate(g)}{/if}
|
||||||
|
</span>
|
||||||
|
{#if g.body}
|
||||||
|
<span class="font-serif text-sm text-ink-2">{plainExcerpt(g.body, 80)}</span>
|
||||||
|
{/if}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{#if hasOverflow}
|
||||||
|
<footer class="mt-4 border-t border-line pt-3">
|
||||||
|
<a
|
||||||
|
href="/geschichten?personId={personId}"
|
||||||
|
class="inline-flex items-center font-sans text-sm font-medium text-ink hover:underline"
|
||||||
|
>
|
||||||
|
{m.geschichten_card_show_all_for_person({ name: personName })} →
|
||||||
|
</a>
|
||||||
|
</footer>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
140
frontend/src/lib/components/GeschichtenCard.svelte.spec.ts
Normal file
140
frontend/src/lib/components/GeschichtenCard.svelte.spec.ts
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { page } from 'vitest/browser';
|
||||||
|
import GeschichtenCard from './GeschichtenCard.svelte';
|
||||||
|
|
||||||
|
const makeStory = (id: string, title: string, body: string | null = '<p>Body</p>') => ({
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
status: 'PUBLISHED' as const,
|
||||||
|
publishedAt: '2024-04-01T12:00:00',
|
||||||
|
createdAt: '2024-03-01T12:00:00',
|
||||||
|
updatedAt: '2024-04-01T12:00:00',
|
||||||
|
persons: [],
|
||||||
|
documents: [],
|
||||||
|
author: {
|
||||||
|
id: 'u1',
|
||||||
|
email: 'marcel@example.com',
|
||||||
|
firstName: 'Marcel',
|
||||||
|
lastName: 'Raddatz',
|
||||||
|
enabled: true,
|
||||||
|
notifyOnReply: false,
|
||||||
|
notifyOnMention: false,
|
||||||
|
groups: [],
|
||||||
|
createdAt: '2024-01-01T00:00:00',
|
||||||
|
color: '#000'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => cleanup());
|
||||||
|
|
||||||
|
describe('GeschichtenCard', () => {
|
||||||
|
it('renders nothing when geschichten is empty', async () => {
|
||||||
|
render(GeschichtenCard, {
|
||||||
|
geschichten: [],
|
||||||
|
personId: 'p1',
|
||||||
|
personName: 'Franz',
|
||||||
|
canWrite: true
|
||||||
|
});
|
||||||
|
// No heading, no list — the entire <section> should not exist
|
||||||
|
expect(
|
||||||
|
document.querySelector('section[aria-labelledby="geschichten-card-heading"]')
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the section heading and stories when geschichten is non-empty', async () => {
|
||||||
|
render(GeschichtenCard, {
|
||||||
|
geschichten: [makeStory('g1', 'Erinnerung an Franz')],
|
||||||
|
personId: 'p1',
|
||||||
|
personName: 'Franz',
|
||||||
|
canWrite: false
|
||||||
|
});
|
||||||
|
await expect.element(page.getByText('Geschichten')).toBeInTheDocument();
|
||||||
|
// The whole row is one link to the story; matching on the title text via
|
||||||
|
// a partial regex tolerates trailing author/date metadata in the
|
||||||
|
// accessible name.
|
||||||
|
const link = await page
|
||||||
|
.getByRole('link', { name: /Erinnerung an Franz/ })
|
||||||
|
.first()
|
||||||
|
.element();
|
||||||
|
expect(link.getAttribute('href')).toBe('/geschichten/g1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('makes the entire story row a single clickable link', async () => {
|
||||||
|
render(GeschichtenCard, {
|
||||||
|
geschichten: [makeStory('g1', 'A title', '<p>Some body excerpt text</p>')],
|
||||||
|
personId: 'p1',
|
||||||
|
personName: 'Franz',
|
||||||
|
canWrite: false
|
||||||
|
});
|
||||||
|
// The body-excerpt text is inside the same <a> as the title.
|
||||||
|
const links = await page.getByRole('link', { name: /A title/ }).all();
|
||||||
|
expect(links.length).toBeGreaterThan(0);
|
||||||
|
const linkEl = await links[0].element();
|
||||||
|
expect(linkEl.tagName).toBe('A');
|
||||||
|
expect(linkEl.textContent).toContain('Some body excerpt text');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides the "+ Geschichte schreiben" link when canWrite is false', async () => {
|
||||||
|
render(GeschichtenCard, {
|
||||||
|
geschichten: [makeStory('g1', 'A story')],
|
||||||
|
personId: 'p1',
|
||||||
|
personName: 'Franz',
|
||||||
|
canWrite: false
|
||||||
|
});
|
||||||
|
const writeLinks = await page.getByText(/Geschichte schreiben/).all();
|
||||||
|
expect(writeLinks).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the write-action link only when canWrite is true', async () => {
|
||||||
|
render(GeschichtenCard, {
|
||||||
|
geschichten: [makeStory('g1', 'A story')],
|
||||||
|
personId: 'p1',
|
||||||
|
personName: 'Franz',
|
||||||
|
canWrite: true
|
||||||
|
});
|
||||||
|
const link = await page.getByRole('link', { name: /Geschichte schreiben/ }).element();
|
||||||
|
expect(link.getAttribute('href')).toBe('/geschichten/new?personId=p1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides the "Alle Geschichten zu …" footer link below the 3-story threshold', async () => {
|
||||||
|
render(GeschichtenCard, {
|
||||||
|
geschichten: [makeStory('g1', 'A'), makeStory('g2', 'B')],
|
||||||
|
personId: 'p1',
|
||||||
|
personName: 'Franz',
|
||||||
|
canWrite: false
|
||||||
|
});
|
||||||
|
const overflow = await page.getByText(/Alle Geschichten zu/).all();
|
||||||
|
expect(overflow).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the footer link at the 3-story threshold (>= 3)', async () => {
|
||||||
|
render(GeschichtenCard, {
|
||||||
|
geschichten: [makeStory('g1', 'A'), makeStory('g2', 'B'), makeStory('g3', 'C')],
|
||||||
|
personId: 'p1',
|
||||||
|
personName: 'Franz',
|
||||||
|
canWrite: false
|
||||||
|
});
|
||||||
|
const link = await page.getByRole('link', { name: /Alle Geschichten zu Franz/ }).element();
|
||||||
|
expect(link.getAttribute('href')).toBe('/geschichten?personId=p1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a plain-text excerpt without HTML markup', async () => {
|
||||||
|
render(GeschichtenCard, {
|
||||||
|
geschichten: [
|
||||||
|
makeStory(
|
||||||
|
'g1',
|
||||||
|
'Mit HTML',
|
||||||
|
'<p>Plain <strong>bold</strong> story</p><script>alert(1)</script>'
|
||||||
|
)
|
||||||
|
],
|
||||||
|
personId: 'p1',
|
||||||
|
personName: 'Franz',
|
||||||
|
canWrite: false
|
||||||
|
});
|
||||||
|
// Body excerpt appears once as plain text — no <strong> rendered, no script
|
||||||
|
await expect.element(page.getByText(/Plain bold story/)).toBeInTheDocument();
|
||||||
|
expect(document.body.innerHTML).not.toContain('<script>');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -41,6 +41,7 @@ export type ErrorCode =
|
|||||||
| 'RELATIONSHIP_NOT_FOUND'
|
| 'RELATIONSHIP_NOT_FOUND'
|
||||||
| 'CIRCULAR_RELATIONSHIP'
|
| 'CIRCULAR_RELATIONSHIP'
|
||||||
| 'DUPLICATE_RELATIONSHIP'
|
| 'DUPLICATE_RELATIONSHIP'
|
||||||
|
| 'GESCHICHTE_NOT_FOUND'
|
||||||
| 'MISSING_CREDENTIALS'
|
| 'MISSING_CREDENTIALS'
|
||||||
| 'UNAUTHORIZED'
|
| 'UNAUTHORIZED'
|
||||||
| 'FORBIDDEN'
|
| 'FORBIDDEN'
|
||||||
@@ -145,6 +146,8 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string {
|
|||||||
return m.error_circular_relationship();
|
return m.error_circular_relationship();
|
||||||
case 'DUPLICATE_RELATIONSHIP':
|
case 'DUPLICATE_RELATIONSHIP':
|
||||||
return m.error_duplicate_relationship();
|
return m.error_duplicate_relationship();
|
||||||
|
case 'GESCHICHTE_NOT_FOUND':
|
||||||
|
return m.error_geschichte_not_found();
|
||||||
case 'MISSING_CREDENTIALS':
|
case 'MISSING_CREDENTIALS':
|
||||||
return m.login_error_missing_credentials();
|
return m.login_error_missing_credentials();
|
||||||
case 'UNAUTHORIZED':
|
case 'UNAUTHORIZED':
|
||||||
|
|||||||
@@ -388,6 +388,22 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/geschichten": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["list"];
|
||||||
|
put?: never;
|
||||||
|
post: operations["create"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/documents": {
|
"/api/documents": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -692,6 +708,22 @@ export interface paths {
|
|||||||
patch: operations["updateGroup"];
|
patch: operations["updateGroup"];
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/geschichten/{id}": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["getById"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete: operations["delete"];
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch: operations["update"];
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/documents/{id}/training-labels": {
|
"/api/documents/{id}/training-labels": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1807,6 +1839,31 @@ export interface components {
|
|||||||
name?: string;
|
name?: string;
|
||||||
permissions?: string[];
|
permissions?: string[];
|
||||||
};
|
};
|
||||||
|
GeschichteUpdateDTO: {
|
||||||
|
title?: string;
|
||||||
|
body?: string;
|
||||||
|
/** @enum {string} */
|
||||||
|
status?: "DRAFT" | "PUBLISHED";
|
||||||
|
personIds?: string[];
|
||||||
|
documentIds?: string[];
|
||||||
|
};
|
||||||
|
Geschichte: {
|
||||||
|
/** Format: uuid */
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
body?: string;
|
||||||
|
/** @enum {string} */
|
||||||
|
status: "DRAFT" | "PUBLISHED";
|
||||||
|
author?: components["schemas"]["AppUser"];
|
||||||
|
persons?: components["schemas"]["Person"][];
|
||||||
|
documents?: components["schemas"]["Document"][];
|
||||||
|
/** Format: date-time */
|
||||||
|
createdAt: string;
|
||||||
|
/** Format: date-time */
|
||||||
|
updatedAt: string;
|
||||||
|
/** Format: date-time */
|
||||||
|
publishedAt?: string;
|
||||||
|
};
|
||||||
CreateTranscriptionBlockDTO: {
|
CreateTranscriptionBlockDTO: {
|
||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
pageNumber?: number;
|
pageNumber?: number;
|
||||||
@@ -2083,9 +2140,9 @@ export interface components {
|
|||||||
deathYear?: number;
|
deathYear?: number;
|
||||||
familyMember?: boolean;
|
familyMember?: boolean;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
|
alias?: string;
|
||||||
/** Format: int64 */
|
/** Format: int64 */
|
||||||
documentCount?: number;
|
documentCount?: number;
|
||||||
alias?: string;
|
|
||||||
};
|
};
|
||||||
InferredRelationshipWithPersonDTO: {
|
InferredRelationshipWithPersonDTO: {
|
||||||
person: components["schemas"]["PersonNodeDTO"];
|
person: components["schemas"]["PersonNodeDTO"];
|
||||||
@@ -3278,6 +3335,55 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
list: {
|
||||||
|
parameters: {
|
||||||
|
query?: {
|
||||||
|
status?: "DRAFT" | "PUBLISHED";
|
||||||
|
personId?: string[];
|
||||||
|
documentId?: string;
|
||||||
|
limit?: number;
|
||||||
|
};
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"*/*": components["schemas"]["Geschichte"][];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
create: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["GeschichteUpdateDTO"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"*/*": components["schemas"]["Geschichte"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
createDocument: {
|
createDocument: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -3846,6 +3952,74 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
getById: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"*/*": components["schemas"]["Geschichte"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
delete: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
update: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["GeschichteUpdateDTO"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"*/*": components["schemas"]["Geschichte"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
patchTrainingLabel: {
|
patchTrainingLabel: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
67
frontend/src/lib/utils/extractText.spec.ts
Normal file
67
frontend/src/lib/utils/extractText.spec.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { extractText, plainExcerpt } from './extractText';
|
||||||
|
|
||||||
|
describe('extractText', () => {
|
||||||
|
it('returns empty string for null/undefined/empty', () => {
|
||||||
|
expect(extractText(null)).toBe('');
|
||||||
|
expect(extractText(undefined)).toBe('');
|
||||||
|
expect(extractText('')).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips tags and preserves visible text', () => {
|
||||||
|
expect(extractText('<p>Hello <strong>world</strong></p>')).toBe('Hello world');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('collapses whitespace within and between blocks', () => {
|
||||||
|
expect(extractText('<p>One</p><p>Two</p>')).toBe('OneTwo');
|
||||||
|
expect(extractText('<p>foo bar</p>')).toBe('foo bar');
|
||||||
|
});
|
||||||
|
|
||||||
|
// XSS-shaped inputs: extractText must NOT execute, render, or expose the
|
||||||
|
// payload as HTML. It is only required to return *some* string. The fact
|
||||||
|
// that it exists is documented as a non-sanitiser; these tests prevent
|
||||||
|
// silent regressions where the function might somehow leak a tag.
|
||||||
|
describe('XSS-shaped input — never re-emits markup, even though this is not a sanitiser', () => {
|
||||||
|
it('drops <script> and surfaces only its text content', () => {
|
||||||
|
const out = extractText('<p>ok</p><script>alert(1)</script>');
|
||||||
|
expect(out).not.toContain('<script>');
|
||||||
|
expect(out).not.toContain('</script>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops <svg/onload> markup', () => {
|
||||||
|
const out = extractText('<svg/onload=alert(1)>');
|
||||||
|
expect(out).not.toContain('<svg');
|
||||||
|
expect(out).not.toContain('onload');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops <iframe srcdoc=…> markup', () => {
|
||||||
|
const out = extractText('<iframe srcdoc="<script>alert(1)</script>">');
|
||||||
|
expect(out).not.toContain('<iframe');
|
||||||
|
expect(out).not.toContain('srcdoc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops <a href="javascript:…"> tag (text content may remain)', () => {
|
||||||
|
const out = extractText('<a href="javascript:alert(1)">click</a>');
|
||||||
|
expect(out).not.toContain('<a ');
|
||||||
|
expect(out).not.toContain('javascript:');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('plainExcerpt', () => {
|
||||||
|
it('returns full text when under the limit', () => {
|
||||||
|
expect(plainExcerpt('<p>short</p>', 80)).toBe('short');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('truncates at the boundary with an ellipsis', () => {
|
||||||
|
const html = '<p>' + 'a'.repeat(100) + '</p>';
|
||||||
|
const out = plainExcerpt(html, 20);
|
||||||
|
expect(out.length).toBeLessThanOrEqual(21);
|
||||||
|
expect(out.endsWith('…')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('breaks at a word boundary when possible', () => {
|
||||||
|
const out = plainExcerpt('<p>The quick brown fox jumps over</p>', 18);
|
||||||
|
expect(out).toBe('The quick brown…');
|
||||||
|
});
|
||||||
|
});
|
||||||
38
frontend/src/lib/utils/extractText.ts
Normal file
38
frontend/src/lib/utils/extractText.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
/**
|
||||||
|
* **Not a sanitizer.** This module extracts visible text from a (presumed
|
||||||
|
* already-sanitised) HTML string for excerpt rendering. It is safe ONLY
|
||||||
|
* because the Geschichte body is sanitised against the OWASP allow-list
|
||||||
|
* on the server before persistence, and via DOMPurify on render.
|
||||||
|
*
|
||||||
|
* Do not use these helpers to defend against XSS — `safeHtml()` in
|
||||||
|
* `./sanitize.ts` is the only sanitiser. Calling `extractText()` on
|
||||||
|
* untrusted input that has not been sanitised does not protect against
|
||||||
|
* `javascript:` URLs, event-handler attributes, or `<svg/onload>` payloads.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip tags and return plain text. Uses DOMParser in the browser; on the
|
||||||
|
* server it falls back to a regex that drops angle-bracket sequences.
|
||||||
|
* The fallback is **not** a sanitiser — see module docstring.
|
||||||
|
*/
|
||||||
|
export function extractText(html: string | null | undefined): string {
|
||||||
|
if (!html) return '';
|
||||||
|
if (typeof DOMParser === 'function') {
|
||||||
|
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||||||
|
return (doc.body.textContent ?? '').replace(/\s+/g, ' ').trim();
|
||||||
|
}
|
||||||
|
return html
|
||||||
|
.replace(/<[^>]*>/g, '')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip tags then truncate to `max` chars on a word boundary, appending an
|
||||||
|
* ellipsis when truncated. Used for editorial story excerpts.
|
||||||
|
*/
|
||||||
|
export function plainExcerpt(html: string | null | undefined, max = 80): string {
|
||||||
|
const text = extractText(html);
|
||||||
|
if (text.length <= max) return text;
|
||||||
|
return text.slice(0, max).replace(/\s+\S*$/, '') + '…';
|
||||||
|
}
|
||||||
47
frontend/src/lib/utils/sanitize.spec.ts
Normal file
47
frontend/src/lib/utils/sanitize.spec.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { safeHtml } from './sanitize';
|
||||||
|
|
||||||
|
describe('safeHtml', () => {
|
||||||
|
it('returns empty string for null/undefined/empty input', () => {
|
||||||
|
expect(safeHtml(null)).toBe('');
|
||||||
|
expect(safeHtml(undefined)).toBe('');
|
||||||
|
expect(safeHtml('')).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps allowed tags: p, strong, em, br, h2, h3, ul, ol, li', () => {
|
||||||
|
const html =
|
||||||
|
'<p><strong>bold</strong> <em>italic</em><br>x</p>' +
|
||||||
|
'<h2>H2</h2><h3>H3</h3>' +
|
||||||
|
'<ul><li>a</li></ul><ol><li>b</li></ol>';
|
||||||
|
const result = safeHtml(html);
|
||||||
|
expect(result).toContain('<strong>bold</strong>');
|
||||||
|
expect(result).toContain('<em>italic</em>');
|
||||||
|
expect(result).toContain('<br>');
|
||||||
|
expect(result).toContain('<h2>H2</h2>');
|
||||||
|
expect(result).toContain('<h3>H3</h3>');
|
||||||
|
expect(result).toContain('<ul>');
|
||||||
|
expect(result).toContain('<ol>');
|
||||||
|
expect(result).toContain('<li>a</li>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips <script> tags entirely', () => {
|
||||||
|
const result = safeHtml('<p>ok</p><script>alert(1)</script>');
|
||||||
|
expect(result).not.toContain('<script>');
|
||||||
|
expect(result).not.toContain('alert');
|
||||||
|
expect(result).toContain('<p>ok</p>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips on* event-handler attributes', () => {
|
||||||
|
const result = safeHtml('<p onclick="evil()">x</p>');
|
||||||
|
expect(result).not.toContain('onclick');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips disallowed elements like <img>, <a>, <iframe>', () => {
|
||||||
|
const result = safeHtml(
|
||||||
|
'<p>x</p><img src="x" onerror="alert(1)"><a href="javascript:alert(1)">link</a><iframe></iframe>'
|
||||||
|
);
|
||||||
|
expect(result).not.toContain('<img');
|
||||||
|
expect(result).not.toContain('<a ');
|
||||||
|
expect(result).not.toContain('<iframe');
|
||||||
|
});
|
||||||
|
});
|
||||||
17
frontend/src/lib/utils/sanitize.ts
Normal file
17
frontend/src/lib/utils/sanitize.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import DOMPurify from 'isomorphic-dompurify';
|
||||||
|
|
||||||
|
const ALLOWED_TAGS = ['p', 'br', 'strong', 'em', 'h2', 'h3', 'ul', 'ol', 'li'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render-side sanitiser for Geschichte body HTML. The backend already
|
||||||
|
* sanitises with the OWASP allow-list on save, but we re-run on render
|
||||||
|
* because the API can be called directly and stored content can pre-date
|
||||||
|
* a tightening of the allow-list.
|
||||||
|
*/
|
||||||
|
export function safeHtml(raw: string | null | undefined): string {
|
||||||
|
if (!raw) return '';
|
||||||
|
return DOMPurify.sanitize(raw, {
|
||||||
|
ALLOWED_TAGS,
|
||||||
|
ALLOWED_ATTR: []
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ export const load: LayoutServerLoad = async ({ locals }) => {
|
|||||||
canWrite: groups.some((g) => g.permissions.includes('WRITE_ALL')),
|
canWrite: groups.some((g) => g.permissions.includes('WRITE_ALL')),
|
||||||
canAnnotate: groups.some(
|
canAnnotate: groups.some(
|
||||||
(g) => g.permissions.includes('WRITE_ALL') || g.permissions.includes('ANNOTATE_ALL')
|
(g) => g.permissions.includes('WRITE_ALL') || g.permissions.includes('ANNOTATE_ALL')
|
||||||
)
|
),
|
||||||
|
canBlogWrite: groups.some((g) => g.permissions.includes('BLOG_WRITE'))
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -68,6 +68,16 @@ function handleOverlayKeydown(event: KeyboardEvent) {
|
|||||||
>
|
>
|
||||||
{m.nav_stammbaum()}
|
{m.nav_stammbaum()}
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="/geschichten"
|
||||||
|
class="my-2 inline-flex items-center px-3 font-sans text-xs font-bold tracking-widest uppercase transition-colors focus:outline-none focus-visible:rounded focus-visible:ring-2 focus-visible:ring-focus-ring
|
||||||
|
{page.url.pathname.startsWith('/geschichten')
|
||||||
|
? 'border-b-2 border-accent text-white'
|
||||||
|
: 'text-white/70 hover:text-white'}"
|
||||||
|
>
|
||||||
|
{m.nav_geschichten()}
|
||||||
|
</a>
|
||||||
{#if isAdmin}
|
{#if isAdmin}
|
||||||
<a
|
<a
|
||||||
href="/admin"
|
href="/admin"
|
||||||
@@ -170,6 +180,16 @@ function handleOverlayKeydown(event: KeyboardEvent) {
|
|||||||
{m.nav_stammbaum()}
|
{m.nav_stammbaum()}
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="/geschichten"
|
||||||
|
class="block flex min-h-[44px] w-full items-center px-4 py-3 font-sans text-sm font-bold tracking-widest uppercase transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-inset
|
||||||
|
{page.url.pathname.startsWith('/geschichten')
|
||||||
|
? 'bg-accent-bg text-ink'
|
||||||
|
: 'text-ink-2 hover:bg-muted hover:text-ink'}"
|
||||||
|
>
|
||||||
|
{m.nav_geschichten()}
|
||||||
|
</a>
|
||||||
|
|
||||||
{#if isAdmin}
|
{#if isAdmin}
|
||||||
<a
|
<a
|
||||||
href="/admin"
|
href="/admin"
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ $effect(() => {
|
|||||||
const STANDARD_PERMISSIONS = $derived([
|
const STANDARD_PERMISSIONS = $derived([
|
||||||
{ value: 'READ_ALL', label: m.admin_perm_read_all() },
|
{ value: 'READ_ALL', label: m.admin_perm_read_all() },
|
||||||
{ value: 'ANNOTATE_ALL', label: m.admin_perm_annotate_all() },
|
{ value: 'ANNOTATE_ALL', label: m.admin_perm_annotate_all() },
|
||||||
{ value: 'WRITE_ALL', label: m.admin_perm_write_all() }
|
{ value: 'WRITE_ALL', label: m.admin_perm_write_all() },
|
||||||
|
{ value: 'BLOG_WRITE', label: m.admin_perm_blog_write() }
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const ADMIN_PERMISSIONS = $derived([
|
const ADMIN_PERMISSIONS = $derived([
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import { m } from '$lib/paraglide/messages.js';
|
|||||||
const availableStandard = $derived([
|
const availableStandard = $derived([
|
||||||
{ value: 'READ_ALL', label: m.admin_perm_read_all() },
|
{ value: 'READ_ALL', label: m.admin_perm_read_all() },
|
||||||
{ value: 'ANNOTATE_ALL', label: m.admin_perm_annotate_all() },
|
{ value: 'ANNOTATE_ALL', label: m.admin_perm_annotate_all() },
|
||||||
{ value: 'WRITE_ALL', label: m.admin_perm_write_all() }
|
{ value: 'WRITE_ALL', label: m.admin_perm_write_all() },
|
||||||
|
{ value: 'BLOG_WRITE', label: m.admin_perm_blog_write() }
|
||||||
]);
|
]);
|
||||||
const availableAdmin = $derived([
|
const availableAdmin = $derived([
|
||||||
{ value: 'ADMIN', label: m.admin_perm_admin() },
|
{ value: 'ADMIN', label: m.admin_perm_admin() },
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ const baseData = {
|
|||||||
user: undefined,
|
user: undefined,
|
||||||
canWrite: true,
|
canWrite: true,
|
||||||
canAnnotate: false,
|
canAnnotate: false,
|
||||||
|
canBlogWrite: false,
|
||||||
editUser: makeUser(),
|
editUser: makeUser(),
|
||||||
groups
|
groups
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,7 +10,13 @@ const groups = [
|
|||||||
{ id: 'g2', name: 'Admins', permissions: ['ADMIN'] }
|
{ id: 'g2', name: 'Admins', permissions: ['ADMIN'] }
|
||||||
];
|
];
|
||||||
|
|
||||||
const baseData = { user: undefined, canWrite: true, canAnnotate: false, groups };
|
const baseData = {
|
||||||
|
user: undefined,
|
||||||
|
canWrite: true,
|
||||||
|
canAnnotate: false,
|
||||||
|
canBlogWrite: false,
|
||||||
|
groups
|
||||||
|
};
|
||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const baseData = {
|
|||||||
user: undefined,
|
user: undefined,
|
||||||
canWrite: true,
|
canWrite: true,
|
||||||
canAnnotate: false,
|
canAnnotate: false,
|
||||||
|
canBlogWrite: false,
|
||||||
documents: [],
|
documents: [],
|
||||||
initialValues: { senderName: '', receiverName: '' },
|
initialValues: { senderName: '', receiverName: '' },
|
||||||
filters: { senderId: '', receiverId: '', from: '', to: '', dir: 'DESC' as const }
|
filters: { senderId: '', receiverId: '', from: '', to: '', dir: 'DESC' as const }
|
||||||
|
|||||||
@@ -7,7 +7,12 @@ export async function load({ params, fetch }) {
|
|||||||
const { id } = params;
|
const { id } = params;
|
||||||
const api = createApiClient(fetch);
|
const api = createApiClient(fetch);
|
||||||
|
|
||||||
const docResult = await api.GET('/api/documents/{id}', { params: { path: { id } } });
|
const [docResult, geschichtenResult] = await Promise.all([
|
||||||
|
api.GET('/api/documents/{id}', { params: { path: { id } } }),
|
||||||
|
api.GET('/api/geschichten', {
|
||||||
|
params: { query: { status: 'PUBLISHED', documentId: id } }
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
if (docResult.response.status === 401) throw redirect(302, '/login');
|
if (docResult.response.status === 401) throw redirect(302, '/login');
|
||||||
|
|
||||||
@@ -18,8 +23,9 @@ export async function load({ params, fetch }) {
|
|||||||
|
|
||||||
const document = docResult.data!;
|
const document = docResult.data!;
|
||||||
const inferredRelationship = await loadInferredRelationship(api, document);
|
const inferredRelationship = await loadInferredRelationship(api, document);
|
||||||
|
const geschichten = geschichtenResult.data ?? [];
|
||||||
|
|
||||||
return { document, inferredRelationship };
|
return { document, inferredRelationship, geschichten };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadInferredRelationship(
|
async function loadInferredRelationship(
|
||||||
|
|||||||
@@ -424,6 +424,8 @@ onMount(() => {
|
|||||||
fileUrl={fileLoader.fileUrl}
|
fileUrl={fileLoader.fileUrl}
|
||||||
bind:transcribeMode={transcribeMode}
|
bind:transcribeMode={transcribeMode}
|
||||||
inferredRelationship={data.inferredRelationship}
|
inferredRelationship={data.inferredRelationship}
|
||||||
|
geschichten={data.geschichten ?? []}
|
||||||
|
canBlogWrite={data.canBlogWrite ?? false}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="relative flex-1 overflow-hidden {transcribeMode ? 'flex flex-col md:flex-row' : ''}">
|
<div class="relative flex-1 overflow-hidden {transcribeMode ? 'flex flex-col md:flex-row' : ''}">
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const baseData = {
|
|||||||
user: undefined,
|
user: undefined,
|
||||||
canWrite: true,
|
canWrite: true,
|
||||||
canAnnotate: false,
|
canAnnotate: false,
|
||||||
|
canBlogWrite: false,
|
||||||
persons: [],
|
persons: [],
|
||||||
initialSenderId: '',
|
initialSenderId: '',
|
||||||
initialSenderName: '',
|
initialSenderName: '',
|
||||||
|
|||||||
41
frontend/src/routes/geschichten/+page.server.ts
Normal file
41
frontend/src/routes/geschichten/+page.server.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
import { createApiClient } from '$lib/api.server';
|
||||||
|
import { getErrorMessage } from '$lib/errors';
|
||||||
|
import type { components } from '$lib/generated/api';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
type Person = components['schemas']['Person'];
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ url, fetch }) => {
|
||||||
|
const api = createApiClient(fetch);
|
||||||
|
const personIds = url.searchParams.getAll('personId');
|
||||||
|
const documentId = url.searchParams.get('documentId') ?? undefined;
|
||||||
|
|
||||||
|
const [listResult, ...personResults] = await Promise.all([
|
||||||
|
api.GET('/api/geschichten', {
|
||||||
|
params: {
|
||||||
|
query: {
|
||||||
|
status: 'PUBLISHED',
|
||||||
|
personId: personIds.length ? personIds : undefined,
|
||||||
|
documentId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
...personIds.map((id) => api.GET('/api/persons/{id}', { params: { path: { id } } }))
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!listResult.response.ok) {
|
||||||
|
const code = (listResult.error as unknown as { code?: string })?.code;
|
||||||
|
throw error(listResult.response.status, getErrorMessage(code));
|
||||||
|
}
|
||||||
|
|
||||||
|
const personFilters = personResults
|
||||||
|
.filter((r) => r && r.response.ok && r.data)
|
||||||
|
.map((r) => r!.data!) as Person[];
|
||||||
|
|
||||||
|
return {
|
||||||
|
geschichten: listResult.data ?? [],
|
||||||
|
personFilters,
|
||||||
|
documentFilter: documentId ?? null
|
||||||
|
};
|
||||||
|
};
|
||||||
149
frontend/src/routes/geschichten/+page.svelte
Normal file
149
frontend/src/routes/geschichten/+page.svelte
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import { plainExcerpt } from '$lib/utils/extractText';
|
||||||
|
import { formatDate } from '$lib/utils/date';
|
||||||
|
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
|
let showPersonPicker = $state(false);
|
||||||
|
|
||||||
|
const selectedPersonIds = $derived(data.personFilters.map((p) => p.id!));
|
||||||
|
const hasFilters = $derived(data.personFilters.length > 0 || !!data.documentFilter);
|
||||||
|
|
||||||
|
function rebuildUrl(personIds: string[]) {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.delete('personId');
|
||||||
|
url.searchParams.delete('documentId');
|
||||||
|
for (const id of personIds) url.searchParams.append('personId', id);
|
||||||
|
return url.pathname + url.search;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAll() {
|
||||||
|
goto(rebuildUrl([]), { replaceState: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function addPerson(personId: string) {
|
||||||
|
if (!personId || selectedPersonIds.includes(personId)) {
|
||||||
|
showPersonPicker = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showPersonPicker = false;
|
||||||
|
goto(rebuildUrl([...selectedPersonIds, personId]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function removePerson(personId: string) {
|
||||||
|
goto(rebuildUrl(selectedPersonIds.filter((id) => id !== personId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function authorName(g: { author?: { firstName?: string; lastName?: string; email: string } }) {
|
||||||
|
const a = g.author;
|
||||||
|
if (!a) return '';
|
||||||
|
const full = [a.firstName, a.lastName].filter(Boolean).join(' ').trim();
|
||||||
|
return full || a.email || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function publishedAt(g: { publishedAt?: string }): string | null {
|
||||||
|
if (!g.publishedAt) return null;
|
||||||
|
return formatDate(g.publishedAt.slice(0, 10), 'short');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-4xl px-4 py-8">
|
||||||
|
<header class="mb-6 flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<h1 class="font-serif text-3xl font-bold text-ink">{m.geschichten_index_title()}</h1>
|
||||||
|
{#if data.canBlogWrite}
|
||||||
|
<a
|
||||||
|
href="/geschichten/new"
|
||||||
|
class="inline-flex h-11 items-center rounded bg-primary px-4 font-sans text-sm font-medium text-primary-fg hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||||
|
>
|
||||||
|
{m.geschichten_new_button()}
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Filter pills -->
|
||||||
|
<div class="mb-6 flex flex-wrap items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-pressed={!hasFilters}
|
||||||
|
onclick={clearAll}
|
||||||
|
class="inline-flex h-11 items-center rounded-full border border-line px-3 font-sans text-xs font-bold tracking-wider text-ink-2 uppercase hover:bg-muted aria-pressed:bg-ink aria-pressed:text-primary-fg"
|
||||||
|
>
|
||||||
|
{m.geschichten_filter_all_pill()}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#each data.personFilters as p (p.id)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-pressed="true"
|
||||||
|
aria-label={m.geschichten_filter_remove_chip({ name: p.displayName })}
|
||||||
|
onclick={() => removePerson(p.id!)}
|
||||||
|
class="inline-flex h-11 items-center gap-2 rounded-full bg-ink px-3 font-sans text-xs font-bold tracking-wider text-primary-fg uppercase"
|
||||||
|
>
|
||||||
|
{p.displayName}
|
||||||
|
<span aria-hidden="true">×</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-expanded={showPersonPicker}
|
||||||
|
onclick={() => (showPersonPicker = !showPersonPicker)}
|
||||||
|
class="inline-flex h-11 items-center rounded-full border border-line px-3 font-sans text-xs font-bold tracking-wider text-ink-2 uppercase hover:bg-muted"
|
||||||
|
>
|
||||||
|
+ {m.geschichten_filter_choose_person()}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if showPersonPicker}
|
||||||
|
<div class="mb-4">
|
||||||
|
<PersonTypeahead
|
||||||
|
name="filter-person"
|
||||||
|
label={m.geschichten_filter_choose_person()}
|
||||||
|
compact
|
||||||
|
autofocus
|
||||||
|
onchange={addPerson}
|
||||||
|
/>
|
||||||
|
{#if selectedPersonIds.length > 1}
|
||||||
|
<p class="mt-1 font-sans text-xs text-ink-3">
|
||||||
|
{m.geschichten_filter_and_hint()}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Card list -->
|
||||||
|
{#if data.geschichten.length === 0}
|
||||||
|
<div class="rounded border border-line bg-surface p-6 text-center font-sans text-sm text-ink-3">
|
||||||
|
{#if data.personFilters.length > 0}
|
||||||
|
{m.geschichten_empty_for_persons({
|
||||||
|
names: data.personFilters.map((p) => p.displayName).join(' & ')
|
||||||
|
})}
|
||||||
|
{:else}
|
||||||
|
{m.geschichten_empty_no_filter()}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<ul class="flex flex-col gap-4">
|
||||||
|
{#each data.geschichten as g (g.id)}
|
||||||
|
<li
|
||||||
|
class="rounded border border-line bg-surface p-5 shadow-sm transition-shadow hover:shadow-md"
|
||||||
|
>
|
||||||
|
<a href="/geschichten/{g.id}" class="block">
|
||||||
|
<h2 class="mb-1 font-serif text-xl font-bold text-ink">{g.title}</h2>
|
||||||
|
<p class="mb-3 font-sans text-xs text-ink-3">
|
||||||
|
{authorName(g)}
|
||||||
|
{#if publishedAt(g)}· {m.geschichten_published_on({ date: publishedAt(g)! })}{/if}
|
||||||
|
</p>
|
||||||
|
{#if g.body}
|
||||||
|
<p class="font-serif text-base text-ink-2">{plainExcerpt(g.body, 150)}</p>
|
||||||
|
{/if}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
16
frontend/src/routes/geschichten/[id]/+page.server.ts
Normal file
16
frontend/src/routes/geschichten/[id]/+page.server.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
import { createApiClient } from '$lib/api.server';
|
||||||
|
import { getErrorMessage } from '$lib/errors';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ params, fetch }) => {
|
||||||
|
const api = createApiClient(fetch);
|
||||||
|
const result = await api.GET('/api/geschichten/{id}', {
|
||||||
|
params: { path: { id: params.id } }
|
||||||
|
});
|
||||||
|
if (!result.response.ok) {
|
||||||
|
const code = (result.error as unknown as { code?: string })?.code;
|
||||||
|
throw error(result.response.status, getErrorMessage(code));
|
||||||
|
}
|
||||||
|
return { geschichte: result.data! };
|
||||||
|
};
|
||||||
142
frontend/src/routes/geschichten/[id]/+page.svelte
Normal file
142
frontend/src/routes/geschichten/[id]/+page.svelte
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import { safeHtml } from '$lib/utils/sanitize';
|
||||||
|
import { formatDate } from '$lib/utils/date';
|
||||||
|
import { getConfirmService } from '$lib/services/confirm.svelte';
|
||||||
|
import BackButton from '$lib/components/BackButton.svelte';
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
|
const g = $derived(data.geschichte);
|
||||||
|
const sanitized = $derived(safeHtml(g.body));
|
||||||
|
|
||||||
|
const publishedAt = $derived.by(() => {
|
||||||
|
if (!g.publishedAt) return null;
|
||||||
|
return formatDate(g.publishedAt.slice(0, 10), 'long');
|
||||||
|
});
|
||||||
|
|
||||||
|
function authorName(): string {
|
||||||
|
const a = g.author;
|
||||||
|
if (!a) return '';
|
||||||
|
const full = [a.firstName, a.lastName].filter(Boolean).join(' ').trim();
|
||||||
|
return full || a.email || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirm = getConfirmService();
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
const ok = await confirm.confirm({
|
||||||
|
title: m.geschichte_delete_confirm_title(),
|
||||||
|
body: m.geschichte_delete_confirm_body(),
|
||||||
|
confirmLabel: m.btn_delete(),
|
||||||
|
cancelLabel: m.btn_cancel(),
|
||||||
|
destructive: true
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
const res = await fetch(`/api/geschichten/${g.id}`, { method: 'DELETE' });
|
||||||
|
if (res.ok) {
|
||||||
|
goto('/geschichten');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-3xl px-4 py-8">
|
||||||
|
<div class="mb-6">
|
||||||
|
<BackButton />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<article aria-labelledby="geschichte-title">
|
||||||
|
<header class="mb-6">
|
||||||
|
<h1 id="geschichte-title" class="mb-3 font-serif text-4xl font-bold text-ink">
|
||||||
|
{g.title}
|
||||||
|
</h1>
|
||||||
|
<p class="font-sans text-sm text-ink-3">
|
||||||
|
{authorName()}
|
||||||
|
{#if publishedAt}· {m.geschichten_published_on({ date: publishedAt })}{/if}
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Body styles are explicit (no `prose`) so the text uses the full max-w-3xl
|
||||||
|
parent width — Tailwind Typography's default `max-w-prose` clamps to ~65ch
|
||||||
|
and produces a much narrower column inside an already narrow page, which
|
||||||
|
Leonie flagged as unreadable for the senior-author persona.
|
||||||
|
|
||||||
|
Sanitised via safeHtml() (DOMPurify) on render — matches backend OWASP allow-list.
|
||||||
|
-->
|
||||||
|
<div
|
||||||
|
class="font-serif text-lg leading-relaxed text-ink [&_h2]:mt-8 [&_h2]:mb-3 [&_h2]:text-2xl [&_h2]:font-bold [&_h3]:mt-6 [&_h3]:mb-2 [&_h3]:text-xl [&_h3]:font-bold [&_li]:mb-1 [&_ol]:mb-4 [&_ol]:list-decimal [&_ol]:pl-6 [&_p]:mb-4 [&_ul]:mb-4 [&_ul]:list-disc [&_ul]:pl-6"
|
||||||
|
>
|
||||||
|
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||||
|
{@html sanitized}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<!-- Personen -->
|
||||||
|
{#if g.persons && g.persons.length > 0}
|
||||||
|
<section class="mt-10 border-t border-line pt-6">
|
||||||
|
<h2 class="mb-3 font-sans text-xs font-bold tracking-widest text-ink-2 uppercase">
|
||||||
|
{m.geschichten_persons_section()}
|
||||||
|
</h2>
|
||||||
|
<ul class="flex flex-wrap gap-2">
|
||||||
|
{#each g.persons as p (p.id)}
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="/persons/{p.id}"
|
||||||
|
class="inline-flex items-center rounded-full bg-muted px-3 py-1 font-sans text-sm text-ink hover:bg-accent-bg"
|
||||||
|
>
|
||||||
|
{p.displayName}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Dokumente -->
|
||||||
|
{#if g.documents && g.documents.length > 0}
|
||||||
|
<section class="mt-8 border-t border-line pt-6">
|
||||||
|
<h2 class="mb-3 font-sans text-xs font-bold tracking-widest text-ink-2 uppercase">
|
||||||
|
{m.geschichten_documents_section()}
|
||||||
|
</h2>
|
||||||
|
<ul class="flex flex-col gap-2">
|
||||||
|
{#each g.documents as d (d.id)}
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="/documents/{d.id}"
|
||||||
|
class="block rounded border border-line bg-surface px-4 py-3 font-serif text-base text-ink hover:bg-muted"
|
||||||
|
>
|
||||||
|
{d.title}
|
||||||
|
{#if d.documentDate}
|
||||||
|
<span class="ml-2 font-sans text-xs text-ink-3">
|
||||||
|
{formatDate(d.documentDate, 'short')}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Author actions -->
|
||||||
|
{#if data.canBlogWrite}
|
||||||
|
<div class="mt-10 flex items-center gap-3 border-t border-line pt-6">
|
||||||
|
<a
|
||||||
|
href="/geschichten/{g.id}/edit"
|
||||||
|
class="inline-flex h-11 items-center rounded border border-line bg-surface px-4 font-sans text-sm font-medium text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||||
|
>
|
||||||
|
{m.btn_edit()}
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={handleDelete}
|
||||||
|
class="inline-flex h-11 items-center rounded font-sans text-sm font-medium text-danger hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||||
|
>
|
||||||
|
{m.btn_delete()}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
20
frontend/src/routes/geschichten/[id]/edit/+page.server.ts
Normal file
20
frontend/src/routes/geschichten/[id]/edit/+page.server.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { error, redirect } from '@sveltejs/kit';
|
||||||
|
import { createApiClient } from '$lib/api.server';
|
||||||
|
import { getErrorMessage } from '$lib/errors';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ params, fetch, parent }) => {
|
||||||
|
const layout = await parent();
|
||||||
|
if (!layout.canBlogWrite) {
|
||||||
|
throw redirect(303, `/geschichten/${params.id}`);
|
||||||
|
}
|
||||||
|
const api = createApiClient(fetch);
|
||||||
|
const result = await api.GET('/api/geschichten/{id}', {
|
||||||
|
params: { path: { id: params.id } }
|
||||||
|
});
|
||||||
|
if (!result.response.ok) {
|
||||||
|
const code = (result.error as unknown as { code?: string })?.code;
|
||||||
|
throw error(result.response.status, getErrorMessage(code));
|
||||||
|
}
|
||||||
|
return { geschichte: result.data! };
|
||||||
|
};
|
||||||
60
frontend/src/routes/geschichten/[id]/edit/+page.svelte
Normal file
60
frontend/src/routes/geschichten/[id]/edit/+page.svelte
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import GeschichteEditor from '$lib/components/GeschichteEditor.svelte';
|
||||||
|
import BackButton from '$lib/components/BackButton.svelte';
|
||||||
|
import { getErrorMessage } from '$lib/errors';
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
|
let submitting = $state(false);
|
||||||
|
let errorMessage: string | null = $state(null);
|
||||||
|
|
||||||
|
async function handleSubmit(payload: {
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
status: 'DRAFT' | 'PUBLISHED';
|
||||||
|
personIds: string[];
|
||||||
|
documentIds: string[];
|
||||||
|
}) {
|
||||||
|
submitting = true;
|
||||||
|
errorMessage = null;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/geschichten/${data.geschichte.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const code = (await res.json().catch(() => ({})))?.code;
|
||||||
|
errorMessage = getErrorMessage(code);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
goto(`/geschichten/${data.geschichte.id}`);
|
||||||
|
} finally {
|
||||||
|
submitting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-5xl px-4 py-8">
|
||||||
|
<div class="mb-6">
|
||||||
|
<BackButton />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 class="mb-6 font-serif text-3xl font-bold text-ink">
|
||||||
|
{m.btn_edit()}: {data.geschichte.title}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{#if errorMessage}
|
||||||
|
<div
|
||||||
|
class="mb-4 rounded border border-danger bg-danger/10 p-3 text-sm text-danger"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
{errorMessage}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<GeschichteEditor geschichte={data.geschichte} onSubmit={handleSubmit} submitting={submitting} />
|
||||||
|
</div>
|
||||||
33
frontend/src/routes/geschichten/new/+page.server.ts
Normal file
33
frontend/src/routes/geschichten/new/+page.server.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
import { createApiClient } from '$lib/api.server';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ url, fetch, parent }) => {
|
||||||
|
const layout = await parent();
|
||||||
|
if (!layout.canBlogWrite) {
|
||||||
|
throw redirect(303, '/geschichten');
|
||||||
|
}
|
||||||
|
|
||||||
|
const api = createApiClient(fetch);
|
||||||
|
const personId = url.searchParams.get('personId');
|
||||||
|
const documentId = url.searchParams.get('documentId');
|
||||||
|
|
||||||
|
const [personResult, documentResult] = await Promise.all([
|
||||||
|
personId
|
||||||
|
? api.GET('/api/persons/{id}', { params: { path: { id: personId } } })
|
||||||
|
: Promise.resolve(null),
|
||||||
|
documentId
|
||||||
|
? api.GET('/api/documents/{id}', { params: { path: { id: documentId } } })
|
||||||
|
: Promise.resolve(null)
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Silently ignore 404/403 to avoid leaking entity existence on unknown IDs.
|
||||||
|
const initialPersons =
|
||||||
|
personResult && personResult.response.ok && personResult.data ? [personResult.data] : [];
|
||||||
|
const initialDocuments =
|
||||||
|
documentResult && documentResult.response.ok && documentResult.data
|
||||||
|
? [documentResult.data]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return { initialPersons, initialDocuments };
|
||||||
|
};
|
||||||
64
frontend/src/routes/geschichten/new/+page.svelte
Normal file
64
frontend/src/routes/geschichten/new/+page.svelte
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import GeschichteEditor from '$lib/components/GeschichteEditor.svelte';
|
||||||
|
import BackButton from '$lib/components/BackButton.svelte';
|
||||||
|
import { getErrorMessage } from '$lib/errors';
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
|
let submitting = $state(false);
|
||||||
|
let errorMessage: string | null = $state(null);
|
||||||
|
|
||||||
|
async function handleSubmit(payload: {
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
status: 'DRAFT' | 'PUBLISHED';
|
||||||
|
personIds: string[];
|
||||||
|
documentIds: string[];
|
||||||
|
}) {
|
||||||
|
submitting = true;
|
||||||
|
errorMessage = null;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/geschichten', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const code = (await res.json().catch(() => ({})))?.code;
|
||||||
|
errorMessage = getErrorMessage(code);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const created = await res.json();
|
||||||
|
goto(`/geschichten/${created.id}`);
|
||||||
|
} finally {
|
||||||
|
submitting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-5xl px-4 py-8">
|
||||||
|
<div class="mb-6">
|
||||||
|
<BackButton />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 class="mb-6 font-serif text-3xl font-bold text-ink">{m.geschichten_new_button()}</h1>
|
||||||
|
|
||||||
|
{#if errorMessage}
|
||||||
|
<div
|
||||||
|
class="mb-4 rounded border border-danger bg-danger/10 p-3 text-sm text-danger"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
{errorMessage}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<GeschichteEditor
|
||||||
|
initialPersons={data.initialPersons}
|
||||||
|
initialDocuments={data.initialDocuments}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
submitting={submitting}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
123
frontend/src/routes/geschichten/page.svelte.spec.ts
Normal file
123
frontend/src/routes/geschichten/page.svelte.spec.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { page } from 'vitest/browser';
|
||||||
|
|
||||||
|
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
||||||
|
vi.mock('$app/state', () => ({ navigating: { to: null } }));
|
||||||
|
|
||||||
|
import Page from './+page.svelte';
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
function person(id: string, displayName: string) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
firstName: displayName.split(' ')[0] ?? displayName,
|
||||||
|
lastName: displayName.split(' ').slice(1).join(' ') || 'X',
|
||||||
|
displayName,
|
||||||
|
personType: 'PERSON'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeData(overrides: Partial<PageData> = {}): PageData {
|
||||||
|
return {
|
||||||
|
geschichten: [],
|
||||||
|
personFilters: [],
|
||||||
|
documentFilter: null,
|
||||||
|
canBlogWrite: false,
|
||||||
|
...overrides
|
||||||
|
} as unknown as PageData;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('geschichten page — multi-person filter chips', () => {
|
||||||
|
it('renders one chip per person in personFilters', async () => {
|
||||||
|
render(Page, {
|
||||||
|
data: makeData({
|
||||||
|
personFilters: [person('a', 'Anna A'), person('b', 'Bertha B')] as PageData['personFilters']
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.element(page.getByRole('button', { name: /Anna A aus Filter entfernen/ }))
|
||||||
|
.toBeVisible();
|
||||||
|
await expect
|
||||||
|
.element(page.getByRole('button', { name: /Bertha B aus Filter entfernen/ }))
|
||||||
|
.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the "All" pill in pressed state when no filters are active', async () => {
|
||||||
|
render(Page, { data: makeData() });
|
||||||
|
await expect
|
||||||
|
.element(page.getByRole('button', { name: 'Alle' }))
|
||||||
|
.toHaveAttribute('aria-pressed', 'true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the "All" pill in unpressed state when at least one filter is active', async () => {
|
||||||
|
render(Page, {
|
||||||
|
data: makeData({
|
||||||
|
personFilters: [person('a', 'Anna A')] as PageData['personFilters']
|
||||||
|
})
|
||||||
|
});
|
||||||
|
await expect
|
||||||
|
.element(page.getByRole('button', { name: 'Alle' }))
|
||||||
|
.toHaveAttribute('aria-pressed', 'false');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking × on a chip removes only that person from the URL', async () => {
|
||||||
|
const { goto } = await import('$app/navigation');
|
||||||
|
vi.mocked(goto).mockClear();
|
||||||
|
|
||||||
|
// Seed window.location so the chip-removal logic builds the new URL deterministically.
|
||||||
|
const originalHref = window.location.href;
|
||||||
|
window.history.replaceState({}, '', '/geschichten?personId=a&personId=b');
|
||||||
|
|
||||||
|
render(Page, {
|
||||||
|
data: makeData({
|
||||||
|
personFilters: [person('a', 'Anna A'), person('b', 'Bertha B')] as PageData['personFilters']
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /Anna A aus Filter entfernen/ }).click();
|
||||||
|
|
||||||
|
expect(goto).toHaveBeenCalledOnce();
|
||||||
|
const url = vi.mocked(goto).mock.calls[0][0] as string;
|
||||||
|
expect(url).toContain('personId=b');
|
||||||
|
expect(url).not.toContain('personId=a');
|
||||||
|
|
||||||
|
window.history.replaceState({}, '', originalHref);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the "+ Person wählen" button even when filters are already active', async () => {
|
||||||
|
render(Page, {
|
||||||
|
data: makeData({
|
||||||
|
personFilters: [person('a', 'Anna A')] as PageData['personFilters']
|
||||||
|
})
|
||||||
|
});
|
||||||
|
await expect.element(page.getByRole('button', { name: /Person wählen/ })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders all filter pills with a 44px touch target (h-11)', async () => {
|
||||||
|
render(Page, {
|
||||||
|
data: makeData({
|
||||||
|
personFilters: [person('a', 'Anna A')] as PageData['personFilters']
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
// All three pill variants must use h-11 (44px) per the senior-author touch-target rule
|
||||||
|
const all = page.getByRole('button', { name: 'Alle' });
|
||||||
|
const chip = page.getByRole('button', { name: /Anna A aus Filter entfernen/ });
|
||||||
|
const add = page.getByRole('button', { name: /Person wählen/ });
|
||||||
|
|
||||||
|
const allEl = (await all.element()) as HTMLElement;
|
||||||
|
const chipEl = (await chip.element()) as HTMLElement;
|
||||||
|
const addEl = (await add.element()) as HTMLElement;
|
||||||
|
|
||||||
|
expect(allEl.className).toContain('h-11');
|
||||||
|
expect(chipEl.className).toContain('h-11');
|
||||||
|
expect(addEl.className).toContain('h-11');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -25,6 +25,7 @@ const makeData = (overrides = {}) => ({
|
|||||||
},
|
},
|
||||||
canWrite: true,
|
canWrite: true,
|
||||||
canAnnotate: false,
|
canAnnotate: false,
|
||||||
|
canBlogWrite: false,
|
||||||
...overrides
|
...overrides
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ const baseData = {
|
|||||||
} as User,
|
} as User,
|
||||||
canWrite: true,
|
canWrite: true,
|
||||||
canAnnotate: false,
|
canAnnotate: false,
|
||||||
|
canBlogWrite: false,
|
||||||
resumeDoc: null,
|
resumeDoc: null,
|
||||||
pulse: null,
|
pulse: null,
|
||||||
activityFeed: [],
|
activityFeed: [],
|
||||||
|
|||||||
@@ -17,14 +17,18 @@ export async function load({ params, fetch, locals }) {
|
|||||||
receivedDocsResult,
|
receivedDocsResult,
|
||||||
aliasesResult,
|
aliasesResult,
|
||||||
relsResult,
|
relsResult,
|
||||||
inferredResult
|
inferredResult,
|
||||||
|
geschichtenResult
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
api.GET('/api/persons/{id}', { params: { path: { id } } }),
|
api.GET('/api/persons/{id}', { params: { path: { id } } }),
|
||||||
api.GET('/api/persons/{id}/documents', { params: { path: { id } } }),
|
api.GET('/api/persons/{id}/documents', { params: { path: { id } } }),
|
||||||
api.GET('/api/persons/{id}/received-documents', { params: { path: { id } } }),
|
api.GET('/api/persons/{id}/received-documents', { params: { path: { id } } }),
|
||||||
api.GET('/api/persons/{id}/aliases', { params: { path: { id } } }),
|
api.GET('/api/persons/{id}/aliases', { params: { path: { id } } }),
|
||||||
api.GET('/api/persons/{id}/relationships', { params: { path: { id } } }),
|
api.GET('/api/persons/{id}/relationships', { params: { path: { id } } }),
|
||||||
api.GET('/api/persons/{id}/inferred-relationships', { params: { path: { id } } })
|
api.GET('/api/persons/{id}/inferred-relationships', { params: { path: { id } } }),
|
||||||
|
api.GET('/api/geschichten', {
|
||||||
|
params: { query: { status: 'PUBLISHED', personId: id } }
|
||||||
|
})
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!personResult.response.ok) {
|
if (!personResult.response.ok) {
|
||||||
@@ -39,6 +43,7 @@ export async function load({ params, fetch, locals }) {
|
|||||||
aliases: aliasesResult.data ?? [],
|
aliases: aliasesResult.data ?? [],
|
||||||
relationships: relsResult.data ?? [],
|
relationships: relsResult.data ?? [],
|
||||||
inferredRelationships: inferredResult.data ?? [],
|
inferredRelationships: inferredResult.data ?? [],
|
||||||
|
geschichten: geschichtenResult.data ?? [],
|
||||||
canWrite
|
canWrite
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import NameHistoryCard from './NameHistoryCard.svelte';
|
|||||||
import CoCorrespondentsList from './CoCorrespondentsList.svelte';
|
import CoCorrespondentsList from './CoCorrespondentsList.svelte';
|
||||||
import PersonDocumentList from './PersonDocumentList.svelte';
|
import PersonDocumentList from './PersonDocumentList.svelte';
|
||||||
import PersonRelationshipsCard from './PersonRelationshipsCard.svelte';
|
import PersonRelationshipsCard from './PersonRelationshipsCard.svelte';
|
||||||
|
import GeschichtenCard from '$lib/components/GeschichtenCard.svelte';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
@@ -92,6 +93,17 @@ const coCorrespondents = $derived.by(() => {
|
|||||||
emptyMessage={m.person_no_received_docs()}
|
emptyMessage={m.person_no_received_docs()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if data.geschichten && data.geschichten.length > 0}
|
||||||
|
<div class="mt-6">
|
||||||
|
<GeschichtenCard
|
||||||
|
geschichten={data.geschichten}
|
||||||
|
personId={person.id}
|
||||||
|
personName={person.displayName}
|
||||||
|
canWrite={data.canBlogWrite ?? false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ describe('person detail load — happy path', () => {
|
|||||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
||||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
||||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
||||||
|
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
||||||
} as ReturnType<typeof createApiClient>);
|
} as ReturnType<typeof createApiClient>);
|
||||||
|
|
||||||
const result = await load({ params: { id: 'p1' }, fetch: mockFetch, locals: mockLocals });
|
const result = await load({ params: { id: 'p1' }, fetch: mockFetch, locals: mockLocals });
|
||||||
@@ -51,6 +52,7 @@ describe('person detail load — happy path', () => {
|
|||||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
||||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
||||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
||||||
|
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
||||||
} as ReturnType<typeof createApiClient>);
|
} as ReturnType<typeof createApiClient>);
|
||||||
|
|
||||||
const result = await load({ params: { id: 'p1' }, fetch: mockFetch, locals: mockLocalsWriter });
|
const result = await load({ params: { id: 'p1' }, fetch: mockFetch, locals: mockLocalsWriter });
|
||||||
@@ -71,6 +73,7 @@ describe('person detail load — happy path', () => {
|
|||||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
||||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
||||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
||||||
|
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
||||||
} as ReturnType<typeof createApiClient>);
|
} as ReturnType<typeof createApiClient>);
|
||||||
|
|
||||||
const result = await load({ params: { id: 'p1' }, fetch: mockFetch, locals: mockLocals });
|
const result = await load({ params: { id: 'p1' }, fetch: mockFetch, locals: mockLocals });
|
||||||
@@ -93,6 +96,7 @@ describe('person detail load — error paths', () => {
|
|||||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
||||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
||||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
||||||
|
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
||||||
} as ReturnType<typeof createApiClient>);
|
} as ReturnType<typeof createApiClient>);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
@@ -112,6 +116,7 @@ describe('person detail load — error paths', () => {
|
|||||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
||||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
||||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
||||||
|
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
||||||
} as ReturnType<typeof createApiClient>);
|
} as ReturnType<typeof createApiClient>);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ const emptyData = {
|
|||||||
user: undefined,
|
user: undefined,
|
||||||
canWrite: true,
|
canWrite: true,
|
||||||
canAnnotate: false,
|
canAnnotate: false,
|
||||||
|
canBlogWrite: false,
|
||||||
q: '',
|
q: '',
|
||||||
persons: [],
|
persons: [],
|
||||||
stats: defaultStats
|
stats: defaultStats
|
||||||
|
|||||||
2534
frontend/yarn.lock
2534
frontend/yarn.lock
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user