feat(journey-editor): JourneyEditor frontend — issue #753 #792

Merged
marcel merged 92 commits from feat/issue-753-journey-editor into feat/issue-750-lesereisen-data-model 2026-06-11 12:07:23 +02:00
98 changed files with 5139 additions and 794 deletions

View File

@@ -155,7 +155,7 @@ Services are annotated with `@Service`, `@RequiredArgsConstructor`, and optional
### DTOs ### DTOs
Input DTOs live flat in the domain package. Response types are the model entities themselves (no response DTOs). Input DTOs live flat in the domain package. Response types are the model entities themselves (no response DTOs)**except the geschichte domain**, where every response is a view (`GeschichteView`/`GeschichteSummary`/`JourneyItemView`) assembled inside the service transaction and entities never cross the controller boundary. See [ADR-036](./docs/adr/036-geschichte-responses-are-views-not-entities.md) — lazy collections + `open-in-view: false` make serialized entities a 500 waiting to happen.
- `@Schema(requiredMode = REQUIRED)` on every field the backend always populates — drives TypeScript generation. - `@Schema(requiredMode = REQUIRED)` on every field the backend always populates — drives TypeScript generation.
@@ -163,7 +163,7 @@ Input DTOs live flat in the domain package. Response types are the model entitie
→ See [CONTRIBUTING.md §Error handling](./CONTRIBUTING.md#error-handling) → See [CONTRIBUTING.md §Error handling](./CONTRIBUTING.md#error-handling)
**LLM reminder:** use `DomainException.notFound/forbidden/conflict/internal()` from service methods — never throw raw exceptions. When adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded). **LLM reminder:** use `DomainException.notFound/forbidden/conflict/internal()` from service methods — never throw raw exceptions. When adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded); `JOURNEY_NOTE_TOO_LONG`, `JOURNEY_DOCUMENT_ALREADY_ADDED`, `GESCHICHTE_TYPE_IMMUTABLE`, `GESCHICHTE_TITLE_TOO_LONG`, `GESCHICHTE_INTRO_TOO_LONG` (journey/geschichte domain constraints).
### Security / Permissions ### Security / Permissions
@@ -271,7 +271,7 @@ Back button pattern — use the shared `<BackButton>` component from `$lib/share
→ See [CONTRIBUTING.md §Error handling](./CONTRIBUTING.md#error-handling) → See [CONTRIBUTING.md §Error handling](./CONTRIBUTING.md#error-handling)
**LLM reminder:** when adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded). **LLM reminder:** when adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded); `JOURNEY_NOTE_TOO_LONG`, `JOURNEY_DOCUMENT_ALREADY_ADDED`, `GESCHICHTE_TYPE_IMMUTABLE`, `GESCHICHTE_TITLE_TOO_LONG`, `GESCHICHTE_INTRO_TOO_LONG` (journey/geschichte domain constraints).
--- ---

View File

@@ -36,6 +36,13 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
@EntityGraph("Document.list") @EntityGraph("Document.list")
Page<Document> findAll(Pageable pageable); Page<Document> findAll(Pageable pageable);
// Loader for the relevance fast path: list-item enrichment reads tags after the
// repository call returns, so the fetch shape must match the spec-based findAll
// overloads above. Plain findAllById carries no entity graph and must not feed
// enrichItems — see DocumentService.relevanceSortedPageFromSql.
@EntityGraph("Document.list")
List<Document> findByIdIn(Collection<UUID> ids);
// Findet ein Dokument anhand des ursprünglichen Dateinamens // Findet ein Dokument anhand des ursprünglichen Dateinamens
// Wichtig für den Abgleich beim Excel-Import & Datei-Upload // Wichtig für den Abgleich beim Excel-Import & Datei-Upload
Optional<Document> findByOriginalFilename(String originalFilename); Optional<Document> findByOriginalFilename(String originalFilename);

View File

@@ -851,14 +851,14 @@ public class DocumentService {
FtsPage ftsPage = toFtsPage(documentRepository.findFtsPageRaw(text, offset, limit)); FtsPage ftsPage = toFtsPage(documentRepository.findFtsPageRaw(text, offset, limit));
if (ftsPage.hits().isEmpty()) return DocumentSearchResult.of(List.of()); if (ftsPage.hits().isEmpty()) return DocumentSearchResult.of(List.of());
// Preserve ts_rank order from SQL across the JPA findAllById call. // Preserve ts_rank order from SQL across the JPA findByIdIn call.
Map<UUID, Integer> rankMap = new HashMap<>(); Map<UUID, Integer> rankMap = new HashMap<>();
List<UUID> pageIds = new ArrayList<>(); List<UUID> pageIds = new ArrayList<>();
for (int i = 0; i < ftsPage.hits().size(); i++) { for (int i = 0; i < ftsPage.hits().size(); i++) {
rankMap.put(ftsPage.hits().get(i).id(), i); rankMap.put(ftsPage.hits().get(i).id(), i);
pageIds.add(ftsPage.hits().get(i).id()); pageIds.add(ftsPage.hits().get(i).id());
} }
List<Document> docs = documentRepository.findAllById(pageIds).stream() List<Document> docs = documentRepository.findByIdIn(pageIds).stream()
.sorted(Comparator.comparingInt(d -> rankMap.getOrDefault(d.getId(), Integer.MAX_VALUE))) .sorted(Comparator.comparingInt(d -> rankMap.getOrDefault(d.getId(), Integer.MAX_VALUE)))
.toList(); .toList();
return buildResultPaged(docs, text, pageable, ftsPage.total()); return buildResultPaged(docs, text, pageable, ftsPage.total());

View File

@@ -128,8 +128,18 @@ public enum ErrorCode {
JOURNEY_ITEM_POSITION_CONFLICT, JOURNEY_ITEM_POSITION_CONFLICT,
/** The journey already has the maximum allowed number of items (100). 400 */ /** The journey already has the maximum allowed number of items (100). 400 */
JOURNEY_AT_CAPACITY, JOURNEY_AT_CAPACITY,
/** The document is already present in this journey — duplicate items are not allowed. 409 */
JOURNEY_DOCUMENT_ALREADY_ADDED,
/** The Geschichte is not of type JOURNEY — journey-item operations are not allowed on it. 400 */ /** The Geschichte is not of type JOURNEY — journey-item operations are not allowed on it. 400 */
GESCHICHTE_TYPE_MISMATCH, GESCHICHTE_TYPE_MISMATCH,
/** The type of an existing Geschichte cannot be changed via PATCH. 409 */
GESCHICHTE_TYPE_IMMUTABLE,
/** A journey-item note exceeds the maximum length (2000 characters). 400 */
JOURNEY_NOTE_TOO_LONG,
/** A Geschichte title exceeds the maximum length (255 characters — the DB column bound). 400 */
GESCHICHTE_TITLE_TOO_LONG,
/** A JOURNEY intro (body) exceeds the maximum length (4000 characters). 400 */
GESCHICHTE_INTRO_TOO_LONG,
// --- Tags --- // --- Tags ---
/** A tag with the given ID does not exist. 404 */ /** A tag with the given ID does not exist. 404 */

View File

@@ -37,10 +37,12 @@ public class GeschichteController {
public List<GeschichteSummary> list( public List<GeschichteSummary> list(
@RequestParam(required = false) GeschichteStatus status, @RequestParam(required = false) GeschichteStatus status,
@RequestParam(name = "personId", required = false) List<UUID> personIds, @RequestParam(name = "personId", required = false) List<UUID> personIds,
@RequestParam(required = false) UUID documentId,
@RequestParam(required = false, defaultValue = "50") int limit) { @RequestParam(required = false, defaultValue = "50") int limit) {
return geschichteService.list( return geschichteService.list(
status, status,
personIds == null ? List.of() : personIds, personIds == null ? List.of() : personIds,
documentId,
limit); limit);
} }
@@ -51,14 +53,14 @@ public class GeschichteController {
@PostMapping @PostMapping
@RequirePermission(Permission.BLOG_WRITE) @RequirePermission(Permission.BLOG_WRITE)
public ResponseEntity<Geschichte> create(@RequestBody GeschichteUpdateDTO dto) { public ResponseEntity<GeschichteView> create(@RequestBody GeschichteUpdateDTO dto) {
Geschichte created = geschichteService.create(dto); GeschichteView created = geschichteService.create(dto);
return ResponseEntity.status(HttpStatus.CREATED).body(created); return ResponseEntity.status(HttpStatus.CREATED).body(created);
} }
@PatchMapping("/{id}") @PatchMapping("/{id}")
@RequirePermission(Permission.BLOG_WRITE) @RequirePermission(Permission.BLOG_WRITE)
public Geschichte update(@PathVariable UUID id, @RequestBody GeschichteUpdateDTO dto) { public GeschichteView update(@PathVariable UUID id, @RequestBody GeschichteUpdateDTO dto) {
return geschichteService.update(id, dto); return geschichteService.update(id, dto);
} }

View File

@@ -25,7 +25,7 @@ public interface GeschichteRepository extends JpaRepository<Geschichte, UUID>, J
*/ */
@Query(""" @Query("""
SELECT g.id AS id, g.title AS title, g.status AS status, g.type AS type, SELECT g.id AS id, g.title AS title, g.status AS status, g.type AS type,
g.author AS author, g.publishedAt AS publishedAt, g.body AS body g.author AS author, g.publishedAt AS publishedAt, g.updatedAt AS updatedAt, g.body AS body
FROM Geschichte g FROM Geschichte g
WHERE g.status = :effectiveStatus WHERE g.status = :effectiveStatus
AND (:authorId IS NULL OR g.author.id = :authorId) AND (:authorId IS NULL OR g.author.id = :authorId)
@@ -33,11 +33,15 @@ public interface GeschichteRepository extends JpaRepository<Geschichte, UUID>, J
(SELECT COUNT(DISTINCT p.id) (SELECT COUNT(DISTINCT p.id)
FROM Geschichte g2 JOIN g2.persons p FROM Geschichte g2 JOIN g2.persons p
WHERE g2.id = g.id AND p.id IN :personIds) = :personCount) WHERE g2.id = g.id AND p.id IN :personIds) = :personCount)
AND (:documentId IS NULL OR
EXISTS (SELECT 1 FROM JourneyItem ji
WHERE ji.geschichte = g AND ji.document.id = :documentId))
ORDER BY COALESCE(g.publishedAt, g.updatedAt) DESC ORDER BY COALESCE(g.publishedAt, g.updatedAt) DESC
""") """)
List<GeschichteSummary> findSummaries( List<GeschichteSummary> findSummaries(
@Param("effectiveStatus") GeschichteStatus effectiveStatus, @Param("effectiveStatus") GeschichteStatus effectiveStatus,
@Param("authorId") UUID authorId, @Param("authorId") UUID authorId,
@Param("personIds") Collection<UUID> personIds, @Param("personIds") Collection<UUID> personIds,
@Param("personCount") long personCount); @Param("personCount") long personCount,
@Param("documentId") UUID documentId);
} }

View File

@@ -50,6 +50,15 @@ public class GeschichteService {
private static final int DEFAULT_LIMIT = 50; private static final int DEFAULT_LIMIT = 50;
private static final int MAX_LIMIT = 200; private static final int MAX_LIMIT = 200;
// Matches the geschichten.title VARCHAR(255) column (V58) — the service check
// turns what would be a DB-level 500 into a friendly 400.
static final int MAX_TITLE_LENGTH = 255;
// JOURNEY intros travel the verbatim (unsanitized) write path, so they get the
// same three-layer bound as journey notes: frontend maxlength, this check, and
// the V75 CHECK constraint. STORY bodies are sanitized Tiptap HTML and stay
// unbounded on purpose.
static final int MAX_INTRO_LENGTH = 4000;
// ─── Read API ──────────────────────────────────────────────────────────── // ─── Read API ────────────────────────────────────────────────────────────
public long countPublished() { public long countPublished() {
@@ -105,7 +114,7 @@ public class GeschichteService {
* <p>Returns a {@link GeschichteSummary} projection — never carries items, preventing * <p>Returns a {@link GeschichteSummary} projection — never carries items, preventing
* LazyInitializationException on the non-transactional list path. * LazyInitializationException on the non-transactional list path.
*/ */
public List<GeschichteSummary> list(GeschichteStatus status, List<UUID> personIds, int limit) { public List<GeschichteSummary> list(GeschichteStatus status, List<UUID> personIds, UUID documentId, int limit) {
GeschichteStatus effective = currentUserHasBlogWrite() ? status : GeschichteStatus.PUBLISHED; GeschichteStatus effective = currentUserHasBlogWrite() ? status : GeschichteStatus.PUBLISHED;
int safeLimit = limit <= 0 ? DEFAULT_LIMIT : Math.min(limit, MAX_LIMIT); int safeLimit = limit <= 0 ? DEFAULT_LIMIT : Math.min(limit, MAX_LIMIT);
@@ -119,7 +128,7 @@ public class GeschichteService {
long personCount = (personIds == null) ? 0 : personIds.size(); long personCount = (personIds == null) ? 0 : personIds.size();
return geschichteRepository return geschichteRepository
.findSummaries(effective, authorId, safePersonIds, personCount) .findSummaries(effective, authorId, safePersonIds, personCount, documentId)
.stream() .stream()
.limit(safeLimit) .limit(safeLimit)
.toList(); .toList();
@@ -127,13 +136,19 @@ public class GeschichteService {
// ─── Write API ─────────────────────────────────────────────────────────── // ─── Write API ───────────────────────────────────────────────────────────
// Write methods return GeschichteView, never the entity: Jackson serializes after
// the transaction closed, where the lazy items collection is a dead proxy.
// The view is assembled in-transaction, so no force-init tricks are needed.
@Transactional @Transactional
public Geschichte create(GeschichteUpdateDTO dto) { public GeschichteView create(GeschichteUpdateDTO dto) {
requireTitle(dto.getTitle()); requireTitle(dto.getTitle());
GeschichteType type = dto.getType() != null ? dto.getType() : GeschichteType.STORY;
Geschichte g = Geschichte.builder() Geschichte g = Geschichte.builder()
.title(dto.getTitle().trim()) .title(dto.getTitle().trim())
.body(sanitize(dto.getBody())) .body(bodyForType(type, dto.getBody()))
.status(GeschichteStatus.DRAFT) .status(GeschichteStatus.DRAFT)
.type(type)
.author(currentUser()) .author(currentUser())
.persons(resolvePersons(dto.getPersonIds())) .persons(resolvePersons(dto.getPersonIds()))
.build(); .build();
@@ -141,20 +156,28 @@ public class GeschichteService {
g.setStatus(GeschichteStatus.PUBLISHED); g.setStatus(GeschichteStatus.PUBLISHED);
g.setPublishedAt(LocalDateTime.now()); g.setPublishedAt(LocalDateTime.now());
} }
return geschichteRepository.save(g); Geschichte saved = geschichteRepository.save(g);
// A freshly created Geschichte has no items by construction — items are only
// addable via the separate /items endpoints. Revisit if a create DTO ever
// accepts initial items.
return toView(saved, List.of());
} }
@Transactional @Transactional
public Geschichte update(UUID id, GeschichteUpdateDTO dto) { public GeschichteView update(UUID id, GeschichteUpdateDTO dto) {
Geschichte g = geschichteRepository.findById(id) Geschichte g = geschichteRepository.findById(id)
.orElseThrow(() -> DomainException.notFound( .orElseThrow(() -> DomainException.notFound(
ErrorCode.GESCHICHTE_NOT_FOUND, "Geschichte not found: " + id)); ErrorCode.GESCHICHTE_NOT_FOUND, "Geschichte not found: " + id));
if (dto.getType() != null && dto.getType() != g.getType()) {
throw DomainException.conflict(ErrorCode.GESCHICHTE_TYPE_IMMUTABLE,
"The type of a Geschichte cannot be changed after creation");
}
if (dto.getTitle() != null) { if (dto.getTitle() != null) {
requireTitle(dto.getTitle()); requireTitle(dto.getTitle());
g.setTitle(dto.getTitle().trim()); g.setTitle(dto.getTitle().trim());
} }
if (dto.getBody() != null) { if (dto.getBody() != null) {
g.setBody(sanitize(dto.getBody())); g.setBody(bodyForType(g.getType(), dto.getBody()));
} }
if (dto.getPersonIds() != null) { if (dto.getPersonIds() != null) {
g.setPersons(resolvePersons(dto.getPersonIds())); g.setPersons(resolvePersons(dto.getPersonIds()));
@@ -162,7 +185,8 @@ public class GeschichteService {
if (dto.getStatus() != null && dto.getStatus() != g.getStatus()) { if (dto.getStatus() != null && dto.getStatus() != g.getStatus()) {
applyStatusTransition(g, dto.getStatus()); applyStatusTransition(g, dto.getStatus());
} }
return geschichteRepository.save(g); Geschichte saved = geschichteRepository.save(g);
return toView(saved, journeyItemService.getItems(id));
} }
@Transactional @Transactional
@@ -190,6 +214,27 @@ public class GeschichteService {
throw DomainException.badRequest( throw DomainException.badRequest(
ErrorCode.VALIDATION_ERROR, "Title is required"); ErrorCode.VALIDATION_ERROR, "Title is required");
} }
if (title.trim().length() > MAX_TITLE_LENGTH) {
throw DomainException.badRequest(ErrorCode.GESCHICHTE_TITLE_TOO_LONG,
"Title exceeds maximum length of " + MAX_TITLE_LENGTH + " characters");
}
}
/**
* STORY bodies are Tiptap HTML and go through the OWASP allow-list sanitizer.
* JOURNEY intros are plain text: the reader renders them via Svelte text
* interpolation (never {@code {@html}}), so entity-encoding them here would
* corrupt content ("&" → "&amp;") and re-encode on every editor round-trip.
*/
private String bodyForType(GeschichteType type, String body) {
if (type != GeschichteType.JOURNEY) {
return sanitize(body);
}
if (body != null && body.length() > MAX_INTRO_LENGTH) {
throw DomainException.badRequest(ErrorCode.GESCHICHTE_INTRO_TOO_LONG,
"Intro exceeds maximum length of " + MAX_INTRO_LENGTH + " characters");
}
return body;
} }
private String sanitize(String body) { private String sanitize(String body) {

View File

@@ -31,12 +31,15 @@ public interface GeschichteSummary {
LocalDateTime getPublishedAt(); LocalDateTime getPublishedAt();
/** Always set (@UpdateTimestamp) — drives "bearbeitet vor X" on dashboard cards. */
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
LocalDateTime getUpdatedAt();
String getBody(); String getBody();
/** Author projection — names only; never email or group memberships (same rule as GeschichteView.AuthorView). */
interface AuthorSummary { interface AuthorSummary {
String getFirstName(); String getFirstName();
String getLastName(); String getLastName();
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
String getEmail();
} }
} }

View File

@@ -15,5 +15,6 @@ public class GeschichteUpdateDTO {
private String title; private String title;
private String body; private String body;
private GeschichteStatus status; private GeschichteStatus status;
private GeschichteType type;
private List<UUID> personIds; private List<UUID> personIds;
} }

View File

@@ -30,6 +30,19 @@ public interface JourneyItemRepository extends JpaRepository<JourneyItem, UUID>
/** COUNT for the 100-item cap check — COUNT(*)-based, never MAX(position)-derived. */ /** COUNT for the 100-item cap check — COUNT(*)-based, never MAX(position)-derived. */
long countByGeschichteId(UUID geschichteId); long countByGeschichteId(UUID geschichteId);
/**
* Dedup guard: true when the document is already linked to this journey.
* Explicit JPQL, not a derived query: the transient {@code getDocumentId()}
* getter on JourneyItem makes Spring Data resolve the derived path as a
* direct {@code documentId} attribute, which Hibernate cannot map.
*/
@Query("""
SELECT COUNT(i) > 0 FROM JourneyItem i
WHERE i.geschichte.id = :geschichteId AND i.document.id = :documentId
""")
boolean existsByGeschichteIdAndDocumentId(
@Param("geschichteId") UUID geschichteId, @Param("documentId") UUID documentId);
/** /**
* Loads journey items with their linked Document in a single JOIN FETCH query, * Loads journey items with their linked Document in a single JOIN FETCH query,
* eliminating the N+1 SELECT that would occur when accessing item.getDocument() * eliminating the N+1 SELECT that would occur when accessing item.getDocument()

View File

@@ -18,6 +18,7 @@ import org.raddatz.familienarchiv.user.AppUser;
import org.raddatz.familienarchiv.user.UserService; import org.raddatz.familienarchiv.user.UserService;
import org.springframework.security.core.Authentication; import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -30,7 +31,8 @@ public class JourneyItemService {
static final int MAX_ITEMS = 100; static final int MAX_ITEMS = 100;
static final int POSITION_STEP = 10; static final int POSITION_STEP = 10;
static final int MAX_NOTE_LENGTH = 5000; // 2000 per the editor spec — frontend maxlength and the i18n error message agree (#793).
static final int MAX_NOTE_LENGTH = 2000;
private final JourneyItemRepository journeyItemRepository; private final JourneyItemRepository journeyItemRepository;
private final GeschichteQueryService geschichteQueryService; private final GeschichteQueryService geschichteQueryService;
@@ -63,12 +65,16 @@ public class JourneyItemService {
} }
if (note != null && note.length() > MAX_NOTE_LENGTH) { if (note != null && note.length() > MAX_NOTE_LENGTH) {
throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR, throw DomainException.badRequest(ErrorCode.JOURNEY_NOTE_TOO_LONG,
"Note exceeds maximum length of " + MAX_NOTE_LENGTH + " characters"); "Note exceeds maximum length of " + MAX_NOTE_LENGTH + " characters");
} }
Document doc = null; Document doc = null;
if (dto.getDocumentId() != null) { if (dto.getDocumentId() != null) {
if (journeyItemRepository.existsByGeschichteIdAndDocumentId(geschichteId, dto.getDocumentId())) {
throw DomainException.conflict(ErrorCode.JOURNEY_DOCUMENT_ALREADY_ADDED,
"Document already in journey: " + dto.getDocumentId());
}
doc = documentService.findSummaryByIdInternal(dto.getDocumentId()); doc = documentService.findSummaryByIdInternal(dto.getDocumentId());
} }
@@ -82,7 +88,22 @@ public class JourneyItemService {
.document(doc) .document(doc)
.note(note) .note(note)
.build(); .build();
JourneyItem saved = journeyItemRepository.save(item); // saveAndFlush so the partial unique index on (geschichte_id, document_id)
// fires here, not at commit — two concurrent appends can both pass the
// exists() pre-check above, and the index is the atomic backstop (V74).
JourneyItem saved;
try {
saved = journeyItemRepository.saveAndFlush(item);
} catch (DataIntegrityViolationException e) {
// Only the dedup index earns the friendly 409 — any other integrity
// failure (e.g. an FK violation on a concurrently deleted document)
// must not be mislabeled as "already added".
if (!isDuplicateDocumentViolation(e)) {
throw e;
}
throw DomainException.conflict(ErrorCode.JOURNEY_DOCUMENT_ALREADY_ADDED,
"Document already in journey: " + dto.getDocumentId());
}
UUID actorId = currentUser().getId(); UUID actorId = currentUser().getId();
auditService.logAfterCommit(AuditKind.JOURNEY_ITEM_ADDED, actorId, null, auditService.logAfterCommit(AuditKind.JOURNEY_ITEM_ADDED, actorId, null,
@@ -106,7 +127,7 @@ public class JourneyItemService {
String note = normalizeNote(noteField.orElse(null)); String note = normalizeNote(noteField.orElse(null));
if (note != null && note.length() > MAX_NOTE_LENGTH) { if (note != null && note.length() > MAX_NOTE_LENGTH) {
throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR, throw DomainException.badRequest(ErrorCode.JOURNEY_NOTE_TOO_LONG,
"Note exceeds maximum length of " + MAX_NOTE_LENGTH + " characters"); "Note exceeds maximum length of " + MAX_NOTE_LENGTH + " characters");
} }
@@ -234,6 +255,12 @@ public class JourneyItemService {
.orElse(null); .orElse(null);
} }
private static boolean isDuplicateDocumentViolation(DataIntegrityViolationException e) {
Throwable cause = e.getMostSpecificCause();
String message = cause != null ? cause.getMessage() : e.getMessage();
return message != null && message.contains("uq_journey_items_geschichte_document");
}
private static String normalizeNote(String raw) { private static String normalizeNote(String raw) {
if (raw == null || raw.isBlank()) return null; if (raw == null || raw.isBlank()) return null;
return raw.trim(); return raw.trim();

View File

@@ -0,0 +1,37 @@
-- Two constraints the service-level checks need as atomic backstops:
--
-- 1. Partial unique index on (geschichte_id, document_id): the append dedup
-- guard is a check-then-insert (existsByGeschichteIdAndDocumentId), so two
-- concurrent appends of the same document can both pass the pre-check.
-- The index rejects the second INSERT; JourneyItemService.append translates
-- the DataIntegrityViolationException into the same 409
-- JOURNEY_DOCUMENT_ALREADY_ADDED as the friendly pre-check.
-- Partial (WHERE document_id IS NOT NULL) — note-only interludes must not collide.
--
-- 2. CHECK on note length: mirrors chk_text_length on transcription_blocks.
-- 2000 is the spec'd limit — JourneyItemService.MAX_NOTE_LENGTH, the frontend
-- maxlength, and the i18n error message all agree (#793).
--
-- Defensive cleanup first: a database that served writes on the base branch
-- (no dedup guard, MAX_NOTE_LENGTH = 5000) can hold rows that would make the
-- DDL below fail mid-migration and boot-loop the backend on a failed Flyway
-- row. Both statements are no-ops on a clean database.
-- Keep the earliest-positioned row of each (geschichte, document) pair.
DELETE FROM journey_items a
USING journey_items b
WHERE a.geschichte_id = b.geschichte_id
AND a.document_id = b.document_id
AND a.document_id IS NOT NULL
AND a.position > b.position;
-- Clamp over-long notes written under the old 5000-char service limit.
UPDATE journey_items SET note = left(note, 2000) WHERE length(note) > 2000;
CREATE UNIQUE INDEX uq_journey_items_geschichte_document
ON journey_items (geschichte_id, document_id)
WHERE document_id IS NOT NULL;
ALTER TABLE journey_items
ADD CONSTRAINT chk_journey_item_note_length
CHECK (note IS NULL OR length(note) <= 2000);

View File

@@ -0,0 +1,16 @@
-- JOURNEY intros travel the verbatim (unsanitized) write path and get the same
-- three-layer bound as journey notes: frontend maxlength, the
-- GeschichteService.MAX_INTRO_LENGTH check, and this CHECK as the atomic backstop.
-- STORY bodies are sanitized Tiptap HTML and stay unbounded on purpose.
--
-- The title needs no CHECK here — VARCHAR(255) (V58) already bounds it at the
-- DB layer; the service-level check exists to turn that 500 into a friendly 400.
-- Defensive clamp first: intros written before this migration may exceed the
-- cap. No-op on a clean database.
UPDATE geschichten SET body = left(body, 4000)
WHERE type = 'JOURNEY' AND length(body) > 4000;
ALTER TABLE geschichten
ADD CONSTRAINT chk_geschichte_journey_intro_length
CHECK (type <> 'JOURNEY' OR body IS NULL OR length(body) <= 4000);

View File

@@ -131,6 +131,28 @@ class DocumentLazyLoadingTest {
.doesNotThrowAnyException(); .doesNotThrowAnyException();
} }
@Test
void searchDocuments_pureTextRelevance_doesNotThrowLazyInitializationException() {
// q + default sort + no other filters → the relevance fast path
// (relevanceSortedPageFromSql), which loads documents by id outside any
// transaction and must still deliver an initialized tags collection.
Person sender = savedPerson("Hans", "FtSender");
Tag tag = savedTag("FtTag");
savedDocument("Brief von Walter", "ft_doc.pdf", sender, Set.of(), Set.of(tag));
SearchFilters textOnly = new SearchFilters(
"Walter", null, null, null, null, null, null, null, null, false);
DocumentSearchResult result = documentService.searchDocuments(
textOnly, null, "DESC", PageRequest.of(0, 10));
assertThat(result.totalElements()).isEqualTo(1);
assertThatCode(() ->
result.items().forEach(i -> i.tags().size()))
.doesNotThrowAnyException();
assertThat(result.items().getFirst().tags()).extracting(Tag::getName).containsExactly("FtTag");
}
@Test @Test
void searchDocuments_senderSort_doesNotThrowLazyInitializationException() { void searchDocuments_senderSort_doesNotThrowLazyInitializationException() {
Person sender = savedPerson("Hans", "SsSender"); Person sender = savedPerson("Hans", "SsSender");

View File

@@ -81,7 +81,7 @@ class DocumentServiceSortTest {
UUID id1 = UUID.randomUUID(); UUID id1 = UUID.randomUUID();
List<Object[]> ftsRows = ftsRows(id1, 0.5d, 1L); List<Object[]> ftsRows = ftsRows(id1, 0.5d, 1L);
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows); when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows);
when(documentRepository.findAllById(any())) when(documentRepository.findByIdIn(any()))
.thenReturn(List.of(doc(id1))); .thenReturn(List.of(doc(id1)));
documentService.searchDocuments( documentService.searchDocuments(
@@ -101,7 +101,7 @@ class DocumentServiceSortTest {
ftsRows.add(new Object[]{id1, 0.8d, 2L}); ftsRows.add(new Object[]{id1, 0.8d, 2L});
ftsRows.add(new Object[]{id2, 0.3d, 2L}); ftsRows.add(new Object[]{id2, 0.3d, 2L});
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows); when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows);
when(documentRepository.findAllById(any())).thenReturn(List.of(doc(id2), doc(id1))); // unordered from JPA when(documentRepository.findByIdIn(any())).thenReturn(List.of(doc(id2), doc(id1))); // unordered from JPA
DocumentSearchResult result = documentService.searchDocuments( DocumentSearchResult result = documentService.searchDocuments(
new SearchFilters("Brief", null, null, null, null, null, null, null, null, false), new SearchFilters("Brief", null, null, null, null, null, null, null, null, false),
@@ -119,7 +119,7 @@ class DocumentServiceSortTest {
ftsRows.add(new Object[]{id1, 0.8d, 2L}); ftsRows.add(new Object[]{id1, 0.8d, 2L});
ftsRows.add(new Object[]{id2, 0.3d, 2L}); ftsRows.add(new Object[]{id2, 0.3d, 2L});
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows); when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows);
when(documentRepository.findAllById(any())).thenReturn(List.of(doc(id2), doc(id1))); when(documentRepository.findByIdIn(any())).thenReturn(List.of(doc(id2), doc(id1)));
DocumentSearchResult result = documentService.searchDocuments( DocumentSearchResult result = documentService.searchDocuments(
new SearchFilters("Brief", null, null, null, null, null, null, null, null, false), new SearchFilters("Brief", null, null, null, null, null, null, null, null, false),
@@ -153,7 +153,7 @@ class DocumentServiceSortTest {
List<Object[]> ftsRows = new ArrayList<>(); List<Object[]> ftsRows = new ArrayList<>();
ftsRows.add(new Object[]{stringId, 0.5d, 1L}); ftsRows.add(new Object[]{stringId, 0.5d, 1L});
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows); when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows);
when(documentRepository.findAllById(any())).thenReturn(List.of(doc(uuidId))); when(documentRepository.findByIdIn(any())).thenReturn(List.of(doc(uuidId)));
DocumentSearchResult result = documentService.searchDocuments( DocumentSearchResult result = documentService.searchDocuments(
new SearchFilters("Brief", null, null, null, null, null, null, null, null, false), new SearchFilters("Brief", null, null, null, null, null, null, null, null, false),

View File

@@ -2166,7 +2166,7 @@ class DocumentServiceTest {
List<Object[]> ftsRows = new java.util.ArrayList<>(); List<Object[]> ftsRows = new java.util.ArrayList<>();
ftsRows.add(new Object[]{docId, 0.5d, 1L}); ftsRows.add(new Object[]{docId, 0.5d, 1L});
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows); when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows);
when(documentRepository.findAllById(any())).thenReturn(List.of(doc)); when(documentRepository.findByIdIn(any())).thenReturn(List.of(doc));
when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows); when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows);
DocumentSearchResult result = documentService.searchDocuments( DocumentSearchResult result = documentService.searchDocuments(
@@ -2202,7 +2202,7 @@ class DocumentServiceTest {
List<Object[]> snippetFtsRows = new java.util.ArrayList<>(); List<Object[]> snippetFtsRows = new java.util.ArrayList<>();
snippetFtsRows.add(new Object[]{docId, 0.5d, 1L}); snippetFtsRows.add(new Object[]{docId, 0.5d, 1L});
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(snippetFtsRows); when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(snippetFtsRows);
when(documentRepository.findAllById(any())).thenReturn(List.of(doc)); when(documentRepository.findByIdIn(any())).thenReturn(List.of(doc));
when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows); when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows);
DocumentSearchResult result = documentService.searchDocuments( DocumentSearchResult result = documentService.searchDocuments(

View File

@@ -0,0 +1,66 @@
package org.raddatz.familienarchiv.geschichte;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.jdbc.core.JdbcTemplate;
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.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Raw-SQL constraint tests for geschichten — deliberately NOT @Transactional at
* class level (see JourneyItemConstraintsTest for the rationale).
*
* The V75 CHECK is the atomic backstop for GeschichteService.MAX_INTRO_LENGTH on
* the verbatim JOURNEY intro write path. STORY bodies are intentionally exempt.
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@ActiveProfiles("test")
@Import(PostgresContainerConfig.class)
class GeschichteConstraintsTest {
@MockitoBean
S3Client s3Client;
@Autowired JdbcTemplate jdbcTemplate;
private UUID insertGeschichte(String type, String body) {
UUID id = UUID.randomUUID();
jdbcTemplate.update(
"INSERT INTO geschichten (id, title, body, status, type, created_at, updated_at) "
+ "VALUES (?, ?, ?, 'DRAFT', ?, now(), now())",
id, "Constraints-Test", body, type);
return id;
}
@Test
void journey_intro_check_rejects_4001_chars() {
assertThatThrownBy(() -> insertGeschichte("JOURNEY", "x".repeat(4001)))
.isInstanceOf(DataIntegrityViolationException.class);
}
@Test
void journey_intro_check_accepts_exactly_4000_chars() {
UUID id = insertGeschichte("JOURNEY", "x".repeat(4000));
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM geschichten WHERE id = ?", Integer.class, id);
assertThat(count).isEqualTo(1);
}
@Test
void story_bodies_are_not_constrained_by_the_intro_check() {
UUID id = insertGeschichte("STORY", "<p>" + "x".repeat(4001) + "</p>");
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM geschichten WHERE id = ?", Integer.class, id);
assertThat(count).isEqualTo(1);
}
}

View File

@@ -63,7 +63,7 @@ class GeschichteControllerTest {
@Test @Test
@WithMockUser(authorities = "READ_ALL") @WithMockUser(authorities = "READ_ALL")
void list_returns200_forReader() throws Exception { void list_returns200_forReader() throws Exception {
when(geschichteService.list(any(), any(), anyInt())) when(geschichteService.list(any(), any(), any(), anyInt()))
.thenReturn(List.of(summaryStub("Story A"))); .thenReturn(List.of(summaryStub("Story A")));
mockMvc.perform(get("/api/geschichten")) mockMvc.perform(get("/api/geschichten"))
@@ -75,13 +75,13 @@ class GeschichteControllerTest {
@WithMockUser(authorities = "READ_ALL") @WithMockUser(authorities = "READ_ALL")
void list_passesSinglePersonIdFilterToServiceAsListOfOne() throws Exception { void list_passesSinglePersonIdFilterToServiceAsListOfOne() throws Exception {
UUID personId = UUID.randomUUID(); UUID personId = UUID.randomUUID();
when(geschichteService.list(any(), eq(List.of(personId)), anyInt())) when(geschichteService.list(any(), eq(List.of(personId)), any(), anyInt()))
.thenReturn(List.of()); .thenReturn(List.of());
mockMvc.perform(get("/api/geschichten").param("personId", personId.toString())) mockMvc.perform(get("/api/geschichten").param("personId", personId.toString()))
.andExpect(status().isOk()); .andExpect(status().isOk());
verify(geschichteService).list(any(), eq(List.of(personId)), anyInt()); verify(geschichteService).list(any(), eq(List.of(personId)), any(), anyInt());
} }
@Test @Test
@@ -89,7 +89,7 @@ class GeschichteControllerTest {
void list_passesRepeatedPersonIdParamsAsListForAndFilter() throws Exception { void list_passesRepeatedPersonIdParamsAsListForAndFilter() throws Exception {
UUID a = UUID.randomUUID(); UUID a = UUID.randomUUID();
UUID b = UUID.randomUUID(); UUID b = UUID.randomUUID();
when(geschichteService.list(any(), eq(List.of(a, b)), anyInt())) when(geschichteService.list(any(), eq(List.of(a, b)), any(), anyInt()))
.thenReturn(List.of()); .thenReturn(List.of());
mockMvc.perform(get("/api/geschichten") mockMvc.perform(get("/api/geschichten")
@@ -97,7 +97,7 @@ class GeschichteControllerTest {
.param("personId", b.toString())) .param("personId", b.toString()))
.andExpect(status().isOk()); .andExpect(status().isOk());
verify(geschichteService).list(any(), eq(List.of(a, b)), anyInt()); verify(geschichteService).list(any(), eq(List.of(a, b)), any(), anyInt());
} }
// ─── GET /api/geschichten/{id} ─────────────────────────────────────────── // ─── GET /api/geschichten/{id} ───────────────────────────────────────────
@@ -150,7 +150,7 @@ class GeschichteControllerTest {
void create_returns201_withBlogWrite() throws Exception { void create_returns201_withBlogWrite() throws Exception {
UUID id = UUID.randomUUID(); UUID id = UUID.randomUUID();
when(geschichteService.create(any(GeschichteUpdateDTO.class))) when(geschichteService.create(any(GeschichteUpdateDTO.class)))
.thenReturn(draft(id, "New")); .thenReturn(viewStub(id, "New", GeschichteStatus.DRAFT));
GeschichteUpdateDTO dto = new GeschichteUpdateDTO(); GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setTitle("New"); dto.setTitle("New");
@@ -178,7 +178,7 @@ class GeschichteControllerTest {
void update_returns200_withBlogWrite() throws Exception { void update_returns200_withBlogWrite() throws Exception {
UUID id = UUID.randomUUID(); UUID id = UUID.randomUUID();
when(geschichteService.update(eq(id), any(GeschichteUpdateDTO.class))) when(geschichteService.update(eq(id), any(GeschichteUpdateDTO.class)))
.thenReturn(published(id, "Updated")); .thenReturn(viewStub(id, "Updated", GeschichteStatus.PUBLISHED));
mockMvc.perform(patch("/api/geschichten/{id}", id).with(csrf()) mockMvc.perform(patch("/api/geschichten/{id}", id).with(csrf())
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
@@ -381,35 +381,13 @@ class GeschichteControllerTest {
return new JourneyItemView(id, position, null, note); return new JourneyItemView(id, position, null, note);
} }
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<>())
.items(new ArrayList<>())
.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<>())
.items(new ArrayList<>())
.build();
}
private GeschichteView viewStub(UUID id, String title) { private GeschichteView viewStub(UUID id, String title) {
return viewStub(id, title, GeschichteStatus.PUBLISHED);
}
private GeschichteView viewStub(UUID id, String title, GeschichteStatus status) {
return new GeschichteView(id, title, "<p>x</p>", return new GeschichteView(id, title, "<p>x</p>",
GeschichteStatus.PUBLISHED, GeschichteType.STORY, status, GeschichteType.STORY,
null, new HashSet<>(), List.of(), null, new HashSet<>(), List.of(),
LocalDateTime.now(), LocalDateTime.now(), LocalDateTime.now()); LocalDateTime.now(), LocalDateTime.now(), LocalDateTime.now());
} }
@@ -423,6 +401,7 @@ class GeschichteControllerTest {
public GeschichteType getType() { return GeschichteType.STORY; } public GeschichteType getType() { return GeschichteType.STORY; }
public AuthorSummary getAuthor() { return null; } public AuthorSummary getAuthor() { return null; }
public LocalDateTime getPublishedAt() { return LocalDateTime.now(); } public LocalDateTime getPublishedAt() { return LocalDateTime.now(); }
public LocalDateTime getUpdatedAt() { return LocalDateTime.now(); }
public String getBody() { return null; } public String getBody() { return null; }
}; };
} }

View File

@@ -6,6 +6,8 @@ import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItem; import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItem;
import org.raddatz.familienarchiv.user.AppUser; import org.raddatz.familienarchiv.user.AppUser;
import org.raddatz.familienarchiv.user.AppUserRepository; import org.raddatz.familienarchiv.user.AppUserRepository;
import org.raddatz.familienarchiv.user.UserGroup;
import org.raddatz.familienarchiv.user.UserGroupRepository;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort; import org.springframework.boot.test.web.server.LocalServerPort;
@@ -16,6 +18,7 @@ import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.http.client.ClientHttpResponse; import org.springframework.http.client.ClientHttpResponse;
import org.springframework.http.client.JdkClientHttpRequestFactory;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.context.bean.override.mockito.MockitoBean;
@@ -28,6 +31,7 @@ import java.time.LocalDateTime;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashSet; import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Set;
import java.util.UUID; import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
@@ -49,6 +53,7 @@ class GeschichteHttpTest {
@Autowired GeschichteRepository geschichteRepository; @Autowired GeschichteRepository geschichteRepository;
@Autowired AppUserRepository appUserRepository; @Autowired AppUserRepository appUserRepository;
@Autowired UserGroupRepository userGroupRepository;
@Autowired PasswordEncoder passwordEncoder; @Autowired PasswordEncoder passwordEncoder;
private RestTemplate http; private RestTemplate http;
@@ -63,6 +68,8 @@ class GeschichteHttpTest {
baseUrl = "http://localhost:" + port; baseUrl = "http://localhost:" + port;
geschichteRepository.deleteAll(); geschichteRepository.deleteAll();
appUserRepository.findByEmail(WRITER_EMAIL).ifPresent(appUserRepository::delete); appUserRepository.findByEmail(WRITER_EMAIL).ifPresent(appUserRepository::delete);
appUserRepository.findByEmail(BLOG_WRITER_EMAIL).ifPresent(appUserRepository::delete);
userGroupRepository.findByName("HttpTest-BlogWriters").ifPresent(userGroupRepository::delete);
appUserRepository.save(AppUser.builder() appUserRepository.save(AppUser.builder()
.email(WRITER_EMAIL) .email(WRITER_EMAIL)
.password(passwordEncoder.encode(WRITER_PASSWORD)) .password(passwordEncoder.encode(WRITER_PASSWORD))
@@ -184,15 +191,78 @@ class GeschichteHttpTest {
assertThat(response.getStatusCode().value()).isEqualTo(404); assertThat(response.getStatusCode().value()).isEqualTo(404);
} }
// ─── PATCH /api/geschichten/{id} ─────────────────────────────────────────
@Test
void update_returns_200_and_serializes_items_open_in_view_false() {
// Canonical guard for the write path: PATCH must not 500 when the response
// is serialized after the service transaction closed. The raw entity carries
// a dead lazy items proxy at that point — the endpoint must answer with a
// view assembled inside the transaction.
AppUser writer = blogWriter();
Geschichte journey = Geschichte.builder()
.title("Reise vor dem Umbenennen")
.status(GeschichteStatus.DRAFT)
.type(GeschichteType.JOURNEY)
.author(writer)
.items(new ArrayList<>())
.persons(new HashSet<>())
.build();
journey.getItems().add(JourneyItem.builder()
.geschichte(journey).position(1000).note("Prolog").build());
Geschichte saved = geschichteRepository.save(journey);
String session = loginAs(BLOG_WRITER_EMAIL, BLOG_WRITER_PASSWORD);
ResponseEntity<String> response = http.exchange(
baseUrl + "/api/geschichten/" + saved.getId(), HttpMethod.PATCH,
new HttpEntity<>("{\"title\":\"Reise nach dem Umbenennen\"}", csrfJsonHeaders(session)),
String.class);
assertThat(response.getStatusCode().value()).isEqualTo(200);
assertThat(response.getBody())
.contains("Reise nach dem Umbenennen")
.contains("Prolog");
}
// ─── helpers ───────────────────────────────────────────────────────────── // ─── helpers ─────────────────────────────────────────────────────────────
private static final String BLOG_WRITER_EMAIL = "geschichten-http-blogwriter@test.de";
private static final String BLOG_WRITER_PASSWORD = "pass!Geschichte2";
/** A user whose group actually grants BLOG_WRITE — unlike the plain writer above. */
private AppUser blogWriter() {
UserGroup group = userGroupRepository.save(UserGroup.builder()
.name("HttpTest-BlogWriters")
.permissions(new HashSet<>(Set.of("BLOG_WRITE")))
.build());
return appUserRepository.save(AppUser.builder()
.email(BLOG_WRITER_EMAIL)
.password(passwordEncoder.encode(BLOG_WRITER_PASSWORD))
.groups(new HashSet<>(Set.of(group)))
.build());
}
/** Session cookie + double-submit CSRF pair + JSON content type for write requests. */
private HttpHeaders csrfJsonHeaders(String sessionId) {
String xsrf = UUID.randomUUID().toString();
HttpHeaders headers = new HttpHeaders();
headers.set("Cookie", "fa_session=" + sessionId + "; XSRF-TOKEN=" + xsrf);
headers.set("X-XSRF-TOKEN", xsrf);
headers.setContentType(MediaType.APPLICATION_JSON);
return headers;
}
private String loginAsWriter() { private String loginAsWriter() {
return loginAs(WRITER_EMAIL, WRITER_PASSWORD);
}
private String loginAs(String email, String password) {
String xsrf = UUID.randomUUID().toString(); String xsrf = UUID.randomUUID().toString();
HttpHeaders headers = new HttpHeaders(); HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON); headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("Cookie", "XSRF-TOKEN=" + xsrf); headers.set("Cookie", "XSRF-TOKEN=" + xsrf);
headers.set("X-XSRF-TOKEN", xsrf); headers.set("X-XSRF-TOKEN", xsrf);
String body = "{\"email\":\"" + WRITER_EMAIL + "\",\"password\":\"" + WRITER_PASSWORD + "\"}"; String body = "{\"email\":\"" + email + "\",\"password\":\"" + password + "\"}";
ResponseEntity<String> resp = http.postForEntity( ResponseEntity<String> resp = http.postForEntity(
baseUrl + "/api/auth/login", new HttpEntity<>(body, headers), String.class); baseUrl + "/api/auth/login", new HttpEntity<>(body, headers), String.class);
return extractFaSessionCookie(resp); return extractFaSessionCookie(resp);
@@ -215,7 +285,8 @@ class GeschichteHttpTest {
} }
private RestTemplate noThrowRestTemplate() { private RestTemplate noThrowRestTemplate() {
RestTemplate template = new RestTemplate(); // JDK HttpClient factory — the default HttpURLConnection factory cannot send PATCH.
RestTemplate template = new RestTemplate(new JdkClientHttpRequestFactory());
template.setErrorHandler(new DefaultResponseErrorHandler() { template.setErrorHandler(new DefaultResponseErrorHandler() {
@Override @Override
public boolean hasError(ClientHttpResponse response) throws IOException { public boolean hasError(ClientHttpResponse response) throws IOException {

View File

@@ -4,6 +4,11 @@ import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig; import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.config.FlywayConfig; import org.raddatz.familienarchiv.config.FlywayConfig;
import org.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.document.DocumentRepository;
import org.raddatz.familienarchiv.document.DocumentStatus;
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItem;
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemRepository;
import org.raddatz.familienarchiv.person.Person; import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.person.PersonRepository; import org.raddatz.familienarchiv.person.PersonRepository;
import org.raddatz.familienarchiv.user.AppUser; import org.raddatz.familienarchiv.user.AppUser;
@@ -27,6 +32,8 @@ class GeschichteListProjectionTest {
@Autowired GeschichteRepository geschichteRepository; @Autowired GeschichteRepository geschichteRepository;
@Autowired AppUserRepository appUserRepository; @Autowired AppUserRepository appUserRepository;
@Autowired PersonRepository personRepository; @Autowired PersonRepository personRepository;
@Autowired DocumentRepository documentRepository;
@Autowired JourneyItemRepository journeyItemRepository;
AppUser author; AppUser author;
AppUser otherAuthor; AppUser otherAuthor;
@@ -48,18 +55,31 @@ class GeschichteListProjectionTest {
geschichteRepository.save(draft("Entwurf", author)); geschichteRepository.save(draft("Entwurf", author));
List<GeschichteSummary> result = geschichteRepository.findSummaries( List<GeschichteSummary> result = geschichteRepository.findSummaries(
GeschichteStatus.PUBLISHED, null, sentinel(), 0); GeschichteStatus.PUBLISHED, null, sentinel(), 0, null);
assertThat(result).hasSize(1); assertThat(result).hasSize(1);
assertThat(result.get(0).getTitle()).isEqualTo("Veröffentlicht"); assertThat(result.get(0).getTitle()).isEqualTo("Veröffentlicht");
} }
@Test
void findSummaries_carries_updatedAt_for_dashboard_relative_times() {
// ReaderDraftsModule renders "bearbeitet vor X" from updatedAt — the
// projection must carry it for drafts, where publishedAt is null.
geschichteRepository.save(draft("Mein Entwurf", author));
List<GeschichteSummary> result = geschichteRepository.findSummaries(
GeschichteStatus.DRAFT, author.getId(), sentinel(), 0, null);
assertThat(result).hasSize(1);
assertThat(result.get(0).getUpdatedAt()).isNotNull();
}
@Test @Test
void findSummaries_returns_empty_list_when_no_published_geschichten_exist() { void findSummaries_returns_empty_list_when_no_published_geschichten_exist() {
geschichteRepository.save(draft("Nur Entwurf", author)); geschichteRepository.save(draft("Nur Entwurf", author));
List<GeschichteSummary> result = geschichteRepository.findSummaries( List<GeschichteSummary> result = geschichteRepository.findSummaries(
GeschichteStatus.PUBLISHED, null, sentinel(), 0); GeschichteStatus.PUBLISHED, null, sentinel(), 0, null);
assertThat(result).isEmpty(); assertThat(result).isEmpty();
} }
@@ -67,17 +87,24 @@ class GeschichteListProjectionTest {
// ─── AuthorSummary nested projection ───────────────────────────────────── // ─── AuthorSummary nested projection ─────────────────────────────────────
@Test @Test
void findSummaries_exposes_nested_author_firstName_lastName_email() { void findSummaries_exposes_nested_author_names_but_never_email() {
AppUser richAuthor = appUserRepository.save(AppUser.builder() AppUser richAuthor = appUserRepository.save(AppUser.builder()
.firstName("Franz").lastName("Raddatz")
.email("franz@raddatz.de").password("pw").build()); .email("franz@raddatz.de").password("pw").build());
geschichteRepository.save(published("Briefe aus der Front", richAuthor)); geschichteRepository.save(published("Briefe aus der Front", richAuthor));
List<GeschichteSummary> result = geschichteRepository.findSummaries( List<GeschichteSummary> result = geschichteRepository.findSummaries(
GeschichteStatus.PUBLISHED, null, sentinel(), 0); GeschichteStatus.PUBLISHED, null, sentinel(), 0, null);
assertThat(result).hasSize(1); assertThat(result).hasSize(1);
GeschichteSummary.AuthorSummary a = result.get(0).getAuthor(); GeschichteSummary.AuthorSummary a = result.get(0).getAuthor();
assertThat(a.getEmail()).isEqualTo("franz@raddatz.de"); assertThat(a.getFirstName()).isEqualTo("Franz");
assertThat(a.getLastName()).isEqualTo("Raddatz");
// Design rule (GeschichteView.AuthorView javadoc): author projections never
// expose email or group memberships to readers.
assertThat(GeschichteSummary.AuthorSummary.class.getMethods())
.extracting(java.lang.reflect.Method::getName)
.doesNotContain("getEmail");
} }
// ─── GeschichteType is exposed ──────────────────────────────────────────── // ─── GeschichteType is exposed ────────────────────────────────────────────
@@ -94,7 +121,7 @@ class GeschichteListProjectionTest {
geschichteRepository.save(journey); geschichteRepository.save(journey);
List<GeschichteSummary> result = geschichteRepository.findSummaries( List<GeschichteSummary> result = geschichteRepository.findSummaries(
GeschichteStatus.PUBLISHED, null, sentinel(), 0); GeschichteStatus.PUBLISHED, null, sentinel(), 0, null);
assertThat(result).hasSize(1); assertThat(result).hasSize(1);
assertThat(result.get(0).getType()).isEqualTo(GeschichteType.JOURNEY); assertThat(result.get(0).getType()).isEqualTo(GeschichteType.JOURNEY);
@@ -108,7 +135,7 @@ class GeschichteListProjectionTest {
geschichteRepository.save(draft("Fremder Entwurf", otherAuthor)); geschichteRepository.save(draft("Fremder Entwurf", otherAuthor));
List<GeschichteSummary> result = geschichteRepository.findSummaries( List<GeschichteSummary> result = geschichteRepository.findSummaries(
GeschichteStatus.DRAFT, author.getId(), sentinel(), 0); GeschichteStatus.DRAFT, author.getId(), sentinel(), 0, null);
assertThat(result).hasSize(1); assertThat(result).hasSize(1);
assertThat(result.get(0).getTitle()).isEqualTo("Mein Entwurf"); assertThat(result.get(0).getTitle()).isEqualTo("Mein Entwurf");
@@ -122,7 +149,7 @@ class GeschichteListProjectionTest {
geschichteRepository.save(published("B", author)); geschichteRepository.save(published("B", author));
List<GeschichteSummary> result = geschichteRepository.findSummaries( List<GeschichteSummary> result = geschichteRepository.findSummaries(
GeschichteStatus.PUBLISHED, null, sentinel(), 0); GeschichteStatus.PUBLISHED, null, sentinel(), 0, null);
assertThat(result).hasSize(2); assertThat(result).hasSize(2);
} }
@@ -143,7 +170,7 @@ class GeschichteListProjectionTest {
geschichteRepository.save(withAnna); geschichteRepository.save(withAnna);
List<GeschichteSummary> result = geschichteRepository.findSummaries( List<GeschichteSummary> result = geschichteRepository.findSummaries(
GeschichteStatus.PUBLISHED, null, List.of(franz.getId()), 1); GeschichteStatus.PUBLISHED, null, List.of(franz.getId()), 1, null);
assertThat(result).hasSize(1); assertThat(result).hasSize(1);
assertThat(result.get(0).getTitle()).isEqualTo("Franz story"); assertThat(result.get(0).getTitle()).isEqualTo("Franz story");
@@ -164,12 +191,41 @@ class GeschichteListProjectionTest {
geschichteRepository.save(onlyFranz); geschichteRepository.save(onlyFranz);
List<GeschichteSummary> result = geschichteRepository.findSummaries( List<GeschichteSummary> result = geschichteRepository.findSummaries(
GeschichteStatus.PUBLISHED, null, List.of(franz.getId(), anna.getId()), 2); GeschichteStatus.PUBLISHED, null, List.of(franz.getId(), anna.getId()), 2, null);
assertThat(result).hasSize(1); assertThat(result).hasSize(1);
assertThat(result.get(0).getTitle()).isEqualTo("Both"); assertThat(result.get(0).getTitle()).isEqualTo("Both");
} }
// ─── documentId filter (JPQL EXISTS subquery) ────────────────────────────
@Test
void findSummaries_with_documentId_returns_journey_containing_that_document() {
Document doc = documentRepository.save(Document.builder()
.title("Brief").originalFilename("brief.pdf").status(DocumentStatus.UPLOADED).build());
Geschichte withDoc = geschichteRepository.save(journey("Reise mit Dokument", author));
Geschichte withoutDoc = geschichteRepository.save(journey("Reise ohne Dokument", author));
journeyItemRepository.save(JourneyItem.builder()
.geschichte(withDoc).document(doc).position(1).build());
List<GeschichteSummary> result = geschichteRepository.findSummaries(
GeschichteStatus.PUBLISHED, null, sentinel(), 0, doc.getId());
assertThat(result).hasSize(1);
assertThat(result.get(0).getTitle()).isEqualTo("Reise mit Dokument");
assertThat(result).extracting(GeschichteSummary::getTitle).doesNotContain("Reise ohne Dokument");
}
@Test
void findSummaries_with_unknown_documentId_returns_empty() {
geschichteRepository.save(journey("Irgendeine Reise", author));
List<GeschichteSummary> result = geschichteRepository.findSummaries(
GeschichteStatus.PUBLISHED, null, sentinel(), 0, UUID.randomUUID());
assertThat(result).isEmpty();
}
// ─── helpers ───────────────────────────────────────────────────────────── // ─── helpers ─────────────────────────────────────────────────────────────
private Geschichte published(String title, AppUser writer) { private Geschichte published(String title, AppUser writer) {
@@ -189,6 +245,16 @@ class GeschichteListProjectionTest {
.build(); .build();
} }
private Geschichte journey(String title, AppUser writer) {
return Geschichte.builder()
.title(title)
.status(GeschichteStatus.PUBLISHED)
.type(GeschichteType.JOURNEY)
.author(writer)
.publishedAt(LocalDateTime.now())
.build();
}
/** Sentinel UUID passed when personCount=0 — the IN() clause is never evaluated. */ /** Sentinel UUID passed when personCount=0 — the IN() clause is never evaluated. */
private List<UUID> sentinel() { private List<UUID> sentinel() {
return List.of(UUID.fromString("00000000-0000-0000-0000-000000000000")); return List.of(UUID.fromString("00000000-0000-0000-0000-000000000000"));

View File

@@ -80,20 +80,20 @@ class GeschichteServiceIntegrationTest {
+ "<script>alert('xss')</script>"); + "<script>alert('xss')</script>");
dto.setPersonIds(List.of(franz.getId())); dto.setPersonIds(List.of(franz.getId()));
Geschichte created = geschichteService.create(dto); GeschichteView created = geschichteService.create(dto);
assertThat(created.getId()).isNotNull(); assertThat(created.id()).isNotNull();
assertThat(created.getStatus()).isEqualTo(GeschichteStatus.DRAFT); assertThat(created.status()).isEqualTo(GeschichteStatus.DRAFT);
assertThat(created.getBody()) assertThat(created.body())
.contains("<strong>jeden Sonntag</strong>") .contains("<strong>jeden Sonntag</strong>")
.doesNotContain("<script>"); .doesNotContain("<script>");
// Reader cannot see DRAFT in list // Reader cannot see DRAFT in list
authenticateAs(reader, Permission.READ_ALL); authenticateAs(reader, Permission.READ_ALL);
assertThat(geschichteService.list(null, List.of(), 50)).isEmpty(); assertThat(geschichteService.list(null, List.of(), null, 50)).isEmpty();
// Reader cannot fetch DRAFT by id (404 via GESCHICHTE_NOT_FOUND) // Reader cannot fetch DRAFT by id (404 via GESCHICHTE_NOT_FOUND)
UUID draftId = created.getId(); UUID draftId = created.id();
org.assertj.core.api.Assertions.assertThatThrownBy(() -> geschichteService.getById(draftId)) org.assertj.core.api.Assertions.assertThatThrownBy(() -> geschichteService.getById(draftId))
.hasMessageContaining("not found"); .hasMessageContaining("not found");
@@ -101,13 +101,13 @@ class GeschichteServiceIntegrationTest {
authenticateAs(writer, Permission.BLOG_WRITE); authenticateAs(writer, Permission.BLOG_WRITE);
GeschichteUpdateDTO publishDto = new GeschichteUpdateDTO(); GeschichteUpdateDTO publishDto = new GeschichteUpdateDTO();
publishDto.setStatus(GeschichteStatus.PUBLISHED); publishDto.setStatus(GeschichteStatus.PUBLISHED);
Geschichte publishedGesch = geschichteService.update(draftId, publishDto); GeschichteView publishedGesch = geschichteService.update(draftId, publishDto);
assertThat(publishedGesch.getPublishedAt()).isNotNull(); assertThat(publishedGesch.publishedAt()).isNotNull();
// Reader can now see and fetch it // Reader can now see and fetch it
authenticateAs(reader, Permission.READ_ALL); authenticateAs(reader, Permission.READ_ALL);
assertThat(geschichteService.list(null, List.of(), 50)).hasSize(1); assertThat(geschichteService.list(null, List.of(), null, 50)).hasSize(1);
assertThat(geschichteService.list(null, List.of(franz.getId()), 50)).hasSize(1); assertThat(geschichteService.list(null, List.of(franz.getId()), null, 50)).hasSize(1);
Geschichte fetched = geschichteService.getById(draftId); Geschichte fetched = geschichteService.getById(draftId);
GeschichteView fetchedView = geschichteService.toView(fetched, journeyItemService.getItems(draftId)); GeschichteView fetchedView = geschichteService.toView(fetched, journeyItemService.getItems(draftId));
assertThat(fetchedView.title()).isEqualTo("Erinnerung an Opa Franz"); assertThat(fetchedView.title()).isEqualTo("Erinnerung an Opa Franz");
@@ -141,26 +141,26 @@ class GeschichteServiceIntegrationTest {
authenticateAs(reader, Permission.READ_ALL); authenticateAs(reader, Permission.READ_ALL);
// No filter → all three // No filter → all three
assertThat(geschichteService.list(null, List.of(), 50)) assertThat(geschichteService.list(null, List.of(), null, 50))
.extracting(GeschichteSummary::getId) .extracting(GeschichteSummary::getId)
.containsExactlyInAnyOrder(storyAB, storyAC, storyA); .containsExactlyInAnyOrder(storyAB, storyAC, storyA);
// Single filter (Anna) → all three // Single filter (Anna) → all three
assertThat(geschichteService.list(null, List.of(a.getId()), 50)) assertThat(geschichteService.list(null, List.of(a.getId()), null, 50))
.extracting(GeschichteSummary::getId) .extracting(GeschichteSummary::getId)
.containsExactlyInAnyOrder(storyAB, storyAC, storyA); .containsExactlyInAnyOrder(storyAB, storyAC, storyA);
// AND: Anna AND Bertha → only the AB story (NOT story_A, NOT story_AC) // AND: Anna AND Bertha → only the AB story (NOT story_A, NOT story_AC)
assertThat(geschichteService.list(null, List.of(a.getId(), b.getId()), 50)) assertThat(geschichteService.list(null, List.of(a.getId(), b.getId()), null, 50))
.extracting(GeschichteSummary::getId) .extracting(GeschichteSummary::getId)
.containsExactly(storyAB); .containsExactly(storyAB);
// AND: Bertha AND Carl → none (no story has both) // AND: Bertha AND Carl → none (no story has both)
assertThat(geschichteService.list(null, List.of(b.getId(), c.getId()), 50)) assertThat(geschichteService.list(null, List.of(b.getId(), c.getId()), null, 50))
.isEmpty(); .isEmpty();
// AND: Anna AND Bertha AND Carl → none // AND: Anna AND Bertha AND Carl → none
assertThat(geschichteService.list(null, List.of(a.getId(), b.getId(), c.getId()), 50)) assertThat(geschichteService.list(null, List.of(a.getId(), b.getId(), c.getId()), null, 50))
.isEmpty(); .isEmpty();
} }
@@ -179,7 +179,7 @@ class GeschichteServiceIntegrationTest {
geschichteService.create(dto); geschichteService.create(dto);
authenticateAs(writer2, Permission.BLOG_WRITE); authenticateAs(writer2, Permission.BLOG_WRITE);
List<GeschichteSummary> result = geschichteService.list(GeschichteStatus.DRAFT, List.of(), 50); List<GeschichteSummary> result = geschichteService.list(GeschichteStatus.DRAFT, List.of(), null, 50);
assertThat(result).isEmpty(); assertThat(result).isEmpty();
} }
@@ -190,7 +190,7 @@ class GeschichteServiceIntegrationTest {
dto.setBody("<p>body</p>"); dto.setBody("<p>body</p>");
dto.setPersonIds(personIds); dto.setPersonIds(personIds);
dto.setStatus(GeschichteStatus.PUBLISHED); dto.setStatus(GeschichteStatus.PUBLISHED);
return geschichteService.create(dto).getId(); return geschichteService.create(dto).id();
} }
private void authenticateAs(AppUser user, Permission... permissions) { private void authenticateAs(AppUser user, Permission... permissions) {

View File

@@ -34,6 +34,7 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never; import static org.mockito.Mockito.never;
@@ -222,12 +223,12 @@ class GeschichteServiceTest {
@Test @Test
void list_forces_PUBLISHED_status_for_reader_without_BLOG_WRITE() { void list_forces_PUBLISHED_status_for_reader_without_BLOG_WRITE() {
authenticateAs(reader, Permission.READ_ALL); authenticateAs(reader, Permission.READ_ALL);
when(geschichteRepository.findSummaries(any(), any(), any(), anyLong())) when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any()))
.thenReturn(List.of()); .thenReturn(List.of());
geschichteService.list(null, List.of(), 50); geschichteService.list(null, List.of(), null, 50);
verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong()); verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong(), any());
} }
@Test @Test
@@ -235,25 +236,25 @@ class GeschichteServiceTest {
authenticateAs(writer, Permission.BLOG_WRITE); authenticateAs(writer, Permission.BLOG_WRITE);
GeschichteSummary s1 = mock(GeschichteSummary.class); GeschichteSummary s1 = mock(GeschichteSummary.class);
GeschichteSummary s2 = mock(GeschichteSummary.class); GeschichteSummary s2 = mock(GeschichteSummary.class);
when(geschichteRepository.findSummaries(any(), any(), any(), anyLong())) when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any()))
.thenReturn(List.of(s1, s2)); .thenReturn(List.of(s1, s2));
List<GeschichteSummary> out = geschichteService.list(null, List.of(), 50); List<GeschichteSummary> out = geschichteService.list(null, List.of(), null, 50);
assertThat(out).hasSize(2); assertThat(out).hasSize(2);
verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong()); verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong(), any());
} }
@Test @Test
void list_invokes_repository_findSummaries_when_filtering_by_single_personId() { void list_invokes_repository_findSummaries_when_filtering_by_single_personId() {
authenticateAs(reader, Permission.READ_ALL); authenticateAs(reader, Permission.READ_ALL);
UUID personId = UUID.randomUUID(); UUID personId = UUID.randomUUID();
when(geschichteRepository.findSummaries(any(), any(), any(), anyLong())) when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any()))
.thenReturn(List.of()); .thenReturn(List.of());
geschichteService.list(null, List.of(personId), 50); geschichteService.list(null, List.of(personId), null, 50);
verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong()); verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong(), any());
} }
@Test @Test
@@ -261,21 +262,33 @@ class GeschichteServiceTest {
authenticateAs(reader, Permission.READ_ALL); authenticateAs(reader, Permission.READ_ALL);
UUID a = UUID.randomUUID(); UUID a = UUID.randomUUID();
UUID b = UUID.randomUUID(); UUID b = UUID.randomUUID();
when(geschichteRepository.findSummaries(any(), any(), any(), anyLong())) when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any()))
.thenReturn(List.of()); .thenReturn(List.of());
geschichteService.list(null, List.of(a, b), 50); geschichteService.list(null, List.of(a, b), null, 50);
verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong()); verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong(), any());
}
@Test
void list_passes_documentId_to_repository_as_journey_item_filter() {
authenticateAs(reader, Permission.READ_ALL);
UUID documentId = UUID.randomUUID();
when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any()))
.thenReturn(List.of());
geschichteService.list(null, List.of(), documentId, 50);
verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong(), eq(documentId));
} }
@Test @Test
void list_caps_limit_at_max_when_caller_passes_huge_value() { void list_caps_limit_at_max_when_caller_passes_huge_value() {
authenticateAs(reader, Permission.READ_ALL); authenticateAs(reader, Permission.READ_ALL);
when(geschichteRepository.findSummaries(any(), any(), any(), anyLong())) when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any()))
.thenReturn(List.of(mock(GeschichteSummary.class))); .thenReturn(List.of(mock(GeschichteSummary.class)));
List<GeschichteSummary> out = geschichteService.list(null, List.of(), 9999); List<GeschichteSummary> out = geschichteService.list(null, List.of(), null, 9999);
assertThat(out).hasSizeLessThanOrEqualTo(200); assertThat(out).hasSizeLessThanOrEqualTo(200);
} }
@@ -293,11 +306,11 @@ class GeschichteServiceTest {
dto.setTitle("My Story"); dto.setTitle("My Story");
dto.setBody("<p>plain text</p>"); dto.setBody("<p>plain text</p>");
Geschichte saved = geschichteService.create(dto); GeschichteView saved = geschichteService.create(dto);
assertThat(saved.getStatus()).isEqualTo(GeschichteStatus.DRAFT); assertThat(saved.status()).isEqualTo(GeschichteStatus.DRAFT);
assertThat(saved.getPublishedAt()).isNull(); assertThat(saved.publishedAt()).isNull();
assertThat(saved.getAuthor()).isSameAs(writer); assertThat(saved.author().id()).isEqualTo(writer.getId());
} }
@Test @Test
@@ -311,9 +324,9 @@ class GeschichteServiceTest {
dto.setTitle("XSS attempt"); dto.setTitle("XSS attempt");
dto.setBody("<p>safe</p><script>alert(1)</script><img src=x onerror=alert(2)>"); dto.setBody("<p>safe</p><script>alert(1)</script><img src=x onerror=alert(2)>");
Geschichte saved = geschichteService.create(dto); GeschichteView saved = geschichteService.create(dto);
assertThat(saved.getBody()) assertThat(saved.body())
.contains("<p>safe</p>") .contains("<p>safe</p>")
.doesNotContain("<script>") .doesNotContain("<script>")
.doesNotContain("onerror") .doesNotContain("onerror")
@@ -332,9 +345,9 @@ class GeschichteServiceTest {
dto.setBody("<h2>Heading</h2><p>Some <strong>bold</strong> and <em>italic</em>.</p>" 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>"); + "<ul><li>one</li></ul><ol><li>first</li></ol>");
Geschichte saved = geschichteService.create(dto); GeschichteView saved = geschichteService.create(dto);
assertThat(saved.getBody()) assertThat(saved.body())
.contains("<h2>Heading</h2>") .contains("<h2>Heading</h2>")
.contains("<strong>bold</strong>") .contains("<strong>bold</strong>")
.contains("<em>italic</em>") .contains("<em>italic</em>")
@@ -357,9 +370,9 @@ class GeschichteServiceTest {
dto.setTitle("Linked"); dto.setTitle("Linked");
dto.setPersonIds(List.of(personId)); dto.setPersonIds(List.of(personId));
Geschichte saved = geschichteService.create(dto); GeschichteView saved = geschichteService.create(dto);
assertThat(saved.getPersons()).containsExactly(person); assertThat(saved.persons()).extracting(GeschichteView.PersonView::id).containsExactly(personId);
} }
@Test @Test
@@ -376,6 +389,202 @@ class GeschichteServiceTest {
.isEqualTo(ErrorCode.VALIDATION_ERROR); .isEqualTo(ErrorCode.VALIDATION_ERROR);
} }
@Test
void create_preserves_JOURNEY_type_from_dto() {
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 Journey");
dto.setType(GeschichteType.JOURNEY);
GeschichteView saved = geschichteService.create(dto);
assertThat(saved.type()).isEqualTo(GeschichteType.JOURNEY);
}
@Test
void create_defaults_to_STORY_when_type_is_null() {
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");
GeschichteView saved = geschichteService.create(dto);
assertThat(saved.type()).isEqualTo(GeschichteType.STORY);
}
@Test
void create_stores_JOURNEY_intro_verbatim_without_html_entity_encoding() {
// The journey intro is plain text: JourneyReader renders it via Svelte text
// interpolation (never {@html}), so the OWASP sanitizer's entity encoding
// would corrupt real content ("Müller & Söhne" → "Müller &amp; Söhne") and
// re-encode cumulatively on every editor round-trip.
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("Winterbriefe");
dto.setType(GeschichteType.JOURNEY);
dto.setBody("Müller & Söhne, Temperatur < 0");
GeschichteView saved = geschichteService.create(dto);
assertThat(saved.body()).isEqualTo("Müller & Söhne, Temperatur < 0");
}
@Test
void update_stores_JOURNEY_intro_verbatim_without_html_entity_encoding() {
authenticateAs(writer, Permission.BLOG_WRITE);
UUID id = UUID.randomUUID();
Geschichte existing = draft(id);
existing.setType(GeschichteType.JOURNEY);
when(geschichteRepository.findById(id)).thenReturn(Optional.of(existing));
when(geschichteRepository.save(any(Geschichte.class)))
.thenAnswer(inv -> inv.getArgument(0));
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setBody("Temperatur < 0 & Schnee");
GeschichteView saved = geschichteService.update(id, dto);
assertThat(saved.body()).isEqualTo("Temperatur < 0 & Schnee");
}
@Test
void update_still_sanitizes_STORY_body() {
authenticateAs(writer, Permission.BLOG_WRITE);
UUID id = UUID.randomUUID();
Geschichte existing = draft(id);
existing.setType(GeschichteType.STORY);
when(geschichteRepository.findById(id)).thenReturn(Optional.of(existing));
when(geschichteRepository.save(any(Geschichte.class)))
.thenAnswer(inv -> inv.getArgument(0));
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setBody("<p>ok</p><script>alert(1)</script>");
GeschichteView saved = geschichteService.update(id, dto);
assertThat(saved.body()).doesNotContain("<script>").contains("<p>ok</p>");
}
// ─── length caps ─────────────────────────────────────────────────────────
@Test
void create_rejects_title_longer_than_255_with_GESCHICHTE_TITLE_TOO_LONG() {
authenticateAs(writer, Permission.BLOG_WRITE);
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setTitle("x".repeat(256));
assertThatThrownBy(() -> geschichteService.create(dto))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.GESCHICHTE_TITLE_TOO_LONG);
}
@Test
void create_accepts_title_of_exactly_255_chars() {
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("x".repeat(255));
assertThat(geschichteService.create(dto).title()).hasSize(255);
}
@Test
void update_rejects_title_longer_than_255_with_GESCHICHTE_TITLE_TOO_LONG() {
authenticateAs(writer, Permission.BLOG_WRITE);
UUID id = UUID.randomUUID();
when(geschichteRepository.findById(id)).thenReturn(Optional.of(draft(id)));
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setTitle("x".repeat(256));
assertThatThrownBy(() -> geschichteService.update(id, dto))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.GESCHICHTE_TITLE_TOO_LONG);
}
@Test
void create_rejects_JOURNEY_intro_longer_than_4000_with_GESCHICHTE_INTRO_TOO_LONG() {
authenticateAs(writer, Permission.BLOG_WRITE);
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setTitle("Winterbriefe");
dto.setType(GeschichteType.JOURNEY);
dto.setBody("x".repeat(4001));
assertThatThrownBy(() -> geschichteService.create(dto))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.GESCHICHTE_INTRO_TOO_LONG);
}
@Test
void create_accepts_JOURNEY_intro_of_exactly_4000_chars() {
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("Winterbriefe");
dto.setType(GeschichteType.JOURNEY);
dto.setBody("x".repeat(4000));
assertThat(geschichteService.create(dto).body()).hasSize(4000);
}
@Test
void update_rejects_JOURNEY_intro_longer_than_4000_with_GESCHICHTE_INTRO_TOO_LONG() {
authenticateAs(writer, Permission.BLOG_WRITE);
UUID id = UUID.randomUUID();
Geschichte existing = draft(id);
existing.setType(GeschichteType.JOURNEY);
when(geschichteRepository.findById(id)).thenReturn(Optional.of(existing));
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setBody("x".repeat(4001));
assertThatThrownBy(() -> geschichteService.update(id, dto))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.GESCHICHTE_INTRO_TOO_LONG);
}
@Test
void update_does_not_apply_the_intro_cap_to_STORY_bodies() {
// STORY bodies are sanitized Tiptap HTML and intentionally unbounded —
// the 4000-char cap exists for the verbatim JOURNEY intro path only.
authenticateAs(writer, Permission.BLOG_WRITE);
UUID id = UUID.randomUUID();
Geschichte existing = draft(id);
existing.setType(GeschichteType.STORY);
when(geschichteRepository.findById(id)).thenReturn(Optional.of(existing));
when(geschichteRepository.save(any(Geschichte.class)))
.thenAnswer(inv -> inv.getArgument(0));
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setBody("<p>" + "x".repeat(4001) + "</p>");
assertThat(geschichteService.update(id, dto).body()).contains("<p>");
}
// ─── update ────────────────────────────────────────────────────────────── // ─── update ──────────────────────────────────────────────────────────────
@Test @Test
@@ -391,10 +600,10 @@ class GeschichteServiceTest {
GeschichteUpdateDTO dto = new GeschichteUpdateDTO(); GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setStatus(GeschichteStatus.PUBLISHED); dto.setStatus(GeschichteStatus.PUBLISHED);
Geschichte saved = geschichteService.update(id, dto); GeschichteView saved = geschichteService.update(id, dto);
assertThat(saved.getStatus()).isEqualTo(GeschichteStatus.PUBLISHED); assertThat(saved.status()).isEqualTo(GeschichteStatus.PUBLISHED);
assertThat(saved.getPublishedAt()).isNotNull(); assertThat(saved.publishedAt()).isNotNull();
} }
@Test @Test
@@ -410,10 +619,10 @@ class GeschichteServiceTest {
GeschichteUpdateDTO dto = new GeschichteUpdateDTO(); GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setStatus(GeschichteStatus.DRAFT); dto.setStatus(GeschichteStatus.DRAFT);
Geschichte saved = geschichteService.update(id, dto); GeschichteView saved = geschichteService.update(id, dto);
assertThat(saved.getStatus()).isEqualTo(GeschichteStatus.DRAFT); assertThat(saved.status()).isEqualTo(GeschichteStatus.DRAFT);
assertThat(saved.getPublishedAt()).isNull(); assertThat(saved.publishedAt()).isNull();
} }
@Test @Test
@@ -427,9 +636,46 @@ class GeschichteServiceTest {
GeschichteUpdateDTO dto = new GeschichteUpdateDTO(); GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setBody("<p>ok</p><script>alert(1)</script>"); dto.setBody("<p>ok</p><script>alert(1)</script>");
Geschichte saved = geschichteService.update(id, dto); GeschichteView saved = geschichteService.update(id, dto);
assertThat(saved.getBody()).doesNotContain("<script>").contains("<p>ok</p>"); assertThat(saved.body()).doesNotContain("<script>").contains("<p>ok</p>");
}
@Test
void update_rejects_type_change_with_409_GESCHICHTE_TYPE_IMMUTABLE() {
authenticateAs(writer, Permission.BLOG_WRITE);
UUID id = UUID.randomUUID();
Geschichte existing = draft(id);
existing.setType(GeschichteType.STORY);
when(geschichteRepository.findById(id)).thenReturn(Optional.of(existing));
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setType(GeschichteType.JOURNEY);
assertThatThrownBy(() -> geschichteService.update(id, dto))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.GESCHICHTE_TYPE_IMMUTABLE);
}
@Test
void update_accepts_dto_carrying_the_unchanged_type() {
authenticateAs(writer, Permission.BLOG_WRITE);
UUID id = UUID.randomUUID();
Geschichte existing = draft(id);
existing.setType(GeschichteType.STORY);
when(geschichteRepository.findById(id)).thenReturn(Optional.of(existing));
when(geschichteRepository.save(any(Geschichte.class)))
.thenAnswer(inv -> inv.getArgument(0));
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setType(GeschichteType.STORY);
dto.setTitle("Unverändert getypt");
GeschichteView saved = geschichteService.update(id, dto);
assertThat(saved.type()).isEqualTo(GeschichteType.STORY);
assertThat(saved.title()).isEqualTo("Unverändert getypt");
} }
@Test @Test

View File

@@ -74,6 +74,72 @@ class JourneyItemConstraintsTest {
assertThat(condeferred).as("constraint must be initially deferred").isTrue(); assertThat(condeferred).as("constraint must be initially deferred").isTrue();
} }
@Test
void unique_index_rejects_duplicate_document_per_geschichte() {
// Atomic backstop for the service-level dedup pre-check (check-then-insert race).
jdbcTemplate.update(
"INSERT INTO journey_items (id, geschichte_id, position, document_id) VALUES (?, ?, ?, ?)",
UUID.randomUUID(), geschichteId, 10, documentId);
assertThatThrownBy(() ->
jdbcTemplate.update(
"INSERT INTO journey_items (id, geschichte_id, position, document_id) VALUES (?, ?, ?, ?)",
UUID.randomUUID(), geschichteId, 20, documentId))
.isInstanceOf(DataIntegrityViolationException.class);
}
@Test
void unique_index_allows_same_document_in_different_journeys() {
Geschichte other = geschichteRepository.save(Geschichte.builder()
.title("Andere Lesereise")
.status(GeschichteStatus.DRAFT)
.type(GeschichteType.JOURNEY)
.build());
jdbcTemplate.update(
"INSERT INTO journey_items (id, geschichte_id, position, document_id) VALUES (?, ?, ?, ?)",
UUID.randomUUID(), geschichteId, 10, documentId);
jdbcTemplate.update(
"INSERT INTO journey_items (id, geschichte_id, position, document_id) VALUES (?, ?, ?, ?)",
UUID.randomUUID(), other.getId(), 10, documentId);
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM journey_items WHERE document_id = ?", Integer.class, documentId);
assertThat(count).isEqualTo(2);
}
@Test
void unique_index_allows_multiple_note_only_items() {
// document_id IS NULL rows must not collide — the index is partial.
jdbcTemplate.update(
"INSERT INTO journey_items (id, geschichte_id, position, note) VALUES (?, ?, ?, ?)",
UUID.randomUUID(), geschichteId, 10, "erste Notiz");
jdbcTemplate.update(
"INSERT INTO journey_items (id, geschichte_id, position, note) VALUES (?, ?, ?, ?)",
UUID.randomUUID(), geschichteId, 20, "zweite Notiz");
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM journey_items WHERE geschichte_id = ?", Integer.class, geschichteId);
assertThat(count).isEqualTo(2);
}
@Test
void note_length_check_rejects_2001_chars() {
assertThatThrownBy(() ->
jdbcTemplate.update(
"INSERT INTO journey_items (id, geschichte_id, position, note) VALUES (?, ?, ?, ?)",
UUID.randomUUID(), geschichteId, 10, "x".repeat(2001)))
.isInstanceOf(DataIntegrityViolationException.class);
}
@Test
void note_length_check_accepts_exactly_2000_chars() {
// Pins the boundary at the DB layer too — a future <= vs < migration edit
// must fail here, not only in the mock-based service test.
jdbcTemplate.update(
"INSERT INTO journey_items (id, geschichte_id, position, note) VALUES (?, ?, ?, ?)",
UUID.randomUUID(), geschichteId, 10, "x".repeat(2000));
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM journey_items WHERE geschichte_id = ?", Integer.class, geschichteId);
assertThat(count).isEqualTo(1);
}
@Test @Test
void position_check_rejects_nonpositive() { void position_check_rejects_nonpositive() {
UUID itemId = UUID.randomUUID(); UUID itemId = UUID.randomUUID();

View File

@@ -94,9 +94,11 @@ class JourneyItemIntegrationTest {
void items_are_returned_in_position_order_regardless_of_insertion_order() { void items_are_returned_in_position_order_regardless_of_insertion_order() {
Geschichte managed = geschichteRepository.findById(journey.getId()).orElseThrow(); Geschichte managed = geschichteRepository.findById(journey.getId()).orElseThrow();
// Distinct content per item — V74's partial unique index forbids the same
// document twice in one journey, and ordering doesn't depend on it.
JourneyItem third = JourneyItem.builder().geschichte(managed).position(3000).document(doc).build(); JourneyItem third = JourneyItem.builder().geschichte(managed).position(3000).document(doc).build();
JourneyItem first = JourneyItem.builder().geschichte(managed).position(1000).document(doc).build(); JourneyItem first = JourneyItem.builder().geschichte(managed).position(1000).note("erstes").build();
JourneyItem second = JourneyItem.builder().geschichte(managed).position(2000).document(doc).build(); JourneyItem second = JourneyItem.builder().geschichte(managed).position(2000).note("zweites").build();
managed.getItems().addAll(List.of(third, first, second)); managed.getItems().addAll(List.of(third, first, second));
geschichteRepository.save(managed); geschichteRepository.save(managed);
em.flush(); em.flush();
@@ -258,6 +260,30 @@ class JourneyItemIntegrationTest {
assertThat(persisted.get(0).getNote()).isEqualTo("First stop"); assertThat(persisted.get(0).getNote()).isEqualTo("First stop");
} }
@Test
void append_document_persists_and_rejects_duplicate() {
// Covers the document branch of append, including the duplicate guard —
// the derived exists query must resolve document.id, which the transient
// getDocumentId() getter on JourneyItem shadows for Spring Data.
authenticateAs(writer, Permission.BLOG_WRITE);
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setDocumentId(doc.getId());
JourneyItemView view = journeyItemService.append(journey.getId(), dto);
em.flush();
em.clear();
assertThat(view.document()).isNotNull();
assertThat(view.document().id()).isEqualTo(doc.getId());
JourneyItemCreateDTO duplicate = new JourneyItemCreateDTO();
duplicate.setDocumentId(doc.getId());
assertThatThrownBy(() -> journeyItemService.append(journey.getId(), duplicate))
.hasFieldOrPropertyWithValue("code",
org.raddatz.familienarchiv.exception.ErrorCode.JOURNEY_DOCUMENT_ALREADY_ADDED);
}
// ─── JourneyItemService.reorder — atomicity check ──────────────────────── // ─── JourneyItemService.reorder — atomicity check ────────────────────────
@Test @Test

View File

@@ -149,7 +149,7 @@ class JourneyItemServiceTest {
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L); when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L);
when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.empty()); when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.empty());
JourneyItem saved = savedItem(itemId, journey, 10, null, "Note"); JourneyItem saved = savedItem(itemId, journey, 10, null, "Note");
when(journeyItemRepository.save(any())).thenReturn(saved); when(journeyItemRepository.saveAndFlush(any())).thenReturn(saved);
JourneyItemCreateDTO dto = new JourneyItemCreateDTO(); JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setNote("Note"); dto.setNote("Note");
@@ -166,7 +166,7 @@ class JourneyItemServiceTest {
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(2L); when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(2L);
when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.of(40)); when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.of(40));
JourneyItem saved = savedItem(itemId, journey, 50, null, "Note"); JourneyItem saved = savedItem(itemId, journey, 50, null, "Note");
when(journeyItemRepository.save(any())).thenReturn(saved); when(journeyItemRepository.saveAndFlush(any())).thenReturn(saved);
JourneyItemCreateDTO dto = new JourneyItemCreateDTO(); JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setNote("Note"); dto.setNote("Note");
@@ -203,18 +203,34 @@ class JourneyItemServiceTest {
} }
@Test @Test
void append_returns400_when_note_exceeds_5000_chars() { void append_rejects_note_longer_than_2000_chars_with_JOURNEY_NOTE_TOO_LONG() {
// 2000 is the spec'd limit (frontend maxlength + i18n message agree) — see #793.
Geschichte journey = journey(geschichteId); Geschichte journey = journey(geschichteId);
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey)); when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L); when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L);
JourneyItemCreateDTO dto = new JourneyItemCreateDTO(); JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setNote("x".repeat(5001)); dto.setNote("x".repeat(2001));
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto)) assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
.isInstanceOf(DomainException.class) .isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getCode()) .satisfies(e -> assertThat(((DomainException) e).getCode())
.isEqualTo(ErrorCode.VALIDATION_ERROR)); .isEqualTo(ErrorCode.JOURNEY_NOTE_TOO_LONG));
}
@Test
void append_accepts_note_of_exactly_2000_chars() {
Geschichte journey = journey(geschichteId);
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L);
when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.empty());
JourneyItem saved = savedItem(itemId, journey, 10, null, "x".repeat(2000));
when(journeyItemRepository.saveAndFlush(any())).thenReturn(saved);
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setNote("x".repeat(2000));
assertThat(journeyItemService.append(geschichteId, dto).note()).hasSize(2000);
} }
@Test @Test
@@ -288,6 +304,22 @@ class JourneyItemServiceTest {
.isEqualTo(ErrorCode.JOURNEY_AT_CAPACITY)); .isEqualTo(ErrorCode.JOURNEY_AT_CAPACITY));
} }
@Test
void append_returns409_when_document_already_in_journey() {
Geschichte journey = journey(geschichteId);
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(1L);
when(journeyItemRepository.existsByGeschichteIdAndDocumentId(geschichteId, docId)).thenReturn(true);
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setDocumentId(docId);
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
.isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getCode())
.isEqualTo(ErrorCode.JOURNEY_DOCUMENT_ALREADY_ADDED));
}
@Test @Test
void cap_is_COUNT_based_not_MAX_position_based() { void cap_is_COUNT_based_not_MAX_position_based() {
// 99 rows with MAX(position)=2000 should still accept the 100th append // 99 rows with MAX(position)=2000 should still accept the 100th append
@@ -296,7 +328,7 @@ class JourneyItemServiceTest {
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(99L); when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(99L);
when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.of(2000)); when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.of(2000));
JourneyItem saved = savedItem(itemId, journey, 2010, null, "Note"); JourneyItem saved = savedItem(itemId, journey, 2010, null, "Note");
when(journeyItemRepository.save(any())).thenReturn(saved); when(journeyItemRepository.saveAndFlush(any())).thenReturn(saved);
JourneyItemCreateDTO dto = new JourneyItemCreateDTO(); JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setNote("Note"); dto.setNote("Note");
@@ -304,6 +336,51 @@ class JourneyItemServiceTest {
assertThat(journeyItemService.append(geschichteId, dto).position()).isEqualTo(2010); assertThat(journeyItemService.append(geschichteId, dto).position()).isEqualTo(2010);
} }
@Test
void append_maps_unique_index_violation_to_409_JOURNEY_DOCUMENT_ALREADY_ADDED() {
// Two concurrent appends can both pass the exists() pre-check; the partial
// unique index then rejects the second INSERT at flush. The service must
// translate that into the same friendly 409 as the pre-check.
Geschichte journey = journey(geschichteId);
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(1L);
when(journeyItemRepository.existsByGeschichteIdAndDocumentId(eq(geschichteId), any())).thenReturn(false);
when(documentService.findSummaryByIdInternal(any())).thenReturn(makeDoc(UUID.randomUUID(), null, List.of(), null, null));
when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.of(10));
when(journeyItemRepository.saveAndFlush(any()))
.thenThrow(new org.springframework.dao.DataIntegrityViolationException(
"duplicate key value violates unique constraint \"uq_journey_items_geschichte_document\""));
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setDocumentId(UUID.randomUUID());
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
.isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getCode())
.isEqualTo(ErrorCode.JOURNEY_DOCUMENT_ALREADY_ADDED));
}
@Test
void append_rethrows_unrelated_integrity_violations_instead_of_mislabeling_them() {
// An FK violation (document deleted between load and flush) must NOT be
// translated into "already added" — only the dedup index earns that 409.
Geschichte journey = journey(geschichteId);
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(1L);
when(journeyItemRepository.existsByGeschichteIdAndDocumentId(eq(geschichteId), any())).thenReturn(false);
when(documentService.findSummaryByIdInternal(any())).thenReturn(makeDoc(UUID.randomUUID(), null, List.of(), null, null));
when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.of(10));
when(journeyItemRepository.saveAndFlush(any()))
.thenThrow(new org.springframework.dao.DataIntegrityViolationException(
"insert or update on table \"journey_items\" violates foreign key constraint \"fk_journey_items_document\""));
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setDocumentId(UUID.randomUUID());
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
.isInstanceOf(org.springframework.dao.DataIntegrityViolationException.class);
}
@Test @Test
void append_audits_JOURNEY_ITEM_ADDED() { void append_audits_JOURNEY_ITEM_ADDED() {
Geschichte journey = journey(geschichteId); Geschichte journey = journey(geschichteId);
@@ -311,7 +388,7 @@ class JourneyItemServiceTest {
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L); when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L);
when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.empty()); when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.empty());
JourneyItem saved = savedItem(itemId, journey, 10, null, "Note"); JourneyItem saved = savedItem(itemId, journey, 10, null, "Note");
when(journeyItemRepository.save(any())).thenReturn(saved); when(journeyItemRepository.saveAndFlush(any())).thenReturn(saved);
JourneyItemCreateDTO dto = new JourneyItemCreateDTO(); JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setNote("Note"); dto.setNote("Note");
@@ -404,18 +481,18 @@ class JourneyItemServiceTest {
} }
@Test @Test
void patch_returns400_when_note_exceeds_5000_chars() { void patch_rejects_note_longer_than_2000_chars_with_JOURNEY_NOTE_TOO_LONG() {
Geschichte journey = journey(geschichteId); Geschichte journey = journey(geschichteId);
JourneyItem item = savedItem(itemId, journey, 10, null, "Old"); JourneyItem item = savedItem(itemId, journey, 10, null, "Old");
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item)); when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item));
JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO(); JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
dto.setNote(Optional.of("x".repeat(5001))); dto.setNote(Optional.of("x".repeat(2001)));
assertThatThrownBy(() -> journeyItemService.updateNote(geschichteId, itemId, dto)) assertThatThrownBy(() -> journeyItemService.updateNote(geschichteId, itemId, dto))
.isInstanceOf(DomainException.class) .isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getCode()) .satisfies(e -> assertThat(((DomainException) e).getCode())
.isEqualTo(ErrorCode.VALIDATION_ERROR)); .isEqualTo(ErrorCode.JOURNEY_NOTE_TOO_LONG));
} }
@Test @Test

View File

@@ -61,7 +61,7 @@ Members of the cross-cutting layer have no entity of their own, no user-facing C
| `audit` | Append-only event store (`audit_log`) for all domain mutations. Feeds the activity feed and Family Pulse dashboard. | Consumed by 5+ domains; no user-facing CRUD of its own | | `audit` | Append-only event store (`audit_log`) for all domain mutations. Feeds the activity feed and Family Pulse dashboard. | Consumed by 5+ domains; no user-facing CRUD of its own |
| `config` | Infrastructure bean definitions: `MinioConfig`, `AsyncConfig`, `WebConfig` | Framework infra; no business logic | | `config` | Infrastructure bean definitions: `MinioConfig`, `AsyncConfig`, `WebConfig` | Framework infra; no business logic |
| `dashboard` | Stats aggregation for the admin dashboard and Family Pulse widget | Aggregates from 3+ domains; no owned entities | | `dashboard` | Stats aggregation for the admin dashboard and Family Pulse widget | Aggregates from 3+ domains; no owned entities |
| `exception` | `DomainException`, `ErrorCode` enum, `GlobalExceptionHandler` | Framework infra; consumed by every controller and service. Adding a new `ErrorCode` requires matching updates in `frontend/src/lib/shared/errors.ts` and all three `messages/*.json` locale files. Current security-related codes: `CSRF_TOKEN_MISSING` (403 on mutating request without valid `X-XSRF-TOKEN` header), `TOO_MANY_LOGIN_ATTEMPTS` (429 when login rate limit exceeded). | | `exception` | `DomainException`, `ErrorCode` enum, `GlobalExceptionHandler` | Framework infra; consumed by every controller and service. Adding a new `ErrorCode` requires matching updates in `frontend/src/lib/shared/errors.ts` and all three `messages/*.json` locale files. Current security-related codes: `CSRF_TOKEN_MISSING` (403 on mutating request without valid `X-XSRF-TOKEN` header), `TOO_MANY_LOGIN_ATTEMPTS` (429 when login rate limit exceeded). Journey/geschichte domain codes: `JOURNEY_NOTE_TOO_LONG`, `JOURNEY_DOCUMENT_ALREADY_ADDED`, `GESCHICHTE_TYPE_IMMUTABLE`, `GESCHICHTE_TITLE_TOO_LONG`, `GESCHICHTE_INTRO_TOO_LONG`. |
| `filestorage` | `FileService` — MinIO/S3 upload, download, presigned-URL generation | Generic service; consumed by `document` and `ocr` | | `filestorage` | `FileService` — MinIO/S3 upload, download, presigned-URL generation | Generic service; consumed by `document` and `ocr` |
| `importing` | `CanonicalImportOrchestrator` — async canonical import running four idempotent loaders (`TagTreeImporter``PersonRegisterImporter``PersonTreeImporter``DocumentImporter`) over the normalizer's committed canonical artifacts (`canonical-*.xlsx` + `canonical-persons-tree.json`) | Orchestrates across `person`, `tag`, `document` | | `importing` | `CanonicalImportOrchestrator` — async canonical import running four idempotent loaders (`TagTreeImporter``PersonRegisterImporter``PersonTreeImporter``DocumentImporter`) over the normalizer's committed canonical artifacts (`canonical-*.xlsx` + `canonical-persons-tree.json`) | Orchestrates across `person`, `tag`, `document` |
| `security` | `SecurityConfig`, `Permission` enum, `@RequirePermission` annotation, `PermissionAspect` (AOP) | Framework infra; enforced globally across all controllers | | `security` | `SecurityConfig`, `Permission` enum, `@RequirePermission` annotation, `PermissionAspect` (AOP) | Framework infra; enforced globally across all controllers |

View File

@@ -159,6 +159,9 @@ _See also [Chronik](#chronik-internal)._
**DocumentSummary** (`DocumentSummary`) `[internal]` — lean document read-model used inside `JourneyItemView`. Contains title, date, senderName, receiverName, receiverCount, datePrecision — no tags or file storage info. **DocumentSummary** (`DocumentSummary`) `[internal]` — lean document read-model used inside `JourneyItemView`. Contains title, date, senderName, receiverName, receiverCount, datePrecision — no tags or file storage info.
**Interlude / Zwischentext** `[user-facing]` — an editorial paragraph inserted between document items in a *Lesereise*. An interlude is a `JourneyItem` with `document_id IS NULL` and a non-empty `note`; its content is a plain-text string stored in the `note` column (not `body` or `text`). Visually distinguished by `--color-interlude-bg/border/label` CSS tokens and a `ZWISCHENTEXT` label. Interludes cannot have their note removed (removing the interlude deletes the entire item).
_Not to be confused with a document item's optional note_ — a document item's note is curator commentary attached to a linked letter; an interlude is standalone editorial prose with no backing document.
**Lesereise** `[user-facing]` — a curated reading journey through a sequence of family documents, optionally annotated with editorial notes. Implemented as a `Geschichte` with `type=JOURNEY`. The reader UI (follow-on issue) renders items as a sequential reading experience. **Lesereise** `[user-facing]` — a curated reading journey through a sequence of family documents, optionally annotated with editorial notes. Implemented as a `Geschichte` with `type=JOURNEY`. The reader UI (follow-on issue) renders items as a sequential reading experience.
**Notification** (`Notification`) — an in-app message delivered to an `AppUser`. No email or SMS delivery exists today. Delivered via Server-Sent Events (`SseEmitterRegistry`) and persisted in the `notifications` table. **Notification** (`Notification`) — an in-app message delivered to an `AppUser`. No email or SMS delivery exists today. Delivered via Server-Sent Events (`SseEmitterRegistry`) and persisted in the `notifications` table.

View File

@@ -0,0 +1,65 @@
# ADR-036 — Geschichte responses are views assembled in-transaction, never entities
**Status:** Accepted
**Date:** 2026-06-10
**Issue:** #753 (JourneyEditor frontend), PR #792 review
## Context
The project convention (CLAUDE.md §DTOs) has been: *"Response types are the model
entities themselves (no response DTOs)."* That convention assumed entities whose
associations are either eager or initialized by the time Jackson serializes.
The lazy-fetch migration (ADR-022, `open-in-view: false`) broke that assumption:
Jackson serializes **after** the service transaction has closed, so any lazy
collection on a returned entity is a dead proxy. `Geschichte.items` (added with the
Lesereisen data model, #750) made this concrete: every `PATCH /api/geschichten/{id}`
(save draft, publish) failed with HTTP 500
`LazyInitializationException: Geschichte.items … (no session)`.
Per-endpoint force-initialization (`g.getItems().size()` inside the transaction)
worked for `getById()` but is a footgun: every new write method must remember the
trick, the entity carries a warning comment nobody reads, and the raw entity also
leaks the `author` `AppUser` graph (email, password hash, groups).
## Decision
In the **geschichte domain**, controllers never return entities. Every response is a
purpose-built read model assembled **inside** the service transaction:
- `GET /api/geschichten``GeschichteSummary` (projection; never carries items;
author exposes names only — never email)
- `GET /api/geschichten/{id}``GeschichteView` (with `AuthorView`, `PersonView`,
`JourneyItemView` items)
- `POST /api/geschichten`, `PATCH /api/geschichten/{id}``GeschichteView`
- JourneyItem endpoints → `JourneyItemView`
The invariant: **entities never cross the controller boundary in this domain.**
A view is constructed while the Hibernate session is open, so serialization can
never touch a lazy proxy, and the response shape is an explicit, security-reviewed
contract.
## Alternatives rejected
- **`@Transactional` on read/write methods + force-init (`getItems().size()`)** —
fixes one endpoint at a time, silently regresses when the next write method is
added, and still serializes the raw `AppUser` author graph.
- **`open-in-view: true`** — re-opens the session during rendering; hides N+1
queries and couples the HTTP layer to Hibernate session lifetime. Rejected
already by ADR-022.
- **Jackson `@JsonIgnore` on lazy fields** — loses the data the client needs
(items ARE the journey) instead of loading it deliberately.
## Consequences
- CLAUDE.md §DTOs names the geschichte domain as the exception to the
entities-as-responses convention. Other domains (document, person, tag) still
return entities; they predate ADR-022's lazy collections on their hot paths and
migrate opportunistically when they grow lazy collections of their own.
- `npm run generate:api` must run after any view change — the generated
`Geschichte` schema no longer exists; frontend consumers use
`GeschichteView`/`GeschichteSummary`.
- New geschichte endpoints must add a view (or extend an existing one), not return
the entity. The regression guards are `GeschichteHttpTest`
(`update_returns_200_and_serializes_items_open_in_view_false`) and
`GeschichteListProjectionTest`.

View File

@@ -12,7 +12,7 @@ System_Boundary(frontend, "Web Frontend (SvelteKit / SSR)") {
Component(personReview, "/persons/review", "SvelteKit Route", "Transcriber triage view (WRITE-gated link). Lists provisional persons; per-row Merge / Umbenennen / Bestätigen / Löschen. Actions: POST /merge, PUT /{id}, PATCH /{id}/confirm, DELETE /{id}.") Component(personReview, "/persons/review", "SvelteKit Route", "Transcriber triage view (WRITE-gated link). Lists provisional persons; per-row Merge / Umbenennen / Bestätigen / Löschen. Actions: POST /merge, PUT /{id}, PATCH /{id}/confirm, DELETE /{id}.")
Component(aktivitaeten, "/aktivitaeten", "SvelteKit Route", "Unified activity feed (Chronik). Loader: GET /api/dashboard/activity and GET /api/notifications?read=false.") Component(aktivitaeten, "/aktivitaeten", "SvelteKit Route", "Unified activity feed (Chronik). Loader: GET /api/dashboard/activity and GET /api/notifications?read=false.")
Component(geschichten, "/geschichten and /geschichten/[id]", "SvelteKit Routes", "Story/Journey list and detail pages. List: GeschichteListRow with REISE badge for JOURNEY type. Detail: dispatches to StoryReader (rich text + persons) or JourneyReader (intro + ordered JourneyItemCard/JourneyInterlude items + empty state) based on GeschichteType. BLOG_WRITE users see edit/delete actions. Loader: GET /api/geschichten, GET /api/geschichten/{id}.") Component(geschichten, "/geschichten and /geschichten/[id]", "SvelteKit Routes", "Story/Journey list and detail pages. List: GeschichteListRow with REISE badge for JOURNEY type. Detail: dispatches to StoryReader (rich text + persons) or JourneyReader (intro + ordered JourneyItemCard/JourneyInterlude items + empty state) based on GeschichteType. BLOG_WRITE users see edit/delete actions. Loader: GET /api/geschichten, GET /api/geschichten/{id}.")
Component(geschichtenEdit, "/geschichten/[id]/edit and /geschichten/new", "SvelteKit Routes", "Story editor and creation flow. New: TypeSelector (STORY/JOURNEY radio group with roving tabindex) → StoryCreate (rich text editor, person linking, POST /api/geschichten) or JOURNEY placeholder (editor deferred to #753). Edit: PUT /api/geschichten/{id}. Requires BLOG_WRITE permission.") Component(geschichtenEdit, "/geschichten/[id]/edit and /geschichten/new", "SvelteKit Routes", "Story editor and creation flow. New: TypeSelector (STORY/JOURNEY radio group with roving tabindex) → StoryCreate (TipTap rich text, person linking, POST /api/geschichten) or JourneyCreate (title + first item). Edit: branches on GeschichteType — STORY opens GeschichteEditor (TipTap body + GeschichteSidebar); JOURNEY opens JourneyEditor (title, intro textarea, ordered JourneyItemRow list with drag-reorder + move-up/down, JourneyAddBar for document/interlude addition, GeschichteSidebar). JourneyEditor mutations: POST/DELETE /items, PUT /items/reorder, PATCH /items/{id}. Requires BLOG_WRITE permission.")
Component(stammbaum, "/stammbaum", "SvelteKit Route", "Family tree visualisation. Loader: GET /api/network (nodes + edges). Renders interactive family tree from network graph data.") Component(stammbaum, "/stammbaum", "SvelteKit Route", "Family tree visualisation. Loader: GET /api/network (nodes + edges). Renders interactive family tree from network graph data.")
Component(themen, "/themen", "SvelteKit Route", "Browsable topic index. Shows all root tags as cards with color bars and child rows. ThemenWidget also embedded in the home dashboard (reader + editor sidebar). Loader: GET /api/tags/tree.") Component(themen, "/themen", "SvelteKit Route", "Browsable topic index. Shows all root tags as cards with color bars and child rows. ThemenWidget also embedded in the home dashboard (reader + editor sidebar). Loader: GET /api/tags/tree.")
Component(profilePage, "/profile", "SvelteKit Route", "Current user profile settings. Loader: GET /api/users/me/notification-preferences. Actions: update name/password and notification preferences.") Component(profilePage, "/profile", "SvelteKit Route", "Current user profile settings. Loader: GET /api/users/me/notification-preferences. Actions: update name/password and notification preferences.")
@@ -25,7 +25,7 @@ Rel(personEdit, backend, "GET /api/persons/{id}, PUT /api/persons/{id}, POST /ap
Rel(personReview, backend, "GET /api/persons?provisional=true, PATCH /api/persons/{id}/confirm, DELETE /api/persons/{id}, POST /api/persons/{id}/merge", "HTTP / JSON") Rel(personReview, backend, "GET /api/persons?provisional=true, PATCH /api/persons/{id}/confirm, DELETE /api/persons/{id}, POST /api/persons/{id}/merge", "HTTP / JSON")
Rel(aktivitaeten, backend, "GET /api/dashboard/activity, GET /api/notifications", "HTTP / JSON") Rel(aktivitaeten, backend, "GET /api/dashboard/activity, GET /api/notifications", "HTTP / JSON")
Rel(geschichten, backend, "GET /api/geschichten, GET /api/geschichten/{id}, DELETE /api/geschichten/{id}", "HTTP / JSON") Rel(geschichten, backend, "GET /api/geschichten, GET /api/geschichten/{id}, DELETE /api/geschichten/{id}", "HTTP / JSON")
Rel(geschichtenEdit, backend, "GET /api/persons/{id} (pre-populate), POST /api/geschichten, PUT /api/geschichten/{id}", "HTTP / JSON") Rel(geschichtenEdit, backend, "GET /api/persons/{id} (pre-populate), POST /api/geschichten, PUT /api/geschichten/{id}, POST/DELETE /api/geschichten/{id}/items, PUT /api/geschichten/{id}/items/reorder, PATCH /api/geschichten/{id}/items/{itemId}", "HTTP / JSON")
Rel(stammbaum, backend, "GET /api/network", "HTTP / JSON") Rel(stammbaum, backend, "GET /api/network", "HTTP / JSON")
Rel(themen, backend, "GET /api/tags/tree", "HTTP / JSON") Rel(themen, backend, "GET /api/tags/tree", "HTTP / JSON")
Rel(profilePage, backend, "GET/PUT /api/users/me, notification-preferences", "HTTP / JSON") Rel(profilePage, backend, "GET/PUT /api/users/me, notification-preferences", "HTTP / JSON")

View File

@@ -357,7 +357,7 @@ package "Supporting" {
id : UUID <<PK>> id : UUID <<PK>>
-- --
title : VARCHAR(255) NOT NULL title : VARCHAR(255) NOT NULL
body : TEXT body : TEXT CHECK (JOURNEY: length <= 4000)
status : VARCHAR(32) NOT NULL status : VARCHAR(32) NOT NULL
type : VARCHAR(32) NOT NULL type : VARCHAR(32) NOT NULL
author_id : UUID <<FK>> author_id : UUID <<FK>>
@@ -377,9 +377,10 @@ package "Supporting" {
geschichte_id : UUID <<FK>> geschichte_id : UUID <<FK>>
document_id : UUID <<FK>> document_id : UUID <<FK>>
position : INTEGER NOT NULL CHECK (position > 0) position : INTEGER NOT NULL CHECK (position > 0)
note : TEXT note : TEXT CHECK (length <= 2000)
== ==
UNIQUE (geschichte_id, position) DEFERRABLE INITIALLY DEFERRED UNIQUE (geschichte_id, position) DEFERRABLE INITIALLY DEFERRED
UNIQUE (geschichte_id, document_id) WHERE document_id IS NOT NULL
} }
} }

View File

@@ -131,5 +131,7 @@ geschichten_persons }o--|| geschichten : geschichte_id
geschichten_persons }o--|| persons : person_id geschichten_persons }o--|| persons : person_id
journey_items }o--|| geschichten : geschichte_id (ON DELETE CASCADE) journey_items }o--|| geschichten : geschichte_id (ON DELETE CASCADE)
journey_items }o--o| documents : document_id (ON DELETE SET NULL) journey_items }o--o| documents : document_id (ON DELETE SET NULL)
note right of journey_items : partial UNIQUE (geschichte_id, document_id)\nWHERE document_id IS NOT NULL (V74)
note right of geschichten : CHECK length(body) <= 4000\nfor type = JOURNEY (V75)
@enduml @enduml

View File

@@ -421,16 +421,16 @@
<tbody> <tbody>
<tr class="grp"><td colspan="3">Seitenstruktur</td></tr> <tr class="grp"><td colspan="3">Seitenstruktur</td></tr>
<tr><td>Page title</td><td>font-family:var(--font-display);font-size:24px;color:var(--navy)</td><td>Fraunces, nicht fett</td></tr> <tr><td>Page title</td><td>font-family:var(--font-display);font-size:24px;color:var(--navy)</td><td>Fraunces, nicht fett</td></tr>
<tr><td>Editorial list card</td><td>bg-white shadow-sm border border-brand-sand rounded-sm</td><td>wraps alle Zeilen</td></tr> <tr><td>Editorial list card</td><td><s>bg-white shadow-sm border border-brand-sand rounded-sm</s> <em>(implementiert: bg-surface border-line — semantische Tokens für Dark Mode)</em></td><td>wraps alle Zeilen</td></tr>
<tr class="grp"><td colspan="3">Listenzeile</td></tr> <tr class="grp"><td colspan="3">Listenzeile</td></tr>
<tr><td>List row</td><td>flex gap-0 border-b border-brand-sand last:border-0 hover:bg-surface</td><td>min-h-[44px] auf Mobile</td></tr> <tr><td>List row</td><td>flex gap-0 border-b border-brand-sand last:border-0 hover:bg-surface</td><td>min-h-[44px] auf Mobile</td></tr>
<tr><td>Meta column</td><td>w-[88px] shrink-0 flex flex-col gap-1 p-3 border-r border-brand-sand</td><td>feste Breite</td></tr> <tr><td>Meta column</td><td>w-40 shrink-0 flex flex-col gap-1 p-3 border-r border-line-2</td><td>feste Breite — breit genug für text-sm Namen ohne Umbruch</td></tr>
<tr><td>Author avatar</td><td>w-7 h-7 rounded-full text-[9px] font-bold text-white flex items-center justify-center</td><td>personAvatarColor(userId)</td></tr> <tr><td>Author avatar</td><td>w-7 h-7 rounded-full text-[9px] font-bold text-white flex items-center justify-center</td><td>personAvatarColor(userId)</td></tr>
<tr><td>Author name</td><td>font-sans text-xs font-semibold text-ink</td><td></td></tr> <tr><td>Author name</td><td>font-sans text-sm font-semibold text-ink</td><td></td></tr>
<tr><td>Date</td><td>font-sans text-xs text-ink-3</td><td>formatDate(publishedAt)</td></tr> <tr><td>Date</td><td>font-sans text-sm text-ink-3</td><td>formatDate(publishedAt)</td></tr>
<tr><td>Person chip</td><td>inline-flex items-center gap-1 rounded-full bg-surface border border-line px-2 py-0.5 text-[10px] font-medium text-ink</td><td>links zu /persons/[id]; optional</td></tr> <tr><td>Person chip</td><td>inline-flex items-center gap-1 rounded-full bg-surface border border-line px-2 py-0.5 text-[10px] font-medium text-ink</td><td>links zu /persons/[id]; optional</td></tr>
<tr><td>Story title</td><td>font-serif text-[15px] text-ink leading-snug mb-1 hover:text-primary</td><td>link zu /geschichten/[id]</td></tr> <tr><td>Story title</td><td>font-serif text-lg text-ink leading-snug mb-1 hover:text-primary</td><td>link zu /geschichten/[id]</td></tr>
<tr><td>Excerpt</td><td>font-sans text-xs text-ink-3 line-clamp-2</td><td>max. 150 Zeichen aus body</td></tr> <tr><td>Excerpt</td><td>font-sans text-sm text-ink-3 line-clamp-2</td><td>max. 150 Zeichen aus body</td></tr>
<tr class="grp"><td colspan="3">Filter</td></tr> <tr class="grp"><td colspan="3">Filter</td></tr>
<tr><td>Filter pill (inaktiv)</td><td>rounded-full border border-line px-3 py-1 text-xs font-semibold text-ink-2 hover:bg-muted</td><td>aria-pressed="false"</td></tr> <tr><td>Filter pill (inaktiv)</td><td>rounded-full border border-line px-3 py-1 text-xs font-semibold text-ink-2 hover:bg-muted</td><td>aria-pressed="false"</td></tr>
<tr><td>Filter pill (aktiv)</td><td>rounded-full bg-primary text-primary-fg px-3 py-1 text-xs font-semibold</td><td>aria-pressed="true"</td></tr> <tr><td>Filter pill (aktiv)</td><td>rounded-full bg-primary text-primary-fg px-3 py-1 text-xs font-semibold</td><td>aria-pressed="true"</td></tr>
@@ -640,7 +640,8 @@
<thead><tr><th>Element</th><th>Wert</th><th>Hinweise</th></tr></thead> <thead><tr><th>Element</th><th>Wert</th><th>Hinweise</th></tr></thead>
<tbody> <tbody>
<tr class="grp"><td colspan="3">Artikel-Container</td></tr> <tr class="grp"><td colspan="3">Artikel-Container</td></tr>
<tr><td>Article container</td><td>max-w-3xl mx-auto px-4 py-10</td><td>zentriert, volle Breite auf Mobile</td></tr> <tr><td>Article container</td><td>max-w-7xl mx-auto px-4 py-8; innere Lesespalte: max-w-3xl mx-auto</td><td>Seite so breit wie Dokumente/Personen; Textspalte bleibt lesbar zentriert</td></tr>
<tr><td>Article sheet</td><td>rounded-sm border border-line bg-sheet shadow-sm px-5 py-6 sm:px-10 sm:py-10</td><td>Lesebogen-Panel zwischen Canvas und weißen Karten (Token --color-sheet); BackButton bleibt außerhalb</td></tr>
<tr><td>Story title</td><td>font-family:var(--font-display);font-size:clamp(22px,4vw,32px);color:var(--navy)</td><td>Fraunces, nicht fett</td></tr> <tr><td>Story title</td><td>font-family:var(--font-display);font-size:clamp(22px,4vw,32px);color:var(--navy)</td><td>Fraunces, nicht fett</td></tr>
<tr><td>Back button</td><td>&lt;BackButton /&gt; aus $lib/components/BackButton.svelte</td><td>history.back(); nicht &lt;a href&gt;</td></tr> <tr><td>Back button</td><td>&lt;BackButton /&gt; aus $lib/components/BackButton.svelte</td><td>history.back(); nicht &lt;a href&gt;</td></tr>
<tr class="grp"><td colspan="3">Metazeile</td></tr> <tr class="grp"><td colspan="3">Metazeile</td></tr>
@@ -657,7 +658,7 @@
<tr><td>Doc reference card</td><td>flex gap-3 items-start bg-white border border-brand-sand rounded-sm p-3 hover:shadow-sm</td><td>links zu /documents/[id]</td></tr> <tr><td>Doc reference card</td><td>flex gap-3 items-start bg-white border border-brand-sand rounded-sm p-3 hover:shadow-sm</td><td>links zu /documents/[id]</td></tr>
<tr><td>Doc icon</td><td>w-9 h-9 bg-surface rounded flex items-center justify-center shrink-0</td><td>Dateisymbol SVG</td></tr> <tr><td>Doc icon</td><td>w-9 h-9 bg-surface rounded flex items-center justify-center shrink-0</td><td>Dateisymbol SVG</td></tr>
<tr class="grp"><td colspan="3">Mobile</td></tr> <tr class="grp"><td colspan="3">Mobile</td></tr>
<tr><td>… Menü (Mobile)</td><td>ml-auto text-ink-3; öffnet BottomSheet mit Bearbeiten + Löschen</td><td>BLOG_WRITE-Aktionen auf Mobile</td></tr> <tr><td>… Menü (Mobile)</td><td><s>ml-auto text-ink-3; öffnet BottomSheet mit Bearbeiten + Löschen</s> <em>(implementiert: Bearbeiten/Löschen bleiben inline in der Metazeile auf allen Breiten — kein BottomSheet)</em></td><td>BLOG_WRITE-Aktionen auf Mobile</td></tr>
<tr><td>Person chips (Mobile)</td><td>flex-wrap, volle Breite</td><td>kein horizontales Scrollen</td></tr> <tr><td>Person chips (Mobile)</td><td>flex-wrap, volle Breite</td><td>kein horizontales Scrollen</td></tr>
<tr><td>Doc cards (Mobile)</td><td>flex-col gap-2</td><td>stapeln vertikal</td></tr> <tr><td>Doc cards (Mobile)</td><td>flex-col gap-2</td><td>stapeln vertikal</td></tr>
</tbody> </tbody>
@@ -712,7 +713,7 @@
<ul> <ul>
<li>Die Schaltflächen „+ Neue Geschichte", „Bearbeiten" und „Löschen" werden nur gerendert, wenn <code>currentUser.permissions.includes('BLOG_WRITE')</code> wahr ist.</li> <li>Die Schaltflächen „+ Neue Geschichte", „Bearbeiten" und „Löschen" werden nur gerendert, wenn <code>currentUser.permissions.includes('BLOG_WRITE')</code> wahr ist.</li>
<li>Nicht nur ausblenden — Backend-Endpunkte für Schreib-/Löschoperationen sind ebenfalls durch <code>@RequirePermission(Permission.BLOG_WRITE)</code> geschützt.</li> <li>Nicht nur ausblenden — Backend-Endpunkte für Schreib-/Löschoperationen sind ebenfalls durch <code>@RequirePermission(Permission.BLOG_WRITE)</code> geschützt.</li>
<li>Auf Mobile werden Bearbeiten/Löschen aus dem Layout entfernt und erscheinen in einem BottomSheet, das über das ··· Menü in der Metazeile geöffnet wird.</li> <li><s>Auf Mobile werden Bearbeiten/Löschen aus dem Layout entfernt und erscheinen in einem BottomSheet, das über das ··· Menü in der Metazeile geöffnet wird.</s> <em>(implementiert: Aktionen bleiben inline in der Metazeile — h-11 Touch-Targets, kein BottomSheet)</em></li>
</ul> </ul>
<h3>Barrierefreiheit</h3> <h3>Barrierefreiheit</h3>

View File

@@ -500,7 +500,7 @@
<thead><tr><th>Element</th><th>Wert</th><th>Hinweise</th></tr></thead> <thead><tr><th>Element</th><th>Wert</th><th>Hinweise</th></tr></thead>
<tbody> <tbody>
<tr class="grp"><td colspan="3">Item-Zeile allgemein</td></tr> <tr class="grp"><td colspan="3">Item-Zeile allgemein</td></tr>
<tr><td>Item-Container</td><td>flex items-stretch bg-white border border-line rounded-sm mb-2 overflow-hidden</td><td>interlude: bg-orange-50 border-orange-200</td></tr> <tr><td>Item-Container</td><td>flex items-stretch bg-white border border-line rounded-sm mb-2 overflow-hidden</td><td>interlude: <del>bg-orange-50 border-orange-200</del><code>--color-interlude-bg</code> / <code>--color-interlude-border</code> CSS tokens</td></tr>
<tr><td>Drag-Handle</td><td>w-4 bg-surface border-r border-line flex items-center justify-center cursor-grab shrink-0</td><td>aria-label="Reihenfolge ändern"; cursor-grabbing während Drag</td></tr> <tr><td>Drag-Handle</td><td>w-4 bg-surface border-r border-line flex items-center justify-center cursor-grab shrink-0</td><td>aria-label="Reihenfolge ändern"; cursor-grabbing während Drag</td></tr>
<tr><td>Positions-Nr.</td><td>w-5 text-[10px] font-bold text-ink-3 flex items-start justify-center pt-2 shrink-0</td><td>aus Array-Index, nicht item.position</td></tr> <tr><td>Positions-Nr.</td><td>w-5 text-[10px] font-bold text-ink-3 flex items-start justify-center pt-2 shrink-0</td><td>aus Array-Index, nicht item.position</td></tr>
<tr><td>Entfernen-Button</td><td>w-6 flex items-start justify-center pt-2 shrink-0</td><td>× aria-label="Eintrag entfernen"; hover: text-red-500; Confirm nur wenn note vorhanden</td></tr> <tr><td>Entfernen-Button</td><td>w-6 flex items-start justify-center pt-2 shrink-0</td><td>× aria-label="Eintrag entfernen"; hover: text-red-500; Confirm nur wenn note vorhanden</td></tr>
@@ -508,18 +508,18 @@
<tr><td>Brieftitel</td><td>text-[11px] font-semibold text-ink leading-snug mb-0.5</td><td>document.title</td></tr> <tr><td>Brieftitel</td><td>text-[11px] font-semibold text-ink leading-snug mb-0.5</td><td>document.title</td></tr>
<tr><td>Briefmeta</td><td>text-xs text-ink-3</td><td>formatDate(doc.documentDate) · "von X" oder "von X an Y"</td></tr> <tr><td>Briefmeta</td><td>text-xs text-ink-3</td><td>formatDate(doc.documentDate) · "von X" oder "von X an Y"</td></tr>
<tr><td>Notiz-Textarea (sichtbar)</td><td>w-full min-h-[40px] font-serif text-xs italic bg-surface border border-line rounded-sm p-1.5 resize-none focus:border-primary focus:bg-white mt-2</td><td>auto-expand; bind:value={item.note}</td></tr> <tr><td>Notiz-Textarea (sichtbar)</td><td>w-full min-h-[40px] font-serif text-xs italic bg-surface border border-line rounded-sm p-1.5 resize-none focus:border-primary focus:bg-white mt-2</td><td>auto-expand; bind:value={item.note}</td></tr>
<tr><td>„Notiz hinzufügen" Link</td><td>text-xs font-semibold text-blue-600 inline-flex items-center gap-1 mt-1</td><td>togglet Notiz-Textarea</td></tr> <tr><td>„Notiz hinzufügen" Link</td><td><del>text-xs font-semibold text-blue-600</del><code>text-xs text-ink-3 underline hover:text-accent</code></td><td>togglet Notiz-Textarea</td></tr>
<tr><td>„Notiz entfernen" Link</td><td>text-xs text-ink-3 inline-flex items-center gap-1 mt-1</td><td>zeigt sich wenn note.trim() nicht leer; setzt note = '' und blendet Textarea aus</td></tr> <tr><td>„Notiz entfernen" Link</td><td>text-xs text-ink-3 inline-flex items-center gap-1 mt-1</td><td>zeigt sich wenn note.trim() nicht leer; setzt note = '' und blendet Textarea aus</td></tr>
<tr class="grp"><td colspan="3">Interlude-Item</td></tr> <tr class="grp"><td colspan="3">Interlude-Item</td></tr>
<tr><td>Interlude-Container</td><td>bg-orange-50 border-orange-200 (überschreibt Item-Container)</td><td>kein Positions-Kreis; Positions-Spalte zeigt Icon statt Zahl</td></tr> <tr><td>Interlude-Container</td><td><del>bg-orange-50 border-orange-200</del><code>--color-interlude-bg</code> left-accent border via <code>--color-interlude-border</code></td><td>kein Positions-Kreis; Positions-Spalte zeigt Icon statt Zahl</td></tr>
<tr><td>Label „Zwischentext"</td><td>text-[9px] font-bold uppercase tracking-widest text-orange-700 mb-1</td><td>immer sichtbar; nicht editierbar</td></tr> <tr><td>Label „Zwischentext"</td><td><del>text-orange-700</del><code>color: var(--color-interlude-label)</code></td><td>immer sichtbar; nicht editierbar</td></tr>
<tr><td>Zwischentext-Textarea</td><td>w-full min-h-[44px] font-serif text-xs italic bg-white/60 border border-orange-200 rounded-sm p-1.5 resize-none focus:border-orange-400</td><td>bind:value={item.note}; auto-expand; min 44px für Touch-Target</td></tr> <tr><td>Zwischentext-Textarea</td><td><del>border-orange-200 focus:border-orange-400</del><code>border-line focus-visible:ring-focus-ring</code></td><td>bind:value={item.note}; auto-expand; min 44px für Touch-Target</td></tr>
<tr class="grp"><td colspan="3">Aktionsleiste</td></tr> <tr class="grp"><td colspan="3">Aktionsleiste</td></tr>
<tr><td>Add Bar</td><td>flex gap-2 pt-2 pb-1</td><td>immer unten sichtbar, auch wenn Liste gefüllt</td></tr> <tr><td>Add Bar</td><td>flex gap-2 pt-2 pb-1</td><td>immer unten sichtbar, auch wenn Liste gefüllt</td></tr>
<tr><td>„Brief hinzufügen" Button</td><td>border border-dashed border-line rounded-sm px-3 py-1.5 text-xs font-semibold text-ink-2 hover:border-primary hover:text-primary flex items-center gap-1</td><td>öffnet existierende DocumentPicker-Komponente als Dropdown/Modal</td></tr> <tr><td>„Brief hinzufügen" Button</td><td>border border-dashed border-line rounded-sm px-3 py-1.5 text-xs font-semibold text-ink-2 hover:border-primary hover:text-primary flex items-center gap-1</td><td>öffnet existierende DocumentPicker-Komponente als Dropdown/Modal</td></tr>
<tr><td>„Zwischentext hinzufügen" Button</td><td>gleich wie Brief-Button</td><td>fügt neues Interlude-Item am Ende ein; Fokus auf das neue Textarea</td></tr> <tr><td>„Zwischentext hinzufügen" Button</td><td>gleich wie Brief-Button</td><td>fügt neues Interlude-Item am Ende ein; Fokus auf das neue Textarea</td></tr>
<tr class="grp"><td colspan="3">Drag-to-Reorder</td></tr> <tr class="grp"><td colspan="3">Drag-to-Reorder</td></tr>
<tr><td>Bibliothek</td><td>@dnd-kit/core oder svelte-dnd-action (bereits im Projekt prüfen)</td><td>kein neues Package ohne Absprache</td></tr> <tr><td>Bibliothek</td><td><del>@dnd-kit/core oder svelte-dnd-action</del><code>createBlockDragDrop&lt;JourneyItemView&gt;</code> aus <code>$lib/document/transcription/useBlockDragDrop.svelte</code></td><td>kein externes Package; pointer-Events + data-drag-handle / data-block-wrapper Kontrakt</td></tr>
<tr><td>Reorder-API-Call</td><td>PUT /api/geschichten/{id}/items/reorder — body: [{id, position}] für alle Items</td><td>nach jedem Drop ausgelöst; optimistisch: lokalen State sofort aktualisieren</td></tr> <tr><td>Reorder-API-Call</td><td>PUT /api/geschichten/{id}/items/reorder — body: [{id, position}] für alle Items</td><td>nach jedem Drop ausgelöst; optimistisch: lokalen State sofort aktualisieren</td></tr>
<tr><td>Accessibility</td><td>Drag-Handle: role="button" tabIndex=0; Keyboard: Space startet Drag, Arrow hoch/runter verschiebt, Space/Enter bestätigt, Esc abbricht</td><td>WCAG 2.1 SC 2.1.1</td></tr> <tr><td>Accessibility</td><td>Drag-Handle: role="button" tabIndex=0; Keyboard: Space startet Drag, Arrow hoch/runter verschiebt, Space/Enter bestätigt, Esc abbricht</td><td>WCAG 2.1 SC 2.1.1</td></tr>
</tbody> </tbody>
@@ -720,7 +720,7 @@
<tr><td>Split entfällt</td><td>@media (max-width: 768px): flex-col; Sidebar-Sektionen als Collapsibles am Ende</td><td>gleich wie GeschichteEditor auf Mobile</td></tr> <tr><td>Split entfällt</td><td>@media (max-width: 768px): flex-col; Sidebar-Sektionen als Collapsibles am Ende</td><td>gleich wie GeschichteEditor auf Mobile</td></tr>
<tr><td>Collapsibles</td><td>details/summary oder eigene boolean-Toggle; Personen + Status separat</td><td>geschlossen beim ersten Laden; Fokus öffnet</td></tr> <tr><td>Collapsibles</td><td>details/summary oder eigene boolean-Toggle; Personen + Status separat</td><td>geschlossen beim ersten Laden; Fokus öffnet</td></tr>
<tr class="grp"><td colspan="3">Touch &amp; Drag</td></tr> <tr class="grp"><td colspan="3">Touch &amp; Drag</td></tr>
<tr><td>Drag auf Mobile</td><td>Long-Press (500ms) auf dem Drag-Handle aktiviert Drag</td><td>dnd-kit unterstützt Touch nativ; kein separates Config nötig</td></tr> <tr><td>Drag auf Mobile</td><td>Move-Up/Down Buttons statt Drag (44px touch targets)</td><td><del>dnd-kit unterstützt Touch nativ</del> → Pointer-Drag nur Desktop; Keyboard via Pfeil-Buttons</td></tr>
<tr><td>Touch Target Items</td><td>min-h-[44px] für jede Item-Zeile</td><td>WCAG 2.2 AA; durch Padding gesichert</td></tr> <tr><td>Touch Target Items</td><td>min-h-[44px] für jede Item-Zeile</td><td>WCAG 2.2 AA; durch Padding gesichert</td></tr>
<tr><td>Add-Buttons</td><td>flex-1; volle verfügbare Breite geteilt</td><td>min-h-[44px] als Touch-Target</td></tr> <tr><td>Add-Buttons</td><td>flex-1; volle verfügbare Breite geteilt</td><td>min-h-[44px] als Touch-Target</td></tr>
<tr class="grp"><td colspan="3">Savebar</td></tr> <tr class="grp"><td colspan="3">Savebar</td></tr>
@@ -779,7 +779,7 @@
<h3>Drag-to-Reorder</h3> <h3>Drag-to-Reorder</h3>
<ul> <ul>
<li>Bibliothek: prüfe zunächst ob <code>@dnd-kit/core</code> oder <code>svelte-dnd-action</code> bereits im <code>package.json</code> ist. Kein neues Package einführen ohne Absprache.</li> <li><del>Bibliothek: prüfe zunächst ob <code>@dnd-kit/core</code> oder <code>svelte-dnd-action</code> bereits im <code>package.json</code> ist.</del> → Implementiert mit <code>createBlockDragDrop&lt;JourneyItemView&gt;</code> (kein externes Package).</li>
<li>Nach dem Drop: neue Reihenfolge als Array <code>[{id, position}]</code> berechnen (position = index * 10 lässt Lücken für künftige Inserts) und <code>PUT /items/reorder</code> senden.</li> <li>Nach dem Drop: neue Reihenfolge als Array <code>[{id, position}]</code> berechnen (position = index * 10 lässt Lücken für künftige Inserts) und <code>PUT /items/reorder</code> senden.</li>
<li>Keyboard-Drag: Space/Enter startet, Arrow Up/Down verschiebt, Space/Enter bestätigt, Escape abbricht. Screenreader-Announcement: „Eintrag X von Position Y nach Z verschoben".</li> <li>Keyboard-Drag: Space/Enter startet, Arrow Up/Down verschiebt, Space/Enter bestätigt, Escape abbricht. Screenreader-Announcement: „Eintrag X von Position Y nach Z verschoben".</li>
</ul> </ul>

View File

@@ -629,27 +629,28 @@
<tbody> <tbody>
<tr class="grp"><td colspan="3">Seitenstruktur</td></tr> <tr class="grp"><td colspan="3">Seitenstruktur</td></tr>
<tr><td>Bedingte Logik</td><td>{#if geschichte.type === 'JOURNEY'} JourneyReader {:else} StoryReader {/if}</td><td>in +page.svelte von /geschichten/[id]</td></tr> <tr><td>Bedingte Logik</td><td>{#if geschichte.type === 'JOURNEY'} JourneyReader {:else} StoryReader {/if}</td><td>in +page.svelte von /geschichten/[id]</td></tr>
<tr><td>Artikel-Container</td><td>max-w-3xl mx-auto px-4 py-8</td><td>gleich wie StoryReader</td></tr> <tr><td>Artikel-Container</td><td>max-w-7xl mx-auto px-4 py-8; innere Lesespalte: max-w-3xl mx-auto</td><td>gleich wie StoryReader (R-2)</td></tr>
<tr><td>Journey-Badge</td><td>inline-flex px-2 py-px rounded-sm text-[10px] font-bold uppercase tracking-widest bg-orange-50 text-orange-700 border border-orange-200 mb-2</td><td>über dem Titel; nicht für STORY</td></tr> <tr><td>Artikel-Sheet</td><td>rounded-sm border border-line bg-sheet shadow-sm px-5 py-6 sm:px-10 sm:py-10</td><td>Lesebogen-Panel zwischen Canvas und weißen Karten (Token --color-sheet), gleich wie Story (R-2); BackButton bleibt außerhalb</td></tr>
<tr><td>Journey-Badge</td><td>inline-flex px-2 py-px rounded-sm text-[10px] font-bold uppercase tracking-widest bg-journey-tint text-journey border border-journey-border mb-2</td><td>über dem Titel; nicht für STORY</td></tr>
<tr><td>Titel</td><td>font-serif text-3xl text-ink leading-tight mb-4</td><td>gleich wie Story</td></tr> <tr><td>Titel</td><td>font-serif text-3xl text-ink leading-tight mb-4</td><td>gleich wie Story</td></tr>
<tr><td>Metabar</td><td>flex items-center gap-3 pb-4 border-b border-subtle mb-4</td><td>gleich wie Story</td></tr> <tr><td>Metabar</td><td>flex items-center gap-3 pb-4 border-b border-subtle mb-4</td><td>gleich wie Story</td></tr>
<tr><td>Bearbeiten/Löschen</td><td>nur BLOG_WRITE; auf Mobile im ··· BottomSheet</td><td>gleich wie Story</td></tr> <tr><td>Bearbeiten/Löschen</td><td>nur BLOG_WRITE; <s>auf Mobile im ··· BottomSheet</s> <em>(implementiert: inline in der Metazeile auf allen Breiten)</em></td><td>gleich wie Story</td></tr>
<tr class="grp"><td colspan="3">Intro-Absatz</td></tr> <tr class="grp"><td colspan="3">Intro-Absatz</td></tr>
<tr><td>Intro (body)</td><td>font-serif text-sm text-ink-2 italic leading-relaxed mb-6 pb-4 border-b border-dashed border-subtle</td><td>nur rendern wenn body nicht leer; kein HTML-Rendering — plaintext</td></tr> <tr><td>Intro (body)</td><td>font-serif text-lg text-ink-2 italic leading-relaxed mb-6 pb-4 border-b border-dashed border-subtle</td><td>nur rendern wenn body nicht leer; kein HTML-Rendering — plaintext</td></tr>
<tr class="grp"><td colspan="3">Dokument-Item</td></tr> <tr class="grp"><td colspan="3">Dokument-Item</td></tr>
<tr><td>Item-Zeile</td><td>mb-3</td><td>kein flex nötig — Karte ist full-width</td></tr> <tr><td>Item-Zeile</td><td>mb-3</td><td>kein flex nötig — Karte ist full-width</td></tr>
<tr><td>Dokumentkarte</td><td>bg-white border border-line rounded-sm p-3</td><td></td></tr> <tr><td>Dokumentkarte</td><td>bg-surface border border-line rounded-sm p-3</td><td></td></tr>
<tr><td>Brieftitel</td><td>font-serif text-sm text-ink leading-snug mb-0.5</td><td>document.title</td></tr> <tr><td>Brieftitel</td><td>font-serif text-base text-ink leading-snug mb-0.5</td><td>document.title</td></tr>
<tr><td>Briefmeta</td><td>text-xs text-ink-3 mb-2</td><td>formatDate(document.documentDate) · "von X an Y"</td></tr> <tr><td>Briefmeta</td><td>text-sm text-ink-3 mb-2</td><td>formatDate(document.documentDate) · "von X an Y"</td></tr>
<tr><td>Brief öffnen Link</td><td>inline-flex items-center gap-1 text-xs font-semibold text-ink hover:text-primary</td><td>href="/documents/{item.document.id}"</td></tr> <tr><td>Brief öffnen Link</td><td>inline-flex items-center gap-1 text-sm font-semibold text-ink hover:text-primary</td><td>href="/documents/{item.document.id}"</td></tr>
<tr class="grp"><td colspan="3">Kuratoren-Annotation</td></tr> <tr class="grp"><td colspan="3">Kuratoren-Annotation</td></tr>
<tr><td>Annotation</td><td>mt-3 pl-3 border-l-2 border-mint bg-surface rounded-r-sm py-1.5 pr-2</td><td>nur rendern wenn item.note vorhanden</td></tr> <tr><td>Annotation</td><td>mt-3 pl-3 border-l-2 border-brand-mint bg-muted rounded-r-sm py-1.5 pr-2</td><td>nur rendern wenn item.note vorhanden</td></tr>
<tr><td>Annotations-Text</td><td>text-xs italic text-ink-2 leading-relaxed</td><td></td></tr> <tr><td>Annotations-Text</td><td>text-base italic text-ink-2 leading-relaxed</td><td></td></tr>
<tr class="grp"><td colspan="3">Interlude-Item</td></tr> <tr class="grp"><td colspan="3">Interlude-Item</td></tr>
<tr><td>Interlude-Block</td><td>pl-3 border-l-2 border-orange-400 bg-orange-50 rounded-r-sm py-2 pr-3 my-4</td><td>item.document === null</td></tr> <tr><td>Interlude-Block</td><td>pl-3 border-l-2 border-journey-border bg-journey-tint rounded-r-sm py-2 pr-3 my-4</td><td>item.document === null</td></tr>
<tr><td>Interlude-Text</td><td>text-xs italic text-ink leading-relaxed</td><td>item.note; plaintext</td></tr> <tr><td>Interlude-Text</td><td>text-base italic text-ink leading-relaxed</td><td>item.note; plaintext</td></tr>
<tr class="grp"><td colspan="3">Mobile</td></tr> <tr class="grp"><td colspan="3">Mobile</td></tr>
<tr><td>··· Menü</td><td>ml-auto text-ink-3; öffnet BottomSheet mit Bearbeiten + Löschen</td><td>BLOG_WRITE; gleich wie Story</td></tr> <tr><td>··· Menü</td><td><s>ml-auto text-ink-3; öffnet BottomSheet mit Bearbeiten + Löschen</s> <em>(implementiert: kein BottomSheet — Aktionen inline)</em></td><td>BLOG_WRITE; gleich wie Story</td></tr>
<tr><td>Touch Target (Brief öffnen)</td><td>min-h-[44px] durch padding auf der Karte</td><td>WCAG 2.2 AA</td></tr> <tr><td>Touch Target (Brief öffnen)</td><td>min-h-[44px] durch padding auf der Karte</td><td>WCAG 2.2 AA</td></tr>
</tbody> </tbody>
</table> </table>
@@ -710,7 +711,7 @@
<h3>Berechtigungen</h3> <h3>Berechtigungen</h3>
<ul> <ul>
<li>„Bearbeiten" und „Löschen" nur für <code>currentUser.permissions.includes('BLOG_WRITE')</code> — gleich wie Story.</li> <li>„Bearbeiten" und „Löschen" nur für <code>currentUser.permissions.includes('BLOG_WRITE')</code> — gleich wie Story.</li>
<li>Auf Mobile: Bearbeiten/Löschen im BottomSheet hinter ··· — gleich wie Story.</li> <li><s>Auf Mobile: Bearbeiten/Löschen im BottomSheet hinter ··· — gleich wie Story.</s> <em>(implementiert: Aktionen bleiben inline in der Metazeile — h-11 Touch-Targets)</em></li>
</ul> </ul>
<h3>Barrierefreiheit</h3> <h3>Barrierefreiheit</h3>

View File

@@ -301,6 +301,8 @@
"comp_multiselect_placeholder": "Namen tippen...", "comp_multiselect_placeholder": "Namen tippen...",
"comp_multiselect_remove": "Entfernen", "comp_multiselect_remove": "Entfernen",
"comp_multiselect_loading": "Suche...", "comp_multiselect_loading": "Suche...",
"comp_typeahead_error": "Suche fehlgeschlagen. Bitte versuchen Sie es erneut.",
"comp_typeahead_no_results": "Keine Treffer",
"comp_taginput_placeholder_create": "Schlagworte hinzufügen...", "comp_taginput_placeholder_create": "Schlagworte hinzufügen...",
"comp_taginput_placeholder_filter": "Nach Schlagworten filtern...", "comp_taginput_placeholder_filter": "Nach Schlagworten filtern...",
"comp_taginput_remove": "Schlagwort entfernen", "comp_taginput_remove": "Schlagwort entfernen",
@@ -1040,6 +1042,7 @@
"geschichten_empty_no_filter": "Es gibt noch keine veröffentlichten Geschichten.", "geschichten_empty_no_filter": "Es gibt noch keine veröffentlichten Geschichten.",
"geschichten_back_to_index": "Zurück zu Geschichten", "geschichten_back_to_index": "Zurück zu Geschichten",
"geschichten_published_on": "veröffentlicht am {date}", "geschichten_published_on": "veröffentlicht am {date}",
"journey_compiled_on": "zusammengestellt am {date}",
"geschichten_persons_section": "Personen in dieser Geschichte", "geschichten_persons_section": "Personen in dieser Geschichte",
"geschichten_documents_section": "Erwähnte Dokumente", "geschichten_documents_section": "Erwähnte Dokumente",
"geschichten_document_link_placeholder": "Dokument öffnen", "geschichten_document_link_placeholder": "Dokument öffnen",
@@ -1050,6 +1053,7 @@
"geschichten_card_show_all": "Alle anzeigen", "geschichten_card_show_all": "Alle anzeigen",
"geschichte_editor_title_placeholder": "Titel der Geschichte", "geschichte_editor_title_placeholder": "Titel der Geschichte",
"geschichte_editor_body_placeholder": "Schreibe hier deine Geschichte…", "geschichte_editor_body_placeholder": "Schreibe hier deine Geschichte…",
"geschichte_sidebar_status": "Status",
"geschichte_editor_status_draft": "ENTWURF", "geschichte_editor_status_draft": "ENTWURF",
"geschichte_editor_status_published": "VERÖFFENTLICHT", "geschichte_editor_status_published": "VERÖFFENTLICHT",
"geschichte_editor_status_draft_hint": "Noch nicht öffentlich sichtbar.", "geschichte_editor_status_draft_hint": "Noch nicht öffentlich sichtbar.",
@@ -1169,10 +1173,51 @@
"journey_selector_journey_desc": "Eine kuratierte Auswahl von Briefen mit Notizen.", "journey_selector_journey_desc": "Eine kuratierte Auswahl von Briefen mit Notizen.",
"journey_selector_next_btn": "Weiter", "journey_selector_next_btn": "Weiter",
"journey_placeholder_back": "andere Auswahl", "journey_placeholder_back": "andere Auswahl",
"journey_placeholder_heading": "Lesereise-Editor folgt in #753", "journey_create_submit": "Lesereise erstellen",
"journey_item_open_aria": "Brief vom {date} öffnen", "journey_item_open_aria": "Brief vom {date} öffnen",
"journey_item_open_aria_undated": "Brief öffnen", "journey_item_open_aria_undated": "Brief öffnen",
"journey_item_open": "Brief öffnen",
"journey_item_meta_from_to": "von {sender} an {receiver}",
"journey_empty_state": "Diese Lesereise ist noch leer.", "journey_empty_state": "Diese Lesereise ist noch leer.",
"journey_interlude_aria_label": "Kuratorennotiz", "journey_interlude_aria_label": "Kuratorennotiz",
"journey_selector_aria_live_hint": "Bitte wähle einen Typ aus, um fortzufahren." "journey_selector_aria_live_hint": "Bitte wähle einen Typ aus, um fortzufahren.",
"journey_add_document": "Brief hinzufügen",
"journey_add_interlude": "Zwischentext hinzufügen",
"journey_interlude_label": "Zwischentext",
"journey_item_pending_remove": "wird entfernt…",
"journey_publish_disabled_hint": "Titel und mindestens ein Eintrag erforderlich.",
"journey_title_aria_label": "Titel der Lesereise",
"journey_intro_aria_label": "Einleitung der Lesereise",
"journey_note_add": "Notiz hinzufügen",
"journey_note_remove": "Notiz entfernen",
"journey_note_save_hint": "Wird gespeichert, wenn du das Feld verlässt.",
"journey_intro_save_hint": "Wird mit 'Speichern' gesichert.",
"journey_already_added": "Bereits enthalten",
"journey_note_aria_label": "Kuratoren-Notiz für {title}",
"journey_move_up": "'{title}' nach oben verschieben",
"journey_move_down": "'{title}' nach unten verschieben",
"journey_note_error": "Notiz konnte nicht gespeichert werden",
"journey_item_moved": "Eintrag {position} von {total} — nach Position {newPosition} verschoben",
"journey_remove_item_aria": "'{title}' entfernen",
"journey_remove_confirm": "Wirklich entfernen?",
"journey_remove_confirm_yes": "Bestätigen",
"journey_remove_confirm_cancel": "Abbrechen",
"journey_mutation_error_reload": "Aktion fehlgeschlagen bitte Seite neu laden.",
"journey_published_empty_warning": "Diese Reise wird ohne Einträge veröffentlicht bleiben.",
"journey_intro_placeholder": "Einleitung (optional)",
"journey_interlude_placeholder": "Zwischentext eingeben…",
"journey_add_interlude_confirm": "Hinzufügen",
"journey_edit_title_story": "Geschichte bearbeiten",
"journey_edit_title_journey": "Lesereise bearbeiten",
"journey_publish_disabled_title": "Titel und mindestens ein Eintrag erforderlich",
"journey_save_hint_published": "Änderungen werden sofort für alle Leser sichtbar.",
"error_journey_note_too_long": "Die Notiz ist zu lang (maximal 2000 Zeichen).",
"error_geschichte_title_too_long": "Der Titel ist zu lang (maximal 255 Zeichen).",
"error_geschichte_intro_too_long": "Die Einleitung ist zu lang (maximal 4000 Zeichen).",
"person_unknown": "[Unbekannt]",
"error_geschichte_title_too_long": "Der Titel ist zu lang (maximal 255 Zeichen).",
"error_geschichte_intro_too_long": "Die Einleitung ist zu lang (maximal 4000 Zeichen).",
"person_unknown": "[Unbekannt]",
"error_journey_document_already_added": "Dieser Brief ist bereits in der Lesereise enthalten.",
"error_geschichte_type_immutable": "Der Typ einer Geschichte kann nach der Erstellung nicht mehr geändert werden."
} }

View File

@@ -301,6 +301,8 @@
"comp_multiselect_placeholder": "Type a name...", "comp_multiselect_placeholder": "Type a name...",
"comp_multiselect_remove": "Remove", "comp_multiselect_remove": "Remove",
"comp_multiselect_loading": "Searching...", "comp_multiselect_loading": "Searching...",
"comp_typeahead_error": "Search failed. Please try again.",
"comp_typeahead_no_results": "No matches",
"comp_taginput_placeholder_create": "Add tags...", "comp_taginput_placeholder_create": "Add tags...",
"comp_taginput_placeholder_filter": "Filter by tags...", "comp_taginput_placeholder_filter": "Filter by tags...",
"comp_taginput_remove": "Remove tag", "comp_taginput_remove": "Remove tag",
@@ -1040,6 +1042,7 @@
"geschichten_empty_no_filter": "There are no published stories yet.", "geschichten_empty_no_filter": "There are no published stories yet.",
"geschichten_back_to_index": "Back to stories", "geschichten_back_to_index": "Back to stories",
"geschichten_published_on": "published on {date}", "geschichten_published_on": "published on {date}",
"journey_compiled_on": "compiled on {date}",
"geschichten_persons_section": "People in this story", "geschichten_persons_section": "People in this story",
"geschichten_documents_section": "Referenced documents", "geschichten_documents_section": "Referenced documents",
"geschichten_document_link_placeholder": "Open document", "geschichten_document_link_placeholder": "Open document",
@@ -1050,6 +1053,7 @@
"geschichten_card_show_all": "Show all", "geschichten_card_show_all": "Show all",
"geschichte_editor_title_placeholder": "Story title", "geschichte_editor_title_placeholder": "Story title",
"geschichte_editor_body_placeholder": "Write your story here…", "geschichte_editor_body_placeholder": "Write your story here…",
"geschichte_sidebar_status": "Status",
"geschichte_editor_status_draft": "DRAFT", "geschichte_editor_status_draft": "DRAFT",
"geschichte_editor_status_published": "PUBLISHED", "geschichte_editor_status_published": "PUBLISHED",
"geschichte_editor_status_draft_hint": "Not yet visible to readers.", "geschichte_editor_status_draft_hint": "Not yet visible to readers.",
@@ -1169,10 +1173,51 @@
"journey_selector_journey_desc": "A curated selection of letters with notes.", "journey_selector_journey_desc": "A curated selection of letters with notes.",
"journey_selector_next_btn": "Continue", "journey_selector_next_btn": "Continue",
"journey_placeholder_back": "different selection", "journey_placeholder_back": "different selection",
"journey_placeholder_heading": "Reading Journey editor coming in #753", "journey_create_submit": "Create reading journey",
"journey_item_open_aria": "Open letter from {date}", "journey_item_open_aria": "Open letter from {date}",
"journey_item_open_aria_undated": "Open letter", "journey_item_open_aria_undated": "Open letter",
"journey_item_open": "Open letter",
"journey_item_meta_from_to": "from {sender} to {receiver}",
"journey_empty_state": "This reading journey is still empty.", "journey_empty_state": "This reading journey is still empty.",
"journey_interlude_aria_label": "Curator's note", "journey_interlude_aria_label": "Curator's note",
"journey_selector_aria_live_hint": "Please select a type to continue." "journey_selector_aria_live_hint": "Please select a type to continue.",
"journey_add_document": "Add letter",
"journey_add_interlude": "Add interlude",
"journey_interlude_label": "Interlude",
"journey_item_pending_remove": "removing…",
"journey_publish_disabled_hint": "A title and at least one entry are required.",
"journey_title_aria_label": "Title of the reading journey",
"journey_intro_aria_label": "Introduction of the reading journey",
"journey_note_add": "Add note",
"journey_note_remove": "Remove note",
"journey_note_save_hint": "Saved when you leave the field.",
"journey_intro_save_hint": "Saved when you click 'Save'.",
"journey_already_added": "Already included",
"journey_note_aria_label": "Curator note for {title}",
"journey_move_up": "Move '{title}' up",
"journey_move_down": "Move '{title}' down",
"journey_note_error": "Could not save note",
"journey_item_moved": "Entry {position} of {total} — moved to position {newPosition}",
"journey_remove_item_aria": "Remove '{title}'",
"journey_remove_confirm": "Really remove?",
"journey_remove_confirm_yes": "Confirm",
"journey_remove_confirm_cancel": "Cancel",
"journey_mutation_error_reload": "Action failed please reload the page.",
"journey_published_empty_warning": "This journey will remain published without any entries.",
"journey_intro_placeholder": "Introduction (optional)",
"journey_interlude_placeholder": "Enter interlude text…",
"journey_add_interlude_confirm": "Add",
"journey_edit_title_story": "Edit story",
"journey_edit_title_journey": "Edit reading journey",
"journey_publish_disabled_title": "Title and at least one entry required",
"journey_save_hint_published": "Changes will be immediately visible to all readers.",
"error_journey_note_too_long": "The note is too long (maximum 2000 characters).",
"error_geschichte_title_too_long": "The title is too long (maximum 255 characters).",
"error_geschichte_intro_too_long": "The introduction is too long (maximum 4000 characters).",
"person_unknown": "[Unknown]",
"error_geschichte_title_too_long": "The title is too long (maximum 255 characters).",
"error_geschichte_intro_too_long": "The introduction is too long (maximum 4000 characters).",
"person_unknown": "[Unknown]",
"error_journey_document_already_added": "This letter is already included in the reading journey.",
"error_geschichte_type_immutable": "The type of a story cannot be changed after creation."
} }

View File

@@ -301,6 +301,8 @@
"comp_multiselect_placeholder": "Escriba un nombre...", "comp_multiselect_placeholder": "Escriba un nombre...",
"comp_multiselect_remove": "Eliminar", "comp_multiselect_remove": "Eliminar",
"comp_multiselect_loading": "Buscando...", "comp_multiselect_loading": "Buscando...",
"comp_typeahead_error": "La búsqueda falló. Inténtelo de nuevo.",
"comp_typeahead_no_results": "Sin resultados",
"comp_taginput_placeholder_create": "Añadir etiquetas...", "comp_taginput_placeholder_create": "Añadir etiquetas...",
"comp_taginput_placeholder_filter": "Filtrar por etiquetas...", "comp_taginput_placeholder_filter": "Filtrar por etiquetas...",
"comp_taginput_remove": "Eliminar etiqueta", "comp_taginput_remove": "Eliminar etiqueta",
@@ -1040,6 +1042,7 @@
"geschichten_empty_no_filter": "Aún no hay historias publicadas.", "geschichten_empty_no_filter": "Aún no hay historias publicadas.",
"geschichten_back_to_index": "Volver a Historias", "geschichten_back_to_index": "Volver a Historias",
"geschichten_published_on": "publicada el {date}", "geschichten_published_on": "publicada el {date}",
"journey_compiled_on": "recopilada el {date}",
"geschichten_persons_section": "Personas en esta historia", "geschichten_persons_section": "Personas en esta historia",
"geschichten_documents_section": "Documentos mencionados", "geschichten_documents_section": "Documentos mencionados",
"geschichten_document_link_placeholder": "Abrir documento", "geschichten_document_link_placeholder": "Abrir documento",
@@ -1050,6 +1053,7 @@
"geschichten_card_show_all": "Mostrar todas", "geschichten_card_show_all": "Mostrar todas",
"geschichte_editor_title_placeholder": "Título de la historia", "geschichte_editor_title_placeholder": "Título de la historia",
"geschichte_editor_body_placeholder": "Escribe tu historia aquí…", "geschichte_editor_body_placeholder": "Escribe tu historia aquí…",
"geschichte_sidebar_status": "Estado",
"geschichte_editor_status_draft": "BORRADOR", "geschichte_editor_status_draft": "BORRADOR",
"geschichte_editor_status_published": "PUBLICADA", "geschichte_editor_status_published": "PUBLICADA",
"geschichte_editor_status_draft_hint": "Aún no visible para lectores.", "geschichte_editor_status_draft_hint": "Aún no visible para lectores.",
@@ -1169,10 +1173,51 @@
"journey_selector_journey_desc": "Una selección curada de cartas con notas.", "journey_selector_journey_desc": "Una selección curada de cartas con notas.",
"journey_selector_next_btn": "Continuar", "journey_selector_next_btn": "Continuar",
"journey_placeholder_back": "otra selección", "journey_placeholder_back": "otra selección",
"journey_placeholder_heading": "Editor de viaje de lectura próximamente en #753", "journey_create_submit": "Crear viaje de lectura",
"journey_item_open_aria": "Abrir carta del {date}", "journey_item_open_aria": "Abrir carta del {date}",
"journey_item_open_aria_undated": "Abrir carta", "journey_item_open_aria_undated": "Abrir carta",
"journey_item_open": "Abrir carta",
"journey_item_meta_from_to": "de {sender} a {receiver}",
"journey_empty_state": "Este viaje de lectura está vacío.", "journey_empty_state": "Este viaje de lectura está vacío.",
"journey_interlude_aria_label": "Nota del curador", "journey_interlude_aria_label": "Nota del curador",
"journey_selector_aria_live_hint": "Por favor, selecciona un tipo para continuar." "journey_selector_aria_live_hint": "Por favor, selecciona un tipo para continuar.",
"journey_add_document": "Añadir carta",
"journey_add_interlude": "Añadir interludio",
"journey_interlude_label": "Interludio",
"journey_item_pending_remove": "eliminando…",
"journey_publish_disabled_hint": "Se requieren un título y al menos una entrada.",
"journey_title_aria_label": "Título del viaje de lectura",
"journey_intro_aria_label": "Introducción del viaje de lectura",
"journey_note_add": "Añadir nota",
"journey_note_remove": "Eliminar nota",
"journey_note_save_hint": "Se guarda al salir del campo.",
"journey_intro_save_hint": "Se guarda al hacer clic en 'Guardar'.",
"journey_already_added": "Ya incluido",
"journey_note_aria_label": "Nota del curador para {title}",
"journey_move_up": "Subir '{title}'",
"journey_move_down": "Bajar '{title}'",
"journey_note_error": "No se pudo guardar la nota",
"journey_item_moved": "Entrada {position} de {total} — movida a la posición {newPosition}",
"journey_remove_item_aria": "Eliminar '{title}'",
"journey_remove_confirm": "¿Realmente eliminar?",
"journey_remove_confirm_yes": "Confirmar",
"journey_remove_confirm_cancel": "Cancelar",
"journey_mutation_error_reload": "Acción fallida por favor recarga la página.",
"journey_published_empty_warning": "Este viaje permanecerá publicado sin entradas.",
"journey_intro_placeholder": "Introducción (opcional)",
"journey_interlude_placeholder": "Escribe el texto del interludio…",
"journey_add_interlude_confirm": "Añadir",
"journey_edit_title_story": "Editar historia",
"journey_edit_title_journey": "Editar viaje de lectura",
"journey_publish_disabled_title": "Se requiere título y al menos una entrada",
"journey_save_hint_published": "Los cambios serán visibles inmediatamente para todos los lectores.",
"error_journey_note_too_long": "La nota es demasiado larga (máximo 2000 caracteres).",
"error_geschichte_title_too_long": "El título es demasiado largo (máximo 255 caracteres).",
"error_geschichte_intro_too_long": "La introducción es demasiado larga (máximo 4000 caracteres).",
"person_unknown": "[Desconocido]",
"error_geschichte_title_too_long": "El título es demasiado largo (máximo 255 caracteres).",
"error_geschichte_intro_too_long": "La introducción es demasiado larga (máximo 4000 caracteres).",
"person_unknown": "[Desconocido]",
"error_journey_document_already_added": "Esta carta ya está incluida en el viaje de lectura.",
"error_geschichte_type_immutable": "El tipo de una historia no se puede cambiar después de su creación."
} }

View File

@@ -1,21 +1,11 @@
<script lang="ts"> <script lang="ts">
import type { components } from '$lib/generated/api';
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import { clickOutside } from '$lib/shared/actions/clickOutside'; import { clickOutside } from '$lib/shared/actions/clickOutside';
import { formatDocumentDate, type DatePrecision } from '$lib/shared/utils/documentDate'; import {
import { getLocale } from '$lib/paraglide/runtime.js'; createDocumentTypeahead,
formatDocumentOption,
type DocumentListItem = components['schemas']['DocumentListItem']; type DocumentOption
} from './documentTypeahead';
/**
* Exactly the fields this picker reads — id for selection/dedup, the rest for
* the honest date label. A full `Document` and a `DocumentListItem` are both
* structurally assignable, so the search results need no cast.
*/
type DocumentOption = Pick<
DocumentListItem,
'id' | 'title' | 'documentDate' | 'metaDatePrecision' | 'metaDateEnd'
>;
interface Props { interface Props {
selectedDocuments?: DocumentOption[]; selectedDocuments?: DocumentOption[];
@@ -30,13 +20,16 @@ let {
}: Props = $props(); }: Props = $props();
let searchTerm = $state(''); let searchTerm = $state('');
let results: DocumentOption[] = $state([]);
let showDropdown = $state(false);
let loading = $state(false);
let debounceTimer: ReturnType<typeof setTimeout>;
let inputEl: HTMLInputElement; let inputEl: HTMLInputElement;
let dropdownStyle = $state(''); let dropdownStyle = $state('');
const picker = createDocumentTypeahead();
// Filter out already-selected documents from typeahead results.
const filteredResults = $derived(
picker.results.filter((d) => !selectedDocuments.some((s) => s.id === d.id))
);
function updateDropdownPosition() { function updateDropdownPosition() {
if (!inputEl) return; if (!inputEl) return;
const rect = inputEl.getBoundingClientRect(); const rect = inputEl.getBoundingClientRect();
@@ -44,57 +37,22 @@ function updateDropdownPosition() {
} }
function handleInput() { function handleInput() {
showDropdown = true; if (searchTerm.trim().length >= 1) {
clearTimeout(debounceTimer); picker.setQuery(searchTerm);
debounceTimer = setTimeout(async () => { } else {
if (searchTerm.length < 1) { picker.close();
results = []; }
return;
}
loading = true;
try {
const res = await fetch(`/api/documents/search?q=${encodeURIComponent(searchTerm)}&size=10`);
if (res.ok) {
const body: { items: DocumentListItem[] } = await res.json();
const docs: DocumentOption[] = body.items.map((it) => ({
id: it.id,
title: it.title,
documentDate: it.documentDate,
metaDatePrecision: it.metaDatePrecision,
metaDateEnd: it.metaDateEnd
}));
results = docs.filter((d) => !selectedDocuments.some((s) => s.id === d.id));
}
} catch {
results = [];
} finally {
loading = false;
}
}, 300);
} }
function selectDocument(doc: DocumentOption) { function selectDocument(doc: DocumentOption) {
selectedDocuments = [...selectedDocuments, doc]; selectedDocuments = [...selectedDocuments, doc];
searchTerm = ''; searchTerm = '';
showDropdown = false; picker.close();
results = [];
} }
function removeDocument(id: string | undefined) { function removeDocument(id: string | undefined) {
selectedDocuments = selectedDocuments.filter((d) => d.id !== id); selectedDocuments = selectedDocuments.filter((d) => d.id !== id);
} }
function formatDocLabel(doc: DocumentOption): string {
if (!doc.documentDate) return doc.title;
const label = formatDocumentDate(
doc.documentDate,
doc.metaDatePrecision as DatePrecision,
doc.metaDateEnd,
null,
getLocale()
);
return `${doc.title} · ${label}`;
}
</script> </script>
<svelte:window onscroll={updateDropdownPosition} onresize={updateDropdownPosition} /> <svelte:window onscroll={updateDropdownPosition} onresize={updateDropdownPosition} />
@@ -103,7 +61,7 @@ function formatDocLabel(doc: DocumentOption): string {
<input type="hidden" name={hiddenInputName} value={doc.id} /> <input type="hidden" name={hiddenInputName} value={doc.id} />
{/each} {/each}
<div class="relative" use:clickOutside onclickoutside={() => (showDropdown = false)}> <div class="relative" use:clickOutside onclickoutside={() => picker.close()}>
<div <div
class="flex min-h-[38px] flex-wrap gap-2 rounded border border-line bg-surface p-2 shadow-sm focus-within:ring-2 focus-within:ring-focus-ring focus-within:outline-none" class="flex min-h-[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"
> >
@@ -111,7 +69,7 @@ function formatDocLabel(doc: DocumentOption): string {
<span <span
class="inline-flex items-center gap-1 rounded bg-muted px-2 py-1 text-sm font-medium text-ink" class="inline-flex items-center gap-1 rounded bg-muted px-2 py-1 text-sm font-medium text-ink"
> >
{formatDocLabel(doc)} {formatDocumentOption(doc)}
<button <button
type="button" type="button"
onclick={() => removeDocument(doc.id)} onclick={() => removeDocument(doc.id)}
@@ -136,24 +94,23 @@ function formatDocLabel(doc: DocumentOption): string {
autocomplete="off" autocomplete="off"
bind:value={searchTerm} bind:value={searchTerm}
oninput={handleInput} oninput={handleInput}
onfocus={() => { onfocus={() => updateDropdownPosition()}
updateDropdownPosition();
showDropdown = true;
}}
placeholder={placeholder} placeholder={placeholder}
class="min-w-[120px] flex-1 border-none bg-transparent p-1 text-sm outline-none focus:ring-0" class="min-w-[120px] flex-1 border-none bg-transparent p-1 text-sm outline-none focus:ring-0"
/> />
</div> </div>
{#if showDropdown && (results.length > 0 || loading)} {#if picker.isOpen && (filteredResults.length > 0 || picker.loading || picker.error)}
<div <div
style={dropdownStyle} 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" 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} {#if picker.loading}
<div class="p-2 text-sm text-ink-2">{m.comp_multiselect_loading()}</div> <div class="p-2 text-sm text-ink-2">{m.comp_multiselect_loading()}</div>
{:else if picker.error}
<div role="alert" class="p-2 text-sm text-danger">{m.comp_typeahead_error()}</div>
{:else} {:else}
{#each results as doc (doc.id)} {#each filteredResults as doc (doc.id)}
<div <div
class="cursor-pointer py-2 pr-9 pl-3 text-ink select-none hover:bg-muted" class="cursor-pointer py-2 pr-9 pl-3 text-ink select-none hover:bg-muted"
onclick={() => selectDocument(doc)} onclick={() => selectDocument(doc)}
@@ -161,7 +118,7 @@ function formatDocLabel(doc: DocumentOption): string {
role="button" role="button"
tabindex="0" tabindex="0"
> >
{formatDocLabel(doc)} {formatDocumentOption(doc)}
</div> </div>
{/each} {/each}
{/if} {/if}

View File

@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte'; import { cleanup, render } from 'vitest-browser-svelte';
import { page, userEvent } from 'vitest/browser'; import { page, userEvent } from 'vitest/browser';
import DocumentMultiSelect from './DocumentMultiSelect.svelte'; import DocumentMultiSelect from './DocumentMultiSelect.svelte';
import { m } from '$lib/paraglide/messages.js';
const waitForDebounce = () => new Promise((r) => setTimeout(r, 350)); const waitForDebounce = () => new Promise((r) => setTimeout(r, 350));
@@ -124,6 +125,28 @@ describe('DocumentMultiSelect — search and select', () => {
}); });
}); });
describe('DocumentMultiSelect — search failure', () => {
it('shows an error row when the search request fails instead of looking like "no results"', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: false,
status: 500,
json: vi.fn().mockResolvedValue({ code: 'INTERNAL_ERROR' })
})
);
render(DocumentMultiSelect);
await userEvent.fill(page.getByPlaceholder('Dokument suchen…'), 'Eug');
await waitForDebounce();
const alert = page.getByRole('alert');
await expect.element(alert).toBeInTheDocument();
await expect.element(alert).toHaveTextContent(m.comp_typeahead_error());
});
});
describe('DocumentMultiSelect — remove', () => { describe('DocumentMultiSelect — remove', () => {
it('removes a chip when its × button is clicked', async () => { it('removes a chip when its × button is clicked', async () => {
render(DocumentMultiSelect, { render(DocumentMultiSelect, {

View File

@@ -0,0 +1,150 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { clickOutside } from '$lib/shared/actions/clickOutside';
import {
createDocumentTypeahead,
formatDocumentOption,
type DocumentOption
} from './documentTypeahead';
interface Props {
alreadyAddedIds?: Set<string>;
placeholder?: string;
onSelect: (doc: DocumentOption) => void;
}
let {
alreadyAddedIds = new Set(),
placeholder = m.journey_add_document(),
onSelect
}: Props = $props();
const uid = $props.id();
const listboxId = `doc-picker-listbox-${uid}`;
const picker = createDocumentTypeahead();
let inputValue = $state('');
const activeOptionId = $derived(
picker.isOpen && picker.activeIndex >= 0 && picker.results[picker.activeIndex]
? `${listboxId}-option-${picker.activeIndex}`
: undefined
);
function handleInput(e: Event) {
const q = (e.currentTarget as HTMLInputElement).value;
inputValue = q;
picker.setActiveIndex(-1);
if (q.trim().length >= 1) {
picker.setQuery(q);
} else {
picker.close();
}
}
function handleSelect(doc: DocumentOption) {
if (alreadyAddedIds.has(doc.id!)) return;
inputValue = '';
picker.close();
onSelect(doc);
}
function handleKeydown(e: KeyboardEvent) {
if (!picker.isOpen) return;
const results = picker.results;
if (e.key === 'ArrowDown') {
e.preventDefault();
if (results.length > 0) {
picker.setActiveIndex((picker.activeIndex + 1) % results.length);
}
} else if (e.key === 'ArrowUp') {
e.preventDefault();
if (results.length > 0) {
picker.setActiveIndex((picker.activeIndex - 1 + results.length) % results.length);
}
} else if (e.key === 'Enter') {
e.preventDefault();
const active = results[picker.activeIndex];
// handleSelect is a no-op for already-added (aria-disabled) options.
if (active) handleSelect(active);
} else if (e.key === 'Escape') {
e.preventDefault();
picker.close();
}
}
</script>
<div use:clickOutside onclickoutside={() => picker.close()} class="relative">
<input
type="text"
role="combobox"
autocomplete="off"
aria-label={placeholder}
aria-expanded={picker.isOpen}
aria-controls={picker.isOpen && !picker.loading && !picker.error && picker.results.length > 0
? listboxId
: undefined}
aria-autocomplete="list"
aria-activedescendant={activeOptionId}
placeholder={placeholder}
value={inputValue}
oninput={handleInput}
onkeydown={handleKeydown}
class="block w-full rounded border border-line bg-surface px-3 py-2 text-sm text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
{#if picker.isOpen}
{#if picker.loading}
<div
class="ring-opacity-5 absolute z-50 mt-1 w-full rounded-md bg-surface py-1 text-sm shadow-lg ring-1 ring-black"
>
<p role="status" class="px-3 py-2 text-ink-2">{m.comp_multiselect_loading()}</p>
</div>
{:else if picker.error}
<div
class="ring-opacity-5 absolute z-50 mt-1 w-full rounded-md bg-surface py-1 text-sm shadow-lg ring-1 ring-black"
>
<p role="alert" class="px-3 py-2 text-danger">{m.comp_typeahead_error()}</p>
</div>
{:else if picker.results.length === 0}
<div
class="ring-opacity-5 absolute z-50 mt-1 w-full rounded-md bg-surface py-1 text-sm shadow-lg ring-1 ring-black"
>
<p role="status" class="px-3 py-2 text-ink-2">{m.comp_typeahead_no_results()}</p>
</div>
{:else}
<ul
id={listboxId}
role="listbox"
class="ring-opacity-5 absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-md bg-surface py-1 text-sm shadow-lg ring-1 ring-black"
>
{#each picker.results as doc, i (doc.id)}
{@const disabled = alreadyAddedIds.has(doc.id!)}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<li
id={`${listboxId}-option-${i}`}
role="option"
aria-selected={i === picker.activeIndex}
aria-disabled={disabled}
onclick={() => handleSelect(doc)}
class={[
'px-3 py-2 text-ink select-none',
i === picker.activeIndex ? 'bg-muted' : '',
disabled
? 'cursor-default opacity-50'
: 'cursor-pointer hover:bg-muted'
].join(' ')}
>
{formatDocumentOption(doc)}
{#if disabled}
<span class="sr-only">{m.journey_already_added()}</span>
{/if}
</li>
{/each}
</ul>
{/if}
{/if}
</div>

View File

@@ -0,0 +1,243 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page, userEvent } from 'vitest/browser';
import DocumentPickerDropdown from './DocumentPickerDropdown.svelte';
import { m } from '$lib/paraglide/messages.js';
const waitForDebounce = () => new Promise((r) => setTimeout(r, 350));
const docFactory = (id: string, title: string) => ({
id,
title,
documentDate: '1880-01-01',
metaDatePrecision: 'DAY' as const,
metaDateEnd: undefined
});
function mockSearchResponse(items: ReturnType<typeof docFactory>[]) {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue({ items })
})
);
}
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
describe('DocumentPickerDropdown — empty query guard', () => {
it('does not call fetch on empty query', async () => {
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
render(DocumentPickerDropdown, { onSelect: vi.fn() });
await userEvent.fill(page.getByRole('combobox'), '');
await waitForDebounce();
expect(fetchMock).not.toHaveBeenCalled();
});
});
describe('DocumentPickerDropdown — already-added indicator', () => {
it('shows already-added document as aria-disabled with sr-only hint', async () => {
mockSearchResponse([docFactory('d1', 'Brief von Eugenie'), docFactory('d2', 'Brief 2')]);
render(DocumentPickerDropdown, {
alreadyAddedIds: new Set(['d1']),
onSelect: vi.fn()
});
await userEvent.fill(page.getByRole('combobox'), 'Brief');
await waitForDebounce();
const disabledItem = page
.getByText(/Brief von Eugenie/i)
.element()
.closest('li')!;
expect(disabledItem.getAttribute('aria-disabled')).toBe('true');
// Screen-reader text "bereits enthalten" must be present in the item
await expect.element(page.getByText(/bereits enthalten/i)).toBeInTheDocument();
});
});
describe('DocumentPickerDropdown — selection', () => {
it('calls onSelect with the item when a non-disabled option is clicked', async () => {
const onSelect = vi.fn();
mockSearchResponse([docFactory('d1', 'Brief von Eugenie')]);
render(DocumentPickerDropdown, { onSelect });
await userEvent.fill(page.getByRole('combobox'), 'Brief');
await waitForDebounce();
await userEvent.click(page.getByText(/Brief von Eugenie/i));
expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ id: 'd1' }));
});
it('does not call onSelect when an aria-disabled option is clicked', async () => {
const onSelect = vi.fn();
mockSearchResponse([docFactory('d1', 'Brief von Eugenie')]);
render(DocumentPickerDropdown, {
alreadyAddedIds: new Set(['d1']),
onSelect
});
await userEvent.fill(page.getByRole('combobox'), 'Brief');
await waitForDebounce();
await page.getByText(/Brief von Eugenie/i).click({ force: true });
expect(onSelect).not.toHaveBeenCalled();
});
});
describe('DocumentPickerDropdown — keyboard navigation', () => {
it('selects the first option via ArrowDown then Enter', async () => {
const onSelect = vi.fn();
mockSearchResponse([docFactory('d1', 'Brief von Eugenie'), docFactory('d2', 'Brief 2')]);
render(DocumentPickerDropdown, { onSelect });
await userEvent.fill(page.getByRole('combobox'), 'Brief');
await waitForDebounce();
await userEvent.keyboard('{ArrowDown}');
await userEvent.keyboard('{Enter}');
expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ id: 'd1' }));
});
it('does not select an aria-disabled option on Enter', async () => {
const onSelect = vi.fn();
mockSearchResponse([docFactory('d1', 'Brief von Eugenie')]);
render(DocumentPickerDropdown, {
alreadyAddedIds: new Set(['d1']),
onSelect
});
await userEvent.fill(page.getByRole('combobox'), 'Brief');
await waitForDebounce();
await userEvent.keyboard('{ArrowDown}');
await userEvent.keyboard('{Enter}');
expect(onSelect).not.toHaveBeenCalled();
});
it('closes the dropdown on Escape', async () => {
mockSearchResponse([docFactory('d1', 'Brief von Eugenie')]);
render(DocumentPickerDropdown, { onSelect: vi.fn() });
await userEvent.fill(page.getByRole('combobox'), 'Brief');
await waitForDebounce();
await expect.element(page.getByRole('listbox')).toBeInTheDocument();
await userEvent.keyboard('{Escape}');
await expect.element(page.getByRole('listbox')).not.toBeInTheDocument();
});
it('points aria-activedescendant at the active option', async () => {
mockSearchResponse([docFactory('d1', 'Brief von Eugenie'), docFactory('d2', 'Brief 2')]);
render(DocumentPickerDropdown, { onSelect: vi.fn() });
const input = page.getByRole('combobox');
await userEvent.fill(input, 'Brief');
await waitForDebounce();
expect(input.element().getAttribute('aria-activedescendant')).toBeNull();
await userEvent.keyboard('{ArrowDown}');
const activeId = input.element().getAttribute('aria-activedescendant');
expect(activeId).toMatch(/-option-0$/);
const firstOption = page
.getByText(/Brief von Eugenie/i)
.element()
.closest('li')!;
expect(firstOption.id).toBe(activeId);
expect(firstOption.getAttribute('aria-selected')).toBe('true');
});
});
describe('DocumentPickerDropdown — no results', () => {
it('shows a non-interactive no-results row when the search returns zero hits', async () => {
mockSearchResponse([]);
render(DocumentPickerDropdown, { onSelect: vi.fn() });
await userEvent.fill(page.getByRole('combobox'), 'xyz');
await waitForDebounce();
await expect.element(page.getByText(m.comp_typeahead_no_results())).toBeInTheDocument();
});
});
describe('DocumentPickerDropdown — search failure', () => {
it('shows an error message when the search request fails instead of vanishing', async () => {
// 500 from /api/documents/search — must surface, not render as "no results"
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: false,
status: 500,
json: vi.fn().mockResolvedValue({ code: 'INTERNAL_ERROR' })
})
);
render(DocumentPickerDropdown, { onSelect: vi.fn() });
await userEvent.fill(page.getByRole('combobox'), 'Brief');
await waitForDebounce();
await expect.element(page.getByText(m.comp_typeahead_error())).toBeInTheDocument();
});
});
describe('DocumentPickerDropdown — ARIA listbox integrity', () => {
it('does not render a listbox when results are empty (no aria-required-children violation)', async () => {
mockSearchResponse([]);
render(DocumentPickerDropdown, { onSelect: vi.fn() });
await userEvent.fill(page.getByRole('combobox'), 'xyz');
await waitForDebounce();
// no-results message must be visible, but NOT inside a listbox
await expect.element(page.getByText(m.comp_typeahead_no_results())).toBeInTheDocument();
expect(document.querySelector('[role="listbox"]')).toBeNull();
});
it('does not render a listbox when loading (no aria-required-children violation)', async () => {
let resolveSearch!: (v: unknown) => void;
vi.stubGlobal(
'fetch',
vi.fn().mockReturnValue(new Promise((resolve) => (resolveSearch = resolve)))
);
render(DocumentPickerDropdown, { onSelect: vi.fn() });
await userEvent.fill(page.getByRole('combobox'), 'Brief');
// While in-flight, no listbox should exist
expect(document.querySelector('[role="listbox"]')).toBeNull();
resolveSearch({ ok: true, json: () => Promise.resolve({ items: [] }) });
});
it('option elements do not have tabindex (combobox pattern: focus stays on input)', async () => {
mockSearchResponse([docFactory('d1', 'Brief A'), docFactory('d2', 'Brief B')]);
render(DocumentPickerDropdown, { onSelect: vi.fn() });
await userEvent.fill(page.getByRole('combobox'), 'Brief');
await waitForDebounce();
const options = document.querySelectorAll('[role="listbox"] [role="option"]');
expect(options.length).toBeGreaterThan(0);
options.forEach((opt) => {
expect(opt).not.toHaveAttribute('tabindex');
});
});
});

View File

@@ -0,0 +1,45 @@
import type { components } from '$lib/generated/api';
import { createTypeahead } from '$lib/shared/hooks/useTypeahead.svelte';
import { formatDocumentDate, type DatePrecision } from '$lib/shared/utils/documentDate';
import { getLocale } from '$lib/paraglide/runtime.js';
type DocumentListItem = components['schemas']['DocumentListItem'];
export type DocumentOption = Pick<
DocumentListItem,
'id' | 'title' | 'documentDate' | 'metaDatePrecision' | 'metaDateEnd'
>;
export function createDocumentTypeahead() {
return createTypeahead<DocumentOption>({
fetchUrl: (q) =>
fetch(`/api/documents/search?q=${encodeURIComponent(q)}&size=10`)
.then((r) => {
// Without this check a 401/500 parses as JSON without `items` and
// renders as "no results" — errors must reach the hook's error state.
if (!r.ok) throw new Error(`document search failed: ${r.status}`);
return r.json();
})
.then((b: { items: DocumentListItem[] }) =>
b.items.map((it) => ({
id: it.id,
title: it.title,
documentDate: it.documentDate,
metaDatePrecision: it.metaDatePrecision,
metaDateEnd: it.metaDateEnd
}))
)
});
}
export function formatDocumentOption(doc: DocumentOption): string {
if (!doc.documentDate) return doc.title;
const label = formatDocumentDate(
doc.documentDate,
doc.metaDatePrecision as DatePrecision,
doc.metaDateEnd,
null,
getLocale()
);
return `${doc.title} · ${label}`;
}

View File

@@ -5,7 +5,7 @@ import OcrTrigger from '$lib/ocr/OcrTrigger.svelte';
import TranscribeCoachEmptyState from '$lib/shared/help/TranscribeCoachEmptyState.svelte'; import TranscribeCoachEmptyState from '$lib/shared/help/TranscribeCoachEmptyState.svelte';
import type { PersonMention, TranscriptionBlockData } from '$lib/shared/types'; import type { PersonMention, TranscriptionBlockData } from '$lib/shared/types';
import { createBlockAutoSave } from '$lib/document/transcription/useBlockAutoSave.svelte'; import { createBlockAutoSave } from '$lib/document/transcription/useBlockAutoSave.svelte';
import { createBlockDragDrop } from '$lib/document/transcription/useBlockDragDrop.svelte'; import { createBlockDragDrop } from '$lib/shared/hooks/useBlockDragDrop.svelte';
import { csrfFetch } from '$lib/shared/cookies'; import { csrfFetch } from '$lib/shared/cookies';
type Props = { type Props = {

View File

@@ -728,6 +728,22 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/admin/backfill-titles": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["backfillTitles"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/admin/backfill-file-hashes": { "/api/admin/backfill-file-hashes": {
parameters: { parameters: {
query?: never; query?: never;
@@ -1464,22 +1480,6 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/documents/conversation": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getConversation"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/dashboard/resume": { "/api/dashboard/resume": {
parameters: { parameters: {
query?: never; query?: never;
@@ -1836,6 +1836,7 @@ export interface components {
sender?: components["schemas"]["Person"]; sender?: components["schemas"]["Person"];
tags?: components["schemas"]["Tag"][]; tags?: components["schemas"]["Tag"][];
trainingLabels?: ("KURRENT_RECOGNITION" | "KURRENT_SEGMENTATION")[]; trainingLabels?: ("KURRENT_RECOGNITION" | "KURRENT_SEGMENTATION")[];
hasTranscription: boolean;
thumbnailUrl?: string; thumbnailUrl?: string;
}; };
PersonMention: { PersonMention: {
@@ -2023,25 +2024,44 @@ export interface components {
body?: string; body?: string;
/** @enum {string} */ /** @enum {string} */
status?: "DRAFT" | "PUBLISHED"; status?: "DRAFT" | "PUBLISHED";
/** @enum {string} */
type?: "STORY" | "JOURNEY";
personIds?: string[]; personIds?: string[];
documentIds?: string[];
}; };
Geschichte: { AuthorView: {
/** Format: uuid */
id: string;
displayName: string;
};
GeschichteView: {
/** Format: uuid */ /** Format: uuid */
id: string; id: string;
title: string; title: string;
body?: string; body?: string;
/** @enum {string} */ /** @enum {string} */
status: "DRAFT" | "PUBLISHED"; status: "DRAFT" | "PUBLISHED";
author?: components["schemas"]["AppUser"]; /** @enum {string} */
persons?: components["schemas"]["Person"][]; type: "STORY" | "JOURNEY";
documents?: components["schemas"]["Document"][]; author?: components["schemas"]["AuthorView"];
persons: components["schemas"]["PersonView"][];
items: components["schemas"]["JourneyItemView"][];
/** Format: date-time */
publishedAt?: string;
/** Format: date-time */ /** Format: date-time */
createdAt: string; createdAt: string;
/** Format: date-time */ /** Format: date-time */
updatedAt: string; updatedAt: string;
/** Format: date-time */ };
publishedAt?: string; PersonView: {
/** Format: uuid */
id: string;
firstName?: string;
lastName?: string;
};
JourneyItemCreateDTO: {
/** Format: uuid */
documentId?: string;
note?: string;
}; };
CreateTranscriptionBlockDTO: { CreateTranscriptionBlockDTO: {
/** Format: int32 */ /** Format: int32 */
@@ -2311,6 +2331,11 @@ export interface components {
color?: string; color?: string;
/** Format: int32 */ /** Format: int32 */
documentCount: number; documentCount: number;
/**
* Format: int32
* @description Distinct documents tagged with this tag or any descendant tag (subtree rollup)
*/
subtreeDocumentCount: number;
children?: components["schemas"]["TagTreeNodeDTO"][]; children?: components["schemas"]["TagTreeNodeDTO"][];
/** /**
* Format: uuid * Format: uuid
@@ -2486,7 +2511,6 @@ export interface components {
AuthorSummary: { AuthorSummary: {
firstName?: string; firstName?: string;
lastName?: string; lastName?: string;
email: string;
}; };
GeschichteSummary: { GeschichteSummary: {
body?: string; body?: string;
@@ -2497,40 +2521,12 @@ export interface components {
type: "STORY" | "JOURNEY"; type: "STORY" | "JOURNEY";
/** @enum {string} */ /** @enum {string} */
status: "DRAFT" | "PUBLISHED"; status: "DRAFT" | "PUBLISHED";
/** Format: date-time */
updatedAt: string;
author?: components["schemas"]["AuthorSummary"]; author?: components["schemas"]["AuthorSummary"];
/** Format: date-time */ /** Format: date-time */
publishedAt?: string; publishedAt?: string;
}; };
AuthorView: {
/** Format: uuid */
id: string;
displayName: string;
};
GeschichteView: {
/** Format: uuid */
id: string;
title: string;
body?: string;
/** @enum {string} */
status: "DRAFT" | "PUBLISHED";
/** @enum {string} */
type: "STORY" | "JOURNEY";
author?: components["schemas"]["AuthorView"];
persons: components["schemas"]["PersonView"][];
items: components["schemas"]["JourneyItemView"][];
/** Format: date-time */
publishedAt?: string;
/** Format: date-time */
createdAt: string;
/** Format: date-time */
updatedAt: string;
};
PersonView: {
/** Format: uuid */
id: string;
firstName?: string;
lastName?: string;
};
DocumentVersionSummary: { DocumentVersionSummary: {
/** Format: uuid */ /** Format: uuid */
id: string; id: string;
@@ -3733,7 +3729,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"*/*": components["schemas"]["Geschichte"][]; "*/*": components["schemas"]["GeschichteSummary"][];
}; };
}; };
}; };
@@ -3757,7 +3753,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"*/*": components["schemas"]["Geschichte"]; "*/*": components["schemas"]["GeschichteView"];
}; };
}; };
}; };
@@ -4286,6 +4282,26 @@ export interface operations {
}; };
}; };
}; };
backfillTitles: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["BackfillResult"];
};
};
};
};
backfillFileHashes: { backfillFileHashes: {
parameters: { parameters: {
query?: never; query?: never;
@@ -4485,7 +4501,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"*/*": components["schemas"]["Geschichte"]; "*/*": components["schemas"]["GeschichteView"];
}; };
}; };
}; };
@@ -5476,32 +5492,6 @@ export interface operations {
}; };
}; };
}; };
getConversation: {
parameters: {
query: {
senderId: string;
receiverId?: string;
from?: string;
to?: string;
dir?: string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["Document"][];
};
};
};
};
getResume: { getResume: {
parameters: { parameters: {
query?: never; query?: never;

View File

@@ -5,14 +5,16 @@ import { Editor } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit'; import StarterKit from '@tiptap/starter-kit';
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import type { components } from '$lib/generated/api'; import type { components } from '$lib/generated/api';
import PersonMultiSelect from '$lib/person/PersonMultiSelect.svelte'; import GeschichteSidebar from '$lib/geschichte/GeschichteSidebar.svelte';
import { toPersonOption, type PersonOption } from '$lib/person/personOption';
type Geschichte = components['schemas']['Geschichte']; type GeschichteView = components['schemas']['GeschichteView'];
type Person = components['schemas']['Person']; type Person = components['schemas']['Person'];
interface Props { interface Props {
geschichte?: Geschichte | null; geschichte?: GeschichteView | null;
initialPersons?: Person[]; initialPersons?: Person[];
/** Must reject when the save failed — the editor keeps its dirty state then. */
onSubmit: (payload: { onSubmit: (payload: {
title: string; title: string;
body: string; body: string;
@@ -31,8 +33,8 @@ let { geschichte = null, initialPersons = [], onSubmit, submitting = false }: Pr
let title = $state(geschichte?.title ?? ''); let title = $state(geschichte?.title ?? '');
let body = $state(geschichte?.body ?? ''); let body = $state(geschichte?.body ?? '');
let status: 'DRAFT' | 'PUBLISHED' = $state(geschichte?.status ?? 'DRAFT'); let status: 'DRAFT' | 'PUBLISHED' = $state(geschichte?.status ?? 'DRAFT');
let selectedPersons: Person[] = $state( let selectedPersons: PersonOption[] = $state(
geschichte?.persons ? Array.from(geschichte.persons) : initialPersons geschichte?.persons ? Array.from(geschichte.persons).map(toPersonOption) : initialPersons
); );
let dirty = $state(false); let dirty = $state(false);
@@ -105,13 +107,17 @@ function handleTitleInput() {
async function save(nextStatus: 'DRAFT' | 'PUBLISHED') { async function save(nextStatus: 'DRAFT' | 'PUBLISHED') {
titleTouched = true; titleTouched = true;
if (titleEmpty) return; if (titleEmpty) return;
await onSubmit({ try {
title: title.trim(), await onSubmit({
body, title: title.trim(),
status: nextStatus, body,
personIds: selectedPersons.map((p) => p.id!).filter(Boolean) status: nextStatus,
}); personIds: selectedPersons.map((p) => p.id!).filter(Boolean)
dirty = false; });
dirty = false;
} catch {
// onSubmit signalled failure — keep dirty so the unsaved guard stays armed
}
} }
function isActive(name: string, attrs?: Record<string, unknown>): boolean { function isActive(name: string, attrs?: Record<string, unknown>): boolean {
@@ -134,6 +140,7 @@ function exec(action: () => void) {
<input <input
type="text" type="text"
bind:value={title} bind:value={title}
maxlength="255"
oninput={handleTitleInput} oninput={handleTitleInput}
onblur={handleTitleBlur} onblur={handleTitleBlur}
placeholder={m.geschichte_editor_title_placeholder()} placeholder={m.geschichte_editor_title_placeholder()}
@@ -227,35 +234,7 @@ function exec(action: () => void) {
</div> </div>
<!-- Sidebar --> <!-- Sidebar -->
<aside class="flex flex-col gap-6"> <GeschichteSidebar status={status} bind:selectedPersons={selectedPersons} />
<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>
</aside>
</div> </div>
<!-- Save bar --> <!-- Save bar -->

View File

@@ -54,6 +54,22 @@ describe('GeschichteEditor — title-required guard', () => {
}); });
}); });
describe('GeschichteEditor — onSubmit rejects on failure', () => {
it('catches a rejecting onSubmit (no unhandled rejection) and stays editable', async () => {
// Contract: onSubmit rejects on failure. Without the catch in save(), this
// click would surface as an unhandled promise rejection and fail the run.
const onSubmit = vi.fn().mockRejectedValue(new Error('save failed'));
render(GeschichteEditor, { geschichte: draftFactory(), onSubmit });
await userEvent.click(page.getByRole('button', { name: 'Entwurf speichern' }));
await vi.waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
// Editor still functional — a second save attempt goes through
await userEvent.click(page.getByRole('button', { name: 'Entwurf speichern' }));
await vi.waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(2));
});
});
describe('GeschichteEditor — save bar adapts to status', () => { describe('GeschichteEditor — save bar adapts to status', () => {
it('renders DRAFT mode buttons when no geschichte prop is supplied', async () => { it('renders DRAFT mode buttons when no geschichte prop is supplied', async () => {
render(GeschichteEditor, { onSubmit: vi.fn() }); render(GeschichteEditor, { onSubmit: vi.fn() });

View File

@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import { plainExcerpt } from '$lib/shared/utils/extractText'; import { plainExcerpt } from '$lib/shared/utils/extractText';
import { getInitials, personAvatarColor } from '$lib/person/personFormat';
import { formatAuthorName, formatPublishedAt } from './utils'; import { formatAuthorName, formatPublishedAt } from './utils';
import type { components } from '$lib/generated/api'; import type { components } from '$lib/generated/api';
@@ -18,25 +19,67 @@ const publishedAt = $derived(formatPublishedAt(geschichte.publishedAt, 'short'))
const authorName = $derived(formatAuthorName(geschichte.author)); const authorName = $derived(formatAuthorName(geschichte.author));
</script> </script>
<a href="/geschichten/{geschichte.id}" class="block"> <a
<div class="mb-1 flex items-center gap-1.5"> href="/geschichten/{geschichte.id}"
<h2 class="font-serif text-xl font-bold text-ink">{geschichte.title}</h2> class="group flex min-h-[44px] transition-colors hover:bg-canvas/60 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>
<!-- Meta column (desktop) -->
<div class="hidden w-40 shrink-0 flex-col items-start gap-1 border-r border-line-2 p-3 sm:flex">
<span
aria-hidden="true"
class="flex h-7 w-7 items-center justify-center rounded-full font-sans text-[9px] font-bold text-white"
style="background-color: {personAvatarColor(authorName)}"
>
{getInitials(authorName)}
</span>
<span class="font-sans text-sm leading-tight font-semibold text-ink">{authorName}</span>
{#if publishedAt}
<span class="font-sans text-sm text-ink-3">{publishedAt}</span>
{/if}
{#if isJourney} {#if isJourney}
<span <span
data-testid="journey-badge" data-testid="journey-badge"
style="font-size: 0.75rem" class="inline-flex items-center rounded-sm border border-journey-border bg-journey-tint px-1.5 py-px font-sans text-xs font-bold tracking-wide text-journey uppercase"
class="inline-block rounded-full bg-journey-tint px-2 py-0.5 text-xs font-bold tracking-wider text-journey uppercase"
> >
{m.journey_badge_list()} {m.journey_badge_list()}
</span> </span>
{/if} {/if}
</div> </div>
<p class="mb-3 font-sans text-xs text-ink-3">
{authorName} <!-- Content column -->
{#if publishedAt}· {m.geschichten_published_on({ date: publishedAt })}{/if} <div class="min-w-0 flex-1 p-3 sm:px-4">
</p> <!-- Compact meta line (mobile only) -->
{#if geschichte.body} <div class="mb-1 flex items-center gap-1.5 sm:hidden">
<!-- plaintext for JOURNEY, sanitised-HTML→text for STORY; never {@html} --> <!-- 7px initials render as smudge at this size — a plain color dot reads better -->
<p class="font-serif text-base text-ink-2">{plainExcerpt(geschichte.body, 150)}</p> <span
{/if} aria-hidden="true"
class="h-2.5 w-2.5 shrink-0 rounded-full"
style="background-color: {personAvatarColor(authorName)}"
></span>
<span class="font-sans text-sm font-semibold text-ink">{authorName}</span>
{#if publishedAt}
<span class="ml-auto font-sans text-sm text-ink-3">{publishedAt}</span>
{/if}
</div>
<div class="mb-1 flex items-center gap-1.5">
<h2 class="font-serif text-lg leading-snug text-ink group-hover:underline">
{geschichte.title}
</h2>
{#if isJourney}
<span
data-testid="journey-badge-mobile"
class="inline-flex shrink-0 items-center rounded-sm border border-journey-border bg-journey-tint px-1.5 py-px font-sans text-xs font-bold tracking-wide text-journey uppercase sm:hidden"
>
{m.journey_badge_list()}
</span>
{/if}
</div>
{#if geschichte.body}
<!-- plaintext for JOURNEY, sanitised-HTML→text for STORY; never {@html} -->
<p class="line-clamp-2 font-sans text-sm leading-relaxed text-ink-3">
{plainExcerpt(geschichte.body, 150)}
</p>
{/if}
</div>
</a> </a>

View File

@@ -12,7 +12,7 @@ const baseRow = (overrides = {}) => ({
body: '<p>Im Jahr 1923...</p>', body: '<p>Im Jahr 1923...</p>',
type: 'STORY' as 'STORY' | 'JOURNEY', type: 'STORY' as 'STORY' | 'JOURNEY',
status: 'PUBLISHED' as 'PUBLISHED' | 'DRAFT', status: 'PUBLISHED' as 'PUBLISHED' | 'DRAFT',
author: { firstName: 'Anna', lastName: 'Schmidt', email: 'a@x' }, author: { firstName: 'Anna', lastName: 'Schmidt' },
publishedAt: '2026-04-15T10:00:00Z', publishedAt: '2026-04-15T10:00:00Z',
...overrides ...overrides
}); });
@@ -25,6 +25,35 @@ describe('GeschichteListRow', () => {
.toHaveTextContent('Die Reise nach Berlin'); .toHaveTextContent('Die Reise nach Berlin');
}); });
it('row text sizes suit the full-width list: title text-lg, excerpt/meta text-sm (#802)', async () => {
render(GeschichteListRow, { props: { geschichte: baseRow() } });
const title = document.querySelector('h2');
expect(title!.className).toContain('text-lg');
expect(title!.className).not.toContain('text-[15px]');
const excerpt = document.querySelector('p');
expect(excerpt!.className).toContain('text-sm');
expect(excerpt!.className).not.toContain('text-xs');
const meta = Array.from(document.querySelectorAll('span')).filter((s) =>
s.textContent?.includes('Anna Schmidt')
);
expect(meta.length).toBeGreaterThan(0);
for (const span of meta) {
expect(span.className).toContain('text-sm');
}
});
it('desktop meta column is wide enough for text-sm names (w-40, #802)', async () => {
render(GeschichteListRow, { props: { geschichte: baseRow() } });
const metaColumn = document.querySelector('[class*="border-r"]');
expect(metaColumn).not.toBeNull();
expect(metaColumn!.className).toContain('w-40');
expect(metaColumn!.className).not.toContain('w-28');
});
it('shows no badge for STORY type', async () => { it('shows no badge for STORY type', async () => {
render(GeschichteListRow, { props: { geschichte: baseRow({ type: 'STORY' }) } }); render(GeschichteListRow, { props: { geschichte: baseRow({ type: 'STORY' }) } });
expect(document.querySelector('[data-testid="journey-badge"]')).toBeNull(); expect(document.querySelector('[data-testid="journey-badge"]')).toBeNull();
@@ -50,12 +79,12 @@ describe('GeschichteListRow', () => {
expect(badge?.tagName.toLowerCase()).toBe('span'); expect(badge?.tagName.toLowerCase()).toBe('span');
}); });
it('badge has small font size appropriate for a label', async () => { it('badge uses the 12px label size — text-xs is the visible-text floor', async () => {
render(GeschichteListRow, { props: { geschichte: baseRow({ type: 'JOURNEY' }) } }); render(GeschichteListRow, { props: { geschichte: baseRow({ type: 'JOURNEY' }) } });
const badge = document.querySelector('[data-testid="journey-badge"]'); const badge = document.querySelector('[data-testid="journey-badge"]');
const fontSize = parseFloat(window.getComputedStyle(badge!).fontSize); expect(badge!.className).toContain('text-xs');
expect(fontSize).toBeGreaterThan(0); // 10px was below the house floor for the 60+ audience (round-3 review)
expect(fontSize).toBeLessThanOrEqual(14); // label badge must not exceed body text size expect(badge!.className).not.toContain('text-[10px]');
}); });
it('renders author name in meta line', async () => { it('renders author name in meta line', async () => {

View File

@@ -0,0 +1,65 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import PersonMultiSelect from '$lib/person/PersonMultiSelect.svelte';
import type { PersonOption } from '$lib/person/personOption';
interface Props {
status: 'DRAFT' | 'PUBLISHED';
selectedPersons: PersonOption[];
}
let { status, selectedPersons = $bindable() }: Props = $props();
const isDraft = $derived(status === 'DRAFT');
</script>
<aside class="flex flex-col gap-6">
<!-- Status section -->
<details open class="sm:contents">
<summary
class="flex min-h-[44px] cursor-pointer items-center px-4 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase sm:hidden"
>
{m.geschichte_sidebar_status()}
</summary>
<section class="rounded border border-line bg-surface p-4 shadow-sm">
<!-- hidden below sm: the <summary> already shows this label there -->
<h2
class="mb-1 hidden font-sans text-xs font-bold tracking-widest text-ink-3 uppercase sm:block"
>
{m.geschichte_sidebar_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>
</details>
<!-- Persons section -->
<details open class="sm:contents">
<summary
class="flex min-h-[44px] cursor-pointer items-center px-4 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase sm:hidden"
>
{m.geschichte_editor_personen_heading()}
</summary>
<section class="rounded border border-line bg-surface p-4 shadow-sm">
<h2
class="mb-2 hidden font-sans text-xs font-bold tracking-widest text-ink-3 uppercase sm:block"
>
{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>
</details>
</aside>

View File

@@ -3,6 +3,7 @@ import { m } from '$lib/paraglide/messages.js';
import type { components } from '$lib/generated/api'; import type { components } from '$lib/generated/api';
import { plainExcerpt } from '$lib/shared/utils/extractText'; import { plainExcerpt } from '$lib/shared/utils/extractText';
import { formatDate } from '$lib/shared/utils/date'; import { formatDate } from '$lib/shared/utils/date';
import { formatAuthorName } from './utils';
type GeschichteSummary = components['schemas']['GeschichteSummary']; type GeschichteSummary = components['schemas']['GeschichteSummary'];
@@ -24,10 +25,7 @@ function formatPublishedDate(g: GeschichteSummary): string | null {
} }
function authorName(g: GeschichteSummary): string { function authorName(g: GeschichteSummary): string {
const a = g.author; return formatAuthorName(g.author);
if (!a) return '';
const full = [a.firstName, a.lastName].filter(Boolean).join(' ').trim();
return full || a.email || '';
} }
</script> </script>

View File

@@ -16,7 +16,6 @@ const makeStory = (id: string, title: string, body: string | undefined = '<p>Bod
items: [], items: [],
author: { author: {
id: 'u1', id: 'u1',
email: 'marcel@example.com',
firstName: 'Marcel', firstName: 'Marcel',
lastName: 'Raddatz', lastName: 'Raddatz',
enabled: true, enabled: true,

View File

@@ -17,7 +17,6 @@ const makeGeschichte = (overrides: Record<string, unknown> = {}): GeschichteSumm
type: 'STORY' as const, type: 'STORY' as const,
publishedAt: '2026-04-15T10:00:00Z', publishedAt: '2026-04-15T10:00:00Z',
author: { author: {
email: 'a@b',
firstName: 'Anna', firstName: 'Anna',
lastName: 'Schmidt' lastName: 'Schmidt'
}, },
@@ -103,17 +102,17 @@ describe('GeschichtenCard', () => {
await expect.element(page.getByText(/Anna Schmidt/)).toBeVisible(); await expect.element(page.getByText(/Anna Schmidt/)).toBeVisible();
}); });
it('falls back to author email when no name', async () => { it('falls back to [Unbekannt] when no name', async () => {
render(GeschichtenCard, { render(GeschichtenCard, {
props: baseProps({ props: baseProps({
geschichten: [ geschichten: [
makeGeschichte({ makeGeschichte({
author: { firstName: undefined, lastName: undefined, email: 'fallback@x' } author: { firstName: undefined, lastName: undefined }
}) })
] ]
}) })
}); });
await expect.element(page.getByText(/fallback@x/)).toBeVisible(); await expect.element(page.getByText('[Unbekannt]')).toBeVisible();
}); });
}); });

View File

@@ -0,0 +1,126 @@
<script lang="ts">
import { tick } from 'svelte';
import { m } from '$lib/paraglide/messages.js';
import DocumentPickerDropdown from '$lib/document/DocumentPickerDropdown.svelte';
import type { DocumentOption } from '$lib/document/documentTypeahead';
interface Props {
alreadyAddedIds?: Set<string>;
onAddDocument: (doc: DocumentOption) => void;
onAddInterlude: (text: string) => void;
}
let { alreadyAddedIds = new Set(), onAddDocument, onAddInterlude }: Props = $props();
let showPicker = $state(false);
let showInterludeForm = $state(false);
let interludeDraft = $state('');
let rootEl: HTMLElement | null = $state(null);
const canConfirmInterlude = $derived(interludeDraft.trim().length > 0);
async function togglePicker() {
showPicker = !showPicker;
showInterludeForm = false;
if (showPicker) {
// Keyboard users need a perceivable result of activating the toggle.
await tick();
rootEl?.querySelector<HTMLInputElement>('#journey-add-picker input')?.focus();
}
}
async function toggleInterludeForm() {
showInterludeForm = !showInterludeForm;
showPicker = false;
if (showInterludeForm) {
await tick();
rootEl?.querySelector<HTMLTextAreaElement>('#journey-add-interlude textarea')?.focus();
}
}
function handleDocumentSelect(doc: DocumentOption) {
showPicker = false;
onAddDocument(doc);
}
function handleInterludeConfirm() {
if (!canConfirmInterlude) return;
const text = interludeDraft.trim();
interludeDraft = '';
showInterludeForm = false;
onAddInterlude(text);
}
function handleInterludeCancel() {
interludeDraft = '';
showInterludeForm = false;
}
</script>
<div bind:this={rootEl} class="flex flex-col gap-3">
<div class="flex flex-wrap gap-2">
<button
type="button"
data-add-document
onclick={togglePicker}
aria-expanded={showPicker}
aria-controls={showPicker ? 'journey-add-picker' : undefined}
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.journey_add_document()}
</button>
<button
type="button"
onclick={toggleInterludeForm}
aria-expanded={showInterludeForm}
aria-controls={showInterludeForm ? 'journey-add-interlude' : undefined}
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.journey_add_interlude()}
</button>
</div>
{#if showPicker}
<div id="journey-add-picker">
<DocumentPickerDropdown
alreadyAddedIds={alreadyAddedIds}
onSelect={handleDocumentSelect}
placeholder={m.journey_add_document()}
/>
</div>
{/if}
{#if showInterludeForm}
<div id="journey-add-interlude" class="flex flex-col gap-2">
<textarea
bind:value={interludeDraft}
placeholder={m.journey_interlude_placeholder()}
rows={3}
maxlength={2000}
class="block w-full resize-y rounded border border-line bg-surface px-3 py-2 font-sans text-sm text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
></textarea>
<div class="flex gap-2">
<button
type="button"
onclick={handleInterludeConfirm}
disabled={!canConfirmInterlude}
class={[
'inline-flex h-11 items-center rounded px-4 font-sans text-sm font-medium focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring',
canConfirmInterlude
? 'bg-primary text-primary-fg hover:opacity-90'
: 'cursor-not-allowed bg-primary/40 text-primary-fg/60'
].join(' ')}
>
{m.journey_add_interlude_confirm()}
</button>
<button
type="button"
onclick={handleInterludeCancel}
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.journey_remove_confirm_cancel()}
</button>
</div>
</div>
{/if}
</div>

View File

@@ -0,0 +1,72 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page, userEvent } from 'vitest/browser';
import { m } from '$lib/paraglide/messages.js';
import JourneyAddBar from './JourneyAddBar.svelte';
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
describe('JourneyAddBar — interlude flow', () => {
it('interlude confirm button is natively disabled when text is empty (WCAG 4.1.2)', async () => {
render(JourneyAddBar, { onAddDocument: vi.fn(), onAddInterlude: vi.fn() });
await userEvent.click(page.getByText(m.journey_add_interlude()));
const confirmBtn = page.getByRole('button', {
name: m.journey_add_interlude_confirm(),
exact: true
});
await expect.element(confirmBtn).toBeDisabled();
});
it('confirm becomes enabled after typing text', async () => {
render(JourneyAddBar, { onAddDocument: vi.fn(), onAddInterlude: vi.fn() });
await userEvent.click(page.getByText(m.journey_add_interlude()));
await userEvent.fill(page.getByRole('textbox'), 'Eine schöne Reise');
const confirmBtn = page.getByRole('button', {
name: m.journey_add_interlude_confirm(),
exact: true
});
await expect.element(confirmBtn).toBeEnabled();
});
it('calls onAddInterlude with text on confirm', async () => {
const onAddInterlude = vi.fn();
render(JourneyAddBar, { onAddDocument: vi.fn(), onAddInterlude });
await userEvent.click(page.getByText(m.journey_add_interlude()));
await userEvent.fill(page.getByRole('textbox'), 'Reise nach Wien');
await userEvent.click(
page.getByRole('button', { name: m.journey_add_interlude_confirm(), exact: true })
);
expect(onAddInterlude).toHaveBeenCalledWith('Reise nach Wien');
});
it('limits the interlude textarea to 2000 characters', async () => {
render(JourneyAddBar, { onAddDocument: vi.fn(), onAddInterlude: vi.fn() });
await userEvent.click(page.getByText(m.journey_add_interlude()));
await expect.element(page.getByRole('textbox')).toHaveAttribute('maxlength', '2000');
});
});
describe('JourneyAddBar — document picker', () => {
it('reveals picker when "Brief hinzufügen" is clicked', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue({ items: [] }) })
);
render(JourneyAddBar, { onAddDocument: vi.fn(), onAddInterlude: vi.fn() });
await userEvent.click(page.getByText(m.journey_add_document()));
await expect.element(page.getByRole('combobox')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,408 @@
<script lang="ts">
import { tick } from 'svelte';
import type { components } from '$lib/generated/api';
import { m } from '$lib/paraglide/messages.js';
import { csrfFetch } from '$lib/shared/cookies';
import { getErrorMessage } from '$lib/shared/errors';
import { createBlockDragDrop } from '$lib/shared/hooks/useBlockDragDrop.svelte';
import { toPersonOption, type PersonOption } from '$lib/person/personOption';
import { createUnsavedWarning } from '$lib/shared/hooks/useUnsavedWarning.svelte';
import type { DocumentOption } from '$lib/document/documentTypeahead';
import GeschichteSidebar from './GeschichteSidebar.svelte';
import JourneyItemRow from './JourneyItemRow.svelte';
import JourneyAddBar from './JourneyAddBar.svelte';
import UnsavedWarningBanner from '$lib/shared/primitives/UnsavedWarningBanner.svelte';
type GeschichteView = components['schemas']['GeschichteView'];
type JourneyItemView = components['schemas']['JourneyItemView'];
interface Props {
geschichte: GeschichteView;
/** Must reject when the save failed — the editor keeps its dirty state then. */
onSubmit: (payload: {
title: string;
body: string;
status: 'DRAFT' | 'PUBLISHED';
personIds: string[];
}) => Promise<void>;
submitting?: boolean;
}
let { geschichte, onSubmit, submitting = false }: Props = $props();
const unsaved = createUnsavedWarning();
let title = $state(geschichte.title ?? '');
let body = $state(geschichte.body ?? '');
let status: 'DRAFT' | 'PUBLISHED' = $state(geschichte.status ?? 'DRAFT');
let selectedPersons: PersonOption[] = $state(
geschichte.persons ? Array.from(geschichte.persons).map(toPersonOption) : []
);
let items: JourneyItemView[] = $state(
[...(geschichte.items ?? [])].sort((a, b) => a.position - b.position)
);
let titleTouched = $state(false);
let mutationError = $state('');
let pendingRemoveIds: string[] = $state([]);
let liveAnnounce = $state('');
let announceTimer: ReturnType<typeof setTimeout> | null = null;
function scheduleAnnounceReset() {
if (announceTimer) clearTimeout(announceTimer);
announceTimer = setTimeout(() => {
liveAnnounce = '';
announceTimer = null;
}, 500);
}
const titleEmpty = $derived(title.trim().length === 0);
const showTitleError = $derived(titleEmpty && titleTouched);
const isDraft = $derived(status === 'DRAFT');
const alreadyAddedIds = $derived(
new Set(items.filter((i) => i.document).map((i) => i.document!.id))
);
const canPublish = $derived(items.length > 0 && !titleEmpty);
const showPublishedEmptyWarning = $derived(status === 'PUBLISHED' && items.length === 0);
// Skip the initial run so mounting with pre-existing persons doesn't mark dirty.
let _personEffectMounted = false;
$effect(() => {
void selectedPersons.length;
if (!_personEffectMounted) {
_personEffectMounted = true;
return;
}
unsaved.markDirty();
});
let listEl: HTMLElement | null = $state(null);
let editorColEl: HTMLElement | null = $state(null);
const dragDrop = createBlockDragDrop<JourneyItemView>({
getSortedBlocks: () => items,
onReorder: handleReorder
});
$effect(() => {
dragDrop.setListElement(listEl);
});
/** Maps a failed mutation response to a user-facing message via its backend error code. */
async function failureMessage(res: Response): Promise<string> {
const code = (await res.json().catch(() => ({})))?.code;
return code ? getErrorMessage(code) : m.journey_mutation_error_reload();
}
/** Moves keyboard focus to a control inside the row of the given item. */
async function focusRowControl(itemId: string, selector: string) {
await tick();
editorColEl?.querySelector<HTMLElement>(`[data-block-id="${itemId}"] ${selector}`)?.focus();
}
async function handleReorder(itemIds: string[]) {
const prev = [...items];
items = itemIds.map((id) => items.find((i) => i.id === id)!);
mutationError = '';
try {
const res = await csrfFetch(`/api/geschichten/${geschichte.id}/items/reorder`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ itemIds })
});
if (!res.ok) {
items = prev;
mutationError = await failureMessage(res);
return;
}
const updated: JourneyItemView[] = await res.json();
items = updated.sort((a, b) => a.position - b.position);
} catch (e) {
console.error('Journey reorder failed', e);
items = prev;
mutationError = m.journey_mutation_error_reload();
}
}
/** Pessimistic append shared by both add paths — items update only on API success. */
async function appendItem(body: { documentId?: string; note?: string }) {
mutationError = '';
try {
const res = await csrfFetch(`/api/geschichten/${geschichte.id}/items`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!res.ok) {
mutationError = await failureMessage(res);
return;
}
const newItem: JourneyItemView = await res.json();
items = [...items, newItem];
// Move-up is disabled on the first row — fall back to the remove button then.
await focusRowControl(newItem.id, '[data-move-up]:not([disabled]), [data-remove-btn]');
} catch (e) {
console.error('Journey item append failed', e);
mutationError = m.journey_mutation_error_reload();
}
}
async function handleAddDocument(doc: DocumentOption) {
await appendItem({ documentId: doc.id });
}
async function handleAddInterlude(text: string) {
await appendItem({ note: text });
}
async function handleRemove(itemId: string) {
const idx = items.findIndex((i) => i.id === itemId);
mutationError = '';
pendingRemoveIds = [...pendingRemoveIds, itemId];
liveAnnounce = m.journey_item_pending_remove();
scheduleAnnounceReset();
try {
const res = await csrfFetch(`/api/geschichten/${geschichte.id}/items/${itemId}`, {
method: 'DELETE'
});
if (!res.ok) {
mutationError = await failureMessage(res);
return;
}
items = items.filter((i) => i.id !== itemId);
await tick();
if (items.length === 0 || idx <= 0) {
editorColEl?.querySelector<HTMLElement>('[data-add-document]')?.focus();
} else {
await focusRowControl(items[idx - 1].id, '[data-remove-btn]');
}
} catch (e) {
console.error('Journey item remove failed', e);
mutationError = m.journey_mutation_error_reload();
} finally {
pendingRemoveIds = pendingRemoveIds.filter((id) => id !== itemId);
}
}
async function handleNotePatch(itemId: string, note: string | null) {
const res = await csrfFetch(`/api/geschichten/${geschichte.id}/items/${itemId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ note: note })
});
// Carry the backend error code's message so the row can show the specific
// reason (e.g. JOURNEY_NOTE_TOO_LONG) instead of a generic alert.
if (!res.ok) throw new Error(await failureMessage(res));
const updated: JourneyItemView = await res.json();
items = items.map((i) => (i.id === itemId ? updated : i));
}
async function handleMoveUp(index: number) {
if (index === 0) return;
const total = items.length;
const ids = items.map((i) => i.id);
[ids[index - 1], ids[index]] = [ids[index], ids[index - 1]];
await handleReorder(ids);
// Announce only after the server confirmed (or rejected) the reorder —
// announcing beforehand would claim success for a move that rolled back.
liveAnnounce = mutationError
? mutationError
: m.journey_item_moved({ position: index + 1, total, newPosition: index });
scheduleAnnounceReset();
}
async function handleMoveDown(index: number) {
if (index === items.length - 1) return;
const total = items.length;
const ids = items.map((i) => i.id);
[ids[index], ids[index + 1]] = [ids[index + 1], ids[index]];
await handleReorder(ids);
liveAnnounce = mutationError
? mutationError
: m.journey_item_moved({ position: index + 1, total, newPosition: index + 2 });
scheduleAnnounceReset();
}
async function save(nextStatus: 'DRAFT' | 'PUBLISHED') {
titleTouched = true;
if (titleEmpty) return;
try {
await onSubmit({
title: title.trim(),
body,
status: nextStatus,
personIds: selectedPersons.map((p) => p.id!).filter(Boolean)
});
unsaved.clearOnSuccess();
} catch {
// onSubmit signalled failure — keep dirty flag so the banner stays
}
}
</script>
<!-- Screen-reader live region for move announcements -->
<div aria-live="polite" aria-atomic="true" class="sr-only">{liveAnnounce}</div>
{#if unsaved.showUnsavedWarning}
<UnsavedWarningBanner onDiscard={unsaved.discard} />
{/if}
<div class="grid grid-cols-1 gap-6 lg:grid-cols-[2fr_1fr]">
<!-- Editor column -->
<div bind:this={editorColEl} class="flex flex-col gap-4">
<!-- Title -->
<div>
<input
type="text"
bind:value={title}
maxlength="255"
oninput={() => unsaved.markDirty()}
onblur={() => (titleTouched = true)}
placeholder={m.geschichte_editor_title_placeholder()}
aria-label={m.journey_title_aria_label()}
aria-invalid={showTitleError}
aria-describedby={showTitleError ? 'journey-title-error' : undefined}
class="block w-full rounded border {showTitleError
? 'border-danger'
: '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="journey-title-error" class="mt-1 text-sm text-danger" role="alert">
{m.geschichte_editor_title_required()}
</p>
{/if}
</div>
<!-- Intro textarea -->
<div>
<textarea
bind:value={body}
maxlength="4000"
oninput={() => unsaved.markDirty()}
placeholder={m.journey_intro_placeholder()}
aria-label={m.journey_intro_aria_label()}
rows={3}
class="block w-full resize-y rounded border border-line bg-surface px-3 py-2 font-serif text-base text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
></textarea>
<p class="mt-1 font-sans text-xs text-ink-3">{m.journey_intro_save_hint()}</p>
</div>
<!-- Item list -->
{#if showPublishedEmptyWarning}
<p
class="rounded border border-[var(--color-warning-border)] bg-[var(--color-warning-bg)] px-3 py-2 font-sans text-sm text-[var(--color-warning-text)]"
>
{m.journey_published_empty_warning()}
</p>
{/if}
{#if mutationError}
<p
class="rounded border border-danger bg-danger/10 px-3 py-2 font-sans text-sm text-danger"
role="alert"
>
{mutationError}
</p>
{/if}
{#if items.length === 0}
<p class="font-sans text-sm text-ink-3">{m.journey_empty_state()}</p>
{:else}
<!-- pointer events managed by createBlockDragDrop; keyboard reorder available via move-up/down buttons on each item -->
<ol
bind:this={listEl}
onpointermove={(e) => dragDrop.handlePointerMove(e)}
onpointerup={() => dragDrop.handlePointerUp()}
class="m-0 flex list-none flex-col gap-2 p-0"
>
{#each items as item, i (item.id)}
<!-- pointerdown initiates drag; the drag handle button inside is the semantic interactive element -->
<li
data-block-wrapper
onpointerdown={(e) => dragDrop.handleGripDown(e, item.id)}
class="transition-all duration-150 {dragDrop.draggedBlockId === item.id ? 'z-10 rounded-lg shadow-xl ring-2 ring-focus-ring/40' : ''}"
style={dragDrop.draggedBlockId === item.id
? `transform: translateY(${dragDrop.dragOffsetY}px) scale(1.02); opacity: 0.9;`
: ''}
>
{#if dragDrop.dropTargetIdx === i}
<div class="mb-1 h-1 rounded-full bg-accent transition-all"></div>
{/if}
<JourneyItemRow
item={item}
index={i}
total={items.length}
pendingRemove={pendingRemoveIds.includes(item.id)}
onMoveUp={() => handleMoveUp(i)}
onMoveDown={() => handleMoveDown(i)}
onRemove={() => handleRemove(item.id)}
onNotePatch={(note) => handleNotePatch(item.id, note)}
/>
</li>
{/each}
</ol>
{/if}
<JourneyAddBar
alreadyAddedIds={alreadyAddedIds}
onAddDocument={handleAddDocument}
onAddInterlude={handleAddInterlude}
/>
</div>
<!-- Sidebar -->
<GeschichteSidebar status={status} bind:selectedPersons={selectedPersons} />
</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.journey_save_hint_published()}
</p>
<div class="flex flex-col items-start gap-1 sm:items-end">
<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 || !canPublish}
title={canPublish ? undefined : m.journey_publish_disabled_title()}
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-[var(--color-warning-text)] 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>
{#if isDraft && !canPublish}
<p class="font-sans text-xs text-ink-3">{m.journey_publish_disabled_hint()}</p>
{/if}
</div>
</div>

View File

@@ -0,0 +1,813 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page, userEvent } from 'vitest/browser';
import { m } from '$lib/paraglide/messages.js';
import JourneyEditor from './JourneyEditor.svelte';
vi.mock('$app/navigation', () => ({ beforeNavigate: vi.fn(), goto: vi.fn() }));
import { beforeNavigate } from '$app/navigation';
const docSummary = (id: string, title: string) => ({
id,
title,
datePrecision: 'DAY' as const,
receiverCount: 0
});
/** DocumentListItem fixture as returned by the picker search endpoint. */
const makeSearchResultItem = (id: string, title: string) => ({
id,
title,
documentDate: '1880-01-01',
metaDatePrecision: 'DAY',
originalFilename: 'brief.pdf',
receivers: [],
tags: [],
completionPercentage: 0,
contributors: [],
matchData: {
titleOffsets: [],
senderMatched: false,
matchedReceiverIds: [],
matchedTagIds: [],
snippetOffsets: [],
summaryOffsets: []
},
status: 'UPLOADED',
metadataComplete: false,
scriptType: 'UNKNOWN',
createdAt: '2024-01-01T00:00:00',
updatedAt: '2024-01-01T00:00:00'
});
const makeGeschichte = (overrides: Record<string, unknown> = {}) => ({
id: 'g1',
title: 'Briefe der Familie Raddatz',
body: '',
status: 'DRAFT' as const,
type: 'JOURNEY' as const,
persons: [],
items: [],
createdAt: '2024-01-01T00:00:00',
updatedAt: '2024-01-01T00:00:00',
...overrides
});
const defaultProps = (overrides: Record<string, unknown> = {}) => ({
geschichte: makeGeschichte(),
onSubmit: vi.fn().mockResolvedValue(undefined),
submitting: false,
...overrides
});
function mockCsrfFetch(responseFactory: () => object) {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue(responseFactory())
})
);
}
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
describe('JourneyEditor — empty state', () => {
it('renders title input and intro textarea', async () => {
render(JourneyEditor, defaultProps());
await expect.element(page.getByPlaceholder(/Titel/)).toBeInTheDocument();
await expect.element(page.getByPlaceholder(/Einleitung/)).toBeInTheDocument();
});
it('labels the title input and intro textarea for screen readers', async () => {
render(JourneyEditor, defaultProps());
await expect
.element(page.getByRole('textbox', { name: m.journey_title_aria_label() }))
.toBeInTheDocument();
await expect
.element(page.getByRole('textbox', { name: m.journey_intro_aria_label() }))
.toBeInTheDocument();
});
it('shows empty state message when items list is empty', async () => {
render(JourneyEditor, defaultProps());
await expect.element(page.getByText(m.journey_empty_state())).toBeInTheDocument();
});
});
describe('JourneyEditor — items in position order', () => {
it('renders items sorted by position', async () => {
const items = [
{ id: 'i2', position: 1, document: docSummary('d2', 'Brief B') },
{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }
];
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
// Brief A (position 0) must appear before Brief B (position 1) in DOM order
const briefA = page.getByText('Brief A').element();
const briefB = page.getByText('Brief B').element();
expect(briefA.compareDocumentPosition(briefB) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
});
describe('JourneyEditor — publish surface', () => {
it('publish button disabled when no items', async () => {
render(JourneyEditor, defaultProps());
await expect.element(page.getByRole('button', { name: /Veröffentlichen/ })).toBeDisabled();
});
it('shows a visible hint while publishing is disabled', async () => {
render(JourneyEditor, defaultProps());
await expect.element(page.getByText(m.journey_publish_disabled_hint())).toBeInTheDocument();
});
it('publish stays disabled until title is non-empty', async () => {
render(
JourneyEditor,
defaultProps({
geschichte: makeGeschichte({
title: '',
items: [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }]
})
})
);
await expect.element(page.getByRole('button', { name: /Veröffentlichen/ })).toBeDisabled();
const titleInput = page.getByPlaceholder(/Titel/);
await userEvent.fill(titleInput, 'Meine Reise');
await expect.element(page.getByRole('button', { name: /Veröffentlichen/ })).not.toBeDisabled();
});
it('adding an item enables the publish button (canPublish becomes true)', async () => {
const newItem = { id: 'i1', position: 0, note: 'Test' };
mockCsrfFetch(() => newItem);
render(JourneyEditor, defaultProps());
// Publish should be disabled before adding item
await expect.element(page.getByRole('button', { name: /Veröffentlichen/ })).toBeDisabled();
// Add interlude
await userEvent.click(page.getByText(m.journey_add_interlude()));
await userEvent.fill(page.getByPlaceholder(m.journey_interlude_placeholder()), 'Test');
await userEvent.click(
page.getByRole('button', { name: m.journey_add_interlude_confirm(), exact: true })
);
// After item add, publish becomes enabled — item was added and state is correct
await expect.element(page.getByRole('button', { name: /Veröffentlichen/ })).not.toBeDisabled();
});
it('clicking Veröffentlichen calls onSubmit with status PUBLISHED and the trimmed title', async () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
render(
JourneyEditor,
defaultProps({
onSubmit,
geschichte: makeGeschichte({
title: ' Meine Reise ',
items: [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }]
})
})
);
await userEvent.click(page.getByRole('button', { name: /Veröffentlichen/ }));
await vi.waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({ status: 'PUBLISHED', title: 'Meine Reise' })
);
});
});
it('unpublish button calls onSubmit with status DRAFT in PUBLISHED state', async () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
render(
JourneyEditor,
defaultProps({
onSubmit,
geschichte: makeGeschichte({
status: 'PUBLISHED',
items: [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }]
})
})
);
await userEvent.click(page.getByRole('button', { name: m.geschichte_editor_unpublish() }));
await vi.waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ status: 'DRAFT' }));
});
});
it('renders the published-empty warning banner when PUBLISHED with 0 items', async () => {
render(
JourneyEditor,
defaultProps({ geschichte: makeGeschichte({ status: 'PUBLISHED', items: [] }) })
);
await expect.element(page.getByText(m.journey_published_empty_warning())).toBeInTheDocument();
});
});
describe('JourneyEditor — add document', () => {
it('calls POST with documentId when document selected from picker', async () => {
const newItem = { id: 'i1', position: 0, document: docSummary('d1', 'Brief von Karl') };
vi.stubGlobal(
'fetch',
vi
.fn()
.mockResolvedValueOnce({
// picker search results
ok: true,
json: vi.fn().mockResolvedValue({ items: [makeSearchResultItem('d1', 'Brief von Karl')] })
})
.mockResolvedValueOnce({
// POST /items
ok: true,
json: vi.fn().mockResolvedValue(newItem)
})
);
render(JourneyEditor, defaultProps());
await userEvent.click(page.getByText(m.journey_add_document()));
await userEvent.fill(page.getByRole('combobox'), 'Karl');
// dropdown option appears after the typeahead debounce
await expect.element(page.getByText(/Brief von Karl ·/)).toBeInTheDocument();
await userEvent.click(page.getByText(/Brief von Karl ·/));
await vi.waitFor(() => {
expect(globalThis.fetch).toHaveBeenCalledWith(
expect.stringContaining('/items'),
expect.objectContaining({ method: 'POST' })
);
});
});
});
describe('JourneyEditor — add interlude', () => {
it('calls POST with note on interlude confirm', async () => {
const newItem = { id: 'i1', position: 0, note: 'Reise nach Wien' };
mockCsrfFetch(() => newItem);
render(JourneyEditor, defaultProps());
await userEvent.click(page.getByText(m.journey_add_interlude()));
await userEvent.fill(
page.getByPlaceholder(m.journey_interlude_placeholder()),
'Reise nach Wien'
);
await userEvent.click(
page.getByRole('button', { name: m.journey_add_interlude_confirm(), exact: true })
);
await vi.waitFor(() => {
expect(globalThis.fetch).toHaveBeenCalledWith(
expect.stringContaining('/items'),
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ note: 'Reise nach Wien' })
})
);
});
});
it('moves keyboard focus into the new row after the interlude is added', async () => {
const newItem = { id: 'i1', position: 0, note: 'Reise nach Wien' };
mockCsrfFetch(() => newItem);
render(JourneyEditor, defaultProps());
await userEvent.click(page.getByText(m.journey_add_interlude()));
await userEvent.fill(
page.getByPlaceholder(m.journey_interlude_placeholder()),
'Reise nach Wien'
);
await userEvent.click(
page.getByRole('button', { name: m.journey_add_interlude_confirm(), exact: true })
);
await vi.waitFor(() => {
expect(document.activeElement?.closest('[data-block-id="i1"]')).toBeTruthy();
});
});
});
describe('JourneyEditor — mutation error code routing', () => {
it('shows the specific i18n message when POST /items fails with a backend error code', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: false,
json: vi.fn().mockResolvedValue({ code: 'JOURNEY_DOCUMENT_ALREADY_ADDED' })
})
);
render(JourneyEditor, defaultProps());
await userEvent.click(page.getByText(m.journey_add_interlude()));
await userEvent.fill(
page.getByPlaceholder(m.journey_interlude_placeholder()),
'Reise nach Wien'
);
await userEvent.click(
page.getByRole('button', { name: m.journey_add_interlude_confirm(), exact: true })
);
await expect
.element(page.getByText(m.error_journey_document_already_added()))
.toBeInTheDocument();
await expect.element(page.getByText(m.journey_mutation_error_reload())).not.toBeInTheDocument();
});
});
describe('JourneyEditor — remove with pending state', () => {
it('keeps the row in the DOM with pending treatment while the DELETE is in flight', async () => {
const items = [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }];
let resolveFetch!: (value: unknown) => void;
vi.stubGlobal(
'fetch',
vi.fn().mockImplementation(() => new Promise((resolve) => (resolveFetch = resolve)))
);
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
await userEvent.click(
page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief A' }) })
);
// Row still present, marked as pending (text appears in the row AND the live region,
// so scope the query to the row instead of using a page-wide locator)
await expect.element(page.getByText('Brief A')).toBeInTheDocument();
await vi.waitFor(() => {
const row = document.querySelector('[data-block-id="i1"]');
expect(row).toBeTruthy();
expect(row!.textContent).toContain(m.journey_item_pending_remove());
expect(row!.className).toContain('opacity-60');
});
resolveFetch({ ok: true });
await expect.element(page.getByText('Brief A')).not.toBeInTheDocument();
});
it('keeps the row and shows an error alert on failed DELETE (non-ok response)', async () => {
const items = [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }];
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({ ok: false, json: vi.fn().mockResolvedValue({}) })
);
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
// Click remove (no note → direct remove)
await userEvent.click(
page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief A' }) })
);
await expect.element(page.getByRole('alert')).toBeInTheDocument();
await expect.element(page.getByText('Brief A')).toBeInTheDocument();
});
it('removes the row on successful DELETE', async () => {
const items = [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }];
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true }));
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
await userEvent.click(
page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief A' }) })
);
await expect.element(page.getByText('Brief A')).not.toBeInTheDocument();
});
it('focuses a sensible target after a successful remove (not body)', async () => {
const items = [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }];
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true }));
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
await userEvent.click(
page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief A' }) })
);
await expect.element(page.getByText('Brief A')).not.toBeInTheDocument();
await vi.waitFor(() => {
expect(document.activeElement).not.toBe(document.body);
expect(document.activeElement?.hasAttribute('data-add-document')).toBe(true);
});
});
});
describe('JourneyEditor — reorder via move buttons', () => {
it('move-up calls PUT reorder with swapped IDs', async () => {
const items = [
{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') },
{ id: 'i2', position: 1, document: docSummary('d2', 'Brief B') }
];
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue([
{ id: 'i2', position: 0, document: docSummary('d2', 'Brief B') },
{ id: 'i1', position: 1, document: docSummary('d1', 'Brief A') }
])
})
);
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
await userEvent.click(page.getByRole('button', { name: /Brief B.*nach oben verschieben/ }));
await vi.waitFor(() => {
expect(globalThis.fetch).toHaveBeenCalledWith(
expect.stringContaining('/items/reorder'),
expect.objectContaining({
method: 'PUT',
body: JSON.stringify({ itemIds: ['i2', 'i1'] })
})
);
});
});
it('move-down calls PUT reorder with swapped IDs', async () => {
const items = [
{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') },
{ id: 'i2', position: 1, document: docSummary('d2', 'Brief B') }
];
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue([
{ id: 'i2', position: 0, document: docSummary('d2', 'Brief B') },
{ id: 'i1', position: 1, document: docSummary('d1', 'Brief A') }
])
})
);
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
await userEvent.click(page.getByRole('button', { name: /Brief A.*nach unten verschieben/ }));
await vi.waitFor(() => {
expect(globalThis.fetch).toHaveBeenCalledWith(
expect.stringContaining('/items/reorder'),
expect.objectContaining({
method: 'PUT',
body: JSON.stringify({ itemIds: ['i2', 'i1'] })
})
);
});
});
it('renders the server-confirmed order after a successful reorder', async () => {
const items = [
{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') },
{ id: 'i2', position: 1, document: docSummary('d2', 'Brief B') }
];
// Server response deliberately NOT pre-sorted — pins items = updated.sort(...)
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue([
{ id: 'i1', position: 20, document: docSummary('d1', 'Brief A') },
{ id: 'i2', position: 10, document: docSummary('d2', 'Brief B') }
])
})
);
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
await userEvent.click(page.getByRole('button', { name: /Brief A.*nach unten verschieben/ }));
await vi.waitFor(() => {
const briefB = page.getByText('Brief B').element();
const briefA = page.getByText('Brief A').element();
expect(
briefB.compareDocumentPosition(briefA) & Node.DOCUMENT_POSITION_FOLLOWING
).toBeTruthy();
});
});
it('restores the original DOM order and shows an alert on failed reorder (non-ok)', async () => {
const items = [
{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') },
{ id: 'i2', position: 1, document: docSummary('d2', 'Brief B') }
];
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({ ok: false, json: vi.fn().mockResolvedValue({}) })
);
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
await userEvent.click(page.getByRole('button', { name: /Brief B.*nach oben verschieben/ }));
await expect.element(page.getByRole('alert')).toBeInTheDocument();
const briefA = page.getByText('Brief A').element();
const briefB = page.getByText('Brief B').element();
expect(briefA.compareDocumentPosition(briefB) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
it('restores the original DOM order and shows an alert when the reorder request rejects', async () => {
const items = [
{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') },
{ id: 'i2', position: 1, document: docSummary('d2', 'Brief B') }
];
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('network down')));
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
await userEvent.click(page.getByRole('button', { name: /Brief B.*nach oben verschieben/ }));
await expect.element(page.getByRole('alert')).toBeInTheDocument();
const briefA = page.getByText('Brief A').element();
const briefB = page.getByText('Brief B').element();
expect(briefA.compareDocumentPosition(briefB) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(consoleError).toHaveBeenCalled();
consoleError.mockRestore();
});
});
describe('JourneyEditor — live announce region', () => {
it('announces the move only after the reorder resolved, then clears', async () => {
const items = [
{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') },
{ id: 'i2', position: 1, document: docSummary('d2', 'Brief B') }
];
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue([
{ id: 'i2', position: 0, document: docSummary('d2', 'Brief B') },
{ id: 'i1', position: 1, document: docSummary('d1', 'Brief A') }
])
})
);
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
await userEvent.click(page.getByRole('button', { name: /Brief B.*nach oben verschieben/ }));
const liveRegion = document.querySelector('[aria-live="polite"]');
await vi.waitFor(() => {
expect((liveRegion?.textContent ?? '').trim().length).toBeGreaterThan(0);
});
await vi.waitFor(
() => {
expect((liveRegion?.textContent ?? '').trim()).toBe('');
},
{ timeout: 2000 }
);
});
it('announces the error text instead of a success message when the move fails', async () => {
const items = [
{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') },
{ id: 'i2', position: 1, document: docSummary('d2', 'Brief B') }
];
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({ ok: false, json: vi.fn().mockResolvedValue({}) })
);
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
await userEvent.click(page.getByRole('button', { name: /Brief B.*nach oben verschieben/ }));
const liveRegion = document.querySelector('[aria-live="polite"]');
await vi.waitFor(() => {
expect((liveRegion?.textContent ?? '').trim()).toBe(m.journey_mutation_error_reload());
});
});
});
describe('JourneyEditor — note patch body', () => {
it('sends {"note":null} when note textarea is cleared and blurred', async () => {
const items = [
{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A'), note: 'old note' }
];
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: vi
.fn()
.mockResolvedValue({ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') })
})
);
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
const textarea = page.getByRole('textbox', { name: /Kuratoren-Notiz/ });
await userEvent.clear(textarea);
await textarea.element().dispatchEvent(new FocusEvent('blur'));
await vi.waitFor(() => {
expect(globalThis.fetch).toHaveBeenCalledWith(
expect.stringContaining('/items/i1'),
expect.objectContaining({
method: 'PATCH',
body: JSON.stringify({ note: null })
})
);
});
});
});
describe('JourneyEditor — duplicate document aria-disabled', () => {
it('already-added document appears as aria-disabled in picker', async () => {
const items = [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief von Karl') }];
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue({ items: [makeSearchResultItem('d1', 'Brief von Karl')] })
})
);
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
await userEvent.click(page.getByText(m.journey_add_document()));
await userEvent.fill(page.getByRole('combobox'), 'Karl');
// The dropdown item includes the date ("Brief von Karl · …"), the list item does not
await expect.element(page.getByText(/Brief von Karl ·/)).toBeInTheDocument();
const option = page
.getByText(/Brief von Karl ·/)
.element()
.closest('li')!;
expect(option.getAttribute('aria-disabled')).toBe('true');
});
});
describe('JourneyEditor — unsaved warning banner', () => {
function triggerNavigationAttempt() {
const calls = vi.mocked(beforeNavigate).mock.calls;
if (calls.length === 0) return;
const [callback] = calls[calls.length - 1];
const cancel = vi.fn();
(callback as (nav: { cancel: () => void; to: { url: URL } | null }) => void)({
cancel,
to: { url: new URL('http://localhost/geschichten') }
});
return cancel;
}
it('banner is absent before any edit or navigation attempt', async () => {
render(JourneyEditor, defaultProps());
expect(page.getByText(m.admin_unsaved_warning()).query()).toBeNull();
});
it('banner appears when dirty and a navigation is attempted', async () => {
render(JourneyEditor, defaultProps());
// Mark dirty by editing the title
const titleInput = page.getByPlaceholder(/Titel/);
await titleInput.element().dispatchEvent(new InputEvent('input', { bubbles: true }));
// Simulate the user trying to navigate away
const cancel = triggerNavigationAttempt();
expect(cancel).toHaveBeenCalled();
await expect.element(page.getByText(m.admin_unsaved_warning())).toBeInTheDocument();
});
it('banner stays after a failed save (clearOnSuccess not called when onSubmit throws)', async () => {
const onSubmit = vi.fn().mockRejectedValue(new Error('server error'));
render(
JourneyEditor,
defaultProps({
onSubmit,
geschichte: makeGeschichte({
title: 'Titel',
items: [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }]
})
})
);
// Mark dirty
const titleInput = page.getByPlaceholder(/Titel/);
await titleInput.element().dispatchEvent(new InputEvent('input', { bubbles: true }));
// Trigger navigation → banner appears
triggerNavigationAttempt();
await expect.element(page.getByText(m.admin_unsaved_warning())).toBeInTheDocument();
// Attempt save — onSubmit throws
await userEvent.click(page.getByRole('button', { name: m.geschichte_editor_save_draft() }));
// Banner must still be visible (isDirty was not cleared)
await vi.waitFor(() => {
expect(page.getByText(m.admin_unsaved_warning()).query()).toBeTruthy();
});
});
it('successful save clears the unsaved warning (navigation unblocked after onSubmit resolves)', async () => {
// Regression guard for clearOnSuccess(): without it, a curator who edits the
// title and saves successfully stays trapped — the page goto() gets cancelled
// by the still-armed guard and the banner appears after a *successful* save.
const onSubmit = vi.fn().mockResolvedValue(undefined);
render(JourneyEditor, defaultProps({ onSubmit }));
// Mark dirty
const titleInput = page.getByPlaceholder(/Titel/);
await titleInput.element().dispatchEvent(new InputEvent('input', { bubbles: true }));
// Dirty state blocks navigation
expect(triggerNavigationAttempt()).toHaveBeenCalled();
// Save succeeds
await userEvent.click(page.getByRole('button', { name: m.geschichte_editor_save_draft() }));
await vi.waitFor(() => expect(onSubmit).toHaveBeenCalled());
// Guard is disarmed again — navigation passes and no banner shows
await vi.waitFor(() => {
expect(triggerNavigationAttempt()).not.toHaveBeenCalled();
});
expect(page.getByText(m.admin_unsaved_warning()).query()).toBeNull();
});
it('item add does not arm the unsaved-changes guard (items persist immediately)', async () => {
mockCsrfFetch(() => ({ id: 'i-new', position: 10, note: 'Zwischentext' }));
render(JourneyEditor, defaultProps());
await userEvent.click(page.getByText(m.journey_add_interlude()));
await userEvent.fill(page.getByPlaceholder(m.journey_interlude_placeholder()), 'Zwischentext');
await userEvent.click(
page.getByRole('button', { name: m.journey_add_interlude_confirm(), exact: true })
);
// The new interlude row renders its note textarea once the POST resolved
await expect
.element(page.getByRole('textbox', { name: /Kuratoren-Notiz/ }))
.toBeInTheDocument();
// The item was persisted by its own POST — navigating away loses nothing
expect(triggerNavigationAttempt()).not.toHaveBeenCalled();
});
});
describe('JourneyEditor — selectedPersons marks dirty', () => {
function getNavCallback() {
const calls = vi.mocked(beforeNavigate).mock.calls;
const [callback] = calls[calls.length - 1];
return (cancel = vi.fn()) => {
(callback as (nav: { cancel: () => void; to: { url: URL } | null }) => void)({
cancel,
to: { url: new URL('http://localhost/geschichten') }
});
return cancel;
};
}
it('removing a person chip marks the editor dirty', async () => {
render(
JourneyEditor,
defaultProps({
geschichte: makeGeschichte({
persons: [{ id: 'p1', firstName: 'Anna', lastName: 'Schmidt' }]
})
})
);
// Confirm navigation is NOT blocked initially (clean state)
const triggerNav = getNavCallback();
expect(triggerNav()).not.toHaveBeenCalled();
// Remove the person chip (aria-label = m.comp_multiselect_remove() = "Entfernen")
await userEvent.click(page.getByRole('button', { name: m.comp_multiselect_remove() }));
// After person removal, navigation should be blocked
await vi.waitFor(() => {
const cancel = triggerNav();
expect(cancel).toHaveBeenCalled();
});
});
});
describe('JourneyEditor — person chips from GeschichteView', () => {
it('renders person names in the sidebar chips (PersonView carries no displayName)', async () => {
render(
JourneyEditor,
defaultProps({
geschichte: makeGeschichte({
persons: [{ id: 'p1', firstName: 'Anna', lastName: 'Schmidt' }]
})
})
);
await expect.element(page.getByText('Anna Schmidt')).toBeInTheDocument();
});
});

View File

@@ -11,14 +11,8 @@ let { note }: Props = $props();
<div <div
role="note" role="note"
aria-label={m.journey_interlude_aria_label()} aria-label={m.journey_interlude_aria_label()}
class="my-2 border-l-4 border-journey-border bg-journey-tint px-4 py-3" class="my-4 rounded-r-sm border-l-2 border-journey-border bg-journey-tint py-2 pr-3 pl-3"
> >
<p
class="text-center font-sans text-xs tracking-widest text-journey uppercase"
aria-hidden="true"
>
</p>
<!-- plaintext — do NOT use {@html} here --> <!-- plaintext — do NOT use {@html} here -->
<p class="font-serif text-base leading-relaxed text-ink-2 italic">{note}</p> <p class="text-base leading-relaxed text-ink italic">{note}</p>
</div> </div>

View File

@@ -35,10 +35,21 @@ describe('JourneyInterlude', () => {
expect(el?.getAttribute('aria-label')).toBe(m.journey_interlude_aria_label()); expect(el?.getAttribute('aria-label')).toBe(m.journey_interlude_aria_label());
}); });
it('renders the section-break glyph ❦', async () => { it('uses mode-aware journey tokens, not raw orange utilities (#801)', async () => {
render(JourneyInterlude, { props: { note: 'Notiz' } }); render(JourneyInterlude, { props: { note: 'Notiz' } });
expect(document.body.textContent).toContain('❦'); const block = document.querySelector('[role="note"]');
expect(block!.className).toContain('bg-journey-tint');
expect(block!.className).toContain('border-journey-border');
expect(block!.className).not.toContain('bg-orange-50');
});
it('note text uses readable body size (text-base, #800)', async () => {
render(JourneyInterlude, { props: { note: 'Notiz' } });
const text = document.querySelector('[role="note"] p');
expect(text!.className).toContain('text-base');
expect(text!.className).not.toContain('text-xs');
}); });
it('XSS: note is rendered as plaintext — injected payload does not execute', async () => { it('XSS: note is rendered as plaintext — injected payload does not execute', async () => {

View File

@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import { formatDate } from '$lib/shared/utils/date'; import { formatDate } from '$lib/shared/utils/date';
import { formatDocumentMetaLine } from './utils';
import type { components } from '$lib/generated/api'; import type { components } from '$lib/generated/api';
type JourneyItemView = components['schemas']['JourneyItemView']; type JourneyItemView = components['schemas']['JourneyItemView'];
@@ -14,7 +15,8 @@ let { item }: Props = $props();
// Safe: JourneyReader filters out items where document === null before rendering this component. // Safe: JourneyReader filters out items where document === null before rendering this component.
const doc = $derived(item.document!); const doc = $derived(item.document!);
const formattedDate = $derived(doc.documentDate ? formatDate(doc.documentDate, 'short') : null); const formattedDate = $derived(doc.documentDate ? formatDate(doc.documentDate, 'short') : null);
const ariaLabel = $derived( const metaLine = $derived(formatDocumentMetaLine(doc));
const openAriaLabel = $derived(
formattedDate formattedDate
? m.journey_item_open_aria({ date: formattedDate }) ? m.journey_item_open_aria({ date: formattedDate })
: m.journey_item_open_aria_undated() : m.journey_item_open_aria_undated()
@@ -22,22 +24,43 @@ const ariaLabel = $derived(
const hasNote = $derived(item.note != null && item.note.trim().length > 0); const hasNote = $derived(item.note != null && item.note.trim().length > 0);
</script> </script>
<a <div class="mb-3">
href="/documents/{doc.id}" <div class="rounded-sm border border-line bg-surface p-3">
aria-label={ariaLabel} <!-- plaintext — do NOT use {@html} here -->
style="display: flex; min-height: 44px; flex-direction: column" <p class="mb-0.5 font-serif text-base leading-snug text-ink">{doc.title}</p>
class="flex min-h-[44px] flex-col gap-1 rounded border border-line bg-surface px-4 py-3 font-serif text-base text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring" {#if metaLine}
> <p class="mb-2 text-sm text-ink-3">{metaLine}</p>
<span class="font-bold">{doc.title}</span> {/if}
{#if formattedDate} <a
<span class="font-sans text-sm text-ink-3">{formattedDate}</span> href="/documents/{doc.id}"
{/if} aria-label={openAriaLabel}
</a> class="-my-2 inline-flex min-h-[44px] items-center gap-1 text-sm font-semibold text-ink hover:text-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>
{#if hasNote} <svg class="h-3 w-3 shrink-0" viewBox="0 0 10 12" fill="none">
<!-- plaintext — do NOT use {@html} here --> <rect x="1" y="1" width="8" height="10" rx="1" stroke="currentColor" stroke-width="1" />
<p class="mt-1 flex items-baseline gap-1 font-sans text-sm text-ink-3"> <path
<span aria-hidden="true"></span> d="M3 4h4M3 6.5h4M3 9h2"
{item.note} stroke="currentColor"
</p> stroke-width=".7"
{/if} stroke-linecap="round"
/>
</svg>
{m.journey_item_open()}
<svg class="h-3 w-3 shrink-0" viewBox="0 0 10 10" fill="none">
<path
d="M4 2l4 3-4 3"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</a>
{#if hasNote}
<!-- plaintext — do NOT use {@html} here -->
<div class="mt-3 rounded-r-sm border-l-2 border-brand-mint bg-muted py-1.5 pr-2 pl-3">
<p class="text-base leading-relaxed text-ink-2 italic">{item.note}</p>
</div>
{/if}
</div>
</div>

View File

@@ -1,6 +1,7 @@
import { describe, it, expect, afterEach } from 'vitest'; import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte'; import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser'; import { page } from 'vitest/browser';
import { m } from '$lib/paraglide/messages.js';
import type { components } from '$lib/generated/api'; import type { components } from '$lib/generated/api';
const { default: JourneyItemCard } = await import('./JourneyItemCard.svelte'); const { default: JourneyItemCard } = await import('./JourneyItemCard.svelte');
@@ -22,7 +23,8 @@ const baseItem = (overrides: Partial<JourneyItemView> = {}): JourneyItemView =>
id: 'd1', id: 'd1',
title: 'Brief an Helene', title: 'Brief an Helene',
documentDate: '1923-05-15', documentDate: '1923-05-15',
datePrecision: 'FULL' datePrecision: 'DAY',
receiverCount: 0
}, },
...overrides ...overrides
}); });
@@ -34,46 +36,56 @@ describe('JourneyItemCard', () => {
await expect.element(page.getByText('Brief an Helene')).toBeVisible(); await expect.element(page.getByText('Brief an Helene')).toBeVisible();
}); });
it('renders the document date when documentDate is present', async () => { it('renders the document date in the meta line when documentDate is present', async () => {
render(JourneyItemCard, { props: { item: baseItem() } }); render(JourneyItemCard, { props: { item: baseItem() } });
await expect.element(page.getByText(/1923/)).toBeVisible(); await expect.element(page.getByText(/1923/)).toBeVisible();
}); });
it('whole card is a single <a> element', async () => { it('"Brief öffnen" link points to /documents/:id', async () => {
render(JourneyItemCard, { props: { item: baseItem() } }); render(JourneyItemCard, { props: { item: baseItem() } });
const link = document.querySelector('a'); const link = page.getByRole('link', { name: /öffnen/i });
expect(link).not.toBeNull(); await expect.element(link).toBeInTheDocument();
expect(link?.href).toContain('/documents/d1'); const el = await link.element();
expect(el.getAttribute('href')).toContain('/documents/d1');
}); });
it('link has dated aria-label when documentDate is present', async () => { it('"Brief öffnen" link meets the 44px touch-target floor', async () => {
// Primary tap action of the phone read path — WCAG 2.5.5 / house rule.
render(JourneyItemCard, { props: { item: baseItem() } }); render(JourneyItemCard, { props: { item: baseItem() } });
const link = document.querySelector('a'); const link = page.getByRole('link', { name: /öffnen/i });
expect(link?.getAttribute('aria-label')).toContain('Brief'); await expect.element(link).toBeInTheDocument();
expect(link?.getAttribute('aria-label')).toContain('1923'); const height = link.element().getBoundingClientRect().height;
expect(height).toBeGreaterThanOrEqual(44);
}); });
it('link has undated aria-label when documentDate is absent', async () => { it('"Brief öffnen" link has dated aria-label when documentDate is present', async () => {
render(JourneyItemCard, { props: { item: baseItem() } });
const link = page.getByRole('link', { name: /1923/i });
await expect.element(link).toBeInTheDocument();
});
it('"Brief öffnen" link has undated aria-label when documentDate is absent', async () => {
render(JourneyItemCard, { render(JourneyItemCard, {
props: { props: {
item: baseItem({ item: baseItem({
document: { id: 'd2', title: 'Ohne Datum', datePrecision: 'NONE' } document: { id: 'd2', title: 'Ohne Datum', datePrecision: 'UNKNOWN', receiverCount: 0 }
}) })
} }
}); });
const link = document.querySelector('a'); const link = page.getByRole('link', { name: m.journey_item_open_aria_undated() });
expect(link?.getAttribute('aria-label')).toBe('Brief öffnen'); await expect.element(link).toBeInTheDocument();
}); });
it('omits date text when documentDate is absent', async () => { it('omits date from meta line when documentDate is absent', async () => {
render(JourneyItemCard, { render(JourneyItemCard, {
props: { props: {
item: baseItem({ item: baseItem({
document: { id: 'd2', title: 'Ohne Datum', datePrecision: 'NONE' } document: { id: 'd2', title: 'Ohne Datum', datePrecision: 'UNKNOWN', receiverCount: 0 }
}) })
} }
}); });
@@ -81,35 +93,87 @@ describe('JourneyItemCard', () => {
await expect.element(page.getByText(/1923/)).not.toBeInTheDocument(); await expect.element(page.getByText(/1923/)).not.toBeInTheDocument();
}); });
it('renders ✎ glyph and note text when note is present', async () => { it('renders sender→receiver in meta line when both are present', async () => {
render(JourneyItemCard, {
props: {
item: baseItem({
document: {
id: 'd1',
title: 'Brief an Helene',
documentDate: '1923-05-15',
datePrecision: 'DAY',
senderName: 'Franz Raddatz',
receiverName: 'Emma Müller',
receiverCount: 1
}
})
}
});
await expect.element(page.getByText(/Franz Raddatz/)).toBeVisible();
await expect.element(page.getByText(/Emma Müller/)).toBeVisible();
});
it('renders note as annotation block when note is present', async () => {
render(JourneyItemCard, { props: { item: baseItem({ note: 'Ein wichtiger Brief' }) } }); render(JourneyItemCard, { props: { item: baseItem({ note: 'Ein wichtiger Brief' }) } });
expect(document.body.textContent).toContain('✎');
await expect.element(page.getByText('Ein wichtiger Brief')).toBeVisible(); await expect.element(page.getByText('Ein wichtiger Brief')).toBeVisible();
}); });
it('annotation block uses the brand mint accent border (#798)', async () => {
render(JourneyItemCard, { props: { item: baseItem({ note: 'Ein wichtiger Brief' }) } });
const note = document.querySelector('[class*="border-l-2"]');
expect(note).not.toBeNull();
expect(note!.className).toContain('border-brand-mint');
});
it('card uses the surface token, not bg-white, so dark mode remaps it', async () => {
render(JourneyItemCard, { props: { item: baseItem() } });
const card = document.querySelector('[class*="border-line"]');
expect(card).not.toBeNull();
expect(card!.className).toContain('bg-surface');
expect(card!.className).not.toContain('bg-white');
});
it('annotation block is tinted with bg-muted to stand off the white card', async () => {
render(JourneyItemCard, { props: { item: baseItem({ note: 'Ein wichtiger Brief' }) } });
const note = document.querySelector('[class*="border-l-2"]');
expect(note!.className).toContain('bg-muted');
});
it('reading text sizes meet the accessibility floor (#800)', async () => {
render(JourneyItemCard, { props: { item: baseItem({ note: 'Ein wichtiger Brief' }) } });
const title = page.getByText('Brief an Helene');
expect((await title.element()).className).toContain('text-base');
const link = await page.getByRole('link', { name: /öffnen/i }).element();
expect(link.className).toContain('text-sm');
expect(link.className).not.toContain('text-xs');
const noteText = document.querySelector('[class*="border-l-2"] p');
expect(noteText!.className).toContain('text-base');
expect(noteText!.className).not.toContain('text-xs');
});
it('omits annotation block when note is blank or whitespace', async () => { it('omits annotation block when note is blank or whitespace', async () => {
render(JourneyItemCard, { props: { item: baseItem({ note: ' ' }) } }); render(JourneyItemCard, { props: { item: baseItem({ note: ' ' }) } });
expect(document.body.textContent).not.toContain('✎'); await expect.element(page.getByText(/ {3}/)).not.toBeInTheDocument();
}); });
it('omits annotation block when note is absent', async () => { it('omits annotation block when note is absent', async () => {
render(JourneyItemCard, { props: { item: baseItem({ note: undefined }) } }); render(JourneyItemCard, { props: { item: baseItem({ note: undefined }) } });
expect(document.body.textContent).not.toContain('✎'); const notes = document.querySelectorAll('[class*="border-mint"]');
}); expect(notes.length).toBe(0);
it('link meets 44px touch-target minimum height', async () => {
render(JourneyItemCard, { props: { item: baseItem() } });
const link = document.querySelector('a');
const rect = link?.getBoundingClientRect();
expect(rect?.height).toBeGreaterThanOrEqual(44);
}); });
it('XSS: note is rendered as plaintext — injected payload does not execute', async () => { it('XSS: note is rendered as plaintext — injected payload does not execute', async () => {
// Note uses Svelte text interpolation ({note}), NOT {@html}. // Note uses Svelte text interpolation ({item.note}), NOT {@html}.
render(JourneyItemCard, { render(JourneyItemCard, {
props: { props: {
item: baseItem({ item: baseItem({

View File

@@ -0,0 +1,268 @@
<script lang="ts">
import { tick } from 'svelte';
import type { components } from '$lib/generated/api';
import { m } from '$lib/paraglide/messages.js';
import { formatDocumentMetaLine } from './utils';
type JourneyItemView = components['schemas']['JourneyItemView'];
interface Props {
item: JourneyItemView;
index: number;
total: number;
pendingRemove?: boolean;
onMoveUp: () => void;
onMoveDown: () => void;
onRemove: () => void;
onNotePatch: (note: string | null) => Promise<void>;
}
let {
item,
index,
total,
pendingRemove = false,
onMoveUp,
onMoveDown,
onRemove,
onNotePatch
}: Props = $props();
const isInterlude = $derived(!item.document);
const itemTitle = $derived(item.document?.title ?? m.journey_interlude_label());
// Spec LE-2 "Briefmeta": date · von X an Y disambiguates near-identical titles.
const metaLine = $derived(item.document ? formatDocumentMetaLine(item.document) : '');
const needsConfirmOnRemove = $derived(!!item.note);
let rootEl: HTMLElement | null = $state(null);
let showNote = $state(!!item.note);
let noteDraft = $state(item.note ?? '');
let noteSaving = $state(false);
let noteError = $state('');
let showRemoveConfirm = $state(false);
async function handleNoteBlur() {
if (noteSaving) return;
const normalizedDraft = noteDraft.trim().length === 0 ? null : noteDraft;
// '' and undefined both mean "no note" — never PATCH a no-op.
if (normalizedDraft === (item.note ?? null)) {
// Opened "Notiz hinzufügen" and blurred without typing → collapse again.
if (!isInterlude && normalizedDraft === null) showNote = false;
return;
}
if (isInterlude && normalizedDraft === null) {
// Interludes must keep a note — restore the draft so the UI doesn't show
// an emptied text that the server still holds.
noteDraft = item.note ?? '';
return;
}
noteSaving = true;
noteError = '';
try {
await onNotePatch(normalizedDraft);
// Clearing an existing note collapses the textarea after the PATCH lands.
if (normalizedDraft === null) showNote = false;
} catch (e) {
noteError = e instanceof Error && e.message ? e.message : m.journey_note_error();
} finally {
noteSaving = false;
}
}
async function handleNoteRemove() {
const prevDraft = noteDraft;
const prevShowNote = showNote;
noteDraft = '';
showNote = false;
noteError = '';
try {
await onNotePatch(null);
} catch (e) {
noteDraft = prevDraft;
showNote = prevShowNote;
noteError = e instanceof Error && e.message ? e.message : m.journey_note_error();
}
}
async function handleNoteOpen() {
showNote = true;
// Spec LE-3: focus moves into the revealed textarea.
await tick();
rootEl?.querySelector<HTMLTextAreaElement>('textarea')?.focus();
}
async function handleRemoveClick() {
if (needsConfirmOnRemove) {
showRemoveConfirm = true;
await tick();
rootEl?.querySelector<HTMLElement>('[data-remove-confirm-cancel]')?.focus();
} else {
onRemove();
}
}
function handleRemoveConfirm() {
showRemoveConfirm = false;
onRemove();
}
async function handleRemoveCancel() {
showRemoveConfirm = false;
await tick();
rootEl?.querySelector<HTMLElement>('[data-remove-btn]')?.focus();
}
</script>
<div
bind:this={rootEl}
data-block-id={item.id}
class={[
'flex min-w-0 flex-col rounded border transition-colors',
pendingRemove ? 'opacity-60' : '',
isInterlude
? 'border-l-4 border-line border-l-interlude-border bg-interlude-bg'
: 'border-line bg-surface'
].join(' ')}
>
<div class="flex min-w-0 items-center gap-1 px-2 py-1">
<!-- Drag handle (desktop, pointer-only — keyboard users reorder via the move buttons) -->
<button
type="button"
data-drag-handle
tabindex="-1"
aria-hidden="true"
class="hidden shrink-0 cursor-grab items-center justify-center self-center text-ink-3 transition-colors hover:text-ink active:cursor-grabbing md:flex"
style="min-height: 44px; min-width: 44px;"
>
</button>
<!-- Move up/down (mobile + always visible) -->
<div class="flex shrink-0 flex-col self-start">
<button
type="button"
data-move-up
onclick={onMoveUp}
disabled={index === 0}
aria-label={m.journey_move_up({ title: itemTitle })}
class="flex min-h-[44px] min-w-[44px] items-center justify-center rounded text-ink-3 transition-colors hover:bg-muted hover:text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:opacity-20"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 15l7-7 7 7" />
</svg>
</button>
<button
type="button"
onclick={onMoveDown}
disabled={index === total - 1}
aria-label={m.journey_move_down({ title: itemTitle })}
class="flex min-h-[44px] min-w-[44px] items-center justify-center rounded text-ink-3 transition-colors hover:bg-muted hover:text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:opacity-20"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
</div>
<!-- Content (title + note inline) -->
<div class="min-w-0 flex-1 py-1 break-words">
{#if isInterlude}
<span class="font-sans text-xs font-bold tracking-widest text-interlude-label uppercase">
{m.journey_interlude_label()}
</span>
{:else}
<span class="font-sans text-xs text-ink-3">{index + 1}.</span>
<span class="ml-1 font-serif text-sm text-ink">{item.document!.title}</span>
{#if metaLine}
<p class="mt-0.5 font-sans text-xs text-ink-3">{metaLine}</p>
{/if}
{/if}
{#if showNote}
<div class="mt-2">
<textarea
aria-label={m.journey_note_aria_label({ title: itemTitle })}
bind:value={noteDraft}
onblur={handleNoteBlur}
maxlength={2000}
rows={2}
class="block w-full resize-y rounded border border-line bg-transparent px-2 py-1.5 font-sans text-sm text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
></textarea>
<div class="mt-1 flex items-center justify-between gap-2">
<p class="font-sans text-xs text-ink-3">{m.journey_note_save_hint()}</p>
{#if !isInterlude}
<button
type="button"
onclick={handleNoteRemove}
class="inline-flex min-h-[44px] items-center font-sans text-xs text-ink-3 underline hover:text-danger focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>
{m.journey_note_remove()}
</button>
{/if}
</div>
{#if noteError}
<p class="mt-1 font-sans text-xs text-danger" role="alert">{noteError}</p>
{/if}
</div>
{:else if !isInterlude}
<button
type="button"
onclick={handleNoteOpen}
aria-expanded={showNote}
class="mt-0.5 inline-flex min-h-[44px] items-center font-sans text-xs text-ink-3 underline hover:text-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>
{m.journey_note_add()}
</button>
{/if}
</div>
<!-- Remove button / confirm / pending -->
<div class="shrink-0 self-start">
{#if pendingRemove}
<span class="inline-flex min-h-[44px] items-center font-sans text-xs text-ink-3 italic">
{m.journey_item_pending_remove()}
</span>
{:else if showRemoveConfirm}
<div role="group" aria-label={m.journey_remove_confirm()} class="flex items-center gap-2">
<span class="font-sans text-xs text-ink-2">{m.journey_remove_confirm()}</span>
<button
type="button"
onclick={handleRemoveConfirm}
onkeydown={(e) => e.key === 'Escape' && handleRemoveCancel()}
class="inline-flex min-h-[44px] items-center rounded bg-danger px-3 font-sans text-xs font-medium text-white hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>
{m.journey_remove_confirm_yes()}
</button>
<button
type="button"
data-remove-confirm-cancel
onclick={handleRemoveCancel}
onkeydown={(e) => e.key === 'Escape' && handleRemoveCancel()}
class="inline-flex min-h-[44px] items-center rounded border border-line px-3 font-sans text-xs font-medium text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>
{m.journey_remove_confirm_cancel()}
</button>
</div>
{:else}
<button
type="button"
data-remove-btn
onclick={handleRemoveClick}
aria-label={m.journey_remove_item_aria({ title: itemTitle })}
class="-m-1 inline-flex min-h-[44px] min-w-[44px] items-center justify-center rounded p-3 text-ink-3 hover:text-danger focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>
<svg
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
{/if}
</div>
</div>
</div>

View File

@@ -0,0 +1,354 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page, userEvent } from 'vitest/browser';
import { m } from '$lib/paraglide/messages.js';
import JourneyItemRow from './JourneyItemRow.svelte';
const docItem = (overrides: Partial<{ note: string }> = {}) => ({
id: 'item-1',
position: 0,
document: {
id: 'doc-1',
title: 'Brief von Karl',
datePrecision: 'DAY' as const,
receiverCount: 0
},
...overrides
});
const interludeItem = (note = 'Reise nach Wien') => ({
id: 'item-2',
position: 1,
note
});
const defaultProps = (overrides = {}) => ({
index: 0,
total: 3,
onMoveUp: vi.fn(),
onMoveDown: vi.fn(),
onRemove: vi.fn(),
onNotePatch: vi.fn().mockResolvedValue(undefined),
...overrides
});
afterEach(() => cleanup());
describe('JourneyItemRow — interlude label', () => {
it('shows "Zwischentext" (not the add-button label) on interlude rows', async () => {
render(JourneyItemRow, { item: interludeItem(), ...defaultProps() });
await expect.element(page.getByText(m.journey_interlude_label())).toBeInTheDocument();
await expect.element(page.getByText(m.journey_add_interlude())).not.toBeInTheDocument();
});
it('uses "Zwischentext" in the move button aria-labels', async () => {
render(JourneyItemRow, { item: interludeItem(), ...defaultProps({ index: 1 }) });
await expect
.element(
page.getByRole('button', {
name: m.journey_move_up({ title: m.journey_interlude_label() })
})
)
.toBeInTheDocument();
});
});
describe('JourneyItemRow — note textarea', () => {
it('opens note textarea on "Notiz hinzufügen" click', async () => {
render(JourneyItemRow, { item: docItem(), ...defaultProps() });
await userEvent.click(page.getByText(m.journey_note_add()));
await expect
.element(page.getByRole('textbox', { name: /Kuratoren-Notiz für Brief von Karl/ }))
.toBeInTheDocument();
});
it('blur without typing does not call onNotePatch and collapses the textarea', async () => {
// '' (untouched draft) and undefined (no note) both mean "no note" — a
// spurious PATCH {note: null} must not fire, and the empty textarea closes.
const onNotePatch = vi.fn().mockResolvedValue(undefined);
render(JourneyItemRow, { item: docItem(), ...defaultProps({ onNotePatch }) });
await userEvent.click(page.getByText(m.journey_note_add()));
const textarea = page.getByRole('textbox', { name: /Kuratoren-Notiz für Brief von Karl/ });
await textarea.element().dispatchEvent(new FocusEvent('blur'));
expect(onNotePatch).not.toHaveBeenCalled();
await expect.element(page.getByText(m.journey_note_add())).toBeInTheDocument();
});
it('moves focus into the textarea when "Notiz hinzufügen" opens it', async () => {
render(JourneyItemRow, { item: docItem(), ...defaultProps() });
const toggle = page.getByText(m.journey_note_add());
expect(toggle.element().getAttribute('aria-expanded')).toBe('false');
await userEvent.click(toggle);
await vi.waitFor(() => {
const textarea = page
.getByRole('textbox', { name: /Kuratoren-Notiz für Brief von Karl/ })
.element();
expect(document.activeElement).toBe(textarea);
});
});
it('calls onNotePatch on textarea blur with non-empty value', async () => {
const onNotePatch = vi.fn().mockResolvedValue(undefined);
render(JourneyItemRow, { item: docItem(), ...defaultProps({ onNotePatch }) });
await userEvent.click(page.getByText(m.journey_note_add()));
const textarea = page.getByRole('textbox', { name: /Kuratoren-Notiz für Brief von Karl/ });
await userEvent.fill(textarea, 'Eine neue Notiz');
await textarea.element().dispatchEvent(new FocusEvent('blur'));
expect(onNotePatch).toHaveBeenCalledWith('Eine neue Notiz');
});
it('limits the note textarea to 2000 characters', async () => {
render(JourneyItemRow, { item: docItem({ note: 'Notiz' }), ...defaultProps() });
const textarea = page.getByRole('textbox', { name: /Kuratoren-Notiz/ });
await expect.element(textarea).toHaveAttribute('maxlength', '2000');
});
});
describe('JourneyItemRow — note error state', () => {
it('shows role=alert error message when onNotePatch rejects', async () => {
const onNotePatch = vi.fn().mockRejectedValue(new Error('server error'));
render(JourneyItemRow, { item: docItem(), ...defaultProps({ onNotePatch }) });
await userEvent.click(page.getByText(m.journey_note_add()));
const textarea = page.getByRole('textbox', { name: /Kuratoren-Notiz für Brief von Karl/ });
await userEvent.fill(textarea, 'Eine Notiz');
await textarea.element().dispatchEvent(new FocusEvent('blur'));
await expect.element(page.getByRole('alert')).toBeInTheDocument();
});
});
describe('JourneyItemRow — note remove error state', () => {
it('restores note and shows error when onNotePatch rejects during remove', async () => {
const onNotePatch = vi.fn().mockRejectedValue(new Error('server error'));
render(JourneyItemRow, {
item: docItem({ note: 'keep me' }),
...defaultProps({ onNotePatch })
});
await userEvent.click(page.getByText(m.journey_note_remove()));
// textarea should be visible again (showNote restored)
await expect
.element(page.getByRole('textbox', { name: /Kuratoren-Notiz/ }))
.toBeInTheDocument();
// error alert should be shown
await expect.element(page.getByRole('alert')).toBeInTheDocument();
});
});
describe('JourneyItemRow — interlude rules', () => {
it('does not show "Notiz entfernen" for interlude items', async () => {
render(JourneyItemRow, { item: interludeItem(), ...defaultProps() });
// Note section should be visible (interlude always shows note)
await expect
.element(page.getByRole('textbox', { name: /Kuratoren-Notiz/ }))
.toBeInTheDocument();
// But "Notiz entfernen" must be absent
await expect.element(page.getByText(m.journey_note_remove())).not.toBeInTheDocument();
});
it('blocks saving empty text on interlude note blur', async () => {
const onNotePatch = vi.fn().mockResolvedValue(undefined);
render(JourneyItemRow, {
item: interludeItem('original text'),
...defaultProps({ onNotePatch })
});
const textarea = page.getByRole('textbox', { name: /Kuratoren-Notiz/ });
await userEvent.clear(textarea);
await textarea.element().dispatchEvent(new FocusEvent('blur'));
expect(onNotePatch).not.toHaveBeenCalled();
});
it('restores the original note text after a blocked empty-clear blur', async () => {
render(JourneyItemRow, {
item: interludeItem('original text'),
...defaultProps()
});
const textarea = page.getByRole('textbox', { name: /Kuratoren-Notiz/ });
await userEvent.clear(textarea);
await textarea.element().dispatchEvent(new FocusEvent('blur'));
await expect.element(textarea).toHaveValue('original text');
});
});
describe('JourneyItemRow — remove confirm', () => {
it('shows inline confirm when removing a document item that has a note', async () => {
render(JourneyItemRow, {
item: docItem({ note: 'Wichtige Notiz' }),
...defaultProps()
});
// Click remove (x button)
await userEvent.click(
page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief von Karl' }) })
);
await expect.element(page.getByText(m.journey_remove_confirm())).toBeInTheDocument();
await expect
.element(page.getByRole('button', { name: m.journey_remove_confirm_yes() }))
.toBeInTheDocument();
});
it('clicking Bestätigen invokes onRemove (destructive path)', async () => {
const onRemove = vi.fn();
render(JourneyItemRow, {
item: docItem({ note: 'Wichtige Notiz' }),
...defaultProps({ onRemove })
});
await userEvent.click(
page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief von Karl' }) })
);
await userEvent.click(page.getByRole('button', { name: m.journey_remove_confirm_yes() }));
expect(onRemove).toHaveBeenCalledTimes(1);
});
it('confirm cancel restores remove button without calling onRemove', async () => {
const onRemove = vi.fn();
render(JourneyItemRow, {
item: docItem({ note: 'Notiz' }),
...defaultProps({ onRemove })
});
await userEvent.click(
page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief von Karl' }) })
);
await userEvent.click(page.getByRole('button', { name: m.journey_remove_confirm_cancel() }));
expect(onRemove).not.toHaveBeenCalled();
// The remove button should be back
await expect
.element(
page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief von Karl' }) })
)
.toBeInTheDocument();
});
it('confirm cancel returns keyboard focus to the row remove button', async () => {
render(JourneyItemRow, {
item: docItem({ note: 'Notiz' }),
...defaultProps()
});
await userEvent.click(
page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief von Karl' }) })
);
await userEvent.click(page.getByRole('button', { name: m.journey_remove_confirm_cancel() }));
await vi.waitFor(() => {
const removeBtn = page
.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief von Karl' }) })
.element();
expect(document.activeElement).toBe(removeBtn);
});
});
});
describe('JourneyItemRow — remove confirm a11y', () => {
it('confirm area is wrapped in role=group with an accessible label', async () => {
render(JourneyItemRow, {
item: docItem({ note: 'Wichtige Notiz' }),
...defaultProps()
});
await userEvent.click(
page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief von Karl' }) })
);
const group = document.querySelector('[role="group"]');
expect(group).toBeTruthy();
expect(group!.getAttribute('aria-label')).toBeTruthy();
});
it('keyboard focus moves to Cancel button when confirm appears', async () => {
render(JourneyItemRow, {
item: docItem({ note: 'Wichtige Notiz' }),
...defaultProps()
});
await userEvent.click(
page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief von Karl' }) })
);
await vi.waitFor(() => {
const cancelBtn = page
.getByRole('button', { name: m.journey_remove_confirm_cancel() })
.element();
expect(document.activeElement).toBe(cancelBtn);
});
});
it('pressing Escape while confirm is open hides confirm and refocuses remove button', async () => {
render(JourneyItemRow, {
item: docItem({ note: 'Wichtige Notiz' }),
...defaultProps()
});
await userEvent.click(
page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief von Karl' }) })
);
await vi.waitFor(() => {
const cancelBtn = page
.getByRole('button', { name: m.journey_remove_confirm_cancel() })
.element();
expect(document.activeElement).toBe(cancelBtn);
});
await userEvent.keyboard('{Escape}');
await vi.waitFor(() => {
const removeBtn = page
.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief von Karl' }) })
.element();
expect(document.activeElement).toBe(removeBtn);
});
await expect.element(page.getByText(m.journey_remove_confirm())).not.toBeInTheDocument();
});
});
describe('JourneyItemRow — pending remove state', () => {
it('renders dimmed with the pending text and without a remove button', async () => {
render(JourneyItemRow, {
item: docItem(),
...defaultProps({ pendingRemove: true })
});
await expect.element(page.getByText(m.journey_item_pending_remove())).toBeInTheDocument();
await expect
.element(
page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief von Karl' }) })
)
.not.toBeInTheDocument();
const root = document.querySelector('[data-block-id="item-1"]')!;
expect(root.className).toContain('opacity-60');
});
});
describe('JourneyItemRow — drag handle', () => {
it('is pointer-only: removed from tab order and hidden from the accessibility tree', async () => {
render(JourneyItemRow, { item: docItem(), ...defaultProps() });
const handle = document.querySelector('[data-drag-handle]')!;
expect(handle.getAttribute('tabindex')).toBe('-1');
expect(handle.getAttribute('aria-hidden')).toBe('true');
});
});

View File

@@ -9,11 +9,9 @@ type JourneyItemView = components['schemas']['JourneyItemView'];
interface Props { interface Props {
geschichte: GeschichteView; geschichte: GeschichteView;
canBlogWrite: boolean;
ondelete?: () => Promise<void>;
} }
let { geschichte: g, canBlogWrite, ondelete }: Props = $props(); let { geschichte: g }: Props = $props();
// Render intro only when body is a non-empty, non-whitespace string. // Render intro only when body is a non-empty, non-whitespace string.
const introText = $derived(g.body?.trim() ? g.body : null); const introText = $derived(g.body?.trim() ? g.body : null);
@@ -29,7 +27,11 @@ const validItems = $derived(
{#if introText} {#if introText}
<!-- plaintext — do NOT use {@html} here --> <!-- plaintext — do NOT use {@html} here -->
<p class="mb-8 font-serif text-base leading-relaxed text-ink-2 italic">{introText}</p> <p
class="mb-6 border-b border-dashed border-line-2 pb-4 font-serif text-lg leading-relaxed text-ink-2 italic"
>
{introText}
</p>
{/if} {/if}
{#if validItems.length === 0} {#if validItems.length === 0}
@@ -37,7 +39,7 @@ const validItems = $derived(
{m.journey_empty_state()} {m.journey_empty_state()}
</p> </p>
{:else} {:else}
<ol class="flex list-none flex-col gap-4"> <ol class="flex list-none flex-col">
{#each validItems as item (item.id)} {#each validItems as item (item.id)}
<li> <li>
{#if item.document != null} {#if item.document != null}
@@ -49,22 +51,3 @@ const validItems = $derived(
{/each} {/each}
</ol> </ol>
{/if} {/if}
<!-- Author actions -->
{#if 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={() => ondelete?.()}
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}

View File

@@ -1,6 +1,6 @@
import { describe, it, expect, vi, afterEach } from 'vitest'; import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte'; import { cleanup, render } from 'vitest-browser-svelte';
import { page, userEvent } from 'vitest/browser'; import { page } from 'vitest/browser';
import { createConfirmService, CONFIRM_KEY } from '$lib/shared/services/confirm.svelte.js'; import { createConfirmService, CONFIRM_KEY } from '$lib/shared/services/confirm.svelte.js';
import type { components } from '$lib/generated/api'; import type { components } from '$lib/generated/api';
@@ -33,7 +33,13 @@ const baseGeschichte = (overrides: Partial<GeschichteView> = {}): GeschichteView
const docItem = (id: string, title: string, position: number, note?: string): JourneyItemView => ({ const docItem = (id: string, title: string, position: number, note?: string): JourneyItemView => ({
id, id,
position, position,
document: { id: `d${id}`, title, datePrecision: 'FULL', documentDate: '1923-05-15' }, document: {
id: `d${id}`,
title,
datePrecision: 'DAY',
documentDate: '1923-05-15',
receiverCount: 0
},
note note
}); });
@@ -50,19 +56,27 @@ describe('JourneyReader', () => {
it('renders intro paragraph when body is non-empty', async () => { it('renders intro paragraph when body is non-empty', async () => {
render(JourneyReader, { render(JourneyReader, {
context: ctx(), context: ctx(),
props: { props: { geschichte: baseGeschichte({ body: 'Eine Reise durch die Geschichte.' }) }
geschichte: baseGeschichte({ body: 'Eine Reise durch die Geschichte.' }),
canBlogWrite: false
}
}); });
await expect.element(page.getByText('Eine Reise durch die Geschichte.')).toBeVisible(); await expect.element(page.getByText('Eine Reise durch die Geschichte.')).toBeVisible();
}); });
it('intro paragraph uses readable body size (text-lg, #800)', async () => {
render(JourneyReader, {
context: ctx(),
props: { geschichte: baseGeschichte({ body: 'Eine Reise durch die Geschichte.' }) }
});
const intro = document.querySelector('p');
expect(intro!.className).toContain('text-lg');
expect(intro!.className).not.toContain('text-sm');
});
it('omits intro paragraph when body is null', async () => { it('omits intro paragraph when body is null', async () => {
render(JourneyReader, { render(JourneyReader, {
context: ctx(), context: ctx(),
props: { geschichte: baseGeschichte({ body: undefined }), canBlogWrite: false } props: { geschichte: baseGeschichte({ body: undefined }) }
}); });
// Only empty state should render // Only empty state should render
@@ -72,7 +86,7 @@ describe('JourneyReader', () => {
it('omits intro paragraph when body is only whitespace', async () => { it('omits intro paragraph when body is only whitespace', async () => {
render(JourneyReader, { render(JourneyReader, {
context: ctx(), context: ctx(),
props: { geschichte: baseGeschichte({ body: ' ' }), canBlogWrite: false } props: { geschichte: baseGeschichte({ body: ' ' }) }
}); });
// Whitespace-only body must NOT produce a visible intro paragraph. // Whitespace-only body must NOT produce a visible intro paragraph.
@@ -85,7 +99,7 @@ describe('JourneyReader', () => {
it('renders empty-state message when items array is empty', async () => { it('renders empty-state message when items array is empty', async () => {
render(JourneyReader, { render(JourneyReader, {
context: ctx(), context: ctx(),
props: { geschichte: baseGeschichte({ items: [] }), canBlogWrite: false } props: { geschichte: baseGeschichte({ items: [] }) }
}); });
await expect.element(page.getByText('Diese Lesereise ist noch leer.')).toBeVisible(); await expect.element(page.getByText('Diese Lesereise ist noch leer.')).toBeVisible();
@@ -95,11 +109,7 @@ describe('JourneyReader', () => {
render(JourneyReader, { render(JourneyReader, {
context: ctx(), context: ctx(),
props: { props: {
geschichte: baseGeschichte({ geschichte: baseGeschichte({ body: 'Eine Einleitung.', items: [] })
body: 'Eine Einleitung.',
items: []
}),
canBlogWrite: false
} }
}); });
@@ -111,8 +121,7 @@ describe('JourneyReader', () => {
render(JourneyReader, { render(JourneyReader, {
context: ctx(), context: ctx(),
props: { props: {
geschichte: baseGeschichte({ items: [docItem('item1', 'Brief an Helene', 0)] }), geschichte: baseGeschichte({ items: [docItem('item1', 'Brief an Helene', 0)] })
canBlogWrite: false
} }
}); });
@@ -123,13 +132,11 @@ describe('JourneyReader', () => {
render(JourneyReader, { render(JourneyReader, {
context: ctx(), context: ctx(),
props: { props: {
geschichte: baseGeschichte({ items: [interludeItem('inter1', 'Eine Pause.', 0)] }), geschichte: baseGeschichte({ items: [interludeItem('inter1', 'Eine Pause.', 0)] })
canBlogWrite: false
} }
}); });
await expect.element(page.getByText('Eine Pause.')).toBeVisible(); await expect.element(page.getByText('Eine Pause.')).toBeVisible();
expect(document.body.textContent).toContain('❦');
}); });
it('omits items where document is null AND note is blank (dangling-item rule)', async () => { it('omits items where document is null AND note is blank (dangling-item rule)', async () => {
@@ -141,8 +148,7 @@ describe('JourneyReader', () => {
{ id: 'dangling', position: 0, document: undefined, note: ' ' }, { id: 'dangling', position: 0, document: undefined, note: ' ' },
docItem('item2', 'Echter Brief', 1) docItem('item2', 'Echter Brief', 1)
] ]
}), })
canBlogWrite: false
} }
}); });
@@ -151,22 +157,6 @@ describe('JourneyReader', () => {
await expect.element(page.getByText('Diese Lesereise ist noch leer.')).not.toBeInTheDocument(); await expect.element(page.getByText('Diese Lesereise ist noch leer.')).not.toBeInTheDocument();
}); });
it('clicking delete button calls ondelete prop', async () => {
const ondelete = vi.fn().mockResolvedValue(undefined);
render(JourneyReader, {
context: ctx(),
props: {
geschichte: baseGeschichte({ items: [docItem('i1', 'Brief', 0)] }),
canBlogWrite: true,
ondelete
}
});
await userEvent.click(page.getByRole('button', { name: /löschen/i }));
expect(ondelete).toHaveBeenCalledOnce();
});
it('XSS: Journey body is rendered as plaintext — injected payload does not execute', async () => { it('XSS: Journey body is rendered as plaintext — injected payload does not execute', async () => {
// JourneyReader uses Svelte text interpolation, NOT {@html}. // JourneyReader uses Svelte text interpolation, NOT {@html}.
render(JourneyReader, { render(JourneyReader, {
@@ -174,8 +164,7 @@ describe('JourneyReader', () => {
props: { props: {
geschichte: baseGeschichte({ geschichte: baseGeschichte({
body: '<img src=x onerror="window.__xss_journey=1">' body: '<img src=x onerror="window.__xss_journey=1">'
}), })
canBlogWrite: false
} }
}); });

View File

@@ -4,7 +4,7 @@ UI for family stories (Geschichte) and reading journeys (Lesereise): the rich-te
## What this domain owns ## What this domain owns
Components: `GeschichteEditor.svelte`, `GeschichtenCard.svelte`, `GeschichteListRow.svelte`, `StoryReader.svelte`, `JourneyReader.svelte`, `JourneyItemCard.svelte`, `JourneyInterlude.svelte`. Components: `GeschichteEditor.svelte`, `GeschichteSidebar.svelte`, `JourneyEditor.svelte`, `JourneyItemRow.svelte`, `JourneyAddBar.svelte`, `GeschichtenCard.svelte`, `GeschichteListRow.svelte`, `StoryReader.svelte`, `JourneyReader.svelte`, `JourneyItemCard.svelte`, `JourneyInterlude.svelte`.
Utilities: `utils.ts`. Utilities: `utils.ts`.
## What this domain does NOT own ## What this domain does NOT own
@@ -15,20 +15,25 @@ Utilities: `utils.ts`.
## Key components ## Key components
| Component | Used in | Notes | | Component | Used in | Notes |
| -------------------------- | -------------------------------------------- | ----------------------------------------------------------------------------------------- | | -------------------------- | -------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| `GeschichteEditor.svelte` | `/geschichten/new`, `/geschichten/[id]/edit` | Rich-text editor with person/document @-mentions and inline embeds | | `GeschichteEditor.svelte` | `/geschichten/new`, `/geschichten/[id]/edit` | Rich-text editor (TipTap) for STORY type; delegates sidebar to `GeschichteSidebar` |
| `GeschichtenCard.svelte` | Person-detail sidebar | Sidebar card showing the 3 most recent stories linked to a person — NOT the list page | | `GeschichteSidebar.svelte` | `GeschichteEditor`, `JourneyEditor` | Status badge + PersonMultiSelect sidebar; `<details>` mobile collapsibles with 44px touch targets |
| `GeschichteListRow.svelte` | `/geschichten` (list) | Row component for the list; shows JOURNEY type badge (`text-xs` orange pill) | | `JourneyEditor.svelte` | `/geschichten/[id]/edit` (JOURNEY branch) | Curator editing surface: title, intro textarea, ordered item list with drag/reorder, add bar, save/publish |
| `StoryReader.svelte` | `/geschichten/[id]` (STORY branch) | Renders sanitised rich-text body, persons section, documents section, and author actions | | `JourneyItemRow.svelte` | `JourneyEditor.svelte` | Item row: drag handle, move-up/down, note textarea (PATCH on blur), inline remove confirm |
| `JourneyReader.svelte` | `/geschichten/[id]` (JOURNEY branch) | Renders intro paragraph, ordered items list, empty-state; delegates to ItemCard/Interlude | | `JourneyAddBar.svelte` | `JourneyEditor.svelte` | Two add buttons: document picker (`DocumentPickerDropdown`) and interlude draft form |
| `JourneyItemCard.svelte` | `JourneyReader.svelte` | Whole-card `<a>` for a document item; dated/undated aria-label, ✎ annotation glyph | | `GeschichtenCard.svelte` | Person-detail sidebar | Sidebar card showing the 3 most recent stories linked to a person — NOT the list page |
| `JourneyInterlude.svelte` | `JourneyReader.svelte` | Typographic aside between letters; ❦ glyph, `aria-label="Kuratorennotiz"` | | `GeschichteListRow.svelte` | `/geschichten` (list) | Editorial list row: meta column (avatar, author, date, REISE badge), title + excerpt content column |
| `StoryReader.svelte` | `/geschichten/[id]` (STORY branch) | Renders sanitised rich-text body, persons section, documents section, and author actions |
| `JourneyReader.svelte` | `/geschichten/[id]` (JOURNEY branch) | Renders intro paragraph, ordered items list, empty-state; delegates to ItemCard/Interlude |
| `JourneyItemCard.svelte` | `JourneyReader.svelte` | Card per document item: title, meta line (date · von X an Y), "Brief öffnen →" link, mint-border note |
| `JourneyInterlude.svelte` | `JourneyReader.svelte` | Left-accent interlude box between letters (mode-aware tokens); `aria-label="Kuratorennotiz"` |
## utils.ts ## utils.ts
`formatAuthorName(author)` — joins `firstName + lastName`, falls back to `email` (for list/summary shapes). `formatAuthorName(author)` — joins `firstName + lastName`, falls back to the localized `person_unknown` key (for list/summary shapes; email is not exposed).
`formatAuthorDisplayName(author)` — returns `displayName` (for detail `AuthorView` shape). `formatAuthorDisplayName(author)` — returns `displayName`, localizing the server's `[Unbekannt]` fallback (for detail `AuthorView` shape).
`formatDocumentMetaLine(doc)``"12.07.1938 · von Franz an Emma"`; shared by `JourneyItemCard`, `JourneyItemRow`, and the story doc-reference cards.
`formatPublishedAt(publishedAt, style)` — wraps `formatDate` with null check; `style` is `'short'` (list) or `'long'` (detail). `formatPublishedAt(publishedAt, style)` — wraps `formatDate` with null check; `style` is `'short'` (list) or `'long'` (detail).
## Public list is PUBLISHED-only ## Public list is PUBLISHED-only

View File

@@ -1,20 +1,22 @@
<script lang="ts"> <script lang="ts">
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import { safeHtml } from '$lib/shared/utils/sanitize'; import { safeHtml } from '$lib/shared/utils/sanitize';
import { getInitials, personAvatarColor } from '$lib/person/personFormat';
import { formatDocumentMetaLine } from './utils';
import type { components } from '$lib/generated/api'; import type { components } from '$lib/generated/api';
type GeschichteView = components['schemas']['GeschichteView']; type GeschichteView = components['schemas']['GeschichteView'];
interface Props { interface Props {
geschichte: GeschichteView; geschichte: GeschichteView;
canBlogWrite: boolean;
ondelete?: () => Promise<void>;
} }
let { geschichte: g, canBlogWrite, ondelete }: Props = $props(); let { geschichte: g }: Props = $props();
const sanitized = $derived(safeHtml(g.body)); const sanitized = $derived(safeHtml(g.body));
const documentItems = $derived(g.items.filter((i) => i.document));
function personName(p: { firstName?: string; lastName?: string }): string { function personName(p: { firstName?: string; lastName?: string }): string {
return [p.firstName, p.lastName].filter(Boolean).join(' ').trim(); return [p.firstName, p.lastName].filter(Boolean).join(' ').trim();
} }
@@ -47,8 +49,15 @@ function personName(p: { firstName?: string; lastName?: string }): string {
<a <a
href="/persons/{p.id}" href="/persons/{p.id}"
style="display: inline-flex; min-height: 44px" style="display: inline-flex; min-height: 44px"
class="inline-flex min-h-[44px] items-center rounded-full bg-muted px-3 py-2.5 font-sans text-sm text-ink hover:bg-accent-bg focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring" class="inline-flex min-h-[44px] items-center gap-2 rounded-full border border-line bg-surface px-3 py-1.5 font-sans text-sm font-medium text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
> >
<span
aria-hidden="true"
class="flex h-5 w-5 shrink-0 items-center justify-center rounded-full font-sans text-[8px] font-bold text-white"
style="background-color: {personAvatarColor(p.id)}"
>
{getInitials(personName(p))}
</span>
{personName(p)} {personName(p)}
</a> </a>
</li> </li>
@@ -58,19 +67,50 @@ function personName(p: { firstName?: string; lastName?: string }): string {
{/if} {/if}
<!-- Dokumente (JourneyItems) --> <!-- Dokumente (JourneyItems) -->
{#if g.items && g.items.some((i) => i.document)} {#if documentItems.length > 0}
<section class="mt-8 border-t border-line pt-6"> <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"> <h2 class="mb-3 font-sans text-xs font-bold tracking-widest text-ink-2 uppercase">
{m.geschichten_documents_section()} {m.geschichten_documents_section()}
</h2> </h2>
<ul class="flex flex-col gap-2"> <ul class="flex flex-col gap-2">
{#each g.items.filter((i) => i.document) as item (item.id)} {#each documentItems as item (item.id)}
<li> <li>
<a <a
href="/documents/{item.document!.id}" href="/documents/{item.document!.id}"
class="block rounded border border-line bg-surface px-4 py-3 font-serif text-base text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring" class="flex items-start gap-3 rounded-sm border border-line bg-surface p-3 transition-shadow hover:shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
> >
{m.geschichten_document_link_placeholder()} <span
aria-hidden="true"
class="flex h-9 w-9 shrink-0 items-center justify-center rounded bg-muted"
>
<svg class="h-4 w-4 text-ink-3" viewBox="0 0 10 12" fill="none">
<rect
x="1"
y="1"
width="8"
height="10"
rx="1"
stroke="currentColor"
stroke-width="1"
/>
<path
d="M3 4h4M3 6.5h4M3 9h2"
stroke="currentColor"
stroke-width=".8"
stroke-linecap="round"
/>
</svg>
</span>
<span class="min-w-0">
<span class="block font-sans text-sm leading-snug font-semibold text-ink">
{item.document!.title}
</span>
{#if formatDocumentMetaLine(item.document!)}
<span class="block font-sans text-xs text-ink-3">
{formatDocumentMetaLine(item.document!)}
</span>
{/if}
</span>
</a> </a>
{#if item.note} {#if item.note}
<!-- plaintext — do NOT use {@html} here --> <!-- plaintext — do NOT use {@html} here -->
@@ -81,22 +121,3 @@ function personName(p: { firstName?: string; lastName?: string }): string {
</ul> </ul>
</section> </section>
{/if} {/if}
<!-- Author actions -->
{#if 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={() => ondelete?.()}
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}

View File

@@ -1,7 +1,6 @@
import { describe, it, expect, vi, afterEach } from 'vitest'; import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte'; import { cleanup, render } from 'vitest-browser-svelte';
import { page, userEvent } from 'vitest/browser'; import { page } from 'vitest/browser';
import { createConfirmService, CONFIRM_KEY } from '$lib/shared/services/confirm.svelte.js';
import type { components } from '$lib/generated/api'; import type { components } from '$lib/generated/api';
const { default: StoryReader } = await import('./StoryReader.svelte'); const { default: StoryReader } = await import('./StoryReader.svelte');
@@ -24,38 +23,28 @@ const baseGeschichte = (overrides: Partial<GeschichteView> = {}): GeschichteView
...overrides ...overrides
}); });
const ctx = () => new Map([[CONFIRM_KEY, createConfirmService()]]);
describe('StoryReader', () => { describe('StoryReader', () => {
it('renders body HTML content', async () => { it('renders body HTML content', async () => {
render(StoryReader, { render(StoryReader, { props: { geschichte: baseGeschichte() } });
context: ctx(),
props: { geschichte: baseGeschichte(), canBlogWrite: false }
});
await expect.element(page.getByText(/Im Jahr 1923/)).toBeVisible(); await expect.element(page.getByText(/Im Jahr 1923/)).toBeVisible();
}); });
it('omits persons section when persons array is empty', async () => { it('omits persons section when persons array is empty', async () => {
render(StoryReader, { render(StoryReader, { props: { geschichte: baseGeschichte({ persons: [] }) } });
context: ctx(),
props: { geschichte: baseGeschichte({ persons: [] }), canBlogWrite: false }
});
await expect.element(page.getByText(/Personen in dieser Geschichte/i)).not.toBeInTheDocument(); await expect.element(page.getByText(/Personen in dieser Geschichte/i)).not.toBeInTheDocument();
}); });
it('renders persons section with firstName + lastName joined', async () => { it('renders persons section with firstName + lastName joined', async () => {
render(StoryReader, { render(StoryReader, {
context: ctx(),
props: { props: {
geschichte: baseGeschichte({ geschichte: baseGeschichte({
persons: [ persons: [
{ id: 'p1', firstName: 'Helene', lastName: 'Schmidt' }, { id: 'p1', firstName: 'Helene', lastName: 'Schmidt' },
{ id: 'p2', firstName: 'Karl', lastName: 'Müller' } { id: 'p2', firstName: 'Karl', lastName: 'Müller' }
] ]
}), })
canBlogWrite: false
} }
}); });
@@ -65,79 +54,50 @@ describe('StoryReader', () => {
}); });
it('omits documents section when no items have documents', async () => { it('omits documents section when no items have documents', async () => {
render(StoryReader, { render(StoryReader, { props: { geschichte: baseGeschichte({ items: [] }) } });
context: ctx(),
props: { geschichte: baseGeschichte({ items: [] }), canBlogWrite: false }
});
await expect.element(page.getByText('Erwähnte Dokumente')).not.toBeInTheDocument(); await expect.element(page.getByText('Erwähnte Dokumente')).not.toBeInTheDocument();
}); });
it('renders documents section for items with documents', async () => { it('renders document reference cards with title and link for items with documents', async () => {
render(StoryReader, { render(StoryReader, {
context: ctx(),
props: { props: {
geschichte: baseGeschichte({ geschichte: baseGeschichte({
items: [ items: [
{ {
id: 'i1', id: 'i1',
position: 0, position: 0,
document: { id: 'd1', title: 'Brief 1', datePrecision: 'FULL' }, document: {
id: 'd1',
title: 'Brief vom 12. Juli 1938',
documentDate: '1938-07-12',
senderName: 'Franz Raddatz',
receiverName: 'Emma Müller',
datePrecision: 'DAY',
receiverCount: 1
} as unknown as NonNullable<components['schemas']['JourneyItemView']['document']>,
note: 'Wichtiger Brief' note: 'Wichtiger Brief'
} }
] ]
}), })
canBlogWrite: false
} }
}); });
await expect.element(page.getByText('Erwähnte Dokumente')).toBeVisible(); await expect.element(page.getByText('Erwähnte Dokumente')).toBeVisible();
await expect.element(page.getByText('Dokument öffnen')).toBeVisible(); await expect.element(page.getByText('Brief vom 12. Juli 1938')).toBeVisible();
await expect.element(page.getByText(/von Franz Raddatz an Emma Müller/)).toBeVisible();
await expect.element(page.getByText('Wichtiger Brief')).toBeVisible(); await expect.element(page.getByText('Wichtiger Brief')).toBeVisible();
});
it('shows edit/delete actions when canBlogWrite is true', async () => { const link = document.querySelector<HTMLAnchorElement>('a[href="/documents/d1"]');
render(StoryReader, { expect(link).not.toBeNull();
context: ctx(),
props: { geschichte: baseGeschichte(), canBlogWrite: true }
});
await expect
.element(page.getByRole('link', { name: /bearbeiten/i }))
.toHaveAttribute('href', '/geschichten/g1/edit');
await expect.element(page.getByRole('button', { name: /löschen/i })).toBeVisible();
});
it('hides edit/delete actions when canBlogWrite is false', async () => {
render(StoryReader, {
context: ctx(),
props: { geschichte: baseGeschichte(), canBlogWrite: false }
});
await expect.element(page.getByRole('link', { name: /bearbeiten/i })).not.toBeInTheDocument();
await expect.element(page.getByRole('button', { name: /löschen/i })).not.toBeInTheDocument();
});
it('clicking delete button calls ondelete prop', async () => {
const ondelete = vi.fn().mockResolvedValue(undefined);
render(StoryReader, {
context: ctx(),
props: { geschichte: baseGeschichte(), canBlogWrite: true, ondelete }
});
await userEvent.click(page.getByRole('button', { name: /löschen/i }));
expect(ondelete).toHaveBeenCalledOnce();
}); });
it('person chip link meets 44px touch-target minimum height', async () => { it('person chip link meets 44px touch-target minimum height', async () => {
render(StoryReader, { render(StoryReader, {
context: ctx(),
props: { props: {
geschichte: baseGeschichte({ geschichte: baseGeschichte({
persons: [{ id: 'p1', firstName: 'Helene', lastName: 'Schmidt' }] persons: [{ id: 'p1', firstName: 'Helene', lastName: 'Schmidt' }]
}), })
canBlogWrite: false
} }
}); });
@@ -146,15 +106,26 @@ describe('StoryReader', () => {
expect(rect?.height).toBeGreaterThanOrEqual(44); expect(rect?.height).toBeGreaterThanOrEqual(44);
}); });
it('person chip shows avatar initials', async () => {
render(StoryReader, {
props: {
geschichte: baseGeschichte({
persons: [{ id: 'p1', firstName: 'Helene', lastName: 'Schmidt' }]
})
}
});
const chip = document.querySelector<HTMLAnchorElement>('a[href="/persons/p1"]');
expect(chip?.textContent).toContain('HS');
});
it('XSS: Story body is sanitised — injected payload does not execute', async () => { it('XSS: Story body is sanitised — injected payload does not execute', async () => {
// StoryReader uses {@html safeHtml(g.body)} — DOMPurify must strip the payload. // StoryReader uses {@html safeHtml(g.body)} — DOMPurify must strip the payload.
render(StoryReader, { render(StoryReader, {
context: ctx(),
props: { props: {
geschichte: baseGeschichte({ geschichte: baseGeschichte({
body: '<img src=x onerror="(window as any).__xss_story=1">' body: '<img src=x onerror="(window as any).__xss_story=1">'
}), })
canBlogWrite: false
} }
}); });

View File

@@ -3,21 +3,19 @@ import { formatAuthorName, formatAuthorDisplayName, formatPublishedAt } from './
describe('formatAuthorName', () => { describe('formatAuthorName', () => {
it('joins firstName and lastName with a space', () => { it('joins firstName and lastName with a space', () => {
expect(formatAuthorName({ firstName: 'Anna', lastName: 'Schmidt', email: 'a@x' })).toBe( expect(formatAuthorName({ firstName: 'Anna', lastName: 'Schmidt' })).toBe('Anna Schmidt');
'Anna Schmidt'
);
}); });
it('returns firstName alone when lastName is absent', () => { it('returns firstName alone when lastName is absent', () => {
expect(formatAuthorName({ firstName: 'Anna', email: 'a@x' })).toBe('Anna'); expect(formatAuthorName({ firstName: 'Anna' })).toBe('Anna');
}); });
it('returns lastName alone when firstName is absent', () => { it('returns lastName alone when firstName is absent', () => {
expect(formatAuthorName({ lastName: 'Schmidt', email: 'a@x' })).toBe('Schmidt'); expect(formatAuthorName({ lastName: 'Schmidt' })).toBe('Schmidt');
}); });
it('falls back to email when both names are absent', () => { it('falls back to [Unbekannt] when both names are absent', () => {
expect(formatAuthorName({ email: 'fallback@example.com' })).toBe('fallback@example.com'); expect(formatAuthorName({})).toBe('[Unbekannt]');
}); });
it('returns empty string for null input', () => { it('returns empty string for null input', () => {

View File

@@ -1,16 +1,21 @@
import { formatDate } from '$lib/shared/utils/date'; import { formatDate } from '$lib/shared/utils/date';
import { m } from '$lib/paraglide/messages.js';
import { joinNameOrUnknown, unknownPersonName } from '$lib/person/personFormat';
type AuthorSummary = { firstName?: string; lastName?: string; email: string }; type AuthorSummary = { firstName?: string; lastName?: string };
type DocumentMeta = { documentDate?: string; senderName?: string; receiverName?: string };
type AuthorView = { displayName: string }; type AuthorView = { displayName: string };
export function formatAuthorName(author: AuthorSummary | null | undefined): string { export function formatAuthorName(author: AuthorSummary | null | undefined): string {
if (!author) return ''; if (!author) return '';
const full = [author.firstName, author.lastName].filter(Boolean).join(' ').trim(); // Email is no longer exposed — names or the localized fallback only.
return full || author.email || ''; return joinNameOrUnknown(author.firstName, author.lastName);
} }
export function formatAuthorDisplayName(author: AuthorView | null | undefined): string { export function formatAuthorDisplayName(author: AuthorView | null | undefined): string {
return author?.displayName ?? ''; if (!author) return '';
// The server-side fallback is the literal '[Unbekannt]' — localize it here.
return author.displayName === '[Unbekannt]' ? unknownPersonName() : author.displayName;
} }
export function formatPublishedAt( export function formatPublishedAt(
@@ -20,3 +25,15 @@ export function formatPublishedAt(
if (!publishedAt) return null; if (!publishedAt) return null;
return formatDate(publishedAt.slice(0, 10), style); return formatDate(publishedAt.slice(0, 10), style);
} }
/** "12.07.1938 · von Franz an Emma" — shared by JourneyItemCard and the story doc-reference cards. */
export function formatDocumentMetaLine(doc: DocumentMeta): string {
const parts: string[] = [];
if (doc.documentDate) parts.push(formatDate(doc.documentDate, 'short'));
if (doc.senderName && doc.receiverName) {
parts.push(m.journey_item_meta_from_to({ sender: doc.senderName, receiver: doc.receiverName }));
} else if (doc.senderName) {
parts.push(doc.senderName);
}
return parts.join(' · ');
}

View File

@@ -2,10 +2,11 @@
import type { components } from '$lib/generated/api'; import type { components } from '$lib/generated/api';
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import { clickOutside } from '$lib/shared/actions/clickOutside'; import { clickOutside } from '$lib/shared/actions/clickOutside';
import type { PersonOption } from './personOption';
type Person = components['schemas']['Person']; type Person = components['schemas']['Person'];
interface Props { interface Props {
selectedPersons?: Person[]; selectedPersons?: PersonOption[];
} }
let { selectedPersons = $bindable([]) }: Props = $props(); let { selectedPersons = $bindable([]) }: Props = $props();

View File

@@ -1,4 +1,5 @@
import { formatDate } from '$lib/shared/utils/date'; import { formatDate } from '$lib/shared/utils/date';
import { m } from '$lib/paraglide/messages.js';
type Person = { firstName?: string | null; lastName: string; displayName: string }; type Person = { firstName?: string | null; lastName: string; displayName: string };
type DocForMeta = { type DocForMeta = {
@@ -17,6 +18,19 @@ function djb2(str: string): number {
return Math.abs(hash); return Math.abs(hash);
} }
/** Localized fallback when a person has no name parts. */
export function unknownPersonName(): string {
return m.person_unknown();
}
/**
* Single source for the join-names-or-fallback rule. Mirrors the server-side
* fallback in GeschichteService.toView (which emits the literal '[Unbekannt]').
*/
export function joinNameOrUnknown(firstName?: string | null, lastName?: string | null): string {
return [firstName, lastName].filter(Boolean).join(' ').trim() || unknownPersonName();
}
export function getInitials(name: string): string { export function getInitials(name: string): string {
const words = name.trim().split(/\s+/).filter(Boolean); const words = name.trim().split(/\s+/).filter(Boolean);
if (words.length === 0) return ''; if (words.length === 0) return '';

View File

@@ -0,0 +1,27 @@
import type { components } from '$lib/generated/api';
import { joinNameOrUnknown } from './personFormat';
type Person = components['schemas']['Person'];
/**
* Narrow chip/dedup contract for person pickers: exactly what PersonMultiSelect
* renders. Full `Person` objects (search results) are structurally assignable;
* view projections without a displayName go through {@link toPersonOption}.
*/
export type PersonOption = Pick<Person, 'id' | 'displayName'>;
/**
* Maps a name-carrying projection (e.g. GeschichteView.PersonView, which has no
* server-computed displayName) into the chip contract. Mirrors the server-side
* fallback in GeschichteService.toView.
*/
export function toPersonOption(p: {
id: string;
firstName?: string | null;
lastName?: string | null;
}): PersonOption {
return {
id: p.id,
displayName: joinNameOrUnknown(p.firstName, p.lastName)
};
}

View File

@@ -3,10 +3,10 @@ import * as m from '$lib/paraglide/messages.js';
import { relativeTimeDe } from '$lib/shared/relativeTime'; import { relativeTimeDe } from '$lib/shared/relativeTime';
import type { components } from '$lib/generated/api'; import type { components } from '$lib/generated/api';
type Geschichte = components['schemas']['Geschichte']; type GeschichteSummary = components['schemas']['GeschichteSummary'];
interface Props { interface Props {
drafts: Geschichte[]; drafts: GeschichteSummary[];
} }
const { drafts }: Props = $props(); const { drafts }: Props = $props();

View File

@@ -5,24 +5,25 @@ import { page } from 'vitest/browser';
import ReaderDraftsModule from './ReaderDraftsModule.svelte'; import ReaderDraftsModule from './ReaderDraftsModule.svelte';
import type { components } from '$lib/generated/api'; import type { components } from '$lib/generated/api';
type Geschichte = components['schemas']['Geschichte']; type GeschichteSummary = components['schemas']['GeschichteSummary'];
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
}); });
const draft1: Geschichte = { const draft1: GeschichteSummary = {
id: 'g1', id: 'g1',
title: 'Mein erster Entwurf', title: 'Mein erster Entwurf',
status: 'DRAFT', status: 'DRAFT',
createdAt: '2025-01-01T00:00:00Z', type: 'STORY',
updatedAt: '2025-01-02T00:00:00Z' updatedAt: '2025-01-02T00:00:00Z'
}; };
const draft2: Geschichte = { const draft2: GeschichteSummary = {
id: 'g2', id: 'g2',
title: 'Zweiter Entwurf', title: 'Zweiter Entwurf',
status: 'DRAFT', status: 'DRAFT',
type: 'STORY',
createdAt: '2025-02-01T00:00:00Z', createdAt: '2025-02-01T00:00:00Z',
updatedAt: '2025-02-01T00:00:00Z' updatedAt: '2025-02-01T00:00:00Z'
}; };

View File

@@ -3,10 +3,10 @@ import * as m from '$lib/paraglide/messages.js';
import { relativeTimeDe } from '$lib/shared/relativeTime'; import { relativeTimeDe } from '$lib/shared/relativeTime';
import type { components } from '$lib/generated/api'; import type { components } from '$lib/generated/api';
type Geschichte = components['schemas']['Geschichte']; type GeschichteSummary = components['schemas']['GeschichteSummary'];
interface Props { interface Props {
stories: Geschichte[]; stories: GeschichteSummary[];
} }
const { stories }: Props = $props(); const { stories }: Props = $props();

View File

@@ -5,27 +5,28 @@ import { page } from 'vitest/browser';
import ReaderRecentStories from './ReaderRecentStories.svelte'; import ReaderRecentStories from './ReaderRecentStories.svelte';
import type { components } from '$lib/generated/api'; import type { components } from '$lib/generated/api';
type Geschichte = components['schemas']['Geschichte']; type GeschichteSummary = components['schemas']['GeschichteSummary'];
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
}); });
const story1: Geschichte = { const story1: GeschichteSummary = {
id: 'g1', id: 'g1',
title: 'Die Familie Müller', title: 'Die Familie Müller',
body: '<p>Dies ist eine sehr lange Geschichte über die Familie Müller. Sie lebten in Bayern und hatten viele Kinder. Das war früher so üblich in diesen Gebieten.</p>', body: '<p>Dies ist eine sehr lange Geschichte über die Familie Müller. Sie lebten in Bayern und hatten viele Kinder. Das war früher so üblich in diesen Gebieten.</p>',
status: 'PUBLISHED', status: 'PUBLISHED',
createdAt: '2025-01-01T00:00:00Z', type: 'STORY',
updatedAt: '2025-01-01T00:00:00Z', updatedAt: '2025-01-01T00:00:00Z',
publishedAt: '2025-01-01T00:00:00Z' publishedAt: '2025-01-01T00:00:00Z'
}; };
const longBodyStory: Geschichte = { const longBodyStory: GeschichteSummary = {
id: 'g2', id: 'g2',
title: 'Sehr lange Geschichte', title: 'Sehr lange Geschichte',
body: '<p>' + 'A'.repeat(200) + '</p>', body: '<p>' + 'A'.repeat(200) + '</p>',
status: 'PUBLISHED', status: 'PUBLISHED',
type: 'STORY',
createdAt: '2025-02-01T00:00:00Z', createdAt: '2025-02-01T00:00:00Z',
updatedAt: '2025-02-01T00:00:00Z', updatedAt: '2025-02-01T00:00:00Z',
publishedAt: '2025-02-01T00:00:00Z' publishedAt: '2025-02-01T00:00:00Z'

View File

@@ -49,7 +49,12 @@ export type ErrorCode =
| 'JOURNEY_ITEM_NOT_FOUND' | 'JOURNEY_ITEM_NOT_FOUND'
| 'JOURNEY_ITEM_POSITION_CONFLICT' | 'JOURNEY_ITEM_POSITION_CONFLICT'
| 'JOURNEY_AT_CAPACITY' | 'JOURNEY_AT_CAPACITY'
| 'JOURNEY_NOTE_TOO_LONG'
| 'JOURNEY_DOCUMENT_ALREADY_ADDED'
| 'GESCHICHTE_TYPE_MISMATCH' | 'GESCHICHTE_TYPE_MISMATCH'
| 'GESCHICHTE_TYPE_IMMUTABLE'
| 'GESCHICHTE_TITLE_TOO_LONG'
| 'GESCHICHTE_INTRO_TOO_LONG'
| 'INVALID_CREDENTIALS' | 'INVALID_CREDENTIALS'
| 'SESSION_EXPIRED' | 'SESSION_EXPIRED'
| 'MISSING_CREDENTIALS' | 'MISSING_CREDENTIALS'
@@ -174,8 +179,18 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string {
return m.error_journey_item_position_conflict(); return m.error_journey_item_position_conflict();
case 'JOURNEY_AT_CAPACITY': case 'JOURNEY_AT_CAPACITY':
return m.error_journey_at_capacity(); return m.error_journey_at_capacity();
case 'JOURNEY_NOTE_TOO_LONG':
return m.error_journey_note_too_long();
case 'JOURNEY_DOCUMENT_ALREADY_ADDED':
return m.error_journey_document_already_added();
case 'GESCHICHTE_TYPE_MISMATCH': case 'GESCHICHTE_TYPE_MISMATCH':
return m.error_geschichte_type_mismatch(); return m.error_geschichte_type_mismatch();
case 'GESCHICHTE_TYPE_IMMUTABLE':
return m.error_geschichte_type_immutable();
case 'GESCHICHTE_TITLE_TOO_LONG':
return m.error_geschichte_title_too_long();
case 'GESCHICHTE_INTRO_TOO_LONG':
return m.error_geschichte_intro_too_long();
case 'INVALID_CREDENTIALS': case 'INVALID_CREDENTIALS':
return m.error_invalid_credentials(); return m.error_invalid_credentials();
case 'SESSION_EXPIRED': case 'SESSION_EXPIRED':

View File

@@ -1,7 +1,35 @@
import { describe, it, expect, vi } from 'vitest'; import { describe, it, expect, vi, expectTypeOf } from 'vitest';
import { createBlockDragDrop } from './useBlockDragDrop.svelte'; import { createBlockDragDrop } from './useBlockDragDrop.svelte';
import type { TranscriptionBlockData } from '$lib/shared/types'; import type { TranscriptionBlockData } from '$lib/shared/types';
// ---------------------------------------------------------------------------
// Type-regression guard: createBlockDragDrop must accept any T extends {id: string}
// so JourneyEditor can reuse it without importing TranscriptionBlockData.
// This test fails with "Expected 0 type arguments, but got 1" via tsc --noEmit
// until the function is made generic.
// ---------------------------------------------------------------------------
describe('createBlockDragDrop — generic type guard', () => {
it('accepts items shaped as { id: string; position: number } — not only TranscriptionBlockData', () => {
type SimpleItem = { id: string; position: number };
const items: SimpleItem[] = [
{ id: 'item-1', position: 0 },
{ id: 'item-2', position: 1 }
];
const onReorder = vi.fn();
const dd = createBlockDragDrop<SimpleItem>({ getSortedBlocks: () => items, onReorder });
// Verify the hook is functional with the new type — state reads must work
expect(dd.draggedBlockId).toBeNull();
expect(dd.dragOffsetY).toBe(0);
});
it('TranscriptionBlockData caller still compiles — regression guard for existing transcription editor', () => {
// If the generic constraint is wrong this line fails tsc --noEmit
expectTypeOf(createBlockDragDrop<TranscriptionBlockData>).toBeFunction();
// Runtime assertion so browser-mode doesn't report "no assertions"
expect(typeof createBlockDragDrop).toBe('function');
});
});
function makeBlock(id: string, sortOrder: number): TranscriptionBlockData { function makeBlock(id: string, sortOrder: number): TranscriptionBlockData {
return { return {
id, id,

View File

@@ -1,11 +1,12 @@
import type { TranscriptionBlockData } from '$lib/shared/types'; type Options<T extends { id: string }> = {
getSortedBlocks: () => T[];
type Options = {
getSortedBlocks: () => TranscriptionBlockData[];
onReorder: (blockIds: string[]) => void; onReorder: (blockIds: string[]) => void;
}; };
export function createBlockDragDrop({ getSortedBlocks, onReorder }: Options) { export function createBlockDragDrop<T extends { id: string }>({
getSortedBlocks,
onReorder
}: Options<T>) {
let draggedBlockId = $state<string | null>(null); let draggedBlockId = $state<string | null>(null);
let dropTargetIdx = $state<number | null>(null); let dropTargetIdx = $state<number | null>(null);
let dragOffsetY = $state(0); let dragOffsetY = $state(0);

View File

@@ -78,6 +78,56 @@ describe('createTypeahead', () => {
errorSpy.mockRestore(); errorSpy.mockRestore();
}); });
it('fetch error sets the error flag so callers can render a failure state', async () => {
const fetchUrl = vi.fn().mockRejectedValue(new Error('500'));
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const ta = createTypeahead({ fetchUrl, debounceMs: 0 });
expect(ta.error).toBe(false);
ta.setQuery('foo');
await vi.advanceTimersByTimeAsync(0);
expect(ta.error).toBe(true);
errorSpy.mockRestore();
});
it('error flag clears on the next successful fetch', async () => {
const fetchUrl = vi
.fn()
.mockRejectedValueOnce(new Error('500'))
.mockResolvedValueOnce([{ id: '1' }]);
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const ta = createTypeahead({ fetchUrl, debounceMs: 0 });
ta.setQuery('foo');
await vi.advanceTimersByTimeAsync(0);
expect(ta.error).toBe(true);
ta.setQuery('foob');
await vi.advanceTimersByTimeAsync(0);
expect(ta.error).toBe(false);
expect(ta.results).toEqual([{ id: '1' }]);
errorSpy.mockRestore();
});
it('sets loading immediately on setQuery so empty results read as pending, not "no results"', async () => {
const fetchUrl = vi.fn().mockResolvedValue([]);
const ta = createTypeahead({ fetchUrl, debounceMs: 300 });
ta.setQuery('foo');
// During the debounce window no fetch has run yet — callers must be able to
// distinguish "still searching" from "searched, zero hits".
expect(ta.loading).toBe(true);
await vi.advanceTimersByTimeAsync(300);
expect(ta.loading).toBe(false);
});
it('close() cancels the pending debounce — no stale fetch fires, loading resets', async () => {
const fetchUrl = vi.fn().mockResolvedValue([{ id: '1' }]);
const ta = createTypeahead({ fetchUrl, debounceMs: 300 });
ta.setQuery('foo');
expect(ta.loading).toBe(true);
ta.close();
expect(ta.loading).toBe(false);
await vi.advanceTimersByTimeAsync(300);
expect(fetchUrl).not.toHaveBeenCalled();
});
it('setActiveIndex updates activeIndex', () => { it('setActiveIndex updates activeIndex', () => {
const ta = createTypeahead({ fetchUrl: vi.fn().mockResolvedValue([]) }); const ta = createTypeahead({ fetchUrl: vi.fn().mockResolvedValue([]) });
expect(ta.activeIndex).toBe(-1); expect(ta.activeIndex).toBe(-1);

View File

@@ -11,6 +11,7 @@ export function createTypeahead<T>(options: Options<T>) {
let results: T[] = $state([]); let results: T[] = $state([]);
let isOpen = $state(false); let isOpen = $state(false);
let loading = $state(false); let loading = $state(false);
let error = $state(false);
let activeIndex = $state(-1); let activeIndex = $state(-1);
let debounceTimer: ReturnType<typeof setTimeout> | undefined; let debounceTimer: ReturnType<typeof setTimeout> | undefined;
@@ -18,14 +19,18 @@ export function createTypeahead<T>(options: Options<T>) {
function setQuery(q: string) { function setQuery(q: string) {
query = q; query = q;
isOpen = true; isOpen = true;
// Set loading before the debounce fires so callers can distinguish
// "still searching" from "searched, zero hits" during the debounce window.
loading = true;
clearTimeout(debounceTimer); clearTimeout(debounceTimer);
debounceTimer = setTimeout(async () => { debounceTimer = setTimeout(async () => {
loading = true; error = false;
try { try {
results = await fetchUrl(q); results = await fetchUrl(q);
} catch (e) { } catch (e) {
console.error('typeahead fetch error', e); console.error('typeahead fetch error', e);
results = []; results = [];
error = true;
} finally { } finally {
loading = false; loading = false;
} }
@@ -35,6 +40,10 @@ export function createTypeahead<T>(options: Options<T>) {
function close() { function close() {
isOpen = false; isOpen = false;
activeIndex = -1; activeIndex = -1;
// Cancel the pending debounce — an abandoned query must not fire a stale
// fetch after the dropdown closed, nor leave loading stuck on true.
clearTimeout(debounceTimer);
loading = false;
} }
function setActiveIndex(idx: number) { function setActiveIndex(idx: number) {
@@ -65,6 +74,9 @@ export function createTypeahead<T>(options: Options<T>) {
get loading() { get loading() {
return loading; return loading;
}, },
get error() {
return error;
},
get activeIndex() { get activeIndex() {
return activeIndex; return activeIndex;
}, },

View File

@@ -1,13 +1,18 @@
/** /**
* **Not a sanitizer.** This module extracts visible text from a (presumed * **Not a sanitizer.** This module extracts visible text from an HTML (or
* already-sanitised) HTML string for excerpt rendering. It is safe ONLY * plain-text) string for excerpt rendering. The safety invariant is: the
* because the Geschichte body is sanitised against the OWASP allow-list * OUTPUT must only ever be rendered via Svelte text interpolation — never
* on the server before persistence, and via DOMPurify on render. * `{@html}`. The DOMParser document is inert (scripts don't execute), but
* the returned string is whatever text the input carried.
*
* Note on inputs: STORY bodies are additionally sanitised against the OWASP
* allow-list on the server; JOURNEY intros are stored VERBATIM (unsanitised
* by design — see GeschichteService.bodyForType) and arrive here untrusted.
* *
* Do not use these helpers to defend against XSS — `safeHtml()` in * Do not use these helpers to defend against XSS — `safeHtml()` in
* `./sanitize.ts` is the only sanitiser. Calling `extractText()` on * `./sanitize.ts` is the only sanitiser. Calling `extractText()` on
* untrusted input that has not been sanitised does not protect against * untrusted input does not protect against `javascript:` URLs,
* `javascript:` URLs, event-handler attributes, or `<svg/onload>` payloads. * event-handler attributes, or `<svg/onload>` payloads.
*/ */
/** /**

View File

@@ -11,7 +11,7 @@ type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO'];
type IncompleteDocumentDTO = components['schemas']['IncompleteDocumentDTO']; type IncompleteDocumentDTO = components['schemas']['IncompleteDocumentDTO'];
type PersonSummaryDTO = components['schemas']['PersonSummaryDTO']; type PersonSummaryDTO = components['schemas']['PersonSummaryDTO'];
type DocumentListItem = components['schemas']['DocumentListItem']; type DocumentListItem = components['schemas']['DocumentListItem'];
type Geschichte = components['schemas']['Geschichte']; type GeschichteSummary = components['schemas']['GeschichteSummary'];
type TagTreeNodeDTO = components['schemas']['TagTreeNodeDTO']; type TagTreeNodeDTO = components['schemas']['TagTreeNodeDTO'];
function settled<T>(res: PromiseSettledResult<unknown> | undefined): T | null { function settled<T>(res: PromiseSettledResult<unknown> | undefined): T | null {
@@ -57,9 +57,9 @@ export async function load({ fetch, parent }) {
const topPersons = settled<{ items: PersonSummaryDTO[] }>(topPersonsRes)?.items ?? []; const topPersons = settled<{ items: PersonSummaryDTO[] }>(topPersonsRes)?.items ?? [];
const searchData = settled<{ items: DocumentListItem[] }>(recentDocsRes); const searchData = settled<{ items: DocumentListItem[] }>(recentDocsRes);
const recentDocs = searchData?.items ?? []; const recentDocs = searchData?.items ?? [];
const recentStories = settled<Geschichte[]>(recentStoriesRes) ?? []; const recentStories = settled<GeschichteSummary[]>(recentStoriesRes) ?? [];
const tagTree = settled<TagTreeNodeDTO[]>(tagTreeRes) ?? []; const tagTree = settled<TagTreeNodeDTO[]>(tagTreeRes) ?? [];
const drafts = settled<Geschichte[]>(draftsRes) ?? []; const drafts = settled<GeschichteSummary[]>(draftsRes) ?? [];
return { return {
isReader: true as const, isReader: true as const,
@@ -179,9 +179,9 @@ export async function load({ fetch, parent }) {
readerStats: null, readerStats: null,
topPersons: [] as PersonSummaryDTO[], topPersons: [] as PersonSummaryDTO[],
recentDocs: [] as DocumentListItem[], recentDocs: [] as DocumentListItem[],
recentStories: [] as Geschichte[], recentStories: [] as GeschichteSummary[],
tagTree: [] as TagTreeNodeDTO[], tagTree: [] as TagTreeNodeDTO[],
drafts: [] as Geschichte[], drafts: [] as GeschichteSummary[],
error: 'Daten konnten nicht geladen werden.' as string | null error: 'Daten konnten nicht geladen werden.' as string | null
}; };
} }

View File

@@ -47,6 +47,10 @@ describe('SearchFilterBar AND/OR tag operator toggle', () => {
async function openAdvanced() { async function openAdvanced() {
const filterBtn = page.getByRole('button', { name: 'Filter', exact: true }); const filterBtn = page.getByRole('button', { name: 'Filter', exact: true });
await filterBtn.click(); await filterBtn.click();
// Wait for slide transition to finish before interacting with contents —
// clicking during the transition triggers track_reactivity_loss in Svelte 5 async.js
// (same guard as the undated-only describe below; this block flaked in CI run 2208).
await expect.element(page.getByTestId('undated-only-toggle')).toBeVisible();
} }
it('hides AND/OR toggle when fewer than 2 tags are selected', async () => { it('hides AND/OR toggle when fewer than 2 tags are selected', async () => {
@@ -132,6 +136,9 @@ describe('SearchFilterBar undated-only toggle (#668)', () => {
async function openAdvanced() { async function openAdvanced() {
const filterBtn = page.getByRole('button', { name: 'Filter', exact: true }); const filterBtn = page.getByRole('button', { name: 'Filter', exact: true });
await filterBtn.click(); await filterBtn.click();
// Wait for slide transition to finish before interacting with contents —
// clicking during the transition triggers track_reactivity_loss in Svelte 5 async.js
await expect.element(page.getByTestId('undated-only-toggle')).toBeVisible();
} }
it('renders the "Nur undatierte" toggle in the advanced row', async () => { it('renders the "Nur undatierte" toggle in the advanced row', async () => {

View File

@@ -6,16 +6,21 @@ import type { PageServerLoad } from './$types';
type Person = components['schemas']['Person']; type Person = components['schemas']['Person'];
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export const load: PageServerLoad = async ({ url, fetch }) => { export const load: PageServerLoad = async ({ url, fetch }) => {
const api = createApiClient(fetch); const api = createApiClient(fetch);
const personIds = url.searchParams.getAll('personId'); const personIds = url.searchParams.getAll('personId');
const rawDocumentId = url.searchParams.get('documentId');
const documentId = rawDocumentId && UUID_PATTERN.test(rawDocumentId) ? rawDocumentId : null;
const [listResult, ...personResults] = await Promise.all([ const [listResult, ...personResults] = await Promise.all([
api.GET('/api/geschichten', { api.GET('/api/geschichten', {
params: { params: {
query: { query: {
status: 'PUBLISHED', status: 'PUBLISHED',
personId: personIds.length ? personIds : undefined personId: personIds.length ? personIds : undefined,
documentId: documentId ?? undefined
} }
} }
}), }),
@@ -32,6 +37,7 @@ export const load: PageServerLoad = async ({ url, fetch }) => {
return { return {
geschichten: listResult.data ?? [], geschichten: listResult.data ?? [],
personFilters personFilters,
documentIdFilter: documentId
}; };
}; };

View File

@@ -37,9 +37,9 @@ function removePerson(personId: string) {
} }
</script> </script>
<div class="mx-auto max-w-4xl px-4 py-8"> <div class="mx-auto max-w-7xl px-4 py-8">
<header class="mb-6 flex flex-wrap items-center justify-between gap-3"> <header class="mb-4 flex flex-wrap items-center justify-between gap-3">
<h1 class="font-serif text-3xl font-bold text-ink">{m.geschichten_index_title()}</h1> <h1 class="font-serif text-2xl text-ink">{m.geschichten_index_title()}</h1>
{#if data.canBlogWrite} {#if data.canBlogWrite}
<a <a
href="/geschichten/new" href="/geschichten/new"
@@ -50,78 +50,79 @@ function removePerson(personId: string) {
{/if} {/if}
</header> </header>
<!-- Filter pills --> <!-- Editorial list card: filter pills + rows share one surface -->
<div class="mb-6 flex flex-wrap items-center gap-2"> <div class="overflow-hidden rounded-sm border border-line bg-surface shadow-sm">
<button <!-- Filter pills -->
type="button" <div class="flex flex-wrap items-center gap-2 border-b border-line-2 px-3 py-2.5">
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 <button
type="button" type="button"
aria-pressed="true" aria-pressed={!hasFilters}
aria-label={m.geschichten_filter_remove_chip({ name: p.displayName })} onclick={clearAll}
onclick={() => removePerson(p.id!)} class="inline-flex h-11 items-center rounded-full border border-line px-3 font-sans text-xs font-semibold text-ink-2 hover:bg-muted aria-pressed:border-primary aria-pressed:bg-primary aria-pressed:text-primary-fg"
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} {m.geschichten_filter_all_pill()}
<span aria-hidden="true">×</span>
</button> </button>
{/each}
<button {#each data.personFilters as p (p.id)}
type="button" <button
aria-expanded={showPersonPicker} type="button"
onclick={() => (showPersonPicker = !showPersonPicker)} aria-pressed="true"
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-label={m.geschichten_filter_remove_chip({ name: p.displayName })}
> onclick={() => removePerson(p.id!)}
+ {m.geschichten_filter_choose_person()} class="inline-flex h-11 items-center gap-1.5 rounded-full border border-primary bg-primary px-3 font-sans text-xs font-semibold text-primary-fg"
</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"
> >
<!-- plaintext for JOURNEY, sanitised-HTML→text for STORY; never {@html} --> {p.displayName}
<GeschichteListRow geschichte={g} /> <span aria-hidden="true">×</span>
</li> </button>
{/each} {/each}
</ul>
{/if} <button
type="button"
aria-expanded={showPersonPicker}
onclick={() => (showPersonPicker = !showPersonPicker)}
class="inline-flex h-11 items-center rounded-full border border-dashed border-line px-3 font-sans text-xs font-semibold text-ink-3 hover:bg-muted"
>
+ {m.geschichten_filter_choose_person()}
</button>
</div>
{#if showPersonPicker}
<div class="border-b border-line-2 px-3 py-3">
<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}
<!-- Rows -->
{#if data.geschichten.length === 0}
<div class="px-4 py-12 text-center font-serif text-sm text-ink-3 italic">
{#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>
{#each data.geschichten as g (g.id)}
<li class="border-b border-line-2 last:border-b-0">
<!-- plaintext for JOURNEY, sanitised-HTML→text for STORY; never {@html} -->
<GeschichteListRow geschichte={g} />
</li>
{/each}
</ul>
{/if}
</div>
</div> </div>

View File

@@ -3,6 +3,7 @@ import { goto } from '$app/navigation';
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import { formatDate } from '$lib/shared/utils/date'; import { formatDate } from '$lib/shared/utils/date';
import { formatAuthorDisplayName } from '$lib/geschichte/utils'; import { formatAuthorDisplayName } from '$lib/geschichte/utils';
import { getInitials, personAvatarColor } from '$lib/person/personFormat';
import { getConfirmService } from '$lib/shared/services/confirm.svelte'; import { getConfirmService } from '$lib/shared/services/confirm.svelte';
import { csrfFetch } from '$lib/shared/cookies'; import { csrfFetch } from '$lib/shared/cookies';
import { parseBackendError, getErrorMessage } from '$lib/shared/errors'; import { parseBackendError, getErrorMessage } from '$lib/shared/errors';
@@ -47,44 +48,85 @@ async function handleDelete() {
} }
</script> </script>
<div class="mx-auto max-w-3xl px-4 py-8"> <div class="mx-auto max-w-7xl px-4 py-8">
<div class="mb-6"> <div class="mb-6">
<BackButton /> <BackButton />
</div> </div>
<article aria-labelledby="geschichte-title"> <article
<header class="mb-6"> aria-labelledby="geschichte-title"
<div class="mb-3 flex flex-wrap items-center gap-2"> class="rounded-sm border border-line bg-sheet px-5 py-6 shadow-sm sm:px-10 sm:py-10"
<h1 id="geschichte-title" class="font-serif text-4xl font-bold text-ink"> >
{g.title} <div class="mx-auto max-w-3xl">
</h1> <header class="mb-6">
{#if isJourney} {#if isJourney}
<span <span
class="inline-block rounded-full bg-journey-tint px-2 py-0.5 text-xs font-bold tracking-wider text-journey uppercase" class="mb-2 inline-flex rounded-sm border border-journey-border bg-journey-tint px-2 py-px text-xs font-bold tracking-widest text-journey uppercase"
> >
{m.journey_badge_detail()} {m.journey_badge_detail()}
</span> </span>
{/if} {/if}
</div> <h1 id="geschichte-title" class="mb-4 font-serif text-3xl leading-tight text-ink">
<p class="font-sans text-sm text-ink-3"> {g.title}
{authorName} </h1>
{#if publishedAt}· {m.geschichten_published_on({ date: publishedAt })}{/if} <div class="mb-4 flex items-center gap-3 border-b border-line-2 pb-4">
</p> {#if authorName}
</header> <span
aria-hidden="true"
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full font-sans text-xs font-bold text-white"
style="background-color: {personAvatarColor(authorName)}"
>
{getInitials(authorName)}
</span>
{/if}
<div>
{#if authorName}
<p class="font-sans text-sm leading-tight font-semibold text-ink">{authorName}</p>
{/if}
{#if publishedAt}
<p class="font-sans text-xs text-ink-3">
{#if isJourney}
{m.journey_compiled_on({ date: publishedAt })}
{:else}
{m.geschichten_published_on({ date: publishedAt })}
{/if}
</p>
{/if}
</div>
{#if data.canBlogWrite}
<div class="ml-auto flex items-center gap-3">
<a
href="/geschichten/{g.id}/edit"
class="inline-flex h-11 items-center rounded border border-line bg-surface px-3 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>
</header>
{#if deleteError} {#if deleteError}
<p <p
role="alert" role="alert"
class="mb-4 rounded border border-danger/30 bg-danger/10 px-4 py-3 font-sans text-sm text-danger" class="mb-4 rounded border border-danger/30 bg-danger/10 px-4 py-3 font-sans text-sm text-danger"
> >
{deleteError} {deleteError}
</p> </p>
{/if} {/if}
{#if isJourney} {#if isJourney}
<JourneyReader geschichte={g} canBlogWrite={data.canBlogWrite} ondelete={handleDelete} /> <JourneyReader geschichte={g} />
{:else} {:else}
<StoryReader geschichte={g} canBlogWrite={data.canBlogWrite} ondelete={handleDelete} /> <StoryReader geschichte={g} />
{/if} {/if}
</div>
</article> </article>
</div> </div>

View File

@@ -2,6 +2,7 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import GeschichteEditor from '$lib/geschichte/GeschichteEditor.svelte'; import GeschichteEditor from '$lib/geschichte/GeschichteEditor.svelte';
import JourneyEditor from '$lib/geschichte/JourneyEditor.svelte';
import BackButton from '$lib/shared/primitives/BackButton.svelte'; import BackButton from '$lib/shared/primitives/BackButton.svelte';
import { getErrorMessage } from '$lib/shared/errors'; import { getErrorMessage } from '$lib/shared/errors';
import { csrfFetch } from '$lib/shared/cookies'; import { csrfFetch } from '$lib/shared/cookies';
@@ -12,6 +13,8 @@ let { data }: { data: PageData } = $props();
let submitting = $state(false); let submitting = $state(false);
let errorMessage: string | null = $state(null); let errorMessage: string | null = $state(null);
const isJourney = $derived(data.geschichte.type === 'JOURNEY');
async function handleSubmit(payload: { async function handleSubmit(payload: {
title: string; title: string;
body: string; body: string;
@@ -29,9 +32,17 @@ async function handleSubmit(payload: {
if (!res.ok) { if (!res.ok) {
const code = (await res.json().catch(() => ({})))?.code; const code = (await res.json().catch(() => ({})))?.code;
errorMessage = getErrorMessage(code); errorMessage = getErrorMessage(code);
return; throw new Error('save failed');
} }
goto(`/geschichten/${data.geschichte.id}`); goto(`/geschichten/${data.geschichte.id}`);
} catch (e) {
if (!errorMessage) {
console.error('Geschichte save failed', e);
errorMessage = getErrorMessage(undefined);
}
// Contract: onSubmit rejects on failure — both editors catch and keep
// their dirty state instead of disarming the unsaved-changes guard.
throw e;
} finally { } finally {
submitting = false; submitting = false;
} }
@@ -44,7 +55,8 @@ async function handleSubmit(payload: {
</div> </div>
<h1 class="mb-6 font-serif text-3xl font-bold text-ink"> <h1 class="mb-6 font-serif text-3xl font-bold text-ink">
{m.btn_edit()}: {data.geschichte.title} {isJourney ? m.journey_edit_title_journey() : m.journey_edit_title_story()}:
{data.geschichte.title}
</h1> </h1>
{#if errorMessage} {#if errorMessage}
@@ -56,5 +68,13 @@ async function handleSubmit(payload: {
</div> </div>
{/if} {/if}
<GeschichteEditor geschichte={data.geschichte} onSubmit={handleSubmit} submitting={submitting} /> {#if isJourney}
<JourneyEditor geschichte={data.geschichte} onSubmit={handleSubmit} submitting={submitting} />
{:else}
<GeschichteEditor
geschichte={data.geschichte}
onSubmit={handleSubmit}
submitting={submitting}
/>
{/if}
</div> </div>

View File

@@ -1,6 +1,7 @@
import { describe, it, expect, vi, afterEach } from 'vitest'; import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte'; import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser'; import { page } from 'vitest/browser';
import { m } from '$lib/paraglide/messages.js';
vi.mock('$app/navigation', () => ({ vi.mock('$app/navigation', () => ({
beforeNavigate: () => {}, beforeNavigate: () => {},
@@ -21,13 +22,20 @@ const { default: GeschichtenEditPage } = await import('./+page.svelte');
afterEach(cleanup); afterEach(cleanup);
const baseData = (overrides: Record<string, unknown> = {}) => ({ const baseData = (overrides: Record<string, unknown> = {}) => ({
user: undefined,
canWrite: true,
canAnnotate: false,
canBlogWrite: true,
geschichte: { geschichte: {
id: 'g1', id: 'g1',
title: 'Die Reise nach Berlin', title: 'Die Reise nach Berlin',
body: '<p>Im Jahr 1923...</p>', body: '<p>Im Jahr 1923...</p>',
status: 'PUBLISHED' as 'DRAFT' | 'PUBLISHED', status: 'PUBLISHED' as 'DRAFT' | 'PUBLISHED',
type: 'STORY' as 'STORY' | 'JOURNEY',
persons: [], persons: [],
documents: [] items: [],
createdAt: '2024-01-01T00:00:00',
updatedAt: '2024-01-01T00:00:00'
}, },
...overrides ...overrides
}); });
@@ -60,4 +68,50 @@ describe('geschichten/[id]/edit page', () => {
const inputs = document.querySelectorAll('input, textarea, [contenteditable]'); const inputs = document.querySelectorAll('input, textarea, [contenteditable]');
expect(inputs.length).toBeGreaterThan(0); expect(inputs.length).toBeGreaterThan(0);
}); });
it('renders the JourneyEditor (add-bar, no TipTap toolbar) for JOURNEY-type geschichten', async () => {
render(GeschichtenEditPage, {
props: {
data: baseData({
geschichte: {
id: 'g1',
title: 'Die Reise nach Berlin',
body: '',
status: 'DRAFT' as const,
type: 'JOURNEY' as const,
persons: [],
items: [],
createdAt: '2024-01-01T00:00:00',
updatedAt: '2024-01-01T00:00:00'
}
})
}
});
await expect.element(page.getByText(m.journey_add_document())).toBeVisible();
expect(document.querySelector('[role="toolbar"]')).toBeNull();
});
it('renders the GeschichteEditor (TipTap toolbar, no add-bar) for STORY-type geschichten', async () => {
render(GeschichtenEditPage, {
props: {
data: baseData({
geschichte: {
id: 'g1',
title: 'Die Reise nach Berlin',
body: '<p>Im Jahr 1923...</p>',
status: 'DRAFT' as const,
type: 'STORY' as const,
persons: [],
items: [],
createdAt: '2024-01-01T00:00:00',
updatedAt: '2024-01-01T00:00:00'
}
})
}
});
await expect.element(page.getByRole('toolbar')).toBeVisible();
await expect.element(page.getByText(m.journey_add_document())).not.toBeInTheDocument();
});
}); });

View File

@@ -50,6 +50,9 @@ const baseGeschichte = (overrides: Partial<GeschichteView> = {}): GeschichteView
}); });
const baseData = (overrides: Record<string, unknown> = {}) => ({ const baseData = (overrides: Record<string, unknown> = {}) => ({
user: undefined,
canWrite: false,
canAnnotate: false,
geschichte: baseGeschichte(), geschichte: baseGeschichte(),
canBlogWrite: false, canBlogWrite: false,
...overrides ...overrides
@@ -67,6 +70,46 @@ describe('geschichten/[id] page', () => {
.toBeVisible(); .toBeVisible();
}); });
it('spans the directory width with a centered reading column inside the sheet (#799)', async () => {
render(GeschichtePage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: { data: baseData() }
});
const outer = document.querySelector('[class*="mx-auto"]');
expect(outer!.className).toContain('max-w-7xl');
const column = document.querySelector('article [class*="max-w-3xl"]');
expect(column).not.toBeNull();
expect(column!.className).toContain('mx-auto');
});
it('renders the article on a reading-sheet surface card (#797)', async () => {
render(GeschichtePage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: { data: baseData() }
});
const article = document.querySelector('article');
expect(article).not.toBeNull();
// bg-sheet sits between the sand canvas and the white cards inside the article
for (const cls of ['bg-sheet', 'border-line', 'rounded-sm', 'shadow-sm']) {
expect(article!.className).toContain(cls);
}
});
it('journey badge uses mode-aware journey tokens, not raw orange utilities (#801)', async () => {
render(GeschichtePage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: { data: baseData({ geschichte: baseGeschichte({ type: 'JOURNEY' }) }) }
});
const badge = document.querySelector('h1')!.parentElement!.querySelector('span');
expect(badge!.className).toContain('bg-journey-tint');
expect(badge!.className).toContain('text-journey');
expect(badge!.className).not.toContain('bg-orange-50');
});
it('renders the author full name from firstName + lastName', async () => { it('renders the author full name from firstName + lastName', async () => {
render(GeschichtePage, { render(GeschichtePage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]), context: new Map([[CONFIRM_KEY, createConfirmService()]]),
@@ -76,7 +119,7 @@ describe('geschichten/[id] page', () => {
await expect.element(page.getByText(/Anna Schmidt/)).toBeVisible(); await expect.element(page.getByText(/Anna Schmidt/)).toBeVisible();
}); });
it('falls back to author email when no name is set', async () => { it('renders the server-computed author displayName verbatim', async () => {
render(GeschichtePage, { render(GeschichtePage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]), context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: { props: {
@@ -166,7 +209,7 @@ describe('geschichten/[id] page', () => {
{ {
id: 'item1', id: 'item1',
position: 0, position: 0,
document: { id: 'd1', title: 'Brief 1923', datePrecision: 'FULL' }, document: { id: 'd1', title: 'Brief 1923', datePrecision: 'DAY', receiverCount: 0 },
note: 'Brief aus 1923' note: 'Brief aus 1923'
} }
] ]
@@ -176,8 +219,28 @@ describe('geschichten/[id] page', () => {
}); });
await expect.element(page.getByText('Erwähnte Dokumente')).toBeVisible(); await expect.element(page.getByText('Erwähnte Dokumente')).toBeVisible();
await expect.element(page.getByText('Dokument öffnen')).toBeVisible(); await expect.element(page.getByText('Brief 1923')).toBeVisible();
await expect.element(page.getByText('Brief aus 1923')).toBeVisible(); await expect.element(page.getByText('Brief aus 1923')).toBeVisible();
expect(document.querySelector('a[href="/documents/d1"]')).not.toBeNull();
});
it('JOURNEY shows "zusammengestellt am" instead of "veröffentlicht am"', async () => {
render(GeschichtePage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: { data: baseData({ geschichte: baseGeschichte({ type: 'JOURNEY' }) }) }
});
await expect.element(page.getByText(/zusammengestellt am/i)).toBeVisible();
await expect.element(page.getByText(/veröffentlicht am/i)).not.toBeInTheDocument();
});
it('renders the author avatar initials in the meta bar', async () => {
render(GeschichtePage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: { data: baseData() }
});
await expect.element(page.getByText('AS', { exact: true })).toBeInTheDocument();
}); });
it('renders edit and delete actions when canBlogWrite is true', async () => { it('renders edit and delete actions when canBlogWrite is true', async () => {

View File

@@ -4,6 +4,7 @@ import { m } from '$lib/paraglide/messages.js';
import BackButton from '$lib/shared/primitives/BackButton.svelte'; import BackButton from '$lib/shared/primitives/BackButton.svelte';
import TypeSelector from './TypeSelector.svelte'; import TypeSelector from './TypeSelector.svelte';
import StoryCreate from './StoryCreate.svelte'; import StoryCreate from './StoryCreate.svelte';
import JourneyCreate from './JourneyCreate.svelte';
import type { PageData } from './$types'; import type { PageData } from './$types';
let { data }: { data: PageData } = $props(); let { data }: { data: PageData } = $props();
@@ -19,12 +20,7 @@ let { data }: { data: PageData } = $props();
{#if data.selectedType === 'STORY'} {#if data.selectedType === 'STORY'}
<StoryCreate initialPersons={data.initialPersons} /> <StoryCreate initialPersons={data.initialPersons} />
{:else if data.selectedType === 'JOURNEY'} {:else if data.selectedType === 'JOURNEY'}
<div data-testid="journey-placeholder"> <JourneyCreate />
<p class="mb-4 font-sans text-base text-ink-2">{m.journey_placeholder_heading()}</p>
<a href="/geschichten/new" class="font-sans text-sm text-ink-3 underline hover:text-ink">
{m.journey_placeholder_back()}
</a>
</div>
{:else} {:else}
<TypeSelector onweiter={(type) => goto(`/geschichten/new?type=${type}`)} /> <TypeSelector onweiter={(type) => goto(`/geschichten/new?type=${type}`)} />
{/if} {/if}

View File

@@ -0,0 +1,90 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { m } from '$lib/paraglide/messages.js';
import { getErrorMessage } from '$lib/shared/errors';
import { csrfFetch } from '$lib/shared/cookies';
let title = $state('');
let titleTouched = $state(false);
let submitting = $state(false);
let errorMessage: string | null = $state(null);
const titleEmpty = $derived(title.trim().length === 0);
const showTitleError = $derived(titleEmpty && titleTouched);
async function handleSubmit(e: SubmitEvent) {
e.preventDefault();
titleTouched = true;
if (titleEmpty) return;
submitting = true;
errorMessage = null;
try {
const res = await csrfFetch('/api/geschichten', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: title.trim(),
type: 'JOURNEY',
status: 'DRAFT',
body: '',
personIds: []
})
});
if (!res.ok) {
const code = (await res.json().catch(() => ({})))?.code;
errorMessage = getErrorMessage(code);
return;
}
const created = await res.json();
goto(`/geschichten/${created.id}/edit`);
} catch (e) {
console.error('JourneyCreate submit failed', e);
errorMessage = getErrorMessage(undefined);
} finally {
submitting = false;
}
}
</script>
<form onsubmit={handleSubmit} class="max-w-lg">
{#if errorMessage}
<div
class="mb-4 rounded border border-danger bg-danger/10 p-3 text-sm text-danger"
role="alert"
>
{errorMessage}
</div>
{/if}
<div class="mb-4">
<input
type="text"
bind:value={title}
maxlength="255"
onblur={() => (titleTouched = true)}
placeholder={m.geschichte_editor_title_placeholder()}
aria-label={m.journey_title_aria_label()}
aria-invalid={showTitleError}
class="block w-full rounded border px-3 py-2 font-serif text-lg text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring {showTitleError
? 'border-danger'
: 'border-line'}"
/>
{#if showTitleError}
<p class="mt-1 font-sans text-xs text-danger">{m.geschichte_editor_title_required()}</p>
{/if}
</div>
<div class="flex items-center gap-4">
<button
type="submit"
disabled={submitting}
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:opacity-50"
>
{m.journey_create_submit()}
</button>
<a href="/geschichten/new" class="font-sans text-sm text-ink-3 underline hover:text-ink">
{m.journey_placeholder_back()}
</a>
</div>
</form>

View File

@@ -0,0 +1,89 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page, userEvent } from 'vitest/browser';
import { m } from '$lib/paraglide/messages.js';
import { getErrorMessage } from '$lib/shared/errors';
vi.mock('$app/navigation', () => ({
goto: vi.fn()
}));
const { default: JourneyCreate } = await import('./JourneyCreate.svelte');
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
describe('JourneyCreate — failure path', () => {
it('renders the mapped error message when POST /api/geschichten fails with a code', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: false,
json: vi.fn().mockResolvedValue({ code: 'VALIDATION_ERROR' })
})
);
render(JourneyCreate, {});
await userEvent.fill(
page.getByRole('textbox', { name: m.journey_title_aria_label() }),
'Meine Lesereise'
);
await userEvent.click(page.getByRole('button', { name: m.journey_create_submit() }));
const alert = page.getByRole('alert');
await expect.element(alert).toBeInTheDocument();
await expect.element(alert).toHaveTextContent(getErrorMessage('VALIDATION_ERROR'));
});
it('navigates to the edit page on success', async () => {
const { goto } = await import('$app/navigation');
vi.mocked(goto).mockClear();
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue({ id: 'g-new' })
})
);
render(JourneyCreate, {});
await userEvent.fill(
page.getByRole('textbox', { name: m.journey_title_aria_label() }),
'Meine Lesereise'
);
await userEvent.click(page.getByRole('button', { name: m.journey_create_submit() }));
await vi.waitFor(() => {
expect(goto).toHaveBeenCalledWith('/geschichten/g-new/edit');
});
});
it('shows an error alert when the network request rejects (no crash)', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('network down')));
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
render(JourneyCreate, {});
await userEvent.fill(
page.getByRole('textbox', { name: m.journey_title_aria_label() }),
'Meine Lesereise'
);
await userEvent.click(page.getByRole('button', { name: m.journey_create_submit() }));
await expect.element(page.getByRole('alert')).toBeInTheDocument();
consoleError.mockRestore();
});
it('has an accessible label on the title input', async () => {
vi.stubGlobal('fetch', vi.fn());
render(JourneyCreate, {});
await expect
.element(page.getByRole('textbox', { name: m.journey_title_aria_label() }))
.toBeInTheDocument();
});
});

View File

@@ -31,10 +31,18 @@ async function handleSubmit(payload: {
if (!res.ok) { if (!res.ok) {
const code = (await res.json().catch(() => ({})))?.code; const code = (await res.json().catch(() => ({})))?.code;
errorMessage = getErrorMessage(code); errorMessage = getErrorMessage(code);
return; throw new Error('create failed');
} }
const created = await res.json(); const created = await res.json();
goto(`/geschichten/${created.id}`); goto(`/geschichten/${created.id}`);
} catch (e) {
if (!errorMessage) {
console.error('Story create failed', e);
errorMessage = getErrorMessage(undefined);
}
// Contract: onSubmit rejects on failure — GeschichteEditor catches and
// keeps its dirty state instead of disarming the unsaved-changes guard.
throw e;
} finally { } finally {
submitting = false; submitting = false;
} }

View File

@@ -73,14 +73,13 @@ describe('geschichten/new page', () => {
await expect.element(page.getByRole('radiogroup')).toBeVisible(); await expect.element(page.getByRole('radiogroup')).toBeVisible();
}); });
it('shows JOURNEY placeholder when selectedType is JOURNEY', async () => { it('shows JourneyCreate form when selectedType is JOURNEY', async () => {
render(GeschichtenNewPage, { props: { data: baseData({ selectedType: 'JOURNEY' }) } }); render(GeschichtenNewPage, { props: { data: baseData({ selectedType: 'JOURNEY' }) } });
const placeholder = document.querySelector('[data-testid="journey-placeholder"]'); await expect.element(page.getByRole('button', { name: /Lesereise erstellen/i })).toBeVisible();
expect(placeholder).not.toBeNull();
}); });
it('JOURNEY placeholder offers a return-to-selection link', async () => { it('JOURNEY create form offers a return-to-selection link', async () => {
render(GeschichtenNewPage, { props: { data: baseData({ selectedType: 'JOURNEY' }) } }); render(GeschichtenNewPage, { props: { data: baseData({ selectedType: 'JOURNEY' }) } });
const backLink = page.getByRole('link', { name: /andere Auswahl/i }); const backLink = page.getByRole('link', { name: /andere Auswahl/i });

View File

@@ -0,0 +1,115 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
vi.mock('$lib/shared/api.server', () => ({
createApiClient: vi.fn(),
extractErrorCode: (e: unknown) => (e as { code?: string } | undefined)?.code
}));
import { load } from './+page.server';
import { createApiClient } from '$lib/shared/api.server';
beforeEach(() => vi.clearAllMocks());
const VALID_UUID = '11111111-2222-3333-4444-555555555555';
function makeUrl(params: Record<string, string | string[]> = {}) {
const url = new URL('http://localhost/geschichten');
for (const [key, value] of Object.entries(params)) {
if (Array.isArray(value)) {
value.forEach((v) => url.searchParams.append(key, v));
} else {
url.searchParams.set(key, value);
}
}
return url;
}
function mockApi() {
const mockGet = vi.fn().mockResolvedValue({
response: { ok: true, status: 200 },
data: []
});
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
typeof createApiClient
>);
return mockGet;
}
function callLoad(url: URL) {
return load({
url,
request: new Request('http://localhost/geschichten'),
fetch: vi.fn() as unknown as typeof fetch
});
}
// ─── documentId filter forwarding ─────────────────────────────────────────────
describe('geschichten page load — documentId filter', () => {
it('passes a valid documentId to the geschichten API', async () => {
const mockGet = mockApi();
await callLoad(makeUrl({ documentId: VALID_UUID }));
expect(mockGet).toHaveBeenCalledWith(
'/api/geschichten',
expect.objectContaining({
params: expect.objectContaining({
query: expect.objectContaining({ documentId: VALID_UUID })
})
})
);
});
it('omits documentId from the API call when the value is not a UUID', async () => {
const mockGet = mockApi();
await callLoad(makeUrl({ documentId: 'not-a-uuid' }));
const query = mockGet.mock.calls[0][1].params.query;
expect(query.documentId).toBeUndefined();
});
it('omits documentId from the API call when the param is absent', async () => {
const mockGet = mockApi();
await callLoad(makeUrl());
const query = mockGet.mock.calls[0][1].params.query;
expect(query.documentId).toBeUndefined();
});
it('returns documentIdFilter in page data when a valid documentId is given', async () => {
mockApi();
const result = await callLoad(makeUrl({ documentId: VALID_UUID }));
expect(result.documentIdFilter).toBe(VALID_UUID);
});
it('returns null documentIdFilter when documentId is invalid', async () => {
mockApi();
const result = await callLoad(makeUrl({ documentId: 'not-a-uuid' }));
expect(result.documentIdFilter).toBeNull();
});
it('keeps forwarding personId filters alongside documentId', async () => {
const mockGet = mockApi();
await callLoad(makeUrl({ documentId: VALID_UUID, personId: [VALID_UUID] }));
expect(mockGet).toHaveBeenCalledWith(
'/api/geschichten',
expect.objectContaining({
params: expect.objectContaining({
query: expect.objectContaining({
documentId: VALID_UUID,
personId: [VALID_UUID]
})
})
})
);
});
});

View File

@@ -26,7 +26,7 @@ const baseData = (overrides: Record<string, unknown> = {}) => ({
title: string; title: string;
body?: string; body?: string;
publishedAt?: string; publishedAt?: string;
author?: { firstName?: string; lastName?: string; email: string }; author?: { firstName?: string; lastName?: string };
}>, }>,
personFilters: [] as { id?: string; displayName: string }[], personFilters: [] as { id?: string; displayName: string }[],
documentFilter: null, documentFilter: null,
@@ -35,6 +35,14 @@ const baseData = (overrides: Record<string, unknown> = {}) => ({
}); });
describe('geschichten/+ page', () => { describe('geschichten/+ page', () => {
it('uses the same directory width as Dokumente/Personen overviews (max-w-7xl)', async () => {
render(GeschichtenListPage, { props: { data: baseData() } });
const container = document.querySelector('[class*="mx-auto"]');
expect(container).not.toBeNull();
expect(container!.className).toContain('max-w-7xl');
});
it('renders the page heading', async () => { it('renders the page heading', async () => {
render(GeschichtenListPage, { props: { data: baseData() } }); render(GeschichtenListPage, { props: { data: baseData() } });
@@ -127,7 +135,7 @@ describe('geschichten/+ page', () => {
title: 'Reise nach Berlin', title: 'Reise nach Berlin',
body: '<p>Im Jahr 1923...</p>', body: '<p>Im Jahr 1923...</p>',
publishedAt: '2026-04-15T10:00:00Z', publishedAt: '2026-04-15T10:00:00Z',
author: { firstName: 'Anna', lastName: 'Schmidt', email: 'a@x' } author: { firstName: 'Anna', lastName: 'Schmidt' }
} }
] ]
}) })
@@ -139,7 +147,7 @@ describe('geschichten/+ page', () => {
.toBeVisible(); .toBeVisible();
}); });
it('authorName falls back to email when first/last names are missing', async () => { it('authorName falls back to [Unbekannt] when first/last names are missing', async () => {
render(GeschichtenListPage, { render(GeschichtenListPage, {
props: { props: {
data: baseData({ data: baseData({
@@ -147,14 +155,14 @@ describe('geschichten/+ page', () => {
{ {
id: 'g1', id: 'g1',
title: 'Anonym', title: 'Anonym',
author: { email: 'anon@example.com' } author: {}
} }
] ]
}) })
} }
}); });
expect(document.body.textContent).toContain('anon@example.com'); expect(document.body.textContent).toContain('[Unbekannt]');
}); });
it('authorName renders empty when author is undefined', async () => { it('authorName renders empty when author is undefined', async () => {
@@ -178,7 +186,7 @@ describe('geschichten/+ page', () => {
{ {
id: 'g1', id: 'g1',
title: 'Draft', title: 'Draft',
author: { firstName: 'Anna', lastName: 'Schmidt', email: 'a@x' } author: { firstName: 'Anna', lastName: 'Schmidt' }
} }
] ]
}) })
@@ -202,7 +210,7 @@ describe('geschichten/+ page', () => {
id: 'g1', id: 'g1',
title: 'No Body', title: 'No Body',
body: '', body: '',
author: { firstName: 'Anna', lastName: 'Schmidt', email: 'a@x' } author: { firstName: 'Anna', lastName: 'Schmidt' }
} }
] ]
}) })

View File

@@ -34,6 +34,9 @@
--color-overlay: var(--c-overlay); --color-overlay: var(--c-overlay);
--color-muted: var(--c-muted); --color-muted: var(--c-muted);
/* Reading sheet — article panel between canvas and the white cards it contains */
--color-sheet: var(--c-sheet);
/* Borders */ /* Borders */
--color-line: var(--c-line); --color-line: var(--c-line);
--color-line-2: var(--c-line-2); --color-line-2: var(--c-line-2);
@@ -77,11 +80,21 @@
--color-warning: #b45309; --color-warning: #b45309;
--color-warning-fg: #ffffff; --color-warning-fg: #ffffff;
/* Warning surface — amber banner (bg/border/text), mode-aware */
--color-warning-bg: var(--c-warning-bg);
--color-warning-border: var(--c-warning-border);
--color-warning-text: var(--c-warning-text);
/* Journey / Lesereise — orange semantic tokens (badge, interlude block, annotation) */ /* Journey / Lesereise — orange semantic tokens (badge, interlude block, annotation) */
--color-journey-tint: var(--c-journey-bg); --color-journey-tint: var(--c-journey-bg);
--color-journey: var(--c-journey-text); --color-journey: var(--c-journey-text);
--color-journey-border: var(--c-journey-border); --color-journey-border: var(--c-journey-border);
/* Interlude row — neutral surface with left accent border; ZWISCHENTEXT label */
--color-interlude-bg: var(--c-interlude-bg);
--color-interlude-border: var(--c-interlude-border);
--color-interlude-label: var(--c-interlude-label);
/* Static brand tokens (not themed) */ /* Static brand tokens (not themed) */
--color-brand-navy: var(--palette-navy); --color-brand-navy: var(--palette-navy);
--color-brand-mint: var(--palette-mint); --color-brand-mint: var(--palette-mint);
@@ -96,6 +109,7 @@
--c-surface: #ffffff; --c-surface: #ffffff;
--c-overlay: #ffffff; --c-overlay: #ffffff;
--c-muted: #f5f4ef; --c-muted: #f5f4ef;
--c-sheet: #fafaf7; /* between canvas and surface — spec .g-article value */
--c-line: #e4e2d7; --c-line: #e4e2d7;
--c-line-2: #eeede8; --c-line-2: #eeede8;
@@ -139,6 +153,16 @@
--c-journey-text: #7a3f0e; --c-journey-text: #7a3f0e;
--c-journey-border: #f0c99a; --c-journey-border: #f0c99a;
/* Interlude (Zwischentext) — neutral warm surface with left accent border */
--c-interlude-bg: #f5f4f0;
--c-interlude-border: #a1dcd8;
--c-interlude-label: #4b5563;
/* Warning surface — amber banner; text #92400E on #FFFBEB ≈ 7.7:1 — WCAG AAA ✓ */
--c-warning-bg: #fffbeb;
--c-warning-border: #fcd34d;
--c-warning-text: #92400e;
/* Tag color tokens — decorative dot colors on tag chips */ /* Tag color tokens — decorative dot colors on tag chips */
--c-tag-sage: #5a8a6a; --c-tag-sage: #5a8a6a;
--c-tag-sienna: #a0522d; --c-tag-sienna: #a0522d;
@@ -193,6 +217,7 @@
--c-surface: #011526; --c-surface: #011526;
--c-overlay: #011e38; --c-overlay: #011e38;
--c-muted: #011a30; --c-muted: #011a30;
--c-sheet: #011222; /* between canvas and surface */
--c-line: #0d3358; --c-line: #0d3358;
--c-line-2: #092843; --c-line-2: #092843;
@@ -263,6 +288,17 @@
--c-journey-bg: #3a2a1a; --c-journey-bg: #3a2a1a;
--c-journey-text: #e8862a; --c-journey-text: #e8862a;
--c-journey-border: #7a4a1e; --c-journey-border: #7a4a1e;
/* Interlude (Zwischentext) — KEEP IN SYNC with :root[data-theme='dark'] */
--c-interlude-bg: #151c22;
--c-interlude-border: #00c7b1;
--c-interlude-label: #8b97a5;
/* Warning surface — muted amber on dark; text #FBD38D on #2A2113 ≈ 9.5:1 — WCAG AAA ✓
KEEP IN SYNC with :root[data-theme='dark'] */
--c-warning-bg: #2a2113;
--c-warning-border: #6d5417;
--c-warning-text: #fbd38d;
} }
} }
@@ -275,6 +311,7 @@
--c-surface: #011526; --c-surface: #011526;
--c-overlay: #011e38; --c-overlay: #011e38;
--c-muted: #011a30; --c-muted: #011a30;
--c-sheet: #011222; /* between canvas and surface */
--c-line: #0d3358; --c-line: #0d3358;
--c-line-2: #092843; --c-line-2: #092843;
@@ -343,6 +380,16 @@
--c-journey-bg: #3a2a1a; --c-journey-bg: #3a2a1a;
--c-journey-text: #e8862a; --c-journey-text: #e8862a;
--c-journey-border: #7a4a1e; --c-journey-border: #7a4a1e;
/* Interlude (Zwischentext) — KEEP IN SYNC with the @media block above */
--c-interlude-bg: #151c22;
--c-interlude-border: #00c7b1;
--c-interlude-label: #8b97a5;
/* Warning surface — KEEP IN SYNC with the @media block above */
--c-warning-bg: #2a2113;
--c-warning-border: #6d5417;
--c-warning-text: #fbd38d;
} }
/* ─── 6. Icon inversion — De Gruyter icons are black SVGs loaded as <img> ──── */ /* ─── 6. Icon inversion — De Gruyter icons are black SVGs loaded as <img> ──── */