Compare commits

..

26 Commits

Author SHA1 Message Date
Marcel
53b482c5f2 fix(e2e): fix admin tag test (use existing tag) and annotation locator
Some checks failed
CI / Unit & Component Tests (push) Successful in 2m37s
CI / Backend Unit Tests (push) Successful in 2m18s
CI / E2E Tests (push) Failing after 30m45s
- Admin tag test: "Familie" never existed in the database; use "Fest"
  which is a real seeded tag, with a matching rename-back to restore state
- Annotation hash test: the broad `[data-testid^="annotation-"]` locator
  also matched `annotation-side-panel` (always in DOM, even when
  off-screen); extend the :not() exclusion list to cover it

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 11:34:42 +01:00
Marcel
fa9577052d fix(e2e): fix 4 failing e2e tests — strict mode locator and nested form
Some checks failed
CI / Unit & Component Tests (pull_request) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
CI / E2E Tests (pull_request) Has been cancelled
CI / Unit & Component Tests (push) Successful in 2m23s
CI / Backend Unit Tests (push) Successful in 2m11s
CI / E2E Tests (push) Failing after 29m1s
documents.spec.ts: replace getByText with getByRole('heading') to avoid
Svelte's #svelte-announcer matching the same text (strict mode violation).

SaveBar.svelte: move <form id="mark-for-review-form"> out of the component
and into +page.svelte as a sibling of delete-form. The form was previously
nested inside <form id="update-form">, which is invalid HTML. The browser
auto-repaired it, causing a Svelte hydration mismatch that broke the edit
form's use:enhance, preventing version snapshots from being recorded —
leaving history tests with 0 versions instead of the expected 2.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 10:04:21 +01:00
Marcel
a7eaa40852 fix(#68): hide native file input, show selected filename in upload zone
Some checks failed
CI / Unit & Component Tests (pull_request) Successful in 2m47s
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Unit & Component Tests (push) Has been cancelled
CI / E2E Tests (pull_request) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
The native browser file input showed an untranslatable "Browse…" button
and "No file selected" text. The input is now sr-only; the large upload
zone label acts as the sole click target. When a file is selected its
name replaces the prompt text inside the zone.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 07:04:54 +01:00
Marcel
c5e28ac18e feat(#68): lead new document form with file upload, all metadata optional
Some checks failed
CI / Unit & Component Tests (push) Failing after 1m17s
CI / Backend Unit Tests (push) Failing after 9h3m48s
CI / E2E Tests (push) Failing after 28m15s
Restructure the "New Document" page so users can save quickly:

- FileSectionNew becomes the first element, redesigned as a prominent
  upload zone with an icon and large click target
- Title field is rendered standalone below the upload zone; it
  auto-populates from the filename (via parseFilename + stripExtension
  fallback) unless the user has already typed something
- All remaining metadata (who/when, description, transcription) moves
  into a collapsible "Weitere Details" section that auto-expands when
  URL prefill data or a form error is present, or when filename parsing
  detects a date/person
- title is no longer required — the form can be saved with only a file
- DescriptionSection gains a `hideTitle` prop for use in this layout
- `form_label_title` translation key no longer carries a hardcoded `*`;
  the asterisk is rendered by the template only when `titleRequired` is
  set (currently only the edit form)
- E2E tests added for all three scenarios from the issue

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 22:52:12 +01:00
Marcel
d6f4ea05d9 feat(#68): fall back to filename as title when createDocument gets no title
When a document is created without an explicit title (null or blank),
the service now derives the title from the uploaded filename using the
same titleFromFilename() logic already used by storeDocument — stripping
the extension for plain names and formatting structured names as
"Firstname Lastname (DD.MM.YYYY)".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 22:51:24 +01:00
Marcel
065dd8fabd fix(e2e): fix two flaky annotation tests
Test 6 (delete annotation): the mouse-draw test can create multiple
annotations in CI. Changed the assertion to `countBefore - 1` instead
of a hard-coded 0, so the test is resilient to any pre-existing count.

Test 7 (hash versioning): `[data-testid^="annotation-"]` matched both
real annotation elements AND `annotation-outdated-notice` (which also
starts with "annotation-"), inflating the count to 2 instead of 0.
Added `:not([data-testid="annotation-outdated-notice"])` to exclude the
notice from the count assertion.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 22:32:58 +01:00
Marcel
a967483cd9 fix(e2e): update tests to match current UI and fix panel persistence
Some checks failed
CI / Unit & Component Tests (push) Successful in 2m35s
CI / Backend Unit Tests (push) Successful in 2m15s
CI / E2E Tests (push) Failing after 27m18s
Code:
- Persist panelOpen to localStorage so panel stays open after reload
- Auto-open panel to Metadaten when document has no file (no prior state)

Tests:
- Nav active state: check bg-nav-active instead of text-brand-navy
  (nav uses semantic tokens since dark mode refactor)
- Save button: use exact:true to avoid matching "Speichern & abschließen"
  (new button was added alongside the plain "Speichern" button)

Note: annotation tests (documents.spec.ts:324, 356) are pre-existing
flaky failures due to test data contamination, not caused by this PR.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 22:26:03 +01:00
Marcel
5d0a2a2c9c fix: use semantic color tokens for enrich hint box
Some checks failed
CI / Unit & Component Tests (pull_request) Successful in 2m29s
CI / Backend Unit Tests (pull_request) Successful in 2m12s
CI / E2E Tests (pull_request) Failing after 24s
CI / Unit & Component Tests (push) Successful in 2m17s
CI / Backend Unit Tests (push) Successful in 2m1s
CI / E2E Tests (push) Has started running
Replaced hardcoded brand-navy/brand-mint palette constants with
semantic tokens (ink, accent, accent-bg) so the hint box themes
correctly in dark mode.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 19:47:44 +01:00
Marcel
0f0d74eb2f fix(#81): use text-primary-fg for badge text so dark mode reads correctly
In dark mode --c-primary flips to mint (#a1dcd8), making text-white
unreadable. text-primary-fg is already paired correctly in both modes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 19:41:10 +01:00
Marcel
20f6de4424 refactor(#81): replace nudge button with always-visible count badge
Show the discussion count badge on every state (including 0) instead of
a separate nudge button. Simpler, less intrusive, and works without
needing an extra element near the panel.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 18:43:48 +01:00
Marcel
bf82ebfe1d feat(#81): improve discussion discoverability
- Add comment count badge on the Discussion tab (seeded from SSR, updated live)
- Add 'Diskussion starten' nudge above collapsed panel when no comments exist
- Add empty state hint with speech-bubble icon inside the discussion panel
- Fix CommentThread to fire onCountChange with SSR-seeded count on mount
- Add tests for all three behaviours in CommentThread and DocumentBottomPanel

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 18:19:38 +01:00
Marcel
c6984e49ee fix(dropzone): vertical layout, larger icon, improved copy
Some checks failed
CI / Unit & Component Tests (pull_request) Successful in 2m23s
CI / Backend Unit Tests (pull_request) Successful in 2m9s
CI / E2E Tests (pull_request) Failing after 28m26s
CI / E2E Tests (push) Failing after 28m40s
CI / Backend Unit Tests (push) Successful in 2m12s
CI / Unit & Component Tests (push) Successful in 2m28s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 17:47:08 +01:00
Marcel
150bc2f171 feat(dropzone): replace upload icon with multi-file icon and clearer hint text
Some checks failed
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Unit & Component Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Successful in 2m22s
CI / Backend Unit Tests (pull_request) Successful in 2m4s
CI / E2E Tests (pull_request) Failing after 26m49s
Swaps the generic upload arrow for Display-Pages-MD (stack of pages) and
shortens the hint text to convey that multiple files are welcome at a glance.

Closes #79
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 17:32:15 +01:00
Marcel
41c311249b fix(enrich): use fixed positioning for fullscreen layout and fix done icon
Some checks failed
CI / Unit & Component Tests (push) Successful in 2m16s
CI / Backend Unit Tests (push) Successful in 2m10s
CI / E2E Tests (push) Failing after 27m43s
Align enrich/[id] with the document detail page pattern: position fixed
with runtime header height measurement instead of a hardcoded calc value.
The root layout is reverted to its original simple form with no per-route
detection. Also replaces the missing check icon on the done page with
Check-Double-LG from the icon library.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 16:56:39 +01:00
Marcel
2efa790243 Revert "fix(enrich): restore fixed-position layout and done icon"
Some checks failed
CI / Unit & Component Tests (pull_request) Successful in 2m28s
CI / Backend Unit Tests (pull_request) Successful in 2m13s
CI / E2E Tests (pull_request) Failing after 27m56s
CI / Unit & Component Tests (push) Successful in 2m24s
CI / Backend Unit Tests (push) Successful in 2m12s
CI / E2E Tests (push) Failing after 28m17s
This reverts commit 648bdffe4f.
2026-03-26 15:52:47 +01:00
Marcel
648bdffe4f fix(enrich): restore fixed-position layout and done icon
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Successful in 3m39s
CI / Backend Unit Tests (pull_request) Successful in 2m39s
CI / E2E Tests (pull_request) Failing after 30m26s
Re-applies the scroll fix from 0d3c557 which was missing from this branch:
- measure header height at mount, use it as top offset instead of hardcoded 68px
- fix done page icon to Check-Double-LG

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 15:51:15 +01:00
Marcel
99e3163c0e feat(quick-upload): pre-fill date and sender from structured filename
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Successful in 2m28s
CI / Backend Unit Tests (pull_request) Successful in 2m14s
CI / E2E Tests (pull_request) Failing after 28m25s
storeDocument() now uses the ParsedFilename record to also set
documentDate and sender on new quick-uploads. Sender lookup is
an exact case-insensitive first+last name match — no new persons
are created. Unmatched filenames behave as before.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 15:43:39 +01:00
Marcel
f0940524e7 feat(filename): support compound last names like de Gruyter
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Successful in 2m17s
CI / Backend Unit Tests (pull_request) Successful in 2m13s
CI / E2E Tests (pull_request) Failing after 25m0s
Replace the four fixed regexes with a split-based algorithm:
- first segment = date → last segment = firstName, rest = lastName parts
- last segment = date → second-to-last = firstName, rest = lastName parts

18881025_de_Gruyter_Walter.pdf now correctly yields "Walter de Gruyter".
Simple two-segment names behave identically to before.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 15:33:21 +01:00
Marcel
a302f96560 feat(quick-upload): generate better title from structured filename
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Successful in 2m16s
CI / Backend Unit Tests (pull_request) Successful in 2m8s
CI / E2E Tests (pull_request) Failing after 26m22s
titleFromFilename() mirrors the same four patterns as the frontend
parseFilename() utility. Dropzone uploads to Mueller_Hans_19650312.pdf
now land with title "Hans Mueller (12.03.1965)" instead of the raw
stripped filename.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 15:18:34 +01:00
Marcel
654e736f8a feat(dropzone): add filename hint showing supported naming pattern
Shows a concrete example (2024-03-15_Mueller_Hans.pdf) so users know
which filenames will be auto-parsed during bulk upload.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 15:18:12 +01:00
Marcel
078bc1c886 feat(new-doc): pre-fill date, sender and title from parsed filename
When a file is selected on the new document page, parseFilename runs
on the filename and suggests date, sender name and title via the new
suggestedDateIso / suggestedSenderName / suggestedTitle props. Each
suggestion is applied only while the respective field is still clean
(not dirty), so manual input is never overwritten.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 15:17:47 +01:00
Marcel
8555193a79 feat(filename): add parseFilename utility with full-pattern-only matching
Supports four patterns: date_lastname_firstname and lastname_firstname_date,
both with ISO (YYYY-MM-DD) and compact (YYYYMMDD) date formats.
Returns dateIso, personName and a formatted suggestedTitle.
Partial matches are rejected — unrecognised filenames return {}.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 15:17:16 +01:00
Marcel
aab9e9a4b0 feat(enrich): add metadata enrichment queue UI
Some checks failed
CI / Unit & Component Tests (pull_request) Successful in 2m19s
CI / Backend Unit Tests (pull_request) Successful in 2m11s
CI / E2E Tests (pull_request) Failing after 29m32s
CI / Unit & Component Tests (push) Successful in 2m21s
CI / Backend Unit Tests (push) Successful in 2m12s
CI / E2E Tests (push) Failing after 28m54s
Home page shows "Needs metadata" card when incomplete documents exist.
/enrich list shows all incomplete documents; /enrich/[id] provides a
split PDF-preview + compact form view with Skip / Save / Save & reviewed
actions that auto-advance through the queue.

New document page gets Save vs Save & reviewed split. Edit page gets
"Mark for review" secondary button to push a document back into the queue.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 13:45:16 +01:00
Marcel
0ce18e1eed feat(documents): add metadataComplete flag and enrichment queue endpoints
Adds a metadata_complete column (default true for existing rows) to drive
the enrichment queue. New drop-zone uploads always start as false; createDocument
uses an explicit DTO flag or a heuristic (any of date/sender/receivers present →
true); the mass importer applies the same heuristic per row.

New endpoints: GET /api/documents/incomplete-count, /incomplete, /incomplete/next.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 13:25:57 +01:00
Marcel
2bfbf45eba refactor(types): extract shared types to \$lib/types.ts
Some checks failed
CI / Unit & Component Tests (pull_request) Successful in 2m22s
CI / Backend Unit Tests (pull_request) Successful in 2m16s
CI / E2E Tests (pull_request) Failing after 31m23s
CI / Unit & Component Tests (push) Successful in 2m24s
CI / Backend Unit Tests (push) Successful in 2m6s
CI / E2E Tests (push) Failing after 30m19s
Eliminates type duplication across 6 files by introducing a single
shared types module:

- Comment + CommentReply: were identically defined in CommentThread,
  PanelDiscussion, and DocumentBottomPanel
- DocumentPanelTab: was identically defined in DocumentBottomPanel
  and documents/[id]/+page.svelte
- Annotation: was defined in both AnnotationLayer and PdfViewer
  (PdfViewer's variant with fileHash? is now the canonical definition)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 12:47:08 +01:00
Marcel
40f01a7712 refactor(comments): extract commentEntry snippet to remove duplicated markup
The root-comment and reply rendering blocks were near-identical (view mode
with author/time/edit-delete, and edit mode with textarea/save/cancel).
Extracted a local {#snippet commentEntry(comment, threadId, showReplyButton)}
that handles both states, introducing Svelte 5 snippets to the codebase.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 12:42:39 +01:00
48 changed files with 2080 additions and 347 deletions

View File

@@ -4,6 +4,8 @@ import java.io.IOException;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
@@ -156,6 +158,23 @@ public class DocumentController {
return new QuickUploadResult(created, updated, errors);
}
@GetMapping("/incomplete-count")
public Map<String, Long> getIncompleteCount() {
return Map.of("count", documentService.getIncompleteCount());
}
@GetMapping("/incomplete")
public List<Document> getIncomplete() {
return documentService.findIncompleteDocuments();
}
@GetMapping("/incomplete/next")
public ResponseEntity<Document> getNextIncomplete(@RequestParam UUID excludeId) {
return documentService.findNextIncompleteDocument(excludeId)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.noContent().build());
}
@GetMapping("/search")
public ResponseEntity<List<Document>> search(
@RequestParam(required = false) String q,

View File

@@ -17,4 +17,5 @@ public class DocumentUpdateDTO {
private UUID senderId;
private List<UUID> receiverIds;
private String tags;
private Boolean metadataComplete;
}

View File

@@ -86,6 +86,11 @@ public class Document {
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private LocalDateTime updatedAt;
@Column(name = "metadata_complete", nullable = false)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
@Builder.Default
private boolean metadataComplete = false;
@ManyToMany(fetch = FetchType.EAGER)
@JoinTable(name = "document_receivers", joinColumns = @JoinColumn(name = "document_id"), inverseJoinColumns = @JoinColumn(name = "person_id"))
@Builder.Default

View File

@@ -42,6 +42,12 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
List<Document> findByFileHashIsNullAndFilePathIsNotNull();
long countByMetadataCompleteFalse();
List<Document> findByMetadataCompleteFalse(Sort sort);
Optional<Document> findFirstByMetadataCompleteFalseAndIdNot(UUID id, Sort sort);
@Query("SELECT DISTINCT d FROM Document d " +
"JOIN d.receivers r " +
"WHERE " +

View File

@@ -28,6 +28,9 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
// Lookup by full alias string, used during ODS mass import
Optional<Person> findByAliasIgnoreCase(String alias);
// Exact first+last name match, used for filename-based sender lookup
Optional<Person> findByFirstNameIgnoreCaseAndLastNameIgnoreCase(String firstName, String lastName);
// --- Correspondent queries ---
@Query(value = """

View File

@@ -62,10 +62,18 @@ public class DocumentService {
if (existingDoc.isPresent()) {
document = existingDoc.get();
} else {
// New uploads from the drop zone always start as incomplete
ParsedFilename parsed = parseFilenameData(originalFilename);
Person sender = (parsed != null)
? personService.findByName(parsed.firstName(), parsed.lastName()).orElse(null)
: null;
document = Document.builder()
.originalFilename(originalFilename)
.title(stripExtension(originalFilename))
.title(parsed != null ? parsed.title() : stripExtension(originalFilename))
.documentDate(parsed != null ? parsed.date() : null)
.sender(sender)
.status(DocumentStatus.UPLOADED)
.metadataComplete(false)
.build();
}
@@ -89,15 +97,31 @@ public class DocumentService {
? file.getOriginalFilename()
: (dto.getTitle() != null ? dto.getTitle() : "Unbenanntes Dokument");
// If the caller explicitly sets metadataComplete, use it.
// Otherwise apply heuristic: complete if at least one key field is present.
boolean metadataComplete;
if (dto.getMetadataComplete() != null) {
metadataComplete = dto.getMetadataComplete();
} else {
metadataComplete = dto.getDocumentDate() != null
|| dto.getSenderId() != null
|| (dto.getReceiverIds() != null && !dto.getReceiverIds().isEmpty());
}
String titleToUse = (dto.getTitle() != null && !dto.getTitle().isBlank())
? dto.getTitle()
: titleFromFilename(filename);
Document doc = Document.builder()
.originalFilename(filename)
.title(dto.getTitle())
.title(titleToUse)
.documentDate(dto.getDocumentDate())
.location(dto.getLocation())
.documentLocation(dto.getDocumentLocation())
.transcription(dto.getTranscription())
.summary(dto.getSummary())
.status(DocumentStatus.PLACEHOLDER)
.metadataComplete(metadataComplete)
.build();
doc = documentRepository.save(doc);
@@ -176,6 +200,11 @@ public class DocumentService {
doc.getReceivers().clear(); // Alle entfernen
}
// 3b. metadataComplete — only update when explicitly set in the DTO
if (dto.getMetadataComplete() != null) {
doc.setMetadataComplete(dto.getMetadataComplete());
}
// 4. Datei austauschen (nur wenn eine neue ausgewählt wurde)
if (newFile != null && !newFile.isEmpty()) {
FileService.UploadResult upload = fileService.uploadFile(newFile, newFile.getOriginalFilename());
@@ -280,6 +309,19 @@ public class DocumentService {
return documentRepository.findConversation(senderId, receiverId, dateFrom, dateTo, sort);
}
public long getIncompleteCount() {
return documentRepository.countByMetadataCompleteFalse();
}
public List<Document> findIncompleteDocuments() {
return documentRepository.findByMetadataCompleteFalse(Sort.by(Sort.Direction.DESC, "createdAt"));
}
public Optional<Document> findNextIncompleteDocument(UUID currentId) {
return documentRepository.findFirstByMetadataCompleteFalseAndIdNot(
currentId, Sort.by(Sort.Direction.DESC, "createdAt"));
}
@Transactional
public void deleteDocument(UUID id) {
if (!documentRepository.existsById(id)) {
@@ -324,6 +366,81 @@ public class DocumentService {
return dot > 0 ? filename.substring(0, dot) : filename;
}
private record ParsedFilename(LocalDate date, String firstName, String lastName) {
String title() {
String dateDisplay = String.format("%02d.%02d.%d",
date.getDayOfMonth(), date.getMonthValue(), date.getYear());
return firstName + " " + lastName + " (" + dateDisplay + ")";
}
}
/**
* Parses a structured filename into its date and name components.
*
* Algorithm: split stem on "_", identify the date token (first or last segment),
* treat the outermost remaining segment as firstName, rest as lastName parts.
* Compound last names (e.g. "de_Gruyter") are supported naturally.
* Returns null for unrecognised filenames.
*
* Examples:
* 18881025_de_Gruyter_Walter.pdf → date=1888-10-25, firstName=Walter, lastName=de Gruyter
* 1965-03-12_Mueller_Hans.pdf → date=1965-03-12, firstName=Hans, lastName=Mueller
* Mueller_Hans_19650312.pdf → date=1965-03-12, firstName=Hans, lastName=Mueller
*/
private static ParsedFilename parseFilenameData(String filename) {
if (filename == null) return null;
int dot = filename.lastIndexOf('.');
if (dot < 0) return null;
String stem = filename.substring(0, dot);
String[] parts = stem.split("_", -1);
if (parts.length < 3) return null;
String dateIso;
String[] nameParts;
String dateFromFirst = tryParseDate(parts[0]);
if (dateFromFirst != null) {
dateIso = dateFromFirst;
nameParts = Arrays.copyOfRange(parts, 1, parts.length);
} else {
String dateFromLast = tryParseDate(parts[parts.length - 1]);
if (dateFromLast == null) return null;
dateIso = dateFromLast;
nameParts = Arrays.copyOfRange(parts, 0, parts.length - 1);
}
if (nameParts.length < 2) return null;
for (String p : nameParts) {
if (!p.matches("\\p{L}+")) return null;
}
String firstName = nameParts[nameParts.length - 1];
String lastName = String.join(" ", Arrays.copyOfRange(nameParts, 0, nameParts.length - 1));
return new ParsedFilename(LocalDate.parse(dateIso), firstName, lastName);
}
// Used by tests and as a public utility; delegates to parseFilenameData.
static String titleFromFilename(String filename) {
if (filename == null) return null;
ParsedFilename parsed = parseFilenameData(filename);
return parsed != null ? parsed.title() : stripExtension(filename);
}
private static String tryParseDate(String s) {
if (s.matches("\\d{4}-\\d{2}-\\d{2}")) {
int m = Integer.parseInt(s.substring(5, 7));
int d = Integer.parseInt(s.substring(8, 10));
if (m >= 1 && m <= 12 && d >= 1 && d <= 31) return s;
} else if (s.matches("\\d{8}")) {
int m = Integer.parseInt(s.substring(4, 6));
int d = Integer.parseInt(s.substring(6, 8));
if (m >= 1 && m <= 12 && d >= 1 && d <= 31)
return s.substring(0, 4) + "-" + s.substring(4, 6) + "-" + s.substring(6, 8);
}
return null;
}
private static String sha256Hex(byte[] bytes) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");

View File

@@ -312,6 +312,9 @@ public class MassImportService {
.originalFilename(originalFilename)
.build());
// Heuristic: mark as complete if at least one key field is present in the spreadsheet row
boolean metadataComplete = date != null || !senderRaw.isBlank() || !receiversRaw.isBlank();
doc.setTitle(buildTitle(index, date, location));
doc.setFilePath(s3Key);
doc.setContentType(contentType);
@@ -325,6 +328,7 @@ public class MassImportService {
doc.setSender(sender);
doc.getReceivers().addAll(receivers);
if (tag != null) doc.getTags().add(tag);
doc.setMetadataComplete(metadataComplete);
documentRepository.save(doc);
log.info("Importiert{}: {}", file.isEmpty() ? " (nur Metadaten)" : "", originalFilename);

View File

@@ -1,6 +1,7 @@
package org.raddatz.familienarchiv.service;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.raddatz.familienarchiv.dto.PersonUpdateDTO;
@@ -42,6 +43,10 @@ public class PersonService {
return personRepository.findAllById(ids);
}
public Optional<Person> findByName(String firstName, String lastName) {
return personRepository.findByFirstNameIgnoreCaseAndLastNameIgnoreCase(firstName, lastName);
}
@Transactional
public Person findOrCreateByAlias(String rawName) {
String alias = rawName.trim();

View File

@@ -0,0 +1,6 @@
-- Add metadata_complete flag to documents.
-- Existing rows default to true (already reviewed before this feature existed).
-- New documents created via Java will receive false from the entity default.
ALTER TABLE documents
ADD COLUMN metadata_complete BOOLEAN NOT NULL DEFAULT TRUE;

View File

@@ -21,6 +21,7 @@ import org.springframework.test.web.servlet.MockMvc;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import static org.mockito.ArgumentMatchers.any;
@@ -212,6 +213,78 @@ class DocumentControllerTest {
.andExpect(jsonPath("$.errors[0].code").value("UNSUPPORTED_FILE_TYPE"));
}
// ─── GET /api/documents/incomplete-count ─────────────────────────────────
@Test
void getIncompleteCount_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(get("/api/documents/incomplete-count"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser
void getIncompleteCount_returns200_withCount() throws Exception {
when(documentService.getIncompleteCount()).thenReturn(3L);
mockMvc.perform(get("/api/documents/incomplete-count"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.count").value(3));
}
// ─── GET /api/documents/incomplete ───────────────────────────────────────
@Test
void getIncomplete_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(get("/api/documents/incomplete"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser
void getIncomplete_returns200_withList() throws Exception {
Document doc = Document.builder()
.id(UUID.randomUUID()).title("Unvollständig").originalFilename("scan.pdf").build();
when(documentService.findIncompleteDocuments()).thenReturn(List.of(doc));
mockMvc.perform(get("/api/documents/incomplete"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].title").value("Unvollständig"));
}
// ─── GET /api/documents/incomplete/next ──────────────────────────────────
@Test
void getNextIncomplete_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(get("/api/documents/incomplete/next")
.param("excludeId", UUID.randomUUID().toString()))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser
void getNextIncomplete_returns200_whenNextExists() throws Exception {
UUID excludeId = UUID.randomUUID();
Document next = Document.builder()
.id(UUID.randomUUID()).title("Nächster").originalFilename("next.pdf").build();
when(documentService.findNextIncompleteDocument(excludeId)).thenReturn(Optional.of(next));
mockMvc.perform(get("/api/documents/incomplete/next")
.param("excludeId", excludeId.toString()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.title").value("Nächster"));
}
@Test
@WithMockUser
void getNextIncomplete_returns204_whenNoneRemain() throws Exception {
UUID excludeId = UUID.randomUUID();
when(documentService.findNextIncompleteDocument(excludeId)).thenReturn(Optional.empty());
mockMvc.perform(get("/api/documents/incomplete/next")
.param("excludeId", excludeId.toString()))
.andExpect(status().isNoContent());
}
// ─── GET /api/documents/{id}/versions ────────────────────────────────────
@Test

View File

@@ -2,6 +2,7 @@ package org.raddatz.familienarchiv.service;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@@ -9,9 +10,13 @@ import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.DocumentStatus;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.model.Tag;
import org.raddatz.familienarchiv.repository.DocumentRepository;
import org.springframework.data.domain.Sort;
import org.springframework.mock.web.MockMultipartFile;
import java.time.LocalDate;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
@@ -344,6 +349,265 @@ class DocumentServiceTest {
verify(annotationService).backfillAnnotationFileHashForDocument(eq(docId), any());
}
// ─── getIncompleteCount ───────────────────────────────────────────────────
@Test
void getIncompleteCount_delegatesToRepository() {
when(documentRepository.countByMetadataCompleteFalse()).thenReturn(5L);
assertThat(documentService.getIncompleteCount()).isEqualTo(5L);
}
// ─── findIncompleteDocuments ──────────────────────────────────────────────
@Test
void findIncompleteDocuments_returnsDocumentsOrderedByCreatedAtDesc() {
Document doc = Document.builder().id(UUID.randomUUID()).title("Test").build();
when(documentRepository.findByMetadataCompleteFalse(any(Sort.class))).thenReturn(List.of(doc));
assertThat(documentService.findIncompleteDocuments()).containsExactly(doc);
verify(documentRepository).findByMetadataCompleteFalse(Sort.by(Sort.Direction.DESC, "createdAt"));
}
// ─── findNextIncompleteDocument ───────────────────────────────────────────
@Test
void findNextIncompleteDocument_returnsNext_whenAnotherIncompleteExists() {
UUID currentId = UUID.randomUUID();
Document next = Document.builder().id(UUID.randomUUID()).title("Next").build();
when(documentRepository.findFirstByMetadataCompleteFalseAndIdNot(eq(currentId), any(Sort.class)))
.thenReturn(Optional.of(next));
assertThat(documentService.findNextIncompleteDocument(currentId)).contains(next);
}
@Test
void findNextIncompleteDocument_returnsEmpty_whenNoMoreIncomplete() {
UUID currentId = UUID.randomUUID();
when(documentRepository.findFirstByMetadataCompleteFalseAndIdNot(eq(currentId), any(Sort.class)))
.thenReturn(Optional.empty());
assertThat(documentService.findNextIncompleteDocument(currentId)).isEmpty();
}
// ─── storeDocument metadataComplete ──────────────────────────────────────
@Test
void storeDocument_setsMetadataCompleteFalse_forNewDocument() throws Exception {
MockMultipartFile file = new MockMultipartFile("file", "scan.pdf", "application/pdf", new byte[]{1});
Document saved = Document.builder().id(UUID.randomUUID()).originalFilename("scan.pdf").build();
when(documentRepository.findFirstByOriginalFilename("scan.pdf")).thenReturn(Optional.empty());
when(documentRepository.save(any())).thenReturn(saved);
when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("path", "hash"));
ArgumentCaptor<Document> captor = ArgumentCaptor.forClass(Document.class);
documentService.storeDocument(file);
verify(documentRepository).save(captor.capture());
assertThat(captor.getValue().isMetadataComplete()).isFalse();
}
@Test
void storeDocument_doesNotChangeMetadataComplete_forExistingDocument() throws Exception {
MockMultipartFile file = new MockMultipartFile("file", "scan.pdf", "application/pdf", new byte[]{1});
Document existing = Document.builder().id(UUID.randomUUID()).originalFilename("scan.pdf")
.status(DocumentStatus.PLACEHOLDER).metadataComplete(true).build();
when(documentRepository.findFirstByOriginalFilename("scan.pdf")).thenReturn(Optional.of(existing));
when(documentRepository.save(any())).thenReturn(existing);
when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("path", "hash"));
documentService.storeDocument(file);
assertThat(existing.isMetadataComplete()).isTrue();
}
@Test
void storeDocument_parsesDateFromFilename_forNewDocument() throws Exception {
MockMultipartFile file = new MockMultipartFile("file", "19650312_Mueller_Hans.pdf", "application/pdf", new byte[]{1});
when(documentRepository.findFirstByOriginalFilename(any())).thenReturn(Optional.empty());
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("path", "hash"));
when(personService.findByName(any(), any())).thenReturn(Optional.empty());
ArgumentCaptor<Document> captor = ArgumentCaptor.forClass(Document.class);
documentService.storeDocument(file);
verify(documentRepository).save(captor.capture());
assertThat(captor.getValue().getDocumentDate()).isEqualTo(java.time.LocalDate.of(1965, 3, 12));
assertThat(captor.getValue().getTitle()).isEqualTo("Hans Mueller (12.03.1965)");
}
@Test
void storeDocument_setsSender_whenPersonExistsForParsedName() throws Exception {
MockMultipartFile file = new MockMultipartFile("file", "18881025_de_Gruyter_Walter.pdf", "application/pdf", new byte[]{1});
Person walter = Person.builder().id(UUID.randomUUID()).firstName("Walter").lastName("de Gruyter").build();
when(documentRepository.findFirstByOriginalFilename(any())).thenReturn(Optional.empty());
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("path", "hash"));
when(personService.findByName("Walter", "de Gruyter")).thenReturn(Optional.of(walter));
ArgumentCaptor<Document> captor = ArgumentCaptor.forClass(Document.class);
documentService.storeDocument(file);
verify(documentRepository).save(captor.capture());
assertThat(captor.getValue().getSender()).isEqualTo(walter);
}
@Test
void storeDocument_leavesSenderNull_whenPersonNotFound() throws Exception {
MockMultipartFile file = new MockMultipartFile("file", "19650312_Mueller_Hans.pdf", "application/pdf", new byte[]{1});
when(documentRepository.findFirstByOriginalFilename(any())).thenReturn(Optional.empty());
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("path", "hash"));
when(personService.findByName(any(), any())).thenReturn(Optional.empty());
ArgumentCaptor<Document> captor = ArgumentCaptor.forClass(Document.class);
documentService.storeDocument(file);
verify(documentRepository).save(captor.capture());
assertThat(captor.getValue().getSender()).isNull();
}
// ─── createDocument title fallback ────────────────────────────────────────
@Test
void createDocument_usesTitleFromFilename_whenDtoTitleIsNull() throws Exception {
DocumentUpdateDTO dto = new DocumentUpdateDTO();
// dto.title is null
MockMultipartFile file = new MockMultipartFile("file", "Brief_1965.pdf", "application/pdf", new byte[]{1});
Document saved = Document.builder().id(UUID.randomUUID()).title("Brief_1965")
.originalFilename("Brief_1965.pdf").status(DocumentStatus.PLACEHOLDER).build();
when(documentRepository.save(any())).thenReturn(saved);
when(documentRepository.findById(any())).thenReturn(Optional.of(saved));
when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("path", "hash"));
ArgumentCaptor<Document> captor = ArgumentCaptor.forClass(Document.class);
documentService.createDocument(dto, file);
verify(documentRepository, atLeastOnce()).save(captor.capture());
assertThat(captor.getAllValues().get(0).getTitle()).isEqualTo("Brief_1965");
}
@Test
void createDocument_usesTitleFromFilename_whenDtoTitleIsBlank() throws Exception {
DocumentUpdateDTO dto = new DocumentUpdateDTO();
dto.setTitle(" ");
MockMultipartFile file = new MockMultipartFile("file", "Rechnung_1980.pdf", "application/pdf", new byte[]{1});
Document saved = Document.builder().id(UUID.randomUUID()).title("Rechnung_1980")
.originalFilename("Rechnung_1980.pdf").status(DocumentStatus.PLACEHOLDER).build();
when(documentRepository.save(any())).thenReturn(saved);
when(documentRepository.findById(any())).thenReturn(Optional.of(saved));
when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("path", "hash"));
ArgumentCaptor<Document> captor = ArgumentCaptor.forClass(Document.class);
documentService.createDocument(dto, file);
verify(documentRepository, atLeastOnce()).save(captor.capture());
assertThat(captor.getAllValues().get(0).getTitle()).isEqualTo("Rechnung_1980");
}
@Test
void createDocument_keepsDtoTitle_whenProvided() throws Exception {
DocumentUpdateDTO dto = new DocumentUpdateDTO();
dto.setTitle("Mein Titel");
MockMultipartFile file = new MockMultipartFile("file", "scan.pdf", "application/pdf", new byte[]{1});
Document saved = Document.builder().id(UUID.randomUUID()).title("Mein Titel")
.originalFilename("scan.pdf").status(DocumentStatus.PLACEHOLDER).build();
when(documentRepository.save(any())).thenReturn(saved);
when(documentRepository.findById(any())).thenReturn(Optional.of(saved));
when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("path", "hash"));
ArgumentCaptor<Document> captor = ArgumentCaptor.forClass(Document.class);
documentService.createDocument(dto, file);
verify(documentRepository, atLeastOnce()).save(captor.capture());
assertThat(captor.getAllValues().get(0).getTitle()).isEqualTo("Mein Titel");
}
// ─── createDocument metadataComplete ─────────────────────────────────────
@Test
void createDocument_setsMetadataCompleteFromDto_whenExplicitlyProvided() throws Exception {
DocumentUpdateDTO dto = new DocumentUpdateDTO();
dto.setTitle("Doc");
dto.setMetadataComplete(true);
Document saved = Document.builder().id(UUID.randomUUID()).title("Doc")
.originalFilename("Doc").status(DocumentStatus.PLACEHOLDER).build();
when(documentRepository.save(any())).thenReturn(saved);
when(documentRepository.findById(any())).thenReturn(Optional.of(saved));
ArgumentCaptor<Document> captor = ArgumentCaptor.forClass(Document.class);
documentService.createDocument(dto, null);
verify(documentRepository, atLeastOnce()).save(captor.capture());
assertThat(captor.getAllValues().get(0).isMetadataComplete()).isTrue();
}
@Test
void createDocument_setsMetadataCompleteFalse_whenAllKeyFieldsMissingAndNoExplicitFlag() throws Exception {
DocumentUpdateDTO dto = new DocumentUpdateDTO();
dto.setTitle("Doc");
// no documentDate, no senderId, no receiverIds, no metadataComplete flag
Document saved = Document.builder().id(UUID.randomUUID()).title("Doc")
.originalFilename("Doc").status(DocumentStatus.PLACEHOLDER).build();
when(documentRepository.save(any())).thenReturn(saved);
when(documentRepository.findById(any())).thenReturn(Optional.of(saved));
ArgumentCaptor<Document> captor = ArgumentCaptor.forClass(Document.class);
documentService.createDocument(dto, null);
verify(documentRepository, atLeastOnce()).save(captor.capture());
assertThat(captor.getAllValues().get(0).isMetadataComplete()).isFalse();
}
@Test
void createDocument_setsMetadataCompleteTrue_whenDatePresentAndNoExplicitFlag() throws Exception {
DocumentUpdateDTO dto = new DocumentUpdateDTO();
dto.setTitle("Doc");
dto.setDocumentDate(LocalDate.of(2020, 1, 1));
Document saved = Document.builder().id(UUID.randomUUID()).title("Doc")
.originalFilename("Doc").status(DocumentStatus.PLACEHOLDER).build();
when(documentRepository.save(any())).thenReturn(saved);
when(documentRepository.findById(any())).thenReturn(Optional.of(saved));
ArgumentCaptor<Document> captor = ArgumentCaptor.forClass(Document.class);
documentService.createDocument(dto, null);
verify(documentRepository, atLeastOnce()).save(captor.capture());
assertThat(captor.getAllValues().get(0).isMetadataComplete()).isTrue();
}
// ─── updateDocument metadataComplete ─────────────────────────────────────
@Test
void updateDocument_setsMetadataComplete_whenDtoHasValue() throws Exception {
UUID id = UUID.randomUUID();
Document existing = Document.builder().id(id).title("Doc").originalFilename("doc.pdf")
.status(DocumentStatus.PLACEHOLDER).metadataComplete(false).build();
when(documentRepository.findById(id)).thenReturn(Optional.of(existing));
when(documentRepository.save(any())).thenReturn(existing);
DocumentUpdateDTO dto = new DocumentUpdateDTO();
dto.setMetadataComplete(true);
documentService.updateDocument(id, dto, null);
assertThat(existing.isMetadataComplete()).isTrue();
}
@Test
void updateDocument_doesNotChangeMetadataComplete_whenDtoHasNull() throws Exception {
UUID id = UUID.randomUUID();
Document existing = Document.builder().id(id).title("Doc").originalFilename("doc.pdf")
.status(DocumentStatus.PLACEHOLDER).metadataComplete(false).build();
when(documentRepository.findById(id)).thenReturn(Optional.of(existing));
when(documentRepository.save(any())).thenReturn(existing);
DocumentUpdateDTO dto = new DocumentUpdateDTO();
// metadataComplete not set → null
documentService.updateDocument(id, dto, null);
assertThat(existing.isMetadataComplete()).isFalse();
}
@Test
void backfillFileHashes_returnsCountOfUpdatedDocuments() throws Exception {
UUID id1 = UUID.randomUUID();
@@ -358,4 +622,52 @@ class DocumentServiceTest {
assertThat(count).isEqualTo(2);
}
// ─── titleFromFilename ────────────────────────────────────────────────────
@Test
void titleFromFilename_dateIso_name() {
assertThat(DocumentService.titleFromFilename("1965-03-12_Mueller_Hans.pdf"))
.isEqualTo("Hans Mueller (12.03.1965)");
}
@Test
void titleFromFilename_dateCompact_name() {
assertThat(DocumentService.titleFromFilename("19650312_Mueller_Hans.pdf"))
.isEqualTo("Hans Mueller (12.03.1965)");
}
@Test
void titleFromFilename_name_dateIso() {
assertThat(DocumentService.titleFromFilename("Mueller_Hans_1965-03-12.pdf"))
.isEqualTo("Hans Mueller (12.03.1965)");
}
@Test
void titleFromFilename_name_dateCompact() {
assertThat(DocumentService.titleFromFilename("Mueller_Hans_19650312.pdf"))
.isEqualTo("Hans Mueller (12.03.1965)");
}
@Test
void titleFromFilename_compound_lastName_dateFirst() {
assertThat(DocumentService.titleFromFilename("18881025_de_Gruyter_Walter.pdf"))
.isEqualTo("Walter de Gruyter (25.10.1888)");
}
@Test
void titleFromFilename_compound_lastName_dateLast() {
assertThat(DocumentService.titleFromFilename("de_Gruyter_Walter_18881025.pdf"))
.isEqualTo("Walter de Gruyter (25.10.1888)");
}
@Test
void titleFromFilename_fallsBackToStripExtension() {
assertThat(DocumentService.titleFromFilename("scan_001.pdf")).isEqualTo("scan_001");
}
@Test
void titleFromFilename_null_returnsNull() {
assertThat(DocumentService.titleFromFilename(null)).isNull();
}
}

View File

@@ -180,19 +180,19 @@ test.describe('Admin — tag management', () => {
// Wait for the tags list to render after the tab switch
await page.waitForSelector('ul > li');
// Hover over the "Familie" row to reveal the opacity-0 action buttons
const familieRow = page
// Hover over the "Fest" row to reveal the opacity-0 action buttons
const festRow = page
.locator('ul > li')
.filter({ has: page.locator('span', { hasText: /^Familie$/ }) });
await familieRow.hover();
await familieRow.getByRole('button', { name: 'Schlagwort bearbeiten' }).click();
.filter({ has: page.locator('span', { hasText: /^Fest$/ }) });
await festRow.hover();
await festRow.getByRole('button', { name: 'Schlagwort bearbeiten' }).click();
// After clicking edit, {#if editingTagId} replaces the span with a form —
// the familieRow filter no longer matches, so we find the input directly.
await page.locator('input[name="name"]').fill('Familie (E2E)');
// the festRow filter no longer matches, so we find the input directly.
await page.locator('input[name="name"]').fill('Fest (E2E)');
await page.getByRole('button', { name: 'Speichern' }).click();
await expect(page.getByText('Familie (E2E)')).toBeVisible();
await expect(page.getByText('Fest (E2E)')).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/admin-tag-renamed.png' });
});
@@ -205,14 +205,14 @@ test.describe('Admin — tag management', () => {
const renamedRow = page
.locator('ul > li')
.filter({ has: page.locator('span', { hasText: /^Familie \(E2E\)$/ }) });
.filter({ has: page.locator('span', { hasText: /^Fest \(E2E\)$/ }) });
await renamedRow.hover();
await renamedRow.getByRole('button', { name: 'Schlagwort bearbeiten' }).click();
await page.locator('input[name="name"]').fill('Familie');
await page.locator('input[name="name"]').fill('Fest');
await page.getByRole('button', { name: 'Speichern' }).click();
await expect(page.getByText('Familie')).toBeVisible();
await expect(page.getByText('Fest')).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/admin-tag-restored.png' });
});
});

View File

@@ -25,7 +25,7 @@ test.describe('Document list', () => {
test('navigation bar shows active state for Dokumente', async ({ page }) => {
const navLink = page.getByRole('navigation').getByRole('link', { name: 'Dokumente' });
await expect(navLink).toHaveClass(/text-brand-navy/);
await expect(navLink).toHaveClass(/bg-nav-active/);
});
test('text search filters the document list', async ({ page }) => {
@@ -77,12 +77,49 @@ test.describe('Document detail', () => {
});
test.describe('New document', () => {
test('renders the upload form', async ({ page }) => {
test('renders the upload form with file input first', async ({ page }) => {
await page.goto('/documents/new');
await page.waitForSelector('[data-hydrated]');
await expect(page.getByRole('heading', { name: /Neues Dokument/i })).toBeVisible();
await expect(page.getByLabel('Titel')).toBeVisible();
// File input comes before the title field in DOM order
const fileInput = page.locator('input[type="file"]');
const titleInput = page.getByLabel('Titel');
await expect(fileInput).toBeVisible();
await expect(titleInput).toBeVisible();
const fileBox = await fileInput.boundingBox();
const titleBox = await titleInput.boundingBox();
expect(fileBox!.y).toBeLessThan(titleBox!.y);
await page.screenshot({ path: 'test-results/e2e/document-new.png' });
});
test('title field is pre-filled from filename when a file is selected', async ({ page }) => {
await page.goto('/documents/new');
await page.waitForSelector('[data-hydrated]');
const PDF_FIXTURE = path.resolve(__dirname, 'fixtures/minimal.pdf');
const fileInput = page.locator('input[type="file"]');
await fileInput.setInputFiles({
name: 'Brief_1965.pdf',
mimeType: 'application/pdf',
buffer: fs.readFileSync(PDF_FIXTURE)
});
await expect(page.getByLabel('Titel')).toHaveValue('Brief_1965');
await page.screenshot({ path: 'test-results/e2e/document-new-filename-prefill.png' });
});
test('typed title is not overwritten when a file is selected', async ({ page }) => {
await page.goto('/documents/new');
await page.waitForSelector('[data-hydrated]');
await page.getByLabel('Titel').fill('Weihnachtsbrief 1965');
const PDF_FIXTURE = path.resolve(__dirname, 'fixtures/minimal.pdf');
const fileInput = page.locator('input[type="file"]');
await fileInput.setInputFiles({
name: 'Brief_1965.pdf',
mimeType: 'application/pdf',
buffer: fs.readFileSync(PDF_FIXTURE)
});
await expect(page.getByLabel('Titel')).toHaveValue('Weihnachtsbrief 1965');
await page.screenshot({ path: 'test-results/e2e/document-new-title-not-overwritten.png' });
});
});
test.describe('Document creation', () => {
@@ -91,12 +128,27 @@ test.describe('Document creation', () => {
await page.waitForSelector('[data-hydrated]');
await page.getByLabel('Titel').fill('E2E Testbrief');
await page.getByRole('button', { name: /Speichern/i }).click();
await page.getByRole('button', { name: 'Speichern', exact: true }).click();
await expect(page).toHaveURL(/\/documents\/[^/]+$/);
await expect(page.getByRole('heading', { name: 'E2E Testbrief' })).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/document-create.png' });
});
test('user saves a document with only a file — title comes from filename', async ({ page }) => {
await page.goto('/documents/new');
await page.waitForSelector('[data-hydrated]');
const PDF_FIXTURE = path.resolve(__dirname, 'fixtures/minimal.pdf');
await page.locator('input[type="file"]').setInputFiles({
name: 'Brief_1965.pdf',
mimeType: 'application/pdf',
buffer: fs.readFileSync(PDF_FIXTURE)
});
await page.getByRole('button', { name: 'Speichern', exact: true }).click();
await expect(page).toHaveURL(/\/documents\/[^/]+$/);
await expect(page.getByRole('heading', { name: 'Brief_1965' })).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/document-create-file-only.png' });
});
});
test.describe('Document editing', () => {
@@ -112,10 +164,10 @@ test.describe('Document editing', () => {
await page.waitForSelector('[data-hydrated]');
await page.getByLabel('Titel').fill('E2E Testbrief (überarbeitet)');
await page.getByRole('button', { name: /Speichern/i }).click();
await page.getByRole('button', { name: 'Speichern', exact: true }).click();
await expect(page).toHaveURL(/\/documents\/[^/]+$/);
await expect(page.getByText('E2E Testbrief (überarbeitet)')).toBeVisible();
await expect(page.getByRole('heading', { name: 'E2E Testbrief (überarbeitet)' })).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/document-edit-save.png' });
});
});
@@ -327,10 +379,12 @@ test.describe('PDF annotations — admin', () => {
await page.waitForSelector('[data-hydrated]');
await page.locator('canvas').first().waitFor({ state: 'visible', timeout: 20000 });
// Ensure annotation is visible before enabling annotate mode
// Ensure at least one annotation is visible before enabling annotate mode
await expect(page.locator('[data-testid^="annotation-"]').first()).toBeVisible({
timeout: 8000
});
// Record count now — the draw test may have created more than one annotation
const countBefore = await page.locator('[data-testid^="annotation-"]').count();
// Enable annotate mode to show delete buttons
await page.getByRole('button', { name: /^annotieren$/i }).click();
@@ -339,7 +393,7 @@ test.describe('PDF annotations — admin', () => {
await expect(deleteBtn).toBeVisible({ timeout: 8000 });
await deleteBtn.click();
await expect(page.locator('[data-testid^="annotation-"]')).toHaveCount(0, {
await expect(page.locator('[data-testid^="annotation-"]')).toHaveCount(countBefore - 1, {
timeout: 8000
});
@@ -407,7 +461,12 @@ test.describe('PDF annotations — file hash versioning', () => {
await page.waitForSelector('[data-hydrated]');
await page.locator('canvas').first().waitFor({ state: 'visible', timeout: 20000 });
await expect(page.locator('[data-testid^="annotation-"]')).toHaveCount(0, { timeout: 8000 });
// Use :not() to exclude the outdated-notice and side-panel elements whose testid also starts with "annotation-"
await expect(
page.locator(
'[data-testid^="annotation-"]:not([data-testid="annotation-outdated-notice"]):not([data-testid="annotation-side-panel"])'
)
).toHaveCount(0, { timeout: 8000 });
await expect(page.locator('[data-testid="annotation-outdated-notice"]')).toBeVisible({
timeout: 5000
});

View File

@@ -25,7 +25,7 @@ test.describe('Document history panel', () => {
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();
await page.getByRole('button', { name: 'Speichern', exact: true }).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;
@@ -34,7 +34,7 @@ test.describe('Document history panel', () => {
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.getByRole('button', { name: 'Speichern', exact: true }).click();
await page.waitForURL(/\/documents\/[0-9a-f-]{36}$/);
await context.close();

View File

@@ -212,7 +212,7 @@ test.describe('Conversations', () => {
test('nav link is active on the conversations page', async ({ page }) => {
await page.goto('/conversations');
const navLink = page.getByRole('link', { name: 'Konversationen' });
await expect(navLink).toHaveClass(/text-brand-navy/);
await expect(navLink).toHaveClass(/bg-nav-active/);
});
test('sort toggle changes the button label', async ({ page }) => {

View File

@@ -39,7 +39,7 @@
"form_placeholder_location": "z.B. Berlin, Wien…",
"form_label_sender": "Absender",
"form_label_receivers": "Empfänger",
"form_label_title": "Titel *",
"form_label_title": "Titel",
"form_label_tags": "Schlagworte",
"form_label_content": "Inhalt",
"form_placeholder_content": "Kurze Beschreibung des Inhalts…",
@@ -75,6 +75,7 @@
"doc_file_replace_label": "Neue Datei hochladen",
"doc_file_replace_note": "(ersetzt die aktuelle Datei)",
"doc_current_file_label": "Aktuelle Datei:",
"doc_more_details": "Weitere Details",
"doc_new_heading": "Neues Dokument",
"doc_edit_heading": "Bearbeiten",
"doc_section_details": "Details",
@@ -268,11 +269,30 @@
"doc_panel_discussion_annotation_tab": "Annotation · Seite {page}",
"pdf_annotations_show": "Annotierungen anzeigen",
"pdf_annotations_hide": "Annotierungen verbergen",
"upload_drop_hint": "Dateien ablegen oder auswählen",
"upload_drop_hint": "Einzeln oder mehrere Dateien auf einmal hochladen",
"upload_accepted_types": "PDF, JPEG, PNG, TIFF",
"upload_filename_hint": "Tipp: 2024-03-15_Mueller_Hans.pdf → Datum und Absender werden vorausgefüllt",
"upload_success": "{count} Dokument(e) erstellt",
"upload_duplicate": "{filename} existiert bereits —",
"upload_duplicate_link": "Zum Dokument",
"upload_invalid_type": "{filename}: Dateiformat nicht unterstützt",
"upload_error": "Fehler beim Hochladen von {filename}"
"upload_error": "Fehler beim Hochladen von {filename}",
"enrich_list_back": "Zurück zur Übersicht",
"enrich_list_count": "Dokumente",
"btn_save_and_mark_reviewed": "Speichern & abschließen",
"btn_mark_for_review": "Zur Überprüfung markieren",
"enrich_needs_metadata_title": "Dokumente ohne Metadaten",
"enrich_needs_metadata_count": "{count} Dokument(e) warten auf Metadaten",
"enrich_needs_metadata_cta": "Jetzt vervollständigen",
"enrich_list_heading": "Dokumente ohne Metadaten",
"enrich_list_empty_heading": "Alle Dokumente vollständig",
"enrich_list_empty_body": "Es gibt keine Dokumente, die noch Metadaten benötigen.",
"enrich_list_start": "Überprüfung starten",
"enrich_progress": "{count} verbleibend",
"enrich_skip": "Überspringen",
"enrich_done_heading": "Alles erledigt!",
"enrich_done_body": "Alle Dokumente wurden bearbeitet.",
"enrich_back_to_list": "Zurück zur Liste",
"comment_empty_hint": "Noch keine Kommentare starte die Diskussion!",
"comment_start_discussion": "Diskussion starten →"
}

View File

@@ -39,7 +39,7 @@
"form_placeholder_location": "e.g. Berlin, Vienna…",
"form_label_sender": "Sender",
"form_label_receivers": "Recipients",
"form_label_title": "Title *",
"form_label_title": "Title",
"form_label_tags": "Tags",
"form_label_content": "Content",
"form_placeholder_content": "Brief description of the content…",
@@ -75,6 +75,7 @@
"doc_file_replace_label": "Upload new file",
"doc_file_replace_note": "(replaces the current file)",
"doc_current_file_label": "Current file:",
"doc_more_details": "More details",
"doc_new_heading": "New document",
"doc_edit_heading": "Edit",
"doc_section_details": "Details",
@@ -268,11 +269,30 @@
"doc_panel_discussion_annotation_tab": "Annotation · Page {page}",
"pdf_annotations_show": "Show annotations",
"pdf_annotations_hide": "Hide annotations",
"upload_drop_hint": "Drop files or click to select",
"upload_drop_hint": "Drop one or multiple files at once",
"upload_accepted_types": "PDF, JPEG, PNG, TIFF",
"upload_filename_hint": "Tip: 2024-03-15_Mueller_Hans.pdf → date and sender pre-filled",
"upload_success": "{count} document(s) created",
"upload_duplicate": "{filename} already exists —",
"upload_duplicate_link": "View document",
"upload_invalid_type": "{filename}: unsupported file format",
"upload_error": "Error uploading {filename}"
"upload_error": "Error uploading {filename}",
"enrich_list_back": "Back to overview",
"enrich_list_count": "documents",
"btn_save_and_mark_reviewed": "Save & mark as reviewed",
"btn_mark_for_review": "Mark for review",
"enrich_needs_metadata_title": "Documents without metadata",
"enrich_needs_metadata_count": "{count} document(s) waiting for metadata",
"enrich_needs_metadata_cta": "Complete now",
"enrich_list_heading": "Documents without metadata",
"enrich_list_empty_heading": "All documents complete",
"enrich_list_empty_body": "There are no documents that still need metadata.",
"enrich_list_start": "Start reviewing",
"enrich_progress": "{count} remaining",
"enrich_skip": "Skip",
"enrich_done_heading": "All done!",
"enrich_done_body": "All documents have been processed.",
"enrich_back_to_list": "Back to list",
"comment_empty_hint": "No comments yet start the discussion!",
"comment_start_discussion": "Start discussion →"
}

View File

@@ -39,7 +39,7 @@
"form_placeholder_location": "p.ej. Berlín, Viena…",
"form_label_sender": "Remitente",
"form_label_receivers": "Destinatarios",
"form_label_title": "Título *",
"form_label_title": "Título",
"form_label_tags": "Etiquetas",
"form_label_content": "Contenido",
"form_placeholder_content": "Breve descripción del contenido…",
@@ -75,6 +75,7 @@
"doc_file_replace_label": "Subir nuevo archivo",
"doc_file_replace_note": "(reemplaza el archivo actual)",
"doc_current_file_label": "Archivo actual:",
"doc_more_details": "Más detalles",
"doc_new_heading": "Nuevo documento",
"doc_edit_heading": "Editar",
"doc_section_details": "Detalles",
@@ -268,11 +269,30 @@
"doc_panel_discussion_annotation_tab": "Anotación · Página {page}",
"pdf_annotations_show": "Mostrar anotaciones",
"pdf_annotations_hide": "Ocultar anotaciones",
"upload_drop_hint": "Soltar archivos o hacer clic para seleccionar",
"upload_drop_hint": "Uno o varios archivos a la vez",
"upload_accepted_types": "PDF, JPEG, PNG, TIFF",
"upload_filename_hint": "Consejo: 2024-03-15_Mueller_Hans.pdf → fecha y remitente prellenados",
"upload_success": "{count} documento(s) creado(s)",
"upload_duplicate": "{filename} ya existe —",
"upload_duplicate_link": "Ver documento",
"upload_invalid_type": "{filename}: formato de archivo no admitido",
"upload_error": "Error al subir {filename}"
"upload_error": "Error al subir {filename}",
"enrich_list_back": "Volver a la vista general",
"enrich_list_count": "documentos",
"btn_save_and_mark_reviewed": "Guardar y marcar como revisado",
"btn_mark_for_review": "Marcar para revisión",
"enrich_needs_metadata_title": "Documentos sin metadatos",
"enrich_needs_metadata_count": "{count} documento(s) esperando metadatos",
"enrich_needs_metadata_cta": "Completar ahora",
"enrich_list_heading": "Documentos sin metadatos",
"enrich_list_empty_heading": "Todos los documentos completos",
"enrich_list_empty_body": "No hay documentos que necesiten metadatos.",
"enrich_list_start": "Comenzar revisión",
"enrich_progress": "{count} restante(s)",
"enrich_skip": "Omitir",
"enrich_done_heading": "¡Todo listo!",
"enrich_done_body": "Todos los documentos han sido procesados.",
"enrich_back_to_list": "Volver a la lista",
"comment_empty_hint": "Aún no hay comentarios ¡inicia la discusión!",
"comment_start_discussion": "Iniciar discusión →"
}

View File

@@ -1,15 +1,5 @@
<script lang="ts">
type Annotation = {
id: string;
documentId: string;
pageNumber: number;
x: number;
y: number;
width: number;
height: number;
color: string;
createdAt: string;
};
import type { Annotation } from '$lib/types';
type DrawRect = {
x: number;

View File

@@ -1,25 +1,7 @@
<script lang="ts">
import { onMount, untrack } from 'svelte';
import { m } from '$lib/paraglide/messages.js';
type CommentReply = {
id: string;
authorId: string | null;
authorName: string;
content: string;
createdAt: string;
updatedAt: string;
};
type Comment = {
id: string;
authorId: string | null;
authorName: string;
content: string;
createdAt: string;
updatedAt: string;
replies: CommentReply[];
};
import type { Comment, CommentReply } from '$lib/types';
type Props = {
documentId: string;
@@ -185,158 +167,120 @@ function cancelReply() {
onMount(() => {
if (loadOnMount) {
reload();
} else {
const total = initialComments.reduce((s, c) => s + 1 + c.replies.length, 0);
onCountChange?.(total);
}
});
</script>
<!--
Renders a single comment or reply entry.
showReplyButton: whether the "Reply" button appears (only on last item in a thread).
-->
{#snippet commentEntry(comment: Comment | CommentReply, threadId: string, showReplyButton: boolean)}
{#if editingId === comment.id}
<div class="flex flex-col gap-2">
<textarea
class="w-full resize-none rounded border border-line px-3 py-2 font-serif text-sm text-ink focus:ring-1 focus:ring-accent focus:outline-none"
rows={3}
bind:value={editText}
></textarea>
<div class="flex items-center gap-3">
<button
class="rounded bg-primary px-3 py-1.5 font-sans text-xs font-medium text-white hover:bg-primary/80 disabled:opacity-40"
disabled={posting}
onclick={() => saveEdit(comment.id)}
>
{m.btn_save()}
</button>
<button
class="font-sans text-xs text-ink-3 transition-colors hover:text-ink"
onclick={cancelEdit}
>
{m.btn_cancel()}
</button>
</div>
</div>
{:else}
<div class="flex items-start justify-between gap-2">
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<span class="font-sans text-xs font-semibold text-ink">{comment.authorName}</span>
<span class="font-sans text-xs text-ink-3">{timeAgo(comment.createdAt)}</span>
{#if wasEdited(comment)}
<span class="font-sans text-xs text-ink-3">
{m.comment_edited_label()}
{timeAgo(comment.updatedAt)}
</span>
{/if}
</div>
<p class="mt-1 font-serif text-sm leading-relaxed text-ink-2">{comment.content}</p>
</div>
{#if canModify(comment)}
<div class="flex shrink-0 items-center gap-2">
<button
class="font-sans text-xs text-ink-3 transition-colors hover:text-ink"
onclick={() => startEdit(comment)}
>
{m.btn_edit()}
</button>
<button
class="font-sans text-xs text-ink-3 transition-colors hover:text-ink"
onclick={() => deleteComment(comment.id)}
>
{m.btn_delete()}
</button>
</div>
{/if}
</div>
{#if showReplyButton && canComment}
<div class="mt-1">
<button
class="font-sans text-xs font-medium text-accent transition-colors hover:text-ink"
onclick={() => startReply(threadId)}
>
{m.comment_btn_reply()}
</button>
</div>
{/if}
{/if}
{/snippet}
<div class="space-y-4">
{#if comments.length === 0}
<div class="flex flex-col items-center gap-3 py-8 text-center">
<svg
class="h-10 w-10 text-ink-3"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M2.25 12.76c0 1.6 1.123 2.994 2.707 3.227 1.087.16 2.185.283 3.293.369V21l4.076-4.076a1.526 1.526 0 0 1 1.037-.443 48.282 48.282 0 0 0 5.68-.494c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z"
/>
</svg>
<p class="font-sans text-sm text-ink-3">{m.comment_empty_hint()}</p>
</div>
{/if}
{#each comments as thread, ti (thread.id)}
<div class={ti > 0 ? 'border-t border-line pt-4' : ''}>
<!-- Root comment -->
<div>
{#if editingId === thread.id}
<div class="flex flex-col gap-2">
<textarea
class="w-full resize-none rounded border border-line px-3 py-2 font-serif text-sm text-ink focus:ring-1 focus:ring-accent focus:outline-none"
rows={3}
bind:value={editText}
></textarea>
<div class="flex items-center gap-3">
<button
class="rounded bg-primary px-3 py-1.5 font-sans text-xs font-medium text-white hover:bg-primary/80 disabled:opacity-40"
disabled={posting}
onclick={() => saveEdit(thread.id)}
>
{m.btn_save()}
</button>
<button
class="font-sans text-xs text-ink-3 transition-colors hover:text-ink"
onclick={cancelEdit}
>
{m.btn_cancel()}
</button>
</div>
</div>
{:else}
<div class="flex items-start justify-between gap-2">
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<span class="font-sans text-xs font-semibold text-ink">{thread.authorName}</span>
<span class="font-sans text-xs text-ink-3">{timeAgo(thread.createdAt)}</span>
{#if wasEdited(thread)}
<span class="font-sans text-xs text-ink-3">
{m.comment_edited_label()}
{timeAgo(thread.updatedAt)}
</span>
{/if}
</div>
<p class="mt-1 font-serif text-sm leading-relaxed text-ink-2">{thread.content}</p>
</div>
{#if canModify(thread)}
<div class="flex shrink-0 items-center gap-2">
<button
class="font-sans text-xs text-ink-3 transition-colors hover:text-ink"
onclick={() => startEdit(thread)}
>
{m.btn_edit()}
</button>
<button
class="font-sans text-xs text-ink-3 transition-colors hover:text-ink"
onclick={() => deleteComment(thread.id)}
>
{m.btn_delete()}
</button>
</div>
{/if}
</div>
<!-- Reply button on root comment only if there are no replies -->
{#if thread.replies.length === 0 && canComment}
<div class="mt-1">
<button
class="font-sans text-xs font-medium text-accent transition-colors hover:text-ink"
onclick={() => startReply(thread.id)}
>
{m.comment_btn_reply()}
</button>
</div>
{/if}
{/if}
{@render commentEntry(thread, thread.id, thread.replies.length === 0)}
</div>
<!-- Replies -->
{#each thread.replies as reply, ri (reply.id)}
<div class="mt-3 ml-6 border-l-2 border-line pl-4">
{#if editingId === reply.id}
<div class="flex flex-col gap-2">
<textarea
class="w-full resize-none rounded border border-line px-3 py-2 font-serif text-sm text-ink focus:ring-1 focus:ring-accent focus:outline-none"
rows={3}
bind:value={editText}
></textarea>
<div class="flex items-center gap-3">
<button
class="rounded bg-primary px-3 py-1.5 font-sans text-xs font-medium text-white hover:bg-primary/80 disabled:opacity-40"
disabled={posting}
onclick={() => saveEdit(reply.id)}
>
{m.btn_save()}
</button>
<button
class="font-sans text-xs text-ink-3 transition-colors hover:text-ink"
onclick={cancelEdit}
>
{m.btn_cancel()}
</button>
</div>
</div>
{:else}
<div class="flex items-start justify-between gap-2">
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<span class="font-sans text-xs font-semibold text-ink">{reply.authorName}</span>
<span class="font-sans text-xs text-ink-3">{timeAgo(reply.createdAt)}</span>
{#if wasEdited(reply)}
<span class="font-sans text-xs text-ink-3">
{m.comment_edited_label()}
{timeAgo(reply.updatedAt)}
</span>
{/if}
</div>
<p class="mt-1 font-serif text-sm leading-relaxed text-ink-2">{reply.content}</p>
</div>
{#if canModify(reply)}
<div class="flex shrink-0 items-center gap-2">
<button
class="font-sans text-xs text-ink-3 transition-colors hover:text-ink"
onclick={() => startEdit(reply)}
>
{m.btn_edit()}
</button>
<button
class="font-sans text-xs text-ink-3 transition-colors hover:text-ink"
onclick={() => deleteComment(reply.id)}
>
{m.btn_delete()}
</button>
</div>
{/if}
</div>
<!-- Reply button only on the last reply -->
{#if ri === thread.replies.length - 1 && canComment}
<div class="mt-1">
<button
class="font-sans text-xs font-medium text-accent transition-colors hover:text-ink"
onclick={() => startReply(thread.id)}
>
{m.comment_btn_reply()}
</button>
</div>
{/if}
{/if}
{@render commentEntry(reply, thread.id, ri === thread.replies.length - 1)}
</div>
{/each}
<!-- Reply textarea (shown when replyingTo === thread.id) -->
<!-- Reply compose box -->
{#if replyingTo === thread.id}
<div class="mt-3 ml-6 flex flex-col gap-2">
<textarea
@@ -365,7 +309,7 @@ onMount(() => {
</div>
{/each}
<!-- New top-level comment textarea -->
<!-- New top-level comment -->
{#if canComment}
<div class={comments.length > 0 ? 'border-t border-line pt-4' : ''}>
<div class="flex flex-col gap-2">

View File

@@ -0,0 +1,70 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import CommentThread from './CommentThread.svelte';
import type { Comment } from '$lib/types';
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
function makeComment(id: string, content = 'Hello'): Comment {
return {
id,
authorId: 'user-1',
authorName: 'Alice',
content,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
replies: []
};
}
const baseProps = {
documentId: 'doc-1',
canComment: true,
currentUserId: 'user-1',
canAdmin: false
};
describe('CommentThread empty state', () => {
it('shows empty state hint when there are no comments', async () => {
render(CommentThread, { ...baseProps, initialComments: [] });
await expect
.element(page.getByText('Noch keine Kommentare starte die Diskussion!'))
.toBeInTheDocument();
});
it('does not show empty state hint when comments exist', async () => {
render(CommentThread, { ...baseProps, initialComments: [makeComment('c-1')] });
await expect
.element(page.getByText('Noch keine Kommentare starte die Diskussion!'))
.not.toBeInTheDocument();
});
});
describe('CommentThread onCountChange', () => {
it('calls onCountChange with initial SSR count on mount', async () => {
const onCountChange = vi.fn();
render(CommentThread, {
...baseProps,
initialComments: [makeComment('c-1'), makeComment('c-2')],
onCountChange
});
expect(onCountChange).toHaveBeenCalledWith(2);
});
it('calls onCountChange with 0 when no initial comments', async () => {
const onCountChange = vi.fn();
render(CommentThread, { ...baseProps, initialComments: [], onCountChange });
expect(onCountChange).toHaveBeenCalledWith(0);
});
it('counts replies in the total', async () => {
const onCountChange = vi.fn();
const comment = { ...makeComment('c-1'), replies: [makeComment('r-1') as never] };
render(CommentThread, { ...baseProps, initialComments: [comment], onCountChange });
expect(onCountChange).toHaveBeenCalledWith(2);
});
});

View File

@@ -4,27 +4,7 @@ import PanelMetadata from './PanelMetadata.svelte';
import PanelTranscription from './PanelTranscription.svelte';
import PanelDiscussion from './PanelDiscussion.svelte';
import PanelHistory from './PanelHistory.svelte';
type Tab = 'metadata' | 'transcription' | 'discussion' | 'history';
type CommentReply = {
id: string;
authorId: string | null;
authorName: string;
content: string;
createdAt: string;
updatedAt: string;
};
type Comment = {
id: string;
authorId: string | null;
authorName: string;
content: string;
createdAt: string;
updatedAt: string;
replies: CommentReply[];
};
import type { Comment, DocumentPanelTab } from '$lib/types';
type Doc = {
id: string;
@@ -47,7 +27,7 @@ type Props = {
canAdmin: boolean;
open: boolean;
height: number;
activeTab: Tab;
activeTab: DocumentPanelTab;
};
let {
@@ -72,7 +52,7 @@ function fullHeight() {
return window.innerHeight - (topbar?.getBoundingClientRect().bottom ?? 0);
}
function openTab(tab: Tab) {
function openTab(tab: DocumentPanelTab) {
activeTab = tab;
if (!open) {
open = true;
@@ -110,7 +90,7 @@ function onDragEnd() {
isDragging = false;
}
const tabs: { id: Tab; label: () => string }[] = [
const tabs: { id: DocumentPanelTab; label: () => string }[] = [
{ id: 'metadata', label: m.doc_panel_tab_metadata },
{ id: 'transcription', label: m.doc_panel_tab_transcription },
{ id: 'discussion', label: m.doc_panel_tab_discussion },
@@ -118,6 +98,12 @@ const tabs: { id: Tab; label: () => string }[] = [
];
const panelHeight = $derived(open ? height : MIN_HEIGHT);
let discussionCount = $state((() => comments.reduce((s, c) => s + 1 + c.replies.length, 0))());
function handleCountChange(count: number) {
discussionCount = count;
}
</script>
<div
@@ -151,6 +137,13 @@ const panelHeight = $derived(open ? height : MIN_HEIGHT);
aria-pressed={activeTab === tab.id && open}
>
{tab.label()}
{#if tab.id === 'discussion'}
<span
data-testid="discussion-count-badge"
class="ml-1.5 inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 font-sans text-[10px] font-bold text-primary-fg"
>{discussionCount}</span
>
{/if}
</button>
{/each}
@@ -185,6 +178,7 @@ const panelHeight = $derived(open ? height : MIN_HEIGHT);
canComment={canComment}
currentUserId={currentUserId}
canAdmin={canAdmin}
onCountChange={handleCountChange}
/>
{:else if activeTab === 'history'}
<PanelHistory documentId={doc.id} />

View File

@@ -0,0 +1,47 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import DocumentBottomPanel from './DocumentBottomPanel.svelte';
import type { Comment } from '$lib/types';
afterEach(cleanup);
function makeComment(id: string): Comment {
return {
id,
authorId: 'user-1',
authorName: 'Alice',
content: 'Hello',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
replies: []
};
}
const doc = { id: 'doc-1', title: 'Test' };
const baseProps = {
doc,
canComment: true,
currentUserId: 'user-1',
canAdmin: false,
height: 300,
activeTab: 'discussion' as const
};
describe('DocumentBottomPanel discussion badge', () => {
it('always shows a badge on the Discussion tab', async () => {
render(DocumentBottomPanel, { ...baseProps, comments: [], open: true });
await expect.element(page.getByTestId('discussion-count-badge')).toBeInTheDocument();
await expect.element(page.getByTestId('discussion-count-badge')).toHaveTextContent('0');
});
it('shows the correct count when comments exist', async () => {
render(DocumentBottomPanel, {
...baseProps,
comments: [makeComment('c-1'), makeComment('c-2')],
open: true
});
await expect.element(page.getByTestId('discussion-count-badge')).toHaveTextContent('2');
});
});

View File

@@ -1,24 +1,6 @@
<script lang="ts">
import CommentThread from './CommentThread.svelte';
type CommentReply = {
id: string;
authorId: string | null;
authorName: string;
content: string;
createdAt: string;
updatedAt: string;
};
type Comment = {
id: string;
authorId: string | null;
authorName: string;
content: string;
createdAt: string;
updatedAt: string;
replies: CommentReply[];
};
import type { Comment } from '$lib/types';
type Props = {
documentId: string;
@@ -26,9 +8,11 @@ type Props = {
canComment: boolean;
currentUserId: string | null;
canAdmin: boolean;
onCountChange?: (count: number) => void;
};
let { documentId, initialComments, canComment, currentUserId, canAdmin }: Props = $props();
let { documentId, initialComments, canComment, currentUserId, canAdmin, onCountChange }: Props =
$props();
</script>
<div class="flex-1 overflow-y-auto p-6">
@@ -38,5 +22,6 @@ let { documentId, initialComments, canComment, currentUserId, canAdmin }: Props
canComment={canComment}
currentUserId={currentUserId}
canAdmin={canAdmin}
onCountChange={onCountChange}
/>
</div>

View File

@@ -3,6 +3,7 @@ import { onMount } from 'svelte';
import { SvelteMap } from 'svelte/reactivity';
import type { PDFDocumentProxy, PDFPageProxy, RenderTask } from 'pdfjs-dist';
import AnnotationLayer from './AnnotationLayer.svelte';
import type { Annotation } from '$lib/types';
import { m } from '$lib/paraglide/messages.js';
let {
@@ -43,19 +44,6 @@ let textLayerInstance: { cancel: () => void } | null = null;
let pdfjsLib: typeof import('pdfjs-dist') | null = null;
let pdfjsReady = $state(false);
type Annotation = {
id: string;
documentId: string;
pageNumber: number;
x: number;
y: number;
width: number;
height: number;
color: string;
createdAt: string;
fileHash?: string | null;
};
let annotations = $state<Annotation[]>([]);
let annotateColor = $state('#ffff00');
let commentCounts = new SvelteMap<string, number>();

View File

@@ -9,6 +9,7 @@ interface Props {
label: string;
value?: string;
initialName?: string;
suggestedName?: string;
restrictToCorrespondentsOf?: string;
onchange?: (value: string) => void;
}
@@ -18,12 +19,20 @@ let {
label,
value = $bindable(''),
initialName = '',
suggestedName = '',
restrictToCorrespondentsOf,
onchange
}: Props = $props();
let searchTerm = $state(initialName);
$effect(() => {
const suggested = suggestedName;
if (suggested && !untrack(() => value)) {
searchTerm = suggested;
}
});
let results: Person[] = $state([]);
let showDropdown = $state(false);
let loading = $state(false);

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import { untrack } from 'svelte';
import TagInput from '$lib/components/TagInput.svelte';
import { m } from '$lib/paraglide/messages.js';
@@ -7,14 +8,22 @@ let {
initialTitle = '',
initialDocumentLocation = '',
initialSummary = '',
titleRequired = false
titleRequired = false,
suggestedTitle = '',
hideTitle = false
}: {
tags?: string[];
initialTitle?: string;
initialDocumentLocation?: string;
initialSummary?: string;
titleRequired?: boolean;
suggestedTitle?: string;
hideTitle?: boolean;
} = $props();
let titleDirty = $state(false);
let titleOverride = $state(untrack(() => initialTitle));
let titleValue = $derived(titleDirty ? titleOverride : suggestedTitle || titleOverride);
</script>
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
@@ -23,21 +32,27 @@ let {
</h2>
<div class="space-y-5">
<!-- Titel -->
<div>
<label for="title" class="mb-1 block text-sm font-medium text-ink-2"
>{m.form_label_title()}{#if titleRequired}
*{/if}</label
>
<input
id="title"
type="text"
name="title"
value={initialTitle}
required={titleRequired}
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:border-ink focus:ring-ink"
/>
</div>
{#if !hideTitle}
<!-- Titel -->
<div>
<label for="title" class="mb-1 block text-sm font-medium text-ink-2"
>{m.form_label_title()}{#if titleRequired}
*{/if}</label
>
<input
id="title"
type="text"
name="title"
value={titleValue}
oninput={(e) => {
titleOverride = (e.target as HTMLInputElement).value;
titleDirty = true;
}}
required={titleRequired}
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:border-ink focus:ring-ink"
/>
</div>
{/if}
<!-- Aufbewahrungsort -->
<div>

View File

@@ -16,13 +16,17 @@ let {
selectedReceivers = $bindable<Person[]>([]),
initialDateIso = '',
initialLocation = '',
initialSenderName = ''
initialSenderName = '',
suggestedDateIso = '',
suggestedSenderName = ''
}: {
senderId?: string;
selectedReceivers?: Person[];
initialDateIso?: string;
initialLocation?: string;
initialSenderName?: string;
suggestedDateIso?: string;
suggestedSenderName?: string;
} = $props();
let dateDisplay = $state(untrack(() => isoToGerman(initialDateIso)));
@@ -37,6 +41,14 @@ function handleDateInput(e: Event) {
dateIso = result.iso;
dateDirty = true;
}
$effect(() => {
const suggested = suggestedDateIso;
if (suggested && !untrack(() => dateDirty)) {
dateDisplay = isoToGerman(suggested);
dateIso = suggested;
}
});
</script>
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
@@ -90,6 +102,7 @@ function handleDateInput(e: Event) {
label={m.form_label_sender()}
bind:value={senderId}
initialName={initialSenderName}
suggestedName={suggestedSenderName}
/>
</div>

View File

@@ -468,6 +468,54 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/documents/incomplete-count": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getIncompleteCount"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/documents/incomplete": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getIncomplete"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/documents/incomplete/next": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getNextIncomplete"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/documents/search": {
parameters: {
query?: never;
@@ -1819,6 +1867,77 @@ export interface operations {
};
};
};
getIncompleteCount: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": {
count: number;
};
};
};
};
};
getIncomplete: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["Document"][];
};
};
};
};
getNextIncomplete: {
parameters: {
query: {
excludeId: string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["Document"];
};
};
/** @description No Content */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
importStatus: {
parameters: {
query?: never;

33
frontend/src/lib/types.ts Normal file
View File

@@ -0,0 +1,33 @@
export type CommentReply = {
id: string;
authorId: string | null;
authorName: string;
content: string;
createdAt: string;
updatedAt: string;
};
export type Comment = {
id: string;
authorId: string | null;
authorName: string;
content: string;
createdAt: string;
updatedAt: string;
replies: CommentReply[];
};
export type DocumentPanelTab = 'metadata' | 'transcription' | 'discussion' | 'history';
export type Annotation = {
id: string;
documentId: string;
pageNumber: number;
x: number;
y: number;
width: number;
height: number;
color: string;
createdAt: string;
fileHash?: string | null;
};

View File

@@ -0,0 +1,101 @@
import { describe, it, expect } from 'vitest';
import { parseFilename, stripExtension } from './filename';
describe('parseFilename', () => {
describe('date-first patterns', () => {
it('YYYY-MM-DD_Lastname_Firstname', () => {
expect(parseFilename('1965-03-12_Mueller_Hans.pdf')).toEqual({
dateIso: '1965-03-12',
personName: 'Hans Mueller',
suggestedTitle: 'Hans Mueller (12.03.1965)'
});
});
it('YYYYMMDD_Lastname_Firstname', () => {
expect(parseFilename('19650312_Mueller_Hans.pdf')).toEqual({
dateIso: '1965-03-12',
personName: 'Hans Mueller',
suggestedTitle: 'Hans Mueller (12.03.1965)'
});
});
it('YYYYMMDD_compound_lastname_Firstname', () => {
expect(parseFilename('18881025_de_Gruyter_Walter.pdf')).toEqual({
dateIso: '1888-10-25',
personName: 'Walter de Gruyter',
suggestedTitle: 'Walter de Gruyter (25.10.1888)'
});
});
it('handles umlauts in names', () => {
const result = parseFilename('2024-01-15_Müller_Jürgen.pdf');
expect(result.personName).toBe('Jürgen Müller');
});
});
describe('date-last patterns', () => {
it('Lastname_Firstname_YYYY-MM-DD', () => {
expect(parseFilename('Mueller_Hans_1965-03-12.pdf')).toEqual({
dateIso: '1965-03-12',
personName: 'Hans Mueller',
suggestedTitle: 'Hans Mueller (12.03.1965)'
});
});
it('Lastname_Firstname_YYYYMMDD', () => {
expect(parseFilename('Mueller_Hans_19650312.pdf')).toEqual({
dateIso: '1965-03-12',
personName: 'Hans Mueller',
suggestedTitle: 'Hans Mueller (12.03.1965)'
});
});
it('compound_lastname_Firstname_YYYYMMDD', () => {
expect(parseFilename('de_Gruyter_Walter_18881025.pdf')).toEqual({
dateIso: '1888-10-25',
personName: 'Walter de Gruyter',
suggestedTitle: 'Walter de Gruyter (25.10.1888)'
});
});
});
describe('non-matching filenames', () => {
it('returns empty for date-only filename', () => {
expect(parseFilename('1965-03-12.pdf')).toEqual({});
});
it('returns empty for two segments with no date', () => {
expect(parseFilename('Mueller_Hans.pdf')).toEqual({});
});
it('returns empty for unstructured filename', () => {
expect(parseFilename('scan_001.pdf')).toEqual({});
});
it('returns empty for three name segments with no date', () => {
expect(parseFilename('Mueller_Hans_Juergen.pdf')).toEqual({});
});
it('returns empty for filename without extension', () => {
expect(parseFilename('1965-03-12_Mueller_Hans')).toEqual({});
});
it('rejects implausible date (month 13)', () => {
expect(parseFilename('19651345_Mueller_Hans.pdf')).toEqual({});
});
});
});
describe('stripExtension', () => {
it('removes the extension', () => {
expect(stripExtension('document.pdf')).toBe('document');
});
it('removes only the last extension', () => {
expect(stripExtension('archive.tar.gz')).toBe('archive.tar');
});
it('leaves names without extension unchanged', () => {
expect(stripExtension('nodotfile')).toBe('nodotfile');
});
});

View File

@@ -0,0 +1,83 @@
import { isoToGerman } from './date';
export interface FilenameParseResult {
/** ISO format: YYYY-MM-DD */
dateIso?: string;
/** "Firstname Lastname" — order reversed from filename convention */
personName?: string;
/** Ready-to-use title, e.g. "Hans Mueller (12.03.1965)" */
suggestedTitle?: string;
}
// A date token is either YYYY-MM-DD or YYYYMMDD with a plausible month/day range.
function tryParseDate(s: string): string | undefined {
if (/^\d{4}-\d{2}-\d{2}$/.test(s)) {
const m = parseInt(s.slice(5, 7));
const d = parseInt(s.slice(8, 10));
if (m >= 1 && m <= 12 && d >= 1 && d <= 31) return s;
} else if (/^\d{8}$/.test(s)) {
const m = parseInt(s.slice(4, 6));
const d = parseInt(s.slice(6, 8));
if (m >= 1 && m <= 12 && d >= 1 && d <= 31)
return `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}`;
}
return undefined;
}
const NAME_PART = /^\p{L}+$/u;
/**
* Parses a structured filename and extracts a date and person name.
*
* Supported conventions (date-first or date-last, compound last names supported):
* YYYY-MM-DD_Lastname_Firstname.ext
* YYYYMMDD_Lastname_Firstname.ext
* YYYYMMDD_de_Gruyter_Walter.ext ← compound last name: lastName="de Gruyter"
* Lastname_Firstname_YYYY-MM-DD.ext
* Lastname_Firstname_YYYYMMDD.ext
* de_Gruyter_Walter_YYYYMMDD.ext ← compound last name: lastName="de Gruyter"
*
* Algorithm: split on "_", identify the date token (first or last segment),
* treat the outermost remaining segment as firstName, rest as lastName parts.
* Returns {} for anything that doesn't match cleanly.
*/
export function parseFilename(filename: string): FilenameParseResult {
const dot = filename.lastIndexOf('.');
if (dot < 0) return {}; // no extension — not a real file
const stem = filename.slice(0, dot);
const parts = stem.split('_');
// Minimum: date + at least one lastName segment + firstName = 3 parts
if (parts.length < 3) return {};
let dateIso: string;
let nameParts: string[];
const dateFromFirst = tryParseDate(parts[0]);
if (dateFromFirst) {
dateIso = dateFromFirst;
nameParts = parts.slice(1);
} else {
const dateFromLast = tryParseDate(parts[parts.length - 1]);
if (!dateFromLast) return {};
dateIso = dateFromLast;
nameParts = parts.slice(0, -1);
}
// Need at least lastName + firstName after removing the date
if (nameParts.length < 2) return {};
// All name segments must be pure letters (covers umlauts via \p{L})
if (!nameParts.every((p) => NAME_PART.test(p))) return {};
const firstName = nameParts[nameParts.length - 1];
const lastName = nameParts.slice(0, -1).join(' ');
const personName = `${firstName} ${lastName}`;
const suggestedTitle = `${personName} (${isoToGerman(dateIso)})`;
return { dateIso, personName, suggestedTitle };
}
export function stripExtension(filename: string): string {
return filename.replace(/\.[^/.]+$/, '');
}

View File

@@ -12,7 +12,7 @@ export async function load({ url, fetch }) {
const api = createApiClient(fetch);
try {
const [docsResult, personsResult] = await Promise.all([
const [docsResult, personsResult, incompleteCountResult] = await Promise.all([
api.GET('/api/documents/search', {
params: {
query: {
@@ -25,7 +25,8 @@ export async function load({ url, fetch }) {
}
}
}),
api.GET('/api/persons')
api.GET('/api/persons'),
api.GET('/api/documents/incomplete-count')
]);
if (docsResult.response.status === 401 || personsResult.response.status === 401) {
@@ -39,8 +40,13 @@ export async function load({ url, fetch }) {
const senderObj = allPersons.find((p) => p.id === senderId);
const receiverObj = allPersons.find((p) => p.id === receiverId);
const incompleteCount = incompleteCountResult.response.ok
? (incompleteCountResult.data?.count ?? 0)
: 0;
return {
documents,
incompleteCount,
initialValues: {
senderName: senderObj ? `${senderObj.firstName} ${senderObj.lastName}` : '',
receiverName: receiverObj ? `${receiverObj.firstName} ${receiverObj.lastName}` : ''
@@ -53,6 +59,7 @@ export async function load({ url, fetch }) {
console.error('Error loading data:', e);
return {
documents: [],
incompleteCount: 0,
initialValues: { senderName: '', receiverName: '' },
filters: { q, from, to, senderId, receiverId, tags },
error: 'Daten konnten nicht geladen werden.' as string | null

View File

@@ -5,6 +5,7 @@ import { SvelteURLSearchParams } from 'svelte/reactivity';
import SearchFilterBar from './SearchFilterBar.svelte';
import DropZone from './DropZone.svelte';
import DocumentList from './DocumentList.svelte';
import { m } from '$lib/paraglide/messages.js';
let { data } = $props();
@@ -86,5 +87,34 @@ $effect(() => {
<DropZone />
{/if}
{#if data.incompleteCount > 0}
<a
href="/enrich"
class="mb-6 flex items-center justify-between rounded-sm border border-accent/40 bg-accent-bg px-6 py-4 transition-colors hover:bg-accent/20"
>
<div class="flex items-center gap-4">
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Info/Block/Info-Block-Border-MD.svg"
alt=""
aria-hidden="true"
class="h-6 w-6 opacity-60"
/>
<div>
<p class="font-sans text-xs font-bold tracking-widest text-ink uppercase">
{m.enrich_needs_metadata_title()}
</p>
<p class="mt-0.5 font-serif text-sm text-ink-2">
{m.enrich_needs_metadata_count({ count: data.incompleteCount })}
</p>
</div>
</div>
<span
class="font-sans text-xs font-bold tracking-widest text-ink uppercase transition-colors hover:text-ink-2"
>
{m.enrich_needs_metadata_cta()}
</span>
</a>
{/if}
<DocumentList documents={data.documents ?? []} canWrite={data.canWrite} error={data.error} />
</main>

View File

@@ -142,32 +142,17 @@ $effect(() => {
<div
role="button"
tabindex="0"
class="mb-4 flex cursor-pointer items-center justify-center gap-3 border border-dashed px-6 text-sm transition-all duration-200 {isDragging
class="mb-4 flex cursor-pointer flex-col items-center justify-center gap-2 border border-dashed px-6 transition-all duration-200 {isDragging
? 'border-primary bg-accent-bg py-10 text-primary'
: windowDragging
? 'border-primary/60 bg-accent-bg/50 py-10 text-primary/80'
: 'border-ink/20 py-3 text-ink-3 hover:border-primary hover:text-primary'}"
: 'border-ink/20 py-6 text-ink-3 hover:border-primary hover:text-primary'}"
ondragover={handleDragOver}
ondragleave={handleDragLeave}
ondrop={handleDrop}
onclick={() => fileInput.click()}
onkeydown={(e) => e.key === 'Enter' && fileInput.click()}
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4 shrink-0 opacity-50"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
{#if isUploading}
<div class="flex w-48 flex-col items-center gap-1">
<div class="h-1.5 w-full overflow-hidden rounded-full bg-ink/10">
@@ -179,8 +164,17 @@ $effect(() => {
<span class="font-sans text-xs text-ink-3">{uploadProgress}%</span>
</div>
{:else}
<span class="font-sans font-medium">{m.upload_drop_hint()}</span>
<span class="font-sans text-xs text-ink-3">{m.upload_accepted_types()}</span>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Copy-Item-MD.svg"
alt=""
aria-hidden="true"
class="h-8 w-8 opacity-40"
/>
<div class="flex flex-col items-center gap-0.5 text-center">
<span class="font-sans text-sm text-ink-2">{m.upload_drop_hint()}</span>
<span class="font-sans text-xs text-ink-3">{m.upload_accepted_types()}</span>
<span class="font-sans text-xs text-ink-3 italic">{m.upload_filename_hint()}</span>
</div>
{/if}
</div>

View File

@@ -4,8 +4,7 @@ import DocumentTopBar from '$lib/components/DocumentTopBar.svelte';
import DocumentViewer from '$lib/components/DocumentViewer.svelte';
import DocumentBottomPanel from '$lib/components/DocumentBottomPanel.svelte';
import AnnotationSidePanel from '$lib/components/AnnotationSidePanel.svelte';
type Tab = 'metadata' | 'transcription' | 'discussion' | 'history';
import type { DocumentPanelTab } from '$lib/types';
let { data } = $props();
@@ -68,11 +67,12 @@ $effect(() => {
const LS_KEY_HEIGHT = 'doc-panel-height';
const LS_KEY_TAB = 'doc-panel-tab';
const LS_KEY_OPEN = 'doc-panel-open';
let panelOpen = $state(false);
let panelHeight = $state(0); // set to full height on mount
let navHeight = $state(0);
let activeTab = $state<Tab>('metadata');
let activeTab = $state<DocumentPanelTab>('metadata');
let localStorageRestored = $state(false);
onMount(() => {
@@ -80,9 +80,10 @@ onMount(() => {
const savedHeight = localStorage.getItem(LS_KEY_HEIGHT);
const savedTab = localStorage.getItem(LS_KEY_TAB);
const savedOpen = localStorage.getItem(LS_KEY_OPEN);
if (savedTab && ['metadata', 'transcription', 'discussion', 'history'].includes(savedTab)) {
activeTab = savedTab as Tab;
activeTab = savedTab as DocumentPanelTab;
}
const topbar = document.querySelector('[data-topbar]');
panelHeight = window.innerHeight - navHeight - (topbar?.getBoundingClientRect().height ?? 0);
@@ -91,6 +92,14 @@ onMount(() => {
if (!isNaN(h) && h >= 80) panelHeight = h;
}
if (savedOpen === 'true') {
panelOpen = true;
} else if (savedOpen === null && !doc?.filePath) {
// No prior state and no file — open to metadata so the panel is immediately useful.
panelOpen = true;
activeTab = 'metadata';
}
localStorageRestored = true;
function onKeyDown(e: KeyboardEvent) {
@@ -112,6 +121,7 @@ $effect(() => {
if (!localStorageRestored) return;
localStorage.setItem(LS_KEY_HEIGHT, String(panelHeight));
localStorage.setItem(LS_KEY_TAB, activeTab);
localStorage.setItem(LS_KEY_OPEN, String(panelOpen));
});
</script>

View File

@@ -60,6 +60,53 @@ export const actions = {
throw redirect(303, `/documents/${params.id}`);
},
markForReview: async ({
params,
fetch
}: {
params: { id: string };
fetch: typeof globalThis.fetch;
}) => {
const baseUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
const api = createApiClient(fetch);
// Fetch current document to preserve all existing fields
const docResult = await api.GET('/api/documents/{id}', { params: { path: { id: params.id } } });
if (!docResult.response.ok) {
const code = (docResult.error as unknown as { code?: string })?.code;
return fail(docResult.response.status, { error: getErrorMessage(code) });
}
const doc = docResult.data!;
const formData = new FormData();
if (doc.title) formData.set('title', doc.title);
if (doc.documentDate) formData.set('documentDate', doc.documentDate);
if (doc.location) formData.set('location', doc.location);
if (doc.documentLocation) formData.set('documentLocation', doc.documentLocation);
if (doc.transcription) formData.set('transcription', doc.transcription);
if (doc.summary) formData.set('summary', doc.summary);
if (doc.sender?.id) formData.set('senderId', doc.sender.id);
if (doc.receivers?.length) {
doc.receivers.forEach((r: { id: string }) => formData.append('receiverIds', r.id));
}
if (doc.tags?.length) {
formData.set('tags', doc.tags.map((t: { name: string }) => t.name).join(','));
}
formData.set('metadataComplete', 'false');
const res = await fetch(`${baseUrl}/api/documents/${params.id}`, {
method: 'PUT',
body: formData
});
if (!res.ok) {
const backendError = await parseBackendError(res);
return fail(res.status, { error: getErrorMessage(backendError?.code) });
}
throw redirect(303, `/documents/${params.id}`);
},
delete: async ({ params, fetch }) => {
const baseUrl = env.API_INTERNAL_URL || 'http://localhost:8080';

View File

@@ -68,5 +68,6 @@ let selectedReceivers = $state(doc.receivers ?? []);
<SaveBar docId={doc.id} />
</form>
<form id="mark-for-review-form" method="POST" action="?/markForReview" use:enhance></form>
<form id="delete-form" method="POST" action="?/delete" use:enhance></form>
</div>

View File

@@ -54,7 +54,7 @@ let confirmDelete = $state(false);
{/if}
</div>
<!-- Right: cancel + save -->
<!-- Right: cancel + mark for review + save -->
<div class="flex items-center gap-4">
<a
href="/documents/{docId}"
@@ -62,6 +62,13 @@ let confirmDelete = $state(false);
>
{m.btn_cancel()}
</a>
<button
type="submit"
form="mark-for-review-form"
class="rounded-sm border border-gray-300 px-4 py-2 font-sans text-xs font-bold tracking-widest text-gray-600 uppercase transition-colors hover:bg-gray-50"
>
{m.btn_mark_for_review()}
</button>
<button
type="submit"
class="rounded bg-primary px-6 py-2 text-sm font-bold tracking-widest text-white uppercase transition-colors hover:bg-primary/80"

View File

@@ -55,22 +55,41 @@ export async function load({
};
}
async function submitNewDocument(
request: Request,
fetch: typeof globalThis.fetch,
metadataComplete: boolean
) {
const baseUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
const formData = await request.formData();
formData.set('metadataComplete', String(metadataComplete));
const res = await fetch(`${baseUrl}/api/documents`, {
method: 'POST',
body: formData
});
if (!res.ok) {
const backendError = await parseBackendError(res);
return fail(res.status, { error: getErrorMessage(backendError?.code) });
}
const created = await res.json();
throw redirect(303, `/documents/${created.id}`);
}
export const actions = {
default: async ({ request, fetch }) => {
const baseUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
const formData = await request.formData();
save: async ({ request, fetch }: { request: Request; fetch: typeof globalThis.fetch }) => {
return submitNewDocument(request, fetch, false);
},
const res = await fetch(`${baseUrl}/api/documents`, {
method: 'POST',
body: formData
});
if (!res.ok) {
const backendError = await parseBackendError(res);
return fail(res.status, { error: getErrorMessage(backendError?.code) });
}
const created = await res.json();
throw redirect(303, `/documents/${created.id}`);
saveReviewed: async ({
request,
fetch
}: {
request: Request;
fetch: typeof globalThis.fetch;
}) => {
return submitNewDocument(request, fetch, true);
}
};

View File

@@ -6,6 +6,7 @@ import WhoWhenSection from '$lib/components/document/WhoWhenSection.svelte';
import DescriptionSection from '$lib/components/document/DescriptionSection.svelte';
import TranscriptionSection from '$lib/components/document/TranscriptionSection.svelte';
import FileSectionNew from './FileSectionNew.svelte';
import { type FilenameParseResult } from '$lib/utils/filename';
let { data, form } = $props();
@@ -14,6 +15,32 @@ let senderId = $state(untrack(() => data.initialSenderId));
let selectedReceivers: { id: string; firstName: string; lastName: string }[] = $state(
untrack(() => data.initialReceivers)
);
let parsedSuggestion = $state<FilenameParseResult>({});
// Title is derived from the filename suggestion unless the user has typed something
let titleDirty = $state(false);
let titleOverride = $state('');
let titleValue = $derived(
titleDirty ? titleOverride : (parsedSuggestion.suggestedTitle ?? titleOverride)
);
// Details panel: starts open when prefill data is present or a form error occurred.
// Auto-opens when filename parsing finds a date/sender, but never force-closes — user
// can always collapse the section manually.
let detailsOpen = $state(
!!(
untrack(() => data.initialSenderId) ||
untrack(() => data.initialReceivers).length > 0 ||
untrack(() => form)?.error
)
);
$effect(() => {
if (parsedSuggestion.dateIso || senderId || selectedReceivers.length > 0) {
detailsOpen = true;
}
});
</script>
<div class="mx-auto max-w-4xl px-4 py-8">
@@ -46,14 +73,50 @@ let selectedReceivers: { id: string; firstName: string; lastName: string }[] = $
{/if}
<form method="POST" enctype="multipart/form-data" use:enhance class="space-y-6 pb-20">
<WhoWhenSection
bind:senderId={senderId}
bind:selectedReceivers={selectedReceivers}
initialSenderName={data.initialSenderName}
/>
<DescriptionSection bind:tags={tags} titleRequired={true} />
<TranscriptionSection />
<FileSectionNew />
<!-- File upload — prominent, at the top -->
<FileSectionNew onfileParsed={(r) => (parsedSuggestion = r)} />
<!-- Standalone title card -->
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
<label for="new-title" class="mb-1 block text-sm font-medium text-ink-2"
>{m.form_label_title()}</label
>
<input
id="new-title"
type="text"
name="title"
value={titleValue}
oninput={(e) => {
titleOverride = (e.target as HTMLInputElement).value;
titleDirty = true;
}}
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:border-ink focus:ring-ink"
placeholder="Titel eingeben…"
/>
</div>
<!-- Collapsible further details -->
<details
bind:open={detailsOpen}
class="group rounded-sm border border-line bg-surface shadow-sm"
>
<summary class="cursor-pointer list-none px-6 py-4">
<span class="text-xs font-bold tracking-widest text-ink-3 uppercase"
>{m.doc_more_details()}</span
>
</summary>
<div class="space-y-6 px-0 pb-6">
<WhoWhenSection
bind:senderId={senderId}
bind:selectedReceivers={selectedReceivers}
initialSenderName={data.initialSenderName}
suggestedDateIso={parsedSuggestion.dateIso ?? ''}
suggestedSenderName={parsedSuggestion.personName ?? ''}
/>
<DescriptionSection bind:tags={tags} hideTitle={true} />
<TranscriptionSection />
</div>
</details>
<!-- Sticky Save Bar -->
<div
@@ -62,12 +125,26 @@ let selectedReceivers: { id: string; firstName: string; lastName: string }[] = $
<a href="/" class="text-sm font-medium text-ink-2 transition-colors hover:text-ink">
{m.btn_cancel()}
</a>
<button
type="submit"
class="rounded bg-primary px-6 py-2 text-sm font-bold tracking-widest text-white uppercase transition-colors hover:bg-primary/80"
>
{m.btn_save()}
</button>
<div class="flex items-center gap-3">
<button
type="submit"
name="metadataComplete"
value="false"
formaction="?/save"
class="rounded-sm border border-gray-300 px-5 py-2 font-sans text-xs font-bold tracking-widest text-gray-600 uppercase transition-colors hover:bg-gray-50"
>
{m.btn_save()}
</button>
<button
type="submit"
name="metadataComplete"
value="true"
formaction="?/saveReviewed"
class="rounded-sm bg-brand-navy px-5 py-2 font-sans text-xs font-bold tracking-widest text-white uppercase transition-colors hover:bg-brand-navy/90"
>
{m.btn_save_and_mark_reviewed()}
</button>
</div>
</div>
</form>
</div>

View File

@@ -1,25 +1,59 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { parseFilename, stripExtension, type FilenameParseResult } from '$lib/utils/filename';
let {
onfileParsed
}: {
onfileParsed?: (result: FilenameParseResult) => void;
} = $props();
let selectedFilename = $state<string | null>(null);
function handleFileChange(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0];
if (!file) return;
selectedFilename = file.name;
const parsed = parseFilename(file.name);
const result: FilenameParseResult = {
...parsed,
suggestedTitle: parsed.suggestedTitle ?? stripExtension(file.name)
};
onfileParsed?.(result);
}
</script>
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
<h2 class="mb-5 text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.doc_section_file()}
</h2>
<div class="rounded-sm border border-line bg-surface shadow-sm">
<div class="border-b border-line px-6 py-4">
<h2 class="text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.doc_section_file()}
</h2>
</div>
<label for="file-upload" class="mb-1 block text-sm font-medium text-ink-2">
{m.doc_file_upload_label()}
<span class="font-normal text-ink-3">({m.doc_file_upload_note()})</span>
<label
for="file-upload"
class="flex cursor-pointer flex-col items-center gap-3 px-6 py-10 transition-colors hover:bg-muted/40"
>
<svg
class="h-10 w-10 text-ink-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"
/>
</svg>
{#if selectedFilename}
<span class="text-ink-1 text-sm font-medium">{selectedFilename}</span>
{:else}
<span class="text-sm font-medium text-ink-2">{m.doc_file_upload_label()}</span>
<span class="text-xs text-ink-3">{m.doc_file_upload_note()}</span>
{/if}
</label>
<input
id="file-upload"
type="file"
name="file"
class="block w-full cursor-pointer text-sm
text-ink-2 file:mr-4 file:rounded
file:border-0 file:bg-muted
file:px-4 file:py-2
file:text-sm file:font-semibold
file:text-ink hover:file:bg-muted"
/>
<input id="file-upload" type="file" name="file" onchange={handleFileChange} class="sr-only" />
</div>

View File

@@ -0,0 +1,23 @@
import { redirect } from '@sveltejs/kit';
import { createApiClient } from '$lib/api.server';
export async function load({
fetch,
locals
}: {
fetch: typeof globalThis.fetch;
locals: App.Locals;
}) {
const canWrite =
locals.user?.groups?.some((g: { permissions: string[] }) =>
g.permissions.includes('WRITE_ALL')
) ?? false;
if (!canWrite) throw redirect(303, '/');
const api = createApiClient(fetch);
const result = await api.GET('/api/documents/incomplete');
const documents = result.response.ok ? (result.data ?? []) : [];
return { documents };
}

View File

@@ -0,0 +1,106 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
let { data } = $props();
const documents = $derived(data.documents);
const count = $derived(documents.length);
function formatUploadDate(createdAt: string): string {
return new Intl.DateTimeFormat('de-DE', {
day: 'numeric',
month: 'long',
year: 'numeric'
}).format(new Date(createdAt));
}
</script>
<div class="mx-auto max-w-4xl px-4 py-10">
<!-- Back Link -->
<a
href="/"
class="group mb-4 inline-flex items-center font-sans text-xs font-bold tracking-widest text-gray-500 uppercase transition-colors hover:text-brand-navy"
>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Arrow/Arrow-Left-MD.svg"
alt=""
aria-hidden="true"
class="mr-2 h-4 w-4 transform transition-transform group-hover:-translate-x-1"
/>
{m.enrich_list_back()}
</a>
<!-- Page Header -->
<div class="border-brand-sand mb-8 flex items-center justify-between border-b pb-6">
<div>
<h1 class="font-serif text-3xl font-medium text-brand-navy">
{m.enrich_list_heading()}
</h1>
{#if count > 0}
<p class="mt-2 font-sans text-sm text-brand-navy/60">
{count}
{m.enrich_list_count()}
</p>
{/if}
</div>
{#if count > 0}
<a
href="/enrich/{documents[0].id}"
class="bg-brand-navy px-5 py-2 font-sans text-xs font-bold tracking-widest text-white uppercase transition-colors hover:bg-brand-navy/90"
>
{m.enrich_list_start()}
</a>
{/if}
</div>
<!-- Empty State -->
{#if count === 0}
<div
class="border-brand-sand flex flex-col items-center justify-center rounded-sm border border-dashed bg-white py-20 text-center"
>
<div class="bg-brand-sand/60 mb-4 flex h-14 w-14 items-center justify-center rounded-full">
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Check/Check-Circle-MD.svg"
alt=""
aria-hidden="true"
class="h-7 w-7 opacity-50"
/>
</div>
<p class="font-serif text-lg font-medium text-brand-navy">
{m.enrich_list_empty_heading()}
</p>
<p class="mt-2 max-w-xs font-sans text-sm text-brand-navy/60">
{m.enrich_list_empty_body()}
</p>
</div>
{:else}
<!-- Document Rows -->
<div class="border-brand-sand border bg-white shadow-sm">
<ul class="divide-brand-sand divide-y">
{#each documents as doc (doc.id)}
<li class="group hover:bg-brand-sand/30 transition-colors duration-200">
<a href="/enrich/{doc.id}" class="flex items-center justify-between p-6">
<div class="min-w-0 flex-1">
<p
class="font-serif text-lg font-medium text-brand-navy decoration-brand-mint decoration-2 underline-offset-4 group-hover:underline"
>
{doc.title || doc.originalFilename}
</p>
<p class="mt-1 font-sans text-xs text-brand-navy/50">
{formatUploadDate(doc.createdAt)}
</p>
</div>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Arrow/Arrow-Right-MD.svg"
alt=""
aria-hidden="true"
class="ml-4 h-5 w-5 shrink-0 opacity-30 transition-opacity group-hover:opacity-70"
/>
</a>
</li>
{/each}
</ul>
</div>
{/if}
</div>

View File

@@ -0,0 +1,109 @@
import { error, redirect } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';
import { createApiClient } from '$lib/api.server';
import { getErrorMessage, parseBackendError } from '$lib/errors';
export async function load({
params,
fetch,
locals
}: {
params: { id: string };
fetch: typeof globalThis.fetch;
locals: App.Locals;
}) {
const canWrite =
locals.user?.groups?.some((g: { permissions: string[] }) =>
g.permissions.includes('WRITE_ALL')
) ?? false;
if (!canWrite) throw redirect(303, '/');
const { id } = params;
const api = createApiClient(fetch);
const [docResult, countResult] = await Promise.all([
api.GET('/api/documents/{id}', { params: { path: { id } } }),
api.GET('/api/documents/incomplete-count')
]);
if (!docResult.response.ok) {
const code = (docResult.error as unknown as { code?: string })?.code;
throw error(docResult.response.status, getErrorMessage(code));
}
const incompleteCount = countResult.response.ok ? (countResult.data?.count ?? 0) : 0;
return {
document: docResult.data!,
incompleteCount
};
}
async function redirectToNext(id: string, fetch: typeof globalThis.fetch): Promise<never> {
const api = createApiClient(fetch);
const nextResult = await api.GET('/api/documents/incomplete/next', {
params: { query: { excludeId: id } }
});
if (nextResult.response.ok && nextResult.data) {
throw redirect(303, `/enrich/${nextResult.data.id}`);
}
throw redirect(303, '/enrich/done');
}
export const actions = {
skip: async ({ params, fetch }: { params: { id: string }; fetch: typeof globalThis.fetch }) => {
await redirectToNext(params.id, fetch);
},
save: async ({
params,
request,
fetch
}: {
params: { id: string };
request: Request;
fetch: typeof globalThis.fetch;
}) => {
const baseUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
const formData = await request.formData();
const res = await fetch(`${baseUrl}/api/documents/${params.id}`, {
method: 'PUT',
body: formData
});
if (!res.ok) {
const backendError = await parseBackendError(res);
return { error: getErrorMessage(backendError?.code) };
}
await redirectToNext(params.id, fetch);
},
saveAndReview: async ({
params,
request,
fetch
}: {
params: { id: string };
request: Request;
fetch: typeof globalThis.fetch;
}) => {
const baseUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
const formData = await request.formData();
formData.set('metadataComplete', 'true');
const res = await fetch(`${baseUrl}/api/documents/${params.id}`, {
method: 'PUT',
body: formData
});
if (!res.ok) {
const backendError = await parseBackendError(res);
return { error: getErrorMessage(backendError?.code) };
}
await redirectToNext(params.id, fetch);
}
};

View File

@@ -0,0 +1,167 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { onMount, untrack } from 'svelte';
import { m } from '$lib/paraglide/messages.js';
import DocumentViewer from '$lib/components/DocumentViewer.svelte';
import WhoWhenSection from '$lib/components/document/WhoWhenSection.svelte';
import DescriptionSection from '$lib/components/document/DescriptionSection.svelte';
let { data, form } = $props();
const doc = $derived(data.document);
// File preview state
let fileUrl = $state('');
let fileError = $state('');
let isLoading = $state(false);
let navHeight = $state(0);
onMount(() => {
navHeight = document.querySelector('header')?.getBoundingClientRect().height ?? 0;
});
// Dummy bindable state required by DocumentViewer
let annotateMode = $state(false);
let activeAnnotationId = $state<string | null>(null);
let activeAnnotationPage = $state<number | null>(null);
$effect(() => {
if (doc?.id && doc?.filePath) {
loadFile(doc.id);
}
});
async function loadFile(id: string) {
isLoading = true;
fileError = '';
fileUrl = '';
try {
const response = await fetch(`/api/documents/${id}/file`);
if (!response.ok) throw new Error('Fehler');
const blob = await response.blob();
fileUrl = URL.createObjectURL(blob);
} catch {
fileError = m.doc_file_error_preview();
} finally {
isLoading = false;
}
}
// Form state
let tags = $state(untrack(() => doc.tags?.map((t: { name: string }) => t.name) ?? []));
let senderId = $state(untrack(() => doc.sender?.id ?? ''));
let selectedReceivers = $state(untrack(() => doc.receivers ?? []));
</script>
<svelte:head>
<title>{doc.title || doc.originalFilename || 'Dokument'} — Anreicherung</title>
</svelte:head>
<div class="fixed inset-x-0 bottom-0 flex flex-col" style="top: {navHeight}px">
<!-- Top bar -->
<div class="flex items-center justify-between border-b border-line bg-surface px-6 py-3">
<a
href="/enrich"
class="group inline-flex items-center font-sans text-xs font-bold tracking-widest text-ink-2 uppercase transition-colors hover:text-ink"
>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Arrow/Arrow-Left-MD.svg"
alt=""
aria-hidden="true"
class="mr-2 h-4 w-4 transform transition-transform group-hover:-translate-x-1"
/>
{m.enrich_back_to_list()}
</a>
<p class="max-w-sm truncate text-center font-serif text-sm font-medium text-ink">
{doc.title || doc.originalFilename}
</p>
<p class="font-sans text-xs text-ink-3">
{m.enrich_progress({ count: data.incompleteCount })}
</p>
</div>
<!-- Main content -->
<div class="flex flex-1 overflow-hidden">
<!-- Left: PDF preview (60%) -->
<div class="relative flex-[6] overflow-hidden border-r border-line">
<DocumentViewer
doc={doc}
fileUrl={fileUrl}
isLoading={isLoading}
error={fileError}
bind:annotateMode={annotateMode}
bind:activeAnnotationId={activeAnnotationId}
bind:activeAnnotationPage={activeAnnotationPage}
onAnnotationClick={() => {}}
/>
</div>
<!-- Right: form (40%) -->
<div class="flex flex-[4] flex-col overflow-hidden">
{#if form?.error}
<div class="border-b border-red-200 bg-red-50 px-6 py-3 text-sm text-red-700">
{form.error}
</div>
{/if}
<form
id="save-form"
method="POST"
action="?/save"
enctype="multipart/form-data"
use:enhance
class="flex-1 space-y-5 overflow-y-auto p-6"
>
<WhoWhenSection
bind:senderId={senderId}
bind:selectedReceivers={selectedReceivers}
initialDateIso={doc.documentDate ?? ''}
initialLocation={doc.location ?? ''}
initialSenderName={doc.sender
? `${doc.sender.firstName} ${doc.sender.lastName}`
: ''}
/>
<DescriptionSection bind:tags={tags} initialTitle={doc.title ?? ''} titleRequired={true} />
</form>
<!-- Skip form (outside main form to avoid nesting) -->
<form id="skip-form" method="POST" action="?/skip" use:enhance></form>
<!-- Action bar -->
<div class="flex items-center justify-between gap-3 border-t border-line bg-surface p-4">
<!-- Skip button linked to skip-form -->
<button
type="submit"
form="skip-form"
class="font-sans text-sm font-medium text-brand-navy/60 transition-colors hover:text-brand-navy"
>
{m.enrich_skip()}
</button>
<div class="flex items-center gap-3">
<!-- Save -->
<button
type="submit"
form="save-form"
formaction="?/save"
class="rounded-sm border border-gray-300 px-5 py-2 font-sans text-xs font-bold tracking-widest text-gray-600 uppercase transition-colors hover:bg-gray-50"
>
{m.btn_save()}
</button>
<!-- Save & mark as reviewed -->
<button
type="submit"
form="save-form"
formaction="?/saveAndReview"
class="rounded-sm bg-brand-navy px-5 py-2 font-sans text-xs font-bold tracking-widest text-white uppercase transition-colors hover:bg-brand-navy/90"
>
{m.btn_save_and_mark_reviewed()}
</button>
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,40 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
</script>
<div class="mx-auto max-w-4xl px-4 py-10">
<div
class="border-brand-sand flex flex-col items-center justify-center rounded-sm border bg-white py-20 text-center shadow-sm"
>
<img
src="/degruyter-icons/Simple/Large-32px/SVG/Action/Check/Check-Double-LG.svg"
alt=""
aria-hidden="true"
class="mb-6 h-16 w-16"
/>
<h1 class="font-serif text-2xl font-medium text-brand-navy">
{m.enrich_done_heading()}
</h1>
<p class="mt-2 max-w-xs font-sans text-sm text-gray-500">
{m.enrich_done_body()}
</p>
<div class="mt-8 flex flex-col items-center gap-4">
<a
href="/"
class="bg-brand-navy px-6 py-2 font-sans text-xs font-bold tracking-widest text-white uppercase transition-colors hover:bg-brand-navy/90"
>
{m.btn_back_to_overview()}
</a>
<a
href="/enrich"
class="font-sans text-xs text-gray-400 underline-offset-4 transition-colors hover:text-brand-navy hover:underline"
>
{m.enrich_back_to_list()}
</a>
</div>
</div>
</div>

View File

@@ -23,6 +23,7 @@ const emptyData = {
canAnnotate: false,
filters: { q: '', from: '', to: '', senderId: '', receiverId: '', tags: [] },
documents: [],
incompleteCount: 0,
initialValues: { senderName: '', receiverName: '' },
error: null
};