Compare commits

...

31 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
80 changed files with 2613 additions and 354 deletions

View File

@@ -39,7 +39,7 @@ public class CommentController {
@RequestBody CreateCommentDTO dto,
Authentication 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")
@@ -51,7 +51,7 @@ public class CommentController {
@RequestBody CreateCommentDTO dto,
Authentication 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 ──────────────────────────────────────────────────
@@ -70,7 +70,7 @@ public class CommentController {
@RequestBody CreateCommentDTO dto,
Authentication 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")
@@ -82,7 +82,7 @@ public class CommentController {
@RequestBody CreateCommentDTO dto,
Authentication 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) ─────────────────────────────────────────────

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 java.util.ArrayList;
import java.util.List;
import java.util.UUID;
@Data
public class CreateCommentDTO {
private String content;
private List<UUID> mentionedUserIds = new ArrayList<>();
}

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

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

View File

@@ -51,6 +51,16 @@ public class AppUser {
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
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
@ManyToMany(fetch = FetchType.EAGER)
@JoinTable(name = "users_groups", joinColumns = @JoinColumn(name = "user_id"), inverseJoinColumns = @JoinColumn(name = "group_id"))

View File

@@ -1,10 +1,12 @@
package org.raddatz.familienarchiv.model;
import com.fasterxml.jackson.annotation.JsonIgnore;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import org.raddatz.familienarchiv.dto.MentionDTO;
import java.time.LocalDateTime;
import java.util.ArrayList;
@@ -60,4 +62,21 @@ public class DocumentComment {
@Builder.Default
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
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

@@ -1,10 +1,13 @@
package org.raddatz.familienarchiv.repository;
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.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@@ -12,4 +15,9 @@ import java.util.UUID;
public interface AppUserRepository extends JpaRepository<AppUser, UUID> {
Optional<AppUser> findByUsername(String username);
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

@@ -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

@@ -1,10 +1,12 @@
package org.raddatz.familienarchiv.service;
import lombok.RequiredArgsConstructor;
import org.raddatz.familienarchiv.dto.MentionDTO;
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.repository.AppUserRepository;
import org.raddatz.familienarchiv.repository.CommentRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -17,20 +19,23 @@ import java.util.UUID;
public class CommentService {
private final CommentRepository commentRepository;
private final AppUserRepository userRepository;
private final NotificationService notificationService;
public List<DocumentComment> getCommentsForDocument(UUID documentId) {
List<DocumentComment> roots =
commentRepository.findByDocumentIdAndAnnotationIdIsNullAndParentIdIsNull(documentId);
return withReplies(roots);
return withRepliesAndMentions(roots);
}
public List<DocumentComment> getCommentsForAnnotation(UUID annotationId) {
List<DocumentComment> roots = commentRepository.findByAnnotationIdAndParentIdIsNull(annotationId);
return withReplies(roots);
return withRepliesAndMentions(roots);
}
@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()
.documentId(documentId)
.annotationId(annotationId)
@@ -38,11 +43,16 @@ public class CommentService {
.authorId(author.getId())
.authorName(resolveAuthorName(author))
.build();
return commentRepository.save(comment);
saveMentions(comment, mentionedUserIds);
DocumentComment saved = commentRepository.save(comment);
withMentionDTOs(saved);
notificationService.notifyMentions(mentionedUserIds, saved);
return saved;
}
@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)
.orElseThrow(() -> DomainException.notFound(
ErrorCode.COMMENT_NOT_FOUND, "Comment not found: " + commentId));
@@ -60,7 +70,12 @@ public class CommentService {
.authorId(author.getId())
.authorName(resolveAuthorName(author))
.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
@@ -86,11 +101,29 @@ public class CommentService {
// ─── private helpers ──────────────────────────────────────────────────────
private List<DocumentComment> withReplies(List<DocumentComment> roots) {
roots.forEach(root -> root.setReplies(commentRepository.findByParentId(root.getId())));
private List<DocumentComment> withRepliesAndMentions(List<DocumentComment> roots) {
roots.forEach(root -> {
List<DocumentComment> replies = commentRepository.findByParentId(root.getId());
replies.forEach(this::withMentionDTOs);
root.setReplies(replies);
withMentionDTOs(root);
});
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) {
return commentRepository.findById(commentId)
.filter(c -> documentId.equals(c.getDocumentId()))

View File

@@ -108,9 +108,13 @@ public class DocumentService {
|| (dto.getReceiverIds() != null && !dto.getReceiverIds().isEmpty());
}
String titleToUse = (dto.getTitle() != null && !dto.getTitle().isBlank())
? dto.getTitle()
: titleFromFilename(filename);
Document doc = Document.builder()
.originalFilename(filename)
.title(dto.getTitle())
.title(titleToUse)
.documentDate(dto.getDocumentDate())
.location(dto.getLocation())
.documentLocation(dto.getDocumentLocation())

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

@@ -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,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

@@ -81,7 +81,7 @@ class CommentControllerTest {
void postDocumentComment_returns201_whenHasPermission() throws Exception {
DocumentComment saved = DocumentComment.builder()
.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")
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
@@ -104,7 +104,7 @@ class CommentControllerTest {
DocumentComment saved = DocumentComment.builder()
.id(UUID.randomUUID()).documentId(DOC_ID).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 + "/comments/" + COMMENT_ID + "/replies")
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
@@ -179,7 +179,7 @@ class CommentControllerTest {
DocumentComment saved = DocumentComment.builder()
.id(UUID.randomUUID()).documentId(DOC_ID).annotationId(ANN_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 + "/annotations/" + ANN_ID + "/comments")
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
@@ -194,7 +194,7 @@ class CommentControllerTest {
DocumentComment saved = DocumentComment.builder()
.id(UUID.randomUUID()).documentId(DOC_ID).annotationId(ANN_ID)
.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")
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))

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

@@ -9,6 +9,7 @@ import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.model.DocumentComment;
import org.raddatz.familienarchiv.model.UserGroup;
import org.raddatz.familienarchiv.repository.AppUserRepository;
import org.raddatz.familienarchiv.repository.CommentRepository;
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.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -30,6 +32,8 @@ import static org.springframework.http.HttpStatus.NOT_FOUND;
class CommentServiceTest {
@Mock CommentRepository commentRepository;
@Mock AppUserRepository userRepository;
@Mock NotificationService notificationService;
@InjectMocks CommentService commentService;
// ─── postComment ──────────────────────────────────────────────────────────
@@ -43,7 +47,7 @@ class CommentServiceTest {
.id(UUID.randomUUID()).documentId(docId).authorName("Hans Müller").content("Test").build();
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");
}
@@ -56,7 +60,7 @@ class CommentServiceTest {
.id(UUID.randomUUID()).documentId(docId).authorName("hans42").content("Test").build();
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");
}
@@ -70,7 +74,7 @@ class CommentServiceTest {
AppUser author = AppUser.builder().id(UUID.randomUUID()).username("anna").build();
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)
.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();
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);
}
@@ -114,11 +118,30 @@ class CommentServiceTest {
.id(UUID.randomUUID()).documentId(docId).parentId(rootId).content("Reply").authorName("anna").build();
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);
}
@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 ──────────────────────────────────────────────────────────
@Test

View File

@@ -467,6 +467,62 @@ class DocumentServiceTest {
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

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

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

View File

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

View File

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

View File

@@ -39,7 +39,7 @@
"form_placeholder_location": "z.B. Berlin, Wien…",
"form_label_sender": "Absender",
"form_label_receivers": "Empfänger",
"form_label_title": "Titel *",
"form_label_title": "Titel",
"form_label_tags": "Schlagworte",
"form_label_content": "Inhalt",
"form_placeholder_content": "Kurze Beschreibung des Inhalts…",
@@ -75,6 +75,7 @@
"doc_file_replace_label": "Neue Datei hochladen",
"doc_file_replace_note": "(ersetzt die aktuelle Datei)",
"doc_current_file_label": "Aktuelle Datei:",
"doc_more_details": "Weitere Details",
"doc_new_heading": "Neues Dokument",
"doc_edit_heading": "Bearbeiten",
"doc_section_details": "Details",
@@ -293,5 +294,16 @@
"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 →"
"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

@@ -39,7 +39,7 @@
"form_placeholder_location": "e.g. Berlin, Vienna…",
"form_label_sender": "Sender",
"form_label_receivers": "Recipients",
"form_label_title": "Title *",
"form_label_title": "Title",
"form_label_tags": "Tags",
"form_label_content": "Content",
"form_placeholder_content": "Brief description of the content…",
@@ -75,6 +75,7 @@
"doc_file_replace_label": "Upload new file",
"doc_file_replace_note": "(replaces the current file)",
"doc_current_file_label": "Current file:",
"doc_more_details": "More details",
"doc_new_heading": "New document",
"doc_edit_heading": "Edit",
"doc_section_details": "Details",
@@ -293,5 +294,16 @@
"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 →"
"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

@@ -39,7 +39,7 @@
"form_placeholder_location": "p.ej. Berlín, Viena…",
"form_label_sender": "Remitente",
"form_label_receivers": "Destinatarios",
"form_label_title": "Título *",
"form_label_title": "Título",
"form_label_tags": "Etiquetas",
"form_label_content": "Contenido",
"form_placeholder_content": "Breve descripción del contenido…",
@@ -75,6 +75,7 @@
"doc_file_replace_label": "Subir nuevo archivo",
"doc_file_replace_note": "(reemplaza el archivo actual)",
"doc_current_file_label": "Archivo actual:",
"doc_more_details": "Más detalles",
"doc_new_heading": "Nuevo documento",
"doc_edit_heading": "Editar",
"doc_section_details": "Detalles",
@@ -293,5 +294,16 @@
"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 →"
"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

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

View File

@@ -2,6 +2,9 @@
import { onMount, untrack } from 'svelte';
import { m } from '$lib/paraglide/messages.js';
import type { Comment, CommentReply } from '$lib/types';
import MentionEditor from '$lib/components/MentionEditor.svelte';
import { renderBody, extractContent } from '$lib/utils/mention';
import type { MentionDTO } from '$lib/types';
type Props = {
documentId: string;
@@ -11,6 +14,7 @@ type Props = {
canComment: boolean;
currentUserId: string | null;
canAdmin: boolean;
targetCommentId?: string | null;
onCountChange?: (count: number) => void;
};
@@ -22,16 +26,21 @@ let {
canComment,
currentUserId,
canAdmin,
targetCommentId = null,
onCountChange
}: Props = $props();
let comments: Comment[] = $state(untrack(() => [...initialComments]));
let highlightedCommentId: string | null = $state(untrack(() => targetCommentId ?? null));
let newText: string = $state('');
let replyingTo: string | null = $state(null);
let replyText: string = $state('');
let editingId: string | null = $state(null);
let editText: string = $state('');
let posting: boolean = $state(false);
let newMentionCandidates: MentionDTO[] = $state([]);
let replyMentionCandidates: MentionDTO[] = $state([]);
let editMentionCandidates: MentionDTO[] = $state([]);
const commentsBase = $derived(
annotationId
@@ -76,13 +85,15 @@ async function postComment() {
if (!text || posting) return;
posting = true;
try {
const { content, mentionedUserIds } = extractContent(text, newMentionCandidates);
const res = await fetch(commentsBase, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: text })
body: JSON.stringify({ content, mentionedUserIds })
});
if (res.ok) {
newText = '';
newMentionCandidates = [];
await reload();
}
} finally {
@@ -95,13 +106,15 @@ async function postReply(threadId: string) {
if (!text || posting) return;
posting = true;
try {
const { content, mentionedUserIds } = extractContent(text, replyMentionCandidates);
const res = await fetch(`${commentsBase}/${threadId}/replies`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: text })
body: JSON.stringify({ content, mentionedUserIds })
});
if (res.ok) {
replyText = '';
replyMentionCandidates = [];
replyingTo = null;
await reload();
}
@@ -115,13 +128,15 @@ async function saveEdit(commentId: string) {
if (!text || posting) return;
posting = true;
try {
const { content, mentionedUserIds } = extractContent(text, editMentionCandidates);
const res = await fetch(`/api/documents/${documentId}/comments/${commentId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: text })
body: JSON.stringify({ content, mentionedUserIds })
});
if (res.ok) {
editingId = null;
editMentionCandidates = [];
await reload();
}
} finally {
@@ -147,6 +162,7 @@ async function deleteComment(commentId: string) {
function startEdit(comment: Comment | CommentReply) {
editingId = comment.id;
editText = comment.content;
editMentionCandidates = [];
}
function cancelEdit() {
@@ -171,6 +187,25 @@ onMount(() => {
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>
@@ -181,14 +216,16 @@ onMount(() => {
{#snippet commentEntry(comment: Comment | CommentReply, threadId: string, showReplyButton: boolean)}
{#if editingId === comment.id}
<div class="flex flex-col gap-2">
<textarea
class="w-full resize-none rounded border border-line px-3 py-2 font-serif text-sm text-ink focus:ring-1 focus:ring-accent focus:outline-none"
rows={3}
<MentionEditor
bind:value={editText}
></textarea>
bind:mentionCandidates={editMentionCandidates}
rows={3}
disabled={posting}
onsubmit={() => saveEdit(comment.id)}
/>
<div class="flex items-center gap-3">
<button
class="rounded bg-primary px-3 py-1.5 font-sans text-xs font-medium text-white hover:bg-primary/80 disabled:opacity-40"
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}
onclick={() => saveEdit(comment.id)}
>
@@ -215,7 +252,10 @@ onMount(() => {
</span>
{/if}
</div>
<p class="mt-1 font-serif text-sm leading-relaxed text-ink-2">{comment.content}</p>
<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">
@@ -269,13 +309,23 @@ onMount(() => {
{#each comments as thread, ti (thread.id)}
<div class={ti > 0 ? 'border-t border-line pt-4' : ''}>
<!-- Root comment -->
<div>
<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>
<!-- Replies -->
{#each thread.replies as reply, ri (reply.id)}
<div class="mt-3 ml-6 border-l-2 border-line pl-4">
<div
data-comment-id={reply.id}
class="mt-3 ml-6 border-l-2 border-line pl-4 {highlightedCommentId === reply.id
? 'rounded ring-2 ring-accent ring-offset-1 transition-shadow'
: ''}"
>
{@render commentEntry(reply, thread.id, ri === thread.replies.length - 1)}
</div>
{/each}
@@ -283,15 +333,17 @@ onMount(() => {
<!-- Reply compose box -->
{#if replyingTo === thread.id}
<div class="mt-3 ml-6 flex flex-col gap-2">
<textarea
class="w-full resize-none rounded border border-line px-3 py-2 font-serif text-sm text-ink focus:ring-1 focus:ring-accent focus:outline-none"
<MentionEditor
bind:value={replyText}
bind:mentionCandidates={replyMentionCandidates}
rows={3}
placeholder={m.comment_placeholder()}
bind:value={replyText}
></textarea>
disabled={posting}
onsubmit={() => postReply(thread.id)}
/>
<div class="flex items-center gap-3">
<button
class="rounded bg-primary px-3 py-1.5 font-sans text-xs font-medium text-white hover:bg-primary/80 disabled:opacity-40"
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}
onclick={() => postReply(thread.id)}
>
@@ -313,15 +365,17 @@ onMount(() => {
{#if canComment}
<div class={comments.length > 0 ? 'border-t border-line pt-4' : ''}>
<div class="flex flex-col gap-2">
<textarea
class="w-full resize-none rounded border border-line px-3 py-2 font-serif text-sm text-ink focus:ring-1 focus:ring-accent focus:outline-none"
<MentionEditor
bind:value={newText}
bind:mentionCandidates={newMentionCandidates}
rows={3}
placeholder={m.comment_placeholder()}
bind:value={newText}
></textarea>
disabled={posting}
onsubmit={postComment}
/>
<div>
<button
class="rounded bg-primary px-3 py-1.5 font-sans text-xs font-medium text-white hover:bg-primary/80 disabled:opacity-40"
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()}
onclick={postComment}
>

View File

@@ -28,6 +28,7 @@ type Props = {
open: boolean;
height: number;
activeTab: DocumentPanelTab;
targetCommentId?: string | null;
};
let {
@@ -38,7 +39,8 @@ let {
canAdmin,
open = $bindable(),
height = $bindable(),
activeTab = $bindable()
activeTab = $bindable(),
targetCommentId = null
}: Props = $props();
const MIN_HEIGHT = 52; // drag handle (8px) + tab bar (~44px)
@@ -107,7 +109,7 @@ function handleCountChange(count: number) {
</script>
<div
class="fixed right-0 bottom-0 left-0 z-30 flex flex-col border-t border-line bg-surface shadow-[0_-4px_16px_rgba(0,0,0,0.08)]"
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"
data-testid="bottom-panel"
>
@@ -127,35 +129,37 @@ function handleCountChange(count: number) {
</div>
<!-- Tab bar -->
<div class="flex shrink-0 items-center border-b border-line bg-surface px-4">
{#each tabs as tab (tab.id)}
<button
onclick={() => openTab(tab.id)}
class="mr-1 px-3 py-2.5 font-sans text-xs font-medium transition-colors {activeTab === tab.id && open
? 'border-b-2 border-primary text-ink'
: 'text-ink-3 hover:text-ink'}"
aria-pressed={activeTab === tab.id && open}
>
{tab.label()}
{#if tab.id === 'discussion'}
<span
data-testid="discussion-count-badge"
class="ml-1.5 inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 font-sans text-[10px] font-bold text-primary-fg"
>{discussionCount}</span
>
{/if}
</button>
{/each}
<!-- spacer -->
<div class="flex-1"></div>
<div class="flex shrink-0 items-center border-b border-line bg-surface">
<!-- Scrollable tabs area — hides scrollbar visually -->
<div
class="flex flex-1 items-center overflow-x-auto px-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
>
{#each tabs as tab (tab.id)}
<button
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
? 'border-b-2 border-primary text-ink'
: 'text-ink-3 hover:text-ink'}"
aria-pressed={activeTab === tab.id && open}
>
{tab.label()}
{#if tab.id === 'discussion'}
<span
data-testid="discussion-count-badge"
class="ml-1.5 inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 font-sans text-[10px] font-bold text-primary-fg"
>{discussionCount}</span
>
{/if}
</button>
{/each}
</div>
{#if open}
<button
onclick={closePanel}
data-testid="panel-close-btn"
aria-label="Panel schließen"
class="rounded p-1.5 text-ink-3 transition-colors hover:bg-muted hover:text-ink"
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">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
@@ -178,6 +182,7 @@ function handleCountChange(count: number) {
canComment={canComment}
currentUserId={currentUserId}
canAdmin={canAdmin}
targetCommentId={targetCommentId}
onCountChange={handleCountChange}
/>
{:else if activeTab === 'history'}

View File

@@ -58,7 +58,7 @@ const compactMeta = $derived.by(() => {
</script>
<div
class="z-20 flex shrink-0 items-center justify-between border-b border-line bg-surface 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 -->
@@ -102,8 +102,8 @@ const compactMeta = $derived.by(() => {
onclick={() => (annotateMode = !annotateMode)}
aria-label={annotateMode ? m.doc_panel_annotate_stop() : m.doc_panel_annotate()}
class="flex items-center gap-1.5 rounded px-3 py-1.5 font-sans text-xs font-medium transition {annotateMode
? 'bg-primary text-white'
: 'border border-primary text-ink hover:bg-primary hover:text-white'}"
? 'bg-primary text-primary-fg'
: 'border border-primary text-ink hover:bg-primary hover:text-primary-fg'}"
>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Note/Note-Add-MD.svg"
@@ -111,14 +111,17 @@ const compactMeta = $derived.by(() => {
aria-hidden="true"
class="h-4 w-4 {annotateMode ? 'invert' : ''}"
/>
{annotateMode ? m.doc_panel_annotate_stop() : m.doc_panel_annotate()}
<span class="hidden sm:inline"
>{annotateMode ? m.doc_panel_annotate_stop() : m.doc_panel_annotate()}</span
>
</button>
{/if}
{#if canWrite}
<a
href="/documents/{doc.id}/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-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
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Edit-Content-MD.svg"
@@ -126,7 +129,7 @@ const compactMeta = $derived.by(() => {
aria-hidden="true"
class="h-4 w-4"
/>
{m.btn_edit()}
<span class="hidden sm:inline">{m.btn_edit()}</span>
</a>
{/if}

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

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

View File

@@ -328,7 +328,7 @@ $effect(() => {
<button
onclick={applyCompare}
disabled={!compareA || !compareB || compareA === compareB}
class="w-full rounded bg-primary px-3 py-1.5 font-sans text-xs font-medium text-white transition hover:bg-primary/80 disabled:cursor-not-allowed disabled:opacity-40"
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()}
</button>

View File

@@ -100,7 +100,7 @@ let { doc }: { doc: Doc } = $props();
{#each doc.tags as tag (tag.id)}
<a
href="/?tag={encodeURIComponent(tag.name)}"
class="inline-flex items-center rounded bg-muted px-2 py-0.5 text-xs font-bold tracking-wide text-ink uppercase transition-colors hover:bg-primary hover:text-white"
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 })}
>
{tag.name}
@@ -131,7 +131,7 @@ let { doc }: { doc: Doc } = $props();
>
<div class="flex items-center gap-3">
<div
class="flex h-8 w-8 items-center justify-center rounded-full bg-primary 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]}
</div>

View File

@@ -9,7 +9,8 @@ let {
initialDocumentLocation = '',
initialSummary = '',
titleRequired = false,
suggestedTitle = ''
suggestedTitle = '',
hideTitle = false
}: {
tags?: string[];
initialTitle?: string;
@@ -17,17 +18,12 @@ let {
initialSummary?: string;
titleRequired?: boolean;
suggestedTitle?: string;
hideTitle?: boolean;
} = $props();
let titleValue = $state(untrack(() => initialTitle));
let titleDirty = $state(false);
$effect(() => {
const suggested = suggestedTitle;
if (suggested && !untrack(() => titleDirty)) {
titleValue = suggested;
}
});
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">
@@ -36,25 +32,27 @@ $effect(() => {
</h2>
<div class="space-y-5">
<!-- Titel -->
<div>
<label for="title" class="mb-1 block text-sm font-medium text-ink-2"
>{m.form_label_title()}{#if titleRequired}
*{/if}</label
>
<input
id="title"
type="text"
name="title"
value={titleValue}
oninput={(e) => {
titleValue = (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 !hideTitle}
<!-- Titel -->
<div>
<label for="title" class="mb-1 block text-sm font-medium text-ink-2"
>{m.form_label_title()}{#if titleRequired}
*{/if}</label
>
<input
id="title"
type="text"
name="title"
value={titleValue}
oninput={(e) => {
titleOverride = (e.target as HTMLInputElement).value;
titleDirty = true;
}}
required={titleRequired}
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:border-ink focus:ring-ink"
/>
</div>
{/if}
<!-- Aufbewahrungsort -->
<div>

View File

@@ -1,3 +1,9 @@
export type MentionDTO = {
id: string;
firstName: string;
lastName: string;
};
export type CommentReply = {
id: string;
authorId: string | null;
@@ -5,6 +11,7 @@ export type CommentReply = {
content: string;
createdAt: string;
updatedAt: string;
mentionDTOs?: MentionDTO[];
};
export type Comment = {
@@ -15,6 +22,7 @@ export type Comment = {
createdAt: string;
updatedAt: string;
replies: CommentReply[];
mentionDTOs?: MentionDTO[];
};
export type DocumentPanelTab = 'metadata' | 'transcription' | 'discussion' | 'history';

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

@@ -2,17 +2,14 @@
import './layout.css';
import { page } from '$app/state';
import { onMount } from 'svelte';
import { setLocale, getLocale } from '$lib/paraglide/runtime';
import LanguageSwitcher from '$lib/components/LanguageSwitcher.svelte';
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();
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(
data?.user?.groups?.some((g: { permissions: string[] }) => g.permissions.includes('ADMIN'))
);
@@ -39,9 +36,6 @@ const userInitials = $derived.by(() => {
<div class="min-h-screen bg-canvas" data-hydrated={hydrated || undefined}>
{#if !isAuthPage}
<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="flex h-16 justify-between">
<!-- Logo & Nav -->
@@ -49,25 +43,21 @@ const userInitials = $derived.by(() => {
<!-- Right Side -->
<div class="flex items-center gap-3">
<!-- Language selector -->
<div class="flex items-center gap-1 border-r border-line pr-3">
{#each locales as locale (locale)}
<button
type="button"
onclick={() => setLocale(localeMap[locale])}
class="px-1.5 py-1 font-sans text-xs tracking-widest transition-colors
{activeLocale === locale
? 'font-bold text-ink'
: 'font-normal text-ink-3 hover:text-ink'}"
>
{locale}
</button>
{/each}
<!-- Language selector (desktop only — mobile lives in nav drawer) -->
<div
class="hidden items-center gap-1 border-r border-line pr-3 sm:flex [&_button]:px-1.5 [&_button]:py-1 [&_button]:text-xs"
>
<LanguageSwitcher />
</div>
<!-- Theme toggle -->
<ThemeToggle />
<!-- Notification bell (authenticated users only) -->
{#if data?.user}
<NotificationBell />
{/if}
<!-- User menu -->
<UserMenu userInitials={userInitials} />
</div>

View File

@@ -67,7 +67,7 @@ $effect(() => {
});
</script>
<main class="mx-auto max-w-7xl py-8 font-sans sm:px-6 lg:px-8">
<main class="mx-auto max-w-7xl px-4 py-8 font-sans sm:px-6 lg:px-8">
<SearchFilterBar
bind:q={q}
bind:from={from}

View File

@@ -1,12 +1,35 @@
<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">
<div class="mr-10 flex flex-shrink-0 items-center">
<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
@@ -14,6 +37,7 @@ let { isAdmin = false }: { isAdmin?: boolean } = $props();
</a>
</div>
<!-- Desktop nav -->
<nav class="hidden items-center sm:flex sm:space-x-1">
<a
href="/"
@@ -56,4 +80,115 @@ let { isAdmin = false }: { isAdmin?: boolean } = $props();
</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

@@ -121,7 +121,7 @@ let {
{#each doc.tags as tag (tag.id)}
<button
type="button"
class="relative z-10 inline-flex cursor-pointer items-center rounded bg-muted px-2 py-1 text-[10px] font-bold tracking-widest text-ink uppercase transition-colors hover:bg-primary hover:text-white"
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();

View File

@@ -146,7 +146,7 @@ $effect(() => {
? 'border-primary bg-accent-bg py-10 text-primary'
: windowDragging
? 'border-primary/60 bg-accent-bg/50 py-10 text-primary/80'
: 'border-ink/20 py-6 text-ink-3 hover:border-primary hover:text-primary'}"
: 'border-ink/30 py-6 text-ink-3 hover:border-primary hover:text-primary'}"
ondragover={handleDragOver}
ondragleave={handleDragLeave}
ondrop={handleDrop}

View File

@@ -33,7 +33,7 @@ function clickOutside(node: HTMLElement) {
aria-expanded={userMenuOpen}
aria-haspopup="true"
onclick={() => (userMenuOpen = !userMenuOpen)}
class="flex h-8 w-8 items-center justify-center rounded-full bg-primary font-sans text-xs font-bold text-white transition-opacity hover:opacity-80"
class="flex h-8 w-8 items-center justify-center rounded-full bg-primary font-sans text-xs font-bold text-primary-fg transition-opacity hover:opacity-80"
>
{userInitials}
</button>

View File

@@ -11,37 +11,37 @@ let { data, form } = $props();
let activeTab = $state('users');
</script>
<div class="mx-auto max-w-7xl py-8 font-sans sm:px-6 lg:px-8">
<div class="mb-8 flex items-center justify-between">
<div class="mx-auto max-w-7xl px-4 py-8 font-sans sm:px-6 lg:px-8">
<div class="mb-8 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<h1 class="font-serif text-3xl text-ink">{m.admin_heading()}</h1>
<!-- Tabs -->
<div class="flex rounded-lg border border-line bg-surface p-1 shadow-sm">
<div class="grid grid-cols-2 rounded-lg border border-line bg-surface p-1 shadow-sm sm:flex">
<button
class="rounded-md px-4 py-2 text-sm font-bold tracking-wide uppercase transition {activeTab ===
class="rounded-md px-2 py-2 text-sm font-bold tracking-wide uppercase transition sm:px-4 {activeTab ===
'users'
? 'bg-primary text-white'
? 'bg-primary text-primary-fg'
: 'text-ink-2 hover:text-ink'}"
onclick={() => (activeTab = 'users')}>{m.admin_tab_users()}</button
>
<button
class="rounded-md px-4 py-2 text-sm font-bold tracking-wide uppercase transition {activeTab ===
class="rounded-md px-2 py-2 text-sm font-bold tracking-wide uppercase transition sm:px-4 {activeTab ===
'groups'
? 'bg-primary text-white'
? 'bg-primary text-primary-fg'
: 'text-ink-2 hover:text-ink'}"
onclick={() => (activeTab = 'groups')}>{m.admin_tab_groups()}</button
>
<button
class="rounded-md px-4 py-2 text-sm font-bold tracking-wide uppercase transition {activeTab ===
class="rounded-md px-2 py-2 text-sm font-bold tracking-wide uppercase transition sm:px-4 {activeTab ===
'tags'
? 'bg-primary text-white'
? 'bg-primary text-primary-fg'
: 'text-ink-2 hover:text-ink'}"
onclick={() => (activeTab = 'tags')}>{m.admin_tab_tags()}</button
>
<button
class="rounded-md px-4 py-2 text-sm font-bold tracking-wide uppercase transition {activeTab ===
class="rounded-md px-2 py-2 text-sm font-bold tracking-wide uppercase transition sm:px-4 {activeTab ===
'system'
? 'bg-primary text-white'
? 'bg-primary text-primary-fg'
: 'text-ink-2 hover:text-ink'}"
onclick={() => (activeTab = 'system')}>{m.admin_tab_system()}</button
>

View File

@@ -212,7 +212,7 @@ function cancelEditGroup() {
<button
type="submit"
class="w-full rounded bg-primary px-6 py-2 text-sm font-bold text-white uppercase hover:bg-accent hover:text-ink md:w-auto"
class="w-full rounded bg-primary px-6 py-2 text-sm font-bold text-primary-fg uppercase hover:bg-accent hover:text-ink md:w-auto"
>
{m.btn_create()}
</button>

View File

@@ -41,7 +41,7 @@ async function backfillFileHashes() {
<button
onclick={backfillVersions}
disabled={backfillLoading}
class="rounded bg-primary px-6 py-2 text-sm font-bold text-white uppercase transition hover:bg-accent hover:text-ink disabled:cursor-not-allowed disabled:opacity-50"
class="rounded bg-primary px-6 py-2 text-sm font-bold text-primary-fg uppercase transition hover:bg-accent hover:text-ink disabled:cursor-not-allowed disabled:opacity-50"
>
{backfillLoading ? '…' : m.admin_system_backfill_btn()}
</button>
@@ -60,7 +60,7 @@ async function backfillFileHashes() {
<button
onclick={backfillFileHashes}
disabled={backfillHashesLoading}
class="rounded bg-primary px-6 py-2 text-sm font-bold text-white uppercase transition hover:bg-accent hover:text-ink disabled:cursor-not-allowed disabled:opacity-50"
class="rounded bg-primary px-6 py-2 text-sm font-bold text-primary-fg uppercase transition hover:bg-accent hover:text-ink disabled:cursor-not-allowed disabled:opacity-50"
>
{backfillHashesLoading ? '…' : m.admin_system_backfill_hashes_btn()}
</button>

View File

@@ -20,7 +20,7 @@ let {
<h2 class="text-lg font-bold text-ink-2">{m.admin_section_users()}</h2>
<a
href="/admin/users/new"
class="inline-flex items-center gap-1 rounded-sm bg-primary px-4 py-2 font-sans text-xs font-bold tracking-widest text-white uppercase transition-opacity hover:opacity-80"
class="inline-flex items-center gap-1 rounded-sm bg-primary px-4 py-2 font-sans text-xs font-bold tracking-widest text-primary-fg uppercase transition-opacity hover:opacity-80"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />

View File

@@ -85,7 +85,7 @@ const selectedGroupIds = $derived(data.editUser.groups?.map((g: { id: string })
</a>
<button
type="submit"
class="rounded-sm bg-primary px-5 py-2 font-sans text-xs font-bold tracking-widest text-white uppercase transition-opacity hover:opacity-80"
class="rounded-sm bg-primary px-5 py-2 font-sans text-xs font-bold tracking-widest text-primary-fg uppercase transition-opacity hover:opacity-80"
>
{m.btn_save()}
</button>

View File

@@ -61,7 +61,7 @@ let { data, form } = $props();
</a>
<button
type="submit"
class="rounded-sm bg-primary px-5 py-2 font-sans text-xs font-bold tracking-widest text-white uppercase transition-opacity hover:opacity-80"
class="rounded-sm bg-primary px-5 py-2 font-sans text-xs font-bold tracking-widest text-primary-fg uppercase transition-opacity hover:opacity-80"
>
{m.btn_create()}
</button>

View File

@@ -48,7 +48,7 @@ let {
<button
data-testid="conv-swap-btn"
onclick={onswapPersons}
class="flex w-full items-center justify-center gap-2 border border-line px-3 py-2.5 text-xs font-bold tracking-widest text-ink uppercase transition-colors hover:bg-primary hover:text-white md:w-auto {senderId &&
class="flex w-full items-center justify-center gap-2 border border-line px-3 py-2.5 text-xs font-bold tracking-widest text-ink uppercase transition-colors hover:bg-primary hover:text-primary-fg md:w-auto {senderId &&
receiverId
? ''
: 'invisible'}"
@@ -121,7 +121,7 @@ let {
<div>
<button
onclick={ontoggleSort}
class="flex h-[42px] w-full items-center justify-center border border-line text-xs font-bold tracking-wide text-ink uppercase transition-colors hover:bg-primary hover:text-white"
class="flex h-[42px] w-full items-center justify-center border border-line text-xs font-bold tracking-wide text-ink uppercase transition-colors hover:bg-primary hover:text-primary-fg"
>
<span class="mr-2">{m.conv_sort_label()}</span>
<span>{sortDir === 'DESC' ? m.conv_sort_newest() : m.conv_sort_oldest()}</span>

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/state';
import DocumentTopBar from '$lib/components/DocumentTopBar.svelte';
import DocumentViewer from '$lib/components/DocumentViewer.svelte';
import DocumentBottomPanel from '$lib/components/DocumentBottomPanel.svelte';
@@ -8,6 +9,8 @@ import type { DocumentPanelTab } from '$lib/types';
let { data } = $props();
const targetCommentId = $derived(page.url.searchParams.get('commentId'));
const doc = $derived(data.document);
const canComment = $derived((data.canAnnotate || data.canWrite) ?? false);
const canAdmin = $derived(
@@ -67,6 +70,7 @@ $effect(() => {
const LS_KEY_HEIGHT = 'doc-panel-height';
const LS_KEY_TAB = 'doc-panel-tab';
const LS_KEY_OPEN = 'doc-panel-open';
let panelOpen = $state(false);
let panelHeight = $state(0); // set to full height on mount
@@ -79,6 +83,7 @@ onMount(() => {
const savedHeight = localStorage.getItem(LS_KEY_HEIGHT);
const savedTab = localStorage.getItem(LS_KEY_TAB);
const savedOpen = localStorage.getItem(LS_KEY_OPEN);
if (savedTab && ['metadata', 'transcription', 'discussion', 'history'].includes(savedTab)) {
activeTab = savedTab as DocumentPanelTab;
@@ -90,6 +95,18 @@ onMount(() => {
if (!isNaN(h) && h >= 80) panelHeight = h;
}
if (targetCommentId) {
// Deep-link: always open discussion tab regardless of saved state
panelOpen = true;
activeTab = 'discussion';
} else if (savedOpen === 'true') {
panelOpen = true;
} else if (savedOpen === null && !doc?.filePath) {
// No prior state and no file — open to metadata so the panel is immediately useful.
panelOpen = true;
activeTab = 'metadata';
}
localStorageRestored = true;
function onKeyDown(e: KeyboardEvent) {
@@ -111,6 +128,7 @@ $effect(() => {
if (!localStorageRestored) return;
localStorage.setItem(LS_KEY_HEIGHT, String(panelHeight));
localStorage.setItem(LS_KEY_TAB, activeTab);
localStorage.setItem(LS_KEY_OPEN, String(panelOpen));
});
</script>
@@ -164,6 +182,7 @@ $effect(() => {
canComment={canComment}
currentUserId={currentUserId}
canAdmin={canAdmin}
targetCommentId={targetCommentId}
bind:open={panelOpen}
bind:height={panelHeight}
bind:activeTab={activeTab}

View File

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

View File

@@ -2,14 +2,24 @@
import { m } from '$lib/paraglide/messages.js';
let { originalFilename }: { originalFilename: string } = $props();
let selectedFilename = $state<string | null>(null);
function handleFileChange(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0];
selectedFilename = file?.name ?? null;
}
</script>
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
<h2 class="mb-5 text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.doc_section_file()}
</h2>
<div class="rounded-sm border border-line bg-surface shadow-sm">
<div class="border-b border-line px-6 py-4">
<h2 class="text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.doc_section_file()}
</h2>
</div>
<div class="mb-4 flex items-center gap-3 rounded bg-muted px-3 py-2 text-sm text-ink-2">
<!-- Current file -->
<div class="flex items-center gap-3 border-b border-line px-6 py-3 text-sm text-ink-2">
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/PDF-Document-MD.svg"
alt=""
@@ -22,19 +32,31 @@ let { originalFilename }: { originalFilename: string } = $props();
>
</div>
<label for="file-upload" class="mb-1 block text-sm font-medium text-ink-2">
{m.doc_file_replace_label()}
<span class="font-normal text-ink-3">({m.doc_file_replace_note()})</span>
<!-- Replace file upload zone -->
<label
for="file-upload"
class="flex cursor-pointer flex-col items-center gap-3 px-6 py-8 transition-colors hover:bg-muted/40"
>
<svg
class="h-8 w-8 text-ink-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"
/>
</svg>
{#if selectedFilename}
<span class="text-sm font-medium text-ink">{selectedFilename}</span>
{:else}
<span class="text-sm font-medium text-ink-2">{m.doc_file_replace_label()}</span>
<span class="text-xs text-ink-3">{m.doc_file_replace_note()}</span>
{/if}
</label>
<input
id="file-upload"
type="file"
name="file"
class="block w-full cursor-pointer text-sm
text-ink-2 file:mr-4 file:rounded
file:border-0 file:bg-muted
file:px-4 file:py-2
file:text-sm file:font-semibold
file:text-ink hover:file:bg-muted"
/>
<input id="file-upload" type="file" name="file" onchange={handleFileChange} class="sr-only" />
</div>

View File

@@ -1,6 +1,5 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { enhance } from '$app/forms';
let { docId }: { docId: string } = $props();
@@ -8,75 +7,79 @@ let confirmDelete = $state(false);
</script>
<div
class="sticky bottom-0 z-10 -mx-4 flex items-center justify-between border-t border-line bg-surface px-6 py-4 shadow-[0_-2px_8px_rgba(0,0,0,0.06)]"
class="sticky bottom-0 z-10 -mx-4 border-t border-line bg-surface px-4 py-3 shadow-[0_-2px_8px_rgba(0,0,0,0.06)] sm:px-6 sm:py-4"
>
<!-- Left: delete -->
<div class="flex items-center gap-3">
{#if confirmDelete}
<span class="font-sans text-sm text-red-700">{m.doc_delete_confirm()}</span>
<!-- Desktop: delete left, cancel+buttons right -->
<!-- Mobile: action buttons stacked full-width, delete+cancel row at bottom -->
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<!-- Primary actions first (top on mobile, right on desktop) -->
<div class="flex flex-col gap-2 sm:order-last sm:flex-row sm:items-center sm:gap-4">
<button
type="submit"
form="delete-form"
class="rounded bg-red-600 px-4 py-1.5 text-sm font-bold text-white transition-colors hover:bg-red-700"
form="mark-for-review-form"
class="w-full rounded-sm border border-line px-4 py-2.5 font-sans text-xs font-bold tracking-widest text-ink-2 uppercase transition-colors hover:bg-muted sm:w-auto sm:py-2"
>
{m.btn_delete()}
{m.btn_mark_for_review()}
</button>
<button
type="button"
onclick={() => (confirmDelete = false)}
class="text-sm text-ink-2 transition-colors hover:text-ink"
type="submit"
class="w-full rounded bg-primary px-6 py-2.5 font-sans text-sm font-bold tracking-widest text-primary-fg uppercase transition-colors hover:bg-primary/80 sm:w-auto sm:py-2"
>
{m.btn_cancel()}
{m.btn_save()}
</button>
{:else}
<button
type="button"
onclick={() => (confirmDelete = true)}
class="flex items-center gap-1.5 rounded border border-red-300 px-4 py-1.5 text-sm font-bold text-red-600 transition-colors hover:border-red-600 hover:bg-red-50"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
<path d="M10 11v6M14 11v6" />
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
</svg>
{m.btn_delete()}
</button>
{/if}
</div>
</div>
<!-- Right: cancel + mark for review + save -->
<div class="flex items-center gap-4">
<a
href="/documents/{docId}"
class="text-sm font-medium text-ink-2 transition-colors hover:text-ink"
>
{m.btn_cancel()}
</a>
<button
type="submit"
form="mark-for-review-form"
class="rounded-sm border border-gray-300 px-4 py-2 font-sans text-xs font-bold tracking-widest text-gray-600 uppercase transition-colors hover:bg-gray-50"
>
{m.btn_mark_for_review()}
</button>
<button
type="submit"
class="rounded bg-primary px-6 py-2 text-sm font-bold tracking-widest text-white uppercase transition-colors hover:bg-primary/80"
>
{m.btn_save()}
</button>
<!-- Secondary: delete + cancel (row on both mobile and desktop) -->
<div class="flex items-center justify-between sm:justify-start sm:gap-4">
{#if confirmDelete}
<span class="font-sans text-sm text-red-700">{m.doc_delete_confirm()}</span>
<div class="flex items-center gap-3">
<button
type="submit"
form="delete-form"
class="rounded bg-red-600 px-4 py-1.5 text-sm font-bold text-white transition-colors hover:bg-red-700"
>
{m.btn_delete()}
</button>
<button
type="button"
onclick={() => (confirmDelete = false)}
class="text-sm text-ink-2 transition-colors hover:text-ink"
>
{m.btn_cancel()}
</button>
</div>
{:else}
<button
type="button"
onclick={() => (confirmDelete = true)}
class="flex items-center gap-1.5 rounded border border-red-300 px-4 py-1.5 text-sm font-bold text-red-600 transition-colors hover:border-red-600 hover:bg-red-50"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
<path d="M10 11v6M14 11v6" />
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
</svg>
{m.btn_delete()}
</button>
<a
href="/documents/{docId}"
class="text-sm font-medium text-ink-2 transition-colors hover:text-ink"
>
{m.btn_cancel()}
</a>
{/if}
</div>
</div>
</div>
<form id="mark-for-review-form" method="POST" action="?/markForReview" use:enhance></form>

View File

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

View File

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

View File

@@ -19,7 +19,7 @@ function formatUploadDate(createdAt: string): string {
<!-- Back Link -->
<a
href="/"
class="group mb-4 inline-flex items-center font-sans text-xs font-bold tracking-widest text-gray-500 uppercase transition-colors hover:text-brand-navy"
class="group mb-4 inline-flex items-center font-sans text-xs font-bold tracking-widest text-ink-2 uppercase transition-colors hover:text-ink"
>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Arrow/Arrow-Left-MD.svg"
@@ -47,7 +47,7 @@ function formatUploadDate(createdAt: string): string {
{#if count > 0}
<a
href="/enrich/{documents[0].id}"
class="bg-brand-navy px-5 py-2 font-sans text-xs font-bold tracking-widest text-white uppercase transition-colors hover:bg-brand-navy/90"
class="bg-primary px-5 py-2 font-sans text-xs font-bold tracking-widest text-primary-fg uppercase transition-colors hover:bg-primary/90"
>
{m.enrich_list_start()}
</a>
@@ -76,8 +76,8 @@ function formatUploadDate(createdAt: string): string {
</div>
{:else}
<!-- Document Rows -->
<div class="border-brand-sand border bg-white shadow-sm">
<ul class="divide-brand-sand divide-y">
<div class="border border-line bg-white shadow-sm">
<ul class="divide-y divide-line-2">
{#each documents as doc (doc.id)}
<li class="group hover:bg-brand-sand/30 transition-colors duration-200">
<a href="/enrich/{doc.id}" class="flex items-center justify-between p-6">

View File

@@ -156,7 +156,7 @@ let selectedReceivers = $state(untrack(() => doc.receivers ?? []));
type="submit"
form="save-form"
formaction="?/saveAndReview"
class="rounded-sm bg-brand-navy px-5 py-2 font-sans text-xs font-bold tracking-widest text-white uppercase transition-colors hover:bg-brand-navy/90"
class="rounded-sm bg-primary px-5 py-2 font-sans text-xs font-bold tracking-widest text-primary-fg uppercase transition-colors hover:bg-primary/90"
>
{m.btn_save_and_mark_reviewed()}
</button>

View File

@@ -17,21 +17,21 @@ import { m } from '$lib/paraglide/messages.js';
{m.enrich_done_heading()}
</h1>
<p class="mt-2 max-w-xs font-sans text-sm text-gray-500">
<p class="mt-2 max-w-xs font-sans text-sm text-ink-2">
{m.enrich_done_body()}
</p>
<div class="mt-8 flex flex-col items-center gap-4">
<a
href="/"
class="bg-brand-navy px-6 py-2 font-sans text-xs font-bold tracking-widest text-white uppercase transition-colors hover:bg-brand-navy/90"
class="bg-primary px-6 py-2 font-sans text-xs font-bold tracking-widest text-primary-fg uppercase transition-colors hover:bg-primary/90"
>
{m.btn_back_to_overview()}
</a>
<a
href="/enrich"
class="font-sans text-xs text-gray-400 underline-offset-4 transition-colors hover:text-brand-navy hover:underline"
class="font-sans text-xs text-ink-2 underline-offset-4 transition-colors hover:text-ink hover:underline"
>
{m.enrich_back_to_list()}
</a>

View File

@@ -5,9 +5,6 @@ let { form }: { form?: { error?: string; success?: boolean } } = $props();
</script>
<div class="relative flex min-h-screen flex-col bg-surface">
<!-- Accent strip -->
<div class="h-1 bg-brand-purple"></div>
<div class="flex flex-1 items-center justify-center px-4">
<div class="w-full max-w-sm">
<!-- Logo -->
@@ -57,7 +54,7 @@ let { form }: { form?: { error?: string; success?: boolean } } = $props();
<button
type="submit"
class="mt-2 w-full bg-primary py-2.5 font-sans text-xs font-bold tracking-widest text-white uppercase transition-colors hover:bg-primary/90"
class="mt-2 w-full bg-primary py-2.5 font-sans text-xs font-bold tracking-widest text-primary-fg uppercase transition-colors hover:bg-primary/90"
>
{m.forgot_password_submit()}
</button>

View File

@@ -10,7 +10,6 @@
--palette-mint: #a1dcd8;
--palette-turquoise: #00c7b1;
--palette-sand: #f0efe9;
--palette-purple: #b4b9ff;
/* Typography */
--font-sans: 'Montserrat', ui-sans-serif, system-ui, sans-serif;
@@ -58,7 +57,6 @@
--color-pdf-text: var(--c-pdf-text);
/* Static brand tokens (not themed) */
--color-brand-purple: var(--palette-purple);
--color-brand-navy: var(--palette-navy);
--color-brand-mint: var(--palette-mint);
}
@@ -74,8 +72,8 @@
--c-line-2: #eeede8;
--c-ink: #012851;
--c-ink-2: #6b7280;
--c-ink-3: #9ca3af;
--c-ink-2: #4b5563; /* gray-600 — 7.6:1 on white, 6.6:1 on canvas — WCAG AA ✓ */
--c-ink-3: #6b7280; /* gray-500 — 4.8:1 on white — WCAG AA ✓; use only on surface, not canvas */
--c-accent: #a1dcd8;
--c-accent-bg: rgba(161, 220, 216, 0.15);
@@ -98,12 +96,12 @@
--c-overlay: #242424;
--c-muted: #252525;
--c-line: #2e2e2e;
--c-line-2: #222222;
--c-line: #3d3d3d;
--c-line-2: #2e2e2e;
--c-ink: #f0efe9;
--c-ink-2: #9ca3af;
--c-ink-3: #6b7280;
--c-ink-2: #9ca3af; /* gray-400 — 7.5:1 on dark surface — WCAG AAA ✓ */
--c-ink-3: #8b97a5; /* gray-450 — 6.5:1 on dark surface — WCAG AA ✓ */
--c-accent: #00c7b1;
--c-accent-bg: rgba(0, 199, 177, 0.12);
@@ -126,8 +124,8 @@
--c-overlay: #242424;
--c-muted: #252525;
--c-line: #2e2e2e;
--c-line-2: #222222;
--c-line: #3d3d3d;
--c-line-2: #2e2e2e;
--c-ink: #f0efe9;
--c-ink-2: #9ca3af;

View File

@@ -10,9 +10,6 @@ const activeLocale = $derived(getLocale().toUpperCase());
</script>
<div class="relative flex min-h-screen flex-col bg-canvas">
<!-- DGB purple accent strip -->
<div class="h-1 bg-brand-purple"></div>
<!-- Language switcher -->
<div class="absolute top-4 right-4 flex items-center gap-1">
{#each locales as locale (locale)}
@@ -85,7 +82,7 @@ const activeLocale = $derived(getLocale().toUpperCase());
<button
type="submit"
class="mt-2 w-full bg-primary py-2.5 font-sans text-xs font-bold tracking-widest text-white uppercase transition-colors hover:bg-primary/90"
class="mt-2 w-full bg-primary py-2.5 font-sans text-xs font-bold tracking-widest text-primary-fg uppercase transition-colors hover:bg-primary/90"
>
{m.login_btn_submit()}
</button>

View File

@@ -23,7 +23,7 @@ function handleSearch() {
}
</script>
<div class="mx-auto max-w-7xl py-12 sm:px-6 lg:px-8">
<div class="mx-auto max-w-7xl px-4 py-12 sm:px-6 lg:px-8">
<!-- Header Area -->
<div
class="mb-10 flex flex-col justify-between gap-6 border-b border-ink/10 pb-6 md:flex-row md:items-end"
@@ -107,7 +107,7 @@ function handleSearch() {
<!-- Avatar -->
<div class="flex-shrink-0">
<div
class="flex h-12 w-12 items-center justify-center rounded-full bg-primary font-serif text-lg text-white transition-colors group-hover:bg-accent group-hover:text-ink"
class="flex h-12 w-12 items-center justify-center rounded-full bg-primary font-serif text-lg text-primary-fg transition-colors group-hover:bg-accent group-hover:text-ink"
>
{person.firstName[0]}{person.lastName[0]}
</div>

View File

@@ -143,7 +143,7 @@ $effect(() => {
<div class="flex gap-3">
<button
type="submit"
class="rounded bg-primary px-5 py-2 text-sm font-bold tracking-widest text-white uppercase transition-colors hover:bg-primary/80"
class="rounded bg-primary px-5 py-2 text-sm font-bold tracking-widest text-primary-fg uppercase transition-colors hover:bg-primary/80"
>
{m.btn_save()}
</button>

View File

@@ -44,7 +44,7 @@ const visibleDocuments = $derived(
<div class="mb-10">
<div class="mb-6 flex items-center gap-3 border-b border-ink/10 pb-2">
<h2 class="font-serif text-xl text-ink">{heading}</h2>
<span class="rounded-full bg-primary px-2 py-1 text-xs font-bold text-white">
<span class="rounded-full bg-primary px-2 py-1 text-xs font-bold text-primary-fg">
{documents.length}
</span>
{#if yearRange}

View File

@@ -89,7 +89,7 @@ let { form } = $props();
</a>
<button
type="submit"
class="rounded bg-primary px-6 py-2 text-sm font-bold tracking-widest text-white uppercase transition-colors hover:bg-primary/80"
class="rounded bg-primary px-6 py-2 text-sm font-bold tracking-widest text-primary-fg uppercase transition-colors hover:bg-primary/80"
>
{m.btn_create()}
</button>

View File

@@ -1,10 +1,15 @@
import { fail } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';
import type { PageServerLoad, Actions } from './$types';
import { createApiClient } from '$lib/api.server';
import { getErrorMessage } from '$lib/errors';
export const load: PageServerLoad = async ({ locals }) => {
return { user: locals.user };
const apiBase = () => env.API_INTERNAL_URL || 'http://localhost:8080';
export const load: PageServerLoad = async ({ locals, fetch }) => {
const res = await fetch(`${apiBase()}/api/users/me/notification-preferences`);
const notificationPrefs = res.ok ? await res.json() : null;
return { user: locals.user, notificationPrefs };
};
export const actions: Actions = {
@@ -50,5 +55,26 @@ export const actions: Actions = {
}
return { passwordSuccess: true };
},
updateNotificationPrefs: async ({ request, fetch }) => {
const formData = await request.formData();
const body = {
notifyOnReply: formData.get('notifyOnReply') === 'true',
notifyOnMention: formData.get('notifyOnMention') === 'true'
};
const res = await fetch(`${apiBase()}/api/users/me/notification-preferences`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
return fail(res.status, { prefsError: getErrorMessage(data?.code) });
}
return { prefsSuccess: true };
}
};

View File

@@ -1,9 +1,14 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { untrack } from 'svelte';
import { m } from '$lib/paraglide/messages.js';
import PersonalInfoForm from './PersonalInfoForm.svelte';
import PasswordChangeForm from './PasswordChangeForm.svelte';
let { data, form } = $props();
let notifyOnReply = $state(untrack(() => data.notificationPrefs?.notifyOnReply ?? false));
let notifyOnMention = $state(untrack(() => data.notificationPrefs?.notifyOnMention ?? false));
</script>
<div class="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
@@ -30,4 +35,54 @@ let { data, form } = $props();
<PersonalInfoForm user={data.user} form={form} />
<PasswordChangeForm form={form} />
</div>
<!-- Notification preferences -->
<div class="mt-6 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.notification_prefs_heading()}
</h2>
{#if form?.prefsSuccess}
<div class="mb-5 rounded border border-green-200 bg-green-50 p-3 text-sm text-green-700">
{m.profile_saved()}
</div>
{/if}
{#if form?.prefsError}
<div class="mb-5 rounded border border-red-200 bg-red-50 p-3 text-sm text-red-700">
{form.prefsError}
</div>
{/if}
<form method="POST" action="?/updateNotificationPrefs" use:enhance>
<input type="hidden" name="notifyOnReply" value={notifyOnReply} />
<input type="hidden" name="notifyOnMention" value={notifyOnMention} />
<div class="space-y-4">
<label class="flex cursor-pointer items-start gap-3">
<input
type="checkbox"
bind:checked={notifyOnReply}
class="mt-0.5 h-4 w-4 rounded border-line accent-primary"
/>
<span class="text-sm text-ink">{m.notification_pref_reply()}</span>
</label>
<label class="flex cursor-pointer items-start gap-3">
<input
type="checkbox"
bind:checked={notifyOnMention}
class="mt-0.5 h-4 w-4 rounded border-line accent-primary"
/>
<span class="text-sm text-ink">{m.notification_pref_mention()}</span>
</label>
</div>
<button
type="submit"
class="mt-5 rounded-sm bg-primary px-5 py-2 font-sans text-xs font-bold tracking-widest text-primary-fg uppercase transition-opacity hover:opacity-80"
>
{m.btn_save()}
</button>
</form>
</div>
</div>

View File

@@ -70,7 +70,7 @@ let {
<button
type="submit"
class="mt-5 rounded-sm bg-primary px-5 py-2 font-sans text-xs font-bold tracking-widest text-white uppercase transition-opacity hover:opacity-80"
class="mt-5 rounded-sm bg-primary px-5 py-2 font-sans text-xs font-bold tracking-widest text-primary-fg uppercase transition-opacity hover:opacity-80"
>
{m.btn_save()}
</button>

View File

@@ -115,7 +115,7 @@ function handleBirthDateInput(e: Event) {
<button
type="submit"
class="mt-5 rounded-sm bg-primary px-5 py-2 font-sans text-xs font-bold tracking-widest text-white uppercase transition-opacity hover:opacity-80"
class="mt-5 rounded-sm bg-primary px-5 py-2 font-sans text-xs font-bold tracking-widest text-primary-fg uppercase transition-opacity hover:opacity-80"
>
{m.btn_save()}
</button>

View File

@@ -12,9 +12,6 @@ let {
</script>
<div class="relative flex min-h-screen flex-col bg-surface">
<!-- Accent strip -->
<div class="h-1 bg-brand-purple"></div>
<div class="flex flex-1 items-center justify-center px-4">
<div class="w-full max-w-sm">
<!-- Logo -->
@@ -86,7 +83,7 @@ let {
<button
type="submit"
class="mt-2 w-full bg-primary py-2.5 font-sans text-xs font-bold tracking-widest text-white uppercase transition-colors hover:bg-primary/90"
class="mt-2 w-full bg-primary py-2.5 font-sans text-xs font-bold tracking-widest text-primary-fg uppercase transition-colors hover:bg-primary/90"
>
{m.reset_password_submit()}
</button>

View File

@@ -45,13 +45,13 @@ const initials = $derived.by(() => {
<div class="mb-5 flex justify-center">
{#if initials}
<div
class="flex h-16 w-16 items-center justify-center rounded-full bg-primary text-white"
class="flex h-16 w-16 items-center justify-center rounded-full bg-primary text-primary-fg"
>
<span class="font-serif text-xl font-bold">{initials}</span>
</div>
{:else}
<div
class="flex h-16 w-16 items-center justify-center rounded-full bg-primary text-white"
class="flex h-16 w-16 items-center justify-center rounded-full bg-primary text-primary-fg"
>
<svg
class="h-8 w-8"