Compare commits

...

15 Commits

Author SHA1 Message Date
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
23 changed files with 632 additions and 39 deletions

View File

@@ -28,6 +28,9 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
// Lookup by full alias string, used during ODS mass import // Lookup by full alias string, used during ODS mass import
Optional<Person> findByAliasIgnoreCase(String alias); 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 --- // --- Correspondent queries ---
@Query(value = """ @Query(value = """

View File

@@ -63,9 +63,15 @@ public class DocumentService {
document = existingDoc.get(); document = existingDoc.get();
} else { } else {
// New uploads from the drop zone always start as incomplete // 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() document = Document.builder()
.originalFilename(originalFilename) .originalFilename(originalFilename)
.title(stripExtension(originalFilename)) .title(parsed != null ? parsed.title() : stripExtension(originalFilename))
.documentDate(parsed != null ? parsed.date() : null)
.sender(sender)
.status(DocumentStatus.UPLOADED) .status(DocumentStatus.UPLOADED)
.metadataComplete(false) .metadataComplete(false)
.build(); .build();
@@ -356,6 +362,81 @@ public class DocumentService {
return dot > 0 ? filename.substring(0, dot) : filename; 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) { private static String sha256Hex(byte[] bytes) {
try { try {
MessageDigest digest = MessageDigest.getInstance("SHA-256"); MessageDigest digest = MessageDigest.getInstance("SHA-256");

View File

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

View File

@@ -10,6 +10,7 @@ import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.model.Document; import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.DocumentStatus; import org.raddatz.familienarchiv.model.DocumentStatus;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.model.Tag; import org.raddatz.familienarchiv.model.Tag;
import org.raddatz.familienarchiv.repository.DocumentRepository; import org.raddatz.familienarchiv.repository.DocumentRepository;
import org.springframework.data.domain.Sort; import org.springframework.data.domain.Sort;
@@ -419,6 +420,53 @@ class DocumentServiceTest {
assertThat(existing.isMetadataComplete()).isTrue(); 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 metadataComplete ───────────────────────────────────── // ─── createDocument metadataComplete ─────────────────────────────────────
@Test @Test
@@ -518,4 +566,52 @@ class DocumentServiceTest {
assertThat(count).isEqualTo(2); 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

@@ -268,8 +268,9 @@
"doc_panel_discussion_annotation_tab": "Annotation · Seite {page}", "doc_panel_discussion_annotation_tab": "Annotation · Seite {page}",
"pdf_annotations_show": "Annotierungen anzeigen", "pdf_annotations_show": "Annotierungen anzeigen",
"pdf_annotations_hide": "Annotierungen verbergen", "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_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_success": "{count} Dokument(e) erstellt",
"upload_duplicate": "{filename} existiert bereits —", "upload_duplicate": "{filename} existiert bereits —",
"upload_duplicate_link": "Zum Dokument", "upload_duplicate_link": "Zum Dokument",
@@ -290,5 +291,7 @@
"enrich_skip": "Überspringen", "enrich_skip": "Überspringen",
"enrich_done_heading": "Alles erledigt!", "enrich_done_heading": "Alles erledigt!",
"enrich_done_body": "Alle Dokumente wurden bearbeitet.", "enrich_done_body": "Alle Dokumente wurden bearbeitet.",
"enrich_back_to_list": "Zurück zur Liste" "enrich_back_to_list": "Zurück zur Liste",
"comment_empty_hint": "Noch keine Kommentare starte die Diskussion!",
"comment_start_discussion": "Diskussion starten →"
} }

View File

@@ -268,8 +268,9 @@
"doc_panel_discussion_annotation_tab": "Annotation · Page {page}", "doc_panel_discussion_annotation_tab": "Annotation · Page {page}",
"pdf_annotations_show": "Show annotations", "pdf_annotations_show": "Show annotations",
"pdf_annotations_hide": "Hide 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_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_success": "{count} document(s) created",
"upload_duplicate": "{filename} already exists —", "upload_duplicate": "{filename} already exists —",
"upload_duplicate_link": "View document", "upload_duplicate_link": "View document",
@@ -290,5 +291,7 @@
"enrich_skip": "Skip", "enrich_skip": "Skip",
"enrich_done_heading": "All done!", "enrich_done_heading": "All done!",
"enrich_done_body": "All documents have been processed.", "enrich_done_body": "All documents have been processed.",
"enrich_back_to_list": "Back to list" "enrich_back_to_list": "Back to list",
"comment_empty_hint": "No comments yet start the discussion!",
"comment_start_discussion": "Start discussion →"
} }

View File

@@ -268,8 +268,9 @@
"doc_panel_discussion_annotation_tab": "Anotación · Página {page}", "doc_panel_discussion_annotation_tab": "Anotación · Página {page}",
"pdf_annotations_show": "Mostrar anotaciones", "pdf_annotations_show": "Mostrar anotaciones",
"pdf_annotations_hide": "Ocultar 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_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_success": "{count} documento(s) creado(s)",
"upload_duplicate": "{filename} ya existe —", "upload_duplicate": "{filename} ya existe —",
"upload_duplicate_link": "Ver documento", "upload_duplicate_link": "Ver documento",
@@ -290,5 +291,7 @@
"enrich_skip": "Omitir", "enrich_skip": "Omitir",
"enrich_done_heading": "¡Todo listo!", "enrich_done_heading": "¡Todo listo!",
"enrich_done_body": "Todos los documentos han sido procesados.", "enrich_done_body": "Todos los documentos han sido procesados.",
"enrich_back_to_list": "Volver a la lista" "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

@@ -167,6 +167,9 @@ function cancelReply() {
onMount(() => { onMount(() => {
if (loadOnMount) { if (loadOnMount) {
reload(); reload();
} else {
const total = initialComments.reduce((s, c) => s + 1 + c.replies.length, 0);
onCountChange?.(total);
} }
}); });
</script> </script>
@@ -245,6 +248,24 @@ onMount(() => {
{/snippet} {/snippet}
<div class="space-y-4"> <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)} {#each comments as thread, ti (thread.id)}
<div class={ti > 0 ? 'border-t border-line pt-4' : ''}> <div class={ti > 0 ? 'border-t border-line pt-4' : ''}>
<!-- Root comment --> <!-- Root comment -->

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

@@ -98,6 +98,12 @@ const tabs: { id: DocumentPanelTab; label: () => string }[] = [
]; ];
const panelHeight = $derived(open ? height : MIN_HEIGHT); 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> </script>
<div <div
@@ -131,6 +137,13 @@ const panelHeight = $derived(open ? height : MIN_HEIGHT);
aria-pressed={activeTab === tab.id && open} aria-pressed={activeTab === tab.id && open}
> >
{tab.label()} {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> </button>
{/each} {/each}
@@ -165,6 +178,7 @@ const panelHeight = $derived(open ? height : MIN_HEIGHT);
canComment={canComment} canComment={canComment}
currentUserId={currentUserId} currentUserId={currentUserId}
canAdmin={canAdmin} canAdmin={canAdmin}
onCountChange={handleCountChange}
/> />
{:else if activeTab === 'history'} {:else if activeTab === 'history'}
<PanelHistory documentId={doc.id} /> <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

@@ -8,9 +8,11 @@ type Props = {
canComment: boolean; canComment: boolean;
currentUserId: string | null; currentUserId: string | null;
canAdmin: boolean; canAdmin: boolean;
onCountChange?: (count: number) => void;
}; };
let { documentId, initialComments, canComment, currentUserId, canAdmin }: Props = $props(); let { documentId, initialComments, canComment, currentUserId, canAdmin, onCountChange }: Props =
$props();
</script> </script>
<div class="flex-1 overflow-y-auto p-6"> <div class="flex-1 overflow-y-auto p-6">
@@ -20,5 +22,6 @@ let { documentId, initialComments, canComment, currentUserId, canAdmin }: Props
canComment={canComment} canComment={canComment}
currentUserId={currentUserId} currentUserId={currentUserId}
canAdmin={canAdmin} canAdmin={canAdmin}
onCountChange={onCountChange}
/> />
</div> </div>

View File

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

View File

@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { untrack } from 'svelte';
import TagInput from '$lib/components/TagInput.svelte'; import TagInput from '$lib/components/TagInput.svelte';
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
@@ -7,14 +8,26 @@ let {
initialTitle = '', initialTitle = '',
initialDocumentLocation = '', initialDocumentLocation = '',
initialSummary = '', initialSummary = '',
titleRequired = false titleRequired = false,
suggestedTitle = ''
}: { }: {
tags?: string[]; tags?: string[];
initialTitle?: string; initialTitle?: string;
initialDocumentLocation?: string; initialDocumentLocation?: string;
initialSummary?: string; initialSummary?: string;
titleRequired?: boolean; titleRequired?: boolean;
suggestedTitle?: string;
} = $props(); } = $props();
let titleValue = $state(untrack(() => initialTitle));
let titleDirty = $state(false);
$effect(() => {
const suggested = suggestedTitle;
if (suggested && !untrack(() => titleDirty)) {
titleValue = suggested;
}
});
</script> </script>
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm"> <div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
@@ -33,7 +46,11 @@ let {
id="title" id="title"
type="text" type="text"
name="title" name="title"
value={initialTitle} value={titleValue}
oninput={(e) => {
titleValue = (e.target as HTMLInputElement).value;
titleDirty = true;
}}
required={titleRequired} required={titleRequired}
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:border-ink focus:ring-ink" class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:border-ink focus:ring-ink"
/> />

View File

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

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

@@ -90,7 +90,7 @@ $effect(() => {
{#if data.incompleteCount > 0} {#if data.incompleteCount > 0}
<a <a
href="/enrich" href="/enrich"
class="mb-6 flex items-center justify-between rounded-sm border border-brand-mint/40 bg-brand-mint/10 px-6 py-4 transition-colors hover:bg-brand-mint/20" 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"> <div class="flex items-center gap-4">
<img <img
@@ -100,16 +100,16 @@ $effect(() => {
class="h-6 w-6 opacity-60" class="h-6 w-6 opacity-60"
/> />
<div> <div>
<p class="font-sans text-xs font-bold tracking-widest text-brand-navy uppercase"> <p class="font-sans text-xs font-bold tracking-widest text-ink uppercase">
{m.enrich_needs_metadata_title()} {m.enrich_needs_metadata_title()}
</p> </p>
<p class="mt-0.5 font-serif text-sm text-brand-navy/70"> <p class="mt-0.5 font-serif text-sm text-ink-2">
{m.enrich_needs_metadata_count({ count: data.incompleteCount })} {m.enrich_needs_metadata_count({ count: data.incompleteCount })}
</p> </p>
</div> </div>
</div> </div>
<span <span
class="font-sans text-xs font-bold tracking-widest text-brand-navy uppercase transition-colors hover:text-brand-navy/70" class="font-sans text-xs font-bold tracking-widest text-ink uppercase transition-colors hover:text-ink-2"
> >
{m.enrich_needs_metadata_cta()} {m.enrich_needs_metadata_cta()}
</span> </span>

View File

@@ -142,32 +142,17 @@ $effect(() => {
<div <div
role="button" role="button"
tabindex="0" 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' ? 'border-primary bg-accent-bg py-10 text-primary'
: windowDragging : windowDragging
? 'border-primary/60 bg-accent-bg/50 py-10 text-primary/80' ? '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} ondragover={handleDragOver}
ondragleave={handleDragLeave} ondragleave={handleDragLeave}
ondrop={handleDrop} ondrop={handleDrop}
onclick={() => fileInput.click()} onclick={() => fileInput.click()}
onkeydown={(e) => e.key === 'Enter' && 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} {#if isUploading}
<div class="flex w-48 flex-col items-center gap-1"> <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"> <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> <span class="font-sans text-xs text-ink-3">{uploadProgress}%</span>
</div> </div>
{:else} {:else}
<span class="font-sans font-medium">{m.upload_drop_hint()}</span> <img
<span class="font-sans text-xs text-ink-3">{m.upload_accepted_types()}</span> 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} {/if}
</div> </div>

View File

@@ -6,6 +6,7 @@ import WhoWhenSection from '$lib/components/document/WhoWhenSection.svelte';
import DescriptionSection from '$lib/components/document/DescriptionSection.svelte'; import DescriptionSection from '$lib/components/document/DescriptionSection.svelte';
import TranscriptionSection from '$lib/components/document/TranscriptionSection.svelte'; import TranscriptionSection from '$lib/components/document/TranscriptionSection.svelte';
import FileSectionNew from './FileSectionNew.svelte'; import FileSectionNew from './FileSectionNew.svelte';
import { type FilenameParseResult } from '$lib/utils/filename';
let { data, form } = $props(); let { data, form } = $props();
@@ -14,6 +15,8 @@ let senderId = $state(untrack(() => data.initialSenderId));
let selectedReceivers: { id: string; firstName: string; lastName: string }[] = $state( let selectedReceivers: { id: string; firstName: string; lastName: string }[] = $state(
untrack(() => data.initialReceivers) untrack(() => data.initialReceivers)
); );
let parsedSuggestion = $state<FilenameParseResult>({});
</script> </script>
<div class="mx-auto max-w-4xl px-4 py-8"> <div class="mx-auto max-w-4xl px-4 py-8">
@@ -50,10 +53,16 @@ let selectedReceivers: { id: string; firstName: string; lastName: string }[] = $
bind:senderId={senderId} bind:senderId={senderId}
bind:selectedReceivers={selectedReceivers} bind:selectedReceivers={selectedReceivers}
initialSenderName={data.initialSenderName} initialSenderName={data.initialSenderName}
suggestedDateIso={parsedSuggestion.dateIso ?? ''}
suggestedSenderName={parsedSuggestion.personName ?? ''}
/>
<DescriptionSection
bind:tags={tags}
titleRequired={true}
suggestedTitle={parsedSuggestion.suggestedTitle ?? ''}
/> />
<DescriptionSection bind:tags={tags} titleRequired={true} />
<TranscriptionSection /> <TranscriptionSection />
<FileSectionNew /> <FileSectionNew onfileParsed={(r) => (parsedSuggestion = r)} />
<!-- Sticky Save Bar --> <!-- Sticky Save Bar -->
<div <div

View File

@@ -1,5 +1,17 @@
<script lang="ts"> <script lang="ts">
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import { parseFilename, type FilenameParseResult } from '$lib/utils/filename';
let {
onfileParsed
}: {
onfileParsed?: (result: FilenameParseResult) => void;
} = $props();
function handleFileChange(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0];
if (file) onfileParsed?.(parseFilename(file.name));
}
</script> </script>
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm"> <div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
@@ -15,6 +27,7 @@ import { m } from '$lib/paraglide/messages.js';
id="file-upload" id="file-upload"
type="file" type="file"
name="file" name="file"
onchange={handleFileChange}
class="block w-full cursor-pointer text-sm class="block w-full cursor-pointer text-sm
text-ink-2 file:mr-4 file:rounded text-ink-2 file:mr-4 file:rounded
file:border-0 file:bg-muted file:border-0 file:bg-muted

View File

@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import { untrack } from 'svelte'; import { onMount, untrack } from 'svelte';
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import DocumentViewer from '$lib/components/DocumentViewer.svelte'; import DocumentViewer from '$lib/components/DocumentViewer.svelte';
import WhoWhenSection from '$lib/components/document/WhoWhenSection.svelte'; import WhoWhenSection from '$lib/components/document/WhoWhenSection.svelte';
@@ -15,6 +15,11 @@ let fileUrl = $state('');
let fileError = $state(''); let fileError = $state('');
let isLoading = $state(false); let isLoading = $state(false);
let navHeight = $state(0);
onMount(() => {
navHeight = document.querySelector('header')?.getBoundingClientRect().height ?? 0;
});
// Dummy bindable state required by DocumentViewer // Dummy bindable state required by DocumentViewer
let annotateMode = $state(false); let annotateMode = $state(false);
let activeAnnotationId = $state<string | null>(null); let activeAnnotationId = $state<string | null>(null);
@@ -52,7 +57,7 @@ let selectedReceivers = $state(untrack(() => doc.receivers ?? []));
<title>{doc.title || doc.originalFilename || 'Dokument'} — Anreicherung</title> <title>{doc.title || doc.originalFilename || 'Dokument'} — Anreicherung</title>
</svelte:head> </svelte:head>
<div class="flex h-[calc(100vh-68px)] flex-col"> <div class="fixed inset-x-0 bottom-0 flex flex-col" style="top: {navHeight}px">
<!-- Top bar --> <!-- Top bar -->
<div class="flex items-center justify-between border-b border-line bg-surface px-6 py-3"> <div class="flex items-center justify-between border-b border-line bg-surface px-6 py-3">
<a <a

View File

@@ -7,7 +7,7 @@ import { m } from '$lib/paraglide/messages.js';
class="border-brand-sand flex flex-col items-center justify-center rounded-sm border bg-white py-20 text-center shadow-sm" class="border-brand-sand flex flex-col items-center justify-center rounded-sm border bg-white py-20 text-center shadow-sm"
> >
<img <img
src="/degruyter-icons/Simple/Large-32px/SVG/Action/Check/Check-Circle-LG.svg" src="/degruyter-icons/Simple/Large-32px/SVG/Action/Check/Check-Double-LG.svg"
alt="" alt=""
aria-hidden="true" aria-hidden="true"
class="mb-6 h-16 w-16" class="mb-6 h-16 w-16"