Compare commits

..

10 Commits

Author SHA1 Message Date
Marcel
b698f9f223 test(persons): add seventh GET mock for the geschichten API call
Some checks failed
CI / Backend Unit Tests (push) Failing after 3m24s
CI / Unit & Component Tests (push) Failing after 4m56s
CI / OCR Service Tests (push) Successful in 50s
CI / Unit & Component Tests (pull_request) Failing after 3m51s
CI / OCR Service Tests (pull_request) Successful in 40s
CI / Backend Unit Tests (pull_request) Failing after 3m18s
The /persons/[id] +page.server.ts now fetches geschichten in parallel with
the other endpoints. Each test in this spec mocks the typed-client's GET
call sequentially, so each chain needs one extra resolved value.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 18:12:50 +02:00
Marcel
ed270f68e1 feat(geschichten): wire discovery integrations on Person and Document pages
Person detail (/persons/[id]):
- Server load fetches GET /api/geschichten?status=PUBLISHED&personId={id}
  in parallel with the existing person/document queries.
- Renders <GeschichtenCard> below the received-documents list when the
  person has at least one published story.

Document detail (/documents/[id]):
- Server load adds the same parallel call with documentId={id}.
- DocumentTopBar gains geschichten + canBlogWrite props that flow through
  to DocumentMetadataDrawer.
- DocumentMetadataDrawer's grid expands to lg:grid-cols-4 when the
  Geschichten column should appear (stories exist OR user can author),
  and shows "+ Geschichte anhängen" / "Alle anzeigen" links following the
  >= 3-story threshold from issue comment #5758.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 18:01:19 +02:00
Marcel
fe1014a08a feat(geschichten): add /geschichten routes (index, detail, new, edit)
- /geschichten — published-stories index with filter pills + "+ Neue Geschichte"
  for BLOG_WRITERs; supports ?personId and ?documentId pre-filtering
- /geschichten/[id] — reader detail with sanitised {@html} body, person and
  document chip sections, BLOG_WRITER edit/delete with confirm dialog
- /geschichten/new — editor with optional ?personId and ?documentId pre-fill
  (silent ignore on unknown IDs to avoid leaking entity existence)
- /geschichten/[id]/edit — editor populated from existing story; BLOG_WRITE
  guard redirects readers to the detail page

All routes load via createApiClient(fetch) with !response.ok error handling
following the project pattern; PATCH/DELETE go through raw fetch which the
Vite dev proxy / Caddy production proxy authenticates via cookie.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 17:54:31 +02:00
Marcel
9e6efacbcb feat(geschichten): add stripHtml util and GeschichtenCard component
stripHtml() strips tags via DOMParser (browser) with a regex fallback for
SSR. plainExcerpt() truncates at a word boundary with an ellipsis. Both
covered by Vitest specs.

GeschichtenCard renders the top 3 published stories about a person on
/persons/[id], with an editorial excerpt, publication date, author, and a
"+ Geschichte schreiben" link visible only to BLOG_WRITERs. Footer link to
/geschichten?personId=... appears once geschichten.length >= 3.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 17:50:58 +02:00
Marcel
ab3e633a0c feat(geschichten): add GeschichteEditor with Tiptap toolbar
Tiptap StarterKit configured for B/I/¶/H2/H3/UL/OL/history; code, codeBlock,
blockquote, strike, horizontalRule and hardBreak disabled to keep output
matching the backend HTML allow-list. Two-column responsive layout with the
editor body on the left and Personen / Dokumente / Status sections in the
sidebar. Sticky save bar adapts to DRAFT vs PUBLISHED state. Title-required
guard with inline error and beforeNavigate dirty-state guard.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 17:49:10 +02:00
Marcel
b381b2078a feat(geschichten): add DocumentMultiSelect chip + typeahead component
Mirrors PersonMultiSelect for documents: chip-style multi-select backed by
GET /api/documents/search?q=. Used in the Geschichte editor sidebar to link
referenced documents to a story.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 17:46:02 +02:00
Marcel
9e7861fa03 feat(geschichten): frontend foundation — canBlogWrite, sanitize util, nav, i18n
- Derives canBlogWrite in +layout.server.ts the same way as canAnnotate.
- Adds Geschichten link to AppNav (desktop + mobile, between Stammbaum and Admin).
- Adds error_geschichte_not_found mapping to errors.ts and translation keys
  for the Geschichten index, detail, editor, and confirmation copy in
  de/en/es.
- Adds isomorphic-dompurify-backed safeHtml() helper with allow-list
  matching the backend OWASP policy (p/br/strong/em/h2/h3/ul/ol/li),
  plus Vitest spec.
- Updates legacy spec test data so the new required canBlogWrite layout
  prop type-checks.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 17:43:29 +02:00
Marcel
afd6d0b20d chore(api): regenerate types with Geschichte endpoints
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 17:36:13 +02:00
Marcel
e5024fc804 test(geschichte): add Testcontainers integration test and fix V58 author FK
The end-to-end test creates a DRAFT, verifies it is hidden from a READ_ALL
reader (list and getById), publishes it, verifies the reader sees it, then
deletes it and confirms the join rows go with it but the linked Person
remains. Also corrects the V58 author FK to reference the actual users
table (not app_users).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 17:33:52 +02:00
Marcel
9fc96a15cf feat(geschichte): add REST controller with BLOG_WRITE permission gates
GET endpoints are open to authenticated users (the service layer enforces
DRAFT visibility). POST/PATCH/DELETE require @RequirePermission(BLOG_WRITE).
WebMvcTest slice covers 401/403/200/201/204 paths.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 17:31:43 +02:00
43 changed files with 2826 additions and 36 deletions

View File

@@ -0,0 +1,65 @@
package org.raddatz.familienarchiv.controller;
import lombok.RequiredArgsConstructor;
import org.raddatz.familienarchiv.dto.GeschichteUpdateDTO;
import org.raddatz.familienarchiv.model.Geschichte;
import org.raddatz.familienarchiv.model.GeschichteStatus;
import org.raddatz.familienarchiv.security.Permission;
import org.raddatz.familienarchiv.security.RequirePermission;
import org.raddatz.familienarchiv.service.GeschichteService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.UUID;
@RestController
@RequestMapping("/api/geschichten")
@RequiredArgsConstructor
public class GeschichteController {
private final GeschichteService geschichteService;
@GetMapping
public List<Geschichte> list(
@RequestParam(required = false) GeschichteStatus status,
@RequestParam(required = false) UUID personId,
@RequestParam(required = false) UUID documentId,
@RequestParam(required = false, defaultValue = "50") int limit) {
return geschichteService.list(status, personId, documentId, limit);
}
@GetMapping("/{id}")
public Geschichte getById(@PathVariable UUID id) {
return geschichteService.getById(id);
}
@PostMapping
@RequirePermission(Permission.BLOG_WRITE)
public ResponseEntity<Geschichte> create(@RequestBody GeschichteUpdateDTO dto) {
Geschichte created = geschichteService.create(dto);
return ResponseEntity.status(HttpStatus.CREATED).body(created);
}
@PatchMapping("/{id}")
@RequirePermission(Permission.BLOG_WRITE)
public Geschichte update(@PathVariable UUID id, @RequestBody GeschichteUpdateDTO dto) {
return geschichteService.update(id, dto);
}
@DeleteMapping("/{id}")
@RequirePermission(Permission.BLOG_WRITE)
public ResponseEntity<Void> delete(@PathVariable UUID id) {
geschichteService.delete(id);
return ResponseEntity.noContent().build();
}
}

View File

@@ -6,7 +6,7 @@ CREATE TABLE geschichten (
title VARCHAR(255) NOT NULL,
body TEXT,
status VARCHAR(32) NOT NULL,
author_id UUID REFERENCES app_users (id) ON DELETE SET NULL,
author_id UUID REFERENCES users (id) ON DELETE SET NULL,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
published_at TIMESTAMP

View File

@@ -0,0 +1,221 @@
package org.raddatz.familienarchiv.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.config.SecurityConfig;
import org.raddatz.familienarchiv.dto.GeschichteUpdateDTO;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.Geschichte;
import org.raddatz.familienarchiv.model.GeschichteStatus;
import org.raddatz.familienarchiv.security.PermissionAspect;
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
import org.raddatz.familienarchiv.service.GeschichteService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import java.time.LocalDateTime;
import java.util.HashSet;
import java.util.List;
import java.util.UUID;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest(GeschichteController.class)
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
class GeschichteControllerTest {
@Autowired
MockMvc mockMvc;
private final ObjectMapper objectMapper = new ObjectMapper();
@MockitoBean
GeschichteService geschichteService;
@MockitoBean
CustomUserDetailsService customUserDetailsService;
// ─── GET /api/geschichten ────────────────────────────────────────────────
@Test
void list_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(get("/api/geschichten"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(authorities = "READ_ALL")
void list_returns200_forReader() throws Exception {
when(geschichteService.list(any(), any(), any(), anyInt()))
.thenReturn(List.of(published(UUID.randomUUID(), "Story A")));
mockMvc.perform(get("/api/geschichten"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].title").value("Story A"));
}
@Test
@WithMockUser(authorities = "READ_ALL")
void list_passesPersonIdFilterToService() throws Exception {
UUID personId = UUID.randomUUID();
when(geschichteService.list(any(), eq(personId), any(), anyInt()))
.thenReturn(List.of());
mockMvc.perform(get("/api/geschichten").param("personId", personId.toString()))
.andExpect(status().isOk());
verify(geschichteService).list(any(), eq(personId), any(), anyInt());
}
// ─── GET /api/geschichten/{id} ───────────────────────────────────────────
@Test
@WithMockUser(authorities = "READ_ALL")
void getById_returns200_whenFound() throws Exception {
UUID id = UUID.randomUUID();
when(geschichteService.getById(id)).thenReturn(published(id, "Hello"));
mockMvc.perform(get("/api/geschichten/{id}", id))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(id.toString()))
.andExpect(jsonPath("$.title").value("Hello"));
}
@Test
@WithMockUser(authorities = "READ_ALL")
void getById_returns404_whenServiceThrowsNotFound() throws Exception {
UUID id = UUID.randomUUID();
when(geschichteService.getById(id))
.thenThrow(DomainException.notFound(ErrorCode.GESCHICHTE_NOT_FOUND, "x"));
mockMvc.perform(get("/api/geschichten/{id}", id))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("GESCHICHTE_NOT_FOUND"));
}
// ─── POST /api/geschichten ───────────────────────────────────────────────
@Test
void create_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(post("/api/geschichten")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"title\":\"x\"}"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(authorities = "READ_ALL")
void create_returns403_whenLackingBlogWrite() throws Exception {
mockMvc.perform(post("/api/geschichten")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"title\":\"x\"}"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "BLOG_WRITE")
void create_returns201_withBlogWrite() throws Exception {
UUID id = UUID.randomUUID();
when(geschichteService.create(any(GeschichteUpdateDTO.class)))
.thenReturn(draft(id, "New"));
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setTitle("New");
mockMvc.perform(post("/api/geschichten")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(dto)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").value(id.toString()));
}
// ─── PATCH /api/geschichten/{id} ─────────────────────────────────────────
@Test
@WithMockUser(authorities = "READ_ALL")
void update_returns403_whenLackingBlogWrite() throws Exception {
mockMvc.perform(patch("/api/geschichten/{id}", UUID.randomUUID())
.contentType(MediaType.APPLICATION_JSON)
.content("{}"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "BLOG_WRITE")
void update_returns200_withBlogWrite() throws Exception {
UUID id = UUID.randomUUID();
when(geschichteService.update(eq(id), any(GeschichteUpdateDTO.class)))
.thenReturn(published(id, "Updated"));
mockMvc.perform(patch("/api/geschichten/{id}", id)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"status\":\"PUBLISHED\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("PUBLISHED"));
}
// ─── DELETE /api/geschichten/{id} ────────────────────────────────────────
@Test
@WithMockUser(authorities = "READ_ALL")
void delete_returns403_whenLackingBlogWrite() throws Exception {
mockMvc.perform(delete("/api/geschichten/{id}", UUID.randomUUID()))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "BLOG_WRITE")
void delete_returns204_withBlogWrite() throws Exception {
UUID id = UUID.randomUUID();
mockMvc.perform(delete("/api/geschichten/{id}", id))
.andExpect(status().isNoContent());
verify(geschichteService).delete(id);
}
// ─── helpers ─────────────────────────────────────────────────────────────
private Geschichte published(UUID id, String title) {
return Geschichte.builder()
.id(id)
.title(title)
.body("<p>x</p>")
.status(GeschichteStatus.PUBLISHED)
.publishedAt(LocalDateTime.now())
.createdAt(LocalDateTime.now())
.updatedAt(LocalDateTime.now())
.persons(new HashSet<>())
.documents(new HashSet<>())
.build();
}
private Geschichte draft(UUID id, String title) {
return Geschichte.builder()
.id(id)
.title(title)
.status(GeschichteStatus.DRAFT)
.createdAt(LocalDateTime.now())
.updatedAt(LocalDateTime.now())
.persons(new HashSet<>())
.documents(new HashSet<>())
.build();
}
}

View File

@@ -0,0 +1,127 @@
package org.raddatz.familienarchiv.service;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.dto.GeschichteUpdateDTO;
import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.model.Geschichte;
import org.raddatz.familienarchiv.model.GeschichteStatus;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.repository.AppUserRepository;
import org.raddatz.familienarchiv.repository.GeschichteRepository;
import org.raddatz.familienarchiv.repository.PersonRepository;
import org.raddatz.familienarchiv.security.Permission;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import software.amazon.awssdk.services.s3.S3Client;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@ActiveProfiles("test")
@Import(PostgresContainerConfig.class)
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
class GeschichteServiceIntegrationTest {
@MockitoBean
S3Client s3Client;
@Autowired GeschichteService geschichteService;
@Autowired GeschichteRepository geschichteRepository;
@Autowired PersonRepository personRepository;
@Autowired AppUserRepository appUserRepository;
AppUser writer;
AppUser reader;
@BeforeEach
void seed() {
writer = appUserRepository.save(AppUser.builder()
.email("writer-int@test")
.password("hash")
.build());
reader = appUserRepository.save(AppUser.builder()
.email("reader-int@test")
.password("hash")
.build());
}
@AfterEach
void clear() {
SecurityContextHolder.clearContext();
}
@Test
void create_then_publish_then_read_then_delete_full_lifecycle() {
// Create as writer
authenticateAs(writer, Permission.BLOG_WRITE);
Person franz = personRepository.save(Person.builder().firstName("Franz").lastName("Raddatz").build());
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setTitle("Erinnerung an Opa Franz");
dto.setBody("<p>Ich erinnere mich, wie er <strong>jeden Sonntag</strong> sang.</p>"
+ "<script>alert('xss')</script>");
dto.setPersonIds(List.of(franz.getId()));
Geschichte created = geschichteService.create(dto);
assertThat(created.getId()).isNotNull();
assertThat(created.getStatus()).isEqualTo(GeschichteStatus.DRAFT);
assertThat(created.getBody())
.contains("<strong>jeden Sonntag</strong>")
.doesNotContain("<script>");
// Reader cannot see DRAFT in list
authenticateAs(reader, Permission.READ_ALL);
assertThat(geschichteService.list(null, null, null, 50)).isEmpty();
// Reader cannot fetch DRAFT by id (404 via GESCHICHTE_NOT_FOUND)
UUID draftId = created.getId();
org.assertj.core.api.Assertions.assertThatThrownBy(() -> geschichteService.getById(draftId))
.hasMessageContaining("not found");
// Publish as writer
authenticateAs(writer, Permission.BLOG_WRITE);
GeschichteUpdateDTO publishDto = new GeschichteUpdateDTO();
publishDto.setStatus(GeschichteStatus.PUBLISHED);
Geschichte publishedGesch = geschichteService.update(draftId, publishDto);
assertThat(publishedGesch.getPublishedAt()).isNotNull();
// Reader can now see and fetch it
authenticateAs(reader, Permission.READ_ALL);
assertThat(geschichteService.list(null, null, null, 50)).hasSize(1);
assertThat(geschichteService.list(null, franz.getId(), null, 50)).hasSize(1);
Geschichte fetched = geschichteService.getById(draftId);
assertThat(fetched.getTitle()).isEqualTo("Erinnerung an Opa Franz");
assertThat(fetched.getPersons()).extracting(Person::getId).containsExactly(franz.getId());
// Delete as writer; join rows go with it
authenticateAs(writer, Permission.BLOG_WRITE);
geschichteService.delete(draftId);
assertThat(geschichteRepository.findById(draftId)).isEmpty();
// The Person itself is untouched (cascade only flows from Geschichte to join table)
assertThat(personRepository.findById(franz.getId())).isPresent();
}
private void authenticateAs(AppUser user, Permission... permissions) {
var authorities = java.util.Arrays.stream(permissions)
.map(p -> new SimpleGrantedAuthority(p.name()))
.toList();
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(user.getEmail(), null, authorities));
}
}

View File

@@ -919,6 +919,56 @@
"bulk_edit_count_pill": "{count} werden bearbeitet",
"nav_stammbaum": "Stammbaum",
"nav_geschichten": "Geschichten",
"error_geschichte_not_found": "Die Geschichte wurde nicht gefunden.",
"geschichten_index_title": "Geschichten",
"geschichten_new_button": "Neue Geschichte",
"geschichten_filter_all_pill": "Alle",
"geschichten_filter_choose_person": "Person wählen",
"geschichten_filter_aria_label": "Person filtern",
"geschichten_empty_for_person": "Keine Geschichten für {name} gefunden.",
"geschichten_empty_no_filter": "Es gibt noch keine veröffentlichten Geschichten.",
"geschichten_back_to_index": "Zurück zu Geschichten",
"geschichten_published_on": "veröffentlicht am {date}",
"geschichten_persons_section": "Personen in dieser Geschichte",
"geschichten_documents_section": "Erwähnte Dokumente",
"geschichten_card_heading": "Geschichten",
"geschichten_card_write_action": "+ Geschichte schreiben",
"geschichten_card_attach_action": "+ Geschichte anhängen",
"geschichten_card_show_all_for_person": "Alle Geschichten zu {name}",
"geschichten_card_show_all": "Alle anzeigen",
"geschichte_editor_title_placeholder": "Titel der Geschichte",
"geschichte_editor_body_placeholder": "Schreibe hier deine Geschichte…",
"geschichte_editor_status_draft": "ENTWURF",
"geschichte_editor_status_published": "VERÖFFENTLICHT",
"geschichte_editor_status_draft_hint": "Noch nicht öffentlich sichtbar.",
"geschichte_editor_status_published_hint": "Öffentlich sichtbar für alle Leser.",
"geschichte_editor_save_hint_draft": "Alle Änderungen werden als Entwurf gespeichert.",
"geschichte_editor_save_hint_published": "Änderungen sind sofort live.",
"geschichte_editor_save_draft": "Entwurf speichern",
"geschichte_editor_publish": "Veröffentlichen",
"geschichte_editor_save": "Speichern",
"geschichte_editor_unpublish": "Zurück zu Entwurf",
"geschichte_editor_title_required": "Bitte gib einen Titel ein.",
"geschichte_editor_unsaved_changes": "Du hast ungespeicherte Änderungen — wirklich verlassen?",
"geschichte_editor_personen_heading": "Personen",
"geschichte_editor_personen_hint": "Welche historischen Personen kommen in dieser Geschichte vor?",
"geschichte_editor_dokumente_heading": "Dokumente",
"geschichte_editor_dokumente_hint": "Welche Briefe oder Dokumente sind Teil dieser Geschichte?",
"geschichte_editor_search_person": "Person suchen…",
"geschichte_editor_search_document": "Dokument suchen…",
"geschichte_editor_toolbar_bold": "Fett (Strg+B)",
"geschichte_editor_toolbar_italic": "Kursiv (Strg+I)",
"geschichte_editor_toolbar_h2": "Überschrift",
"geschichte_editor_toolbar_h3": "Unterüberschrift",
"geschichte_editor_toolbar_ul": "Aufzählung",
"geschichte_editor_toolbar_ol": "Nummerierte Liste",
"geschichte_delete_confirm_title": "Geschichte löschen?",
"geschichte_delete_confirm_body": "Diese Aktion kann nicht rückgängig gemacht werden. Die Geschichte wird dauerhaft gelöscht und aus allen verlinkten Personen- und Dokumentseiten entfernt.",
"error_relationship_not_found": "Die Beziehung wurde nicht gefunden.",
"error_circular_relationship": "Diese Beziehung würde einen Kreis erzeugen.",

View File

@@ -919,6 +919,56 @@
"bulk_edit_count_pill": "{count} will be edited",
"nav_stammbaum": "Family tree",
"nav_geschichten": "Stories",
"error_geschichte_not_found": "The story was not found.",
"geschichten_index_title": "Stories",
"geschichten_new_button": "New story",
"geschichten_filter_all_pill": "All",
"geschichten_filter_choose_person": "Choose person",
"geschichten_filter_aria_label": "Filter by person",
"geschichten_empty_for_person": "No stories found for {name}.",
"geschichten_empty_no_filter": "There are no published stories yet.",
"geschichten_back_to_index": "Back to stories",
"geschichten_published_on": "published on {date}",
"geschichten_persons_section": "People in this story",
"geschichten_documents_section": "Referenced documents",
"geschichten_card_heading": "Stories",
"geschichten_card_write_action": "+ Write a story",
"geschichten_card_attach_action": "+ Attach a story",
"geschichten_card_show_all_for_person": "All stories about {name}",
"geschichten_card_show_all": "Show all",
"geschichte_editor_title_placeholder": "Story title",
"geschichte_editor_body_placeholder": "Write your story here…",
"geschichte_editor_status_draft": "DRAFT",
"geschichte_editor_status_published": "PUBLISHED",
"geschichte_editor_status_draft_hint": "Not yet visible to readers.",
"geschichte_editor_status_published_hint": "Visible to all readers.",
"geschichte_editor_save_hint_draft": "All changes are saved as a draft.",
"geschichte_editor_save_hint_published": "Changes go live immediately.",
"geschichte_editor_save_draft": "Save draft",
"geschichte_editor_publish": "Publish",
"geschichte_editor_save": "Save",
"geschichte_editor_unpublish": "Back to draft",
"geschichte_editor_title_required": "Please enter a title.",
"geschichte_editor_unsaved_changes": "You have unsaved changes — leave anyway?",
"geschichte_editor_personen_heading": "People",
"geschichte_editor_personen_hint": "Which historical persons appear in this story?",
"geschichte_editor_dokumente_heading": "Documents",
"geschichte_editor_dokumente_hint": "Which letters or documents are part of this story?",
"geschichte_editor_search_person": "Search person…",
"geschichte_editor_search_document": "Search document…",
"geschichte_editor_toolbar_bold": "Bold (Ctrl+B)",
"geschichte_editor_toolbar_italic": "Italic (Ctrl+I)",
"geschichte_editor_toolbar_h2": "Heading",
"geschichte_editor_toolbar_h3": "Subheading",
"geschichte_editor_toolbar_ul": "Bulleted list",
"geschichte_editor_toolbar_ol": "Numbered list",
"geschichte_delete_confirm_title": "Delete story?",
"geschichte_delete_confirm_body": "This action cannot be undone. The story will be permanently deleted and removed from all linked person and document pages.",
"error_relationship_not_found": "Relationship not found.",
"error_circular_relationship": "This relationship would form a cycle.",

View File

@@ -919,6 +919,56 @@
"bulk_edit_count_pill": "Se editarán {count}",
"nav_stammbaum": "Árbol genealógico",
"nav_geschichten": "Historias",
"error_geschichte_not_found": "No se encontró la historia.",
"geschichten_index_title": "Historias",
"geschichten_new_button": "Nueva historia",
"geschichten_filter_all_pill": "Todas",
"geschichten_filter_choose_person": "Elegir persona",
"geschichten_filter_aria_label": "Filtrar por persona",
"geschichten_empty_for_person": "No hay historias para {name}.",
"geschichten_empty_no_filter": "Aún no hay historias publicadas.",
"geschichten_back_to_index": "Volver a Historias",
"geschichten_published_on": "publicada el {date}",
"geschichten_persons_section": "Personas en esta historia",
"geschichten_documents_section": "Documentos mencionados",
"geschichten_card_heading": "Historias",
"geschichten_card_write_action": "+ Escribir historia",
"geschichten_card_attach_action": "+ Adjuntar historia",
"geschichten_card_show_all_for_person": "Todas las historias sobre {name}",
"geschichten_card_show_all": "Mostrar todas",
"geschichte_editor_title_placeholder": "Título de la historia",
"geschichte_editor_body_placeholder": "Escribe tu historia aquí…",
"geschichte_editor_status_draft": "BORRADOR",
"geschichte_editor_status_published": "PUBLICADA",
"geschichte_editor_status_draft_hint": "Aún no visible para lectores.",
"geschichte_editor_status_published_hint": "Visible para todos los lectores.",
"geschichte_editor_save_hint_draft": "Los cambios se guardan como borrador.",
"geschichte_editor_save_hint_published": "Los cambios se publican inmediatamente.",
"geschichte_editor_save_draft": "Guardar borrador",
"geschichte_editor_publish": "Publicar",
"geschichte_editor_save": "Guardar",
"geschichte_editor_unpublish": "Volver a borrador",
"geschichte_editor_title_required": "Por favor ingresa un título.",
"geschichte_editor_unsaved_changes": "Tienes cambios no guardados — ¿salir igualmente?",
"geschichte_editor_personen_heading": "Personas",
"geschichte_editor_personen_hint": "¿Qué personas históricas aparecen en esta historia?",
"geschichte_editor_dokumente_heading": "Documentos",
"geschichte_editor_dokumente_hint": "¿Qué cartas o documentos forman parte de esta historia?",
"geschichte_editor_search_person": "Buscar persona…",
"geschichte_editor_search_document": "Buscar documento…",
"geschichte_editor_toolbar_bold": "Negrita (Ctrl+B)",
"geschichte_editor_toolbar_italic": "Cursiva (Ctrl+I)",
"geschichte_editor_toolbar_h2": "Encabezado",
"geschichte_editor_toolbar_h3": "Subencabezado",
"geschichte_editor_toolbar_ul": "Lista con viñetas",
"geschichte_editor_toolbar_ol": "Lista numerada",
"geschichte_delete_confirm_title": "¿Eliminar historia?",
"geschichte_delete_confirm_body": "Esta acción no se puede deshacer. La historia se eliminará permanentemente y se quitará de todas las páginas de personas y documentos vinculados.",
"error_relationship_not_found": "La relación no fue encontrada.",
"error_circular_relationship": "Esta relación crearía un ciclo.",

View File

@@ -12,7 +12,7 @@
"@tiptap/extension-mention": "3.22.5",
"@tiptap/starter-kit": "3.22.5",
"diff": "^8.0.3",
"dompurify": "^3.4.2",
"isomorphic-dompurify": "^3.12.0",
"openapi-fetch": "^0.13.5",
"pdfjs-dist": "^5.5.207"
},
@@ -29,7 +29,6 @@
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.17",
"@types/diff": "^7.0.2",
"@types/dompurify": "^3.0.5",
"@types/node": "^24",
"@vitest/browser-playwright": "^4.0.10",
"@vitest/coverage-v8": "^4.1.0",
@@ -53,6 +52,53 @@
"vitest-browser-svelte": "^2.0.1"
}
},
"node_modules/@asamuzakjp/css-color": {
"version": "5.1.11",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz",
"integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==",
"license": "MIT",
"dependencies": {
"@asamuzakjp/generational-cache": "^1.0.1",
"@csstools/css-calc": "^3.2.0",
"@csstools/css-color-parser": "^4.1.0",
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/dom-selector": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz",
"integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==",
"license": "MIT",
"dependencies": {
"@asamuzakjp/generational-cache": "^1.0.1",
"@asamuzakjp/nwsapi": "^2.3.9",
"bidi-js": "^1.0.3",
"css-tree": "^3.2.1",
"is-potential-custom-element-name": "^1.0.1"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/generational-cache": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz",
"integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==",
"license": "MIT",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/nwsapi": {
"version": "2.3.9",
"resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
"integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
"license": "MIT"
},
"node_modules/@axe-core/playwright": {
"version": "4.11.1",
"resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.11.1.tgz",
@@ -148,6 +194,152 @@
"dev": true,
"license": "MIT"
},
"node_modules/@bramus/specificity": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
"integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
"license": "MIT",
"dependencies": {
"css-tree": "^3.0.0"
},
"bin": {
"specificity": "bin/cli.js"
}
},
"node_modules/@csstools/color-helpers": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz",
"integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/@csstools/css-calc": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.0.tgz",
"integrity": "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-color-parser": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.0.tgz",
"integrity": "sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"dependencies": {
"@csstools/color-helpers": "^6.0.2",
"@csstools/css-calc": "^3.2.0"
},
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-parser-algorithms": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
"integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-syntax-patches-for-csstree": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.3.tgz",
"integrity": "sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"peerDependencies": {
"css-tree": "^3.2.1"
},
"peerDependenciesMeta": {
"css-tree": {
"optional": true
}
}
},
"node_modules/@csstools/css-tokenizer": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
"integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz",
@@ -768,6 +960,23 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@exodus/bytes": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz",
"integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==",
"license": "MIT",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
},
"peerDependencies": {
"@noble/hashes": "^1.8.0 || ^2.0.0"
},
"peerDependenciesMeta": {
"@noble/hashes": {
"optional": true
}
}
},
"node_modules/@humanfs/core": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@@ -2622,16 +2831,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/dompurify": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz",
"integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/trusted-types": "*"
}
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -3319,6 +3518,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/bidi-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
"integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
"license": "MIT",
"dependencies": {
"require-from-string": "^2.0.2"
}
},
"node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
@@ -3507,6 +3715,19 @@
"node": ">= 8"
}
},
"node_modules/css-tree": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
"integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
"license": "MIT",
"dependencies": {
"mdn-data": "2.27.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
}
},
"node_modules/cssesc": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@@ -3520,6 +3741,19 @@
"node": ">=4"
}
},
"node_modules/data-urls": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
"integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
"license": "MIT",
"dependencies": {
"whatwg-mimetype": "^5.0.0",
"whatwg-url": "^16.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -3538,6 +3772,12 @@
}
}
},
"node_modules/decimal.js": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
"license": "MIT"
},
"node_modules/dedent": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.1.tgz",
@@ -3619,6 +3859,18 @@
"node": ">=10.13.0"
}
},
"node_modules/entities": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
"integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/es-module-lexer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz",
@@ -4105,6 +4357,18 @@
"node": ">= 0.4"
}
},
"node_modules/html-encoding-sniffer": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
"integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.6.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/html-escaper": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
@@ -4232,6 +4496,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/is-potential-custom-element-name": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
"license": "MIT"
},
"node_modules/is-reference": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz",
@@ -4249,6 +4519,19 @@
"dev": true,
"license": "ISC"
},
"node_modules/isomorphic-dompurify": {
"version": "3.12.0",
"resolved": "https://registry.npmjs.org/isomorphic-dompurify/-/isomorphic-dompurify-3.12.0.tgz",
"integrity": "sha512-8n+j+6ypTHvriJwFOQ2qusQ6bzGjZVcR3jbe1pBpLcGI1dn4WIl0ctLBngqE5QttquQBAlKXwJeTMw+X7x7qKw==",
"license": "MIT",
"dependencies": {
"dompurify": "^3.4.2",
"jsdom": "^29.1.1"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24.0.0"
}
},
"node_modules/istanbul-lib-coverage": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
@@ -4335,6 +4618,46 @@
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/jsdom": {
"version": "29.1.1",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz",
"integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==",
"license": "MIT",
"dependencies": {
"@asamuzakjp/css-color": "^5.1.11",
"@asamuzakjp/dom-selector": "^7.1.1",
"@bramus/specificity": "^2.4.2",
"@csstools/css-syntax-patches-for-csstree": "^1.1.3",
"@exodus/bytes": "^1.15.0",
"css-tree": "^3.2.1",
"data-urls": "^7.0.0",
"decimal.js": "^10.6.0",
"html-encoding-sniffer": "^6.0.0",
"is-potential-custom-element-name": "^1.0.1",
"lru-cache": "^11.3.5",
"parse5": "^8.0.1",
"saxes": "^6.0.0",
"symbol-tree": "^3.2.4",
"tough-cookie": "^6.0.1",
"undici": "^7.25.0",
"w3c-xmlserializer": "^5.0.0",
"webidl-conversions": "^8.0.1",
"whatwg-mimetype": "^5.0.0",
"whatwg-url": "^16.0.1",
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24.0.0"
},
"peerDependencies": {
"canvas": "^3.0.0"
},
"peerDependenciesMeta": {
"canvas": {
"optional": true
}
}
},
"node_modules/json-buffer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
@@ -4727,6 +5050,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/lru-cache": {
"version": "11.3.5",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz",
"integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==",
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -4765,6 +5097,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/mdn-data": {
"version": "2.27.1",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
"integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
"license": "CC0-1.0"
},
"node_modules/mini-svg-data-uri": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz",
@@ -4995,6 +5333,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/parse5": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
"integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
"license": "MIT",
"dependencies": {
"entities": "^8.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -5500,7 +5850,6 @@
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -5524,7 +5873,6 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -5625,6 +5973,18 @@
"node": ">=6"
}
},
"node_modules/saxes": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
"license": "ISC",
"dependencies": {
"xmlchars": "^2.2.0"
},
"engines": {
"node": ">=v12.22.7"
}
},
"node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
@@ -5694,7 +6054,6 @@
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
@@ -5872,6 +6231,12 @@
"@types/estree": "^1.0.6"
}
},
"node_modules/symbol-tree": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
"license": "MIT"
},
"node_modules/tailwindcss": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz",
@@ -5937,6 +6302,24 @@
"node": ">=14.0.0"
}
},
"node_modules/tldts": {
"version": "7.0.30",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.30.tgz",
"integrity": "sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw==",
"license": "MIT",
"dependencies": {
"tldts-core": "^7.0.30"
},
"bin": {
"tldts": "bin/cli.js"
}
},
"node_modules/tldts-core": {
"version": "7.0.30",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.30.tgz",
"integrity": "sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q==",
"license": "MIT"
},
"node_modules/totalist": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
@@ -5947,6 +6330,30 @@
"node": ">=6"
}
},
"node_modules/tough-cookie": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz",
"integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==",
"license": "BSD-3-Clause",
"dependencies": {
"tldts": "^7.0.5"
},
"engines": {
"node": ">=16"
}
},
"node_modules/tr46": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
"integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"engines": {
"node": ">=20"
}
},
"node_modules/ts-api-utils": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz",
@@ -6024,6 +6431,15 @@
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/undici": {
"version": "7.25.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz",
"integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==",
"license": "MIT",
"engines": {
"node": ">=20.18.1"
}
},
"node_modules/undici-types": {
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
@@ -6335,6 +6751,27 @@
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
},
"node_modules/w3c-xmlserializer": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
"integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
"license": "MIT",
"dependencies": {
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/webidl-conversions": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
"integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=20"
}
},
"node_modules/webpack-virtual-modules": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
@@ -6342,6 +6779,29 @@
"dev": true,
"license": "MIT"
},
"node_modules/whatwg-mimetype": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
"integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
"license": "MIT",
"engines": {
"node": ">=20"
}
},
"node_modules/whatwg-url": {
"version": "16.0.1",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
"integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.11.0",
"tr46": "^6.0.0",
"webidl-conversions": "^8.0.1"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -6407,6 +6867,21 @@
}
}
},
"node_modules/xml-name-validator": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
"license": "Apache-2.0",
"engines": {
"node": ">=18"
}
},
"node_modules/xmlchars": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"license": "MIT"
},
"node_modules/yaml-ast-parser": {
"version": "0.0.43",
"resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz",

View File

@@ -25,7 +25,7 @@
"@tiptap/extension-mention": "3.22.5",
"@tiptap/starter-kit": "3.22.5",
"diff": "^8.0.3",
"dompurify": "^3.4.2",
"isomorphic-dompurify": "^3.12.0",
"openapi-fetch": "^0.13.5",
"pdfjs-dist": "^5.5.207"
},
@@ -42,7 +42,6 @@
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.17",
"@types/diff": "^7.0.2",
"@types/dompurify": "^3.0.5",
"@types/node": "^24",
"@vitest/browser-playwright": "^4.0.10",
"@vitest/coverage-v8": "^4.1.0",

View File

@@ -7,6 +7,12 @@ import RelationshipPill from '$lib/components/RelationshipPill.svelte';
type Person = { id: string; firstName?: string | null; lastName: string; displayName: string };
type Tag = { id: string; name: string };
type GeschichteSummary = {
id: string;
title: string;
publishedAt?: string;
author?: { firstName?: string; lastName?: string; email: string };
};
type Props = {
documentDate: string | null;
@@ -16,6 +22,9 @@ type Props = {
receivers: Person[];
tags: Tag[];
inferredRelationship?: { labelFromA: string; labelFromB: string } | null;
geschichten?: GeschichteSummary[];
documentId?: string;
canBlogWrite?: boolean;
};
let {
@@ -25,10 +34,30 @@ let {
sender,
receivers,
tags,
inferredRelationship = null
inferredRelationship = null,
geschichten = [],
documentId,
canBlogWrite = false
}: Props = $props();
const VISIBLE_RECEIVER_LIMIT = 5;
const VISIBLE_GESCHICHTEN_LIMIT = 3;
const showGeschichtenColumn = $derived(geschichten.length > 0 || canBlogWrite);
const visibleGeschichten = $derived(geschichten.slice(0, VISIBLE_GESCHICHTEN_LIMIT));
const hasGeschichtenOverflow = $derived(geschichten.length >= VISIBLE_GESCHICHTEN_LIMIT);
const gridClass = $derived(showGeschichtenColumn ? 'lg:grid-cols-4' : 'lg:grid-cols-3');
function formatGeschichteAuthor(g: GeschichteSummary): string {
const a = g.author;
if (!a) return '';
const full = [a.firstName, a.lastName].filter(Boolean).join(' ').trim();
return full || a.email || '';
}
function formatGeschichteDate(g: GeschichteSummary): string {
if (!g.publishedAt) return '';
return formatDate(g.publishedAt.slice(0, 10), 'short');
}
const formattedDate = $derived(documentDate ? formatDate(documentDate) : '—');
const displayLocation = $derived(location ?? '—');
@@ -67,7 +96,7 @@ function getFullName(person: Person): string {
{/snippet}
<div class="border-b border-line p-6">
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
<div class="grid grid-cols-1 gap-6 {gridClass}">
<!-- Column 1: Details -->
<div>
<h2 class="mb-4 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
@@ -159,5 +188,51 @@ function getFullName(person: Person): string {
<p class="font-serif text-sm text-ink-3">{m.doc_details_no_tags()}</p>
{/if}
</div>
<!-- Column 4: Geschichten (visible when stories exist or user can author) -->
{#if showGeschichtenColumn}
<div>
<div class="mb-4 flex items-center justify-between">
<h2 class="font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.geschichten_card_heading()}
</h2>
{#if canBlogWrite && documentId}
<a
href="/geschichten/new?documentId={documentId}"
class="font-sans text-xs font-medium text-ink/60 hover:text-ink"
>
{m.geschichten_card_attach_action()}
</a>
{/if}
</div>
{#if geschichten.length === 0}
<p class="font-serif text-sm text-ink-3"></p>
{:else}
<ul class="space-y-2 font-serif text-sm">
{#each visibleGeschichten as g (g.id)}
<li>
<a href="/geschichten/{g.id}" class="block text-ink hover:underline">
{g.title}
</a>
<p class="font-sans text-xs text-ink-3">
{formatGeschichteAuthor(g)}
{#if formatGeschichteDate(g)}· {formatGeschichteDate(g)}{/if}
</p>
</li>
{/each}
</ul>
{#if hasGeschichtenOverflow && documentId}
<a
href="/geschichten?documentId={documentId}"
class="mt-3 inline-flex font-sans text-xs font-medium text-ink hover:underline"
>
{m.geschichten_card_show_all()}
</a>
{/if}
{/if}
</div>
{/if}
</div>
</div>

View File

@@ -0,0 +1,147 @@
<script lang="ts">
import type { components } from '$lib/generated/api';
import { m } from '$lib/paraglide/messages.js';
import { clickOutside } from '$lib/actions/clickOutside';
import { formatDate } from '$lib/utils/date';
type Document = components['schemas']['Document'];
type DocumentSearchItem = components['schemas']['DocumentSearchItem'];
interface Props {
selectedDocuments?: Document[];
placeholder?: string;
hiddenInputName?: string;
}
let {
selectedDocuments = $bindable([]),
placeholder = m.geschichte_editor_search_document(),
hiddenInputName = 'documentIds'
}: Props = $props();
let searchTerm = $state('');
let results: Document[] = $state([]);
let showDropdown = $state(false);
let loading = $state(false);
let debounceTimer: ReturnType<typeof setTimeout>;
let inputEl: HTMLInputElement;
let dropdownStyle = $state('');
function updateDropdownPosition() {
if (!inputEl) return;
const rect = inputEl.getBoundingClientRect();
dropdownStyle = `position:fixed;top:${rect.bottom + 4}px;left:${rect.left}px;width:${rect.width}px`;
}
function handleInput() {
showDropdown = true;
clearTimeout(debounceTimer);
debounceTimer = setTimeout(async () => {
if (searchTerm.length < 1) {
results = [];
return;
}
loading = true;
try {
const res = await fetch(`/api/documents/search?q=${encodeURIComponent(searchTerm)}&size=10`);
if (res.ok) {
const body: { items: DocumentSearchItem[] } = await res.json();
const docs = body.items.map((it) => it.document);
results = docs.filter((d) => !selectedDocuments.some((s) => s.id === d.id));
}
} catch {
results = [];
} finally {
loading = false;
}
}, 300);
}
function selectDocument(doc: Document) {
selectedDocuments = [...selectedDocuments, doc];
searchTerm = '';
showDropdown = false;
results = [];
}
function removeDocument(id: string | undefined) {
selectedDocuments = selectedDocuments.filter((d) => d.id !== id);
}
function formatDocLabel(doc: Document): string {
if (doc.documentDate) return `${doc.title} · ${formatDate(doc.documentDate, 'short')}`;
return doc.title;
}
</script>
<svelte:window onscroll={updateDropdownPosition} onresize={updateDropdownPosition} />
{#each selectedDocuments as doc (doc.id)}
<input type="hidden" name={hiddenInputName} value={doc.id} />
{/each}
<div class="relative" use:clickOutside onclickoutside={() => (showDropdown = false)}>
<div
class="flex min-h-[38px] flex-wrap gap-2 rounded border border-line bg-surface p-2 shadow-sm focus-within:ring-2 focus-within:ring-focus-ring focus-within:outline-none"
>
{#each selectedDocuments as doc (doc.id)}
<span
class="inline-flex items-center gap-1 rounded bg-muted px-2 py-1 text-sm font-medium text-ink"
>
{formatDocLabel(doc)}
<button
type="button"
onclick={() => removeDocument(doc.id)}
class="ml-0.5 text-ink/50 hover:text-red-500 focus:outline-none"
aria-label={m.comp_multiselect_remove()}
>
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</span>
{/each}
<input
bind:this={inputEl}
type="text"
autocomplete="off"
bind:value={searchTerm}
oninput={handleInput}
onfocus={() => {
updateDropdownPosition();
showDropdown = true;
}}
placeholder={placeholder}
class="min-w-[120px] flex-1 border-none bg-transparent p-1 text-sm outline-none focus:ring-0"
/>
</div>
{#if showDropdown && (results.length > 0 || loading)}
<div
style={dropdownStyle}
class="ring-opacity-5 z-50 max-h-60 overflow-auto rounded-md bg-surface py-1 text-base shadow-lg ring-1 ring-black sm:text-sm"
>
{#if loading}
<div class="p-2 text-sm text-ink-2">{m.comp_multiselect_loading()}</div>
{:else}
{#each results as doc (doc.id)}
<div
class="cursor-pointer py-2 pr-9 pl-3 text-ink select-none hover:bg-muted"
onclick={() => selectDocument(doc)}
onkeydown={(e) => e.key === 'Enter' && selectDocument(doc)}
role="button"
tabindex="0"
>
{formatDocLabel(doc)}
</div>
{/each}
{/if}
</div>
{/if}
</div>

View File

@@ -25,12 +25,21 @@ type Doc = {
tags?: Tag[] | null;
};
type GeschichteSummary = {
id: string;
title: string;
publishedAt?: string;
author?: { firstName?: string; lastName?: string; email: string };
};
type Props = {
doc: Doc;
canWrite: boolean;
fileUrl: string;
transcribeMode: boolean;
inferredRelationship?: { labelFromA: string; labelFromB: string } | null;
geschichten?: GeschichteSummary[];
canBlogWrite?: boolean;
};
let {
@@ -38,7 +47,9 @@ let {
canWrite,
fileUrl,
transcribeMode = $bindable(),
inferredRelationship = null
inferredRelationship = null,
geschichten = [],
canBlogWrite = false
}: Props = $props();
let detailsOpen = $state(false);
@@ -283,6 +294,9 @@ let mobileMenuOpen = $state(false);
receivers={doc.receivers ? [...doc.receivers] : []}
tags={doc.tags ? [...doc.tags] : []}
inferredRelationship={inferredRelationship}
geschichten={geschichten}
documentId={doc.id}
canBlogWrite={canBlogWrite}
/>
</div>
{/if}

View File

@@ -0,0 +1,329 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { beforeNavigate } from '$app/navigation';
import { Editor } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
import { m } from '$lib/paraglide/messages.js';
import type { components } from '$lib/generated/api';
import PersonMultiSelect from './PersonMultiSelect.svelte';
import DocumentMultiSelect from './DocumentMultiSelect.svelte';
type Geschichte = components['schemas']['Geschichte'];
type Person = components['schemas']['Person'];
type Document = components['schemas']['Document'];
interface Props {
geschichte?: Geschichte | null;
initialPersons?: Person[];
initialDocuments?: Document[];
onSubmit: (payload: {
title: string;
body: string;
status: 'DRAFT' | 'PUBLISHED';
personIds: string[];
documentIds: string[];
}) => Promise<void>;
submitting?: boolean;
}
let {
geschichte = null,
initialPersons = [],
initialDocuments = [],
onSubmit,
submitting = false
}: Props = $props();
// Initial-state snapshot from incoming props. The editor owns these values
// after mount; the parent should re-mount the component with a different
// `geschichte` to reset (consistent with how form components in this codebase
// behave — see DocumentEdit page).
let title = $state(geschichte?.title ?? '');
let body = $state(geschichte?.body ?? '');
let status: 'DRAFT' | 'PUBLISHED' = $state(geschichte?.status ?? 'DRAFT');
let selectedPersons: Person[] = $state(
geschichte?.persons ? Array.from(geschichte.persons) : initialPersons
);
let selectedDocuments: Document[] = $state(
geschichte?.documents ? Array.from(geschichte.documents) : initialDocuments
);
let dirty = $state(false);
let titleTouched = $state(false);
const titleEmpty = $derived(title.trim().length === 0);
const isDraft = $derived(status === 'DRAFT');
const showTitleError = $derived(titleEmpty && titleTouched);
let editorEl: HTMLDivElement;
let editor: Editor | null = null;
let toolbarVersion = $state(0);
onMount(() => {
editor = new Editor({
element: editorEl,
content: body,
extensions: [
StarterKit.configure({
heading: { levels: [2, 3] },
code: false,
codeBlock: false,
blockquote: false,
strike: false,
horizontalRule: false,
hardBreak: false
})
],
editorProps: {
attributes: {
role: 'textbox',
'aria-multiline': 'true',
'aria-label': m.geschichte_editor_body_placeholder(),
class:
'prose max-w-none focus:outline-none min-h-[260px] font-serif text-base leading-relaxed'
}
},
onUpdate({ editor: ed }) {
body = ed.getHTML();
dirty = true;
},
onSelectionUpdate() {
toolbarVersion++;
},
onTransaction() {
toolbarVersion++;
}
});
});
onDestroy(() => {
editor?.destroy();
});
beforeNavigate(({ cancel }) => {
if (dirty && !submitting) {
const ok = window.confirm(m.geschichte_editor_unsaved_changes());
if (!ok) cancel();
}
});
function handleTitleBlur() {
titleTouched = true;
}
function handleTitleInput() {
dirty = true;
}
async function save(nextStatus: 'DRAFT' | 'PUBLISHED') {
titleTouched = true;
if (titleEmpty) return;
await onSubmit({
title: title.trim(),
body,
status: nextStatus,
personIds: selectedPersons.map((p) => p.id!).filter(Boolean),
documentIds: selectedDocuments.map((d) => d.id!).filter(Boolean)
});
dirty = false;
}
function isActive(name: string, attrs?: Record<string, unknown>): boolean {
void toolbarVersion;
return editor?.isActive(name, attrs) ?? false;
}
function exec(action: () => void) {
if (!editor) return;
action();
editor.commands.focus();
}
</script>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-[2fr_1fr]">
<!-- Editor column -->
<div class="flex flex-col gap-4">
<!-- Title -->
<div>
<input
type="text"
bind:value={title}
oninput={handleTitleInput}
onblur={handleTitleBlur}
placeholder={m.geschichte_editor_title_placeholder()}
aria-invalid={showTitleError}
aria-describedby={showTitleError ? 'title-error' : undefined}
class="block w-full rounded border border-line bg-surface px-3 py-3 font-serif text-2xl font-bold text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
{#if showTitleError}
<p id="title-error" class="mt-1 text-sm text-danger" role="alert">
{m.geschichte_editor_title_required()}
</p>
{/if}
</div>
<!-- Toolbar -->
<div
role="toolbar"
aria-label="Formatierung"
class="flex flex-wrap items-center gap-1 rounded border border-line bg-surface p-1"
>
<button
type="button"
onclick={() => exec(() => editor!.chain().focus().toggleBold().run())}
aria-label={m.geschichte_editor_toolbar_bold()}
aria-pressed={isActive('bold')}
title={m.geschichte_editor_toolbar_bold()}
class="inline-flex h-11 min-w-[44px] items-center justify-center rounded px-2 font-bold text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring aria-pressed:bg-accent-bg"
>
B
</button>
<button
type="button"
onclick={() => exec(() => editor!.chain().focus().toggleItalic().run())}
aria-label={m.geschichte_editor_toolbar_italic()}
aria-pressed={isActive('italic')}
title={m.geschichte_editor_toolbar_italic()}
class="inline-flex h-11 min-w-[44px] items-center justify-center rounded px-2 text-ink italic hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring aria-pressed:bg-accent-bg"
>
I
</button>
<span class="mx-1 h-6 w-px bg-line" aria-hidden="true"></span>
<button
type="button"
onclick={() => exec(() => editor!.chain().focus().toggleHeading({ level: 2 }).run())}
aria-label={m.geschichte_editor_toolbar_h2()}
aria-pressed={isActive('heading', { level: 2 })}
title={m.geschichte_editor_toolbar_h2()}
class="inline-flex h-11 min-w-[44px] items-center justify-center rounded px-2 text-sm font-bold text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring aria-pressed:bg-accent-bg"
>
H2
</button>
<button
type="button"
onclick={() => exec(() => editor!.chain().focus().toggleHeading({ level: 3 }).run())}
aria-label={m.geschichte_editor_toolbar_h3()}
aria-pressed={isActive('heading', { level: 3 })}
title={m.geschichte_editor_toolbar_h3()}
class="inline-flex h-11 min-w-[44px] items-center justify-center rounded px-2 text-sm font-bold text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring aria-pressed:bg-accent-bg"
>
H3
</button>
<span class="mx-1 h-6 w-px bg-line" aria-hidden="true"></span>
<button
type="button"
onclick={() => exec(() => editor!.chain().focus().toggleBulletList().run())}
aria-label={m.geschichte_editor_toolbar_ul()}
aria-pressed={isActive('bulletList')}
title={m.geschichte_editor_toolbar_ul()}
class="inline-flex h-11 min-w-[44px] items-center justify-center rounded px-2 text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring aria-pressed:bg-accent-bg"
>
</button>
<button
type="button"
onclick={() => exec(() => editor!.chain().focus().toggleOrderedList().run())}
aria-label={m.geschichte_editor_toolbar_ol()}
aria-pressed={isActive('orderedList')}
title={m.geschichte_editor_toolbar_ol()}
class="inline-flex h-11 min-w-[44px] items-center justify-center rounded px-2 text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring aria-pressed:bg-accent-bg"
>
1.
</button>
</div>
<!-- Editor surface -->
<div
class="rounded border border-line bg-surface p-4 shadow-sm focus-within:ring-2 focus-within:ring-focus-ring"
>
<div bind:this={editorEl} class="min-h-[260px]"></div>
</div>
</div>
<!-- Sidebar -->
<aside class="flex flex-col gap-6">
<section class="rounded border border-line bg-surface p-4 shadow-sm">
<h2 class="mb-1 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">Status</h2>
<p class="mb-3">
<span
class="inline-flex items-center rounded px-2 py-1 font-sans text-xs font-bold tracking-widest uppercase {isDraft
? 'bg-muted text-ink-2'
: 'bg-accent-bg text-ink'}"
>
{isDraft
? m.geschichte_editor_status_draft()
: m.geschichte_editor_status_published()}
</span>
</p>
<p class="font-sans text-xs text-ink-3">
{isDraft
? m.geschichte_editor_status_draft_hint()
: m.geschichte_editor_status_published_hint()}
</p>
</section>
<section class="rounded border border-line bg-surface p-4 shadow-sm">
<h2 class="mb-2 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.geschichte_editor_personen_heading()}
</h2>
<p class="mb-3 font-sans text-xs text-ink-3">{m.geschichte_editor_personen_hint()}</p>
<PersonMultiSelect bind:selectedPersons={selectedPersons} />
</section>
<section class="rounded border border-line bg-surface p-4 shadow-sm">
<h2 class="mb-2 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.geschichte_editor_dokumente_heading()}
</h2>
<p class="mb-3 font-sans text-xs text-ink-3">{m.geschichte_editor_dokumente_hint()}</p>
<DocumentMultiSelect bind:selectedDocuments={selectedDocuments} />
</section>
</aside>
</div>
<!-- Save bar -->
<div
class="sticky bottom-0 z-10 -mx-4 mt-6 flex flex-col gap-3 border-t border-line bg-surface px-6 py-4 shadow-[0_-2px_8px_rgba(0,0,0,0.06)] sm:flex-row sm:items-center sm:justify-between"
>
<p class="font-sans text-xs text-ink-3">
{isDraft
? m.geschichte_editor_save_hint_draft()
: m.geschichte_editor_save_hint_published()}
</p>
<div class="flex gap-2">
{#if isDraft}
<button
type="button"
onclick={() => save('DRAFT')}
disabled={submitting || titleEmpty}
class="inline-flex h-11 items-center rounded border border-line bg-surface px-4 font-sans text-sm font-medium text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:cursor-not-allowed disabled:opacity-50"
>
{m.geschichte_editor_save_draft()}
</button>
<button
type="button"
onclick={() => save('PUBLISHED')}
disabled={submitting || titleEmpty}
class="inline-flex h-11 items-center rounded bg-primary px-4 font-sans text-sm font-medium text-primary-fg hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:cursor-not-allowed disabled:opacity-50"
>
{m.geschichte_editor_publish()}
</button>
{:else}
<button
type="button"
onclick={() => save('DRAFT')}
disabled={submitting}
class="inline-flex h-11 items-center rounded border border-line bg-surface px-4 font-sans text-sm font-medium text-amber-700 hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:cursor-not-allowed disabled:opacity-50"
>
{m.geschichte_editor_unpublish()}
</button>
<button
type="button"
onclick={() => save('PUBLISHED')}
disabled={submitting || titleEmpty}
class="inline-flex h-11 items-center rounded bg-primary px-4 font-sans text-sm font-medium text-primary-fg hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:cursor-not-allowed disabled:opacity-50"
>
{m.geschichte_editor_save()}
</button>
{/if}
</div>
</div>

View File

@@ -0,0 +1,87 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import type { components } from '$lib/generated/api';
import { plainExcerpt } from '$lib/utils/stripHtml';
import { formatDate } from '$lib/utils/date';
type Geschichte = components['schemas']['Geschichte'];
interface Props {
geschichten: Geschichte[];
personId: string;
personName: string;
canWrite: boolean;
}
let { geschichten, personId, personName, canWrite }: Props = $props();
const visible = $derived(geschichten.slice(0, 3));
const hasOverflow = $derived(geschichten.length >= 3);
function formatPublishedDate(g: Geschichte): string | null {
if (!g.publishedAt) return null;
return formatDate(g.publishedAt.slice(0, 10), 'short');
}
function authorName(g: Geschichte): string {
const a = g.author;
if (!a) return '';
const full = [a.firstName, a.lastName].filter(Boolean).join(' ').trim();
return full || a.email || '';
}
</script>
{#if geschichten.length > 0}
<section
aria-labelledby="geschichten-card-heading"
class="rounded-sm border border-line bg-surface p-6 shadow-sm"
>
<header class="mb-5 flex items-center justify-between">
<h2
id="geschichten-card-heading"
class="font-sans text-xs font-bold tracking-widest text-ink-3 uppercase"
>
{m.geschichten_card_heading()}
</h2>
{#if canWrite}
<a
href="/geschichten/new?personId={personId}"
class="inline-flex items-center font-sans text-sm font-medium text-ink/60 hover:text-ink"
>
{m.geschichten_card_write_action()}
</a>
{/if}
</header>
<ul class="flex flex-col gap-4">
{#each visible as g (g.id)}
<li class="flex flex-col gap-1 border-b border-line pb-3 last:border-0 last:pb-0">
<a
href="/geschichten/{g.id}"
class="font-serif text-base font-bold text-ink hover:underline"
>
{g.title}
</a>
<p class="font-sans text-xs text-ink-3">
{authorName(g)}
{#if formatPublishedDate(g)}· {formatPublishedDate(g)}{/if}
</p>
{#if g.body}
<p class="font-serif text-sm text-ink-2">{plainExcerpt(g.body, 80)}</p>
{/if}
</li>
{/each}
</ul>
{#if hasOverflow}
<footer class="mt-4 border-t border-line pt-3">
<a
href="/geschichten?personId={personId}"
class="inline-flex items-center font-sans text-sm font-medium text-ink hover:underline"
>
{m.geschichten_card_show_all_for_person({ name: personName })}
</a>
</footer>
{/if}
</section>
{/if}

View File

@@ -41,6 +41,7 @@ export type ErrorCode =
| 'RELATIONSHIP_NOT_FOUND'
| 'CIRCULAR_RELATIONSHIP'
| 'DUPLICATE_RELATIONSHIP'
| 'GESCHICHTE_NOT_FOUND'
| 'MISSING_CREDENTIALS'
| 'UNAUTHORIZED'
| 'FORBIDDEN'
@@ -145,6 +146,8 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string {
return m.error_circular_relationship();
case 'DUPLICATE_RELATIONSHIP':
return m.error_duplicate_relationship();
case 'GESCHICHTE_NOT_FOUND':
return m.error_geschichte_not_found();
case 'MISSING_CREDENTIALS':
return m.login_error_missing_credentials();
case 'UNAUTHORIZED':

View File

@@ -388,6 +388,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/geschichten": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["list"];
put?: never;
post: operations["create"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/documents": {
parameters: {
query?: never;
@@ -692,6 +708,22 @@ export interface paths {
patch: operations["updateGroup"];
trace?: never;
};
"/api/geschichten/{id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getById"];
put?: never;
post?: never;
delete: operations["delete"];
options?: never;
head?: never;
patch: operations["update"];
trace?: never;
};
"/api/documents/{id}/training-labels": {
parameters: {
query?: never;
@@ -1807,6 +1839,31 @@ export interface components {
name?: string;
permissions?: string[];
};
GeschichteUpdateDTO: {
title?: string;
body?: string;
/** @enum {string} */
status?: "DRAFT" | "PUBLISHED";
personIds?: string[];
documentIds?: string[];
};
Geschichte: {
/** Format: uuid */
id: string;
title: string;
body?: string;
/** @enum {string} */
status: "DRAFT" | "PUBLISHED";
author?: components["schemas"]["AppUser"];
persons?: components["schemas"]["Person"][];
documents?: components["schemas"]["Document"][];
/** Format: date-time */
createdAt: string;
/** Format: date-time */
updatedAt: string;
/** Format: date-time */
publishedAt?: string;
};
CreateTranscriptionBlockDTO: {
/** Format: int32 */
pageNumber?: number;
@@ -3278,6 +3335,55 @@ export interface operations {
};
};
};
list: {
parameters: {
query?: {
status?: "DRAFT" | "PUBLISHED";
personId?: string;
documentId?: string;
limit?: number;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["Geschichte"][];
};
};
};
};
create: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["GeschichteUpdateDTO"];
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["Geschichte"];
};
};
};
};
createDocument: {
parameters: {
query?: never;
@@ -3846,6 +3952,74 @@ export interface operations {
};
};
};
getById: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["Geschichte"];
};
};
};
};
delete: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
update: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["GeschichteUpdateDTO"];
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["Geschichte"];
};
};
};
};
patchTrainingLabel: {
parameters: {
query?: never;

View File

@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest';
import { safeHtml } from './sanitize';
describe('safeHtml', () => {
it('returns empty string for null/undefined/empty input', () => {
expect(safeHtml(null)).toBe('');
expect(safeHtml(undefined)).toBe('');
expect(safeHtml('')).toBe('');
});
it('keeps allowed tags: p, strong, em, br, h2, h3, ul, ol, li', () => {
const html =
'<p><strong>bold</strong> <em>italic</em><br>x</p>' +
'<h2>H2</h2><h3>H3</h3>' +
'<ul><li>a</li></ul><ol><li>b</li></ol>';
const result = safeHtml(html);
expect(result).toContain('<strong>bold</strong>');
expect(result).toContain('<em>italic</em>');
expect(result).toContain('<br>');
expect(result).toContain('<h2>H2</h2>');
expect(result).toContain('<h3>H3</h3>');
expect(result).toContain('<ul>');
expect(result).toContain('<ol>');
expect(result).toContain('<li>a</li>');
});
it('strips <script> tags entirely', () => {
const result = safeHtml('<p>ok</p><script>alert(1)</script>');
expect(result).not.toContain('<script>');
expect(result).not.toContain('alert');
expect(result).toContain('<p>ok</p>');
});
it('strips on* event-handler attributes', () => {
const result = safeHtml('<p onclick="evil()">x</p>');
expect(result).not.toContain('onclick');
});
it('strips disallowed elements like <img>, <a>, <iframe>', () => {
const result = safeHtml(
'<p>x</p><img src="x" onerror="alert(1)"><a href="javascript:alert(1)">link</a><iframe></iframe>'
);
expect(result).not.toContain('<img');
expect(result).not.toContain('<a ');
expect(result).not.toContain('<iframe');
});
});

View File

@@ -0,0 +1,17 @@
import DOMPurify from 'isomorphic-dompurify';
const ALLOWED_TAGS = ['p', 'br', 'strong', 'em', 'h2', 'h3', 'ul', 'ol', 'li'];
/**
* Render-side sanitiser for Geschichte body HTML. The backend already
* sanitises with the OWASP allow-list on save, but we re-run on render
* because the API can be called directly and stored content can pre-date
* a tightening of the allow-list.
*/
export function safeHtml(raw: string | null | undefined): string {
if (!raw) return '';
return DOMPurify.sanitize(raw, {
ALLOWED_TAGS,
ALLOWED_ATTR: []
});
}

View File

@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest';
import { plainExcerpt, stripHtml } from './stripHtml';
describe('stripHtml', () => {
it('returns empty string for null/undefined/empty', () => {
expect(stripHtml(null)).toBe('');
expect(stripHtml(undefined)).toBe('');
expect(stripHtml('')).toBe('');
});
it('strips tags and preserves visible text', () => {
expect(stripHtml('<p>Hello <strong>world</strong></p>')).toBe('Hello world');
});
it('strips nested HTML', () => {
expect(stripHtml('<div><p>A</p><p>B</p></div>')).toBe('AB');
});
});
describe('plainExcerpt', () => {
it('returns full text when under the limit', () => {
expect(plainExcerpt('<p>short</p>', 80)).toBe('short');
});
it('truncates at the boundary with an ellipsis', () => {
const html = '<p>' + 'a'.repeat(100) + '</p>';
const out = plainExcerpt(html, 20);
expect(out.length).toBeLessThanOrEqual(21); // 20 chars + ellipsis
expect(out.endsWith('…')).toBe(true);
});
it('breaks at a word boundary when possible', () => {
const out = plainExcerpt('<p>The quick brown fox jumps over</p>', 18);
expect(out).toBe('The quick brown…');
});
});

View File

@@ -0,0 +1,23 @@
/**
* Strip HTML tags from a string and return the plain text.
* Uses DOMParser in the browser, falls back to a regex strip on the server
* (where DOMParser is not available without isomorphic-dompurify's JSDOM).
*/
export function stripHtml(html: string | null | undefined): string {
if (!html) return '';
if (typeof DOMParser === 'function') {
const doc = new DOMParser().parseFromString(html, 'text/html');
return (doc.body.textContent ?? '').trim();
}
return html.replace(/<[^>]*>/g, '').trim();
}
/**
* Strip HTML and truncate to a maximum length, appending an ellipsis when
* the source exceeds it. Used for editorial story excerpts.
*/
export function plainExcerpt(html: string | null | undefined, max = 80): string {
const text = stripHtml(html);
if (text.length <= max) return text;
return text.slice(0, max).replace(/\s+\S*$/, '') + '…';
}

View File

@@ -7,6 +7,7 @@ export const load: LayoutServerLoad = async ({ locals }) => {
canWrite: groups.some((g) => g.permissions.includes('WRITE_ALL')),
canAnnotate: groups.some(
(g) => g.permissions.includes('WRITE_ALL') || g.permissions.includes('ANNOTATE_ALL')
)
),
canBlogWrite: groups.some((g) => g.permissions.includes('BLOG_WRITE'))
};
};

View File

@@ -68,6 +68,16 @@ function handleOverlayKeydown(event: KeyboardEvent) {
>
{m.nav_stammbaum()}
</a>
<a
href="/geschichten"
class="my-2 inline-flex items-center px-3 font-sans text-xs font-bold tracking-widest uppercase transition-colors focus:outline-none focus-visible:rounded focus-visible:ring-2 focus-visible:ring-focus-ring
{page.url.pathname.startsWith('/geschichten')
? 'border-b-2 border-accent text-white'
: 'text-white/70 hover:text-white'}"
>
{m.nav_geschichten()}
</a>
{#if isAdmin}
<a
href="/admin"
@@ -170,6 +180,16 @@ function handleOverlayKeydown(event: KeyboardEvent) {
{m.nav_stammbaum()}
</a>
<a
href="/geschichten"
class="block flex min-h-[44px] w-full items-center px-4 py-3 font-sans text-sm font-bold tracking-widest uppercase transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-inset
{page.url.pathname.startsWith('/geschichten')
? 'bg-accent-bg text-ink'
: 'text-ink-2 hover:bg-muted hover:text-ink'}"
>
{m.nav_geschichten()}
</a>
{#if isAdmin}
<a
href="/admin"

View File

@@ -41,6 +41,7 @@ const baseData = {
user: undefined,
canWrite: true,
canAnnotate: false,
canBlogWrite: false,
editUser: makeUser(),
groups
};

View File

@@ -10,7 +10,13 @@ const groups = [
{ id: 'g2', name: 'Admins', permissions: ['ADMIN'] }
];
const baseData = { user: undefined, canWrite: true, canAnnotate: false, groups };
const baseData = {
user: undefined,
canWrite: true,
canAnnotate: false,
canBlogWrite: false,
groups
};
afterEach(cleanup);

View File

@@ -13,6 +13,7 @@ const baseData = {
user: undefined,
canWrite: true,
canAnnotate: false,
canBlogWrite: false,
documents: [],
initialValues: { senderName: '', receiverName: '' },
filters: { senderId: '', receiverId: '', from: '', to: '', dir: 'DESC' as const }

View File

@@ -7,7 +7,12 @@ export async function load({ params, fetch }) {
const { id } = params;
const api = createApiClient(fetch);
const docResult = await api.GET('/api/documents/{id}', { params: { path: { id } } });
const [docResult, geschichtenResult] = await Promise.all([
api.GET('/api/documents/{id}', { params: { path: { id } } }),
api.GET('/api/geschichten', {
params: { query: { status: 'PUBLISHED', documentId: id } }
})
]);
if (docResult.response.status === 401) throw redirect(302, '/login');
@@ -18,8 +23,9 @@ export async function load({ params, fetch }) {
const document = docResult.data!;
const inferredRelationship = await loadInferredRelationship(api, document);
const geschichten = geschichtenResult.data ?? [];
return { document, inferredRelationship };
return { document, inferredRelationship, geschichten };
}
async function loadInferredRelationship(

View File

@@ -424,6 +424,8 @@ onMount(() => {
fileUrl={fileLoader.fileUrl}
bind:transcribeMode={transcribeMode}
inferredRelationship={data.inferredRelationship}
geschichten={data.geschichten ?? []}
canBlogWrite={data.canBlogWrite ?? false}
/>
<div class="relative flex-1 overflow-hidden {transcribeMode ? 'flex flex-col md:flex-row' : ''}">

View File

@@ -11,6 +11,7 @@ const baseData = {
user: undefined,
canWrite: true,
canAnnotate: false,
canBlogWrite: false,
persons: [],
initialSenderId: '',
initialSenderName: '',

View File

@@ -0,0 +1,36 @@
import { error } from '@sveltejs/kit';
import { createApiClient } from '$lib/api.server';
import { getErrorMessage } from '$lib/errors';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ url, fetch }) => {
const api = createApiClient(fetch);
const personId = url.searchParams.get('personId') ?? undefined;
const documentId = url.searchParams.get('documentId') ?? undefined;
const [listResult, personResult] = await Promise.all([
api.GET('/api/geschichten', {
params: {
query: {
status: 'PUBLISHED',
personId,
documentId
}
}
}),
personId
? api.GET('/api/persons/{id}', { params: { path: { id: personId } } })
: Promise.resolve(null)
]);
if (!listResult.response.ok) {
const code = (listResult.error as unknown as { code?: string })?.code;
throw error(listResult.response.status, getErrorMessage(code));
}
return {
geschichten: listResult.data ?? [],
personFilter: personResult && personResult.response.ok ? personResult.data! : null,
documentFilter: documentId ?? null
};
};

View File

@@ -0,0 +1,130 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { m } from '$lib/paraglide/messages.js';
import { plainExcerpt } from '$lib/utils/stripHtml';
import { formatDate } from '$lib/utils/date';
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
let showPersonPicker = $state(false);
const filterName = $derived(data.personFilter?.displayName ?? '');
function clearFilter() {
const url = new URL(window.location.href);
url.searchParams.delete('personId');
url.searchParams.delete('documentId');
goto(url.pathname + url.search, { replaceState: true });
}
function pickPerson(personId: string) {
if (!personId) return;
const url = new URL(window.location.href);
url.searchParams.set('personId', personId);
url.searchParams.delete('documentId');
showPersonPicker = false;
goto(url.pathname + url.search);
}
function authorName(g: { author?: { firstName?: string; lastName?: string; email: string } }) {
const a = g.author;
if (!a) return '';
const full = [a.firstName, a.lastName].filter(Boolean).join(' ').trim();
return full || a.email || '';
}
function publishedAt(g: { publishedAt?: string }): string | null {
if (!g.publishedAt) return null;
return formatDate(g.publishedAt.slice(0, 10), 'short');
}
</script>
<div class="mx-auto max-w-4xl px-4 py-8">
<header class="mb-6 flex flex-wrap items-center justify-between gap-3">
<h1 class="font-serif text-3xl font-bold text-ink">{m.geschichten_index_title()}</h1>
{#if data.canBlogWrite}
<a
href="/geschichten/new"
class="inline-flex h-11 items-center rounded bg-primary px-4 font-sans text-sm font-medium text-primary-fg hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>
{m.geschichten_new_button()}
</a>
{/if}
</header>
<!-- Filter pills -->
<div class="mb-6 flex flex-wrap items-center gap-2">
<button
type="button"
aria-pressed={!data.personFilter}
onclick={clearFilter}
class="inline-flex h-9 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>
{#if data.personFilter}
<button
type="button"
aria-pressed="true"
onclick={clearFilter}
class="inline-flex h-9 items-center gap-2 rounded-full bg-ink px-3 font-sans text-xs font-bold tracking-wider text-primary-fg uppercase"
>
{filterName}
<span aria-hidden="true">×</span>
</button>
{:else}
<button
type="button"
aria-label={m.geschichten_filter_aria_label()}
onclick={() => (showPersonPicker = !showPersonPicker)}
class="inline-flex h-9 items-center rounded-full border border-line px-3 font-sans text-xs font-bold tracking-wider text-ink-2 uppercase hover:bg-muted"
>
+ {m.geschichten_filter_choose_person()}
</button>
{/if}
</div>
{#if showPersonPicker}
<div class="mb-4">
<PersonTypeahead
name="filter-person"
label={m.geschichten_filter_choose_person()}
compact
onchange={pickPerson}
/>
</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.personFilter}
{m.geschichten_empty_for_person({ name: filterName })}
{:else}
{m.geschichten_empty_no_filter()}
{/if}
</div>
{:else}
<ul class="flex flex-col gap-4">
{#each data.geschichten as g (g.id)}
<li
class="rounded border border-line bg-surface p-5 shadow-sm transition-shadow hover:shadow-md"
>
<a href="/geschichten/{g.id}" class="block">
<h2 class="mb-1 font-serif text-xl font-bold text-ink">{g.title}</h2>
<p class="mb-3 font-sans text-xs text-ink-3">
{authorName(g)}
{#if publishedAt(g)}· {m.geschichten_published_on({ date: publishedAt(g)! })}{/if}
</p>
{#if g.body}
<p class="font-serif text-base text-ink-2">{plainExcerpt(g.body, 150)}</p>
{/if}
</a>
</li>
{/each}
</ul>
{/if}
</div>

View File

@@ -0,0 +1,16 @@
import { error } from '@sveltejs/kit';
import { createApiClient } from '$lib/api.server';
import { getErrorMessage } from '$lib/errors';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ params, fetch }) => {
const api = createApiClient(fetch);
const result = await api.GET('/api/geschichten/{id}', {
params: { path: { id: params.id } }
});
if (!result.response.ok) {
const code = (result.error as unknown as { code?: string })?.code;
throw error(result.response.status, getErrorMessage(code));
}
return { geschichte: result.data! };
};

View File

@@ -0,0 +1,133 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { m } from '$lib/paraglide/messages.js';
import { safeHtml } from '$lib/utils/sanitize';
import { formatDate } from '$lib/utils/date';
import { getConfirmService } from '$lib/services/confirm.svelte';
import BackButton from '$lib/components/BackButton.svelte';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
const g = $derived(data.geschichte);
const sanitized = $derived(safeHtml(g.body));
const publishedAt = $derived.by(() => {
if (!g.publishedAt) return null;
return formatDate(g.publishedAt.slice(0, 10), 'long');
});
function authorName(): string {
const a = g.author;
if (!a) return '';
const full = [a.firstName, a.lastName].filter(Boolean).join(' ').trim();
return full || a.email || '';
}
const confirm = getConfirmService();
async function handleDelete() {
const ok = await confirm.confirm({
title: m.geschichte_delete_confirm_title(),
body: m.geschichte_delete_confirm_body(),
confirmLabel: m.btn_delete(),
cancelLabel: m.btn_cancel(),
destructive: true
});
if (!ok) return;
const res = await fetch(`/api/geschichten/${g.id}`, { method: 'DELETE' });
if (res.ok) {
goto('/geschichten');
}
}
</script>
<div class="mx-auto max-w-3xl px-4 py-8">
<div class="mb-6">
<BackButton />
</div>
<article aria-labelledby="geschichte-title">
<header class="mb-6">
<h1 id="geschichte-title" class="mb-3 font-serif text-4xl font-bold text-ink">
{g.title}
</h1>
<p class="font-sans text-sm text-ink-3">
{authorName()}
{#if publishedAt}· {m.geschichten_published_on({ date: publishedAt })}{/if}
</p>
</header>
<div class="prose font-serif text-lg leading-relaxed text-ink">
<!-- Sanitised via safeHtml() (DOMPurify) on render — matches backend OWASP allow-list -->
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html sanitized}
</div>
</article>
<!-- Personen -->
{#if g.persons && g.persons.length > 0}
<section class="mt-10 border-t border-line pt-6">
<h2 class="mb-3 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.geschichten_persons_section()}
</h2>
<ul class="flex flex-wrap gap-2">
{#each g.persons as p (p.id)}
<li>
<a
href="/persons/{p.id}"
class="inline-flex items-center rounded-full bg-muted px-3 py-1 font-sans text-sm text-ink hover:bg-accent-bg"
>
{p.displayName}
</a>
</li>
{/each}
</ul>
</section>
{/if}
<!-- Dokumente -->
{#if g.documents && g.documents.length > 0}
<section class="mt-8 border-t border-line pt-6">
<h2 class="mb-3 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.geschichten_documents_section()}
</h2>
<ul class="flex flex-col gap-2">
{#each g.documents as d (d.id)}
<li>
<a
href="/documents/{d.id}"
class="block rounded border border-line bg-surface px-4 py-3 font-serif text-base text-ink hover:bg-muted"
>
{d.title}
{#if d.documentDate}
<span class="ml-2 font-sans text-xs text-ink-3">
{formatDate(d.documentDate, 'short')}
</span>
{/if}
</a>
</li>
{/each}
</ul>
</section>
{/if}
<!-- Author actions -->
{#if data.canBlogWrite}
<div class="mt-10 flex items-center gap-3 border-t border-line pt-6">
<a
href="/geschichten/{g.id}/edit"
class="inline-flex h-11 items-center rounded border border-line bg-surface px-4 font-sans text-sm font-medium text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>
{m.btn_edit()}
</a>
<button
type="button"
onclick={handleDelete}
class="inline-flex h-11 items-center rounded font-sans text-sm font-medium text-danger hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>
{m.btn_delete()}
</button>
</div>
{/if}
</div>

View File

@@ -0,0 +1,20 @@
import { error, redirect } from '@sveltejs/kit';
import { createApiClient } from '$lib/api.server';
import { getErrorMessage } from '$lib/errors';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ params, fetch, parent }) => {
const layout = await parent();
if (!layout.canBlogWrite) {
throw redirect(303, `/geschichten/${params.id}`);
}
const api = createApiClient(fetch);
const result = await api.GET('/api/geschichten/{id}', {
params: { path: { id: params.id } }
});
if (!result.response.ok) {
const code = (result.error as unknown as { code?: string })?.code;
throw error(result.response.status, getErrorMessage(code));
}
return { geschichte: result.data! };
};

View File

@@ -0,0 +1,60 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { m } from '$lib/paraglide/messages.js';
import GeschichteEditor from '$lib/components/GeschichteEditor.svelte';
import BackButton from '$lib/components/BackButton.svelte';
import { getErrorMessage } from '$lib/errors';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
let submitting = $state(false);
let errorMessage: string | null = $state(null);
async function handleSubmit(payload: {
title: string;
body: string;
status: 'DRAFT' | 'PUBLISHED';
personIds: string[];
documentIds: string[];
}) {
submitting = true;
errorMessage = null;
try {
const res = await fetch(`/api/geschichten/${data.geschichte.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!res.ok) {
const code = (await res.json().catch(() => ({})))?.code;
errorMessage = getErrorMessage(code);
return;
}
goto(`/geschichten/${data.geschichte.id}`);
} finally {
submitting = false;
}
}
</script>
<div class="mx-auto max-w-5xl px-4 py-8">
<div class="mb-6">
<BackButton />
</div>
<h1 class="mb-6 font-serif text-3xl font-bold text-ink">
{m.btn_edit()}: {data.geschichte.title}
</h1>
{#if errorMessage}
<div
class="mb-4 rounded border border-danger bg-danger/10 p-3 text-sm text-danger"
role="alert"
>
{errorMessage}
</div>
{/if}
<GeschichteEditor geschichte={data.geschichte} onSubmit={handleSubmit} submitting={submitting} />
</div>

View File

@@ -0,0 +1,33 @@
import { redirect } from '@sveltejs/kit';
import { createApiClient } from '$lib/api.server';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ url, fetch, parent }) => {
const layout = await parent();
if (!layout.canBlogWrite) {
throw redirect(303, '/geschichten');
}
const api = createApiClient(fetch);
const personId = url.searchParams.get('personId');
const documentId = url.searchParams.get('documentId');
const [personResult, documentResult] = await Promise.all([
personId
? api.GET('/api/persons/{id}', { params: { path: { id: personId } } })
: Promise.resolve(null),
documentId
? api.GET('/api/documents/{id}', { params: { path: { id: documentId } } })
: Promise.resolve(null)
]);
// Silently ignore 404/403 to avoid leaking entity existence on unknown IDs.
const initialPersons =
personResult && personResult.response.ok && personResult.data ? [personResult.data] : [];
const initialDocuments =
documentResult && documentResult.response.ok && documentResult.data
? [documentResult.data]
: [];
return { initialPersons, initialDocuments };
};

View File

@@ -0,0 +1,64 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { m } from '$lib/paraglide/messages.js';
import GeschichteEditor from '$lib/components/GeschichteEditor.svelte';
import BackButton from '$lib/components/BackButton.svelte';
import { getErrorMessage } from '$lib/errors';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
let submitting = $state(false);
let errorMessage: string | null = $state(null);
async function handleSubmit(payload: {
title: string;
body: string;
status: 'DRAFT' | 'PUBLISHED';
personIds: string[];
documentIds: string[];
}) {
submitting = true;
errorMessage = null;
try {
const res = await fetch('/api/geschichten', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!res.ok) {
const code = (await res.json().catch(() => ({})))?.code;
errorMessage = getErrorMessage(code);
return;
}
const created = await res.json();
goto(`/geschichten/${created.id}`);
} finally {
submitting = false;
}
}
</script>
<div class="mx-auto max-w-5xl px-4 py-8">
<div class="mb-6">
<BackButton />
</div>
<h1 class="mb-6 font-serif text-3xl font-bold text-ink">{m.geschichten_new_button()}</h1>
{#if errorMessage}
<div
class="mb-4 rounded border border-danger bg-danger/10 p-3 text-sm text-danger"
role="alert"
>
{errorMessage}
</div>
{/if}
<GeschichteEditor
initialPersons={data.initialPersons}
initialDocuments={data.initialDocuments}
onSubmit={handleSubmit}
submitting={submitting}
/>
</div>

View File

@@ -25,6 +25,7 @@ const makeData = (overrides = {}) => ({
},
canWrite: true,
canAnnotate: false,
canBlogWrite: false,
...overrides
});

View File

@@ -22,6 +22,7 @@ const baseData = {
} as User,
canWrite: true,
canAnnotate: false,
canBlogWrite: false,
resumeDoc: null,
pulse: null,
activityFeed: [],

View File

@@ -17,14 +17,18 @@ export async function load({ params, fetch, locals }) {
receivedDocsResult,
aliasesResult,
relsResult,
inferredResult
inferredResult,
geschichtenResult
] = await Promise.all([
api.GET('/api/persons/{id}', { params: { path: { id } } }),
api.GET('/api/persons/{id}/documents', { params: { path: { id } } }),
api.GET('/api/persons/{id}/received-documents', { params: { path: { id } } }),
api.GET('/api/persons/{id}/aliases', { params: { path: { id } } }),
api.GET('/api/persons/{id}/relationships', { params: { path: { id } } }),
api.GET('/api/persons/{id}/inferred-relationships', { params: { path: { id } } })
api.GET('/api/persons/{id}/inferred-relationships', { params: { path: { id } } }),
api.GET('/api/geschichten', {
params: { query: { status: 'PUBLISHED', personId: id } }
})
]);
if (!personResult.response.ok) {
@@ -39,6 +43,7 @@ export async function load({ params, fetch, locals }) {
aliases: aliasesResult.data ?? [],
relationships: relsResult.data ?? [],
inferredRelationships: inferredResult.data ?? [],
geschichten: geschichtenResult.data ?? [],
canWrite
};
}

View File

@@ -7,6 +7,7 @@ import NameHistoryCard from './NameHistoryCard.svelte';
import CoCorrespondentsList from './CoCorrespondentsList.svelte';
import PersonDocumentList from './PersonDocumentList.svelte';
import PersonRelationshipsCard from './PersonRelationshipsCard.svelte';
import GeschichtenCard from '$lib/components/GeschichtenCard.svelte';
let { data } = $props();
@@ -92,6 +93,17 @@ const coCorrespondents = $derived.by(() => {
emptyMessage={m.person_no_received_docs()}
/>
</div>
{#if data.geschichten && data.geschichten.length > 0}
<div class="mt-6">
<GeschichtenCard
geschichten={data.geschichten}
personId={person.id}
personName={person.displayName}
canWrite={data.canBlogWrite ?? false}
/>
</div>
{/if}
</div>
</div>
</div>

View File

@@ -29,6 +29,7 @@ describe('person detail load — happy path', () => {
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
} as ReturnType<typeof createApiClient>);
const result = await load({ params: { id: 'p1' }, fetch: mockFetch, locals: mockLocals });
@@ -51,6 +52,7 @@ describe('person detail load — happy path', () => {
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
} as ReturnType<typeof createApiClient>);
const result = await load({ params: { id: 'p1' }, fetch: mockFetch, locals: mockLocalsWriter });
@@ -71,6 +73,7 @@ describe('person detail load — happy path', () => {
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
} as ReturnType<typeof createApiClient>);
const result = await load({ params: { id: 'p1' }, fetch: mockFetch, locals: mockLocals });
@@ -93,6 +96,7 @@ describe('person detail load — error paths', () => {
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
} as ReturnType<typeof createApiClient>);
await expect(
@@ -112,6 +116,7 @@ describe('person detail load — error paths', () => {
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
} as ReturnType<typeof createApiClient>);
await expect(

View File

@@ -21,6 +21,7 @@ const emptyData = {
user: undefined,
canWrite: true,
canAnnotate: false,
canBlogWrite: false,
q: '',
persons: [],
stats: defaultStats

View File

@@ -2,6 +2,38 @@
# yarn lockfile v1
"@asamuzakjp/css-color@^5.1.11":
version "5.1.11"
resolved "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz"
integrity sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==
dependencies:
"@asamuzakjp/generational-cache" "^1.0.1"
"@csstools/css-calc" "^3.2.0"
"@csstools/css-color-parser" "^4.1.0"
"@csstools/css-parser-algorithms" "^4.0.0"
"@csstools/css-tokenizer" "^4.0.0"
"@asamuzakjp/dom-selector@^7.1.1":
version "7.1.1"
resolved "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz"
integrity sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==
dependencies:
"@asamuzakjp/generational-cache" "^1.0.1"
"@asamuzakjp/nwsapi" "^2.3.9"
bidi-js "^1.0.3"
css-tree "^3.2.1"
is-potential-custom-element-name "^1.0.1"
"@asamuzakjp/generational-cache@^1.0.1":
version "1.0.1"
resolved "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz"
integrity sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==
"@asamuzakjp/nwsapi@^2.3.9":
version "2.3.9"
resolved "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz"
integrity sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==
"@axe-core/playwright@^4.11.1":
version "4.11.1"
resolved "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.11.1.tgz"
@@ -53,6 +85,46 @@
resolved "https://registry.npmjs.org/@blazediff/core/-/core-1.9.1.tgz"
integrity sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA==
"@bramus/specificity@^2.4.2":
version "2.4.2"
resolved "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz"
integrity sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==
dependencies:
css-tree "^3.0.0"
"@csstools/color-helpers@^6.0.2":
version "6.0.2"
resolved "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz"
integrity sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==
"@csstools/css-calc@^3.2.0":
version "3.2.0"
resolved "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.0.tgz"
integrity sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==
"@csstools/css-color-parser@^4.1.0":
version "4.1.0"
resolved "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.0.tgz"
integrity sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ==
dependencies:
"@csstools/color-helpers" "^6.0.2"
"@csstools/css-calc" "^3.2.0"
"@csstools/css-parser-algorithms@^4.0.0":
version "4.0.0"
resolved "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz"
integrity sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==
"@csstools/css-syntax-patches-for-csstree@^1.1.3":
version "1.1.3"
resolved "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.3.tgz"
integrity sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==
"@csstools/css-tokenizer@^4.0.0":
version "4.0.0"
resolved "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz"
integrity sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==
"@esbuild/linux-x64@0.27.4":
version "0.27.4"
resolved "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz"
@@ -133,6 +205,11 @@
"@eslint/core" "^0.17.0"
levn "^0.4.1"
"@exodus/bytes@^1.11.0", "@exodus/bytes@^1.15.0", "@exodus/bytes@^1.6.0":
version "1.15.0"
resolved "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz"
integrity sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==
"@humanfs/core@^0.19.1":
version "0.19.1"
resolved "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz"
@@ -691,13 +768,6 @@
resolved "https://registry.npmjs.org/@types/diff/-/diff-7.0.2.tgz"
integrity sha512-JSWRMozjFKsGlEjiiKajUjIJVKuKdE3oVy2DNtK+fUo8q82nhFZ2CPQwicAIkXrofahDXrWJ7mjelvZphMS98Q==
"@types/dompurify@^3.0.5":
version "3.0.5"
resolved "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz"
integrity sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==
dependencies:
"@types/trusted-types" "*"
"@types/estree@*", "@types/estree@^1.0.0", "@types/estree@^1.0.5", "@types/estree@^1.0.6", "@types/estree@1.0.8":
version "1.0.8"
resolved "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz"
@@ -720,7 +790,7 @@
resolved "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz"
integrity sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==
"@types/trusted-types@*", "@types/trusted-types@^2.0.7":
"@types/trusted-types@^2.0.7":
version "2.0.7"
resolved "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz"
integrity sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==
@@ -1006,6 +1076,13 @@ balanced-match@^4.0.2:
resolved "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz"
integrity sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==
bidi-js@^1.0.3:
version "1.0.3"
resolved "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz"
integrity sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==
dependencies:
require-from-string "^2.0.2"
brace-expansion@^1.1.7:
version "1.1.12"
resolved "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz"
@@ -1127,11 +1204,27 @@ cross-spawn@^7.0.6:
shebang-command "^2.0.0"
which "^2.0.1"
css-tree@^3.0.0, css-tree@^3.2.1:
version "3.2.1"
resolved "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz"
integrity sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==
dependencies:
mdn-data "2.27.1"
source-map-js "^1.2.1"
cssesc@^3.0.0:
version "3.0.0"
resolved "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz"
integrity sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==
data-urls@^7.0.0:
version "7.0.0"
resolved "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz"
integrity sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==
dependencies:
whatwg-mimetype "^5.0.0"
whatwg-url "^16.0.0"
debug@^4.3.1, debug@^4.3.2, debug@^4.4.3, debug@4:
version "4.4.3"
resolved "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz"
@@ -1139,6 +1232,11 @@ debug@^4.3.1, debug@^4.3.2, debug@^4.4.3, debug@4:
dependencies:
ms "^2.1.3"
decimal.js@^10.6.0:
version "10.6.0"
resolved "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz"
integrity sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==
dedent@1.5.1:
version "1.5.1"
resolved "https://registry.npmjs.org/dedent/-/dedent-1.5.1.tgz"
@@ -1184,6 +1282,11 @@ enhanced-resolve@^5.19.0:
graceful-fs "^4.2.4"
tapable "^2.3.0"
entities@^8.0.0:
version "8.0.0"
resolved "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz"
integrity sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==
es-module-lexer@^2.0.0:
version "2.0.0"
resolved "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz"
@@ -1465,6 +1568,13 @@ hasown@^2.0.2:
dependencies:
function-bind "^1.1.2"
html-encoding-sniffer@^6.0.0:
version "6.0.0"
resolved "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz"
integrity sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==
dependencies:
"@exodus/bytes" "^1.6.0"
html-escaper@^2.0.0:
version "2.0.2"
resolved "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz"
@@ -1535,6 +1645,11 @@ is-module@^1.0.0:
resolved "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz"
integrity sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==
is-potential-custom-element-name@^1.0.1:
version "1.0.1"
resolved "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz"
integrity sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==
is-reference@^3.0.3:
version "3.0.3"
resolved "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz"
@@ -1554,6 +1669,14 @@ isexe@^2.0.0:
resolved "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz"
integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==
isomorphic-dompurify@^3.12.0:
version "3.12.0"
resolved "https://registry.npmjs.org/isomorphic-dompurify/-/isomorphic-dompurify-3.12.0.tgz"
integrity sha512-8n+j+6ypTHvriJwFOQ2qusQ6bzGjZVcR3jbe1pBpLcGI1dn4WIl0ctLBngqE5QttquQBAlKXwJeTMw+X7x7qKw==
dependencies:
dompurify "^3.4.2"
jsdom "^29.1.1"
istanbul-lib-coverage@^3.0.0, istanbul-lib-coverage@^3.2.2:
version "3.2.2"
resolved "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz"
@@ -1608,6 +1731,33 @@ js-yaml@^4.1.1, js-yaml@4.1.1:
dependencies:
argparse "^2.0.1"
jsdom@*, jsdom@^29.1.1:
version "29.1.1"
resolved "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz"
integrity sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==
dependencies:
"@asamuzakjp/css-color" "^5.1.11"
"@asamuzakjp/dom-selector" "^7.1.1"
"@bramus/specificity" "^2.4.2"
"@csstools/css-syntax-patches-for-csstree" "^1.1.3"
"@exodus/bytes" "^1.15.0"
css-tree "^3.2.1"
data-urls "^7.0.0"
decimal.js "^10.6.0"
html-encoding-sniffer "^6.0.0"
is-potential-custom-element-name "^1.0.1"
lru-cache "^11.3.5"
parse5 "^8.0.1"
saxes "^6.0.0"
symbol-tree "^3.2.4"
tough-cookie "^6.0.1"
undici "^7.25.0"
w3c-xmlserializer "^5.0.0"
webidl-conversions "^8.0.1"
whatwg-mimetype "^5.0.0"
whatwg-url "^16.0.1"
xml-name-validator "^5.0.0"
json-buffer@3.0.1:
version "3.0.1"
resolved "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz"
@@ -1719,6 +1869,11 @@ lodash.merge@^4.6.2:
resolved "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz"
integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==
lru-cache@^11.3.5:
version "11.3.5"
resolved "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz"
integrity sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==
magic-string@^0.30.11, magic-string@^0.30.21, magic-string@^0.30.3, magic-string@^0.30.5:
version "0.30.21"
resolved "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz"
@@ -1742,6 +1897,11 @@ make-dir@^4.0.0:
dependencies:
semver "^7.5.3"
mdn-data@2.27.1:
version "2.27.1"
resolved "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz"
integrity sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==
mini-svg-data-uri@^1.2.3:
version "1.4.4"
resolved "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz"
@@ -1874,6 +2034,13 @@ parse-json@^8.3.0:
index-to-position "^1.1.0"
type-fest "^4.39.1"
parse5@^8.0.1:
version "8.0.1"
resolved "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz"
integrity sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==
dependencies:
entities "^8.0.0"
path-exists@^4.0.0:
version "4.0.0"
resolved "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz"
@@ -2104,7 +2271,7 @@ prosemirror-view@^1.0.0, prosemirror-view@^1.1.0, prosemirror-view@^1.27.0, pros
prosemirror-state "^1.0.0"
prosemirror-transform "^1.1.0"
punycode@^2.1.0:
punycode@^2.1.0, punycode@^2.3.1:
version "2.3.1"
resolved "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz"
integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==
@@ -2179,6 +2346,13 @@ sade@^1.7.4:
dependencies:
mri "^1.1.0"
saxes@^6.0.0:
version "6.0.0"
resolved "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz"
integrity sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==
dependencies:
xmlchars "^2.2.0"
semver@^7.5.3, semver@^7.6.3, semver@^7.7.2, semver@^7.7.3:
version "7.7.4"
resolved "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz"
@@ -2305,6 +2479,11 @@ svelte-eslint-parser@^1.4.0:
magic-string "^0.30.11"
zimmerframe "^1.1.2"
symbol-tree@^3.2.4:
version "3.2.4"
resolved "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz"
integrity sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==
tailwindcss@^4.1.17, "tailwindcss@>=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20 || >= 4.0.0-beta.1", "tailwindcss@>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1", tailwindcss@4.2.1:
version "4.2.1"
resolved "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz"
@@ -2338,11 +2517,37 @@ tinyrainbow@^3.0.3:
resolved "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz"
integrity sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==
tldts-core@^7.0.30:
version "7.0.30"
resolved "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.30.tgz"
integrity sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q==
tldts@^7.0.5:
version "7.0.30"
resolved "https://registry.npmjs.org/tldts/-/tldts-7.0.30.tgz"
integrity sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw==
dependencies:
tldts-core "^7.0.30"
totalist@^3.0.0:
version "3.0.1"
resolved "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz"
integrity sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==
tough-cookie@^6.0.1:
version "6.0.1"
resolved "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz"
integrity sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==
dependencies:
tldts "^7.0.5"
tr46@^6.0.0:
version "6.0.0"
resolved "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz"
integrity sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==
dependencies:
punycode "^2.3.1"
ts-api-utils@^2.4.0:
version "2.4.0"
resolved "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz"
@@ -2380,6 +2585,11 @@ undici-types@~7.16.0:
resolved "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz"
integrity sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==
undici@^7.25.0:
version "7.25.0"
resolved "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz"
integrity sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==
unplugin@^2.1.2:
version "2.3.11"
resolved "https://registry.npmjs.org/unplugin/-/unplugin-2.3.11.tgz"
@@ -2492,11 +2702,37 @@ w3c-keyname@^2.2.0:
resolved "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz"
integrity sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==
w3c-xmlserializer@^5.0.0:
version "5.0.0"
resolved "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz"
integrity sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==
dependencies:
xml-name-validator "^5.0.0"
webidl-conversions@^8.0.1:
version "8.0.1"
resolved "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz"
integrity sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==
webpack-virtual-modules@^0.6.2:
version "0.6.2"
resolved "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz"
integrity sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==
whatwg-mimetype@^5.0.0:
version "5.0.0"
resolved "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz"
integrity sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==
whatwg-url@^16.0.0, whatwg-url@^16.0.1:
version "16.0.1"
resolved "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz"
integrity sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==
dependencies:
"@exodus/bytes" "^1.11.0"
tr46 "^6.0.0"
webidl-conversions "^8.0.1"
which@^2.0.1:
version "2.0.2"
resolved "https://registry.npmjs.org/which/-/which-2.0.2.tgz"
@@ -2522,6 +2758,16 @@ ws@^8.19.0:
resolved "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz"
integrity sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==
xml-name-validator@^5.0.0:
version "5.0.0"
resolved "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz"
integrity sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==
xmlchars@^2.2.0:
version "2.2.0"
resolved "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz"
integrity sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==
yaml-ast-parser@0.0.43:
version "0.0.43"
resolved "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz"