Compare commits
10 Commits
08d96e5b0f
...
b698f9f223
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b698f9f223 | ||
|
|
ed270f68e1 | ||
|
|
fe1014a08a | ||
|
|
9e6efacbcb | ||
|
|
ab3e633a0c | ||
|
|
b381b2078a | ||
|
|
9e7861fa03 | ||
|
|
afd6d0b20d | ||
|
|
e5024fc804 | ||
|
|
9fc96a15cf |
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ CREATE TABLE geschichten (
|
|||||||
title VARCHAR(255) NOT NULL,
|
title VARCHAR(255) NOT NULL,
|
||||||
body TEXT,
|
body TEXT,
|
||||||
status VARCHAR(32) NOT NULL,
|
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,
|
created_at TIMESTAMP NOT NULL,
|
||||||
updated_at TIMESTAMP NOT NULL,
|
updated_at TIMESTAMP NOT NULL,
|
||||||
published_at TIMESTAMP
|
published_at TIMESTAMP
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -919,6 +919,56 @@
|
|||||||
"bulk_edit_count_pill": "{count} werden bearbeitet",
|
"bulk_edit_count_pill": "{count} werden bearbeitet",
|
||||||
|
|
||||||
"nav_stammbaum": "Stammbaum",
|
"nav_stammbaum": "Stammbaum",
|
||||||
|
"nav_geschichten": "Geschichten",
|
||||||
|
|
||||||
|
"error_geschichte_not_found": "Die Geschichte wurde nicht gefunden.",
|
||||||
|
|
||||||
|
"geschichten_index_title": "Geschichten",
|
||||||
|
"geschichten_new_button": "Neue Geschichte",
|
||||||
|
"geschichten_filter_all_pill": "Alle",
|
||||||
|
"geschichten_filter_choose_person": "Person wählen",
|
||||||
|
"geschichten_filter_aria_label": "Person filtern",
|
||||||
|
"geschichten_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_relationship_not_found": "Die Beziehung wurde nicht gefunden.",
|
||||||
"error_circular_relationship": "Diese Beziehung würde einen Kreis erzeugen.",
|
"error_circular_relationship": "Diese Beziehung würde einen Kreis erzeugen.",
|
||||||
|
|||||||
@@ -919,6 +919,56 @@
|
|||||||
"bulk_edit_count_pill": "{count} will be edited",
|
"bulk_edit_count_pill": "{count} will be edited",
|
||||||
|
|
||||||
"nav_stammbaum": "Family tree",
|
"nav_stammbaum": "Family tree",
|
||||||
|
"nav_geschichten": "Stories",
|
||||||
|
|
||||||
|
"error_geschichte_not_found": "The story was not found.",
|
||||||
|
|
||||||
|
"geschichten_index_title": "Stories",
|
||||||
|
"geschichten_new_button": "New story",
|
||||||
|
"geschichten_filter_all_pill": "All",
|
||||||
|
"geschichten_filter_choose_person": "Choose person",
|
||||||
|
"geschichten_filter_aria_label": "Filter by person",
|
||||||
|
"geschichten_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_relationship_not_found": "Relationship not found.",
|
||||||
"error_circular_relationship": "This relationship would form a cycle.",
|
"error_circular_relationship": "This relationship would form a cycle.",
|
||||||
|
|||||||
@@ -919,6 +919,56 @@
|
|||||||
"bulk_edit_count_pill": "Se editarán {count}",
|
"bulk_edit_count_pill": "Se editarán {count}",
|
||||||
|
|
||||||
"nav_stammbaum": "Árbol genealógico",
|
"nav_stammbaum": "Árbol genealógico",
|
||||||
|
"nav_geschichten": "Historias",
|
||||||
|
|
||||||
|
"error_geschichte_not_found": "No se encontró la historia.",
|
||||||
|
|
||||||
|
"geschichten_index_title": "Historias",
|
||||||
|
"geschichten_new_button": "Nueva historia",
|
||||||
|
"geschichten_filter_all_pill": "Todas",
|
||||||
|
"geschichten_filter_choose_person": "Elegir persona",
|
||||||
|
"geschichten_filter_aria_label": "Filtrar por persona",
|
||||||
|
"geschichten_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_relationship_not_found": "La relación no fue encontrada.",
|
||||||
"error_circular_relationship": "Esta relación crearía un ciclo.",
|
"error_circular_relationship": "Esta relación crearía un ciclo.",
|
||||||
|
|||||||
505
frontend/package-lock.json
generated
505
frontend/package-lock.json
generated
@@ -12,7 +12,7 @@
|
|||||||
"@tiptap/extension-mention": "3.22.5",
|
"@tiptap/extension-mention": "3.22.5",
|
||||||
"@tiptap/starter-kit": "3.22.5",
|
"@tiptap/starter-kit": "3.22.5",
|
||||||
"diff": "^8.0.3",
|
"diff": "^8.0.3",
|
||||||
"dompurify": "^3.4.2",
|
"isomorphic-dompurify": "^3.12.0",
|
||||||
"openapi-fetch": "^0.13.5",
|
"openapi-fetch": "^0.13.5",
|
||||||
"pdfjs-dist": "^5.5.207"
|
"pdfjs-dist": "^5.5.207"
|
||||||
},
|
},
|
||||||
@@ -29,7 +29,6 @@
|
|||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@tailwindcss/vite": "^4.1.17",
|
"@tailwindcss/vite": "^4.1.17",
|
||||||
"@types/diff": "^7.0.2",
|
"@types/diff": "^7.0.2",
|
||||||
"@types/dompurify": "^3.0.5",
|
|
||||||
"@types/node": "^24",
|
"@types/node": "^24",
|
||||||
"@vitest/browser-playwright": "^4.0.10",
|
"@vitest/browser-playwright": "^4.0.10",
|
||||||
"@vitest/coverage-v8": "^4.1.0",
|
"@vitest/coverage-v8": "^4.1.0",
|
||||||
@@ -53,6 +52,53 @@
|
|||||||
"vitest-browser-svelte": "^2.0.1"
|
"vitest-browser-svelte": "^2.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@asamuzakjp/css-color": {
|
||||||
|
"version": "5.1.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz",
|
||||||
|
"integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@asamuzakjp/generational-cache": "^1.0.1",
|
||||||
|
"@csstools/css-calc": "^3.2.0",
|
||||||
|
"@csstools/css-color-parser": "^4.1.0",
|
||||||
|
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||||
|
"@csstools/css-tokenizer": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@asamuzakjp/dom-selector": {
|
||||||
|
"version": "7.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz",
|
||||||
|
"integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@asamuzakjp/generational-cache": "^1.0.1",
|
||||||
|
"@asamuzakjp/nwsapi": "^2.3.9",
|
||||||
|
"bidi-js": "^1.0.3",
|
||||||
|
"css-tree": "^3.2.1",
|
||||||
|
"is-potential-custom-element-name": "^1.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@asamuzakjp/generational-cache": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@asamuzakjp/nwsapi": {
|
||||||
|
"version": "2.3.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
|
||||||
|
"integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@axe-core/playwright": {
|
"node_modules/@axe-core/playwright": {
|
||||||
"version": "4.11.1",
|
"version": "4.11.1",
|
||||||
"resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.11.1.tgz",
|
"resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.11.1.tgz",
|
||||||
@@ -148,6 +194,152 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@bramus/specificity": {
|
||||||
|
"version": "2.4.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
|
||||||
|
"integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"css-tree": "^3.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"specificity": "bin/cli.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@csstools/color-helpers": {
|
||||||
|
"version": "6.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz",
|
||||||
|
"integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/csstools"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/csstools"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT-0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.19.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@csstools/css-calc": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/csstools"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/csstools"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.19.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||||
|
"@csstools/css-tokenizer": "^4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@csstools/css-color-parser": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/csstools"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/csstools"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@csstools/color-helpers": "^6.0.2",
|
||||||
|
"@csstools/css-calc": "^3.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.19.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||||
|
"@csstools/css-tokenizer": "^4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@csstools/css-parser-algorithms": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/csstools"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/csstools"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.19.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@csstools/css-tokenizer": "^4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@csstools/css-syntax-patches-for-csstree": {
|
||||||
|
"version": "1.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.3.tgz",
|
||||||
|
"integrity": "sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/csstools"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/csstools"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT-0",
|
||||||
|
"peerDependencies": {
|
||||||
|
"css-tree": "^3.2.1"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"css-tree": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@csstools/css-tokenizer": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/csstools"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/csstools"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.19.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@esbuild/aix-ppc64": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.27.4",
|
"version": "0.27.4",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz",
|
||||||
@@ -768,6 +960,23 @@
|
|||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@exodus/bytes": {
|
||||||
|
"version": "1.15.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz",
|
||||||
|
"integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@noble/hashes": "^1.8.0 || ^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@noble/hashes": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@humanfs/core": {
|
"node_modules/@humanfs/core": {
|
||||||
"version": "0.19.1",
|
"version": "0.19.1",
|
||||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
||||||
@@ -2622,16 +2831,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||||
@@ -3319,6 +3518,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/bidi-js": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"require-from-string": "^2.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "1.1.12",
|
"version": "1.1.12",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||||
@@ -3507,6 +3715,19 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/css-tree": {
|
||||||
|
"version": "3.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
|
||||||
|
"integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"mdn-data": "2.27.1",
|
||||||
|
"source-map-js": "^1.2.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cssesc": {
|
"node_modules/cssesc": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||||
@@ -3520,6 +3741,19 @@
|
|||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/data-urls": {
|
||||||
|
"version": "7.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
|
||||||
|
"integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"whatwg-mimetype": "^5.0.0",
|
||||||
|
"whatwg-url": "^16.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
@@ -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": {
|
"node_modules/dedent": {
|
||||||
"version": "1.5.1",
|
"version": "1.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.1.tgz",
|
||||||
@@ -3619,6 +3859,18 @@
|
|||||||
"node": ">=10.13.0"
|
"node": ">=10.13.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/entities": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.19.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/es-module-lexer": {
|
"node_modules/es-module-lexer": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz",
|
||||||
@@ -4105,6 +4357,18 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/html-encoding-sniffer": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@exodus/bytes": "^1.6.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/html-escaper": {
|
"node_modules/html-escaper": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
|
||||||
@@ -4232,6 +4496,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/is-potential-custom-element-name": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/is-reference": {
|
"node_modules/is-reference": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz",
|
||||||
@@ -4249,6 +4519,19 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/isomorphic-dompurify": {
|
||||||
|
"version": "3.12.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/isomorphic-dompurify/-/isomorphic-dompurify-3.12.0.tgz",
|
||||||
|
"integrity": "sha512-8n+j+6ypTHvriJwFOQ2qusQ6bzGjZVcR3jbe1pBpLcGI1dn4WIl0ctLBngqE5QttquQBAlKXwJeTMw+X7x7qKw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"dompurify": "^3.4.2",
|
||||||
|
"jsdom": "^29.1.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || ^22.13.0 || >=24.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/istanbul-lib-coverage": {
|
"node_modules/istanbul-lib-coverage": {
|
||||||
"version": "3.2.2",
|
"version": "3.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
|
||||||
@@ -4335,6 +4618,46 @@
|
|||||||
"js-yaml": "bin/js-yaml.js"
|
"js-yaml": "bin/js-yaml.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jsdom": {
|
||||||
|
"version": "29.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz",
|
||||||
|
"integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@asamuzakjp/css-color": "^5.1.11",
|
||||||
|
"@asamuzakjp/dom-selector": "^7.1.1",
|
||||||
|
"@bramus/specificity": "^2.4.2",
|
||||||
|
"@csstools/css-syntax-patches-for-csstree": "^1.1.3",
|
||||||
|
"@exodus/bytes": "^1.15.0",
|
||||||
|
"css-tree": "^3.2.1",
|
||||||
|
"data-urls": "^7.0.0",
|
||||||
|
"decimal.js": "^10.6.0",
|
||||||
|
"html-encoding-sniffer": "^6.0.0",
|
||||||
|
"is-potential-custom-element-name": "^1.0.1",
|
||||||
|
"lru-cache": "^11.3.5",
|
||||||
|
"parse5": "^8.0.1",
|
||||||
|
"saxes": "^6.0.0",
|
||||||
|
"symbol-tree": "^3.2.4",
|
||||||
|
"tough-cookie": "^6.0.1",
|
||||||
|
"undici": "^7.25.0",
|
||||||
|
"w3c-xmlserializer": "^5.0.0",
|
||||||
|
"webidl-conversions": "^8.0.1",
|
||||||
|
"whatwg-mimetype": "^5.0.0",
|
||||||
|
"whatwg-url": "^16.0.1",
|
||||||
|
"xml-name-validator": "^5.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || ^22.13.0 || >=24.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"canvas": "^3.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"canvas": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/json-buffer": {
|
"node_modules/json-buffer": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
|
||||||
@@ -4727,6 +5050,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/lru-cache": {
|
||||||
|
"version": "11.3.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz",
|
||||||
|
"integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==",
|
||||||
|
"license": "BlueOak-1.0.0",
|
||||||
|
"engines": {
|
||||||
|
"node": "20 || >=22"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/magic-string": {
|
"node_modules/magic-string": {
|
||||||
"version": "0.30.21",
|
"version": "0.30.21",
|
||||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||||
@@ -4765,6 +5097,12 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/mdn-data": {
|
||||||
|
"version": "2.27.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
|
||||||
|
"integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
|
||||||
|
"license": "CC0-1.0"
|
||||||
|
},
|
||||||
"node_modules/mini-svg-data-uri": {
|
"node_modules/mini-svg-data-uri": {
|
||||||
"version": "1.4.4",
|
"version": "1.4.4",
|
||||||
"resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz",
|
"resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz",
|
||||||
@@ -4995,6 +5333,18 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/parse5": {
|
||||||
|
"version": "8.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
|
||||||
|
"integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"entities": "^8.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/path-exists": {
|
"node_modules/path-exists": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||||
@@ -5500,7 +5850,6 @@
|
|||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||||
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
@@ -5524,7 +5873,6 @@
|
|||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||||
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@@ -5625,6 +5973,18 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/saxes": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"xmlchars": "^2.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=v12.22.7"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/semver": {
|
"node_modules/semver": {
|
||||||
"version": "7.7.4",
|
"version": "7.7.4",
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||||
@@ -5694,7 +6054,6 @@
|
|||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||||
"dev": true,
|
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@@ -5872,6 +6231,12 @@
|
|||||||
"@types/estree": "^1.0.6"
|
"@types/estree": "^1.0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/symbol-tree": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tailwindcss": {
|
"node_modules/tailwindcss": {
|
||||||
"version": "4.2.1",
|
"version": "4.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz",
|
||||||
@@ -5937,6 +6302,24 @@
|
|||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tldts": {
|
||||||
|
"version": "7.0.30",
|
||||||
|
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.30.tgz",
|
||||||
|
"integrity": "sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tldts-core": "^7.0.30"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"tldts": "bin/cli.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tldts-core": {
|
||||||
|
"version": "7.0.30",
|
||||||
|
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.30.tgz",
|
||||||
|
"integrity": "sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/totalist": {
|
"node_modules/totalist": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
|
||||||
@@ -5947,6 +6330,30 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tough-cookie": {
|
||||||
|
"version": "6.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz",
|
||||||
|
"integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"tldts": "^7.0.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tr46": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"punycode": "^2.3.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ts-api-utils": {
|
"node_modules/ts-api-utils": {
|
||||||
"version": "2.4.0",
|
"version": "2.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz",
|
||||||
@@ -6024,6 +6431,15 @@
|
|||||||
"typescript": ">=4.8.4 <6.0.0"
|
"typescript": ">=4.8.4 <6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/undici": {
|
||||||
|
"version": "7.25.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz",
|
||||||
|
"integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.18.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/undici-types": {
|
"node_modules/undici-types": {
|
||||||
"version": "7.16.0",
|
"version": "7.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
||||||
@@ -6335,6 +6751,27 @@
|
|||||||
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/w3c-xmlserializer": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"xml-name-validator": "^5.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/webidl-conversions": {
|
||||||
|
"version": "8.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
|
||||||
|
"integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/webpack-virtual-modules": {
|
"node_modules/webpack-virtual-modules": {
|
||||||
"version": "0.6.2",
|
"version": "0.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
|
||||||
@@ -6342,6 +6779,29 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/whatwg-mimetype": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/whatwg-url": {
|
||||||
|
"version": "16.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
|
||||||
|
"integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@exodus/bytes": "^1.11.0",
|
||||||
|
"tr46": "^6.0.0",
|
||||||
|
"webidl-conversions": "^8.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/which": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
@@ -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": {
|
"node_modules/yaml-ast-parser": {
|
||||||
"version": "0.0.43",
|
"version": "0.0.43",
|
||||||
"resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz",
|
"resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz",
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
"@tiptap/extension-mention": "3.22.5",
|
"@tiptap/extension-mention": "3.22.5",
|
||||||
"@tiptap/starter-kit": "3.22.5",
|
"@tiptap/starter-kit": "3.22.5",
|
||||||
"diff": "^8.0.3",
|
"diff": "^8.0.3",
|
||||||
"dompurify": "^3.4.2",
|
"isomorphic-dompurify": "^3.12.0",
|
||||||
"openapi-fetch": "^0.13.5",
|
"openapi-fetch": "^0.13.5",
|
||||||
"pdfjs-dist": "^5.5.207"
|
"pdfjs-dist": "^5.5.207"
|
||||||
},
|
},
|
||||||
@@ -42,7 +42,6 @@
|
|||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@tailwindcss/vite": "^4.1.17",
|
"@tailwindcss/vite": "^4.1.17",
|
||||||
"@types/diff": "^7.0.2",
|
"@types/diff": "^7.0.2",
|
||||||
"@types/dompurify": "^3.0.5",
|
|
||||||
"@types/node": "^24",
|
"@types/node": "^24",
|
||||||
"@vitest/browser-playwright": "^4.0.10",
|
"@vitest/browser-playwright": "^4.0.10",
|
||||||
"@vitest/coverage-v8": "^4.1.0",
|
"@vitest/coverage-v8": "^4.1.0",
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ import RelationshipPill from '$lib/components/RelationshipPill.svelte';
|
|||||||
|
|
||||||
type Person = { id: string; firstName?: string | null; lastName: string; displayName: string };
|
type Person = { id: string; firstName?: string | null; lastName: string; displayName: string };
|
||||||
type Tag = { id: string; name: string };
|
type Tag = { id: string; name: string };
|
||||||
|
type GeschichteSummary = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
publishedAt?: string;
|
||||||
|
author?: { firstName?: string; lastName?: string; email: string };
|
||||||
|
};
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
documentDate: string | null;
|
documentDate: string | null;
|
||||||
@@ -16,6 +22,9 @@ type Props = {
|
|||||||
receivers: Person[];
|
receivers: Person[];
|
||||||
tags: Tag[];
|
tags: Tag[];
|
||||||
inferredRelationship?: { labelFromA: string; labelFromB: string } | null;
|
inferredRelationship?: { labelFromA: string; labelFromB: string } | null;
|
||||||
|
geschichten?: GeschichteSummary[];
|
||||||
|
documentId?: string;
|
||||||
|
canBlogWrite?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -25,10 +34,30 @@ let {
|
|||||||
sender,
|
sender,
|
||||||
receivers,
|
receivers,
|
||||||
tags,
|
tags,
|
||||||
inferredRelationship = null
|
inferredRelationship = null,
|
||||||
|
geschichten = [],
|
||||||
|
documentId,
|
||||||
|
canBlogWrite = false
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
const VISIBLE_RECEIVER_LIMIT = 5;
|
const VISIBLE_RECEIVER_LIMIT = 5;
|
||||||
|
const VISIBLE_GESCHICHTEN_LIMIT = 3;
|
||||||
|
const showGeschichtenColumn = $derived(geschichten.length > 0 || canBlogWrite);
|
||||||
|
const visibleGeschichten = $derived(geschichten.slice(0, VISIBLE_GESCHICHTEN_LIMIT));
|
||||||
|
const hasGeschichtenOverflow = $derived(geschichten.length >= VISIBLE_GESCHICHTEN_LIMIT);
|
||||||
|
const gridClass = $derived(showGeschichtenColumn ? 'lg:grid-cols-4' : 'lg:grid-cols-3');
|
||||||
|
|
||||||
|
function formatGeschichteAuthor(g: GeschichteSummary): string {
|
||||||
|
const a = g.author;
|
||||||
|
if (!a) return '';
|
||||||
|
const full = [a.firstName, a.lastName].filter(Boolean).join(' ').trim();
|
||||||
|
return full || a.email || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatGeschichteDate(g: GeschichteSummary): string {
|
||||||
|
if (!g.publishedAt) return '';
|
||||||
|
return formatDate(g.publishedAt.slice(0, 10), 'short');
|
||||||
|
}
|
||||||
|
|
||||||
const formattedDate = $derived(documentDate ? formatDate(documentDate) : '—');
|
const formattedDate = $derived(documentDate ? formatDate(documentDate) : '—');
|
||||||
const displayLocation = $derived(location ?? '—');
|
const displayLocation = $derived(location ?? '—');
|
||||||
@@ -67,7 +96,7 @@ function getFullName(person: Person): string {
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
<div class="border-b border-line p-6">
|
<div class="border-b border-line p-6">
|
||||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
<div class="grid grid-cols-1 gap-6 {gridClass}">
|
||||||
<!-- Column 1: Details -->
|
<!-- Column 1: Details -->
|
||||||
<div>
|
<div>
|
||||||
<h2 class="mb-4 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
<h2 class="mb-4 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||||
@@ -159,5 +188,51 @@ function getFullName(person: Person): string {
|
|||||||
<p class="font-serif text-sm text-ink-3">{m.doc_details_no_tags()}</p>
|
<p class="font-serif text-sm text-ink-3">{m.doc_details_no_tags()}</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Column 4: Geschichten (visible when stories exist or user can author) -->
|
||||||
|
{#if showGeschichtenColumn}
|
||||||
|
<div>
|
||||||
|
<div class="mb-4 flex items-center justify-between">
|
||||||
|
<h2 class="font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||||
|
{m.geschichten_card_heading()}
|
||||||
|
</h2>
|
||||||
|
{#if canBlogWrite && documentId}
|
||||||
|
<a
|
||||||
|
href="/geschichten/new?documentId={documentId}"
|
||||||
|
class="font-sans text-xs font-medium text-ink/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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
147
frontend/src/lib/components/DocumentMultiSelect.svelte
Normal file
147
frontend/src/lib/components/DocumentMultiSelect.svelte
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { components } from '$lib/generated/api';
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import { clickOutside } from '$lib/actions/clickOutside';
|
||||||
|
import { formatDate } from '$lib/utils/date';
|
||||||
|
|
||||||
|
type Document = components['schemas']['Document'];
|
||||||
|
type DocumentSearchItem = components['schemas']['DocumentSearchItem'];
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
selectedDocuments?: Document[];
|
||||||
|
placeholder?: string;
|
||||||
|
hiddenInputName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
selectedDocuments = $bindable([]),
|
||||||
|
placeholder = m.geschichte_editor_search_document(),
|
||||||
|
hiddenInputName = 'documentIds'
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let searchTerm = $state('');
|
||||||
|
let results: Document[] = $state([]);
|
||||||
|
let showDropdown = $state(false);
|
||||||
|
let loading = $state(false);
|
||||||
|
let debounceTimer: ReturnType<typeof setTimeout>;
|
||||||
|
let inputEl: HTMLInputElement;
|
||||||
|
let dropdownStyle = $state('');
|
||||||
|
|
||||||
|
function updateDropdownPosition() {
|
||||||
|
if (!inputEl) return;
|
||||||
|
const rect = inputEl.getBoundingClientRect();
|
||||||
|
dropdownStyle = `position:fixed;top:${rect.bottom + 4}px;left:${rect.left}px;width:${rect.width}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleInput() {
|
||||||
|
showDropdown = true;
|
||||||
|
clearTimeout(debounceTimer);
|
||||||
|
debounceTimer = setTimeout(async () => {
|
||||||
|
if (searchTerm.length < 1) {
|
||||||
|
results = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loading = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/documents/search?q=${encodeURIComponent(searchTerm)}&size=10`);
|
||||||
|
if (res.ok) {
|
||||||
|
const body: { items: DocumentSearchItem[] } = await res.json();
|
||||||
|
const docs = body.items.map((it) => it.document);
|
||||||
|
results = docs.filter((d) => !selectedDocuments.some((s) => s.id === d.id));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
results = [];
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectDocument(doc: Document) {
|
||||||
|
selectedDocuments = [...selectedDocuments, doc];
|
||||||
|
searchTerm = '';
|
||||||
|
showDropdown = false;
|
||||||
|
results = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeDocument(id: string | undefined) {
|
||||||
|
selectedDocuments = selectedDocuments.filter((d) => d.id !== id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDocLabel(doc: Document): string {
|
||||||
|
if (doc.documentDate) return `${doc.title} · ${formatDate(doc.documentDate, 'short')}`;
|
||||||
|
return doc.title;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onscroll={updateDropdownPosition} onresize={updateDropdownPosition} />
|
||||||
|
|
||||||
|
{#each selectedDocuments as doc (doc.id)}
|
||||||
|
<input type="hidden" name={hiddenInputName} value={doc.id} />
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<div class="relative" use:clickOutside onclickoutside={() => (showDropdown = false)}>
|
||||||
|
<div
|
||||||
|
class="flex min-h-[38px] flex-wrap gap-2 rounded border border-line bg-surface p-2 shadow-sm focus-within:ring-2 focus-within:ring-focus-ring focus-within:outline-none"
|
||||||
|
>
|
||||||
|
{#each selectedDocuments as doc (doc.id)}
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center gap-1 rounded bg-muted px-2 py-1 text-sm font-medium text-ink"
|
||||||
|
>
|
||||||
|
{formatDocLabel(doc)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => removeDocument(doc.id)}
|
||||||
|
class="ml-0.5 text-ink/50 hover:text-red-500 focus:outline-none"
|
||||||
|
aria-label={m.comp_multiselect_remove()}
|
||||||
|
>
|
||||||
|
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<input
|
||||||
|
bind:this={inputEl}
|
||||||
|
type="text"
|
||||||
|
autocomplete="off"
|
||||||
|
bind:value={searchTerm}
|
||||||
|
oninput={handleInput}
|
||||||
|
onfocus={() => {
|
||||||
|
updateDropdownPosition();
|
||||||
|
showDropdown = true;
|
||||||
|
}}
|
||||||
|
placeholder={placeholder}
|
||||||
|
class="min-w-[120px] flex-1 border-none bg-transparent p-1 text-sm outline-none focus:ring-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if showDropdown && (results.length > 0 || loading)}
|
||||||
|
<div
|
||||||
|
style={dropdownStyle}
|
||||||
|
class="ring-opacity-5 z-50 max-h-60 overflow-auto rounded-md bg-surface py-1 text-base shadow-lg ring-1 ring-black sm:text-sm"
|
||||||
|
>
|
||||||
|
{#if loading}
|
||||||
|
<div class="p-2 text-sm text-ink-2">{m.comp_multiselect_loading()}</div>
|
||||||
|
{:else}
|
||||||
|
{#each results as doc (doc.id)}
|
||||||
|
<div
|
||||||
|
class="cursor-pointer py-2 pr-9 pl-3 text-ink select-none hover:bg-muted"
|
||||||
|
onclick={() => selectDocument(doc)}
|
||||||
|
onkeydown={(e) => e.key === 'Enter' && selectDocument(doc)}
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
{formatDocLabel(doc)}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -25,12 +25,21 @@ type Doc = {
|
|||||||
tags?: Tag[] | null;
|
tags?: Tag[] | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type GeschichteSummary = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
publishedAt?: string;
|
||||||
|
author?: { firstName?: string; lastName?: string; email: string };
|
||||||
|
};
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
doc: Doc;
|
doc: Doc;
|
||||||
canWrite: boolean;
|
canWrite: boolean;
|
||||||
fileUrl: string;
|
fileUrl: string;
|
||||||
transcribeMode: boolean;
|
transcribeMode: boolean;
|
||||||
inferredRelationship?: { labelFromA: string; labelFromB: string } | null;
|
inferredRelationship?: { labelFromA: string; labelFromB: string } | null;
|
||||||
|
geschichten?: GeschichteSummary[];
|
||||||
|
canBlogWrite?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -38,7 +47,9 @@ let {
|
|||||||
canWrite,
|
canWrite,
|
||||||
fileUrl,
|
fileUrl,
|
||||||
transcribeMode = $bindable(),
|
transcribeMode = $bindable(),
|
||||||
inferredRelationship = null
|
inferredRelationship = null,
|
||||||
|
geschichten = [],
|
||||||
|
canBlogWrite = false
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let detailsOpen = $state(false);
|
let detailsOpen = $state(false);
|
||||||
@@ -283,6 +294,9 @@ let mobileMenuOpen = $state(false);
|
|||||||
receivers={doc.receivers ? [...doc.receivers] : []}
|
receivers={doc.receivers ? [...doc.receivers] : []}
|
||||||
tags={doc.tags ? [...doc.tags] : []}
|
tags={doc.tags ? [...doc.tags] : []}
|
||||||
inferredRelationship={inferredRelationship}
|
inferredRelationship={inferredRelationship}
|
||||||
|
geschichten={geschichten}
|
||||||
|
documentId={doc.id}
|
||||||
|
canBlogWrite={canBlogWrite}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
329
frontend/src/lib/components/GeschichteEditor.svelte
Normal file
329
frontend/src/lib/components/GeschichteEditor.svelte
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onDestroy, onMount } from 'svelte';
|
||||||
|
import { beforeNavigate } from '$app/navigation';
|
||||||
|
import { Editor } from '@tiptap/core';
|
||||||
|
import StarterKit from '@tiptap/starter-kit';
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import type { components } from '$lib/generated/api';
|
||||||
|
import PersonMultiSelect from './PersonMultiSelect.svelte';
|
||||||
|
import DocumentMultiSelect from './DocumentMultiSelect.svelte';
|
||||||
|
|
||||||
|
type Geschichte = components['schemas']['Geschichte'];
|
||||||
|
type Person = components['schemas']['Person'];
|
||||||
|
type Document = components['schemas']['Document'];
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
geschichte?: Geschichte | null;
|
||||||
|
initialPersons?: Person[];
|
||||||
|
initialDocuments?: Document[];
|
||||||
|
onSubmit: (payload: {
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
status: 'DRAFT' | 'PUBLISHED';
|
||||||
|
personIds: string[];
|
||||||
|
documentIds: string[];
|
||||||
|
}) => Promise<void>;
|
||||||
|
submitting?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
geschichte = null,
|
||||||
|
initialPersons = [],
|
||||||
|
initialDocuments = [],
|
||||||
|
onSubmit,
|
||||||
|
submitting = false
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
// Initial-state snapshot from incoming props. The editor owns these values
|
||||||
|
// after mount; the parent should re-mount the component with a different
|
||||||
|
// `geschichte` to reset (consistent with how form components in this codebase
|
||||||
|
// behave — see DocumentEdit page).
|
||||||
|
let title = $state(geschichte?.title ?? '');
|
||||||
|
let body = $state(geschichte?.body ?? '');
|
||||||
|
let status: 'DRAFT' | 'PUBLISHED' = $state(geschichte?.status ?? 'DRAFT');
|
||||||
|
let selectedPersons: Person[] = $state(
|
||||||
|
geschichte?.persons ? Array.from(geschichte.persons) : initialPersons
|
||||||
|
);
|
||||||
|
let selectedDocuments: Document[] = $state(
|
||||||
|
geschichte?.documents ? Array.from(geschichte.documents) : initialDocuments
|
||||||
|
);
|
||||||
|
|
||||||
|
let dirty = $state(false);
|
||||||
|
let titleTouched = $state(false);
|
||||||
|
|
||||||
|
const titleEmpty = $derived(title.trim().length === 0);
|
||||||
|
const isDraft = $derived(status === 'DRAFT');
|
||||||
|
const showTitleError = $derived(titleEmpty && titleTouched);
|
||||||
|
|
||||||
|
let editorEl: HTMLDivElement;
|
||||||
|
let editor: Editor | null = null;
|
||||||
|
let toolbarVersion = $state(0);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
editor = new Editor({
|
||||||
|
element: editorEl,
|
||||||
|
content: body,
|
||||||
|
extensions: [
|
||||||
|
StarterKit.configure({
|
||||||
|
heading: { levels: [2, 3] },
|
||||||
|
code: false,
|
||||||
|
codeBlock: false,
|
||||||
|
blockquote: false,
|
||||||
|
strike: false,
|
||||||
|
horizontalRule: false,
|
||||||
|
hardBreak: false
|
||||||
|
})
|
||||||
|
],
|
||||||
|
editorProps: {
|
||||||
|
attributes: {
|
||||||
|
role: 'textbox',
|
||||||
|
'aria-multiline': 'true',
|
||||||
|
'aria-label': m.geschichte_editor_body_placeholder(),
|
||||||
|
class:
|
||||||
|
'prose max-w-none focus:outline-none min-h-[260px] font-serif text-base leading-relaxed'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onUpdate({ editor: ed }) {
|
||||||
|
body = ed.getHTML();
|
||||||
|
dirty = true;
|
||||||
|
},
|
||||||
|
onSelectionUpdate() {
|
||||||
|
toolbarVersion++;
|
||||||
|
},
|
||||||
|
onTransaction() {
|
||||||
|
toolbarVersion++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
editor?.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeNavigate(({ cancel }) => {
|
||||||
|
if (dirty && !submitting) {
|
||||||
|
const ok = window.confirm(m.geschichte_editor_unsaved_changes());
|
||||||
|
if (!ok) cancel();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleTitleBlur() {
|
||||||
|
titleTouched = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTitleInput() {
|
||||||
|
dirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save(nextStatus: 'DRAFT' | 'PUBLISHED') {
|
||||||
|
titleTouched = true;
|
||||||
|
if (titleEmpty) return;
|
||||||
|
await onSubmit({
|
||||||
|
title: title.trim(),
|
||||||
|
body,
|
||||||
|
status: nextStatus,
|
||||||
|
personIds: selectedPersons.map((p) => p.id!).filter(Boolean),
|
||||||
|
documentIds: selectedDocuments.map((d) => d.id!).filter(Boolean)
|
||||||
|
});
|
||||||
|
dirty = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isActive(name: string, attrs?: Record<string, unknown>): boolean {
|
||||||
|
void toolbarVersion;
|
||||||
|
return editor?.isActive(name, attrs) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function exec(action: () => void) {
|
||||||
|
if (!editor) return;
|
||||||
|
action();
|
||||||
|
editor.commands.focus();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-6 lg:grid-cols-[2fr_1fr]">
|
||||||
|
<!-- Editor column -->
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<!-- Title -->
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={title}
|
||||||
|
oninput={handleTitleInput}
|
||||||
|
onblur={handleTitleBlur}
|
||||||
|
placeholder={m.geschichte_editor_title_placeholder()}
|
||||||
|
aria-invalid={showTitleError}
|
||||||
|
aria-describedby={showTitleError ? 'title-error' : undefined}
|
||||||
|
class="block w-full rounded border border-line bg-surface px-3 py-3 font-serif text-2xl font-bold text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||||
|
/>
|
||||||
|
{#if showTitleError}
|
||||||
|
<p id="title-error" class="mt-1 text-sm text-danger" role="alert">
|
||||||
|
{m.geschichte_editor_title_required()}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Toolbar -->
|
||||||
|
<div
|
||||||
|
role="toolbar"
|
||||||
|
aria-label="Formatierung"
|
||||||
|
class="flex flex-wrap items-center gap-1 rounded border border-line bg-surface p-1"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => exec(() => editor!.chain().focus().toggleBold().run())}
|
||||||
|
aria-label={m.geschichte_editor_toolbar_bold()}
|
||||||
|
aria-pressed={isActive('bold')}
|
||||||
|
title={m.geschichte_editor_toolbar_bold()}
|
||||||
|
class="inline-flex h-11 min-w-[44px] items-center justify-center rounded px-2 font-bold text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring aria-pressed:bg-accent-bg"
|
||||||
|
>
|
||||||
|
B
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => exec(() => editor!.chain().focus().toggleItalic().run())}
|
||||||
|
aria-label={m.geschichte_editor_toolbar_italic()}
|
||||||
|
aria-pressed={isActive('italic')}
|
||||||
|
title={m.geschichte_editor_toolbar_italic()}
|
||||||
|
class="inline-flex h-11 min-w-[44px] items-center justify-center rounded px-2 text-ink italic hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring aria-pressed:bg-accent-bg"
|
||||||
|
>
|
||||||
|
I
|
||||||
|
</button>
|
||||||
|
<span class="mx-1 h-6 w-px bg-line" aria-hidden="true"></span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => exec(() => editor!.chain().focus().toggleHeading({ level: 2 }).run())}
|
||||||
|
aria-label={m.geschichte_editor_toolbar_h2()}
|
||||||
|
aria-pressed={isActive('heading', { level: 2 })}
|
||||||
|
title={m.geschichte_editor_toolbar_h2()}
|
||||||
|
class="inline-flex h-11 min-w-[44px] items-center justify-center rounded px-2 text-sm font-bold text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring aria-pressed:bg-accent-bg"
|
||||||
|
>
|
||||||
|
H2
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => exec(() => editor!.chain().focus().toggleHeading({ level: 3 }).run())}
|
||||||
|
aria-label={m.geschichte_editor_toolbar_h3()}
|
||||||
|
aria-pressed={isActive('heading', { level: 3 })}
|
||||||
|
title={m.geschichte_editor_toolbar_h3()}
|
||||||
|
class="inline-flex h-11 min-w-[44px] items-center justify-center rounded px-2 text-sm font-bold text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring aria-pressed:bg-accent-bg"
|
||||||
|
>
|
||||||
|
H3
|
||||||
|
</button>
|
||||||
|
<span class="mx-1 h-6 w-px bg-line" aria-hidden="true"></span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => exec(() => editor!.chain().focus().toggleBulletList().run())}
|
||||||
|
aria-label={m.geschichte_editor_toolbar_ul()}
|
||||||
|
aria-pressed={isActive('bulletList')}
|
||||||
|
title={m.geschichte_editor_toolbar_ul()}
|
||||||
|
class="inline-flex h-11 min-w-[44px] items-center justify-center rounded px-2 text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring aria-pressed:bg-accent-bg"
|
||||||
|
>
|
||||||
|
•
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => exec(() => editor!.chain().focus().toggleOrderedList().run())}
|
||||||
|
aria-label={m.geschichte_editor_toolbar_ol()}
|
||||||
|
aria-pressed={isActive('orderedList')}
|
||||||
|
title={m.geschichte_editor_toolbar_ol()}
|
||||||
|
class="inline-flex h-11 min-w-[44px] items-center justify-center rounded px-2 text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring aria-pressed:bg-accent-bg"
|
||||||
|
>
|
||||||
|
1.
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Editor surface -->
|
||||||
|
<div
|
||||||
|
class="rounded border border-line bg-surface p-4 shadow-sm focus-within:ring-2 focus-within:ring-focus-ring"
|
||||||
|
>
|
||||||
|
<div bind:this={editorEl} class="min-h-[260px]"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<aside class="flex flex-col gap-6">
|
||||||
|
<section class="rounded border border-line bg-surface p-4 shadow-sm">
|
||||||
|
<h2 class="mb-1 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">Status</h2>
|
||||||
|
<p class="mb-3">
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center rounded px-2 py-1 font-sans text-xs font-bold tracking-widest uppercase {isDraft
|
||||||
|
? 'bg-muted text-ink-2'
|
||||||
|
: 'bg-accent-bg text-ink'}"
|
||||||
|
>
|
||||||
|
{isDraft
|
||||||
|
? m.geschichte_editor_status_draft()
|
||||||
|
: m.geschichte_editor_status_published()}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<p class="font-sans text-xs text-ink-3">
|
||||||
|
{isDraft
|
||||||
|
? m.geschichte_editor_status_draft_hint()
|
||||||
|
: m.geschichte_editor_status_published_hint()}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="rounded border border-line bg-surface p-4 shadow-sm">
|
||||||
|
<h2 class="mb-2 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||||
|
{m.geschichte_editor_personen_heading()}
|
||||||
|
</h2>
|
||||||
|
<p class="mb-3 font-sans text-xs text-ink-3">{m.geschichte_editor_personen_hint()}</p>
|
||||||
|
<PersonMultiSelect bind:selectedPersons={selectedPersons} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="rounded border border-line bg-surface p-4 shadow-sm">
|
||||||
|
<h2 class="mb-2 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||||
|
{m.geschichte_editor_dokumente_heading()}
|
||||||
|
</h2>
|
||||||
|
<p class="mb-3 font-sans text-xs text-ink-3">{m.geschichte_editor_dokumente_hint()}</p>
|
||||||
|
<DocumentMultiSelect bind:selectedDocuments={selectedDocuments} />
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Save bar -->
|
||||||
|
<div
|
||||||
|
class="sticky bottom-0 z-10 -mx-4 mt-6 flex flex-col gap-3 border-t border-line bg-surface px-6 py-4 shadow-[0_-2px_8px_rgba(0,0,0,0.06)] sm:flex-row sm:items-center sm:justify-between"
|
||||||
|
>
|
||||||
|
<p class="font-sans text-xs text-ink-3">
|
||||||
|
{isDraft
|
||||||
|
? m.geschichte_editor_save_hint_draft()
|
||||||
|
: m.geschichte_editor_save_hint_published()}
|
||||||
|
</p>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
{#if isDraft}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => save('DRAFT')}
|
||||||
|
disabled={submitting || titleEmpty}
|
||||||
|
class="inline-flex h-11 items-center rounded border border-line bg-surface px-4 font-sans text-sm font-medium text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{m.geschichte_editor_save_draft()}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => save('PUBLISHED')}
|
||||||
|
disabled={submitting || titleEmpty}
|
||||||
|
class="inline-flex h-11 items-center rounded bg-primary px-4 font-sans text-sm font-medium text-primary-fg hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{m.geschichte_editor_publish()}
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => save('DRAFT')}
|
||||||
|
disabled={submitting}
|
||||||
|
class="inline-flex h-11 items-center rounded border border-line bg-surface px-4 font-sans text-sm font-medium text-amber-700 hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{m.geschichte_editor_unpublish()}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => save('PUBLISHED')}
|
||||||
|
disabled={submitting || titleEmpty}
|
||||||
|
class="inline-flex h-11 items-center rounded bg-primary px-4 font-sans text-sm font-medium text-primary-fg hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{m.geschichte_editor_save()}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
87
frontend/src/lib/components/GeschichtenCard.svelte
Normal file
87
frontend/src/lib/components/GeschichtenCard.svelte
Normal 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}
|
||||||
@@ -41,6 +41,7 @@ export type ErrorCode =
|
|||||||
| 'RELATIONSHIP_NOT_FOUND'
|
| 'RELATIONSHIP_NOT_FOUND'
|
||||||
| 'CIRCULAR_RELATIONSHIP'
|
| 'CIRCULAR_RELATIONSHIP'
|
||||||
| 'DUPLICATE_RELATIONSHIP'
|
| 'DUPLICATE_RELATIONSHIP'
|
||||||
|
| 'GESCHICHTE_NOT_FOUND'
|
||||||
| 'MISSING_CREDENTIALS'
|
| 'MISSING_CREDENTIALS'
|
||||||
| 'UNAUTHORIZED'
|
| 'UNAUTHORIZED'
|
||||||
| 'FORBIDDEN'
|
| 'FORBIDDEN'
|
||||||
@@ -145,6 +146,8 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string {
|
|||||||
return m.error_circular_relationship();
|
return m.error_circular_relationship();
|
||||||
case 'DUPLICATE_RELATIONSHIP':
|
case 'DUPLICATE_RELATIONSHIP':
|
||||||
return m.error_duplicate_relationship();
|
return m.error_duplicate_relationship();
|
||||||
|
case 'GESCHICHTE_NOT_FOUND':
|
||||||
|
return m.error_geschichte_not_found();
|
||||||
case 'MISSING_CREDENTIALS':
|
case 'MISSING_CREDENTIALS':
|
||||||
return m.login_error_missing_credentials();
|
return m.login_error_missing_credentials();
|
||||||
case 'UNAUTHORIZED':
|
case 'UNAUTHORIZED':
|
||||||
|
|||||||
@@ -388,6 +388,22 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/geschichten": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["list"];
|
||||||
|
put?: never;
|
||||||
|
post: operations["create"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/documents": {
|
"/api/documents": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -692,6 +708,22 @@ export interface paths {
|
|||||||
patch: operations["updateGroup"];
|
patch: operations["updateGroup"];
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/geschichten/{id}": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["getById"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete: operations["delete"];
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch: operations["update"];
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/documents/{id}/training-labels": {
|
"/api/documents/{id}/training-labels": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1807,6 +1839,31 @@ export interface components {
|
|||||||
name?: string;
|
name?: string;
|
||||||
permissions?: string[];
|
permissions?: string[];
|
||||||
};
|
};
|
||||||
|
GeschichteUpdateDTO: {
|
||||||
|
title?: string;
|
||||||
|
body?: string;
|
||||||
|
/** @enum {string} */
|
||||||
|
status?: "DRAFT" | "PUBLISHED";
|
||||||
|
personIds?: string[];
|
||||||
|
documentIds?: string[];
|
||||||
|
};
|
||||||
|
Geschichte: {
|
||||||
|
/** Format: uuid */
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
body?: string;
|
||||||
|
/** @enum {string} */
|
||||||
|
status: "DRAFT" | "PUBLISHED";
|
||||||
|
author?: components["schemas"]["AppUser"];
|
||||||
|
persons?: components["schemas"]["Person"][];
|
||||||
|
documents?: components["schemas"]["Document"][];
|
||||||
|
/** Format: date-time */
|
||||||
|
createdAt: string;
|
||||||
|
/** Format: date-time */
|
||||||
|
updatedAt: string;
|
||||||
|
/** Format: date-time */
|
||||||
|
publishedAt?: string;
|
||||||
|
};
|
||||||
CreateTranscriptionBlockDTO: {
|
CreateTranscriptionBlockDTO: {
|
||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
pageNumber?: number;
|
pageNumber?: number;
|
||||||
@@ -3278,6 +3335,55 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
list: {
|
||||||
|
parameters: {
|
||||||
|
query?: {
|
||||||
|
status?: "DRAFT" | "PUBLISHED";
|
||||||
|
personId?: string;
|
||||||
|
documentId?: string;
|
||||||
|
limit?: number;
|
||||||
|
};
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"*/*": components["schemas"]["Geschichte"][];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
create: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["GeschichteUpdateDTO"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"*/*": components["schemas"]["Geschichte"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
createDocument: {
|
createDocument: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -3846,6 +3952,74 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
getById: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"*/*": components["schemas"]["Geschichte"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
delete: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
update: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["GeschichteUpdateDTO"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"*/*": components["schemas"]["Geschichte"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
patchTrainingLabel: {
|
patchTrainingLabel: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
47
frontend/src/lib/utils/sanitize.spec.ts
Normal file
47
frontend/src/lib/utils/sanitize.spec.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { safeHtml } from './sanitize';
|
||||||
|
|
||||||
|
describe('safeHtml', () => {
|
||||||
|
it('returns empty string for null/undefined/empty input', () => {
|
||||||
|
expect(safeHtml(null)).toBe('');
|
||||||
|
expect(safeHtml(undefined)).toBe('');
|
||||||
|
expect(safeHtml('')).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps allowed tags: p, strong, em, br, h2, h3, ul, ol, li', () => {
|
||||||
|
const html =
|
||||||
|
'<p><strong>bold</strong> <em>italic</em><br>x</p>' +
|
||||||
|
'<h2>H2</h2><h3>H3</h3>' +
|
||||||
|
'<ul><li>a</li></ul><ol><li>b</li></ol>';
|
||||||
|
const result = safeHtml(html);
|
||||||
|
expect(result).toContain('<strong>bold</strong>');
|
||||||
|
expect(result).toContain('<em>italic</em>');
|
||||||
|
expect(result).toContain('<br>');
|
||||||
|
expect(result).toContain('<h2>H2</h2>');
|
||||||
|
expect(result).toContain('<h3>H3</h3>');
|
||||||
|
expect(result).toContain('<ul>');
|
||||||
|
expect(result).toContain('<ol>');
|
||||||
|
expect(result).toContain('<li>a</li>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips <script> tags entirely', () => {
|
||||||
|
const result = safeHtml('<p>ok</p><script>alert(1)</script>');
|
||||||
|
expect(result).not.toContain('<script>');
|
||||||
|
expect(result).not.toContain('alert');
|
||||||
|
expect(result).toContain('<p>ok</p>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips on* event-handler attributes', () => {
|
||||||
|
const result = safeHtml('<p onclick="evil()">x</p>');
|
||||||
|
expect(result).not.toContain('onclick');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips disallowed elements like <img>, <a>, <iframe>', () => {
|
||||||
|
const result = safeHtml(
|
||||||
|
'<p>x</p><img src="x" onerror="alert(1)"><a href="javascript:alert(1)">link</a><iframe></iframe>'
|
||||||
|
);
|
||||||
|
expect(result).not.toContain('<img');
|
||||||
|
expect(result).not.toContain('<a ');
|
||||||
|
expect(result).not.toContain('<iframe');
|
||||||
|
});
|
||||||
|
});
|
||||||
17
frontend/src/lib/utils/sanitize.ts
Normal file
17
frontend/src/lib/utils/sanitize.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import DOMPurify from 'isomorphic-dompurify';
|
||||||
|
|
||||||
|
const ALLOWED_TAGS = ['p', 'br', 'strong', 'em', 'h2', 'h3', 'ul', 'ol', 'li'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render-side sanitiser for Geschichte body HTML. The backend already
|
||||||
|
* sanitises with the OWASP allow-list on save, but we re-run on render
|
||||||
|
* because the API can be called directly and stored content can pre-date
|
||||||
|
* a tightening of the allow-list.
|
||||||
|
*/
|
||||||
|
export function safeHtml(raw: string | null | undefined): string {
|
||||||
|
if (!raw) return '';
|
||||||
|
return DOMPurify.sanitize(raw, {
|
||||||
|
ALLOWED_TAGS,
|
||||||
|
ALLOWED_ATTR: []
|
||||||
|
});
|
||||||
|
}
|
||||||
36
frontend/src/lib/utils/stripHtml.spec.ts
Normal file
36
frontend/src/lib/utils/stripHtml.spec.ts
Normal 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…');
|
||||||
|
});
|
||||||
|
});
|
||||||
23
frontend/src/lib/utils/stripHtml.ts
Normal file
23
frontend/src/lib/utils/stripHtml.ts
Normal 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*$/, '') + '…';
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ export const load: LayoutServerLoad = async ({ locals }) => {
|
|||||||
canWrite: groups.some((g) => g.permissions.includes('WRITE_ALL')),
|
canWrite: groups.some((g) => g.permissions.includes('WRITE_ALL')),
|
||||||
canAnnotate: groups.some(
|
canAnnotate: groups.some(
|
||||||
(g) => g.permissions.includes('WRITE_ALL') || g.permissions.includes('ANNOTATE_ALL')
|
(g) => g.permissions.includes('WRITE_ALL') || g.permissions.includes('ANNOTATE_ALL')
|
||||||
)
|
),
|
||||||
|
canBlogWrite: groups.some((g) => g.permissions.includes('BLOG_WRITE'))
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -68,6 +68,16 @@ function handleOverlayKeydown(event: KeyboardEvent) {
|
|||||||
>
|
>
|
||||||
{m.nav_stammbaum()}
|
{m.nav_stammbaum()}
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="/geschichten"
|
||||||
|
class="my-2 inline-flex items-center px-3 font-sans text-xs font-bold tracking-widest uppercase transition-colors focus:outline-none focus-visible:rounded focus-visible:ring-2 focus-visible:ring-focus-ring
|
||||||
|
{page.url.pathname.startsWith('/geschichten')
|
||||||
|
? 'border-b-2 border-accent text-white'
|
||||||
|
: 'text-white/70 hover:text-white'}"
|
||||||
|
>
|
||||||
|
{m.nav_geschichten()}
|
||||||
|
</a>
|
||||||
{#if isAdmin}
|
{#if isAdmin}
|
||||||
<a
|
<a
|
||||||
href="/admin"
|
href="/admin"
|
||||||
@@ -170,6 +180,16 @@ function handleOverlayKeydown(event: KeyboardEvent) {
|
|||||||
{m.nav_stammbaum()}
|
{m.nav_stammbaum()}
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="/geschichten"
|
||||||
|
class="block flex min-h-[44px] w-full items-center px-4 py-3 font-sans text-sm font-bold tracking-widest uppercase transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-inset
|
||||||
|
{page.url.pathname.startsWith('/geschichten')
|
||||||
|
? 'bg-accent-bg text-ink'
|
||||||
|
: 'text-ink-2 hover:bg-muted hover:text-ink'}"
|
||||||
|
>
|
||||||
|
{m.nav_geschichten()}
|
||||||
|
</a>
|
||||||
|
|
||||||
{#if isAdmin}
|
{#if isAdmin}
|
||||||
<a
|
<a
|
||||||
href="/admin"
|
href="/admin"
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ const baseData = {
|
|||||||
user: undefined,
|
user: undefined,
|
||||||
canWrite: true,
|
canWrite: true,
|
||||||
canAnnotate: false,
|
canAnnotate: false,
|
||||||
|
canBlogWrite: false,
|
||||||
editUser: makeUser(),
|
editUser: makeUser(),
|
||||||
groups
|
groups
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,7 +10,13 @@ const groups = [
|
|||||||
{ id: 'g2', name: 'Admins', permissions: ['ADMIN'] }
|
{ id: 'g2', name: 'Admins', permissions: ['ADMIN'] }
|
||||||
];
|
];
|
||||||
|
|
||||||
const baseData = { user: undefined, canWrite: true, canAnnotate: false, groups };
|
const baseData = {
|
||||||
|
user: undefined,
|
||||||
|
canWrite: true,
|
||||||
|
canAnnotate: false,
|
||||||
|
canBlogWrite: false,
|
||||||
|
groups
|
||||||
|
};
|
||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const baseData = {
|
|||||||
user: undefined,
|
user: undefined,
|
||||||
canWrite: true,
|
canWrite: true,
|
||||||
canAnnotate: false,
|
canAnnotate: false,
|
||||||
|
canBlogWrite: false,
|
||||||
documents: [],
|
documents: [],
|
||||||
initialValues: { senderName: '', receiverName: '' },
|
initialValues: { senderName: '', receiverName: '' },
|
||||||
filters: { senderId: '', receiverId: '', from: '', to: '', dir: 'DESC' as const }
|
filters: { senderId: '', receiverId: '', from: '', to: '', dir: 'DESC' as const }
|
||||||
|
|||||||
@@ -7,7 +7,12 @@ export async function load({ params, fetch }) {
|
|||||||
const { id } = params;
|
const { id } = params;
|
||||||
const api = createApiClient(fetch);
|
const api = createApiClient(fetch);
|
||||||
|
|
||||||
const docResult = await api.GET('/api/documents/{id}', { params: { path: { id } } });
|
const [docResult, geschichtenResult] = await Promise.all([
|
||||||
|
api.GET('/api/documents/{id}', { params: { path: { id } } }),
|
||||||
|
api.GET('/api/geschichten', {
|
||||||
|
params: { query: { status: 'PUBLISHED', documentId: id } }
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
if (docResult.response.status === 401) throw redirect(302, '/login');
|
if (docResult.response.status === 401) throw redirect(302, '/login');
|
||||||
|
|
||||||
@@ -18,8 +23,9 @@ export async function load({ params, fetch }) {
|
|||||||
|
|
||||||
const document = docResult.data!;
|
const document = docResult.data!;
|
||||||
const inferredRelationship = await loadInferredRelationship(api, document);
|
const inferredRelationship = await loadInferredRelationship(api, document);
|
||||||
|
const geschichten = geschichtenResult.data ?? [];
|
||||||
|
|
||||||
return { document, inferredRelationship };
|
return { document, inferredRelationship, geschichten };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadInferredRelationship(
|
async function loadInferredRelationship(
|
||||||
|
|||||||
@@ -424,6 +424,8 @@ onMount(() => {
|
|||||||
fileUrl={fileLoader.fileUrl}
|
fileUrl={fileLoader.fileUrl}
|
||||||
bind:transcribeMode={transcribeMode}
|
bind:transcribeMode={transcribeMode}
|
||||||
inferredRelationship={data.inferredRelationship}
|
inferredRelationship={data.inferredRelationship}
|
||||||
|
geschichten={data.geschichten ?? []}
|
||||||
|
canBlogWrite={data.canBlogWrite ?? false}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="relative flex-1 overflow-hidden {transcribeMode ? 'flex flex-col md:flex-row' : ''}">
|
<div class="relative flex-1 overflow-hidden {transcribeMode ? 'flex flex-col md:flex-row' : ''}">
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const baseData = {
|
|||||||
user: undefined,
|
user: undefined,
|
||||||
canWrite: true,
|
canWrite: true,
|
||||||
canAnnotate: false,
|
canAnnotate: false,
|
||||||
|
canBlogWrite: false,
|
||||||
persons: [],
|
persons: [],
|
||||||
initialSenderId: '',
|
initialSenderId: '',
|
||||||
initialSenderName: '',
|
initialSenderName: '',
|
||||||
|
|||||||
36
frontend/src/routes/geschichten/+page.server.ts
Normal file
36
frontend/src/routes/geschichten/+page.server.ts
Normal 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
|
||||||
|
};
|
||||||
|
};
|
||||||
130
frontend/src/routes/geschichten/+page.svelte
Normal file
130
frontend/src/routes/geschichten/+page.svelte
Normal 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>
|
||||||
16
frontend/src/routes/geschichten/[id]/+page.server.ts
Normal file
16
frontend/src/routes/geschichten/[id]/+page.server.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
import { createApiClient } from '$lib/api.server';
|
||||||
|
import { getErrorMessage } from '$lib/errors';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ params, fetch }) => {
|
||||||
|
const api = createApiClient(fetch);
|
||||||
|
const result = await api.GET('/api/geschichten/{id}', {
|
||||||
|
params: { path: { id: params.id } }
|
||||||
|
});
|
||||||
|
if (!result.response.ok) {
|
||||||
|
const code = (result.error as unknown as { code?: string })?.code;
|
||||||
|
throw error(result.response.status, getErrorMessage(code));
|
||||||
|
}
|
||||||
|
return { geschichte: result.data! };
|
||||||
|
};
|
||||||
133
frontend/src/routes/geschichten/[id]/+page.svelte
Normal file
133
frontend/src/routes/geschichten/[id]/+page.svelte
Normal 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>
|
||||||
20
frontend/src/routes/geschichten/[id]/edit/+page.server.ts
Normal file
20
frontend/src/routes/geschichten/[id]/edit/+page.server.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { error, redirect } from '@sveltejs/kit';
|
||||||
|
import { createApiClient } from '$lib/api.server';
|
||||||
|
import { getErrorMessage } from '$lib/errors';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ params, fetch, parent }) => {
|
||||||
|
const layout = await parent();
|
||||||
|
if (!layout.canBlogWrite) {
|
||||||
|
throw redirect(303, `/geschichten/${params.id}`);
|
||||||
|
}
|
||||||
|
const api = createApiClient(fetch);
|
||||||
|
const result = await api.GET('/api/geschichten/{id}', {
|
||||||
|
params: { path: { id: params.id } }
|
||||||
|
});
|
||||||
|
if (!result.response.ok) {
|
||||||
|
const code = (result.error as unknown as { code?: string })?.code;
|
||||||
|
throw error(result.response.status, getErrorMessage(code));
|
||||||
|
}
|
||||||
|
return { geschichte: result.data! };
|
||||||
|
};
|
||||||
60
frontend/src/routes/geschichten/[id]/edit/+page.svelte
Normal file
60
frontend/src/routes/geschichten/[id]/edit/+page.svelte
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import GeschichteEditor from '$lib/components/GeschichteEditor.svelte';
|
||||||
|
import BackButton from '$lib/components/BackButton.svelte';
|
||||||
|
import { getErrorMessage } from '$lib/errors';
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
|
let submitting = $state(false);
|
||||||
|
let errorMessage: string | null = $state(null);
|
||||||
|
|
||||||
|
async function handleSubmit(payload: {
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
status: 'DRAFT' | 'PUBLISHED';
|
||||||
|
personIds: string[];
|
||||||
|
documentIds: string[];
|
||||||
|
}) {
|
||||||
|
submitting = true;
|
||||||
|
errorMessage = null;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/geschichten/${data.geschichte.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const code = (await res.json().catch(() => ({})))?.code;
|
||||||
|
errorMessage = getErrorMessage(code);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
goto(`/geschichten/${data.geschichte.id}`);
|
||||||
|
} finally {
|
||||||
|
submitting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-5xl px-4 py-8">
|
||||||
|
<div class="mb-6">
|
||||||
|
<BackButton />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 class="mb-6 font-serif text-3xl font-bold text-ink">
|
||||||
|
{m.btn_edit()}: {data.geschichte.title}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{#if errorMessage}
|
||||||
|
<div
|
||||||
|
class="mb-4 rounded border border-danger bg-danger/10 p-3 text-sm text-danger"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
{errorMessage}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<GeschichteEditor geschichte={data.geschichte} onSubmit={handleSubmit} submitting={submitting} />
|
||||||
|
</div>
|
||||||
33
frontend/src/routes/geschichten/new/+page.server.ts
Normal file
33
frontend/src/routes/geschichten/new/+page.server.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
import { createApiClient } from '$lib/api.server';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ url, fetch, parent }) => {
|
||||||
|
const layout = await parent();
|
||||||
|
if (!layout.canBlogWrite) {
|
||||||
|
throw redirect(303, '/geschichten');
|
||||||
|
}
|
||||||
|
|
||||||
|
const api = createApiClient(fetch);
|
||||||
|
const personId = url.searchParams.get('personId');
|
||||||
|
const documentId = url.searchParams.get('documentId');
|
||||||
|
|
||||||
|
const [personResult, documentResult] = await Promise.all([
|
||||||
|
personId
|
||||||
|
? api.GET('/api/persons/{id}', { params: { path: { id: personId } } })
|
||||||
|
: Promise.resolve(null),
|
||||||
|
documentId
|
||||||
|
? api.GET('/api/documents/{id}', { params: { path: { id: documentId } } })
|
||||||
|
: Promise.resolve(null)
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Silently ignore 404/403 to avoid leaking entity existence on unknown IDs.
|
||||||
|
const initialPersons =
|
||||||
|
personResult && personResult.response.ok && personResult.data ? [personResult.data] : [];
|
||||||
|
const initialDocuments =
|
||||||
|
documentResult && documentResult.response.ok && documentResult.data
|
||||||
|
? [documentResult.data]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return { initialPersons, initialDocuments };
|
||||||
|
};
|
||||||
64
frontend/src/routes/geschichten/new/+page.svelte
Normal file
64
frontend/src/routes/geschichten/new/+page.svelte
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import GeschichteEditor from '$lib/components/GeschichteEditor.svelte';
|
||||||
|
import BackButton from '$lib/components/BackButton.svelte';
|
||||||
|
import { getErrorMessage } from '$lib/errors';
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
|
let submitting = $state(false);
|
||||||
|
let errorMessage: string | null = $state(null);
|
||||||
|
|
||||||
|
async function handleSubmit(payload: {
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
status: 'DRAFT' | 'PUBLISHED';
|
||||||
|
personIds: string[];
|
||||||
|
documentIds: string[];
|
||||||
|
}) {
|
||||||
|
submitting = true;
|
||||||
|
errorMessage = null;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/geschichten', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const code = (await res.json().catch(() => ({})))?.code;
|
||||||
|
errorMessage = getErrorMessage(code);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const created = await res.json();
|
||||||
|
goto(`/geschichten/${created.id}`);
|
||||||
|
} finally {
|
||||||
|
submitting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-5xl px-4 py-8">
|
||||||
|
<div class="mb-6">
|
||||||
|
<BackButton />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 class="mb-6 font-serif text-3xl font-bold text-ink">{m.geschichten_new_button()}</h1>
|
||||||
|
|
||||||
|
{#if errorMessage}
|
||||||
|
<div
|
||||||
|
class="mb-4 rounded border border-danger bg-danger/10 p-3 text-sm text-danger"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
{errorMessage}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<GeschichteEditor
|
||||||
|
initialPersons={data.initialPersons}
|
||||||
|
initialDocuments={data.initialDocuments}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
submitting={submitting}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
@@ -25,6 +25,7 @@ const makeData = (overrides = {}) => ({
|
|||||||
},
|
},
|
||||||
canWrite: true,
|
canWrite: true,
|
||||||
canAnnotate: false,
|
canAnnotate: false,
|
||||||
|
canBlogWrite: false,
|
||||||
...overrides
|
...overrides
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ const baseData = {
|
|||||||
} as User,
|
} as User,
|
||||||
canWrite: true,
|
canWrite: true,
|
||||||
canAnnotate: false,
|
canAnnotate: false,
|
||||||
|
canBlogWrite: false,
|
||||||
resumeDoc: null,
|
resumeDoc: null,
|
||||||
pulse: null,
|
pulse: null,
|
||||||
activityFeed: [],
|
activityFeed: [],
|
||||||
|
|||||||
@@ -17,14 +17,18 @@ export async function load({ params, fetch, locals }) {
|
|||||||
receivedDocsResult,
|
receivedDocsResult,
|
||||||
aliasesResult,
|
aliasesResult,
|
||||||
relsResult,
|
relsResult,
|
||||||
inferredResult
|
inferredResult,
|
||||||
|
geschichtenResult
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
api.GET('/api/persons/{id}', { params: { path: { id } } }),
|
api.GET('/api/persons/{id}', { params: { path: { id } } }),
|
||||||
api.GET('/api/persons/{id}/documents', { params: { path: { id } } }),
|
api.GET('/api/persons/{id}/documents', { params: { path: { id } } }),
|
||||||
api.GET('/api/persons/{id}/received-documents', { params: { path: { id } } }),
|
api.GET('/api/persons/{id}/received-documents', { params: { path: { id } } }),
|
||||||
api.GET('/api/persons/{id}/aliases', { params: { path: { id } } }),
|
api.GET('/api/persons/{id}/aliases', { params: { path: { id } } }),
|
||||||
api.GET('/api/persons/{id}/relationships', { params: { path: { id } } }),
|
api.GET('/api/persons/{id}/relationships', { params: { path: { id } } }),
|
||||||
api.GET('/api/persons/{id}/inferred-relationships', { params: { path: { id } } })
|
api.GET('/api/persons/{id}/inferred-relationships', { params: { path: { id } } }),
|
||||||
|
api.GET('/api/geschichten', {
|
||||||
|
params: { query: { status: 'PUBLISHED', personId: id } }
|
||||||
|
})
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!personResult.response.ok) {
|
if (!personResult.response.ok) {
|
||||||
@@ -39,6 +43,7 @@ export async function load({ params, fetch, locals }) {
|
|||||||
aliases: aliasesResult.data ?? [],
|
aliases: aliasesResult.data ?? [],
|
||||||
relationships: relsResult.data ?? [],
|
relationships: relsResult.data ?? [],
|
||||||
inferredRelationships: inferredResult.data ?? [],
|
inferredRelationships: inferredResult.data ?? [],
|
||||||
|
geschichten: geschichtenResult.data ?? [],
|
||||||
canWrite
|
canWrite
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import NameHistoryCard from './NameHistoryCard.svelte';
|
|||||||
import CoCorrespondentsList from './CoCorrespondentsList.svelte';
|
import CoCorrespondentsList from './CoCorrespondentsList.svelte';
|
||||||
import PersonDocumentList from './PersonDocumentList.svelte';
|
import PersonDocumentList from './PersonDocumentList.svelte';
|
||||||
import PersonRelationshipsCard from './PersonRelationshipsCard.svelte';
|
import PersonRelationshipsCard from './PersonRelationshipsCard.svelte';
|
||||||
|
import GeschichtenCard from '$lib/components/GeschichtenCard.svelte';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
@@ -92,6 +93,17 @@ const coCorrespondents = $derived.by(() => {
|
|||||||
emptyMessage={m.person_no_received_docs()}
|
emptyMessage={m.person_no_received_docs()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if data.geschichten && data.geschichten.length > 0}
|
||||||
|
<div class="mt-6">
|
||||||
|
<GeschichtenCard
|
||||||
|
geschichten={data.geschichten}
|
||||||
|
personId={person.id}
|
||||||
|
personName={person.displayName}
|
||||||
|
canWrite={data.canBlogWrite ?? false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ describe('person detail load — happy path', () => {
|
|||||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
||||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
||||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
||||||
|
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
||||||
} as ReturnType<typeof createApiClient>);
|
} as ReturnType<typeof createApiClient>);
|
||||||
|
|
||||||
const result = await load({ params: { id: 'p1' }, fetch: mockFetch, locals: mockLocals });
|
const result = await load({ params: { id: 'p1' }, fetch: mockFetch, locals: mockLocals });
|
||||||
@@ -51,6 +52,7 @@ describe('person detail load — happy path', () => {
|
|||||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
||||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
||||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
||||||
|
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
||||||
} as ReturnType<typeof createApiClient>);
|
} as ReturnType<typeof createApiClient>);
|
||||||
|
|
||||||
const result = await load({ params: { id: 'p1' }, fetch: mockFetch, locals: mockLocalsWriter });
|
const result = await load({ params: { id: 'p1' }, fetch: mockFetch, locals: mockLocalsWriter });
|
||||||
@@ -71,6 +73,7 @@ describe('person detail load — happy path', () => {
|
|||||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
||||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
||||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
||||||
|
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
||||||
} as ReturnType<typeof createApiClient>);
|
} as ReturnType<typeof createApiClient>);
|
||||||
|
|
||||||
const result = await load({ params: { id: 'p1' }, fetch: mockFetch, locals: mockLocals });
|
const result = await load({ params: { id: 'p1' }, fetch: mockFetch, locals: mockLocals });
|
||||||
@@ -93,6 +96,7 @@ describe('person detail load — error paths', () => {
|
|||||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
||||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
||||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
||||||
|
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
||||||
} as ReturnType<typeof createApiClient>);
|
} as ReturnType<typeof createApiClient>);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
@@ -112,6 +116,7 @@ describe('person detail load — error paths', () => {
|
|||||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
||||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
||||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
||||||
|
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
||||||
} as ReturnType<typeof createApiClient>);
|
} as ReturnType<typeof createApiClient>);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ const emptyData = {
|
|||||||
user: undefined,
|
user: undefined,
|
||||||
canWrite: true,
|
canWrite: true,
|
||||||
canAnnotate: false,
|
canAnnotate: false,
|
||||||
|
canBlogWrite: false,
|
||||||
q: '',
|
q: '',
|
||||||
persons: [],
|
persons: [],
|
||||||
stats: defaultStats
|
stats: defaultStats
|
||||||
|
|||||||
@@ -2,6 +2,38 @@
|
|||||||
# yarn lockfile v1
|
# 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":
|
"@axe-core/playwright@^4.11.1":
|
||||||
version "4.11.1"
|
version "4.11.1"
|
||||||
resolved "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.11.1.tgz"
|
resolved "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.11.1.tgz"
|
||||||
@@ -53,6 +85,46 @@
|
|||||||
resolved "https://registry.npmjs.org/@blazediff/core/-/core-1.9.1.tgz"
|
resolved "https://registry.npmjs.org/@blazediff/core/-/core-1.9.1.tgz"
|
||||||
integrity sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA==
|
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":
|
"@esbuild/linux-x64@0.27.4":
|
||||||
version "0.27.4"
|
version "0.27.4"
|
||||||
resolved "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz"
|
resolved "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz"
|
||||||
@@ -133,6 +205,11 @@
|
|||||||
"@eslint/core" "^0.17.0"
|
"@eslint/core" "^0.17.0"
|
||||||
levn "^0.4.1"
|
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":
|
"@humanfs/core@^0.19.1":
|
||||||
version "0.19.1"
|
version "0.19.1"
|
||||||
resolved "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz"
|
resolved "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz"
|
||||||
@@ -691,13 +768,6 @@
|
|||||||
resolved "https://registry.npmjs.org/@types/diff/-/diff-7.0.2.tgz"
|
resolved "https://registry.npmjs.org/@types/diff/-/diff-7.0.2.tgz"
|
||||||
integrity sha512-JSWRMozjFKsGlEjiiKajUjIJVKuKdE3oVy2DNtK+fUo8q82nhFZ2CPQwicAIkXrofahDXrWJ7mjelvZphMS98Q==
|
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":
|
"@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"
|
version "1.0.8"
|
||||||
resolved "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz"
|
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"
|
resolved "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz"
|
||||||
integrity sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==
|
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"
|
version "2.0.7"
|
||||||
resolved "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz"
|
resolved "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz"
|
||||||
integrity sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==
|
integrity sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==
|
||||||
@@ -1006,6 +1076,13 @@ balanced-match@^4.0.2:
|
|||||||
resolved "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz"
|
resolved "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz"
|
||||||
integrity sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==
|
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:
|
brace-expansion@^1.1.7:
|
||||||
version "1.1.12"
|
version "1.1.12"
|
||||||
resolved "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz"
|
resolved "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz"
|
||||||
@@ -1127,11 +1204,27 @@ cross-spawn@^7.0.6:
|
|||||||
shebang-command "^2.0.0"
|
shebang-command "^2.0.0"
|
||||||
which "^2.0.1"
|
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:
|
cssesc@^3.0.0:
|
||||||
version "3.0.0"
|
version "3.0.0"
|
||||||
resolved "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz"
|
resolved "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz"
|
||||||
integrity sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==
|
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:
|
debug@^4.3.1, debug@^4.3.2, debug@^4.4.3, debug@4:
|
||||||
version "4.4.3"
|
version "4.4.3"
|
||||||
resolved "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz"
|
resolved "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz"
|
||||||
@@ -1139,6 +1232,11 @@ debug@^4.3.1, debug@^4.3.2, debug@^4.4.3, debug@4:
|
|||||||
dependencies:
|
dependencies:
|
||||||
ms "^2.1.3"
|
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:
|
dedent@1.5.1:
|
||||||
version "1.5.1"
|
version "1.5.1"
|
||||||
resolved "https://registry.npmjs.org/dedent/-/dedent-1.5.1.tgz"
|
resolved "https://registry.npmjs.org/dedent/-/dedent-1.5.1.tgz"
|
||||||
@@ -1184,6 +1282,11 @@ enhanced-resolve@^5.19.0:
|
|||||||
graceful-fs "^4.2.4"
|
graceful-fs "^4.2.4"
|
||||||
tapable "^2.3.0"
|
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:
|
es-module-lexer@^2.0.0:
|
||||||
version "2.0.0"
|
version "2.0.0"
|
||||||
resolved "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz"
|
resolved "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz"
|
||||||
@@ -1465,6 +1568,13 @@ hasown@^2.0.2:
|
|||||||
dependencies:
|
dependencies:
|
||||||
function-bind "^1.1.2"
|
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:
|
html-escaper@^2.0.0:
|
||||||
version "2.0.2"
|
version "2.0.2"
|
||||||
resolved "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz"
|
resolved "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz"
|
||||||
@@ -1535,6 +1645,11 @@ is-module@^1.0.0:
|
|||||||
resolved "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz"
|
resolved "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz"
|
||||||
integrity sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==
|
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:
|
is-reference@^3.0.3:
|
||||||
version "3.0.3"
|
version "3.0.3"
|
||||||
resolved "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz"
|
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"
|
resolved "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz"
|
||||||
integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==
|
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:
|
istanbul-lib-coverage@^3.0.0, istanbul-lib-coverage@^3.2.2:
|
||||||
version "3.2.2"
|
version "3.2.2"
|
||||||
resolved "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz"
|
resolved "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz"
|
||||||
@@ -1608,6 +1731,33 @@ js-yaml@^4.1.1, js-yaml@4.1.1:
|
|||||||
dependencies:
|
dependencies:
|
||||||
argparse "^2.0.1"
|
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:
|
json-buffer@3.0.1:
|
||||||
version "3.0.1"
|
version "3.0.1"
|
||||||
resolved "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz"
|
resolved "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz"
|
||||||
@@ -1719,6 +1869,11 @@ lodash.merge@^4.6.2:
|
|||||||
resolved "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz"
|
resolved "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz"
|
||||||
integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==
|
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:
|
magic-string@^0.30.11, magic-string@^0.30.21, magic-string@^0.30.3, magic-string@^0.30.5:
|
||||||
version "0.30.21"
|
version "0.30.21"
|
||||||
resolved "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz"
|
resolved "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz"
|
||||||
@@ -1742,6 +1897,11 @@ make-dir@^4.0.0:
|
|||||||
dependencies:
|
dependencies:
|
||||||
semver "^7.5.3"
|
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:
|
mini-svg-data-uri@^1.2.3:
|
||||||
version "1.4.4"
|
version "1.4.4"
|
||||||
resolved "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz"
|
resolved "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz"
|
||||||
@@ -1874,6 +2034,13 @@ parse-json@^8.3.0:
|
|||||||
index-to-position "^1.1.0"
|
index-to-position "^1.1.0"
|
||||||
type-fest "^4.39.1"
|
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:
|
path-exists@^4.0.0:
|
||||||
version "4.0.0"
|
version "4.0.0"
|
||||||
resolved "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz"
|
resolved "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz"
|
||||||
@@ -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-state "^1.0.0"
|
||||||
prosemirror-transform "^1.1.0"
|
prosemirror-transform "^1.1.0"
|
||||||
|
|
||||||
punycode@^2.1.0:
|
punycode@^2.1.0, punycode@^2.3.1:
|
||||||
version "2.3.1"
|
version "2.3.1"
|
||||||
resolved "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz"
|
resolved "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz"
|
||||||
integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==
|
integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==
|
||||||
@@ -2179,6 +2346,13 @@ sade@^1.7.4:
|
|||||||
dependencies:
|
dependencies:
|
||||||
mri "^1.1.0"
|
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:
|
semver@^7.5.3, semver@^7.6.3, semver@^7.7.2, semver@^7.7.3:
|
||||||
version "7.7.4"
|
version "7.7.4"
|
||||||
resolved "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz"
|
resolved "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz"
|
||||||
@@ -2305,6 +2479,11 @@ svelte-eslint-parser@^1.4.0:
|
|||||||
magic-string "^0.30.11"
|
magic-string "^0.30.11"
|
||||||
zimmerframe "^1.1.2"
|
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:
|
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"
|
version "4.2.1"
|
||||||
resolved "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz"
|
resolved "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz"
|
||||||
@@ -2338,11 +2517,37 @@ tinyrainbow@^3.0.3:
|
|||||||
resolved "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz"
|
resolved "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz"
|
||||||
integrity sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==
|
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:
|
totalist@^3.0.0:
|
||||||
version "3.0.1"
|
version "3.0.1"
|
||||||
resolved "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz"
|
resolved "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz"
|
||||||
integrity sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==
|
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:
|
ts-api-utils@^2.4.0:
|
||||||
version "2.4.0"
|
version "2.4.0"
|
||||||
resolved "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz"
|
resolved "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz"
|
||||||
@@ -2380,6 +2585,11 @@ undici-types@~7.16.0:
|
|||||||
resolved "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz"
|
resolved "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz"
|
||||||
integrity sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==
|
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:
|
unplugin@^2.1.2:
|
||||||
version "2.3.11"
|
version "2.3.11"
|
||||||
resolved "https://registry.npmjs.org/unplugin/-/unplugin-2.3.11.tgz"
|
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"
|
resolved "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz"
|
||||||
integrity sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==
|
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:
|
webpack-virtual-modules@^0.6.2:
|
||||||
version "0.6.2"
|
version "0.6.2"
|
||||||
resolved "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz"
|
resolved "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz"
|
||||||
integrity sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==
|
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:
|
which@^2.0.1:
|
||||||
version "2.0.2"
|
version "2.0.2"
|
||||||
resolved "https://registry.npmjs.org/which/-/which-2.0.2.tgz"
|
resolved "https://registry.npmjs.org/which/-/which-2.0.2.tgz"
|
||||||
@@ -2522,6 +2758,16 @@ ws@^8.19.0:
|
|||||||
resolved "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz"
|
resolved "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz"
|
||||||
integrity sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==
|
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:
|
yaml-ast-parser@0.0.43:
|
||||||
version "0.0.43"
|
version "0.0.43"
|
||||||
resolved "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz"
|
resolved "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz"
|
||||||
|
|||||||
Reference in New Issue
Block a user