Compare commits

...

23 Commits

Author SHA1 Message Date
Marcel
5bd7f0d486 docs(#240): add Mission Control Strip spec and pattern alternatives
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m25s
CI / Backend Unit Tests (push) Failing after 2m38s
CI / Unit & Component Tests (pull_request) Failing after 2m11s
CI / Backend Unit Tests (pull_request) Failing after 8h41m14s
Adds the design decision record for how to expand the dashboard without
pushing content below the fold: a full-width 3-column strip (Segmentierung /
Transkription / Lesefertig) below the existing grid.

- dashboard-expansion-patterns.html — four pattern alternatives evaluated
  (Tabs, Accordion, Mission Control, Priority Queue) with annotated mockups,
  engagement feature proposal, and final recommendation.
- mission-control-strip-final.html — clean implementation blueprint with
  pipeline diagram, column definitions, seeded-weekly-shuffle sorting,
  expert-flag escape hatch, all Tailwind impl-ref values, and backend
  contracts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 22:48:27 +02:00
4b8da0024f Merge pull request 'refactor(frontend): utility dedup, component splits, dead code removal (#193–#200)' (#241) from refactor/issues-193-200 into main
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m29s
CI / Backend Unit Tests (push) Failing after 2m33s
refactor(frontend): utility dedup, component splits, dead code removal (#193–#200)
2026-04-15 15:23:15 +02:00
Marcel
ed2c0231db test(drag-drop): add reorder logic tests for useBlockDragDrop
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m32s
CI / Backend Unit Tests (push) Failing after 2m34s
CI / Unit & Component Tests (pull_request) Failing after 2m29s
CI / Backend Unit Tests (pull_request) Failing after 2m38s
Adds simulateDragDrop helper and three tests covering the splice/insertAt
index arithmetic in handlePointerUp:
- move-to-end (insertAt path where target > fromIdx)
- move-to-start (insertAt path where target <= fromIdx)
- move-down-by-one (verifies the off-by-one dropTargetIdx - 1 branch)

Fixes @saraholt: "reorder calculation in handlePointerUp is untested"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 15:20:43 +02:00
Marcel
45490ebaac fix(a11y): increase nav label font size from 9px to 11px in EntityNavSection
text-[9px] is below WCAG practical minimum and unreadable for senior users.
Changed all three occurrences (tablet button count, desktop link label,
flyout link label) to text-[11px].

Fixes @leonievoss: "text-[9px] is below 12px minimum"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 15:16:37 +02:00
Marcel
7fb6ec04ab fix(i18n): replace hardcoded German edit hint in CommentMessage with Paraglide key
Adds comment_edit_hint key to de/en/es message files and replaces the
hardcoded "Enter speichern · Esc abbrechen" string in CommentMessage.svelte.

Fixes @felixbrandt + @leonievoss: "hardcoded German bypasses Paraglide"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 15:14:14 +02:00
Marcel
8739511058 test(notifications): add SSE event handling tests for useNotificationStream
Adds MockEventSource.simulate() helper and two tests covering:
- unread notification via SSE prepends to list and increments unreadCount
- read notification via SSE adds to list but does not increment unreadCount

Fixes @saraholt: "SSE event handling not tested"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 15:09:26 +02:00
Marcel
2b93ccf92d refactor(notifications): import relativeTime from canonical time.ts
NotificationDropdown was importing relativeTime through notifications.ts,
creating an accidental coupling to a module unrelated to timestamp formatting.
Now imports directly from the canonical \$lib/utils/time module.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 15:06:26 +02:00
Marcel
ff9ae198c4 refactor(notifications): extract useNotificationStream and NotificationDropdown from NotificationBell (#200)
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m38s
CI / Backend Unit Tests (push) Failing after 2m50s
CI / Unit & Component Tests (pull_request) Failing after 2m30s
CI / Backend Unit Tests (pull_request) Failing after 2m48s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 14:54:55 +02:00
Marcel
8898863a48 refactor(transcription): extract useBlockAutoSave and useBlockDragDrop from TranscriptionEditView (#199)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 14:45:03 +02:00
Marcel
eb8aa92cf0 refactor(pdf): extract usePdfRenderer and PdfControls from PdfViewer (#196)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 14:34:26 +02:00
Marcel
bc3fec11a9 refactor(comments): extract CommentMessage component from CommentThread (#198)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 14:23:25 +02:00
Marcel
fe6c247882 refactor(admin): extract EntityNavSection to eliminate nav markup repetition (#197)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 13:54:42 +02:00
Marcel
accfa5373e refactor(unsaved): extract createUnsavedWarning hook and UnsavedWarningBanner
Move the identical isDirty / beforeNavigate / discard pattern out of the
three admin detail pages (groups, tags, users) into a reusable
createUnsavedWarning() hook and a UnsavedWarningBanner presentational
component.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 13:31:17 +02:00
Marcel
34e7436fdc refactor(fileloader): extract createFileLoader hook from document/enrich pages
Move blob URL lifecycle management into a reusable createFileLoader()
hook that owns revoke-before-create and revoke-on-destroy. Replace
identical inline logic in documents/[id] and enrich/[id] with the hook.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 13:20:32 +02:00
Marcel
dbf7f0bc16 fix(fileloader): revoke blob URLs before re-assignment and on destroy
Calling loadFile a second time previously leaked the previous object URL.
Add URL.revokeObjectURL(fileUrl) before creating a new one and in
onDestroy so all URLs are freed. Revoke behavior will be covered by the
useFileLoader hook tests in the next commit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 13:13:21 +02:00
Marcel
8be876492c refactor(date): consolidate formatDate in date.ts with optional format param
Add format?: 'short'|'long' (default 'long') to date.ts formatDate and
remove the duplicate from personFormat.ts. Update DocumentTopBar to
import from date.ts directly. Move the formatDate tests from
personFormat.spec to date.spec.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 13:10:44 +02:00
Marcel
76d6f234b4 refactor(personFormat): replace getInitials(Person) with getInitials(name: string)
Unify the initials-extraction logic: the new string-based getInitials()
splits on whitespace, takes the first char of the first and last word
uppercased — matching the pattern that was already inlined in
CommentThread. Update PersonChip, DocumentMetadataDrawer, and
CommentThread to use the shared function.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 13:07:23 +02:00
Marcel
655a2003cb refactor(time): extract relativeTime into shared time.ts utility
Move relativeTime from notifications.ts (Intl.RelativeTimeFormat) to a
new time.ts that uses the Paraglide comment_time_* message keys — the
same logic that was already in CommentThread's timeAgo(). Remove the
duplicate timeAgo() from CommentThread and re-export relativeTime from
notifications.ts for backwards compatibility.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 13:02:49 +02:00
Marcel
c50845bcfc refactor(bell): migrate attachClickOutside to use:clickOutside action (#195)
Replace the inline attachClickOutside attachment in NotificationBell with
the shared use:clickOutside action from $lib/actions/clickOutside. The
inline implementation was functionally identical to the existing action.

Guard the onclickoutside handler so it only calls closeDropdown() when
the notification panel is already open, preventing the bell button from
stealing focus from other interactive elements (e.g. the user avatar menu).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 12:55:29 +02:00
Marcel
4446e80875 test(actions): add defaultPrevented coverage for clickOutside (#195)
The action already checks event.defaultPrevented before dispatching
clickoutside, but that branch had no test. Add the missing case and
add a one-line comment explaining why capture phase is used.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 12:46:04 +02:00
Marcel
731cdc75ab refactor(frontend): delete dead conversations/ route (#193)
Remove the old conversations page that was superseded by briefwechsel/.
No navigation link pointed to /conversations; it was unreachable through
the UI. Deletes 5 files, removes 14 orphaned i18n keys from de/en/es
message bundles, and removes E2E tests that navigated to /conversations.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 12:43:40 +02:00
Marcel
4b8e0637ce fix(ci): pin DOCKER_API_VERSION=1.43 for Testcontainers on NAS runner
Some checks failed
CI / Unit & Component Tests (pull_request) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
CI / Unit & Component Tests (push) Successful in 3m41s
CI / Backend Unit Tests (push) Failing after 2m41s
Testcontainers 2.0.2 (via Spring Boot 4.0) negotiates Docker API 1.44,
but the NAS runner has Docker Engine 24.x which caps at 1.43. Forcing
the client version down unblocks tests until Docker is upgraded on the NAS.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 12:28:57 +02:00
Marcel
793e632889 fix(lint): exclude project.inlang/ from Prettier
Some checks failed
CI / Unit & Component Tests (push) Successful in 3m49s
CI / Backend Unit Tests (push) Failing after 2m42s
CI / Unit & Component Tests (pull_request) Successful in 3m46s
CI / Backend Unit Tests (pull_request) Failing after 2m42s
Inlang regenerates .meta.json and README.md on every compilation run.
The regenerated files fail Prettier in CI because the tool writes its
own formatting, not ours.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 12:16:16 +02:00
59 changed files with 4437 additions and 2295 deletions

View File

@@ -52,6 +52,8 @@ jobs:
backend-unit-tests: backend-unit-tests:
name: Backend Unit Tests name: Backend Unit Tests
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
DOCKER_API_VERSION: "1.43" # NAS runner runs Docker 24.x (max API 1.43); Testcontainers 2.x defaults to 1.44
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,814 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Mission-Control-Streifen — Finale Spec (Issue #240)</title>
<style>
:root{
--navy:#002850;--mint:#A6DAD8;--sand:#E4E2D7;
--surface:#FAFAF7;--bg:#E8E7E2;--border:#D8D7D0;
--text:#1C1C18;--muted:#6B6A63;--subtle:#9B9A93;
--orange:#C26A00;--orange-bg:#FEF4E2;
--green:#2E6E39;--green-bg:#EAF5EA;
--purple:#5B5EA6;--purple-bg:#EEEDFE;
--font:system-ui,sans-serif;--mono:'Courier New',monospace;
}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0;}
body{font-family:var(--font);background:var(--bg);color:var(--text);font-size:14px;line-height:1.6;}
.doc{max-width:1100px;margin:0 auto;padding:48px 32px 96px;}
hr{border:none;border-top:1px solid var(--border);margin:48px 0;}
/* Header */
.hdr{background:var(--navy);color:#fff;padding:32px 32px 28px;border-radius:8px 8px 0 0;}
.hdr h1{font-family:Georgia,serif;font-size:26px;font-weight:400;letter-spacing:-.02em;margin-bottom:8px;}
.hdr-meta{font-family:var(--mono);font-size:11px;color:rgba(255,255,255,.45);margin-top:10px;}
.badge{display:inline-flex;align-items:center;padding:2px 8px;border-radius:4px;font-size:10px;font-weight:600;letter-spacing:.05em;background:var(--mint);color:var(--navy);}
.badge-g{background:rgba(255,255,255,.15);color:rgba(255,255,255,.9);}
.badges{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:10px;}
.decision-box{background:#fff;border:1px solid var(--border);border-top:none;border-radius:0 0 6px 6px;padding:20px 28px 24px;margin-bottom:40px;}
.decision-box h2{font-family:Georgia,serif;font-size:16px;font-weight:400;color:var(--navy);margin-bottom:8px;}
.prose{font-size:13px;color:var(--muted);line-height:1.65;max-width:720px;margin-bottom:10px;}
.prose:last-child{margin-bottom:0;}
/* Sections */
.sec{margin-bottom:52px;}
.sec-label{font-size:10px;font-weight:600;letter-spacing:.12em;text-transform:uppercase;color:var(--muted);padding-bottom:8px;border-bottom:1px solid var(--border);margin-bottom:22px;}
.sec-title{font-family:Georgia,serif;font-size:20px;font-weight:400;color:var(--navy);margin-bottom:4px;}
.sec-sub{font-size:13px;color:var(--muted);margin-bottom:16px;}
/* Tags */
.tag-list{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:14px;}
.tag{display:inline-block;padding:2px 8px;border-radius:4px;font-size:10px;font-weight:600;letter-spacing:.04em;}
.t-g{background:var(--green-bg);color:var(--green);}
.t-o{background:var(--orange-bg);color:var(--orange);}
.t-n{background:rgba(0,40,80,.08);color:var(--navy);}
.t-p{background:var(--purple-bg);color:var(--purple);}
/* Pipeline diagram */
.pipeline{display:flex;align-items:center;gap:6px;flex-wrap:wrap;padding:14px 18px;background:#fff;border:1px solid var(--border);border-radius:6px;margin-bottom:24px;}
.pipe-node{text-align:center;}
.pipe-badge{display:inline-block;padding:3px 10px;border-radius:4px;font-size:11px;font-weight:600;margin-bottom:4px;}
.pipe-badge.n1{background:rgba(0,40,80,.08);color:var(--navy);}
.pipe-badge.n2{background:rgba(0,40,80,.08);color:var(--navy);}
.pipe-badge.n3{background:rgba(0,40,80,.08);color:var(--navy);}
.pipe-badge.done{background:var(--green-bg);color:var(--green);}
.pipe-sub{font-size:10px;color:var(--muted);}
.pipe-arrow{font-size:16px;color:var(--border);flex-shrink:0;}
.pipe-col-label{font-size:9px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;margin-top:4px;}
.pipe-col-label.s{color:var(--navy);}
.pipe-col-label.t{color:var(--navy);}
.pipe-col-label.l{color:var(--green);}
/* Column definition grid */
.col-defs{display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;margin-bottom:28px;}
.col-def{background:#fff;border:1px solid var(--border);border-radius:6px;padding:14px;}
.col-def-title{font-size:10px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;margin-bottom:6px;}
.col-def-title.n{color:var(--navy);}
.col-def-title.g{color:var(--green);}
.col-def p{font-size:12px;color:var(--muted);line-height:1.5;margin-bottom:8px;}
.col-def code{font-family:var(--mono);font-size:10px;background:rgba(0,40,80,.06);padding:1px 4px;border-radius:2px;}
/* Callout */
.callout{display:flex;gap:12px;padding:14px 16px;border-radius:4px;margin-bottom:16px;font-size:12px;line-height:1.55;}
.callout.orange{background:var(--orange-bg);border-left:3px solid var(--orange);}
.callout.green{background:var(--green-bg);border-left:3px solid var(--green);}
.callout.navy{background:rgba(0,40,80,.05);border-left:3px solid var(--navy);}
.callout.purple{background:var(--purple-bg);border-left:3px solid var(--purple);}
.callout strong{font-weight:700;}
.callout strong.o{color:var(--orange);}
.callout strong.g{color:var(--green);}
.callout strong.n{color:var(--navy);}
.callout strong.p{color:var(--purple);}
/* Sorting options */
.sort-options{display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;margin-bottom:20px;}
.sort-opt{background:#fff;border:1px solid var(--border);border-radius:6px;padding:14px;position:relative;}
.sort-opt.rec{border-color:var(--navy);box-shadow:0 0 0 1px var(--navy);}
.sort-opt-rec-badge{position:absolute;top:-8px;right:10px;background:var(--navy);color:#fff;font-size:9px;font-weight:700;padding:2px 8px;border-radius:4px;letter-spacing:.05em;}
.sort-opt h4{font-size:12px;font-weight:700;color:var(--navy);margin-bottom:6px;}
.sort-opt p{font-size:11px;color:var(--muted);line-height:1.5;margin-bottom:8px;}
.sort-opt code{font-family:var(--mono);font-size:10px;background:rgba(0,40,80,.06);padding:1px 4px;border-radius:2px;display:block;margin-top:6px;line-height:1.6;}
/* Frames */
.frames-row{display:flex;gap:24px;flex-wrap:wrap;align-items:flex-start;margin-bottom:16px;}
.caption{font-family:var(--mono);font-size:10px;color:var(--muted);display:block;margin-top:6px;}
/* Desktop frame */
.frame-desktop{background:var(--surface);border-radius:8px;overflow:hidden;border:1px solid var(--border);box-shadow:0 4px 16px rgba(0,0,0,.08);}
.f-nav{height:26px;background:var(--navy);display:flex;align-items:center;padding:0 8px;gap:5px;}
.f-logo{font-size:6.5px;font-weight:700;color:#fff;letter-spacing:.7px;border-bottom:1px solid var(--mint);padding-bottom:1px;}
.f-navlinks{display:flex;gap:5px;margin-left:8px;}
.f-navlink{font-size:5.5px;color:rgba(255,255,255,.4);font-weight:600;text-transform:uppercase;}
.f-navlink.on{color:rgba(255,255,255,.9);}
.f-navr{margin-left:auto;}
.f-av{width:14px;height:14px;border-radius:50%;background:rgba(255,255,255,.12);display:flex;align-items:center;justify-content:center;font-size:4.5px;font-weight:800;color:rgba(255,255,255,.5);}
.f-body{padding:10px;}
.f-search{background:#fff;border:1px solid var(--border);border-radius:4px;height:24px;display:flex;align-items:center;padding:0 8px;gap:5px;margin-bottom:5px;}
.f-si{font-size:9px;color:var(--muted);}
.f-st{font-size:7.5px;color:var(--subtle);flex:1;}
.f-resume{background:var(--mint);opacity:.2;height:7px;border-radius:3px;margin-bottom:8px;}
.f-grid-2{display:grid;grid-template-columns:1fr 155px;gap:7px;margin-bottom:7px;}
.f-grid-3{display:grid;grid-template-columns:1fr 1fr 1fr;gap:6px;}
.f-card{background:#fff;border:1px solid var(--sand);border-radius:3px;padding:7px;}
.f-ht{font-size:6px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--muted);margin-bottom:5px;}
.f-ht.o{color:var(--orange);}
.f-ht.g{color:var(--green);}
.f-ht.n{color:var(--navy);}
.f-row{border-bottom:1px solid var(--sand);padding:3px 0;}
.f-row:last-of-type{border-bottom:none;}
.f-dn{font-family:Georgia,serif;font-size:7.5px;color:var(--navy);line-height:1.3;}
.f-ds{font-size:6px;color:var(--muted);margin-top:1px;}
.f-dd{font-size:5.5px;color:var(--subtle);margin-left:auto;white-space:nowrap;flex-shrink:0;padding-top:1px;}
.f-lnk{font-size:6px;color:var(--navy);display:block;margin-top:5px;}
.f-lnk.g{color:var(--green);}
.f-stat{font-size:5.5px;color:var(--muted);margin-top:5px;}
.f-dz{border:1.5px dashed var(--mint);background:rgba(166,218,216,.07);border-radius:3px;padding:7px;text-align:center;}
.f-dz-i{font-size:12px;color:var(--navy);opacity:.35;margin-bottom:2px;}
.f-dz-t{font-size:6px;font-weight:700;color:var(--navy);}
.f-dz-s{font-size:5px;color:var(--muted);}
.rhs{display:flex;flex-direction:column;gap:6px;}
/* Strip columns */
.strip-col{border-radius:3px;padding:6px;display:flex;flex-direction:column;gap:4px;}
.strip-col.seg{background:rgba(0,40,80,.03);border:1px solid var(--sand);}
.strip-col.trans{background:rgba(0,40,80,.03);border:1px solid var(--sand);}
.strip-col.done{background:rgba(166,218,216,.10);border:1px solid var(--mint);}
.strip-col.done-empty{background:rgba(166,218,216,.06);border:1.5px dashed var(--mint);align-items:center;justify-content:center;text-align:center;min-height:100px;}
/* Skill pill */
.skill-pill{display:inline-flex;align-items:center;padding:1px 5px;border-radius:8px;font-size:5px;font-weight:700;margin-bottom:3px;}
.skill-pill.easy{background:var(--green-bg);border:1px solid rgba(46,110,57,.2);color:var(--green);}
.skill-pill.kurrent{background:rgba(0,40,80,.08);border:1px solid rgba(0,40,80,.15);color:var(--navy);}
/* Pulse */
.pulse{display:flex;align-items:center;gap:4px;margin-bottom:3px;}
.pulse-num{font-size:5.5px;font-weight:700;}
.pulse-num.g{color:var(--green);}
.pulse-num.n{color:var(--navy);}
.pulse-open{font-size:5px;color:var(--muted);}
/* Avatars */
.avatars{display:flex;gap:2px;margin-bottom:4px;}
.av-sm{width:10px;height:10px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:4px;font-weight:700;color:#fff;}
.av-more{font-size:5px;color:var(--muted);line-height:10px;margin-left:2px;}
/* Per-doc bar */
.doc-bar-row{display:flex;flex-direction:column;gap:2px;border-bottom:1px solid var(--sand);padding-bottom:4px;}
.doc-bar-row:last-child{border-bottom:none;}
.bar-track{flex:1;height:3px;background:rgba(0,40,80,.12);border-radius:2px;overflow:hidden;}
.bar-fill{height:100%;background:var(--navy);border-radius:2px;}
.bar-label{font-size:5px;color:var(--muted);white-space:nowrap;}
/* CTA button */
.cta-btn{display:block;font-size:6px;font-weight:700;color:#fff;background:var(--navy);border-radius:2px;padding:3px 6px;text-align:center;margin-top:3px;}
.cta-btn.ghost{background:transparent;color:var(--navy);border:1px solid var(--navy);}
/* Expert badge */
.expert-badge{display:inline-flex;align-items:center;gap:2px;padding:1px 4px;border-radius:3px;font-size:5px;font-weight:700;background:var(--purple-bg);color:var(--purple);border:1px solid rgba(91,94,166,.2);margin-left:3px;}
/* Phone frame */
.frame-phone{width:200px;flex-shrink:0;background:var(--surface);border-radius:24px;overflow:hidden;box-shadow:0 4px 20px rgba(0,0,0,.12),0 0 0 1px rgba(0,0,0,.06);display:flex;flex-direction:column;border:4px solid #1C1C18;}
.ph-nav{height:20px;background:var(--navy);display:flex;align-items:center;padding:0 6px;}
.ph-logo{font-size:5.5px;font-weight:700;color:#fff;letter-spacing:.6px;border-bottom:1px solid var(--mint);padding-bottom:1px;}
.ph-body{flex:1;overflow:hidden;padding:6px;display:flex;flex-direction:column;gap:4px;}
.ph-search{background:#fff;border:1px solid var(--border);border-radius:3px;height:18px;display:flex;align-items:center;padding:0 6px;}
.ph-st{font-size:6.5px;color:var(--subtle);flex:1;}
/* impl-ref */
.impl-ref{margin-top:20px;}
.impl-ref table{width:100%;border-collapse:collapse;font-size:12px;}
.impl-ref th{background:var(--navy);color:#fff;padding:6px 10px;text-align:left;font-size:10px;font-weight:600;letter-spacing:.06em;}
.impl-ref td{padding:7px 10px;border-bottom:1px solid var(--border);vertical-align:top;}
.impl-ref tr:nth-child(even) td{background:var(--surface);}
.impl-ref code{font-family:var(--mono);font-size:11px;background:rgba(0,40,80,.06);padding:1px 4px;border-radius:2px;}
/* Component list */
.comp-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:20px;}
.comp-card{background:#fff;border:1px solid var(--border);border-radius:6px;padding:14px;}
.comp-card h4{font-size:12px;font-weight:700;color:var(--navy);margin-bottom:4px;}
.comp-card p{font-size:11px;color:var(--muted);line-height:1.5;}
.comp-card code{font-family:var(--mono);font-size:10px;background:rgba(0,40,80,.06);padding:1px 4px;border-radius:2px;}
</style>
</head>
<body>
<div class="doc">
<!-- ── HEADER ───────────────────────────────────────────────────────── -->
<div class="hdr">
<h1>Mission-Control-Streifen — Finale Spec</h1>
<div class="badges">
<span class="badge">Issue #240</span>
<span class="badge badge-g">Leonie Voss — UX &amp; Accessibility</span>
<span class="badge badge-g">15. April 2026</span>
<span class="badge badge-g">v3 — Final</span>
</div>
<div class="hdr-meta">src/routes/+page.svelte · src/lib/components/DashboardMissionControl.svelte · +page.server.ts</div>
</div>
<div class="decision-box">
<h2>Entscheidung</h2>
<p class="prose">
Der bestehende Dashboard-Aufbau (Neueste Aktivität links, DropZone + Metadaten-Widget rechts) bleibt unverändert.
Unterhalb des Zwei-Spalten-Gitters erscheint ein neuer vollbreiter <strong>Mission-Control-Streifen</strong> mit drei
gleichwertigen Spalten: <em>Rahmen einzeichnen</em> (Segmentierung, kein Vorwissen nötig),
<em>Text eintippen</em> (Transkription, Kurrent hilfreich), <em>Lesefertig ✓</em> (Belohnungsbereich).
</p>
<p class="prose">
Die „Transkription fehlt"-Spalte aus Issue #240 wird in Segmentierung + Transkription aufgeteilt, um
eine klare Beitragspyramide zu schaffen: Jeder kann Rahmen einzeichnen — nicht jeder kann Kurrent lesen.
Ein wöchentlich rotierender Sort mit <em>Experten-gesucht</em>-Escape-Hatch verhindert, dass schwer lesbare
Dokumente dauerhaft die Spalte blockieren.
</p>
</div>
<!-- ── PIPELINE ─────────────────────────────────────────────────────── -->
<div class="sec">
<div class="sec-label">Dokument-Lebenszyklus</div>
<div class="pipeline">
<div class="pipe-node">
<div class="pipe-badge n1">Kein Segment</div>
<div class="pipe-sub">0 Annotationen</div>
<div class="pipe-col-label s">→ Spalte 1</div>
</div>
<div class="pipe-arrow"></div>
<div class="pipe-node">
<div class="pipe-badge n2">Segmentiert</div>
<div class="pipe-sub">Rahmen da, wenig Text</div>
<div class="pipe-col-label t">→ Spalte 2</div>
</div>
<div class="pipe-arrow"></div>
<div class="pipe-node">
<div class="pipe-badge n3">In Review</div>
<div class="pipe-sub">Text da, reviewed &lt; 90 %</div>
<div class="pipe-col-label t">→ Spalte 2</div>
</div>
<div class="pipe-arrow"></div>
<div class="pipe-node">
<div class="pipe-badge done">Lesefertig ✓</div>
<div class="pipe-sub">reviewed ≥ 90 %</div>
<div class="pipe-col-label l">→ Spalte 3</div>
</div>
<div style="margin-left:auto;font-size:11px;color:var(--muted);max-width:200px;line-height:1.4;">
„Segmentiert" und „In Review" landen beide in Spalte 2 —
unterschieden durch den per-Dokument-Balken (0 Blöcke vs. N Blöcke).
</div>
</div>
<!-- Column definitions -->
<div class="col-defs">
<div class="col-def">
<div class="col-def-title n">Spalte 1 — Rahmen einzeichnen</div>
<p>Dokumente ohne Annotationsrahmen. Kein Kurrent nötig — Textblöcke markieren reicht.</p>
<p><strong>Bedingung:</strong> <code>annotation_count = 0</code></p>
<p><strong>Sort:</strong> Wöchentliche Rotation (seeded shuffle, s. u.)</p>
<p><strong>Fortschritt:</strong> Wochenpuls „↑ +5 diese Woche", kein globaler Balken</p>
</div>
<div class="col-def">
<div class="col-def-title n">Spalte 2 — Text eintippen</div>
<p>Annotationen vorhanden, aber Text fehlt oder reviewed &lt; 90 %. Kurrent-Kenntnisse hilfreich.</p>
<p><strong>Bedingung:</strong> <code>annotation_count &gt; 0 AND reviewed_pct &lt; 0.90</code></p>
<p><strong>Sort:</strong> Teilfortschritt zuerst, dann wöchentliche Rotation; <code>needsExpert</code>-Flagge schiebt nach hinten</p>
<p><strong>Fortschritt:</strong> Per-Dokument-Balken „3 / 8 Blöcke"</p>
</div>
<div class="col-def" style="background:rgba(166,218,216,.06);border-color:var(--mint);">
<div class="col-def-title g">Spalte 3 — Lesefertig ✓</div>
<p>Reviewed ≥ 90 %. Keine Aufgabe — Einladung zum Lesen.</p>
<p><strong>Bedingung:</strong> <code>reviewed_pct &gt;= 0.90</code></p>
<p><strong>Sort:</strong> Neueste zuerst</p>
<p><strong>Fortschritt:</strong> „94 % geprüft" als Text — kein Balken, die mint-Spalte ist das Signal</p>
<p><strong>Leerstand:</strong> Cross-Column-Redirect zu Spalte 1</p>
</div>
</div>
</div>
<hr/>
<!-- ── HARD DOCUMENTS PROBLEM ─────────────────────────────────────────── -->
<div class="sec">
<div class="sec-label">Sortierstrategie — Das „zu schwer"-Problem</div>
<div class="sec-title">Schwer lesbare Dokumente blockieren die Spalte</div>
<div class="sec-sub">Wenn dieselben 3 Dokumente immer oben stehen und niemand sie lesen kann, stoppt die Transkription komplett.</div>
<div class="callout orange">
<div><strong class="o">Problem:</strong> Bei 1 500 Dokumenten ohne Transkription und sortiert nach <code>updated_at</code>
können dieselben 3 besonders schwer lesbaren Dokumente dauerhaft die Spalte blockieren.
Jeder öffnet sie, gibt auf, und die Spalte wird zur Sackgasse.</div>
</div>
<div class="sort-options">
<!-- Option 1 -->
<div class="sort-opt">
<h4>Option 1 — Zufällig pro Seitenaufruf</h4>
<p><code>ORDER BY RANDOM()</code></p>
<p>Jeder Besuch zeigt andere Dokumente. Kein Aufwand, aber chaotisch — kein Nutzer sieht ein Dokument zweimal,
kann nicht gezielt zurückkehren.</p>
<div class="tag-list"><span class="tag t-g">+ Null Aufwand</span><span class="tag t-o"> Chaotisch</span><span class="tag t-o"> Kein stabiles Lesezeichen</span></div>
</div>
<!-- Option 2 — RECOMMENDED -->
<div class="sort-opt rec">
<div class="sort-opt-rec-badge">★ Empfohlen</div>
<h4>Option 2 — Teilfortschritt + wöchentliche Rotation</h4>
<p>Dokumente mit Teilfortschritt (3/8 Blöcke) erscheinen zuerst — am ehesten abschließbar. Dokumente mit 0 Blöcken rotieren wöchentlich durch einen deterministischen Shuffle.</p>
<code>ORDER BY textedBlocks DESC,
HASHTEXT(id || EXTRACT(WEEK FROM NOW())::text)</code>
<div class="tag-list" style="margin-top:8px;"><span class="tag t-g">+ Konsistent innerhalb einer Woche</span><span class="tag t-g">+ Bringt leichte Dokumente an die Oberfläche</span><span class="tag t-g">+ Kein neues Datenbankfeld</span></div>
</div>
<!-- Option 3 -->
<div class="sort-opt">
<h4>Option 3 — Manuelle Schwierigkeitsbewertung</h4>
<p>Beitragende bewerten Dokumente 13 nach Versuch. Einfache Dokumente erscheinen zuerst.</p>
<p>Beste Langzeitlösung — braucht aber Bewertungs-UI auf der Enrich-Seite und Signalakkumulation.</p>
<div class="tag-list"><span class="tag t-g">+ Selbstverbessernd</span><span class="tag t-o"> UI-Aufwand</span><span class="tag t-o"> Braucht Zeit bis Signal</span></div>
</div>
</div>
<!-- Escape hatch -->
<div class="callout navy">
<div>
<strong class="n">Escape-Hatch: „Experten gesucht"-Flagge (Option 2 ergänzen)</strong><br/>
Im Enrich-Bereich: ein einzelner Button „Zu schwer — Hilfe gesucht".
Setzt <code>Document.needsExpert = true</code> (1 Boolean, keine Migration wenn Flyway-Migration V{n} hinzugefügt wird).
In der Transkriptions-Spalte zeigen flagged Dokumente einen lila Badge und werden hinter unflagged Dokumenten einsortiert.
Kein Leaderboard, keine Scham — nur ein ehrliches Signal an die Community.
</div>
</div>
<!-- Expert badge mockup -->
<div style="background:#fff;border:1px solid var(--border);border-radius:6px;padding:16px;margin-bottom:16px;">
<div style="font-size:10px;font-weight:600;letter-spacing:.1em;text-transform:uppercase;color:var(--muted);margin-bottom:10px;">Mockup: Experten-gesucht-Badge in der Transkriptions-Zeile</div>
<div style="display:flex;flex-direction:column;gap:4px;max-width:380px;">
<!-- Normal doc -->
<div style="display:flex;flex-direction:column;gap:3px;padding:8px;border:1px solid var(--sand);border-radius:3px;">
<div style="font-family:Georgia,serif;font-size:13px;color:var(--navy);">Reisepass Opa Heinrich <span style="font-family:system-ui;font-size:10px;font-weight:600;background:rgba(0,40,80,.07);color:var(--navy);padding:1px 6px;border-radius:4px;">3 / 8 Blöcke</span></div>
<div style="display:flex;align-items:center;gap:6px;">
<div style="flex:1;height:4px;background:rgba(0,40,80,.12);border-radius:2px;overflow:hidden;"><div style="width:37%;height:100%;background:var(--navy);border-radius:2px;"></div></div>
<div style="font-size:11px;color:var(--muted);">37 %</div>
</div>
</div>
<!-- Expert-needed doc -->
<div style="display:flex;flex-direction:column;gap:3px;padding:8px;border:1px solid rgba(91,94,166,.25);background:rgba(91,94,166,.03);border-radius:3px;">
<div style="font-family:Georgia,serif;font-size:13px;color:var(--navy);">Standesamt Breslau 1872
<span style="font-family:system-ui;font-size:10px;font-weight:600;background:var(--purple-bg);color:var(--purple);padding:1px 6px;border-radius:4px;border:1px solid rgba(91,94,166,.2);">Experten gesucht</span>
</div>
<div style="font-size:11px;color:var(--muted);">Schrift besonders schwer lesbar — Hilfe willkommen</div>
</div>
</div>
</div>
<div class="impl-ref">
<table>
<thead><tr><th>Element</th><th>SQL / Tailwind</th><th>Wert</th><th>Hinweis</th></tr></thead>
<tbody>
<tr><td>Sort Transkription</td><td><code>ORDER BY textedBlocks DESC, HASHTEXT(id::text || EXTRACT(WEEK FROM NOW())::int::text)</code></td><td></td><td>Kein neues Feld nötig; ändert sich automatisch jede Woche</td></tr>
<tr><td><code>needsExpert</code>-Flag</td><td><code>ALTER TABLE documents ADD COLUMN needs_expert BOOLEAN NOT NULL DEFAULT FALSE</code></td><td>Flyway <code>V{n}__add_needs_expert.sql</code></td><td>Flagged Docs ans Ende: <code>ORDER BY needs_expert ASC, ...</code></td></tr>
<tr><td>Experten-Badge</td><td><code>inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold bg-purple-50 border border-purple-200 text-purple-700</code></td><td>Kontrast 6,8:1 ✓</td><td>Nur wenn <code>doc.needsExpert === true</code></td></tr>
<tr><td>„Zu schwer"-Button (Enrich)</td><td><code>text-xs text-gray-400 hover:text-gray-600 underline underline-offset-2</code></td><td></td><td>Unscheinbar — kein roter Knopf, keine Scham</td></tr>
<tr><td>Endpoint (Flagge setzen)</td><td><code>PATCH /api/documents/{id}/needs-expert</code></td><td><code>@RequirePermission(READ_ALL)</code></td><td>Jeder angemeldete Nutzer darf flaggen</td></tr>
</tbody>
</table>
</div>
</div>
<hr/>
<!-- ── DESKTOP MOCKUP — FILLED STATE ─────────────────────────────────── -->
<div class="sec">
<div class="sec-label">Mockup — Desktop, normaler Zustand</div>
<div class="frames-row">
<div style="flex:1;min-width:0;">
<div class="frame-desktop">
<div class="f-nav">
<div class="f-logo">FAMILIENARCHIV</div>
<div class="f-navlinks"><div class="f-navlink on">Archiv</div><div class="f-navlink">Personen</div><div class="f-navlink">Gespräche</div></div>
<div class="f-navr"><div class="f-av">MR</div></div>
</div>
<div class="f-body">
<div class="f-search"><div class="f-si"></div><div class="f-st">Dokumente durchsuchen…</div></div>
<div class="f-resume"></div>
<!-- Existing grid — unchanged -->
<div class="f-grid-2">
<div class="f-card">
<div class="f-ht">Neueste Aktivität</div>
<div class="f-row" style="display:flex;"><div><div class="f-dn">Brief von Oma Martha, 1943</div><div class="f-ds">Karl Raddatz</div></div><div class="f-dd">12. Apr</div></div>
<div class="f-row" style="display:flex;"><div><div class="f-dn">Taufurkunde Karl Raddatz</div><div class="f-ds">Standesamt</div></div><div class="f-dd">9. Apr</div></div>
<div class="f-row" style="display:flex;"><div><div class="f-dn">Postkarte aus Breslau</div><div class="f-ds">Martha Raddatz</div></div><div class="f-dd">7. Apr</div></div>
<div class="f-row" style="display:flex;"><div><div class="f-dn">Familienfoto Sommer 1952</div><div class="f-ds">Unbekannt</div></div><div class="f-dd">3. Apr</div></div>
<div class="f-stat">47 Dokumente · 12 Personen</div>
</div>
<div class="rhs">
<div class="f-dz"><div class="f-dz-i"></div><div class="f-dz-t">Datei hochladen</div><div class="f-dz-s">Drag &amp; Drop</div></div>
<div class="f-card" style="flex:1;">
<div class="f-ht o">Metadaten fehlen</div>
<div class="f-row"><div class="f-dn">Familienfoto 1952</div><div class="f-ds">Titel fehlt</div></div>
<div class="f-row"><div class="f-dn">Standesamtsurkunde</div><div class="f-ds">Datum fehlt</div></div>
<a class="f-lnk">Alle 5 anzeigen →</a>
</div>
</div>
</div>
<!-- ★ Mission Control Strip -->
<div style="background:#fff;border:1px solid var(--sand);border-radius:3px;padding:8px;">
<div class="f-ht" style="margin-bottom:7px;">Was braucht Aufmerksamkeit?</div>
<div class="f-grid-3">
<!-- Col 1: SEGMENTIERUNG -->
<div class="strip-col seg">
<div>
<div class="f-ht n" style="margin-bottom:2px;">Rahmen einzeichnen</div>
<div class="skill-pill easy">✓ Ohne Vorkenntnisse</div>
<div class="pulse"><span class="pulse-num g">↑ +5 diese Woche</span><span class="pulse-open">· 1 480 offen</span></div>
<div class="avatars">
<div class="av-sm" style="background:var(--navy);">MR</div>
<div class="av-sm" style="background:var(--purple);">TG</div>
<div class="av-sm" style="background:#8C6E3F;">AS</div>
<div class="av-more">+ 2</div>
</div>
</div>
<div class="f-row"><div class="f-dn">Taufurkunde Karl R.</div><div class="f-ds">Noch keine Rahmen</div></div>
<div class="f-row"><div class="f-dn">Standesamt 1889</div><div class="f-ds">Noch keine Rahmen</div></div>
<div class="f-row"><div class="f-dn">Heiratsurkunde 1921</div><div class="f-ds">Noch keine Rahmen</div></div>
<a class="cta-btn">Jetzt einzeichnen →</a>
</div>
<!-- Col 2: TRANSKRIPTION with per-doc bar + expert badge -->
<div class="strip-col trans">
<div>
<div class="f-ht n" style="margin-bottom:2px;">Text eintippen</div>
<div class="skill-pill kurrent">Kurrent hilfreich</div>
<div class="pulse"><span class="pulse-num n">↑ +2 diese Woche</span><span class="pulse-open">· 8 offen</span></div>
<div class="avatars">
<div class="av-sm" style="background:var(--navy);">MR</div>
<div class="av-more">1 Person</div>
</div>
</div>
<!-- Per-document bar — partial progress first -->
<div class="doc-bar-row">
<div class="f-dn">Reisepass Opa Heinrich</div>
<div style="display:flex;align-items:center;gap:3px;"><div class="bar-track"><div class="bar-fill" style="width:37%;"></div></div><div class="bar-label">3 / 8 Blöcke</div></div>
</div>
<div class="doc-bar-row">
<div class="f-dn">Brief v. Oma Martha 1943</div>
<div style="display:flex;align-items:center;gap:3px;"><div class="bar-track"><div class="bar-fill" style="width:0%;"></div></div><div class="bar-label">0 / 6 Blöcke</div></div>
</div>
<!-- Expert-needed doc — sorted last -->
<div class="doc-bar-row" style="border-color:rgba(91,94,166,.2);background:rgba(91,94,166,.03);padding:2px 3px;">
<div style="display:flex;align-items:center;flex-wrap:wrap;gap:2px;"><div class="f-dn">Standesamt Breslau 1872</div><span class="expert-badge">Experten gesucht</span></div>
<div class="f-ds">Schrift besonders schwer lesbar</div>
</div>
<a class="cta-btn">Jetzt tippen →</a>
</div>
<!-- Col 3: LESEFERTIG — filled -->
<div class="strip-col done">
<div>
<div class="f-ht g" style="margin-bottom:2px;">Lesefertig ✓</div>
<div style="font-size:5.5px;color:var(--green);font-weight:600;margin-bottom:4px;">3 Dokumente bereit</div>
<div class="avatars">
<div class="av-sm" style="background:var(--green);">MR</div>
<div class="av-sm" style="background:var(--purple);">TG</div>
</div>
</div>
<div class="doc-bar-row" style="border-color:rgba(166,218,216,.4);">
<div class="f-dn">Postkarte aus Breslau 1943</div>
<div style="font-size:5.5px;color:var(--green);font-weight:600;">100 % geprüft</div>
</div>
<div class="doc-bar-row" style="border-color:rgba(166,218,216,.4);">
<div class="f-dn">Brief Oma Martha 1938</div>
<div style="font-size:5.5px;color:var(--green);font-weight:600;">95 % geprüft</div>
</div>
<div class="doc-bar-row" style="border-color:rgba(166,218,216,.4);">
<div class="f-dn">Heiratsurkunde 1921</div>
<div style="font-size:5.5px;color:var(--green);font-weight:600;">91 % geprüft</div>
</div>
<a class="f-lnk g" style="margin-top:3px;">Alle 3 lesen →</a>
</div>
</div>
</div>
</div>
</div>
<span class="caption">Desktop (55 %) — normaler Zustand: Teilfortschritt oben, Experten-gesucht-Dokument unten in Spalte 2</span>
</div>
</div>
</div>
<!-- ── DESKTOP MOCKUP — EARLY STATE (Lesefertig leer) ───────────────── -->
<div class="sec">
<div class="sec-label">Mockup — Desktop, frühe Projektphase (Lesefertig leer)</div>
<div class="frames-row">
<div style="flex:1;min-width:0;">
<div class="frame-desktop">
<div class="f-nav">
<div class="f-logo">FAMILIENARCHIV</div>
<div class="f-navlinks"><div class="f-navlink on">Archiv</div><div class="f-navlink">Personen</div></div>
<div class="f-navr"><div class="f-av">MR</div></div>
</div>
<div class="f-body">
<div class="f-search"><div class="f-si"></div><div class="f-st">Dokumente durchsuchen…</div></div>
<div class="f-resume"></div>
<div class="f-grid-2">
<div class="f-card">
<div class="f-ht">Neueste Aktivität</div>
<div class="f-row" style="display:flex;"><div><div class="f-dn">Brief von Oma Martha, 1943</div></div><div class="f-dd">12. Apr</div></div>
<div class="f-row" style="display:flex;"><div><div class="f-dn">Taufurkunde Karl Raddatz</div></div><div class="f-dd">9. Apr</div></div>
<div class="f-stat">1 500 Dokumente · 12 Personen</div>
</div>
<div class="rhs">
<div class="f-dz"><div class="f-dz-i"></div><div class="f-dz-t">Datei hochladen</div><div class="f-dz-s">Drag &amp; Drop</div></div>
<div class="f-card" style="flex:1;">
<div class="f-ht o">Metadaten fehlen</div>
<div class="f-row"><div class="f-dn">Familienfoto 1952</div></div>
<div class="f-row"><div class="f-dn">Standesamtsurkunde</div></div>
<a class="f-lnk">Alle anzeigen →</a>
</div>
</div>
</div>
<div style="background:#fff;border:1px solid var(--sand);border-radius:3px;padding:8px;">
<div class="f-ht" style="margin-bottom:7px;">Was braucht Aufmerksamkeit?</div>
<div class="f-grid-3">
<div class="strip-col seg">
<div>
<div class="f-ht n" style="margin-bottom:2px;">Rahmen einzeichnen</div>
<div class="skill-pill easy">✓ Ohne Vorkenntnisse</div>
<div class="pulse"><span class="pulse-num g">↑ +3 diese Woche</span><span class="pulse-open">· 1 498 offen</span></div>
<div class="avatars"><div class="av-sm" style="background:var(--navy);">MR</div><div class="av-more">1 Person</div></div>
</div>
<div class="f-row"><div class="f-dn">Taufurkunde Karl R.</div></div>
<div class="f-row"><div class="f-dn">Standesamt 1889</div></div>
<div class="f-row"><div class="f-dn">Heiratsurkunde 1921</div></div>
<a class="cta-btn">Jetzt einzeichnen →</a>
</div>
<div class="strip-col trans">
<div>
<div class="f-ht n" style="margin-bottom:2px;">Text eintippen</div>
<div class="skill-pill kurrent">Kurrent hilfreich</div>
<div class="pulse"><span class="pulse-num n">↑ +1 diese Woche</span><span class="pulse-open">· 2 offen</span></div>
<div class="avatars"><div class="av-sm" style="background:var(--navy);">MR</div><div class="av-more">1 Person</div></div>
</div>
<div class="doc-bar-row">
<div class="f-dn">Brief v. Oma Martha 1943</div>
<div style="display:flex;align-items:center;gap:3px;"><div class="bar-track"><div class="bar-fill" style="width:0%;"></div></div><div class="bar-label">0 / 6 Blöcke</div></div>
</div>
<div class="doc-bar-row">
<div class="f-dn">Reisepass Opa Heinrich</div>
<div style="display:flex;align-items:center;gap:3px;"><div class="bar-track"><div class="bar-fill" style="width:0%;"></div></div><div class="bar-label">0 / 4 Blöcke</div></div>
</div>
<a class="cta-btn">Jetzt tippen →</a>
</div>
<!-- Lesefertig EMPTY — cross-column redirect -->
<div class="strip-col done-empty">
<div style="font-size:11px;color:var(--mint);margin-bottom:3px;"></div>
<div style="font-size:6.5px;font-weight:700;color:var(--navy);margin-bottom:3px;">Noch kein Dokument lesefertig</div>
<div style="font-size:5.5px;color:var(--muted);line-height:1.5;max-width:105px;margin-bottom:5px;">Erscheint hier sobald die Transkription abgeschlossen ist.</div>
<a class="cta-btn ghost" style="font-size:5.5px;padding:2px 7px;">Jetzt mithelfen →</a>
</div>
</div>
</div>
</div>
</div>
<span class="caption">Desktop (55 %) — frühe Phase: 1 500 Dokumente ohne Transkription, Wochenpuls zeigt Schwung statt Berg</span>
</div>
</div>
</div>
<hr/>
<!-- ── MOBILE MOCKUP ─────────────────────────────────────────────────── -->
<div class="sec">
<div class="sec-label">Mockup — Mobil 320 px</div>
<p class="prose" style="margin-bottom:16px;">
Die rechte Spalte (DropZone + Metadaten) erscheint auf Mobil zuerst im DOM (<code>lg:order-last</code> schiebt sie auf Desktop nach rechts).
Der Streifen stapelt seine drei Spalten vertikal. Jede Spalte hat volle Breite — keine Overflow-Probleme.
</p>
<div class="frames-row">
<!-- Phone: filled state -->
<div>
<div class="frame-phone" style="height:620px;">
<div class="ph-nav"><div class="ph-logo">FAMILIENARCHIV</div></div>
<div class="ph-body" style="overflow-y:auto;">
<div class="ph-search"><div class="ph-st">⌕ Dokumente…</div></div>
<!-- Right col first on mobile -->
<div class="f-dz" style="padding:5px;"><div class="f-dz-i" style="font-size:10px;"></div><div class="f-dz-t">Hochladen</div></div>
<div class="f-card" style="padding:5px;">
<div class="f-ht o">Metadaten fehlen</div>
<div class="f-row"><div class="f-dn">Familienfoto 1952</div></div>
<div class="f-row"><div class="f-dn">Standesamtsurkunde</div></div>
</div>
<!-- Left col (recent) -->
<div class="f-card" style="padding:5px;">
<div class="f-ht">Neueste Aktivität</div>
<div class="f-row"><div class="f-dn">Brief von Oma Martha</div></div>
<div class="f-row"><div class="f-dn">Taufurkunde Karl R.</div></div>
<div class="f-stat">1 500 Dok. · 12 Pers.</div>
</div>
<!-- Strip — stacked on mobile -->
<div style="background:#fff;border:1px solid var(--sand);border-radius:3px;padding:5px;display:flex;flex-direction:column;gap:4px;">
<div class="f-ht" style="margin-bottom:3px;">Was braucht Aufmerksamkeit?</div>
<!-- Seg -->
<div class="strip-col seg" style="padding:5px;">
<div class="f-ht n" style="margin-bottom:1px;">Rahmen einzeichnen</div>
<div class="skill-pill easy">✓ Ohne Vorkenntnisse</div>
<div class="pulse" style="margin-bottom:2px;"><span class="pulse-num g">↑ +5 diese Woche</span><span class="pulse-open">· 1 480 offen</span></div>
<div class="f-row"><div class="f-dn">Taufurkunde Karl R.</div></div>
<div class="f-row"><div class="f-dn">Standesamt 1889</div></div>
<a class="cta-btn" style="font-size:6px;">Jetzt einzeichnen →</a>
</div>
<!-- Trans -->
<div class="strip-col trans" style="padding:5px;">
<div class="f-ht n" style="margin-bottom:1px;">Text eintippen</div>
<div class="skill-pill kurrent">Kurrent hilfreich</div>
<div class="pulse" style="margin-bottom:2px;"><span class="pulse-num n">↑ +2 diese Woche</span><span class="pulse-open">· 8 offen</span></div>
<div class="doc-bar-row">
<div class="f-dn">Reisepass Opa Heinrich</div>
<div style="display:flex;align-items:center;gap:3px;"><div class="bar-track"><div class="bar-fill" style="width:37%;"></div></div><div class="bar-label">3 / 8 Blöcke</div></div>
</div>
<div class="doc-bar-row">
<div class="f-dn">Brief v. Oma Martha 1943</div>
<div style="display:flex;align-items:center;gap:3px;"><div class="bar-track"><div class="bar-fill" style="width:0%;"></div></div><div class="bar-label">0 / 6 Blöcke</div></div>
</div>
<a class="cta-btn" style="font-size:6px;">Jetzt tippen →</a>
</div>
<!-- Lesefertig -->
<div class="strip-col done" style="padding:5px;">
<div class="f-ht g" style="margin-bottom:1px;">Lesefertig ✓</div>
<div style="font-size:5.5px;color:var(--green);font-weight:600;margin-bottom:3px;">3 bereit</div>
<div class="doc-bar-row" style="border-color:rgba(166,218,216,.4);"><div class="f-dn">Postkarte 1943</div><div style="font-size:5.5px;color:var(--green);font-weight:600;">100 %</div></div>
<div class="doc-bar-row" style="border-color:rgba(166,218,216,.4);"><div class="f-dn">Brief Oma 1938</div><div style="font-size:5.5px;color:var(--green);font-weight:600;">95 %</div></div>
<a class="f-lnk g">Alle lesen →</a>
</div>
</div>
</div>
</div>
<span class="caption">Mobil 320 px — Streifen stapelt vertikal, volle Breite je Spalte</span>
</div>
<!-- Mobile layout notes -->
<div style="flex:1;min-width:220px;">
<div style="background:#fff;border:1px solid var(--border);border-radius:6px;padding:16px;margin-bottom:12px;">
<div style="font-size:10px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:var(--navy);margin-bottom:8px;">Mobile-Reihenfolge (DOM)</div>
<ol style="font-size:12px;color:var(--muted);line-height:1.8;margin-left:16px;">
<li>Suchleiste</li>
<li>DropZone (write users only)</li>
<li>Metadaten fehlen</li>
<li>Neueste Aktivität</li>
<li>Was braucht Aufmerksamkeit?
<ol style="margin-left:16px;">
<li>Rahmen einzeichnen</li>
<li>Text eintippen</li>
<li>Lesefertig ✓</li>
</ol>
</li>
</ol>
</div>
<div class="callout navy">
<div>
<strong class="n">Touch targets:</strong> Alle CTA-Buttons: <code>min-h-[44px]</code> (WCAG 2.2).
Dokument-Zeilen in den Spalten: <code>min-h-[44px] py-2</code>.
Der „Zu schwer"-Button auf der Enrich-Seite: <code>min-h-[44px]</code> als Icon-Button mit <code>aria-label</code>.
</div>
</div>
</div>
</div>
</div>
<hr/>
<!-- ── ENGAGEMENT FEATURES SUMMARY ──────────────────────────────────── -->
<div class="sec">
<div class="sec-label">Engagement-Elemente — Zusammenfassung</div>
<div class="comp-grid">
<div class="comp-card">
<h4>① Skill-Pill</h4>
<p>Unter jedem Spaltentitel. „Ohne Vorkenntnisse" (grün) vs. „Kurrent hilfreich" (navy-neutral).
Senkt die Hemmschwelle — Neueinsteiger sehen sofort, was ohne Kurrent-Kenntnisse möglich ist.</p>
<p style="margin-top:6px;"><code>bg-green-50 border-green-200 text-green-800</code> / <code>bg-surface border-line text-ink</code></p>
</div>
<div class="comp-card">
<h4>② Wochenpuls</h4>
<p>„↑ +5 diese Woche · 1 480 offen" statt globalem Fortschrittsbalken.
Zeigt Schwung, nicht den Berg. Psychologisch: 0,8 %-Balken ist demotivierender als kein Balken.</p>
<p style="margin-top:6px;"><code>SELECT COUNT(*) WHERE created_at &gt; NOW() - INTERVAL '7 days'</code></p>
</div>
<div class="comp-card">
<h4>③ Per-Dokument-Balken</h4>
<p>Nur in Spalte 2, nur wenn <code>annotation_count &gt; 0</code>. Richtiger Maßstab:
8 Blöcke sind in einer Sitzung abschließbar. Zeigt auch, welche Dokumente „fast fertig" sind.</p>
<p style="margin-top:6px;"><code>width: {textedBlocks / totalBlocks * 100}%</code>; Guard: <code>totalBlocks === 0 → width: 0</code></p>
</div>
<div class="comp-card">
<h4>④ Contributor-Avatare</h4>
<p>Max. 3 Initialen-Bubbles der letzten Beitragenden pro Spalte. Kein Leaderboard (Wettbewerb) —
soziale Sichtbarkeit (Zugehörigkeit). Farbe deterministisch aus User-ID-Hash.</p>
<p style="margin-top:6px;">DTO: <code>lastContributors: [{initials, colorIndex}]</code> — nur Initialen, keine Namen (Nora)</p>
</div>
<div class="comp-card">
<h4>⑤ „Starte hier →"-CTA</h4>
<p>Ein einziger opinionated Button je Aufgaben-Spalte, der direkt zum nächsten Dokument springt.
Entscheidungslähmung ist der Hauptgrund für Non-Participation bei Familienprojekten.</p>
<p style="margin-top:6px;"><code>/enrich?filter=NEEDS_SEGMENTATION&amp;next=1</code> (Segmentierung)<br/><code>/enrich?filter=NEEDS_TRANSCRIPTION&amp;next=1</code> (Transkription)</p>
</div>
<div class="comp-card">
<h4>⑥ Lesefertig-Leerstand → Redirect</h4>
<p>Wenn Spalte 3 leer ist (frühe Phase), erscheint kein toter Endpunkt sondern:
„Erscheint hier, sobald die Transkription abgeschlossen ist — jetzt mithelfen →".
Der Link springt zu Spalte 1.</p>
<p style="margin-top:6px;"><code>{#if readyToRead.length === 0}</code><code>DashboardReadyToReadEmpty.svelte</code></p>
</div>
</div>
</div>
<hr/>
<!-- ── IMPL-REF TABLE ────────────────────────────────────────────────── -->
<div class="sec">
<div class="sec-label">Implementation Reference</div>
<div class="impl-ref">
<table>
<thead><tr><th>Element</th><th>Tailwind-Klassen</th><th>Pixel / Wert</th><th>Hinweis</th></tr></thead>
<tbody>
<tr><td><strong>Streifen-Wrapper</strong></td><td><code>mt-4 bg-white border border-line rounded-sm p-6</code></td><td>padding 24 px</td><td>Direkt nach bestehendem <code>div.mt-4.grid</code></td></tr>
<tr><td>Streifen-Titel</td><td><code>text-xs font-bold uppercase tracking-widest text-gray-400 mb-4</code></td><td>12 px / 700</td><td>Standard-Section-Title-Muster</td></tr>
<tr><td>3-Spalten-Grid</td><td><code>grid grid-cols-1 gap-4 sm:grid-cols-3</code></td><td>gap 16 px</td><td>sm = 640 px; darunter stapeln</td></tr>
<tr><td>Segmentierung-Spalte</td><td><code>bg-surface rounded-sm border border-line p-4 flex flex-col gap-3</code></td><td></td><td>Neutral</td></tr>
<tr><td>Transkription-Spalte</td><td><code>bg-surface rounded-sm border border-line p-4 flex flex-col gap-3</code></td><td></td><td>Neutral — es ist eine Aufgabe</td></tr>
<tr><td>Lesefertig-Spalte (gefüllt)</td><td><code>bg-mint/10 rounded-sm border border-mint p-4 flex flex-col gap-3</code></td><td></td><td>Mint-Ton = Erfolg</td></tr>
<tr><td>Lesefertig-Spalte (leer)</td><td><code>flex flex-col items-center justify-center text-center bg-mint/5 border border-dashed border-mint rounded-sm p-6 min-h-[120px]</code></td><td>min-h 120 px</td><td>Kein toter Endpunkt</td></tr>
<tr><td>Skill-Pill easy</td><td><code>inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-green-50 border border-green-200 text-green-800</code></td><td>Kontrast 9,7:1 ✓ AAA</td><td></td></tr>
<tr><td>Skill-Pill kurrent</td><td><code>inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-surface border border-line text-ink</code></td><td>Kontrast 14,5:1 ✓ AAA</td><td>Neutral — kein Abschreck-Signal</td></tr>
<tr><td>Wochenpuls-Zahl</td><td><code>text-xs font-semibold text-green-700</code> (Seg.) / <code>text-ink</code> (Trans.)</td><td>12 px</td><td>Kein globaler Balken</td></tr>
<tr><td>Per-Dokument-Track</td><td><code>flex-1 h-1 bg-navy/20 rounded-full overflow-hidden</code></td><td>h 4 px</td><td>Nur wenn <code>annotation_count &gt; 0</code></td></tr>
<tr><td>Per-Dokument-Fill</td><td><code>h-full bg-ink rounded-full transition-all</code> + <code>style="width:{pct}%"</code></td><td></td><td>Guard: <code>totalBlocks === 0 → 0%</code></td></tr>
<tr><td>Lesefertig-Prozent</td><td><code>text-xs font-semibold text-green-800</code></td><td>12 px</td><td>Kein Balken — mint-Spalte ist das Signal</td></tr>
<tr><td>Contributor-Avatar</td><td><code>w-6 h-6 rounded-full flex items-center justify-center text-[10px] font-bold text-white shrink-0</code></td><td>24 × 24 px</td><td>Farbe: 6 Werte, Index = <code>userIdHash % 6</code></td></tr>
<tr><td>CTA-Button (primär)</td><td><code>block w-full text-center text-xs font-semibold text-white bg-ink rounded-sm py-2 mt-2 hover:bg-ink-2 transition-colors focus-visible:ring-2 focus-visible:ring-ink focus-visible:ring-offset-1</code></td><td>min-h 36 px</td><td><code>aria-label</code> mit Dokumenttitel falls nötig</td></tr>
<tr><td>CTA-Button (ghost, Leerstand)</td><td><code>inline-flex items-center text-xs font-semibold text-ink border border-ink rounded-sm px-3 py-2 hover:bg-ink hover:text-white transition-colors</code></td><td>min-h 36 px</td><td></td></tr>
<tr><td>Experten-gesucht-Badge</td><td><code>inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold bg-purple-50 border border-purple-200 text-purple-700</code></td><td>Kontrast 6,8:1 ✓ AA</td><td>Nur wenn <code>doc.needsExpert === true</code></td></tr>
<tr><td>Sichtbarkeit Streifen</td><td><code>{#if needsSegmentation.length &gt; 0 || needsTranscription.length &gt; 0 || readyToRead.length &gt; 0}</code></td><td></td><td>Streifen verschwindet wenn alle drei Buckets leer</td></tr>
<tr><td>Dokument-Zeile Mindesthöhe</td><td><code>min-h-[44px] flex items-start py-2</code></td><td>44 px ✓ WCAG 2.2</td><td>Gilt für alle klickbaren Zeilen</td></tr>
</tbody>
</table>
</div>
</div>
<hr/>
<!-- ── BACKEND CONTRACTS ─────────────────────────────────────────────── -->
<div class="sec">
<div class="sec-label">Backend — neue Endpoints &amp; Queries</div>
<div class="impl-ref">
<table>
<thead><tr><th>Endpoint / Query</th><th>Bedingung</th><th>Sort</th><th>Auth</th></tr></thead>
<tbody>
<tr><td><code>GET /api/documents/needs-segmentation?size=3</code></td><td><code>NOT EXISTS (SELECT 1 FROM document_annotations WHERE document_id = d.id)</code></td><td><code>HASHTEXT(id::text || week::text)</code></td><td><code>READ_ALL</code></td></tr>
<tr><td><code>GET /api/documents/needs-transcription?size=3</code></td><td><code>EXISTS annotation AND (no blocks OR reviewed_pct &lt; 0.90)</code></td><td><code>textedBlocks DESC, needs_expert ASC, HASHTEXT(...)</code></td><td><code>READ_ALL</code></td></tr>
<tr><td><code>GET /api/documents/ready-to-read?size=3</code></td><td><code>reviewed_pct &gt;= 0.90</code></td><td><code>updated_at DESC</code></td><td><code>READ_ALL</code></td></tr>
<tr><td><code>PATCH /api/documents/{id}/needs-expert</code></td><td>Setzt <code>needs_expert = true</code></td><td></td><td><code>READ_ALL</code> (jeder Nutzer darf flaggen)</td></tr>
<tr><td><code>GET /api/stats/strip-activity</code></td><td>Wochenpuls: <code>COUNT(*) WHERE created_at &gt; NOW() - INTERVAL '7 days'</code> pro Bucket</td><td></td><td><code>READ_ALL</code></td></tr>
<tr><td>Flyway-Migration</td><td><code>ALTER TABLE documents ADD COLUMN needs_expert BOOLEAN NOT NULL DEFAULT FALSE</code></td><td></td><td>V{n}__add_needs_expert_flag.sql</td></tr>
<tr><td>Index prüfen (Tobias)</td><td><code>document_annotations(document_id)</code>, <code>transcription_blocks(document_id, reviewed)</code></td><td></td><td>EXPLAIN ANALYZE vor Merge</td></tr>
<tr><td>Division durch 0 (Sara)</td><td>Alle reviewed_pct-Queries: <code>CASE WHEN COUNT(*) = 0 THEN 0 ELSE SUM(...)::float / COUNT(*) END</code></td><td></td><td></td></tr>
</tbody>
</table>
</div>
</div>
<hr/>
<!-- ── NEW COMPONENTS ────────────────────────────────────────────────── -->
<div class="sec">
<div class="sec-label">Neue Svelte-Komponenten</div>
<div class="comp-grid">
<div class="comp-card">
<h4><code>DashboardMissionControl.svelte</code></h4>
<p>Wrapper für den vollbreiten Streifen. Props: <code>needsSegmentation</code>, <code>needsTranscription</code>,
<code>readyToRead</code>, <code>weeklyActivity</code>. Rendert die drei Spalten und ist komplett unsichtbar wenn alle Arrays leer sind.</p>
</div>
<div class="comp-card">
<h4><code>DashboardSegmentationCol.svelte</code></h4>
<p>Spalte 1: Skill-Pill, Wochenpuls, Avatare, Dokumentliste, CTA. Keine Balken — keine Dokument-Metadaten vorhanden.</p>
</div>
<div class="comp-card">
<h4><code>DashboardTranscriptionCol.svelte</code></h4>
<p>Spalte 2: Skill-Pill, Wochenpuls, Avatare, per-Dokument-Balken, Experten-Badge bei <code>needsExpert</code>, CTA.</p>
</div>
<div class="comp-card">
<h4><code>DashboardReadyToReadCol.svelte</code></h4>
<p>Spalte 3: Zeigt gefüllten Zustand (Liste mit %-Text) oder leeren Zustand (Cross-Column-Redirect zu Segmentierung).</p>
</div>
</div>
<div class="callout green">
<div>
<strong class="g">Bestehende Komponente bleibt:</strong> <code>DashboardNeedsMetadata.svelte</code> ist unverändert —
sie lebt weiterhin in der rechten Spalte. Der Mission-Control-Streifen ist vollständig additiv und ändert nichts am bestehenden Layout.
</div>
</div>
</div>
</div><!-- /doc -->
</body>
</html>

View File

@@ -18,6 +18,7 @@ bun.lockb
/src/lib/paraglide/ /src/lib/paraglide/
/src/lib/paraglide_bak*/ /src/lib/paraglide_bak*/
/src/paraglide/ /src/paraglide/
/project.inlang/
# Test artifacts # Test artifacts
/test-results/ /test-results/

View File

@@ -24,7 +24,7 @@ test.describe('Authentication', () => {
}); });
test('protected routes redirect to /login without session', async ({ page }) => { test('protected routes redirect to /login without session', async ({ page }) => {
for (const url of ['/documents/new', '/persons', '/conversations']) { for (const url of ['/documents/new', '/persons', '/briefwechsel']) {
await page.goto(url); await page.goto(url);
await expect(page).toHaveURL(/\/login/); await expect(page).toHaveURL(/\/login/);
} }

View File

@@ -181,132 +181,3 @@ test.describe('Person detail — sent and received documents', () => {
// If no person has dated documents, the test is a no-op (year range is optional) // If no person has dated documents, the test is a no-op (year range is optional)
}); });
}); });
test.describe('Person detail — conversations link', () => {
test('co-correspondent chips link to conversations pre-filled with both persons', async ({
page
}) => {
await page.goto('/persons');
const firstLink = page.locator('a[href^="/persons/"]:not([href="/persons/new"])').first();
const href = await firstLink.getAttribute('href');
const personId = href!.split('/persons/')[1];
await firstLink.click();
await page.waitForSelector('[data-hydrated]');
// Co-correspondent chips link to /conversations?senderId=X&receiverId=Y
const chip = page.locator(`a[href^="/conversations?senderId=${personId}&receiverId="]`).first();
if ((await chip.count()) > 0) {
const chipHref = await chip.getAttribute('href');
expect(chipHref).toMatch(/\/conversations\?senderId=.+&receiverId=.+/);
}
});
});
test.describe('Conversations', () => {
test('shows the empty state when no persons are selected', async ({ page }) => {
await page.goto('/conversations');
await expect(page.getByText(/Wählen Sie zwei Personen aus/i)).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/conversations-empty.png' });
});
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(/bg-nav-active/);
});
test('sort toggle changes the button label', async ({ page }) => {
await page.goto('/conversations');
await page.waitForSelector('[data-hydrated]');
const btn = page.getByRole('button', { name: /Sortierung/i });
await expect(btn).toContainText('Neueste zuerst');
await btn.click();
await expect(page).toHaveURL(/dir=ASC/);
await expect(btn).toContainText('Älteste zuerst');
await page.screenshot({ path: 'test-results/e2e/conversations-sort.png' });
});
});
test.describe('Conversations — enhancements', () => {
// Hans→Anna (1923) and Anna→Hans (1965) are seeded in DataInitializer
// Navigate directly by URL so the test doesn't rely on typeahead interaction
async function loadHansAnnaConversation(page: import('@playwright/test').Page) {
// Resolve person IDs from the persons list
await page.goto('/persons');
const hansLink = page.getByRole('link', { name: /Hans Müller/ }).first();
const hansHref = await hansLink.getAttribute('href');
const hansId = hansHref!.split('/').pop()!;
const annaLink = page.getByRole('link', { name: /Anna Schmidt/ }).first();
const annaHref = await annaLink.getAttribute('href');
const annaId = annaHref!.split('/').pop()!;
await page.goto(`/conversations?senderId=${hansId}&receiverId=${annaId}`);
await page.waitForURL(/senderId=/);
}
test('shows document count and year range summary when both persons are selected', async ({
page
}) => {
await loadHansAnnaConversation(page);
// Hans→Anna (1923) + Anna→Hans (1965) = 2 documents, range 19231965
await expect(page.getByTestId('conv-summary')).toContainText('2');
await expect(page.getByTestId('conv-summary')).toContainText('1923');
await expect(page.getByTestId('conv-summary')).toContainText('1965');
await page.screenshot({ path: 'test-results/e2e/conversations-summary.png' });
});
test('shows year dividers between documents from different years', async ({ page }) => {
await loadHansAnnaConversation(page);
// Expect at least two year dividers (1923 and 1965)
await expect(page.getByTestId('year-divider').first()).toBeVisible();
const dividers = page.getByTestId('year-divider');
const texts = await dividers.allTextContents();
expect(texts.some((t) => t.includes('1923'))).toBe(true);
expect(texts.some((t) => t.includes('1965'))).toBe(true);
await page.screenshot({ path: 'test-results/e2e/conversations-year-dividers.png' });
});
test('swap button switches sender and receiver and reloads', async ({ page }) => {
await loadHansAnnaConversation(page);
const url = new URL(page.url());
const originalSenderId = url.searchParams.get('senderId')!;
const originalReceiverId = url.searchParams.get('receiverId')!;
await page.getByTestId('conv-swap-btn').click();
// Wait for the URL to reflect the swapped IDs (not just any URL with senderId=)
await page.waitForURL(
(url) => new URL(url).searchParams.get('senderId') === originalReceiverId
);
const swappedUrl = new URL(page.url());
expect(swappedUrl.searchParams.get('senderId')).toBe(originalReceiverId);
expect(swappedUrl.searchParams.get('receiverId')).toBe(originalSenderId);
await page.screenshot({ path: 'test-results/e2e/conversations-swap.png' });
});
test('shows "new document" link pre-filled with both persons when conversation is loaded', async ({
page
}) => {
await loadHansAnnaConversation(page);
const url = new URL(page.url());
const senderId = url.searchParams.get('senderId')!;
const receiverId = url.searchParams.get('receiverId')!;
const link = page.getByTestId('conv-new-doc-link');
await expect(link).toBeVisible();
const href = await link.getAttribute('href');
expect(href).toContain(`senderId=${senderId}`);
expect(href).toContain(`receiverId=${receiverId}`);
await page.screenshot({ path: 'test-results/e2e/conversations-new-doc-link.png' });
});
test('does not show swap button or new document link when only one person is selected', async ({
page
}) => {
await page.goto('/conversations');
await page.waitForURL('/conversations');
await expect(page.getByTestId('conv-swap-btn')).not.toBeVisible();
await expect(page.getByTestId('conv-new-doc-link')).not.toBeVisible();
});
});

View File

@@ -136,8 +136,6 @@
"person_co_correspondents_heading": "Häufige Korrespondenten", "person_co_correspondents_heading": "Häufige Korrespondenten",
"person_correspondents_hint": "klicken für Konversation", "person_correspondents_hint": "klicken für Konversation",
"person_show_more": "+ {count} weitere anzeigen", "person_show_more": "+ {count} weitere anzeigen",
"conv_heading": "Briefwechsel",
"conv_subtitle": "Briefwechsel einer Person durchsuchen — mit oder ohne Korrespondent.",
"conv_label_person_a": "Person A (Absender)", "conv_label_person_a": "Person A (Absender)",
"conv_label_person_b": "Korrespondent", "conv_label_person_b": "Korrespondent",
"conv_label_from": "Zeitraum von", "conv_label_from": "Zeitraum von",
@@ -146,30 +144,18 @@
"conv_sort_newest": "Neueste zuerst", "conv_sort_newest": "Neueste zuerst",
"conv_sort_oldest": "Älteste zuerst", "conv_sort_oldest": "Älteste zuerst",
"conv_empty_heading": "Wessen Briefe möchten Sie lesen?", "conv_empty_heading": "Wessen Briefe möchten Sie lesen?",
"conv_empty_text": "Wähle eine Person aus dem Archiv um deren Briefe zu sehen — mit oder ohne Korrespondent.",
"conv_hero_crosslink": "Suchen Sie ein bestimmtes Dokument? → Zur Dokumentensuche", "conv_hero_crosslink": "Suchen Sie ein bestimmtes Dokument? → Zur Dokumentensuche",
"conv_no_results_heading": "Keine Dokumente gefunden.", "conv_no_results_heading": "Keine Dokumente gefunden.",
"conv_no_results_text": "Versuchen Sie, den Zeitraum anzupassen.", "conv_no_results_text": "Versuchen Sie, den Zeitraum anzupassen.",
"conv_swap_btn": "Personen tauschen", "conv_swap_btn": "Personen tauschen",
"conv_summary": "{count} Dokumente · {yearFrom}{yearTo}",
"conv_new_doc_link": "Neues Dokument in diesem Briefwechsel", "conv_new_doc_link": "Neues Dokument in diesem Briefwechsel",
"conv_label_correspondent_optional": "Korrespondent",
"conv_hint_single_person": "Alle Briefe von {name} — wähle einen Korrespondenten oben um einzugrenzen",
"conv_hint_single_person_filtered": "Alle Briefe von {name} · {from}{to} · {sortLabel}",
"conv_strip_period": "Zeitraum",
"conv_strip_from_placeholder": "Von…",
"conv_strip_to_placeholder": "Bis…",
"conv_strip_all_correspondents": "Alle Korrespondenten",
"conv_strip_sort_newest": "Neueste", "conv_strip_sort_newest": "Neueste",
"conv_strip_sort_oldest": "Älteste", "conv_strip_sort_oldest": "Älteste",
"conv_suggestions_heading": "Häufigste Korrespondenten", "conv_suggestions_heading": "Häufigste Korrespondenten",
"conv_suggestions_all_label": "Alle Korrespondenten von {name}", "conv_suggestions_all_label": "Alle Korrespondenten von {name}",
"conv_letters_count": "{count} Briefe", "conv_letters_count": "{count} Briefe",
"conv_empty_search_placeholder": "Person suchen…",
"conv_hero_divider": "oder", "conv_hero_divider": "oder",
"conv_empty_recent_label": "Zuletzt geöffnet", "conv_empty_recent_label": "Zuletzt geöffnet",
"conv_asym_sent": "{count} von {name} →",
"conv_asym_received": "{count} von {name} ←",
"conv_no_party": "—", "conv_no_party": "—",
"admin_heading": "Admin Dashboard", "admin_heading": "Admin Dashboard",
"admin_tab_users": "Benutzer", "admin_tab_users": "Benutzer",
@@ -335,6 +321,7 @@
"comment_btn_post": "Senden", "comment_btn_post": "Senden",
"comment_btn_reply": "Antworten", "comment_btn_reply": "Antworten",
"comment_edited_label": "(Bearbeitet)", "comment_edited_label": "(Bearbeitet)",
"comment_edit_hint": "Enter speichern · Esc abbrechen",
"comment_time_just_now": "gerade eben", "comment_time_just_now": "gerade eben",
"comment_time_minutes": "vor {count} Minute(n)", "comment_time_minutes": "vor {count} Minute(n)",
"comment_time_hours": "vor {count} Stunde(n)", "comment_time_hours": "vor {count} Stunde(n)",

View File

@@ -136,8 +136,6 @@
"person_co_correspondents_heading": "Frequent correspondents", "person_co_correspondents_heading": "Frequent correspondents",
"person_correspondents_hint": "click to view conversation", "person_correspondents_hint": "click to view conversation",
"person_show_more": "+ {count} more", "person_show_more": "+ {count} more",
"conv_heading": "Letters",
"conv_subtitle": "Browse a person's letters — with or without a correspondent.",
"conv_label_person_a": "Person A (Sender)", "conv_label_person_a": "Person A (Sender)",
"conv_label_person_b": "Correspondent", "conv_label_person_b": "Correspondent",
"conv_label_from": "Period from", "conv_label_from": "Period from",
@@ -146,30 +144,18 @@
"conv_sort_newest": "Newest first", "conv_sort_newest": "Newest first",
"conv_sort_oldest": "Oldest first", "conv_sort_oldest": "Oldest first",
"conv_empty_heading": "Whose letters would you like to read?", "conv_empty_heading": "Whose letters would you like to read?",
"conv_empty_text": "Choose a person from the archive to see their letters — with or without a correspondent.",
"conv_hero_crosslink": "Looking for a specific document? → Go to document search", "conv_hero_crosslink": "Looking for a specific document? → Go to document search",
"conv_no_results_heading": "No documents found.", "conv_no_results_heading": "No documents found.",
"conv_no_results_text": "Try adjusting the time period.", "conv_no_results_text": "Try adjusting the time period.",
"conv_swap_btn": "Swap persons", "conv_swap_btn": "Swap persons",
"conv_summary": "{count} documents · {yearFrom}{yearTo}",
"conv_new_doc_link": "New document in this exchange", "conv_new_doc_link": "New document in this exchange",
"conv_label_correspondent_optional": "Correspondent",
"conv_hint_single_person": "All letters from {name} — choose a correspondent above to narrow down",
"conv_hint_single_person_filtered": "All letters from {name} · {from}{to} · {sortLabel}",
"conv_strip_period": "Period",
"conv_strip_from_placeholder": "From…",
"conv_strip_to_placeholder": "To…",
"conv_strip_all_correspondents": "All correspondents",
"conv_strip_sort_newest": "Newest", "conv_strip_sort_newest": "Newest",
"conv_strip_sort_oldest": "Oldest", "conv_strip_sort_oldest": "Oldest",
"conv_suggestions_heading": "Top correspondents", "conv_suggestions_heading": "Top correspondents",
"conv_suggestions_all_label": "All correspondents of {name}", "conv_suggestions_all_label": "All correspondents of {name}",
"conv_letters_count": "{count} letters", "conv_letters_count": "{count} letters",
"conv_empty_search_placeholder": "Search person…",
"conv_hero_divider": "or", "conv_hero_divider": "or",
"conv_empty_recent_label": "Recently opened", "conv_empty_recent_label": "Recently opened",
"conv_asym_sent": "{count} from {name} →",
"conv_asym_received": "{count} from {name} ←",
"conv_no_party": "—", "conv_no_party": "—",
"admin_heading": "Admin Dashboard", "admin_heading": "Admin Dashboard",
"admin_tab_users": "Users", "admin_tab_users": "Users",
@@ -335,6 +321,7 @@
"comment_btn_post": "Send", "comment_btn_post": "Send",
"comment_btn_reply": "Reply", "comment_btn_reply": "Reply",
"comment_edited_label": "(Edited)", "comment_edited_label": "(Edited)",
"comment_edit_hint": "Enter to save · Esc to cancel",
"comment_time_just_now": "just now", "comment_time_just_now": "just now",
"comment_time_minutes": "{count} minute(s) ago", "comment_time_minutes": "{count} minute(s) ago",
"comment_time_hours": "{count} hour(s) ago", "comment_time_hours": "{count} hour(s) ago",

View File

@@ -136,8 +136,6 @@
"person_co_correspondents_heading": "Corresponsales frecuentes", "person_co_correspondents_heading": "Corresponsales frecuentes",
"person_correspondents_hint": "clic para ver conversación", "person_correspondents_hint": "clic para ver conversación",
"person_show_more": "+ {count} más", "person_show_more": "+ {count} más",
"conv_heading": "Cartas",
"conv_subtitle": "Explore las cartas de una persona — con o sin corresponsal.",
"conv_label_person_a": "Persona A (Remitente)", "conv_label_person_a": "Persona A (Remitente)",
"conv_label_person_b": "Corresponsal", "conv_label_person_b": "Corresponsal",
"conv_label_from": "Período desde", "conv_label_from": "Período desde",
@@ -146,30 +144,18 @@
"conv_sort_newest": "Más reciente primero", "conv_sort_newest": "Más reciente primero",
"conv_sort_oldest": "Más antiguo primero", "conv_sort_oldest": "Más antiguo primero",
"conv_empty_heading": "¿De quién desea leer las cartas?", "conv_empty_heading": "¿De quién desea leer las cartas?",
"conv_empty_text": "Elige una persona del archivo para ver sus cartas — con o sin corresponsal.",
"conv_hero_crosslink": "¿Busca un documento en particular? → Ir a la búsqueda", "conv_hero_crosslink": "¿Busca un documento en particular? → Ir a la búsqueda",
"conv_no_results_heading": "No se encontraron documentos.", "conv_no_results_heading": "No se encontraron documentos.",
"conv_no_results_text": "Intente ajustar el período de tiempo.", "conv_no_results_text": "Intente ajustar el período de tiempo.",
"conv_swap_btn": "Intercambiar personas", "conv_swap_btn": "Intercambiar personas",
"conv_summary": "{count} documentos · {yearFrom}{yearTo}",
"conv_new_doc_link": "Nuevo documento en este intercambio", "conv_new_doc_link": "Nuevo documento en este intercambio",
"conv_label_correspondent_optional": "Corresponsal",
"conv_hint_single_person": "Todas las cartas de {name} — elige un corresponsal arriba para filtrar",
"conv_hint_single_person_filtered": "Todas las cartas de {name} · {from}{to} · {sortLabel}",
"conv_strip_period": "Período",
"conv_strip_from_placeholder": "Desde…",
"conv_strip_to_placeholder": "Hasta…",
"conv_strip_all_correspondents": "Todos los corresponsales",
"conv_strip_sort_newest": "Más reciente", "conv_strip_sort_newest": "Más reciente",
"conv_strip_sort_oldest": "Más antiguo", "conv_strip_sort_oldest": "Más antiguo",
"conv_suggestions_heading": "Corresponsales frecuentes", "conv_suggestions_heading": "Corresponsales frecuentes",
"conv_suggestions_all_label": "Todos los corresponsales de {name}", "conv_suggestions_all_label": "Todos los corresponsales de {name}",
"conv_letters_count": "{count} cartas", "conv_letters_count": "{count} cartas",
"conv_empty_search_placeholder": "Buscar persona…",
"conv_hero_divider": "o", "conv_hero_divider": "o",
"conv_empty_recent_label": "Recientemente abiertos", "conv_empty_recent_label": "Recientemente abiertos",
"conv_asym_sent": "{count} de {name} →",
"conv_asym_received": "{count} de {name} ←",
"conv_no_party": "—", "conv_no_party": "—",
"admin_heading": "Panel de administración", "admin_heading": "Panel de administración",
"admin_tab_users": "Usuarios", "admin_tab_users": "Usuarios",
@@ -335,6 +321,7 @@
"comment_btn_post": "Enviar", "comment_btn_post": "Enviar",
"comment_btn_reply": "Responder", "comment_btn_reply": "Responder",
"comment_edited_label": "(Editado)", "comment_edited_label": "(Editado)",
"comment_edit_hint": "Enter para guardar · Esc para cancelar",
"comment_time_just_now": "justo ahora", "comment_time_just_now": "justo ahora",
"comment_time_minutes": "hace {count} minuto(s)", "comment_time_minutes": "hace {count} minuto(s)",
"comment_time_hours": "hace {count} hora(s)", "comment_time_hours": "hace {count} hora(s)",

View File

@@ -51,6 +51,18 @@ describe('clickOutside action', () => {
expect(fired).toBe(false); expect(fired).toBe(false);
}); });
it('does not dispatch clickoutside when event.defaultPrevented is true', () => {
const node = makeNode();
const outside = makeNode();
let fired = false;
node.addEventListener('clickoutside', () => (fired = true));
clickOutside(node);
const event = new MouseEvent('click', { bubbles: true, cancelable: true });
event.preventDefault();
outside.dispatchEvent(event);
expect(fired).toBe(false);
});
it('removes the listener on destroy', () => { it('removes the listener on destroy', () => {
const node = makeNode(); const node = makeNode();
const outside = makeNode(); const outside = makeNode();

View File

@@ -5,6 +5,7 @@ export function clickOutside(node: HTMLElement): { destroy: () => void } {
} }
} }
// Capture phase (true) ensures this fires before any child stopPropagation() calls.
document.addEventListener('click', handleClick, true); document.addEventListener('click', handleClick, true);
return { return {

View File

@@ -0,0 +1,111 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import type { FlatMessage } from '$lib/types';
import { extractQuote } from '$lib/utils/comment';
import { getInitials } from '$lib/utils/personFormat';
import { relativeTime } from '$lib/utils/time';
import { renderBody } from '$lib/utils/mention';
type Props = {
message: FlatMessage;
isOwn: boolean;
isEditing: boolean;
editText: string;
onEdit: () => void;
onDelete: () => void;
onEditTextChange: (text: string) => void;
onEditKeydown: (e: KeyboardEvent) => void;
};
let {
message,
isOwn,
isEditing,
editText,
onEdit,
onDelete,
onEditTextChange,
onEditKeydown
}: Props = $props();
const wasEdited = $derived(message.updatedAt > message.createdAt);
const parsed = $derived(extractQuote(message.content));
</script>
<div role="article" class="flex gap-2">
<!-- Avatar circle with initials -->
<div
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary text-[10px] font-bold text-primary-fg"
>
{getInitials(message.authorName)}
</div>
<!-- Content -->
<div class="min-w-0 flex-1">
<!-- Author + timestamp -->
<div class="flex items-center gap-1.5">
<span class="font-sans text-sm font-semibold text-ink">{message.authorName}</span>
{#if wasEdited}
<span class="font-sans text-xs text-ink-3"
>{relativeTime(message.updatedAt)} {m.comment_edited_label()}</span
>
{:else}
<span class="font-sans text-xs text-ink-3">{relativeTime(message.createdAt)}</span>
{/if}
</div>
<!-- Quote block (if present) -->
{#if parsed.quote}
<div class="my-1 border-l-2 border-line pl-2 font-serif text-base text-ink-3 italic">
&ldquo;{parsed.quote}&rdquo;
</div>
{/if}
<!-- Edit mode vs view mode -->
{#if isEditing}
<textarea
class="mt-1 w-full resize-none rounded border border-line bg-surface px-2 py-1 font-serif text-sm leading-relaxed text-ink outline-none focus:border-primary"
rows={2}
value={editText}
oninput={(e) => onEditTextChange((e.currentTarget as HTMLTextAreaElement).value)}
onkeydown={onEditKeydown}
></textarea>
<div class="mt-1 font-sans text-xs text-ink-3">{m.comment_edit_hint()}</div>
{:else}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="relative" onclick={() => { if (isOwn) onEdit(); }}>
<p
class="font-serif text-base leading-relaxed text-ink-2 {isOwn
? '-mx-1 cursor-text rounded px-1 transition-colors hover:bg-surface'
: ''}"
>
<!-- eslint-disable-next-line svelte/no-at-html-tags -- renderBody escapes all HTML before injecting mention links -->
{@html renderBody(parsed.body, message.mentionDTOs ?? [])}
</p>
{#if isOwn}
<button
type="button"
class="hover:text-error absolute -right-1 -bottom-1 cursor-pointer rounded p-2 text-ink-3 transition-colors"
aria-label="{m.btn_delete()} {message.authorName}"
onclick={(e) => { e.stopPropagation(); onDelete(); }}
>
<svg
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
{/if}
</div>
{/if}
</div>
</div>

View File

@@ -0,0 +1,85 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page, userEvent } from 'vitest/browser';
import CommentMessage from './CommentMessage.svelte';
import type { FlatMessage } from '$lib/types';
afterEach(cleanup);
const baseMsg: FlatMessage = {
id: 'msg-1',
authorId: 'user-1',
authorName: 'Anna Müller',
content: 'Hello world',
createdAt: new Date(Date.now() - 5 * 60_000).toISOString(),
updatedAt: new Date(Date.now() - 5 * 60_000).toISOString()
};
function defaultProps(overrides: Partial<Parameters<typeof render>[1]> = {}) {
return {
message: baseMsg,
isOwn: false,
isEditing: false,
editText: '',
onEdit: vi.fn(),
onDelete: vi.fn(),
onEditTextChange: vi.fn(),
onEditKeydown: vi.fn(),
...overrides
};
}
describe('CommentMessage', () => {
it('renders author name', async () => {
render(CommentMessage, defaultProps());
await expect.element(page.getByText('Anna Müller')).toBeInTheDocument();
});
it('renders initials in avatar', async () => {
render(CommentMessage, defaultProps());
await expect.element(page.getByText('AM')).toBeInTheDocument();
});
it('renders message body', async () => {
render(CommentMessage, defaultProps());
await expect.element(page.getByText('Hello world')).toBeInTheDocument();
});
it('renders quoted section when content contains a quote', async () => {
render(
CommentMessage,
defaultProps({
message: { ...baseMsg, content: '> "Interesting passage"\n\nMy reply' }
})
);
await expect.element(page.getByText(/Interesting passage/)).toBeInTheDocument();
await expect.element(page.getByText('My reply')).toBeInTheDocument();
});
it('does not show delete button for messages not owned by current user', async () => {
render(CommentMessage, defaultProps({ isOwn: false }));
await expect.element(page.getByRole('button')).not.toBeInTheDocument();
});
it('shows delete button for own messages', async () => {
render(CommentMessage, defaultProps({ isOwn: true }));
await expect.element(page.getByRole('button')).toBeInTheDocument();
});
it('calls onDelete when delete button is clicked', async () => {
const onDelete = vi.fn();
render(CommentMessage, defaultProps({ isOwn: true, onDelete }));
await userEvent.click(page.getByRole('button'));
expect(onDelete).toHaveBeenCalled();
});
it('shows edit textarea when isEditing is true', async () => {
render(
CommentMessage,
defaultProps({ isOwn: true, isEditing: true, editText: 'current edit text' })
);
const textarea = page.getByRole('textbox');
await expect.element(textarea).toBeInTheDocument();
await expect.element(textarea).toHaveValue('current edit text');
});
});

View File

@@ -1,11 +1,10 @@
<script lang="ts"> <script lang="ts">
import { onMount, untrack } from 'svelte'; import { onMount, untrack } from 'svelte';
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import type { Comment } from '$lib/types'; import type { Comment, FlatMessage } from '$lib/types';
import MentionEditor from '$lib/components/MentionEditor.svelte'; import MentionEditor from '$lib/components/MentionEditor.svelte';
import { renderBody, extractContent } from '$lib/utils/mention'; import CommentMessage from '$lib/components/CommentMessage.svelte';
import type { MentionDTO } from '$lib/types'; import { extractContent } from '$lib/utils/mention';
type Props = { type Props = {
documentId: string; documentId: string;
annotationId?: string | null; annotationId?: string | null;
@@ -32,16 +31,6 @@ let {
onCountChange onCountChange
}: Props = $props(); }: Props = $props();
type FlatMessage = {
id: string;
authorId: string | null;
authorName: string;
content: string;
createdAt: string;
updatedAt: string;
mentionDTOs?: MentionDTO[];
};
let comments: Comment[] = $state(untrack(() => [...initialComments])); let comments: Comment[] = $state(untrack(() => [...initialComments]));
let newText: string = $state(''); let newText: string = $state('');
let posting: boolean = $state(false); let posting: boolean = $state(false);
@@ -67,39 +56,10 @@ $effect(() => {
} }
}); });
function timeAgo(iso: string): string {
const diff = Date.now() - new Date(iso).getTime();
const minutes = Math.floor(diff / 60000);
if (minutes < 1) return m.comment_time_just_now();
if (minutes < 60) return m.comment_time_minutes({ count: minutes });
const hours = Math.floor(minutes / 60);
if (hours < 24) return m.comment_time_hours({ count: hours });
const days = Math.floor(hours / 24);
return m.comment_time_days({ count: days });
}
function wasEdited(c: { createdAt: string; updatedAt: string }): boolean {
return c.updatedAt > c.createdAt;
}
function isOwn(c: { authorId: string | null }): boolean { function isOwn(c: { authorId: string | null }): boolean {
return currentUserId !== null && c.authorId === currentUserId; return currentUserId !== null && c.authorId === currentUserId;
} }
function getInitials(name: string): string {
return name
.split(/\s+/)
.slice(0, 2)
.map((w) => w.charAt(0).toUpperCase())
.join('');
}
function extractQuote(content: string): { quote: string | null; body: string } {
const match = content.match(/^>\s*"(.+?)"\s*\n\n?([\s\S]*)$/);
if (match) return { quote: match[1], body: match[2] };
return { quote: null, body: content };
}
async function reload() { async function reload() {
try { try {
const res = await fetch(commentsBase); const res = await fetch(commentsBase);
@@ -221,77 +181,18 @@ onMount(() => {
{flatMessages.length === 1 ? 'Kommentar' : 'Kommentare'} {flatMessages.length === 1 ? 'Kommentar' : 'Kommentare'}
</div> </div>
<div class="space-y-2"> <div role="log" class="space-y-2">
{#each flatMessages as msg (msg.id)} {#each flatMessages as msg (msg.id)}
{@const parsed = extractQuote(msg.content)} <CommentMessage
<div class="flex gap-2"> message={msg}
<div isOwn={isOwn(msg)}
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary text-[10px] font-bold text-primary-fg" isEditing={editingId === msg.id}
> editText={editText}
{getInitials(msg.authorName)} onEdit={() => startEdit(msg)}
</div> onDelete={() => deleteComment(msg.id)}
<div class="min-w-0 flex-1"> onEditTextChange={(text) => { editText = text; }}
<div class="flex items-center gap-1.5"> onEditKeydown={(e) => handleEditKeydown(e, msg.id)}
<span class="font-sans text-sm font-semibold text-ink">{msg.authorName}</span>
{#if wasEdited(msg)}
<span class="font-sans text-xs text-ink-3"
>{timeAgo(msg.updatedAt)} {m.comment_edited_label()}</span
>
{:else}
<span class="font-sans text-xs text-ink-3">{timeAgo(msg.createdAt)}</span>
{/if}
</div>
{#if parsed.quote}
<div class="my-1 border-l-2 border-line pl-2 font-serif text-base text-ink-3 italic">
&ldquo;{parsed.quote}&rdquo;
</div>
{/if}
{#if editingId === msg.id}
<textarea
class="mt-1 w-full resize-none rounded border border-line bg-surface px-2 py-1 font-serif text-sm leading-relaxed text-ink outline-none focus:border-primary"
rows={2}
bind:value={editText}
onkeydown={(e) => handleEditKeydown(e, msg.id)}
></textarea>
<div class="mt-1 font-sans text-xs text-ink-3">Enter speichern · Esc abbrechen</div>
{:else}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="relative" onclick={() => { if (isOwn(msg)) startEdit(msg); }}>
<p
class="font-serif text-base leading-relaxed text-ink-2 {isOwn(msg) ? '-mx-1 cursor-text rounded px-1 transition-colors hover:bg-surface' : ''}"
>
<!-- eslint-disable-next-line svelte/no-at-html-tags -- renderBody escapes all HTML before injecting mention links -->
{@html renderBody(parsed.body, msg.mentionDTOs ?? [])}
</p>
{#if isOwn(msg)}
<button
type="button"
class="hover:text-error absolute -right-1 -bottom-1 cursor-pointer rounded p-0.5 text-ink-3 transition-colors"
title={m.btn_delete()}
aria-label={m.btn_delete()}
onclick={(e) => { e.stopPropagation(); deleteComment(msg.id); }}
>
<svg
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/> />
</svg>
</button>
{/if}
</div>
{/if}
</div>
</div>
{/each} {/each}
</div> </div>
</div> </div>

View File

@@ -2,7 +2,7 @@
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import { formatDate } from '$lib/utils/date'; import { formatDate } from '$lib/utils/date';
import { formatDocumentStatus } from '$lib/utils/documentStatusLabel'; import { formatDocumentStatus } from '$lib/utils/documentStatusLabel';
import { getInitials as calcInitials, personAvatarColor } from '$lib/utils/personFormat'; import { getInitials, personAvatarColor } from '$lib/utils/personFormat';
type Person = { id: string; firstName?: string | null; lastName: string; displayName: string }; type Person = { id: string; firstName?: string | null; lastName: string; displayName: string };
type Tag = { id: string; name: string }; type Tag = { id: string; name: string };
@@ -32,10 +32,6 @@ let showAllReceivers = $state(false);
const displayedReceivers = $derived(showAllReceivers ? receivers : visibleReceivers); const displayedReceivers = $derived(showAllReceivers ? receivers : visibleReceivers);
function getInitials(person: Person): string {
return calcInitials(person);
}
function getFullName(person: Person): string { function getFullName(person: Person): string {
return person.displayName; return person.displayName;
} }
@@ -51,7 +47,7 @@ function getFullName(person: Person): string {
style="background-color: {personAvatarColor(person.id)}" style="background-color: {personAvatarColor(person.id)}"
aria-hidden="true" aria-hidden="true"
> >
{getInitials(person)} {getInitials(person.displayName)}
</span> </span>
<span class="font-serif text-sm text-ink">{getFullName(person)}</span> <span class="font-serif text-sm text-ink">{getFullName(person)}</span>
</a> </a>

View File

@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import { slide } from 'svelte/transition'; import { slide } from 'svelte/transition';
import { formatDate } from '$lib/utils/personFormat'; import { formatDate } from '$lib/utils/date';
import { clickOutside } from '$lib/actions/clickOutside'; import { clickOutside } from '$lib/actions/clickOutside';
import PersonChipRow from './PersonChipRow.svelte'; import PersonChipRow from './PersonChipRow.svelte';
import OverflowPillButton from './OverflowPillButton.svelte'; import OverflowPillButton from './OverflowPillButton.svelte';

View File

@@ -2,53 +2,24 @@
import { onMount, onDestroy } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import { import { clickOutside } from '$lib/actions/clickOutside';
type NotificationItem, import { createNotificationStream } from '$lib/hooks/useNotificationStream.svelte';
relativeTime, import NotificationDropdown from './NotificationDropdown.svelte';
parseNotificationEvent
} from '$lib/utils/notifications';
let notifications: NotificationItem[] = $state([]);
let unreadCount: number = $state(0);
let open = $state(false); let open = $state(false);
// DOM refs managed via attachments
let bellButtonEl: HTMLButtonElement | null = null; let bellButtonEl: HTMLButtonElement | null = null;
let firstFocusableEl: HTMLButtonElement | null = null;
let eventSource: EventSource | null = null; const stream = createNotificationStream();
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 fetchUnreadCount() {
try {
const res = await fetch('/api/notifications/unread-count');
if (res.ok) {
const data = await res.json();
unreadCount = data.count;
}
} catch (e) {
console.error('Failed to fetch unread count', e);
}
}
async function toggleDropdown() { async function toggleDropdown() {
open = !open; open = !open;
if (open) { if (open) {
await fetchNotifications(); await stream.fetchNotifications();
// defer focus until DOM updates
setTimeout(() => { setTimeout(() => {
firstFocusableEl?.focus(); const firstBtn = document.querySelector<HTMLButtonElement>(
'[role="dialog"] button, [role="dialog"] a'
);
firstBtn?.focus();
}, 0); }, 0);
} }
} }
@@ -58,16 +29,8 @@ function closeDropdown() {
bellButtonEl?.focus(); bellButtonEl?.focus();
} }
async function markRead(notification: NotificationItem) { async function handleMarkRead(notification: Parameters<typeof stream.markRead>[0]) {
if (!notification.read) { await stream.markRead(notification);
try {
await fetch(`/api/notifications/${notification.id}/read`, { method: 'PATCH' });
notification.read = true;
unreadCount = Math.max(0, unreadCount - 1);
} catch (e) {
console.error('Failed to mark notification as read', e);
}
}
const url = notification.annotationId const url = notification.annotationId
? `/documents/${notification.documentId}?commentId=${notification.referenceId}&annotationId=${notification.annotationId}` ? `/documents/${notification.documentId}?commentId=${notification.referenceId}&annotationId=${notification.annotationId}`
: `/documents/${notification.documentId}?commentId=${notification.referenceId}`; : `/documents/${notification.documentId}?commentId=${notification.referenceId}`;
@@ -75,18 +38,6 @@ async function markRead(notification: NotificationItem) {
goto(url); goto(url);
} }
async function markAllRead() {
try {
await fetch('/api/notifications/read-all', { method: 'POST' });
for (const n of notifications) {
n.read = true;
}
unreadCount = 0;
} catch (e) {
console.error('Failed to mark all notifications as read', e);
}
}
function handleKeydown(event: KeyboardEvent) { function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape' && open) { if (event.key === 'Escape' && open) {
event.stopPropagation(); event.stopPropagation();
@@ -94,7 +45,6 @@ function handleKeydown(event: KeyboardEvent) {
} }
} }
// Attachment: stores the element reference for the bell button
function attachBellButton(node: HTMLButtonElement) { function attachBellButton(node: HTMLButtonElement) {
bellButtonEl = node; bellButtonEl = node;
return () => { return () => {
@@ -102,61 +52,30 @@ function attachBellButton(node: HTMLButtonElement) {
}; };
} }
// 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);
};
}
onMount(() => { onMount(() => {
fetchUnreadCount(); stream.init();
eventSource = new EventSource('/api/notifications/stream');
eventSource.addEventListener('notification', (e) => {
const notification = parseNotificationEvent(e.data);
if (!notification) return;
notifications = [notification, ...notifications];
if (!notification.read) unreadCount += 1;
});
}); });
onDestroy(() => { onDestroy(() => {
eventSource?.close(); stream.destroy();
}); });
</script> </script>
<svelte:window onkeydown={handleKeydown} /> <svelte:window onkeydown={handleKeydown} />
<div class="relative" {@attach attachClickOutside}> <div class="relative" use:clickOutside onclickoutside={() => { if (open) closeDropdown(); }}>
<!-- Bell button --> <!-- Bell button -->
<button <button
{@attach attachBellButton} {@attach attachBellButton}
type="button" type="button"
onclick={toggleDropdown} onclick={toggleDropdown}
aria-label={unreadCount > 0 aria-label={stream.unreadCount > 0
? m.notification_bell_unread_label({ count: unreadCount }) ? m.notification_bell_unread_label({ count: stream.unreadCount })
: m.notification_bell_label()} : m.notification_bell_label()}
aria-expanded={open} aria-expanded={open}
aria-haspopup="true" aria-haspopup="true"
class="relative rounded-sm p-2 text-white/65 transition-colors hover:bg-white/10 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring" class="relative rounded-sm p-2 text-white/65 transition-colors hover:bg-white/10 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
> >
<!-- Bell SVG -->
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5" class="h-5 w-5"
@@ -173,143 +92,22 @@ onDestroy(() => {
/> />
</svg> </svg>
<!-- Unread badge --> <!-- Persistent aria-live wrapper — always in DOM so live region history is preserved -->
{#if unreadCount > 0}
<span <span
aria-live="polite" aria-live="polite"
aria-atomic="true" 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" 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 {stream.unreadCount > 0 ? '' : 'hidden'}"
> >
{unreadCount} {stream.unreadCount}
</span> </span>
{/if}
</button> </button>
<!-- Dropdown -->
{#if open} {#if open}
<div <NotificationDropdown
role="dialog" notifications={stream.notifications}
aria-modal="true" onMarkRead={handleMarkRead}
aria-label={m.notification_bell_label()} onMarkAllRead={stream.markAllRead}
class="absolute right-0 z-50 mt-2 w-80 overflow-hidden rounded-sm border border-line bg-surface shadow-lg" onClose={closeDropdown}
>
<!-- 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>
<button
type="button"
onclick={() => markRead(notification)}
class="flex w-full cursor-pointer items-start gap-3 border-b border-line px-4 py-3 text-left 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={m.notification_unread()}
></span>
{/if}
</button>
</li>
{/each}
</ul>
{/if}
<div class="border-t border-line px-4 py-2">
<a
href="/notifications"
onclick={closeDropdown}
class="text-xs font-medium text-ink-2 transition-colors hover:text-ink"
>
{m.notification_view_all()}
</a>
</div>
</div>
{/if} {/if}
</div> </div>

View File

@@ -0,0 +1,138 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { relativeTime } from '$lib/utils/time';
import type { NotificationItem } from '$lib/hooks/useNotificationStream.svelte';
type Props = {
notifications: NotificationItem[];
onMarkRead: (notification: NotificationItem) => void;
onMarkAllRead: () => void;
onClose: () => void;
};
let { notifications, onMarkRead, onMarkAllRead, onClose }: Props = $props();
</script>
<div
role="dialog"
aria-modal="true"
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
type="button"
onclick={onMarkAllRead}
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" class="max-h-[24rem] overflow-y-auto">
{#each notifications as notification (notification.id)}
<li>
<button
type="button"
onclick={() => onMarkRead(notification)}
class="flex w-full cursor-pointer items-start gap-3 border-b border-line px-4 py-3 text-left 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={m.notification_unread()}
></span>
{/if}
</button>
</li>
{/each}
</ul>
{/if}
<div class="border-t border-line px-4 py-2">
<a
href="/notifications"
onclick={onClose}
class="text-xs font-medium text-ink-2 transition-colors hover:text-ink"
>
{m.notification_view_all()}
</a>
</div>
</div>

View File

@@ -0,0 +1,125 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
type Props = {
currentPage: number;
totalPages: number;
isLoaded: boolean;
showAnnotations: boolean;
annotationCount: number;
onPrev: () => void;
onNext: () => void;
onZoomIn: () => void;
onZoomOut: () => void;
onToggleAnnotations: () => void;
};
let {
currentPage,
totalPages,
isLoaded,
showAnnotations,
annotationCount,
onPrev,
onNext,
onZoomIn,
onZoomOut,
onToggleAnnotations
}: Props = $props();
</script>
<div class="flex shrink-0 items-center justify-between gap-2 border-b border-pdf-ctrl px-4 py-2">
<!-- Page navigation: prev button, page counter, next button -->
<div class="flex items-center gap-2">
<button
onclick={onPrev}
disabled={currentPage <= 1}
aria-label="Zurück"
class="rounded p-1 text-ink-3 transition hover:bg-surface/10 disabled:opacity-40"
>
<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="M15 19l-7-7 7-7" />
</svg>
</button>
{#if totalPages > 0}
<span class="font-sans text-xs text-ink-2 tabular-nums">
{currentPage} / {totalPages}
</span>
{/if}
<button
onclick={onNext}
disabled={!isLoaded || currentPage >= totalPages}
aria-label="Weiter"
class="rounded p-1 text-ink-3 transition hover:bg-surface/10 disabled:opacity-40"
>
<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="M9 5l7 7-7 7" />
</svg>
</button>
</div>
<!-- Zoom controls -->
<div class="flex items-center gap-1">
<button
onclick={onZoomOut}
aria-label="Verkleinern"
class="rounded p-1 text-ink-3 transition hover:bg-surface/10"
>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8" />
<path stroke-linecap="round" d="M21 21l-4.35-4.35M8 11h6" />
</svg>
</button>
<button
onclick={onZoomIn}
aria-label="Vergrößern"
class="rounded p-1 text-ink-3 transition hover:bg-surface/10"
>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8" />
<path stroke-linecap="round" d="M21 21l-4.35-4.35M11 8v6M8 11h6" />
</svg>
</button>
</div>
<!-- Annotation visibility toggle (only when annotations exist) -->
{#if annotationCount > 0}
<button
onclick={onToggleAnnotations}
aria-label={showAnnotations ? m.pdf_annotations_hide() : m.pdf_annotations_show()}
class="flex items-center gap-1.5 rounded px-2 py-1 font-sans text-xs transition {showAnnotations
? 'text-ink-2 hover:bg-surface/10'
: 'bg-surface/10 text-accent'}"
>
<svg
class="h-3.5 w-3.5 shrink-0"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
{#if showAnnotations}
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
/>
{:else}
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"
/>
{/if}
</svg>
{showAnnotations ? m.pdf_annotations_hide() : m.pdf_annotations_show()}
</button>
{/if}
</div>

View File

@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onMount, setContext } from 'svelte'; import { onMount, setContext } from 'svelte';
import type { PDFDocumentProxy, PDFPageProxy, RenderTask } from 'pdfjs-dist'; import { createPdfRenderer } from '$lib/hooks/usePdfRenderer.svelte';
import PdfControls from './PdfControls.svelte';
import AnnotationLayer from './AnnotationLayer.svelte'; import AnnotationLayer from './AnnotationLayer.svelte';
import type { Annotation } from '$lib/types'; import type { Annotation } from '$lib/types';
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
@@ -34,26 +35,12 @@ let {
flashAnnotationId?: string | null; flashAnnotationId?: string | null;
} = $props(); } = $props();
let pdfDoc = $state<PDFDocumentProxy | null>(null); const renderer = createPdfRenderer();
let currentPage = $state(1);
let totalPages = $state(0);
let scale = $state(1.5);
let loading = $state(false);
let error = $state<string | null>(null);
// Canvas and text layer container refs — bound via bind:this, not reactive state // Canvas and text layer container refs — bound via bind:this
let canvasEl = $state<HTMLCanvasElement | null>(null); let canvasEl = $state<HTMLCanvasElement | null>(null);
let textLayerEl = $state<HTMLDivElement | null>(null); let textLayerEl = $state<HTMLDivElement | null>(null);
// Internal mutable refs for in-flight tasks — NOT $state to avoid reactive loops
let renderTask: RenderTask | null = null;
let textLayerInstance: { cancel: () => void } | null = null;
// Holds the dynamically-loaded pdfjs module (browser-only)
// Not $state — we use pdfjsReady as the reactive trigger instead
let pdfjsLib: typeof import('pdfjs-dist') | null = null;
let pdfjsReady = $state(false);
let annotations = $state<Annotation[]>([]); let annotations = $state<Annotation[]>([]);
let showAnnotations = $state(true); let showAnnotations = $state(true);
let annotationUpdateError = $state<string | null>(null); let annotationUpdateError = $state<string | null>(null);
@@ -66,115 +53,63 @@ const visibleAnnotations = $derived(
const outdatedCount = $derived(annotations.length - visibleAnnotations.length); const outdatedCount = $derived(annotations.length - visibleAnnotations.length);
onMount(async () => { onMount(async () => {
// Dynamic import keeps pdfjs out of the SSR bundle entirely await renderer.init();
const [lib, { default: workerUrl }] = await Promise.all([
import('pdfjs-dist'),
import('pdfjs-dist/build/pdf.worker.min.mjs?url')
]);
lib.GlobalWorkerOptions.workerSrc = workerUrl;
pdfjsLib = lib;
pdfjsReady = true;
}); });
async function loadDocument(src: string) { // Wire DOM elements to the renderer after they mount
if (!pdfjsLib) return; $effect(() => {
loading = true; if (canvasEl && textLayerEl) {
error = null; renderer.setElements(canvasEl, textLayerEl);
pdfDoc = null;
currentPage = 1;
totalPages = 0;
try {
const loadingTask = pdfjsLib.getDocument(src);
const doc = await loadingTask.promise;
pdfDoc = doc;
totalPages = doc.numPages;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load PDF';
} finally {
loading = false;
} }
} });
async function renderPage(doc: PDFDocumentProxy, pageNum: number) { $effect(() => {
if (!pdfjsLib || !canvasEl || !textLayerEl) return; if (renderer.pdfjsReady && url) {
renderer.loadDocument(url);
// Cancel any in-flight render
if (renderTask) {
renderTask.cancel();
renderTask = null;
}
if (textLayerInstance) {
textLayerInstance.cancel();
textLayerInstance = null;
} }
});
let page: PDFPageProxy; $effect(() => {
try { // Read scale and currentPage synchronously so Svelte tracks them as dependencies.
page = await doc.getPage(pageNum); if (renderer.isLoaded && renderer.currentPage && renderer.scale > 0) {
} catch { renderer.renderCurrentPage().then(() => renderer.prerender());
}
});
$effect(() => {
if (documentId && annotationReloadKey >= 0) {
loadAnnotations(documentId);
}
});
$effect(() => {
if (transcribeMode) showAnnotations = true;
});
// Scroll-sync: when activeAnnotationId changes, navigate to its page
let prevActiveAnnotationId: string | null = null;
$effect(() => {
const id = activeAnnotationId;
if (!id || id === prevActiveAnnotationId || !renderer.isLoaded) {
prevActiveAnnotationId = id;
return; return;
} }
prevActiveAnnotationId = id;
const dpr = window.devicePixelRatio || 1; const ann = annotations.find((a) => a.id === id);
const viewport = page.getViewport({ scale: scale * dpr }); if (!ann) return;
const canvas = canvasEl; if (ann.pageNumber !== renderer.currentPage) {
const ctx = canvas.getContext('2d'); renderer.goToPage(ann.pageNumber);
if (!ctx) return;
canvas.width = viewport.width;
canvas.height = viewport.height;
canvas.style.width = `${viewport.width / dpr}px`;
canvas.style.height = `${viewport.height / dpr}px`;
const task = page.render({ canvas, canvasContext: ctx, viewport });
renderTask = task;
try {
await task.promise;
} catch (e: unknown) {
if (
typeof e === 'object' &&
e !== null &&
'name' in e &&
(e as { name: string }).name === 'RenderingCancelledException'
)
return;
return;
} }
renderTask = null;
// Text layer requestAnimationFrame(() => {
const textDiv = textLayerEl; requestAnimationFrame(() => {
if (!textDiv) return; const el = document.querySelector(`[data-testid="annotation-${id}"]`);
textDiv.innerHTML = ''; el?.scrollIntoView({ behavior: 'smooth', block: 'center' });
textDiv.style.width = `${viewport.width / dpr}px`;
textDiv.style.height = `${viewport.height / dpr}px`;
const tl = new pdfjsLib.TextLayer({
textContentSource: page.streamTextContent(),
container: textDiv,
viewport
}); });
textLayerInstance = tl; });
try { });
await tl.render();
} catch {
// cancelled
}
}
async function prerender(doc: PDFDocumentProxy, pageNum: number) {
const neighbors = [pageNum - 1, pageNum + 1].filter((n) => n >= 1 && n <= doc.numPages);
for (const n of neighbors) {
try {
await doc.getPage(n);
} catch {
// ignore
}
}
}
async function loadAnnotations(docId: string) { async function loadAnnotations(docId: string) {
if (!docId) return; if (!docId) return;
@@ -213,7 +148,7 @@ setContext('annotationUpdate', updateAnnotation);
async function handleDraw(rect: { x: number; y: number; width: number; height: number }) { async function handleDraw(rect: { x: number; y: number; width: number; height: number }) {
if (!documentId || !transcribeMode) return; if (!documentId || !transcribeMode) return;
await onTranscriptionDraw?.({ ...rect, pageNumber: currentPage }); await onTranscriptionDraw?.({ ...rect, pageNumber: renderer.currentPage });
await loadAnnotations(documentId); await loadAnnotations(documentId);
} }
@@ -221,82 +156,13 @@ function handleAnnotationClick(id: string) {
activeAnnotationId = id; activeAnnotationId = id;
onAnnotationClick?.(id); onAnnotationClick?.(id);
} }
$effect(() => {
if (pdfjsReady && url) {
loadDocument(url);
}
});
$effect(() => {
// Read scale synchronously so Svelte tracks it as a dependency.
// Without this, zoom changes don't re-trigger the effect because
// scale is only read inside the async renderPage call.
if (pdfDoc && currentPage && scale > 0) {
renderPage(pdfDoc, currentPage).then(() => {
if (pdfDoc) prerender(pdfDoc, currentPage);
});
}
});
$effect(() => {
if (documentId && annotationReloadKey >= 0) {
loadAnnotations(documentId);
}
});
$effect(() => {
if (transcribeMode) showAnnotations = true;
});
// Scroll-sync: when activeAnnotationId changes, navigate to its page
let prevActiveAnnotationId: string | null = null;
$effect(() => {
const id = activeAnnotationId;
if (!id || id === prevActiveAnnotationId || !pdfDoc) {
prevActiveAnnotationId = id;
return;
}
prevActiveAnnotationId = id;
const ann = annotations.find((a) => a.id === id);
if (!ann) return;
if (ann.pageNumber !== currentPage) {
currentPage = ann.pageNumber;
}
// After page renders, scroll the annotation into view (double-rAF for async render)
requestAnimationFrame(() => {
requestAnimationFrame(() => {
const el = document.querySelector(`[data-testid="annotation-${id}"]`);
el?.scrollIntoView({ behavior: 'smooth', block: 'center' });
});
});
});
function prevPage() {
if (currentPage > 1) currentPage -= 1;
}
function nextPage() {
if (pdfDoc && currentPage < pdfDoc.numPages) currentPage += 1;
}
function zoomIn() {
scale += 0.25;
}
function zoomOut() {
if (scale > 0.5) scale -= 0.25;
}
</script> </script>
{#if !url} {#if !url}
<div class="flex h-full w-full items-center justify-center bg-pdf-bg text-ink-3"> <div class="flex h-full w-full items-center justify-center bg-pdf-bg text-ink-3">
<p class="font-sans text-sm">Keine Datei vorhanden</p> <p class="font-sans text-sm">Keine Datei vorhanden</p>
</div> </div>
{:else if error} {:else if renderer.error}
<div class="flex h-full w-full flex-col items-center justify-center gap-3 bg-pdf-bg text-ink-3"> <div class="flex h-full w-full flex-col items-center justify-center gap-3 bg-pdf-bg text-ink-3">
<p class="font-sans text-sm text-red-400">Fehler beim Laden der PDF</p> <p class="font-sans text-sm text-red-400">Fehler beim Laden der PDF</p>
<a <a
@@ -351,136 +217,23 @@ function zoomOut() {
<span class="font-sans text-xs text-red-300">{annotationUpdateError}</span> <span class="font-sans text-xs text-red-300">{annotationUpdateError}</span>
</div> </div>
{/if} {/if}
<!-- Controls -->
<div
class="flex shrink-0 items-center justify-between gap-2 border-b border-pdf-ctrl px-4 py-2"
>
<!-- Page navigation -->
<div class="flex items-center gap-2">
<button
onclick={prevPage}
disabled={currentPage <= 1}
aria-label="Zurück"
class="rounded p-1 text-ink-3 transition hover:bg-surface/10 disabled:opacity-40"
>
<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="M15 19l-7-7 7-7" />
</svg>
</button>
{#if totalPages > 0} <PdfControls
<span class="font-sans text-xs text-ink-2 tabular-nums"> currentPage={renderer.currentPage}
{currentPage} / {totalPages} totalPages={renderer.totalPages}
</span> isLoaded={renderer.isLoaded}
{/if} showAnnotations={showAnnotations}
annotationCount={annotations.length}
<button onPrev={() => renderer.prevPage()}
onclick={nextPage} onNext={() => renderer.nextPage()}
disabled={!pdfDoc || currentPage >= totalPages} onZoomIn={() => renderer.zoomIn()}
aria-label="Weiter" onZoomOut={() => renderer.zoomOut()}
class="rounded p-1 text-ink-3 transition hover:bg-surface/10 disabled:opacity-40" onToggleAnnotations={() => (showAnnotations = !showAnnotations)}
>
<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="M9 5l7 7-7 7" />
</svg>
</button>
</div>
<!-- Zoom controls -->
<div class="flex items-center gap-1">
<button
onclick={zoomOut}
aria-label="Verkleinern"
class="rounded p-1 text-ink-3 transition hover:bg-surface/10"
>
<svg
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="11" cy="11" r="8" /><path
stroke-linecap="round"
d="M21 21l-4.35-4.35M8 11h6"
/> />
</svg>
</button>
<button
onclick={zoomIn}
aria-label="Vergrößern"
class="rounded p-1 text-ink-3 transition hover:bg-surface/10"
>
<svg
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="11" cy="11" r="8" /><path
stroke-linecap="round"
d="M21 21l-4.35-4.35M11 8v6M8 11h6"
/>
</svg>
</button>
</div>
<!-- Annotation visibility toggle (shown when annotations exist) -->
{#if annotations.length > 0}
<button
onclick={() => (showAnnotations = !showAnnotations)}
aria-label={showAnnotations ? m.pdf_annotations_hide() : m.pdf_annotations_show()}
class="flex items-center gap-1.5 rounded px-2 py-1 font-sans text-xs transition {showAnnotations
? 'text-ink-2 hover:bg-surface/10'
: 'bg-surface/10 text-accent'}"
>
<svg
class="h-3.5 w-3.5 shrink-0"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
{#if showAnnotations}
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
/>
{:else}
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"
/>
{/if}
</svg>
{showAnnotations ? m.pdf_annotations_hide() : m.pdf_annotations_show()}
</button>
{/if}
</div>
<!-- PDF canvas area --> <!-- PDF canvas area -->
<div class="relative flex-1 overflow-auto"> <div class="relative flex-1 overflow-auto">
{#if loading} {#if renderer.loading}
<div class="flex h-full items-center justify-center"> <div class="flex h-full items-center justify-center">
<div <div
class="h-8 w-8 animate-spin rounded-full border-2 border-white/20 border-t-white" class="h-8 w-8 animate-spin rounded-full border-2 border-white/20 border-t-white"
@@ -490,7 +243,7 @@ function zoomOut() {
<div class="flex min-h-full items-start justify-center p-4"> <div class="flex min-h-full items-start justify-center p-4">
<div <div
class="pdf-page relative shadow-xl" class="pdf-page relative shadow-xl"
data-page-number={currentPage} data-page-number={renderer.currentPage}
style="position: relative" style="position: relative"
> >
<canvas bind:this={canvasEl}></canvas> <canvas bind:this={canvasEl}></canvas>
@@ -501,7 +254,9 @@ function zoomOut() {
></div> ></div>
{#if showAnnotations} {#if showAnnotations}
<AnnotationLayer <AnnotationLayer
annotations={visibleAnnotations.filter((a) => a.pageNumber === currentPage)} annotations={visibleAnnotations.filter(
(a) => a.pageNumber === renderer.currentPage
)}
canDraw={transcribeMode} canDraw={transcribeMode}
color={TRANSCRIPTION_COLOR} color={TRANSCRIPTION_COLOR}
blockNumbers={blockNumbers} blockNumbers={blockNumbers}

View File

@@ -12,7 +12,7 @@ let { person, abbreviated }: Props = $props();
const name = $derived(abbreviated ? abbreviateName(person) : person.displayName); const name = $derived(abbreviated ? abbreviateName(person) : person.displayName);
const avatarColor = $derived(personAvatarColor(person.id)); const avatarColor = $derived(personAvatarColor(person.id));
const initials = $derived(getInitials(person)); const initials = $derived(getInitials(person.displayName));
</script> </script>
<a <a

View File

@@ -1,11 +1,10 @@
<script lang="ts"> <script lang="ts">
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import { SvelteMap } from 'svelte/reactivity';
import TranscriptionBlock from './TranscriptionBlock.svelte'; import TranscriptionBlock from './TranscriptionBlock.svelte';
import OcrTrigger from './OcrTrigger.svelte'; import OcrTrigger from './OcrTrigger.svelte';
import type { TranscriptionBlockData } from '$lib/types'; import type { TranscriptionBlockData } from '$lib/types';
import { createBlockAutoSave } from '$lib/hooks/useBlockAutoSave.svelte';
type SaveState = 'idle' | 'saving' | 'saved' | 'fading' | 'error'; import { createBlockDragDrop } from '$lib/hooks/useBlockDragDrop.svelte';
type Props = { type Props = {
documentId: string; documentId: string;
@@ -45,6 +44,13 @@ let {
let activeBlockId: string | null = $state(null); let activeBlockId: string | null = $state(null);
let localLabels: string[] = $derived.by(() => [...trainingLabels]); let localLabels: string[] = $derived.by(() => [...trainingLabels]);
let listEl: HTMLElement | null = $state(null);
const sortedBlocks = $derived([...blocks].sort((a, b) => a.sortOrder - b.sortOrder));
const hasBlocks = $derived(blocks.length > 0);
const reviewedCount = $derived(blocks.filter((b) => b.reviewed).length);
const totalCount = $derived(blocks.length);
const reviewProgress = $derived(totalCount > 0 ? (reviewedCount / totalCount) * 100 : 0);
// Sync: when an annotation is clicked on the PDF, activate the corresponding block // Sync: when an annotation is clicked on the PDF, activate the corresponding block
$effect(() => { $effect(() => {
@@ -52,104 +58,37 @@ $effect(() => {
const block = blocks.find((b) => b.annotationId === activeAnnotationId); const block = blocks.find((b) => b.annotationId === activeAnnotationId);
if (block) activeBlockId = block.id; if (block) activeBlockId = block.id;
}); });
let saveStates = new SvelteMap<string, SaveState>();
let debounceTimers = new SvelteMap<string, ReturnType<typeof setTimeout>>();
let pendingTexts = new SvelteMap<string, string>();
let sortedBlocks = $derived([...blocks].sort((a, b) => a.sortOrder - b.sortOrder));
let hasBlocks = $derived(blocks.length > 0);
let reviewedCount = $derived(blocks.filter((b) => b.reviewed).length);
let totalCount = $derived(blocks.length);
let reviewProgress = $derived(totalCount > 0 ? (reviewedCount / totalCount) * 100 : 0);
function getSaveState(blockId: string): SaveState { const autoSave = createBlockAutoSave({ saveFn: onSaveBlock, documentId });
return saveStates.get(blockId) ?? 'idle';
}
function setSaveState(blockId: string, state: SaveState) { const dragDrop = createBlockDragDrop({
saveStates.set(blockId, state); getSortedBlocks: () => sortedBlocks,
} onReorder: reorder
});
async function executeSave(blockId: string) { // Wire listEl to drag-drop module
const text = pendingTexts.get(blockId); $effect(() => {
if (text === undefined) return; dragDrop.setListElement(listEl);
});
pendingTexts.delete(blockId); $effect(() => {
setSaveState(blockId, 'saving'); function onBeforeUnload() {
autoSave.flushViaBeacon();
try {
await onSaveBlock(blockId, text);
setSaveState(blockId, 'saved');
scheduleSavedFade(blockId);
} catch {
setSaveState(blockId, 'error');
} }
} window.addEventListener('beforeunload', onBeforeUnload);
return () => {
function scheduleSavedFade(blockId: string) { window.removeEventListener('beforeunload', onBeforeUnload);
setTimeout(() => { autoSave.destroy();
if (getSaveState(blockId) === 'saved') { };
setSaveState(blockId, 'fading'); });
setTimeout(() => {
if (getSaveState(blockId) === 'fading') {
setSaveState(blockId, 'idle');
}
}, 300);
}
}, 2000);
}
function scheduleDebounce(blockId: string) {
clearDebounce(blockId);
const timer = setTimeout(() => {
debounceTimers.delete(blockId);
executeSave(blockId);
}, 1500);
debounceTimers.set(blockId, timer);
}
function clearDebounce(blockId: string) {
const existing = debounceTimers.get(blockId);
if (existing !== undefined) {
clearTimeout(existing);
debounceTimers.delete(blockId);
}
}
function flushAllPending() {
for (const [blockId] of debounceTimers) {
clearDebounce(blockId);
executeSave(blockId);
}
}
function handleTextChange(blockId: string, text: string) {
pendingTexts.set(blockId, text);
scheduleDebounce(blockId);
}
function handleFocus(blockId: string) { function handleFocus(blockId: string) {
activeBlockId = blockId; activeBlockId = blockId;
onBlockFocus(blockId); onBlockFocus(blockId);
} }
function handleBlur() {
flushAllPending();
}
async function handleRetry(blockId: string) {
const block = blocks.find((b) => b.id === blockId);
if (!block) return;
const pending = pendingTexts.get(blockId);
const text = pending ?? block.text;
pendingTexts.set(blockId, text);
await executeSave(blockId);
}
function handleDelete(blockId: string) { function handleDelete(blockId: string) {
clearDebounce(blockId); autoSave.clearBlock(blockId);
pendingTexts.delete(blockId);
saveStates.delete(blockId);
onDeleteBlock(blockId); onDeleteBlock(blockId);
} }
@@ -162,7 +101,6 @@ async function reorder(newOrder: string[]) {
}); });
if (!res.ok) return; if (!res.ok) return;
const updated = await res.json(); const updated = await res.json();
// Update blocks with new sort orders from server
for (const b of updated) { for (const b of updated) {
const existing = blocks.find((x) => x.id === b.id); const existing = blocks.find((x) => x.id === b.id);
if (existing) existing.sortOrder = b.sortOrder; if (existing) existing.sortOrder = b.sortOrder;
@@ -188,69 +126,9 @@ function handleMoveDown(blockId: string) {
reorder(sorted.map((b) => b.id)); reorder(sorted.map((b) => b.id));
} }
// ── Pointer-based drag and drop ──────────────────────────────────────────
let draggedBlockId: string | null = $state(null);
let dropTargetIdx: number | null = $state(null);
let dragOffsetY: number = $state(0);
let dragStartY = 0;
let capturedEl: HTMLElement | null = null;
let listEl: HTMLElement | null = $state(null);
function handleGripDown(e: PointerEvent, blockId: string) {
if (!(e.target as HTMLElement).closest('[data-drag-handle]')) return;
e.preventDefault();
draggedBlockId = blockId;
dragStartY = e.clientY;
dragOffsetY = 0;
capturedEl = (e.target as HTMLElement).closest('[data-block-wrapper]') as HTMLElement;
capturedEl?.setPointerCapture(e.pointerId);
}
function handlePointerMove(e: PointerEvent) {
if (!draggedBlockId || !listEl) return;
dragOffsetY = e.clientY - dragStartY;
const wrappers = Array.from(listEl.querySelectorAll('[data-block-wrapper]'));
const dragIdx = sortedBlocks.findIndex((b) => b.id === draggedBlockId);
let target: number | null = null;
for (let i = 0; i < wrappers.length; i++) {
const rect = wrappers[i].getBoundingClientRect();
if (e.clientY < rect.top + rect.height / 2) {
target = i;
break;
}
}
if (target === null) target = wrappers.length;
if (target === dragIdx || target === dragIdx + 1) target = null;
dropTargetIdx = target;
}
function handlePointerUp() {
if (!draggedBlockId) return;
if (dropTargetIdx !== null) {
const sorted = [...sortedBlocks];
const fromIdx = sorted.findIndex((b) => b.id === draggedBlockId);
if (fromIdx >= 0) {
const [moved] = sorted.splice(fromIdx, 1);
const insertAt = dropTargetIdx > fromIdx ? dropTargetIdx - 1 : dropTargetIdx;
sorted.splice(insertAt, 0, moved);
reorder(sorted.map((b) => b.id));
}
}
draggedBlockId = null;
dropTargetIdx = null;
dragOffsetY = 0;
capturedEl = null;
}
async function handleLabelToggle(label: string) { async function handleLabelToggle(label: string) {
if (!onToggleTrainingLabel) return; if (!onToggleTrainingLabel) return;
const enrolled = !localLabels.includes(label); const enrolled = !localLabels.includes(label);
// Optimistic update
if (enrolled) { if (enrolled) {
localLabels = [...localLabels, label]; localLabels = [...localLabels, label];
} else { } else {
@@ -259,35 +137,9 @@ async function handleLabelToggle(label: string) {
try { try {
await onToggleTrainingLabel(label, enrolled); await onToggleTrainingLabel(label, enrolled);
} catch { } catch {
// Revert on failure
localLabels = [...trainingLabels]; localLabels = [...trainingLabels];
} }
} }
function flushViaBeacon() {
for (const [blockId, text] of pendingTexts) {
clearDebounce(blockId);
const url = `/api/documents/${documentId}/transcription-blocks/${blockId}`;
const body = JSON.stringify({ text });
navigator.sendBeacon(url, new Blob([body], { type: 'application/json' }));
pendingTexts.delete(blockId);
}
}
$effect(() => {
function onBeforeUnload() {
flushViaBeacon();
}
window.addEventListener('beforeunload', onBeforeUnload);
return () => {
window.removeEventListener('beforeunload', onBeforeUnload);
for (const timer of debounceTimers.values()) {
clearTimeout(timer);
}
};
});
</script> </script>
<div class="flex h-full flex-col overflow-y-auto bg-surface"> <div class="flex h-full flex-col overflow-y-auto bg-surface">
@@ -309,20 +161,22 @@ $effect(() => {
<div <div
class="flex flex-col gap-3" class="flex flex-col gap-3"
bind:this={listEl} bind:this={listEl}
onpointermove={handlePointerMove} onpointermove={dragDrop.handlePointerMove}
onpointerup={handlePointerUp} onpointerup={dragDrop.handlePointerUp}
> >
{#each sortedBlocks as block, i (block.id)} {#each sortedBlocks as block, i (block.id)}
{#if dropTargetIdx === i} {#if dragDrop.dropTargetIdx === i}
<div class="h-1 rounded-full bg-turquoise transition-all"></div> <div class="h-1 rounded-full bg-turquoise transition-all"></div>
{/if} {/if}
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div <div
data-block-wrapper data-block-wrapper
onblur={handleBlur} onblur={autoSave.handleBlur}
onpointerdown={(e) => handleGripDown(e, block.id)} onpointerdown={(e) => dragDrop.handleGripDown(e, block.id)}
class="relative transition-all duration-150 {draggedBlockId === block.id ? 'z-10 rounded-lg shadow-xl ring-2 ring-turquoise/40' : ''}" class="relative transition-all duration-150 {dragDrop.draggedBlockId === block.id ? 'z-10 rounded-lg shadow-xl ring-2 ring-turquoise/40' : ''}"
style={draggedBlockId === block.id ? `transform: translateY(${dragOffsetY}px) scale(1.02); opacity: 0.9;` : ''} style={dragDrop.draggedBlockId === block.id
? `transform: translateY(${dragDrop.dragOffsetY}px) scale(1.02); opacity: 0.9;`
: ''}
> >
<TranscriptionBlock <TranscriptionBlock
blockId={block.id} blockId={block.id}
@@ -332,13 +186,13 @@ $effect(() => {
label={block.label} label={block.label}
active={activeBlockId === block.id} active={activeBlockId === block.id}
reviewed={block.reviewed ?? false} reviewed={block.reviewed ?? false}
saveState={getSaveState(block.id)} saveState={autoSave.getSaveState(block.id)}
canComment={canComment} canComment={canComment}
currentUserId={currentUserId} currentUserId={currentUserId}
onTextChange={(text) => handleTextChange(block.id, text)} onTextChange={(text) => autoSave.handleTextChange(block.id, text)}
onFocus={() => handleFocus(block.id)} onFocus={() => handleFocus(block.id)}
onDeleteClick={() => handleDelete(block.id)} onDeleteClick={() => handleDelete(block.id)}
onRetry={() => handleRetry(block.id)} onRetry={() => autoSave.handleRetry(block.id, block.text)}
onReviewToggle={() => onReviewToggle(block.id)} onReviewToggle={() => onReviewToggle(block.id)}
onMoveUp={() => handleMoveUp(block.id)} onMoveUp={() => handleMoveUp(block.id)}
onMoveDown={() => handleMoveDown(block.id)} onMoveDown={() => handleMoveDown(block.id)}
@@ -349,7 +203,7 @@ $effect(() => {
</div> </div>
{/each} {/each}
{#if dropTargetIdx === sortedBlocks.length} {#if dragDrop.dropTargetIdx === sortedBlocks.length}
<div class="h-1 rounded-full bg-turquoise transition-all"></div> <div class="h-1 rounded-full bg-turquoise transition-all"></div>
{/if} {/if}

View File

@@ -0,0 +1,22 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
interface Props {
onDiscard: () => void;
}
let { onDiscard }: Props = $props();
</script>
<div
class="mb-5 flex items-center justify-between rounded border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-300"
>
<span>{m.admin_unsaved_warning()}</span>
<button
type="button"
onclick={onDiscard}
class="ml-4 shrink-0 font-sans text-xs font-bold tracking-widest text-amber-800 uppercase hover:text-amber-900 dark:text-amber-300"
>
{m.person_discard_changes()}
</button>
</div>

View File

@@ -0,0 +1,84 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
const mockSaveFn = vi.fn<(blockId: string, text: string) => Promise<void>>();
const { createBlockAutoSave } = await import('../useBlockAutoSave.svelte');
describe('createBlockAutoSave', () => {
beforeEach(() => {
vi.useFakeTimers();
mockSaveFn.mockClear();
mockSaveFn.mockResolvedValue(undefined);
});
afterEach(() => {
vi.useRealTimers();
});
it('getSaveState returns idle initially', () => {
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
expect(as.getSaveState('block-1')).toBe('idle');
});
it('debounce coalesces multiple changes — saves once after 1500ms', async () => {
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
as.handleTextChange('block-1', 'text 1');
as.handleTextChange('block-1', 'text 2');
as.handleTextChange('block-1', 'text 3');
await vi.advanceTimersByTimeAsync(1500);
expect(mockSaveFn).toHaveBeenCalledTimes(1);
expect(mockSaveFn).toHaveBeenCalledWith('block-1', 'text 3');
});
it('handles concurrent blocks independently', async () => {
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
as.handleTextChange('block-1', 'hello');
as.handleTextChange('block-2', 'world');
await vi.advanceTimersByTimeAsync(1500);
expect(mockSaveFn).toHaveBeenCalledTimes(2);
});
it('sets save state to saving then saved on success', async () => {
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
as.handleTextChange('block-1', 'text');
vi.advanceTimersByTime(1500);
expect(as.getSaveState('block-1')).toBe('saving');
await Promise.resolve();
expect(as.getSaveState('block-1')).toBe('saved');
});
it('sets save state to error on save failure', async () => {
mockSaveFn.mockRejectedValue(new Error('save failed'));
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
as.handleTextChange('block-1', 'text');
await vi.advanceTimersByTimeAsync(1500);
expect(as.getSaveState('block-1')).toBe('error');
});
it('handleRetry saves with provided current text', async () => {
mockSaveFn.mockRejectedValueOnce(new Error('first fails'));
mockSaveFn.mockResolvedValueOnce(undefined);
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
as.handleTextChange('block-1', 'original');
await vi.advanceTimersByTimeAsync(1500);
expect(as.getSaveState('block-1')).toBe('error');
await as.handleRetry('block-1', 'original');
expect(mockSaveFn).toHaveBeenCalledTimes(2);
expect(as.getSaveState('block-1')).toBe('saved');
});
it('clearBlock removes all state for a block', () => {
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
as.handleTextChange('block-1', 'text');
as.clearBlock('block-1');
expect(as.getSaveState('block-1')).toBe('idle');
});
it('destroy clears all pending timers so no save occurs', async () => {
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
as.handleTextChange('block-1', 'text');
as.destroy();
await vi.advanceTimersByTimeAsync(2000);
expect(mockSaveFn).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,168 @@
import { describe, it, expect, vi } from 'vitest';
import { createBlockDragDrop } from '../useBlockDragDrop.svelte';
import type { TranscriptionBlockData } from '$lib/types';
function makeBlock(id: string, sortOrder: number): TranscriptionBlockData {
return {
id,
annotationId: `ann-${id}`,
documentId: 'doc-1',
text: '',
label: null,
sortOrder,
version: 1,
source: 'MANUAL',
reviewed: false
};
}
/**
* Builds a DOM list, mocks getBoundingClientRect (60px per wrapper),
* drags `dragId` and drops it so dropTargetIdx === targetIdx, then
* triggers handlePointerUp. Returns the onReorder spy.
*/
function simulateDragDrop(
dragId: string,
targetIdx: number,
blocks: TranscriptionBlockData[]
): ReturnType<typeof vi.fn> {
const onReorder = vi.fn();
const dd = createBlockDragDrop({ getSortedBlocks: () => blocks, onReorder });
// Build DOM
const listEl = document.createElement('div');
const wrappers = blocks.map(() => {
const grip = document.createElement('div');
grip.setAttribute('data-drag-handle', '');
const wrapper = document.createElement('div');
wrapper.setAttribute('data-block-wrapper', '');
wrapper.appendChild(grip);
listEl.appendChild(wrapper);
return { grip, wrapper };
});
document.body.appendChild(listEl);
dd.setListElement(listEl);
// Mock bounding rects: each wrapper is 60px tall starting at y=0
wrappers.forEach(({ wrapper }, i) => {
vi.spyOn(wrapper, 'getBoundingClientRect').mockReturnValue({
top: i * 60,
height: 60,
bottom: (i + 1) * 60,
left: 0,
right: 100,
width: 100,
x: 0,
y: i * 60,
toJSON: () => ({})
} as DOMRect);
});
const dragIdx = blocks.findIndex((b) => b.id === dragId);
const { grip, wrapper: dragWrapper } = wrappers[dragIdx];
dragWrapper.setPointerCapture = vi.fn();
// Start drag
const downEvent = new PointerEvent('pointerdown', { clientY: dragIdx * 60, cancelable: true });
Object.defineProperty(downEvent, 'target', { value: grip });
dd.handleGripDown(downEvent as PointerEvent, dragId);
// Move pointer to achieve the desired targetIdx
// midpoint of wrapper[i] = i*60 + 30
// clientY just before midpoint[i] → target = i
// clientY past last midpoint → target = wrappers.length
let clientY: number;
if (targetIdx <= 0) {
clientY = 5; // before first midpoint (30)
} else if (targetIdx >= wrappers.length) {
clientY = wrappers.length * 60 + 10; // past all midpoints
} else {
clientY = targetIdx * 60 + 5; // just past top of wrapper[targetIdx], before its midpoint
}
const moveEvent = new PointerEvent('pointermove', { clientY });
dd.handlePointerMove(moveEvent as PointerEvent);
dd.handlePointerUp();
document.body.removeChild(listEl);
return onReorder;
}
describe('createBlockDragDrop', () => {
it('initial state — no drag in progress', () => {
const dd = createBlockDragDrop({ getSortedBlocks: () => [], onReorder: vi.fn() });
expect(dd.draggedBlockId).toBeNull();
expect(dd.dropTargetIdx).toBeNull();
expect(dd.dragOffsetY).toBe(0);
});
it('handleGripDown sets draggedBlockId when grip is hit', () => {
const dd = createBlockDragDrop({ getSortedBlocks: () => [], onReorder: vi.fn() });
const grip = document.createElement('div');
grip.setAttribute('data-drag-handle', '');
const wrapper = document.createElement('div');
wrapper.setAttribute('data-block-wrapper', '');
wrapper.appendChild(grip);
document.body.appendChild(wrapper);
const e = new PointerEvent('pointerdown', { clientY: 100, cancelable: true, bubbles: true });
Object.defineProperty(e, 'target', { value: grip });
wrapper.setPointerCapture = vi.fn();
dd.handleGripDown(e as PointerEvent, 'block-1');
expect(dd.draggedBlockId).toBe('block-1');
document.body.removeChild(wrapper);
});
it('handlePointerUp without active drag is a no-op', () => {
const onReorder = vi.fn();
const dd = createBlockDragDrop({ getSortedBlocks: () => [], onReorder });
dd.handlePointerUp();
expect(onReorder).not.toHaveBeenCalled();
});
it('handlePointerUp with null dropTargetIdx does not call onReorder', () => {
const onReorder = vi.fn();
const blocks = [makeBlock('b1', 0), makeBlock('b2', 1)];
const dd = createBlockDragDrop({ getSortedBlocks: () => blocks, onReorder });
const grip = document.createElement('div');
grip.setAttribute('data-drag-handle', '');
const wrapper = document.createElement('div');
wrapper.setAttribute('data-block-wrapper', '');
wrapper.appendChild(grip);
document.body.appendChild(wrapper);
wrapper.setPointerCapture = vi.fn();
const downEvent = new PointerEvent('pointerdown', { clientY: 50, cancelable: true });
Object.defineProperty(downEvent, 'target', { value: grip });
dd.handleGripDown(downEvent as PointerEvent, 'b1');
// dropTargetIdx is still null (no pointer move happened)
dd.handlePointerUp();
expect(onReorder).not.toHaveBeenCalled();
expect(dd.draggedBlockId).toBeNull();
document.body.removeChild(wrapper);
});
it('reorder: moves block from index 0 to end', () => {
const blocks = [makeBlock('b1', 0), makeBlock('b2', 1), makeBlock('b3', 2)];
const onReorder = simulateDragDrop('b1', 3, blocks);
expect(onReorder).toHaveBeenCalledWith(['b2', 'b3', 'b1']);
});
it('reorder: moves block from end to index 0', () => {
const blocks = [makeBlock('b1', 0), makeBlock('b2', 1), makeBlock('b3', 2)];
const onReorder = simulateDragDrop('b3', 0, blocks);
expect(onReorder).toHaveBeenCalledWith(['b3', 'b1', 'b2']);
});
it('reorder: moves block down by one position (tests insertAt = dropTargetIdx - 1)', () => {
const blocks = [makeBlock('b1', 0), makeBlock('b2', 1), makeBlock('b3', 2)];
// dragId=b1 (idx=0), targetIdx=2 → insertAt = 2-1 = 1 → [b2, b1, b3]
const onReorder = simulateDragDrop('b1', 2, blocks);
expect(onReorder).toHaveBeenCalledWith(['b2', 'b1', 'b3']);
});
});

View File

@@ -0,0 +1,98 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { createFileLoader } from '../useFileLoader.svelte';
const FAKE_URL = 'blob:fake-url';
function setupFetch(ok: boolean, body?: Blob) {
const blob = body ?? new Blob(['fake'], { type: 'application/pdf' });
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok,
blob: vi.fn().mockResolvedValue(blob)
})
);
}
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe('createFileLoader', () => {
it('sets fileUrl after a successful fetch', async () => {
vi.stubGlobal('URL', {
createObjectURL: vi.fn().mockReturnValue(FAKE_URL),
revokeObjectURL: vi.fn()
});
setupFetch(true);
const loader = createFileLoader();
await loader.loadFile('/api/documents/1/file');
expect(loader.fileUrl).toBe(FAKE_URL);
expect(loader.isLoading).toBe(false);
expect(loader.fileError).toBe('');
});
it('sets fileError on a failed fetch (non-ok response)', async () => {
vi.stubGlobal('URL', {
createObjectURL: vi.fn(),
revokeObjectURL: vi.fn()
});
setupFetch(false);
const loader = createFileLoader();
await loader.loadFile('/api/documents/1/file');
expect(loader.fileUrl).toBe('');
expect(loader.fileError).not.toBe('');
expect(loader.isLoading).toBe(false);
});
it('revokes the previous URL before creating a new one', async () => {
const revokeObjectURL = vi.fn();
vi.stubGlobal('URL', {
createObjectURL: vi.fn().mockReturnValue(FAKE_URL),
revokeObjectURL
});
setupFetch(true);
const loader = createFileLoader();
await loader.loadFile('/api/documents/1/file');
// First load: no previous URL to revoke
expect(revokeObjectURL).not.toHaveBeenCalled();
await loader.loadFile('/api/documents/2/file');
// Second load: previous URL should be revoked
expect(revokeObjectURL).toHaveBeenCalledWith(FAKE_URL);
});
it('revokes the URL on destroy', async () => {
const revokeObjectURL = vi.fn();
vi.stubGlobal('URL', {
createObjectURL: vi.fn().mockReturnValue(FAKE_URL),
revokeObjectURL
});
setupFetch(true);
const loader = createFileLoader();
await loader.loadFile('/api/documents/1/file');
loader.destroy();
expect(revokeObjectURL).toHaveBeenCalledWith(FAKE_URL);
});
it('does not revoke when no URL has been set', () => {
const revokeObjectURL = vi.fn();
vi.stubGlobal('URL', {
createObjectURL: vi.fn(),
revokeObjectURL
});
const loader = createFileLoader();
loader.destroy();
expect(revokeObjectURL).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,142 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { NotificationItem } from '../useNotificationStream.svelte';
// Track the last created EventSource instance
let lastEventSource: {
close: ReturnType<typeof vi.fn>;
onopen: (() => void) | null;
onerror: (() => void) | null;
simulate: (type: string, data: string) => void;
} | null = null;
class MockEventSource {
onopen: (() => void) | null = null;
onerror: (() => void) | null = null;
close = vi.fn();
private listeners: Record<string, ((e: MessageEvent) => void)[]> = {};
constructor() {
// eslint-disable-next-line @typescript-eslint/no-this-alias
lastEventSource = this;
}
addEventListener(type: string, fn: (e: MessageEvent) => void) {
if (!this.listeners[type]) this.listeners[type] = [];
this.listeners[type].push(fn);
}
simulate(type: string, data: string) {
const event = new MessageEvent(type, { data });
for (const fn of this.listeners[type] ?? []) {
fn(event);
}
}
}
vi.stubGlobal('EventSource', MockEventSource);
const mockFetch = vi.fn();
vi.stubGlobal('fetch', mockFetch);
// Import after stubs are set up
const { createNotificationStream } = await import('../useNotificationStream.svelte');
beforeEach(() => {
mockFetch.mockReset();
lastEventSource = null;
});
function makeNotification(overrides: Partial<NotificationItem> = {}): NotificationItem {
return {
id: 'n1',
type: 'REPLY',
actorName: 'Hans',
documentId: 'doc-1',
referenceId: 'ref-1',
annotationId: null,
read: false,
createdAt: new Date().toISOString(),
...overrides
};
}
describe('createNotificationStream', () => {
it('starts with empty notifications and zero unreadCount', () => {
const stream = createNotificationStream();
expect(stream.notifications).toHaveLength(0);
expect(stream.unreadCount).toBe(0);
});
it('fetchUnreadCount updates unreadCount from API', async () => {
mockFetch.mockResolvedValueOnce(new Response(JSON.stringify({ count: 3 }), { status: 200 }));
const stream = createNotificationStream();
await stream.fetchUnreadCount();
expect(stream.unreadCount).toBe(3);
});
it('fetchNotifications populates notifications from API', async () => {
const items = [makeNotification()];
mockFetch.mockResolvedValueOnce(
new Response(JSON.stringify({ content: items }), { status: 200 })
);
const stream = createNotificationStream();
await stream.fetchNotifications();
expect(stream.notifications).toHaveLength(1);
expect(stream.notifications[0].id).toBe('n1');
});
it('markRead marks notification as read and decrements unreadCount', async () => {
mockFetch
.mockResolvedValueOnce(new Response(JSON.stringify({ count: 2 }), { status: 200 }))
.mockResolvedValueOnce(new Response(null, { status: 200 }));
const stream = createNotificationStream();
await stream.fetchUnreadCount();
const notification = makeNotification({ read: false });
await stream.markRead(notification);
expect(notification.read).toBe(true);
expect(stream.unreadCount).toBe(1);
});
it('markAllRead calls the API and resets unreadCount', async () => {
mockFetch.mockResolvedValueOnce(new Response(null, { status: 200 }));
const stream = createNotificationStream();
await stream.markAllRead();
expect(mockFetch).toHaveBeenCalledWith('/api/notifications/read-all', { method: 'POST' });
expect(stream.unreadCount).toBe(0);
});
it('destroy closes the EventSource', async () => {
mockFetch.mockResolvedValue(new Response(JSON.stringify({ count: 0 }), { status: 200 }));
const stream = createNotificationStream();
stream.init();
expect(lastEventSource).not.toBeNull();
stream.destroy();
expect(lastEventSource!.close).toHaveBeenCalled();
});
it('SSE notification event prepends notification and increments unreadCount', async () => {
mockFetch.mockResolvedValue(new Response(JSON.stringify({ count: 0 }), { status: 200 }));
const stream = createNotificationStream();
stream.init();
const notification = makeNotification({ id: 'sse-1', read: false });
lastEventSource!.simulate('notification', JSON.stringify(notification));
expect(stream.notifications).toHaveLength(1);
expect(stream.notifications[0].id).toBe('sse-1');
expect(stream.unreadCount).toBe(1);
});
it('SSE notification event with read:true does not increment unreadCount', async () => {
mockFetch.mockResolvedValue(new Response(JSON.stringify({ count: 0 }), { status: 200 }));
const stream = createNotificationStream();
stream.init();
const notification = makeNotification({ id: 'sse-2', read: true });
lastEventSource!.simulate('notification', JSON.stringify(notification));
expect(stream.notifications).toHaveLength(1);
expect(stream.unreadCount).toBe(0);
});
});

View File

@@ -0,0 +1,69 @@
import { describe, it, expect } from 'vitest';
import { createPdfRenderer } from '../usePdfRenderer.svelte';
// Note: init() and loadDocument() require pdfjsLib (browser module).
// These tests cover pure state logic only — bounds clamping and zoom limits.
describe('createPdfRenderer', () => {
it('starts at page 1 with scale 1.5 and no error', () => {
const r = createPdfRenderer();
expect(r.currentPage).toBe(1);
expect(r.scale).toBe(1.5);
expect(r.totalPages).toBe(0);
expect(r.loading).toBe(false);
expect(r.error).toBeNull();
expect(r.isLoaded).toBe(false);
expect(r.pdfjsReady).toBe(false);
});
it('prevPage does not go below page 1', () => {
const r = createPdfRenderer();
r.prevPage();
expect(r.currentPage).toBe(1);
});
it('nextPage does not exceed totalPages', () => {
const r = createPdfRenderer();
// totalPages = 0, so 1 < 0 is false → stays at 1
r.nextPage();
expect(r.currentPage).toBe(1);
});
it('goToPage does not navigate when n > totalPages', () => {
const r = createPdfRenderer();
r.goToPage(5);
expect(r.currentPage).toBe(1);
});
it('goToPage does not navigate when n < 1', () => {
const r = createPdfRenderer();
r.goToPage(0);
expect(r.currentPage).toBe(1);
});
it('zoomIn increases scale by 0.25', () => {
const r = createPdfRenderer();
r.zoomIn();
expect(r.scale).toBeCloseTo(1.75);
});
it('zoomOut decreases scale by 0.25', () => {
const r = createPdfRenderer();
r.zoomOut();
expect(r.scale).toBeCloseTo(1.25);
});
it('zoomOut does not go below 0.5', () => {
const r = createPdfRenderer();
for (let i = 0; i < 20; i++) r.zoomOut();
expect(r.scale).toBeCloseTo(0.5);
});
it('loadDocument is a no-op when pdfjsLib not initialized', async () => {
const r = createPdfRenderer();
await r.loadDocument('/some/path');
// No-op because pdfjsLib is null (init not called)
expect(r.error).toBeNull();
expect(r.loading).toBe(false);
});
});

View File

@@ -0,0 +1,95 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Capture the beforeNavigate callback so tests can simulate navigation events
let registeredBeforeNavigate:
| ((nav: { cancel: () => void; to: { url: { href: string } } | null }) => void)
| null = null;
const mockGoto = vi.fn();
vi.mock('$app/navigation', () => ({
beforeNavigate: vi.fn((fn: typeof registeredBeforeNavigate) => {
registeredBeforeNavigate = fn;
}),
goto: mockGoto
}));
const { createUnsavedWarning } = await import('../useUnsavedWarning.svelte');
function simulateNavigate(href: string | null = '/somewhere') {
const cancel = vi.fn();
registeredBeforeNavigate?.({
cancel,
to: href ? { url: { href } } : null
});
return cancel;
}
beforeEach(() => {
registeredBeforeNavigate = null;
mockGoto.mockClear();
});
describe('createUnsavedWarning', () => {
it('isDirty starts false', () => {
const w = createUnsavedWarning();
expect(w.isDirty).toBe(false);
});
it('markDirty sets isDirty to true', () => {
const w = createUnsavedWarning();
w.markDirty();
expect(w.isDirty).toBe(true);
});
it('markDirty hides any existing warning banner', () => {
const w = createUnsavedWarning();
// Simulate a navigation event that showed the banner
w.markDirty();
simulateNavigate();
expect(w.showUnsavedWarning).toBe(true);
// Typing again should hide the banner (form input re-triggers markDirty)
w.markDirty();
expect(w.showUnsavedWarning).toBe(false);
});
it('beforeNavigate cancels and shows banner when dirty', () => {
const w = createUnsavedWarning();
w.markDirty();
const cancel = simulateNavigate('/admin/users');
expect(cancel).toHaveBeenCalled();
expect(w.showUnsavedWarning).toBe(true);
});
it('beforeNavigate stores the target URL', () => {
const w = createUnsavedWarning();
w.markDirty();
simulateNavigate('/admin/users');
expect(w.discardTarget).toBe('/admin/users');
});
it('beforeNavigate does not cancel when not dirty', () => {
createUnsavedWarning();
const cancel = simulateNavigate('/admin/users');
expect(cancel).not.toHaveBeenCalled();
});
it('discard resets state and navigates to target', () => {
const w = createUnsavedWarning();
w.markDirty();
simulateNavigate('/admin/tags');
w.discard();
expect(w.isDirty).toBe(false);
expect(w.showUnsavedWarning).toBe(false);
expect(mockGoto).toHaveBeenCalledWith('/admin/tags');
});
it('clearOnSuccess resets isDirty and warning', () => {
const w = createUnsavedWarning();
w.markDirty();
simulateNavigate('/somewhere');
w.clearOnSuccess();
expect(w.isDirty).toBe(false);
expect(w.showUnsavedWarning).toBe(false);
});
});

View File

@@ -0,0 +1,127 @@
import { SvelteMap } from 'svelte/reactivity';
export type SaveState = 'idle' | 'saving' | 'saved' | 'fading' | 'error';
type Options = {
saveFn: (blockId: string, text: string) => Promise<void>;
documentId: string;
};
export function createBlockAutoSave({ saveFn, documentId }: Options) {
const saveStates = new SvelteMap<string, SaveState>();
const debounceTimers = new SvelteMap<string, ReturnType<typeof setTimeout>>();
const pendingTexts = new SvelteMap<string, string>();
const fadeTimers: ReturnType<typeof setTimeout>[] = [];
function getSaveState(blockId: string): SaveState {
return saveStates.get(blockId) ?? 'idle';
}
function setSaveState(blockId: string, state: SaveState) {
saveStates.set(blockId, state);
}
async function executeSave(blockId: string): Promise<void> {
const text = pendingTexts.get(blockId);
if (text === undefined) return;
pendingTexts.delete(blockId);
setSaveState(blockId, 'saving');
try {
await saveFn(blockId, text);
setSaveState(blockId, 'saved');
scheduleSavedFade(blockId);
} catch {
setSaveState(blockId, 'error');
}
}
function scheduleSavedFade(blockId: string): void {
const t1 = setTimeout(() => {
if (getSaveState(blockId) === 'saved') {
setSaveState(blockId, 'fading');
const t2 = setTimeout(() => {
if (getSaveState(blockId) === 'fading') {
setSaveState(blockId, 'idle');
}
}, 300);
fadeTimers.push(t2);
}
}, 2000);
fadeTimers.push(t1);
}
function scheduleDebounce(blockId: string): void {
clearDebounce(blockId);
const timer = setTimeout(() => {
debounceTimers.delete(blockId);
executeSave(blockId);
}, 1500);
debounceTimers.set(blockId, timer);
}
function clearDebounce(blockId: string): void {
const existing = debounceTimers.get(blockId);
if (existing !== undefined) {
clearTimeout(existing);
debounceTimers.delete(blockId);
}
}
function handleTextChange(blockId: string, text: string): void {
pendingTexts.set(blockId, text);
scheduleDebounce(blockId);
}
function handleBlur(): void {
for (const [blockId] of [...debounceTimers]) {
clearDebounce(blockId);
executeSave(blockId);
}
}
async function handleRetry(blockId: string, currentText: string): Promise<void> {
const pending = pendingTexts.get(blockId);
const text = pending ?? currentText;
pendingTexts.set(blockId, text);
await executeSave(blockId);
}
function clearBlock(blockId: string): void {
clearDebounce(blockId);
pendingTexts.delete(blockId);
saveStates.delete(blockId);
}
function flushViaBeacon(): void {
for (const [blockId, text] of pendingTexts) {
clearDebounce(blockId);
const url = `/api/documents/${documentId}/transcription-blocks/${blockId}`;
const body = JSON.stringify({ text });
navigator.sendBeacon(url, new Blob([body], { type: 'application/json' }));
pendingTexts.delete(blockId);
}
}
function destroy(): void {
for (const timer of debounceTimers.values()) {
clearTimeout(timer);
}
debounceTimers.clear();
for (const timer of fadeTimers) {
clearTimeout(timer);
}
fadeTimers.length = 0;
}
return {
getSaveState,
handleTextChange,
handleBlur,
handleRetry,
clearBlock,
flushViaBeacon,
destroy
};
}

View File

@@ -0,0 +1,88 @@
import type { TranscriptionBlockData } from '$lib/types';
type Options = {
getSortedBlocks: () => TranscriptionBlockData[];
onReorder: (blockIds: string[]) => void;
};
export function createBlockDragDrop({ getSortedBlocks, onReorder }: Options) {
let draggedBlockId = $state<string | null>(null);
let dropTargetIdx = $state<number | null>(null);
let dragOffsetY = $state(0);
// Internal mutable refs — not reactive
let dragStartY = 0;
let capturedEl: HTMLElement | null = null;
let listEl: HTMLElement | null = null;
function setListElement(el: HTMLElement | null): void {
listEl = el;
}
function handleGripDown(e: PointerEvent, blockId: string): void {
if (!(e.target as HTMLElement).closest('[data-drag-handle]')) return;
e.preventDefault();
draggedBlockId = blockId;
dragStartY = e.clientY;
dragOffsetY = 0;
capturedEl = (e.target as HTMLElement).closest('[data-block-wrapper]') as HTMLElement;
capturedEl?.setPointerCapture(e.pointerId);
}
function handlePointerMove(e: PointerEvent): void {
if (!draggedBlockId || !listEl) return;
dragOffsetY = e.clientY - dragStartY;
const sortedBlocks = getSortedBlocks();
const wrappers = Array.from(listEl.querySelectorAll('[data-block-wrapper]'));
const dragIdx = sortedBlocks.findIndex((b) => b.id === draggedBlockId);
let target: number | null = null;
for (let i = 0; i < wrappers.length; i++) {
const rect = wrappers[i].getBoundingClientRect();
if (e.clientY < rect.top + rect.height / 2) {
target = i;
break;
}
}
if (target === null) target = wrappers.length;
if (target === dragIdx || target === dragIdx + 1) target = null;
dropTargetIdx = target;
}
function handlePointerUp(): void {
if (!draggedBlockId) return;
if (dropTargetIdx !== null) {
const sorted = [...getSortedBlocks()];
const fromIdx = sorted.findIndex((b) => b.id === draggedBlockId);
if (fromIdx >= 0) {
const [moved] = sorted.splice(fromIdx, 1);
const insertAt = dropTargetIdx > fromIdx ? dropTargetIdx - 1 : dropTargetIdx;
sorted.splice(insertAt, 0, moved);
onReorder(sorted.map((b) => b.id));
}
}
draggedBlockId = null;
dropTargetIdx = null;
dragOffsetY = 0;
capturedEl = null;
}
return {
get draggedBlockId() {
return draggedBlockId;
},
get dropTargetIdx() {
return dropTargetIdx;
},
get dragOffsetY() {
return dragOffsetY;
},
setListElement,
handleGripDown,
handlePointerMove,
handlePointerUp
};
}

View File

@@ -0,0 +1,41 @@
export function createFileLoader() {
let fileUrl = $state('');
let isLoading = $state(false);
let fileError = $state('');
async function loadFile(url: string): Promise<void> {
isLoading = true;
fileError = '';
if (fileUrl) URL.revokeObjectURL(fileUrl);
fileUrl = '';
try {
const response = await fetch(url);
if (!response.ok) throw new Error('Failed to load file');
const blob = await response.blob();
fileUrl = URL.createObjectURL(blob);
} catch {
fileError = 'Vorschau konnte nicht geladen werden.';
} finally {
isLoading = false;
}
}
function destroy(): void {
if (fileUrl) URL.revokeObjectURL(fileUrl);
}
return {
get fileUrl() {
return fileUrl;
},
get isLoading() {
return isLoading;
},
get fileError() {
return fileError;
},
loadFile,
destroy
};
}

View File

@@ -0,0 +1,95 @@
import { type NotificationItem, parseNotificationEvent } from '$lib/utils/notifications';
export type { NotificationItem };
export function createNotificationStream() {
let notifications = $state<NotificationItem[]>([]);
let unreadCount = $state(0);
let eventSource: EventSource | null = null;
async function fetchNotifications(): Promise<void> {
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 fetchUnreadCount(): Promise<void> {
try {
const res = await fetch('/api/notifications/unread-count');
if (res.ok) {
const data = await res.json();
unreadCount = data.count;
}
} catch (e) {
console.error('Failed to fetch unread count', e);
}
}
async function markRead(notification: NotificationItem): Promise<void> {
if (!notification.read) {
try {
await fetch(`/api/notifications/${notification.id}/read`, { method: 'PATCH' });
notification.read = true;
unreadCount = Math.max(0, unreadCount - 1);
} catch (e) {
console.error('Failed to mark notification as read', e);
}
}
}
async function markAllRead(): Promise<void> {
try {
await fetch('/api/notifications/read-all', { method: 'POST' });
for (const n of notifications) {
n.read = true;
}
unreadCount = 0;
} catch (e) {
console.error('Failed to mark all notifications as read', e);
}
}
function init(): void {
fetchUnreadCount();
eventSource = new EventSource('/api/notifications/stream');
eventSource.addEventListener('notification', (e) => {
const notification = parseNotificationEvent(e.data);
if (!notification) return;
notifications = [notification, ...notifications];
if (!notification.read) unreadCount += 1;
});
eventSource.onopen = () => {
fetchUnreadCount();
};
eventSource.onerror = () => {
// Close on error to avoid repeated reconnect noise
eventSource?.close();
};
}
function destroy(): void {
eventSource?.close();
eventSource = null;
}
return {
get notifications() {
return notifications;
},
get unreadCount() {
return unreadCount;
},
fetchNotifications,
fetchUnreadCount,
markRead,
markAllRead,
init,
destroy
};
}

View File

@@ -0,0 +1,203 @@
import type { PDFDocumentProxy, RenderTask } from 'pdfjs-dist';
export function createPdfRenderer() {
// Reactive state — exposed via getters
let currentPage = $state(1);
let totalPages = $state(0);
let scale = $state(1.5);
let loading = $state(false);
let error = $state<string | null>(null);
let pdfjsReady = $state(false);
// Internal mutable refs — NOT $state to avoid reactive loops
let pdfDoc: PDFDocumentProxy | null = null;
let canvasEl: HTMLCanvasElement | null = null;
let textLayerEl: HTMLDivElement | null = null;
let renderTask: RenderTask | null = null;
let textLayerInstance: { cancel: () => void } | null = null;
let pdfjsLib: typeof import('pdfjs-dist') | null = null;
async function init(): Promise<void> {
const [lib, { default: workerUrl }] = await Promise.all([
import('pdfjs-dist'),
import('pdfjs-dist/build/pdf.worker.min.mjs?url')
]);
lib.GlobalWorkerOptions.workerSrc = workerUrl;
pdfjsLib = lib;
pdfjsReady = true;
}
function setElements(canvas: HTMLCanvasElement, textLayer: HTMLDivElement): void {
canvasEl = canvas;
textLayerEl = textLayer;
}
async function loadDocument(src: string): Promise<void> {
if (!pdfjsLib) return;
loading = true;
error = null;
pdfDoc = null;
currentPage = 1;
totalPages = 0;
try {
const loadingTask = pdfjsLib.getDocument(src);
const doc = await loadingTask.promise;
pdfDoc = doc;
totalPages = doc.numPages;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load PDF';
} finally {
loading = false;
}
}
async function renderCurrentPage(): Promise<void> {
if (!pdfjsLib || !canvasEl || !textLayerEl || !pdfDoc) return;
if (renderTask) {
renderTask.cancel();
renderTask = null;
}
if (textLayerInstance) {
textLayerInstance.cancel();
textLayerInstance = null;
}
let page;
try {
page = await pdfDoc.getPage(currentPage);
} catch {
return;
}
const dpr = window.devicePixelRatio || 1;
const viewport = page.getViewport({ scale: scale * dpr });
const canvas = canvasEl;
const ctx = canvas.getContext('2d');
if (!ctx) return;
canvas.width = viewport.width;
canvas.height = viewport.height;
canvas.style.width = `${viewport.width / dpr}px`;
canvas.style.height = `${viewport.height / dpr}px`;
const task = page.render({ canvas, canvasContext: ctx, viewport });
renderTask = task;
try {
await task.promise;
} catch (e: unknown) {
if (
typeof e === 'object' &&
e !== null &&
'name' in e &&
(e as { name: string }).name === 'RenderingCancelledException'
)
return;
return;
}
renderTask = null;
const textDiv = textLayerEl;
if (!textDiv) return;
textDiv.innerHTML = '';
textDiv.style.width = `${viewport.width / dpr}px`;
textDiv.style.height = `${viewport.height / dpr}px`;
const tl = new pdfjsLib.TextLayer({
textContentSource: page.streamTextContent(),
container: textDiv,
viewport
});
textLayerInstance = tl;
try {
await tl.render();
} catch {
// cancelled
}
}
async function prerender(): Promise<void> {
if (!pdfDoc) return;
const neighbors = [currentPage - 1, currentPage + 1].filter(
(n) => n >= 1 && n <= (pdfDoc?.numPages ?? 0)
);
for (const n of neighbors) {
try {
await pdfDoc.getPage(n);
} catch {
// ignore
}
}
}
function prevPage(): void {
if (currentPage > 1) currentPage -= 1;
}
function nextPage(): void {
if (currentPage < totalPages) currentPage += 1;
}
function goToPage(n: number): void {
if (n >= 1 && n <= totalPages) currentPage = n;
}
function zoomIn(): void {
scale += 0.25;
}
function zoomOut(): void {
if (scale > 0.5) scale -= 0.25;
}
function destroy(): void {
if (renderTask) {
renderTask.cancel();
renderTask = null;
}
if (textLayerInstance) {
textLayerInstance.cancel();
textLayerInstance = null;
}
pdfDoc?.destroy();
pdfDoc = null;
}
return {
get currentPage() {
return currentPage;
},
get totalPages() {
return totalPages;
},
get scale() {
return scale;
},
get loading() {
return loading;
},
get error() {
return error;
},
get isLoaded() {
return pdfDoc !== null;
},
get pdfjsReady() {
return pdfjsReady;
},
setElements,
init,
loadDocument,
renderCurrentPage,
prerender,
prevPage,
nextPage,
goToPage,
zoomIn,
zoomOut,
destroy
};
}

View File

@@ -0,0 +1,46 @@
import { beforeNavigate, goto } from '$app/navigation';
export function createUnsavedWarning() {
let isDirty = $state(false);
let showUnsavedWarning = $state(false);
let discardTarget: string | null = $state(null);
beforeNavigate(({ cancel, to }) => {
if (isDirty) {
cancel();
showUnsavedWarning = true;
discardTarget = to?.url.href ?? null;
}
});
function markDirty() {
isDirty = true;
showUnsavedWarning = false;
}
function discard() {
isDirty = false;
showUnsavedWarning = false;
if (discardTarget) goto(discardTarget);
}
function clearOnSuccess() {
isDirty = false;
showUnsavedWarning = false;
}
return {
get isDirty() {
return isDirty;
},
get showUnsavedWarning() {
return showUnsavedWarning;
},
get discardTarget() {
return discardTarget;
},
markDirty,
discard,
clearOnSuccess
};
}

View File

@@ -14,6 +14,16 @@ export type CommentReply = {
mentionDTOs?: MentionDTO[]; mentionDTOs?: MentionDTO[];
}; };
export type FlatMessage = {
id: string;
authorId: string | null;
authorName: string;
content: string;
createdAt: string;
updatedAt: string;
mentionDTOs?: MentionDTO[];
};
export type Comment = { export type Comment = {
id: string; id: string;
authorId: string | null; authorId: string | null;

View File

@@ -0,0 +1,40 @@
import { describe, it, expect } from 'vitest';
import { extractQuote } from './comment';
describe('extractQuote', () => {
it('returns null quote and full body for plain text', () => {
const result = extractQuote('Hello world');
expect(result.quote).toBeNull();
expect(result.body).toBe('Hello world');
});
it('extracts quote and body with double newline separator', () => {
const result = extractQuote('> "Some quoted text"\n\nReply body');
expect(result.quote).toBe('Some quoted text');
expect(result.body).toBe('Reply body');
});
it('extracts quote and body with single newline separator', () => {
const result = extractQuote('> "Quote"\nBody');
expect(result.quote).toBe('Quote');
expect(result.body).toBe('Body');
});
it('returns null quote when format does not match', () => {
const result = extractQuote('> Not a quote format');
expect(result.quote).toBeNull();
expect(result.body).toBe('> Not a quote format');
});
it('handles empty string', () => {
const result = extractQuote('');
expect(result.quote).toBeNull();
expect(result.body).toBe('');
});
it('does not match when quotes are missing', () => {
const result = extractQuote('> just a blockquote\n\nbody');
expect(result.quote).toBeNull();
expect(result.body).toBe('> just a blockquote\n\nbody');
});
});

View File

@@ -0,0 +1,5 @@
export function extractQuote(content: string): { quote: string | null; body: string } {
const match = content.match(/^>\s*"(.+?)"\s*\n\n?([\s\S]*)$/);
if (match) return { quote: match[1], body: match[2] };
return { quote: null, body: content };
}

View File

@@ -1,5 +1,25 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { formatGermanDateInput, isoToGerman, germanToIso } from './date'; import { formatDate, formatGermanDateInput, isoToGerman, germanToIso } from './date';
// ─── formatDate ──────────────────────────────────────────────────────────────
describe('formatDate', () => {
it('defaults to long format when no format arg is passed', () => {
expect(formatDate('1943-12-24')).toBe('24. Dezember 1943');
});
it('formats long date with German month name', () => {
expect(formatDate('1943-12-24', 'long')).toBe('24. Dezember 1943');
});
it('formats short date as dd.mm.yyyy', () => {
expect(formatDate('1943-12-24', 'short')).toBe('24.12.1943');
});
it('does not shift Dec 31 to Jan 1 (T12:00:00 UTC guard)', () => {
expect(formatDate('1943-12-31', 'short')).toBe('31.12.1943');
});
});
// ─── isoToGerman ───────────────────────────────────────────────────────────── // ─── isoToGerman ─────────────────────────────────────────────────────────────

View File

@@ -1,13 +1,22 @@
/** /**
* Format an ISO date string (YYYY-MM-DD) for display. * Format an ISO date string (YYYY-MM-DD) for display.
* Uses T12:00:00 to avoid UTC timezone off-by-one when converting to local time. * Uses T12:00:00 to avoid UTC timezone off-by-one when converting to local time.
* Defaults to 'long' (e.g. "24. Dezember 1943"); pass 'short' for DD.MM.YYYY.
*/ */
export function formatDate(isoDate: string): string { export function formatDate(isoDate: string, format: 'short' | 'long' = 'long'): string {
const date = new Date(isoDate + 'T12:00:00');
if (format === 'short') {
return new Intl.DateTimeFormat('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
}).format(date);
}
return new Intl.DateTimeFormat('de-DE', { return new Intl.DateTimeFormat('de-DE', {
day: 'numeric', day: 'numeric',
month: 'long', month: 'long',
year: 'numeric' year: 'numeric'
}).format(new Date(isoDate + 'T12:00:00')); }).format(date);
} }
/** /**

View File

@@ -1,56 +1,5 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { relativeTime, parseNotificationEvent } from '$lib/utils/notifications'; import { parseNotificationEvent } from '$lib/utils/notifications';
const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' });
function msAgo(ms: number, now: Date): string {
return new Date(now.getTime() - ms).toISOString();
}
describe('relativeTime', () => {
const now = new Date('2024-06-15T12:00:00.000Z');
it('should use minute bucket for timestamps under 60 seconds ago', () => {
const ts = msAgo(30_000, now);
expect(relativeTime(ts, now)).toBe(rtf.format(0, 'minute'));
});
it('should use minute bucket for exactly 59 minutes ago', () => {
const ts = msAgo(59 * 60_000, now);
expect(relativeTime(ts, now)).toBe(rtf.format(-59, 'minute'));
});
it('should use minute bucket for exactly 1 minute ago', () => {
const ts = msAgo(60_000, now);
expect(relativeTime(ts, now)).toBe(rtf.format(-1, 'minute'));
});
it('should use hour bucket for exactly 1 hour ago', () => {
const ts = msAgo(60 * 60_000, now);
expect(relativeTime(ts, now)).toBe(rtf.format(-1, 'hour'));
});
it('should use hour bucket for 23 hours ago', () => {
const ts = msAgo(23 * 60 * 60_000, now);
expect(relativeTime(ts, now)).toBe(rtf.format(-23, 'hour'));
});
it('should use day bucket for exactly 24 hours ago', () => {
const ts = msAgo(24 * 60 * 60_000, now);
expect(relativeTime(ts, now)).toBe(rtf.format(-1, 'day'));
});
it('should use day bucket for 6 days ago', () => {
const ts = msAgo(6 * 24 * 60 * 60_000, now);
expect(relativeTime(ts, now)).toBe(rtf.format(-6, 'day'));
});
it('should default now to current time when omitted', () => {
// Just verify it returns a non-empty string — exact value depends on runtime clock
const ts = new Date(Date.now() - 5 * 60_000).toISOString();
expect(relativeTime(ts)).toBeTruthy();
});
});
describe('parseNotificationEvent', () => { describe('parseNotificationEvent', () => {
const valid = { const valid = {

View File

@@ -10,18 +10,7 @@ export type NotificationItem = {
documentTitle: string | null; documentTitle: string | null;
}; };
const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }); export { relativeTime } from '$lib/utils/time';
export function relativeTime(isoString: string, now: Date = new Date()): string {
const diffMs = now.getTime() - new Date(isoString).getTime();
const diffMin = Math.floor(diffMs / 60_000);
if (diffMin < 1) return rtf.format(0, 'minute');
if (diffMin < 60) return rtf.format(-diffMin, 'minute');
const diffH = Math.floor(diffMin / 60);
if (diffH < 24) return rtf.format(-diffH, 'hour');
const diffD = Math.floor(diffH / 24);
return rtf.format(-diffD, 'day');
}
export function parseNotificationEvent(raw: string): NotificationItem | null { export function parseNotificationEvent(raw: string): NotificationItem | null {
try { try {

View File

@@ -1,12 +1,37 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { import {
getInitials,
abbreviateName, abbreviateName,
formatXsMeta, formatXsMeta,
personAvatarColor, personAvatarColor,
formatDate,
statusDotClass, statusDotClass,
statusLabel statusLabel
} from './personFormat'; } from './personFormat';
import { formatDate } from './date';
// ─── getInitials ─────────────────────────────────────────────────────────────
describe('getInitials', () => {
it('returns first chars of first and last word uppercased', () => {
expect(getInitials('Marcel Raddatz')).toBe('MR');
});
it('returns single char for a single-word name', () => {
expect(getInitials('Raddatz')).toBe('R');
});
it('returns empty string for an empty name', () => {
expect(getInitials('')).toBe('');
});
it('splits on whitespace only — hyphenated first word counts as one', () => {
expect(getInitials('Anna-Maria Raddatz')).toBe('AR');
});
it('ignores extra whitespace between words', () => {
expect(getInitials(' Karl Raddatz ')).toBe('KR');
});
});
// ─── abbreviateName ────────────────────────────────────────────────────────── // ─── abbreviateName ──────────────────────────────────────────────────────────

View File

@@ -1,4 +1,5 @@
import { formatDocumentStatus } from './documentStatusLabel'; import { formatDocumentStatus } from './documentStatusLabel';
import { formatDate } from './date';
type Person = { firstName?: string | null; lastName: string; displayName: string }; type Person = { firstName?: string | null; lastName: string; displayName: string };
type DocumentStatus = 'PLACEHOLDER' | 'UPLOADED' | 'TRANSCRIBED' | 'REVIEWED' | 'ARCHIVED'; type DocumentStatus = 'PLACEHOLDER' | 'UPLOADED' | 'TRANSCRIBED' | 'REVIEWED' | 'ARCHIVED';
@@ -18,9 +19,11 @@ function djb2(str: string): number {
return Math.abs(hash); return Math.abs(hash);
} }
export function getInitials(person: Person): string { export function getInitials(name: string): string {
if (person.firstName) return `${person.firstName[0]}${person.lastName[0]}`.toUpperCase(); const words = name.trim().split(/\s+/).filter(Boolean);
return person.lastName.substring(0, 2).toUpperCase(); if (words.length === 0) return '';
if (words.length === 1) return words[0].charAt(0).toUpperCase();
return (words[0].charAt(0) + words[words.length - 1].charAt(0)).toUpperCase();
} }
export function abbreviateName(person: Person): string { export function abbreviateName(person: Person): string {
@@ -73,22 +76,6 @@ export function personAvatarColor(personId: string): string {
return AVATAR_PALETTE[djb2(personId) % AVATAR_PALETTE.length]; return AVATAR_PALETTE[djb2(personId) % AVATAR_PALETTE.length];
} }
export function formatDate(isoDate: string, format: 'short' | 'long'): string {
const date = new Date(isoDate + 'T12:00:00');
if (format === 'short') {
return new Intl.DateTimeFormat('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
}).format(date);
}
return new Intl.DateTimeFormat('de-DE', {
day: 'numeric',
month: 'long',
year: 'numeric'
}).format(date);
}
export function statusDotClass(status: DocumentStatus): string { export function statusDotClass(status: DocumentStatus): string {
switch (status) { switch (status) {
case 'PLACEHOLDER': case 'PLACEHOLDER':

View File

@@ -0,0 +1,52 @@
import { describe, it, expect } from 'vitest';
import { m } from '$lib/paraglide/messages.js';
const { relativeTime } = await import('./time');
function msAgo(ms: number, now: Date): string {
return new Date(now.getTime() - ms).toISOString();
}
describe('relativeTime', () => {
const now = new Date('2024-06-15T12:00:00.000Z');
it('returns "just now" for timestamps under 60 seconds ago', () => {
const ts = msAgo(30_000, now);
expect(relativeTime(ts, now)).toBe(m.comment_time_just_now());
});
it('returns 1-minute label for exactly 1 minute ago', () => {
const ts = msAgo(60_000, now);
expect(relativeTime(ts, now)).toBe(m.comment_time_minutes({ count: 1 }));
});
it('returns 59-minute label for exactly 59 minutes ago', () => {
const ts = msAgo(59 * 60_000, now);
expect(relativeTime(ts, now)).toBe(m.comment_time_minutes({ count: 59 }));
});
it('returns 1-hour label for exactly 1 hour ago', () => {
const ts = msAgo(60 * 60_000, now);
expect(relativeTime(ts, now)).toBe(m.comment_time_hours({ count: 1 }));
});
it('returns 23-hour label for 23 hours ago', () => {
const ts = msAgo(23 * 60 * 60_000, now);
expect(relativeTime(ts, now)).toBe(m.comment_time_hours({ count: 23 }));
});
it('returns 1-day label for exactly 24 hours ago', () => {
const ts = msAgo(24 * 60 * 60_000, now);
expect(relativeTime(ts, now)).toBe(m.comment_time_days({ count: 1 }));
});
it('returns 6-day label for 6 days ago', () => {
const ts = msAgo(6 * 24 * 60 * 60_000, now);
expect(relativeTime(ts, now)).toBe(m.comment_time_days({ count: 6 }));
});
it('defaults now to current time when omitted', () => {
const ts = new Date(Date.now() - 5 * 60_000).toISOString();
expect(relativeTime(ts)).toBeTruthy();
});
});

View File

@@ -0,0 +1,12 @@
import { m } from '$lib/paraglide/messages.js';
export function relativeTime(isoString: string, now: Date = new Date()): string {
const diff = now.getTime() - new Date(isoString).getTime();
const minutes = Math.floor(diff / 60_000);
if (minutes < 1) return m.comment_time_just_now();
if (minutes < 60) return m.comment_time_minutes({ count: minutes });
const hours = Math.floor(minutes / 60);
if (hours < 24) return m.comment_time_hours({ count: hours });
const days = Math.floor(hours / 24);
return m.comment_time_days({ count: days });
}

View File

@@ -3,6 +3,7 @@ import { tick } from 'svelte';
import { fly } from 'svelte/transition'; import { fly } from 'svelte/transition';
import { page } from '$app/state'; import { page } from '$app/state';
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import EntityNavSection from './EntityNavSection.svelte';
let { let {
userCount, userCount,
@@ -51,6 +52,76 @@ function handleKeydown(event: KeyboardEvent) {
} }
</script> </script>
{#snippet usersIcon()}
<svg
class="h-5 w-5 flex-shrink-0 {isActive('users') ? 'text-brand-mint' : 'text-white/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 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"
/>
</svg>
{/snippet}
{#snippet groupsIcon()}
<svg
class="h-5 w-5 flex-shrink-0 {isActive('groups') ? 'text-brand-mint' : 'text-white/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="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z"
/>
</svg>
{/snippet}
{#snippet tagsIcon()}
<svg
class="h-5 w-5 flex-shrink-0 {isActive('tags') ? 'text-brand-mint' : 'text-white/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="M9.568 3H5.25A2.25 2.25 0 003 5.25v4.318c0 .597.237 1.17.659 1.591l9.581 9.581c.699.699 1.78.872 2.607.33a18.095 18.095 0 005.223-5.223c.542-.827.369-1.908-.33-2.607L11.16 3.66A2.25 2.25 0 009.568 3z"
/>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 6h.008v.008H6V6z" />
</svg>
{/snippet}
{#snippet systemIcon()}
<svg
class="h-5 w-5 flex-shrink-0 {isActive('system') ? 'text-brand-mint' : 'text-white/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="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z"
/>
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
{/snippet}
<svelte:document onkeydown={handleKeydown} /> <svelte:document onkeydown={handleKeydown} />
<!-- <!--
@@ -69,271 +140,53 @@ function handleKeydown(event: KeyboardEvent) {
</div> </div>
{#if canManageUsers} {#if canManageUsers}
<!-- Tablet trigger button (md only, hidden at lg) --> <EntityNavSection
<button variant="sidebar"
data-flyout-trigger
type="button"
aria-label={m.admin_tab_users()}
title={m.admin_tab_users()}
onclick={openFlyout}
class="flex min-h-[44px] w-full flex-col items-center justify-center gap-0.5 border-l-[3px] py-3 transition-colors lg:hidden
{isActive('users')
? 'border-brand-mint bg-white/10'
: 'border-transparent hover:bg-white/5'}"
>
<svg
class="h-5 w-5 flex-shrink-0 {isActive('users') ? 'text-brand-mint' : 'text-white/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 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"
/>
</svg>
<span class="text-[9px] font-bold {isActive('users') ? 'text-white/80' : 'text-white/35'}">
{userCount}
</span>
</button>
<!-- Desktop link (lg+) -->
<a
href="/admin/users" href="/admin/users"
class="hidden flex-col items-start justify-center gap-0.5 border-l-[3px] px-3.5 py-2.5 transition-colors lg:flex label={m.admin_tab_users()}
{isActive('users') isActive={isActive('users')}
? 'border-brand-mint bg-white/10' count={userCount}
: 'border-transparent hover:bg-white/5'}" onTabletTrigger={openFlyout}
aria-current={isActive('users') ? 'page' : undefined} icon={usersIcon}
title={m.admin_tab_users()}
>
<svg
class="h-5 w-5 flex-shrink-0 {isActive('users') ? 'text-brand-mint' : 'text-white/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 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"
/> />
</svg>
<span class="text-[13px] font-black {isActive('users') ? 'text-white/65' : 'text-white/50'}">
{userCount}
</span>
<span
class="text-[9px] font-extrabold tracking-[0.5px] uppercase
{isActive('users') ? 'text-white' : 'text-white/55'}"
>
{m.admin_tab_users()}
</span>
</a>
{/if} {/if}
{#if canManagePermissions} {#if canManagePermissions}
<!-- Tablet trigger button (md only, hidden at lg) --> <EntityNavSection
<button variant="sidebar"
data-flyout-trigger
type="button"
aria-label={m.admin_tab_groups()}
title={m.admin_tab_groups()}
onclick={openFlyout}
class="flex min-h-[44px] w-full flex-col items-center justify-center gap-0.5 border-l-[3px] py-3 transition-colors lg:hidden
{isActive('groups')
? 'border-brand-mint bg-white/10'
: 'border-transparent hover:bg-white/5'}"
>
<svg
class="h-5 w-5 flex-shrink-0 {isActive('groups') ? 'text-brand-mint' : 'text-white/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="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z"
/>
</svg>
<span class="text-[9px] font-bold {isActive('groups') ? 'text-white/80' : 'text-white/35'}">
{groupCount}
</span>
</button>
<!-- Desktop link (lg+) -->
<a
href="/admin/groups" href="/admin/groups"
class="hidden flex-col items-start justify-center gap-0.5 border-l-[3px] px-3.5 py-2.5 transition-colors lg:flex label={m.admin_tab_groups()}
{isActive('groups') isActive={isActive('groups')}
? 'border-brand-mint bg-white/10' count={groupCount}
: 'border-transparent hover:bg-white/5'}" onTabletTrigger={openFlyout}
aria-current={isActive('groups') ? 'page' : undefined} icon={groupsIcon}
title={m.admin_tab_groups()}
>
<svg
class="h-5 w-5 flex-shrink-0 {isActive('groups') ? 'text-brand-mint' : 'text-white/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="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z"
/> />
</svg>
<span class="text-[13px] font-black {isActive('groups') ? 'text-white/65' : 'text-white/50'}">
{groupCount}
</span>
<span
class="text-[9px] font-extrabold tracking-[0.5px] uppercase
{isActive('groups') ? 'text-white' : 'text-white/55'}"
>
{m.admin_tab_groups()}
</span>
</a>
{/if} {/if}
{#if canManageTags} {#if canManageTags}
<!-- Tablet trigger button (md only, hidden at lg) --> <EntityNavSection
<button variant="sidebar"
data-flyout-trigger
type="button"
aria-label={m.admin_tab_tags()}
title={m.admin_tab_tags()}
onclick={openFlyout}
class="flex min-h-[44px] w-full flex-col items-center justify-center gap-0.5 border-l-[3px] py-3 transition-colors lg:hidden
{isActive('tags')
? 'border-brand-mint bg-white/10'
: 'border-transparent hover:bg-white/5'}"
>
<svg
class="h-5 w-5 flex-shrink-0 {isActive('tags') ? 'text-brand-mint' : 'text-white/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="M9.568 3H5.25A2.25 2.25 0 003 5.25v4.318c0 .597.237 1.17.659 1.591l9.581 9.581c.699.699 1.78.872 2.607.33a18.095 18.095 0 005.223-5.223c.542-.827.369-1.908-.33-2.607L11.16 3.66A2.25 2.25 0 009.568 3z"
/>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 6h.008v.008H6V6z" />
</svg>
<span class="text-[9px] font-bold {isActive('tags') ? 'text-white/80' : 'text-white/35'}">
{tagCount}
</span>
</button>
<!-- Desktop link (lg+) -->
<a
href="/admin/tags" href="/admin/tags"
class="hidden flex-col items-start justify-center gap-0.5 border-l-[3px] px-3.5 py-2.5 transition-colors lg:flex label={m.admin_tab_tags()}
{isActive('tags') isActive={isActive('tags')}
? 'border-brand-mint bg-white/10' count={tagCount}
: 'border-transparent hover:bg-white/5'}" onTabletTrigger={openFlyout}
aria-current={isActive('tags') ? 'page' : undefined} icon={tagsIcon}
title={m.admin_tab_tags()}
>
<svg
class="h-5 w-5 flex-shrink-0 {isActive('tags') ? 'text-brand-mint' : 'text-white/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="M9.568 3H5.25A2.25 2.25 0 003 5.25v4.318c0 .597.237 1.17.659 1.591l9.581 9.581c.699.699 1.78.872 2.607.33a18.095 18.095 0 005.223-5.223c.542-.827.369-1.908-.33-2.607L11.16 3.66A2.25 2.25 0 009.568 3z"
/> />
<path stroke-linecap="round" stroke-linejoin="round" d="M6 6h.008v.008H6V6z" />
</svg>
<span class="text-[13px] font-black {isActive('tags') ? 'text-white/65' : 'text-white/50'}">
{tagCount}
</span>
<span
class="text-[9px] font-extrabold tracking-[0.5px] uppercase
{isActive('tags') ? 'text-white' : 'text-white/55'}"
>
{m.admin_tab_tags()}
</span>
</a>
{/if} {/if}
<div class="flex-1"></div> <div class="flex-1"></div>
{#if canRunMaintenance} {#if canRunMaintenance}
<!-- Tablet trigger button (md only, hidden at lg) --> <EntityNavSection
<button variant="sidebar"
data-flyout-trigger
type="button"
aria-label={m.admin_tab_system()}
title={m.admin_tab_system()}
onclick={openFlyout}
class="flex min-h-[44px] w-full flex-col items-center justify-center gap-0.5 border-t border-l-[3px] border-white/10 py-3 transition-colors lg:hidden
{isActive('system')
? 'border-brand-mint bg-white/10'
: 'border-l-transparent hover:bg-white/5'}"
>
<svg
class="h-5 w-5 flex-shrink-0 {isActive('system') ? 'text-brand-mint' : 'text-white/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="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z"
/>
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</button>
<!-- Desktop link (lg+) -->
<a
href="/admin/system" href="/admin/system"
class="hidden flex-col items-start justify-center gap-0.5 border-t border-l-[3px] border-white/10 px-3.5 py-2.5 transition-colors lg:flex label={m.admin_tab_system()}
{isActive('system') isActive={isActive('system')}
? 'border-brand-mint bg-white/10' topBorder={true}
: 'border-l-transparent hover:bg-white/5'}" onTabletTrigger={openFlyout}
aria-current={isActive('system') ? 'page' : undefined} icon={systemIcon}
title={m.admin_tab_system()}
>
<svg
class="h-5 w-5 flex-shrink-0 {isActive('system') ? 'text-brand-mint' : 'text-white/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="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z"
/> />
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<span
class="text-[9px] font-extrabold tracking-[0.5px] uppercase
{isActive('system') ? 'text-white' : 'text-white/55'}"
>
{m.admin_tab_system()}
</span>
</a>
{/if} {/if}
</nav> </nav>
@@ -360,156 +213,53 @@ function handleKeydown(event: KeyboardEvent) {
</div> </div>
{#if canManageUsers} {#if canManageUsers}
<a <EntityNavSection
variant="flyout"
href="/admin/users" href="/admin/users"
onclick={closeFlyout} label={m.admin_tab_users()}
class="flex flex-col items-start justify-center gap-0.5 border-l-[3px] px-3.5 py-2.5 transition-colors isActive={isActive('users')}
{isActive('users') count={userCount}
? 'border-brand-mint bg-white/10' onFlyoutClick={closeFlyout}
: 'border-transparent hover:bg-white/5'}" icon={usersIcon}
aria-current={isActive('users') ? 'page' : undefined}
>
<svg
class="h-5 w-5 flex-shrink-0 {isActive('users') ? 'text-brand-mint' : 'text-white/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 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"
/> />
</svg>
<span
class="text-[13px] font-black {isActive('users') ? 'text-white/65' : 'text-white/50'}"
>
{userCount}
</span>
<span
class="text-[9px] font-extrabold tracking-[0.5px] uppercase
{isActive('users') ? 'text-white' : 'text-white/55'}"
>
{m.admin_tab_users()}
</span>
</a>
{/if} {/if}
{#if canManagePermissions} {#if canManagePermissions}
<a <EntityNavSection
variant="flyout"
href="/admin/groups" href="/admin/groups"
onclick={closeFlyout} label={m.admin_tab_groups()}
class="flex flex-col items-start justify-center gap-0.5 border-l-[3px] px-3.5 py-2.5 transition-colors isActive={isActive('groups')}
{isActive('groups') count={groupCount}
? 'border-brand-mint bg-white/10' onFlyoutClick={closeFlyout}
: 'border-transparent hover:bg-white/5'}" icon={groupsIcon}
aria-current={isActive('groups') ? 'page' : undefined}
>
<svg
class="h-5 w-5 flex-shrink-0 {isActive('groups') ? 'text-brand-mint' : 'text-white/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="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z"
/> />
</svg>
<span
class="text-[13px] font-black {isActive('groups') ? 'text-white/65' : 'text-white/50'}"
>
{groupCount}
</span>
<span
class="text-[9px] font-extrabold tracking-[0.5px] uppercase
{isActive('groups') ? 'text-white' : 'text-white/55'}"
>
{m.admin_tab_groups()}
</span>
</a>
{/if} {/if}
{#if canManageTags} {#if canManageTags}
<a <EntityNavSection
variant="flyout"
href="/admin/tags" href="/admin/tags"
onclick={closeFlyout} label={m.admin_tab_tags()}
class="flex flex-col items-start justify-center gap-0.5 border-l-[3px] px-3.5 py-2.5 transition-colors isActive={isActive('tags')}
{isActive('tags') count={tagCount}
? 'border-brand-mint bg-white/10' onFlyoutClick={closeFlyout}
: 'border-transparent hover:bg-white/5'}" icon={tagsIcon}
aria-current={isActive('tags') ? 'page' : undefined}
>
<svg
class="h-5 w-5 flex-shrink-0 {isActive('tags') ? 'text-brand-mint' : 'text-white/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="M9.568 3H5.25A2.25 2.25 0 003 5.25v4.318c0 .597.237 1.17.659 1.591l9.581 9.581c.699.699 1.78.872 2.607.33a18.095 18.095 0 005.223-5.223c.542-.827.369-1.908-.33-2.607L11.16 3.66A2.25 2.25 0 009.568 3z"
/> />
<path stroke-linecap="round" stroke-linejoin="round" d="M6 6h.008v.008H6V6z" />
</svg>
<span class="text-[13px] font-black {isActive('tags') ? 'text-white/65' : 'text-white/50'}">
{tagCount}
</span>
<span
class="text-[9px] font-extrabold tracking-[0.5px] uppercase
{isActive('tags') ? 'text-white' : 'text-white/55'}"
>
{m.admin_tab_tags()}
</span>
</a>
{/if} {/if}
<div class="flex-1"></div> <div class="flex-1"></div>
{#if canRunMaintenance} {#if canRunMaintenance}
<a <EntityNavSection
variant="flyout"
href="/admin/system" href="/admin/system"
onclick={closeFlyout} label={m.admin_tab_system()}
class="flex flex-col items-start justify-center gap-0.5 border-t border-l-[3px] border-white/10 px-3.5 py-2.5 transition-colors isActive={isActive('system')}
{isActive('system') topBorder={true}
? 'border-brand-mint bg-white/10' onFlyoutClick={closeFlyout}
: 'border-l-transparent hover:bg-white/5'}" icon={systemIcon}
aria-current={isActive('system') ? 'page' : undefined}
>
<svg
class="h-5 w-5 flex-shrink-0 {isActive('system') ? 'text-brand-mint' : 'text-white/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="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z"
/> />
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
<span
class="text-[9px] font-extrabold tracking-[0.5px] uppercase
{isActive('system') ? 'text-white' : 'text-white/55'}"
>
{m.admin_tab_system()}
</span>
</a>
{/if} {/if}
</div> </div>
{/if} {/if}

View File

@@ -0,0 +1,90 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
href: string;
label: string;
isActive: boolean;
count?: number;
topBorder?: boolean;
icon: Snippet;
variant?: 'sidebar' | 'flyout';
onTabletTrigger?: (event: MouseEvent) => void;
onFlyoutClick?: () => void;
}
let {
href,
label,
isActive,
count,
topBorder = false,
icon,
variant = 'sidebar',
onTabletTrigger,
onFlyoutClick
}: Props = $props();
</script>
{#if variant === 'sidebar'}
<!-- Tablet button (visible at md, hidden at lg) -->
<button
data-flyout-trigger
type="button"
aria-label={label}
title={label}
onclick={onTabletTrigger}
class="flex min-h-[44px] w-full flex-col items-center justify-center gap-0.5 border-l-[3px] py-3 transition-colors lg:hidden
{topBorder ? 'border-t border-white/10' : ''}
{isActive ? 'border-brand-mint bg-white/10' : (topBorder ? 'border-l-transparent hover:bg-white/5' : 'border-transparent hover:bg-white/5')}"
>
{@render icon()}
{#if count !== undefined}
<span class="text-[11px] font-bold {isActive ? 'text-white/80' : 'text-white/35'}"
>{count}</span
>
{/if}
</button>
<!-- Desktop link (hidden at md, visible at lg) -->
<a
href={href}
class="hidden flex-col items-start justify-center gap-0.5 border-l-[3px] px-3.5 py-2.5 transition-colors lg:flex
{topBorder ? 'border-t border-white/10' : ''}
{isActive ? 'border-brand-mint bg-white/10' : (topBorder ? 'border-l-transparent hover:bg-white/5' : 'border-transparent hover:bg-white/5')}"
aria-current={isActive ? 'page' : undefined}
title={label}
>
{@render icon()}
{#if count !== undefined}
<span class="text-[13px] font-black {isActive ? 'text-white/65' : 'text-white/50'}"
>{count}</span
>
{/if}
<span
class="text-[11px] font-extrabold tracking-[0.5px] uppercase {isActive ? 'text-white' : 'text-white/55'}"
>{label}</span
>
</a>
{:else}
<!-- Flyout link -->
<a
href={href}
onclick={onFlyoutClick}
class="flex flex-col items-start justify-center gap-0.5 border-l-[3px] px-3.5 py-2.5 transition-colors
{topBorder ? 'border-t border-white/10' : ''}
{isActive ? 'border-brand-mint bg-white/10' : (topBorder ? 'border-l-transparent hover:bg-white/5' : 'border-transparent hover:bg-white/5')}"
aria-current={isActive ? 'page' : undefined}
>
{@render icon()}
{#if count !== undefined}
<span class="text-[13px] font-black {isActive ? 'text-white/65' : 'text-white/50'}"
>{count}</span
>
{/if}
<span
class="text-[11px] font-extrabold tracking-[0.5px] uppercase {isActive ? 'text-white' : 'text-white/55'}"
>{label}</span
>
</a>
{/if}

View File

@@ -0,0 +1,140 @@
import { afterEach, describe, it, expect } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import { createRawSnippet } from 'svelte';
import EntityNavSection from './EntityNavSection.svelte';
afterEach(cleanup);
const testIcon = createRawSnippet(() => ({
render: () => `<svg aria-label="test-icon" aria-hidden="true"></svg>`,
setup: () => {}
}));
const baseProps = {
href: '/admin/users',
label: 'Benutzer',
icon: testIcon
};
describe('EntityNavSection — sidebar variant (default)', () => {
it('tablet button has border-brand-mint class when isActive=true', async () => {
render(EntityNavSection, { ...baseProps, isActive: true });
const button = document.querySelector('button[data-flyout-trigger]')!;
expect(button.className).toContain('border-brand-mint');
});
it('tablet button has border-transparent class when isActive=false', async () => {
render(EntityNavSection, { ...baseProps, isActive: false });
const button = document.querySelector('button[data-flyout-trigger]')!;
expect(button.className).toContain('border-transparent');
});
it('renders count span when count is provided', async () => {
render(EntityNavSection, { ...baseProps, isActive: false, count: 42 });
// Sidebar renders two elements (tablet button + desktop link), each with a count span
const countSpans = document.querySelectorAll('span');
const countTexts = Array.from(countSpans).filter((s) => s.textContent?.trim() === '42');
expect(countTexts.length).toBeGreaterThanOrEqual(1);
});
it('does not render count span when count is undefined', async () => {
render(EntityNavSection, { ...baseProps, isActive: false });
// No numeric count element — the label text is present but no count span
const spans = document.querySelectorAll('button[data-flyout-trigger] span');
expect(spans.length).toBe(0);
});
it('desktop link has hidden and lg:flex classes', async () => {
render(EntityNavSection, { ...baseProps, isActive: false });
const link = document.querySelector('a[href="/admin/users"]')!;
expect(link.className).toContain('hidden');
expect(link.className).toContain('lg:flex');
});
it('desktop link has aria-current=page when isActive=true', async () => {
render(EntityNavSection, { ...baseProps, isActive: true });
await expect
.element(page.getByRole('link', { name: 'Benutzer' }))
.toHaveAttribute('aria-current', 'page');
});
it('desktop link does not have aria-current when isActive=false', async () => {
render(EntityNavSection, { ...baseProps, isActive: false });
await expect
.element(page.getByRole('link', { name: 'Benutzer' }))
.not.toHaveAttribute('aria-current');
});
it('renders the icon in the tablet button', async () => {
render(EntityNavSection, { ...baseProps, isActive: false });
const button = document.querySelector('button[data-flyout-trigger]')!;
expect(button.querySelector('svg')).not.toBeNull();
});
it('renders count in desktop link when count is provided', async () => {
render(EntityNavSection, { ...baseProps, isActive: false, count: 7 });
const link = document.querySelector('a[href="/admin/users"]')!;
expect(link.textContent).toContain('7');
});
});
describe('EntityNavSection — topBorder prop', () => {
it('tablet button has border-l-transparent (not border-transparent) when topBorder=true and inactive', async () => {
render(EntityNavSection, { ...baseProps, isActive: false, topBorder: true });
const button = document.querySelector('button[data-flyout-trigger]')!;
expect(button.className).toContain('border-l-transparent');
expect(button.className).not.toContain('border-transparent hover:bg-white/5');
});
it('tablet button still has border-brand-mint when topBorder=true and isActive=true', async () => {
render(EntityNavSection, { ...baseProps, isActive: true, topBorder: true });
const button = document.querySelector('button[data-flyout-trigger]')!;
expect(button.className).toContain('border-brand-mint');
});
});
describe('EntityNavSection — flyout variant', () => {
it('renders a single anchor element (no button) in flyout variant', async () => {
render(EntityNavSection, { ...baseProps, isActive: false, variant: 'flyout' });
expect(document.querySelector('button[data-flyout-trigger]')).toBeNull();
expect(document.querySelector('a[href="/admin/users"]')).not.toBeNull();
});
it('flyout link has border-brand-mint class when isActive=true', async () => {
render(EntityNavSection, { ...baseProps, isActive: true, variant: 'flyout' });
const link = document.querySelector('a[href="/admin/users"]')!;
expect(link.className).toContain('border-brand-mint');
});
it('flyout link has border-transparent class when isActive=false', async () => {
render(EntityNavSection, { ...baseProps, isActive: false, variant: 'flyout' });
const link = document.querySelector('a[href="/admin/users"]')!;
expect(link.className).toContain('border-transparent');
});
it('flyout link shows count when count=42', async () => {
render(EntityNavSection, { ...baseProps, isActive: false, variant: 'flyout', count: 42 });
await expect.element(page.getByText('42')).toBeInTheDocument();
});
it('flyout link has aria-current=page when isActive=true', async () => {
render(EntityNavSection, { ...baseProps, isActive: true, variant: 'flyout' });
const link = document.querySelector('a[href="/admin/users"]')!;
expect(link.getAttribute('aria-current')).toBe('page');
});
it('flyout link calls onFlyoutClick when clicked', async () => {
let called = false;
render(EntityNavSection, {
...baseProps,
isActive: false,
variant: 'flyout',
onFlyoutClick: () => {
called = true;
}
});
document.querySelector<HTMLAnchorElement>('a[href="/admin/users"]')!.click();
expect(called).toBe(true);
});
});

View File

@@ -1,16 +1,15 @@
<script lang="ts"> <script lang="ts">
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import { beforeNavigate, goto } from '$app/navigation';
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import { getConfirmService } from '$lib/services/confirm.svelte.js'; import { getConfirmService } from '$lib/services/confirm.svelte.js';
import { createUnsavedWarning } from '$lib/hooks/useUnsavedWarning.svelte';
import UnsavedWarningBanner from '$lib/components/UnsavedWarningBanner.svelte';
let { data, form } = $props(); let { data, form } = $props();
const { confirm } = getConfirmService(); const { confirm } = getConfirmService();
const unsaved = createUnsavedWarning();
let isDirty = $state(false);
let showUnsavedWarning = $state(false);
let discardTarget: string | null = $state(null);
let deleteFormEl = $state<HTMLFormElement | null>(null); let deleteFormEl = $state<HTMLFormElement | null>(null);
async function handleDelete() { async function handleDelete() {
@@ -21,19 +20,8 @@ async function handleDelete() {
if (confirmed) deleteFormEl!.requestSubmit(); if (confirmed) deleteFormEl!.requestSubmit();
} }
beforeNavigate(({ cancel, to }) => {
if (isDirty) {
cancel();
showUnsavedWarning = true;
discardTarget = to?.url.href ?? null;
}
});
$effect(() => { $effect(() => {
if (form?.success) { if (form?.success) unsaved.clearOnSuccess();
isDirty = false;
showUnsavedWarning = false;
}
}); });
const STANDARD_PERMISSIONS = $derived([ const STANDARD_PERMISSIONS = $derived([
@@ -84,23 +72,8 @@ const ADMIN_PERMISSIONS = $derived([
<!-- Scrollable body --> <!-- Scrollable body -->
<div class="flex-1 overflow-y-auto px-5 py-5"> <div class="flex-1 overflow-y-auto px-5 py-5">
{#if showUnsavedWarning} {#if unsaved.showUnsavedWarning}
<div <UnsavedWarningBanner onDiscard={unsaved.discard} />
class="mb-5 flex items-center justify-between rounded border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-300"
>
<span>{m.admin_unsaved_warning()}</span>
<button
type="button"
onclick={() => {
isDirty = false;
showUnsavedWarning = false;
if (discardTarget) goto(discardTarget);
}}
class="ml-4 shrink-0 font-sans text-xs font-bold tracking-widest text-amber-800 uppercase hover:text-amber-900 dark:text-amber-300"
>
{m.person_discard_changes()}
</button>
</div>
{/if} {/if}
{#if form?.success} {#if form?.success}
<div <div
@@ -122,10 +95,7 @@ const ADMIN_PERMISSIONS = $derived([
method="POST" method="POST"
action="?/update" action="?/update"
use:enhance use:enhance
oninput={() => { oninput={unsaved.markDirty}
isDirty = true;
showUnsavedWarning = false;
}}
> >
<!-- Group name card --> <!-- Group name card -->
<div class="mb-5 rounded-sm border border-line bg-surface p-5 shadow-sm"> <div class="mb-5 rounded-sm border border-line bg-surface p-5 shadow-sm">

View File

@@ -1,30 +1,18 @@
<script lang="ts"> <script lang="ts">
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import { beforeNavigate, goto } from '$app/navigation';
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import { createUnsavedWarning } from '$lib/hooks/useUnsavedWarning.svelte';
import UnsavedWarningBanner from '$lib/components/UnsavedWarningBanner.svelte';
let { data, form } = $props(); let { data, form } = $props();
let deleteConfirmName = $state(''); let deleteConfirmName = $state('');
const deleteEnabled = $derived(deleteConfirmName === data.tag.name); const deleteEnabled = $derived(deleteConfirmName === data.tag.name);
let isDirty = $state(false); const unsaved = createUnsavedWarning();
let showUnsavedWarning = $state(false);
let discardTarget: string | null = $state(null);
beforeNavigate(({ cancel, to }) => {
if (isDirty) {
cancel();
showUnsavedWarning = true;
discardTarget = to?.url.href ?? null;
}
});
$effect(() => { $effect(() => {
if (form?.success) { if (form?.success) unsaved.clearOnSuccess();
isDirty = false;
showUnsavedWarning = false;
}
}); });
</script> </script>
@@ -53,23 +41,8 @@ $effect(() => {
<!-- Scrollable body --> <!-- Scrollable body -->
<div class="flex-1 overflow-y-auto px-5 py-5"> <div class="flex-1 overflow-y-auto px-5 py-5">
{#if showUnsavedWarning} {#if unsaved.showUnsavedWarning}
<div <UnsavedWarningBanner onDiscard={unsaved.discard} />
class="mb-5 flex items-center justify-between rounded border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-300"
>
<span>{m.admin_unsaved_warning()}</span>
<button
type="button"
onclick={() => {
isDirty = false;
showUnsavedWarning = false;
if (discardTarget) goto(discardTarget);
}}
class="ml-4 shrink-0 font-sans text-xs font-bold tracking-widest text-amber-800 uppercase hover:text-amber-900 dark:text-amber-300"
>
{m.person_discard_changes()}
</button>
</div>
{/if} {/if}
{#if form?.success} {#if form?.success}
<div class="mb-5 rounded border border-green-200 bg-green-50 p-3 text-sm text-green-700"> <div class="mb-5 rounded border border-green-200 bg-green-50 p-3 text-sm text-green-700">
@@ -88,10 +61,7 @@ $effect(() => {
method="POST" method="POST"
action="?/update" action="?/update"
use:enhance use:enhance
oninput={() => { oninput={unsaved.markDirty}
isDirty = true;
showUnsavedWarning = false;
}}
class="mb-5" class="mb-5"
> >
<div class="rounded-sm border border-line bg-surface p-5 shadow-sm"> <div class="rounded-sm border border-line bg-surface p-5 shadow-sm">

View File

@@ -1,21 +1,20 @@
<script lang="ts"> <script lang="ts">
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import { beforeNavigate, goto } from '$app/navigation';
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import { getConfirmService } from '$lib/services/confirm.svelte.js'; import { getConfirmService } from '$lib/services/confirm.svelte.js';
import UserProfileSection from '$lib/components/user/UserProfileSection.svelte'; import UserProfileSection from '$lib/components/user/UserProfileSection.svelte';
import UserGroupsSection from '$lib/components/user/UserGroupsSection.svelte'; import UserGroupsSection from '$lib/components/user/UserGroupsSection.svelte';
import UserPasswordSection from '$lib/components/user/UserPasswordSection.svelte'; import UserPasswordSection from '$lib/components/user/UserPasswordSection.svelte';
import { createUnsavedWarning } from '$lib/hooks/useUnsavedWarning.svelte';
import UnsavedWarningBanner from '$lib/components/UnsavedWarningBanner.svelte';
let { data, form } = $props(); let { data, form } = $props();
const { confirm } = getConfirmService(); const { confirm } = getConfirmService();
const unsaved = createUnsavedWarning();
const selectedGroupIds = $derived(data.editUser.groups?.map((g: { id: string }) => g.id) ?? []); const selectedGroupIds = $derived(data.editUser.groups?.map((g: { id: string }) => g.id) ?? []);
let isDirty = $state(false);
let showUnsavedWarning = $state(false);
let discardTarget: string | null = $state(null);
let deleteFormEl = $state<HTMLFormElement | null>(null); let deleteFormEl = $state<HTMLFormElement | null>(null);
async function handleDelete() { async function handleDelete() {
@@ -26,19 +25,8 @@ async function handleDelete() {
if (confirmed) deleteFormEl!.requestSubmit(); if (confirmed) deleteFormEl!.requestSubmit();
} }
beforeNavigate(({ cancel, to }) => {
if (isDirty) {
cancel();
showUnsavedWarning = true;
discardTarget = to?.url.href ?? null;
}
});
$effect(() => { $effect(() => {
if (form?.success) { if (form?.success) unsaved.clearOnSuccess();
isDirty = false;
showUnsavedWarning = false;
}
}); });
</script> </script>
@@ -76,23 +64,8 @@ $effect(() => {
<!-- Scrollable body --> <!-- Scrollable body -->
<div class="flex-1 overflow-y-auto px-5 py-5"> <div class="flex-1 overflow-y-auto px-5 py-5">
{#if showUnsavedWarning} {#if unsaved.showUnsavedWarning}
<div <UnsavedWarningBanner onDiscard={unsaved.discard} />
class="mb-5 flex items-center justify-between rounded border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-300"
>
<span>{m.admin_unsaved_warning()}</span>
<button
type="button"
onclick={() => {
isDirty = false;
showUnsavedWarning = false;
if (discardTarget) goto(discardTarget);
}}
class="ml-4 shrink-0 font-sans text-xs font-bold tracking-widest text-amber-800 uppercase hover:text-amber-900 dark:text-amber-300"
>
{m.person_discard_changes()}
</button>
</div>
{/if} {/if}
{#if form?.success} {#if form?.success}
<div class="mb-5 rounded border border-green-200 bg-green-50 p-3 text-sm text-green-700"> <div class="mb-5 rounded border border-green-200 bg-green-50 p-3 text-sm text-green-700">
@@ -109,10 +82,7 @@ $effect(() => {
id="edit-user-form" id="edit-user-form"
method="POST" method="POST"
use:enhance use:enhance
oninput={() => { oninput={unsaved.markDirty}
isDirty = true;
showUnsavedWarning = false;
}}
class="space-y-5" class="space-y-5"
> >
<!-- Profile card --> <!-- Profile card -->

View File

@@ -1,64 +0,0 @@
import type { components } from '$lib/generated/api';
import { createApiClient } from '$lib/api.server';
export async function load({ url, fetch }) {
const senderId = url.searchParams.get('senderId') || '';
const receiverId = url.searchParams.get('receiverId') || '';
const from = url.searchParams.get('from') || '';
const to = url.searchParams.get('to') || '';
const dir = url.searchParams.get('dir') || 'DESC';
const api = createApiClient(fetch);
let documents: components['schemas']['Document'][] = [];
let senderName = '';
let receiverName = '';
const requests: Promise<void>[] = [];
if (senderId && receiverId) {
requests.push(
api
.GET('/api/documents/conversation', {
params: {
query: {
senderId,
receiverId,
dir,
from: from || undefined,
to: to || undefined
}
}
})
.then(({ data }) => {
documents = data ?? [];
})
);
}
if (senderId) {
requests.push(
api.GET('/api/persons/{id}', { params: { path: { id: senderId } } }).then(({ data }) => {
const p = data as { displayName: string } | undefined;
if (p) senderName = p.displayName;
})
);
}
if (receiverId) {
requests.push(
api.GET('/api/persons/{id}', { params: { path: { id: receiverId } } }).then(({ data }) => {
const p = data as { displayName: string } | undefined;
if (p) receiverName = p.displayName;
})
);
}
await Promise.all(requests);
return {
documents,
initialValues: { senderName, receiverName },
filters: { senderId, receiverId, from, to, dir }
};
}

View File

@@ -1,104 +0,0 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { untrack } from 'svelte';
import { SvelteURLSearchParams } from 'svelte/reactivity';
import { m } from '$lib/paraglide/messages.js';
import ConversationFilterBar from './ConversationFilterBar.svelte';
import ConversationTimeline from './ConversationTimeline.svelte';
let { data } = $props();
let senderId = $state(untrack(() => data.filters.senderId));
let receiverId = $state(untrack(() => data.filters.receiverId));
let fromDate = $state(untrack(() => data.filters.from));
let toDate = $state(untrack(() => data.filters.to));
let sortDir = $state(untrack(() => data.filters.dir));
// Sync with server data after navigation
$effect(() => {
senderId = data.filters.senderId;
receiverId = data.filters.receiverId;
fromDate = data.filters.from;
toDate = data.filters.to;
sortDir = data.filters.dir;
});
function applyFilters() {
const params = new SvelteURLSearchParams();
if (senderId) params.set('senderId', senderId);
if (receiverId) params.set('receiverId', receiverId);
if (fromDate) params.set('from', fromDate);
if (toDate) params.set('to', toDate);
params.set('dir', sortDir);
goto(`/conversations?${params.toString()}`, { keepFocus: true });
}
function toggleSort() {
sortDir = sortDir === 'DESC' ? 'ASC' : 'DESC';
applyFilters();
}
function swapPersons() {
const tmp = senderId;
senderId = receiverId;
receiverId = tmp;
applyFilters();
}
</script>
<div class="mx-auto max-w-5xl px-4 py-10">
<!-- Page Header -->
<div class="mb-8 border-b border-ink/10 pb-4">
<h1 class="font-serif text-3xl font-medium text-ink">{m.conv_heading()}</h1>
<p class="mt-2 font-sans text-sm text-ink-2">
{m.conv_subtitle()}
</p>
</div>
<ConversationFilterBar
bind:senderId={senderId}
bind:receiverId={receiverId}
bind:fromDate={fromDate}
bind:toDate={toDate}
bind:sortDir={sortDir}
initialSenderName={data.initialValues.senderName}
initialReceiverName={data.initialValues.receiverName}
onapplyFilters={applyFilters}
ontoggleSort={toggleSort}
onswapPersons={swapPersons}
/>
<!-- RESULTS LIST SECTION -->
{#if !senderId || !receiverId}
<div
class="flex flex-col items-center justify-center rounded-sm border border-dashed border-line bg-surface py-24 text-center"
>
<div class="mb-4 rounded-full bg-muted p-4 text-ink">
<svg class="h-8 w-8" fill="none" stroke="currentColor" viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
/></svg
>
</div>
<p class="font-serif text-lg text-ink">{m.conv_empty_heading()}</p>
<p class="mt-1 font-sans text-sm text-ink-2">{m.conv_empty_text()}</p>
</div>
{:else if data.documents.length === 0}
<div
class="flex flex-col items-center justify-center rounded-sm border border-line bg-surface py-24 text-center shadow-sm"
>
<p class="font-serif text-ink">{m.conv_no_results_heading()}</p>
<p class="mt-2 text-sm text-ink-3">{m.conv_no_results_text()}</p>
</div>
{:else}
<ConversationTimeline
documents={data.documents}
senderId={senderId}
receiverId={receiverId}
canWrite={data.canWrite}
/>
{/if}
</div>

View File

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

View File

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

View File

@@ -1,164 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import Page from './+page.svelte';
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
afterEach(cleanup);
// ─── Test data ────────────────────────────────────────────────────────────────
const baseData = {
user: undefined,
canWrite: true,
canAnnotate: false,
documents: [],
initialValues: { senderName: '', receiverName: '' },
filters: { senderId: '', receiverId: '', from: '', to: '', dir: 'DESC' as const }
};
const withPersons = {
...baseData,
filters: { ...baseData.filters, senderId: 'p1', receiverId: 'p2' }
};
const makeDoc = (overrides: Record<string, unknown> = {}) => ({
id: 'd1',
title: 'Testbrief',
originalFilename: 'testbrief.pdf',
status: 'UPLOADED' as const,
documentDate: '1923-04-12',
location: 'Berlin',
sender: { id: 'p1', firstName: 'Hans', lastName: 'Müller' },
receivers: [{ id: 'p2', firstName: 'Anna', lastName: 'Schmidt' }],
tags: [],
transcription: undefined,
filePath: undefined,
createdAt: '1923-04-12T00:00:00Z',
updatedAt: '1923-04-12T00:00:00Z',
...overrides
});
const withDocs = {
...withPersons,
documents: [makeDoc()]
};
// ─── Empty state ──────────────────────────────────────────────────────────────
describe('Conversations page empty state', () => {
it('shows the empty-state heading when no persons are selected', async () => {
render(Page, { data: baseData });
await expect.element(page.getByText(/Wessen Briefe möchten Sie lesen/i)).toBeInTheDocument();
});
it('hides the swap button when no persons are selected', async () => {
render(Page, { data: baseData });
// Button is always in the DOM (holds grid column width on desktop) but made invisible
await expect.element(page.getByTestId('conv-swap-btn')).toHaveClass('invisible');
});
it('does not show the new document link when no persons are selected', async () => {
render(Page, { data: baseData });
await expect.element(page.getByTestId('conv-new-doc-link')).not.toBeInTheDocument();
});
});
// ─── No results ───────────────────────────────────────────────────────────────
describe('Conversations page no results', () => {
it('shows "no documents found" when both persons are selected but there are no documents', async () => {
render(Page, { data: withPersons });
await expect.element(page.getByText(/Keine Dokumente gefunden/i)).toBeInTheDocument();
});
});
// ─── Swap button ──────────────────────────────────────────────────────────────
describe('Conversations page swap button', () => {
it('shows the swap button when both persons are selected', async () => {
render(Page, { data: withPersons });
await expect.element(page.getByTestId('conv-swap-btn')).not.toHaveClass('invisible');
});
it('calls goto with swapped sender and receiver when clicked', async () => {
const { goto } = await import('$app/navigation');
vi.mocked(goto).mockClear();
render(Page, { data: withPersons });
document.querySelector<HTMLElement>('[data-testid="conv-swap-btn"]')!.click();
expect(goto).toHaveBeenCalledWith(expect.stringContaining('senderId=p2'), expect.anything());
expect(goto).toHaveBeenCalledWith(expect.stringContaining('receiverId=p1'), expect.anything());
});
});
// ─── Summary ──────────────────────────────────────────────────────────────────
describe('Conversations page summary', () => {
it('shows document count and year range when documents are loaded', async () => {
const data = {
...withPersons,
documents: [
makeDoc({ documentDate: '1923-04-12' }),
makeDoc({ id: 'd2', documentDate: '1965-08-03' })
]
};
render(Page, { data });
const summary = page.getByTestId('conv-summary');
await expect.element(summary).toHaveTextContent('2');
await expect.element(summary).toHaveTextContent('1923');
await expect.element(summary).toHaveTextContent('1965');
});
});
// ─── Year dividers ────────────────────────────────────────────────────────────
describe('Conversations page year dividers', () => {
it('renders a year divider for the first document', async () => {
render(Page, { data: withDocs });
await expect.element(page.getByTestId('group-divider').first()).toHaveTextContent('1923');
});
it('renders a divider for each new year in the document list', async () => {
const data = {
...withPersons,
documents: [
makeDoc({ documentDate: '1923-04-12' }),
makeDoc({ id: 'd2', documentDate: '1965-08-03' })
]
};
render(Page, { data });
await expect.element(page.getByTestId('group-divider').first()).toHaveTextContent('1923');
await expect.element(page.getByTestId('group-divider').nth(1)).toHaveTextContent('1965');
});
it('does not render a second divider for documents from the same year', async () => {
const data = {
...withPersons,
documents: [
makeDoc({ documentDate: '1923-04-12' }),
makeDoc({ id: 'd2', documentDate: '1923-09-01' })
]
};
render(Page, { data });
// Only one divider for 1923; 1965 divider should not appear
await expect.element(page.getByTestId('group-divider').first()).toHaveTextContent('1923');
await expect.element(page.getByTestId('group-divider').nth(1)).not.toBeInTheDocument();
});
});
// ─── New document link ────────────────────────────────────────────────────────
describe('Conversations page new document link', () => {
it('shows the link with correct href for a write user', async () => {
render(Page, { data: { ...withDocs, canWrite: true } });
const link = page.getByTestId('conv-new-doc-link');
await expect.element(link).toBeInTheDocument();
await expect.element(link).toHaveAttribute('href', '/documents/new?senderId=p1&receiverId=p2');
});
it('hides the link for a read-only user', async () => {
render(Page, { data: { ...withDocs, canWrite: false } });
await expect.element(page.getByTestId('conv-new-doc-link')).not.toBeInTheDocument();
});
});

View File

@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import DocumentTopBar from '$lib/components/DocumentTopBar.svelte'; import DocumentTopBar from '$lib/components/DocumentTopBar.svelte';
import DocumentViewer from '$lib/components/DocumentViewer.svelte'; import DocumentViewer from '$lib/components/DocumentViewer.svelte';
@@ -9,6 +9,7 @@ import TranscriptionPanelHeader from '$lib/components/TranscriptionPanelHeader.s
import type { TranscriptionBlockData } from '$lib/types'; import type { TranscriptionBlockData } from '$lib/types';
import { getErrorMessage } from '$lib/errors'; import { getErrorMessage } from '$lib/errors';
import { translateOcrProgress } from '$lib/ocr/translateOcrProgress'; import { translateOcrProgress } from '$lib/ocr/translateOcrProgress';
import { createFileLoader } from '$lib/hooks/useFileLoader.svelte';
let { data } = $props(); let { data } = $props();
@@ -18,38 +19,15 @@ const currentUserId = $derived((data.user?.id as string | undefined) ?? null);
// ── File loading ────────────────────────────────────────────────────────────── // ── File loading ──────────────────────────────────────────────────────────────
let fileUrl = $state(''); const fileLoader = createFileLoader();
let isLoading = $state(false);
let fileError = $state('');
$effect(() => { $effect(() => {
if (doc?.id && doc?.filePath) { if (doc?.id && doc?.filePath) {
loadFile(doc.id); fileLoader.loadFile(`/api/documents/${doc.id}/file`);
} }
}); });
async function loadFile(id: string) { onDestroy(() => fileLoader.destroy());
isLoading = true;
fileError = '';
fileUrl = '';
try {
const response = await fetch(`/api/documents/${id}/file`);
if (!response.ok) {
if (response.status === 401) throw new Error('Nicht eingeloggt');
throw new Error('Fehler beim Laden der Datei');
}
const blob = await response.blob();
fileUrl = URL.createObjectURL(blob);
} catch (e) {
console.error(e);
fileError = 'Vorschau konnte nicht geladen werden.';
} finally {
isLoading = false;
}
}
// ── Mode state ─────────────────────────────────────────────────────────────── // ── Mode state ───────────────────────────────────────────────────────────────
@@ -345,7 +323,7 @@ onMount(() => {
<DocumentTopBar <DocumentTopBar
doc={doc} doc={doc}
canWrite={canWrite} canWrite={canWrite}
fileUrl={fileUrl} fileUrl={fileLoader.fileUrl}
bind:transcribeMode={transcribeMode} bind:transcribeMode={transcribeMode}
/> />
@@ -357,9 +335,9 @@ onMount(() => {
> >
<DocumentViewer <DocumentViewer
doc={doc} doc={doc}
fileUrl={fileUrl} fileUrl={fileLoader.fileUrl}
isLoading={isLoading} isLoading={fileLoader.isLoading}
error={fileError} error={fileLoader.fileError}
transcribeMode={transcribeMode && !ocrRunning} transcribeMode={transcribeMode && !ocrRunning}
blockNumbers={blockNumbers} blockNumbers={blockNumbers}
annotationReloadKey={annotationReloadKey} annotationReloadKey={annotationReloadKey}

View File

@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import { onMount, untrack } from 'svelte'; import { onMount, onDestroy, untrack } from 'svelte';
import { createFileLoader } from '$lib/hooks/useFileLoader.svelte';
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import DocumentViewer from '$lib/components/DocumentViewer.svelte'; import DocumentViewer from '$lib/components/DocumentViewer.svelte';
import WhoWhenSection from '$lib/components/document/WhoWhenSection.svelte'; import WhoWhenSection from '$lib/components/document/WhoWhenSection.svelte';
@@ -11,9 +12,7 @@ let { data, form } = $props();
const doc = $derived(data.document); const doc = $derived(data.document);
// File preview state // File preview state
let fileUrl = $state(''); const fileLoader = createFileLoader();
let fileError = $state('');
let isLoading = $state(false);
let navHeight = $state(0); let navHeight = $state(0);
onMount(() => { onMount(() => {
@@ -27,25 +26,11 @@ let activeAnnotationPage = $state<number | null>(null);
$effect(() => { $effect(() => {
if (doc?.id && doc?.filePath) { if (doc?.id && doc?.filePath) {
loadFile(doc.id); fileLoader.loadFile(`/api/documents/${doc.id}/file`);
} }
}); });
async function loadFile(id: string) { onDestroy(() => fileLoader.destroy());
isLoading = true;
fileError = '';
fileUrl = '';
try {
const response = await fetch(`/api/documents/${id}/file`);
if (!response.ok) throw new Error('Fehler');
const blob = await response.blob();
fileUrl = URL.createObjectURL(blob);
} catch {
fileError = m.doc_file_error_preview();
} finally {
isLoading = false;
}
}
// Form state // Form state
let tags = $state(untrack(() => doc.tags?.map((t: { name: string }) => t.name) ?? [])); let tags = $state(untrack(() => doc.tags?.map((t: { name: string }) => t.name) ?? []));
@@ -88,9 +73,9 @@ let selectedReceivers = $state(untrack(() => doc.receivers ?? []));
<div class="relative flex-[6] overflow-hidden border-r border-line"> <div class="relative flex-[6] overflow-hidden border-r border-line">
<DocumentViewer <DocumentViewer
doc={doc} doc={doc}
fileUrl={fileUrl} fileUrl={fileLoader.fileUrl}
isLoading={isLoading} isLoading={fileLoader.isLoading}
error={fileError} error={fileLoader.fileError}
bind:annotateMode={annotateMode} bind:annotateMode={annotateMode}
bind:activeAnnotationId={activeAnnotationId} bind:activeAnnotationId={activeAnnotationId}
bind:activeAnnotationPage={activeAnnotationPage} bind:activeAnnotationPage={activeAnnotationPage}