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;
|
||||
|
||||
import org.raddatz.familienarchiv.dto.BackfillResult;
|
||||
import org.raddatz.familienarchiv.security.Permission;
|
||||
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.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
@@ -18,6 +21,8 @@ import lombok.RequiredArgsConstructor;
|
||||
public class AdminController {
|
||||
|
||||
private final MassImportService massImportService;
|
||||
private final DocumentService documentService;
|
||||
private final DocumentVersionService documentVersionService;
|
||||
|
||||
@PostMapping("/trigger-import")
|
||||
public ResponseEntity<MassImportService.ImportStatus> triggerMassImport() {
|
||||
@@ -29,4 +34,11 @@ public class AdminController {
|
||||
public ResponseEntity<MassImportService.ImportStatus> importStatus() {
|
||||
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);
|
||||
|
||||
@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 " +
|
||||
"JOIN d.receivers r " +
|
||||
"WHERE " +
|
||||
|
||||
@@ -257,6 +257,10 @@ public class DocumentService {
|
||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id));
|
||||
}
|
||||
|
||||
public List<Document> getDocumentsWithoutVersions() {
|
||||
return documentRepository.findDocumentsWithoutVersions();
|
||||
}
|
||||
|
||||
public List<Document> getDocumentsBySender(UUID senderId) {
|
||||
return documentRepository.findBySenderId(senderId);
|
||||
}
|
||||
|
||||
@@ -56,6 +56,26 @@ public class DocumentVersionService {
|
||||
.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) {
|
||||
return versionRepository.findByDocumentIdOrderBySavedAtAsc(documentId).stream()
|
||||
.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);
|
||||
}
|
||||
|
||||
// ─── 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 ──────────────────────────────────────────────────────────────
|
||||
|
||||
private void authenticateAs(String username) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import path from 'path';
|
||||
|
||||
/**
|
||||
* Document management E2E tests.
|
||||
@@ -142,3 +143,78 @@ test.describe('Document edit', () => {
|
||||
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 path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
/**
|
||||
* Document edit history E2E tests.
|
||||
* Relies on the 'Document creation' and 'Document editing' tests in documents.spec.ts
|
||||
* having run first (they create and edit "E2E Testbrief (überarbeitet)").
|
||||
* Assumes auth setup has run.
|
||||
* Creates its own test document (two versions) in beforeAll so these tests
|
||||
* are fully independent of any other spec file.
|
||||
*/
|
||||
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 });
|
||||
await expect(historyToggle).toBeVisible();
|
||||
|
||||
// Expand the history section
|
||||
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"]');
|
||||
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' });
|
||||
});
|
||||
|
||||
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]');
|
||||
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 });
|
||||
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"]');
|
||||
await expect(versionItems.nth(1)).toBeVisible({ timeout: 10000 });
|
||||
await versionItems.nth(1).click();
|
||||
|
||||
// The diff panel should show the "Titel" field as changed
|
||||
const diffPanel = page.locator('[data-testid="history-diff"]');
|
||||
await expect(diffPanel).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 }) => {
|
||||
await page.goto('/?q=E2E+Testbrief');
|
||||
await page.goto(docPath);
|
||||
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 });
|
||||
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 });
|
||||
await expect(compareBtn).toBeVisible();
|
||||
await compareBtn.click();
|
||||
|
||||
// Two version selects should appear
|
||||
const selectA = page.getByLabel(/Version A/i);
|
||||
const selectB = page.getByLabel(/Version B/i);
|
||||
await expect(selectA).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
|
||||
.getByRole('button', { name: /Vergleichen/i })
|
||||
.last()
|
||||
.click();
|
||||
|
||||
// Diff panel should be visible
|
||||
const diffPanel = page.locator('[data-testid="history-diff"]');
|
||||
await expect(diffPanel).toBeVisible();
|
||||
|
||||
|
||||
@@ -233,5 +233,12 @@
|
||||
"history_field_summary": "Zusammenfassung",
|
||||
"history_field_sender": "Absender",
|
||||
"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_sender": "Sender",
|
||||
"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_sender": "Remitente",
|
||||
"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",
|
||||
"dependencies": {
|
||||
"diff": "^8.0.3",
|
||||
"openapi-fetch": "^0.13.5"
|
||||
"openapi-fetch": "^0.13.5",
|
||||
"pdfjs-dist": "^5.5.207"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/compat": "^1.4.0",
|
||||
@@ -885,6 +886,256 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
|
||||
@@ -3954,6 +4205,13 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
|
||||
@@ -4129,6 +4387,19 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
|
||||
@@ -21,7 +21,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"diff": "^8.0.3",
|
||||
"openapi-fetch": "^0.13.5"
|
||||
"openapi-fetch": "^0.13.5",
|
||||
"pdfjs-dist": "^5.5.207"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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;
|
||||
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}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -548,6 +564,10 @@ export interface components {
|
||||
/** Format: date-time */
|
||||
startedAt?: string;
|
||||
};
|
||||
BackfillResult: {
|
||||
/** Format: int32 */
|
||||
count: number;
|
||||
};
|
||||
DocumentVersionSummary: {
|
||||
/** Format: uuid */
|
||||
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: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@@ -9,6 +9,8 @@ let activeTab = $state('users');
|
||||
let editingTagId: string | null = $state(null);
|
||||
let editingTagName = $state('');
|
||||
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'];
|
||||
|
||||
@@ -29,6 +31,20 @@ function startEditGroup(id: string) {
|
||||
function cancelEditGroup() {
|
||||
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>
|
||||
|
||||
<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'}"
|
||||
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>
|
||||
|
||||
@@ -495,5 +518,22 @@ function cancelEditGroup() {
|
||||
</form>
|
||||
</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}
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { formatDate } from '$lib/utils/date';
|
||||
import { diffWords } from 'diff';
|
||||
import ExpandableText from '$lib/components/ExpandableText.svelte';
|
||||
import PdfViewer from '$lib/components/PdfViewer.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
@@ -112,6 +114,44 @@ function personLabel(p: { firstName: string; lastName: string }): string {
|
||||
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[] {
|
||||
const entries: DiffEntry[] = [];
|
||||
|
||||
@@ -119,7 +159,7 @@ function buildDiff(older: SnapshotDoc | null, newer: SnapshotDoc): DiffEntry[] {
|
||||
const a = older?.[field] ?? '';
|
||||
const b = newer[field] ?? '';
|
||||
if (a === b) continue;
|
||||
const parts = diffWords(a, b);
|
||||
const parts = trimContextParts(diffWords(a, b));
|
||||
entries.push({ kind: 'text', field, label: fieldLabels[field](), parts });
|
||||
}
|
||||
|
||||
@@ -217,7 +257,7 @@ async function selectVersion(versionId: string) {
|
||||
try {
|
||||
const idx = versions.findIndex((v) => v.id === 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);
|
||||
if (entries.length === 0) {
|
||||
noDiff = true;
|
||||
@@ -268,7 +308,7 @@ function formatDateTime(iso: string): 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>
|
||||
|
||||
@@ -554,11 +594,7 @@ function versionLabel(v: VersionSummary, index: number): string {
|
||||
<span class="mb-2 block font-sans text-xs text-gray-400 uppercase"
|
||||
>{m.doc_label_summary()}</span
|
||||
>
|
||||
<div
|
||||
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>
|
||||
<ExpandableText text={doc.summary} />
|
||||
</div>
|
||||
{/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"
|
||||
>{m.form_label_transcription()}</span
|
||||
>
|
||||
<div
|
||||
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>
|
||||
<ExpandableText text={doc.transcription} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -689,7 +721,7 @@ function versionLabel(v: VersionSummary, index: number): string {
|
||||
>
|
||||
<div class="flex items-baseline justify-between gap-2">
|
||||
<span class="font-sans text-xs font-medium text-brand-navy">
|
||||
Version {versions.length - i}
|
||||
Version {i + 1}
|
||||
</span>
|
||||
<span class="font-sans text-[10px] text-gray-400">
|
||||
{formatDateTime(v.savedAt)}
|
||||
@@ -842,12 +874,8 @@ function versionLabel(v: VersionSummary, index: number): string {
|
||||
</div>
|
||||
<p class="font-sans text-sm tracking-wide uppercase">{m.doc_no_scan()}</p>
|
||||
</div>
|
||||
{:else if fileUrl && doc.originalFilename.toLowerCase().endsWith('.pdf')}
|
||||
<iframe
|
||||
src={fileUrl}
|
||||
title={m.doc_preview_iframe_title()}
|
||||
class="h-full w-full border-none bg-white"
|
||||
></iframe>
|
||||
{:else if fileUrl && doc.contentType?.startsWith('application/pdf')}
|
||||
<PdfViewer url={fileUrl} />
|
||||
{:else if fileUrl}
|
||||
<div class="flex h-full w-full items-center justify-center overflow-auto p-8">
|
||||
<img
|
||||
|
||||
@@ -6,6 +6,9 @@ import { playwright } from '@vitest/browser-playwright';
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
|
||||
export default defineConfig({
|
||||
optimizeDeps: {
|
||||
include: ['pdfjs-dist']
|
||||
},
|
||||
server: {
|
||||
host: '0.0.0.0', // Erlaubt Zugriff von außen
|
||||
port: 5173, // Standard SvelteKit Port
|
||||
@@ -13,7 +16,19 @@ export default defineConfig({
|
||||
proxy: {
|
||||
'/api': {
|
||||
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"
|
||||
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":
|
||||
version "1.58.2"
|
||||
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"
|
||||
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:
|
||||
version "2.1.1"
|
||||
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"
|
||||
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:
|
||||
version "1.1.1"
|
||||
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