Compare commits
12 Commits
4a0d3b3bea
...
feat/38-do
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca5726e7c3 | ||
|
|
0ef81e20f6 | ||
|
|
1ad8fffd1b | ||
|
|
5fb6a1eec0 | ||
|
|
4f69457a68 | ||
|
|
62f62a89a1 | ||
|
|
d84b997965 | ||
|
|
8c86beb9f9 | ||
|
|
0020d1e773 | ||
|
|
47b8cc9340 | ||
|
|
3e65b2feb3 | ||
|
|
f32ed32f67 |
@@ -1,7 +1,10 @@
|
|||||||
package org.raddatz.familienarchiv.controller;
|
package org.raddatz.familienarchiv.controller;
|
||||||
|
|
||||||
|
import org.raddatz.familienarchiv.dto.BackfillResult;
|
||||||
import org.raddatz.familienarchiv.security.Permission;
|
import org.raddatz.familienarchiv.security.Permission;
|
||||||
import org.raddatz.familienarchiv.security.RequirePermission;
|
import org.raddatz.familienarchiv.security.RequirePermission;
|
||||||
|
import org.raddatz.familienarchiv.service.DocumentService;
|
||||||
|
import org.raddatz.familienarchiv.service.DocumentVersionService;
|
||||||
import org.raddatz.familienarchiv.service.MassImportService;
|
import org.raddatz.familienarchiv.service.MassImportService;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
@@ -18,6 +21,8 @@ import lombok.RequiredArgsConstructor;
|
|||||||
public class AdminController {
|
public class AdminController {
|
||||||
|
|
||||||
private final MassImportService massImportService;
|
private final MassImportService massImportService;
|
||||||
|
private final DocumentService documentService;
|
||||||
|
private final DocumentVersionService documentVersionService;
|
||||||
|
|
||||||
@PostMapping("/trigger-import")
|
@PostMapping("/trigger-import")
|
||||||
public ResponseEntity<MassImportService.ImportStatus> triggerMassImport() {
|
public ResponseEntity<MassImportService.ImportStatus> triggerMassImport() {
|
||||||
@@ -29,4 +34,11 @@ public class AdminController {
|
|||||||
public ResponseEntity<MassImportService.ImportStatus> importStatus() {
|
public ResponseEntity<MassImportService.ImportStatus> importStatus() {
|
||||||
return ResponseEntity.ok(massImportService.getStatus());
|
return ResponseEntity.ok(massImportService.getStatus());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/backfill-versions")
|
||||||
|
public ResponseEntity<BackfillResult> backfillVersions() {
|
||||||
|
int count = documentVersionService.backfillMissingVersions(
|
||||||
|
documentService.getDocumentsWithoutVersions());
|
||||||
|
return ResponseEntity.ok(new BackfillResult(count));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package org.raddatz.familienarchiv.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
|
||||||
|
public record BackfillResult(
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int count
|
||||||
|
) {}
|
||||||
@@ -34,6 +34,9 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
|
|||||||
|
|
||||||
List<Document> findByTags_Id(UUID tagId);
|
List<Document> findByTags_Id(UUID tagId);
|
||||||
|
|
||||||
|
@Query("SELECT d FROM Document d WHERE d.id NOT IN (SELECT DISTINCT dv.documentId FROM DocumentVersion dv)")
|
||||||
|
List<Document> findDocumentsWithoutVersions();
|
||||||
|
|
||||||
@Query("SELECT DISTINCT d FROM Document d " +
|
@Query("SELECT DISTINCT d FROM Document d " +
|
||||||
"JOIN d.receivers r " +
|
"JOIN d.receivers r " +
|
||||||
"WHERE " +
|
"WHERE " +
|
||||||
|
|||||||
@@ -257,6 +257,10 @@ public class DocumentService {
|
|||||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id));
|
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<Document> getDocumentsWithoutVersions() {
|
||||||
|
return documentRepository.findDocumentsWithoutVersions();
|
||||||
|
}
|
||||||
|
|
||||||
public List<Document> getDocumentsBySender(UUID senderId) {
|
public List<Document> getDocumentsBySender(UUID senderId) {
|
||||||
return documentRepository.findBySenderId(senderId);
|
return documentRepository.findBySenderId(senderId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,26 @@ public class DocumentVersionService {
|
|||||||
.build());
|
.build());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public int backfillMissingVersions(List<Document> docs) {
|
||||||
|
int count = 0;
|
||||||
|
for (Document doc : docs) {
|
||||||
|
List<DocumentVersion> existing = versionRepository.findByDocumentIdOrderBySavedAtAsc(doc.getId());
|
||||||
|
if (!existing.isEmpty()) continue;
|
||||||
|
LocalDateTime savedAt = doc.getCreatedAt() != null ? doc.getCreatedAt() : LocalDateTime.now();
|
||||||
|
versionRepository.save(DocumentVersion.builder()
|
||||||
|
.documentId(doc.getId())
|
||||||
|
.savedAt(savedAt)
|
||||||
|
.editorId(null)
|
||||||
|
.editorName("Datenimport")
|
||||||
|
.snapshot(serializeSnapshot(doc))
|
||||||
|
.changedFields("[]")
|
||||||
|
.build());
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
public List<DocumentVersionSummary> getSummaries(UUID documentId) {
|
public List<DocumentVersionSummary> getSummaries(UUID documentId) {
|
||||||
return versionRepository.findByDocumentIdOrderBySavedAtAsc(documentId).stream()
|
return versionRepository.findByDocumentIdOrderBySavedAtAsc(documentId).stream()
|
||||||
.map(v -> new DocumentVersionSummary(
|
.map(v -> new DocumentVersionSummary(
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package org.raddatz.familienarchiv.controller;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.raddatz.familienarchiv.config.SecurityConfig;
|
||||||
|
import org.raddatz.familienarchiv.model.Document;
|
||||||
|
import org.raddatz.familienarchiv.security.PermissionAspect;
|
||||||
|
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
|
||||||
|
import org.raddatz.familienarchiv.service.DocumentService;
|
||||||
|
import org.raddatz.familienarchiv.service.DocumentVersionService;
|
||||||
|
import org.raddatz.familienarchiv.service.MassImportService;
|
||||||
|
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.security.test.context.support.WithMockUser;
|
||||||
|
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||||
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.mockito.ArgumentMatchers.anyList;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
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(AdminController.class)
|
||||||
|
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||||
|
class AdminControllerTest {
|
||||||
|
|
||||||
|
@Autowired MockMvc mockMvc;
|
||||||
|
|
||||||
|
@MockitoBean MassImportService massImportService;
|
||||||
|
@MockitoBean DocumentService documentService;
|
||||||
|
@MockitoBean DocumentVersionService documentVersionService;
|
||||||
|
@MockitoBean CustomUserDetailsService customUserDetailsService;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void backfillVersions_returns401_whenUnauthenticated() throws Exception {
|
||||||
|
mockMvc.perform(post("/api/admin/backfill-versions"))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(roles = "USER")
|
||||||
|
void backfillVersions_returns403_whenNotAdmin() throws Exception {
|
||||||
|
mockMvc.perform(post("/api/admin/backfill-versions"))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "ADMIN")
|
||||||
|
void backfillVersions_returns200_withCount_whenAdmin() throws Exception {
|
||||||
|
when(documentService.getDocumentsWithoutVersions()).thenReturn(List.of(Document.builder().build()));
|
||||||
|
when(documentVersionService.backfillMissingVersions(anyList())).thenReturn(1);
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/admin/backfill-versions"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.count").value(1));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -304,6 +304,76 @@ class DocumentVersionServiceTest {
|
|||||||
.isInstanceOf(DomainException.class);
|
.isInstanceOf(DomainException.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── backfillMissingVersions ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void backfill_createsVersion_withEditorNameDatenimport() {
|
||||||
|
Document doc = minimalDocument();
|
||||||
|
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(doc.getId())).thenReturn(List.of());
|
||||||
|
when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
versionService.backfillMissingVersions(List.of(doc));
|
||||||
|
|
||||||
|
ArgumentCaptor<DocumentVersion> captor = ArgumentCaptor.forClass(DocumentVersion.class);
|
||||||
|
verify(versionRepository).save(captor.capture());
|
||||||
|
assertThat(captor.getValue().getEditorName()).isEqualTo("Datenimport");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void backfill_usesDocumentCreatedAt_asSavedAt() {
|
||||||
|
LocalDateTime createdAt = LocalDateTime.of(2020, 3, 15, 10, 0);
|
||||||
|
Document doc = Document.builder()
|
||||||
|
.id(UUID.randomUUID()).title("T").createdAt(createdAt).build();
|
||||||
|
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(doc.getId())).thenReturn(List.of());
|
||||||
|
when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
versionService.backfillMissingVersions(List.of(doc));
|
||||||
|
|
||||||
|
ArgumentCaptor<DocumentVersion> captor = ArgumentCaptor.forClass(DocumentVersion.class);
|
||||||
|
verify(versionRepository).save(captor.capture());
|
||||||
|
assertThat(captor.getValue().getSavedAt()).isEqualTo(createdAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void backfill_setsChangedFieldsEmpty() {
|
||||||
|
Document doc = minimalDocument();
|
||||||
|
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(doc.getId())).thenReturn(List.of());
|
||||||
|
when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
versionService.backfillMissingVersions(List.of(doc));
|
||||||
|
|
||||||
|
ArgumentCaptor<DocumentVersion> captor = ArgumentCaptor.forClass(DocumentVersion.class);
|
||||||
|
verify(versionRepository).save(captor.capture());
|
||||||
|
assertThat(captor.getValue().getChangedFields()).isEqualTo("[]");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void backfill_skipsDocuments_thatAlreadyHaveVersions() {
|
||||||
|
Document doc = minimalDocument();
|
||||||
|
DocumentVersion existing = DocumentVersion.builder()
|
||||||
|
.id(UUID.randomUUID()).documentId(doc.getId()).snapshot("{}")
|
||||||
|
.changedFields("[]").editorName("user").savedAt(LocalDateTime.now()).build();
|
||||||
|
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(doc.getId())).thenReturn(List.of(existing));
|
||||||
|
|
||||||
|
int count = versionService.backfillMissingVersions(List.of(doc));
|
||||||
|
|
||||||
|
verify(versionRepository, never()).save(any());
|
||||||
|
assertThat(count).isZero();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void backfill_returnsCountOfCreatedVersions() {
|
||||||
|
Document d1 = minimalDocument();
|
||||||
|
Document d2 = minimalDocument();
|
||||||
|
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(d1.getId())).thenReturn(List.of());
|
||||||
|
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(d2.getId())).thenReturn(List.of());
|
||||||
|
when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
int count = versionService.backfillMissingVersions(List.of(d1, d2));
|
||||||
|
|
||||||
|
assertThat(count).isEqualTo(2);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── helpers ──────────────────────────────────────────────────────────────
|
// ─── helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private void authenticateAs(String username) {
|
private void authenticateAs(String username) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
import { test, expect } from '@playwright/test';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Document management E2E tests.
|
* Document management E2E tests.
|
||||||
@@ -142,3 +143,78 @@ test.describe('Document edit', () => {
|
|||||||
await page.screenshot({ path: 'test-results/e2e/document-edit-date-error.png' });
|
await page.screenshot({ path: 'test-results/e2e/document-edit-date-error.png' });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── PDF Viewer ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const PDF_FIXTURE = path.resolve(__dirname, 'fixtures/minimal.pdf');
|
||||||
|
|
||||||
|
test.describe('PDF viewer', () => {
|
||||||
|
let pdfDocHref: string;
|
||||||
|
|
||||||
|
test.beforeAll(async ({ browser }) => {
|
||||||
|
// Create a document and upload the PDF fixture so later tests have a
|
||||||
|
// real file attached. Runs once for the whole describe block.
|
||||||
|
const ctx = await browser.newContext();
|
||||||
|
const p = await ctx.newPage();
|
||||||
|
|
||||||
|
await p.goto('/documents/new');
|
||||||
|
await p.waitForSelector('[data-hydrated]');
|
||||||
|
await p.getByLabel('Titel').fill('E2E PDF Viewer Test');
|
||||||
|
await p.getByRole('button', { name: /Speichern/i }).click();
|
||||||
|
await p.waitForURL(/\/documents\/[^/]+$/);
|
||||||
|
|
||||||
|
// Upload the PDF on the edit page
|
||||||
|
const href = p.url().replace(/\/$/, '');
|
||||||
|
pdfDocHref = href;
|
||||||
|
await p.goto(`${href}/edit`);
|
||||||
|
await p.waitForSelector('[data-hydrated]');
|
||||||
|
await p.locator('input[type="file"][name="file"]').setInputFiles(PDF_FIXTURE);
|
||||||
|
await p.getByRole('button', { name: /Speichern/i }).click();
|
||||||
|
await p.waitForURL(/\/documents\/[^/]+$/);
|
||||||
|
|
||||||
|
await ctx.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('PDF renders in the custom viewer — canvas is present instead of iframe', async ({
|
||||||
|
page
|
||||||
|
}) => {
|
||||||
|
await page.goto(pdfDocHref);
|
||||||
|
await page.waitForSelector('[data-hydrated]');
|
||||||
|
|
||||||
|
// There must be NO iframe — we replaced it with PDF.js canvas rendering.
|
||||||
|
await expect(page.locator('iframe')).not.toBeAttached();
|
||||||
|
|
||||||
|
// At least one canvas element must be visible (one per rendered page).
|
||||||
|
await expect(page.locator('canvas').first()).toBeVisible({ timeout: 15000 });
|
||||||
|
|
||||||
|
await page.screenshot({ path: 'test-results/e2e/pdf-viewer-canvas.png' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('page navigation controls are visible', async ({ page }) => {
|
||||||
|
await page.goto(pdfDocHref);
|
||||||
|
await page.waitForSelector('[data-hydrated]');
|
||||||
|
await page.locator('canvas').first().waitFor({ state: 'visible', timeout: 15000 });
|
||||||
|
|
||||||
|
await expect(page.getByRole('button', { name: /prev|previous|zurück|vorige/i })).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: /next|weiter|nächste/i })).toBeVisible();
|
||||||
|
|
||||||
|
await page.screenshot({ path: 'test-results/e2e/pdf-viewer-nav.png' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('non-PDF attachment renders as an img element, not canvas', async ({ page }) => {
|
||||||
|
// The seed document "Urlaubspostkarte Ostsee" has a .jpg original filename.
|
||||||
|
// Navigate to it and confirm an <img> is used (no canvas, no iframe).
|
||||||
|
await page.goto('/');
|
||||||
|
await page.waitForSelector('[data-hydrated]');
|
||||||
|
await page.goto('/?q=Urlaubspostkarte');
|
||||||
|
const link = page.getByRole('link', { name: /Urlaubspostkarte/i }).first();
|
||||||
|
const href = await link.getAttribute('href');
|
||||||
|
await page.goto(href!);
|
||||||
|
await page.waitForSelector('[data-hydrated]');
|
||||||
|
|
||||||
|
// No canvas — this is an image document
|
||||||
|
await expect(page.locator('canvas')).not.toBeAttached();
|
||||||
|
|
||||||
|
await page.screenshot({ path: 'test-results/e2e/pdf-viewer-image-fallback.png' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
21
frontend/e2e/fixtures/minimal.pdf
Normal file
21
frontend/e2e/fixtures/minimal.pdf
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
%PDF-1.4
|
||||||
|
1 0 obj
|
||||||
|
<</Type/Catalog/Pages 2 0 R>>
|
||||||
|
endobj
|
||||||
|
2 0 obj
|
||||||
|
<</Type/Pages/Kids[3 0 R]/Count 1>>
|
||||||
|
endobj
|
||||||
|
3 0 obj
|
||||||
|
<</Type/Page/MediaBox[0 0 612 792]/Parent 2 0 R>>
|
||||||
|
endobj
|
||||||
|
xref
|
||||||
|
0 4
|
||||||
|
0000000000 65535 f
|
||||||
|
0000000009 00000 n
|
||||||
|
0000000054 00000 n
|
||||||
|
0000000105 00000 n
|
||||||
|
trailer
|
||||||
|
<</Size 4/Root 1 0 R>>
|
||||||
|
startxref
|
||||||
|
170
|
||||||
|
%%EOF
|
||||||
@@ -1,50 +1,72 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
import { test, expect } from '@playwright/test';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Document edit history E2E tests.
|
* Document edit history E2E tests.
|
||||||
* Relies on the 'Document creation' and 'Document editing' tests in documents.spec.ts
|
* Creates its own test document (two versions) in beforeAll so these tests
|
||||||
* having run first (they create and edit "E2E Testbrief (überarbeitet)").
|
* are fully independent of any other spec file.
|
||||||
* Assumes auth setup has run.
|
|
||||||
*/
|
*/
|
||||||
test.describe('Document history panel', () => {
|
|
||||||
test('history section appears after creating and editing a document', async ({ page }) => {
|
|
||||||
// Find the document edited in the documents.spec.ts editing test
|
|
||||||
await page.goto('/?q=E2E+Testbrief');
|
|
||||||
await page.waitForSelector('[data-hydrated]');
|
|
||||||
const docLink = page.getByRole('link', { name: /E2E Testbrief/ }).first();
|
|
||||||
const href = await docLink.getAttribute('href');
|
|
||||||
await page.goto(href!);
|
|
||||||
|
|
||||||
// History section should be present (collapsed by default)
|
let docPath: string;
|
||||||
|
|
||||||
|
test.describe('Document history panel', () => {
|
||||||
|
test.beforeAll(async ({ browser }) => {
|
||||||
|
// Create a fresh browser context that uses the stored auth session
|
||||||
|
const context = await browser.newContext({
|
||||||
|
storageState: path.join(__dirname, '.auth/user.json'),
|
||||||
|
locale: 'de-DE'
|
||||||
|
});
|
||||||
|
const page = await context.newPage();
|
||||||
|
|
||||||
|
// 1. Create a new document
|
||||||
|
await page.goto('/documents/new');
|
||||||
|
await page.waitForSelector('[data-hydrated]');
|
||||||
|
await page.getByLabel('Titel').fill('E2E History Test Dokument');
|
||||||
|
await page.getByRole('button', { name: /Speichern/i }).click();
|
||||||
|
// Wait for redirect to the new document's UUID-based URL (not /documents/new)
|
||||||
|
await page.waitForURL(/\/documents\/[0-9a-f-]{36}$/);
|
||||||
|
docPath = new URL(page.url()).pathname;
|
||||||
|
|
||||||
|
// 2. Edit the document to create a second version
|
||||||
|
await page.goto(`${docPath}/edit`);
|
||||||
|
await page.waitForSelector('[data-hydrated]');
|
||||||
|
await page.getByLabel('Titel').fill('E2E History Test Dokument (bearbeitet)');
|
||||||
|
await page.getByRole('button', { name: /Speichern/i }).click();
|
||||||
|
await page.waitForURL(/\/documents\/[0-9a-f-]{36}$/);
|
||||||
|
|
||||||
|
await context.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('history section appears and shows two versions', async ({ page }) => {
|
||||||
|
await page.goto(docPath);
|
||||||
|
await page.waitForSelector('[data-hydrated]');
|
||||||
|
|
||||||
const historyToggle = page.getByRole('button', { name: /Verlauf/i });
|
const historyToggle = page.getByRole('button', { name: /Verlauf/i });
|
||||||
await expect(historyToggle).toBeVisible();
|
await expect(historyToggle).toBeVisible();
|
||||||
|
|
||||||
// Expand the history section
|
|
||||||
await historyToggle.click();
|
await historyToggle.click();
|
||||||
|
|
||||||
// Should show at least two version entries (created + edited)
|
// Wait for versions to load (API call happens after panel opens)
|
||||||
const versionItems = page.locator('[data-testid="history-version"]');
|
const versionItems = page.locator('[data-testid="history-version"]');
|
||||||
await expect(versionItems).toHaveCount(2, { timeout: 5000 });
|
await expect(versionItems).toHaveCount(2, { timeout: 10000 });
|
||||||
|
|
||||||
await page.screenshot({ path: 'test-results/e2e/history-versions-list.png' });
|
await page.screenshot({ path: 'test-results/e2e/history-versions-list.png' });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('diff view highlights changed field after title edit', async ({ page }) => {
|
test('diff view highlights changed field after title edit', async ({ page }) => {
|
||||||
await page.goto('/?q=E2E+Testbrief');
|
await page.goto(docPath);
|
||||||
await page.waitForSelector('[data-hydrated]');
|
await page.waitForSelector('[data-hydrated]');
|
||||||
const docLink = page.getByRole('link', { name: /E2E Testbrief/ }).first();
|
|
||||||
const href = await docLink.getAttribute('href');
|
|
||||||
await page.goto(href!);
|
|
||||||
|
|
||||||
// Expand history
|
|
||||||
const historyToggle = page.getByRole('button', { name: /Verlauf/i });
|
const historyToggle = page.getByRole('button', { name: /Verlauf/i });
|
||||||
await historyToggle.click();
|
await historyToggle.click();
|
||||||
|
|
||||||
// Click the second version (the edit) to see its diff
|
// Wait for versions to load, then click the second one (the edit)
|
||||||
const versionItems = page.locator('[data-testid="history-version"]');
|
const versionItems = page.locator('[data-testid="history-version"]');
|
||||||
|
await expect(versionItems.nth(1)).toBeVisible({ timeout: 10000 });
|
||||||
await versionItems.nth(1).click();
|
await versionItems.nth(1).click();
|
||||||
|
|
||||||
// The diff panel should show the "Titel" field as changed
|
|
||||||
const diffPanel = page.locator('[data-testid="history-diff"]');
|
const diffPanel = page.locator('[data-testid="history-diff"]');
|
||||||
await expect(diffPanel).toBeVisible();
|
await expect(diffPanel).toBeVisible();
|
||||||
await expect(diffPanel.getByText(/Titel/i)).toBeVisible();
|
await expect(diffPanel.getByText(/Titel/i)).toBeVisible();
|
||||||
@@ -53,34 +75,35 @@ test.describe('Document history panel', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('compare mode lets user compare any two versions', async ({ page }) => {
|
test('compare mode lets user compare any two versions', async ({ page }) => {
|
||||||
await page.goto('/?q=E2E+Testbrief');
|
await page.goto(docPath);
|
||||||
await page.waitForSelector('[data-hydrated]');
|
await page.waitForSelector('[data-hydrated]');
|
||||||
const docLink = page.getByRole('link', { name: /E2E Testbrief/ }).first();
|
|
||||||
const href = await docLink.getAttribute('href');
|
|
||||||
await page.goto(href!);
|
|
||||||
|
|
||||||
// Expand history
|
|
||||||
const historyToggle = page.getByRole('button', { name: /Verlauf/i });
|
const historyToggle = page.getByRole('button', { name: /Verlauf/i });
|
||||||
await historyToggle.click();
|
await historyToggle.click();
|
||||||
|
|
||||||
// Switch to compare mode
|
// Wait for versions to load before the compare button appears
|
||||||
|
await expect(page.locator('[data-testid="history-version"]').first()).toBeVisible({
|
||||||
|
timeout: 10000
|
||||||
|
});
|
||||||
|
|
||||||
const compareBtn = page.getByRole('button', { name: /Vergleichen/i });
|
const compareBtn = page.getByRole('button', { name: /Vergleichen/i });
|
||||||
await expect(compareBtn).toBeVisible();
|
await expect(compareBtn).toBeVisible();
|
||||||
await compareBtn.click();
|
await compareBtn.click();
|
||||||
|
|
||||||
// Two version selects should appear
|
|
||||||
const selectA = page.getByLabel(/Version A/i);
|
const selectA = page.getByLabel(/Version A/i);
|
||||||
const selectB = page.getByLabel(/Version B/i);
|
const selectB = page.getByLabel(/Version B/i);
|
||||||
await expect(selectA).toBeVisible();
|
await expect(selectA).toBeVisible();
|
||||||
await expect(selectB).toBeVisible();
|
await expect(selectB).toBeVisible();
|
||||||
|
|
||||||
// Apply the comparison
|
// Select version 1 for A and version 2 for B
|
||||||
|
await selectA.selectOption({ index: 1 });
|
||||||
|
await selectB.selectOption({ index: 2 });
|
||||||
|
|
||||||
await page
|
await page
|
||||||
.getByRole('button', { name: /Vergleichen/i })
|
.getByRole('button', { name: /Vergleichen/i })
|
||||||
.last()
|
.last()
|
||||||
.click();
|
.click();
|
||||||
|
|
||||||
// Diff panel should be visible
|
|
||||||
const diffPanel = page.locator('[data-testid="history-diff"]');
|
const diffPanel = page.locator('[data-testid="history-diff"]');
|
||||||
await expect(diffPanel).toBeVisible();
|
await expect(diffPanel).toBeVisible();
|
||||||
|
|
||||||
|
|||||||
@@ -233,5 +233,12 @@
|
|||||||
"history_field_summary": "Zusammenfassung",
|
"history_field_summary": "Zusammenfassung",
|
||||||
"history_field_sender": "Absender",
|
"history_field_sender": "Absender",
|
||||||
"history_field_receivers": "Empfänger",
|
"history_field_receivers": "Empfänger",
|
||||||
"history_field_tags": "Schlagworte"
|
"history_field_tags": "Schlagworte",
|
||||||
|
"admin_tab_system": "System",
|
||||||
|
"admin_system_backfill_heading": "Verlaufsdaten auffüllen",
|
||||||
|
"admin_system_backfill_description": "Erstellt einen initialen Verlaufseintrag für alle Dokumente, die noch keinen Verlauf haben (z.B. importierte Dokumente). Dadurch werden beim nächsten Bearbeiten nur die tatsächlich geänderten Felder hervorgehoben.",
|
||||||
|
"admin_system_backfill_btn": "Jetzt auffüllen",
|
||||||
|
"admin_system_backfill_success": "{count} Dokumente wurden aufgefüllt.",
|
||||||
|
"comp_expandable_show_more": "Mehr anzeigen",
|
||||||
|
"comp_expandable_show_less": "Weniger anzeigen"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -233,5 +233,12 @@
|
|||||||
"history_field_summary": "Summary",
|
"history_field_summary": "Summary",
|
||||||
"history_field_sender": "Sender",
|
"history_field_sender": "Sender",
|
||||||
"history_field_receivers": "Receivers",
|
"history_field_receivers": "Receivers",
|
||||||
"history_field_tags": "Tags"
|
"history_field_tags": "Tags",
|
||||||
|
"admin_tab_system": "System",
|
||||||
|
"admin_system_backfill_heading": "Backfill history data",
|
||||||
|
"admin_system_backfill_description": "Creates an initial history entry for all documents that do not have one yet (e.g. imported documents). This ensures that future edits only highlight actually changed fields.",
|
||||||
|
"admin_system_backfill_btn": "Backfill now",
|
||||||
|
"admin_system_backfill_success": "{count} documents were backfilled.",
|
||||||
|
"comp_expandable_show_more": "Show more",
|
||||||
|
"comp_expandable_show_less": "Show less"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -233,5 +233,12 @@
|
|||||||
"history_field_summary": "Resumen",
|
"history_field_summary": "Resumen",
|
||||||
"history_field_sender": "Remitente",
|
"history_field_sender": "Remitente",
|
||||||
"history_field_receivers": "Destinatarios",
|
"history_field_receivers": "Destinatarios",
|
||||||
"history_field_tags": "Etiquetas"
|
"history_field_tags": "Etiquetas",
|
||||||
|
"admin_tab_system": "Sistema",
|
||||||
|
"admin_system_backfill_heading": "Completar datos de historial",
|
||||||
|
"admin_system_backfill_description": "Crea una entrada de historial inicial para todos los documentos que aún no tienen ninguna (p.ej. documentos importados). Así, en la próxima edición solo se resaltarán los campos realmente modificados.",
|
||||||
|
"admin_system_backfill_btn": "Completar ahora",
|
||||||
|
"admin_system_backfill_success": "{count} documentos fueron completados.",
|
||||||
|
"comp_expandable_show_more": "Mostrar más",
|
||||||
|
"comp_expandable_show_less": "Mostrar menos"
|
||||||
}
|
}
|
||||||
|
|||||||
273
frontend/package-lock.json
generated
273
frontend/package-lock.json
generated
@@ -9,7 +9,8 @@
|
|||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"diff": "^8.0.3",
|
"diff": "^8.0.3",
|
||||||
"openapi-fetch": "^0.13.5"
|
"openapi-fetch": "^0.13.5",
|
||||||
|
"pdfjs-dist": "^5.5.207"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/compat": "^1.4.0",
|
"@eslint/compat": "^1.4.0",
|
||||||
@@ -885,6 +886,256 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/@napi-rs/canvas": {
|
||||||
|
"version": "0.1.97",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.97.tgz",
|
||||||
|
"integrity": "sha512-8cFniXvrIEnVwuNSRCW9wirRZbHvrD3JVujdS2P5n5xiJZNZMOZcfOvJ1pb66c7jXMKHHglJEDVJGbm8XWFcXQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"workspaces": [
|
||||||
|
"e2e/*"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@napi-rs/canvas-android-arm64": "0.1.97",
|
||||||
|
"@napi-rs/canvas-darwin-arm64": "0.1.97",
|
||||||
|
"@napi-rs/canvas-darwin-x64": "0.1.97",
|
||||||
|
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.97",
|
||||||
|
"@napi-rs/canvas-linux-arm64-gnu": "0.1.97",
|
||||||
|
"@napi-rs/canvas-linux-arm64-musl": "0.1.97",
|
||||||
|
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.97",
|
||||||
|
"@napi-rs/canvas-linux-x64-gnu": "0.1.97",
|
||||||
|
"@napi-rs/canvas-linux-x64-musl": "0.1.97",
|
||||||
|
"@napi-rs/canvas-win32-arm64-msvc": "0.1.97",
|
||||||
|
"@napi-rs/canvas-win32-x64-msvc": "0.1.97"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-android-arm64": {
|
||||||
|
"version": "0.1.97",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.97.tgz",
|
||||||
|
"integrity": "sha512-V1c/WVw+NzH8vk7ZK/O8/nyBSCQimU8sfMsB/9qeSvdkGKNU7+mxy/bIF0gTgeBFmHpj30S4E9WHMSrxXGQuVQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-darwin-arm64": {
|
||||||
|
"version": "0.1.97",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.97.tgz",
|
||||||
|
"integrity": "sha512-ok+SCEF4YejcxuJ9Rm+WWunHHpf2HmiPxfz6z1a/NFQECGXtsY7A4B8XocK1LmT1D7P174MzwPF9Wy3AUAwEPw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-darwin-x64": {
|
||||||
|
"version": "0.1.97",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.97.tgz",
|
||||||
|
"integrity": "sha512-PUP6e6/UGlclUvAQNnuXCcnkpdUou6VYZfQOQxExLp86epOylmiwLkqXIvpFmjoTEDmPmXrI+coL/9EFU1gKPA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
|
||||||
|
"version": "0.1.97",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.97.tgz",
|
||||||
|
"integrity": "sha512-XyXH2L/cic8eTNtbrXCcvqHtMX/nEOxN18+7rMrAM2XtLYC/EB5s0wnO1FsLMWmK+04ZSLN9FBGipo7kpIkcOw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
|
||||||
|
"version": "0.1.97",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.97.tgz",
|
||||||
|
"integrity": "sha512-Kuq/M3djq0K8ktgz6nPlK7Ne5d4uWeDxPpyKWOjWDK2RIOhHVtLtyLiJw2fuldw7Vn4mhw05EZXCEr4Q76rs9w==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
|
||||||
|
"version": "0.1.97",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.97.tgz",
|
||||||
|
"integrity": "sha512-kKmSkQVnWeqg7qdsiXvYxKhAFuHz3tkBjW/zyQv5YKUPhotpaVhpBGv5LqCngzyuRV85SXoe+OFj+Tv0a0QXkQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
|
||||||
|
"version": "0.1.97",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.97.tgz",
|
||||||
|
"integrity": "sha512-Jc7I3A51jnEOIAXeLsN/M/+Z28LUeakcsXs07FLq9prXc0eYOtVwsDEv913Gr+06IRo34gJJVgT0TXvmz+N2VA==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
|
||||||
|
"version": "0.1.97",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.97.tgz",
|
||||||
|
"integrity": "sha512-iDUBe7AilfuBSRbSa8/IGX38Mf+iCSBqoVKLSQ5XaY2JLOaqz1TVyPFEyIck7wT6mRQhQt5sN6ogfjIDfi74tg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-x64-musl": {
|
||||||
|
"version": "0.1.97",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.97.tgz",
|
||||||
|
"integrity": "sha512-AKLFd/v0Z5fvgqBDqhvqtAdx+fHMJ5t9JcUNKq4FIZ5WH+iegGm8HPdj00NFlCSnm83Fp3Ln8I2f7uq1aIiWaA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-win32-arm64-msvc": {
|
||||||
|
"version": "0.1.97",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.97.tgz",
|
||||||
|
"integrity": "sha512-u883Yr6A6fO7Vpsy9YE4FVCIxzzo5sO+7pIUjjoDLjS3vQaNMkVzx5bdIpEL+ob+gU88WDK4VcxYMZ6nmnoX9A==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
|
||||||
|
"version": "0.1.97",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.97.tgz",
|
||||||
|
"integrity": "sha512-sWtD2EE3fV0IzN+iiQUqr/Q1SwqWhs2O1FKItFlxtdDkikpEj5g7DKQpY3x55H/MAOnL8iomnlk3mcEeGiUMoQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@playwright/test": {
|
"node_modules/@playwright/test": {
|
||||||
"version": "1.58.2",
|
"version": "1.58.2",
|
||||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
|
||||||
@@ -3954,6 +4205,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/node-readable-to-web-readable-stream": {
|
||||||
|
"version": "0.4.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-readable-to-web-readable-stream/-/node-readable-to-web-readable-stream-0.4.2.tgz",
|
||||||
|
"integrity": "sha512-/cMZNI34v//jUTrI+UIo4ieHAB5EZRY/+7OmXZgBxaWBMcW2tGdceIw06RFxWxrKZ5Jp3sI2i5TsRo+CBhtVLQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"node_modules/obug": {
|
"node_modules/obug": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
|
||||||
@@ -4129,6 +4387,19 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/pdfjs-dist": {
|
||||||
|
"version": "5.5.207",
|
||||||
|
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.5.207.tgz",
|
||||||
|
"integrity": "sha512-WMqqw06w1vUt9ZfT0gOFhMf3wHsWhaCrxGrckGs5Cci6ybDW87IvPaOd2pnBwT6BJuP/CzXDZxjFgmSULLdsdw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.19.0 || >=22.13.0 || >=24"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@napi-rs/canvas": "^0.1.95",
|
||||||
|
"node-readable-to-web-readable-stream": "^0.4.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
|
|||||||
@@ -21,7 +21,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"diff": "^8.0.3",
|
"diff": "^8.0.3",
|
||||||
"openapi-fetch": "^0.13.5"
|
"openapi-fetch": "^0.13.5",
|
||||||
|
"pdfjs-dist": "^5.5.207"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/compat": "^1.4.0",
|
"@eslint/compat": "^1.4.0",
|
||||||
|
|||||||
33
frontend/src/lib/components/ExpandableText.svelte
Normal file
33
frontend/src/lib/components/ExpandableText.svelte
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
|
let { text, maxLines = 10 }: { text: string; maxLines?: number } = $props();
|
||||||
|
|
||||||
|
let expanded = $state(false);
|
||||||
|
let el = $state<HTMLElement | undefined>(undefined);
|
||||||
|
let isClamped = $state(false);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (el && !expanded) {
|
||||||
|
isClamped = el.scrollHeight > el.clientHeight;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
bind:this={el}
|
||||||
|
style={!expanded ? `overflow: hidden; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: ${maxLines}` : ''}
|
||||||
|
class="rounded border border-brand-sand bg-brand-sand/30 p-5 font-serif text-sm leading-relaxed whitespace-pre-wrap text-brand-navy"
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</div>
|
||||||
|
{#if isClamped || expanded}
|
||||||
|
<button
|
||||||
|
onclick={() => (expanded = !expanded)}
|
||||||
|
class="mt-2 font-sans text-xs text-gray-400 transition hover:text-brand-navy"
|
||||||
|
>
|
||||||
|
{expanded ? m.comp_expandable_show_less() : m.comp_expandable_show_more()}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
305
frontend/src/lib/components/PdfViewer.svelte
Normal file
305
frontend/src/lib/components/PdfViewer.svelte
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import type { PDFDocumentProxy, PDFPageProxy, RenderTask } from 'pdfjs-dist';
|
||||||
|
|
||||||
|
let { url }: { url: string } = $props();
|
||||||
|
|
||||||
|
let pdfDoc = $state<PDFDocumentProxy | null>(null);
|
||||||
|
let currentPage = $state(1);
|
||||||
|
let totalPages = $state(0);
|
||||||
|
let scale = $state(1.5);
|
||||||
|
let loading = $state(false);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
|
||||||
|
// Canvas and text layer container refs — bound via bind:this, not reactive state
|
||||||
|
let canvasEl = $state<HTMLCanvasElement | null>(null);
|
||||||
|
let textLayerEl = $state<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
// Internal mutable refs for in-flight tasks — NOT $state to avoid reactive loops
|
||||||
|
let renderTask: RenderTask | null = null;
|
||||||
|
let textLayerInstance: { cancel: () => void } | null = null;
|
||||||
|
|
||||||
|
// Holds the dynamically-loaded pdfjs module (browser-only)
|
||||||
|
// Not $state — we use pdfjsReady as the reactive trigger instead
|
||||||
|
let pdfjsLib: typeof import('pdfjs-dist') | null = null;
|
||||||
|
let pdfjsReady = $state(false);
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
// Dynamic import keeps pdfjs out of the SSR bundle entirely
|
||||||
|
const [lib, { default: workerUrl }] = await Promise.all([
|
||||||
|
import('pdfjs-dist'),
|
||||||
|
import('pdfjs-dist/build/pdf.worker.min.mjs?url')
|
||||||
|
]);
|
||||||
|
lib.GlobalWorkerOptions.workerSrc = workerUrl;
|
||||||
|
pdfjsLib = lib;
|
||||||
|
pdfjsReady = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadDocument(src: string) {
|
||||||
|
if (!pdfjsLib) return;
|
||||||
|
loading = true;
|
||||||
|
error = null;
|
||||||
|
pdfDoc = null;
|
||||||
|
currentPage = 1;
|
||||||
|
totalPages = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const loadingTask = pdfjsLib.getDocument(src);
|
||||||
|
const doc = await loadingTask.promise;
|
||||||
|
pdfDoc = doc;
|
||||||
|
totalPages = doc.numPages;
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : 'Failed to load PDF';
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderPage(doc: PDFDocumentProxy, pageNum: number) {
|
||||||
|
if (!pdfjsLib || !canvasEl || !textLayerEl) return;
|
||||||
|
|
||||||
|
// Cancel any in-flight render
|
||||||
|
if (renderTask) {
|
||||||
|
renderTask.cancel();
|
||||||
|
renderTask = null;
|
||||||
|
}
|
||||||
|
if (textLayerInstance) {
|
||||||
|
textLayerInstance.cancel();
|
||||||
|
textLayerInstance = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let page: PDFPageProxy;
|
||||||
|
try {
|
||||||
|
page = await doc.getPage(pageNum);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
const viewport = page.getViewport({ scale: scale * dpr });
|
||||||
|
|
||||||
|
const canvas = canvasEl;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
canvas.width = viewport.width;
|
||||||
|
canvas.height = viewport.height;
|
||||||
|
canvas.style.width = `${viewport.width / dpr}px`;
|
||||||
|
canvas.style.height = `${viewport.height / dpr}px`;
|
||||||
|
|
||||||
|
const task = page.render({ canvas, canvasContext: ctx, viewport });
|
||||||
|
renderTask = task;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await task.promise;
|
||||||
|
} catch (e: unknown) {
|
||||||
|
if (
|
||||||
|
typeof e === 'object' &&
|
||||||
|
e !== null &&
|
||||||
|
'name' in e &&
|
||||||
|
(e as { name: string }).name === 'RenderingCancelledException'
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
renderTask = null;
|
||||||
|
|
||||||
|
// Text layer
|
||||||
|
const textDiv = textLayerEl;
|
||||||
|
textDiv.innerHTML = '';
|
||||||
|
textDiv.style.width = `${viewport.width / dpr}px`;
|
||||||
|
textDiv.style.height = `${viewport.height / dpr}px`;
|
||||||
|
|
||||||
|
const tl = new pdfjsLib.TextLayer({
|
||||||
|
textContentSource: page.streamTextContent(),
|
||||||
|
container: textDiv,
|
||||||
|
viewport
|
||||||
|
});
|
||||||
|
textLayerInstance = tl;
|
||||||
|
try {
|
||||||
|
await tl.render();
|
||||||
|
} catch {
|
||||||
|
// cancelled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prerender(doc: PDFDocumentProxy, pageNum: number) {
|
||||||
|
const neighbors = [pageNum - 1, pageNum + 1].filter((n) => n >= 1 && n <= doc.numPages);
|
||||||
|
for (const n of neighbors) {
|
||||||
|
try {
|
||||||
|
await doc.getPage(n);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (pdfjsReady && url) {
|
||||||
|
loadDocument(url);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
// Read scale synchronously so Svelte tracks it as a dependency.
|
||||||
|
// Without this, zoom changes don't re-trigger the effect because
|
||||||
|
// scale is only read inside the async renderPage call.
|
||||||
|
if (pdfDoc && currentPage && scale > 0) {
|
||||||
|
renderPage(pdfDoc, currentPage).then(() => {
|
||||||
|
if (pdfDoc) prerender(pdfDoc, currentPage);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function prevPage() {
|
||||||
|
if (currentPage > 1) currentPage -= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextPage() {
|
||||||
|
if (pdfDoc && currentPage < pdfDoc.numPages) currentPage += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function zoomIn() {
|
||||||
|
scale += 0.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
function zoomOut() {
|
||||||
|
if (scale > 0.5) scale -= 0.25;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if !url}
|
||||||
|
<div class="flex h-full w-full items-center justify-center bg-[#2A2A2A] text-gray-400">
|
||||||
|
<p class="font-sans text-sm">Keine Datei vorhanden</p>
|
||||||
|
</div>
|
||||||
|
{:else if error}
|
||||||
|
<div
|
||||||
|
class="flex h-full w-full flex-col items-center justify-center gap-3 bg-[#2A2A2A] text-gray-300"
|
||||||
|
>
|
||||||
|
<p class="font-sans text-sm text-red-400">Fehler beim Laden der PDF</p>
|
||||||
|
<a
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="font-sans text-xs text-brand-mint underline hover:opacity-80"
|
||||||
|
>
|
||||||
|
Direkt öffnen
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="flex h-full w-full flex-col bg-[#2A2A2A]">
|
||||||
|
<!-- Controls -->
|
||||||
|
<div
|
||||||
|
class="flex shrink-0 items-center justify-between gap-2 border-b border-white/10 px-4 py-2"
|
||||||
|
>
|
||||||
|
<!-- Page navigation -->
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onclick={prevPage}
|
||||||
|
disabled={currentPage <= 1}
|
||||||
|
aria-label="Zurück"
|
||||||
|
class="rounded p-1 text-gray-300 transition hover:bg-white/10 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="h-4 w-4"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if totalPages > 0}
|
||||||
|
<span class="font-sans text-xs text-gray-300 tabular-nums">
|
||||||
|
{currentPage} / {totalPages}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onclick={nextPage}
|
||||||
|
disabled={!pdfDoc || currentPage >= totalPages}
|
||||||
|
aria-label="Weiter"
|
||||||
|
class="rounded p-1 text-gray-300 transition hover:bg-white/10 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="h-4 w-4"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Zoom controls -->
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onclick={zoomOut}
|
||||||
|
aria-label="Verkleinern"
|
||||||
|
class="rounded p-1 text-gray-300 transition hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="h-4 w-4"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<circle cx="11" cy="11" r="8" /><path
|
||||||
|
stroke-linecap="round"
|
||||||
|
d="M21 21l-4.35-4.35M8 11h6"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={zoomIn}
|
||||||
|
aria-label="Vergrößern"
|
||||||
|
class="rounded p-1 text-gray-300 transition hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="h-4 w-4"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<circle cx="11" cy="11" r="8" /><path
|
||||||
|
stroke-linecap="round"
|
||||||
|
d="M21 21l-4.35-4.35M11 8v6M8 11h6"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- PDF canvas area -->
|
||||||
|
<div class="relative flex-1 overflow-auto">
|
||||||
|
{#if loading}
|
||||||
|
<div class="flex h-full items-center justify-center">
|
||||||
|
<div
|
||||||
|
class="h-8 w-8 animate-spin rounded-full border-2 border-white/20 border-t-white"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="flex min-h-full items-start justify-center p-4">
|
||||||
|
<div
|
||||||
|
class="pdf-page relative shadow-xl"
|
||||||
|
data-page-number={currentPage}
|
||||||
|
style="position: relative"
|
||||||
|
>
|
||||||
|
<canvas bind:this={canvasEl}></canvas>
|
||||||
|
<div
|
||||||
|
bind:this={textLayerEl}
|
||||||
|
class="textLayer"
|
||||||
|
style="position: absolute; top: 0; left: 0; overflow: hidden; pointer-events: none; line-height: 1;"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
53
frontend/src/lib/components/PdfViewer.svelte.spec.ts
Normal file
53
frontend/src/lib/components/PdfViewer.svelte.spec.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { vi, describe, it, expect, afterEach } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { page } from 'vitest/browser';
|
||||||
|
|
||||||
|
// pdfjs-dist is a rendering dependency — we mock it so unit tests don't need
|
||||||
|
// a real browser PDF engine. The interesting behaviour under test here is the
|
||||||
|
// component's own UI logic (controls, page counter), not pdfjs internals.
|
||||||
|
vi.mock('pdfjs-dist', () => {
|
||||||
|
function TextLayerMock() {}
|
||||||
|
TextLayerMock.prototype.render = () => Promise.resolve();
|
||||||
|
TextLayerMock.prototype.cancel = () => {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
GlobalWorkerOptions: { workerSrc: '' },
|
||||||
|
getDocument: vi.fn().mockReturnValue({
|
||||||
|
promise: Promise.resolve({
|
||||||
|
numPages: 2,
|
||||||
|
getPage: vi.fn().mockResolvedValue({
|
||||||
|
getViewport: vi.fn().mockReturnValue({ width: 595, height: 842 }),
|
||||||
|
render: vi.fn().mockReturnValue({ promise: Promise.resolve() }),
|
||||||
|
streamTextContent: vi.fn().mockReturnValue(new ReadableStream())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
TextLayer: TextLayerMock
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('pdfjs-dist/build/pdf.worker.min.mjs?url', () => ({ default: '' }));
|
||||||
|
|
||||||
|
import PdfViewer from './PdfViewer.svelte';
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
describe('PdfViewer', () => {
|
||||||
|
it('shows previous and next page navigation buttons', async () => {
|
||||||
|
render(PdfViewer, { url: '/api/documents/test-id/file' });
|
||||||
|
await expect.element(page.getByRole('button', { name: /zurück/i })).toBeInTheDocument();
|
||||||
|
await expect.element(page.getByRole('button', { name: /weiter/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows zoom controls', async () => {
|
||||||
|
render(PdfViewer, { url: '/api/documents/test-id/file' });
|
||||||
|
await expect.element(page.getByRole('button', { name: /vergrößern/i })).toBeInTheDocument();
|
||||||
|
await expect.element(page.getByRole('button', { name: /verkleinern/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays the page counter once the PDF has loaded', async () => {
|
||||||
|
render(PdfViewer, { url: '/api/documents/test-id/file' });
|
||||||
|
// Mock resolves synchronously, so "1 / 2" should appear quickly
|
||||||
|
await expect.element(page.getByText(/1\s*\/\s*2/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -228,6 +228,22 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/admin/backfill-versions": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post: operations["backfillVersions"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/groups/{id}": {
|
"/api/groups/{id}": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -548,6 +564,10 @@ export interface components {
|
|||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
startedAt?: string;
|
startedAt?: string;
|
||||||
};
|
};
|
||||||
|
BackfillResult: {
|
||||||
|
/** Format: int32 */
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
DocumentVersionSummary: {
|
DocumentVersionSummary: {
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
id: string;
|
id: string;
|
||||||
@@ -1106,6 +1126,26 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
backfillVersions: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"*/*": components["schemas"]["BackfillResult"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
deleteGroup: {
|
deleteGroup: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ let activeTab = $state('users');
|
|||||||
let editingTagId: string | null = $state(null);
|
let editingTagId: string | null = $state(null);
|
||||||
let editingTagName = $state('');
|
let editingTagName = $state('');
|
||||||
let editingGroupId: string | null = $state(null);
|
let editingGroupId: string | null = $state(null);
|
||||||
|
let backfillResult: number | null = $state(null);
|
||||||
|
let backfillLoading = $state(false);
|
||||||
|
|
||||||
const availablePermissions = ['WRITE_ALL', 'ADMIN', 'ADMIN_USER', 'ADMIN_TAG', 'ADMIN_PERMISSION'];
|
const availablePermissions = ['WRITE_ALL', 'ADMIN', 'ADMIN_USER', 'ADMIN_TAG', 'ADMIN_PERMISSION'];
|
||||||
|
|
||||||
@@ -29,6 +31,20 @@ function startEditGroup(id: string) {
|
|||||||
function cancelEditGroup() {
|
function cancelEditGroup() {
|
||||||
editingGroupId = null;
|
editingGroupId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function backfillVersions() {
|
||||||
|
backfillLoading = true;
|
||||||
|
backfillResult = null;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/backfill-versions', { method: 'POST' });
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
backfillResult = data.count;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
backfillLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="mx-auto max-w-7xl py-8 font-sans sm:px-6 lg:px-8">
|
<div class="mx-auto max-w-7xl py-8 font-sans sm:px-6 lg:px-8">
|
||||||
@@ -58,6 +74,13 @@ function cancelEditGroup() {
|
|||||||
: 'text-gray-500 hover:text-brand-navy'}"
|
: 'text-gray-500 hover:text-brand-navy'}"
|
||||||
onclick={() => (activeTab = 'tags')}>{m.admin_tab_tags()}</button
|
onclick={() => (activeTab = 'tags')}>{m.admin_tab_tags()}</button
|
||||||
>
|
>
|
||||||
|
<button
|
||||||
|
class="rounded-md px-4 py-2 text-sm font-bold tracking-wide uppercase transition {activeTab ===
|
||||||
|
'system'
|
||||||
|
? 'bg-brand-navy text-white'
|
||||||
|
: 'text-gray-500 hover:text-brand-navy'}"
|
||||||
|
onclick={() => (activeTab = 'system')}>{m.admin_tab_system()}</button
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -495,5 +518,22 @@ function cancelEditGroup() {
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{:else if activeTab === 'system'}
|
||||||
|
<div class="rounded-sm border border-brand-sand bg-white p-6 shadow-sm">
|
||||||
|
<h2 class="mb-1 text-lg font-bold text-gray-700">{m.admin_system_backfill_heading()}</h2>
|
||||||
|
<p class="mb-4 text-sm text-gray-500">{m.admin_system_backfill_description()}</p>
|
||||||
|
<button
|
||||||
|
onclick={backfillVersions}
|
||||||
|
disabled={backfillLoading}
|
||||||
|
class="rounded bg-brand-navy px-6 py-2 text-sm font-bold text-white uppercase transition hover:bg-brand-mint hover:text-brand-navy disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{backfillLoading ? '…' : m.admin_system_backfill_btn()}
|
||||||
|
</button>
|
||||||
|
{#if backfillResult !== null}
|
||||||
|
<p class="mt-4 text-sm font-medium text-brand-navy">
|
||||||
|
{m.admin_system_backfill_success({ count: backfillResult })}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import { formatDate } from '$lib/utils/date';
|
import { formatDate } from '$lib/utils/date';
|
||||||
import { diffWords } from 'diff';
|
import { diffWords } from 'diff';
|
||||||
|
import ExpandableText from '$lib/components/ExpandableText.svelte';
|
||||||
|
import PdfViewer from '$lib/components/PdfViewer.svelte';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
@@ -112,6 +114,44 @@ function personLabel(p: { firstName: string; lastName: string }): string {
|
|||||||
return `${p.firstName} ${p.lastName}`.trim();
|
return `${p.firstName} ${p.lastName}`.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DIFF_CONTEXT_WORDS = 4;
|
||||||
|
|
||||||
|
type DiffPart = { value: string; added?: boolean; removed?: boolean };
|
||||||
|
|
||||||
|
function trimContextParts(parts: DiffPart[]): DiffPart[] {
|
||||||
|
return parts.flatMap((part, i) => {
|
||||||
|
if (part.added || part.removed) return [part];
|
||||||
|
const tokens = part.value.split(/(\s+)/).filter(Boolean);
|
||||||
|
const wordCount = tokens.filter((t) => /\S/.test(t)).length;
|
||||||
|
if (wordCount <= DIFF_CONTEXT_WORDS * 2) return [part];
|
||||||
|
|
||||||
|
function keepFirst(n: number): string {
|
||||||
|
let count = 0;
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const t of tokens) {
|
||||||
|
out.push(t);
|
||||||
|
if (/\S/.test(t) && ++count >= n) break;
|
||||||
|
}
|
||||||
|
return out.join('');
|
||||||
|
}
|
||||||
|
function keepLast(n: number): string {
|
||||||
|
let count = 0;
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const t of [...tokens].reverse()) {
|
||||||
|
out.unshift(t);
|
||||||
|
if (/\S/.test(t) && ++count >= n) break;
|
||||||
|
}
|
||||||
|
return out.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
const isFirst = i === 0;
|
||||||
|
const isLast = i === parts.length - 1;
|
||||||
|
if (isFirst) return [{ value: '… ' + keepLast(DIFF_CONTEXT_WORDS) }];
|
||||||
|
if (isLast) return [{ value: keepFirst(DIFF_CONTEXT_WORDS) + ' …' }];
|
||||||
|
return [{ value: keepFirst(DIFF_CONTEXT_WORDS) + ' … ' + keepLast(DIFF_CONTEXT_WORDS) }];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function buildDiff(older: SnapshotDoc | null, newer: SnapshotDoc): DiffEntry[] {
|
function buildDiff(older: SnapshotDoc | null, newer: SnapshotDoc): DiffEntry[] {
|
||||||
const entries: DiffEntry[] = [];
|
const entries: DiffEntry[] = [];
|
||||||
|
|
||||||
@@ -119,7 +159,7 @@ function buildDiff(older: SnapshotDoc | null, newer: SnapshotDoc): DiffEntry[] {
|
|||||||
const a = older?.[field] ?? '';
|
const a = older?.[field] ?? '';
|
||||||
const b = newer[field] ?? '';
|
const b = newer[field] ?? '';
|
||||||
if (a === b) continue;
|
if (a === b) continue;
|
||||||
const parts = diffWords(a, b);
|
const parts = trimContextParts(diffWords(a, b));
|
||||||
entries.push({ kind: 'text', field, label: fieldLabels[field](), parts });
|
entries.push({ kind: 'text', field, label: fieldLabels[field](), parts });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,7 +257,7 @@ async function selectVersion(versionId: string) {
|
|||||||
try {
|
try {
|
||||||
const idx = versions.findIndex((v) => v.id === versionId);
|
const idx = versions.findIndex((v) => v.id === versionId);
|
||||||
const newerSnap = await fetchSnapshot(versionId);
|
const newerSnap = await fetchSnapshot(versionId);
|
||||||
const olderSnap = idx + 1 < versions.length ? await fetchSnapshot(versions[idx + 1].id) : null;
|
const olderSnap = idx > 0 ? await fetchSnapshot(versions[idx - 1].id) : null;
|
||||||
const entries = buildDiff(olderSnap, newerSnap);
|
const entries = buildDiff(olderSnap, newerSnap);
|
||||||
if (entries.length === 0) {
|
if (entries.length === 0) {
|
||||||
noDiff = true;
|
noDiff = true;
|
||||||
@@ -268,7 +308,7 @@ function formatDateTime(iso: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function versionLabel(v: VersionSummary, index: number): string {
|
function versionLabel(v: VersionSummary, index: number): string {
|
||||||
return `Version ${versions.length - index} — ${v.editorName} — ${formatDateTime(v.savedAt)}`;
|
return `Version ${index + 1} — ${v.editorName} — ${formatDateTime(v.savedAt)}`;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -554,11 +594,7 @@ function versionLabel(v: VersionSummary, index: number): string {
|
|||||||
<span class="mb-2 block font-sans text-xs text-gray-400 uppercase"
|
<span class="mb-2 block font-sans text-xs text-gray-400 uppercase"
|
||||||
>{m.doc_label_summary()}</span
|
>{m.doc_label_summary()}</span
|
||||||
>
|
>
|
||||||
<div
|
<ExpandableText text={doc.summary} />
|
||||||
class="rounded border border-brand-sand bg-brand-sand/30 p-5 font-serif text-sm leading-relaxed whitespace-pre-wrap text-brand-navy"
|
|
||||||
>
|
|
||||||
{doc.summary}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -567,11 +603,7 @@ function versionLabel(v: VersionSummary, index: number): string {
|
|||||||
<span class="mb-2 block font-sans text-xs text-gray-400 uppercase"
|
<span class="mb-2 block font-sans text-xs text-gray-400 uppercase"
|
||||||
>{m.form_label_transcription()}</span
|
>{m.form_label_transcription()}</span
|
||||||
>
|
>
|
||||||
<div
|
<ExpandableText text={doc.transcription} />
|
||||||
class="rounded border border-brand-sand bg-brand-sand/30 p-5 font-serif text-sm leading-relaxed whitespace-pre-wrap text-brand-navy"
|
|
||||||
>
|
|
||||||
{doc.transcription}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -689,7 +721,7 @@ function versionLabel(v: VersionSummary, index: number): string {
|
|||||||
>
|
>
|
||||||
<div class="flex items-baseline justify-between gap-2">
|
<div class="flex items-baseline justify-between gap-2">
|
||||||
<span class="font-sans text-xs font-medium text-brand-navy">
|
<span class="font-sans text-xs font-medium text-brand-navy">
|
||||||
Version {versions.length - i}
|
Version {i + 1}
|
||||||
</span>
|
</span>
|
||||||
<span class="font-sans text-[10px] text-gray-400">
|
<span class="font-sans text-[10px] text-gray-400">
|
||||||
{formatDateTime(v.savedAt)}
|
{formatDateTime(v.savedAt)}
|
||||||
@@ -842,12 +874,8 @@ function versionLabel(v: VersionSummary, index: number): string {
|
|||||||
</div>
|
</div>
|
||||||
<p class="font-sans text-sm tracking-wide uppercase">{m.doc_no_scan()}</p>
|
<p class="font-sans text-sm tracking-wide uppercase">{m.doc_no_scan()}</p>
|
||||||
</div>
|
</div>
|
||||||
{:else if fileUrl && doc.originalFilename.toLowerCase().endsWith('.pdf')}
|
{:else if fileUrl && doc.contentType?.startsWith('application/pdf')}
|
||||||
<iframe
|
<PdfViewer url={fileUrl} />
|
||||||
src={fileUrl}
|
|
||||||
title={m.doc_preview_iframe_title()}
|
|
||||||
class="h-full w-full border-none bg-white"
|
|
||||||
></iframe>
|
|
||||||
{:else if fileUrl}
|
{:else if fileUrl}
|
||||||
<div class="flex h-full w-full items-center justify-center overflow-auto p-8">
|
<div class="flex h-full w-full items-center justify-center overflow-auto p-8">
|
||||||
<img
|
<img
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import { playwright } from '@vitest/browser-playwright';
|
|||||||
import { sveltekit } from '@sveltejs/kit/vite';
|
import { sveltekit } from '@sveltejs/kit/vite';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
optimizeDeps: {
|
||||||
|
include: ['pdfjs-dist']
|
||||||
|
},
|
||||||
server: {
|
server: {
|
||||||
host: '0.0.0.0', // Erlaubt Zugriff von außen
|
host: '0.0.0.0', // Erlaubt Zugriff von außen
|
||||||
port: 5173, // Standard SvelteKit Port
|
port: 5173, // Standard SvelteKit Port
|
||||||
@@ -13,7 +16,19 @@ export default defineConfig({
|
|||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: process.env.API_PROXY_TARGET || 'http://localhost:8080',
|
target: process.env.API_PROXY_TARGET || 'http://localhost:8080',
|
||||||
changeOrigin: true
|
changeOrigin: true,
|
||||||
|
// Inject Authorization header from the auth_token cookie so that
|
||||||
|
// browser-side fetch('/api/...') calls work the same as SSR fetches
|
||||||
|
// (which go through handleFetch in hooks.server.ts).
|
||||||
|
configure: (proxy) => {
|
||||||
|
proxy.on('proxyReq', (proxyReq, req) => {
|
||||||
|
const cookies = req.headers.cookie ?? '';
|
||||||
|
const match = cookies.match(/auth_token=([^;]+)/);
|
||||||
|
if (match) {
|
||||||
|
proxyReq.setHeader('Authorization', decodeURIComponent(match[1]));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -207,6 +207,28 @@
|
|||||||
resolved "https://registry.npmjs.org/@lix-js/server-protocol-schema/-/server-protocol-schema-0.1.1.tgz"
|
resolved "https://registry.npmjs.org/@lix-js/server-protocol-schema/-/server-protocol-schema-0.1.1.tgz"
|
||||||
integrity sha512-jBeALB6prAbtr5q4vTuxnRZZv1M2rKe8iNqRQhFJ4Tv7150unEa0vKyz0hs8Gl3fUGsWaNJBh3J8++fpbrpRBQ==
|
integrity sha512-jBeALB6prAbtr5q4vTuxnRZZv1M2rKe8iNqRQhFJ4Tv7150unEa0vKyz0hs8Gl3fUGsWaNJBh3J8++fpbrpRBQ==
|
||||||
|
|
||||||
|
"@napi-rs/canvas-linux-x64-gnu@0.1.97":
|
||||||
|
version "0.1.97"
|
||||||
|
resolved "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.97.tgz"
|
||||||
|
integrity sha512-iDUBe7AilfuBSRbSa8/IGX38Mf+iCSBqoVKLSQ5XaY2JLOaqz1TVyPFEyIck7wT6mRQhQt5sN6ogfjIDfi74tg==
|
||||||
|
|
||||||
|
"@napi-rs/canvas@^0.1.95":
|
||||||
|
version "0.1.97"
|
||||||
|
resolved "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.97.tgz"
|
||||||
|
integrity sha512-8cFniXvrIEnVwuNSRCW9wirRZbHvrD3JVujdS2P5n5xiJZNZMOZcfOvJ1pb66c7jXMKHHglJEDVJGbm8XWFcXQ==
|
||||||
|
optionalDependencies:
|
||||||
|
"@napi-rs/canvas-android-arm64" "0.1.97"
|
||||||
|
"@napi-rs/canvas-darwin-arm64" "0.1.97"
|
||||||
|
"@napi-rs/canvas-darwin-x64" "0.1.97"
|
||||||
|
"@napi-rs/canvas-linux-arm-gnueabihf" "0.1.97"
|
||||||
|
"@napi-rs/canvas-linux-arm64-gnu" "0.1.97"
|
||||||
|
"@napi-rs/canvas-linux-arm64-musl" "0.1.97"
|
||||||
|
"@napi-rs/canvas-linux-riscv64-gnu" "0.1.97"
|
||||||
|
"@napi-rs/canvas-linux-x64-gnu" "0.1.97"
|
||||||
|
"@napi-rs/canvas-linux-x64-musl" "0.1.97"
|
||||||
|
"@napi-rs/canvas-win32-arm64-msvc" "0.1.97"
|
||||||
|
"@napi-rs/canvas-win32-x64-msvc" "0.1.97"
|
||||||
|
|
||||||
"@playwright/test@^1.58.2":
|
"@playwright/test@^1.58.2":
|
||||||
version "1.58.2"
|
version "1.58.2"
|
||||||
resolved "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz"
|
resolved "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz"
|
||||||
@@ -1462,6 +1484,11 @@ natural-compare@^1.4.0:
|
|||||||
resolved "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz"
|
resolved "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz"
|
||||||
integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==
|
integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==
|
||||||
|
|
||||||
|
node-readable-to-web-readable-stream@^0.4.2:
|
||||||
|
version "0.4.2"
|
||||||
|
resolved "https://registry.npmjs.org/node-readable-to-web-readable-stream/-/node-readable-to-web-readable-stream-0.4.2.tgz"
|
||||||
|
integrity sha512-/cMZNI34v//jUTrI+UIo4ieHAB5EZRY/+7OmXZgBxaWBMcW2tGdceIw06RFxWxrKZ5Jp3sI2i5TsRo+CBhtVLQ==
|
||||||
|
|
||||||
obug@^2.1.0, obug@^2.1.1:
|
obug@^2.1.0, obug@^2.1.1:
|
||||||
version "2.1.1"
|
version "2.1.1"
|
||||||
resolved "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz"
|
resolved "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz"
|
||||||
@@ -1553,6 +1580,14 @@ pathe@^2.0.3:
|
|||||||
resolved "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz"
|
resolved "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz"
|
||||||
integrity sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==
|
integrity sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==
|
||||||
|
|
||||||
|
pdfjs-dist@^5.5.207:
|
||||||
|
version "5.5.207"
|
||||||
|
resolved "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.5.207.tgz"
|
||||||
|
integrity sha512-WMqqw06w1vUt9ZfT0gOFhMf3wHsWhaCrxGrckGs5Cci6ybDW87IvPaOd2pnBwT6BJuP/CzXDZxjFgmSULLdsdw==
|
||||||
|
optionalDependencies:
|
||||||
|
"@napi-rs/canvas" "^0.1.95"
|
||||||
|
node-readable-to-web-readable-stream "^0.4.2"
|
||||||
|
|
||||||
picocolors@^1.0.0, picocolors@^1.1.1:
|
picocolors@^1.0.0, picocolors@^1.1.1:
|
||||||
version "1.1.1"
|
version "1.1.1"
|
||||||
resolved "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz"
|
resolved "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz"
|
||||||
|
|||||||
21
scripts/rebuild-frontend.sh
Executable file
21
scripts/rebuild-frontend.sh
Executable file
@@ -0,0 +1,21 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Rebuilds the frontend Docker container and refreshes the node_modules volume.
|
||||||
|
# Run this after adding or updating npm dependencies.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
echo "Stopping frontend container..."
|
||||||
|
docker compose stop frontend
|
||||||
|
|
||||||
|
echo "Removing frontend container..."
|
||||||
|
docker compose rm -f frontend
|
||||||
|
|
||||||
|
echo "Removing stale node_modules volume..."
|
||||||
|
docker volume rm familienarchiv_frontend_node_modules 2>/dev/null || true
|
||||||
|
|
||||||
|
echo "Rebuilding image and starting container..."
|
||||||
|
docker compose up -d --build frontend
|
||||||
|
|
||||||
|
echo "Done. Tailing logs (Ctrl+C to exit)..."
|
||||||
|
docker compose logs -f frontend
|
||||||
Reference in New Issue
Block a user