Compare commits

..

105 Commits

Author SHA1 Message Date
Marcel
2bc3b3fb6c feat(#73): deep-link to specific comments via ?commentId= query param
Some checks failed
CI / Unit & Component Tests (push) Failing after 1m55s
CI / Backend Unit Tests (push) Successful in 2m10s
CI / E2E Tests (push) Failing after 2h23m30s
CI / Unit & Component Tests (pull_request) Failing after 2m3s
CI / Backend Unit Tests (pull_request) Successful in 2m20s
CI / E2E Tests (pull_request) Failing after 2h3m35s
- +page.svelte: read ?commentId= from URL; on mount, if present open bottom panel to discussion tab
- CommentThread: add targetCommentId prop — scrolls to comment on mount (scrollIntoView), applies ring highlight, removes highlight on first user interaction (click/keydown/scroll)
- CommentThread: add data-comment-id attributes to thread root and reply divs
- PanelDiscussion / DocumentBottomPanel: thread targetCommentId prop through the chain

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 20:37:22 +01:00
Marcel
55cf1fb0a4 feat(#72): add @mention support in comment editor
- mention.ts: detectMention (cursor-aware), extractContent (parse @Name → UUID), renderBody (XSS-safe: escape-first then inject anchor tags, replaceAll for all occurrences)
- 19 unit tests in mention.spec.ts (all green)
- MentionEditor.svelte: textarea with @-trigger popup, debounced /api/users/search, keyboard navigation (↑↓ Enter Esc), Ctrl+Enter submit, @ button for accessibility
- CommentThread.svelte: replace plain textareas with MentionEditor, send mentionedUserIds on post/reply/edit, render comment bodies with {@html renderBody(...)}
- types.ts: add MentionDTO, add optional mentionDTOs to Comment and CommentReply

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 20:32:54 +01:00
Marcel
e455efa670 feat(#71): add notification bell + preferences UI
- NotificationBell.svelte: bell icon in header with unread badge, dropdown showing last 10 notifications, mark-all-read, click-outside close, keyboard Escape support, polls every PUBLIC_NOTIFICATION_POLL_MS ms
- Wire NotificationBell into +layout.svelte between ThemeToggle and UserMenu (authenticated users only)
- Profile page: add notification preferences card with notifyOnReply / notifyOnMention toggles, loaded via GET and saved via PUT /api/users/me/notification-preferences
- i18n: de/en/es message keys for bell, notifications list, and preference labels

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 20:20:58 +01:00
Marcel
1615a4ffa5 feat(backend): add V17 migration, @mention storage, MentionDTO, user search endpoint, and tests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 20:09:40 +01:00
Marcel
bc62f3b0af feat(backend): trigger reply notifications from CommentService
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 20:05:29 +01:00
Marcel
420f50b6d5 feat(backend): add Notification entity, NotificationService, NotificationController, and tests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 20:03:34 +01:00
Marcel
d91a10ef8e feat(backend): add V16 migration for notifications table and user preference columns
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 19:55:40 +01:00
Marcel
44f495ca8b fix(touch): enable annotation drawing and hover on touch devices
Some checks failed
CI / Unit & Component Tests (push) Successful in 2m28s
CI / Backend Unit Tests (push) Successful in 2m15s
CI / E2E Tests (push) Failing after 29m24s
- Add touch-action:none to container when in annotate mode so the
  browser doesn't intercept touch gestures for scroll/pan
- Replace onmouseenter/onmouseleave with onpointerenter/onpointerleave
  so the highlight effect also fires on touch/stylus

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 17:05:26 +01:00
Marcel
74bf49552b refactor: extract LanguageSwitcher into a reusable component
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
Removes duplicated locale logic from +layout.svelte and AppNav.svelte.
Context-specific sizing (text-xs/min-h-[44px]) stays in the wrapper
via [&_button]: selectors so the component itself is layout-agnostic.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 17:03:40 +01:00
Marcel
1de4f8a605 fix(ui): hide logo on mobile+tablet, fix admin tab overflow
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
- AppNav: hide entire logo div (incl. mr-10 margin) below md: breakpoint
  to eliminate the phantom whitespace left of the hamburger button
- admin: 2×2 grid on mobile → flex row at sm:, so "Schlagworte" fits

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 17:00:56 +01:00
Marcel
f8d888a5be fix(#103): move language switcher from header into mobile nav drawer
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
On mobile the header is now cleaner — language buttons move to the
bottom of the hamburger panel. Desktop header is unchanged (sm:flex).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 16:41:51 +01:00
Marcel
29f0ec8a05 fix(#102): replace native file input in edit form with styled upload zone
Matches the FileSectionNew design: upload arrow icon, hidden <input>,
styled label as the click target, shows selected filename on pick.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 16:40:23 +01:00
Marcel
5db17880f9 fix(#101): stop bottom panel from overlapping document viewer
Replaced position:fixed on the bottom panel with shrink-0 flex child,
so the viewer (flex-1) naturally stops at the panel top instead of
extending behind it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 16:38:47 +01:00
Marcel
ce02c1bf39 fix(#100): hide action button labels on mobile to prevent toolbar overflow
At 320px, showing "Annotieren" + "Bearbeiten" + download pushed the
toolbar past its bounds. Icon-only at mobile, labels revealed at sm:.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 16:36:15 +01:00
Marcel
e1c09ddc7f fix(#99): make document detail tab bar scrollable on narrow screens
Wrap tabs in overflow-x-auto container with hidden scrollbar so all 4
German labels ("Transkription" etc.) are reachable at 320px. Close
button stays pinned outside the scroll area, always visible.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 16:33:47 +01:00
Marcel
93408c5825 fix(#98): make drop zone border and card borders visible in dark mode
- DropZone: raise border opacity from /20 to /30 for dashed drop zone
- layout.css: bump dark mode --c-line from #2e2e2e to #3d3d3d (was
  ~1.3:1 contrast on #1a1a1a surface, effectively invisible)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 16:31:00 +01:00
Marcel
2a2ce240e1 fix(#97): add px-4 base padding to person directory on mobile
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 16:28:31 +01:00
Marcel
0bd7a70c96 fix: hide Familienarchiv wordmark below sm breakpoint to save header space
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
On mobile the text consumed most of the header width, leaving no room
for the hamburger, theme toggle, and user menu. Uses hidden sm:inline —
aria-label on the anchor preserves screen reader access at all sizes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 16:25:50 +01:00
Marcel
a570dff4e9 fix(#95): stack save bar buttons full-width on mobile to prevent text wrap
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
Long button labels (e.g. German "Speichern & Als überprüft markieren")
require ~515px at text-xs tracking-widest — impossible at 320px inline.

Both save bars (new document + edit document) now use flex-col on mobile
with w-full buttons and flex-row on sm+. Primary actions appear first
(top on mobile, right on desktop). Also fixes hardcoded border-gray-300/
text-gray-600 → border-line/text-ink-2 and bg-brand-navy/text-white →
bg-primary/text-primary-fg in these two components.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 16:23:12 +01:00
Marcel
fcff7fbdb1 fix(#94): replace text-white with text-primary-fg on all primary buttons
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
In dark mode --c-primary switches from navy (#012851) to mint (#a1dcd8).
Buttons using bg-primary+text-white showed white text on mint at 1.4:1
contrast — invisible. bg-brand-navy buttons were also invisible (navy on
near-black canvas, 1.3:1).

Replaced in 28 components app-wide:
- bg-primary ... text-white → text-primary-fg
- hover:bg-primary hover:text-white → hover:text-primary-fg
- bg-brand-navy ... text-white + hover:bg-brand-navy/90 →
  bg-primary ... text-primary-fg + hover:bg-primary/90

Light mode is unchanged: primary-fg = white in light mode.
Dark mode: primary-fg = navy (#012851) on mint bg = readable.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 16:07:37 +01:00
Marcel
5cf6947040 fix(#93): migrate hardcoded text-gray-400/500 to semantic ink tokens
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
enrich/+page.svelte back link: text-gray-500 → text-ink-2 / hover:text-ink
enrich/done/+page.svelte body text: text-gray-500 → text-ink-2
enrich/done/+page.svelte list link: text-gray-400 (2.6:1, fails AA) → text-ink-2

Root fix for section label contrast (text-ink-3 uppercase pattern used
app-wide) is in PR #107 via the ink-3 token value change.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 16:06:57 +01:00
Marcel
d053f6dc40 fix(#92): fix ink-2 and ink-3 contrast to meet WCAG AA across all modes
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
Light mode:
- ink-2 #6b7280 → #4b5563 (gray-600): was 4.2:1 on canvas — now 6.6:1 ✓
- ink-3 #9ca3af → #6b7280 (gray-500): was 2.6:1 on white — now 4.8:1 ✓

Dark mode:
- ink-3 #6b7280 → #8b97a5: was 4.0:1 on dark surface — now 6.5:1 ✓
- ink-2 #9ca3af unchanged (already 7.5:1 — WCAG AAA)

Both the media-query and manual-override dark sections updated.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 16:05:01 +01:00
Marcel
afebaf4c53 fix(#91): add px-4 base padding and fix admin tab overflow at 320px
Some checks failed
CI / Unit & Component Tests (pull_request) Successful in 2m42s
CI / Backend Unit Tests (pull_request) Successful in 2m23s
CI / E2E Tests (pull_request) Failing after 29m0s
CI / Unit & Component Tests (push) Successful in 3m20s
CI / Backend Unit Tests (push) Successful in 2m21s
CI / E2E Tests (push) Failing after 29m37s
Home and Admin had no horizontal padding below the sm breakpoint (640px),
causing content to bleed to viewport edges. Admin's flex justify-between
row with h1 + 4 tab buttons overflowed by ~110px at 320px.

- +page.svelte: add px-4 to <main> (sm:px-6 lg:px-8 unchanged)
- admin/+page.svelte: add px-4 to outer container; stack header row
  vertically on mobile (flex-col sm:flex-row); reduce tab button padding
  to px-2 on mobile (sm:px-4 on desktop)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 15:50:03 +01:00
Marcel
1bfe0ab022 fix(#96): remove off-brand lavender accent bar from all pages
Some checks failed
CI / Unit & Component Tests (push) Successful in 2m33s
CI / Backend Unit Tests (push) Successful in 2m15s
CI / E2E Tests (push) Failing after 28m13s
CI / Unit & Component Tests (pull_request) Successful in 2m44s
CI / Backend Unit Tests (pull_request) Successful in 2m16s
CI / E2E Tests (pull_request) Failing after 24m21s
The h-1 bg-brand-purple strip (#b4b9ff) is not a De Gruyter brand
color and was added as a rough placeholder. Removed from +layout.svelte
and the three auth pages (login, forgot-password, reset-password).
Also removed the unused --palette-purple and --color-brand-purple CSS
tokens from layout.css.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 15:49:13 +01:00
Marcel
6ebae19984 feat(#90): add hamburger menu and mobile nav drawer below 640px
Some checks failed
CI / Unit & Component Tests (pull_request) Successful in 2m26s
CI / Backend Unit Tests (pull_request) Successful in 2m11s
CI / E2E Tests (pull_request) Failing after 25m59s
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
Nav links were completely hidden on mobile (sm:flex / hidden split).
Adds a 44×44px hamburger toggle, a fixed overlay panel with full-width
nav links (min-h-[44px] touch targets), backdrop-click and Escape to
close, and a $effect that auto-closes on route change.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 12:38:52 +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
Marcel
9aa98b4fb6 merge(frontend): resolve conflicts with main — integrate fileHash feature into panel architecture
Some checks failed
CI / Unit & Component Tests (pull_request) Successful in 2m21s
CI / Backend Unit Tests (pull_request) Successful in 2m11s
CI / E2E Tests (pull_request) Failing after 28m37s
CI / Unit & Component Tests (push) Successful in 2m26s
CI / Backend Unit Tests (push) Successful in 2m14s
CI / E2E Tests (push) Has started running
Keep the new bottom-panel / AnnotationSidePanel architecture from this branch
while pulling in the documentFileHash / visibleAnnotations filter that was added
on main. Thread documentFileHash through DocumentViewer so outdated-annotation
filtering works end-to-end.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 11:20:48 +01:00
Marcel
dd360ade8b fix(frontend): fix side panel X button click falling through to PDF toolbar
Some checks failed
CI / Unit & Component Tests (push) Successful in 2m24s
CI / Backend Unit Tests (push) Successful in 2m14s
CI / Unit & Component Tests (pull_request) Successful in 2m20s
CI / Backend Unit Tests (pull_request) Successful in 2m12s
CI / E2E Tests (push) Failing after 29m14s
CI / E2E Tests (pull_request) Failing after 29m37s
pointer-events-none and pointer-events-auto were both present as static
and conditional Tailwind classes simultaneously. CSS specificity meant
pointer-events-none always won, so clicks passed through to the
annotation toggle button behind the panel. Now pointer-events-none is
only applied when the panel is hidden (translated off-screen).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 07:33:59 +01:00
Marcel
f71712ab4b feat(frontend): move annotation comments to right-side panel
Annotation threads now open in a slide-in side panel (320 px, right
edge of the PDF viewer) instead of expanding the bottom drawer.
The PDF stays visible while the user reads and writes annotation
comments.

- Add AnnotationSidePanel component (absolute-positioned, CSS slide
  transition, keyed CommentThread, close via X or Escape)
- Remove the $effect that opened the bottom drawer on annotation click
- Simplify PanelDiscussion back to document-level thread only (no
  annotation sub-tabs)
- Remove annotation-related props from DocumentBottomPanel and
  PanelDiscussion

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 07:23:20 +01:00
Marcel
10783fdb55 fix(frontend): always start with panel closed on document open
Removed localStorage persistence for the open/closed state so the PDF
is always visible first when navigating to a document. Height and active
tab are still remembered across visits.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 07:04:20 +01:00
Marcel
5ea5590c89 fix(frontend): restore global nav bar on document detail page
The document viewer container was using fixed inset-0 z-50 which
covered the sticky global nav bar. Now measures nav height at mount
and offsets the container top accordingly, dropping z-index to z-40.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 23:26:29 +01:00
Marcel
142f296255 feat(frontend): close bottom panel when entering annotate mode
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 23:21:38 +01:00
Marcel
c19f7b3b1a fix(frontend): correct path for Note-Add-MD icon on Annotieren button
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 23:20:49 +01:00
Marcel
db9d8ed457 feat(frontend): add Note-Add-MD icon to the Annotieren button
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 23:18:21 +01:00
Marcel
65457a5650 feat(frontend): show history diff inline below the selected version
Instead of rendering the diff at the bottom of the list (requiring the user
to scroll down), it now appears directly below whichever version item was
clicked. Compare-mode diff stays at the bottom of the compare form where it
makes sense, since it is not tied to a specific list item.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 23:15:47 +01:00
Marcel
1eb2659ba0 fix(frontend): open bottom panel to full height below the document header
Instead of an arbitrary 80 % cap, the panel now measures the actual
DocumentTopBar height at open time and fills the remaining viewport
exactly — so the PDF is fully covered and the drawer reaches right up
to the header. Drag-to-shrink still works as before.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 23:10:26 +01:00
Marcel
f18649fb79 feat(frontend): open bottom panel at full height (80vh) by default
Panel now opens to 80 % of the viewport height so the user can immediately
read comments and metadata without having to drag it up first.
The user can drag the top handle down to make it smaller; that size is
persisted to localStorage and restored on the next visit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 23:06:42 +01:00
Marcel
a392e85f43 fix(frontend): move annotation toggle into PDF toolbar and add text label
Button was rendered outside the controls bar (below the toolbar). Moved it
inside so it stays in the same row as zoom and page controls. Added a text
label next to the eye icon so the action is self-descriptive.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 23:03:37 +01:00
Marcel
c9b4e6dad4 feat(frontend): add annotation visibility toggle to PDF toolbar
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 2m27s
CI / Backend Unit Tests (pull_request) Successful in 2m6s
CI / E2E Tests (pull_request) Failing after 26m28s
Eye/eye-slash button in the PDF controls bar lets the user hide all
annotation highlights to read the document unobstructed and show them again
with one click.

- Button only renders when at least one annotation exists
- Active state (hidden) highlighted with brand-mint/bg-white/10 so the
  current state is always clear
- i18n keys added for de/en/es

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 22:59:53 +01:00
Marcel
8519fbb48a fix(frontend): lock document page to viewport with position: fixed
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 2m20s
CI / Backend Unit Tests (pull_request) Successful in 2m11s
CI / E2E Tests (pull_request) Failing after 26m7s
The global layout wraps pages in min-h-screen + main.py-6, which pushed
the h-screen document container below the sticky nav and caused page-level
scrolling. Switching to fixed inset-0 z-50 fully escapes the layout flow:

- DocumentTopBar always visible (no scrolling it away)
- PDF controls always visible
- Only the PDF canvas area scrolls
- DocumentBottomPanel moved inside the fixed container (logically grouped)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 22:53:02 +01:00
Marcel
ee85ce4668 feat(frontend): keep annotation tab after switching to document discussion
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 / E2E Tests (pull_request) Failing after 26m48s
CI / Unit & Component Tests (pull_request) Successful in 2m29s
CI / Backend Unit Tests (pull_request) Successful in 2m16s
Clicking the Diskussion sub-tab no longer deselects the active annotation,
so the Annotation tab stays visible and accessible for easy toggling back.

The annotation is cleared only via Escape or clicking elsewhere on the PDF.
Removes the now-unused onClearAnnotation callback chain.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 22:49:44 +01:00
Marcel
ecfd80bf9a feat(frontend): add discussion sub-tab navigation for annotation threads
Some checks failed
CI / E2E Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Successful in 2m34s
CI / Backend Unit Tests (pull_request) Successful in 2m16s
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (pull_request) Failing after 24m11s
Within the Diskussion panel tab, show two sub-tabs when an annotation is
active: «Diskussion» (document-level thread, with comment-count badge) and
«Annotation · Seite N» (annotation-specific thread).

Behaviour:
- Clicking an annotation auto-switches to the Annotation sub-tab
- Clicking the Diskussion sub-tab deselects the annotation and returns to
  the document thread
- Escape clears the active annotation (or collapses the panel if none)
- activeAnnotationPage is now lifted from PdfViewer → DocumentViewer →
  page → DocumentBottomPanel → PanelDiscussion so the tab label shows the
  correct page number

Closes #60
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 22:45:35 +01:00
Marcel
63013cc86a test(e2e): update reader annotation test to match post-#61 behaviour
Some checks failed
CI / Unit & Component Tests (pull_request) Successful in 2m22s
CI / Backend Unit Tests (pull_request) Successful in 2m23s
CI / Backend Unit Tests (push) Successful in 2m16s
CI / Unit & Component Tests (push) Successful in 2m29s
CI / E2E Tests (pull_request) Successful in 23m27s
CI / E2E Tests (push) Failing after 16m19s
The old test waited for the PDF canvas (30 s timeout) before checking
for a disabled Annotieren button — a brittle dependency that caused
consistent failure because the reader's file fetch never completed in
CI. Since issue #61 will remove the disabled button entirely for users
without ANNOTATE_ALL, rewrite the test to assert the button is absent,
which is correct both in the interim and after #61.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 18:18:36 +01:00
Marcel
9e2419a48e feat(frontend): remove document status pills
Status badges (UPLOADED, PLACEHOLDER, etc.) provided no real value
to users and have been removed from the document list and document
detail header.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 17:55:53 +01:00
Marcel
00195dc8db feat(frontend): add backfill file hashes card to admin System tab
Some checks failed
CI / Unit & Component Tests (push) Successful in 2m28s
CI / Backend Unit Tests (push) Successful in 2m17s
CI / E2E Tests (push) Failing after 24m34s
CI / Unit & Component Tests (pull_request) Successful in 2m16s
CI / Backend Unit Tests (pull_request) Successful in 2m7s
CI / E2E Tests (pull_request) Failing after 22m53s
- System tab gains a second card with a 'Datei-Hashes berechnen' button
  that calls POST /api/admin/backfill-file-hashes and shows the updated count
- i18n: admin_system_backfill_hashes_* keys added in de/en/es
- E2E: test verifies the button triggers the backfill and shows the success message

Closes #56

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 17:33:01 +01:00
Marcel
0ec86220d3 feat(backend): add POST /api/admin/backfill-file-hashes endpoint
- DocumentRepository: findByFileHashIsNullAndFilePathIsNotNull()
- AnnotationRepository: findByDocumentIdAndFileHashIsNull()
- FileService: downloadFileBytes() downloads raw bytes from S3 for hashing
- AnnotationService: backfillAnnotationFileHashForDocument() sets hash on null-hash annotations
- DocumentService: backfillFileHashes() iterates documents with null hash,
  downloads bytes, computes SHA-256, saves doc, then propagates hash to annotations
- AdminController: POST /api/admin/backfill-file-hashes delegates to DocumentService

Closes #56

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 17:32:29 +01:00
Marcel
7fbc33b32d feat(frontend): hide outdated annotations when file version changes
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 / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Unit & Component Tests (push) Has started running
- Regenerate API types with fileHash on Document and DocumentAnnotation
- PdfViewer accepts documentFileHash prop; filters visibleAnnotations to
  those whose hash matches (or is null) and shows an amber notice banner
  when any annotations are hidden due to a hash mismatch
- Document detail page passes doc.fileHash to PdfViewer
- Add i18n key annotation_outdated_notice in de/en/es
- E2E: two new tests covering hide-on-reupload and restore-on-original-reupload
  scenarios; add minimal2.pdf fixture for a different-hash upload

Closes #55

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 17:09:26 +01:00
Marcel
93f57477cd feat(backend): hash uploaded files and store hash on documents and annotations
- Flyway V13: add file_hash column to documents and document_annotations
- FileService.uploadFile() now returns UploadResult(s3Key, fileHash) with SHA-256 hash computed from raw bytes
- Document and DocumentAnnotation models gain a fileHash field
- DocumentService propagates the hash at all three upload sites (storeDocument, createDocument, updateDocument)
- AnnotationService.createAnnotation() accepts and persists a fileHash
- AnnotationController resolves the document's hash and passes it through

Closes #55

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 17:08:55 +01:00
144 changed files with 9411 additions and 3518 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

@@ -41,4 +41,10 @@ public class AdminController {
documentService.getDocumentsWithoutVersions()); documentService.getDocumentsWithoutVersions());
return ResponseEntity.ok(new BackfillResult(count)); return ResponseEntity.ok(new BackfillResult(count));
} }
@PostMapping("/backfill-file-hashes")
public ResponseEntity<BackfillResult> backfillFileHashes() {
int count = documentService.backfillFileHashes();
return ResponseEntity.ok(new BackfillResult(count));
}
} }

View File

@@ -4,10 +4,12 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.raddatz.familienarchiv.dto.CreateAnnotationDTO; import org.raddatz.familienarchiv.dto.CreateAnnotationDTO;
import org.raddatz.familienarchiv.model.AppUser; import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.DocumentAnnotation; import org.raddatz.familienarchiv.model.DocumentAnnotation;
import org.raddatz.familienarchiv.security.Permission; import org.raddatz.familienarchiv.security.Permission;
import org.raddatz.familienarchiv.security.RequirePermission; import org.raddatz.familienarchiv.security.RequirePermission;
import org.raddatz.familienarchiv.service.AnnotationService; import org.raddatz.familienarchiv.service.AnnotationService;
import org.raddatz.familienarchiv.service.DocumentService;
import org.raddatz.familienarchiv.service.UserService; import org.raddatz.familienarchiv.service.UserService;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.security.core.Authentication; import org.springframework.security.core.Authentication;
@@ -23,6 +25,7 @@ import java.util.UUID;
public class AnnotationController { public class AnnotationController {
private final AnnotationService annotationService; private final AnnotationService annotationService;
private final DocumentService documentService;
private final UserService userService; private final UserService userService;
@GetMapping @GetMapping
@@ -38,7 +41,8 @@ public class AnnotationController {
@RequestBody CreateAnnotationDTO dto, @RequestBody CreateAnnotationDTO dto,
Authentication authentication) { Authentication authentication) {
UUID userId = resolveUserId(authentication); UUID userId = resolveUserId(authentication);
return annotationService.createAnnotation(documentId, dto, userId); Document doc = documentService.getDocumentById(documentId);
return annotationService.createAnnotation(documentId, dto, userId, doc.getFileHash());
} }
@DeleteMapping("/{annotationId}") @DeleteMapping("/{annotationId}")

View File

@@ -39,7 +39,7 @@ public class CommentController {
@RequestBody CreateCommentDTO dto, @RequestBody CreateCommentDTO dto,
Authentication authentication) { Authentication authentication) {
AppUser author = resolveUser(authentication); AppUser author = resolveUser(authentication);
return commentService.postComment(documentId, null, dto.getContent(), author); return commentService.postComment(documentId, null, dto.getContent(), dto.getMentionedUserIds(), author);
} }
@PostMapping("/api/documents/{documentId}/comments/{commentId}/replies") @PostMapping("/api/documents/{documentId}/comments/{commentId}/replies")
@@ -51,7 +51,7 @@ public class CommentController {
@RequestBody CreateCommentDTO dto, @RequestBody CreateCommentDTO dto,
Authentication authentication) { Authentication authentication) {
AppUser author = resolveUser(authentication); AppUser author = resolveUser(authentication);
return commentService.replyToComment(documentId, commentId, dto.getContent(), author); return commentService.replyToComment(documentId, commentId, dto.getContent(), dto.getMentionedUserIds(), author);
} }
// ─── Annotation comments ────────────────────────────────────────────────── // ─── Annotation comments ──────────────────────────────────────────────────
@@ -70,7 +70,7 @@ public class CommentController {
@RequestBody CreateCommentDTO dto, @RequestBody CreateCommentDTO dto,
Authentication authentication) { Authentication authentication) {
AppUser author = resolveUser(authentication); AppUser author = resolveUser(authentication);
return commentService.postComment(documentId, annotationId, dto.getContent(), author); return commentService.postComment(documentId, annotationId, dto.getContent(), dto.getMentionedUserIds(), author);
} }
@PostMapping("/api/documents/{documentId}/annotations/{annotationId}/comments/{commentId}/replies") @PostMapping("/api/documents/{documentId}/annotations/{annotationId}/comments/{commentId}/replies")
@@ -82,7 +82,7 @@ public class CommentController {
@RequestBody CreateCommentDTO dto, @RequestBody CreateCommentDTO dto,
Authentication authentication) { Authentication authentication) {
AppUser author = resolveUser(authentication); AppUser author = resolveUser(authentication);
return commentService.replyToComment(documentId, commentId, dto.getContent(), author); return commentService.replyToComment(documentId, commentId, dto.getContent(), dto.getMentionedUserIds(), author);
} }
// ─── Edit and delete (shared) ───────────────────────────────────────────── // ─── Edit and delete (shared) ─────────────────────────────────────────────

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

@@ -0,0 +1,71 @@
package org.raddatz.familienarchiv.controller;
import lombok.RequiredArgsConstructor;
import org.raddatz.familienarchiv.dto.NotificationPreferenceDTO;
import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.model.Notification;
import org.raddatz.familienarchiv.service.NotificationService;
import org.raddatz.familienarchiv.service.UserService;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.UUID;
@RestController
@RequiredArgsConstructor
public class NotificationController {
private final NotificationService notificationService;
private final UserService userService;
@GetMapping("/api/notifications")
public Page<Notification> getNotifications(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size,
Authentication authentication) {
AppUser user = resolveUser(authentication);
PageRequest pageable = PageRequest.of(page, size, Sort.by("createdAt").descending());
return notificationService.getNotifications(user.getId(), pageable);
}
@PostMapping("/api/notifications/read-all")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void markAllRead(Authentication authentication) {
AppUser user = resolveUser(authentication);
notificationService.markAllRead(user.getId());
}
@PatchMapping("/api/notifications/{id}/read")
public Notification markOneRead(
@PathVariable UUID id,
Authentication authentication) {
AppUser user = resolveUser(authentication);
return notificationService.markRead(id, user.getId());
}
@GetMapping("/api/users/me/notification-preferences")
public NotificationPreferenceDTO getPreferences(Authentication authentication) {
AppUser user = resolveUser(authentication);
return new NotificationPreferenceDTO(user.isNotifyOnReply(), user.isNotifyOnMention());
}
@PutMapping("/api/users/me/notification-preferences")
public NotificationPreferenceDTO updatePreferences(
@RequestBody NotificationPreferenceDTO dto,
Authentication authentication) {
AppUser user = resolveUser(authentication);
AppUser updated = notificationService.updatePreferences(
user.getId(), dto.notifyOnReply(), dto.notifyOnMention());
return new NotificationPreferenceDTO(updated.isNotifyOnReply(), updated.isNotifyOnMention());
}
// ─── private helpers ──────────────────────────────────────────────────────
private AppUser resolveUser(Authentication authentication) {
return userService.findByUsername(authentication.getName());
}
}

View File

@@ -0,0 +1,29 @@
package org.raddatz.familienarchiv.controller;
import lombok.RequiredArgsConstructor;
import org.raddatz.familienarchiv.dto.MentionDTO;
import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.service.UserSearchService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequiredArgsConstructor
public class UserSearchController {
private final UserSearchService userSearchService;
@GetMapping("/api/users/search")
public List<MentionDTO> search(@RequestParam(defaultValue = "") String q) {
return userSearchService.search(q).stream()
.map(this::toMentionDTO)
.toList();
}
private MentionDTO toMentionDTO(AppUser user) {
return new MentionDTO(user.getId(), user.getFirstName(), user.getLastName());
}
}

View File

@@ -2,7 +2,12 @@ package org.raddatz.familienarchiv.dto;
import lombok.Data; import lombok.Data;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
@Data @Data
public class CreateCommentDTO { public class CreateCommentDTO {
private String content; private String content;
private List<UUID> mentionedUserIds = new ArrayList<>();
} }

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

@@ -0,0 +1,11 @@
package org.raddatz.familienarchiv.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.UUID;
public record MentionDTO(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String firstName,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String lastName
) {}

View File

@@ -0,0 +1,3 @@
package org.raddatz.familienarchiv.dto;
public record NotificationPreferenceDTO(boolean notifyOnReply, boolean notifyOnMention) {}

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 */
@@ -48,6 +50,10 @@ public enum ErrorCode {
/** The comment with the given ID does not exist. 404 */ /** The comment with the given ID does not exist. 404 */
COMMENT_NOT_FOUND, COMMENT_NOT_FOUND,
// --- Notifications ---
/** The notification with the given ID does not exist. 404 */
NOTIFICATION_NOT_FOUND,
// --- Generic --- // --- Generic ---
/** Request validation failed (missing or malformed fields). 400 */ /** Request validation failed (missing or malformed fields). 400 */
VALIDATION_ERROR, VALIDATION_ERROR,

View File

@@ -51,6 +51,16 @@ public class AppUser {
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) @Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private boolean enabled = true; // Um User zu sperren ohne sie zu löschen private boolean enabled = true; // Um User zu sperren ohne sie zu löschen
@Column(nullable = false)
@Builder.Default
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private boolean notifyOnReply = false;
@Column(nullable = false)
@Builder.Default
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private boolean notifyOnMention = false;
// Ein User kann in mehreren Gruppen sein // Ein User kann in mehreren Gruppen sein
@ManyToMany(fetch = FetchType.EAGER) @ManyToMany(fetch = FetchType.EAGER)
@JoinTable(name = "users_groups", joinColumns = @JoinColumn(name = "user_id"), inverseJoinColumns = @JoinColumn(name = "group_id")) @JoinTable(name = "users_groups", joinColumns = @JoinColumn(name = "user_id"), inverseJoinColumns = @JoinColumn(name = "group_id"))

View File

@@ -39,6 +39,10 @@ public class Document {
@Column(name = "content_type") @Column(name = "content_type")
private String contentType; private String contentType;
// SHA-256 hash of the uploaded file — used to link annotations to a file version
@Column(name = "file_hash", length = 64)
private String fileHash;
// Originaler Dateiname beim Upload (z.B. "Brief_Oma_1940.pdf") // Originaler Dateiname beim Upload (z.B. "Brief_Oma_1940.pdf")
@Column(name = "original_filename", nullable = false) @Column(name = "original_filename", nullable = false)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) @Schema(requiredMode = Schema.RequiredMode.REQUIRED)
@@ -82,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

@@ -49,6 +49,9 @@ public class DocumentAnnotation {
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) @Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private String color; private String color;
@Column(name = "file_hash", length = 64)
private String fileHash;
@Column(name = "created_by") @Column(name = "created_by")
private UUID createdBy; private UUID createdBy;

View File

@@ -1,10 +1,12 @@
package org.raddatz.familienarchiv.model; package org.raddatz.familienarchiv.model;
import com.fasterxml.jackson.annotation.JsonIgnore;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.persistence.*; import jakarta.persistence.*;
import lombok.*; import lombok.*;
import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp; import org.hibernate.annotations.UpdateTimestamp;
import org.raddatz.familienarchiv.dto.MentionDTO;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.ArrayList; import java.util.ArrayList;
@@ -60,4 +62,21 @@ public class DocumentComment {
@Builder.Default @Builder.Default
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) @Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private List<DocumentComment> replies = new ArrayList<>(); private List<DocumentComment> replies = new ArrayList<>();
// JPA join table for structured mention references — not serialized directly
@ManyToMany(fetch = FetchType.LAZY)
@JoinTable(
name = "comment_mentions",
joinColumns = @JoinColumn(name = "comment_id"),
inverseJoinColumns = @JoinColumn(name = "user_id")
)
@JsonIgnore
@Builder.Default
private List<AppUser> mentions = new ArrayList<>();
// Populated by CommentService before serialization — not persisted.
@Transient
@Builder.Default
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private List<MentionDTO> mentionDTOs = new ArrayList<>();
} }

View File

@@ -0,0 +1,53 @@
package org.raddatz.familienarchiv.model;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.CreationTimestamp;
import java.time.LocalDateTime;
import java.util.UUID;
@Entity
@Table(name = "notifications")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Notification {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private UUID id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "recipient_id", nullable = false)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private AppUser recipient;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private NotificationType type;
@Column(name = "document_id")
private UUID documentId;
@Column(name = "reference_id")
private UUID referenceId;
@Column(nullable = false)
@Builder.Default
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private boolean read = false;
@CreationTimestamp
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private LocalDateTime createdAt;
// Populated by NotificationService before serialization — not persisted.
@Transient
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private String actorName;
}

View File

@@ -0,0 +1,6 @@
package org.raddatz.familienarchiv.model;
public enum NotificationType {
REPLY,
MENTION
}

View File

@@ -14,4 +14,6 @@ public interface AnnotationRepository extends JpaRepository<DocumentAnnotation,
List<DocumentAnnotation> findByDocumentIdAndPageNumber(UUID documentId, int pageNumber); List<DocumentAnnotation> findByDocumentIdAndPageNumber(UUID documentId, int pageNumber);
Optional<DocumentAnnotation> findByIdAndDocumentId(UUID id, UUID documentId); Optional<DocumentAnnotation> findByIdAndDocumentId(UUID id, UUID documentId);
List<DocumentAnnotation> findByDocumentIdAndFileHashIsNull(UUID documentId);
} }

View File

@@ -1,10 +1,13 @@
package org.raddatz.familienarchiv.repository; package org.raddatz.familienarchiv.repository;
import org.raddatz.familienarchiv.model.AppUser; import org.raddatz.familienarchiv.model.AppUser;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.UUID; import java.util.UUID;
@@ -12,4 +15,9 @@ import java.util.UUID;
public interface AppUserRepository extends JpaRepository<AppUser, UUID> { public interface AppUserRepository extends JpaRepository<AppUser, UUID> {
Optional<AppUser> findByUsername(String username); Optional<AppUser> findByUsername(String username);
Optional<AppUser> findByEmail(String email); Optional<AppUser> findByEmail(String email);
@Query("SELECT u FROM AppUser u WHERE " +
"LOWER(COALESCE(u.firstName, '') || ' ' || COALESCE(u.lastName, '')) LIKE LOWER(CONCAT('%', :q, '%')) " +
"OR LOWER(u.username) LIKE LOWER(CONCAT('%', :q, '%'))")
List<AppUser> searchByNameOrUsername(@Param("q") String q, Pageable pageable);
} }

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);
@@ -37,6 +40,14 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
@Query("SELECT d FROM Document d WHERE d.id NOT IN (SELECT DISTINCT dv.documentId FROM DocumentVersion dv)") @Query("SELECT d FROM Document d WHERE d.id NOT IN (SELECT DISTINCT dv.documentId FROM DocumentVersion dv)")
List<Document> findDocumentsWithoutVersions(); List<Document> findDocumentsWithoutVersions();
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

@@ -0,0 +1,25 @@
package org.raddatz.familienarchiv.repository;
import org.raddatz.familienarchiv.model.Notification;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
import java.util.UUID;
public interface NotificationRepository extends JpaRepository<Notification, UUID> {
Page<Notification> findByRecipientIdOrderByCreatedAtDesc(UUID recipientId, Pageable pageable);
long countByRecipientIdAndReadFalse(UUID recipientId);
@Modifying
@Query("UPDATE Notification n SET n.read = true WHERE n.recipient.id = :userId")
void markAllReadByRecipientId(@Param("userId") UUID userId);
List<Notification> findByRecipientIdOrderByCreatedAtDesc(UUID recipientId);
}

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

@@ -23,7 +23,7 @@ public class AnnotationService {
} }
@Transactional @Transactional
public DocumentAnnotation createAnnotation(UUID documentId, CreateAnnotationDTO dto, UUID userId) { public DocumentAnnotation createAnnotation(UUID documentId, CreateAnnotationDTO dto, UUID userId, String fileHash) {
List<DocumentAnnotation> existing = List<DocumentAnnotation> existing =
annotationRepository.findByDocumentIdAndPageNumber(documentId, dto.getPageNumber()); annotationRepository.findByDocumentIdAndPageNumber(documentId, dto.getPageNumber());
@@ -41,6 +41,7 @@ public class AnnotationService {
.width(dto.getWidth()) .width(dto.getWidth())
.height(dto.getHeight()) .height(dto.getHeight())
.color(dto.getColor()) .color(dto.getColor())
.fileHash(fileHash)
.createdBy(userId) .createdBy(userId)
.build(); .build();
@@ -61,6 +62,14 @@ public class AnnotationService {
annotationRepository.delete(annotation); annotationRepository.delete(annotation);
} }
@Transactional
public void backfillAnnotationFileHashForDocument(UUID documentId, String fileHash) {
annotationRepository.findByDocumentIdAndFileHashIsNull(documentId).forEach(a -> {
a.setFileHash(fileHash);
annotationRepository.save(a);
});
}
// ─── private helpers ────────────────────────────────────────────────────── // ─── private helpers ──────────────────────────────────────────────────────
private boolean overlaps(DocumentAnnotation existing, CreateAnnotationDTO dto) { private boolean overlaps(DocumentAnnotation existing, CreateAnnotationDTO dto) {

View File

@@ -1,10 +1,12 @@
package org.raddatz.familienarchiv.service; package org.raddatz.familienarchiv.service;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.raddatz.familienarchiv.dto.MentionDTO;
import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode; import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.AppUser; import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.model.DocumentComment; import org.raddatz.familienarchiv.model.DocumentComment;
import org.raddatz.familienarchiv.repository.AppUserRepository;
import org.raddatz.familienarchiv.repository.CommentRepository; import org.raddatz.familienarchiv.repository.CommentRepository;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -17,20 +19,23 @@ import java.util.UUID;
public class CommentService { public class CommentService {
private final CommentRepository commentRepository; private final CommentRepository commentRepository;
private final AppUserRepository userRepository;
private final NotificationService notificationService;
public List<DocumentComment> getCommentsForDocument(UUID documentId) { public List<DocumentComment> getCommentsForDocument(UUID documentId) {
List<DocumentComment> roots = List<DocumentComment> roots =
commentRepository.findByDocumentIdAndAnnotationIdIsNullAndParentIdIsNull(documentId); commentRepository.findByDocumentIdAndAnnotationIdIsNullAndParentIdIsNull(documentId);
return withReplies(roots); return withRepliesAndMentions(roots);
} }
public List<DocumentComment> getCommentsForAnnotation(UUID annotationId) { public List<DocumentComment> getCommentsForAnnotation(UUID annotationId) {
List<DocumentComment> roots = commentRepository.findByAnnotationIdAndParentIdIsNull(annotationId); List<DocumentComment> roots = commentRepository.findByAnnotationIdAndParentIdIsNull(annotationId);
return withReplies(roots); return withRepliesAndMentions(roots);
} }
@Transactional @Transactional
public DocumentComment postComment(UUID documentId, UUID annotationId, String content, AppUser author) { public DocumentComment postComment(UUID documentId, UUID annotationId, String content,
List<UUID> mentionedUserIds, AppUser author) {
DocumentComment comment = DocumentComment.builder() DocumentComment comment = DocumentComment.builder()
.documentId(documentId) .documentId(documentId)
.annotationId(annotationId) .annotationId(annotationId)
@@ -38,11 +43,16 @@ public class CommentService {
.authorId(author.getId()) .authorId(author.getId())
.authorName(resolveAuthorName(author)) .authorName(resolveAuthorName(author))
.build(); .build();
return commentRepository.save(comment); saveMentions(comment, mentionedUserIds);
DocumentComment saved = commentRepository.save(comment);
withMentionDTOs(saved);
notificationService.notifyMentions(mentionedUserIds, saved);
return saved;
} }
@Transactional @Transactional
public DocumentComment replyToComment(UUID documentId, UUID commentId, String content, AppUser author) { public DocumentComment replyToComment(UUID documentId, UUID commentId, String content,
List<UUID> mentionedUserIds, AppUser author) {
DocumentComment target = commentRepository.findById(commentId) DocumentComment target = commentRepository.findById(commentId)
.orElseThrow(() -> DomainException.notFound( .orElseThrow(() -> DomainException.notFound(
ErrorCode.COMMENT_NOT_FOUND, "Comment not found: " + commentId)); ErrorCode.COMMENT_NOT_FOUND, "Comment not found: " + commentId));
@@ -60,7 +70,12 @@ public class CommentService {
.authorId(author.getId()) .authorId(author.getId())
.authorName(resolveAuthorName(author)) .authorName(resolveAuthorName(author))
.build(); .build();
return commentRepository.save(reply); saveMentions(reply, mentionedUserIds);
DocumentComment saved = commentRepository.save(reply);
withMentionDTOs(saved);
notificationService.notifyReply(saved, root);
notificationService.notifyMentions(mentionedUserIds, saved);
return saved;
} }
@Transactional @Transactional
@@ -86,11 +101,29 @@ public class CommentService {
// ─── private helpers ────────────────────────────────────────────────────── // ─── private helpers ──────────────────────────────────────────────────────
private List<DocumentComment> withReplies(List<DocumentComment> roots) { private List<DocumentComment> withRepliesAndMentions(List<DocumentComment> roots) {
roots.forEach(root -> root.setReplies(commentRepository.findByParentId(root.getId()))); roots.forEach(root -> {
List<DocumentComment> replies = commentRepository.findByParentId(root.getId());
replies.forEach(this::withMentionDTOs);
root.setReplies(replies);
withMentionDTOs(root);
});
return roots; return roots;
} }
private void saveMentions(DocumentComment comment, List<UUID> mentionedUserIds) {
if (mentionedUserIds == null || mentionedUserIds.isEmpty()) return;
List<AppUser> users = userRepository.findAllById(mentionedUserIds);
comment.setMentions(users);
}
private void withMentionDTOs(DocumentComment comment) {
List<MentionDTO> dtos = comment.getMentions().stream()
.map(u -> new MentionDTO(u.getId(), u.getFirstName(), u.getLastName()))
.toList();
comment.setMentionDTOs(dtos);
}
private DocumentComment findComment(UUID documentId, UUID commentId) { private DocumentComment findComment(UUID documentId, UUID commentId) {
return commentRepository.findById(commentId) return commentRepository.findById(commentId)
.filter(c -> documentId.equals(c.getDocumentId())) .filter(c -> documentId.equals(c.getDocumentId()))

View File

@@ -18,6 +18,8 @@ import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import java.io.IOException; import java.io.IOException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
@@ -38,42 +40,55 @@ public class DocumentService {
private final FileService fileService; private final FileService fileService;
private final TagService tagService; private final TagService tagService;
private final DocumentVersionService documentVersionService; private final DocumentVersionService documentVersionService;
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();
} }
// 2. Delegate Storage to FileService // 2. Delegate Storage to FileService
String s3Key = fileService.uploadFile(file, originalFilename); FileService.UploadResult upload = fileService.uploadFile(file, originalFilename);
// 3. Update Database // 3. Update Database
document.setFilePath(s3Key); document.setFilePath(upload.s3Key());
document.setFileHash(upload.fileHash());
document.setContentType(file.getContentType()); document.setContentType(file.getContentType());
if (document.getStatus() == DocumentStatus.PLACEHOLDER) { if (document.getStatus() == DocumentStatus.PLACEHOLDER) {
document.setStatus(DocumentStatus.UPLOADED); document.setStatus(DocumentStatus.UPLOADED);
} }
return documentRepository.save(document); return new StoreResult(documentRepository.save(document), isNew);
} }
@Transactional @Transactional
@@ -82,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);
@@ -120,8 +151,9 @@ public class DocumentService {
// Datei // Datei
if (file != null && !file.isEmpty()) { if (file != null && !file.isEmpty()) {
String s3Key = fileService.uploadFile(file, file.getOriginalFilename()); FileService.UploadResult upload = fileService.uploadFile(file, file.getOriginalFilename());
doc.setFilePath(s3Key); doc.setFilePath(upload.s3Key());
doc.setFileHash(upload.fileHash());
doc.setContentType(file.getContentType()); doc.setContentType(file.getContentType());
doc.setStatus(DocumentStatus.UPLOADED); doc.setStatus(DocumentStatus.UPLOADED);
} }
@@ -168,14 +200,16 @@ 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()) {
// Alte Datei könnte man hier theoretisch löschen (optional) FileService.UploadResult upload = fileService.uploadFile(newFile, newFile.getOriginalFilename());
doc.setFilePath(upload.s3Key());
// Neue Datei hochladen doc.setFileHash(upload.fileHash());
String s3Key = fileService.uploadFile(newFile, newFile.getOriginalFilename());
doc.setFilePath(s3Key);
doc.setOriginalFilename(newFile.getOriginalFilename()); doc.setOriginalFilename(newFile.getOriginalFilename());
doc.setContentType(newFile.getContentType()); doc.setContentType(newFile.getContentType());
doc.setStatus(DocumentStatus.UPLOADED); doc.setStatus(DocumentStatus.UPLOADED);
@@ -232,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
@@ -275,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 -> {
@@ -283,4 +338,120 @@ public class DocumentService {
}); });
tagService.delete(tagId); tagService.delete(tagId);
} }
@Transactional
public int backfillFileHashes() {
List<Document> docs = documentRepository.findByFileHashIsNullAndFilePathIsNotNull();
int count = 0;
for (Document doc : docs) {
try {
byte[] bytes = fileService.downloadFileBytes(doc.getFilePath());
String hash = sha256Hex(bytes);
doc.setFileHash(hash);
documentRepository.save(doc);
annotationService.backfillAnnotationFileHashForDocument(doc.getId(), hash);
count++;
} catch (Exception e) {
log.warn("Failed to backfill hash for document {}: {}", doc.getId(), e.getMessage());
}
}
return count;
}
// ─── 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) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(bytes);
StringBuilder sb = new StringBuilder(64);
for (byte b : hash) {
sb.append(String.format("%02x", b));
}
return sb.toString();
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 not available", e);
}
}
} }

View File

@@ -13,6 +13,9 @@ import org.springframework.web.multipart.MultipartFile;
import org.springframework.core.io.InputStreamResource; import org.springframework.core.io.InputStreamResource;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.UUID; import java.util.UUID;
@Service @Service
@@ -29,10 +32,14 @@ public class FileService {
} }
/** /**
* Uploads a file to S3/MinIO and returns the generated object key. * Uploads a file to S3/MinIO.
* Returns an {@link UploadResult} containing the S3 key and the SHA-256
* hash of the file content. The hash is used to link annotations to the
* specific file version they were created against.
*/ */
public String uploadFile(MultipartFile file, String originalFilename) throws IOException { public UploadResult uploadFile(MultipartFile file, String originalFilename) throws IOException {
// Generate secure unique path: "documents/UUID_filename" byte[] bytes = file.getBytes();
String fileHash = sha256Hex(bytes);
String s3Key = "documents/" + UUID.randomUUID() + "_" + originalFilename; String s3Key = "documents/" + UUID.randomUUID() + "_" + originalFilename;
try { try {
@@ -42,11 +49,10 @@ public class FileService {
.contentType(file.getContentType()) .contentType(file.getContentType())
.build(); .build();
s3Client.putObject(putObjectRequest, s3Client.putObject(putObjectRequest, RequestBody.fromBytes(bytes));
RequestBody.fromInputStream(file.getInputStream(), file.getSize()));
log.info("Uploaded file to S3: {}", s3Key); log.info("Uploaded file to S3: {} (hash={})", s3Key, fileHash);
return s3Key; return new UploadResult(s3Key, fileHash);
} catch (S3Exception e) { } catch (S3Exception e) {
log.error("S3 Upload Error", e); log.error("S3 Upload Error", e);
throw new IOException("Failed to upload file to storage", e); throw new IOException("Failed to upload file to storage", e);
@@ -58,32 +64,72 @@ public class FileService {
* Returns a wrapper containing the stream and content type. * Returns a wrapper containing the stream and content type.
*/ */
public S3FileDownload downloadFile(String s3Key) { public S3FileDownload downloadFile(String s3Key) {
try { try {
GetObjectRequest getObjectRequest = GetObjectRequest.builder() GetObjectRequest getObjectRequest = GetObjectRequest.builder()
.bucket(bucketName) .bucket(bucketName)
.key(s3Key) .key(s3Key)
.build(); .build();
ResponseInputStream<GetObjectResponse> s3Object = s3Client.getObject(getObjectRequest); ResponseInputStream<GetObjectResponse> s3Object = s3Client.getObject(getObjectRequest);
// Use whatever content type S3 has stored (set at upload time) String contentType = s3Object.response().contentType();
String contentType = s3Object.response().contentType(); if (contentType == null || contentType.isBlank()) {
if (contentType == null || contentType.isBlank()) { contentType = "application/octet-stream";
contentType = "application/octet-stream"; }
return new S3FileDownload(new InputStreamResource(s3Object), contentType);
} catch (NoSuchKeyException e) {
throw new StorageFileNotFoundException("File not found in storage: " + s3Key);
} catch (S3Exception e) {
throw new RuntimeException("Storage Error: " + e.getMessage());
} }
return new S3FileDownload(new InputStreamResource(s3Object), contentType);
} catch (NoSuchKeyException e) {
throw new StorageFileNotFoundException("File not found in storage: " + s3Key);
} catch (S3Exception e) {
throw new RuntimeException("Storage Error: " + e.getMessage());
} }
}
// Helper Record to carry the stream and metadata back to the controller /**
* Downloads a file from S3/MinIO and returns its raw bytes.
* Used for hash backfill — callers are responsible for not calling this on large files unnecessarily.
*/
public byte[] downloadFileBytes(String s3Key) throws IOException {
try {
GetObjectRequest getObjectRequest = GetObjectRequest.builder()
.bucket(bucketName)
.key(s3Key)
.build();
try (InputStream in = s3Client.getObject(getObjectRequest)) {
return in.readAllBytes();
}
} catch (NoSuchKeyException e) {
throw new StorageFileNotFoundException("File not found in storage: " + s3Key);
} catch (S3Exception e) {
throw new IOException("Failed to download file from storage: " + e.getMessage(), e);
}
}
// ─── private helpers ──────────────────────────────────────────────────────
private static String sha256Hex(byte[] bytes) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(bytes);
StringBuilder sb = new StringBuilder(64);
for (byte b : hash) {
sb.append(String.format("%02x", b));
}
return sb.toString();
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 not available", e);
}
}
// ─── result types ─────────────────────────────────────────────────────────
/** Carries the S3 object key and the content hash back to the caller. */
public record UploadResult(String s3Key, String fileHash) {}
/** Carries the download stream and content type. */
public record S3FileDownload(InputStreamResource resource, String contentType) {} public record S3FileDownload(InputStreamResource resource, String contentType) {}
// Custom Exception
public static class StorageFileNotFoundException extends RuntimeException { public static class StorageFileNotFoundException extends RuntimeException {
public StorageFileNotFoundException(String message) { super(message); } public StorageFileNotFoundException(String message) { super(message); }
} }

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

@@ -0,0 +1,187 @@
package org.raddatz.familienarchiv.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.model.DocumentComment;
import org.raddatz.familienarchiv.model.Notification;
import org.raddatz.familienarchiv.model.NotificationType;
import org.raddatz.familienarchiv.repository.AppUserRepository;
import org.raddatz.familienarchiv.repository.CommentRepository;
import org.raddatz.familienarchiv.repository.NotificationRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.mail.MailException;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
@Service
@RequiredArgsConstructor
@Slf4j
public class NotificationService {
private final NotificationRepository notificationRepository;
private final CommentRepository commentRepository;
private final AppUserRepository userRepository;
@Autowired(required = false)
private JavaMailSender mailSender;
@Value("${app.mail.from:noreply@familienarchiv.local}")
private String mailFrom;
@Value("${app.base-url:http://localhost:3000}")
private String baseUrl;
/**
* Creates REPLY notifications for all participants in the thread that the given reply belongs to,
* excluding the replier themselves.
*/
@Transactional
public void notifyReply(DocumentComment reply, DocumentComment root) {
Set<UUID> participantIds = collectParticipantIds(root);
participantIds.remove(reply.getAuthorId());
for (UUID participantId : participantIds) {
Optional<AppUser> recipientOpt = userRepository.findById(participantId);
if (recipientOpt.isEmpty()) continue;
AppUser recipient = recipientOpt.get();
Notification notification = Notification.builder()
.recipient(recipient)
.type(NotificationType.REPLY)
.documentId(reply.getDocumentId())
.referenceId(reply.getId())
.build();
notificationRepository.save(notification);
if (recipient.isNotifyOnReply()) {
sendNotificationEmail(recipient, reply, NotificationType.REPLY);
}
}
}
/**
* Creates MENTION notifications for each mentioned user.
*/
@Transactional
public void notifyMentions(List<UUID> mentionedUserIds, DocumentComment comment) {
for (UUID mentionedUserId : mentionedUserIds) {
Optional<AppUser> recipientOpt = userRepository.findById(mentionedUserId);
if (recipientOpt.isEmpty()) continue;
AppUser recipient = recipientOpt.get();
Notification notification = Notification.builder()
.recipient(recipient)
.type(NotificationType.MENTION)
.documentId(comment.getDocumentId())
.referenceId(comment.getId())
.build();
notificationRepository.save(notification);
if (recipient.isNotifyOnMention()) {
sendNotificationEmail(recipient, comment, NotificationType.MENTION);
}
}
}
public Page<Notification> getNotifications(UUID userId, Pageable pageable) {
return notificationRepository.findByRecipientIdOrderByCreatedAtDesc(userId, pageable);
}
public long countUnread(UUID userId) {
return notificationRepository.countByRecipientIdAndReadFalse(userId);
}
@Transactional
public void markAllRead(UUID userId) {
notificationRepository.markAllReadByRecipientId(userId);
}
@Transactional
public Notification markRead(UUID notificationId, UUID userId) {
Notification notification = notificationRepository.findById(notificationId)
.orElseThrow(() -> DomainException.notFound(
ErrorCode.NOTIFICATION_NOT_FOUND, "Notification not found: " + notificationId));
if (!notification.getRecipient().getId().equals(userId)) {
throw DomainException.forbidden("Notification belongs to a different user");
}
notification.setRead(true);
return notificationRepository.save(notification);
}
@Transactional
public AppUser updatePreferences(UUID userId, boolean notifyOnReply, boolean notifyOnMention) {
AppUser user = userRepository.findById(userId)
.orElseThrow(() -> DomainException.notFound(ErrorCode.USER_NOT_FOUND, "User not found: " + userId));
user.setNotifyOnReply(notifyOnReply);
user.setNotifyOnMention(notifyOnMention);
return userRepository.save(user);
}
// ─── private helpers ──────────────────────────────────────────────────────
private Set<UUID> collectParticipantIds(DocumentComment root) {
Set<UUID> ids = new LinkedHashSet<>();
if (root.getAuthorId() != null) ids.add(root.getAuthorId());
commentRepository.findByParentId(root.getId())
.forEach(reply -> {
if (reply.getAuthorId() != null) ids.add(reply.getAuthorId());
});
return ids;
}
private void buildCommentPath(DocumentComment comment, StringBuilder sb) {
sb.append("?commentId=").append(comment.getId());
if (comment.getAnnotationId() != null) {
sb.append("&annotationId=").append(comment.getAnnotationId());
}
}
private void sendNotificationEmail(AppUser recipient, DocumentComment comment, NotificationType type) {
if (mailSender == null) {
log.warn("Mail sender not configured — skipping notification email to {}", recipient.getEmail());
return;
}
if (recipient.getEmail() == null || recipient.getEmail().isBlank()) return;
StringBuilder path = new StringBuilder("/documents/").append(comment.getDocumentId());
buildCommentPath(comment, path);
String link = baseUrl + path;
String subject = type == NotificationType.REPLY
? "Neue Antwort auf deinen Kommentar — Familienarchiv"
: "Du wurdest in einem Kommentar erwähnt — Familienarchiv";
String body = type == NotificationType.REPLY
? "Hallo,\n\njemand hat auf einen Kommentar geantwortet, an dem du beteiligt warst.\n\n"
+ "Zum Kommentar:\n" + link + "\n\nDein Familienarchiv-Team"
: "Hallo,\n\njemand hat dich in einem Kommentar erwähnt.\n\n"
+ "Zum Kommentar:\n" + link + "\n\nDein Familienarchiv-Team";
SimpleMailMessage message = new SimpleMailMessage();
message.setFrom(mailFrom);
message.setTo(recipient.getEmail());
message.setSubject(subject);
message.setText(body);
try {
mailSender.send(message);
} catch (MailException e) {
log.error("Failed to send notification email to {}: {}", recipient.getEmail(), e.getMessage());
}
}
}

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,23 @@
package org.raddatz.familienarchiv.service;
import lombok.RequiredArgsConstructor;
import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.repository.AppUserRepository;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
@RequiredArgsConstructor
public class UserSearchService {
private static final int MAX_RESULTS = 10;
private final AppUserRepository userRepository;
public List<AppUser> search(String query) {
if (query == null || query.isBlank()) return List.of();
return userRepository.searchByNameOrUsername(query.trim(), PageRequest.of(0, MAX_RESULTS));
}
}

View File

@@ -0,0 +1,7 @@
-- Add content-based file hash to documents for annotation versioning
ALTER TABLE documents
ADD COLUMN file_hash VARCHAR(64);
-- Each annotation remembers which file version it was created against
ALTER TABLE document_annotations
ADD COLUMN file_hash VARCHAR(64);

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

@@ -0,0 +1,16 @@
-- Notification preferences on the user record — no separate entity needed
ALTER TABLE users ADD COLUMN notify_on_reply BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE users ADD COLUMN notify_on_mention BOOLEAN NOT NULL DEFAULT false;
-- In-app notifications
CREATE TABLE notifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
recipient_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
type VARCHAR(32) NOT NULL, -- 'REPLY' | 'MENTION'
document_id UUID,
reference_id UUID, -- commentId that triggered this notification
read BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMP NOT NULL DEFAULT now()
);
CREATE INDEX idx_notifications_recipient ON notifications(recipient_id, read, created_at DESC);

View File

@@ -0,0 +1,5 @@
CREATE TABLE comment_mentions (
comment_id UUID NOT NULL REFERENCES document_comments(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
PRIMARY KEY (comment_id, user_id)
);

View File

@@ -58,4 +58,29 @@ class AdminControllerTest {
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.count").value(1)); .andExpect(jsonPath("$.count").value(1));
} }
// ─── POST /api/admin/backfill-file-hashes ──────────────────────────────────
@Test
void backfillFileHashes_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(post("/api/admin/backfill-file-hashes"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(roles = "USER")
void backfillFileHashes_returns403_whenNotAdmin() throws Exception {
mockMvc.perform(post("/api/admin/backfill-file-hashes"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "ADMIN")
void backfillFileHashes_returns200_withCount_whenAdmin() throws Exception {
when(documentService.backfillFileHashes()).thenReturn(3);
mockMvc.perform(post("/api/admin/backfill-file-hashes"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.count").value(3));
}
} }

View File

@@ -4,10 +4,12 @@ import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.config.SecurityConfig; import org.raddatz.familienarchiv.config.SecurityConfig;
import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode; import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.DocumentAnnotation; import org.raddatz.familienarchiv.model.DocumentAnnotation;
import org.raddatz.familienarchiv.security.PermissionAspect; import org.raddatz.familienarchiv.security.PermissionAspect;
import org.raddatz.familienarchiv.service.AnnotationService; import org.raddatz.familienarchiv.service.AnnotationService;
import org.raddatz.familienarchiv.service.CustomUserDetailsService; import org.raddatz.familienarchiv.service.CustomUserDetailsService;
import org.raddatz.familienarchiv.service.DocumentService;
import org.raddatz.familienarchiv.service.UserService; import org.raddatz.familienarchiv.service.UserService;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration; import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
@@ -36,6 +38,7 @@ class AnnotationControllerTest {
@Autowired MockMvc mockMvc; @Autowired MockMvc mockMvc;
@MockitoBean AnnotationService annotationService; @MockitoBean AnnotationService annotationService;
@MockitoBean DocumentService documentService;
@MockitoBean UserService userService; @MockitoBean UserService userService;
@MockitoBean CustomUserDetailsService customUserDetailsService; @MockitoBean CustomUserDetailsService customUserDetailsService;
@@ -85,7 +88,8 @@ class AnnotationControllerTest {
DocumentAnnotation saved = DocumentAnnotation.builder() DocumentAnnotation saved = DocumentAnnotation.builder()
.id(UUID.randomUUID()).documentId(docId).pageNumber(1) .id(UUID.randomUUID()).documentId(docId).pageNumber(1)
.x(0.1).y(0.1).width(0.2).height(0.2).color("#ff0000").build(); .x(0.1).y(0.1).width(0.2).height(0.2).color("#ff0000").build();
when(annotationService.createAnnotation(any(), any(), any())).thenReturn(saved); when(documentService.getDocumentById(any())).thenReturn(Document.builder().build());
when(annotationService.createAnnotation(any(), any(), any(), any())).thenReturn(saved);
mockMvc.perform(post("/api/documents/" + docId + "/annotations") mockMvc.perform(post("/api/documents/" + docId + "/annotations")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
@@ -97,7 +101,8 @@ class AnnotationControllerTest {
@Test @Test
@WithMockUser(authorities = "ANNOTATE_ALL") @WithMockUser(authorities = "ANNOTATE_ALL")
void createAnnotation_returns409_whenOverlap() throws Exception { void createAnnotation_returns409_whenOverlap() throws Exception {
when(annotationService.createAnnotation(any(), any(), any())) when(documentService.getDocumentById(any())).thenReturn(Document.builder().build());
when(annotationService.createAnnotation(any(), any(), any(), any()))
.thenThrow(DomainException.conflict(ErrorCode.ANNOTATION_OVERLAP, "Overlap")); .thenThrow(DomainException.conflict(ErrorCode.ANNOTATION_OVERLAP, "Overlap"));
mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations") mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations")

View File

@@ -81,7 +81,7 @@ class CommentControllerTest {
void postDocumentComment_returns201_whenHasPermission() throws Exception { void postDocumentComment_returns201_whenHasPermission() throws Exception {
DocumentComment saved = DocumentComment.builder() DocumentComment saved = DocumentComment.builder()
.id(COMMENT_ID).documentId(DOC_ID).authorName("Hans").content("Test comment").build(); .id(COMMENT_ID).documentId(DOC_ID).authorName("Hans").content("Test comment").build();
when(commentService.postComment(any(), any(), any(), any())).thenReturn(saved); when(commentService.postComment(any(), any(), any(), any(), any())).thenReturn(saved);
mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments") mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments")
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON)) .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
@@ -104,7 +104,7 @@ class CommentControllerTest {
DocumentComment saved = DocumentComment.builder() DocumentComment saved = DocumentComment.builder()
.id(UUID.randomUUID()).documentId(DOC_ID).parentId(COMMENT_ID) .id(UUID.randomUUID()).documentId(DOC_ID).parentId(COMMENT_ID)
.authorName("Anna").content("Test comment").build(); .authorName("Anna").content("Test comment").build();
when(commentService.replyToComment(any(), any(), any(), any())).thenReturn(saved); when(commentService.replyToComment(any(), any(), any(), any(), any())).thenReturn(saved);
mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID + "/replies") mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID + "/replies")
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON)) .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
@@ -179,7 +179,7 @@ class CommentControllerTest {
DocumentComment saved = DocumentComment.builder() DocumentComment saved = DocumentComment.builder()
.id(UUID.randomUUID()).documentId(DOC_ID).annotationId(ANN_ID) .id(UUID.randomUUID()).documentId(DOC_ID).annotationId(ANN_ID)
.authorName("Hans").content("Test comment").build(); .authorName("Hans").content("Test comment").build();
when(commentService.postComment(any(), any(), any(), any())).thenReturn(saved); when(commentService.postComment(any(), any(), any(), any(), any())).thenReturn(saved);
mockMvc.perform(post("/api/documents/" + DOC_ID + "/annotations/" + ANN_ID + "/comments") mockMvc.perform(post("/api/documents/" + DOC_ID + "/annotations/" + ANN_ID + "/comments")
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON)) .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
@@ -194,7 +194,7 @@ class CommentControllerTest {
DocumentComment saved = DocumentComment.builder() DocumentComment saved = DocumentComment.builder()
.id(UUID.randomUUID()).documentId(DOC_ID).annotationId(ANN_ID) .id(UUID.randomUUID()).documentId(DOC_ID).annotationId(ANN_ID)
.parentId(COMMENT_ID).authorName("Anna").content("Test comment").build(); .parentId(COMMENT_ID).authorName("Anna").content("Test comment").build();
when(commentService.replyToComment(any(), any(), any(), any())).thenReturn(saved); when(commentService.replyToComment(any(), any(), any(), any(), any())).thenReturn(saved);
mockMvc.perform(post("/api/documents/" + DOC_ID + "/annotations/" + ANN_ID + "/comments/" + COMMENT_ID + "/replies") mockMvc.perform(post("/api/documents/" + DOC_ID + "/annotations/" + ANN_ID + "/comments/" + COMMENT_ID + "/replies")
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON)) .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))

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

@@ -0,0 +1,162 @@
package org.raddatz.familienarchiv.controller;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.config.SecurityConfig;
import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.model.Notification;
import org.raddatz.familienarchiv.model.NotificationType;
import org.raddatz.familienarchiv.security.PermissionAspect;
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
import org.raddatz.familienarchiv.service.NotificationService;
import org.raddatz.familienarchiv.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
import org.springframework.http.MediaType;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import java.util.UUID;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@WebMvcTest(NotificationController.class)
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
class NotificationControllerTest {
@Autowired MockMvc mockMvc;
@MockitoBean NotificationService notificationService;
@MockitoBean UserService userService;
@MockitoBean CustomUserDetailsService customUserDetailsService;
private static final UUID USER_ID = UUID.randomUUID();
// ─── GET /api/notifications ───────────────────────────────────────────────
@Test
void getNotifications_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(get("/api/notifications"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(username = "testuser")
void getNotifications_returns200WithList_whenAuthenticated() throws Exception {
AppUser user = AppUser.builder().id(USER_ID).username("testuser").build();
Notification n = Notification.builder()
.id(UUID.randomUUID()).recipient(user)
.type(NotificationType.REPLY).read(false).build();
when(userService.findByUsername("testuser")).thenReturn(user);
when(notificationService.getNotifications(eq(USER_ID), any()))
.thenReturn(new PageImpl<>(List.of(n), PageRequest.of(0, 10), 1));
mockMvc.perform(get("/api/notifications"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.content").isArray());
}
@Test
@WithMockUser(username = "testuser")
void getNotifications_returnsOnlyCurrentUsersNotifications() throws Exception {
AppUser user = AppUser.builder().id(USER_ID).username("testuser").build();
when(userService.findByUsername("testuser")).thenReturn(user);
when(notificationService.getNotifications(eq(USER_ID), any()))
.thenReturn(new PageImpl<>(List.of()));
mockMvc.perform(get("/api/notifications"))
.andExpect(status().isOk());
verify(notificationService).getNotifications(eq(USER_ID), any());
}
// ─── POST /api/notifications/read-all ────────────────────────────────────
@Test
void markAllRead_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(post("/api/notifications/read-all"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(username = "testuser")
void markAllRead_returns204_whenAuthenticated() throws Exception {
AppUser user = AppUser.builder().id(USER_ID).username("testuser").build();
when(userService.findByUsername("testuser")).thenReturn(user);
mockMvc.perform(post("/api/notifications/read-all"))
.andExpect(status().isNoContent());
verify(notificationService).markAllRead(USER_ID);
}
// ─── PATCH /api/notifications/{id}/read ──────────────────────────────────
@Test
@WithMockUser(username = "testuser")
void markOneRead_returns403_whenNotificationBelongsToDifferentUser() throws Exception {
AppUser user = AppUser.builder().id(USER_ID).username("testuser").build();
UUID notifId = UUID.randomUUID();
when(userService.findByUsername("testuser")).thenReturn(user);
org.mockito.Mockito.doThrow(
org.raddatz.familienarchiv.exception.DomainException.forbidden("not yours"))
.when(notificationService).markRead(notifId, USER_ID);
mockMvc.perform(patch("/api/notifications/" + notifId + "/read"))
.andExpect(status().isForbidden());
}
// ─── GET /api/users/me/notification-preferences ──────────────────────────
@Test
void getPreferences_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(get("/api/users/me/notification-preferences"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(username = "testuser")
void getPreferences_returnsCurrentPreferences() throws Exception {
AppUser user = AppUser.builder().id(USER_ID).username("testuser")
.notifyOnReply(true).notifyOnMention(false).build();
when(userService.findByUsername("testuser")).thenReturn(user);
mockMvc.perform(get("/api/users/me/notification-preferences"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.notifyOnReply").value(true))
.andExpect(jsonPath("$.notifyOnMention").value(false));
}
// ─── PUT /api/users/me/notification-preferences ──────────────────────────
@Test
@WithMockUser(username = "testuser")
void updatePreferences_persistsBothBooleans() throws Exception {
AppUser user = AppUser.builder().id(USER_ID).username("testuser")
.notifyOnReply(false).notifyOnMention(false).build();
when(userService.findByUsername("testuser")).thenReturn(user);
AppUser updated = AppUser.builder().id(USER_ID).username("testuser")
.notifyOnReply(true).notifyOnMention(true).build();
when(notificationService.updatePreferences(USER_ID, true, true)).thenReturn(updated);
mockMvc.perform(put("/api/users/me/notification-preferences")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"notifyOnReply\":true,\"notifyOnMention\":true}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.notifyOnReply").value(true))
.andExpect(jsonPath("$.notifyOnMention").value(true));
}
}

View File

@@ -0,0 +1,71 @@
package org.raddatz.familienarchiv.controller;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.config.SecurityConfig;
import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.security.PermissionAspect;
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
import org.raddatz.familienarchiv.service.UserSearchService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import java.util.UUID;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest(UserSearchController.class)
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
class UserSearchControllerTest {
@Autowired MockMvc mockMvc;
@MockitoBean UserSearchService userSearchService;
@MockitoBean CustomUserDetailsService customUserDetailsService;
@Test
void search_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(get("/api/users/search").param("q", "Hans"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser
void search_returns200_whenAuthenticated() throws Exception {
AppUser user = AppUser.builder().id(UUID.randomUUID())
.firstName("Hans").lastName("Mueller").username("hans").build();
when(userSearchService.search("Hans")).thenReturn(List.of(user));
mockMvc.perform(get("/api/users/search").param("q", "Hans"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].firstName").value("Hans"));
}
@Test
@WithMockUser
void search_returnsEmptyList_whenQueryIsEmpty() throws Exception {
when(userSearchService.search("")).thenReturn(List.of());
mockMvc.perform(get("/api/users/search").param("q", ""))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isEmpty());
}
@Test
@WithMockUser
void search_returnsAtMostTenResults() throws Exception {
when(userSearchService.search(anyString())).thenReturn(List.of());
mockMvc.perform(get("/api/users/search").param("q", "a"))
.andExpect(status().isOk());
}
}

View File

@@ -44,7 +44,7 @@ class AnnotationServiceTest {
when(annotationRepository.findByDocumentIdAndPageNumber(docId, 1)) when(annotationRepository.findByDocumentIdAndPageNumber(docId, 1))
.thenReturn(List.of(existing)); .thenReturn(List.of(existing));
assertThatThrownBy(() -> annotationService.createAnnotation(docId, dto, userId)) assertThatThrownBy(() -> annotationService.createAnnotation(docId, dto, userId, null))
.isInstanceOf(DomainException.class) .isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getStatus()).isEqualTo(CONFLICT)); .satisfies(e -> assertThat(((DomainException) e).getStatus()).isEqualTo(CONFLICT));
@@ -63,7 +63,7 @@ class AnnotationServiceTest {
.x(0.0).y(0.0).width(0.05).height(0.05).color("#ff0000").createdBy(userId).build(); .x(0.0).y(0.0).width(0.05).height(0.05).color("#ff0000").createdBy(userId).build();
when(annotationRepository.save(any())).thenReturn(saved); when(annotationRepository.save(any())).thenReturn(saved);
DocumentAnnotation result = annotationService.createAnnotation(docId, dto, userId); DocumentAnnotation result = annotationService.createAnnotation(docId, dto, userId, null);
assertThat(result).isEqualTo(saved); assertThat(result).isEqualTo(saved);
verify(annotationRepository).save(any()); verify(annotationRepository).save(any());
@@ -117,6 +117,35 @@ class AnnotationServiceTest {
verify(annotationRepository).delete(annotation); verify(annotationRepository).delete(annotation);
} }
@Test
void createAnnotation_setsFileHash_whenProvided() {
UUID docId = UUID.randomUUID();
UUID userId = UUID.randomUUID();
CreateAnnotationDTO dto = new CreateAnnotationDTO(1, 0.0, 0.0, 0.05, 0.05, "#ff0000");
String fileHash = "abc123";
when(annotationRepository.findByDocumentIdAndPageNumber(docId, 1)).thenReturn(List.of());
when(annotationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
DocumentAnnotation result = annotationService.createAnnotation(docId, dto, userId, fileHash);
assertThat(result.getFileHash()).isEqualTo(fileHash);
}
@Test
void createAnnotation_setsNullFileHash_whenNoneProvided() {
UUID docId = UUID.randomUUID();
UUID userId = UUID.randomUUID();
CreateAnnotationDTO dto = new CreateAnnotationDTO(1, 0.0, 0.0, 0.05, 0.05, "#ff0000");
when(annotationRepository.findByDocumentIdAndPageNumber(docId, 1)).thenReturn(List.of());
when(annotationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
DocumentAnnotation result = annotationService.createAnnotation(docId, dto, userId, null);
assertThat(result.getFileHash()).isNull();
}
// ─── listAnnotations ────────────────────────────────────────────────────── // ─── listAnnotations ──────────────────────────────────────────────────────
@Test @Test
@@ -128,4 +157,30 @@ class AnnotationServiceTest {
assertThat(annotationService.listAnnotations(docId)).containsExactly(a); assertThat(annotationService.listAnnotations(docId)).containsExactly(a);
} }
// ─── backfillAnnotationFileHashForDocument ────────────────────────────────
@Test
void backfillAnnotationFileHashForDocument_setsHashOnAnnotationsWithNullHash() {
UUID docId = UUID.randomUUID();
String hash = "abc123";
DocumentAnnotation a = DocumentAnnotation.builder()
.id(UUID.randomUUID()).documentId(docId).build();
when(annotationRepository.findByDocumentIdAndFileHashIsNull(docId)).thenReturn(List.of(a));
annotationService.backfillAnnotationFileHashForDocument(docId, hash);
assertThat(a.getFileHash()).isEqualTo(hash);
verify(annotationRepository).save(a);
}
@Test
void backfillAnnotationFileHashForDocument_doesNothingWhenNoAnnotations() {
UUID docId = UUID.randomUUID();
when(annotationRepository.findByDocumentIdAndFileHashIsNull(docId)).thenReturn(List.of());
annotationService.backfillAnnotationFileHashForDocument(docId, "hash");
verify(annotationRepository, never()).save(any());
}
} }

View File

@@ -9,6 +9,7 @@ import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.model.AppUser; import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.model.DocumentComment; import org.raddatz.familienarchiv.model.DocumentComment;
import org.raddatz.familienarchiv.model.UserGroup; import org.raddatz.familienarchiv.model.UserGroup;
import org.raddatz.familienarchiv.repository.AppUserRepository;
import org.raddatz.familienarchiv.repository.CommentRepository; import org.raddatz.familienarchiv.repository.CommentRepository;
import java.time.LocalDateTime; import java.time.LocalDateTime;
@@ -20,6 +21,7 @@ import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never; import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
@@ -30,6 +32,8 @@ import static org.springframework.http.HttpStatus.NOT_FOUND;
class CommentServiceTest { class CommentServiceTest {
@Mock CommentRepository commentRepository; @Mock CommentRepository commentRepository;
@Mock AppUserRepository userRepository;
@Mock NotificationService notificationService;
@InjectMocks CommentService commentService; @InjectMocks CommentService commentService;
// ─── postComment ────────────────────────────────────────────────────────── // ─── postComment ──────────────────────────────────────────────────────────
@@ -43,7 +47,7 @@ class CommentServiceTest {
.id(UUID.randomUUID()).documentId(docId).authorName("Hans Müller").content("Test").build(); .id(UUID.randomUUID()).documentId(docId).authorName("Hans Müller").content("Test").build();
when(commentRepository.save(any())).thenReturn(saved); when(commentRepository.save(any())).thenReturn(saved);
DocumentComment result = commentService.postComment(docId, null, "Test", author); DocumentComment result = commentService.postComment(docId, null, "Test", List.of(), author);
assertThat(result.getAuthorName()).isEqualTo("Hans Müller"); assertThat(result.getAuthorName()).isEqualTo("Hans Müller");
} }
@@ -56,7 +60,7 @@ class CommentServiceTest {
.id(UUID.randomUUID()).documentId(docId).authorName("hans42").content("Test").build(); .id(UUID.randomUUID()).documentId(docId).authorName("hans42").content("Test").build();
when(commentRepository.save(any())).thenReturn(saved); when(commentRepository.save(any())).thenReturn(saved);
DocumentComment result = commentService.postComment(docId, null, "Test", author); DocumentComment result = commentService.postComment(docId, null, "Test", List.of(), author);
assertThat(result.getAuthorName()).isEqualTo("hans42"); assertThat(result.getAuthorName()).isEqualTo("hans42");
} }
@@ -70,7 +74,7 @@ class CommentServiceTest {
AppUser author = AppUser.builder().id(UUID.randomUUID()).username("anna").build(); AppUser author = AppUser.builder().id(UUID.randomUUID()).username("anna").build();
when(commentRepository.findById(commentId)).thenReturn(Optional.empty()); when(commentRepository.findById(commentId)).thenReturn(Optional.empty());
assertThatThrownBy(() -> commentService.replyToComment(docId, commentId, "Reply", author)) assertThatThrownBy(() -> commentService.replyToComment(docId, commentId, "Reply", List.of(), author))
.isInstanceOf(DomainException.class) .isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getStatus()).isEqualTo(NOT_FOUND)); .satisfies(e -> assertThat(((DomainException) e).getStatus()).isEqualTo(NOT_FOUND));
@@ -95,7 +99,7 @@ class CommentServiceTest {
.id(UUID.randomUUID()).documentId(docId).parentId(rootId).content("Reply2").authorName("anna").build(); .id(UUID.randomUUID()).documentId(docId).parentId(rootId).content("Reply2").authorName("anna").build();
when(commentRepository.save(any())).thenReturn(saved); when(commentRepository.save(any())).thenReturn(saved);
DocumentComment result = commentService.replyToComment(docId, replyId, "Reply2", author); DocumentComment result = commentService.replyToComment(docId, replyId, "Reply2", List.of(), author);
assertThat(result.getParentId()).isEqualTo(rootId); assertThat(result.getParentId()).isEqualTo(rootId);
} }
@@ -114,11 +118,30 @@ class CommentServiceTest {
.id(UUID.randomUUID()).documentId(docId).parentId(rootId).content("Reply").authorName("anna").build(); .id(UUID.randomUUID()).documentId(docId).parentId(rootId).content("Reply").authorName("anna").build();
when(commentRepository.save(any())).thenReturn(saved); when(commentRepository.save(any())).thenReturn(saved);
DocumentComment result = commentService.replyToComment(docId, rootId, "Reply", author); DocumentComment result = commentService.replyToComment(docId, rootId, "Reply", List.of(), author);
assertThat(result.getParentId()).isEqualTo(rootId); assertThat(result.getParentId()).isEqualTo(rootId);
} }
@Test
void replyToComment_triggersNotification_afterSave() {
UUID docId = UUID.randomUUID();
UUID rootId = UUID.randomUUID();
AppUser author = AppUser.builder().id(UUID.randomUUID()).username("anna").build();
DocumentComment root = DocumentComment.builder()
.id(rootId).documentId(docId).parentId(null).content("Root").authorName("Hans").build();
DocumentComment saved = DocumentComment.builder()
.id(UUID.randomUUID()).documentId(docId).parentId(rootId).content("Reply").authorName("anna").build();
when(commentRepository.findById(rootId)).thenReturn(Optional.of(root));
when(commentRepository.save(any())).thenReturn(saved);
commentService.replyToComment(docId, rootId, "Reply", List.of(), author);
verify(notificationService).notifyReply(eq(saved), eq(root));
}
// ─── editComment ────────────────────────────────────────────────────────── // ─── editComment ──────────────────────────────────────────────────────────
@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;
@@ -21,6 +26,7 @@ import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*; import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
@@ -31,8 +37,32 @@ class DocumentServiceTest {
@Mock FileService fileService; @Mock FileService fileService;
@Mock TagService tagService; @Mock TagService tagService;
@Mock DocumentVersionService documentVersionService; @Mock DocumentVersionService documentVersionService;
@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
@@ -135,6 +165,48 @@ class DocumentServiceTest {
assertThat(documentService.getDocumentsByReceiver(receiverId)).containsExactly(doc); assertThat(documentService.getDocumentsByReceiver(receiverId)).containsExactly(doc);
} }
// ─── file hash propagation ───────────────────────────────────────────────
@Test
void createDocument_setsFileHashFromUpload_whenFileProvided() throws Exception {
DocumentUpdateDTO dto = new DocumentUpdateDTO();
dto.setTitle("Doc");
org.springframework.mock.web.MockMultipartFile file =
new org.springframework.mock.web.MockMultipartFile("file", "scan.pdf", "application/pdf", new byte[]{1});
FileService.UploadResult uploadResult = new FileService.UploadResult("documents/uuid_scan.pdf", "deadbeef");
Document savedDoc = Document.builder().id(UUID.randomUUID()).title("Doc")
.originalFilename("scan.pdf").status(DocumentStatus.PLACEHOLDER).build();
when(documentRepository.save(any())).thenReturn(savedDoc);
when(documentRepository.findById(any())).thenReturn(Optional.of(savedDoc));
when(fileService.uploadFile(any(), any())).thenReturn(uploadResult);
documentService.createDocument(dto, file);
org.mockito.ArgumentCaptor<Document> captor = org.mockito.ArgumentCaptor.forClass(Document.class);
verify(documentRepository, atLeastOnce()).save(captor.capture());
assertThat(captor.getAllValues()).anySatisfy(d -> assertThat(d.getFileHash()).isEqualTo("deadbeef"));
}
@Test
void updateDocument_setsFileHashFromUpload_whenNewFileProvided() throws Exception {
UUID id = UUID.randomUUID();
Document existing = Document.builder()
.id(id).title("Alt").originalFilename("old.pdf")
.status(DocumentStatus.UPLOADED).build();
org.springframework.mock.web.MockMultipartFile newFile =
new org.springframework.mock.web.MockMultipartFile("file", "new.pdf", "application/pdf", new byte[]{2});
FileService.UploadResult uploadResult = new FileService.UploadResult("documents/uuid_new.pdf", "cafebabe");
when(documentRepository.findById(id)).thenReturn(Optional.of(existing));
when(fileService.uploadFile(any(), any())).thenReturn(uploadResult);
when(documentRepository.save(any())).thenReturn(existing);
documentService.updateDocument(id, new DocumentUpdateDTO(), newFile);
assertThat(existing.getFileHash()).isEqualTo("cafebabe");
}
// ─── versioning ─────────────────────────────────────────────────────────── // ─── versioning ───────────────────────────────────────────────────────────
@Test @Test
@@ -167,4 +239,435 @@ 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 ───────────────────────────────────────────────────
@Test
void backfillFileHashes_skipsDocumentsWithNoFilePath() throws Exception {
Document noFile = Document.builder().id(UUID.randomUUID()).build();
when(documentRepository.findByFileHashIsNullAndFilePathIsNotNull()).thenReturn(List.of());
int count = documentService.backfillFileHashes();
assertThat(count).isZero();
verify(fileService, never()).downloadFileBytes(any());
}
@Test
void backfillFileHashes_computesHashAndSavesDocument() throws Exception {
UUID docId = UUID.randomUUID();
Document doc = Document.builder().id(docId).filePath("documents/scan.pdf").build();
when(documentRepository.findByFileHashIsNullAndFilePathIsNotNull()).thenReturn(List.of(doc));
when(fileService.downloadFileBytes("documents/scan.pdf")).thenReturn(new byte[]{1, 2, 3});
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
documentService.backfillFileHashes();
assertThat(doc.getFileHash()).isNotNull().hasSize(64);
verify(documentRepository).save(doc);
}
@Test
void backfillFileHashes_propagatesHashToAnnotations() throws Exception {
UUID docId = UUID.randomUUID();
Document doc = Document.builder().id(docId).filePath("documents/scan.pdf").build();
when(documentRepository.findByFileHashIsNullAndFilePathIsNotNull()).thenReturn(List.of(doc));
when(fileService.downloadFileBytes("documents/scan.pdf")).thenReturn(new byte[]{1, 2, 3});
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
documentService.backfillFileHashes();
verify(annotationService).backfillAnnotationFileHashForDocument(eq(docId), any());
}
// ─── getIncompleteCount ───────────────────────────────────────────────────
@Test
void getIncompleteCount_delegatesToRepository() {
when(documentRepository.countByMetadataCompleteFalse()).thenReturn(5L);
assertThat(documentService.getIncompleteCount()).isEqualTo(5L);
}
// ─── findIncompleteDocuments ──────────────────────────────────────────────
@Test
void findIncompleteDocuments_returnsDocumentsOrderedByCreatedAtDesc() {
Document doc = Document.builder().id(UUID.randomUUID()).title("Test").build();
when(documentRepository.findByMetadataCompleteFalse(any(Sort.class))).thenReturn(List.of(doc));
assertThat(documentService.findIncompleteDocuments()).containsExactly(doc);
verify(documentRepository).findByMetadataCompleteFalse(Sort.by(Sort.Direction.DESC, "createdAt"));
}
// ─── findNextIncompleteDocument ───────────────────────────────────────────
@Test
void findNextIncompleteDocument_returnsNext_whenAnotherIncompleteExists() {
UUID currentId = UUID.randomUUID();
Document next = Document.builder().id(UUID.randomUUID()).title("Next").build();
when(documentRepository.findFirstByMetadataCompleteFalseAndIdNot(eq(currentId), any(Sort.class)))
.thenReturn(Optional.of(next));
assertThat(documentService.findNextIncompleteDocument(currentId)).contains(next);
}
@Test
void findNextIncompleteDocument_returnsEmpty_whenNoMoreIncomplete() {
UUID currentId = UUID.randomUUID();
when(documentRepository.findFirstByMetadataCompleteFalseAndIdNot(eq(currentId), any(Sort.class)))
.thenReturn(Optional.empty());
assertThat(documentService.findNextIncompleteDocument(currentId)).isEmpty();
}
// ─── storeDocument metadataComplete ──────────────────────────────────────
@Test
void storeDocument_setsMetadataCompleteFalse_forNewDocument() throws Exception {
MockMultipartFile file = new MockMultipartFile("file", "scan.pdf", "application/pdf", new byte[]{1});
Document saved = Document.builder().id(UUID.randomUUID()).originalFilename("scan.pdf").build();
when(documentRepository.findFirstByOriginalFilename("scan.pdf")).thenReturn(Optional.empty());
when(documentRepository.save(any())).thenReturn(saved);
when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("path", "hash"));
ArgumentCaptor<Document> captor = ArgumentCaptor.forClass(Document.class);
documentService.storeDocument(file);
verify(documentRepository).save(captor.capture());
assertThat(captor.getValue().isMetadataComplete()).isFalse();
}
@Test
void storeDocument_doesNotChangeMetadataComplete_forExistingDocument() throws Exception {
MockMultipartFile file = new MockMultipartFile("file", "scan.pdf", "application/pdf", new byte[]{1});
Document existing = Document.builder().id(UUID.randomUUID()).originalFilename("scan.pdf")
.status(DocumentStatus.PLACEHOLDER).metadataComplete(true).build();
when(documentRepository.findFirstByOriginalFilename("scan.pdf")).thenReturn(Optional.of(existing));
when(documentRepository.save(any())).thenReturn(existing);
when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("path", "hash"));
documentService.storeDocument(file);
assertThat(existing.isMetadataComplete()).isTrue();
}
@Test
void storeDocument_parsesDateFromFilename_forNewDocument() throws Exception {
MockMultipartFile file = new MockMultipartFile("file", "19650312_Mueller_Hans.pdf", "application/pdf", new byte[]{1});
when(documentRepository.findFirstByOriginalFilename(any())).thenReturn(Optional.empty());
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("path", "hash"));
when(personService.findByName(any(), any())).thenReturn(Optional.empty());
ArgumentCaptor<Document> captor = ArgumentCaptor.forClass(Document.class);
documentService.storeDocument(file);
verify(documentRepository).save(captor.capture());
assertThat(captor.getValue().getDocumentDate()).isEqualTo(java.time.LocalDate.of(1965, 3, 12));
assertThat(captor.getValue().getTitle()).isEqualTo("Hans Mueller (12.03.1965)");
}
@Test
void storeDocument_setsSender_whenPersonExistsForParsedName() throws Exception {
MockMultipartFile file = new MockMultipartFile("file", "18881025_de_Gruyter_Walter.pdf", "application/pdf", new byte[]{1});
Person walter = Person.builder().id(UUID.randomUUID()).firstName("Walter").lastName("de Gruyter").build();
when(documentRepository.findFirstByOriginalFilename(any())).thenReturn(Optional.empty());
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("path", "hash"));
when(personService.findByName("Walter", "de Gruyter")).thenReturn(Optional.of(walter));
ArgumentCaptor<Document> captor = ArgumentCaptor.forClass(Document.class);
documentService.storeDocument(file);
verify(documentRepository).save(captor.capture());
assertThat(captor.getValue().getSender()).isEqualTo(walter);
}
@Test
void storeDocument_leavesSenderNull_whenPersonNotFound() throws Exception {
MockMultipartFile file = new MockMultipartFile("file", "19650312_Mueller_Hans.pdf", "application/pdf", new byte[]{1});
when(documentRepository.findFirstByOriginalFilename(any())).thenReturn(Optional.empty());
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("path", "hash"));
when(personService.findByName(any(), any())).thenReturn(Optional.empty());
ArgumentCaptor<Document> captor = ArgumentCaptor.forClass(Document.class);
documentService.storeDocument(file);
verify(documentRepository).save(captor.capture());
assertThat(captor.getValue().getSender()).isNull();
}
// ─── createDocument title fallback ────────────────────────────────────────
@Test
void createDocument_usesTitleFromFilename_whenDtoTitleIsNull() throws Exception {
DocumentUpdateDTO dto = new DocumentUpdateDTO();
// dto.title is null
MockMultipartFile file = new MockMultipartFile("file", "Brief_1965.pdf", "application/pdf", new byte[]{1});
Document saved = Document.builder().id(UUID.randomUUID()).title("Brief_1965")
.originalFilename("Brief_1965.pdf").status(DocumentStatus.PLACEHOLDER).build();
when(documentRepository.save(any())).thenReturn(saved);
when(documentRepository.findById(any())).thenReturn(Optional.of(saved));
when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("path", "hash"));
ArgumentCaptor<Document> captor = ArgumentCaptor.forClass(Document.class);
documentService.createDocument(dto, file);
verify(documentRepository, atLeastOnce()).save(captor.capture());
assertThat(captor.getAllValues().get(0).getTitle()).isEqualTo("Brief_1965");
}
@Test
void createDocument_usesTitleFromFilename_whenDtoTitleIsBlank() throws Exception {
DocumentUpdateDTO dto = new DocumentUpdateDTO();
dto.setTitle(" ");
MockMultipartFile file = new MockMultipartFile("file", "Rechnung_1980.pdf", "application/pdf", new byte[]{1});
Document saved = Document.builder().id(UUID.randomUUID()).title("Rechnung_1980")
.originalFilename("Rechnung_1980.pdf").status(DocumentStatus.PLACEHOLDER).build();
when(documentRepository.save(any())).thenReturn(saved);
when(documentRepository.findById(any())).thenReturn(Optional.of(saved));
when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("path", "hash"));
ArgumentCaptor<Document> captor = ArgumentCaptor.forClass(Document.class);
documentService.createDocument(dto, file);
verify(documentRepository, atLeastOnce()).save(captor.capture());
assertThat(captor.getAllValues().get(0).getTitle()).isEqualTo("Rechnung_1980");
}
@Test
void createDocument_keepsDtoTitle_whenProvided() throws Exception {
DocumentUpdateDTO dto = new DocumentUpdateDTO();
dto.setTitle("Mein Titel");
MockMultipartFile file = new MockMultipartFile("file", "scan.pdf", "application/pdf", new byte[]{1});
Document saved = Document.builder().id(UUID.randomUUID()).title("Mein Titel")
.originalFilename("scan.pdf").status(DocumentStatus.PLACEHOLDER).build();
when(documentRepository.save(any())).thenReturn(saved);
when(documentRepository.findById(any())).thenReturn(Optional.of(saved));
when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("path", "hash"));
ArgumentCaptor<Document> captor = ArgumentCaptor.forClass(Document.class);
documentService.createDocument(dto, file);
verify(documentRepository, atLeastOnce()).save(captor.capture());
assertThat(captor.getAllValues().get(0).getTitle()).isEqualTo("Mein Titel");
}
// ─── createDocument metadataComplete ─────────────────────────────────────
@Test
void createDocument_setsMetadataCompleteFromDto_whenExplicitlyProvided() throws Exception {
DocumentUpdateDTO dto = new DocumentUpdateDTO();
dto.setTitle("Doc");
dto.setMetadataComplete(true);
Document saved = Document.builder().id(UUID.randomUUID()).title("Doc")
.originalFilename("Doc").status(DocumentStatus.PLACEHOLDER).build();
when(documentRepository.save(any())).thenReturn(saved);
when(documentRepository.findById(any())).thenReturn(Optional.of(saved));
ArgumentCaptor<Document> captor = ArgumentCaptor.forClass(Document.class);
documentService.createDocument(dto, null);
verify(documentRepository, atLeastOnce()).save(captor.capture());
assertThat(captor.getAllValues().get(0).isMetadataComplete()).isTrue();
}
@Test
void createDocument_setsMetadataCompleteFalse_whenAllKeyFieldsMissingAndNoExplicitFlag() throws Exception {
DocumentUpdateDTO dto = new DocumentUpdateDTO();
dto.setTitle("Doc");
// no documentDate, no senderId, no receiverIds, no metadataComplete flag
Document saved = Document.builder().id(UUID.randomUUID()).title("Doc")
.originalFilename("Doc").status(DocumentStatus.PLACEHOLDER).build();
when(documentRepository.save(any())).thenReturn(saved);
when(documentRepository.findById(any())).thenReturn(Optional.of(saved));
ArgumentCaptor<Document> captor = ArgumentCaptor.forClass(Document.class);
documentService.createDocument(dto, null);
verify(documentRepository, atLeastOnce()).save(captor.capture());
assertThat(captor.getAllValues().get(0).isMetadataComplete()).isFalse();
}
@Test
void createDocument_setsMetadataCompleteTrue_whenDatePresentAndNoExplicitFlag() throws Exception {
DocumentUpdateDTO dto = new DocumentUpdateDTO();
dto.setTitle("Doc");
dto.setDocumentDate(LocalDate.of(2020, 1, 1));
Document saved = Document.builder().id(UUID.randomUUID()).title("Doc")
.originalFilename("Doc").status(DocumentStatus.PLACEHOLDER).build();
when(documentRepository.save(any())).thenReturn(saved);
when(documentRepository.findById(any())).thenReturn(Optional.of(saved));
ArgumentCaptor<Document> captor = ArgumentCaptor.forClass(Document.class);
documentService.createDocument(dto, null);
verify(documentRepository, atLeastOnce()).save(captor.capture());
assertThat(captor.getAllValues().get(0).isMetadataComplete()).isTrue();
}
// ─── updateDocument metadataComplete ─────────────────────────────────────
@Test
void updateDocument_setsMetadataComplete_whenDtoHasValue() throws Exception {
UUID id = UUID.randomUUID();
Document existing = Document.builder().id(id).title("Doc").originalFilename("doc.pdf")
.status(DocumentStatus.PLACEHOLDER).metadataComplete(false).build();
when(documentRepository.findById(id)).thenReturn(Optional.of(existing));
when(documentRepository.save(any())).thenReturn(existing);
DocumentUpdateDTO dto = new DocumentUpdateDTO();
dto.setMetadataComplete(true);
documentService.updateDocument(id, dto, null);
assertThat(existing.isMetadataComplete()).isTrue();
}
@Test
void updateDocument_doesNotChangeMetadataComplete_whenDtoHasNull() throws Exception {
UUID id = UUID.randomUUID();
Document existing = Document.builder().id(id).title("Doc").originalFilename("doc.pdf")
.status(DocumentStatus.PLACEHOLDER).metadataComplete(false).build();
when(documentRepository.findById(id)).thenReturn(Optional.of(existing));
when(documentRepository.save(any())).thenReturn(existing);
DocumentUpdateDTO dto = new DocumentUpdateDTO();
// metadataComplete not set → null
documentService.updateDocument(id, dto, null);
assertThat(existing.isMetadataComplete()).isFalse();
}
@Test
void backfillFileHashes_returnsCountOfUpdatedDocuments() throws Exception {
UUID id1 = UUID.randomUUID();
UUID id2 = UUID.randomUUID();
Document doc1 = Document.builder().id(id1).filePath("documents/a.pdf").build();
Document doc2 = Document.builder().id(id2).filePath("documents/b.pdf").build();
when(documentRepository.findByFileHashIsNullAndFilePathIsNotNull()).thenReturn(List.of(doc1, doc2));
when(fileService.downloadFileBytes(any())).thenReturn(new byte[]{1});
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
int count = documentService.backfillFileHashes();
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

@@ -0,0 +1,85 @@
package org.raddatz.familienarchiv.service;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.mock.web.MockMultipartFile;
import software.amazon.awssdk.core.sync.RequestBody;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
import java.io.IOException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
class FileServiceTest {
private S3Client s3Client;
private FileService fileService;
@BeforeEach
void setUp() {
s3Client = mock(S3Client.class);
fileService = new FileService(s3Client, "test-bucket");
}
@Test
void uploadFile_returnsS3Key() throws IOException {
MockMultipartFile file = new MockMultipartFile(
"file", "test.pdf", "application/pdf", new byte[]{1, 2, 3});
FileService.UploadResult result = fileService.uploadFile(file, "test.pdf");
assertThat(result.s3Key()).startsWith("documents/");
assertThat(result.s3Key()).endsWith("_test.pdf");
verify(s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class));
}
@Test
void uploadFile_returnsCorrectSha256FileHash() throws IOException, NoSuchAlgorithmException {
byte[] content = "hello pdf content".getBytes();
MockMultipartFile file = new MockMultipartFile(
"file", "doc.pdf", "application/pdf", content);
FileService.UploadResult result = fileService.uploadFile(file, "doc.pdf");
// Compute expected hash independently
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hashBytes = digest.digest(content);
StringBuilder expected = new StringBuilder();
for (byte b : hashBytes) {
expected.append(String.format("%02x", b));
}
assertThat(result.fileHash()).isEqualTo(expected.toString());
}
@Test
void uploadFile_differentContents_produceDifferentHashes() throws IOException {
MockMultipartFile file1 = new MockMultipartFile(
"f", "a.pdf", "application/pdf", new byte[]{1, 2, 3});
MockMultipartFile file2 = new MockMultipartFile(
"f", "b.pdf", "application/pdf", new byte[]{4, 5, 6});
FileService.UploadResult r1 = fileService.uploadFile(file1, "a.pdf");
FileService.UploadResult r2 = fileService.uploadFile(file2, "b.pdf");
assertThat(r1.fileHash()).isNotEqualTo(r2.fileHash());
}
@Test
void uploadFile_sameContents_produceSameHash() throws IOException {
byte[] content = new byte[]{10, 20, 30};
MockMultipartFile file1 = new MockMultipartFile("f", "x.pdf", "application/pdf", content);
MockMultipartFile file2 = new MockMultipartFile("f", "y.pdf", "application/pdf", content);
FileService.UploadResult r1 = fileService.uploadFile(file1, "x.pdf");
FileService.UploadResult r2 = fileService.uploadFile(file2, "y.pdf");
assertThat(r1.fileHash()).isEqualTo(r2.fileHash());
}
}

View File

@@ -0,0 +1,201 @@
package org.raddatz.familienarchiv.service;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.model.*;
import org.raddatz.familienarchiv.repository.AppUserRepository;
import org.raddatz.familienarchiv.repository.CommentRepository;
import org.raddatz.familienarchiv.repository.NotificationRepository;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class NotificationServiceTest {
@Mock NotificationRepository notificationRepository;
@Mock CommentRepository commentRepository;
@Mock AppUserRepository userRepository;
@Mock JavaMailSender mailSender;
@InjectMocks NotificationService notificationService;
private AppUser userA;
private AppUser userB;
private AppUser userC;
@BeforeEach
void setUp() {
// mailSender is @Autowired(required=false) — not in the @RequiredArgsConstructor
// constructor, so Mockito won't inject it automatically. Inject explicitly.
ReflectionTestUtils.setField(notificationService, "mailSender", mailSender);
userA = AppUser.builder().id(UUID.randomUUID()).username("userA")
.firstName("Anna").lastName("Smith").email("a@test.com")
.notifyOnReply(false).notifyOnMention(false).build();
userB = AppUser.builder().id(UUID.randomUUID()).username("userB")
.firstName("Bob").lastName("Jones").email("b@test.com")
.notifyOnReply(false).notifyOnMention(false).build();
userC = AppUser.builder().id(UUID.randomUUID()).username("userC")
.firstName("Clara").lastName("Doe").email("c@test.com")
.notifyOnReply(false).notifyOnMention(false).build();
}
// ─── notifyReply ──────────────────────────────────────────────────────────
@Test
void notifyReply_createsNotificationForThreadParticipant() {
DocumentComment root = commentWithAuthor(UUID.randomUUID(), null, userA.getId());
DocumentComment existing = commentWithAuthor(UUID.randomUUID(), root.getId(), userB.getId());
DocumentComment reply = commentWithAuthor(UUID.randomUUID(), root.getId(), userC.getId());
when(commentRepository.findByParentId(root.getId())).thenReturn(List.of(existing, reply));
when(userRepository.findById(userA.getId())).thenReturn(Optional.of(userA));
when(userRepository.findById(userB.getId())).thenReturn(Optional.of(userB));
when(notificationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
notificationService.notifyReply(reply, root);
ArgumentCaptor<Notification> captor = ArgumentCaptor.forClass(Notification.class);
verify(notificationRepository, times(2)).save(captor.capture());
List<Notification> saved = captor.getAllValues();
assertThat(saved).extracting(n -> n.getRecipient().getId())
.containsExactlyInAnyOrder(userA.getId(), userB.getId());
assertThat(saved).allMatch(n -> n.getType() == NotificationType.REPLY);
assertThat(saved).allMatch(n -> !n.isRead());
}
@Test
void notifyReply_doesNotNotifyTheReplierThemselves() {
// userA is both a thread participant and the replier
DocumentComment root = commentWithAuthor(UUID.randomUUID(), null, userA.getId());
DocumentComment reply = commentWithAuthor(UUID.randomUUID(), root.getId(), userA.getId());
when(commentRepository.findByParentId(root.getId())).thenReturn(List.of(reply));
notificationService.notifyReply(reply, root);
verify(notificationRepository, never()).save(any());
}
@Test
void notifyReply_deduplicatesParticipants() {
// userB has posted twice in the thread — should get exactly one notification
DocumentComment root = commentWithAuthor(UUID.randomUUID(), null, userA.getId());
DocumentComment first = commentWithAuthor(UUID.randomUUID(), root.getId(), userB.getId());
DocumentComment second = commentWithAuthor(UUID.randomUUID(), root.getId(), userB.getId());
DocumentComment reply = commentWithAuthor(UUID.randomUUID(), root.getId(), userC.getId());
when(commentRepository.findByParentId(root.getId())).thenReturn(List.of(first, second, reply));
when(userRepository.findById(userA.getId())).thenReturn(Optional.of(userA));
when(userRepository.findById(userB.getId())).thenReturn(Optional.of(userB));
when(notificationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
notificationService.notifyReply(reply, root);
// userA (root) + userB (deduplicated) = 2 notifications, not 3
verify(notificationRepository, times(2)).save(any());
}
@Test
void notifyReply_sendsEmailOnlyToUsersWithReplyNotificationsEnabled() {
userA.setNotifyOnReply(true);
userB.setNotifyOnReply(false);
DocumentComment root = commentWithAuthor(UUID.randomUUID(), null, userA.getId());
DocumentComment existing = commentWithAuthor(UUID.randomUUID(), root.getId(), userB.getId());
DocumentComment reply = commentWithAuthor(UUID.randomUUID(), root.getId(), userC.getId());
when(commentRepository.findByParentId(root.getId())).thenReturn(List.of(existing, reply));
when(userRepository.findById(userA.getId())).thenReturn(Optional.of(userA));
when(userRepository.findById(userB.getId())).thenReturn(Optional.of(userB));
when(notificationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
notificationService.notifyReply(reply, root);
// Only userA has email enabled — one email sent
verify(mailSender, times(1)).send(any(SimpleMailMessage.class));
}
// ─── notifyMentions ───────────────────────────────────────────────────────
@Test
void notifyMentions_createsNotificationPerMentionedUser() {
DocumentComment comment = commentWithAuthor(UUID.randomUUID(), null, userC.getId());
when(userRepository.findById(userA.getId())).thenReturn(Optional.of(userA));
when(userRepository.findById(userB.getId())).thenReturn(Optional.of(userB));
when(notificationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
notificationService.notifyMentions(List.of(userA.getId(), userB.getId()), comment);
ArgumentCaptor<Notification> captor = ArgumentCaptor.forClass(Notification.class);
verify(notificationRepository, times(2)).save(captor.capture());
List<Notification> saved = captor.getAllValues();
assertThat(saved).extracting(n -> n.getRecipient().getId())
.containsExactlyInAnyOrder(userA.getId(), userB.getId());
assertThat(saved).allMatch(n -> n.getType() == NotificationType.MENTION);
}
@Test
void notifyMentions_sendsEmailOnlyToUsersWithMentionNotificationsEnabled() {
userA.setNotifyOnMention(true);
userB.setNotifyOnMention(false);
DocumentComment comment = commentWithAuthor(UUID.randomUUID(), null, userC.getId());
when(userRepository.findById(userA.getId())).thenReturn(Optional.of(userA));
when(userRepository.findById(userB.getId())).thenReturn(Optional.of(userB));
when(notificationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
notificationService.notifyMentions(List.of(userA.getId(), userB.getId()), comment);
verify(mailSender, times(1)).send(any(SimpleMailMessage.class));
}
// ─── markRead ─────────────────────────────────────────────────────────────
@Test
void markRead_throwsForbidden_whenNotificationBelongsToDifferentUser() {
Notification notification = Notification.builder()
.id(UUID.randomUUID())
.recipient(userA)
.type(NotificationType.REPLY)
.read(false)
.build();
when(notificationRepository.findById(notification.getId())).thenReturn(Optional.of(notification));
assertThatThrownBy(() -> notificationService.markRead(notification.getId(), userB.getId()))
.isInstanceOf(DomainException.class)
.hasMessageContaining("different user");
}
// ─── private helpers ──────────────────────────────────────────────────────
private DocumentComment commentWithAuthor(UUID id, UUID parentId, UUID authorId) {
return DocumentComment.builder()
.id(id)
.documentId(UUID.randomUUID())
.parentId(parentId)
.authorId(authorId)
.authorName("Author")
.content("content")
.build();
}
}

View File

@@ -216,3 +216,35 @@ test.describe('Admin — tag management', () => {
await page.screenshot({ path: 'test-results/e2e/admin-tag-restored.png' }); await page.screenshot({ path: 'test-results/e2e/admin-tag-restored.png' });
}); });
}); });
// ─── System tab — backfill file hashes ────────────────────────────────────────
test.describe('Admin system tab — backfill file hashes', () => {
test('admin triggers file hash backfill and sees success message', async ({ request, page }) => {
test.setTimeout(60_000);
// Create a document via API so there is at least one without a hash
const createRes = await request.post('/api/documents', {
multipart: { title: 'E2E Backfill Hash Test' }
});
if (!createRes.ok()) throw new Error(`Create failed: ${createRes.status()}`);
await page.goto('/admin');
await page.waitForSelector('[data-hydrated]');
// Navigate to System tab
await page.getByRole('button', { name: /system/i }).click();
// Click the backfill hashes button
const btn = page.getByRole('button', { name: /datei-hashes berechnen/i });
await expect(btn).toBeVisible();
await btn.click();
// Success message must appear (count >= 0)
await expect(page.locator('text=/\\d+ Dokumente wurden aktualisiert/i')).toBeVisible({
timeout: 15000
});
await page.screenshot({ path: 'test-results/e2e/admin-backfill-hashes.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
}); });
@@ -347,13 +401,145 @@ test.describe('PDF annotations — admin', () => {
}); });
}); });
// ─── PDF Annotations — file hash (version awareness) ─────────────────────────
test.describe('PDF annotations — file hash versioning', () => {
const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000';
const PDF_FIXTURE2 = path.resolve(__dirname, 'fixtures/minimal2.pdf');
test('annotations are hidden after a different file is uploaded', async ({ page, request }) => {
test.setTimeout(90_000);
// 1. Create document and upload original PDF
const createRes = await request.post('/api/documents', {
multipart: { title: 'E2E Hash Test — version' }
});
if (!createRes.ok()) throw new Error(`Create failed: ${createRes.status()}`);
const doc = await createRes.json();
const uploadRes = await request.put(`/api/documents/${doc.id}`, {
multipart: {
title: doc.title,
file: {
name: 'minimal.pdf',
mimeType: 'application/pdf',
buffer: fs.readFileSync(PDF_FIXTURE)
}
}
});
if (!uploadRes.ok()) throw new Error(`Upload failed: ${uploadRes.status()}`);
// 2. Create an annotation via API
const annotRes = await request.post(`/api/documents/${doc.id}/annotations`, {
data: { pageNumber: 1, x: 0.1, y: 0.1, width: 0.2, height: 0.2, color: '#ff0000' }
});
if (!annotRes.ok()) throw new Error(`Create annotation failed: ${annotRes.status()}`);
// 3. Verify annotation appears before re-upload
await page.goto(`${baseURL}/documents/${doc.id}`);
await page.waitForSelector('[data-hydrated]');
await page.locator('canvas').first().waitFor({ state: 'visible', timeout: 20000 });
await expect(page.locator('[data-testid^="annotation-"]').first()).toBeVisible({
timeout: 8000
});
// 4. Upload a different file (different hash)
const reuploadRes = await request.put(`/api/documents/${doc.id}`, {
multipart: {
title: doc.title,
file: {
name: 'minimal2.pdf',
mimeType: 'application/pdf',
buffer: fs.readFileSync(PDF_FIXTURE2)
}
}
});
if (!reuploadRes.ok()) throw new Error(`Re-upload failed: ${reuploadRes.status()}`);
// 5. Reload — annotation must be hidden and notice shown
await page.reload();
await page.waitForSelector('[data-hydrated]');
await page.locator('canvas').first().waitFor({ state: 'visible', timeout: 20000 });
// Use :not() to exclude the outdated-notice element whose testid also starts with "annotation-"
await expect(
page.locator('[data-testid^="annotation-"]:not([data-testid="annotation-outdated-notice"])')
).toHaveCount(0, { timeout: 8000 });
await expect(page.locator('[data-testid="annotation-outdated-notice"]')).toBeVisible({
timeout: 5000
});
await page.screenshot({ path: 'test-results/e2e/annotation-hidden-after-reupload.png' });
});
test('annotations reappear after re-uploading the original file', async ({ page, request }) => {
test.setTimeout(90_000);
// 1. Create document and upload original PDF
const createRes = await request.post('/api/documents', {
multipart: { title: 'E2E Hash Test — restore' }
});
if (!createRes.ok()) throw new Error(`Create failed: ${createRes.status()}`);
const doc = await createRes.json();
const originalBytes = fs.readFileSync(PDF_FIXTURE);
const uploadRes = await request.put(`/api/documents/${doc.id}`, {
multipart: {
title: doc.title,
file: { name: 'minimal.pdf', mimeType: 'application/pdf', buffer: originalBytes }
}
});
if (!uploadRes.ok()) throw new Error(`Upload failed: ${uploadRes.status()}`);
// 2. Create annotation
const annotRes = await request.post(`/api/documents/${doc.id}/annotations`, {
data: { pageNumber: 1, x: 0.1, y: 0.1, width: 0.2, height: 0.2, color: '#0000ff' }
});
if (!annotRes.ok()) throw new Error(`Create annotation failed: ${annotRes.status()}`);
// 3. Replace with different file
const replaceRes = await request.put(`/api/documents/${doc.id}`, {
multipart: {
title: doc.title,
file: {
name: 'minimal2.pdf',
mimeType: 'application/pdf',
buffer: fs.readFileSync(PDF_FIXTURE2)
}
}
});
if (!replaceRes.ok()) throw new Error(`Replace failed: ${replaceRes.status()}`);
// 4. Re-upload original file (restoring the hash)
const restoreRes = await request.put(`/api/documents/${doc.id}`, {
multipart: {
title: doc.title,
file: { name: 'minimal.pdf', mimeType: 'application/pdf', buffer: originalBytes }
}
});
if (!restoreRes.ok()) throw new Error(`Restore failed: ${restoreRes.status()}`);
// 5. Verify annotation reappears and notice is gone
await page.goto(`${baseURL}/documents/${doc.id}`);
await page.waitForSelector('[data-hydrated]');
await page.locator('canvas').first().waitFor({ state: 'visible', timeout: 20000 });
await expect(page.locator('[data-testid^="annotation-"]').first()).toBeVisible({
timeout: 8000
});
await expect(page.locator('[data-testid="annotation-outdated-notice"]')).not.toBeVisible();
await page.screenshot({ path: 'test-results/e2e/annotation-restored.png' });
});
});
// ─── PDF Annotations (read-only user) ───────────────────────────────────────── // ─── PDF Annotations (read-only user) ─────────────────────────────────────────
test.describe('PDF annotations — read-only user', () => { test.describe('PDF annotations — read-only user', () => {
// Isolated session — does not share the admin storage state // Isolated session — does not share the admin storage state
test.use({ storageState: { cookies: [], origins: [] } }); test.use({ storageState: { cookies: [], origins: [] } });
test('read-only user sees a disabled Annotieren button', async ({ page }) => { test('read-only user does not see the Annotieren button', async ({ page }) => {
test.setTimeout(60_000); test.setTimeout(60_000);
await page.goto('/login'); await page.goto('/login');
await page.getByLabel('Benutzername').fill('reader'); await page.getByLabel('Benutzername').fill('reader');
@@ -365,12 +551,10 @@ test.describe('PDF annotations — read-only user', () => {
const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000'; const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000';
await page.goto(`${baseURL}/documents/${sharedAnnotationDocId}`); await page.goto(`${baseURL}/documents/${sharedAnnotationDocId}`);
await page.waitForSelector('[data-hydrated]'); await page.waitForSelector('[data-hydrated]');
// Wait for the PDF canvas — once rendered, the controls bar (with disabled button) is shown.
await page.locator('canvas').first().waitFor({ state: 'visible', timeout: 30000 });
const disabledBtn = page.getByRole('button', { name: /annotieren/i }); // Reader users do not have ANNOTATE_ALL permission — the button must not be shown at all.
await expect(disabledBtn).toBeVisible({ timeout: 5000 }); const annotateBtn = page.getByRole('button', { name: /annotieren/i });
await expect(disabledBtn).toBeDisabled(); await expect(annotateBtn).not.toBeVisible({ timeout: 5000 });
await page.screenshot({ path: 'test-results/e2e/annotations-button-reader.png' }); await page.screenshot({ path: 'test-results/e2e/annotations-button-reader.png' });
}); });

View File

@@ -0,0 +1,21 @@
%PDF-1.4
1 0 obj
<</Type/Catalog/Pages 2 0 R>>
endobj
2 0 obj
<</Type/Pages/Kids[3 0 R]/Count 1>>
endobj
3 0 obj
<</Type/Page/MediaBox[0 0 3 3]/Parent 2 0 R>>
endobj
xref
0 4
0000000000 65535 f
0000000009 00000 n
0000000058 00000 n
0000000115 00000 n
trailer
<</Size 4/Root 1 0 R>>
startxref
190
%%EOF

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

@@ -2,10 +2,12 @@
"$schema": "https://inlang.com/schema/inlang-message-format", "$schema": "https://inlang.com/schema/inlang-message-format",
"error_annotation_not_found": "Die Annotation wurde nicht gefunden.", "error_annotation_not_found": "Die Annotation wurde nicht gefunden.",
"error_annotation_overlap": "Die Annotation überschneidet sich mit einer vorhandenen.", "error_annotation_overlap": "Die Annotation überschneidet sich mit einer vorhandenen.",
"annotation_outdated_notice": "Einige Annotationen beziehen sich auf eine frühere Dateiversion und werden nicht angezeigt.",
"error_document_not_found": "Das Dokument wurde nicht gefunden.", "error_document_not_found": "Das Dokument wurde nicht gefunden.",
"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.",
@@ -22,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",
@@ -36,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…",
@@ -72,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",
@@ -241,6 +245,10 @@
"admin_system_backfill_description": "Erstellt einen initialen Verlaufseintrag für alle Dokumente, die noch keinen Verlauf haben (z.B. importierte Dokumente). Dadurch werden beim nächsten Bearbeiten nur die tatsächlich geänderten Felder hervorgehoben.", "admin_system_backfill_description": "Erstellt einen initialen Verlaufseintrag für alle Dokumente, die noch keinen Verlauf haben (z.B. importierte Dokumente). Dadurch werden beim nächsten Bearbeiten nur die tatsächlich geänderten Felder hervorgehoben.",
"admin_system_backfill_btn": "Jetzt auffüllen", "admin_system_backfill_btn": "Jetzt auffüllen",
"admin_system_backfill_success": "{count} Dokumente wurden aufgefüllt.", "admin_system_backfill_success": "{count} Dokumente wurden aufgefüllt.",
"admin_system_backfill_hashes_heading": "Datei-Hashes berechnen",
"admin_system_backfill_hashes_description": "Berechnet den SHA-256-Hash für alle bereits hochgeladenen Dokumente, die noch keinen Hash haben. Dadurch werden Annotationen korrekt mit ihrer Dateiversion verknüpft und wieder angezeigt.",
"admin_system_backfill_hashes_btn": "Datei-Hashes berechnen",
"admin_system_backfill_hashes_success": "{count} Dokumente wurden aktualisiert.",
"comp_expandable_show_more": "Mehr anzeigen", "comp_expandable_show_more": "Mehr anzeigen",
"comp_expandable_show_less": "Weniger anzeigen", "comp_expandable_show_less": "Weniger anzeigen",
"error_comment_not_found": "Der Kommentar wurde nicht gefunden.", "error_comment_not_found": "Der Kommentar wurde nicht gefunden.",
@@ -257,5 +265,45 @@
"doc_panel_tab_history": "Verlauf", "doc_panel_tab_history": "Verlauf",
"doc_panel_annotate": "Annotieren", "doc_panel_annotate": "Annotieren",
"doc_panel_annotate_stop": "Fertig", "doc_panel_annotate_stop": "Fertig",
"doc_panel_annotation_thread_title": "Annotation" "doc_panel_annotation_thread_title": "Annotation",
"doc_panel_discussion_annotation_tab": "Annotation · Seite {page}",
"pdf_annotations_show": "Annotierungen anzeigen",
"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 →",
"notification_bell_label": "Benachrichtigungen",
"notification_bell_unread_label": "{count} ungelesene Benachrichtigungen",
"notification_mark_all_read": "Alle gelesen",
"notification_empty": "Keine neuen Benachrichtigungen",
"notification_type_reply": "{actor} hat auf deinen Kommentar geantwortet",
"notification_type_mention": "{actor} hat dich in einem Kommentar erwähnt",
"notification_prefs_heading": "Benachrichtigungen",
"notification_pref_reply": "E-Mail, wenn jemand auf meinen Kommentar antwortet",
"notification_pref_mention": "E-Mail, wenn jemand mich in einem Kommentar erwähnt",
"mention_btn_label": "Person erwähnen",
"mention_popup_empty": "Keine Nutzer gefunden"
} }

View File

@@ -2,10 +2,12 @@
"$schema": "https://inlang.com/schema/inlang-message-format", "$schema": "https://inlang.com/schema/inlang-message-format",
"error_annotation_not_found": "Annotation not found.", "error_annotation_not_found": "Annotation not found.",
"error_annotation_overlap": "The annotation overlaps an existing one.", "error_annotation_overlap": "The annotation overlaps an existing one.",
"annotation_outdated_notice": "Some annotations refer to an earlier file version and are not shown.",
"error_document_not_found": "Document not found.", "error_document_not_found": "Document not found.",
"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.",
@@ -22,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",
@@ -36,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…",
@@ -72,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",
@@ -241,6 +245,10 @@
"admin_system_backfill_description": "Creates an initial history entry for all documents that do not have one yet (e.g. imported documents). This ensures that future edits only highlight actually changed fields.", "admin_system_backfill_description": "Creates an initial history entry for all documents that do not have one yet (e.g. imported documents). This ensures that future edits only highlight actually changed fields.",
"admin_system_backfill_btn": "Backfill now", "admin_system_backfill_btn": "Backfill now",
"admin_system_backfill_success": "{count} documents were backfilled.", "admin_system_backfill_success": "{count} documents were backfilled.",
"admin_system_backfill_hashes_heading": "Compute file hashes",
"admin_system_backfill_hashes_description": "Computes the SHA-256 hash for all previously uploaded documents that do not have one yet. This ensures annotations are correctly linked to their file version and shown again.",
"admin_system_backfill_hashes_btn": "Compute file hashes",
"admin_system_backfill_hashes_success": "{count} documents were updated.",
"comp_expandable_show_more": "Show more", "comp_expandable_show_more": "Show more",
"comp_expandable_show_less": "Show less", "comp_expandable_show_less": "Show less",
"error_comment_not_found": "The comment could not be found.", "error_comment_not_found": "The comment could not be found.",
@@ -257,5 +265,45 @@
"doc_panel_tab_history": "History", "doc_panel_tab_history": "History",
"doc_panel_annotate": "Annotate", "doc_panel_annotate": "Annotate",
"doc_panel_annotate_stop": "Done", "doc_panel_annotate_stop": "Done",
"doc_panel_annotation_thread_title": "Annotation" "doc_panel_annotation_thread_title": "Annotation",
"doc_panel_discussion_annotation_tab": "Annotation · Page {page}",
"pdf_annotations_show": "Show 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 →",
"notification_bell_label": "Notifications",
"notification_bell_unread_label": "{count} unread notifications",
"notification_mark_all_read": "Mark all read",
"notification_empty": "No new notifications",
"notification_type_reply": "{actor} replied to your comment",
"notification_type_mention": "{actor} mentioned you in a comment",
"notification_prefs_heading": "Notifications",
"notification_pref_reply": "Email when someone replies to my comment",
"notification_pref_mention": "Email when someone mentions me in a comment",
"mention_btn_label": "Mention person",
"mention_popup_empty": "No users found"
} }

View File

@@ -2,10 +2,12 @@
"$schema": "https://inlang.com/schema/inlang-message-format", "$schema": "https://inlang.com/schema/inlang-message-format",
"error_annotation_not_found": "Anotación no encontrada.", "error_annotation_not_found": "Anotación no encontrada.",
"error_annotation_overlap": "La anotación se superpone con una existente.", "error_annotation_overlap": "La anotación se superpone con una existente.",
"annotation_outdated_notice": "Algunas anotaciones hacen referencia a una versión anterior del archivo y no se muestran.",
"error_document_not_found": "Documento no encontrado.", "error_document_not_found": "Documento no encontrado.",
"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.",
@@ -22,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",
@@ -36,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…",
@@ -72,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",
@@ -241,6 +245,10 @@
"admin_system_backfill_description": "Crea una entrada de historial inicial para todos los documentos que aún no tienen ninguna (p.ej. documentos importados). Así, en la próxima edición solo se resaltarán los campos realmente modificados.", "admin_system_backfill_description": "Crea una entrada de historial inicial para todos los documentos que aún no tienen ninguna (p.ej. documentos importados). Así, en la próxima edición solo se resaltarán los campos realmente modificados.",
"admin_system_backfill_btn": "Completar ahora", "admin_system_backfill_btn": "Completar ahora",
"admin_system_backfill_success": "{count} documentos fueron completados.", "admin_system_backfill_success": "{count} documentos fueron completados.",
"admin_system_backfill_hashes_heading": "Calcular hashes de archivo",
"admin_system_backfill_hashes_description": "Calcula el hash SHA-256 para todos los documentos ya subidos que aún no tienen uno. Así las anotaciones se vinculan correctamente a su versión del archivo y vuelven a mostrarse.",
"admin_system_backfill_hashes_btn": "Calcular hashes de archivo",
"admin_system_backfill_hashes_success": "{count} documentos fueron actualizados.",
"comp_expandable_show_more": "Mostrar más", "comp_expandable_show_more": "Mostrar más",
"comp_expandable_show_less": "Mostrar menos", "comp_expandable_show_less": "Mostrar menos",
"error_comment_not_found": "El comentario no pudo encontrarse.", "error_comment_not_found": "El comentario no pudo encontrarse.",
@@ -257,5 +265,45 @@
"doc_panel_tab_history": "Historial", "doc_panel_tab_history": "Historial",
"doc_panel_annotate": "Anotar", "doc_panel_annotate": "Anotar",
"doc_panel_annotate_stop": "Listo", "doc_panel_annotate_stop": "Listo",
"doc_panel_annotation_thread_title": "Anotación" "doc_panel_annotation_thread_title": "Anotación",
"doc_panel_discussion_annotation_tab": "Anotación · Página {page}",
"pdf_annotations_show": "Mostrar 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 →",
"notification_bell_label": "Notificaciones",
"notification_bell_unread_label": "{count} notificaciones sin leer",
"notification_mark_all_read": "Marcar todo como leído",
"notification_empty": "No hay notificaciones nuevas",
"notification_type_reply": "{actor} respondió a tu comentario",
"notification_type_mention": "{actor} te mencionó en un comentario",
"notification_prefs_heading": "Notificaciones",
"notification_pref_reply": "Correo cuando alguien responde a mi comentario",
"notification_pref_mention": "Correo cuando alguien me menciona en un comentario",
"mention_btn_label": "Mencionar persona",
"mention_popup_empty": "No se encontraron usuarios"
} }

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;
@@ -103,7 +93,7 @@ function handlePointerUp(event: PointerEvent) {
let hoveredId = $state<string | null>(null); let hoveredId = $state<string | null>(null);
const containerStyle = $derived( const containerStyle = $derived(
`position: absolute; top: 0; left: 0; width: 100%; height: 100%;${canAnnotate ? ' cursor: crosshair;' : ''}` `position: absolute; top: 0; left: 0; width: 100%; height: 100%;${canAnnotate ? ' cursor: crosshair; touch-action: none;' : ''}`
); );
</script> </script>
@@ -123,8 +113,8 @@ const containerStyle = $derived(
aria-label="Kommentare anzeigen" aria-label="Kommentare anzeigen"
onclick={() => onAnnotationClick?.(annotation.id)} onclick={() => onAnnotationClick?.(annotation.id)}
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') onAnnotationClick?.(annotation.id); }} onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') onAnnotationClick?.(annotation.id); }}
onmouseenter={() => (hoveredId = annotation.id)} onpointerenter={() => (hoveredId = annotation.id)}
onmouseleave={() => (hoveredId = null)} onpointerleave={() => (hoveredId = null)}
style=" style="
position: absolute; position: absolute;
left: {annotation.x * 100}%; left: {annotation.x * 100}%;

View File

@@ -0,0 +1,65 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import CommentThread from './CommentThread.svelte';
type Props = {
documentId: string;
activeAnnotationId: string | null;
activeAnnotationPage: number | null;
canComment: boolean;
currentUserId: string | null;
canAdmin: boolean;
onClose: () => void;
};
let {
documentId,
activeAnnotationId,
activeAnnotationPage,
canComment,
currentUserId,
canAdmin,
onClose
}: Props = $props();
const visible = $derived(activeAnnotationId !== null);
</script>
<div
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'
: 'pointer-events-none translate-x-full'}"
data-testid="annotation-side-panel"
>
<!-- Header -->
<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-ink">
{m.doc_panel_discussion_annotation_tab({ page: String(activeAnnotationPage ?? '?') })}
</span>
<button
onclick={onClose}
aria-label={m.comment_panel_close()}
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">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Comment thread -->
<div class="flex-1 overflow-y-auto p-4">
{#if activeAnnotationId}
{#key activeAnnotationId}
<CommentThread
documentId={documentId}
annotationId={activeAnnotationId}
canComment={canComment}
currentUserId={currentUserId}
canAdmin={canAdmin}
loadOnMount={true}
/>
{/key}
{/if}
</div>
</div>

View File

@@ -1,25 +1,10 @@
<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 = { import MentionEditor from '$lib/components/MentionEditor.svelte';
id: string; import { renderBody, extractContent } from '$lib/utils/mention';
authorId: string | null; import type { MentionDTO } from '$lib/types';
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;
@@ -29,6 +14,7 @@ type Props = {
canComment: boolean; canComment: boolean;
currentUserId: string | null; currentUserId: string | null;
canAdmin: boolean; canAdmin: boolean;
targetCommentId?: string | null;
onCountChange?: (count: number) => void; onCountChange?: (count: number) => void;
}; };
@@ -40,16 +26,21 @@ let {
canComment, canComment,
currentUserId, currentUserId,
canAdmin, canAdmin,
targetCommentId = null,
onCountChange onCountChange
}: Props = $props(); }: Props = $props();
let comments: Comment[] = $state(untrack(() => [...initialComments])); let comments: Comment[] = $state(untrack(() => [...initialComments]));
let highlightedCommentId: string | null = $state(untrack(() => targetCommentId ?? null));
let newText: string = $state(''); let newText: string = $state('');
let replyingTo: string | null = $state(null); let replyingTo: string | null = $state(null);
let replyText: string = $state(''); let replyText: string = $state('');
let editingId: string | null = $state(null); let editingId: string | null = $state(null);
let editText: string = $state(''); let editText: string = $state('');
let posting: boolean = $state(false); let posting: boolean = $state(false);
let newMentionCandidates: MentionDTO[] = $state([]);
let replyMentionCandidates: MentionDTO[] = $state([]);
let editMentionCandidates: MentionDTO[] = $state([]);
const commentsBase = $derived( const commentsBase = $derived(
annotationId annotationId
@@ -94,13 +85,15 @@ async function postComment() {
if (!text || posting) return; if (!text || posting) return;
posting = true; posting = true;
try { try {
const { content, mentionedUserIds } = extractContent(text, newMentionCandidates);
const res = await fetch(commentsBase, { const res = await fetch(commentsBase, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: text }) body: JSON.stringify({ content, mentionedUserIds })
}); });
if (res.ok) { if (res.ok) {
newText = ''; newText = '';
newMentionCandidates = [];
await reload(); await reload();
} }
} finally { } finally {
@@ -113,13 +106,15 @@ async function postReply(threadId: string) {
if (!text || posting) return; if (!text || posting) return;
posting = true; posting = true;
try { try {
const { content, mentionedUserIds } = extractContent(text, replyMentionCandidates);
const res = await fetch(`${commentsBase}/${threadId}/replies`, { const res = await fetch(`${commentsBase}/${threadId}/replies`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: text }) body: JSON.stringify({ content, mentionedUserIds })
}); });
if (res.ok) { if (res.ok) {
replyText = ''; replyText = '';
replyMentionCandidates = [];
replyingTo = null; replyingTo = null;
await reload(); await reload();
} }
@@ -133,13 +128,15 @@ async function saveEdit(commentId: string) {
if (!text || posting) return; if (!text || posting) return;
posting = true; posting = true;
try { try {
const { content, mentionedUserIds } = extractContent(text, editMentionCandidates);
const res = await fetch(`/api/documents/${documentId}/comments/${commentId}`, { const res = await fetch(`/api/documents/${documentId}/comments/${commentId}`, {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: text }) body: JSON.stringify({ content, mentionedUserIds })
}); });
if (res.ok) { if (res.ok) {
editingId = null; editingId = null;
editMentionCandidates = [];
await reload(); await reload();
} }
} finally { } finally {
@@ -165,6 +162,7 @@ async function deleteComment(commentId: string) {
function startEdit(comment: Comment | CommentReply) { function startEdit(comment: Comment | CommentReply) {
editingId = comment.id; editingId = comment.id;
editText = comment.content; editText = comment.content;
editMentionCandidates = [];
} }
function cancelEdit() { function cancelEdit() {
@@ -185,180 +183,174 @@ 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);
}
if (targetCommentId) {
// Scroll to target after a tick so the DOM is settled
setTimeout(() => {
const el = document.querySelector(`[data-comment-id="${targetCommentId}"]`);
el?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}, 100);
// Remove highlight on first user interaction
const clearHighlight = () => {
highlightedCommentId = null;
document.removeEventListener('click', clearHighlight, true);
document.removeEventListener('keydown', clearHighlight, true);
document.removeEventListener('scroll', clearHighlight, true);
};
document.addEventListener('click', clearHighlight, true);
document.addEventListener('keydown', clearHighlight, true);
document.addEventListener('scroll', clearHighlight, true);
} }
}); });
</script> </script>
<div class="space-y-4"> <!--
{#each comments as thread, ti (thread.id)} Renders a single comment or reply entry.
<div class={ti > 0 ? 'border-t border-brand-sand pt-4' : ''}> showReplyButton: whether the "Reply" button appears (only on last item in a thread).
<!-- Root comment --> -->
<div> {#snippet commentEntry(comment: Comment | CommentReply, threadId: string, showReplyButton: boolean)}
{#if editingId === thread.id} {#if editingId === comment.id}
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<textarea <MentionEditor
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" bind:value={editText}
rows={3} bind:mentionCandidates={editMentionCandidates}
bind:value={editText} rows={3}
></textarea> disabled={posting}
<div class="flex items-center gap-3"> onsubmit={() => saveEdit(comment.id)}
<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" <div class="flex items-center gap-3">
disabled={posting} <button
onclick={() => saveEdit(thread.id)} class="rounded bg-primary px-3 py-1.5 font-sans text-xs font-medium text-primary-fg hover:bg-primary/80 disabled:opacity-40"
> disabled={posting}
{m.btn_save()} onclick={() => saveEdit(comment.id)}
</button> >
<button {m.btn_save()}
class="font-sans text-xs text-gray-400 transition-colors hover:text-brand-navy" </button>
onclick={cancelEdit} <button
> class="font-sans text-xs text-ink-3 transition-colors hover:text-ink"
{m.btn_cancel()} onclick={cancelEdit}
</button> >
</div> {m.btn_cancel()}
</div> </button>
{:else} </div>
<div class="flex items-start justify-between gap-2"> </div>
<div class="min-w-0 flex-1"> {:else}
<div class="flex flex-wrap items-center gap-2"> <div class="flex items-start justify-between gap-2">
<span class="font-sans text-xs font-semibold text-brand-navy" <div class="min-w-0 flex-1">
>{thread.authorName}</span <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-gray-400">{timeAgo(thread.createdAt)}</span> <span class="font-sans text-xs text-ink-3">{timeAgo(comment.createdAt)}</span>
{#if wasEdited(thread)} {#if wasEdited(comment)}
<span class="font-sans text-xs text-gray-400"> <span class="font-sans text-xs text-ink-3">
{m.comment_edited_label()} {m.comment_edited_label()}
{timeAgo(thread.updatedAt)} {timeAgo(comment.updatedAt)}
</span> </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}
{/if} </div>
<p class="mt-1 font-serif text-sm leading-relaxed text-ink-2">
<!-- eslint-disable-next-line svelte/no-at-html-tags -- renderBody escapes all HTML before injecting mention links -->
{@html renderBody(comment.content, comment.mentionDTOs ?? [])}
</p>
</div>
{#if canModify(comment)}
<div class="flex shrink-0 items-center gap-2">
<button
class="font-sans text-xs text-ink-3 transition-colors hover:text-ink"
onclick={() => startEdit(comment)}
>
{m.btn_edit()}
</button>
<button
class="font-sans text-xs text-ink-3 transition-colors hover:text-ink"
onclick={() => deleteComment(comment.id)}
>
{m.btn_delete()}
</button>
</div>
{/if}
</div>
{#if showReplyButton && canComment}
<div class="mt-1">
<button
class="font-sans text-xs font-medium text-accent transition-colors hover:text-ink"
onclick={() => startReply(threadId)}
>
{m.comment_btn_reply()}
</button>
</div>
{/if}
{/if}
{/snippet}
<div class="space-y-4">
{#if comments.length === 0}
<div class="flex flex-col items-center gap-3 py-8 text-center">
<svg
class="h-10 w-10 text-ink-3"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M2.25 12.76c0 1.6 1.123 2.994 2.707 3.227 1.087.16 2.185.283 3.293.369V21l4.076-4.076a1.526 1.526 0 0 1 1.037-.443 48.282 48.282 0 0 0 5.68-.494c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z"
/>
</svg>
<p class="font-sans text-sm text-ink-3">{m.comment_empty_hint()}</p>
</div>
{/if}
{#each comments as thread, ti (thread.id)}
<div class={ti > 0 ? 'border-t border-line pt-4' : ''}>
<!-- Root comment -->
<div
data-comment-id={thread.id}
class={highlightedCommentId === thread.id
? 'rounded ring-2 ring-accent ring-offset-1 transition-shadow'
: ''}
>
{@render commentEntry(thread, thread.id, thread.replies.length === 0)}
</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
{#if editingId === reply.id} data-comment-id={reply.id}
<div class="flex flex-col gap-2"> class="mt-3 ml-6 border-l-2 border-line pl-4 {highlightedCommentId === reply.id
<textarea ? 'rounded ring-2 ring-accent ring-offset-1 transition-shadow'
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} {@render commentEntry(reply, thread.id, ri === thread.replies.length - 1)}
></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 <MentionEditor
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" bind:value={replyText}
bind:mentionCandidates={replyMentionCandidates}
rows={3} rows={3}
placeholder={m.comment_placeholder()} placeholder={m.comment_placeholder()}
bind:value={replyText} disabled={posting}
></textarea> onsubmit={() => postReply(thread.id)}
/>
<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-primary-fg 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 +361,21 @@ 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 <MentionEditor
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" bind:value={newText}
bind:mentionCandidates={newMentionCandidates}
rows={3} rows={3}
placeholder={m.comment_placeholder()} placeholder={m.comment_placeholder()}
bind:value={newText} disabled={posting}
></textarea> onsubmit={postComment}
/>
<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-primary-fg 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,9 +27,8 @@ type Props = {
canAdmin: boolean; canAdmin: boolean;
open: boolean; open: boolean;
height: number; height: number;
activeTab: Tab; activeTab: DocumentPanelTab;
activeAnnotationId: string | null; targetCommentId?: string | null;
onAnnotationCommentCountChange?: (annotationId: string, count: number) => void;
}; };
let { let {
@@ -61,22 +40,25 @@ let {
open = $bindable(), open = $bindable(),
height = $bindable(), height = $bindable(),
activeTab = $bindable(), activeTab = $bindable(),
activeAnnotationId, targetCommentId = null
onAnnotationCommentCountChange
}: Props = $props(); }: Props = $props();
const MIN_HEIGHT = 52; // drag handle (8px) + tab bar (~44px) const MIN_HEIGHT = 52; // drag handle (8px) + tab bar (~44px)
const DEFAULT_HEIGHT = 320;
let isDragging = $state(false); let isDragging = $state(false);
let dragStartY = 0; let dragStartY = 0;
let dragStartHeight = 0; let dragStartHeight = 0;
function openTab(tab: Tab) { function fullHeight() {
const topbar = document.querySelector('[data-topbar]');
return window.innerHeight - (topbar?.getBoundingClientRect().bottom ?? 0);
}
function openTab(tab: DocumentPanelTab) {
activeTab = tab; activeTab = tab;
if (!open) { if (!open) {
open = true; open = true;
if (height <= MIN_HEIGHT) height = DEFAULT_HEIGHT; if (height <= MIN_HEIGHT) height = fullHeight();
} }
} }
@@ -95,14 +77,14 @@ function onDragMove(e: PointerEvent) {
if (!isDragging) return; if (!isDragging) return;
const delta = dragStartY - e.clientY; // positive = dragging up = bigger panel const delta = dragStartY - e.clientY; // positive = dragging up = bigger panel
const newHeight = dragStartHeight + delta; const newHeight = dragStartHeight + delta;
const maxHeight = Math.floor(window.innerHeight * 0.8); const maxHeight = fullHeight();
if (newHeight <= MIN_HEIGHT + 20) { if (newHeight <= MIN_HEIGHT + 20) {
// collapsed past threshold → close // collapsed past threshold → close
open = false; open = false;
} else { } else {
open = true; open = true;
height = Math.max(DEFAULT_HEIGHT / 4, Math.min(newHeight, maxHeight)); height = Math.max(80, Math.min(newHeight, maxHeight));
} }
} }
@@ -110,7 +92,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 +100,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="z-30 flex shrink-0 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,32 +125,41 @@ 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">
{#each tabs as tab (tab.id)} <!-- Scrollable tabs area — hides scrollbar visually -->
<button <div
onclick={() => openTab(tab.id)} class="flex flex-1 items-center overflow-x-auto px-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
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' {#each tabs as tab (tab.id)}
: 'text-gray-400 hover:text-brand-navy'}" <button
aria-pressed={activeTab === tab.id && open} onclick={() => openTab(tab.id)}
> class="mr-1 shrink-0 px-3 py-2.5 font-sans text-xs font-medium transition-colors {activeTab === tab.id && open
{tab.label()} ? 'border-b-2 border-primary text-ink'
</button> : 'text-ink-3 hover:text-ink'}"
{/each} aria-pressed={activeTab === tab.id && open}
>
<!-- spacer --> {tab.label()}
<div class="flex-1"></div> {#if tab.id === 'discussion'}
<span
data-testid="discussion-count-badge"
class="ml-1.5 inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 font-sans text-[10px] font-bold text-primary-fg"
>{discussionCount}</span
>
{/if}
</button>
{/each}
</div>
{#if open} {#if open}
<button <button
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="mr-2 shrink-0 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" />
@@ -181,12 +178,12 @@ const panelHeight = $derived(open ? height : MIN_HEIGHT);
{:else if activeTab === 'discussion'} {:else if activeTab === 'discussion'}
<PanelDiscussion <PanelDiscussion
documentId={doc.id} documentId={doc.id}
activeAnnotationId={activeAnnotationId}
initialComments={comments} initialComments={comments}
canComment={canComment} canComment={canComment}
currentUserId={currentUserId} currentUserId={currentUserId}
canAdmin={canAdmin} canAdmin={canAdmin}
onAnnotationCommentCountChange={onAnnotationCommentCountChange} targetCommentId={targetCommentId}
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,16 +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-3 py-3 shadow-sm sm:px-6"
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"
@@ -79,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}
@@ -100,18 +101,27 @@ const compactMeta = $derived.by(() => {
<button <button
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="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-primary-fg'
: 'border border-brand-navy text-brand-navy hover:bg-brand-navy hover:text-white'}" : 'border border-primary text-ink hover:bg-primary hover:text-primary-fg'}"
> >
{annotateMode ? m.doc_panel_annotate_stop() : m.doc_panel_annotate()} <img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Note/Note-Add-MD.svg"
alt=""
aria-hidden="true"
class="h-4 w-4 {annotateMode ? 'invert' : ''}"
/>
<span class="hidden sm:inline"
>{annotateMode ? m.doc_panel_annotate_stop() : m.doc_panel_annotate()}</span
>
</button> </button>
{/if} {/if}
{#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" aria-label={m.btn_edit()}
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-primary-fg"
> >
<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"
@@ -119,7 +129,7 @@ const compactMeta = $derived.by(() => {
aria-hidden="true" aria-hidden="true"
class="h-4 w-4" class="h-4 w-4"
/> />
{m.btn_edit()} <span class="hidden sm:inline">{m.btn_edit()}</span>
</a> </a>
{/if} {/if}
@@ -127,7 +137,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

@@ -6,6 +6,7 @@ type Doc = {
id: string; id: string;
filePath?: string | null; filePath?: string | null;
contentType?: string | null; contentType?: string | null;
fileHash?: string | null;
}; };
type Props = { type Props = {
@@ -15,6 +16,7 @@ type Props = {
error: string; error: string;
annotateMode: boolean; annotateMode: boolean;
activeAnnotationId: string | null; activeAnnotationId: string | null;
activeAnnotationPage: number | null;
onAnnotationClick: (id: string) => void; onAnnotationClick: (id: string) => void;
}; };
@@ -25,13 +27,14 @@ let {
error, error,
annotateMode = $bindable(), annotateMode = $bindable(),
activeAnnotationId = $bindable(), activeAnnotationId = $bindable(),
activeAnnotationPage = $bindable(),
onAnnotationClick onAnnotationClick
}: 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"
@@ -49,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
@@ -62,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=""
@@ -79,7 +82,9 @@ let {
documentId={doc.id} documentId={doc.id}
bind:annotateMode={annotateMode} bind:annotateMode={annotateMode}
bind:activeAnnotationId={activeAnnotationId} bind:activeAnnotationId={activeAnnotationId}
bind:activeAnnotationPage={activeAnnotationPage}
onAnnotationClick={onAnnotationClick} onAnnotationClick={onAnnotationClick}
documentFileHash={doc.fileHash ?? null}
/> />
{:else if fileUrl} {:else if fileUrl}
<div class="flex h-full w-full items-center justify-center overflow-auto p-8"> <div class="flex h-full w-full items-center justify-center overflow-auto p-8">

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

@@ -0,0 +1,18 @@
<script lang="ts">
import { setLocale, getLocale } from '$lib/paraglide/runtime';
const locales = ['DE', 'EN', 'ES'] as const;
const localeMap = { DE: 'de', EN: 'en', ES: 'es' } as const;
const activeLocale = $derived(getLocale().toUpperCase());
</script>
{#each locales as locale (locale)}
<button
type="button"
onclick={() => setLocale(localeMap[locale])}
class="font-sans tracking-widest transition-colors
{activeLocale === locale ? 'font-bold text-ink' : 'font-normal text-ink-3 hover:text-ink'}"
>
{locale}
</button>
{/each}

View File

@@ -0,0 +1,235 @@
<script lang="ts">
import { detectMention } from '$lib/utils/mention';
import type { MentionDTO } from '$lib/types';
import { m } from '$lib/paraglide/messages.js';
type Props = {
value: string;
mentionCandidates: MentionDTO[];
placeholder?: string;
rows?: number;
disabled?: boolean;
onsubmit?: () => void;
};
let {
value = $bindable(''),
mentionCandidates = $bindable([]),
placeholder = '',
rows = 3,
disabled = false,
onsubmit
}: Props = $props();
let query: string | null = $state(null);
let results: MentionDTO[] = $state([]);
let highlightedIndex = $state(0);
let mentionStart = $state(0);
let textarea: HTMLTextAreaElement | null = null;
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
function attachTextarea(node: HTMLTextAreaElement) {
textarea = node;
return () => {
textarea = null;
};
}
function handleInput() {
if (!textarea) return;
const cursorPos = textarea.selectionStart;
const detected = detectMention(value, cursorPos);
if (detected === null) {
closePopup();
return;
}
// Calculate where the @ starts
const before = value.slice(0, cursorPos);
const atIndex = before.lastIndexOf('@');
mentionStart = atIndex;
if (query !== detected) {
query = detected;
highlightedIndex = 0;
scheduleSearch(detected);
}
}
function scheduleSearch(q: string) {
clearTimeout(debounceTimer);
if (!q) {
results = [];
return;
}
debounceTimer = setTimeout(async () => {
try {
const res = await fetch(`/api/users/search?q=${encodeURIComponent(q)}`);
if (res.ok) {
const data: MentionDTO[] = await res.json();
results = data.slice(0, 5);
} else {
results = [];
}
} catch {
results = [];
}
}, 200);
}
function selectUser(user: MentionDTO) {
if (!textarea) return;
const displayName = `${user.firstName} ${user.lastName}`;
// Replace @partialQuery with @FirstName LastName (plus trailing space)
const replacement = `@${displayName} `;
const cursorPos = textarea.selectionStart;
const before = value.slice(0, mentionStart);
const after = value.slice(cursorPos);
value = before + replacement + after;
// Deduplicate and add to candidates
if (!mentionCandidates.some((c) => c.id === user.id)) {
mentionCandidates = [...mentionCandidates, user];
}
closePopup();
// Reposition cursor after the inserted mention
setTimeout(() => {
if (!textarea) return;
const pos = mentionStart + replacement.length;
textarea.selectionStart = pos;
textarea.selectionEnd = pos;
textarea.focus();
}, 0);
}
function closePopup() {
query = null;
results = [];
highlightedIndex = 0;
clearTimeout(debounceTimer);
}
function handleKeydown(e: KeyboardEvent) {
if (e.ctrlKey && e.key === 'Enter') {
e.preventDefault();
onsubmit?.();
return;
}
if (query === null) return;
if (e.key === 'Escape') {
e.preventDefault();
closePopup();
return;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
if (results.length > 0) {
highlightedIndex = (highlightedIndex + 1) % results.length;
}
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
if (results.length > 0) {
highlightedIndex = (highlightedIndex - 1 + results.length) % results.length;
}
return;
}
if (e.key === 'Enter' && results.length > 0) {
e.preventDefault();
selectUser(results[highlightedIndex]);
return;
}
}
function handleAtButtonClick() {
if (!textarea) return;
const pos = textarea.selectionStart;
const before = value.slice(0, pos);
const after = value.slice(pos);
// Ensure @ is preceded by whitespace or is at the start
const needsSpace = before.length > 0 && !/\s$/.test(before);
const insertion = needsSpace ? ' @' : '@';
value = before + insertion + after;
setTimeout(() => {
if (!textarea) return;
const newPos = pos + insertion.length;
textarea.selectionStart = newPos;
textarea.selectionEnd = newPos;
textarea.focus();
// Trigger mention detection after inserting @
const detected = detectMention(value, newPos);
if (detected !== null) {
mentionStart = newPos - 1;
query = detected;
highlightedIndex = 0;
scheduleSearch(detected);
}
}, 0);
}
const popupOpen = $derived(query !== null);
</script>
<div class="relative">
<textarea
{@attach attachTextarea}
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={rows}
placeholder={placeholder}
disabled={disabled}
bind:value={value}
oninput={handleInput}
onkeydown={handleKeydown}
></textarea>
{#if popupOpen}
<div
class="absolute z-20 mt-1 w-64 overflow-hidden rounded-sm border border-line bg-surface shadow-lg"
role="listbox"
aria-label={m.mention_btn_label()}
>
{#if results.length === 0}
<p class="px-3 py-2 font-sans text-sm text-ink-3">{m.mention_popup_empty()}</p>
{:else}
{#each results as user, i (user.id)}
<button
class="w-full px-3 py-2 text-left font-sans text-sm text-ink hover:bg-canvas {i === highlightedIndex ? 'bg-canvas' : ''}"
role="option"
aria-selected={i === highlightedIndex}
onmousedown={(e) => {
// Use mousedown to fire before textarea blur
e.preventDefault();
selectUser(user);
}}
>
{user.firstName}
{user.lastName}
</button>
{/each}
{/if}
</div>
{/if}
<button
type="button"
aria-label={m.mention_btn_label()}
disabled={disabled}
class="mt-1 rounded border border-line px-2 py-0.5 font-sans text-xs font-medium text-ink-3 transition-colors hover:border-ink hover:text-ink disabled:opacity-40"
onclick={handleAtButtonClick}
>
@
</button>
</div>

View File

@@ -0,0 +1,304 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { goto } from '$app/navigation';
import { PUBLIC_NOTIFICATION_POLL_MS } from '$env/static/public';
import { m } from '$lib/paraglide/messages.js';
type NotificationItem = {
id: string;
type: 'REPLY' | 'MENTION';
documentId: string;
referenceId: string;
read: boolean;
createdAt: string;
actorName: string;
};
let notifications: NotificationItem[] = $state([]);
let unreadCount = $derived(notifications.filter((n) => !n.read).length);
let open = $state(false);
// DOM refs managed via attachments
let bellButtonEl: HTMLButtonElement | null = null;
let firstFocusableEl: HTMLButtonElement | null = null;
const pollMs = Number(PUBLIC_NOTIFICATION_POLL_MS) || 60000;
let intervalId: ReturnType<typeof setInterval>;
async function fetchNotifications() {
try {
const res = await fetch('/api/notifications?size=10');
if (res.ok) {
const data = await res.json();
notifications = data.content ?? [];
}
} catch (e) {
console.error('Failed to fetch notifications', e);
}
}
async function toggleDropdown() {
open = !open;
if (open) {
await fetchNotifications();
// defer focus until DOM updates
setTimeout(() => {
firstFocusableEl?.focus();
}, 0);
}
}
function closeDropdown() {
open = false;
bellButtonEl?.focus();
}
async function markRead(notification: NotificationItem) {
if (!notification.read) {
try {
await fetch(`/api/notifications/${notification.id}/read`, { method: 'PATCH' });
notification.read = true;
} catch (e) {
console.error('Failed to mark notification as read', e);
}
}
const url = `/documents/${notification.documentId}?commentId=${notification.referenceId}`;
closeDropdown();
goto(url);
}
async function markAllRead() {
try {
await fetch('/api/notifications/read-all', { method: 'POST' });
for (const n of notifications) {
n.read = true;
}
} catch (e) {
console.error('Failed to mark all notifications as read', e);
}
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape' && open) {
event.stopPropagation();
closeDropdown();
}
}
// Attachment: stores the element reference for the bell button
function attachBellButton(node: HTMLButtonElement) {
bellButtonEl = node;
return () => {
bellButtonEl = null;
};
}
// Attachment: stores the element reference for the first focusable element in the dropdown
function attachFirstFocusable(node: HTMLButtonElement) {
firstFocusableEl = node;
return () => {
firstFocusableEl = null;
};
}
// Attachment: closes dropdown when clicking outside the wrapper element
function attachClickOutside(node: HTMLElement) {
const handleClick = (event: MouseEvent) => {
if (!node.contains(event.target as Node) && !event.defaultPrevented) {
if (open) {
open = false;
}
}
};
document.addEventListener('click', handleClick, true);
return () => {
document.removeEventListener('click', handleClick, true);
};
}
function relativeTime(isoString: string): string {
const now = Date.now();
const then = new Date(isoString).getTime();
const diffMs = now - then;
const diffMin = Math.floor(diffMs / 60000);
if (diffMin < 1) return 'gerade eben';
if (diffMin < 60) return `vor ${diffMin} Min.`;
const diffH = Math.floor(diffMin / 60);
if (diffH < 24) return `vor ${diffH} Std.`;
const diffD = Math.floor(diffH / 24);
return `vor ${diffD} Tag${diffD !== 1 ? 'en' : ''}`;
}
onMount(() => {
fetchNotifications();
intervalId = setInterval(fetchNotifications, pollMs);
});
onDestroy(() => {
clearInterval(intervalId);
});
</script>
<svelte:window onkeydown={handleKeydown} />
<div class="relative" {@attach attachClickOutside}>
<!-- Bell button -->
<button
{@attach attachBellButton}
type="button"
onclick={toggleDropdown}
aria-label={unreadCount > 0
? m.notification_bell_unread_label({ count: unreadCount })
: m.notification_bell_label()}
aria-expanded={open}
aria-haspopup="true"
class="relative rounded-sm p-2 text-ink-2 transition-colors hover:text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
>
<!-- Bell SVG -->
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
/>
</svg>
<!-- Unread badge -->
{#if unreadCount > 0}
<span
aria-live="polite"
aria-atomic="true"
class="absolute -top-1 -right-1 flex h-5 min-w-5 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-bold text-primary-fg"
>
{unreadCount}
</span>
{/if}
</button>
<!-- Dropdown -->
{#if open}
<div
role="dialog"
aria-label={m.notification_bell_label()}
class="absolute right-0 z-50 mt-2 w-80 overflow-hidden rounded-sm border border-line bg-surface shadow-lg"
>
<!-- Header -->
<div class="flex items-center justify-between border-b border-line px-4 py-3">
<span class="text-xs font-bold tracking-widest text-ink-2 uppercase">
{m.notification_bell_label()}
</span>
{#if notifications.length > 0}
<button
{@attach attachFirstFocusable}
type="button"
onclick={markAllRead}
class="text-xs font-medium text-ink-3 transition-colors hover:text-ink"
>
{m.notification_mark_all_read()}
</button>
{/if}
</div>
<!-- Notification list -->
{#if notifications.length === 0}
<!-- Empty state -->
<div class="flex flex-col items-center gap-2 px-4 py-8 text-center text-sm text-ink-3">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-8 w-8 text-ink-3 opacity-40"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
/>
</svg>
<span>{m.notification_empty()}</span>
</div>
{:else}
<ul role="list">
{#each notifications as notification (notification.id)}
<li>
<div
role="button"
tabindex="0"
onclick={() => markRead(notification)}
onkeydown={(e) => e.key === 'Enter' && markRead(notification)}
class="flex cursor-pointer items-start gap-3 border-b border-line px-4 py-3 last:border-b-0 hover:bg-canvas
{!notification.read ? 'bg-accent-bg/20' : ''}"
>
<!-- Type icon -->
<span class="mt-0.5 shrink-0 text-ink-3" aria-hidden="true">
{#if notification.type === 'REPLY'}
<!-- Reply icon -->
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"
/>
</svg>
{:else}
<!-- Mention icon -->
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16 12a4 4 0 10-8 0 4 4 0 008 0zm0 0v1.5a2.5 2.5 0 005 0V12a9 9 0 10-9 9m4.5-1.206a8.959 8.959 0 01-4.5 1.207"
/>
</svg>
{/if}
</span>
<!-- Text + time -->
<div class="min-w-0 flex-1">
<p class="text-sm leading-snug text-ink">
{notification.type === 'REPLY'
? m.notification_type_reply({ actor: notification.actorName })
: m.notification_type_mention({ actor: notification.actorName })}
</p>
<p class="mt-1 text-xs text-ink-3">{relativeTime(notification.createdAt)}</p>
</div>
<!-- Unread dot -->
{#if !notification.read}
<span
class="mt-1.5 h-2 w-2 shrink-0 rounded-full bg-primary"
aria-label="ungelesen"
></span>
{/if}
</div>
</li>
{/each}
</ul>
{/if}
</div>
{/if}
</div>

View File

@@ -1,85 +1,36 @@
<script lang="ts"> <script lang="ts">
import { m } from '$lib/paraglide/messages.js';
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;
activeAnnotationId: string | null;
initialComments: Comment[]; initialComments: Comment[];
canComment: boolean; canComment: boolean;
currentUserId: string | null; currentUserId: string | null;
canAdmin: boolean; canAdmin: boolean;
onAnnotationCommentCountChange?: (annotationId: string, count: number) => void; targetCommentId?: string | null;
onCountChange?: (count: number) => void;
}; };
let { let {
documentId, documentId,
activeAnnotationId,
initialComments, initialComments,
canComment, canComment,
currentUserId, currentUserId,
canAdmin, canAdmin,
onAnnotationCommentCountChange targetCommentId = null,
onCountChange
}: Props = $props(); }: Props = $props();
</script> </script>
<div class="space-y-8 p-6"> <div class="flex-1 overflow-y-auto p-6">
<!-- Annotation thread (shown when an annotation is active) --> <CommentThread
{#if activeAnnotationId} documentId={documentId}
<div> initialComments={initialComments}
<h4 canComment={canComment}
class="mb-3 border-b border-brand-sand pb-2 font-sans text-xs font-bold tracking-widest text-brand-navy uppercase" currentUserId={currentUserId}
> canAdmin={canAdmin}
{m.doc_panel_annotation_thread_title()} targetCommentId={targetCommentId}
</h4> onCountChange={onCountChange}
{#key activeAnnotationId} />
<CommentThread
documentId={documentId}
annotationId={activeAnnotationId}
canComment={canComment}
currentUserId={currentUserId}
canAdmin={canAdmin}
loadOnMount={true}
onCountChange={(count) => onAnnotationCommentCountChange?.(activeAnnotationId, count)}
/>
{/key}
</div>
{/if}
<!-- General document discussion -->
<div>
{#if activeAnnotationId}
<h4
class="mb-3 border-b border-brand-sand pb-2 font-sans text-xs font-bold tracking-widest text-brand-navy uppercase"
>
{m.comment_section_title()}
</h4>
{/if}
<CommentThread
documentId={documentId}
initialComments={initialComments}
canComment={canComment}
currentUserId={currentUserId}
canAdmin={canAdmin}
/>
</div>
</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,38 +328,109 @@ $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-primary-fg transition hover:bg-primary/80 disabled:cursor-not-allowed disabled:opacity-40"
> >
{m.history_compare_apply()} {m.history_compare_apply()}
</button> </button>
</div> </div>
<!-- Diff panel for compare mode -->
{#if diffLoading}
<p class="font-sans text-xs text-ink-3">{m.history_loading()}</p>
{:else if noDiff}
<div
data-testid="history-diff"
class="rounded-sm border border-line bg-surface p-4 font-serif text-sm text-ink-3 italic"
>
{m.history_diff_no_changes()}
</div>
{:else if diffEntries.length > 0}
<div
data-testid="history-diff"
class="space-y-4 rounded-sm border border-line bg-surface p-4"
>
{#each diffEntries as entry (entry.field)}
<div>
<span
class="mb-1.5 block font-sans text-[10px] font-bold tracking-wide text-ink-3 uppercase"
>{entry.label}</span
>
{#if entry.kind === 'text'}
<p class="font-serif text-sm leading-relaxed">
{#each entry.parts as part, partIdx (partIdx)}
{#if part.added}
<span class="bg-green-50 text-green-700">{part.value}</span>
{:else if part.removed}
<span class="bg-red-50 text-red-600 line-through">{part.value}</span>
{:else}
<span>{part.value}</span>
{/if}
{/each}
</p>
{:else if entry.kind === 'scalar'}
<div class="flex items-center gap-2 font-serif text-sm">
<span class="text-red-600 line-through">{entry.oldVal || '—'}</span>
<svg
class="h-3 w-3 flex-shrink-0 text-ink-3"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z"
clip-rule="evenodd"
/>
</svg>
<span class="text-green-700">{entry.newVal || '—'}</span>
</div>
{:else if entry.kind === 'relation'}
<div class="flex flex-wrap gap-1.5">
{#each entry.removed as item (item)}
<span
class="rounded bg-red-50 px-1.5 py-0.5 font-sans text-[11px] text-red-600 line-through"
>{item}</span
>
{/each}
{#each entry.added as item (item)}
<span
class="rounded bg-green-50 px-1.5 py-0.5 font-sans text-[11px] text-green-700"
>{item}</span
>
{/each}
</div>
{/if}
</div>
{/each}
</div>
{/if}
{:else} {:else}
<!-- Version list --> <!-- 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>
@@ -367,80 +438,82 @@ $effect(() => {
</div> </div>
{/if} {/if}
</button> </button>
<!-- Diff shown inline below the selected version -->
{#if selectedVersionId === v.id}
{#if diffLoading}
<p class="pb-3 pl-2 font-sans text-xs text-ink-3">{m.history_loading()}</p>
{:else if noDiff}
<div
data-testid="history-diff"
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()}
</div>
{:else if diffEntries.length > 0}
<div
data-testid="history-diff"
class="mb-2 space-y-4 rounded-sm border border-line bg-surface p-4"
>
{#each diffEntries as entry (entry.field)}
<div>
<span
class="mb-1.5 block font-sans text-[10px] font-bold tracking-wide text-ink-3 uppercase"
>{entry.label}</span
>
{#if entry.kind === 'text'}
<p class="font-serif text-sm leading-relaxed">
{#each entry.parts as part, partIdx (partIdx)}
{#if part.added}
<span class="bg-green-50 text-green-700">{part.value}</span>
{:else if part.removed}
<span class="bg-red-50 text-red-600 line-through">{part.value}</span>
{:else}
<span>{part.value}</span>
{/if}
{/each}
</p>
{:else if entry.kind === 'scalar'}
<div class="flex items-center gap-2 font-serif text-sm">
<span class="text-red-600 line-through">{entry.oldVal || '—'}</span>
<svg
class="h-3 w-3 flex-shrink-0 text-ink-3"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z"
clip-rule="evenodd"
/>
</svg>
<span class="text-green-700">{entry.newVal || '—'}</span>
</div>
{:else if entry.kind === 'relation'}
<div class="flex flex-wrap gap-1.5">
{#each entry.removed as item (item)}
<span
class="rounded bg-red-50 px-1.5 py-0.5 font-sans text-[11px] text-red-600 line-through"
>{item}</span
>
{/each}
{#each entry.added as item (item)}
<span
class="rounded bg-green-50 px-1.5 py-0.5 font-sans text-[11px] text-green-700"
>{item}</span
>
{/each}
</div>
{/if}
</div>
{/each}
</div>
{/if}
{/if}
</li> </li>
{/each} {/each}
</ul> </ul>
{/if} {/if}
<!-- Diff panel -->
{#if diffLoading}
<p class="font-sans text-xs text-gray-400">{m.history_loading()}</p>
{:else if noDiff}
<div
data-testid="history-diff"
class="rounded-sm border border-brand-sand bg-white p-4 font-serif text-sm text-gray-400 italic"
>
{m.history_diff_no_changes()}
</div>
{:else if diffEntries.length > 0}
<div
data-testid="history-diff"
class="space-y-4 rounded-sm border border-brand-sand bg-white p-4"
>
{#each diffEntries as entry (entry.field)}
<div>
<span
class="mb-1.5 block font-sans text-[10px] font-bold tracking-wide text-gray-400 uppercase"
>{entry.label}</span
>
{#if entry.kind === 'text'}
<p class="font-serif text-sm leading-relaxed">
{#each entry.parts as part, partIdx (partIdx)}
{#if part.added}
<span class="bg-green-50 text-green-700">{part.value}</span>
{:else if part.removed}
<span class="bg-red-50 text-red-600 line-through">{part.value}</span>
{:else}
<span>{part.value}</span>
{/if}
{/each}
</p>
{:else if entry.kind === 'scalar'}
<div class="flex items-center gap-2 font-serif text-sm">
<span class="text-red-600 line-through">{entry.oldVal || '—'}</span>
<svg
class="h-3 w-3 flex-shrink-0 text-gray-400"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z"
clip-rule="evenodd"
/>
</svg>
<span class="text-green-700">{entry.newVal || '—'}</span>
</div>
{:else if entry.kind === 'relation'}
<div class="flex flex-wrap gap-1.5">
{#each entry.removed as item (item)}
<span
class="rounded bg-red-50 px-1.5 py-0.5 font-sans text-[11px] text-red-600 line-through"
>{item}</span
>
{/each}
{#each entry.added as item (item)}
<span
class="rounded bg-green-50 px-1.5 py-0.5 font-sans text-[11px] text-green-700"
>{item}</span
>
{/each}
</div>
{/if}
</div>
{/each}
</div>
{/if}
{/if} {/if}
</div> </div>

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-primary-fg"
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-primary-fg"
> >
{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,19 +3,25 @@ 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';
let { let {
url, url,
documentId = '', documentId = '',
annotateMode = $bindable(false), annotateMode = $bindable(false),
activeAnnotationId = $bindable<string | null>(null), activeAnnotationId = $bindable<string | null>(null),
onAnnotationClick activeAnnotationPage = $bindable<number | null>(null),
onAnnotationClick,
documentFileHash
}: { }: {
url: string; url: string;
documentId?: string; documentId?: string;
annotateMode?: boolean; annotateMode?: boolean;
activeAnnotationId?: string | null; activeAnnotationId?: string | null;
activeAnnotationPage?: number | null;
onAnnotationClick?: (id: string) => void; onAnnotationClick?: (id: string) => void;
documentFileHash?: string | null;
} = $props(); } = $props();
let pdfDoc = $state<PDFDocumentProxy | null>(null); let pdfDoc = $state<PDFDocumentProxy | null>(null);
@@ -38,21 +44,15 @@ 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;
};
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>();
let showAnnotations = $state(true);
const visibleAnnotations = $derived(
annotations.filter((a) => !a.fileHash || !documentFileHash || a.fileHash === documentFileHash)
);
const outdatedCount = $derived(annotations.length - visibleAnnotations.length);
onMount(async () => { onMount(async () => {
// Dynamic import keeps pdfjs out of the SSR bundle entirely // Dynamic import keeps pdfjs out of the SSR bundle entirely
@@ -213,6 +213,7 @@ async function handleAnnotationDraw(rect: { x: number; y: number; width: number;
const created: Annotation = await res.json(); const created: Annotation = await res.json();
annotations = [...annotations, created]; annotations = [...annotations, created];
activeAnnotationId = created.id; activeAnnotationId = created.id;
activeAnnotationPage = created.pageNumber;
onAnnotationClick?.(created.id); onAnnotationClick?.(created.id);
} }
} catch { } catch {
@@ -236,6 +237,8 @@ async function handleAnnotationDelete(annotationId: string) {
function handleAnnotationClick(id: string) { function handleAnnotationClick(id: string) {
activeAnnotationId = id; activeAnnotationId = id;
const ann = annotations.find((a) => a.id === id);
activeAnnotationPage = ann?.pageNumber ?? null;
onAnnotationClick?.(id); onAnnotationClick?.(id);
} }
@@ -262,6 +265,10 @@ $effect(() => {
} }
}); });
$effect(() => {
if (annotateMode) showAnnotations = true;
});
function prevPage() { function prevPage() {
if (currentPage > 1) currentPage -= 1; if (currentPage > 1) currentPage -= 1;
} }
@@ -280,28 +287,47 @@ 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}
<div
class="flex shrink-0 items-center gap-2 border-b border-amber-500/30 bg-amber-500/10 px-4 py-2"
data-testid="annotation-outdated-notice"
>
<svg
class="h-4 w-4 shrink-0 text-amber-400"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
/>
</svg>
<span class="font-sans text-xs text-amber-300">{m.annotation_outdated_notice()}</span>
</div>
{/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">
@@ -309,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"
@@ -323,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}
@@ -332,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"
@@ -351,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"
@@ -369,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"
@@ -396,6 +422,44 @@ function zoomOut() {
title="Farbe wählen" title="Farbe wählen"
/> />
{/if} {/if}
<!-- Annotation visibility toggle (shown when annotations exist) -->
{#if annotations.length > 0}
<button
onclick={() => (showAnnotations = !showAnnotations)}
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
? 'text-ink-3 hover:bg-surface/10'
: 'bg-surface/10 text-accent'}"
>
<svg
class="h-3.5 w-3.5 shrink-0"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
{#if showAnnotations}
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
/>
{:else}
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"
/>
{/if}
</svg>
{showAnnotations ? m.pdf_annotations_hide() : m.pdf_annotations_show()}
</button>
{/if}
</div> </div>
<!-- PDF canvas area --> <!-- PDF canvas area -->
@@ -419,15 +483,17 @@ function zoomOut() {
class="textLayer" class="textLayer"
style="position: absolute; top: 0; left: 0; overflow: hidden; pointer-events: none; line-height: 1;" style="position: absolute; top: 0; left: 0; overflow: hidden; pointer-events: none; line-height: 1;"
></div> ></div>
<AnnotationLayer {#if showAnnotations}
annotations={annotations.filter((a) => a.pageNumber === currentPage)} <AnnotationLayer
canAnnotate={annotateMode} annotations={visibleAnnotations.filter((a) => a.pageNumber === currentPage)}
color={annotateColor} canAnnotate={annotateMode}
onDraw={handleAnnotationDraw} color={annotateColor}
onDelete={handleAnnotationDelete} onDraw={handleAnnotationDraw}
commentCounts={Object.fromEntries(commentCounts)} onDelete={handleAnnotationDelete}
onAnnotationClick={handleAnnotationClick} commentCounts={Object.fromEntries(commentCounts)}
/> onAnnotationClick={handleAnnotationClick}
/>
{/if}
</div> </div>
</div> </div>
{/if} {/if}

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

@@ -180,6 +180,86 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/documents/{documentId}/comments": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getDocumentComments"];
put?: never;
post: operations["postDocumentComment"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/documents/{documentId}/comments/{commentId}/replies": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["replyToDocumentComment"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/documents/{documentId}/annotations": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["listAnnotations"];
put?: never;
post: operations["createAnnotation"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/documents/{documentId}/annotations/{annotationId}/comments": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getAnnotationComments"];
put?: never;
post: operations["postAnnotationComment"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/documents/{documentId}/annotations/{annotationId}/comments/{commentId}/replies": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["replyToAnnotationComment"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/auth/reset-password": { "/api/auth/reset-password": {
parameters: { parameters: {
query?: never; query?: never;
@@ -260,6 +340,22 @@ export interface paths {
patch: operations["updateGroup"]; patch: operations["updateGroup"];
trace?: never; trace?: never;
}; };
"/api/documents/{documentId}/comments/{commentId}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
delete: operations["deleteComment"];
options?: never;
head?: never;
patch: operations["editComment"];
trace?: never;
};
"/api/tags": { "/api/tags": {
parameters: { parameters: {
query?: never; query?: never;
@@ -372,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;
@@ -420,6 +564,22 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/documents/{documentId}/annotations/{annotationId}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
delete: operations["deleteAnnotation"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
} }
export type webhooks = Record<string, never>; export type webhooks = Record<string, never>;
export interface components { export interface components {
@@ -510,6 +670,7 @@ export interface components {
title: string; title: string;
filePath?: string; filePath?: string;
contentType?: string; contentType?: string;
fileHash?: string;
originalFilename: string; originalFilename: string;
/** @enum {string} */ /** @enum {string} */
status: "PLACEHOLDER" | "UPLOADED" | "TRANSCRIBED" | "REVIEWED" | "ARCHIVED"; status: "PLACEHOLDER" | "UPLOADED" | "TRANSCRIBED" | "REVIEWED" | "ARCHIVED";
@@ -548,6 +709,63 @@ export interface components {
name?: string; name?: string;
permissions?: string[]; permissions?: string[];
}; };
CreateCommentDTO: {
content?: string;
};
DocumentComment: {
/** Format: uuid */
id: string;
/** Format: uuid */
documentId: string;
/** Format: uuid */
annotationId?: string;
/** Format: uuid */
parentId?: string;
/** Format: uuid */
authorId?: string;
authorName: string;
content: string;
/** Format: date-time */
createdAt: string;
/** Format: date-time */
updatedAt: string;
replies: components["schemas"]["DocumentComment"][];
};
CreateAnnotationDTO: {
/** Format: int32 */
pageNumber?: number;
/** Format: double */
x?: number;
/** Format: double */
y?: number;
/** Format: double */
width?: number;
/** Format: double */
height?: number;
color?: string;
};
DocumentAnnotation: {
/** Format: uuid */
id: string;
/** Format: uuid */
documentId: string;
/** Format: int32 */
pageNumber: number;
/** Format: double */
x: number;
/** Format: double */
y: number;
/** Format: double */
width: number;
/** Format: double */
height: number;
color: string;
fileHash?: string;
/** Format: uuid */
createdBy?: string;
/** Format: date-time */
createdAt: string;
};
ResetPasswordRequest: { ResetPasswordRequest: {
token?: string; token?: string;
newPassword?: string; newPassword?: string;
@@ -1062,6 +1280,205 @@ export interface operations {
}; };
}; };
}; };
getDocumentComments: {
parameters: {
query?: never;
header?: never;
path: {
documentId: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["DocumentComment"][];
};
};
};
};
postDocumentComment: {
parameters: {
query?: never;
header?: never;
path: {
documentId: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["CreateCommentDTO"];
};
};
responses: {
/** @description Created */
201: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["DocumentComment"];
};
};
};
};
replyToDocumentComment: {
parameters: {
query?: never;
header?: never;
path: {
documentId: string;
commentId: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["CreateCommentDTO"];
};
};
responses: {
/** @description Created */
201: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["DocumentComment"];
};
};
};
};
listAnnotations: {
parameters: {
query?: never;
header?: never;
path: {
documentId: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["DocumentAnnotation"][];
};
};
};
};
createAnnotation: {
parameters: {
query?: never;
header?: never;
path: {
documentId: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["CreateAnnotationDTO"];
};
};
responses: {
/** @description Created */
201: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["DocumentAnnotation"];
};
};
};
};
getAnnotationComments: {
parameters: {
query?: never;
header?: never;
path: {
annotationId: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["DocumentComment"][];
};
};
};
};
postAnnotationComment: {
parameters: {
query?: never;
header?: never;
path: {
documentId: string;
annotationId: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["CreateCommentDTO"];
};
};
responses: {
/** @description Created */
201: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["DocumentComment"];
};
};
};
};
replyToAnnotationComment: {
parameters: {
query?: never;
header?: never;
path: {
documentId: string;
commentId: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["CreateCommentDTO"];
};
};
responses: {
/** @description Created */
201: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["DocumentComment"];
};
};
};
};
resetPassword: { resetPassword: {
parameters: { parameters: {
query?: never; query?: never;
@@ -1192,6 +1609,54 @@ export interface operations {
}; };
}; };
}; };
deleteComment: {
parameters: {
query?: never;
header?: never;
path: {
documentId: string;
commentId: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description No Content */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
editComment: {
parameters: {
query?: never;
header?: never;
path: {
documentId: string;
commentId: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["CreateCommentDTO"];
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["DocumentComment"];
};
};
};
};
searchTags: { searchTags: {
parameters: { parameters: {
query?: { query?: {
@@ -1402,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;
@@ -1422,4 +1958,25 @@ export interface operations {
}; };
}; };
}; };
deleteAnnotation: {
parameters: {
query?: never;
header?: never;
path: {
documentId: string;
annotationId: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description No Content */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
} }

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

@@ -0,0 +1,41 @@
export type MentionDTO = {
id: string;
firstName: string;
lastName: string;
};
export type CommentReply = {
id: string;
authorId: string | null;
authorName: string;
content: string;
createdAt: string;
updatedAt: string;
mentionDTOs?: MentionDTO[];
};
export type Comment = {
id: string;
authorId: string | null;
authorName: string;
content: string;
createdAt: string;
updatedAt: string;
replies: CommentReply[];
mentionDTOs?: MentionDTO[];
};
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

@@ -0,0 +1,120 @@
import { describe, it, expect } from 'vitest';
import { detectMention, extractContent, renderBody } from './mention';
import type { MentionDTO } from '$lib/types';
// ─── detectMention ────────────────────────────────────────────────────────────
describe('detectMention', () => {
it('returns null when text has no @', () => {
expect(detectMention('hello world', 11)).toBeNull();
});
it('returns null when @ is not the most recent trigger word', () => {
// cursor is past a completed mention (next word started)
expect(detectMention('hello @Hans Müller more', 22)).toBeNull();
});
it('returns empty string immediately after @', () => {
expect(detectMention('hello @', 7)).toBe('');
});
it('returns query text after @', () => {
expect(detectMention('hello @Han', 10)).toBe('Han');
});
it('returns null when @ is preceded by a letter (email address pattern)', () => {
expect(detectMention('user@example', 12)).toBeNull();
});
it('returns query for @ at the very start of string', () => {
expect(detectMention('@Hans', 5)).toBe('Hans');
});
it('returns null when cursor is before the @', () => {
expect(detectMention('@Hans', 0)).toBeNull();
});
});
// ─── extractContent ───────────────────────────────────────────────────────────
describe('extractContent', () => {
it('returns empty arrays for empty string', () => {
const result = extractContent('', []);
expect(result.content).toBe('');
expect(result.mentionedUserIds).toEqual([]);
});
it('returns plain content unchanged when no candidates', () => {
const result = extractContent('Hello world', []);
expect(result.content).toBe('Hello world');
expect(result.mentionedUserIds).toEqual([]);
});
it('extracts user id when @FirstName LastName is in content', () => {
const candidates: MentionDTO[] = [{ id: 'uuid-1', firstName: 'Hans', lastName: 'Müller' }];
const result = extractContent('Hey @Hans Müller how are you?', candidates);
expect(result.mentionedUserIds).toContain('uuid-1');
});
it('deduplicates user ids when same user mentioned twice', () => {
const candidates: MentionDTO[] = [{ id: 'uuid-1', firstName: 'Hans', lastName: 'Müller' }];
const result = extractContent('@Hans Müller and @Hans Müller again', candidates);
expect(result.mentionedUserIds).toHaveLength(1);
expect(result.mentionedUserIds).toContain('uuid-1');
});
it('collects multiple distinct users', () => {
const candidates: MentionDTO[] = [
{ id: 'uuid-1', firstName: 'Hans', lastName: 'Müller' },
{ id: 'uuid-2', firstName: 'Anna', lastName: 'Schmidt' }
];
const result = extractContent('@Hans Müller and @Anna Schmidt', candidates);
expect(result.mentionedUserIds).toContain('uuid-1');
expect(result.mentionedUserIds).toContain('uuid-2');
});
});
// ─── renderBody ───────────────────────────────────────────────────────────────
describe('renderBody', () => {
it('returns escaped plain text when no mentions', () => {
expect(renderBody('Hello world', [])).toBe('Hello world');
});
it('escapes < and > in content', () => {
const result = renderBody('<script>alert(1)</script>', []);
expect(result).toContain('&lt;script&gt;');
expect(result).not.toContain('<script>');
});
it('escapes & in content', () => {
const result = renderBody('AT&T', []);
expect(result).toContain('AT&amp;T');
});
it('wraps @mention in an anchor tag', () => {
const mentions: MentionDTO[] = [{ id: 'uuid-1', firstName: 'Hans', lastName: 'Müller' }];
const result = renderBody('Hey @Hans Müller!', mentions);
expect(result).toContain('<a');
expect(result).toContain('Hans Müller');
});
it('does not double-encode already escaped text', () => {
const mentions: MentionDTO[] = [{ id: 'uuid-1', firstName: 'Hans', lastName: 'Müller' }];
const result = renderBody('Check @Hans Müller', mentions);
expect(result).not.toContain('&amp;');
});
it('replaces all occurrences of the same mention', () => {
const mentions: MentionDTO[] = [{ id: 'uuid-1', firstName: 'Hans', lastName: 'Müller' }];
const result = renderBody('@Hans Müller and @Hans Müller', mentions);
const linkCount = (result.match(/<a /g) ?? []).length;
expect(linkCount).toBe(2);
});
it('converts newlines to <br>', () => {
const result = renderBody('line1\nline2', []);
expect(result).toContain('<br>');
expect(result).not.toContain('\n');
});
});

View File

@@ -0,0 +1,67 @@
import type { MentionDTO } from '$lib/types';
/**
* Given the current textarea value and cursor position, returns the
* @-mention query being typed (the text after the last triggering @),
* or null if no mention is active.
*
* Rules:
* - @ must be preceded by whitespace or be at the start of the string
* - The text between @ and the cursor must not contain a space (a
* completed mention word already has a space)
*/
export function detectMention(text: string, cursorPos: number): string | null {
const before = text.slice(0, cursorPos);
const atIndex = before.lastIndexOf('@');
if (atIndex === -1) return null;
// @ must be at start or preceded by whitespace
if (atIndex > 0 && !/\s/.test(before[atIndex - 1])) return null;
const query = before.slice(atIndex + 1);
// If the query contains a space the user has moved past the trigger word
if (query.includes(' ')) return null;
return query;
}
/**
* Given the raw textarea value and a list of candidate users (from the
* mention popup selections), returns the plain content string and the
* de-duplicated list of mentioned user IDs.
*/
export function extractContent(
text: string,
candidates: MentionDTO[]
): { content: string; mentionedUserIds: string[] } {
const seen = new Set<string>();
for (const user of candidates) {
const displayName = `${user.firstName} ${user.lastName}`.trim();
if (text.includes(`@${displayName}`)) {
seen.add(user.id);
}
}
return { content: text, mentionedUserIds: [...seen] };
}
/**
* Renders a comment body as safe HTML:
* 1. Escapes all HTML-special characters in the raw content
* 2. Replaces every @FirstName LastName occurrence with an anchor link
* 3. Converts newlines to <br>
*/
export function renderBody(content: string, mentions: MentionDTO[]): string {
let escaped = content
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;');
for (const mention of mentions) {
const displayName = `${mention.firstName} ${mention.lastName}`.trim();
const link = `<a class="mention" data-user-id="${mention.id}" href="#">@${displayName}</a>`;
escaped = escaped.replaceAll(`@${displayName}`, link);
}
return escaped.replaceAll('\n', '<br>');
}

View File

@@ -1,17 +1,15 @@
<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 LanguageSwitcher from '$lib/components/LanguageSwitcher.svelte';
import { setLocale, getLocale } from '$lib/paraglide/runtime'; import ThemeToggle from '$lib/components/ThemeToggle.svelte';
import NotificationBell from '$lib/components/NotificationBell.svelte';
import AppNav from './AppNav.svelte';
import UserMenu from './UserMenu.svelte';
let { children, data } = $props(); let { children, data } = $props();
const locales = ['DE', 'EN', 'ES'] as const;
const localeMap = { DE: 'de', EN: 'en', ES: 'es' } as const;
const activeLocale = $derived(getLocale().toUpperCase());
const isAdmin = $derived( const isAdmin = $derived(
data?.user?.groups?.some((g: { permissions: string[] }) => g.permissions.includes('ADMIN')) data?.user?.groups?.some((g: { permissions: string[] }) => g.permissions.includes('ADMIN'))
); );
@@ -23,7 +21,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 +31,42 @@ 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 -->
<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 (desktop only — mobile lives in nav drawer) -->
<div class="flex items-center gap-1 border-r border-gray-200 pr-3"> <div
{#each locales as locale (locale)} class="hidden items-center gap-1 border-r border-line pr-3 sm:flex [&_button]:px-1.5 [&_button]:py-1 [&_button]:text-xs"
<button >
type="button" <LanguageSwitcher />
onclick={() => setLocale(localeMap[locale])}
class="px-1.5 py-1 font-sans text-xs tracking-widest transition-colors
{activeLocale === locale
? 'font-bold text-brand-navy'
: 'font-normal text-gray-400 hover:text-brand-navy'}"
>
{locale}
</button>
{/each}
</div> </div>
<!-- Theme toggle -->
<ThemeToggle />
<!-- Notification bell (authenticated users only) -->
{#if data?.user}
<NotificationBell />
{/if}
<!-- User menu --> <!-- User menu -->
<div <UserMenu userInitials={userInitials} />
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}
<div
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,303 +67,54 @@ $effect(() => {
}); });
</script> </script>
<!-- Outer Container: Matches the 'Sand' background of the layout --> <main class="mx-auto max-w-7xl px-4 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"> <SearchFilterBar
<!-- SEARCH & FILTER CARD --> bind:q={q}
<div class="mb-8 rounded-sm border border-brand-sand bg-white p-6 shadow-sm"> bind:from={from}
<!-- ROW 1: Main Search (One Line) --> bind:to={to}
<div class="flex items-center gap-4"> bind:senderId={senderId}
<!-- Full Text Search --> bind:receiverId={receiverId}
<div class="relative flex-1"> bind:tagNames={tagNames}
<input bind:showAdvanced={showAdvanced}
type="text" initialSenderName={data.initialValues?.senderName}
bind:value={q} initialReceiverName={data.initialValues?.receiverName}
oninput={handleTextSearch} onSearch={handleTextSearch}
onfocus={() => (qFocused = true)} onfocus={() => (qFocused = true)}
onblur={() => (qFocused = false)} onblur={() => (qFocused = false)}
placeholder={m.docs_search_placeholder()} />
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>
<!-- Status Badge -->
<span
class="ml-3 inline-flex items-center rounded-full border px-2.5 py-0.5 text-[10px] font-bold tracking-wide uppercase
{doc.status === 'UPLOADED'
? 'border-brand-mint/50 bg-brand-mint/20 text-brand-navy'
: 'border-yellow-200 bg-yellow-50 text-yellow-700'}"
>
{doc.status}
</span>
</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,194 @@
<script lang="ts">
import { page } from '$app/state';
import { untrack } from 'svelte';
import { m } from '$lib/paraglide/messages.js';
import LanguageSwitcher from '$lib/components/LanguageSwitcher.svelte';
let { isAdmin = false }: { isAdmin?: boolean } = $props();
let mobileNavOpen = $state(false);
$effect(() => {
// Read pathname to establish the reactive dependency.
// Write via untrack so the effect doesn't re-run on its own write.
void page.url.pathname;
untrack(() => {
mobileNavOpen = false;
});
});
function closeMobileNav() {
mobileNavOpen = false;
}
function handleOverlayKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
mobileNavOpen = false;
}
}
</script>
<div class="flex items-center">
<div class="mr-10 hidden flex-shrink-0 items-center md:flex">
<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>
<!-- Desktop nav -->
<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>
<!-- Hamburger toggle (mobile only) -->
<button
class="ml-auto flex h-11 w-11 items-center justify-center rounded text-ink-2 transition-colors hover:bg-muted hover:text-ink sm:hidden"
aria-label={mobileNavOpen ? 'Menü schließen' : 'Menü öffnen'}
aria-expanded={mobileNavOpen}
aria-controls="mobile-nav"
onclick={() => (mobileNavOpen = !mobileNavOpen)}
>
{#if mobileNavOpen}
<!-- X icon -->
<svg
xmlns="http://www.w3.org/2000/svg"
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
{:else}
<!-- Hamburger icon -->
<svg
xmlns="http://www.w3.org/2000/svg"
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="18" x2="21" y2="18" />
</svg>
{/if}
</button>
</div>
<!-- Mobile nav overlay -->
{#if mobileNavOpen}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="fixed inset-0 top-[68px] z-40 sm:hidden" onkeydown={handleOverlayKeydown}>
<!-- Backdrop -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="absolute inset-0 bg-black/20" onclick={closeMobileNav}></div>
<!-- Panel -->
<div class="relative border-b border-line bg-surface shadow-md">
<nav id="mobile-nav">
<a
href="/"
class="block flex min-h-[44px] w-full items-center px-4 py-3 font-sans text-sm font-bold tracking-widest uppercase transition-colors
{page.url.pathname === '/' || page.url.pathname.startsWith('/documents')
? 'bg-nav-active text-ink'
: 'text-ink-2 hover:bg-muted hover:text-ink'}"
>
{m.nav_documents()}
</a>
<a
href="/persons"
class="block flex min-h-[44px] w-full items-center px-4 py-3 font-sans text-sm font-bold tracking-widest uppercase transition-colors
{page.url.pathname.startsWith('/persons')
? 'bg-nav-active text-ink'
: 'text-ink-2 hover:bg-muted hover:text-ink'}"
>
{m.nav_persons()}
</a>
<a
href="/conversations"
class="block flex min-h-[44px] w-full items-center px-4 py-3 font-sans text-sm font-bold tracking-widest uppercase transition-colors
{page.url.pathname.startsWith('/conversations')
? 'bg-nav-active text-ink'
: 'text-ink-2 hover:bg-muted hover:text-ink'}"
>
{m.nav_conversations()}
</a>
{#if isAdmin}
<a
href="/admin"
class="block flex min-h-[44px] w-full items-center px-4 py-3 font-sans text-sm font-bold tracking-widest uppercase transition-colors
{page.url.pathname.startsWith('/admin')
? 'bg-nav-active text-ink'
: 'text-ink-2 hover:bg-muted hover:text-ink'}"
>
{m.nav_admin()}
</a>
{/if}
<!-- Language switcher -->
<div
class="flex items-center gap-2 border-t border-line px-4 py-3 [&_button]:min-h-[44px] [&_button]:px-3 [&_button]:text-sm"
>
<LanguageSwitcher />
</div>
</nav>
</div>
</div>
{/if}

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-primary-fg"
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/30 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}
/>

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