Compare commits

...

59 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
Marcel
0db68da00c refactor(persons): extract PersonCard, PersonMergePanel, CoCorrespondentsList, PersonDocumentList
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 / Backend Unit Tests (pull_request) Has been cancelled
CI / E2E Tests (pull_request) Has been cancelled
CI / Unit & Component Tests (pull_request) Has been cancelled
Split the 610-line person detail page into four focused co-located components:
- PersonCard: view/edit card with inline form (owns editMode)
- PersonMergePanel: merge target typeahead + two-step confirm (state reset via {#key})
- CoCorrespondentsList: frequency-ranked correspondent chips linking to conversations
- PersonDocumentList: reusable sorted/paginated document list (used for sent + received)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 12:32:01 +01:00
Marcel
e831de4f85 refactor(home): extract SearchFilterBar, DropZone, and DocumentList
Split the 580-line home page into three focused co-located components:
- SearchFilterBar: full-text search + collapsible advanced filters
- DropZone: drag-and-drop / click-to-upload with progress and messages
- DocumentList: document list with new-doc link and empty state

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 12:28:18 +01:00
Marcel
90e94b350a refactor(conversations): extract filter bar and timeline sub-components
Split conversations/+page.svelte (346 lines) into:
- ConversationFilterBar.svelte: person A/B typeaheads, swap button, date range, sort toggle
- ConversationTimeline.svelte: summary bar, chat bubbles, year dividers, new-doc link

Page drops from 346 → ~70 lines; navigation logic and filter state stay in the page.

Part of #75

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 12:22:38 +01:00
Marcel
1facf9cd60 refactor(documents): extract document form sub-components
Shared (src/lib/components/document/):
- WhoWhenSection.svelte: date/location/sender/receivers; owns date state
- DescriptionSection.svelte: title/archive-loc/tags/summary; owns tag binding
- TranscriptionSection.svelte: transcription textarea

Page-local:
- documents/[id]/edit/FileSectionEdit.svelte: current file + replace input
- documents/[id]/edit/SaveBar.svelte: sticky bar with two-step delete confirm
- documents/new/FileSectionNew.svelte: initial file upload input

documents/[id]/edit drops from 319 → ~40 lines.
documents/new drops from 254 → ~30 lines.
Date handling imported from \$lib/utils/date.

Part of #75

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 12:20:34 +01:00
Marcel
25014cce2d refactor(admin/users): extract user form sub-components
Shared (src/lib/components/user/):
- UserProfileSection.svelte: name/birth-date/email/contact fields
- UserGroupsSection.svelte: group checkboxes
- UserPasswordSection.svelte: new/confirm password fields

Page-local:
- admin/users/new/AccountSection.svelte: username + initial password

admin/users/[id] drops from 224 → ~35 lines.
admin/users/new drops from 191 → ~30 lines.
Date utilities imported from \$lib/utils/date.

Part of #75

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 12:17:55 +01:00
Marcel
6f71682454 refactor(profile): extract PersonalInfoForm and PasswordChangeForm
Split profile/+page.svelte (240 lines) into:
- PersonalInfoForm.svelte: name/birth-date/email/contact with own date state
- PasswordChangeForm.svelte: current/new/confirm password fields

Page drops from 240 → ~25 lines.
Date utilities now imported from \$lib/utils/date instead of duplicated inline.

Part of #75

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 12:14:20 +01:00
Marcel
af59ed4de4 refactor(admin): split admin page into tab sub-components
Split admin/+page.svelte (573 lines) into:
- UsersTab.svelte: user table with delete action
- TagsTab.svelte: tag list with inline rename and delete
- GroupsTab.svelte: groups table with inline edit + create form
- SystemTab.svelte: backfill buttons with own state

Page drops from 573 → ~40 lines.

Part of #75

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 12:13:06 +01:00
Marcel
d46764ef4f refactor(layout): extract AppNav and UserMenu sub-components
Split +layout.svelte (205 lines) into:
- AppNav.svelte: logo + nav links with active-state styling
- UserMenu.svelte: avatar button, dropdown, click-outside handler

Layout drops from 205 → 80 lines.

Part of #75

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 12:10:54 +01:00
Marcel
d40d4b21e1 refactor(utils): consolidate date utilities into \$lib/utils/date.ts
Move isoToGerman and germanToIso from utils.ts into utils/date.ts alongside
formatDate, and add handleGermanDateInput for the shared date field handler.
Make utils.ts a re-export shim so existing imports continue to work.

Closes part of #75

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 12:07:46 +01:00
Marcel
1ea84e4dc8 feat(upload): show progress bar in drop zone during upload
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 2m13s
CI / E2E Tests (push) Failing after 29m59s
Replaces fetch with XMLHttpRequest to get upload progress events.
The drop zone shows a filling progress bar and percentage while
files are uploading, then reverts to the normal hint when done.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 11:37:28 +01:00
Marcel
d078ad8224 feat(upload): warn on duplicate filename with link to existing document
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) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
CI / E2E Tests (pull_request) Has been cancelled
- storeDocument now returns StoreResult(document, isNew) to distinguish
  new uploads from updates to existing documents
- QuickUploadResult gains an `updated` list alongside `created`
- Frontend shows an amber warning with a "View document" link for duplicates
  instead of silently re-uploading and leaving the user confused

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 11:31:31 +01:00
Marcel
9d5c57b49b fix(dropzone): replace broken degruyter upload icon with inline SVG
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) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
CI / E2E Tests (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 11:14:37 +01:00
Marcel
0795e4099f fix(delete): add cascade deletes and fix SvelteKit named action conflict
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) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
CI / E2E Tests (pull_request) Has been cancelled
- Add V14 migration: ON DELETE CASCADE for document_tags and document_receivers
  so deleting a document removes its join-table rows automatically
- Rename default form action to 'update' in the edit page — SvelteKit forbids
  mixing a default action with named actions (was causing 500 on delete)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 11:12:21 +01:00
Marcel
1413058ae7 fix(documents): style delete button as red outlined button with inline trash 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) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
CI / E2E Tests (pull_request) Has been cancelled
Replace the subtle link-style delete trigger and broken degruyter icon
with a proper red outlined button and an inline SVG trash bin icon.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 11:00:06 +01:00
Marcel
91a29d501d feat(documents): add delete button to document edit form
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) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
CI / E2E Tests (pull_request) Has been cancelled
- DELETE /api/documents/{id} endpoint (204 No Content, WRITE_ALL required)
- DocumentService.deleteDocument() — throws 404 if not found, cascades
  via DB foreign keys (versions, annotations, comments all ON DELETE CASCADE)
- Delete form action in edit page server: redirects to / on success
- Two-step confirmation in the save bar: first click reveals inline
  "Wirklich löschen?" + confirm/cancel, avoiding native browser dialogs
- i18n key doc_delete_confirm added to de/en/es

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 10:52:43 +01:00
Marcel
963807ff05 fix(upload): structured error codes for quick-upload, fix duplicate filename crash
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) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
CI / E2E Tests (pull_request) Has been cancelled
- Switch errors from plain strings to { filename, code } objects so the
  frontend can show translated messages instead of raw exception text
- Add UNSUPPORTED_FILE_TYPE error code end-to-end (Java enum → errors.ts
  → de/en/es messages)
- Fix IncorrectResultSizeDataAccessException when a filename exists more
  than once in the DB: use findFirstByOriginalFilename instead of
  findByOriginalFilename in storeDocument()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 10:38:30 +01:00
Marcel
6a663cefe6 fix(search): sort document overview by createdAt DESC instead of documentDate ASC
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) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
CI / E2E Tests (pull_request) Has been cancelled
Newly uploaded documents (from bulk drop-zone or Excel import) have no
documentDate, so they were sinking to the bottom. Sorting by createdAt
DESC puts the most recently added documents first.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 10:32:46 +01:00
Marcel
db103ca1ab fix(test): add invalidateAll to $app/navigation mock in home page spec
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 2m24s
CI / Backend Unit Tests (pull_request) Successful in 2m11s
CI / E2E Tests (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 10:20:03 +01:00
Marcel
3ec680b812 feat(upload): expand drop zone when dragging file over browser window
Adds window-level dragenter/dragleave/drop listeners that detect when
the user drags any file into the browser. The drop zone expands from
py-3 to py-10 with a softened highlight, giving a clear visual cue
that dropping is possible anywhere on the page.

Uses a drag-counter to correctly handle the dragenter/dragleave storm
that fires as the pointer moves across child elements.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 10:19:34 +01:00
Marcel
50e3f948c7 fix(upload): use border-ink/20 and primary color for drop zone visibility
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) Failing after 1m59s
CI / Backend Unit Tests (pull_request) Successful in 2m16s
CI / E2E Tests (pull_request) Failing after 30m10s
In light mode, border-line-2 (#eeede8) was nearly invisible and
accent (#a1dcd8, mint) was too light for hover text. Switch to:
- border-ink/20 — navy-tinted dashed border, readable in both modes
- hover:border-primary / hover:text-primary — navy in light, mint in dark

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 10:09:17 +01:00
Marcel
bbfef9a22d feat(upload): add drag-and-drop bulk upload zone to home page
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m25s
CI / Backend Unit Tests (push) Successful in 2m26s
CI / E2E Tests (push) Has started running
CI / Unit & Component Tests (pull_request) Failing after 1m49s
CI / Backend Unit Tests (pull_request) Successful in 2m2s
CI / E2E Tests (pull_request) Failing after 30m19s
Adds a compact, unobtrusive drop zone between the search card and the
document list. Only visible to users with WRITE_ALL permission.

- Drag-and-drop or click-to-select multiple files at once
- Client-side MIME type validation with per-file error messages
- POSTs to /api/documents/quick-upload; refreshes list via invalidateAll()
- Inline feedback: success count + per-file errors
- i18n keys added to de/en/es message files

Closes #66 (frontend part)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 10:00:19 +01:00
Marcel
332b5b3c40 feat(upload): add POST /api/documents/quick-upload endpoint for bulk file upload
Adds a new multipart endpoint that accepts multiple files and creates one
document per file without requiring any form metadata. Each document gets
title = filename-without-extension and status = UPLOADED.

- Fix storeDocument() to strip the file extension from the document title
- Validate content type (PDF/JPEG/PNG/TIFF) server-side; unsupported files
  are skipped and returned as per-file errors in QuickUploadResult
- Tests cover 401/403 auth, success path, and unsupported file type

Closes #66 (backend part)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 09:59:59 +01:00
Marcel
29a71f4421 fix(login): remove py-6 padding from layout on auth pages to prevent scrolling
Some checks failed
CI / Unit & Component Tests (pull_request) Successful in 2m25s
CI / Backend Unit Tests (pull_request) Successful in 2m10s
CI / E2E Tests (pull_request) Failing after 29m35s
CI / Unit & Component Tests (push) Successful in 2m27s
CI / Backend Unit Tests (push) Successful in 2m17s
CI / E2E Tests (push) Failing after 30m42s
The global layout wrapped all pages in <main class="py-6">, adding 48px
of vertical padding. Combined with min-h-screen on the login page div,
the total height exceeded 100vh and made the page scrollable.

Auth pages (/login, /forgot-password, /reset-password) now get no
padding from the layout — the same path check already used to hide
the nav header.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 13:47:56 +01:00
Marcel
eade2aa48a fix(login): use bg-canvas instead of bg-surface for page background
The login page used bg-surface (white) as its outer background.
The global layout already has bg-canvas (sand), so using bg-surface
created a visible white layer with a mismatched color.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 13:47:56 +01:00
Marcel
bda3cdf9af fix(annotations): show annotations when entering annotate mode + restore documentFileHash
- PdfViewer: add $effect that forces showAnnotations=true when annotateMode
  becomes true, so hiding annotations before drawing no longer breaks drawing
- DocumentViewer: restore missing fileHash field on Doc type and pass
  documentFileHash to PdfViewer (lost when rebase dropped the merge commit)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 13:47:56 +01:00
Marcel
1765ffce01 fix(conversations): use text-primary-fg instead of text-white on sender bubbles
In dark mode --c-primary is mint (#a1dcd8), a light colour, making hardcoded
white text barely readable. Replacing text-white/text-blue-100 with
text-primary-fg (white in light, navy in dark) restores contrast in both modes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 13:47:56 +01:00
Marcel
399fa36f60 fix(e2e): reset admin password to configured value on every e2e backend startup
The password-reset E2E test changes the admin password mid-test and relies on a
UI step to restore it. If that step fails or the test is interrupted the account
is left with the wrong password, locking out all subsequent runs.

Fix: in DataInitializer.initE2EData (e2e profile only), always reset the admin
password to the value from ${app.admin.password} (default: admin123) on startup.
This is idempotent — it is safe to run even when the password is already correct.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 13:47:56 +01:00
Marcel
51a0eb76de fix(css): set form control bg/color to surface tokens in base layer
Browser-default form controls (input, textarea, select) render with a white
background that ignores CSS custom properties in dark mode. Adding bg-surface
and text-ink to the base layer ensures they theme correctly without touching
every component.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 13:47:56 +01:00
Marcel
162c58e8c5 fix(components): replace remaining unthemed gray classes with semantic tokens
Replace text-gray-*, bg-gray-*, border-gray-*, divide-gray-*, placeholder-gray-*,
focus:border-blue-*, focus:ring-blue-*, hover:bg-blue-*, and ring-brand-mint with
their semantic-token equivalents (text-ink, bg-muted, border-line, etc.) across
all pages and shared components so dark mode renders correctly everywhere.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 13:47:56 +01:00
Marcel
e4539ed0f0 refactor(components): replace all hardcoded colors with semantic tokens
Replaces bg-white, text-brand-navy, border-brand-sand, text-gray-*, bg-[#2A2A2A],
bg-brand-purple/15, hover:bg-brand-sand, etc. across all 35 .svelte files with
semantic token utilities (bg-surface, text-ink, border-line, bg-pdf-bg, bg-nav-active,
bg-muted, text-accent, bg-primary, ...).

Also adds CSS filter: invert(1) in layout.css for De Gruyter <img> icons in dark mode,
excluding icons that carry .invert already (to prevent double-inversion).

Closes #64
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 13:47:56 +01:00
Marcel
caba89dacc feat(nav): add ThemeToggle component with moon/sun icons and no-flash script
- Inline <script> in app.html applies saved localStorage theme before first
  paint to prevent flash of wrong theme
- ThemeToggle.svelte: moon/sun button, localStorage persistence, sets
  data-theme on <html>, defaults to system preference on first visit
- Placed in +layout.svelte between language selector and user menu
- E2E tests cover visibility, toggle, reverse toggle, persistence, and
  no-flash behaviour — all 6 passing

Refs #64
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 13:47:56 +01:00
Marcel
e83ba9b681 style(frontend): apply Prettier formatting to 26 pre-existing files
No logic changes — whitespace and indentation only. These were flagged
by the pre-commit hook when running lint after layout.css was modified.

Refs #64
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 13:47:56 +01:00
Marcel
93befbd8da refactor(css): remove colors/fonts from tailwind.config.js — layout.css is sole theme source
All color and font definitions live in layout.css via Tailwind 4 @theme.
Keeping only the content glob in the config file.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 13:47:56 +01:00
104 changed files with 5857 additions and 3279 deletions

View File

@@ -84,6 +84,14 @@ public class DataInitializer {
TagRepository tagRepo, TagRepository tagRepo,
PasswordEncoder passwordEncoder) { PasswordEncoder passwordEncoder) {
return args -> { return args -> {
// Always reset the admin password to the configured value so a failed password-reset
// test from a previous run can never leave the account locked out.
userRepository.findByUsername(adminUsername).ifPresent(admin -> {
admin.setPassword(passwordEncoder.encode(adminPassword));
userRepository.save(admin);
log.info("E2E seed: Admin-Passwort auf konfigurierten Wert zurückgesetzt.");
});
// Always ensure the read-only test user exists, even when seed data was already loaded. // Always ensure the read-only test user exists, even when seed data was already loaded.
if (userRepository.findByUsername("reader").isEmpty()) { if (userRepository.findByUsername("reader").isEmpty()) {
log.info("E2E seed: Erstelle 'reader'-Testbenutzer..."); log.info("E2E seed: Erstelle 'reader'-Testbenutzer...");

View File

@@ -2,7 +2,11 @@ package org.raddatz.familienarchiv.controller;
import java.io.IOException; import java.io.IOException;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID; import java.util.UUID;
@@ -23,6 +27,7 @@ import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.core.io.InputStreamResource; import org.springframework.core.io.InputStreamResource;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
@@ -103,6 +108,73 @@ public class DocumentController {
} }
} }
// --- DELETE ---
@DeleteMapping("/{id}")
@RequirePermission(Permission.WRITE_ALL)
public ResponseEntity<Void> deleteDocument(@PathVariable UUID id) {
documentService.deleteDocument(id);
return ResponseEntity.noContent().build();
}
// --- QUICK UPLOAD ---
private static final Set<String> ALLOWED_CONTENT_TYPES = Set.of(
"application/pdf", "image/jpeg", "image/png", "image/tiff");
public record UploadError(String filename, String code) {}
public record QuickUploadResult(List<Document> created, List<Document> updated, List<UploadError> errors) {}
@PostMapping(value = "/quick-upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@RequirePermission(Permission.WRITE_ALL)
public QuickUploadResult quickUpload(
@RequestPart(value = "files", required = false) List<MultipartFile> files) {
List<Document> created = new ArrayList<>();
List<Document> updated = new ArrayList<>();
List<UploadError> errors = new ArrayList<>();
if (files == null || files.isEmpty()) {
return new QuickUploadResult(created, updated, errors);
}
for (MultipartFile file : files) {
if (!ALLOWED_CONTENT_TYPES.contains(file.getContentType())) {
errors.add(new UploadError(file.getOriginalFilename(), "UNSUPPORTED_FILE_TYPE"));
continue;
}
try {
DocumentService.StoreResult result = documentService.storeDocument(file);
if (result.isNew()) {
created.add(result.document());
} else {
updated.add(result.document());
}
} catch (Exception e) {
errors.add(new UploadError(file.getOriginalFilename(), "FILE_UPLOAD_FAILED"));
log.warn("Quick upload failed for file {}: {}", file.getOriginalFilename(), e.getMessage());
}
}
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") @GetMapping("/search")
public ResponseEntity<List<Document>> search( public ResponseEntity<List<Document>> search(
@RequestParam(required = false) String q, @RequestParam(required = false) String q,

View File

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

View File

@@ -17,6 +17,8 @@ public enum ErrorCode {
FILE_NOT_FOUND, FILE_NOT_FOUND,
/** An error occurred while uploading a file to object storage. 500 */ /** An error occurred while uploading a file to object storage. 500 */
FILE_UPLOAD_FAILED, FILE_UPLOAD_FAILED,
/** The uploaded file's content type is not supported (PDF/JPEG/PNG/TIFF only). 400 */
UNSUPPORTED_FILE_TYPE,
// --- Users --- // --- Users ---
/** A user with the given ID or username does not exist. 404 */ /** A user with the given ID or username does not exist. 404 */

View File

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

View File

@@ -21,6 +21,9 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
// Wichtig für den Abgleich beim Excel-Import & Datei-Upload // Wichtig für den Abgleich beim Excel-Import & Datei-Upload
Optional<Document> findByOriginalFilename(String originalFilename); Optional<Document> findByOriginalFilename(String originalFilename);
// Wie oben, gibt aber nur das erste Ergebnis zurück — sicher wenn doppelte Dateinamen existieren
Optional<Document> findFirstByOriginalFilename(String originalFilename);
// Findet alle Dokumente mit einem bestimmten Status // Findet alle Dokumente mit einem bestimmten Status
// z.B. um alle offenen "PLACEHOLDER" zu finden // z.B. um alle offenen "PLACEHOLDER" zu finden
List<Document> findByStatus(DocumentStatus status); List<Document> findByStatus(DocumentStatus status);
@@ -39,6 +42,12 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
List<Document> findByFileHashIsNullAndFilePathIsNotNull(); List<Document> findByFileHashIsNullAndFilePathIsNotNull();
long countByMetadataCompleteFalse();
List<Document> findByMetadataCompleteFalse(Sort sort);
Optional<Document> findFirstByMetadataCompleteFalseAndIdNot(UUID id, Sort sort);
@Query("SELECT DISTINCT d FROM Document d " + @Query("SELECT DISTINCT d FROM Document d " +
"JOIN d.receivers r " + "JOIN d.receivers r " +
"WHERE " + "WHERE " +

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

@@ -42,27 +42,38 @@ public class DocumentService {
private final DocumentVersionService documentVersionService; private final DocumentVersionService documentVersionService;
private final AnnotationService annotationService; private final AnnotationService annotationService;
public record StoreResult(Document document, boolean isNew) {}
/** /**
* Lädt eine Datei hoch. * Lädt eine Datei hoch.
* - Prüft, ob ein Eintrag (aus Excel) schon existiert. * - Prüft, ob ein Eintrag (aus Excel) schon existiert.
* - Wenn JA: Aktualisiert Status und verknüpft Datei. * - Wenn JA: Aktualisiert Status und verknüpft Datei — isNew = false.
* - Wenn NEIN: Erstellt neuen Eintrag (wartet auf Metadaten). * - Wenn NEIN: Erstellt neuen Eintrag — isNew = true.
*/ */
@Transactional @Transactional
public Document storeDocument(MultipartFile file) throws IOException { public StoreResult storeDocument(MultipartFile file) throws IOException {
String originalFilename = file.getOriginalFilename(); String originalFilename = file.getOriginalFilename();
// 1. Check for existing record // 1. Check for existing record (findFirst to survive duplicate filenames in the DB)
Optional<Document> existingDoc = documentRepository.findByOriginalFilename(originalFilename); Optional<Document> existingDoc = documentRepository.findFirstByOriginalFilename(originalFilename);
boolean isNew = existingDoc.isEmpty();
Document document; Document document;
if (existingDoc.isPresent()) { if (existingDoc.isPresent()) {
document = existingDoc.get(); document = existingDoc.get();
} else { } 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() document = Document.builder()
.originalFilename(originalFilename) .originalFilename(originalFilename)
.title(originalFilename) .title(parsed != null ? parsed.title() : stripExtension(originalFilename))
.documentDate(parsed != null ? parsed.date() : null)
.sender(sender)
.status(DocumentStatus.UPLOADED) .status(DocumentStatus.UPLOADED)
.metadataComplete(false)
.build(); .build();
} }
@@ -77,7 +88,7 @@ public class DocumentService {
document.setStatus(DocumentStatus.UPLOADED); document.setStatus(DocumentStatus.UPLOADED);
} }
return documentRepository.save(document); return new StoreResult(documentRepository.save(document), isNew);
} }
@Transactional @Transactional
@@ -86,15 +97,31 @@ public class DocumentService {
? file.getOriginalFilename() ? file.getOriginalFilename()
: (dto.getTitle() != null ? dto.getTitle() : "Unbenanntes Dokument"); : (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() Document doc = Document.builder()
.originalFilename(filename) .originalFilename(filename)
.title(dto.getTitle()) .title(titleToUse)
.documentDate(dto.getDocumentDate()) .documentDate(dto.getDocumentDate())
.location(dto.getLocation()) .location(dto.getLocation())
.documentLocation(dto.getDocumentLocation()) .documentLocation(dto.getDocumentLocation())
.transcription(dto.getTranscription()) .transcription(dto.getTranscription())
.summary(dto.getSummary()) .summary(dto.getSummary())
.status(DocumentStatus.PLACEHOLDER) .status(DocumentStatus.PLACEHOLDER)
.metadataComplete(metadataComplete)
.build(); .build();
doc = documentRepository.save(doc); doc = documentRepository.save(doc);
@@ -173,6 +200,11 @@ public class DocumentService {
doc.getReceivers().clear(); // Alle entfernen 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) // 4. Datei austauschen (nur wenn eine neue ausgewählt wurde)
if (newFile != null && !newFile.isEmpty()) { if (newFile != null && !newFile.isEmpty()) {
FileService.UploadResult upload = fileService.uploadFile(newFile, newFile.getOriginalFilename()); FileService.UploadResult upload = fileService.uploadFile(newFile, newFile.getOriginalFilename());
@@ -234,8 +266,8 @@ public class DocumentService {
.and(hasReceiver(receiver)) .and(hasReceiver(receiver))
.and(hasTags(tags)); .and(hasTags(tags));
// Immer sortiert nach Datum // Neueste zuerst (nach Erstellungsdatum)
return documentRepository.findAll(spec, Sort.by(Sort.Direction.ASC, "documentDate")); return documentRepository.findAll(spec, Sort.by(Sort.Direction.DESC, "createdAt"));
} }
// 2. SPEZIALITÄT: Der Schriftwechsel // 2. SPEZIALITÄT: Der Schriftwechsel
@@ -277,6 +309,27 @@ public class DocumentService {
return documentRepository.findConversation(senderId, receiverId, dateFrom, dateTo, sort); 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)) {
throw DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id);
}
documentRepository.deleteById(id);
}
@Transactional @Transactional
public void deleteTagCascading(UUID tagId) { public void deleteTagCascading(UUID tagId) {
documentRepository.findByTags_Id(tagId).forEach(doc -> { documentRepository.findByTags_Id(tagId).forEach(doc -> {
@@ -307,6 +360,87 @@ public class DocumentService {
// ─── private helpers ────────────────────────────────────────────────────── // ─── private helpers ──────────────────────────────────────────────────────
private static String stripExtension(String filename) {
if (filename == null) return null;
int dot = filename.lastIndexOf('.');
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

@@ -312,6 +312,9 @@ public class MassImportService {
.originalFilename(originalFilename) .originalFilename(originalFilename)
.build()); .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.setTitle(buildTitle(index, date, location));
doc.setFilePath(s3Key); doc.setFilePath(s3Key);
doc.setContentType(contentType); doc.setContentType(contentType);
@@ -325,6 +328,7 @@ public class MassImportService {
doc.setSender(sender); doc.setSender(sender);
doc.getReceivers().addAll(receivers); doc.getReceivers().addAll(receivers);
if (tag != null) doc.getTags().add(tag); if (tag != null) doc.getTags().add(tag);
doc.setMetadataComplete(metadataComplete);
documentRepository.save(doc); documentRepository.save(doc);
log.info("Importiert{}: {}", file.isEmpty() ? " (nur Metadaten)" : "", originalFilename); log.info("Importiert{}: {}", file.isEmpty() ? " (nur Metadaten)" : "", originalFilename);

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

@@ -0,0 +1,12 @@
-- Add ON DELETE CASCADE to document_tags and document_receivers so that
-- deleting a document automatically removes its tag and receiver associations.
ALTER TABLE public.document_tags
DROP CONSTRAINT fkc99c5qjulwx9gru07yrhicgd2,
ADD CONSTRAINT fkc99c5qjulwx9gru07yrhicgd2
FOREIGN KEY (document_id) REFERENCES public.documents(id) ON DELETE CASCADE;
ALTER TABLE public.document_receivers
DROP CONSTRAINT fks7t60twjgfmpeqcuc3g0fvjpm,
ADD CONSTRAINT fks7t60twjgfmpeqcuc3g0fvjpm
FOREIGN KEY (document_id) REFERENCES public.documents(id) ON DELETE CASCADE;

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.time.LocalDateTime;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Optional;
import java.util.UUID; import java.util.UUID;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
@@ -121,6 +122,169 @@ class DocumentControllerTest {
.andExpect(status().isOk()); .andExpect(status().isOk());
} }
// ─── DELETE /api/documents/{id} ──────────────────────────────────────────
@Test
void deleteDocument_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders
.delete("/api/documents/" + UUID.randomUUID()))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser
void deleteDocument_returns403_whenMissingWritePermission() throws Exception {
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders
.delete("/api/documents/" + UUID.randomUUID()))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void deleteDocument_returns204_whenHasWritePermission() throws Exception {
UUID id = UUID.randomUUID();
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders
.delete("/api/documents/" + id))
.andExpect(status().isNoContent());
}
// ─── POST /api/documents/quick-upload ────────────────────────────────────
@Test
void quickUpload_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(multipart("/api/documents/quick-upload"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser
void quickUpload_returns403_whenMissingWritePermission() throws Exception {
mockMvc.perform(multipart("/api/documents/quick-upload"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void quickUpload_returns200_withValidPdfFile() throws Exception {
Document doc = Document.builder()
.id(UUID.randomUUID()).title("scan001").originalFilename("scan001.pdf").build();
when(documentService.storeDocument(any()))
.thenReturn(new DocumentService.StoreResult(doc, true));
org.springframework.mock.web.MockMultipartFile file =
new org.springframework.mock.web.MockMultipartFile("files", "scan001.pdf", "application/pdf", new byte[]{1});
mockMvc.perform(multipart("/api/documents/quick-upload").file(file))
.andExpect(status().isOk())
.andExpect(jsonPath("$.created[0].title").value("scan001"))
.andExpect(jsonPath("$.updated").isEmpty())
.andExpect(jsonPath("$.errors").isEmpty());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void quickUpload_placesDocumentInUpdated_whenFilenameAlreadyExists() throws Exception {
Document existing = Document.builder()
.id(UUID.randomUUID()).title("Alter Brief").originalFilename("scan001.pdf").build();
when(documentService.storeDocument(any()))
.thenReturn(new DocumentService.StoreResult(existing, false));
org.springframework.mock.web.MockMultipartFile file =
new org.springframework.mock.web.MockMultipartFile("files", "scan001.pdf", "application/pdf", new byte[]{1});
mockMvc.perform(multipart("/api/documents/quick-upload").file(file))
.andExpect(status().isOk())
.andExpect(jsonPath("$.created").isEmpty())
.andExpect(jsonPath("$.updated[0].title").value("Alter Brief"))
.andExpect(jsonPath("$.errors").isEmpty());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void quickUpload_skipsUnsupportedFileType_andReturnsError() throws Exception {
org.springframework.mock.web.MockMultipartFile file =
new org.springframework.mock.web.MockMultipartFile("files", "report.docx",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document", new byte[]{1});
mockMvc.perform(multipart("/api/documents/quick-upload").file(file))
.andExpect(status().isOk())
.andExpect(jsonPath("$.created").isEmpty())
.andExpect(jsonPath("$.errors[0].filename").value("report.docx"))
.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 ──────────────────────────────────── // ─── GET /api/documents/{id}/versions ────────────────────────────────────
@Test @Test

View File

@@ -2,6 +2,7 @@ package org.raddatz.familienarchiv.service;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks; import org.mockito.InjectMocks;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension; 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.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.mock.web.MockMultipartFile;
import java.time.LocalDate;
import java.util.HashSet; import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
@@ -35,6 +40,29 @@ class DocumentServiceTest {
@Mock AnnotationService annotationService; @Mock AnnotationService annotationService;
@InjectMocks DocumentService documentService; @InjectMocks DocumentService documentService;
// ─── deleteDocument ───────────────────────────────────────────────────────
@Test
void deleteDocument_deletesById_whenExists() {
UUID id = UUID.randomUUID();
when(documentRepository.existsById(id)).thenReturn(true);
documentService.deleteDocument(id);
verify(documentRepository).deleteById(id);
}
@Test
void deleteDocument_throwsNotFound_whenMissing() {
UUID id = UUID.randomUUID();
when(documentRepository.existsById(id)).thenReturn(false);
assertThatThrownBy(() -> documentService.deleteDocument(id))
.isInstanceOf(DomainException.class)
.hasMessageContaining(id.toString());
verify(documentRepository, never()).deleteById(any());
}
// ─── getDocumentById ────────────────────────────────────────────────────── // ─── getDocumentById ──────────────────────────────────────────────────────
@Test @Test
@@ -212,6 +240,75 @@ class DocumentServiceTest {
verify(documentVersionService).recordVersion(any(Document.class)); verify(documentVersionService).recordVersion(any(Document.class));
} }
// ─── storeDocument ───────────────────────────────────────────────────────
@Test
void storeDocument_setsTitle_withoutFileExtension_forNewDocument() throws Exception {
org.springframework.mock.web.MockMultipartFile file =
new org.springframework.mock.web.MockMultipartFile("file", "scan001.pdf", "application/pdf", new byte[]{1});
FileService.UploadResult uploadResult = new FileService.UploadResult("documents/uuid_scan001.pdf", "abc123");
Document saved = Document.builder().id(UUID.randomUUID()).title("scan001").originalFilename("scan001.pdf").build();
when(documentRepository.findFirstByOriginalFilename("scan001.pdf")).thenReturn(Optional.empty());
when(documentRepository.save(any())).thenReturn(saved);
when(fileService.uploadFile(any(), any())).thenReturn(uploadResult);
org.mockito.ArgumentCaptor<Document> captor = org.mockito.ArgumentCaptor.forClass(Document.class);
documentService.storeDocument(file);
verify(documentRepository).save(captor.capture());
assertThat(captor.getValue().getTitle()).isEqualTo("scan001");
}
@Test
void storeDocument_preservesExistingTitle_whenPlaceholderAlreadyExists() throws Exception {
org.springframework.mock.web.MockMultipartFile file =
new org.springframework.mock.web.MockMultipartFile("file", "scan001.pdf", "application/pdf", new byte[]{1});
FileService.UploadResult uploadResult = new FileService.UploadResult("documents/uuid_scan001.pdf", "abc123");
Document placeholder = Document.builder()
.id(UUID.randomUUID()).title("Brief an Oma").originalFilename("scan001.pdf")
.status(DocumentStatus.PLACEHOLDER).build();
when(documentRepository.findFirstByOriginalFilename("scan001.pdf")).thenReturn(Optional.of(placeholder));
when(documentRepository.save(any())).thenReturn(placeholder);
when(fileService.uploadFile(any(), any())).thenReturn(uploadResult);
documentService.storeDocument(file);
assertThat(placeholder.getTitle()).isEqualTo("Brief an Oma");
}
@Test
void storeDocument_marksResultAsNew_whenNoExistingDocument() throws Exception {
org.springframework.mock.web.MockMultipartFile file =
new org.springframework.mock.web.MockMultipartFile("file", "new.pdf", "application/pdf", new byte[]{1});
Document saved = Document.builder().id(UUID.randomUUID()).originalFilename("new.pdf").build();
when(documentRepository.findFirstByOriginalFilename("new.pdf")).thenReturn(Optional.empty());
when(documentRepository.save(any())).thenReturn(saved);
when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("documents/new.pdf", "hash"));
DocumentService.StoreResult result = documentService.storeDocument(file);
assertThat(result.isNew()).isTrue();
}
@Test
void storeDocument_marksResultAsNotNew_whenDocumentWithSameFilenameExists() throws Exception {
org.springframework.mock.web.MockMultipartFile file =
new org.springframework.mock.web.MockMultipartFile("file", "existing.pdf", "application/pdf", new byte[]{1});
Document existing = Document.builder().id(UUID.randomUUID()).originalFilename("existing.pdf")
.status(DocumentStatus.UPLOADED).build();
when(documentRepository.findFirstByOriginalFilename("existing.pdf")).thenReturn(Optional.of(existing));
when(documentRepository.save(any())).thenReturn(existing);
when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("documents/existing.pdf", "hash"));
DocumentService.StoreResult result = documentService.storeDocument(file);
assertThat(result.isNew()).isFalse();
}
// ─── backfillFileHashes ─────────────────────────────────────────────────── // ─── backfillFileHashes ───────────────────────────────────────────────────
@Test @Test
@@ -252,6 +349,265 @@ class DocumentServiceTest {
verify(annotationService).backfillAnnotationFileHashForDocument(eq(docId), any()); 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 @Test
void backfillFileHashes_returnsCountOfUpdatedDocuments() throws Exception { void backfillFileHashes_returnsCountOfUpdatedDocuments() throws Exception {
UUID id1 = UUID.randomUUID(); UUID id1 = UUID.randomUUID();
@@ -266,4 +622,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

@@ -180,19 +180,19 @@ test.describe('Admin — tag management', () => {
// Wait for the tags list to render after the tab switch // Wait for the tags list to render after the tab switch
await page.waitForSelector('ul > li'); await page.waitForSelector('ul > li');
// Hover over the "Familie" row to reveal the opacity-0 action buttons // Hover over the "Fest" row to reveal the opacity-0 action buttons
const familieRow = page const festRow = page
.locator('ul > li') .locator('ul > li')
.filter({ has: page.locator('span', { hasText: /^Familie$/ }) }); .filter({ has: page.locator('span', { hasText: /^Fest$/ }) });
await familieRow.hover(); await festRow.hover();
await familieRow.getByRole('button', { name: 'Schlagwort bearbeiten' }).click(); await festRow.getByRole('button', { name: 'Schlagwort bearbeiten' }).click();
// After clicking edit, {#if editingTagId} replaces the span with a form — // After clicking edit, {#if editingTagId} replaces the span with a form —
// the familieRow filter no longer matches, so we find the input directly. // the festRow filter no longer matches, so we find the input directly.
await page.locator('input[name="name"]').fill('Familie (E2E)'); await page.locator('input[name="name"]').fill('Fest (E2E)');
await page.getByRole('button', { name: 'Speichern' }).click(); 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' }); await page.screenshot({ path: 'test-results/e2e/admin-tag-renamed.png' });
}); });
@@ -205,14 +205,14 @@ test.describe('Admin — tag management', () => {
const renamedRow = page const renamedRow = page
.locator('ul > li') .locator('ul > li')
.filter({ has: page.locator('span', { hasText: /^Familie \(E2E\)$/ }) }); .filter({ has: page.locator('span', { hasText: /^Fest \(E2E\)$/ }) });
await renamedRow.hover(); await renamedRow.hover();
await renamedRow.getByRole('button', { name: 'Schlagwort bearbeiten' }).click(); 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 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' }); 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 }) => { test('navigation bar shows active state for Dokumente', async ({ page }) => {
const navLink = page.getByRole('navigation').getByRole('link', { name: 'Dokumente' }); 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 }) => { test('text search filters the document list', async ({ page }) => {
@@ -77,12 +77,49 @@ test.describe('Document detail', () => {
}); });
test.describe('New document', () => { 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.goto('/documents/new');
await page.waitForSelector('[data-hydrated]');
await expect(page.getByRole('heading', { name: /Neues Dokument/i })).toBeVisible(); 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' }); 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', () => { test.describe('Document creation', () => {
@@ -91,12 +128,27 @@ test.describe('Document creation', () => {
await page.waitForSelector('[data-hydrated]'); await page.waitForSelector('[data-hydrated]');
await page.getByLabel('Titel').fill('E2E Testbrief'); 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).toHaveURL(/\/documents\/[^/]+$/);
await expect(page.getByRole('heading', { name: 'E2E Testbrief' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'E2E Testbrief' })).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/document-create.png' }); 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', () => { test.describe('Document editing', () => {
@@ -112,10 +164,10 @@ test.describe('Document editing', () => {
await page.waitForSelector('[data-hydrated]'); await page.waitForSelector('[data-hydrated]');
await page.getByLabel('Titel').fill('E2E Testbrief (überarbeitet)'); 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).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' }); 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.waitForSelector('[data-hydrated]');
await page.locator('canvas').first().waitFor({ state: 'visible', timeout: 20000 }); 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({ await expect(page.locator('[data-testid^="annotation-"]').first()).toBeVisible({
timeout: 8000 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 // Enable annotate mode to show delete buttons
await page.getByRole('button', { name: /^annotieren$/i }).click(); await page.getByRole('button', { name: /^annotieren$/i }).click();
@@ -339,7 +393,7 @@ test.describe('PDF annotations — admin', () => {
await expect(deleteBtn).toBeVisible({ timeout: 8000 }); await expect(deleteBtn).toBeVisible({ timeout: 8000 });
await deleteBtn.click(); await deleteBtn.click();
await expect(page.locator('[data-testid^="annotation-"]')).toHaveCount(0, { await expect(page.locator('[data-testid^="annotation-"]')).toHaveCount(countBefore - 1, {
timeout: 8000 timeout: 8000
}); });
@@ -407,7 +461,12 @@ test.describe('PDF annotations — file hash versioning', () => {
await page.waitForSelector('[data-hydrated]'); await page.waitForSelector('[data-hydrated]');
await page.locator('canvas').first().waitFor({ state: 'visible', timeout: 20000 }); 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({ await expect(page.locator('[data-testid="annotation-outdated-notice"]')).toBeVisible({
timeout: 5000 timeout: 5000
}); });

View File

@@ -25,7 +25,7 @@ test.describe('Document history panel', () => {
await page.goto('/documents/new'); await page.goto('/documents/new');
await page.waitForSelector('[data-hydrated]'); await page.waitForSelector('[data-hydrated]');
await page.getByLabel('Titel').fill('E2E History Test Dokument'); 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) // Wait for redirect to the new document's UUID-based URL (not /documents/new)
await page.waitForURL(/\/documents\/[0-9a-f-]{36}$/); await page.waitForURL(/\/documents\/[0-9a-f-]{36}$/);
docPath = new URL(page.url()).pathname; docPath = new URL(page.url()).pathname;
@@ -34,7 +34,7 @@ test.describe('Document history panel', () => {
await page.goto(`${docPath}/edit`); await page.goto(`${docPath}/edit`);
await page.waitForSelector('[data-hydrated]'); await page.waitForSelector('[data-hydrated]');
await page.getByLabel('Titel').fill('E2E History Test Dokument (bearbeitet)'); 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 page.waitForURL(/\/documents\/[0-9a-f-]{36}$/);
await context.close(); await context.close();

View File

@@ -212,7 +212,7 @@ test.describe('Conversations', () => {
test('nav link is active on the conversations page', async ({ page }) => { test('nav link is active on the conversations page', async ({ page }) => {
await page.goto('/conversations'); await page.goto('/conversations');
const navLink = page.getByRole('link', { name: 'Konversationen' }); 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 }) => { test('sort toggle changes the button label', async ({ page }) => {

View File

@@ -0,0 +1,73 @@
import { test, expect } from '@playwright/test';
test.describe('Theme toggle', () => {
test.beforeEach(async ({ page }) => {
// Clear any saved theme preference before each test
await page.goto('/');
await page.evaluate(() => localStorage.removeItem('theme'));
});
test('toggle button is visible in the header', async ({ page }) => {
await page.goto('/');
await expect(
page.getByRole('banner').getByRole('button', { name: /dark mode|light mode/i })
).toBeVisible();
});
test('clicking the toggle switches to dark mode', async ({ page }) => {
await page.goto('/');
await page.waitForSelector('[data-hydrated]');
const html = page.locator('html');
await expect(html).not.toHaveAttribute('data-theme', 'dark');
await page
.getByRole('banner')
.getByRole('button', { name: /dark mode/i })
.click();
await expect(html).toHaveAttribute('data-theme', 'dark');
});
test('clicking the toggle again switches back to light mode', async ({ page }) => {
await page.goto('/');
await page.waitForSelector('[data-hydrated]');
await page
.getByRole('banner')
.getByRole('button', { name: /dark mode/i })
.click();
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
await page
.getByRole('banner')
.getByRole('button', { name: /light mode/i })
.click();
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light');
});
test('theme persists after page reload', async ({ page }) => {
await page.goto('/');
await page.waitForSelector('[data-hydrated]');
await page
.getByRole('banner')
.getByRole('button', { name: /dark mode/i })
.click();
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
await page.reload();
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
});
test('saved theme is applied before first paint (no flash)', async ({ page }) => {
// Set dark theme in localStorage before navigating
await page.goto('/');
await page.evaluate(() => localStorage.setItem('theme', 'dark'));
// Intercept the initial HTML to verify data-theme is set immediately
await page.goto('/');
const theme = await page.evaluate(() => document.documentElement.getAttribute('data-theme'));
expect(theme).toBe('dark');
});
});

View File

@@ -7,6 +7,7 @@
"error_document_no_file": "Diesem Dokument ist noch keine Datei zugeordnet.", "error_document_no_file": "Diesem Dokument ist noch keine Datei zugeordnet.",
"error_file_not_found": "Die Datei konnte im Speicher nicht gefunden werden.", "error_file_not_found": "Die Datei konnte im Speicher nicht gefunden werden.",
"error_file_upload_failed": "Die Datei konnte nicht hochgeladen werden.", "error_file_upload_failed": "Die Datei konnte nicht hochgeladen werden.",
"error_unsupported_file_type": "Dieses Dateiformat wird nicht unterstützt.",
"error_user_not_found": "Der Benutzer wurde nicht gefunden.", "error_user_not_found": "Der Benutzer wurde nicht gefunden.",
"error_import_already_running": "Ein Import läuft bereits. Bitte warten Sie, bis dieser abgeschlossen ist.", "error_import_already_running": "Ein Import läuft bereits. Bitte warten Sie, bis dieser abgeschlossen ist.",
"error_unauthorized": "Sie sind nicht angemeldet.", "error_unauthorized": "Sie sind nicht angemeldet.",
@@ -23,6 +24,7 @@
"btn_edit": "Bearbeiten", "btn_edit": "Bearbeiten",
"btn_create": "Erstellen", "btn_create": "Erstellen",
"btn_delete": "Löschen", "btn_delete": "Löschen",
"doc_delete_confirm": "Dokument wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.",
"btn_back_to_overview": "Zurück zur Übersicht", "btn_back_to_overview": "Zurück zur Übersicht",
"btn_back": "Zurück", "btn_back": "Zurück",
"btn_back_to_document": "Zurück zum Dokument", "btn_back_to_document": "Zurück zum Dokument",
@@ -37,7 +39,7 @@
"form_placeholder_location": "z.B. Berlin, Wien…", "form_placeholder_location": "z.B. Berlin, Wien…",
"form_label_sender": "Absender", "form_label_sender": "Absender",
"form_label_receivers": "Empfänger", "form_label_receivers": "Empfänger",
"form_label_title": "Titel *", "form_label_title": "Titel",
"form_label_tags": "Schlagworte", "form_label_tags": "Schlagworte",
"form_label_content": "Inhalt", "form_label_content": "Inhalt",
"form_placeholder_content": "Kurze Beschreibung des Inhalts…", "form_placeholder_content": "Kurze Beschreibung des Inhalts…",
@@ -73,6 +75,7 @@
"doc_file_replace_label": "Neue Datei hochladen", "doc_file_replace_label": "Neue Datei hochladen",
"doc_file_replace_note": "(ersetzt die aktuelle Datei)", "doc_file_replace_note": "(ersetzt die aktuelle Datei)",
"doc_current_file_label": "Aktuelle Datei:", "doc_current_file_label": "Aktuelle Datei:",
"doc_more_details": "Weitere Details",
"doc_new_heading": "Neues Dokument", "doc_new_heading": "Neues Dokument",
"doc_edit_heading": "Bearbeiten", "doc_edit_heading": "Bearbeiten",
"doc_section_details": "Details", "doc_section_details": "Details",
@@ -265,5 +268,31 @@
"doc_panel_annotation_thread_title": "Annotation", "doc_panel_annotation_thread_title": "Annotation",
"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": "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}",
"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

@@ -7,6 +7,7 @@
"error_document_no_file": "No file is associated with this document.", "error_document_no_file": "No file is associated with this document.",
"error_file_not_found": "The file could not be found in storage.", "error_file_not_found": "The file could not be found in storage.",
"error_file_upload_failed": "The file could not be uploaded.", "error_file_upload_failed": "The file could not be uploaded.",
"error_unsupported_file_type": "This file format is not supported.",
"error_user_not_found": "User not found.", "error_user_not_found": "User not found.",
"error_import_already_running": "An import is already running. Please wait for it to finish.", "error_import_already_running": "An import is already running. Please wait for it to finish.",
"error_unauthorized": "You are not logged in.", "error_unauthorized": "You are not logged in.",
@@ -23,6 +24,7 @@
"btn_edit": "Edit", "btn_edit": "Edit",
"btn_create": "Create", "btn_create": "Create",
"btn_delete": "Delete", "btn_delete": "Delete",
"doc_delete_confirm": "Really delete this document? This action cannot be undone.",
"btn_back_to_overview": "Back to overview", "btn_back_to_overview": "Back to overview",
"btn_back": "Back", "btn_back": "Back",
"btn_back_to_document": "Back to document", "btn_back_to_document": "Back to document",
@@ -37,7 +39,7 @@
"form_placeholder_location": "e.g. Berlin, Vienna…", "form_placeholder_location": "e.g. Berlin, Vienna…",
"form_label_sender": "Sender", "form_label_sender": "Sender",
"form_label_receivers": "Recipients", "form_label_receivers": "Recipients",
"form_label_title": "Title *", "form_label_title": "Title",
"form_label_tags": "Tags", "form_label_tags": "Tags",
"form_label_content": "Content", "form_label_content": "Content",
"form_placeholder_content": "Brief description of the content…", "form_placeholder_content": "Brief description of the content…",
@@ -73,6 +75,7 @@
"doc_file_replace_label": "Upload new file", "doc_file_replace_label": "Upload new file",
"doc_file_replace_note": "(replaces the current file)", "doc_file_replace_note": "(replaces the current file)",
"doc_current_file_label": "Current file:", "doc_current_file_label": "Current file:",
"doc_more_details": "More details",
"doc_new_heading": "New document", "doc_new_heading": "New document",
"doc_edit_heading": "Edit", "doc_edit_heading": "Edit",
"doc_section_details": "Details", "doc_section_details": "Details",
@@ -265,5 +268,31 @@
"doc_panel_annotation_thread_title": "Annotation", "doc_panel_annotation_thread_title": "Annotation",
"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 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}",
"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

@@ -7,6 +7,7 @@
"error_document_no_file": "No hay ningún archivo asociado a este documento.", "error_document_no_file": "No hay ningún archivo asociado a este documento.",
"error_file_not_found": "El archivo no pudo encontrarse en el almacenamiento.", "error_file_not_found": "El archivo no pudo encontrarse en el almacenamiento.",
"error_file_upload_failed": "No se pudo subir el archivo.", "error_file_upload_failed": "No se pudo subir el archivo.",
"error_unsupported_file_type": "Este formato de archivo no está admitido.",
"error_user_not_found": "Usuario no encontrado.", "error_user_not_found": "Usuario no encontrado.",
"error_import_already_running": "Ya hay una importación en curso. Por favor, espere a que finalice.", "error_import_already_running": "Ya hay una importación en curso. Por favor, espere a que finalice.",
"error_unauthorized": "No ha iniciado sesión.", "error_unauthorized": "No ha iniciado sesión.",
@@ -23,6 +24,7 @@
"btn_edit": "Editar", "btn_edit": "Editar",
"btn_create": "Crear", "btn_create": "Crear",
"btn_delete": "Eliminar", "btn_delete": "Eliminar",
"doc_delete_confirm": "¿Realmente eliminar este documento? Esta acción no se puede deshacer.",
"btn_back_to_overview": "Volver al resumen", "btn_back_to_overview": "Volver al resumen",
"btn_back": "Volver", "btn_back": "Volver",
"btn_back_to_document": "Volver al documento", "btn_back_to_document": "Volver al documento",
@@ -37,7 +39,7 @@
"form_placeholder_location": "p.ej. Berlín, Viena…", "form_placeholder_location": "p.ej. Berlín, Viena…",
"form_label_sender": "Remitente", "form_label_sender": "Remitente",
"form_label_receivers": "Destinatarios", "form_label_receivers": "Destinatarios",
"form_label_title": "Título *", "form_label_title": "Título",
"form_label_tags": "Etiquetas", "form_label_tags": "Etiquetas",
"form_label_content": "Contenido", "form_label_content": "Contenido",
"form_placeholder_content": "Breve descripción del contenido…", "form_placeholder_content": "Breve descripción del contenido…",
@@ -73,6 +75,7 @@
"doc_file_replace_label": "Subir nuevo archivo", "doc_file_replace_label": "Subir nuevo archivo",
"doc_file_replace_note": "(reemplaza el archivo actual)", "doc_file_replace_note": "(reemplaza el archivo actual)",
"doc_current_file_label": "Archivo actual:", "doc_current_file_label": "Archivo actual:",
"doc_more_details": "Más detalles",
"doc_new_heading": "Nuevo documento", "doc_new_heading": "Nuevo documento",
"doc_edit_heading": "Editar", "doc_edit_heading": "Editar",
"doc_section_details": "Detalles", "doc_section_details": "Detalles",
@@ -265,5 +268,31 @@
"doc_panel_annotation_thread_title": "Anotación", "doc_panel_annotation_thread_title": "Anotación",
"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": "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}",
"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

@@ -3,6 +3,12 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<script>
(function () {
var t = localStorage.getItem('theme');
if (t === 'dark' || t === 'light') document.documentElement.setAttribute('data-theme', t);
})();
</script>
%sveltekit.head% %sveltekit.head%
</head> </head>
<body data-sveltekit-preload-data="hover"> <body data-sveltekit-preload-data="hover">

View File

@@ -25,16 +25,16 @@ let {
<!-- Desktop / tablet panel (≥ sm): absolute overlay on the right side --> <!-- Desktop / tablet panel (≥ sm): absolute overlay on the right side -->
<div <div
class="absolute top-0 right-0 z-50 hidden h-full w-80 flex-col border-l border-brand-sand bg-white shadow-2xl sm:flex" class="absolute top-0 right-0 z-50 hidden h-full w-80 flex-col border-l border-line bg-surface shadow-2xl sm:flex"
> >
<div class="flex shrink-0 items-center justify-between border-b border-brand-sand px-4 py-3"> <div class="flex shrink-0 items-center justify-between border-b border-line px-4 py-3">
<h3 class="font-sans text-xs font-bold tracking-widest text-brand-navy uppercase"> <h3 class="font-sans text-xs font-bold tracking-widest text-ink uppercase">
{m.comment_panel_title()} {m.comment_panel_title()}
</h3> </h3>
<button <button
onclick={onClose} onclick={onClose}
aria-label={m.comment_panel_close()} aria-label={m.comment_panel_close()}
class="rounded p-1 text-gray-400 transition-colors hover:bg-brand-sand/50 hover:text-brand-navy" class="rounded p-1 text-ink-3 transition-colors hover:bg-muted hover:text-ink"
> >
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /> <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
@@ -60,15 +60,15 @@ let {
<div class="flex-1 bg-black/40" onclick={onClose} role="presentation"></div> <div class="flex-1 bg-black/40" onclick={onClose} role="presentation"></div>
<!-- Slide-up panel --> <!-- Slide-up panel -->
<div class="flex max-h-[80vh] flex-col rounded-t-2xl bg-white shadow-2xl"> <div class="flex max-h-[80vh] flex-col rounded-t-2xl bg-surface shadow-2xl">
<div class="flex shrink-0 items-center justify-between border-b border-brand-sand px-4 py-3"> <div class="flex shrink-0 items-center justify-between border-b border-line px-4 py-3">
<h3 class="font-sans text-xs font-bold tracking-widest text-brand-navy uppercase"> <h3 class="font-sans text-xs font-bold tracking-widest text-ink uppercase">
{m.comment_panel_title()} {m.comment_panel_title()}
</h3> </h3>
<button <button
onclick={onClose} onclick={onClose}
aria-label={m.comment_panel_close()} aria-label={m.comment_panel_close()}
class="rounded p-1 text-gray-400 transition-colors hover:bg-brand-sand/50 hover:text-brand-navy" class="rounded p-1 text-ink-3 transition-colors hover:bg-muted hover:text-ink"
> >
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /> <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />

View File

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

View File

@@ -26,20 +26,20 @@ const visible = $derived(activeAnnotationId !== null);
</script> </script>
<div <div
class="absolute inset-y-0 right-0 z-10 flex w-80 flex-col border-l border-brand-sand bg-white shadow-[-4px_0_16px_rgba(0,0,0,0.08)] transition-transform duration-200 {visible class="absolute inset-y-0 right-0 z-10 flex w-80 flex-col border-l border-line bg-surface shadow-[-4px_0_16px_rgba(0,0,0,0.08)] transition-transform duration-200 {visible
? 'translate-x-0' ? 'translate-x-0'
: 'pointer-events-none translate-x-full'}" : 'pointer-events-none translate-x-full'}"
data-testid="annotation-side-panel" data-testid="annotation-side-panel"
> >
<!-- Header --> <!-- Header -->
<div class="flex shrink-0 items-center justify-between border-b border-brand-sand px-4 py-3"> <div class="flex shrink-0 items-center justify-between border-b border-line px-4 py-3">
<span class="font-sans text-xs font-medium text-brand-navy"> <span class="font-sans text-xs font-medium text-ink">
{m.doc_panel_discussion_annotation_tab({ page: String(activeAnnotationPage ?? '?') })} {m.doc_panel_discussion_annotation_tab({ page: String(activeAnnotationPage ?? '?') })}
</span> </span>
<button <button
onclick={onClose} onclick={onClose}
aria-label={m.comment_panel_close()} aria-label={m.comment_panel_close()}
class="rounded p-1 text-gray-400 transition-colors hover:bg-brand-sand/50 hover:text-brand-navy" class="rounded p-1 text-ink-3 transition-colors hover:bg-muted hover:text-ink"
> >
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /> <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />

View File

@@ -1,25 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onMount, untrack } from 'svelte'; import { onMount, untrack } from 'svelte';
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import type { Comment, CommentReply } from '$lib/types';
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[];
};
type Props = { type Props = {
documentId: string; documentId: string;
@@ -185,180 +167,138 @@ 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>
<!--
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"> <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-brand-sand pt-4' : ''}> <div class={ti > 0 ? 'border-t border-line pt-4' : ''}>
<!-- Root comment --> <!-- Root comment -->
<div> <div>
{#if editingId === thread.id} {@render commentEntry(thread, thread.id, thread.replies.length === 0)}
<div class="flex flex-col gap-2">
<textarea
class="w-full resize-none rounded border border-brand-sand px-3 py-2 font-serif text-sm text-brand-navy focus:ring-1 focus:ring-brand-mint focus:outline-none"
rows={3}
bind:value={editText}
></textarea>
<div class="flex items-center gap-3">
<button
class="rounded bg-brand-navy px-3 py-1.5 font-sans text-xs font-medium text-white hover:bg-brand-navy/80 disabled:opacity-40"
disabled={posting}
onclick={() => saveEdit(thread.id)}
>
{m.btn_save()}
</button>
<button
class="font-sans text-xs text-gray-400 transition-colors hover:text-brand-navy"
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-brand-navy"
>{thread.authorName}</span
>
<span class="font-sans text-xs text-gray-400">{timeAgo(thread.createdAt)}</span>
{#if wasEdited(thread)}
<span class="font-sans text-xs text-gray-400">
{m.comment_edited_label()}
{timeAgo(thread.updatedAt)}
</span>
{/if}
</div>
<p class="mt-1 font-serif text-sm leading-relaxed text-gray-700">{thread.content}</p>
</div>
{#if canModify(thread)}
<div class="flex shrink-0 items-center gap-2">
<button
class="font-sans text-xs text-gray-400 transition-colors hover:text-brand-navy"
onclick={() => startEdit(thread)}
>
{m.btn_edit()}
</button>
<button
class="font-sans text-xs text-gray-400 transition-colors hover:text-brand-navy"
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-brand-mint transition-colors hover:text-brand-navy"
onclick={() => startReply(thread.id)}
>
{m.comment_btn_reply()}
</button>
</div>
{/if}
{/if}
</div> </div>
<!-- Replies --> <!-- Replies -->
{#each thread.replies as reply, ri (reply.id)} {#each thread.replies as reply, ri (reply.id)}
<div class="mt-3 ml-6 border-l-2 border-brand-sand pl-4"> <div class="mt-3 ml-6 border-l-2 border-line pl-4">
{#if editingId === reply.id} {@render commentEntry(reply, thread.id, ri === thread.replies.length - 1)}
<div class="flex flex-col gap-2">
<textarea
class="w-full resize-none rounded border border-brand-sand px-3 py-2 font-serif text-sm text-brand-navy focus:ring-1 focus:ring-brand-mint focus:outline-none"
rows={3}
bind:value={editText}
></textarea>
<div class="flex items-center gap-3">
<button
class="rounded bg-brand-navy px-3 py-1.5 font-sans text-xs font-medium text-white hover:bg-brand-navy/80 disabled:opacity-40"
disabled={posting}
onclick={() => saveEdit(reply.id)}
>
{m.btn_save()}
</button>
<button
class="font-sans text-xs text-gray-400 transition-colors hover:text-brand-navy"
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-brand-navy"
>{reply.authorName}</span
>
<span class="font-sans text-xs text-gray-400">{timeAgo(reply.createdAt)}</span>
{#if wasEdited(reply)}
<span class="font-sans text-xs text-gray-400">
{m.comment_edited_label()}
{timeAgo(reply.updatedAt)}
</span>
{/if}
</div>
<p class="mt-1 font-serif text-sm leading-relaxed text-gray-700">{reply.content}</p>
</div>
{#if canModify(reply)}
<div class="flex shrink-0 items-center gap-2">
<button
class="font-sans text-xs text-gray-400 transition-colors hover:text-brand-navy"
onclick={() => startEdit(reply)}
>
{m.btn_edit()}
</button>
<button
class="font-sans text-xs text-gray-400 transition-colors hover:text-brand-navy"
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-brand-mint transition-colors hover:text-brand-navy"
onclick={() => startReply(thread.id)}
>
{m.comment_btn_reply()}
</button>
</div>
{/if}
{/if}
</div> </div>
{/each} {/each}
<!-- Reply textarea (shown when replyingTo === thread.id) --> <!-- Reply compose box -->
{#if replyingTo === thread.id} {#if replyingTo === thread.id}
<div class="mt-3 ml-6 flex flex-col gap-2"> <div class="mt-3 ml-6 flex flex-col gap-2">
<textarea <textarea
class="w-full resize-none rounded border border-brand-sand px-3 py-2 font-serif text-sm text-brand-navy focus:ring-1 focus:ring-brand-mint focus:outline-none" 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} rows={3}
placeholder={m.comment_placeholder()} placeholder={m.comment_placeholder()}
bind:value={replyText} bind:value={replyText}
></textarea> ></textarea>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<button <button
class="rounded bg-brand-navy px-3 py-1.5 font-sans text-xs font-medium text-white hover:bg-brand-navy/80 disabled:opacity-40" 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} disabled={posting}
onclick={() => postReply(thread.id)} onclick={() => postReply(thread.id)}
> >
{m.comment_btn_post()} {m.comment_btn_post()}
</button> </button>
<button <button
class="font-sans text-xs text-gray-400 transition-colors hover:text-brand-navy" class="font-sans text-xs text-ink-3 transition-colors hover:text-ink"
onclick={cancelReply} onclick={cancelReply}
> >
{m.btn_cancel()} {m.btn_cancel()}
@@ -369,19 +309,19 @@ onMount(() => {
</div> </div>
{/each} {/each}
<!-- New top-level comment textarea --> <!-- New top-level comment -->
{#if canComment} {#if canComment}
<div class={comments.length > 0 ? 'border-t border-brand-sand pt-4' : ''}> <div class={comments.length > 0 ? 'border-t border-line pt-4' : ''}>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<textarea <textarea
class="w-full resize-none rounded border border-brand-sand px-3 py-2 font-serif text-sm text-brand-navy focus:ring-1 focus:ring-brand-mint focus:outline-none" 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} rows={3}
placeholder={m.comment_placeholder()} placeholder={m.comment_placeholder()}
bind:value={newText} bind:value={newText}
></textarea> ></textarea>
<div> <div>
<button <button
class="rounded bg-brand-navy px-3 py-1.5 font-sans text-xs font-medium text-white hover:bg-brand-navy/80 disabled:opacity-40" 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 || !newText.trim()} disabled={posting || !newText.trim()}
onclick={postComment} onclick={postComment}
> >

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 PanelTranscription from './PanelTranscription.svelte';
import PanelDiscussion from './PanelDiscussion.svelte'; import PanelDiscussion from './PanelDiscussion.svelte';
import PanelHistory from './PanelHistory.svelte'; import PanelHistory from './PanelHistory.svelte';
import type { Comment, DocumentPanelTab } from '$lib/types';
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[];
};
type Doc = { type Doc = {
id: string; id: string;
@@ -47,7 +27,7 @@ type Props = {
canAdmin: boolean; canAdmin: boolean;
open: boolean; open: boolean;
height: number; height: number;
activeTab: Tab; activeTab: DocumentPanelTab;
}; };
let { let {
@@ -72,7 +52,7 @@ function fullHeight() {
return window.innerHeight - (topbar?.getBoundingClientRect().bottom ?? 0); return window.innerHeight - (topbar?.getBoundingClientRect().bottom ?? 0);
} }
function openTab(tab: Tab) { function openTab(tab: DocumentPanelTab) {
activeTab = tab; activeTab = tab;
if (!open) { if (!open) {
open = true; open = true;
@@ -110,7 +90,7 @@ function onDragEnd() {
isDragging = false; isDragging = false;
} }
const tabs: { id: Tab; label: () => string }[] = [ const tabs: { id: DocumentPanelTab; label: () => string }[] = [
{ id: 'metadata', label: m.doc_panel_tab_metadata }, { id: 'metadata', label: m.doc_panel_tab_metadata },
{ id: 'transcription', label: m.doc_panel_tab_transcription }, { id: 'transcription', label: m.doc_panel_tab_transcription },
{ id: 'discussion', label: m.doc_panel_tab_discussion }, { id: 'discussion', label: m.doc_panel_tab_discussion },
@@ -118,16 +98,22 @@ const tabs: { id: Tab; 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
class="fixed right-0 bottom-0 left-0 z-30 flex flex-col border-t border-brand-sand bg-white shadow-[0_-4px_16px_rgba(0,0,0,0.08)]" class="fixed right-0 bottom-0 left-0 z-30 flex flex-col border-t border-line bg-surface shadow-[0_-4px_16px_rgba(0,0,0,0.08)]"
style="height: {panelHeight}px" style="height: {panelHeight}px"
data-testid="bottom-panel" data-testid="bottom-panel"
> >
<!-- Drag handle --> <!-- Drag handle -->
<div <div
class="flex h-2 shrink-0 cursor-ns-resize items-center justify-center bg-white" class="flex h-2 shrink-0 cursor-ns-resize items-center justify-center bg-surface"
style="touch-action: none" style="touch-action: none"
role="separator" role="separator"
aria-orientation="horizontal" aria-orientation="horizontal"
@@ -137,20 +123,27 @@ const panelHeight = $derived(open ? height : MIN_HEIGHT);
onpointerup={onDragEnd} onpointerup={onDragEnd}
onpointercancel={onDragEnd} onpointercancel={onDragEnd}
> >
<div class="h-1 w-12 rounded-full bg-gray-200"></div> <div class="h-1 w-12 rounded-full bg-line"></div>
</div> </div>
<!-- Tab bar --> <!-- Tab bar -->
<div class="flex shrink-0 items-center border-b border-brand-sand bg-white px-4"> <div class="flex shrink-0 items-center border-b border-line bg-surface px-4">
{#each tabs as tab (tab.id)} {#each tabs as tab (tab.id)}
<button <button
onclick={() => openTab(tab.id)} onclick={() => openTab(tab.id)}
class="mr-1 px-3 py-2.5 font-sans text-xs font-medium transition-colors {activeTab === tab.id && open class="mr-1 px-3 py-2.5 font-sans text-xs font-medium transition-colors {activeTab === tab.id && open
? 'border-b-2 border-brand-navy text-brand-navy' ? 'border-b-2 border-primary text-ink'
: 'text-gray-400 hover:text-brand-navy'}" : 'text-ink-3 hover:text-ink'}"
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}
@@ -162,7 +155,7 @@ const panelHeight = $derived(open ? height : MIN_HEIGHT);
onclick={closePanel} onclick={closePanel}
data-testid="panel-close-btn" data-testid="panel-close-btn"
aria-label="Panel schließen" aria-label="Panel schließen"
class="rounded p-1.5 text-gray-400 transition-colors hover:bg-brand-sand/50 hover:text-brand-navy" class="rounded p-1.5 text-ink-3 transition-colors hover:bg-muted hover:text-ink"
> >
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /> <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
@@ -185,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

@@ -58,17 +58,17 @@ const compactMeta = $derived.by(() => {
</script> </script>
<div <div
class="z-20 flex shrink-0 items-center justify-between border-b border-brand-sand bg-white px-6 py-3 shadow-sm" class="z-20 flex shrink-0 items-center justify-between border-b border-line bg-surface px-6 py-3 shadow-sm"
data-topbar data-topbar
> >
<!-- Left: back + title --> <!-- Left: back + title -->
<div class="flex min-w-0 items-center gap-4 overflow-hidden"> <div class="flex min-w-0 items-center gap-4 overflow-hidden">
<a <a
href="/" href="/"
class="group flex shrink-0 items-center gap-2 font-sans text-sm font-medium text-gray-500 transition-colors hover:text-brand-navy" class="group flex shrink-0 items-center gap-2 font-sans text-sm font-medium text-ink-2 transition-colors hover:text-ink"
> >
<div <div
class="flex h-8 w-8 items-center justify-center rounded-full bg-brand-sand transition-colors group-hover:bg-brand-mint" class="flex h-8 w-8 items-center justify-center rounded-full bg-canvas transition-colors group-hover:bg-accent"
> >
<img <img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Arrow/Arrow-Left-MD.svg" src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Arrow/Arrow-Left-MD.svg"
@@ -80,15 +80,15 @@ const compactMeta = $derived.by(() => {
<span class="hidden sm:inline">{m.btn_back()}</span> <span class="hidden sm:inline">{m.btn_back()}</span>
</a> </a>
<div class="min-w-0 border-l border-gray-200 pl-4"> <div class="min-w-0 border-l border-line pl-4">
<h1 <h1
class="truncate font-serif text-base leading-tight text-brand-navy" class="truncate font-serif text-base leading-tight text-ink"
title={doc.title ?? doc.originalFilename ?? ''} title={doc.title ?? doc.originalFilename ?? ''}
> >
{doc.title || doc.originalFilename} {doc.title || doc.originalFilename}
</h1> </h1>
{#if compactMeta} {#if compactMeta}
<p class="truncate font-sans text-xs text-gray-500" title={compactMeta}> <p class="truncate font-sans text-xs text-ink-2" title={compactMeta}>
{compactMeta} {compactMeta}
</p> </p>
{/if} {/if}
@@ -102,8 +102,8 @@ const compactMeta = $derived.by(() => {
onclick={() => (annotateMode = !annotateMode)} onclick={() => (annotateMode = !annotateMode)}
aria-label={annotateMode ? m.doc_panel_annotate_stop() : m.doc_panel_annotate()} aria-label={annotateMode ? m.doc_panel_annotate_stop() : m.doc_panel_annotate()}
class="flex items-center gap-1.5 rounded px-3 py-1.5 font-sans text-xs font-medium transition {annotateMode class="flex items-center gap-1.5 rounded px-3 py-1.5 font-sans text-xs font-medium transition {annotateMode
? 'bg-brand-navy text-white' ? 'bg-primary text-white'
: 'border border-brand-navy text-brand-navy hover:bg-brand-navy hover:text-white'}" : 'border border-primary text-ink hover:bg-primary hover:text-white'}"
> >
<img <img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Note/Note-Add-MD.svg" src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Note/Note-Add-MD.svg"
@@ -118,7 +118,7 @@ const compactMeta = $derived.by(() => {
{#if canWrite} {#if canWrite}
<a <a
href="/documents/{doc.id}/edit" href="/documents/{doc.id}/edit"
class="flex items-center gap-2 rounded border border-brand-navy bg-transparent px-3 py-1.5 text-xs font-medium text-brand-navy transition hover:bg-brand-navy hover:text-white" class="flex items-center gap-2 rounded border border-primary bg-transparent px-3 py-1.5 text-xs font-medium text-ink transition hover:bg-primary hover:text-white"
> >
<img <img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Edit-Content-MD.svg" src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Edit-Content-MD.svg"
@@ -134,7 +134,7 @@ const compactMeta = $derived.by(() => {
<a <a
href={fileUrl} href={fileUrl}
download={doc.originalFilename} download={doc.originalFilename}
class="rounded border border-transparent bg-brand-sand/50 p-1.5 text-brand-navy transition hover:bg-brand-mint" class="rounded border border-transparent bg-muted p-1.5 text-ink transition hover:bg-accent"
title={m.doc_download_title()} title={m.doc_download_title()}
> >
<img <img

View File

@@ -32,9 +32,9 @@ let {
}: Props = $props(); }: Props = $props();
</script> </script>
<div class="absolute inset-0 bg-[#2A2A2A]"> <div class="absolute inset-0 bg-pdf-bg">
{#if isLoading} {#if isLoading}
<div class="flex h-full flex-col items-center justify-center text-brand-mint"> <div class="flex h-full flex-col items-center justify-center text-accent">
<svg <svg
class="mb-4 h-8 w-8 animate-spin" class="mb-4 h-8 w-8 animate-spin"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
@@ -52,7 +52,7 @@ let {
<span class="font-sans text-sm tracking-wide">{m.doc_loading()}</span> <span class="font-sans text-sm tracking-wide">{m.doc_loading()}</span>
</div> </div>
{:else if error} {:else if error}
<div class="flex h-full flex-col items-center justify-center px-4 text-center text-gray-400"> <div class="flex h-full flex-col items-center justify-center px-4 text-center text-ink-3">
<p class="mb-2 font-serif">{error}</p> <p class="mb-2 font-serif">{error}</p>
{#if doc.filePath} {#if doc.filePath}
<a <a
@@ -65,8 +65,8 @@ let {
{/if} {/if}
</div> </div>
{:else if !doc.filePath} {:else if !doc.filePath}
<div class="flex h-full flex-col items-center justify-center text-gray-400"> <div class="flex h-full flex-col items-center justify-center text-ink-3">
<div class="mb-6 rounded-full bg-white/5 p-8"> <div class="mb-6 rounded-full bg-surface/5 p-8">
<img <img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/PDF-Document-MD.svg" src="/degruyter-icons/Simple/Medium-24px/SVG/Action/PDF-Document-MD.svg"
alt="" alt=""

View File

@@ -18,14 +18,14 @@ $effect(() => {
<div <div
bind:this={el} bind:this={el}
style={!expanded ? `overflow: hidden; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: ${maxLines}` : ''} style={!expanded ? `overflow: hidden; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: ${maxLines}` : ''}
class="rounded border border-brand-sand bg-brand-sand/30 p-5 font-serif text-sm leading-relaxed whitespace-pre-wrap text-brand-navy" class="rounded border border-line bg-muted p-5 font-serif text-sm leading-relaxed whitespace-pre-wrap text-ink"
> >
{text} {text}
</div> </div>
{#if isClamped || expanded} {#if isClamped || expanded}
<button <button
onclick={() => (expanded = !expanded)} onclick={() => (expanded = !expanded)}
class="mt-2 font-sans text-xs text-gray-400 transition hover:text-brand-navy" class="mt-2 font-sans text-xs text-ink-3 transition hover:text-ink"
> >
{expanded ? m.comp_expandable_show_less() : m.comp_expandable_show_more()} {expanded ? m.comp_expandable_show_less() : m.comp_expandable_show_more()}
</button> </button>

View File

@@ -1,24 +1,6 @@
<script lang="ts"> <script lang="ts">
import CommentThread from './CommentThread.svelte'; import CommentThread from './CommentThread.svelte';
import type { Comment } from '$lib/types';
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[];
};
type Props = { type Props = {
documentId: string; documentId: string;
@@ -26,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">
@@ -38,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

@@ -270,11 +270,11 @@ $effect(() => {
<div class="space-y-4 p-6"> <div class="space-y-4 p-6">
{#if historyLoading} {#if historyLoading}
<p class="font-sans text-xs text-gray-400">{m.history_loading()}</p> <p class="font-sans text-xs text-ink-3">{m.history_loading()}</p>
{:else if !historyLoaded} {:else if !historyLoaded}
<!-- initial state before effect runs — show nothing --> <!-- initial state before effect runs — show nothing -->
{:else if versions.length === 0} {:else if versions.length === 0}
<p class="font-serif text-sm text-gray-400 italic">{m.history_empty()}</p> <p class="font-serif text-sm text-ink-3 italic">{m.history_empty()}</p>
{:else} {:else}
<!-- Compare mode toggle --> <!-- Compare mode toggle -->
<div class="flex justify-end"> <div class="flex justify-end">
@@ -286,8 +286,8 @@ $effect(() => {
selectedVersionId = null; selectedVersionId = null;
}} }}
class="font-sans text-xs font-medium transition {compareMode class="font-sans text-xs font-medium transition {compareMode
? 'text-brand-navy underline' ? 'text-ink underline'
: 'text-gray-400 hover:text-brand-navy'}" : 'text-ink-3 hover:text-ink'}"
> >
{m.history_compare_mode()} {m.history_compare_mode()}
</button> </button>
@@ -296,13 +296,13 @@ $effect(() => {
{#if compareMode} {#if compareMode}
<div class="space-y-2"> <div class="space-y-2">
<div> <div>
<label for="compare-a" class="mb-1 block font-sans text-[10px] text-gray-400 uppercase" <label for="compare-a" class="mb-1 block font-sans text-[10px] text-ink-3 uppercase"
>{m.history_compare_select_a()}</label >{m.history_compare_select_a()}</label
> >
<select <select
id="compare-a" id="compare-a"
bind:value={compareA} bind:value={compareA}
class="w-full rounded border border-brand-sand bg-white px-2 py-1 font-sans text-xs text-brand-navy focus:ring-1 focus:ring-brand-mint focus:outline-none" class="w-full rounded border border-line bg-surface px-2 py-1 font-sans text-xs text-ink focus:ring-1 focus:ring-accent focus:outline-none"
> >
<option value=""></option> <option value=""></option>
{#each versions as v, i (v.id)} {#each versions as v, i (v.id)}
@@ -311,13 +311,13 @@ $effect(() => {
</select> </select>
</div> </div>
<div> <div>
<label for="compare-b" class="mb-1 block font-sans text-[10px] text-gray-400 uppercase" <label for="compare-b" class="mb-1 block font-sans text-[10px] text-ink-3 uppercase"
>{m.history_compare_select_b()}</label >{m.history_compare_select_b()}</label
> >
<select <select
id="compare-b" id="compare-b"
bind:value={compareB} bind:value={compareB}
class="w-full rounded border border-brand-sand bg-white px-2 py-1 font-sans text-xs text-brand-navy focus:ring-1 focus:ring-brand-mint focus:outline-none" class="w-full rounded border border-line bg-surface px-2 py-1 font-sans text-xs text-ink focus:ring-1 focus:ring-accent focus:outline-none"
> >
<option value=""></option> <option value=""></option>
{#each versions as v, i (v.id)} {#each versions as v, i (v.id)}
@@ -328,7 +328,7 @@ $effect(() => {
<button <button
onclick={applyCompare} onclick={applyCompare}
disabled={!compareA || !compareB || compareA === compareB} disabled={!compareA || !compareB || compareA === compareB}
class="w-full rounded bg-brand-navy px-3 py-1.5 font-sans text-xs font-medium text-white transition hover:bg-brand-navy/80 disabled:cursor-not-allowed disabled:opacity-40" class="w-full rounded bg-primary px-3 py-1.5 font-sans text-xs font-medium text-white transition hover:bg-primary/80 disabled:cursor-not-allowed disabled:opacity-40"
> >
{m.history_compare_apply()} {m.history_compare_apply()}
</button> </button>
@@ -336,23 +336,23 @@ $effect(() => {
<!-- Diff panel for compare mode --> <!-- Diff panel for compare mode -->
{#if diffLoading} {#if diffLoading}
<p class="font-sans text-xs text-gray-400">{m.history_loading()}</p> <p class="font-sans text-xs text-ink-3">{m.history_loading()}</p>
{:else if noDiff} {:else if noDiff}
<div <div
data-testid="history-diff" data-testid="history-diff"
class="rounded-sm border border-brand-sand bg-white p-4 font-serif text-sm text-gray-400 italic" class="rounded-sm border border-line bg-surface p-4 font-serif text-sm text-ink-3 italic"
> >
{m.history_diff_no_changes()} {m.history_diff_no_changes()}
</div> </div>
{:else if diffEntries.length > 0} {:else if diffEntries.length > 0}
<div <div
data-testid="history-diff" data-testid="history-diff"
class="space-y-4 rounded-sm border border-brand-sand bg-white p-4" class="space-y-4 rounded-sm border border-line bg-surface p-4"
> >
{#each diffEntries as entry (entry.field)} {#each diffEntries as entry (entry.field)}
<div> <div>
<span <span
class="mb-1.5 block font-sans text-[10px] font-bold tracking-wide text-gray-400 uppercase" class="mb-1.5 block font-sans text-[10px] font-bold tracking-wide text-ink-3 uppercase"
>{entry.label}</span >{entry.label}</span
> >
{#if entry.kind === 'text'} {#if entry.kind === 'text'}
@@ -371,7 +371,7 @@ $effect(() => {
<div class="flex items-center gap-2 font-serif text-sm"> <div class="flex items-center gap-2 font-serif text-sm">
<span class="text-red-600 line-through">{entry.oldVal || '—'}</span> <span class="text-red-600 line-through">{entry.oldVal || '—'}</span>
<svg <svg
class="h-3 w-3 flex-shrink-0 text-gray-400" class="h-3 w-3 flex-shrink-0 text-ink-3"
viewBox="0 0 20 20" viewBox="0 0 20 20"
fill="currentColor" fill="currentColor"
aria-hidden="true" aria-hidden="true"
@@ -406,31 +406,31 @@ $effect(() => {
{/if} {/if}
{:else} {:else}
<!-- Version list with inline diff below each selected item --> <!-- Version list with inline diff below each selected item -->
<ul class="divide-y divide-brand-sand"> <ul class="divide-brand-sand divide-y">
{#each versions as v, i (v.id)} {#each versions as v, i (v.id)}
<li> <li>
<button <button
onclick={() => selectVersion(v.id)} onclick={() => selectVersion(v.id)}
data-testid="history-version" data-testid="history-version"
class="w-full py-2 text-left transition hover:bg-brand-sand/30 {selectedVersionId === class="w-full py-2 text-left transition hover:bg-muted {selectedVersionId ===
v.id v.id
? 'border-l-2 border-brand-mint pl-2' ? 'border-l-2 border-accent pl-2'
: 'pl-0'}" : 'pl-0'}"
> >
<div class="flex items-baseline justify-between gap-2"> <div class="flex items-baseline justify-between gap-2">
<span class="font-sans text-xs font-medium text-brand-navy"> <span class="font-sans text-xs font-medium text-ink">
Version {i + 1} Version {i + 1}
</span> </span>
<span class="font-sans text-[10px] text-gray-400"> <span class="font-sans text-[10px] text-ink-3">
{formatDateTime(v.savedAt)} {formatDateTime(v.savedAt)}
</span> </span>
</div> </div>
<span class="font-sans text-[11px] text-gray-500">{v.editorName}</span> <span class="font-sans text-[11px] text-ink-2">{v.editorName}</span>
{#if v.changedFields && v.changedFields.length > 0} {#if v.changedFields && v.changedFields.length > 0}
<div class="mt-1 flex flex-wrap gap-1"> <div class="mt-1 flex flex-wrap gap-1">
{#each v.changedFields as field (field)} {#each v.changedFields as field (field)}
<span <span
class="rounded bg-brand-sand/50 px-1.5 py-0.5 font-sans text-[10px] tracking-wide text-gray-500 uppercase" class="rounded bg-muted px-1.5 py-0.5 font-sans text-[10px] tracking-wide text-ink-2 uppercase"
> >
{fieldLabels[field] ? fieldLabels[field]() : field} {fieldLabels[field] ? fieldLabels[field]() : field}
</span> </span>
@@ -442,23 +442,23 @@ $effect(() => {
<!-- Diff shown inline below the selected version --> <!-- Diff shown inline below the selected version -->
{#if selectedVersionId === v.id} {#if selectedVersionId === v.id}
{#if diffLoading} {#if diffLoading}
<p class="pb-3 pl-2 font-sans text-xs text-gray-400">{m.history_loading()}</p> <p class="pb-3 pl-2 font-sans text-xs text-ink-3">{m.history_loading()}</p>
{:else if noDiff} {:else if noDiff}
<div <div
data-testid="history-diff" data-testid="history-diff"
class="mb-2 rounded-sm border border-brand-sand bg-white p-4 font-serif text-sm text-gray-400 italic" class="mb-2 rounded-sm border border-line bg-surface p-4 font-serif text-sm text-ink-3 italic"
> >
{m.history_diff_no_changes()} {m.history_diff_no_changes()}
</div> </div>
{:else if diffEntries.length > 0} {:else if diffEntries.length > 0}
<div <div
data-testid="history-diff" data-testid="history-diff"
class="mb-2 space-y-4 rounded-sm border border-brand-sand bg-white p-4" class="mb-2 space-y-4 rounded-sm border border-line bg-surface p-4"
> >
{#each diffEntries as entry (entry.field)} {#each diffEntries as entry (entry.field)}
<div> <div>
<span <span
class="mb-1.5 block font-sans text-[10px] font-bold tracking-wide text-gray-400 uppercase" class="mb-1.5 block font-sans text-[10px] font-bold tracking-wide text-ink-3 uppercase"
>{entry.label}</span >{entry.label}</span
> >
{#if entry.kind === 'text'} {#if entry.kind === 'text'}
@@ -477,7 +477,7 @@ $effect(() => {
<div class="flex items-center gap-2 font-serif text-sm"> <div class="flex items-center gap-2 font-serif text-sm">
<span class="text-red-600 line-through">{entry.oldVal || '—'}</span> <span class="text-red-600 line-through">{entry.oldVal || '—'}</span>
<svg <svg
class="h-3 w-3 flex-shrink-0 text-gray-400" class="h-3 w-3 flex-shrink-0 text-ink-3"
viewBox="0 0 20 20" viewBox="0 0 20 20"
fill="currentColor" fill="currentColor"
aria-hidden="true" aria-hidden="true"

View File

@@ -21,14 +21,14 @@ let { doc }: { doc: Doc } = $props();
<!-- DETAILS GROUP --> <!-- DETAILS GROUP -->
<div> <div>
<h3 <h3
class="mb-4 border-b border-brand-sand pb-2 font-sans text-xs font-bold tracking-widest text-brand-navy uppercase" class="mb-4 border-b border-line pb-2 font-sans text-xs font-bold tracking-widest text-ink uppercase"
> >
{m.doc_section_details()} {m.doc_section_details()}
</h3> </h3>
<div class="space-y-5"> <div class="space-y-5">
<!-- Date --> <!-- Date -->
<div class="flex items-start"> <div class="flex items-start">
<span class="mt-0.5 w-8 text-brand-mint"> <span class="mt-0.5 w-8 text-accent">
<img <img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Calendar/Calendar-Add-MD.svg" src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Calendar/Calendar-Add-MD.svg"
alt="" alt=""
@@ -37,16 +37,16 @@ let { doc }: { doc: Doc } = $props();
/> />
</span> </span>
<div> <div>
<span class="block font-serif text-lg text-brand-navy"> <span class="block font-serif text-lg text-ink">
{doc.documentDate ? formatDate(doc.documentDate) : '—'} {doc.documentDate ? formatDate(doc.documentDate) : '—'}
</span> </span>
<span class="font-sans text-xs text-gray-500">{m.doc_label_document_date()}</span> <span class="font-sans text-xs text-ink-2">{m.doc_label_document_date()}</span>
</div> </div>
</div> </div>
<!-- Creation Location --> <!-- Creation Location -->
<div class="flex items-start"> <div class="flex items-start">
<span class="mt-0.5 w-8 text-brand-mint"> <span class="mt-0.5 w-8 text-accent">
<img <img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Location-MD.svg" src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Location-MD.svg"
alt="" alt=""
@@ -55,17 +55,17 @@ let { doc }: { doc: Doc } = $props();
/> />
</span> </span>
<div> <div>
<span class="block font-serif text-lg text-brand-navy"> <span class="block font-serif text-lg text-ink">
{doc.location ? doc.location : '—'} {doc.location ? doc.location : '—'}
</span> </span>
<span class="font-sans text-xs text-gray-500">{m.doc_label_creation_location()}</span> <span class="font-sans text-xs text-ink-2">{m.doc_label_creation_location()}</span>
</div> </div>
</div> </div>
<!-- Physical Archive Location --> <!-- Physical Archive Location -->
{#if doc.documentLocation} {#if doc.documentLocation}
<div class="flex items-start"> <div class="flex items-start">
<span class="mt-0.5 w-8 text-brand-mint"> <span class="mt-0.5 w-8 text-accent">
<img <img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Folder-MD.svg" src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Folder-MD.svg"
alt="" alt=""
@@ -74,10 +74,10 @@ let { doc }: { doc: Doc } = $props();
/> />
</span> </span>
<div> <div>
<span class="block font-serif text-lg text-brand-navy"> <span class="block font-serif text-lg text-ink">
{doc.documentLocation} {doc.documentLocation}
</span> </span>
<span class="font-sans text-xs text-gray-500" <span class="font-sans text-xs text-ink-2"
>{m.doc_label_archive_location_original()}</span >{m.doc_label_archive_location_original()}</span
> >
</div> </div>
@@ -87,7 +87,7 @@ let { doc }: { doc: Doc } = $props();
<!-- Tags --> <!-- Tags -->
{#if doc.tags && doc.tags.length > 0} {#if doc.tags && doc.tags.length > 0}
<div class="flex items-start"> <div class="flex items-start">
<span class="mt-0.5 w-8 text-brand-mint"> <span class="mt-0.5 w-8 text-accent">
<img <img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Bookmark/Bookmark-Outline-MD.svg" src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Bookmark/Bookmark-Outline-MD.svg"
alt="" alt=""
@@ -100,14 +100,14 @@ let { doc }: { doc: Doc } = $props();
{#each doc.tags as tag (tag.id)} {#each doc.tags as tag (tag.id)}
<a <a
href="/?tag={encodeURIComponent(tag.name)}" href="/?tag={encodeURIComponent(tag.name)}"
class="inline-flex items-center rounded bg-brand-sand/50 px-2 py-0.5 text-xs font-bold tracking-wide text-brand-navy uppercase transition-colors hover:bg-brand-navy hover:text-white" class="inline-flex items-center rounded bg-muted px-2 py-0.5 text-xs font-bold tracking-wide text-ink uppercase transition-colors hover:bg-primary hover:text-white"
title={m.doc_tag_filter_title({ name: tag.name })} title={m.doc_tag_filter_title({ name: tag.name })}
> >
{tag.name} {tag.name}
</a> </a>
{/each} {/each}
</div> </div>
<span class="font-sans text-xs text-gray-500">{m.form_label_tags()}</span> <span class="font-sans text-xs text-ink-2">{m.form_label_tags()}</span>
</div> </div>
</div> </div>
{/if} {/if}
@@ -117,61 +117,59 @@ let { doc }: { doc: Doc } = $props();
<!-- PERSONEN GROUP --> <!-- PERSONEN GROUP -->
<div> <div>
<h3 <h3
class="mb-4 border-b border-brand-sand pb-2 font-sans text-xs font-bold tracking-widest text-brand-navy uppercase" class="mb-4 border-b border-line pb-2 font-sans text-xs font-bold tracking-widest text-ink uppercase"
> >
{m.doc_section_persons()} {m.doc_section_persons()}
</h3> </h3>
<div class="mb-6"> <div class="mb-6">
<span class="mb-2 block font-sans text-xs text-gray-400 uppercase" <span class="mb-2 block font-sans text-xs text-ink-3 uppercase">{m.form_label_sender()}</span>
>{m.form_label_sender()}</span
>
{#if doc.sender} {#if doc.sender}
<a <a
href="/persons/{doc.sender.id}" href="/persons/{doc.sender.id}"
class="group block rounded border border-brand-sand bg-brand-sand/20 p-3 transition hover:border-brand-mint hover:bg-brand-mint/10" class="group block rounded border border-line bg-muted p-3 transition hover:border-accent hover:bg-accent/10"
> >
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div <div
class="flex h-8 w-8 items-center justify-center rounded-full bg-brand-navy font-serif text-sm text-white" class="flex h-8 w-8 items-center justify-center rounded-full bg-primary font-serif text-sm text-white"
> >
{doc.sender.firstName[0]}{doc.sender.lastName[0]} {doc.sender.firstName[0]}{doc.sender.lastName[0]}
</div> </div>
<div> <div>
<p <p
class="font-serif text-brand-navy decoration-brand-mint underline-offset-2 group-hover:underline" class="font-serif text-ink decoration-brand-mint underline-offset-2 group-hover:underline"
> >
{doc.sender.firstName} {doc.sender.firstName}
{doc.sender.lastName} {doc.sender.lastName}
</p> </p>
{#if doc.sender.alias} {#if doc.sender.alias}
<p class="font-sans text-xs text-gray-500">{doc.sender.alias}</p> <p class="font-sans text-xs text-ink-2">{doc.sender.alias}</p>
{/if} {/if}
</div> </div>
</div> </div>
</a> </a>
{:else} {:else}
<span class="font-serif text-sm text-gray-400 italic">{m.doc_sender_not_specified()}</span> <span class="font-serif text-sm text-ink-3 italic">{m.doc_sender_not_specified()}</span>
{/if} {/if}
</div> </div>
<div> <div>
<span class="mb-2 block font-sans text-xs text-gray-400 uppercase" <span class="mb-2 block font-sans text-xs text-ink-3 uppercase"
>{m.form_label_receivers()}</span >{m.form_label_receivers()}</span
> >
{#if doc.receivers && doc.receivers.length > 0} {#if doc.receivers && doc.receivers.length > 0}
<div class="space-y-2"> <div class="space-y-2">
{#each doc.receivers as receiver (receiver.id)} {#each doc.receivers as receiver (receiver.id)}
<div <div
class="group flex items-center justify-between rounded border border-brand-sand bg-white p-3 transition hover:border-brand-navy" class="group flex items-center justify-between rounded border border-line bg-surface p-3 transition hover:border-primary"
> >
<a href="/persons/{receiver.id}" class="flex min-w-0 flex-1 items-center gap-3"> <a href="/persons/{receiver.id}" class="flex min-w-0 flex-1 items-center gap-3">
<div <div
class="flex h-6 w-6 items-center justify-center rounded-full bg-gray-100 font-serif text-xs text-gray-500" class="flex h-6 w-6 items-center justify-center rounded-full bg-muted font-serif text-xs text-ink-2"
> >
{receiver.firstName[0]}{receiver.lastName[0]} {receiver.firstName[0]}{receiver.lastName[0]}
</div> </div>
<span class="truncate font-serif text-sm text-brand-navy"> <span class="truncate font-serif text-sm text-ink">
{receiver.firstName} {receiver.firstName}
{receiver.lastName} {receiver.lastName}
</span> </span>
@@ -180,7 +178,7 @@ let { doc }: { doc: Doc } = $props();
{#if doc.sender} {#if doc.sender}
<a <a
href="/conversations?senderId={doc.sender.id}&receiverId={receiver.id}" href="/conversations?senderId={doc.sender.id}&receiverId={receiver.id}"
class="text-gray-300 transition hover:text-brand-mint" class="text-ink-3 transition hover:text-accent"
title={m.doc_conversation_title()} title={m.doc_conversation_title()}
> >
<img <img
@@ -195,7 +193,7 @@ let { doc }: { doc: Doc } = $props();
{/each} {/each}
</div> </div>
{:else} {:else}
<span class="font-serif text-sm text-gray-400 italic">{m.doc_no_receivers()}</span> <span class="font-serif text-sm text-ink-3 italic">{m.doc_no_receivers()}</span>
{/if} {/if}
</div> </div>
</div> </div>

View File

@@ -12,28 +12,24 @@ let { doc }: { doc: Doc } = $props();
<div class="flex justify-center px-6 py-8"> <div class="flex justify-center px-6 py-8">
<div class="w-full max-w-prose space-y-8"> <div class="w-full max-w-prose space-y-8">
{#if !doc.summary && !doc.transcription} {#if !doc.summary && !doc.transcription}
<p class="font-serif text-sm text-gray-400 italic"></p> <p class="font-serif text-sm text-ink-3 italic"></p>
{/if} {/if}
{#if doc.summary} {#if doc.summary}
<div> <div>
<span <span class="mb-3 block font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
class="mb-3 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
>
{m.doc_label_summary()} {m.doc_label_summary()}
</span> </span>
<p class="font-serif text-base leading-relaxed text-brand-navy">{doc.summary}</p> <p class="font-serif text-base leading-relaxed text-ink">{doc.summary}</p>
</div> </div>
{/if} {/if}
{#if doc.transcription} {#if doc.transcription}
<div> <div>
<span <span class="mb-3 block font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
class="mb-3 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
>
{m.form_label_transcription()} {m.form_label_transcription()}
</span> </span>
<p class="font-serif text-base leading-relaxed whitespace-pre-wrap text-brand-navy"> <p class="font-serif text-base leading-relaxed whitespace-pre-wrap text-ink">
{doc.transcription} {doc.transcription}
</p> </p>
</div> </div>

View File

@@ -3,6 +3,7 @@ import { onMount } from 'svelte';
import { SvelteMap } from 'svelte/reactivity'; import { SvelteMap } from 'svelte/reactivity';
import type { PDFDocumentProxy, PDFPageProxy, RenderTask } from 'pdfjs-dist'; import type { PDFDocumentProxy, PDFPageProxy, RenderTask } from 'pdfjs-dist';
import AnnotationLayer from './AnnotationLayer.svelte'; import AnnotationLayer from './AnnotationLayer.svelte';
import type { Annotation } from '$lib/types';
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
let { let {
@@ -43,19 +44,6 @@ let textLayerInstance: { cancel: () => void } | null = null;
let pdfjsLib: typeof import('pdfjs-dist') | null = null; let pdfjsLib: typeof import('pdfjs-dist') | null = null;
let pdfjsReady = $state(false); 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 annotations = $state<Annotation[]>([]);
let annotateColor = $state('#ffff00'); let annotateColor = $state('#ffff00');
let commentCounts = new SvelteMap<string, number>(); let commentCounts = new SvelteMap<string, number>();
@@ -277,6 +265,10 @@ $effect(() => {
} }
}); });
$effect(() => {
if (annotateMode) showAnnotations = true;
});
function prevPage() { function prevPage() {
if (currentPage > 1) currentPage -= 1; if (currentPage > 1) currentPage -= 1;
} }
@@ -295,25 +287,23 @@ function zoomOut() {
</script> </script>
{#if !url} {#if !url}
<div class="flex h-full w-full items-center justify-center bg-[#2A2A2A] text-gray-400"> <div class="flex h-full w-full items-center justify-center bg-pdf-bg text-ink-3">
<p class="font-sans text-sm">Keine Datei vorhanden</p> <p class="font-sans text-sm">Keine Datei vorhanden</p>
</div> </div>
{:else if error} {:else if error}
<div <div class="flex h-full w-full flex-col items-center justify-center gap-3 bg-pdf-bg text-ink-3">
class="flex h-full w-full flex-col items-center justify-center gap-3 bg-[#2A2A2A] text-gray-300"
>
<p class="font-sans text-sm text-red-400">Fehler beim Laden der PDF</p> <p class="font-sans text-sm text-red-400">Fehler beim Laden der PDF</p>
<a <a
href={url} href={url}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
class="font-sans text-xs text-brand-mint underline hover:opacity-80" class="font-sans text-xs text-accent underline hover:opacity-80"
> >
Direkt öffnen Direkt öffnen
</a> </a>
</div> </div>
{:else} {:else}
<div class="flex h-full w-full flex-col bg-[#2A2A2A]"> <div class="flex h-full w-full flex-col bg-pdf-bg">
{#if outdatedCount > 0} {#if outdatedCount > 0}
<div <div
class="flex shrink-0 items-center gap-2 border-b border-amber-500/30 bg-amber-500/10 px-4 py-2" class="flex shrink-0 items-center gap-2 border-b border-amber-500/30 bg-amber-500/10 px-4 py-2"
@@ -337,7 +327,7 @@ function zoomOut() {
{/if} {/if}
<!-- Controls --> <!-- Controls -->
<div <div
class="flex shrink-0 items-center justify-between gap-2 border-b border-white/10 px-4 py-2" class="flex shrink-0 items-center justify-between gap-2 border-b border-pdf-ctrl px-4 py-2"
> >
<!-- Page navigation --> <!-- Page navigation -->
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -345,7 +335,7 @@ function zoomOut() {
onclick={prevPage} onclick={prevPage}
disabled={currentPage <= 1} disabled={currentPage <= 1}
aria-label="Zurück" aria-label="Zurück"
class="rounded p-1 text-gray-300 transition hover:bg-white/10 disabled:opacity-40" class="rounded p-1 text-ink-3 transition hover:bg-surface/10 disabled:opacity-40"
> >
<svg <svg
class="h-4 w-4" class="h-4 w-4"
@@ -359,7 +349,7 @@ function zoomOut() {
</button> </button>
{#if totalPages > 0} {#if totalPages > 0}
<span class="font-sans text-xs text-gray-300 tabular-nums"> <span class="font-sans text-xs text-ink-3 tabular-nums">
{currentPage} / {totalPages} {currentPage} / {totalPages}
</span> </span>
{/if} {/if}
@@ -368,7 +358,7 @@ function zoomOut() {
onclick={nextPage} onclick={nextPage}
disabled={!pdfDoc || currentPage >= totalPages} disabled={!pdfDoc || currentPage >= totalPages}
aria-label="Weiter" aria-label="Weiter"
class="rounded p-1 text-gray-300 transition hover:bg-white/10 disabled:opacity-40" class="rounded p-1 text-ink-3 transition hover:bg-surface/10 disabled:opacity-40"
> >
<svg <svg
class="h-4 w-4" class="h-4 w-4"
@@ -387,7 +377,7 @@ function zoomOut() {
<button <button
onclick={zoomOut} onclick={zoomOut}
aria-label="Verkleinern" aria-label="Verkleinern"
class="rounded p-1 text-gray-300 transition hover:bg-white/10" class="rounded p-1 text-ink-3 transition hover:bg-surface/10"
> >
<svg <svg
class="h-4 w-4" class="h-4 w-4"
@@ -405,7 +395,7 @@ function zoomOut() {
<button <button
onclick={zoomIn} onclick={zoomIn}
aria-label="Vergrößern" aria-label="Vergrößern"
class="rounded p-1 text-gray-300 transition hover:bg-white/10" class="rounded p-1 text-ink-3 transition hover:bg-surface/10"
> >
<svg <svg
class="h-4 w-4" class="h-4 w-4"
@@ -438,8 +428,8 @@ function zoomOut() {
onclick={() => (showAnnotations = !showAnnotations)} onclick={() => (showAnnotations = !showAnnotations)}
aria-label={showAnnotations ? m.pdf_annotations_hide() : m.pdf_annotations_show()} aria-label={showAnnotations ? m.pdf_annotations_hide() : m.pdf_annotations_show()}
class="flex items-center gap-1.5 rounded px-2 py-1 font-sans text-xs transition {showAnnotations class="flex items-center gap-1.5 rounded px-2 py-1 font-sans text-xs transition {showAnnotations
? 'text-gray-300 hover:bg-white/10' ? 'text-ink-3 hover:bg-surface/10'
: 'bg-white/10 text-brand-mint'}" : 'bg-surface/10 text-accent'}"
> >
<svg <svg
class="h-3.5 w-3.5 shrink-0" class="h-3.5 w-3.5 shrink-0"

View File

@@ -80,18 +80,18 @@ function clickOutside(node: HTMLElement) {
<div class="relative" use:clickOutside> <div class="relative" use:clickOutside>
<div <div
class="flex min-h-[42px] flex-wrap gap-2 rounded border border-gray-300 bg-white p-2 focus-within:border-brand-navy focus-within:ring-1 focus-within:ring-brand-navy" class="flex min-h-[42px] flex-wrap gap-2 rounded border border-line bg-surface p-2 focus-within:border-ink focus-within:ring-1 focus-within:ring-ink"
> >
{#each selectedPersons as person (person.id)} {#each selectedPersons as person (person.id)}
<span <span
class="inline-flex items-center gap-1 rounded bg-brand-sand/40 px-2 py-1 text-sm font-medium text-brand-navy" class="inline-flex items-center gap-1 rounded bg-muted px-2 py-1 text-sm font-medium text-ink"
> >
{person.firstName} {person.firstName}
{person.lastName} {person.lastName}
<button <button
type="button" type="button"
onclick={() => removePerson(person.id)} onclick={() => removePerson(person.id)}
class="ml-0.5 text-brand-navy/50 hover:text-red-500 focus:outline-none" class="ml-0.5 text-ink/50 hover:text-red-500 focus:outline-none"
aria-label={m.comp_multiselect_remove()} aria-label={m.comp_multiselect_remove()}
> >
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -121,14 +121,14 @@ function clickOutside(node: HTMLElement) {
{#if showDropdown && (results.length > 0 || loading)} {#if showDropdown && (results.length > 0 || loading)}
<div <div
style={dropdownStyle} style={dropdownStyle}
class="ring-opacity-5 z-50 max-h-60 overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black sm:text-sm" class="ring-opacity-5 z-50 max-h-60 overflow-auto rounded-md bg-surface py-1 text-base shadow-lg ring-1 ring-black sm:text-sm"
> >
{#if loading} {#if loading}
<div class="p-2 text-sm text-gray-500">{m.comp_multiselect_loading()}</div> <div class="p-2 text-sm text-ink-2">{m.comp_multiselect_loading()}</div>
{:else} {:else}
{#each results as person (person.id)} {#each results as person (person.id)}
<div <div
class="cursor-pointer py-2 pr-9 pl-3 text-gray-900 select-none hover:bg-brand-sand/30" class="cursor-pointer py-2 pr-9 pl-3 text-ink select-none hover:bg-muted"
onclick={() => selectPerson(person)} onclick={() => selectPerson(person)}
onkeydown={(e) => e.key === 'Enter' && selectPerson(person)} onkeydown={(e) => e.key === 'Enter' && selectPerson(person)}
role="button" role="button"

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);
@@ -111,7 +120,7 @@ function clickOutside(node: HTMLElement) {
</script> </script>
<div class="relative" use:clickOutside> <div class="relative" use:clickOutside>
<label for={name} class="block text-sm font-medium text-gray-700">{label}</label> <label for={name} class="block text-sm font-medium text-ink-2">{label}</label>
<input type="hidden" name={name} bind:value={value} /> <input type="hidden" name={name} bind:value={value} />
@@ -123,19 +132,19 @@ function clickOutside(node: HTMLElement) {
oninput={handleInput} oninput={handleInput}
onfocus={handleFocus} onfocus={handleFocus}
placeholder={m.comp_typeahead_placeholder()} placeholder={m.comp_typeahead_placeholder()}
class="mt-1 block w-full rounded-md border border-gray-300 p-2 shadow-sm focus:border-blue-500 focus:ring-blue-500" class="mt-1 block w-full rounded-md border border-line p-2 shadow-sm focus:border-accent focus:ring-accent"
/> />
{#if showDropdown && (results.length > 0 || loading)} {#if showDropdown && (results.length > 0 || loading)}
<div <div
class="ring-opacity-5 absolute top-full left-0 z-50 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black focus:outline-none sm:text-sm" class="ring-opacity-5 absolute top-full left-0 z-50 mt-1 max-h-60 w-full overflow-auto rounded-md bg-surface py-1 text-base shadow-lg ring-1 ring-black focus:outline-none sm:text-sm"
> >
{#if loading} {#if loading}
<div class="p-2 text-sm text-gray-500">{m.comp_typeahead_loading()}</div> <div class="p-2 text-sm text-ink-2">{m.comp_typeahead_loading()}</div>
{:else} {:else}
{#each results as person (person.id)} {#each results as person (person.id)}
<div <div
class="relative cursor-pointer py-2 pr-9 pl-3 text-gray-900 select-none hover:bg-blue-100" class="relative cursor-pointer py-2 pr-9 pl-3 text-ink select-none hover:bg-accent-bg"
onclick={() => selectPerson(person)} onclick={() => selectPerson(person)}
onkeydown={(e) => e.key === 'Enter' && selectPerson(person)} onkeydown={(e) => e.key === 'Enter' && selectPerson(person)}
role="button" role="button"

View File

@@ -85,19 +85,17 @@ function clickOutside(node: HTMLElement) {
<div class="w-full" use:clickOutside> <div class="w-full" use:clickOutside>
<!-- Tag Container --> <!-- Tag Container -->
<div <div
class="flex min-h-[42px] flex-wrap gap-2 rounded border border-gray-300 bg-white p-2 focus-within:border-brand-navy focus-within:ring-1 focus-within:ring-brand-navy" class="flex min-h-[42px] flex-wrap gap-2 rounded border border-line bg-surface p-2 focus-within:border-ink focus-within:ring-1 focus-within:ring-ink"
> >
<!-- Render Selected Tags --> <!-- Render Selected Tags -->
{#each tags as tag, i (i)} {#each tags as tag, i (i)}
<span <span class="flex items-center gap-1 rounded bg-muted px-2 py-1 text-sm font-medium text-ink">
class="flex items-center gap-1 rounded bg-brand-sand/30 px-2 py-1 text-sm font-medium text-brand-navy"
>
{tag} {tag}
<button <button
type="button" type="button"
onclick={() => removeTag(i)} onclick={() => removeTag(i)}
aria-label={m.comp_taginput_remove()} aria-label={m.comp_taginput_remove()}
class="text-brand-navy/50 hover:text-red-500 focus:outline-none" class="text-ink/50 hover:text-red-500 focus:outline-none"
> >
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" <svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"
><path ><path
@@ -130,16 +128,16 @@ function clickOutside(node: HTMLElement) {
<!-- Typeahead Dropdown --> <!-- Typeahead Dropdown -->
{#if showSuggestions && suggestions.length > 0} {#if showSuggestions && suggestions.length > 0}
<ul <ul
class="absolute top-full left-0 z-50 mt-1 max-h-48 w-full overflow-y-auto rounded border border-gray-200 bg-white shadow-lg" class="absolute top-full left-0 z-50 mt-1 max-h-48 w-full overflow-y-auto rounded border border-line bg-surface shadow-lg"
> >
{#each suggestions as suggestion, i (i)} {#each suggestions as suggestion, i (i)}
<li <li
role="option" role="option"
aria-selected={i === activeIndex} aria-selected={i === activeIndex}
tabindex="0" tabindex="0"
class="cursor-pointer px-3 py-2 text-sm hover:bg-brand-sand/20 {i === activeIndex class="cursor-pointer px-3 py-2 text-sm hover:bg-muted {i === activeIndex
? 'bg-brand-sand/20 font-bold text-brand-navy' ? 'bg-muted font-bold text-ink'
: 'text-gray-700'}" : 'text-ink-2'}"
onclick={() => addTag(suggestion)} onclick={() => addTag(suggestion)}
onkeydown={(e) => e.key === 'Enter' && addTag(suggestion)} onkeydown={(e) => e.key === 'Enter' && addTag(suggestion)}
> >
@@ -151,6 +149,6 @@ function clickOutside(node: HTMLElement) {
</div> </div>
</div> </div>
{#if allowCreation} {#if allowCreation}
<p class="mt-1 text-xs text-gray-400">{m.comp_taginput_create_hint()}</p> <p class="mt-1 text-xs text-ink-3">{m.comp_taginput_create_hint()}</p>
{/if} {/if}
</div> </div>

View File

@@ -0,0 +1,69 @@
<script lang="ts">
import { onMount } from 'svelte';
type Theme = 'light' | 'dark';
function systemPrefersDark(): boolean {
return window.matchMedia('(prefers-color-scheme: dark)').matches;
}
function resolveInitialTheme(): Theme {
const saved = localStorage.getItem('theme');
if (saved === 'light' || saved === 'dark') return saved;
return systemPrefersDark() ? 'dark' : 'light';
}
let theme = $state<Theme>('light');
onMount(() => {
theme = resolveInitialTheme();
});
function toggle() {
theme = theme === 'dark' ? 'light' : 'dark';
localStorage.setItem('theme', theme);
document.documentElement.setAttribute('data-theme', theme);
}
</script>
<button
type="button"
onclick={toggle}
aria-label={theme === 'dark' ? 'light mode' : 'dark mode'}
title={theme === 'dark' ? 'light mode' : 'dark mode'}
class="rounded p-1.5 text-ink-2 transition-colors hover:bg-muted hover:text-ink"
>
{#if theme === 'dark'}
<!-- Sun icon — click to go light -->
<svg
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
aria-hidden="true"
>
<circle cx="12" cy="12" r="4" />
<path
stroke-linecap="round"
d="M12 2v2M12 20v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M2 12h2M20 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"
/>
</svg>
{:else}
<!-- Moon icon — click to go dark -->
<svg
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z"
/>
</svg>
{/if}
</button>

View File

@@ -0,0 +1,95 @@
<script lang="ts">
import { untrack } from 'svelte';
import TagInput from '$lib/components/TagInput.svelte';
import { m } from '$lib/paraglide/messages.js';
let {
tags = $bindable<string[]>([]),
initialTitle = '',
initialDocumentLocation = '',
initialSummary = '',
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">
<h2 class="mb-5 text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.doc_section_description()}
</h2>
<div class="space-y-5">
{#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>
<label for="documentLocation" class="mb-1 block text-sm font-medium text-ink-2"
>{m.form_label_archive_location()}</label
>
<input
id="documentLocation"
type="text"
name="documentLocation"
value={initialDocumentLocation}
placeholder={m.form_placeholder_archive_location()}
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:border-ink focus:ring-ink"
/>
<p class="mt-1 text-xs text-ink-3">{m.form_helper_archive_location()}</p>
</div>
<!-- Schlagworte -->
<div>
<p class="mb-1 block text-sm font-medium text-ink-2">{m.form_label_tags()}</p>
<TagInput bind:tags={tags} />
<input type="hidden" name="tags" value={tags.join(',')} />
</div>
<!-- Inhalt -->
<div>
<label for="summary" class="mb-1 block text-sm font-medium text-ink-2"
>{m.form_label_content()}</label
>
<textarea
id="summary"
name="summary"
rows="5"
placeholder={m.form_placeholder_content()}
class="block w-full rounded border border-line p-2 font-serif text-sm shadow-sm focus:border-ink focus:ring-ink"
>{initialSummary}</textarea
>
</div>
</div>
</div>

View File

@@ -0,0 +1,19 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
let { initialTranscription = '' }: { initialTranscription?: string } = $props();
</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.form_label_transcription()}
</h2>
<textarea
id="transcription"
name="transcription"
rows="12"
placeholder={m.form_placeholder_transcription()}
class="block w-full rounded border border-line p-2 font-serif text-sm shadow-sm focus:border-ink focus:ring-ink"
>{initialTranscription}</textarea
>
</div>

View File

@@ -0,0 +1,115 @@
<script lang="ts">
import { untrack } from 'svelte';
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
import PersonMultiSelect from '$lib/components/PersonMultiSelect.svelte';
import { isoToGerman, handleGermanDateInput } from '$lib/utils/date';
import { m } from '$lib/paraglide/messages.js';
interface Person {
id: string;
firstName: string;
lastName: string;
}
let {
senderId = $bindable(''),
selectedReceivers = $bindable<Person[]>([]),
initialDateIso = '',
initialLocation = '',
initialSenderName = '',
suggestedDateIso = '',
suggestedSenderName = ''
}: {
senderId?: string;
selectedReceivers?: Person[];
initialDateIso?: string;
initialLocation?: string;
initialSenderName?: string;
suggestedDateIso?: string;
suggestedSenderName?: string;
} = $props();
let dateDisplay = $state(untrack(() => isoToGerman(initialDateIso)));
let dateIso = $state(untrack(() => initialDateIso));
let dateDirty = $state(false);
const dateInvalid = $derived(dateDirty && dateDisplay.length > 0 && dateIso === '');
function handleDateInput(e: Event) {
const result = handleGermanDateInput(e);
dateDisplay = result.display;
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">
<h2 class="mb-5 text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.doc_section_who_when()}
</h2>
<div class="grid grid-cols-1 gap-5 md:grid-cols-2">
<!-- Datum -->
<div>
<label for="documentDate" class="mb-1 block text-sm font-medium text-ink-2"
>{m.form_label_date()}</label
>
<input
id="documentDate"
type="text"
inputmode="numeric"
value={dateDisplay}
oninput={handleDateInput}
placeholder={m.form_placeholder_date()}
maxlength="10"
class="block w-full rounded border border-line p-2 text-sm shadow-sm
{dateInvalid ? 'border-red-400 focus:border-red-500 focus:ring-red-500' : 'focus:border-ink focus:ring-ink'}"
aria-describedby={dateInvalid ? 'date-error' : undefined}
/>
<input type="hidden" name="documentDate" value={dateIso} />
{#if dateInvalid}
<p id="date-error" class="mt-1 text-xs text-red-600">{m.form_date_error()}</p>
{/if}
</div>
<!-- Ort -->
<div>
<label for="location" class="mb-1 block text-sm font-medium text-ink-2"
>{m.form_label_location()}</label
>
<input
id="location"
type="text"
name="location"
value={initialLocation}
placeholder={m.form_placeholder_location()}
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:border-ink focus:ring-ink"
/>
</div>
<!-- Absender -->
<div>
<PersonTypeahead
name="senderId"
label={m.form_label_sender()}
bind:value={senderId}
initialName={initialSenderName}
suggestedName={suggestedSenderName}
/>
</div>
<!-- Empfänger -->
<div>
<p class="mb-1 block text-sm font-medium text-ink-2">{m.form_label_receivers()}</p>
<PersonMultiSelect bind:selectedPersons={selectedReceivers} />
</div>
</div>
</div>

View File

@@ -0,0 +1,24 @@
<script lang="ts">
let {
groups,
selectedGroupIds = []
}: {
groups: { id: string; name: string }[];
selectedGroupIds?: string[];
} = $props();
</script>
<div class="flex flex-wrap gap-3">
{#each groups as group (group.id)}
<label class="inline-flex items-center gap-2 text-sm text-ink-2">
<input
type="checkbox"
name="groupIds"
value={group.id}
checked={selectedGroupIds.includes(group.id)}
class="rounded border-line text-ink focus:ring-accent"
/>
{group.name}
</label>
{/each}
</div>

View File

@@ -0,0 +1,31 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
let { required = false }: { required?: boolean } = $props();
</script>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<label class="block">
<span class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.profile_label_new_password()}
</span>
<input
type="password"
name="newPassword"
required={required}
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:border-ink focus:outline-none"
/>
</label>
<label class="block">
<span class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.profile_label_new_password_confirm()}
</span>
<input
type="password"
name="confirmPassword"
required={required}
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:border-ink focus:outline-none"
/>
</label>
</div>

View File

@@ -0,0 +1,95 @@
<script lang="ts">
import { untrack } from 'svelte';
import { isoToGerman, handleGermanDateInput } from '$lib/utils/date';
import { m } from '$lib/paraglide/messages.js';
let {
firstName = '',
lastName = '',
birthDate = '',
email = '',
contact = ''
}: {
firstName?: string;
lastName?: string;
birthDate?: string;
email?: string;
contact?: string;
} = $props();
let birthDateDisplay = $state(untrack(() => isoToGerman(birthDate)));
let birthDateIso = $state(untrack(() => birthDate));
function handleBirthDateInput(e: Event) {
const result = handleGermanDateInput(e);
birthDateDisplay = result.display;
birthDateIso = result.iso;
}
</script>
<div class="space-y-4">
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<label class="block">
<span class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.profile_label_first_name()}
</span>
<input
type="text"
name="firstName"
value={firstName}
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:border-ink focus:outline-none"
/>
</label>
<label class="block">
<span class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.profile_label_last_name()}
</span>
<input
type="text"
name="lastName"
value={lastName}
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:border-ink focus:outline-none"
/>
</label>
</div>
<label class="block">
<span class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.profile_label_birth_date()}
</span>
<input
type="text"
placeholder="TT.MM.JJJJ"
value={birthDateDisplay}
oninput={handleBirthDateInput}
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:border-ink focus:outline-none"
/>
<input type="hidden" name="birthDate" value={birthDateIso} />
</label>
<label class="block">
<span class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.profile_label_email()}
</span>
<input
type="email"
name="email"
value={email}
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:border-ink focus:outline-none"
/>
</label>
<label class="block">
<span class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.profile_label_contact()}
</span>
<textarea
name="contact"
rows="3"
placeholder={m.profile_contact_placeholder()}
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:border-ink focus:outline-none"
>{contact}</textarea
>
</label>
</div>

View File

@@ -9,6 +9,7 @@ export type ErrorCode =
| 'DOCUMENT_NO_FILE' | 'DOCUMENT_NO_FILE'
| 'FILE_NOT_FOUND' | 'FILE_NOT_FOUND'
| 'FILE_UPLOAD_FAILED' | 'FILE_UPLOAD_FAILED'
| 'UNSUPPORTED_FILE_TYPE'
| 'USER_NOT_FOUND' | 'USER_NOT_FOUND'
| 'EMAIL_ALREADY_IN_USE' | 'EMAIL_ALREADY_IN_USE'
| 'WRONG_CURRENT_PASSWORD' | 'WRONG_CURRENT_PASSWORD'
@@ -54,6 +55,8 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string {
return m.error_file_not_found(); return m.error_file_not_found();
case 'FILE_UPLOAD_FAILED': case 'FILE_UPLOAD_FAILED':
return m.error_file_upload_failed(); return m.error_file_upload_failed();
case 'UNSUPPORTED_FILE_TYPE':
return m.error_unsupported_file_type();
case 'USER_NOT_FOUND': case 'USER_NOT_FOUND':
return m.error_user_not_found(); return m.error_user_not_found();
case 'EMAIL_ALREADY_IN_USE': case 'EMAIL_ALREADY_IN_USE':

View File

@@ -468,6 +468,54 @@ export interface paths {
patch?: never; patch?: never;
trace?: 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": { "/api/documents/search": {
parameters: { parameters: {
query?: never; 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: { importStatus: {
parameters: { parameters: {
query?: never; 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

@@ -1,20 +1 @@
/** export { isoToGerman, germanToIso } from '$lib/utils/date';
* Converts an ISO date string (YYYY-MM-DD) to German display format (DD.MM.YYYY).
* Returns an empty string for invalid or empty input.
*/
export function isoToGerman(iso: string): string {
if (!iso || !/^\d{4}-\d{2}-\d{2}$/.test(iso)) return '';
const [y, m, d] = iso.split('-');
return `${d}.${m}.${y}`;
}
/**
* Converts a German date string (DD.MM.YYYY) to ISO format (YYYY-MM-DD).
* Returns an empty string for invalid or empty input.
*/
export function germanToIso(german: string): string {
const match = german.match(/^(\d{2})\.(\d{2})\.(\d{4})$/);
if (!match) return '';
const [, d, m, y] = match;
return `${y}-${m}-${d}`;
}

View File

@@ -9,3 +9,44 @@ export function formatDate(isoDate: string): string {
year: 'numeric' year: 'numeric'
}).format(new Date(isoDate + 'T12:00:00')); }).format(new Date(isoDate + 'T12:00:00'));
} }
/**
* Converts an ISO date string (YYYY-MM-DD) to German display format (DD.MM.YYYY).
* Returns an empty string for invalid or empty input.
*/
export function isoToGerman(iso: string): string {
if (!iso || !/^\d{4}-\d{2}-\d{2}$/.test(iso)) return '';
const [y, m, d] = iso.split('-');
return `${d}.${m}.${y}`;
}
/**
* Converts a German date string (DD.MM.YYYY) to ISO format (YYYY-MM-DD).
* Returns an empty string for invalid or empty input.
*/
export function germanToIso(german: string): string {
const match = german.match(/^(\d{2})\.(\d{2})\.(\d{4})$/);
if (!match) return '';
const [, d, m, y] = match;
return `${y}-${m}-${d}`;
}
/**
* Handles a date input event for German-format date fields (DD.MM.YYYY).
* Strips non-digits, formats with dots, mutates the input's displayed value,
* and returns the display string and its ISO equivalent.
*/
export function handleGermanDateInput(e: Event): { display: string; iso: string } {
const input = e.target as HTMLInputElement;
const digits = input.value.replace(/\D/g, '').slice(0, 8);
let display: string;
if (digits.length <= 2) {
display = digits;
} else if (digits.length <= 4) {
display = `${digits.slice(0, 2)}.${digits.slice(2)}`;
} else {
display = `${digits.slice(0, 2)}.${digits.slice(2, 4)}.${digits.slice(4)}`;
}
input.value = display;
return { display, iso: germanToIso(display) };
}

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

@@ -1,10 +1,11 @@
<script lang="ts"> <script lang="ts">
import './layout.css'; import './layout.css';
import { enhance } from '$app/forms';
import { page } from '$app/state'; import { page } from '$app/state';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { m } from '$lib/paraglide/messages.js';
import { setLocale, getLocale } from '$lib/paraglide/runtime'; import { setLocale, getLocale } from '$lib/paraglide/runtime';
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
import AppNav from './AppNav.svelte';
import UserMenu from './UserMenu.svelte';
let { children, data } = $props(); let { children, data } = $props();
@@ -23,7 +24,9 @@ onMount(() => {
hydrated = true; hydrated = true;
}); });
let userMenuOpen = $state(false); const isAuthPage = $derived(
['/login', '/forgot-password', '/reset-password'].some((p) => page.url.pathname.startsWith(p))
);
const userInitials = $derived.by(() => { const userInitials = $derived.by(() => {
const first = data?.user?.firstName?.[0]; const first = data?.user?.firstName?.[0];
@@ -31,166 +34,49 @@ const userInitials = $derived.by(() => {
if (first && last) return (first + last).toUpperCase(); if (first && last) return (first + last).toUpperCase();
return null; return null;
}); });
function clickOutside(node: HTMLElement) {
const handleClick = (event: MouseEvent) => {
if (node && !node.contains(event.target as Node) && !event.defaultPrevented) {
userMenuOpen = false;
}
};
document.addEventListener('click', handleClick, true);
return () => {
document.removeEventListener('click', handleClick, true);
};
}
</script> </script>
<div class="min-h-screen bg-white" data-hydrated={hydrated || undefined}> <div class="min-h-screen bg-canvas" data-hydrated={hydrated || undefined}>
{#if !['/login', '/forgot-password', '/reset-password'].some((p) => page.url.pathname.startsWith(p))} {#if !isAuthPage}
<header class="sticky top-0 z-50 border-b border-gray-100 bg-white"> <header class="sticky top-0 z-50 border-b border-line-2 bg-surface">
<!-- De Gruyter Brill purple accent strip --> <!-- De Gruyter Brill purple accent strip -->
<div class="h-1 bg-brand-purple"></div> <div class="h-1 bg-brand-purple"></div>
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"> <div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div class="flex h-16 justify-between"> <div class="flex h-16 justify-between">
<!-- Logo & Nav --> <!-- Logo & Nav -->
<div class="flex"> <AppNav isAdmin={isAdmin} />
<div class="mr-10 flex flex-shrink-0 items-center">
<a href="/" class="flex items-center" aria-label="Familienarchiv">
<span class="font-sans text-xl font-bold tracking-widest text-brand-navy uppercase"
>Familienarchiv</span
>
</a>
</div>
<nav class="hidden items-center sm:flex sm:space-x-1">
<a
href="/"
class="inline-flex items-center px-3 py-1.5 font-sans text-xs font-bold tracking-widest uppercase transition-colors
{page.url.pathname === '/' || page.url.pathname.startsWith('/documents')
? 'rounded bg-brand-purple/15 text-brand-navy'
: 'rounded text-gray-500 hover:bg-brand-sand/60 hover:text-brand-navy'}"
>
{m.nav_documents()}
</a>
<a
href="/persons"
class="inline-flex items-center px-3 py-1.5 font-sans text-xs font-bold tracking-widest uppercase transition-colors
{page.url.pathname.startsWith('/persons')
? 'rounded bg-brand-purple/15 text-brand-navy'
: 'rounded text-gray-500 hover:bg-brand-sand/60 hover:text-brand-navy'}"
>
{m.nav_persons()}
</a>
<a
href="/conversations"
class="inline-flex items-center px-3 py-1.5 font-sans text-xs font-bold tracking-widest uppercase transition-colors
{page.url.pathname.startsWith('/conversations')
? 'rounded bg-brand-purple/15 text-brand-navy'
: 'rounded text-gray-500 hover:bg-brand-sand/60 hover:text-brand-navy'}"
>
{m.nav_conversations()}
</a>
{#if isAdmin}
<a
href="/admin"
class="inline-flex items-center px-3 py-1.5 font-sans text-xs font-bold tracking-widest uppercase transition-colors
{page.url.pathname.startsWith('/admin')
? 'rounded bg-brand-purple/15 text-brand-navy'
: 'rounded text-gray-500 hover:bg-brand-sand/60 hover:text-brand-navy'}"
>
{m.nav_admin()}
</a>
{/if}
</nav>
</div>
<!-- Right Side --> <!-- Right Side -->
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<!-- Language selector --> <!-- Language selector -->
<div class="flex items-center gap-1 border-r border-gray-200 pr-3"> <div class="flex items-center gap-1 border-r border-line pr-3">
{#each locales as locale (locale)} {#each locales as locale (locale)}
<button <button
type="button" type="button"
onclick={() => setLocale(localeMap[locale])} onclick={() => setLocale(localeMap[locale])}
class="px-1.5 py-1 font-sans text-xs tracking-widest transition-colors class="px-1.5 py-1 font-sans text-xs tracking-widest transition-colors
{activeLocale === locale {activeLocale === locale
? 'font-bold text-brand-navy' ? 'font-bold text-ink'
: 'font-normal text-gray-400 hover:text-brand-navy'}" : 'font-normal text-ink-3 hover:text-ink'}"
> >
{locale} {locale}
</button> </button>
{/each} {/each}
</div> </div>
<!-- User menu --> <!-- Theme toggle -->
<div <ThemeToggle />
class="relative"
{@attach clickOutside}
onkeydown={(e) => { if (e.key === 'Escape') userMenuOpen = false; }}
role="none"
>
{#if userInitials}
<button
type="button"
aria-expanded={userMenuOpen}
aria-haspopup="true"
onclick={() => (userMenuOpen = !userMenuOpen)}
class="flex h-8 w-8 items-center justify-center rounded-full bg-brand-navy font-sans text-xs font-bold text-white transition-opacity hover:opacity-80"
>
{userInitials}
</button>
{:else}
<button
type="button"
aria-label={m.nav_profile()}
aria-expanded={userMenuOpen}
aria-haspopup="true"
onclick={() => (userMenuOpen = !userMenuOpen)}
class="inline-flex items-center gap-1.5 px-3 py-2 font-sans text-xs font-bold tracking-widest text-gray-400 uppercase transition-colors hover:text-brand-navy"
>
<img
src="/degruyter-icons/Simple/Small-16px/SVG/Action/Account-SM.svg"
alt=""
aria-hidden="true"
class="h-4 w-4 opacity-50"
/>
</button>
{/if}
{#if userMenuOpen} <!-- User menu -->
<div <UserMenu userInitials={userInitials} />
class="absolute top-full right-0 z-50 mt-1 min-w-[10rem] rounded-sm border border-brand-sand bg-white shadow-md"
>
<a
href="/profile"
onclick={() => (userMenuOpen = false)}
class="block px-4 py-2.5 font-sans text-xs font-bold tracking-widest text-gray-600 uppercase transition-colors hover:bg-brand-sand/40 hover:text-brand-navy"
>
{m.nav_profile()}
</a>
<div class="border-t border-brand-sand">
<form action="/logout" method="POST" use:enhance>
<button
type="submit"
class="w-full px-4 py-2.5 text-left font-sans text-xs font-bold tracking-widest text-gray-400 uppercase transition-colors hover:bg-brand-sand/40 hover:text-brand-navy"
>
{m.nav_logout()}
</button>
</form>
</div>
</div>
{/if}
</div>
</div> </div>
</div> </div>
</div> </div>
</header> </header>
{/if} {/if}
<main class="py-6"> <main class={isAuthPage ? '' : 'py-6'}>
{@render children()} {@render children()}
</main> </main>
</div> </div>

View File

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

View File

@@ -1,12 +1,11 @@
<script lang="ts"> <script lang="ts">
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import TagInput from '$lib/components/TagInput.svelte';
import { slide } from 'svelte/transition';
import { untrack } from 'svelte'; import { untrack } from 'svelte';
import { SvelteURLSearchParams } from 'svelte/reactivity'; 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'; import { m } from '$lib/paraglide/messages.js';
import { formatDate } from '$lib/utils/date';
let { data } = $props(); let { data } = $props();
@@ -18,8 +17,6 @@ let senderId = $state(untrack(() => data.filters?.senderId || ''));
let receiverId = $state(untrack(() => data.filters?.receiverId || '')); let receiverId = $state(untrack(() => data.filters?.receiverId || ''));
let tagNames = $state<string[]>(untrack(() => data.filters?.tags || [])); let tagNames = $state<string[]>(untrack(() => data.filters?.tags || []));
let searchTimer: ReturnType<typeof setTimeout>;
const hasAdvancedFilters = (filters: typeof data.filters) => const hasAdvancedFilters = (filters: typeof data.filters) =>
(filters?.tags?.length ?? 0) > 0 || (filters?.tags?.length ?? 0) > 0 ||
!!filters?.senderId || !!filters?.senderId ||
@@ -29,27 +26,22 @@ const hasAdvancedFilters = (filters: typeof data.filters) =>
let showAdvanced = $state(untrack(() => hasAdvancedFilters(data.filters))); let showAdvanced = $state(untrack(() => hasAdvancedFilters(data.filters)));
let searchTimer: ReturnType<typeof setTimeout>;
function triggerSearch() { function triggerSearch() {
const params = new SvelteURLSearchParams(); const params = new SvelteURLSearchParams();
if (q) params.set('q', q); if (q) params.set('q', q);
if (from) params.set('from', from); if (from) params.set('from', from);
if (to) params.set('to', to); if (to) params.set('to', to);
if (senderId) params.set('senderId', senderId); if (senderId) params.set('senderId', senderId);
if (receiverId) params.set('receiverId', receiverId); if (receiverId) params.set('receiverId', receiverId);
if (tagNames) tagNames.forEach((tag) => params.append('tag', tag)); if (tagNames) tagNames.forEach((tag) => params.append('tag', tag));
goto(`/?${params.toString()}`, { keepFocus: true, noScroll: true });
goto(`/?${params.toString()}`, {
keepFocus: true,
noScroll: true
});
} }
function handleTextSearch() { function handleTextSearch() {
clearTimeout(searchTimer); clearTimeout(searchTimer);
searchTimer = setTimeout(() => { searchTimer = setTimeout(() => triggerSearch(), 500);
triggerSearch();
}, 500);
} }
// Trigger search when tags change // Trigger search when tags change
@@ -75,293 +67,54 @@ $effect(() => {
}); });
</script> </script>
<!-- Outer Container: Matches the 'Sand' background of the layout -->
<main class="mx-auto max-w-7xl py-8 font-sans sm:px-6 lg:px-8"> <main class="mx-auto max-w-7xl py-8 font-sans sm:px-6 lg:px-8">
<!-- SEARCH & FILTER CARD --> <SearchFilterBar
<div class="mb-8 rounded-sm border border-brand-sand bg-white p-6 shadow-sm"> bind:q={q}
<!-- ROW 1: Main Search (One Line) --> bind:from={from}
<div class="flex items-center gap-4"> bind:to={to}
<!-- Full Text Search --> bind:senderId={senderId}
<div class="relative flex-1"> bind:receiverId={receiverId}
<input bind:tagNames={tagNames}
type="text" bind:showAdvanced={showAdvanced}
bind:value={q} initialSenderName={data.initialValues?.senderName}
oninput={handleTextSearch} initialReceiverName={data.initialValues?.receiverName}
onfocus={() => (qFocused = true)} onSearch={handleTextSearch}
onblur={() => (qFocused = false)} onfocus={() => (qFocused = true)}
placeholder={m.docs_search_placeholder()} onblur={() => (qFocused = false)}
class="block w-full border-gray-300 py-2.5 pr-10 pl-3 placeholder-gray-400 shadow-sm focus:border-brand-navy focus:ring-brand-navy" />
/>
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Mag-Glass-MD.svg"
alt=""
aria-hidden="true"
class="h-4 w-4 opacity-40"
/>
</div>
</div>
<!-- Toggle Advanced Button --> {#if data.canWrite}
<button <DropZone />
onclick={() => (showAdvanced = !showAdvanced)} {/if}
class="flex items-center gap-2 border border-gray-300 bg-gray-50 px-4 py-2.5 text-sm font-bold tracking-wide text-gray-600 uppercase transition hover:bg-gray-100 hover:text-brand-navy"
> {#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 <img
src="/degruyter-icons/Simple/Small-16px/SVG/Action/Chevron/Chevron-Down-SM.svg" src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Info/Block/Info-Block-Border-MD.svg"
alt="" alt=""
aria-hidden="true" aria-hidden="true"
class="h-4 w-4 transform transition-transform duration-200 {showAdvanced ? 'rotate-180' : ''}" class="h-6 w-6 opacity-60"
/> />
{m.docs_btn_filter()} <div>
</button> <p class="font-sans text-xs font-bold tracking-widest text-ink uppercase">
{m.enrich_needs_metadata_title()}
<!-- Reset Button --> </p>
<a <p class="mt-0.5 font-serif text-sm text-ink-2">
href="/" {m.enrich_needs_metadata_count({ count: data.incompleteCount })}
class="flex items-center justify-center border border-transparent px-3 py-2.5 text-gray-400 transition hover:text-red-500"
title={m.docs_btn_reset_title()}
>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Close-MD.svg"
alt=""
aria-hidden="true"
class="h-5 w-5 opacity-40"
/>
</a>
</div>
<!-- ROW 2: Advanced Filters (Collapsible) -->
{#if showAdvanced}
<div
transition:slide
class="mt-6 grid grid-cols-1 gap-6 border-t border-gray-100 pt-6 md:grid-cols-12"
>
<!-- Tag Filter -->
<div class="md:col-span-12">
<p class="mb-2 block text-xs font-bold tracking-widest text-gray-500 uppercase">
{m.docs_filter_label_tags()}
</p> </p>
<TagInput bind:tags={tagNames} allowCreation={false} />
</div>
<!-- Sender -->
<div class="md:col-span-3">
<div
class="[&_input]:border-gray-300 [&_input]:py-2.5 [&_label]:mb-2 [&_label]:text-xs [&_label]:font-bold [&_label]:tracking-widest [&_label]:text-gray-500 [&_label]:uppercase"
>
<PersonTypeahead
name="senderId"
label={m.docs_filter_label_sender()}
bind:value={senderId}
initialName={data.initialValues?.senderName}
onchange={triggerSearch}
/>
</div>
</div>
<!-- Receiver -->
<div class="md:col-span-3">
<div
class="[&_input]:border-gray-300 [&_input]:py-2.5 [&_label]:mb-2 [&_label]:text-xs [&_label]:font-bold [&_label]:tracking-widest [&_label]:text-gray-500 [&_label]:uppercase"
>
<PersonTypeahead
name="receiverId"
label={m.docs_filter_label_receivers()}
bind:value={receiverId}
initialName={data.initialValues?.receiverName}
onchange={triggerSearch}
/>
</div>
</div>
<!-- Dates -->
<div class="grid grid-cols-2 gap-4 md:col-span-6">
<div>
<label
for="from"
class="mb-2 block text-xs font-bold tracking-widest text-gray-500 uppercase"
>{m.docs_filter_label_from()}</label
>
<input
type="date"
id="from"
bind:value={from}
onchange={triggerSearch}
class="block w-full border-gray-300 py-2.5 text-sm shadow-sm"
/>
</div>
<div>
<label
for="to"
class="mb-2 block text-xs font-bold tracking-widest text-gray-500 uppercase"
>{m.docs_filter_label_to()}</label
>
<input
type="date"
id="to"
bind:value={to}
onchange={triggerSearch}
class="block w-full border-gray-300 py-2.5 text-sm shadow-sm"
/>
</div>
</div> </div>
</div> </div>
{/if} <span
</div> class="font-sans text-xs font-bold tracking-widest text-ink uppercase transition-colors hover:text-ink-2"
<!-- DOCUMENT LIST HEADER -->
<div class="mb-2 flex justify-end">
{#if data.canWrite}
<a
href="/documents/new"
class="inline-flex items-center gap-1 text-sm font-medium text-brand-navy/60 transition-colors hover:text-brand-navy"
> >
<img {m.enrich_needs_metadata_cta()}
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Add/Add-General-MD.svg" </span>
alt="" </a>
aria-hidden="true" {/if}
class="h-4 w-4"
/>
{m.docs_btn_new()}
</a>
{/if}
</div>
<!-- DOCUMENT LIST --> <DocumentList documents={data.documents ?? []} canWrite={data.canWrite} error={data.error} />
<div class="border border-brand-sand bg-white shadow-sm">
{#if data.error}
<div class="bg-red-50 p-8 text-center text-red-600">
{data.error}
</div>
{:else if data.documents && data.documents.length > 0}
<ul class="divide-y divide-gray-100">
{#each data.documents as doc (doc.id)}
<li class="group transition-colors duration-200 hover:bg-brand-sand/10">
<!-- LINK TO DETAIL PAGE -->
<a href="/documents/{doc.id}" class="block p-6">
<div class="flex flex-col gap-6 sm:flex-row">
<!-- Main Info -->
<div class="flex-1">
<div class="mb-2 flex items-baseline justify-between">
<!-- Title: Serif & Brand Navy -->
<h3
class="font-serif text-xl font-medium text-brand-navy decoration-brand-mint decoration-2 underline-offset-4 group-hover:underline"
>
{doc.title || doc.originalFilename}
</h3>
</div>
<!-- Metadata Row -->
<div class="mb-4 flex flex-wrap gap-6 font-sans text-sm text-gray-500">
<div class="flex items-center">
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Calendar/Calendar-Add-MD.svg"
alt=""
aria-hidden="true"
class="mr-1.5 h-4 w-4"
/>
{doc.documentDate ? formatDate(doc.documentDate) : '—'}
</div>
{#if doc.location}
<div class="flex items-center">
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Location-MD.svg"
alt=""
aria-hidden="true"
class="mr-1.5 h-4 w-4"
/>
{doc.location}
</div>
{/if}
</div>
<!-- Sender/Receiver Info -->
<div class="grid grid-cols-1 gap-4 font-serif text-sm sm:grid-cols-2">
<div class="flex items-baseline">
<span
class="w-10 font-sans text-xs font-bold tracking-wide text-gray-400 uppercase"
>{m.docs_list_from()}</span
>
{#if doc.sender}
<span class="text-gray-900"
>{doc.sender.firstName} {doc.sender.lastName}</span
>
{:else}
<span class="text-gray-400 italic">{m.docs_list_unknown()}</span>
{/if}
</div>
<div class="flex items-baseline">
<span
class="w-10 font-sans text-xs font-bold tracking-wide text-gray-400 uppercase"
>{m.docs_list_to()}</span
>
{#if doc.receivers && doc.receivers.length > 0}
<span class="text-gray-900">
{doc.receivers.map((p) => p.firstName + ' ' + p.lastName).join(', ')}
</span>
{:else}
<span class="text-gray-400 italic">{m.docs_list_unknown()}</span>
{/if}
</div>
</div>
<!-- Tags Display -->
{#if doc.tags && doc.tags.length > 0}
<div class="mt-4 flex flex-wrap gap-2 pt-3">
{#each doc.tags as tag (tag.id)}
<button
type="button"
class="relative z-10 inline-flex cursor-pointer items-center rounded bg-brand-sand/30 px-2 py-1 text-[10px] font-bold tracking-widest text-brand-navy uppercase transition-colors hover:bg-brand-navy hover:text-white"
onclick={(e) => { e.preventDefault(); e.stopPropagation(); goto(`/?tag=${encodeURIComponent(tag.name)}`); }}
>
{tag.name}
</button>
{/each}
</div>
{/if}
</div>
<!-- Arrow Icon -->
<div
class="hidden items-center text-gray-300 transition-colors group-hover:text-brand-mint sm:flex"
>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Arrow/Arrow-Right-MD.svg"
alt=""
aria-hidden="true"
class="h-6 w-6"
/>
</div>
</div>
</a>
</li>
{/each}
</ul>
{:else}
<!-- Empty State -->
<div class="p-16 text-center">
<div
class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-brand-sand/30"
>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Mag-Glass-MD.svg"
alt=""
aria-hidden="true"
class="h-6 w-6"
/>
</div>
<h3 class="font-serif text-lg font-medium text-brand-navy">{m.docs_empty_heading()}</h3>
<p class="mt-1 font-sans text-sm text-gray-500">
{m.docs_empty_text()}
</p>
<button
onclick={() => goto('/')}
class="mt-6 text-sm font-bold tracking-wide text-brand-mint uppercase transition hover:text-brand-navy"
>
{m.docs_empty_btn_clear()}
</button>
</div>
{/if}
</div>
</main> </main>

View File

@@ -0,0 +1,59 @@
<script lang="ts">
import { page } from '$app/state';
import { m } from '$lib/paraglide/messages.js';
let { isAdmin = false }: { isAdmin?: boolean } = $props();
</script>
<div class="flex">
<div class="mr-10 flex flex-shrink-0 items-center">
<a href="/" class="flex items-center" aria-label="Familienarchiv">
<span class="font-sans text-xl font-bold tracking-widest text-ink uppercase"
>Familienarchiv</span
>
</a>
</div>
<nav class="hidden items-center sm:flex sm:space-x-1">
<a
href="/"
class="inline-flex items-center px-3 py-1.5 font-sans text-xs font-bold tracking-widest uppercase transition-colors
{page.url.pathname === '/' || page.url.pathname.startsWith('/documents')
? 'rounded bg-nav-active text-ink'
: 'rounded text-ink-2 hover:bg-muted hover:text-ink'}"
>
{m.nav_documents()}
</a>
<a
href="/persons"
class="inline-flex items-center px-3 py-1.5 font-sans text-xs font-bold tracking-widest uppercase transition-colors
{page.url.pathname.startsWith('/persons')
? 'rounded bg-nav-active text-ink'
: 'rounded text-ink-2 hover:bg-muted hover:text-ink'}"
>
{m.nav_persons()}
</a>
<a
href="/conversations"
class="inline-flex items-center px-3 py-1.5 font-sans text-xs font-bold tracking-widest uppercase transition-colors
{page.url.pathname.startsWith('/conversations')
? 'rounded bg-nav-active text-ink'
: 'rounded text-ink-2 hover:bg-muted hover:text-ink'}"
>
{m.nav_conversations()}
</a>
{#if isAdmin}
<a
href="/admin"
class="inline-flex items-center px-3 py-1.5 font-sans text-xs font-bold tracking-widest uppercase transition-colors
{page.url.pathname.startsWith('/admin')
? 'rounded bg-nav-active text-ink'
: 'rounded text-ink-2 hover:bg-muted hover:text-ink'}"
>
{m.nav_admin()}
</a>
{/if}
</nav>
</div>

View File

@@ -0,0 +1,177 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { m } from '$lib/paraglide/messages.js';
import { formatDate } from '$lib/utils/date';
let {
documents,
canWrite,
error
}: {
documents: {
id: string;
title?: string | null;
originalFilename: string;
documentDate?: string | null;
location?: string | null;
sender?: { firstName: string; lastName: string } | null;
receivers?: { firstName: string; lastName: string }[];
tags?: { id: string; name: string }[];
}[];
canWrite: boolean;
error?: string | null;
} = $props();
</script>
<!-- DOCUMENT LIST HEADER -->
<div class="mb-2 flex justify-end">
{#if canWrite}
<a
href="/documents/new"
class="inline-flex items-center gap-1 text-sm font-medium text-ink/60 transition-colors hover:text-ink"
>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Add/Add-General-MD.svg"
alt=""
aria-hidden="true"
class="h-4 w-4"
/>
{m.docs_btn_new()}
</a>
{/if}
</div>
<!-- DOCUMENT LIST -->
<div class="border border-line bg-surface shadow-sm">
{#if error}
<div class="bg-red-50 p-8 text-center text-red-600">
{error}
</div>
{:else if documents.length > 0}
<ul class="divide-y divide-line-2">
{#each documents as doc (doc.id)}
<li class="group transition-colors duration-200 hover:bg-muted/50">
<a href="/documents/{doc.id}" class="block p-6">
<div class="flex flex-col gap-6 sm:flex-row">
<!-- Main Info -->
<div class="flex-1">
<div class="mb-2 flex items-baseline justify-between">
<h3
class="font-serif text-xl font-medium text-ink decoration-brand-mint decoration-2 underline-offset-4 group-hover:underline"
>
{doc.title || doc.originalFilename}
</h3>
</div>
<!-- Metadata Row -->
<div class="mb-4 flex flex-wrap gap-6 font-sans text-sm text-ink-2">
<div class="flex items-center">
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Calendar/Calendar-Add-MD.svg"
alt=""
aria-hidden="true"
class="mr-1.5 h-4 w-4"
/>
{doc.documentDate ? formatDate(doc.documentDate) : '—'}
</div>
{#if doc.location}
<div class="flex items-center">
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Location-MD.svg"
alt=""
aria-hidden="true"
class="mr-1.5 h-4 w-4"
/>
{doc.location}
</div>
{/if}
</div>
<!-- Sender/Receiver Info -->
<div class="grid grid-cols-1 gap-4 font-serif text-sm sm:grid-cols-2">
<div class="flex items-baseline">
<span
class="w-10 font-sans text-xs font-bold tracking-wide text-ink-3 uppercase"
>{m.docs_list_from()}</span
>
{#if doc.sender}
<span class="text-ink">{doc.sender.firstName} {doc.sender.lastName}</span>
{:else}
<span class="text-ink-3 italic">{m.docs_list_unknown()}</span>
{/if}
</div>
<div class="flex items-baseline">
<span
class="w-10 font-sans text-xs font-bold tracking-wide text-ink-3 uppercase"
>{m.docs_list_to()}</span
>
{#if doc.receivers && doc.receivers.length > 0}
<span class="text-ink">
{doc.receivers.map((p) => p.firstName + ' ' + p.lastName).join(', ')}
</span>
{:else}
<span class="text-ink-3 italic">{m.docs_list_unknown()}</span>
{/if}
</div>
</div>
<!-- Tags Display -->
{#if doc.tags && doc.tags.length > 0}
<div class="mt-4 flex flex-wrap gap-2 pt-3">
{#each doc.tags as tag (tag.id)}
<button
type="button"
class="relative z-10 inline-flex cursor-pointer items-center rounded bg-muted px-2 py-1 text-[10px] font-bold tracking-widest text-ink uppercase transition-colors hover:bg-primary hover:text-white"
onclick={(e) => {
e.preventDefault();
e.stopPropagation();
goto(`/?tag=${encodeURIComponent(tag.name)}`);
}}
>
{tag.name}
</button>
{/each}
</div>
{/if}
</div>
<!-- Arrow Icon -->
<div
class="hidden items-center text-ink-3 transition-colors group-hover:text-accent sm:flex"
>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Arrow/Arrow-Right-MD.svg"
alt=""
aria-hidden="true"
class="h-6 w-6"
/>
</div>
</div>
</a>
</li>
{/each}
</ul>
{:else}
<!-- Empty State -->
<div class="p-16 text-center">
<div class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-muted">
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Mag-Glass-MD.svg"
alt=""
aria-hidden="true"
class="h-6 w-6"
/>
</div>
<h3 class="font-serif text-lg font-medium text-ink">{m.docs_empty_heading()}</h3>
<p class="mt-1 font-sans text-sm text-ink-2">
{m.docs_empty_text()}
</p>
<button
onclick={() => goto('/')}
class="mt-6 text-sm font-bold tracking-wide text-accent uppercase transition hover:text-ink"
>
{m.docs_empty_btn_clear()}
</button>
</div>
{/if}
</div>

View File

@@ -0,0 +1,207 @@
<script lang="ts">
import { invalidateAll } from '$app/navigation';
import { m } from '$lib/paraglide/messages.js';
import { getErrorMessage } from '$lib/errors';
const ACCEPTED_TYPES = ['application/pdf', 'image/jpeg', 'image/png', 'image/tiff'];
let isDragging = $state(false);
let windowDragging = $state(false);
let dragCounter = 0;
let isUploading = $state(false);
let uploadProgress = $state(0);
let uploadMessages = $state<{ text: string; isError: boolean; link?: string }[]>([]);
let fileInput: HTMLInputElement;
function handleDragOver(e: DragEvent) {
e.preventDefault();
isDragging = true;
}
function handleDragLeave() {
isDragging = false;
}
async function handleDrop(e: DragEvent) {
e.preventDefault();
isDragging = false;
windowDragging = false;
dragCounter = 0;
const files = Array.from(e.dataTransfer?.files ?? []);
await uploadFiles(files);
}
async function handleFileSelect(e: Event) {
const input = e.target as HTMLInputElement;
const files = Array.from(input.files ?? []);
input.value = '';
await uploadFiles(files);
}
async function uploadFiles(files: File[]) {
if (files.length === 0) return;
const messages: { text: string; isError: boolean; link?: string }[] = [];
const valid: File[] = [];
for (const file of files) {
if (!ACCEPTED_TYPES.includes(file.type)) {
messages.push({ text: m.upload_invalid_type({ filename: file.name }), isError: true });
} else {
valid.push(file);
}
}
if (valid.length === 0) {
uploadMessages = messages;
return;
}
isUploading = true;
uploadProgress = 0;
try {
const formData = new FormData();
for (const file of valid) {
formData.append('files', file);
}
const { ok, body } = await new Promise<{ ok: boolean; body: string }>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', '/api/documents/quick-upload');
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) uploadProgress = Math.round((e.loaded / e.total) * 100);
});
xhr.addEventListener('load', () => resolve({ ok: xhr.status < 300, body: xhr.responseText }));
xhr.addEventListener('error', () => reject(new Error('Network error')));
xhr.send(formData);
});
if (ok) {
const result = JSON.parse(body);
if (result.created?.length > 0) {
messages.push({ text: m.upload_success({ count: result.created.length }), isError: false });
}
for (const doc of result.updated ?? []) {
messages.push({
text: m.upload_duplicate({ filename: doc.originalFilename }),
isError: false,
link: `/documents/${doc.id}`
});
}
for (const err of result.errors ?? []) {
messages.push({
text: `${err.filename}: ${getErrorMessage(err.code)}`,
isError: true
});
}
await invalidateAll();
} else {
for (const file of valid) {
messages.push({ text: m.upload_error({ filename: file.name }), isError: true });
}
}
} finally {
isUploading = false;
uploadProgress = 0;
uploadMessages = messages;
}
}
$effect(() => {
function onWindowDragEnter(e: DragEvent) {
if (!e.dataTransfer?.types.includes('Files')) return;
dragCounter++;
windowDragging = true;
}
function onWindowDragLeave() {
dragCounter--;
if (dragCounter <= 0) {
dragCounter = 0;
windowDragging = false;
}
}
function onWindowDrop() {
dragCounter = 0;
windowDragging = false;
}
window.addEventListener('dragenter', onWindowDragEnter);
window.addEventListener('dragleave', onWindowDragLeave);
window.addEventListener('drop', onWindowDrop);
return () => {
window.removeEventListener('dragenter', onWindowDragEnter);
window.removeEventListener('dragleave', onWindowDragLeave);
window.removeEventListener('drop', onWindowDrop);
};
});
</script>
<div
role="button"
tabindex="0"
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-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()}
>
{#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">
<div
class="h-full rounded-full bg-primary transition-all duration-200"
style="width: {uploadProgress}%"
></div>
</div>
<span class="font-sans text-xs text-ink-3">{uploadProgress}%</span>
</div>
{:else}
<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>
{#if uploadMessages.length > 0}
<div class="mb-4 flex flex-col gap-1">
{#each uploadMessages as msg, i (i)}
<p
class="font-sans text-sm {msg.isError
? 'text-red-600'
: msg.link
? 'text-amber-700'
: 'text-green-700'}"
>
{msg.text}
{#if msg.link}
<a href={msg.link} class="underline hover:no-underline">{m.upload_duplicate_link()}</a>
{/if}
</p>
{/each}
</div>
{/if}
<input
bind:this={fileInput}
type="file"
multiple
accept=".pdf,.jpg,.jpeg,.png,.tif,.tiff"
class="sr-only"
onchange={handleFileSelect}
/>

View File

@@ -0,0 +1,164 @@
<script lang="ts">
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
import TagInput from '$lib/components/TagInput.svelte';
import { slide } from 'svelte/transition';
import { m } from '$lib/paraglide/messages.js';
let {
q = $bindable(''),
from = $bindable(''),
to = $bindable(''),
senderId = $bindable(''),
receiverId = $bindable(''),
tagNames = $bindable<string[]>([]),
showAdvanced = $bindable(false),
initialSenderName = '',
initialReceiverName = '',
onSearch,
onfocus,
onblur
}: {
q?: string;
from?: string;
to?: string;
senderId?: string;
receiverId?: string;
tagNames?: string[];
showAdvanced?: boolean;
initialSenderName?: string;
initialReceiverName?: string;
onSearch: () => void;
onfocus?: () => void;
onblur?: () => void;
} = $props();
</script>
<div class="mb-8 rounded-sm border border-line bg-surface p-6 shadow-sm">
<!-- ROW 1: Main Search (One Line) -->
<div class="flex items-center gap-4">
<!-- Full Text Search -->
<div class="relative flex-1">
<input
type="text"
bind:value={q}
oninput={onSearch}
onfocus={onfocus}
onblur={onblur}
placeholder={m.docs_search_placeholder()}
class="block w-full border-line py-2.5 pr-10 pl-3 placeholder-ink-3 shadow-sm focus:border-ink focus:ring-ink"
/>
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Mag-Glass-MD.svg"
alt=""
aria-hidden="true"
class="h-4 w-4 opacity-40"
/>
</div>
</div>
<!-- Toggle Advanced Button -->
<button
onclick={() => (showAdvanced = !showAdvanced)}
class="flex items-center gap-2 border border-line bg-muted px-4 py-2.5 text-sm font-bold tracking-wide text-ink-2 uppercase transition hover:bg-muted hover:text-ink"
>
<img
src="/degruyter-icons/Simple/Small-16px/SVG/Action/Chevron/Chevron-Down-SM.svg"
alt=""
aria-hidden="true"
class="h-4 w-4 transform transition-transform duration-200 {showAdvanced ? 'rotate-180' : ''}"
/>
{m.docs_btn_filter()}
</button>
<!-- Reset Button -->
<a
href="/"
class="flex items-center justify-center border border-transparent px-3 py-2.5 text-ink-3 transition hover:text-red-500"
title={m.docs_btn_reset_title()}
>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Close-MD.svg"
alt=""
aria-hidden="true"
class="h-5 w-5 opacity-40"
/>
</a>
</div>
<!-- ROW 2: Advanced Filters (Collapsible) -->
{#if showAdvanced}
<div
transition:slide
class="mt-6 grid grid-cols-1 gap-6 border-t border-line-2 pt-6 md:grid-cols-12"
>
<!-- Tag Filter -->
<div class="md:col-span-12">
<p class="mb-2 block text-xs font-bold tracking-widest text-ink-2 uppercase">
{m.docs_filter_label_tags()}
</p>
<TagInput bind:tags={tagNames} allowCreation={false} />
</div>
<!-- Sender -->
<div class="md:col-span-3">
<div
class="[&_input]:border-line [&_input]:py-2.5 [&_label]:mb-2 [&_label]:text-xs [&_label]:font-bold [&_label]:tracking-widest [&_label]:text-ink-2 [&_label]:uppercase"
>
<PersonTypeahead
name="senderId"
label={m.docs_filter_label_sender()}
bind:value={senderId}
initialName={initialSenderName}
onchange={onSearch}
/>
</div>
</div>
<!-- Receiver -->
<div class="md:col-span-3">
<div
class="[&_input]:border-line [&_input]:py-2.5 [&_label]:mb-2 [&_label]:text-xs [&_label]:font-bold [&_label]:tracking-widest [&_label]:text-ink-2 [&_label]:uppercase"
>
<PersonTypeahead
name="receiverId"
label={m.docs_filter_label_receivers()}
bind:value={receiverId}
initialName={initialReceiverName}
onchange={onSearch}
/>
</div>
</div>
<!-- Dates -->
<div class="grid grid-cols-2 gap-4 md:col-span-6">
<div>
<label
for="from"
class="mb-2 block text-xs font-bold tracking-widest text-ink-2 uppercase"
>{m.docs_filter_label_from()}</label
>
<input
type="date"
id="from"
bind:value={from}
onchange={onSearch}
class="block w-full border-line py-2.5 text-sm shadow-sm"
/>
</div>
<div>
<label for="to" class="mb-2 block text-xs font-bold tracking-widest text-ink-2 uppercase"
>{m.docs_filter_label_to()}</label
>
<input
type="date"
id="to"
bind:value={to}
onchange={onSearch}
class="block w-full border-line py-2.5 text-sm shadow-sm"
/>
</div>
</div>
</div>
{/if}
</div>

View File

@@ -0,0 +1,81 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { m } from '$lib/paraglide/messages.js';
let { userInitials }: { userInitials: string | null } = $props();
let userMenuOpen = $state(false);
function clickOutside(node: HTMLElement) {
const handleClick = (event: MouseEvent) => {
if (node && !node.contains(event.target as Node) && !event.defaultPrevented) {
userMenuOpen = false;
}
};
document.addEventListener('click', handleClick, true);
return () => {
document.removeEventListener('click', handleClick, true);
};
}
</script>
<div
class="relative"
{@attach clickOutside}
onkeydown={(e) => {
if (e.key === 'Escape') userMenuOpen = false;
}}
role="none"
>
{#if userInitials}
<button
type="button"
aria-expanded={userMenuOpen}
aria-haspopup="true"
onclick={() => (userMenuOpen = !userMenuOpen)}
class="flex h-8 w-8 items-center justify-center rounded-full bg-primary font-sans text-xs font-bold text-white transition-opacity hover:opacity-80"
>
{userInitials}
</button>
{:else}
<button
type="button"
aria-label={m.nav_profile()}
aria-expanded={userMenuOpen}
aria-haspopup="true"
onclick={() => (userMenuOpen = !userMenuOpen)}
class="inline-flex items-center gap-1.5 px-3 py-2 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase transition-colors hover:text-ink"
>
<img
src="/degruyter-icons/Simple/Small-16px/SVG/Action/Account-SM.svg"
alt=""
aria-hidden="true"
class="h-4 w-4 opacity-50"
/>
</button>
{/if}
{#if userMenuOpen}
<div
class="absolute top-full right-0 z-50 mt-1 min-w-[10rem] rounded-sm border border-line bg-overlay shadow-md"
>
<a
href="/profile"
onclick={() => (userMenuOpen = false)}
class="block px-4 py-2.5 font-sans text-xs font-bold tracking-widest text-ink-2 uppercase transition-colors hover:bg-muted hover:text-ink"
>
{m.nav_profile()}
</a>
<div class="border-t border-line">
<form action="/logout" method="POST" use:enhance>
<button
type="submit"
class="w-full px-4 py-2.5 text-left font-sans text-xs font-bold tracking-widest text-ink-3 uppercase transition-colors hover:bg-muted hover:text-ink"
>
{m.nav_logout()}
</button>
</form>
</div>
</div>
{/if}
</div>

View File

@@ -1,574 +1,74 @@
<script lang="ts"> <script lang="ts">
import { enhance } from '$app/forms';
import { slide } from 'svelte/transition'; import { slide } from 'svelte/transition';
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import UsersTab from './UsersTab.svelte';
import TagsTab from './TagsTab.svelte';
import GroupsTab from './GroupsTab.svelte';
import SystemTab from './SystemTab.svelte';
let { data, form } = $props(); let { data, form } = $props();
let activeTab = $state('users'); let activeTab = $state('users');
let editingTagId: string | null = $state(null);
let editingTagName = $state('');
let editingGroupId: string | null = $state(null);
let backfillResult: number | null = $state(null);
let backfillLoading = $state(false);
let backfillHashesResult: number | null = $state(null);
let backfillHashesLoading = $state(false);
const availablePermissions = ['WRITE_ALL', 'ADMIN', 'ADMIN_USER', 'ADMIN_TAG', 'ADMIN_PERMISSION'];
function startEditTag(tag: { id: string; name: string }) {
editingTagId = tag.id;
editingTagName = tag.name;
}
function cancelEditTag() {
editingTagId = null;
editingTagName = '';
}
function startEditGroup(id: string) {
editingGroupId = id;
}
function cancelEditGroup() {
editingGroupId = null;
}
async function backfillVersions() {
backfillLoading = true;
backfillResult = null;
try {
const res = await fetch('/api/admin/backfill-versions', { method: 'POST' });
if (res.ok) {
const data = await res.json();
backfillResult = data.count;
}
} finally {
backfillLoading = false;
}
}
async function backfillFileHashes() {
backfillHashesLoading = true;
backfillHashesResult = null;
try {
const res = await fetch('/api/admin/backfill-file-hashes', { method: 'POST' });
if (res.ok) {
const data = await res.json();
backfillHashesResult = data.count;
}
} finally {
backfillHashesLoading = false;
}
}
</script> </script>
<div class="mx-auto max-w-7xl py-8 font-sans sm:px-6 lg:px-8"> <div class="mx-auto max-w-7xl py-8 font-sans sm:px-6 lg:px-8">
<div class="mb-8 flex items-center justify-between"> <div class="mb-8 flex items-center justify-between">
<h1 class="font-serif text-3xl text-brand-navy">{m.admin_heading()}</h1> <h1 class="font-serif text-3xl text-ink">{m.admin_heading()}</h1>
<!-- Tabs --> <!-- Tabs -->
<div class="flex rounded-lg border border-gray-200 bg-white p-1 shadow-sm"> <div class="flex rounded-lg border border-line bg-surface p-1 shadow-sm">
<button <button
class="rounded-md px-4 py-2 text-sm font-bold tracking-wide uppercase transition {activeTab === class="rounded-md px-4 py-2 text-sm font-bold tracking-wide uppercase transition {activeTab ===
'users' 'users'
? 'bg-brand-navy text-white' ? 'bg-primary text-white'
: 'text-gray-500 hover:text-brand-navy'}" : 'text-ink-2 hover:text-ink'}"
onclick={() => (activeTab = 'users')}>{m.admin_tab_users()}</button onclick={() => (activeTab = 'users')}>{m.admin_tab_users()}</button
> >
<button <button
class="rounded-md px-4 py-2 text-sm font-bold tracking-wide uppercase transition {activeTab === class="rounded-md px-4 py-2 text-sm font-bold tracking-wide uppercase transition {activeTab ===
'groups' 'groups'
? 'bg-brand-navy text-white' ? 'bg-primary text-white'
: 'text-gray-500 hover:text-brand-navy'}" : 'text-ink-2 hover:text-ink'}"
onclick={() => (activeTab = 'groups')}>{m.admin_tab_groups()}</button onclick={() => (activeTab = 'groups')}>{m.admin_tab_groups()}</button
> >
<button <button
class="rounded-md px-4 py-2 text-sm font-bold tracking-wide uppercase transition {activeTab === class="rounded-md px-4 py-2 text-sm font-bold tracking-wide uppercase transition {activeTab ===
'tags' 'tags'
? 'bg-brand-navy text-white' ? 'bg-primary text-white'
: 'text-gray-500 hover:text-brand-navy'}" : 'text-ink-2 hover:text-ink'}"
onclick={() => (activeTab = 'tags')}>{m.admin_tab_tags()}</button onclick={() => (activeTab = 'tags')}>{m.admin_tab_tags()}</button
> >
<button <button
class="rounded-md px-4 py-2 text-sm font-bold tracking-wide uppercase transition {activeTab === class="rounded-md px-4 py-2 text-sm font-bold tracking-wide uppercase transition {activeTab ===
'system' 'system'
? 'bg-brand-navy text-white' ? 'bg-primary text-white'
: 'text-gray-500 hover:text-brand-navy'}" : 'text-ink-2 hover:text-ink'}"
onclick={() => (activeTab = 'system')}>{m.admin_tab_system()}</button onclick={() => (activeTab = 'system')}>{m.admin_tab_system()}</button
> >
</div> </div>
</div> </div>
{#if form?.message} {#if form?.message}
<div class="mb-6 rounded border border-brand-mint/50 bg-brand-mint/20 p-4 text-brand-navy"> <div class="mb-6 rounded border border-accent/50 bg-accent/20 p-4 text-ink">
{form.message} {form.message}
</div> </div>
{/if} {/if}
{#if activeTab === 'users'} {#if activeTab === 'users'}
<div class="overflow-hidden rounded-lg border border-brand-sand bg-white shadow-sm" in:slide> <div in:slide>
<div class="flex items-center justify-between border-b border-gray-100 p-6"> <UsersTab users={data.users} />
<h2 class="text-lg font-bold text-gray-700">{m.admin_section_users()}</h2>
<a
href="/admin/users/new"
class="inline-flex items-center gap-1 rounded-sm bg-brand-navy px-4 py-2 font-sans text-xs font-bold tracking-widest text-white uppercase transition-opacity hover:opacity-80"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg>
{m.admin_btn_new_user()}
</a>
</div>
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-bold tracking-wider text-gray-500 uppercase"
>{m.admin_col_login()}</th
>
<th class="px-6 py-3 text-left text-xs font-bold tracking-wider text-gray-500 uppercase"
>{m.admin_col_full_name()}</th
>
<th class="px-6 py-3 text-left text-xs font-bold tracking-wider text-gray-500 uppercase"
>{m.admin_col_groups()}</th
>
<th
class="px-6 py-3 text-right text-xs font-bold tracking-wider text-gray-500 uppercase"
>{m.admin_col_actions()}</th
>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
{#each data.users as user (user.id)}
<tr class="group/row hover:bg-gray-50">
<td class="px-6 py-4 text-sm font-medium whitespace-nowrap text-gray-900">
{user.username}
</td>
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-500">
{#if user.firstName || user.lastName}
{user.firstName ?? ''} {user.lastName ?? ''}
{:else}
<span class="text-gray-300 italic"></span>
{/if}
</td>
<td class="px-6 py-4 text-sm text-gray-500">
<div class="flex flex-wrap gap-1">
{#if user.groups && user.groups.length > 0}
{#each user.groups as group (group.id)}
<span
class="rounded-full border border-blue-100 bg-blue-50 px-2 py-0.5 text-[10px] font-bold text-blue-700 uppercase"
>
{group.name}
</span>
{/each}
{:else}
<span class="text-xs text-gray-400 italic">{m.admin_no_groups()}</span>
{/if}
</div>
</td>
<td class="px-6 py-4 text-right whitespace-nowrap">
<div class="flex items-center justify-end gap-4">
<a
href="/admin/users/{user.id}"
class="text-sm font-bold tracking-wide text-brand-mint uppercase hover:text-brand-navy"
>
{m.btn_edit()}
</a>
<form
method="POST"
action="?/deleteUser"
use:enhance={({ cancel }) => {
if (!confirm(m.admin_user_delete_confirm({ username: user.username }))) {
cancel();
}
return async ({ update }) => {
await update();
};
}}
class="flex items-center"
>
<input type="hidden" name="id" value={user.id} />
<button
class="p-1 text-gray-300 transition-colors hover:text-red-600"
title={m.admin_btn_delete_user_title()}
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</form>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div> </div>
{:else if activeTab === 'tags'} {:else if activeTab === 'tags'}
<div class="overflow-hidden rounded-lg border border-brand-sand bg-white shadow-sm" in:slide> <div in:slide>
<div class="border-b border-gray-100 bg-yellow-50/50 p-6"> <TagsTab tags={data.tags} />
<h2 class="text-lg font-bold text-gray-700">{m.admin_section_tags()}</h2>
<p class="mt-1 text-xs text-yellow-800">
{m.admin_tags_warning()}
</p>
</div>
<ul class="max-h-[600px] divide-y divide-gray-100 overflow-y-auto">
{#each data.tags as tag (tag.id)}
<li class="group flex items-center justify-between px-6 py-3 hover:bg-gray-50">
{#if editingTagId === tag.id}
<form
method="POST"
action="?/updateTag"
use:enhance={() =>
async ({ update }) => {
await update();
cancelEditTag();
}}
class="flex flex-1 items-center gap-2"
>
<input type="hidden" name="id" value={tag.id} />
<input
type="text"
name="name"
bind:value={editingTagName}
class="flex-1 rounded border-brand-mint px-2 py-1 text-sm ring-1 ring-brand-mint"
/>
<button aria-label={m.btn_save()} class="text-green-600 hover:text-green-800"
><svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/></svg
></button
>
<button
type="button"
onclick={cancelEditTag}
aria-label={m.btn_cancel()}
class="text-gray-400 hover:text-gray-600"
><svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/></svg
></button
>
</form>
{:else}
<span class="rounded bg-brand-sand/30 px-2 py-1 text-sm font-medium text-brand-navy">
{tag.name}
</span>
<div
class="flex items-center gap-2 opacity-0 transition-opacity group-hover:opacity-100"
>
<button
onclick={() => startEditTag(tag)}
aria-label={m.admin_btn_edit_tag_label()}
class="p-1 text-gray-400 hover:text-brand-navy"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
/></svg
>
</button>
<form
method="POST"
action="?/deleteTag"
use:enhance={({ cancel }) => {
if (
!confirm(m.admin_tag_delete_confirm())
) {
cancel();
}
return async ({ update }) => {
await update();
};
}}
class="inline"
>
<input type="hidden" name="id" value={tag.id} />
<button
aria-label={m.admin_btn_delete_tag_label()}
class="p-1 text-gray-400 hover:text-red-600"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/></svg
>
</button>
</form>
</div>
{/if}
</li>
{/each}
</ul>
</div> </div>
{:else if activeTab === 'groups'} {:else if activeTab === 'groups'}
<div class="overflow-hidden rounded-lg border border-brand-sand bg-white shadow-sm" in:slide> <div in:slide>
<div class="flex items-center justify-between border-b border-gray-100 p-6"> <GroupsTab groups={data.groups} />
<h2 class="text-lg font-bold text-gray-700">{m.admin_section_groups()}</h2>
</div>
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-bold tracking-wider text-gray-500 uppercase"
>{m.admin_col_name()}</th
>
<th class="px-6 py-3 text-left text-xs font-bold tracking-wider text-gray-500 uppercase"
>{m.admin_col_permissions()}</th
>
<th
class="px-6 py-3 text-right text-xs font-bold tracking-wider text-gray-500 uppercase"
>{m.admin_col_actions()}</th
>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
{#each data.groups as group (group.id)}
<tr class="group/row hover:bg-gray-50">
{#if editingGroupId === group.id}
<!-- EDIT MODE -->
<td colspan="3" class="px-6 py-4">
<form
method="POST"
action="?/updateGroup"
use:enhance={() =>
async ({ update }) => {
await update();
cancelEditGroup();
}}
class="flex w-full flex-col items-start gap-4 sm:flex-row"
>
<input type="hidden" name="id" value={group.id} />
<div class="w-full sm:w-1/3">
<input
type="text"
name="name"
value={group.name}
class="w-full rounded border-brand-mint text-sm"
required
/>
</div>
<div class="flex h-full flex-1 flex-wrap items-center gap-4 pt-2">
{#each availablePermissions as perm (perm)}
<label
class="inline-flex items-center text-xs font-bold text-gray-600 uppercase"
>
<input
type="checkbox"
name="permissions"
value={perm}
checked={group.permissions.includes(perm)}
class="mr-2 rounded border-gray-300 text-brand-navy focus:ring-brand-mint"
/>
{perm.replace('_', ' ')}
</label>
{/each}
</div>
<div class="flex gap-2 self-start sm:self-center">
<button
type="submit"
aria-label={m.btn_save()}
class="p-1 text-green-600 hover:text-green-800"
>
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/></svg
>
</button>
<button
type="button"
onclick={cancelEditGroup}
aria-label={m.btn_cancel()}
class="p-1 text-gray-400 hover:text-red-500"
>
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/></svg
>
</button>
</div>
</form>
</td>
{:else}
<!-- VIEW MODE -->
<td class="px-6 py-4 text-sm font-bold whitespace-nowrap text-brand-navy">
{group.name}
</td>
<td class="px-6 py-4 text-sm text-gray-500">
<div class="flex flex-wrap gap-1">
{#each group.permissions as perm (perm)}
<span
class="rounded-full px-2 py-0.5 text-[10px] font-bold uppercase
{perm === 'ADMIN'
? 'border-red-100 bg-red-50 text-red-700'
: 'border-gray-200 bg-gray-100 text-gray-600'}"
>
{perm}
</span>
{/each}
</div>
</td>
<td class="px-6 py-4 text-right whitespace-nowrap">
<div class="flex items-center justify-end gap-3">
<button
onclick={() => startEditGroup(group.id)}
class="text-sm font-bold tracking-wide text-brand-mint uppercase hover:text-brand-navy"
>
{m.btn_edit()}
</button>
<form
method="POST"
action="?/deleteGroup"
use:enhance={({ cancel }) => {
if (!confirm(m.admin_group_delete_confirm())) {
cancel();
}
return async ({ update }) => {
await update();
};
}}
>
<input type="hidden" name="id" value={group.id} />
<button
class="p-1 text-gray-300 transition-colors hover:text-red-600"
title={m.btn_delete()}
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</form>
</div>
</td>
{/if}
</tr>
{/each}
</tbody>
</table>
<!-- CREATE GROUP FORM -->
<div class="border-t border-gray-200 bg-gray-50 p-6">
<h3 class="mb-4 text-xs font-bold tracking-wide text-gray-500 uppercase">
{m.admin_section_new_group()}
</h3>
<form
method="POST"
action="?/createGroup"
use:enhance
class="flex flex-col items-start gap-4 md:flex-row md:items-center"
>
<div class="w-full flex-1">
<input
type="text"
name="name"
placeholder={m.admin_group_name_placeholder()}
required
class="w-full rounded border-gray-300 text-sm"
/>
</div>
<div class="flex items-center gap-4">
{#each availablePermissions as perm (perm)}
<label class="inline-flex items-center text-xs font-bold text-gray-600 uppercase">
<input
type="checkbox"
name="permissions"
value={perm}
class="mr-2 rounded border-gray-300 text-brand-navy focus:ring-brand-mint"
/>
{perm.replace('_', ' ')}
</label>
{/each}
</div>
<button
type="submit"
class="w-full rounded bg-brand-navy px-6 py-2 text-sm font-bold text-white uppercase hover:bg-brand-mint hover:text-brand-navy md:w-auto"
>
{m.btn_create()}
</button>
</form>
</div>
</div> </div>
{:else if activeTab === 'system'} {:else if activeTab === 'system'}
<div class="rounded-sm border border-brand-sand bg-white p-6 shadow-sm"> <div in:slide>
<h2 class="mb-1 text-lg font-bold text-gray-700">{m.admin_system_backfill_heading()}</h2> <SystemTab />
<p class="mb-4 text-sm text-gray-500">{m.admin_system_backfill_description()}</p>
<button
onclick={backfillVersions}
disabled={backfillLoading}
class="rounded bg-brand-navy px-6 py-2 text-sm font-bold text-white uppercase transition hover:bg-brand-mint hover:text-brand-navy disabled:cursor-not-allowed disabled:opacity-50"
>
{backfillLoading ? '…' : m.admin_system_backfill_btn()}
</button>
{#if backfillResult !== null}
<p class="mt-4 text-sm font-medium text-brand-navy">
{m.admin_system_backfill_success({ count: backfillResult })}
</p>
{/if}
</div>
<div class="mt-4 rounded-sm border border-brand-sand bg-white p-6 shadow-sm">
<h2 class="mb-1 text-lg font-bold text-gray-700">
{m.admin_system_backfill_hashes_heading()}
</h2>
<p class="mb-4 text-sm text-gray-500">{m.admin_system_backfill_hashes_description()}</p>
<button
onclick={backfillFileHashes}
disabled={backfillHashesLoading}
class="rounded bg-brand-navy px-6 py-2 text-sm font-bold text-white uppercase transition hover:bg-brand-mint hover:text-brand-navy disabled:cursor-not-allowed disabled:opacity-50"
>
{backfillHashesLoading ? '…' : m.admin_system_backfill_hashes_btn()}
</button>
{#if backfillHashesResult !== null}
<p class="mt-4 text-sm font-medium text-brand-navy">
{m.admin_system_backfill_hashes_success({ count: backfillHashesResult })}
</p>
{/if}
</div> </div>
{/if} {/if}
</div> </div>

View File

@@ -0,0 +1,221 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { m } from '$lib/paraglide/messages.js';
let { groups }: { groups: { id: string; name: string; permissions: string[] }[] } = $props();
const availablePermissions = ['WRITE_ALL', 'ADMIN', 'ADMIN_USER', 'ADMIN_TAG', 'ADMIN_PERMISSION'];
let editingGroupId: string | null = $state(null);
function startEditGroup(id: string) {
editingGroupId = id;
}
function cancelEditGroup() {
editingGroupId = null;
}
</script>
<div class="overflow-hidden rounded-lg border border-line bg-surface shadow-sm">
<div class="flex items-center justify-between border-b border-line-2 p-6">
<h2 class="text-lg font-bold text-ink-2">{m.admin_section_groups()}</h2>
</div>
<table class="min-w-full divide-y divide-line">
<thead class="bg-muted">
<tr>
<th class="px-6 py-3 text-left text-xs font-bold tracking-wider text-ink-2 uppercase"
>{m.admin_col_name()}</th
>
<th class="px-6 py-3 text-left text-xs font-bold tracking-wider text-ink-2 uppercase"
>{m.admin_col_permissions()}</th
>
<th class="px-6 py-3 text-right text-xs font-bold tracking-wider text-ink-2 uppercase"
>{m.admin_col_actions()}</th
>
</tr>
</thead>
<tbody class="divide-y divide-line bg-surface">
{#each groups as group (group.id)}
<tr class="group/row hover:bg-muted">
{#if editingGroupId === group.id}
<!-- EDIT MODE -->
<td colspan="3" class="px-6 py-4">
<form
method="POST"
action="?/updateGroup"
use:enhance={() =>
async ({ update }) => {
await update();
cancelEditGroup();
}}
class="flex w-full flex-col items-start gap-4 sm:flex-row"
>
<input type="hidden" name="id" value={group.id} />
<div class="w-full sm:w-1/3">
<input
type="text"
name="name"
value={group.name}
class="w-full rounded border-accent text-sm"
required
/>
</div>
<div class="flex h-full flex-1 flex-wrap items-center gap-4 pt-2">
{#each availablePermissions as perm (perm)}
<label class="inline-flex items-center text-xs font-bold text-ink-2 uppercase">
<input
type="checkbox"
name="permissions"
value={perm}
checked={group.permissions.includes(perm)}
class="mr-2 rounded border-line text-ink focus:ring-accent"
/>
{perm.replace('_', ' ')}
</label>
{/each}
</div>
<div class="flex gap-2 self-start sm:self-center">
<button
type="submit"
aria-label={m.btn_save()}
class="p-1 text-green-600 hover:text-green-800"
>
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/></svg
>
</button>
<button
type="button"
onclick={cancelEditGroup}
aria-label={m.btn_cancel()}
class="p-1 text-ink-3 hover:text-red-500"
>
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/></svg
>
</button>
</div>
</form>
</td>
{:else}
<!-- VIEW MODE -->
<td class="px-6 py-4 text-sm font-bold whitespace-nowrap text-ink">
{group.name}
</td>
<td class="px-6 py-4 text-sm text-ink-2">
<div class="flex flex-wrap gap-1">
{#each group.permissions as perm (perm)}
<span
class="rounded-full px-2 py-0.5 text-[10px] font-bold uppercase
{perm === 'ADMIN'
? 'border-red-100 bg-red-50 text-red-700'
: 'border-line bg-muted text-ink-2'}"
>
{perm}
</span>
{/each}
</div>
</td>
<td class="px-6 py-4 text-right whitespace-nowrap">
<div class="flex items-center justify-end gap-3">
<button
onclick={() => startEditGroup(group.id)}
class="text-sm font-bold tracking-wide text-accent uppercase hover:text-ink"
>
{m.btn_edit()}
</button>
<form
method="POST"
action="?/deleteGroup"
use:enhance={({ cancel }) => {
if (!confirm(m.admin_group_delete_confirm())) {
cancel();
}
return async ({ update }) => {
await update();
};
}}
>
<input type="hidden" name="id" value={group.id} />
<button
class="p-1 text-ink-3 transition-colors hover:text-red-600"
title={m.btn_delete()}
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</form>
</div>
</td>
{/if}
</tr>
{/each}
</tbody>
</table>
<!-- CREATE GROUP FORM -->
<div class="border-t border-line bg-muted p-6">
<h3 class="mb-4 text-xs font-bold tracking-wide text-ink-2 uppercase">
{m.admin_section_new_group()}
</h3>
<form
method="POST"
action="?/createGroup"
use:enhance
class="flex flex-col items-start gap-4 md:flex-row md:items-center"
>
<div class="w-full flex-1">
<input
type="text"
name="name"
placeholder={m.admin_group_name_placeholder()}
required
class="w-full rounded border-line text-sm"
/>
</div>
<div class="flex items-center gap-4">
{#each availablePermissions as perm (perm)}
<label class="inline-flex items-center text-xs font-bold text-ink-2 uppercase">
<input
type="checkbox"
name="permissions"
value={perm}
class="mr-2 rounded border-line text-ink focus:ring-accent"
/>
{perm.replace('_', ' ')}
</label>
{/each}
</div>
<button
type="submit"
class="w-full rounded bg-primary px-6 py-2 text-sm font-bold text-white uppercase hover:bg-accent hover:text-ink md:w-auto"
>
{m.btn_create()}
</button>
</form>
</div>
</div>

View File

@@ -0,0 +1,72 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
let backfillResult: number | null = $state(null);
let backfillLoading = $state(false);
let backfillHashesResult: number | null = $state(null);
let backfillHashesLoading = $state(false);
async function backfillVersions() {
backfillLoading = true;
backfillResult = null;
try {
const res = await fetch('/api/admin/backfill-versions', { method: 'POST' });
if (res.ok) {
const data = await res.json();
backfillResult = data.count;
}
} finally {
backfillLoading = false;
}
}
async function backfillFileHashes() {
backfillHashesLoading = true;
backfillHashesResult = null;
try {
const res = await fetch('/api/admin/backfill-file-hashes', { method: 'POST' });
if (res.ok) {
const data = await res.json();
backfillHashesResult = data.count;
}
} finally {
backfillHashesLoading = false;
}
}
</script>
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
<h2 class="mb-1 text-lg font-bold text-ink-2">{m.admin_system_backfill_heading()}</h2>
<p class="mb-4 text-sm text-ink-2">{m.admin_system_backfill_description()}</p>
<button
onclick={backfillVersions}
disabled={backfillLoading}
class="rounded bg-primary px-6 py-2 text-sm font-bold text-white uppercase transition hover:bg-accent hover:text-ink disabled:cursor-not-allowed disabled:opacity-50"
>
{backfillLoading ? '…' : m.admin_system_backfill_btn()}
</button>
{#if backfillResult !== null}
<p class="mt-4 text-sm font-medium text-ink">
{m.admin_system_backfill_success({ count: backfillResult })}
</p>
{/if}
</div>
<div class="mt-4 rounded-sm border border-line bg-surface p-6 shadow-sm">
<h2 class="mb-1 text-lg font-bold text-ink-2">
{m.admin_system_backfill_hashes_heading()}
</h2>
<p class="mb-4 text-sm text-ink-2">{m.admin_system_backfill_hashes_description()}</p>
<button
onclick={backfillFileHashes}
disabled={backfillHashesLoading}
class="rounded bg-primary px-6 py-2 text-sm font-bold text-white uppercase transition hover:bg-accent hover:text-ink disabled:cursor-not-allowed disabled:opacity-50"
>
{backfillHashesLoading ? '…' : m.admin_system_backfill_hashes_btn()}
</button>
{#if backfillHashesResult !== null}
<p class="mt-4 text-sm font-medium text-ink">
{m.admin_system_backfill_hashes_success({ count: backfillHashesResult })}
</p>
{/if}
</div>

View File

@@ -0,0 +1,127 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { m } from '$lib/paraglide/messages.js';
let { tags }: { tags: { id: string; name: string }[] } = $props();
let editingTagId: string | null = $state(null);
let editingTagName = $state('');
function startEditTag(tag: { id: string; name: string }) {
editingTagId = tag.id;
editingTagName = tag.name;
}
function cancelEditTag() {
editingTagId = null;
editingTagName = '';
}
</script>
<div class="overflow-hidden rounded-lg border border-line bg-surface shadow-sm">
<div class="border-b border-line-2 bg-yellow-50/50 p-6">
<h2 class="text-lg font-bold text-ink-2">{m.admin_section_tags()}</h2>
<p class="mt-1 text-xs text-yellow-800">
{m.admin_tags_warning()}
</p>
</div>
<ul class="max-h-[600px] divide-y divide-line-2 overflow-y-auto">
{#each tags as tag (tag.id)}
<li class="group flex items-center justify-between px-6 py-3 hover:bg-muted">
{#if editingTagId === tag.id}
<form
method="POST"
action="?/updateTag"
use:enhance={() =>
async ({ update }) => {
await update();
cancelEditTag();
}}
class="flex flex-1 items-center gap-2"
>
<input type="hidden" name="id" value={tag.id} />
<input
type="text"
name="name"
bind:value={editingTagName}
class="flex-1 rounded border-accent px-2 py-1 text-sm ring-1 ring-accent"
/>
<button aria-label={m.btn_save()} class="text-green-600 hover:text-green-800"
><svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/></svg
></button
>
<button
type="button"
onclick={cancelEditTag}
aria-label={m.btn_cancel()}
class="text-ink-3 hover:text-ink-2"
><svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/></svg
></button
>
</form>
{:else}
<span class="rounded bg-muted px-2 py-1 text-sm font-medium text-ink">
{tag.name}
</span>
<div class="flex items-center gap-2 opacity-0 transition-opacity group-hover:opacity-100">
<button
onclick={() => startEditTag(tag)}
aria-label={m.admin_btn_edit_tag_label()}
class="p-1 text-ink-3 hover:text-ink"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
/></svg
>
</button>
<form
method="POST"
action="?/deleteTag"
use:enhance={({ cancel }) => {
if (!confirm(m.admin_tag_delete_confirm())) {
cancel();
}
return async ({ update }) => {
await update();
};
}}
class="inline"
>
<input type="hidden" name="id" value={tag.id} />
<button
aria-label={m.admin_btn_delete_tag_label()}
class="p-1 text-ink-3 hover:text-red-600"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/></svg
>
</button>
</form>
</div>
{/if}
</li>
{/each}
</ul>
</div>

View File

@@ -0,0 +1,120 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { m } from '$lib/paraglide/messages.js';
let {
users
}: {
users: {
id: string;
username: string;
firstName?: string;
lastName?: string;
groups?: { id: string; name: string }[];
}[];
} = $props();
</script>
<div class="overflow-hidden rounded-lg border border-line bg-surface shadow-sm">
<div class="flex items-center justify-between border-b border-line-2 p-6">
<h2 class="text-lg font-bold text-ink-2">{m.admin_section_users()}</h2>
<a
href="/admin/users/new"
class="inline-flex items-center gap-1 rounded-sm bg-primary px-4 py-2 font-sans text-xs font-bold tracking-widest text-white uppercase transition-opacity hover:opacity-80"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
{m.admin_btn_new_user()}
</a>
</div>
<table class="min-w-full divide-y divide-line">
<thead class="bg-muted">
<tr>
<th class="px-6 py-3 text-left text-xs font-bold tracking-wider text-ink-2 uppercase"
>{m.admin_col_login()}</th
>
<th class="px-6 py-3 text-left text-xs font-bold tracking-wider text-ink-2 uppercase"
>{m.admin_col_full_name()}</th
>
<th class="px-6 py-3 text-left text-xs font-bold tracking-wider text-ink-2 uppercase"
>{m.admin_col_groups()}</th
>
<th class="px-6 py-3 text-right text-xs font-bold tracking-wider text-ink-2 uppercase"
>{m.admin_col_actions()}</th
>
</tr>
</thead>
<tbody class="divide-y divide-line bg-surface">
{#each users as user (user.id)}
<tr class="group/row hover:bg-muted">
<td class="px-6 py-4 text-sm font-medium whitespace-nowrap text-ink">
{user.username}
</td>
<td class="px-6 py-4 text-sm whitespace-nowrap text-ink-2">
{#if user.firstName || user.lastName}
{user.firstName ?? ''} {user.lastName ?? ''}
{:else}
<span class="text-ink-3 italic"></span>
{/if}
</td>
<td class="px-6 py-4 text-sm text-ink-2">
<div class="flex flex-wrap gap-1">
{#if user.groups && user.groups.length > 0}
{#each user.groups as group (group.id)}
<span
class="rounded-full border border-blue-100 bg-blue-50 px-2 py-0.5 text-[10px] font-bold text-blue-700 uppercase"
>
{group.name}
</span>
{/each}
{:else}
<span class="text-xs text-ink-3 italic">{m.admin_no_groups()}</span>
{/if}
</div>
</td>
<td class="px-6 py-4 text-right whitespace-nowrap">
<div class="flex items-center justify-end gap-4">
<a
href="/admin/users/{user.id}"
class="text-sm font-bold tracking-wide text-accent uppercase hover:text-ink"
>
{m.btn_edit()}
</a>
<form
method="POST"
action="?/deleteUser"
use:enhance={({ cancel }) => {
if (!confirm(m.admin_user_delete_confirm({ username: user.username }))) {
cancel();
}
return async ({ update }) => {
await update();
};
}}
class="flex items-center"
>
<input type="hidden" name="id" value={user.id} />
<button
class="p-1 text-ink-3 transition-colors hover:text-red-600"
title={m.admin_btn_delete_user_title()}
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</form>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>

View File

@@ -1,47 +1,19 @@
<script lang="ts"> <script lang="ts">
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import { untrack } from 'svelte';
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import UserProfileSection from '$lib/components/user/UserProfileSection.svelte';
import UserGroupsSection from '$lib/components/user/UserGroupsSection.svelte';
import UserPasswordSection from '$lib/components/user/UserPasswordSection.svelte';
let { data, form } = $props(); let { data, form } = $props();
function isoToGerman(iso: string | undefined): string { const selectedGroupIds = $derived(data.editUser.groups?.map((g: { id: string }) => g.id) ?? []);
if (!iso) return '';
const match = iso.match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (!match) return '';
return `${match[3]}.${match[2]}.${match[1]}`;
}
function germanToIso(german: string): string {
const match = german.match(/^(\d{2})\.(\d{2})\.(\d{4})$/);
if (!match) return '';
return `${match[3]}-${match[2]}-${match[1]}`;
}
let birthDateDisplay = $state(untrack(() => isoToGerman(data.editUser?.birthDate)));
let birthDateIso = $state(untrack(() => data.editUser?.birthDate ?? ''));
function handleBirthDateInput(e: Event) {
const input = e.target as HTMLInputElement;
const digits = input.value.replace(/\D/g, '').slice(0, 8);
let formatted: string;
if (digits.length <= 2) {
formatted = digits;
} else if (digits.length <= 4) {
formatted = `${digits.slice(0, 2)}.${digits.slice(2)}`;
} else {
formatted = `${digits.slice(0, 2)}.${digits.slice(2, 4)}.${digits.slice(4)}`;
}
input.value = formatted;
birthDateDisplay = formatted;
birthDateIso = germanToIso(formatted);
}
</script> </script>
<div class="mx-auto max-w-3xl px-4 py-8 sm:px-6 lg:px-8"> <div class="mx-auto max-w-3xl px-4 py-8 sm:px-6 lg:px-8">
<a <a
href="/admin" href="/admin"
class="group mb-4 inline-flex items-center text-xs font-bold tracking-widest text-gray-500 uppercase transition-colors hover:text-brand-navy" class="group mb-4 inline-flex items-center text-xs font-bold tracking-widest text-ink-2 uppercase transition-colors hover:text-ink"
> >
<svg <svg
class="mr-2 h-4 w-4 transform transition-transform group-hover:-translate-x-1" class="mr-2 h-4 w-4 transform transition-transform group-hover:-translate-x-1"
@@ -55,7 +27,7 @@ function handleBirthDateInput(e: Event) {
{m.btn_back_to_overview()} {m.btn_back_to_overview()}
</a> </a>
<h1 class="mb-6 font-serif text-3xl font-bold text-brand-navy"> <h1 class="mb-6 font-serif text-3xl font-bold text-ink">
{m.admin_user_edit_heading({ username: data.editUser.username })} {m.admin_user_edit_heading({ username: data.editUser.username })}
</h1> </h1>
@@ -72,159 +44,48 @@ function handleBirthDateInput(e: Event) {
<form method="POST" use:enhance class="space-y-6"> <form method="POST" use:enhance class="space-y-6">
<!-- Profile card --> <!-- Profile card -->
<div class="rounded-sm border border-brand-sand bg-white p-6 shadow-sm"> <div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
<h2 class="mb-5 text-xs font-bold tracking-widest text-gray-400 uppercase"> <h2 class="mb-5 text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.profile_section_personal()} {m.profile_section_personal()}
</h2> </h2>
<UserProfileSection
<div class="space-y-4"> firstName={data.editUser.firstName ?? ''}
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2"> lastName={data.editUser.lastName ?? ''}
<label class="block"> birthDate={data.editUser.birthDate ?? ''}
<span email={data.editUser.email ?? ''}
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase" contact={data.editUser.contact ?? ''}
> />
{m.profile_label_first_name()}
</span>
<input
type="text"
name="firstName"
value={data.editUser.firstName ?? ''}
class="w-full rounded-sm border border-brand-sand px-3 py-2 font-serif text-sm focus:border-brand-navy focus:outline-none"
/>
</label>
<label class="block">
<span
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
>
{m.profile_label_last_name()}
</span>
<input
type="text"
name="lastName"
value={data.editUser.lastName ?? ''}
class="w-full rounded-sm border border-brand-sand px-3 py-2 font-serif text-sm focus:border-brand-navy focus:outline-none"
/>
</label>
</div>
<label class="block">
<span
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
>
{m.profile_label_birth_date()}
</span>
<input
type="text"
placeholder="TT.MM.JJJJ"
value={birthDateDisplay}
oninput={handleBirthDateInput}
class="w-full rounded-sm border border-brand-sand px-3 py-2 font-serif text-sm focus:border-brand-navy focus:outline-none"
/>
<input type="hidden" name="birthDate" value={birthDateIso} />
</label>
<label class="block">
<span
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
>
{m.profile_label_email()}
</span>
<input
type="email"
name="email"
value={data.editUser.email ?? ''}
class="w-full rounded-sm border border-brand-sand px-3 py-2 font-serif text-sm focus:border-brand-navy focus:outline-none"
/>
</label>
<label class="block">
<span
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
>
{m.profile_label_contact()}
</span>
<textarea
name="contact"
rows="3"
placeholder={m.profile_contact_placeholder()}
class="w-full rounded-sm border border-brand-sand px-3 py-2 font-serif text-sm focus:border-brand-navy focus:outline-none"
>{data.editUser.contact ?? ''}</textarea
>
</label>
</div>
</div> </div>
<!-- Groups card --> <!-- Groups card -->
<div class="rounded-sm border border-brand-sand bg-white p-6 shadow-sm"> <div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
<h2 class="mb-5 text-xs font-bold tracking-widest text-gray-400 uppercase"> <h2 class="mb-5 text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.admin_col_groups()} {m.admin_col_groups()}
</h2> </h2>
<UserGroupsSection groups={data.groups} selectedGroupIds={selectedGroupIds} />
<div class="flex flex-wrap gap-3">
{#each data.groups as group (group.id)}
<label class="inline-flex items-center gap-2 text-sm text-gray-700">
<input
type="checkbox"
name="groupIds"
value={group.id}
checked={data.editUser.groups?.some((g: { id: string }) => g.id === group.id)}
class="rounded border-gray-300 text-brand-navy focus:ring-brand-mint"
/>
{group.name}
</label>
{/each}
</div>
</div> </div>
<!-- Password card --> <!-- Password card -->
<div class="rounded-sm border border-brand-sand bg-white p-6 shadow-sm"> <div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
<h2 class="mb-5 text-xs font-bold tracking-widest text-gray-400 uppercase"> <h2 class="mb-5 text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.admin_label_new_password_optional()} {m.admin_label_new_password_optional()}
</h2> </h2>
<UserPasswordSection />
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<label class="block">
<span
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
>
{m.profile_label_new_password()}
</span>
<input
type="password"
name="newPassword"
class="w-full rounded-sm border border-brand-sand px-3 py-2 font-serif text-sm focus:border-brand-navy focus:outline-none"
/>
</label>
<label class="block">
<span
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
>
{m.profile_label_new_password_confirm()}
</span>
<input
type="password"
name="confirmPassword"
class="w-full rounded-sm border border-brand-sand px-3 py-2 font-serif text-sm focus:border-brand-navy focus:outline-none"
/>
</label>
</div>
</div> </div>
<!-- Save bar --> <!-- Save bar -->
<div <div
class="sticky bottom-0 z-10 -mx-4 flex items-center justify-between border-t border-brand-sand bg-white px-6 py-4 shadow-[0_-2px_8px_rgba(0,0,0,0.06)]" class="sticky bottom-0 z-10 -mx-4 flex items-center justify-between border-t border-line bg-surface px-6 py-4 shadow-[0_-2px_8px_rgba(0,0,0,0.06)]"
> >
<a <a
href="/admin" href="/admin"
class="font-sans text-xs font-bold tracking-widest text-gray-500 uppercase hover:text-brand-navy" class="font-sans text-xs font-bold tracking-widest text-ink-2 uppercase hover:text-ink"
> >
{m.btn_cancel()} {m.btn_cancel()}
</a> </a>
<button <button
type="submit" type="submit"
class="rounded-sm bg-brand-navy px-5 py-2 font-sans text-xs font-bold tracking-widest text-white uppercase transition-opacity hover:opacity-80" class="rounded-sm bg-primary px-5 py-2 font-sans text-xs font-bold tracking-widest text-white uppercase transition-opacity hover:opacity-80"
> >
{m.btn_save()} {m.btn_save()}
</button> </button>

View File

@@ -1,37 +1,17 @@
<script lang="ts"> <script lang="ts">
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import UserProfileSection from '$lib/components/user/UserProfileSection.svelte';
import UserGroupsSection from '$lib/components/user/UserGroupsSection.svelte';
import AccountSection from './AccountSection.svelte';
let { data, form } = $props(); let { data, form } = $props();
function germanToIso(german: string): string {
const match = german.match(/^(\d{2})\.(\d{2})\.(\d{4})$/);
if (!match) return '';
return `${match[3]}-${match[2]}-${match[1]}`;
}
let birthDateIso = $state('');
function handleBirthDateInput(e: Event) {
const input = e.target as HTMLInputElement;
const digits = input.value.replace(/\D/g, '').slice(0, 8);
let formatted: string;
if (digits.length <= 2) {
formatted = digits;
} else if (digits.length <= 4) {
formatted = `${digits.slice(0, 2)}.${digits.slice(2)}`;
} else {
formatted = `${digits.slice(0, 2)}.${digits.slice(2, 4)}.${digits.slice(4)}`;
}
input.value = formatted;
birthDateIso = germanToIso(formatted);
}
</script> </script>
<div class="mx-auto max-w-3xl px-4 py-8 sm:px-6 lg:px-8"> <div class="mx-auto max-w-3xl px-4 py-8 sm:px-6 lg:px-8">
<a <a
href="/admin" href="/admin"
class="group mb-4 inline-flex items-center text-xs font-bold tracking-widest text-gray-500 uppercase transition-colors hover:text-brand-navy" class="group mb-4 inline-flex items-center text-xs font-bold tracking-widest text-ink-2 uppercase transition-colors hover:text-ink"
> >
<svg <svg
class="mr-2 h-4 w-4 transform transition-transform group-hover:-translate-x-1" class="mr-2 h-4 w-4 transform transition-transform group-hover:-translate-x-1"
@@ -45,7 +25,7 @@ function handleBirthDateInput(e: Event) {
{m.btn_back_to_overview()} {m.btn_back_to_overview()}
</a> </a>
<h1 class="mb-6 font-serif text-3xl font-bold text-brand-navy">{m.admin_user_new_heading()}</h1> <h1 class="mb-6 font-serif text-3xl font-bold text-ink">{m.admin_user_new_heading()}</h1>
{#if form?.error} {#if form?.error}
<div class="mb-5 rounded border border-red-200 bg-red-50 p-3 text-sm text-red-700"> <div class="mb-5 rounded border border-red-200 bg-red-50 p-3 text-sm text-red-700">
@@ -53,148 +33,35 @@ function handleBirthDateInput(e: Event) {
</div> </div>
{/if} {/if}
<div class="rounded-sm border border-brand-sand bg-white p-6 shadow-sm"> <div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
<form method="POST" use:enhance class="space-y-5"> <form method="POST" use:enhance class="space-y-5">
<!-- Account --> <AccountSection />
<h2 class="text-xs font-bold tracking-widest text-gray-400 uppercase">
{m.admin_section_users()}
</h2>
<label class="block">
<span
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
>
{m.admin_col_login()}
</span>
<input
type="text"
name="username"
required
class="w-full rounded-sm border border-brand-sand px-3 py-2 font-serif text-sm focus:border-brand-navy focus:outline-none"
/>
</label>
<label class="block">
<span
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
>
{m.admin_label_initial_password()}
</span>
<input
type="password"
name="password"
required
class="w-full rounded-sm border border-brand-sand px-3 py-2 font-serif text-sm focus:border-brand-navy focus:outline-none"
/>
</label>
<!-- Profile --> <!-- Profile -->
<h2 class="pt-2 text-xs font-bold tracking-widest text-gray-400 uppercase"> <h2 class="pt-2 text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.profile_section_personal()} {m.profile_section_personal()}
</h2> </h2>
<UserProfileSection />
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<label class="block">
<span
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
>
{m.profile_label_first_name()}
</span>
<input
type="text"
name="firstName"
class="w-full rounded-sm border border-brand-sand px-3 py-2 font-serif text-sm focus:border-brand-navy focus:outline-none"
/>
</label>
<label class="block">
<span
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
>
{m.profile_label_last_name()}
</span>
<input
type="text"
name="lastName"
class="w-full rounded-sm border border-brand-sand px-3 py-2 font-serif text-sm focus:border-brand-navy focus:outline-none"
/>
</label>
</div>
<label class="block">
<span
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
>
{m.profile_label_birth_date()}
</span>
<input
type="text"
placeholder="TT.MM.JJJJ"
oninput={handleBirthDateInput}
class="w-full rounded-sm border border-brand-sand px-3 py-2 font-serif text-sm focus:border-brand-navy focus:outline-none"
/>
<input type="hidden" name="birthDate" value={birthDateIso} />
</label>
<label class="block">
<span
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
>
{m.profile_label_email()}
</span>
<input
type="email"
name="email"
class="w-full rounded-sm border border-brand-sand px-3 py-2 font-serif text-sm focus:border-brand-navy focus:outline-none"
/>
</label>
<label class="block">
<span
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
>
{m.profile_label_contact()}
</span>
<textarea
name="contact"
rows="3"
placeholder={m.profile_contact_placeholder()}
class="w-full rounded-sm border border-brand-sand px-3 py-2 font-serif text-sm focus:border-brand-navy focus:outline-none"
></textarea>
</label>
<!-- Groups --> <!-- Groups -->
<h2 class="pt-2 text-xs font-bold tracking-widest text-gray-400 uppercase"> <h2 class="pt-2 text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.admin_col_groups()} {m.admin_col_groups()}
</h2> </h2>
<UserGroupsSection groups={data.groups} />
<div class="flex flex-wrap gap-3">
{#each data.groups as group (group.id)}
<label class="inline-flex items-center gap-2 text-sm text-gray-700">
<input
type="checkbox"
name="groupIds"
value={group.id}
class="rounded border-gray-300 text-brand-navy focus:ring-brand-mint"
/>
{group.name}
</label>
{/each}
</div>
<!-- Save bar --> <!-- Save bar -->
<div <div
class="mt-4 flex items-center justify-between rounded-sm border border-brand-sand bg-white px-6 py-4 shadow-sm" class="mt-4 flex items-center justify-between rounded-sm border border-line bg-surface px-6 py-4 shadow-sm"
> >
<a <a
href="/admin" href="/admin"
class="font-sans text-xs font-bold tracking-widest text-gray-500 uppercase hover:text-brand-navy" class="font-sans text-xs font-bold tracking-widest text-ink-2 uppercase hover:text-ink"
> >
{m.btn_cancel()} {m.btn_cancel()}
</a> </a>
<button <button
type="submit" type="submit"
class="rounded-sm bg-brand-navy px-5 py-2 font-sans text-xs font-bold tracking-widest text-white uppercase transition-opacity hover:opacity-80" class="rounded-sm bg-primary px-5 py-2 font-sans text-xs font-bold tracking-widest text-white uppercase transition-opacity hover:opacity-80"
> >
{m.btn_create()} {m.btn_create()}
</button> </button>

View File

@@ -0,0 +1,31 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
</script>
<h2 class="text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.admin_section_users()}
</h2>
<label class="block">
<span class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.admin_col_login()}
</span>
<input
type="text"
name="username"
required
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:border-ink focus:outline-none"
/>
</label>
<label class="block">
<span class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.admin_label_initial_password()}
</span>
<input
type="password"
name="password"
required
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:border-ink focus:outline-none"
/>
</label>

View File

@@ -1,10 +1,10 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
import { untrack } from 'svelte'; import { untrack } from 'svelte';
import { SvelteURLSearchParams } from 'svelte/reactivity'; import { SvelteURLSearchParams } from 'svelte/reactivity';
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import { formatDate } from '$lib/utils/date'; import ConversationFilterBar from './ConversationFilterBar.svelte';
import ConversationTimeline from './ConversationTimeline.svelte';
let { data } = $props(); let { data } = $props();
@@ -14,14 +14,6 @@ let fromDate = $state(untrack(() => data.filters.from));
let toDate = $state(untrack(() => data.filters.to)); let toDate = $state(untrack(() => data.filters.to));
let sortDir = $state(untrack(() => data.filters.dir)); let sortDir = $state(untrack(() => data.filters.dir));
const documentYears = $derived(
data.documents
.map((doc) => (doc.documentDate ? new Date(doc.documentDate).getFullYear() : null))
.filter((y): y is number => y !== null)
);
const yearFrom = $derived(documentYears.length > 0 ? Math.min(...documentYears) : null);
const yearTo = $derived(documentYears.length > 0 ? Math.max(...documentYears) : null);
// Sync with server data after navigation // Sync with server data after navigation
$effect(() => { $effect(() => {
senderId = data.filters.senderId; senderId = data.filters.senderId;
@@ -52,153 +44,36 @@ function swapPersons() {
receiverId = tmp; receiverId = tmp;
applyFilters(); applyFilters();
} }
const enrichedDocuments = $derived(
data.documents.map((doc, i) => {
const year = doc.documentDate ? new Date(doc.documentDate).getFullYear() : null;
const prevYear =
i > 0 && data.documents[i - 1].documentDate
? new Date(data.documents[i - 1].documentDate!).getFullYear()
: null;
return { doc, year, showYearDivider: year !== null && year !== prevYear };
})
);
</script> </script>
<div class="mx-auto max-w-5xl px-4 py-10"> <div class="mx-auto max-w-5xl px-4 py-10">
<!-- Page Header --> <!-- Page Header -->
<div class="mb-8 border-b border-brand-navy/10 pb-4"> <div class="mb-8 border-b border-ink/10 pb-4">
<h1 class="font-serif text-3xl font-medium text-brand-navy">{m.conv_heading()}</h1> <h1 class="font-serif text-3xl font-medium text-ink">{m.conv_heading()}</h1>
<p class="mt-2 font-sans text-sm text-brand-navy/60"> <p class="mt-2 font-sans text-sm text-ink/60">
{m.conv_subtitle()} {m.conv_subtitle()}
</p> </p>
</div> </div>
<!-- FILTER BAR --> <ConversationFilterBar
<div class="relative z-20 mb-10 border border-brand-sand bg-white p-8 shadow-sm"> bind:senderId={senderId}
<div class="mb-6 grid grid-cols-1 items-end gap-4 md:grid-cols-[1fr_auto_1fr] md:gap-6"> bind:receiverId={receiverId}
<!-- Sender --> bind:fromDate={fromDate}
<div bind:toDate={toDate}
class="relative z-30 [&_input]:border-gray-300 [&_input]:py-2.5 [&_input]:focus:border-brand-navy [&_input]:focus:ring-brand-navy [&_label]:mb-2 [&_label]:text-xs [&_label]:font-bold [&_label]:tracking-widest [&_label]:text-gray-500 [&_label]:uppercase" bind:sortDir={sortDir}
> initialSenderName={data.initialValues.senderName}
<PersonTypeahead initialReceiverName={data.initialValues.receiverName}
name="senderId" onapplyFilters={applyFilters}
label={m.conv_label_person_a()} ontoggleSort={toggleSort}
bind:value={senderId} onswapPersons={swapPersons}
initialName={data.initialValues.senderName} />
restrictToCorrespondentsOf={receiverId || undefined}
onchange={() => applyFilters()}
/>
</div>
<!-- Swap button — always rendered to hold grid column width on desktop.
On mobile: hidden (display:none) when no persons selected so no gap appears.
On desktop: invisible (visibility:hidden) when no persons so both 1fr columns stay equal. -->
<div class="{senderId && receiverId ? 'flex' : 'hidden md:flex'} items-end">
<button
data-testid="conv-swap-btn"
onclick={swapPersons}
class="flex w-full items-center justify-center gap-2 border border-brand-sand px-3 py-2.5 text-xs font-bold tracking-widest text-brand-navy uppercase transition-colors hover:bg-brand-navy hover:text-white md:w-auto {senderId &&
receiverId
? ''
: 'invisible'}"
title={m.conv_swap_btn()}
>
<svg
class="h-4 w-4 flex-shrink-0 md:rotate-90"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 16V4m0 0L3 8m4-4l4 4M17 8v12m0 0l4-4m-4 4l-4-4"
></path>
</svg>
<span class="md:hidden">{m.conv_swap_btn()}</span>
</button>
</div>
<!-- Receiver -->
<div
class="relative z-30 [&_input]:border-gray-300 [&_input]:py-2.5 [&_input]:focus:border-brand-navy [&_input]:focus:ring-brand-navy [&_label]:mb-2 [&_label]:text-xs [&_label]:font-bold [&_label]:tracking-widest [&_label]:text-gray-500 [&_label]:uppercase"
>
<PersonTypeahead
name="receiverId"
label={m.conv_label_person_b()}
bind:value={receiverId}
initialName={data.initialValues.receiverName}
restrictToCorrespondentsOf={senderId || undefined}
onchange={() => applyFilters()}
/>
</div>
</div>
<div class="relative z-10 grid grid-cols-1 items-end gap-6 md:grid-cols-3">
<!-- Date From -->
<div>
<label
for="dateFrom"
class="mb-2 block text-xs font-bold tracking-widest text-gray-500 uppercase"
>{m.conv_label_from()}</label
>
<input
id="dateFrom"
type="date"
bind:value={fromDate}
onchange={() => applyFilters()}
class="block w-full border-gray-300 py-2.5 text-sm shadow-sm focus:border-brand-navy focus:ring-brand-navy"
/>
</div>
<!-- Date To -->
<div>
<label
for="dateTo"
class="mb-2 block text-xs font-bold tracking-widest text-gray-500 uppercase"
>{m.conv_label_to()}</label
>
<input
id="dateTo"
type="date"
bind:value={toDate}
onchange={() => applyFilters()}
class="block w-full border-gray-300 py-2.5 text-sm shadow-sm focus:border-brand-navy focus:ring-brand-navy"
/>
</div>
<!-- Sort Toggle -->
<div>
<button
onclick={toggleSort}
class="flex h-[42px] w-full items-center justify-center border border-brand-sand text-xs font-bold tracking-wide text-brand-navy uppercase transition-colors hover:bg-brand-navy hover:text-white"
>
<span class="mr-2">{m.conv_sort_label()}</span>
<span>{sortDir === 'DESC' ? m.conv_sort_newest() : m.conv_sort_oldest()}</span>
<svg
class="ml-2 h-4 w-4 transform {sortDir === 'ASC'
? 'rotate-180'
: ''} transition-transform"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"
></path>
</svg>
</button>
</div>
</div>
</div>
<!-- RESULTS LIST SECTION --> <!-- RESULTS LIST SECTION -->
{#if !senderId || !receiverId} {#if !senderId || !receiverId}
<div <div
class="flex flex-col items-center justify-center rounded-sm border border-dashed border-brand-sand bg-white py-24 text-center" class="flex flex-col items-center justify-center rounded-sm border border-dashed border-line bg-surface py-24 text-center"
> >
<div class="mb-4 rounded-full bg-brand-sand/30 p-4 text-brand-navy"> <div class="mb-4 rounded-full bg-muted p-4 text-ink">
<svg class="h-8 w-8" fill="none" stroke="currentColor" viewBox="0 0 24 24" <svg class="h-8 w-8" fill="none" stroke="currentColor" viewBox="0 0 24 24"
><path ><path
stroke-linecap="round" stroke-linecap="round"
@@ -208,139 +83,22 @@ const enrichedDocuments = $derived(
/></svg /></svg
> >
</div> </div>
<p class="font-serif text-lg text-brand-navy">{m.conv_empty_heading()}</p> <p class="font-serif text-lg text-ink">{m.conv_empty_heading()}</p>
<p class="mt-1 font-sans text-sm text-gray-500">{m.conv_empty_text()}</p> <p class="mt-1 font-sans text-sm text-ink-2">{m.conv_empty_text()}</p>
</div> </div>
{:else if data.documents.length === 0} {:else if data.documents.length === 0}
<div <div
class="flex flex-col items-center justify-center rounded-sm border border-brand-sand bg-white py-24 text-center shadow-sm" class="flex flex-col items-center justify-center rounded-sm border border-line bg-surface py-24 text-center shadow-sm"
> >
<p class="font-serif text-brand-navy">{m.conv_no_results_heading()}</p> <p class="font-serif text-ink">{m.conv_no_results_heading()}</p>
<p class="mt-2 text-sm text-gray-400">{m.conv_no_results_text()}</p> <p class="mt-2 text-sm text-ink-3">{m.conv_no_results_text()}</p>
</div> </div>
{:else} {:else}
<!-- Summary bar --> <ConversationTimeline
<div class="mb-4 flex items-center justify-between"> documents={data.documents}
{#if yearFrom !== null && yearTo !== null} senderId={senderId}
<p data-testid="conv-summary" class="font-sans text-sm font-medium text-brand-navy/70"> receiverId={receiverId}
{m.conv_summary({ count: data.documents.length, yearFrom, yearTo })} canWrite={data.canWrite}
</p> />
{:else}
<p data-testid="conv-summary" class="font-sans text-sm font-medium text-brand-navy/70">
{data.documents.length}
</p>
{/if}
{#if data.canWrite}
<a
data-testid="conv-new-doc-link"
href="/documents/new?senderId={senderId}&receiverId={receiverId}"
class="inline-flex items-center gap-1 text-sm font-medium text-brand-navy/60 transition-colors hover:text-brand-navy"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"
></path>
</svg>
{m.conv_new_doc_link()}
</a>
{/if}
</div>
<!-- CHAT CONTAINER -->
<div class="relative overflow-hidden rounded-sm border border-brand-sand bg-white shadow-sm">
<!-- Decoration: Central Timeline Line -->
<div
class="absolute top-0 bottom-0 left-1/2 hidden w-px -translate-x-1/2 transform bg-brand-sand/30 md:block"
></div>
<div class="p-6 md:p-8">
<div class="relative z-10 flex flex-col gap-4">
{#each enrichedDocuments as { doc, year, showYearDivider } (doc.id)}
{#if showYearDivider}
<div data-testid="year-divider" class="relative flex items-center py-2 text-center">
<div class="flex-grow border-t border-brand-sand"></div>
<span
class="mx-4 font-sans text-xs font-bold tracking-widest text-brand-navy/40 uppercase"
>{year}</span
>
<div class="flex-grow border-t border-brand-sand"></div>
</div>
{/if}
{@const isRight = doc.sender?.id === senderId}
<!-- Message Row -->
<div class="flex w-full {isRight ? 'justify-end' : 'justify-start'}">
<!-- Bubble Group -->
<div
class="flex max-w-[90%] gap-3 md:max-w-[70%] {isRight
? 'flex-row-reverse'
: 'flex-row'}"
>
<!-- AVATAR -->
<div class="mt-auto mb-1 hidden flex-shrink-0 sm:block">
<div
class="flex h-8 w-8 items-center justify-center rounded-full border font-serif text-xs shadow-sm
{isRight
? 'border-brand-navy bg-brand-navy text-white'
: 'border-brand-sand bg-white text-brand-navy'}"
>
{#if doc.sender}
{doc.sender.firstName[0]}{doc.sender.lastName[0]}
{:else}
?
{/if}
</div>
</div>
<!-- BUBBLE CARD -->
<a
href="/documents/{doc.id}"
class="group block transform rounded border p-4 shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md
{isRight
? 'rounded-br-none border-brand-navy bg-brand-navy text-white'
: 'rounded-bl-none border-brand-sand bg-brand-sand/10 text-brand-navy'}"
>
<!-- Header -->
<div class="mb-2 flex items-start justify-between gap-4">
<h3
class="font-serif text-sm leading-snug font-medium {isRight
? 'text-white'
: 'text-brand-navy'}"
>
{doc.title || doc.originalFilename}
</h3>
<!-- Status Dot -->
<span
class="mt-1.5 h-1.5 w-1.5 flex-shrink-0 rounded-full
{doc.status === 'UPLOADED'
? 'bg-brand-mint'
: 'bg-yellow-400'}"
title={doc.status}
>
</span>
</div>
<!-- Metadata -->
<div
class="flex flex-wrap gap-3 font-sans text-[10px] tracking-wider uppercase opacity-80 {isRight
? 'text-blue-100'
: 'text-gray-500'}"
>
<span class="flex items-center">
{doc.documentDate ? formatDate(doc.documentDate) : '—'}
</span>
{#if doc.location}
<span class="flex items-center">
{doc.location}
</span>
{/if}
</div>
</a>
</div>
</div>
{/each}
</div>
</div>
</div>
{/if} {/if}
</div> </div>

View File

@@ -0,0 +1,142 @@
<script lang="ts">
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
import { m } from '$lib/paraglide/messages.js';
let {
senderId = $bindable(''),
receiverId = $bindable(''),
fromDate = $bindable(''),
toDate = $bindable(''),
sortDir = $bindable('DESC'),
initialSenderName = '',
initialReceiverName = '',
onapplyFilters,
ontoggleSort,
onswapPersons
}: {
senderId?: string;
receiverId?: string;
fromDate?: string;
toDate?: string;
sortDir?: string;
initialSenderName?: string;
initialReceiverName?: string;
onapplyFilters: () => void;
ontoggleSort: () => void;
onswapPersons: () => void;
} = $props();
</script>
<div class="relative z-20 mb-10 border border-line bg-surface p-8 shadow-sm">
<div class="mb-6 grid grid-cols-1 items-end gap-4 md:grid-cols-[1fr_auto_1fr] md:gap-6">
<!-- Sender -->
<div
class="relative z-30 [&_input]:border-line [&_input]:py-2.5 [&_input]:focus:border-ink [&_input]:focus:ring-ink [&_label]:mb-2 [&_label]:text-xs [&_label]:font-bold [&_label]:tracking-widest [&_label]:text-ink-2 [&_label]:uppercase"
>
<PersonTypeahead
name="senderId"
label={m.conv_label_person_a()}
bind:value={senderId}
initialName={initialSenderName}
restrictToCorrespondentsOf={receiverId || undefined}
onchange={() => onapplyFilters()}
/>
</div>
<!-- Swap button -->
<div class="{senderId && receiverId ? 'flex' : 'hidden md:flex'} items-end">
<button
data-testid="conv-swap-btn"
onclick={onswapPersons}
class="flex w-full items-center justify-center gap-2 border border-line px-3 py-2.5 text-xs font-bold tracking-widest text-ink uppercase transition-colors hover:bg-primary hover:text-white md:w-auto {senderId &&
receiverId
? ''
: 'invisible'}"
title={m.conv_swap_btn()}
>
<svg
class="h-4 w-4 flex-shrink-0 md:rotate-90"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 16V4m0 0L3 8m4-4l4 4M17 8v12m0 0l4-4m-4 4l-4-4"
></path>
</svg>
<span class="md:hidden">{m.conv_swap_btn()}</span>
</button>
</div>
<!-- Receiver -->
<div
class="relative z-30 [&_input]:border-line [&_input]:py-2.5 [&_input]:focus:border-ink [&_input]:focus:ring-ink [&_label]:mb-2 [&_label]:text-xs [&_label]:font-bold [&_label]:tracking-widest [&_label]:text-ink-2 [&_label]:uppercase"
>
<PersonTypeahead
name="receiverId"
label={m.conv_label_person_b()}
bind:value={receiverId}
initialName={initialReceiverName}
restrictToCorrespondentsOf={senderId || undefined}
onchange={() => onapplyFilters()}
/>
</div>
</div>
<div class="relative z-10 grid grid-cols-1 items-end gap-6 md:grid-cols-3">
<!-- Date From -->
<div>
<label
for="dateFrom"
class="mb-2 block text-xs font-bold tracking-widest text-ink-2 uppercase"
>{m.conv_label_from()}</label
>
<input
id="dateFrom"
type="date"
bind:value={fromDate}
onchange={() => onapplyFilters()}
class="block w-full border-line py-2.5 text-sm shadow-sm focus:border-ink focus:ring-ink"
/>
</div>
<!-- Date To -->
<div>
<label for="dateTo" class="mb-2 block text-xs font-bold tracking-widest text-ink-2 uppercase"
>{m.conv_label_to()}</label
>
<input
id="dateTo"
type="date"
bind:value={toDate}
onchange={() => onapplyFilters()}
class="block w-full border-line py-2.5 text-sm shadow-sm focus:border-ink focus:ring-ink"
/>
</div>
<!-- Sort Toggle -->
<div>
<button
onclick={ontoggleSort}
class="flex h-[42px] w-full items-center justify-center border border-line text-xs font-bold tracking-wide text-ink uppercase transition-colors hover:bg-primary hover:text-white"
>
<span class="mr-2">{m.conv_sort_label()}</span>
<span>{sortDir === 'DESC' ? m.conv_sort_newest() : m.conv_sort_oldest()}</span>
<svg
class="ml-2 h-4 w-4 transform {sortDir === 'ASC'
? 'rotate-180'
: ''} transition-transform"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"
></path>
</svg>
</button>
</div>
</div>
</div>

View File

@@ -0,0 +1,164 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { formatDate } from '$lib/utils/date';
let {
documents,
senderId,
receiverId,
canWrite
}: {
documents: {
id: string;
title?: string;
originalFilename: string;
documentDate?: string;
location?: string;
status: string;
sender?: { id: string; firstName: string; lastName: string } | null;
}[];
senderId: string;
receiverId: string;
canWrite: boolean;
} = $props();
const documentYears = $derived(
documents
.map((doc) => (doc.documentDate ? new Date(doc.documentDate).getFullYear() : null))
.filter((y): y is number => y !== null)
);
const yearFrom = $derived(documentYears.length > 0 ? Math.min(...documentYears) : null);
const yearTo = $derived(documentYears.length > 0 ? Math.max(...documentYears) : null);
const enrichedDocuments = $derived(
documents.map((doc, i) => {
const year = doc.documentDate ? new Date(doc.documentDate).getFullYear() : null;
const prevYear =
i > 0 && documents[i - 1].documentDate
? new Date(documents[i - 1].documentDate!).getFullYear()
: null;
return { doc, year, showYearDivider: year !== null && year !== prevYear };
})
);
</script>
<!-- Summary bar -->
<div class="mb-4 flex items-center justify-between">
{#if yearFrom !== null && yearTo !== null}
<p data-testid="conv-summary" class="font-sans text-sm font-medium text-ink/70">
{m.conv_summary({ count: documents.length, yearFrom, yearTo })}
</p>
{:else}
<p data-testid="conv-summary" class="font-sans text-sm font-medium text-ink/70">
{documents.length}
</p>
{/if}
{#if canWrite}
<a
data-testid="conv-new-doc-link"
href="/documents/new?senderId={senderId}&receiverId={receiverId}"
class="inline-flex items-center gap-1 text-sm font-medium text-ink/60 transition-colors hover:text-ink"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"
></path>
</svg>
{m.conv_new_doc_link()}
</a>
{/if}
</div>
<!-- CHAT CONTAINER -->
<div class="relative overflow-hidden rounded-sm border border-line bg-surface shadow-sm">
<!-- Decoration: Central Timeline Line -->
<div
class="absolute top-0 bottom-0 left-1/2 hidden w-px -translate-x-1/2 transform bg-muted md:block"
></div>
<div class="p-6 md:p-8">
<div class="relative z-10 flex flex-col gap-4">
{#each enrichedDocuments as { doc, year, showYearDivider } (doc.id)}
{#if showYearDivider}
<div data-testid="year-divider" class="relative flex items-center py-2 text-center">
<div class="flex-grow border-t border-line"></div>
<span class="mx-4 font-sans text-xs font-bold tracking-widest text-ink/40 uppercase"
>{year}</span
>
<div class="flex-grow border-t border-line"></div>
</div>
{/if}
{@const isRight = doc.sender?.id === senderId}
<!-- Message Row -->
<div class="flex w-full {isRight ? 'justify-end' : 'justify-start'}">
<!-- Bubble Group -->
<div
class="flex max-w-[90%] gap-3 md:max-w-[70%] {isRight
? 'flex-row-reverse'
: 'flex-row'}"
>
<!-- AVATAR -->
<div class="mt-auto mb-1 hidden flex-shrink-0 sm:block">
<div
class="flex h-8 w-8 items-center justify-center rounded-full border font-serif text-xs shadow-sm
{isRight
? 'border-primary bg-primary text-primary-fg'
: 'border-line bg-surface text-ink'}"
>
{#if doc.sender}
{doc.sender.firstName[0]}{doc.sender.lastName[0]}
{:else}
?
{/if}
</div>
</div>
<!-- BUBBLE CARD -->
<a
href="/documents/{doc.id}"
class="group block transform rounded border p-4 shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md
{isRight
? 'rounded-br-none border-primary bg-primary text-primary-fg'
: 'rounded-bl-none border-line bg-muted/50 text-ink'}"
>
<!-- Header -->
<div class="mb-2 flex items-start justify-between gap-4">
<h3
class="font-serif text-sm leading-snug font-medium {isRight
? 'text-primary-fg'
: 'text-ink'}"
>
{doc.title || doc.originalFilename}
</h3>
<!-- Status Dot -->
<span
class="mt-1.5 h-1.5 w-1.5 flex-shrink-0 rounded-full
{doc.status === 'UPLOADED' ? 'bg-accent' : 'bg-yellow-400'}"
title={doc.status}
>
</span>
</div>
<!-- Metadata -->
<div
class="flex flex-wrap gap-3 font-sans text-[10px] tracking-wider uppercase opacity-80 {isRight
? 'text-primary-fg/70'
: 'text-ink-2'}"
>
<span class="flex items-center">
{doc.documentDate ? formatDate(doc.documentDate) : '—'}
</span>
{#if doc.location}
<span class="flex items-center">
{doc.location}
</span>
{/if}
</div>
</a>
</div>
</div>
{/each}
</div>
</div>
</div>

View File

@@ -4,8 +4,7 @@ import DocumentTopBar from '$lib/components/DocumentTopBar.svelte';
import DocumentViewer from '$lib/components/DocumentViewer.svelte'; import DocumentViewer from '$lib/components/DocumentViewer.svelte';
import DocumentBottomPanel from '$lib/components/DocumentBottomPanel.svelte'; import DocumentBottomPanel from '$lib/components/DocumentBottomPanel.svelte';
import AnnotationSidePanel from '$lib/components/AnnotationSidePanel.svelte'; import AnnotationSidePanel from '$lib/components/AnnotationSidePanel.svelte';
import type { DocumentPanelTab } from '$lib/types';
type Tab = 'metadata' | 'transcription' | 'discussion' | 'history';
let { data } = $props(); let { data } = $props();
@@ -68,11 +67,12 @@ $effect(() => {
const LS_KEY_HEIGHT = 'doc-panel-height'; const LS_KEY_HEIGHT = 'doc-panel-height';
const LS_KEY_TAB = 'doc-panel-tab'; const LS_KEY_TAB = 'doc-panel-tab';
const LS_KEY_OPEN = 'doc-panel-open';
let panelOpen = $state(false); let panelOpen = $state(false);
let panelHeight = $state(0); // set to full height on mount let panelHeight = $state(0); // set to full height on mount
let navHeight = $state(0); let navHeight = $state(0);
let activeTab = $state<Tab>('metadata'); let activeTab = $state<DocumentPanelTab>('metadata');
let localStorageRestored = $state(false); let localStorageRestored = $state(false);
onMount(() => { onMount(() => {
@@ -80,9 +80,10 @@ onMount(() => {
const savedHeight = localStorage.getItem(LS_KEY_HEIGHT); const savedHeight = localStorage.getItem(LS_KEY_HEIGHT);
const savedTab = localStorage.getItem(LS_KEY_TAB); const savedTab = localStorage.getItem(LS_KEY_TAB);
const savedOpen = localStorage.getItem(LS_KEY_OPEN);
if (savedTab && ['metadata', 'transcription', 'discussion', 'history'].includes(savedTab)) { if (savedTab && ['metadata', 'transcription', 'discussion', 'history'].includes(savedTab)) {
activeTab = savedTab as Tab; activeTab = savedTab as DocumentPanelTab;
} }
const topbar = document.querySelector('[data-topbar]'); const topbar = document.querySelector('[data-topbar]');
panelHeight = window.innerHeight - navHeight - (topbar?.getBoundingClientRect().height ?? 0); panelHeight = window.innerHeight - navHeight - (topbar?.getBoundingClientRect().height ?? 0);
@@ -91,6 +92,14 @@ onMount(() => {
if (!isNaN(h) && h >= 80) panelHeight = h; 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; localStorageRestored = true;
function onKeyDown(e: KeyboardEvent) { function onKeyDown(e: KeyboardEvent) {
@@ -112,6 +121,7 @@ $effect(() => {
if (!localStorageRestored) return; if (!localStorageRestored) return;
localStorage.setItem(LS_KEY_HEIGHT, String(panelHeight)); localStorage.setItem(LS_KEY_HEIGHT, String(panelHeight));
localStorage.setItem(LS_KEY_TAB, activeTab); localStorage.setItem(LS_KEY_TAB, activeTab);
localStorage.setItem(LS_KEY_OPEN, String(panelOpen));
}); });
</script> </script>
@@ -120,7 +130,7 @@ $effect(() => {
</svelte:head> </svelte:head>
<div <div
class="fixed right-0 bottom-0 left-0 z-40 flex flex-col overflow-hidden bg-white" class="fixed right-0 bottom-0 left-0 z-40 flex flex-col overflow-hidden bg-surface"
style="top: {navHeight}px" style="top: {navHeight}px"
data-hydrated data-hydrated
> >

View File

@@ -41,7 +41,7 @@ export async function load({
} }
export const actions = { export const actions = {
default: async ({ request, params, fetch }) => { update: async ({ request, params, fetch }) => {
// Raw fetch is used here because FormData multipart bodies are passed through // Raw fetch is used here because FormData multipart bodies are passed through
// directly from the browser without transformation. // directly from the browser without transformation.
const baseUrl = env.API_INTERNAL_URL || 'http://localhost:8080'; const baseUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
@@ -58,5 +58,67 @@ export const actions = {
} }
throw redirect(303, `/documents/${params.id}`); 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';
const res = await fetch(`${baseUrl}/api/documents/${params.id}`, {
method: 'DELETE'
});
if (!res.ok) {
const backendError = await parseBackendError(res);
return fail(res.status, { error: getErrorMessage(backendError?.code) });
}
throw redirect(303, '/');
} }
}; };

View File

@@ -1,11 +1,12 @@
<script lang="ts"> <script lang="ts">
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import TagInput from '$lib/components/TagInput.svelte';
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
import PersonMultiSelect from '$lib/components/PersonMultiSelect.svelte';
import { untrack } from 'svelte'; import { untrack } from 'svelte';
import { isoToGerman, germanToIso } from '$lib/utils';
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
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 FileSectionEdit from './FileSectionEdit.svelte';
import SaveBar from './SaveBar.svelte';
let { data, form } = $props(); let { data, form } = $props();
@@ -13,29 +14,6 @@ let { document: doc } = untrack(() => data);
let tags = $state(doc.tags ? doc.tags.map((t: { name: string }) => t.name) : []); let tags = $state(doc.tags ? doc.tags.map((t: { name: string }) => t.name) : []);
let senderId = $state(doc.sender?.id ?? ''); let senderId = $state(doc.sender?.id ?? '');
let selectedReceivers = $state(doc.receivers ?? []); let selectedReceivers = $state(doc.receivers ?? []);
let dateDisplay = $state(isoToGerman(doc.documentDate ?? ''));
let dateIso = $state(doc.documentDate ?? '');
let dateDirty = $state(false);
const dateInvalid = $derived(dateDirty && dateDisplay.length > 0 && dateIso === '');
function handleDateInput(e: Event) {
const input = e.target as HTMLInputElement;
const digits = input.value.replace(/\D/g, '').slice(0, 8);
let formatted: string;
if (digits.length <= 2) {
formatted = digits;
} else if (digits.length <= 4) {
formatted = `${digits.slice(0, 2)}.${digits.slice(2)}`;
} else {
formatted = `${digits.slice(0, 2)}.${digits.slice(2, 4)}.${digits.slice(4)}`;
}
input.value = formatted;
dateDisplay = formatted;
dateIso = germanToIso(formatted);
dateDirty = true;
}
</script> </script>
<div class="mx-auto max-w-4xl px-4 py-8"> <div class="mx-auto max-w-4xl px-4 py-8">
@@ -43,7 +21,7 @@ function handleDateInput(e: Event) {
<div class="mb-6"> <div class="mb-6">
<a <a
href="/documents/{doc.id}" href="/documents/{doc.id}"
class="group mb-4 inline-flex items-center text-xs font-bold tracking-widest text-gray-500 uppercase transition-colors hover:text-brand-navy" class="group mb-4 inline-flex items-center text-xs font-bold tracking-widest text-ink-2 uppercase transition-colors hover:text-ink"
> >
<img <img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Arrow/Arrow-Left-MD.svg" src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Arrow/Arrow-Left-MD.svg"
@@ -53,9 +31,9 @@ function handleDateInput(e: Event) {
/> />
{m.btn_back_to_document()} {m.btn_back_to_document()}
</a> </a>
<h1 class="font-serif text-3xl text-brand-navy"> <h1 class="font-serif text-3xl text-ink">
{m.doc_edit_heading()} {m.doc_edit_heading()}
<span class="text-brand-navy/70">{doc.title || doc.originalFilename}</span> <span class="text-ink/70">{doc.title || doc.originalFilename}</span>
</h1> </h1>
</div> </div>
@@ -63,201 +41,33 @@ function handleDateInput(e: Event) {
<div class="mb-6 rounded border border-red-200 bg-red-50 p-4 text-red-700">{form.error}</div> <div class="mb-6 rounded border border-red-200 bg-red-50 p-4 text-red-700">{form.error}</div>
{/if} {/if}
<form method="POST" enctype="multipart/form-data" use:enhance class="space-y-6 pb-20"> <form
<!-- ── Section 1: Wer & Wann ── --> id="update-form"
<div class="rounded-sm border border-brand-sand bg-white p-6 shadow-sm"> method="POST"
<h2 class="mb-5 text-xs font-bold tracking-widest text-gray-400 uppercase"> action="?/update"
{m.doc_section_who_when()} enctype="multipart/form-data"
</h2> use:enhance
class="space-y-6 pb-20"
<div class="grid grid-cols-1 gap-5 md:grid-cols-2"> >
<!-- Datum --> <WhoWhenSection
<div> bind:senderId={senderId}
<label for="documentDate" class="mb-1 block text-sm font-medium text-gray-700" bind:selectedReceivers={selectedReceivers}
>{m.form_label_date()}</label initialDateIso={doc.documentDate ?? ''}
> initialLocation={doc.location ?? ''}
<input initialSenderName={doc.sender ? `${doc.sender.firstName} ${doc.sender.lastName}` : ''}
id="documentDate" />
type="text" <DescriptionSection
inputmode="numeric" bind:tags={tags}
value={dateDisplay} initialTitle={doc.title ?? ''}
oninput={handleDateInput} initialDocumentLocation={doc.documentLocation ?? ''}
placeholder={m.form_placeholder_date()} initialSummary={doc.summary ?? ''}
maxlength="10" titleRequired={true}
class="block w-full rounded border border-gray-300 p-2 text-sm shadow-sm />
{dateInvalid ? 'border-red-400 focus:border-red-500 focus:ring-red-500' : 'focus:border-brand-navy focus:ring-brand-navy'}" <TranscriptionSection initialTranscription={doc.transcription ?? ''} />
aria-describedby={dateInvalid ? 'date-error' : undefined} <FileSectionEdit originalFilename={doc.originalFilename} />
/> <SaveBar docId={doc.id} />
<input type="hidden" name="documentDate" value={dateIso} />
{#if dateInvalid}
<p id="date-error" class="mt-1 text-xs text-red-600">{m.form_date_error()}</p>
{/if}
</div>
<!-- Ort -->
<div>
<label for="location" class="mb-1 block text-sm font-medium text-gray-700"
>{m.form_label_location()}</label
>
<input
id="location"
type="text"
name="location"
value={doc.location || ''}
placeholder={m.form_placeholder_location()}
class="block w-full rounded border border-gray-300 p-2 text-sm shadow-sm focus:border-brand-navy focus:ring-brand-navy"
/>
</div>
<!-- Absender -->
<div>
<PersonTypeahead
name="senderId"
label={m.form_label_sender()}
bind:value={senderId}
initialName={doc.sender ? `${doc.sender.firstName} ${doc.sender.lastName}` : ''}
/>
</div>
<!-- Empfänger -->
<div>
<p class="mb-1 block text-sm font-medium text-gray-700">{m.form_label_receivers()}</p>
<PersonMultiSelect bind:selectedPersons={selectedReceivers} />
</div>
</div>
</div>
<!-- ── Section 2: Beschreibung ── -->
<div class="rounded-sm border border-brand-sand bg-white p-6 shadow-sm">
<h2 class="mb-5 text-xs font-bold tracking-widest text-gray-400 uppercase">
{m.doc_section_description()}
</h2>
<div class="space-y-5">
<!-- Titel -->
<div>
<label for="title" class="mb-1 block text-sm font-medium text-gray-700"
>{m.form_label_title()} *</label
>
<input
id="title"
type="text"
name="title"
value={doc.title || ''}
required
class="block w-full rounded border border-gray-300 p-2 text-sm shadow-sm focus:border-brand-navy focus:ring-brand-navy"
/>
</div>
<!-- Aufbewahrungsort -->
<div>
<label for="documentLocation" class="mb-1 block text-sm font-medium text-gray-700"
>{m.form_label_archive_location()}</label
>
<input
id="documentLocation"
type="text"
name="documentLocation"
value={doc.documentLocation || ''}
placeholder={m.form_placeholder_archive_location()}
class="block w-full rounded border border-gray-300 p-2 text-sm shadow-sm focus:border-brand-navy focus:ring-brand-navy"
/>
<p class="mt-1 text-xs text-gray-400">{m.form_helper_archive_location()}</p>
</div>
<!-- Schlagworte -->
<div>
<p class="mb-1 block text-sm font-medium text-gray-700">{m.form_label_tags()}</p>
<TagInput bind:tags={tags} />
<input type="hidden" name="tags" value={tags.join(',')} />
</div>
<!-- Inhalt -->
<div>
<label for="summary" class="mb-1 block text-sm font-medium text-gray-700"
>{m.form_label_content()}</label
>
<textarea
id="summary"
name="summary"
rows="5"
placeholder={m.form_placeholder_content()}
class="block w-full rounded border border-gray-300 p-2 font-serif text-sm shadow-sm focus:border-brand-navy focus:ring-brand-navy"
>{doc.summary || ''}</textarea
>
</div>
</div>
</div>
<!-- ── Section 3: Transkription ── -->
<div class="rounded-sm border border-brand-sand bg-white p-6 shadow-sm">
<h2 class="mb-5 text-xs font-bold tracking-widest text-gray-400 uppercase">
{m.form_label_transcription()}
</h2>
<textarea
id="transcription"
name="transcription"
rows="12"
placeholder={m.form_placeholder_transcription()}
class="block w-full rounded border border-gray-300 p-2 font-serif text-sm shadow-sm focus:border-brand-navy focus:ring-brand-navy"
>{doc.transcription || ''}</textarea
>
</div>
<!-- ── Section 4: Datei ── -->
<div class="rounded-sm border border-brand-sand bg-white p-6 shadow-sm">
<h2 class="mb-5 text-xs font-bold tracking-widest text-gray-400 uppercase">
{m.doc_section_file()}
</h2>
<div
class="mb-4 flex items-center gap-3 rounded bg-brand-sand/20 px-3 py-2 text-sm text-gray-600"
>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/PDF-Document-MD.svg"
alt=""
aria-hidden="true"
class="h-4 w-4 flex-shrink-0"
/>
<span
>{m.doc_current_file_label()}
<strong class="font-medium text-brand-navy">{doc.originalFilename}</strong></span
>
</div>
<label for="file-upload" class="mb-1 block text-sm font-medium text-gray-700">
{m.doc_file_replace_label()}
<span class="font-normal text-gray-400">({m.doc_file_replace_note()})</span>
</label>
<input
id="file-upload"
type="file"
name="file"
class="block w-full cursor-pointer text-sm
text-gray-500 file:mr-4 file:rounded
file:border-0 file:bg-brand-sand/40
file:px-4 file:py-2
file:text-sm file:font-semibold
file:text-brand-navy hover:file:bg-brand-sand/60"
/>
</div>
<!-- ── Sticky Save Bar ── -->
<div
class="sticky bottom-0 z-10 -mx-4 flex items-center justify-between border-t border-brand-sand bg-white px-6 py-4 shadow-[0_-2px_8px_rgba(0,0,0,0.06)]"
>
<a
href="/documents/{doc.id}"
class="text-sm font-medium text-gray-500 transition-colors hover:text-brand-navy"
>
{m.btn_cancel()}
</a>
<button
type="submit"
class="rounded bg-brand-navy px-6 py-2 text-sm font-bold tracking-widest text-white uppercase transition-colors hover:bg-brand-navy/80"
>
{m.btn_save()}
</button>
</div>
</form> </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> </div>

View File

@@ -0,0 +1,40 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
let { originalFilename }: { originalFilename: string } = $props();
</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="mb-4 flex items-center gap-3 rounded bg-muted px-3 py-2 text-sm text-ink-2">
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/PDF-Document-MD.svg"
alt=""
aria-hidden="true"
class="h-4 w-4 flex-shrink-0"
/>
<span
>{m.doc_current_file_label()}
<strong class="font-medium text-ink">{originalFilename}</strong></span
>
</div>
<label for="file-upload" class="mb-1 block text-sm font-medium text-ink-2">
{m.doc_file_replace_label()}
<span class="font-normal text-ink-3">({m.doc_file_replace_note()})</span>
</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"
/>
</div>

View File

@@ -0,0 +1,79 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
let { docId }: { docId: string } = $props();
let confirmDelete = $state(false);
</script>
<div
class="sticky bottom-0 z-10 -mx-4 flex items-center justify-between border-t border-line bg-surface px-6 py-4 shadow-[0_-2px_8px_rgba(0,0,0,0.06)]"
>
<!-- Left: delete -->
<div class="flex items-center gap-3">
{#if confirmDelete}
<span class="font-sans text-sm text-red-700">{m.doc_delete_confirm()}</span>
<button
type="submit"
form="delete-form"
class="rounded bg-red-600 px-4 py-1.5 text-sm font-bold text-white transition-colors hover:bg-red-700"
>
{m.btn_delete()}
</button>
<button
type="button"
onclick={() => (confirmDelete = false)}
class="text-sm text-ink-2 transition-colors hover:text-ink"
>
{m.btn_cancel()}
</button>
{:else}
<button
type="button"
onclick={() => (confirmDelete = true)}
class="flex items-center gap-1.5 rounded border border-red-300 px-4 py-1.5 text-sm font-bold text-red-600 transition-colors hover:border-red-600 hover:bg-red-50"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
<path d="M10 11v6M14 11v6" />
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
</svg>
{m.btn_delete()}
</button>
{/if}
</div>
<!-- Right: cancel + mark for review + save -->
<div class="flex items-center gap-4">
<a
href="/documents/{docId}"
class="text-sm font-medium text-ink-2 transition-colors hover:text-ink"
>
{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"
>
{m.btn_save()}
</button>
</div>
</div>

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 = { export const actions = {
default: async ({ request, fetch }) => { save: async ({ request, fetch }: { request: Request; fetch: typeof globalThis.fetch }) => {
const baseUrl = env.API_INTERNAL_URL || 'http://localhost:8080'; return submitNewDocument(request, fetch, false);
const formData = await request.formData(); },
const res = await fetch(`${baseUrl}/api/documents`, { saveReviewed: async ({
method: 'POST', request,
body: formData fetch
}); }: {
request: Request;
if (!res.ok) { fetch: typeof globalThis.fetch;
const backendError = await parseBackendError(res); }) => {
return fail(res.status, { error: getErrorMessage(backendError?.code) }); return submitNewDocument(request, fetch, true);
}
const created = await res.json();
throw redirect(303, `/documents/${created.id}`);
} }
}; };

View File

@@ -1,10 +1,12 @@
<script lang="ts"> <script lang="ts">
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import TagInput from '$lib/components/TagInput.svelte';
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
import PersonMultiSelect from '$lib/components/PersonMultiSelect.svelte';
import { untrack } from 'svelte'; import { untrack } from 'svelte';
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
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(); let { data, form } = $props();
@@ -14,35 +16,31 @@ let selectedReceivers: { id: string; firstName: string; lastName: string }[] = $
untrack(() => data.initialReceivers) untrack(() => data.initialReceivers)
); );
let dateDisplay = $state(''); let parsedSuggestion = $state<FilenameParseResult>({});
let dateIso = $state('');
let dateDirty = $state(false);
const dateInvalid = $derived(dateDirty && dateDisplay.length > 0 && dateIso === ''); // 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)
);
function germanToIso(german: string): string { // Details panel: starts open when prefill data is present or a form error occurred.
const match = german.match(/^(\d{2})\.(\d{2})\.(\d{4})$/); // Auto-opens when filename parsing finds a date/sender, but never force-closes — user
if (!match) return ''; // can always collapse the section manually.
const [, d, m, y] = match; let detailsOpen = $state(
return `${y}-${m}-${d}`; !!(
} untrack(() => data.initialSenderId) ||
untrack(() => data.initialReceivers).length > 0 ||
untrack(() => form)?.error
)
);
function handleDateInput(e: Event) { $effect(() => {
const input = e.target as HTMLInputElement; if (parsedSuggestion.dateIso || senderId || selectedReceivers.length > 0) {
const digits = input.value.replace(/\D/g, '').slice(0, 8); detailsOpen = true;
let formatted: string;
if (digits.length <= 2) {
formatted = digits;
} else if (digits.length <= 4) {
formatted = `${digits.slice(0, 2)}.${digits.slice(2)}`;
} else {
formatted = `${digits.slice(0, 2)}.${digits.slice(2, 4)}.${digits.slice(4)}`;
} }
input.value = formatted; });
dateDisplay = formatted;
dateIso = germanToIso(formatted);
dateDirty = true;
}
</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,7 +48,7 @@ function handleDateInput(e: Event) {
<div class="mb-6"> <div class="mb-6">
<a <a
href="/" href="/"
class="group mb-4 inline-flex items-center text-xs font-bold tracking-widest text-gray-500 uppercase transition-colors hover:text-brand-navy" class="group mb-4 inline-flex items-center text-xs font-bold tracking-widest text-ink-2 uppercase transition-colors hover:text-ink"
> >
<svg <svg
class="mr-2 h-4 w-4 transform transition-transform group-hover:-translate-x-1" class="mr-2 h-4 w-4 transform transition-transform group-hover:-translate-x-1"
@@ -67,7 +65,7 @@ function handleDateInput(e: Event) {
</svg> </svg>
{m.btn_back_to_overview()} {m.btn_back_to_overview()}
</a> </a>
<h1 class="font-serif text-3xl text-brand-navy">{m.doc_new_heading()}</h1> <h1 class="font-serif text-3xl text-ink">{m.doc_new_heading()}</h1>
</div> </div>
{#if form?.error} {#if form?.error}
@@ -75,179 +73,78 @@ function handleDateInput(e: Event) {
{/if} {/if}
<form method="POST" enctype="multipart/form-data" use:enhance class="space-y-6 pb-20"> <form method="POST" enctype="multipart/form-data" use:enhance class="space-y-6 pb-20">
<!-- ── Section 1: Wer & Wann ── --> <!-- File upload — prominent, at the top -->
<div class="rounded-sm border border-brand-sand bg-white p-6 shadow-sm"> <FileSectionNew onfileParsed={(r) => (parsedSuggestion = r)} />
<h2 class="mb-5 text-xs font-bold tracking-widest text-gray-400 uppercase">
{m.doc_section_who_when()}
</h2>
<div class="grid grid-cols-1 gap-5 md:grid-cols-2"> <!-- Standalone title card -->
<!-- Datum --> <div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
<div> <label for="new-title" class="mb-1 block text-sm font-medium text-ink-2"
<label for="documentDate" class="mb-1 block text-sm font-medium text-gray-700" >{m.form_label_title()}</label
>{m.form_label_date()}</label >
>
<input
id="documentDate"
type="text"
inputmode="numeric"
value={dateDisplay}
oninput={handleDateInput}
placeholder={m.form_placeholder_date()}
maxlength="10"
class="block w-full rounded border border-gray-300 p-2 text-sm shadow-sm
{dateInvalid ? 'border-red-400 focus:border-red-500 focus:ring-red-500' : 'focus:border-brand-navy focus:ring-brand-navy'}"
aria-describedby={dateInvalid ? 'date-error' : undefined}
/>
<input type="hidden" name="documentDate" value={dateIso} />
{#if dateInvalid}
<p id="date-error" class="mt-1 text-xs text-red-600">
{m.form_date_error()}
</p>
{/if}
</div>
<!-- Ort -->
<div>
<label for="location" class="mb-1 block text-sm font-medium text-gray-700"
>{m.form_label_location()}</label
>
<input
id="location"
type="text"
name="location"
placeholder={m.form_placeholder_location()}
class="block w-full rounded border border-gray-300 p-2 text-sm shadow-sm focus:border-brand-navy focus:ring-brand-navy"
/>
</div>
<!-- Absender -->
<div>
<PersonTypeahead
name="senderId"
label={m.form_label_sender()}
bind:value={senderId}
initialName={data.initialSenderName}
/>
</div>
<!-- Empfänger -->
<div>
<p class="mb-1 block text-sm font-medium text-gray-700">{m.form_label_receivers()}</p>
<PersonMultiSelect bind:selectedPersons={selectedReceivers} />
</div>
</div>
</div>
<!-- ── Section 2: Beschreibung ── -->
<div class="rounded-sm border border-brand-sand bg-white p-6 shadow-sm">
<h2 class="mb-5 text-xs font-bold tracking-widest text-gray-400 uppercase">
{m.doc_section_description()}
</h2>
<div class="space-y-5">
<!-- Titel -->
<div>
<label for="title" class="mb-1 block text-sm font-medium text-gray-700"
>{m.form_label_title()} *</label
>
<input
id="title"
type="text"
name="title"
required
class="block w-full rounded border border-gray-300 p-2 text-sm shadow-sm focus:border-brand-navy focus:ring-brand-navy"
/>
</div>
<!-- Aufbewahrungsort -->
<div>
<label for="documentLocation" class="mb-1 block text-sm font-medium text-gray-700"
>{m.form_label_archive_location()}</label
>
<input
id="documentLocation"
type="text"
name="documentLocation"
placeholder={m.form_placeholder_archive_location()}
class="block w-full rounded border border-gray-300 p-2 text-sm shadow-sm focus:border-brand-navy focus:ring-brand-navy"
/>
<p class="mt-1 text-xs text-gray-400">{m.form_helper_archive_location()}</p>
</div>
<!-- Schlagworte -->
<div>
<p class="mb-1 block text-sm font-medium text-gray-700">{m.form_label_tags()}</p>
<TagInput bind:tags={tags} />
<input type="hidden" name="tags" value={tags.join(',')} />
</div>
<!-- Inhalt -->
<div>
<label for="summary" class="mb-1 block text-sm font-medium text-gray-700"
>{m.form_label_content()}</label
>
<textarea
id="summary"
name="summary"
rows="5"
placeholder={m.form_placeholder_content()}
class="block w-full rounded border border-gray-300 p-2 font-serif text-sm shadow-sm focus:border-brand-navy focus:ring-brand-navy"
></textarea>
</div>
</div>
</div>
<!-- ── Section 3: Transkription ── -->
<div class="rounded-sm border border-brand-sand bg-white p-6 shadow-sm">
<h2 class="mb-5 text-xs font-bold tracking-widest text-gray-400 uppercase">
{m.form_label_transcription()}
</h2>
<textarea
id="transcription"
name="transcription"
rows="12"
placeholder={m.form_placeholder_transcription()}
class="block w-full rounded border border-gray-300 p-2 font-serif text-sm shadow-sm focus:border-brand-navy focus:ring-brand-navy"
></textarea>
</div>
<!-- ── Section 4: Datei ── -->
<div class="rounded-sm border border-brand-sand bg-white p-6 shadow-sm">
<h2 class="mb-5 text-xs font-bold tracking-widest text-gray-400 uppercase">
{m.doc_section_file()}
</h2>
<label for="file-upload" class="mb-1 block text-sm font-medium text-gray-700">
{m.doc_file_upload_label()}
<span class="font-normal text-gray-400">({m.doc_file_upload_note()})</span>
</label>
<input <input
id="file-upload" id="new-title"
type="file" type="text"
name="file" name="title"
class="block w-full cursor-pointer text-sm value={titleValue}
text-gray-500 file:mr-4 file:rounded oninput={(e) => {
file:border-0 file:bg-brand-sand/40 titleOverride = (e.target as HTMLInputElement).value;
file:px-4 file:py-2 titleDirty = true;
file:text-sm file:font-semibold }}
file:text-brand-navy hover:file:bg-brand-sand/60" class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:border-ink focus:ring-ink"
placeholder="Titel eingeben…"
/> />
</div> </div>
<!-- ── Sticky Save Bar ── --> <!-- Collapsible further details -->
<div <details
class="sticky bottom-0 z-10 -mx-4 flex items-center justify-between border-t border-brand-sand bg-white px-6 py-4 shadow-[0_-2px_8px_rgba(0,0,0,0.06)]" bind:open={detailsOpen}
class="group rounded-sm border border-line bg-surface shadow-sm"
> >
<a href="/" class="text-sm font-medium text-gray-500 transition-colors hover:text-brand-navy"> <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
class="sticky bottom-0 z-10 -mx-4 flex items-center justify-between border-t border-line bg-surface px-6 py-4 shadow-[0_-2px_8px_rgba(0,0,0,0.06)]"
>
<a href="/" class="text-sm font-medium text-ink-2 transition-colors hover:text-ink">
{m.btn_cancel()} {m.btn_cancel()}
</a> </a>
<button <div class="flex items-center gap-3">
type="submit" <button
class="rounded bg-brand-navy px-6 py-2 text-sm font-bold tracking-widest text-white uppercase transition-colors hover:bg-brand-navy/80" type="submit"
> name="metadataComplete"
{m.btn_save()} value="false"
</button> 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> </div>
</form> </form>
</div> </div>

View File

@@ -0,0 +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 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="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" 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

@@ -4,7 +4,7 @@ import { m } from '$lib/paraglide/messages.js';
let { form }: { form?: { error?: string; success?: boolean } } = $props(); let { form }: { form?: { error?: string; success?: boolean } } = $props();
</script> </script>
<div class="relative flex min-h-screen flex-col bg-white"> <div class="relative flex min-h-screen flex-col bg-surface">
<!-- Accent strip --> <!-- Accent strip -->
<div class="h-1 bg-brand-purple"></div> <div class="h-1 bg-brand-purple"></div>
@@ -13,15 +13,15 @@ let { form }: { form?: { error?: string; success?: boolean } } = $props();
<!-- Logo --> <!-- Logo -->
<div class="mb-10 text-center"> <div class="mb-10 text-center">
<a href="/" class="inline-flex items-center" aria-label="Familienarchiv"> <a href="/" class="inline-flex items-center" aria-label="Familienarchiv">
<span class="font-sans text-2xl font-bold tracking-widest text-brand-navy uppercase" <span class="font-sans text-2xl font-bold tracking-widest text-ink uppercase"
>Familienarchiv</span >Familienarchiv</span
> >
</a> </a>
</div> </div>
<!-- Card --> <!-- Card -->
<div class="rounded-sm border border-brand-sand bg-white p-8 shadow-sm"> <div class="rounded-sm border border-line bg-surface p-8 shadow-sm">
<h1 class="mb-6 font-sans text-sm font-bold tracking-widest text-brand-navy uppercase"> <h1 class="mb-6 font-sans text-sm font-bold tracking-widest text-ink uppercase">
{m.forgot_password_heading()} {m.forgot_password_heading()}
</h1> </h1>
@@ -30,9 +30,7 @@ let { form }: { form?: { error?: string; success?: boolean } } = $props();
<p class="font-sans text-xs text-green-700">{m.forgot_password_success()}</p> <p class="font-sans text-xs text-green-700">{m.forgot_password_success()}</p>
</div> </div>
<a <a href="/login" class="font-sans text-xs text-ink-3 transition-colors hover:text-ink"
href="/login"
class="font-sans text-xs text-gray-400 transition-colors hover:text-brand-navy"
>{m.forgot_password_back_to_login()}</a >{m.forgot_password_back_to_login()}</a
> >
{:else} {:else}
@@ -40,7 +38,7 @@ let { form }: { form?: { error?: string; success?: boolean } } = $props();
<div> <div>
<label <label
for="email" for="email"
class="mb-1.5 block font-sans text-xs font-bold tracking-widest text-gray-500 uppercase" class="mb-1.5 block font-sans text-xs font-bold tracking-widest text-ink-2 uppercase"
>{m.forgot_password_email_label()}</label >{m.forgot_password_email_label()}</label
> >
<input <input
@@ -49,7 +47,7 @@ let { form }: { form?: { error?: string; success?: boolean } } = $props();
id="email" id="email"
required required
autocomplete="email" autocomplete="email"
class="block w-full border border-gray-300 px-3 py-2.5 font-serif text-sm text-brand-navy placeholder-gray-400 focus:border-brand-navy focus:ring-1 focus:ring-brand-navy focus:outline-none" class="block w-full border border-line px-3 py-2.5 font-serif text-sm text-ink placeholder-ink-3 focus:border-ink focus:ring-1 focus:ring-ink focus:outline-none"
/> />
</div> </div>
@@ -59,15 +57,13 @@ let { form }: { form?: { error?: string; success?: boolean } } = $props();
<button <button
type="submit" type="submit"
class="mt-2 w-full bg-brand-navy py-2.5 font-sans text-xs font-bold tracking-widest text-white uppercase transition-colors hover:bg-brand-navy/90" class="mt-2 w-full bg-primary py-2.5 font-sans text-xs font-bold tracking-widest text-white uppercase transition-colors hover:bg-primary/90"
> >
{m.forgot_password_submit()} {m.forgot_password_submit()}
</button> </button>
<div class="mt-4 text-center"> <div class="mt-4 text-center">
<a <a href="/login" class="font-sans text-xs text-ink-3 transition-colors hover:text-ink"
href="/login"
class="font-sans text-xs text-gray-400 transition-colors hover:text-brand-navy"
>{m.forgot_password_back_to_login()}</a >{m.forgot_password_back_to_login()}</a
> >
</div> </div>
@@ -79,6 +75,6 @@ let { form }: { form?: { error?: string; success?: boolean } } = $props();
<!-- Footer --> <!-- Footer -->
<div class="py-4 text-center"> <div class="py-4 text-center">
<p class="font-sans text-xs tracking-widest text-gray-300 uppercase">Familienarchiv</p> <p class="font-sans text-xs tracking-widest text-ink-3 uppercase">Familienarchiv</p>
</div> </div>
</div> </div>

View File

@@ -1,34 +1,176 @@
/* 1. Import Tailwind (replaces @tailwind base/components/utilities) */ /* ─── 1. Fonts & Tailwind ──────────────────────────────────────────────────── */
/* Fonts: Montserrat = Gotham substitute | Tinos = Times substitute (De Gruyter Brill CI) */ /* Tinos = Times substitute | Montserrat = Gotham substitute (De Gruyter Brill CI) */
@import url('https://fonts.googleapis.com/css2?family=Tinos:ital,wght@0,400;0,700;1,400;1,700&family=Montserrat:wght@400;500;600;700&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Tinos:ital,wght@0,400;0,700;1,400;1,700&family=Montserrat:wght@400;500;600;700&display=swap');
@import 'tailwindcss'; @import 'tailwindcss';
/* 2. Define Custom Theme Variables — De Gruyter Brill CI */ /* ─── 2. Raw palette — never used directly in components ──────────────────── */
@theme { @theme {
/* COLORS — exact De Gruyter Brill brand palette */ /* Brand palette constants */
--color-brand-navy: #012851; /* Prussian Blue */ --palette-navy: #012851;
--color-brand-mint: #a1dcd8; /* Aqua Island */ --palette-mint: #a1dcd8;
--color-brand-purple: #b4b9ff; /* Melrose */ --palette-turquoise: #00c7b1;
--color-brand-sand: #f0efe9; /* Neutral paper tone */ --palette-sand: #f0efe9;
--color-brand-white: #ffffff; --palette-purple: #b4b9ff;
--color-brand-dark: #0d0d0d;
/* FONTS */ /* Typography */
--font-sans: 'Montserrat', ui-sans-serif, system-ui, sans-serif; --font-sans: 'Montserrat', ui-sans-serif, system-ui, sans-serif;
--font-serif: 'Tinos', 'Times New Roman', Georgia, serif; --font-serif: 'Tinos', 'Times New Roman', Georgia, serif;
--text-huge: 4rem; --text-huge: 4rem;
} }
/* 3. Base Styles */ /* ─── 3. Semantic tokens — Tailwind utilities backed by CSS variables ──────── */
/*
@theme inline makes Tailwind generate utility classes (bg-surface, text-ink,
border-line, etc.) whose values are CSS custom properties, not hardcoded hex.
Changing --c-surface on :root is all it takes to retheme the whole UI.
*/
@theme inline {
/* Surfaces */
--color-canvas: var(--c-canvas);
--color-surface: var(--c-surface);
--color-overlay: var(--c-overlay);
--color-muted: var(--c-muted);
/* Borders */
--color-line: var(--c-line);
--color-line-2: var(--c-line-2);
/* Text */
--color-ink: var(--c-ink);
--color-ink-2: var(--c-ink-2);
--color-ink-3: var(--c-ink-3);
/* Accent (mint ↔ turquoise) */
--color-accent: var(--c-accent);
--color-accent-bg: var(--c-accent-bg);
/* Primary interactive (navy ↔ mint) */
--color-primary: var(--c-primary);
--color-primary-fg: var(--c-primary-fg);
/* Nav active state */
--color-nav-active: var(--c-nav-active);
/* PDF viewer */
--color-pdf-bg: var(--c-pdf-bg);
--color-pdf-ctrl: var(--c-pdf-ctrl);
--color-pdf-text: var(--c-pdf-text);
/* Static brand tokens (not themed) */
--color-brand-purple: var(--palette-purple);
--color-brand-navy: var(--palette-navy);
--color-brand-mint: var(--palette-mint);
}
/* ─── 4. Light mode (default) ─────────────────────────────────────────────── */
:root {
--c-canvas: #f0efe9;
--c-surface: #ffffff;
--c-overlay: #ffffff;
--c-muted: #f5f4ef;
--c-line: #e4e2d7;
--c-line-2: #eeede8;
--c-ink: #012851;
--c-ink-2: #6b7280;
--c-ink-3: #9ca3af;
--c-accent: #a1dcd8;
--c-accent-bg: rgba(161, 220, 216, 0.15);
--c-primary: #012851;
--c-primary-fg: #ffffff;
--c-nav-active: rgba(180, 185, 255, 0.15);
--c-pdf-bg: #ebebeb;
--c-pdf-ctrl: #d8d8d8;
--c-pdf-text: #333333;
}
/* ─── 5. Dark mode ─────────────────────────────────────────────────────────── */
@media (prefers-color-scheme: dark) {
:root:not([data-theme='light']) {
--c-canvas: #0d0d0d;
--c-surface: #1a1a1a;
--c-overlay: #242424;
--c-muted: #252525;
--c-line: #2e2e2e;
--c-line-2: #222222;
--c-ink: #f0efe9;
--c-ink-2: #9ca3af;
--c-ink-3: #6b7280;
--c-accent: #00c7b1;
--c-accent-bg: rgba(0, 199, 177, 0.12);
--c-primary: #a1dcd8;
--c-primary-fg: #012851;
--c-nav-active: rgba(180, 185, 255, 0.12);
--c-pdf-bg: #1e1e1e;
--c-pdf-ctrl: #2a2a2a;
--c-pdf-text: #d1d1d1;
}
}
/* Manual dark override — takes precedence over media query */
:root[data-theme='dark'] {
--c-canvas: #0d0d0d;
--c-surface: #1a1a1a;
--c-overlay: #242424;
--c-muted: #252525;
--c-line: #2e2e2e;
--c-line-2: #222222;
--c-ink: #f0efe9;
--c-ink-2: #9ca3af;
--c-ink-3: #6b7280;
--c-accent: #00c7b1;
--c-accent-bg: rgba(0, 199, 177, 0.12);
--c-primary: #a1dcd8;
--c-primary-fg: #012851;
--c-nav-active: rgba(180, 185, 255, 0.12);
--c-pdf-bg: #1e1e1e;
--c-pdf-ctrl: #2a2a2a;
--c-pdf-text: #d1d1d1;
}
/* ─── 6. Icon inversion — De Gruyter icons are black SVGs loaded as <img> ──── */
/*
In dark mode, invert all brand icons so they read as white on dark surfaces.
Exclude .invert icons (already inverted for placement on dark backgrounds)
so they don't get double-inverted back to black.
*/
@media (prefers-color-scheme: dark) {
:root:not([data-theme='light']) img[src*='degruyter-icons']:not(.invert) {
filter: invert(1);
}
}
:root[data-theme='dark'] img[src*='degruyter-icons']:not(.invert) {
filter: invert(1);
}
/* ─── 7. Base styles ───────────────────────────────────────────────────────── */
@layer base { @layer base {
html { html {
overscroll-behavior: none; overscroll-behavior: none;
} }
body { body {
background-color: #ffffff; background-color: var(--c-canvas);
color: var(--color-brand-navy); color: var(--c-ink);
font-family: var(--font-serif); font-family: var(--font-serif);
} }
@@ -41,4 +183,12 @@
font-family: var(--font-sans); font-family: var(--font-sans);
font-weight: 600; font-weight: 600;
} }
/* Form controls — always use surface bg and ink text so they theme correctly */
input,
textarea,
select {
background-color: var(--c-surface);
color: var(--c-ink);
}
} }

View File

@@ -9,7 +9,7 @@ const localeMap = { DE: 'de', EN: 'en', ES: 'es' } as const;
const activeLocale = $derived(getLocale().toUpperCase()); const activeLocale = $derived(getLocale().toUpperCase());
</script> </script>
<div class="relative flex min-h-screen flex-col bg-white"> <div class="relative flex min-h-screen flex-col bg-canvas">
<!-- DGB purple accent strip --> <!-- DGB purple accent strip -->
<div class="h-1 bg-brand-purple"></div> <div class="h-1 bg-brand-purple"></div>
@@ -21,8 +21,8 @@ const activeLocale = $derived(getLocale().toUpperCase());
onclick={() => setLocale(localeMap[locale])} onclick={() => setLocale(localeMap[locale])}
class="px-1.5 py-1 font-sans text-xs tracking-widest transition-colors class="px-1.5 py-1 font-sans text-xs tracking-widest transition-colors
{activeLocale === locale {activeLocale === locale
? 'font-bold text-brand-navy' ? 'font-bold text-ink'
: 'font-normal text-gray-400 hover:text-brand-navy'}" : 'font-normal text-ink-3 hover:text-ink'}"
> >
{locale} {locale}
</button> </button>
@@ -34,15 +34,15 @@ const activeLocale = $derived(getLocale().toUpperCase());
<!-- Logo --> <!-- Logo -->
<div class="mb-10 text-center"> <div class="mb-10 text-center">
<a href="/" class="inline-flex items-center" aria-label="Familienarchiv"> <a href="/" class="inline-flex items-center" aria-label="Familienarchiv">
<span class="font-sans text-2xl font-bold tracking-widest text-brand-navy uppercase" <span class="font-sans text-2xl font-bold tracking-widest text-ink uppercase"
>Familienarchiv</span >Familienarchiv</span
> >
</a> </a>
</div> </div>
<!-- Card --> <!-- Card -->
<div class="rounded-sm border border-brand-sand bg-white p-8 shadow-sm"> <div class="rounded-sm border border-line bg-surface p-8 shadow-sm">
<h1 class="mb-6 font-sans text-sm font-bold tracking-widest text-brand-navy uppercase"> <h1 class="mb-6 font-sans text-sm font-bold tracking-widest text-ink uppercase">
{m.login_heading()} {m.login_heading()}
</h1> </h1>
@@ -50,7 +50,7 @@ const activeLocale = $derived(getLocale().toUpperCase());
<div> <div>
<label <label
for="username" for="username"
class="mb-1.5 block font-sans text-xs font-bold tracking-widest text-gray-500 uppercase" class="mb-1.5 block font-sans text-xs font-bold tracking-widest text-ink-2 uppercase"
>{m.login_label_username()}</label >{m.login_label_username()}</label
> >
<input <input
@@ -59,14 +59,14 @@ const activeLocale = $derived(getLocale().toUpperCase());
id="username" id="username"
required required
autocomplete="username" autocomplete="username"
class="block w-full border border-gray-300 px-3 py-2.5 font-serif text-sm text-brand-navy placeholder-gray-400 focus:border-brand-navy focus:ring-1 focus:ring-brand-navy focus:outline-none" class="block w-full border border-line px-3 py-2.5 font-serif text-sm text-ink placeholder-ink-3 focus:border-ink focus:ring-1 focus:ring-ink focus:outline-none"
/> />
</div> </div>
<div> <div>
<label <label
for="password" for="password"
class="mb-1.5 block font-sans text-xs font-bold tracking-widest text-gray-500 uppercase" class="mb-1.5 block font-sans text-xs font-bold tracking-widest text-ink-2 uppercase"
>{m.login_label_password()}</label >{m.login_label_password()}</label
> >
<input <input
@@ -75,7 +75,7 @@ const activeLocale = $derived(getLocale().toUpperCase());
id="password" id="password"
required required
autocomplete="current-password" autocomplete="current-password"
class="block w-full border border-gray-300 px-3 py-2.5 font-serif text-sm text-brand-navy placeholder-gray-400 focus:border-brand-navy focus:ring-1 focus:ring-brand-navy focus:outline-none" class="block w-full border border-line px-3 py-2.5 font-serif text-sm text-ink placeholder-ink-3 focus:border-ink focus:ring-1 focus:ring-ink focus:outline-none"
/> />
</div> </div>
@@ -85,7 +85,7 @@ const activeLocale = $derived(getLocale().toUpperCase());
<button <button
type="submit" type="submit"
class="mt-2 w-full bg-brand-navy py-2.5 font-sans text-xs font-bold tracking-widest text-white uppercase transition-colors hover:bg-brand-navy/90" class="mt-2 w-full bg-primary py-2.5 font-sans text-xs font-bold tracking-widest text-white uppercase transition-colors hover:bg-primary/90"
> >
{m.login_btn_submit()} {m.login_btn_submit()}
</button> </button>
@@ -93,7 +93,7 @@ const activeLocale = $derived(getLocale().toUpperCase());
<div class="mt-4 text-center"> <div class="mt-4 text-center">
<a <a
href="/forgot-password" href="/forgot-password"
class="font-sans text-xs text-gray-400 transition-colors hover:text-brand-navy" class="font-sans text-xs text-ink-3 transition-colors hover:text-ink"
>{m.login_forgot_password()}</a >{m.login_forgot_password()}</a
> >
</div> </div>
@@ -104,6 +104,6 @@ const activeLocale = $derived(getLocale().toUpperCase());
<!-- Footer --> <!-- Footer -->
<div class="py-4 text-center"> <div class="py-4 text-center">
<p class="font-sans text-xs tracking-widest text-gray-300 uppercase">Familienarchiv</p> <p class="font-sans text-xs tracking-widest text-ink-3 uppercase">Familienarchiv</p>
</div> </div>
</div> </div>

View File

@@ -5,7 +5,7 @@ import Page from './+page.svelte';
const tick = () => new Promise((r) => setTimeout(r, 0)); const tick = () => new Promise((r) => setTimeout(r, 0));
vi.mock('$app/navigation', () => ({ goto: vi.fn() })); vi.mock('$app/navigation', () => ({ goto: vi.fn(), invalidateAll: vi.fn() }));
// Silence fetch calls from PersonTypeahead when advanced filters are open // Silence fetch calls from PersonTypeahead when advanced filters are open
vi.stubGlobal( vi.stubGlobal(
@@ -23,6 +23,7 @@ const emptyData = {
canAnnotate: false, canAnnotate: false,
filters: { q: '', from: '', to: '', senderId: '', receiverId: '', tags: [] }, filters: { q: '', from: '', to: '', senderId: '', receiverId: '', tags: [] },
documents: [], documents: [],
incompleteCount: 0,
initialValues: { senderName: '', receiverName: '' }, initialValues: { senderName: '', receiverName: '' },
error: null error: null
}; };

View File

@@ -26,17 +26,17 @@ function handleSearch() {
<div class="mx-auto max-w-7xl py-12 sm:px-6 lg:px-8"> <div class="mx-auto max-w-7xl py-12 sm:px-6 lg:px-8">
<!-- Header Area --> <!-- Header Area -->
<div <div
class="mb-10 flex flex-col justify-between gap-6 border-b border-brand-navy/10 pb-6 md:flex-row md:items-end" class="mb-10 flex flex-col justify-between gap-6 border-b border-ink/10 pb-6 md:flex-row md:items-end"
> >
<div> <div>
<h1 class="font-serif text-3xl font-medium text-brand-navy">{m.persons_heading()}</h1> <h1 class="font-serif text-3xl font-medium text-ink">{m.persons_heading()}</h1>
<p class="mt-2 max-w-xl font-sans text-sm text-brand-navy/60"> <p class="mt-2 max-w-xl font-sans text-sm text-ink/60">
{m.persons_subtitle()} {m.persons_subtitle()}
</p> </p>
{#if data.canWrite} {#if data.canWrite}
<a <a
href="/persons/new" href="/persons/new"
class="mt-3 inline-flex items-center gap-1 text-sm font-medium text-brand-navy/60 transition-colors hover:text-brand-navy" class="mt-3 inline-flex items-center gap-1 text-sm font-medium text-ink/60 transition-colors hover:text-ink"
> >
<img <img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Add/Add-General-MD.svg" src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Add/Add-General-MD.svg"
@@ -61,10 +61,10 @@ function handleSearch() {
oninput={handleSearch} oninput={handleSearch}
onfocus={() => (qFocused = true)} onfocus={() => (qFocused = true)}
onblur={() => (qFocused = false)} onblur={() => (qFocused = false)}
class="block w-full rounded-sm border border-gray-300 bg-white py-2.5 pr-10 pl-4 font-sans text-sm text-brand-navy placeholder-gray-400 shadow-sm focus:border-brand-navy focus:ring-1 focus:ring-brand-navy focus:outline-none" class="block w-full rounded-sm border border-line bg-surface py-2.5 pr-10 pl-4 font-sans text-sm text-ink placeholder-ink-3 shadow-sm focus:border-ink focus:ring-1 focus:ring-ink focus:outline-none"
/> />
<div <div
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400" class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3 text-ink-3"
> >
<img <img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Mag-Glass-MD.svg" src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Mag-Glass-MD.svg"
@@ -79,11 +79,9 @@ function handleSearch() {
{#if data.persons.length === 0} {#if data.persons.length === 0}
<div <div
class="flex flex-col items-center justify-center rounded-lg border border-dashed border-brand-sand bg-white py-16 text-center" class="flex flex-col items-center justify-center rounded-lg border border-dashed border-line bg-surface py-16 text-center"
> >
<div <div class="mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-muted text-ink">
class="mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-brand-sand/30 text-brand-navy"
>
<img <img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Account-MD.svg" src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Account-MD.svg"
alt="" alt=""
@@ -91,25 +89,25 @@ function handleSearch() {
class="h-6 w-6" class="h-6 w-6"
/> />
</div> </div>
<p class="font-serif text-lg text-brand-navy">{m.persons_empty_heading()}</p> <p class="font-serif text-lg text-ink">{m.persons_empty_heading()}</p>
<p class="mt-1 font-sans text-sm text-gray-500">{m.persons_empty_text()}</p> <p class="mt-1 font-sans text-sm text-ink-2">{m.persons_empty_text()}</p>
</div> </div>
{:else} {:else}
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4"> <div class="grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
{#each data.persons as person (person.id)} {#each data.persons as person (person.id)}
<a href="/persons/{person.id}" class="group block h-full"> <a href="/persons/{person.id}" class="group block h-full">
<div <div
class="relative flex h-full items-center gap-4 overflow-hidden rounded border border-brand-sand bg-white p-6 shadow-sm transition-all duration-200 hover:border-brand-navy hover:shadow-md" class="relative flex h-full items-center gap-4 overflow-hidden rounded border border-line bg-surface p-6 shadow-sm transition-all duration-200 hover:border-primary hover:shadow-md"
> >
<!-- Decorative Accent on Hover --> <!-- Decorative Accent on Hover -->
<div <div
class="absolute top-0 bottom-0 left-0 w-1 bg-brand-navy opacity-0 transition-opacity group-hover:opacity-100" class="absolute top-0 bottom-0 left-0 w-1 bg-primary opacity-0 transition-opacity group-hover:opacity-100"
></div> ></div>
<!-- Avatar --> <!-- Avatar -->
<div class="flex-shrink-0"> <div class="flex-shrink-0">
<div <div
class="flex h-12 w-12 items-center justify-center rounded-full bg-brand-navy font-serif text-lg text-white transition-colors group-hover:bg-brand-mint group-hover:text-brand-navy" class="flex h-12 w-12 items-center justify-center rounded-full bg-primary font-serif text-lg text-white transition-colors group-hover:bg-accent group-hover:text-ink"
> >
{person.firstName[0]}{person.lastName[0]} {person.firstName[0]}{person.lastName[0]}
</div> </div>
@@ -118,13 +116,13 @@ function handleSearch() {
<!-- Info --> <!-- Info -->
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<p <p
class="truncate font-serif text-base font-medium text-brand-navy decoration-brand-mint decoration-2 underline-offset-2 group-hover:underline" class="truncate font-serif text-base font-medium text-ink decoration-brand-mint decoration-2 underline-offset-2 group-hover:underline"
> >
{person.firstName} {person.firstName}
{person.lastName} {person.lastName}
</p> </p>
{#if person.alias} {#if person.alias}
<p class="mt-0.5 truncate font-sans text-xs text-gray-500">"{person.alias}"</p> <p class="mt-0.5 truncate font-sans text-xs text-ink-2">"{person.alias}"</p>
{/if} {/if}
</div> </div>
</div> </div>

View File

@@ -1,10 +1,10 @@
<script lang="ts"> <script lang="ts">
import { enhance } from '$app/forms';
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import { sortDocumentsByDate, type SortDir } from '$lib/utils/sort';
import { formatDate } from '$lib/utils/date';
import { SvelteMap } from 'svelte/reactivity'; import { SvelteMap } from 'svelte/reactivity';
import PersonCard from './PersonCard.svelte';
import PersonMergePanel from './PersonMergePanel.svelte';
import CoCorrespondentsList from './CoCorrespondentsList.svelte';
import PersonDocumentList from './PersonDocumentList.svelte';
let { data, form } = $props(); let { data, form } = $props();
@@ -12,36 +12,6 @@ const person = $derived(data.person);
const sentDocuments = $derived(data.sentDocuments); const sentDocuments = $derived(data.sentDocuments);
const receivedDocuments = $derived(data.receivedDocuments); const receivedDocuments = $derived(data.receivedDocuments);
const DOCS_PREVIEW_LIMIT = 5;
let sortDirSent = $state<SortDir>('DESC');
let sortDirReceived = $state<SortDir>('DESC');
let showAllSent = $state(false);
let showAllReceived = $state(false);
const sortedSentDocuments = $derived(sortDocumentsByDate(sentDocuments, sortDirSent));
const sortedReceivedDocuments = $derived(sortDocumentsByDate(receivedDocuments, sortDirReceived));
const visibleSentDocuments = $derived(
showAllSent ? sortedSentDocuments : sortedSentDocuments.slice(0, DOCS_PREVIEW_LIMIT)
);
const visibleReceivedDocuments = $derived(
showAllReceived ? sortedReceivedDocuments : sortedReceivedDocuments.slice(0, DOCS_PREVIEW_LIMIT)
);
function yearRange(docs: typeof sentDocuments) {
const years = docs
.filter((d) => d.documentDate)
.map((d) => parseInt(d.documentDate!.substring(0, 4)));
if (!years.length) return null;
const min = Math.min(...years);
const max = Math.max(...years);
return min === max ? `${min}` : `${min} ${max}`;
}
const sentYearRange = $derived(yearRange(sentDocuments));
const receivedYearRange = $derived(yearRange(receivedDocuments));
const coCorrespondents = $derived.by(() => { const coCorrespondents = $derived.by(() => {
const freq = new SvelteMap<string, { id: string; name: string; count: number }>(); const freq = new SvelteMap<string, { id: string; name: string; count: number }>();
@@ -75,22 +45,6 @@ const coCorrespondents = $derived.by(() => {
return [...freq.values()].sort((a, b) => b.count - a.count).slice(0, 5); return [...freq.values()].sort((a, b) => b.count - a.count).slice(0, 5);
}); });
let editMode = $state(false);
let mergeTargetId = $state('');
let showMergeConfirm = $state(false);
$effect(() => {
if (form?.updated) editMode = false;
});
$effect(() => {
// Reset merge state whenever person changes
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
person.id; // reactive dependency
mergeTargetId = '';
showMergeConfirm = false;
});
</script> </script>
<div class="mx-auto max-w-4xl px-4 py-10"> <div class="mx-auto max-w-4xl px-4 py-10">
@@ -98,7 +52,7 @@ $effect(() => {
<div class="mb-6"> <div class="mb-6">
<a <a
href="/persons" href="/persons"
class="group inline-flex items-center text-xs font-bold tracking-widest text-gray-500 uppercase transition-colors hover:text-brand-navy" class="group inline-flex items-center text-xs font-bold tracking-widest text-ink-2 uppercase transition-colors hover:text-ink"
> >
<img <img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Arrow/Arrow-Left-MD.svg" src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Arrow/Arrow-Left-MD.svg"
@@ -110,502 +64,25 @@ $effect(() => {
</a> </a>
</div> </div>
<!-- Header / Metadata Card --> <PersonCard person={person} canWrite={data.canWrite} form={form} />
<div class="mb-10 overflow-hidden rounded-sm border border-brand-sand bg-white shadow-sm">
<div class="h-2 w-full bg-brand-navy"></div>
<div class="p-8 md:p-10">
{#if editMode && data.canWrite}
<!-- Edit Form -->
<form method="POST" action="?/update" use:enhance>
<div class="flex flex-col gap-6">
<h2 class="border-b border-gray-100 pb-3 font-serif text-xl text-brand-navy">
{m.person_edit_heading()}
</h2>
{#if form?.updateError}
<p class="rounded border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{form.updateError}
</p>
{/if}
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label
for="firstName"
class="mb-1 block text-xs font-bold tracking-widest text-gray-400 uppercase"
>{m.form_label_first_name()} *</label
>
<input
id="firstName"
name="firstName"
type="text"
required
value={person.firstName}
class="block w-full rounded border border-gray-300 px-3 py-2 font-serif text-brand-navy focus:border-brand-navy focus:outline-none"
/>
</div>
<div>
<label
for="lastName"
class="mb-1 block text-xs font-bold tracking-widest text-gray-400 uppercase"
>{m.form_label_last_name()} *</label
>
<input
id="lastName"
name="lastName"
type="text"
required
value={person.lastName}
class="block w-full rounded border border-gray-300 px-3 py-2 font-serif text-brand-navy focus:border-brand-navy focus:outline-none"
/>
</div>
<div class="md:col-span-2">
<label
for="alias"
class="mb-1 block text-xs font-bold tracking-widest text-gray-400 uppercase"
>{m.form_label_alias()}</label
>
<input
id="alias"
name="alias"
type="text"
value={person.alias ?? ''}
class="block w-full rounded border border-gray-300 px-3 py-2 font-serif text-brand-navy focus:border-brand-navy focus:outline-none"
/>
</div>
<div>
<label
for="birthYear"
class="mb-1 block text-xs font-bold tracking-widest text-gray-400 uppercase"
>{m.person_label_birth_year()}</label
>
<input
id="birthYear"
name="birthYear"
type="number"
min="1"
max="2100"
placeholder={m.person_placeholder_year()}
value={person.birthYear ?? ''}
class="block w-full rounded border border-gray-300 px-3 py-2 font-serif text-brand-navy focus:border-brand-navy focus:outline-none"
/>
</div>
<div>
<label
for="deathYear"
class="mb-1 block text-xs font-bold tracking-widest text-gray-400 uppercase"
>{m.person_label_death_year()}</label
>
<input
id="deathYear"
name="deathYear"
type="number"
min="1"
max="2100"
placeholder={m.person_placeholder_year()}
value={person.deathYear ?? ''}
class="block w-full rounded border border-gray-300 px-3 py-2 font-serif text-brand-navy focus:border-brand-navy focus:outline-none"
/>
</div>
<div class="md:col-span-2">
<label
for="notes"
class="mb-1 block text-xs font-bold tracking-widest text-gray-400 uppercase"
>{m.person_label_notes()}</label
>
<textarea
id="notes"
name="notes"
rows="4"
placeholder={m.person_placeholder_notes()}
class="block w-full resize-y rounded border border-gray-300 px-3 py-2 font-serif text-brand-navy focus:border-brand-navy focus:outline-none"
>{person.notes ?? ''}</textarea
>
</div>
</div>
<div class="flex gap-3">
<button
type="submit"
class="rounded bg-brand-navy px-5 py-2 text-sm font-bold tracking-widest text-white uppercase transition-colors hover:bg-brand-navy/80"
>
{m.btn_save()}
</button>
<button
type="button"
onclick={() => (editMode = false)}
class="rounded border border-gray-300 px-5 py-2 text-sm font-bold tracking-widest text-gray-600 uppercase transition-colors hover:bg-gray-50"
>
{m.btn_cancel()}
</button>
</div>
</div>
</form>
{:else}
<!-- View Mode -->
<div class="flex flex-col items-start gap-8 md:flex-row">
<div class="flex-shrink-0">
<div
class="flex h-24 w-24 items-center justify-center rounded-full border border-brand-sand bg-brand-sand/30 text-brand-navy"
>
<span class="font-serif text-3xl">{person.firstName[0]}{person.lastName[0]}</span>
</div>
</div>
<div class="w-full flex-1">
<div class="mb-8 flex items-start justify-between border-b border-gray-100 pb-4">
<h1 class="font-serif text-4xl text-brand-navy">
{person.firstName}
{person.lastName}
</h1>
<div class="ml-4 flex flex-shrink-0 items-center gap-2">
{#if data.canWrite}
<button
onclick={() => (editMode = true)}
class="inline-flex items-center gap-1.5 rounded border border-gray-300 px-3 py-1.5 text-xs font-bold tracking-widest text-gray-500 uppercase transition-colors hover:border-brand-navy hover:text-brand-navy"
>
<img
src="/degruyter-icons/Simple/Small-16px/SVG/Action/Edit-Content-SM.svg"
alt=""
aria-hidden="true"
class="h-3.5 w-3.5"
/>
{m.btn_edit()}
</button>
{/if}
</div>
</div>
<div class="grid grid-cols-1 gap-8 md:grid-cols-2">
<div>
<span
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
>{m.person_label_full_name()}</span
>
<span class="block font-serif text-lg text-brand-navy"
>{person.firstName} {person.lastName}</span
>
</div>
{#if person.alias}
<div>
<span
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
>{m.form_label_alias()}</span
>
<span class="block font-serif text-lg text-brand-navy italic"
>"{person.alias}"</span
>
</div>
{/if}
{#if person.birthYear || person.deathYear}
<div>
<span
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
>
{#if person.birthYear && person.deathYear}{m.person_label_birth_year()} / {m.person_label_death_year()}{:else if person.birthYear}{m.person_label_birth_year()}{:else}{m.person_label_death_year()}{/if}
</span>
<span class="block font-serif text-lg text-brand-navy">
{#if person.birthYear}* {person.birthYear}{/if}{#if person.birthYear && person.deathYear}
&nbsp;{/if}{#if person.deathYear}{person.deathYear}{/if}
</span>
</div>
{/if}
{#if person.notes}
<div class="md:col-span-2">
<span
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
>{m.person_label_notes()}</span
>
<p class="font-serif text-base whitespace-pre-wrap text-brand-navy">
{person.notes}
</p>
</div>
{/if}
</div>
</div>
</div>
{/if}
</div>
</div>
<!-- Merge Section -->
{#if data.canWrite} {#if data.canWrite}
{#key person.id} {#key person.id}
<div class="mb-10 overflow-hidden rounded-sm border border-brand-sand bg-white shadow-sm"> <PersonMergePanel person={person} form={form} />
<div class="p-6 md:p-8">
<h2 class="mb-1 font-serif text-lg text-brand-navy">{m.person_merge_heading()}</h2>
<p class="mb-5 font-sans text-sm text-gray-500">
{m.person_merge_description()}
</p>
{#if form?.mergeError}
<p class="mb-4 rounded border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{form.mergeError}
</p>
{/if}
<form method="POST" action="?/merge" use:enhance>
<input type="hidden" name="targetPersonId" bind:value={mergeTargetId} />
<div class="flex flex-col items-end gap-3 sm:flex-row">
<div class="flex-1">
<PersonTypeahead
name="_targetPersonDisplay"
label={m.person_merge_target_label()}
value={mergeTargetId}
onchange={(value) => { mergeTargetId = value; showMergeConfirm = false; }}
/>
</div>
{#if !showMergeConfirm}
<button
type="button"
disabled={!mergeTargetId}
onclick={() => (showMergeConfirm = true)}
class="rounded border border-red-300 px-4 py-2 text-sm font-bold tracking-widest text-red-600 uppercase transition-colors hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-40"
>
{m.person_btn_merge()}
</button>
{:else}
<div class="flex gap-2">
<button
type="submit"
class="rounded bg-red-600 px-4 py-2 text-sm font-bold tracking-widest text-white uppercase transition-colors hover:bg-red-700"
>
{m.person_btn_merge_confirm()}
</button>
<button
type="button"
onclick={() => (showMergeConfirm = false)}
class="rounded border border-gray-300 px-4 py-2 text-sm font-bold tracking-widest text-gray-500 uppercase transition-colors hover:bg-gray-50"
>
{m.btn_cancel()}
</button>
</div>
{/if}
</div>
{#if showMergeConfirm}
<p
class="mt-3 rounded border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700"
>
{m.person_merge_warning()} <strong>{person.firstName} {person.lastName}</strong>
{m.person_merge_will_be_deleted()}
</p>
{/if}
</form>
</div>
</div>
{/key} {/key}
{/if} {/if}
<!-- Co-Correspondents Section --> <CoCorrespondentsList coCorrespondents={coCorrespondents} personId={person.id} />
{#if coCorrespondents.length > 0}
<div class="mb-6">
<h3 class="mb-3 text-xs font-bold tracking-widest text-gray-400 uppercase">
{m.person_co_correspondents_heading()}
</h3>
<div class="flex flex-wrap gap-2">
{#each coCorrespondents as c (c.id)}
<a
href="/conversations?senderId={person.id}&receiverId={c.id}"
class="inline-flex items-center gap-1.5 rounded-full border border-brand-sand px-3 py-1 font-serif text-sm text-brand-navy transition-colors hover:border-brand-navy"
>
{c.name}
<span class="font-sans text-xs text-gray-400">({c.count})</span>
</a>
{/each}
</div>
</div>
{/if}
<!-- Sent Documents Section --> <PersonDocumentList
<div class="mb-10"> documents={sentDocuments}
<div class="mb-6 flex items-center gap-3 border-b border-brand-navy/10 pb-2"> heading={m.person_docs_heading()}
<h2 class="font-serif text-xl text-brand-navy">{m.person_docs_heading()}</h2> emptyMessage={m.person_no_docs()}
<span class="rounded-full bg-brand-navy px-2 py-1 text-xs font-bold text-white"> />
{sentDocuments.length}
</span>
{#if sentYearRange}
<span class="font-sans text-xs text-gray-400">{sentYearRange}</span>
{/if}
{#if sentDocuments.length > 1}
<button
onclick={() => (sortDirSent = sortDirSent === 'DESC' ? 'ASC' : 'DESC')}
class="ml-auto text-xs font-bold tracking-widest text-gray-400 uppercase transition-colors hover:text-brand-navy"
>
{sortDirSent === 'DESC' ? m.conv_sort_newest() : m.conv_sort_oldest()}
</button>
{/if}
</div>
{#if sentDocuments.length === 0} <PersonDocumentList
<div class="rounded-sm border border-dashed border-brand-sand bg-white p-12 text-center"> documents={receivedDocuments}
<p class="font-sans text-gray-500">{m.person_no_docs()}</p> heading={m.person_received_docs_heading()}
</div> emptyMessage={m.person_no_received_docs()}
{:else} />
<ul class="space-y-3">
{#each visibleSentDocuments as doc (doc.id)}
<li class="group">
<a
href="/documents/{doc.id}"
class="block border border-brand-sand bg-white p-4 transition-all duration-200 hover:border-brand-navy hover:shadow-md"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-4 overflow-hidden">
<div
class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded bg-brand-sand/20 text-brand-navy transition-colors group-hover:bg-brand-mint group-hover:text-brand-navy"
>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/PDF-Document-MD.svg"
alt=""
aria-hidden="true"
class="h-5 w-5"
/>
</div>
<div class="min-w-0">
<div
class="truncate font-serif text-base font-medium text-brand-navy decoration-brand-mint decoration-2 underline-offset-2 group-hover:underline"
>
{doc.title || doc.originalFilename}
</div>
<div class="mt-0.5 flex items-center space-x-2 font-sans text-xs text-gray-500">
<span
>{doc.documentDate ? formatDate(doc.documentDate) : m.doc_no_date()}</span
>
{#if doc.location}
<span class="text-brand-mint"></span>
<span>{doc.location}</span>
{/if}
</div>
</div>
</div>
<div class="flex flex-shrink-0 items-center gap-2 pl-4">
<span
class="hidden items-center rounded-full border px-2 py-0.5 text-[10px] font-bold tracking-wide uppercase sm:inline-flex
{doc.status === 'UPLOADED'
? 'border-brand-mint/50 bg-brand-mint/20 text-brand-navy'
: 'border-yellow-200 bg-yellow-50 text-yellow-800'}"
>
{doc.status}
</span>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Arrow/Arrow-Right-MD.svg"
alt=""
aria-hidden="true"
class="ml-2 h-5 w-5 opacity-40 transition-opacity group-hover:opacity-100"
/>
</div>
</div>
</a>
</li>
{/each}
</ul>
{#if sentDocuments.length > DOCS_PREVIEW_LIMIT && !showAllSent}
<button
onclick={() => (showAllSent = true)}
class="mt-3 text-xs font-bold tracking-widest text-brand-navy/50 uppercase transition-colors hover:text-brand-navy"
>
{m.person_show_more({ count: sentDocuments.length - DOCS_PREVIEW_LIMIT })}
</button>
{/if}
{/if}
</div>
<!-- Received Documents Section -->
<div>
<div class="mb-6 flex items-center gap-3 border-b border-brand-navy/10 pb-2">
<h2 class="font-serif text-xl text-brand-navy">{m.person_received_docs_heading()}</h2>
<span class="rounded-full bg-brand-navy px-2 py-1 text-xs font-bold text-white">
{receivedDocuments.length}
</span>
{#if receivedYearRange}
<span class="font-sans text-xs text-gray-400">{receivedYearRange}</span>
{/if}
{#if receivedDocuments.length > 1}
<button
onclick={() => (sortDirReceived = sortDirReceived === 'DESC' ? 'ASC' : 'DESC')}
class="ml-auto text-xs font-bold tracking-widest text-gray-400 uppercase transition-colors hover:text-brand-navy"
>
{sortDirReceived === 'DESC' ? m.conv_sort_newest() : m.conv_sort_oldest()}
</button>
{/if}
</div>
{#if receivedDocuments.length === 0}
<div class="rounded-sm border border-dashed border-brand-sand bg-white p-12 text-center">
<p class="font-sans text-gray-500">{m.person_no_received_docs()}</p>
</div>
{:else}
<ul class="space-y-3">
{#each visibleReceivedDocuments as doc (doc.id)}
<li class="group">
<a
href="/documents/{doc.id}"
class="block border border-brand-sand bg-white p-4 transition-all duration-200 hover:border-brand-navy hover:shadow-md"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-4 overflow-hidden">
<div
class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded bg-brand-sand/20 text-brand-navy transition-colors group-hover:bg-brand-mint group-hover:text-brand-navy"
>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/PDF-Document-MD.svg"
alt=""
aria-hidden="true"
class="h-5 w-5"
/>
</div>
<div class="min-w-0">
<div
class="truncate font-serif text-base font-medium text-brand-navy decoration-brand-mint decoration-2 underline-offset-2 group-hover:underline"
>
{doc.title || doc.originalFilename}
</div>
<div class="mt-0.5 flex items-center space-x-2 font-sans text-xs text-gray-500">
<span
>{doc.documentDate ? formatDate(doc.documentDate) : m.doc_no_date()}</span
>
{#if doc.location}
<span class="text-brand-mint"></span>
<span>{doc.location}</span>
{/if}
</div>
</div>
</div>
<div class="flex flex-shrink-0 items-center gap-2 pl-4">
<span
class="hidden items-center rounded-full border px-2 py-0.5 text-[10px] font-bold tracking-wide uppercase sm:inline-flex
{doc.status === 'UPLOADED'
? 'border-brand-mint/50 bg-brand-mint/20 text-brand-navy'
: 'border-yellow-200 bg-yellow-50 text-yellow-800'}"
>
{doc.status}
</span>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Arrow/Arrow-Right-MD.svg"
alt=""
aria-hidden="true"
class="ml-2 h-5 w-5 opacity-40 transition-opacity group-hover:opacity-100"
/>
</div>
</div>
</a>
</li>
{/each}
</ul>
{#if receivedDocuments.length > DOCS_PREVIEW_LIMIT && !showAllReceived}
<button
onclick={() => (showAllReceived = true)}
class="mt-3 text-xs font-bold tracking-widest text-brand-navy/50 uppercase transition-colors hover:text-brand-navy"
>
{m.person_show_more({ count: receivedDocuments.length - DOCS_PREVIEW_LIMIT })}
</button>
{/if}
{/if}
</div>
</div> </div>

View File

@@ -0,0 +1,30 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
let {
coCorrespondents,
personId
}: {
coCorrespondents: { id: string; name: string; count: number }[];
personId: string;
} = $props();
</script>
{#if coCorrespondents.length > 0}
<div class="mb-6">
<h3 class="mb-3 text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.person_co_correspondents_heading()}
</h3>
<div class="flex flex-wrap gap-2">
{#each coCorrespondents as c (c.id)}
<a
href="/conversations?senderId={personId}&receiverId={c.id}"
class="inline-flex items-center gap-1.5 rounded-full border border-line px-3 py-1 font-serif text-sm text-ink transition-colors hover:border-primary"
>
{c.name}
<span class="font-sans text-xs text-ink-3">({c.count})</span>
</a>
{/each}
</div>
</div>
{/if}

View File

@@ -0,0 +1,246 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { m } from '$lib/paraglide/messages.js';
let {
person,
canWrite,
form
}: {
person: {
firstName: string;
lastName: string;
alias?: string | null;
birthYear?: number | null;
deathYear?: number | null;
notes?: string | null;
};
canWrite: boolean;
form?: { updated?: boolean; updateError?: string } | null;
} = $props();
let editMode = $state(false);
$effect(() => {
if (form?.updated) editMode = false;
});
</script>
<div class="mb-10 overflow-hidden rounded-sm border border-line bg-surface shadow-sm">
<div class="h-2 w-full bg-primary"></div>
<div class="p-8 md:p-10">
{#if editMode && canWrite}
<!-- Edit Form -->
<form method="POST" action="?/update" use:enhance>
<div class="flex flex-col gap-6">
<h2 class="border-b border-line-2 pb-3 font-serif text-xl text-ink">
{m.person_edit_heading()}
</h2>
{#if form?.updateError}
<p class="rounded border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{form.updateError}
</p>
{/if}
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label
for="firstName"
class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
>{m.form_label_first_name()} *</label
>
<input
id="firstName"
name="firstName"
type="text"
required
value={person.firstName}
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:border-ink focus:outline-none"
/>
</div>
<div>
<label
for="lastName"
class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
>{m.form_label_last_name()} *</label
>
<input
id="lastName"
name="lastName"
type="text"
required
value={person.lastName}
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:border-ink focus:outline-none"
/>
</div>
<div class="md:col-span-2">
<label
for="alias"
class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
>{m.form_label_alias()}</label
>
<input
id="alias"
name="alias"
type="text"
value={person.alias ?? ''}
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:border-ink focus:outline-none"
/>
</div>
<div>
<label
for="birthYear"
class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
>{m.person_label_birth_year()}</label
>
<input
id="birthYear"
name="birthYear"
type="number"
min="1"
max="2100"
placeholder={m.person_placeholder_year()}
value={person.birthYear ?? ''}
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:border-ink focus:outline-none"
/>
</div>
<div>
<label
for="deathYear"
class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
>{m.person_label_death_year()}</label
>
<input
id="deathYear"
name="deathYear"
type="number"
min="1"
max="2100"
placeholder={m.person_placeholder_year()}
value={person.deathYear ?? ''}
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:border-ink focus:outline-none"
/>
</div>
<div class="md:col-span-2">
<label
for="notes"
class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
>{m.person_label_notes()}</label
>
<textarea
id="notes"
name="notes"
rows="4"
placeholder={m.person_placeholder_notes()}
class="block w-full resize-y rounded border border-line px-3 py-2 font-serif text-ink focus:border-ink focus:outline-none"
>{person.notes ?? ''}</textarea
>
</div>
</div>
<div class="flex gap-3">
<button
type="submit"
class="rounded bg-primary px-5 py-2 text-sm font-bold tracking-widest text-white uppercase transition-colors hover:bg-primary/80"
>
{m.btn_save()}
</button>
<button
type="button"
onclick={() => (editMode = false)}
class="rounded border border-line px-5 py-2 text-sm font-bold tracking-widest text-ink-2 uppercase transition-colors hover:bg-muted"
>
{m.btn_cancel()}
</button>
</div>
</div>
</form>
{:else}
<!-- View Mode -->
<div class="flex flex-col items-start gap-8 md:flex-row">
<div class="flex-shrink-0">
<div
class="flex h-24 w-24 items-center justify-center rounded-full border border-line bg-muted text-ink"
>
<span class="font-serif text-3xl">{person.firstName[0]}{person.lastName[0]}</span>
</div>
</div>
<div class="w-full flex-1">
<div class="mb-8 flex items-start justify-between border-b border-line-2 pb-4">
<h1 class="font-serif text-4xl text-ink">
{person.firstName}
{person.lastName}
</h1>
<div class="ml-4 flex flex-shrink-0 items-center gap-2">
{#if canWrite}
<button
onclick={() => (editMode = true)}
class="inline-flex items-center gap-1.5 rounded border border-line px-3 py-1.5 text-xs font-bold tracking-widest text-ink-2 uppercase transition-colors hover:border-primary hover:text-ink"
>
<img
src="/degruyter-icons/Simple/Small-16px/SVG/Action/Edit-Content-SM.svg"
alt=""
aria-hidden="true"
class="h-3.5 w-3.5"
/>
{m.btn_edit()}
</button>
{/if}
</div>
</div>
<div class="grid grid-cols-1 gap-8 md:grid-cols-2">
<div>
<span
class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-3 uppercase"
>{m.person_label_full_name()}</span
>
<span class="block font-serif text-lg text-ink"
>{person.firstName} {person.lastName}</span
>
</div>
{#if person.alias}
<div>
<span
class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-3 uppercase"
>{m.form_label_alias()}</span
>
<span class="block font-serif text-lg text-ink italic">"{person.alias}"</span>
</div>
{/if}
{#if person.birthYear || person.deathYear}
<div>
<span
class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-3 uppercase"
>
{#if person.birthYear && person.deathYear}{m.person_label_birth_year()} / {m.person_label_death_year()}{:else if person.birthYear}{m.person_label_birth_year()}{:else}{m.person_label_death_year()}{/if}
</span>
<span class="block font-serif text-lg text-ink">
{#if person.birthYear}* {person.birthYear}{/if}{#if person.birthYear && person.deathYear}
&nbsp;{/if}{#if person.deathYear}{person.deathYear}{/if}
</span>
</div>
{/if}
{#if person.notes}
<div class="md:col-span-2">
<span
class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-3 uppercase"
>{m.person_label_notes()}</span
>
<p class="font-serif text-base whitespace-pre-wrap text-ink">
{person.notes}
</p>
</div>
{/if}
</div>
</div>
</div>
{/if}
</div>
</div>

View File

@@ -0,0 +1,132 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { formatDate } from '$lib/utils/date';
import { sortDocumentsByDate, type SortDir } from '$lib/utils/sort';
const DOCS_PREVIEW_LIMIT = 5;
let {
documents,
heading,
emptyMessage
}: {
documents: {
id: string;
title?: string | null;
originalFilename: string;
documentDate?: string | null;
location?: string | null;
status: string;
}[];
heading: string;
emptyMessage: string;
} = $props();
const yearRange = $derived.by(() => {
const years = documents
.filter((d) => d.documentDate)
.map((d) => parseInt(d.documentDate!.substring(0, 4)));
if (!years.length) return null;
const min = Math.min(...years);
const max = Math.max(...years);
return min === max ? `${min}` : `${min} ${max}`;
});
let sortDir = $state<SortDir>('DESC');
let showAll = $state(false);
const sortedDocuments = $derived(sortDocumentsByDate(documents, sortDir));
const visibleDocuments = $derived(
showAll ? sortedDocuments : sortedDocuments.slice(0, DOCS_PREVIEW_LIMIT)
);
</script>
<div class="mb-10">
<div class="mb-6 flex items-center gap-3 border-b border-ink/10 pb-2">
<h2 class="font-serif text-xl text-ink">{heading}</h2>
<span class="rounded-full bg-primary px-2 py-1 text-xs font-bold text-white">
{documents.length}
</span>
{#if yearRange}
<span class="font-sans text-xs text-ink-3">{yearRange}</span>
{/if}
{#if documents.length > 1}
<button
onclick={() => (sortDir = sortDir === 'DESC' ? 'ASC' : 'DESC')}
class="ml-auto text-xs font-bold tracking-widest text-ink-3 uppercase transition-colors hover:text-ink"
>
{sortDir === 'DESC' ? m.conv_sort_newest() : m.conv_sort_oldest()}
</button>
{/if}
</div>
{#if documents.length === 0}
<div class="rounded-sm border border-dashed border-line bg-surface p-12 text-center">
<p class="font-sans text-ink-2">{emptyMessage}</p>
</div>
{:else}
<ul class="space-y-3">
{#each visibleDocuments as doc (doc.id)}
<li class="group">
<a
href="/documents/{doc.id}"
class="block border border-line bg-surface p-4 transition-all duration-200 hover:border-primary hover:shadow-md"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-4 overflow-hidden">
<div
class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded bg-muted text-ink transition-colors group-hover:bg-accent group-hover:text-ink"
>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/PDF-Document-MD.svg"
alt=""
aria-hidden="true"
class="h-5 w-5"
/>
</div>
<div class="min-w-0">
<div
class="truncate font-serif text-base font-medium text-ink decoration-brand-mint decoration-2 underline-offset-2 group-hover:underline"
>
{doc.title || doc.originalFilename}
</div>
<div class="mt-0.5 flex items-center space-x-2 font-sans text-xs text-ink-2">
<span>{doc.documentDate ? formatDate(doc.documentDate) : m.doc_no_date()}</span>
{#if doc.location}
<span class="text-accent"></span>
<span>{doc.location}</span>
{/if}
</div>
</div>
</div>
<div class="flex flex-shrink-0 items-center gap-2 pl-4">
<span
class="hidden items-center rounded-full border px-2 py-0.5 text-[10px] font-bold tracking-wide uppercase sm:inline-flex
{doc.status === 'UPLOADED'
? 'border-accent/50 bg-accent/20 text-ink'
: 'border-yellow-200 bg-yellow-50 text-yellow-800'}"
>
{doc.status}
</span>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Arrow/Arrow-Right-MD.svg"
alt=""
aria-hidden="true"
class="ml-2 h-5 w-5 opacity-40 transition-opacity group-hover:opacity-100"
/>
</div>
</div>
</a>
</li>
{/each}
</ul>
{#if documents.length > DOCS_PREVIEW_LIMIT && !showAll}
<button
onclick={() => (showAll = true)}
class="mt-3 text-xs font-bold tracking-widest text-ink/50 uppercase transition-colors hover:text-ink"
>
{m.person_show_more({ count: documents.length - DOCS_PREVIEW_LIMIT })}
</button>
{/if}
{/if}
</div>

View File

@@ -0,0 +1,83 @@
<script lang="ts">
import { enhance } from '$app/forms';
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
import { m } from '$lib/paraglide/messages.js';
let {
person,
form
}: {
person: { firstName: string; lastName: string };
form?: { mergeError?: string } | null;
} = $props();
let mergeTargetId = $state('');
let showMergeConfirm = $state(false);
</script>
<div class="mb-10 overflow-hidden rounded-sm border border-line bg-surface shadow-sm">
<div class="p-6 md:p-8">
<h2 class="mb-1 font-serif text-lg text-ink">{m.person_merge_heading()}</h2>
<p class="mb-5 font-sans text-sm text-ink-2">
{m.person_merge_description()}
</p>
{#if form?.mergeError}
<p class="mb-4 rounded border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{form.mergeError}
</p>
{/if}
<form method="POST" action="?/merge" use:enhance>
<input type="hidden" name="targetPersonId" bind:value={mergeTargetId} />
<div class="flex flex-col items-end gap-3 sm:flex-row">
<div class="flex-1">
<PersonTypeahead
name="_targetPersonDisplay"
label={m.person_merge_target_label()}
value={mergeTargetId}
onchange={(value) => {
mergeTargetId = value;
showMergeConfirm = false;
}}
/>
</div>
{#if !showMergeConfirm}
<button
type="button"
disabled={!mergeTargetId}
onclick={() => (showMergeConfirm = true)}
class="rounded border border-red-300 px-4 py-2 text-sm font-bold tracking-widest text-red-600 uppercase transition-colors hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-40"
>
{m.person_btn_merge()}
</button>
{:else}
<div class="flex gap-2">
<button
type="submit"
class="rounded bg-red-600 px-4 py-2 text-sm font-bold tracking-widest text-white uppercase transition-colors hover:bg-red-700"
>
{m.person_btn_merge_confirm()}
</button>
<button
type="button"
onclick={() => (showMergeConfirm = false)}
class="rounded border border-line px-4 py-2 text-sm font-bold tracking-widest text-ink-2 uppercase transition-colors hover:bg-muted"
>
{m.btn_cancel()}
</button>
</div>
{/if}
</div>
{#if showMergeConfirm}
<p class="mt-3 rounded border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{m.person_merge_warning()} <strong>{person.firstName} {person.lastName}</strong>
{m.person_merge_will_be_deleted()}
</p>
{/if}
</form>
</div>
</div>

View File

@@ -8,7 +8,7 @@ let { form } = $props();
<div class="mb-6"> <div class="mb-6">
<a <a
href="/persons" href="/persons"
class="group mb-4 inline-flex items-center text-xs font-bold tracking-widest text-gray-500 uppercase transition-colors hover:text-brand-navy" class="group mb-4 inline-flex items-center text-xs font-bold tracking-widest text-ink-2 uppercase transition-colors hover:text-ink"
> >
<svg <svg
class="mr-2 h-4 w-4 transform transition-transform group-hover:-translate-x-1" class="mr-2 h-4 w-4 transform transition-transform group-hover:-translate-x-1"
@@ -25,7 +25,7 @@ let { form } = $props();
</svg> </svg>
{m.btn_back_to_overview()} {m.btn_back_to_overview()}
</a> </a>
<h1 class="font-serif text-3xl text-brand-navy">{m.persons_new_heading()}</h1> <h1 class="font-serif text-3xl text-ink">{m.persons_new_heading()}</h1>
</div> </div>
{#if form?.error} {#if form?.error}
@@ -33,14 +33,14 @@ let { form } = $props();
{/if} {/if}
<form method="POST"> <form method="POST">
<div class="rounded-sm border border-brand-sand bg-white p-6 shadow-sm"> <div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
<h2 class="mb-5 text-xs font-bold tracking-widest text-gray-400 uppercase"> <h2 class="mb-5 text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.persons_section_details()} {m.persons_section_details()}
</h2> </h2>
<div class="grid grid-cols-1 gap-5 md:grid-cols-2"> <div class="grid grid-cols-1 gap-5 md:grid-cols-2">
<div> <div>
<label for="firstName" class="mb-1 block text-sm font-medium text-gray-700" <label for="firstName" class="mb-1 block text-sm font-medium text-ink-2"
>{m.form_label_first_name()} *</label >{m.form_label_first_name()} *</label
> >
<input <input
@@ -48,12 +48,12 @@ let { form } = $props();
name="firstName" name="firstName"
type="text" type="text"
required required
class="block w-full rounded border border-gray-300 p-2 text-sm shadow-sm focus:border-brand-navy focus:ring-brand-navy" class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:border-ink focus:ring-ink"
/> />
</div> </div>
<div> <div>
<label for="lastName" class="mb-1 block text-sm font-medium text-gray-700" <label for="lastName" class="mb-1 block text-sm font-medium text-ink-2"
>{m.form_label_last_name()} *</label >{m.form_label_last_name()} *</label
> >
<input <input
@@ -61,12 +61,12 @@ let { form } = $props();
name="lastName" name="lastName"
type="text" type="text"
required required
class="block w-full rounded border border-gray-300 p-2 text-sm shadow-sm focus:border-brand-navy focus:ring-brand-navy" class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:border-ink focus:ring-ink"
/> />
</div> </div>
<div class="md:col-span-2"> <div class="md:col-span-2">
<label for="alias" class="mb-1 block text-sm font-medium text-gray-700" <label for="alias" class="mb-1 block text-sm font-medium text-ink-2"
>{m.form_label_alias()}</label >{m.form_label_alias()}</label
> >
<input <input
@@ -74,7 +74,7 @@ let { form } = $props();
name="alias" name="alias"
type="text" type="text"
placeholder={m.form_placeholder_alias()} placeholder={m.form_placeholder_alias()}
class="block w-full rounded border border-gray-300 p-2 text-sm shadow-sm focus:border-brand-navy focus:ring-brand-navy" class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:border-ink focus:ring-ink"
/> />
</div> </div>
</div> </div>
@@ -82,17 +82,14 @@ let { form } = $props();
<!-- Save Bar --> <!-- Save Bar -->
<div <div
class="mt-4 flex items-center justify-between rounded-sm border border-brand-sand bg-white px-6 py-4 shadow-sm" class="mt-4 flex items-center justify-between rounded-sm border border-line bg-surface px-6 py-4 shadow-sm"
> >
<a <a href="/persons" class="text-sm font-medium text-ink-2 transition-colors hover:text-ink">
href="/persons"
class="text-sm font-medium text-gray-500 transition-colors hover:text-brand-navy"
>
{m.btn_cancel()} {m.btn_cancel()}
</a> </a>
<button <button
type="submit" type="submit"
class="rounded bg-brand-navy px-6 py-2 text-sm font-bold tracking-widest text-white uppercase transition-colors hover:bg-brand-navy/80" 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_create()} {m.btn_create()}
</button> </button>

View File

@@ -1,48 +1,16 @@
<script lang="ts"> <script lang="ts">
import { enhance } from '$app/forms';
import { untrack } from 'svelte';
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import PersonalInfoForm from './PersonalInfoForm.svelte';
import PasswordChangeForm from './PasswordChangeForm.svelte';
let { data, form } = $props(); let { data, form } = $props();
function isoToGerman(iso: string | undefined): string {
if (!iso) return '';
const match = iso.match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (!match) return '';
return `${match[3]}.${match[2]}.${match[1]}`;
}
function germanToIso(german: string): string {
const match = german.match(/^(\d{2})\.(\d{2})\.(\d{4})$/);
if (!match) return '';
return `${match[3]}-${match[2]}-${match[1]}`;
}
let birthDateDisplay = $state(untrack(() => isoToGerman(data.user?.birthDate)));
let birthDateIso = $state(untrack(() => data.user?.birthDate ?? ''));
function handleBirthDateInput(e: Event) {
const input = e.target as HTMLInputElement;
const digits = input.value.replace(/\D/g, '').slice(0, 8);
let formatted: string;
if (digits.length <= 2) {
formatted = digits;
} else if (digits.length <= 4) {
formatted = `${digits.slice(0, 2)}.${digits.slice(2)}`;
} else {
formatted = `${digits.slice(0, 2)}.${digits.slice(2, 4)}.${digits.slice(4)}`;
}
input.value = formatted;
birthDateDisplay = formatted;
birthDateIso = germanToIso(formatted);
}
</script> </script>
<div class="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8"> <div class="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<!-- Back link --> <!-- Back link -->
<a <a
href="/" href="/"
class="group mb-4 inline-flex items-center text-xs font-bold tracking-widest text-gray-500 uppercase transition-colors hover:text-brand-navy" class="group mb-4 inline-flex items-center text-xs font-bold tracking-widest text-ink-2 uppercase transition-colors hover:text-ink"
> >
<svg <svg
class="mr-2 h-4 w-4 transform transition-transform group-hover:-translate-x-1" class="mr-2 h-4 w-4 transform transition-transform group-hover:-translate-x-1"
@@ -56,184 +24,10 @@ function handleBirthDateInput(e: Event) {
{m.btn_back_to_overview()} {m.btn_back_to_overview()}
</a> </a>
<h1 class="mb-6 font-serif text-3xl font-bold text-brand-navy">{m.profile_heading()}</h1> <h1 class="mb-6 font-serif text-3xl font-bold text-ink">{m.profile_heading()}</h1>
<div class="grid grid-cols-1 gap-6 md:grid-cols-2"> <div class="grid grid-cols-1 gap-6 md:grid-cols-2">
<!-- Personal info card --> <PersonalInfoForm user={data.user} form={form} />
<div class="rounded-sm border border-brand-sand bg-white p-6 shadow-sm"> <PasswordChangeForm form={form} />
<h2 class="mb-5 text-xs font-bold tracking-widest text-gray-400 uppercase">
{m.profile_section_personal()}
</h2>
{#if form?.updateSuccess}
<div class="mb-5 rounded border border-green-200 bg-green-50 p-3 text-sm text-green-700">
{m.profile_saved()}
</div>
{/if}
{#if form?.updateError}
<div class="mb-5 rounded border border-red-200 bg-red-50 p-3 text-sm text-red-700">
{form.updateError}
</div>
{/if}
<form method="POST" action="?/updateProfile" use:enhance>
<div class="space-y-4">
<label class="block">
<span
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
>
{m.profile_label_first_name()}
</span>
<input
type="text"
name="firstName"
value={data.user?.firstName ?? ''}
class="w-full rounded-sm border border-brand-sand px-3 py-2 font-serif text-sm focus:border-brand-navy focus:outline-none"
/>
</label>
<label class="block">
<span
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
>
{m.profile_label_last_name()}
</span>
<input
type="text"
name="lastName"
value={data.user?.lastName ?? ''}
class="w-full rounded-sm border border-brand-sand px-3 py-2 font-serif text-sm focus:border-brand-navy focus:outline-none"
/>
</label>
<label class="block">
<span
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
>
{m.profile_label_birth_date()}
</span>
<input
type="text"
placeholder="TT.MM.JJJJ"
value={birthDateDisplay}
oninput={handleBirthDateInput}
class="w-full rounded-sm border border-brand-sand px-3 py-2 font-serif text-sm focus:border-brand-navy focus:outline-none"
/>
<input type="hidden" name="birthDate" value={birthDateIso} />
</label>
<label class="block">
<span
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
>
{m.profile_label_email()}
</span>
<input
type="email"
name="email"
value={data.user?.email ?? ''}
class="w-full rounded-sm border border-brand-sand px-3 py-2 font-serif text-sm focus:border-brand-navy focus:outline-none"
/>
</label>
<label class="block">
<span
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
>
{m.profile_label_contact()}
</span>
<textarea
name="contact"
rows="3"
placeholder={m.profile_contact_placeholder()}
class="w-full rounded-sm border border-brand-sand px-3 py-2 font-serif text-sm focus:border-brand-navy focus:outline-none"
>{data.user?.contact ?? ''}</textarea
>
</label>
</div>
<button
type="submit"
class="mt-5 rounded-sm bg-brand-navy px-5 py-2 font-sans text-xs font-bold tracking-widest text-white uppercase transition-opacity hover:opacity-80"
>
{m.btn_save()}
</button>
</form>
</div>
<!-- Password change card -->
<div class="rounded-sm border border-brand-sand bg-white p-6 shadow-sm">
<h2 class="mb-5 text-xs font-bold tracking-widest text-gray-400 uppercase">
{m.profile_section_password()}
</h2>
{#if form?.passwordSuccess}
<div class="mb-5 rounded border border-green-200 bg-green-50 p-3 text-sm text-green-700">
{m.profile_password_changed()}
</div>
{/if}
{#if form?.passwordError}
<div class="mb-5 rounded border border-red-200 bg-red-50 p-3 text-sm text-red-700">
{#if form.passwordError === 'PASSWORDS_DO_NOT_MATCH'}
{m.profile_password_mismatch()}
{:else}
{form.passwordError}
{/if}
</div>
{/if}
<form method="POST" action="?/changePassword" use:enhance>
<div class="space-y-4">
<label class="block">
<span
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
>
{m.profile_label_current_password()}
</span>
<input
type="password"
name="currentPassword"
required
class="w-full rounded-sm border border-brand-sand px-3 py-2 font-serif text-sm focus:border-brand-navy focus:outline-none"
/>
</label>
<label class="block">
<span
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
>
{m.profile_label_new_password()}
</span>
<input
type="password"
name="newPassword"
required
class="w-full rounded-sm border border-brand-sand px-3 py-2 font-serif text-sm focus:border-brand-navy focus:outline-none"
/>
</label>
<label class="block">
<span
class="mb-1 block font-sans text-xs font-bold tracking-widest text-gray-400 uppercase"
>
{m.profile_label_new_password_confirm()}
</span>
<input
type="password"
name="confirmPassword"
required
class="w-full rounded-sm border border-brand-sand px-3 py-2 font-serif text-sm focus:border-brand-navy focus:outline-none"
/>
</label>
</div>
<button
type="submit"
class="mt-5 rounded-sm bg-brand-navy px-5 py-2 font-sans text-xs font-bold tracking-widest text-white uppercase transition-opacity hover:opacity-80"
>
{m.btn_save()}
</button>
</form>
</div>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,78 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { m } from '$lib/paraglide/messages.js';
let {
form
}: {
form?: { passwordSuccess?: boolean; passwordError?: string } | null;
} = $props();
</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.profile_section_password()}
</h2>
{#if form?.passwordSuccess}
<div class="mb-5 rounded border border-green-200 bg-green-50 p-3 text-sm text-green-700">
{m.profile_password_changed()}
</div>
{/if}
{#if form?.passwordError}
<div class="mb-5 rounded border border-red-200 bg-red-50 p-3 text-sm text-red-700">
{#if form.passwordError === 'PASSWORDS_DO_NOT_MATCH'}
{m.profile_password_mismatch()}
{:else}
{form.passwordError}
{/if}
</div>
{/if}
<form method="POST" action="?/changePassword" use:enhance>
<div class="space-y-4">
<label class="block">
<span class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.profile_label_current_password()}
</span>
<input
type="password"
name="currentPassword"
required
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:border-ink focus:outline-none"
/>
</label>
<label class="block">
<span class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.profile_label_new_password()}
</span>
<input
type="password"
name="newPassword"
required
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:border-ink focus:outline-none"
/>
</label>
<label class="block">
<span class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.profile_label_new_password_confirm()}
</span>
<input
type="password"
name="confirmPassword"
required
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:border-ink focus:outline-none"
/>
</label>
</div>
<button
type="submit"
class="mt-5 rounded-sm bg-primary px-5 py-2 font-sans text-xs font-bold tracking-widest text-white uppercase transition-opacity hover:opacity-80"
>
{m.btn_save()}
</button>
</form>
</div>

Some files were not shown because too many files have changed in this diff Show More