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>
This commit is contained in:
Marcel
2026-03-24 22:45:35 +01:00
parent 8c2bdbd777
commit ecfd80bf9a
8 changed files with 112 additions and 28 deletions

View File

@@ -257,5 +257,6 @@
"doc_panel_tab_history": "Verlauf", "doc_panel_tab_history": "Verlauf",
"doc_panel_annotate": "Annotieren", "doc_panel_annotate": "Annotieren",
"doc_panel_annotate_stop": "Fertig", "doc_panel_annotate_stop": "Fertig",
"doc_panel_annotation_thread_title": "Annotation" "doc_panel_annotation_thread_title": "Annotation",
"doc_panel_discussion_annotation_tab": "Annotation · Seite {page}"
} }

View File

@@ -257,5 +257,6 @@
"doc_panel_tab_history": "History", "doc_panel_tab_history": "History",
"doc_panel_annotate": "Annotate", "doc_panel_annotate": "Annotate",
"doc_panel_annotate_stop": "Done", "doc_panel_annotate_stop": "Done",
"doc_panel_annotation_thread_title": "Annotation" "doc_panel_annotation_thread_title": "Annotation",
"doc_panel_discussion_annotation_tab": "Annotation · Page {page}"
} }

View File

@@ -257,5 +257,6 @@
"doc_panel_tab_history": "Historial", "doc_panel_tab_history": "Historial",
"doc_panel_annotate": "Anotar", "doc_panel_annotate": "Anotar",
"doc_panel_annotate_stop": "Listo", "doc_panel_annotate_stop": "Listo",
"doc_panel_annotation_thread_title": "Anotación" "doc_panel_annotation_thread_title": "Anotación",
"doc_panel_discussion_annotation_tab": "Anotación · Página {page}"
} }

View File

@@ -49,6 +49,8 @@ type Props = {
height: number; height: number;
activeTab: Tab; activeTab: Tab;
activeAnnotationId: string | null; activeAnnotationId: string | null;
activeAnnotationPage: number | null;
onClearAnnotation?: () => void;
onAnnotationCommentCountChange?: (annotationId: string, count: number) => void; onAnnotationCommentCountChange?: (annotationId: string, count: number) => void;
}; };
@@ -62,6 +64,8 @@ let {
height = $bindable(), height = $bindable(),
activeTab = $bindable(), activeTab = $bindable(),
activeAnnotationId, activeAnnotationId,
activeAnnotationPage,
onClearAnnotation,
onAnnotationCommentCountChange onAnnotationCommentCountChange
}: Props = $props(); }: Props = $props();
@@ -182,10 +186,12 @@ const panelHeight = $derived(open ? height : MIN_HEIGHT);
<PanelDiscussion <PanelDiscussion
documentId={doc.id} documentId={doc.id}
activeAnnotationId={activeAnnotationId} activeAnnotationId={activeAnnotationId}
activeAnnotationPage={activeAnnotationPage}
initialComments={comments} initialComments={comments}
canComment={canComment} canComment={canComment}
currentUserId={currentUserId} currentUserId={currentUserId}
canAdmin={canAdmin} canAdmin={canAdmin}
onClearAnnotation={onClearAnnotation}
onAnnotationCommentCountChange={onAnnotationCommentCountChange} onAnnotationCommentCountChange={onAnnotationCommentCountChange}
/> />
{:else if activeTab === 'history'} {:else if activeTab === 'history'}

View File

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

View File

@@ -24,33 +24,98 @@ type Comment = {
type Props = { type Props = {
documentId: string; documentId: string;
activeAnnotationId: string | null; activeAnnotationId: string | null;
activeAnnotationPage: number | null;
initialComments: Comment[]; initialComments: Comment[];
canComment: boolean; canComment: boolean;
currentUserId: string | null; currentUserId: string | null;
canAdmin: boolean; canAdmin: boolean;
onClearAnnotation?: () => void;
onAnnotationCommentCountChange?: (annotationId: string, count: number) => void; onAnnotationCommentCountChange?: (annotationId: string, count: number) => void;
}; };
let { let {
documentId, documentId,
activeAnnotationId, activeAnnotationId,
activeAnnotationPage,
initialComments, initialComments,
canComment, canComment,
currentUserId, currentUserId,
canAdmin, canAdmin,
onClearAnnotation,
onAnnotationCommentCountChange onAnnotationCommentCountChange
}: Props = $props(); }: Props = $props();
// Sub-tab within the discussion panel: 'document' or 'annotation'
type DiscussionTab = 'document' | 'annotation';
let activeSubTab = $state<DiscussionTab>('document');
// Track document-level comment count for badge.
// CommentThread calls onCountChange immediately on mount with the accurate total.
let docCommentCount = $state(0);
// When an annotation becomes active, switch to the annotation sub-tab automatically.
$effect(() => {
if (activeAnnotationId) {
activeSubTab = 'annotation';
} else {
activeSubTab = 'document';
}
});
function selectDocumentTab() {
activeSubTab = 'document';
onClearAnnotation?.();
}
function selectAnnotationTab() {
activeSubTab = 'annotation';
}
</script> </script>
<div class="space-y-8 p-6"> <div class="flex h-full flex-col">
<!-- Annotation thread (shown when an annotation is active) --> <!-- Sub-tab bar (only shown when annotation is active) -->
{#if activeAnnotationId} {#if activeAnnotationId}
<div> <div class="flex shrink-0 border-b border-brand-sand/70 bg-gray-50 px-4">
<h4 <button
class="mb-3 border-b border-brand-sand pb-2 font-sans text-xs font-bold tracking-widest text-brand-navy uppercase" onclick={selectDocumentTab}
class="mr-1 px-3 py-2 font-sans text-xs font-medium transition-colors {activeSubTab === 'document'
? 'border-b-2 border-brand-navy text-brand-navy'
: 'text-gray-400 hover:text-brand-navy'}"
> >
{m.doc_panel_annotation_thread_title()} {m.doc_panel_tab_discussion()}
</h4> {#if docCommentCount > 0}
<span
class="ml-1 inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-brand-mint px-1 font-mono text-[10px] text-brand-navy"
>
{docCommentCount}
</span>
{/if}
</button>
<button
onclick={selectAnnotationTab}
class="px-3 py-2 font-sans text-xs font-medium transition-colors {activeSubTab === 'annotation'
? 'border-b-2 border-brand-navy text-brand-navy'
: 'text-gray-400 hover:text-brand-navy'}"
>
{m.doc_panel_discussion_annotation_tab({ page: String(activeAnnotationPage ?? '?') })}
</button>
</div>
{/if}
<!-- Content area -->
<div class="flex-1 overflow-y-auto p-6">
{#if !activeAnnotationId || activeSubTab === 'document'}
<!-- Document-level thread -->
<CommentThread
documentId={documentId}
initialComments={initialComments}
canComment={canComment}
currentUserId={currentUserId}
canAdmin={canAdmin}
onCountChange={(count) => (docCommentCount = count)}
/>
{:else}
<!-- Annotation-level thread -->
{#key activeAnnotationId} {#key activeAnnotationId}
<CommentThread <CommentThread
documentId={documentId} documentId={documentId}
@@ -62,24 +127,6 @@ let {
onCountChange={(count) => onAnnotationCommentCountChange?.(activeAnnotationId, count)} onCountChange={(count) => onAnnotationCommentCountChange?.(activeAnnotationId, count)}
/> />
{/key} {/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} {/if}
<CommentThread
documentId={documentId}
initialComments={initialComments}
canComment={canComment}
currentUserId={currentUserId}
canAdmin={canAdmin}
/>
</div> </div>
</div> </div>

View File

@@ -9,12 +9,14 @@ let {
documentId = '', documentId = '',
annotateMode = $bindable(false), annotateMode = $bindable(false),
activeAnnotationId = $bindable<string | null>(null), activeAnnotationId = $bindable<string | null>(null),
activeAnnotationPage = $bindable<number | null>(null),
onAnnotationClick onAnnotationClick
}: { }: {
url: string; url: string;
documentId?: string; documentId?: string;
annotateMode?: boolean; annotateMode?: boolean;
activeAnnotationId?: string | null; activeAnnotationId?: string | null;
activeAnnotationPage?: number | null;
onAnnotationClick?: (id: string) => void; onAnnotationClick?: (id: string) => void;
} = $props(); } = $props();
@@ -213,6 +215,7 @@ async function handleAnnotationDraw(rect: { x: number; y: number; width: number;
const created: Annotation = await res.json(); const created: Annotation = await res.json();
annotations = [...annotations, created]; annotations = [...annotations, created];
activeAnnotationId = created.id; activeAnnotationId = created.id;
activeAnnotationPage = created.pageNumber;
onAnnotationClick?.(created.id); onAnnotationClick?.(created.id);
} }
} catch { } catch {
@@ -236,6 +239,8 @@ async function handleAnnotationDelete(annotationId: string) {
function handleAnnotationClick(id: string) { function handleAnnotationClick(id: string) {
activeAnnotationId = id; activeAnnotationId = id;
const ann = annotations.find((a) => a.id === id);
activeAnnotationPage = ann?.pageNumber ?? null;
onAnnotationClick?.(id); onAnnotationClick?.(id);
} }

View File

@@ -56,6 +56,7 @@ async function loadFile(id: string) {
let annotateMode = $state(false); let annotateMode = $state(false);
let activeAnnotationId = $state<string | null>(null); let activeAnnotationId = $state<string | null>(null);
let activeAnnotationPage = $state<number | null>(null);
// When an annotation is clicked, open the Diskussion tab. // When an annotation is clicked, open the Diskussion tab.
$effect(() => { $effect(() => {
@@ -97,6 +98,19 @@ onMount(() => {
} }
localStorageRestored = true; 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). // Persist panel state whenever it changes (after initial restore).
@@ -129,6 +143,7 @@ $effect(() => {
error={fileError} error={fileError}
bind:annotateMode={annotateMode} bind:annotateMode={annotateMode}
bind:activeAnnotationId={activeAnnotationId} bind:activeAnnotationId={activeAnnotationId}
bind:activeAnnotationPage={activeAnnotationPage}
onAnnotationClick={(id) => { onAnnotationClick={(id) => {
activeAnnotationId = id; activeAnnotationId = id;
}} }}
@@ -146,4 +161,9 @@ $effect(() => {
bind:height={panelHeight} bind:height={panelHeight}
bind:activeTab={activeTab} bind:activeTab={activeTab}
activeAnnotationId={activeAnnotationId} activeAnnotationId={activeAnnotationId}
activeAnnotationPage={activeAnnotationPage}
onClearAnnotation={() => {
activeAnnotationId = null;
activeAnnotationPage = null;
}}
/> />