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
|
||||
**/test-results/
|
||||
.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>
|
||||
<version>3.12.0</version>
|
||||
</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>
|
||||
|
||||
|
||||
|
||||
@@ -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 */
|
||||
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 ---
|
||||
/** A tag with the given ID does not exist. 404 */
|
||||
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,
|
||||
WRITE_ALL,
|
||||
ANNOTATE_ALL,
|
||||
BLOG_WRITE,
|
||||
ADMIN,
|
||||
ADMIN_USER,
|
||||
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-results/
|
||||
/test-results.locked/
|
||||
/e2e/.auth/
|
||||
/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_annotate_all": "Lesen & Annotieren",
|
||||
"admin_perm_write_all": "Lesen & Schreiben",
|
||||
"admin_perm_blog_write": "Geschichten schreiben",
|
||||
"admin_perm_admin": "Vollzugriff (Admin)",
|
||||
"admin_perm_admin_user": "Benutzer verwalten",
|
||||
"admin_perm_admin_tag": "Schlagworte verwalten",
|
||||
@@ -919,6 +920,59 @@
|
||||
"bulk_edit_count_pill": "{count} werden bearbeitet",
|
||||
|
||||
"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_circular_relationship": "Diese Beziehung würde einen Kreis erzeugen.",
|
||||
|
||||
@@ -228,6 +228,7 @@
|
||||
"admin_perm_read_all": "Read only",
|
||||
"admin_perm_annotate_all": "Read & Annotate",
|
||||
"admin_perm_write_all": "Read & Write",
|
||||
"admin_perm_blog_write": "Write stories",
|
||||
"admin_perm_admin": "Full access (Admin)",
|
||||
"admin_perm_admin_user": "Manage users",
|
||||
"admin_perm_admin_tag": "Manage tags",
|
||||
@@ -919,6 +920,59 @@
|
||||
"bulk_edit_count_pill": "{count} will be edited",
|
||||
|
||||
"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_circular_relationship": "This relationship would form a cycle.",
|
||||
|
||||
@@ -228,6 +228,7 @@
|
||||
"admin_perm_read_all": "Solo lectura",
|
||||
"admin_perm_annotate_all": "Leer y anotar",
|
||||
"admin_perm_write_all": "Leer y escribir",
|
||||
"admin_perm_blog_write": "Escribir historias",
|
||||
"admin_perm_admin": "Acceso completo (Admin)",
|
||||
"admin_perm_admin_user": "Gestionar usuarios",
|
||||
"admin_perm_admin_tag": "Gestionar etiquetas",
|
||||
@@ -919,6 +920,59 @@
|
||||
"bulk_edit_count_pill": "Se editarán {count}",
|
||||
|
||||
"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_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/starter-kit": "3.22.5",
|
||||
"diff": "^8.0.3",
|
||||
"isomorphic-dompurify": "^3.12.0",
|
||||
"openapi-fetch": "^0.13.5",
|
||||
"pdfjs-dist": "^5.5.207"
|
||||
},
|
||||
@@ -51,6 +52,53 @@
|
||||
"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": {
|
||||
"version": "4.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.11.1.tgz",
|
||||
@@ -146,6 +194,152 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "0.27.4",
|
||||
"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_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": {
|
||||
"version": "0.19.1",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
||||
@@ -2655,7 +2866,7 @@
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
@@ -3307,6 +3518,15 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
@@ -3495,6 +3715,19 @@
|
||||
"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": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||
@@ -3508,6 +3741,19 @@
|
||||
"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": {
|
||||
"version": "4.4.3",
|
||||
"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": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.1.tgz",
|
||||
@@ -3584,6 +3836,15 @@
|
||||
"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": {
|
||||
"version": "5.20.0",
|
||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz",
|
||||
@@ -3598,6 +3859,18 @@
|
||||
"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": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz",
|
||||
@@ -4084,6 +4357,18 @@
|
||||
"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": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
|
||||
@@ -4211,6 +4496,12 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz",
|
||||
@@ -4228,6 +4519,19 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "3.2.2",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
|
||||
@@ -4706,6 +5050,15 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
@@ -4744,6 +5097,12 @@
|
||||
"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": {
|
||||
"version": "1.4.4",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
@@ -5479,7 +5850,6 @@
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
@@ -5503,7 +5873,6 @@
|
||||
"version": "2.0.2",
|
||||
"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==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -5604,6 +5973,18 @@
|
||||
"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": {
|
||||
"version": "7.7.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||
@@ -5673,7 +6054,6 @@
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -5851,6 +6231,12 @@
|
||||
"@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": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz",
|
||||
@@ -5916,6 +6302,24 @@
|
||||
"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": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
|
||||
@@ -5926,6 +6330,30 @@
|
||||
"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": {
|
||||
"version": "2.4.0",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "7.16.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
||||
@@ -6314,6 +6751,27 @@
|
||||
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
||||
"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": {
|
||||
"version": "0.6.2",
|
||||
"resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
|
||||
@@ -6321,6 +6779,29 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "2.0.2",
|
||||
"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": {
|
||||
"version": "0.0.43",
|
||||
"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/starter-kit": "3.22.5",
|
||||
"diff": "^8.0.3",
|
||||
"isomorphic-dompurify": "^3.12.0",
|
||||
"openapi-fetch": "^0.13.5",
|
||||
"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 Tag = { id: string; name: string };
|
||||
type GeschichteSummary = {
|
||||
id: string;
|
||||
title: string;
|
||||
publishedAt?: string;
|
||||
author?: { firstName?: string; lastName?: string; email: string };
|
||||
};
|
||||
|
||||
type Props = {
|
||||
documentDate: string | null;
|
||||
@@ -16,6 +22,9 @@ type Props = {
|
||||
receivers: Person[];
|
||||
tags: Tag[];
|
||||
inferredRelationship?: { labelFromA: string; labelFromB: string } | null;
|
||||
geschichten?: GeschichteSummary[];
|
||||
documentId?: string;
|
||||
canBlogWrite?: boolean;
|
||||
};
|
||||
|
||||
let {
|
||||
@@ -25,10 +34,30 @@ let {
|
||||
sender,
|
||||
receivers,
|
||||
tags,
|
||||
inferredRelationship = null
|
||||
inferredRelationship = null,
|
||||
geschichten = [],
|
||||
documentId,
|
||||
canBlogWrite = false
|
||||
}: Props = $props();
|
||||
|
||||
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 displayLocation = $derived(location ?? '—');
|
||||
@@ -67,7 +96,7 @@ function getFullName(person: Person): string {
|
||||
{/snippet}
|
||||
|
||||
<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 -->
|
||||
<div>
|
||||
<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>
|
||||
{/if}
|
||||
</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>
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
type GeschichteSummary = {
|
||||
id: string;
|
||||
title: string;
|
||||
publishedAt?: string;
|
||||
author?: { firstName?: string; lastName?: string; email: string };
|
||||
};
|
||||
|
||||
type Props = {
|
||||
doc: Doc;
|
||||
canWrite: boolean;
|
||||
fileUrl: string;
|
||||
transcribeMode: boolean;
|
||||
inferredRelationship?: { labelFromA: string; labelFromB: string } | null;
|
||||
geschichten?: GeschichteSummary[];
|
||||
canBlogWrite?: boolean;
|
||||
};
|
||||
|
||||
let {
|
||||
@@ -38,7 +47,9 @@ let {
|
||||
canWrite,
|
||||
fileUrl,
|
||||
transcribeMode = $bindable(),
|
||||
inferredRelationship = null
|
||||
inferredRelationship = null,
|
||||
geschichten = [],
|
||||
canBlogWrite = false
|
||||
}: Props = $props();
|
||||
|
||||
let detailsOpen = $state(false);
|
||||
@@ -283,6 +294,9 @@ let mobileMenuOpen = $state(false);
|
||||
receivers={doc.receivers ? [...doc.receivers] : []}
|
||||
tags={doc.tags ? [...doc.tags] : []}
|
||||
inferredRelationship={inferredRelationship}
|
||||
geschichten={geschichten}
|
||||
documentId={doc.id}
|
||||
canBlogWrite={canBlogWrite}
|
||||
/>
|
||||
</div>
|
||||
{/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'
|
||||
| 'CIRCULAR_RELATIONSHIP'
|
||||
| 'DUPLICATE_RELATIONSHIP'
|
||||
| 'GESCHICHTE_NOT_FOUND'
|
||||
| 'MISSING_CREDENTIALS'
|
||||
| 'UNAUTHORIZED'
|
||||
| 'FORBIDDEN'
|
||||
@@ -145,6 +146,8 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string {
|
||||
return m.error_circular_relationship();
|
||||
case 'DUPLICATE_RELATIONSHIP':
|
||||
return m.error_duplicate_relationship();
|
||||
case 'GESCHICHTE_NOT_FOUND':
|
||||
return m.error_geschichte_not_found();
|
||||
case 'MISSING_CREDENTIALS':
|
||||
return m.login_error_missing_credentials();
|
||||
case 'UNAUTHORIZED':
|
||||
|
||||
@@ -388,6 +388,22 @@ export interface paths {
|
||||
patch?: 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": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -692,6 +708,22 @@ export interface paths {
|
||||
patch: operations["updateGroup"];
|
||||
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": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1807,6 +1839,31 @@ export interface components {
|
||||
name?: 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: {
|
||||
/** Format: int32 */
|
||||
pageNumber?: number;
|
||||
@@ -2083,9 +2140,9 @@ export interface components {
|
||||
deathYear?: number;
|
||||
familyMember?: boolean;
|
||||
notes?: string;
|
||||
alias?: string;
|
||||
/** Format: int64 */
|
||||
documentCount?: number;
|
||||
alias?: string;
|
||||
};
|
||||
InferredRelationshipWithPersonDTO: {
|
||||
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: {
|
||||
parameters: {
|
||||
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: {
|
||||
parameters: {
|
||||
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')),
|
||||
canAnnotate: groups.some(
|
||||
(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()}
|
||||
</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}
|
||||
<a
|
||||
href="/admin"
|
||||
@@ -170,6 +180,16 @@ function handleOverlayKeydown(event: KeyboardEvent) {
|
||||
{m.nav_stammbaum()}
|
||||
</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}
|
||||
<a
|
||||
href="/admin"
|
||||
|
||||
@@ -27,7 +27,8 @@ $effect(() => {
|
||||
const STANDARD_PERMISSIONS = $derived([
|
||||
{ value: 'READ_ALL', label: m.admin_perm_read_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([
|
||||
|
||||
@@ -6,7 +6,8 @@ import { m } from '$lib/paraglide/messages.js';
|
||||
const availableStandard = $derived([
|
||||
{ value: 'READ_ALL', label: m.admin_perm_read_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([
|
||||
{ value: 'ADMIN', label: m.admin_perm_admin() },
|
||||
|
||||
@@ -41,6 +41,7 @@ const baseData = {
|
||||
user: undefined,
|
||||
canWrite: true,
|
||||
canAnnotate: false,
|
||||
canBlogWrite: false,
|
||||
editUser: makeUser(),
|
||||
groups
|
||||
};
|
||||
|
||||
@@ -10,7 +10,13 @@ const groups = [
|
||||
{ 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);
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ const baseData = {
|
||||
user: undefined,
|
||||
canWrite: true,
|
||||
canAnnotate: false,
|
||||
canBlogWrite: false,
|
||||
documents: [],
|
||||
initialValues: { senderName: '', receiverName: '' },
|
||||
filters: { senderId: '', receiverId: '', from: '', to: '', dir: 'DESC' as const }
|
||||
|
||||
@@ -7,7 +7,12 @@ export async function load({ params, fetch }) {
|
||||
const { id } = params;
|
||||
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');
|
||||
|
||||
@@ -18,8 +23,9 @@ export async function load({ params, fetch }) {
|
||||
|
||||
const document = docResult.data!;
|
||||
const inferredRelationship = await loadInferredRelationship(api, document);
|
||||
const geschichten = geschichtenResult.data ?? [];
|
||||
|
||||
return { document, inferredRelationship };
|
||||
return { document, inferredRelationship, geschichten };
|
||||
}
|
||||
|
||||
async function loadInferredRelationship(
|
||||
|
||||
@@ -424,6 +424,8 @@ onMount(() => {
|
||||
fileUrl={fileLoader.fileUrl}
|
||||
bind:transcribeMode={transcribeMode}
|
||||
inferredRelationship={data.inferredRelationship}
|
||||
geschichten={data.geschichten ?? []}
|
||||
canBlogWrite={data.canBlogWrite ?? false}
|
||||
/>
|
||||
|
||||
<div class="relative flex-1 overflow-hidden {transcribeMode ? 'flex flex-col md:flex-row' : ''}">
|
||||
|
||||
@@ -11,6 +11,7 @@ const baseData = {
|
||||
user: undefined,
|
||||
canWrite: true,
|
||||
canAnnotate: false,
|
||||
canBlogWrite: false,
|
||||
persons: [],
|
||||
initialSenderId: '',
|
||||
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,
|
||||
canAnnotate: false,
|
||||
canBlogWrite: false,
|
||||
...overrides
|
||||
});
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ const baseData = {
|
||||
} as User,
|
||||
canWrite: true,
|
||||
canAnnotate: false,
|
||||
canBlogWrite: false,
|
||||
resumeDoc: null,
|
||||
pulse: null,
|
||||
activityFeed: [],
|
||||
|
||||
@@ -17,14 +17,18 @@ export async function load({ params, fetch, locals }) {
|
||||
receivedDocsResult,
|
||||
aliasesResult,
|
||||
relsResult,
|
||||
inferredResult
|
||||
inferredResult,
|
||||
geschichtenResult
|
||||
] = await Promise.all([
|
||||
api.GET('/api/persons/{id}', { 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}/aliases', { 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) {
|
||||
@@ -39,6 +43,7 @@ export async function load({ params, fetch, locals }) {
|
||||
aliases: aliasesResult.data ?? [],
|
||||
relationships: relsResult.data ?? [],
|
||||
inferredRelationships: inferredResult.data ?? [],
|
||||
geschichten: geschichtenResult.data ?? [],
|
||||
canWrite
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import NameHistoryCard from './NameHistoryCard.svelte';
|
||||
import CoCorrespondentsList from './CoCorrespondentsList.svelte';
|
||||
import PersonDocumentList from './PersonDocumentList.svelte';
|
||||
import PersonRelationshipsCard from './PersonRelationshipsCard.svelte';
|
||||
import GeschichtenCard from '$lib/components/GeschichtenCard.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
@@ -92,6 +93,17 @@ const coCorrespondents = $derived.by(() => {
|
||||
emptyMessage={m.person_no_received_docs()}
|
||||
/>
|
||||
</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>
|
||||
|
||||
@@ -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: [] })
|
||||
} as ReturnType<typeof createApiClient>);
|
||||
|
||||
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: [] })
|
||||
} as ReturnType<typeof createApiClient>);
|
||||
|
||||
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: [] })
|
||||
} as ReturnType<typeof createApiClient>);
|
||||
|
||||
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: [] })
|
||||
} as ReturnType<typeof createApiClient>);
|
||||
|
||||
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: [] })
|
||||
} as ReturnType<typeof createApiClient>);
|
||||
|
||||
await expect(
|
||||
|
||||
@@ -21,6 +21,7 @@ const emptyData = {
|
||||
user: undefined,
|
||||
canWrite: true,
|
||||
canAnnotate: false,
|
||||
canBlogWrite: false,
|
||||
q: '',
|
||||
persons: [],
|
||||
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