Compare commits

...

15 Commits

Author SHA1 Message Date
Marcel
dd360ade8b fix(frontend): fix side panel X button click falling through to PDF toolbar
Some checks failed
CI / Unit & Component Tests (push) Successful in 2m24s
CI / Backend Unit Tests (push) Successful in 2m14s
CI / Unit & Component Tests (pull_request) Successful in 2m20s
CI / Backend Unit Tests (pull_request) Successful in 2m12s
CI / E2E Tests (push) Failing after 29m14s
CI / E2E Tests (pull_request) Failing after 29m37s
pointer-events-none and pointer-events-auto were both present as static
and conditional Tailwind classes simultaneously. CSS specificity meant
pointer-events-none always won, so clicks passed through to the
annotation toggle button behind the panel. Now pointer-events-none is
only applied when the panel is hidden (translated off-screen).

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Closes #60
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 22:45:35 +01:00
11 changed files with 359 additions and 177 deletions

View File

@@ -257,5 +257,8 @@
"doc_panel_tab_history": "Verlauf",
"doc_panel_annotate": "Annotieren",
"doc_panel_annotate_stop": "Fertig",
"doc_panel_annotation_thread_title": "Annotation"
"doc_panel_annotation_thread_title": "Annotation",
"doc_panel_discussion_annotation_tab": "Annotation · Seite {page}",
"pdf_annotations_show": "Annotierungen anzeigen",
"pdf_annotations_hide": "Annotierungen verbergen"
}

View File

@@ -257,5 +257,8 @@
"doc_panel_tab_history": "History",
"doc_panel_annotate": "Annotate",
"doc_panel_annotate_stop": "Done",
"doc_panel_annotation_thread_title": "Annotation"
"doc_panel_annotation_thread_title": "Annotation",
"doc_panel_discussion_annotation_tab": "Annotation · Page {page}",
"pdf_annotations_show": "Show annotations",
"pdf_annotations_hide": "Hide annotations"
}

View File

@@ -257,5 +257,8 @@
"doc_panel_tab_history": "Historial",
"doc_panel_annotate": "Anotar",
"doc_panel_annotate_stop": "Listo",
"doc_panel_annotation_thread_title": "Anotación"
"doc_panel_annotation_thread_title": "Anotación",
"doc_panel_discussion_annotation_tab": "Anotación · Página {page}",
"pdf_annotations_show": "Mostrar anotaciones",
"pdf_annotations_hide": "Ocultar anotaciones"
}

View File

@@ -0,0 +1,65 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import CommentThread from './CommentThread.svelte';
type Props = {
documentId: string;
activeAnnotationId: string | null;
activeAnnotationPage: number | null;
canComment: boolean;
currentUserId: string | null;
canAdmin: boolean;
onClose: () => void;
};
let {
documentId,
activeAnnotationId,
activeAnnotationPage,
canComment,
currentUserId,
canAdmin,
onClose
}: Props = $props();
const visible = $derived(activeAnnotationId !== null);
</script>
<div
class="absolute inset-y-0 right-0 z-10 flex w-80 flex-col border-l border-brand-sand bg-white shadow-[-4px_0_16px_rgba(0,0,0,0.08)] transition-transform duration-200 {visible
? 'translate-x-0'
: 'pointer-events-none translate-x-full'}"
data-testid="annotation-side-panel"
>
<!-- Header -->
<div class="flex shrink-0 items-center justify-between border-b border-brand-sand px-4 py-3">
<span class="font-sans text-xs font-medium text-brand-navy">
{m.doc_panel_discussion_annotation_tab({ page: String(activeAnnotationPage ?? '?') })}
</span>
<button
onclick={onClose}
aria-label={m.comment_panel_close()}
class="rounded p-1 text-gray-400 transition-colors hover:bg-brand-sand/50 hover:text-brand-navy"
>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Comment thread -->
<div class="flex-1 overflow-y-auto p-4">
{#if activeAnnotationId}
{#key activeAnnotationId}
<CommentThread
documentId={documentId}
annotationId={activeAnnotationId}
canComment={canComment}
currentUserId={currentUserId}
canAdmin={canAdmin}
loadOnMount={true}
/>
{/key}
{/if}
</div>
</div>

View File

@@ -48,8 +48,6 @@ type Props = {
open: boolean;
height: number;
activeTab: Tab;
activeAnnotationId: string | null;
onAnnotationCommentCountChange?: (annotationId: string, count: number) => void;
};
let {
@@ -60,23 +58,25 @@ let {
canAdmin,
open = $bindable(),
height = $bindable(),
activeTab = $bindable(),
activeAnnotationId,
onAnnotationCommentCountChange
activeTab = $bindable()
}: Props = $props();
const MIN_HEIGHT = 52; // drag handle (8px) + tab bar (~44px)
const DEFAULT_HEIGHT = 320;
let isDragging = $state(false);
let dragStartY = 0;
let dragStartHeight = 0;
function fullHeight() {
const topbar = document.querySelector('[data-topbar]');
return window.innerHeight - (topbar?.getBoundingClientRect().bottom ?? 0);
}
function openTab(tab: Tab) {
activeTab = tab;
if (!open) {
open = true;
if (height <= MIN_HEIGHT) height = DEFAULT_HEIGHT;
if (height <= MIN_HEIGHT) height = fullHeight();
}
}
@@ -95,14 +95,14 @@ function onDragMove(e: PointerEvent) {
if (!isDragging) return;
const delta = dragStartY - e.clientY; // positive = dragging up = bigger panel
const newHeight = dragStartHeight + delta;
const maxHeight = Math.floor(window.innerHeight * 0.8);
const maxHeight = fullHeight();
if (newHeight <= MIN_HEIGHT + 20) {
// collapsed past threshold → close
open = false;
} else {
open = true;
height = Math.max(DEFAULT_HEIGHT / 4, Math.min(newHeight, maxHeight));
height = Math.max(80, Math.min(newHeight, maxHeight));
}
}
@@ -181,12 +181,10 @@ const panelHeight = $derived(open ? height : MIN_HEIGHT);
{:else if activeTab === 'discussion'}
<PanelDiscussion
documentId={doc.id}
activeAnnotationId={activeAnnotationId}
initialComments={comments}
canComment={canComment}
currentUserId={currentUserId}
canAdmin={canAdmin}
onAnnotationCommentCountChange={onAnnotationCommentCountChange}
/>
{:else if activeTab === 'history'}
<PanelHistory documentId={doc.id} />

View File

@@ -59,6 +59,7 @@ const compactMeta = $derived.by(() => {
<div
class="z-20 flex shrink-0 items-center justify-between border-b border-brand-sand bg-white px-6 py-3 shadow-sm"
data-topbar
>
<!-- Left: back + title -->
<div class="flex min-w-0 items-center gap-4 overflow-hidden">
@@ -100,10 +101,16 @@ const compactMeta = $derived.by(() => {
<button
onclick={() => (annotateMode = !annotateMode)}
aria-label={annotateMode ? m.doc_panel_annotate_stop() : m.doc_panel_annotate()}
class="rounded px-3 py-1.5 font-sans text-xs font-medium transition {annotateMode
class="flex items-center gap-1.5 rounded px-3 py-1.5 font-sans text-xs font-medium transition {annotateMode
? 'bg-brand-navy text-white'
: 'border border-brand-navy text-brand-navy hover:bg-brand-navy hover:text-white'}"
>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Note/Note-Add-MD.svg"
alt=""
aria-hidden="true"
class="h-4 w-4 {annotateMode ? 'invert' : ''}"
/>
{annotateMode ? m.doc_panel_annotate_stop() : m.doc_panel_annotate()}
</button>
{/if}

View File

@@ -15,6 +15,7 @@ type Props = {
error: string;
annotateMode: boolean;
activeAnnotationId: string | null;
activeAnnotationPage: number | null;
onAnnotationClick: (id: string) => void;
};
@@ -25,6 +26,7 @@ let {
error,
annotateMode = $bindable(),
activeAnnotationId = $bindable(),
activeAnnotationPage = $bindable(),
onAnnotationClick
}: Props = $props();
</script>
@@ -79,6 +81,7 @@ let {
documentId={doc.id}
bind:annotateMode={annotateMode}
bind:activeAnnotationId={activeAnnotationId}
bind:activeAnnotationPage={activeAnnotationPage}
onAnnotationClick={onAnnotationClick}
/>
{:else if fileUrl}

View File

@@ -1,5 +1,4 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import CommentThread from './CommentThread.svelte';
type CommentReply = {
@@ -23,63 +22,21 @@ type Comment = {
type Props = {
documentId: string;
activeAnnotationId: string | null;
initialComments: Comment[];
canComment: boolean;
currentUserId: string | null;
canAdmin: boolean;
onAnnotationCommentCountChange?: (annotationId: string, count: number) => void;
};
let {
documentId,
activeAnnotationId,
initialComments,
canComment,
currentUserId,
canAdmin,
onAnnotationCommentCountChange
}: Props = $props();
let { documentId, initialComments, canComment, currentUserId, canAdmin }: Props = $props();
</script>
<div class="space-y-8 p-6">
<!-- Annotation thread (shown when an annotation is active) -->
{#if activeAnnotationId}
<div>
<h4
class="mb-3 border-b border-brand-sand pb-2 font-sans text-xs font-bold tracking-widest text-brand-navy uppercase"
>
{m.doc_panel_annotation_thread_title()}
</h4>
{#key activeAnnotationId}
<CommentThread
documentId={documentId}
annotationId={activeAnnotationId}
canComment={canComment}
currentUserId={currentUserId}
canAdmin={canAdmin}
loadOnMount={true}
onCountChange={(count) => onAnnotationCommentCountChange?.(activeAnnotationId, count)}
/>
{/key}
</div>
{/if}
<!-- General document discussion -->
<div>
{#if activeAnnotationId}
<h4
class="mb-3 border-b border-brand-sand pb-2 font-sans text-xs font-bold tracking-widest text-brand-navy uppercase"
>
{m.comment_section_title()}
</h4>
{/if}
<CommentThread
documentId={documentId}
initialComments={initialComments}
canComment={canComment}
currentUserId={currentUserId}
canAdmin={canAdmin}
/>
</div>
<div class="flex-1 overflow-y-auto p-6">
<CommentThread
documentId={documentId}
initialComments={initialComments}
canComment={canComment}
currentUserId={currentUserId}
canAdmin={canAdmin}
/>
</div>

View File

@@ -333,8 +333,79 @@ $effect(() => {
{m.history_compare_apply()}
</button>
</div>
<!-- Diff panel for compare mode -->
{#if diffLoading}
<p class="font-sans text-xs text-gray-400">{m.history_loading()}</p>
{:else if noDiff}
<div
data-testid="history-diff"
class="rounded-sm border border-brand-sand bg-white p-4 font-serif text-sm text-gray-400 italic"
>
{m.history_diff_no_changes()}
</div>
{:else if diffEntries.length > 0}
<div
data-testid="history-diff"
class="space-y-4 rounded-sm border border-brand-sand bg-white p-4"
>
{#each diffEntries as entry (entry.field)}
<div>
<span
class="mb-1.5 block font-sans text-[10px] font-bold tracking-wide text-gray-400 uppercase"
>{entry.label}</span
>
{#if entry.kind === 'text'}
<p class="font-serif text-sm leading-relaxed">
{#each entry.parts as part, partIdx (partIdx)}
{#if part.added}
<span class="bg-green-50 text-green-700">{part.value}</span>
{:else if part.removed}
<span class="bg-red-50 text-red-600 line-through">{part.value}</span>
{:else}
<span>{part.value}</span>
{/if}
{/each}
</p>
{:else if entry.kind === 'scalar'}
<div class="flex items-center gap-2 font-serif text-sm">
<span class="text-red-600 line-through">{entry.oldVal || '—'}</span>
<svg
class="h-3 w-3 flex-shrink-0 text-gray-400"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z"
clip-rule="evenodd"
/>
</svg>
<span class="text-green-700">{entry.newVal || '—'}</span>
</div>
{:else if entry.kind === 'relation'}
<div class="flex flex-wrap gap-1.5">
{#each entry.removed as item (item)}
<span
class="rounded bg-red-50 px-1.5 py-0.5 font-sans text-[11px] text-red-600 line-through"
>{item}</span
>
{/each}
{#each entry.added as item (item)}
<span
class="rounded bg-green-50 px-1.5 py-0.5 font-sans text-[11px] text-green-700"
>{item}</span
>
{/each}
</div>
{/if}
</div>
{/each}
</div>
{/if}
{:else}
<!-- Version list -->
<!-- Version list with inline diff below each selected item -->
<ul class="divide-y divide-brand-sand">
{#each versions as v, i (v.id)}
<li>
@@ -367,80 +438,82 @@ $effect(() => {
</div>
{/if}
</button>
<!-- Diff shown inline below the selected version -->
{#if selectedVersionId === v.id}
{#if diffLoading}
<p class="pb-3 pl-2 font-sans text-xs text-gray-400">{m.history_loading()}</p>
{:else if noDiff}
<div
data-testid="history-diff"
class="mb-2 rounded-sm border border-brand-sand bg-white p-4 font-serif text-sm text-gray-400 italic"
>
{m.history_diff_no_changes()}
</div>
{:else if diffEntries.length > 0}
<div
data-testid="history-diff"
class="mb-2 space-y-4 rounded-sm border border-brand-sand bg-white p-4"
>
{#each diffEntries as entry (entry.field)}
<div>
<span
class="mb-1.5 block font-sans text-[10px] font-bold tracking-wide text-gray-400 uppercase"
>{entry.label}</span
>
{#if entry.kind === 'text'}
<p class="font-serif text-sm leading-relaxed">
{#each entry.parts as part, partIdx (partIdx)}
{#if part.added}
<span class="bg-green-50 text-green-700">{part.value}</span>
{:else if part.removed}
<span class="bg-red-50 text-red-600 line-through">{part.value}</span>
{:else}
<span>{part.value}</span>
{/if}
{/each}
</p>
{:else if entry.kind === 'scalar'}
<div class="flex items-center gap-2 font-serif text-sm">
<span class="text-red-600 line-through">{entry.oldVal || '—'}</span>
<svg
class="h-3 w-3 flex-shrink-0 text-gray-400"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z"
clip-rule="evenodd"
/>
</svg>
<span class="text-green-700">{entry.newVal || '—'}</span>
</div>
{:else if entry.kind === 'relation'}
<div class="flex flex-wrap gap-1.5">
{#each entry.removed as item (item)}
<span
class="rounded bg-red-50 px-1.5 py-0.5 font-sans text-[11px] text-red-600 line-through"
>{item}</span
>
{/each}
{#each entry.added as item (item)}
<span
class="rounded bg-green-50 px-1.5 py-0.5 font-sans text-[11px] text-green-700"
>{item}</span
>
{/each}
</div>
{/if}
</div>
{/each}
</div>
{/if}
{/if}
</li>
{/each}
</ul>
{/if}
<!-- Diff panel -->
{#if diffLoading}
<p class="font-sans text-xs text-gray-400">{m.history_loading()}</p>
{:else if noDiff}
<div
data-testid="history-diff"
class="rounded-sm border border-brand-sand bg-white p-4 font-serif text-sm text-gray-400 italic"
>
{m.history_diff_no_changes()}
</div>
{:else if diffEntries.length > 0}
<div
data-testid="history-diff"
class="space-y-4 rounded-sm border border-brand-sand bg-white p-4"
>
{#each diffEntries as entry (entry.field)}
<div>
<span
class="mb-1.5 block font-sans text-[10px] font-bold tracking-wide text-gray-400 uppercase"
>{entry.label}</span
>
{#if entry.kind === 'text'}
<p class="font-serif text-sm leading-relaxed">
{#each entry.parts as part, partIdx (partIdx)}
{#if part.added}
<span class="bg-green-50 text-green-700">{part.value}</span>
{:else if part.removed}
<span class="bg-red-50 text-red-600 line-through">{part.value}</span>
{:else}
<span>{part.value}</span>
{/if}
{/each}
</p>
{:else if entry.kind === 'scalar'}
<div class="flex items-center gap-2 font-serif text-sm">
<span class="text-red-600 line-through">{entry.oldVal || '—'}</span>
<svg
class="h-3 w-3 flex-shrink-0 text-gray-400"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z"
clip-rule="evenodd"
/>
</svg>
<span class="text-green-700">{entry.newVal || '—'}</span>
</div>
{:else if entry.kind === 'relation'}
<div class="flex flex-wrap gap-1.5">
{#each entry.removed as item (item)}
<span
class="rounded bg-red-50 px-1.5 py-0.5 font-sans text-[11px] text-red-600 line-through"
>{item}</span
>
{/each}
{#each entry.added as item (item)}
<span
class="rounded bg-green-50 px-1.5 py-0.5 font-sans text-[11px] text-green-700"
>{item}</span
>
{/each}
</div>
{/if}
</div>
{/each}
</div>
{/if}
{/if}
</div>

View File

@@ -3,18 +3,21 @@ import { onMount } from 'svelte';
import { SvelteMap } from 'svelte/reactivity';
import type { PDFDocumentProxy, PDFPageProxy, RenderTask } from 'pdfjs-dist';
import AnnotationLayer from './AnnotationLayer.svelte';
import { m } from '$lib/paraglide/messages.js';
let {
url,
documentId = '',
annotateMode = $bindable(false),
activeAnnotationId = $bindable<string | null>(null),
activeAnnotationPage = $bindable<number | null>(null),
onAnnotationClick
}: {
url: string;
documentId?: string;
annotateMode?: boolean;
activeAnnotationId?: string | null;
activeAnnotationPage?: number | null;
onAnnotationClick?: (id: string) => void;
} = $props();
@@ -53,6 +56,7 @@ type Annotation = {
let annotations = $state<Annotation[]>([]);
let annotateColor = $state('#ffff00');
let commentCounts = new SvelteMap<string, number>();
let showAnnotations = $state(true);
onMount(async () => {
// Dynamic import keeps pdfjs out of the SSR bundle entirely
@@ -213,6 +217,7 @@ async function handleAnnotationDraw(rect: { x: number; y: number; width: number;
const created: Annotation = await res.json();
annotations = [...annotations, created];
activeAnnotationId = created.id;
activeAnnotationPage = created.pageNumber;
onAnnotationClick?.(created.id);
}
} catch {
@@ -236,6 +241,8 @@ async function handleAnnotationDelete(annotationId: string) {
function handleAnnotationClick(id: string) {
activeAnnotationId = id;
const ann = annotations.find((a) => a.id === id);
activeAnnotationPage = ann?.pageNumber ?? null;
onAnnotationClick?.(id);
}
@@ -396,6 +403,44 @@ function zoomOut() {
title="Farbe wählen"
/>
{/if}
<!-- Annotation visibility toggle (shown when annotations exist) -->
{#if annotations.length > 0}
<button
onclick={() => (showAnnotations = !showAnnotations)}
aria-label={showAnnotations ? m.pdf_annotations_hide() : m.pdf_annotations_show()}
class="flex items-center gap-1.5 rounded px-2 py-1 font-sans text-xs transition {showAnnotations
? 'text-gray-300 hover:bg-white/10'
: 'bg-white/10 text-brand-mint'}"
>
<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 -->
@@ -419,15 +464,17 @@ function zoomOut() {
class="textLayer"
style="position: absolute; top: 0; left: 0; overflow: hidden; pointer-events: none; line-height: 1;"
></div>
<AnnotationLayer
annotations={annotations.filter((a) => a.pageNumber === currentPage)}
canAnnotate={annotateMode}
color={annotateColor}
onDraw={handleAnnotationDraw}
onDelete={handleAnnotationDelete}
commentCounts={Object.fromEntries(commentCounts)}
onAnnotationClick={handleAnnotationClick}
/>
{#if showAnnotations}
<AnnotationLayer
annotations={annotations.filter((a) => a.pageNumber === currentPage)}
canAnnotate={annotateMode}
color={annotateColor}
onDraw={handleAnnotationDraw}
onDelete={handleAnnotationDelete}
commentCounts={Object.fromEntries(commentCounts)}
onAnnotationClick={handleAnnotationClick}
/>
{/if}
</div>
</div>
{/if}

View File

@@ -3,6 +3,7 @@ import { onMount } from 'svelte';
import DocumentTopBar from '$lib/components/DocumentTopBar.svelte';
import DocumentViewer from '$lib/components/DocumentViewer.svelte';
import DocumentBottomPanel from '$lib/components/DocumentBottomPanel.svelte';
import AnnotationSidePanel from '$lib/components/AnnotationSidePanel.svelte';
type Tab = 'metadata' | 'transcription' | 'discussion' | 'history';
@@ -56,53 +57,59 @@ async function loadFile(id: string) {
let annotateMode = $state(false);
let activeAnnotationId = $state<string | null>(null);
let activeAnnotationPage = $state<number | null>(null);
// When an annotation is clicked, open the Diskussion tab.
// Close the panel when entering annotate mode so the PDF is fully visible.
$effect(() => {
if (activeAnnotationId) {
activeTab = 'discussion';
panelOpen = true;
}
if (annotateMode) panelOpen = false;
});
// ── Bottom panel state ────────────────────────────────────────────────────────
const LS_KEY_OPEN = 'doc-panel-open';
const LS_KEY_HEIGHT = 'doc-panel-height';
const LS_KEY_TAB = 'doc-panel-tab';
let panelOpen = $state(false);
let panelHeight = $state(320);
let panelHeight = $state(0); // set to full height on mount
let navHeight = $state(0);
let activeTab = $state<Tab>('metadata');
let localStorageRestored = $state(false);
onMount(() => {
const savedOpen = localStorage.getItem(LS_KEY_OPEN);
navHeight = document.querySelector('header')?.getBoundingClientRect().height ?? 0;
const savedHeight = localStorage.getItem(LS_KEY_HEIGHT);
const savedTab = localStorage.getItem(LS_KEY_TAB);
if (savedTab && ['metadata', 'transcription', 'discussion', 'history'].includes(savedTab)) {
activeTab = savedTab as Tab;
}
const topbar = document.querySelector('[data-topbar]');
panelHeight = window.innerHeight - navHeight - (topbar?.getBoundingClientRect().height ?? 0);
if (savedHeight) {
const h = parseInt(savedHeight, 10);
if (!isNaN(h) && h >= 80) panelHeight = h;
}
if (savedOpen !== null) {
panelOpen = savedOpen === 'true';
} else if (!doc.filePath) {
// No previous state and no file → open to Metadaten by default
panelOpen = true;
activeTab = 'metadata';
}
localStorageRestored = true;
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') {
if (activeAnnotationId) {
activeAnnotationId = null;
activeAnnotationPage = null;
} else if (panelOpen) {
panelOpen = false;
}
}
}
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
});
// Persist panel state whenever it changes (after initial restore).
$effect(() => {
if (!localStorageRestored) return;
localStorage.setItem(LS_KEY_OPEN, String(panelOpen));
localStorage.setItem(LS_KEY_HEIGHT, String(panelHeight));
localStorage.setItem(LS_KEY_TAB, activeTab);
});
@@ -112,7 +119,11 @@ $effect(() => {
<title>{doc.title || doc.originalFilename || 'Dokument'}</title>
</svelte:head>
<div class="flex h-screen flex-col overflow-hidden bg-white" data-hydrated>
<div
class="fixed right-0 bottom-0 left-0 z-40 flex flex-col overflow-hidden bg-white"
style="top: {navHeight}px"
data-hydrated
>
<DocumentTopBar
doc={doc}
canWrite={data.canWrite ?? false}
@@ -129,21 +140,33 @@ $effect(() => {
error={fileError}
bind:annotateMode={annotateMode}
bind:activeAnnotationId={activeAnnotationId}
bind:activeAnnotationPage={activeAnnotationPage}
onAnnotationClick={(id) => {
activeAnnotationId = id;
}}
/>
<AnnotationSidePanel
documentId={doc.id}
activeAnnotationId={activeAnnotationId}
activeAnnotationPage={activeAnnotationPage}
canComment={canComment}
currentUserId={currentUserId}
canAdmin={canAdmin}
onClose={() => {
activeAnnotationId = null;
activeAnnotationPage = null;
}}
/>
</div>
</div>
<DocumentBottomPanel
doc={doc}
comments={(data.comments ?? []) as never[]}
canComment={canComment}
currentUserId={currentUserId}
canAdmin={canAdmin}
bind:open={panelOpen}
bind:height={panelHeight}
bind:activeTab={activeTab}
activeAnnotationId={activeAnnotationId}
/>
<DocumentBottomPanel
doc={doc}
comments={(data.comments ?? []) as never[]}
canComment={canComment}
currentUserId={currentUserId}
canAdmin={canAdmin}
bind:open={panelOpen}
bind:height={panelHeight}
bind:activeTab={activeTab}
/>
</div>