Compare commits

..

6 Commits

Author SHA1 Message Date
Marcel
5d0a2a2c9c fix: use semantic color tokens for enrich hint box
Some checks failed
CI / Unit & Component Tests (pull_request) Successful in 2m29s
CI / Backend Unit Tests (pull_request) Successful in 2m12s
CI / E2E Tests (pull_request) Failing after 24s
CI / Unit & Component Tests (push) Successful in 2m17s
CI / Backend Unit Tests (push) Successful in 2m1s
CI / E2E Tests (push) Has started running
Replaced hardcoded brand-navy/brand-mint palette constants with
semantic tokens (ink, accent, accent-bg) so the hint box themes
correctly in dark mode.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 19:47:44 +01:00
Marcel
0f0d74eb2f fix(#81): use text-primary-fg for badge text so dark mode reads correctly
In dark mode --c-primary flips to mint (#a1dcd8), making text-white
unreadable. text-primary-fg is already paired correctly in both modes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 19:41:10 +01:00
Marcel
20f6de4424 refactor(#81): replace nudge button with always-visible count badge
Show the discussion count badge on every state (including 0) instead of
a separate nudge button. Simpler, less intrusive, and works without
needing an extra element near the panel.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 18:43:48 +01:00
Marcel
bf82ebfe1d feat(#81): improve discussion discoverability
- Add comment count badge on the Discussion tab (seeded from SSR, updated live)
- Add 'Diskussion starten' nudge above collapsed panel when no comments exist
- Add empty state hint with speech-bubble icon inside the discussion panel
- Fix CommentThread to fire onCountChange with SSR-seeded count on mount
- Add tests for all three behaviours in CommentThread and DocumentBottomPanel

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 18:19:38 +01:00
Marcel
c6984e49ee fix(dropzone): vertical layout, larger icon, improved copy
Some checks failed
CI / Unit & Component Tests (pull_request) Successful in 2m23s
CI / Backend Unit Tests (pull_request) Successful in 2m9s
CI / E2E Tests (pull_request) Failing after 28m26s
CI / E2E Tests (push) Failing after 28m40s
CI / Backend Unit Tests (push) Successful in 2m12s
CI / Unit & Component Tests (push) Successful in 2m28s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 17:47:08 +01:00
Marcel
150bc2f171 feat(dropzone): replace upload icon with multi-file icon and clearer hint text
Some checks failed
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Unit & Component Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Successful in 2m22s
CI / Backend Unit Tests (pull_request) Successful in 2m4s
CI / E2E Tests (pull_request) Failing after 26m49s
Swaps the generic upload arrow for Display-Pages-MD (stack of pages) and
shortens the hint text to convey that multiple files are welcome at a glance.

Closes #79
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 17:32:15 +01:00
10 changed files with 183 additions and 31 deletions

View File

@@ -268,7 +268,7 @@
"doc_panel_discussion_annotation_tab": "Annotation · Seite {page}",
"pdf_annotations_show": "Annotierungen anzeigen",
"pdf_annotations_hide": "Annotierungen verbergen",
"upload_drop_hint": "Dateien ablegen oder auswählen",
"upload_drop_hint": "Einzeln oder mehrere Dateien auf einmal hochladen",
"upload_accepted_types": "PDF, JPEG, PNG, TIFF",
"upload_filename_hint": "Tipp: 2024-03-15_Mueller_Hans.pdf → Datum und Absender werden vorausgefüllt",
"upload_success": "{count} Dokument(e) erstellt",
@@ -291,5 +291,7 @@
"enrich_skip": "Überspringen",
"enrich_done_heading": "Alles erledigt!",
"enrich_done_body": "Alle Dokumente wurden bearbeitet.",
"enrich_back_to_list": "Zurück zur Liste"
"enrich_back_to_list": "Zurück zur Liste",
"comment_empty_hint": "Noch keine Kommentare starte die Diskussion!",
"comment_start_discussion": "Diskussion starten →"
}

View File

@@ -268,7 +268,7 @@
"doc_panel_discussion_annotation_tab": "Annotation · Page {page}",
"pdf_annotations_show": "Show annotations",
"pdf_annotations_hide": "Hide annotations",
"upload_drop_hint": "Drop files or click to select",
"upload_drop_hint": "Drop one or multiple files at once",
"upload_accepted_types": "PDF, JPEG, PNG, TIFF",
"upload_filename_hint": "Tip: 2024-03-15_Mueller_Hans.pdf → date and sender pre-filled",
"upload_success": "{count} document(s) created",
@@ -291,5 +291,7 @@
"enrich_skip": "Skip",
"enrich_done_heading": "All done!",
"enrich_done_body": "All documents have been processed.",
"enrich_back_to_list": "Back to list"
"enrich_back_to_list": "Back to list",
"comment_empty_hint": "No comments yet start the discussion!",
"comment_start_discussion": "Start discussion →"
}

View File

@@ -268,7 +268,7 @@
"doc_panel_discussion_annotation_tab": "Anotación · Página {page}",
"pdf_annotations_show": "Mostrar anotaciones",
"pdf_annotations_hide": "Ocultar anotaciones",
"upload_drop_hint": "Soltar archivos o hacer clic para seleccionar",
"upload_drop_hint": "Uno o varios archivos a la vez",
"upload_accepted_types": "PDF, JPEG, PNG, TIFF",
"upload_filename_hint": "Consejo: 2024-03-15_Mueller_Hans.pdf → fecha y remitente prellenados",
"upload_success": "{count} documento(s) creado(s)",
@@ -291,5 +291,7 @@
"enrich_skip": "Omitir",
"enrich_done_heading": "¡Todo listo!",
"enrich_done_body": "Todos los documentos han sido procesados.",
"enrich_back_to_list": "Volver a la lista"
"enrich_back_to_list": "Volver a la lista",
"comment_empty_hint": "Aún no hay comentarios ¡inicia la discusión!",
"comment_start_discussion": "Iniciar discusión →"
}

View File

@@ -167,6 +167,9 @@ function cancelReply() {
onMount(() => {
if (loadOnMount) {
reload();
} else {
const total = initialComments.reduce((s, c) => s + 1 + c.replies.length, 0);
onCountChange?.(total);
}
});
</script>
@@ -245,6 +248,24 @@ onMount(() => {
{/snippet}
<div class="space-y-4">
{#if comments.length === 0}
<div class="flex flex-col items-center gap-3 py-8 text-center">
<svg
class="h-10 w-10 text-ink-3"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M2.25 12.76c0 1.6 1.123 2.994 2.707 3.227 1.087.16 2.185.283 3.293.369V21l4.076-4.076a1.526 1.526 0 0 1 1.037-.443 48.282 48.282 0 0 0 5.68-.494c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z"
/>
</svg>
<p class="font-sans text-sm text-ink-3">{m.comment_empty_hint()}</p>
</div>
{/if}
{#each comments as thread, ti (thread.id)}
<div class={ti > 0 ? 'border-t border-line pt-4' : ''}>
<!-- Root comment -->

View File

@@ -0,0 +1,70 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import CommentThread from './CommentThread.svelte';
import type { Comment } from '$lib/types';
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
function makeComment(id: string, content = 'Hello'): Comment {
return {
id,
authorId: 'user-1',
authorName: 'Alice',
content,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
replies: []
};
}
const baseProps = {
documentId: 'doc-1',
canComment: true,
currentUserId: 'user-1',
canAdmin: false
};
describe('CommentThread empty state', () => {
it('shows empty state hint when there are no comments', async () => {
render(CommentThread, { ...baseProps, initialComments: [] });
await expect
.element(page.getByText('Noch keine Kommentare starte die Diskussion!'))
.toBeInTheDocument();
});
it('does not show empty state hint when comments exist', async () => {
render(CommentThread, { ...baseProps, initialComments: [makeComment('c-1')] });
await expect
.element(page.getByText('Noch keine Kommentare starte die Diskussion!'))
.not.toBeInTheDocument();
});
});
describe('CommentThread onCountChange', () => {
it('calls onCountChange with initial SSR count on mount', async () => {
const onCountChange = vi.fn();
render(CommentThread, {
...baseProps,
initialComments: [makeComment('c-1'), makeComment('c-2')],
onCountChange
});
expect(onCountChange).toHaveBeenCalledWith(2);
});
it('calls onCountChange with 0 when no initial comments', async () => {
const onCountChange = vi.fn();
render(CommentThread, { ...baseProps, initialComments: [], onCountChange });
expect(onCountChange).toHaveBeenCalledWith(0);
});
it('counts replies in the total', async () => {
const onCountChange = vi.fn();
const comment = { ...makeComment('c-1'), replies: [makeComment('r-1') as never] };
render(CommentThread, { ...baseProps, initialComments: [comment], onCountChange });
expect(onCountChange).toHaveBeenCalledWith(2);
});
});

View File

@@ -98,6 +98,12 @@ const tabs: { id: DocumentPanelTab; label: () => string }[] = [
];
const panelHeight = $derived(open ? height : MIN_HEIGHT);
let discussionCount = $state((() => comments.reduce((s, c) => s + 1 + c.replies.length, 0))());
function handleCountChange(count: number) {
discussionCount = count;
}
</script>
<div
@@ -131,6 +137,13 @@ const panelHeight = $derived(open ? height : MIN_HEIGHT);
aria-pressed={activeTab === tab.id && open}
>
{tab.label()}
{#if tab.id === 'discussion'}
<span
data-testid="discussion-count-badge"
class="ml-1.5 inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 font-sans text-[10px] font-bold text-primary-fg"
>{discussionCount}</span
>
{/if}
</button>
{/each}
@@ -165,6 +178,7 @@ const panelHeight = $derived(open ? height : MIN_HEIGHT);
canComment={canComment}
currentUserId={currentUserId}
canAdmin={canAdmin}
onCountChange={handleCountChange}
/>
{:else if activeTab === 'history'}
<PanelHistory documentId={doc.id} />

View File

@@ -0,0 +1,47 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import DocumentBottomPanel from './DocumentBottomPanel.svelte';
import type { Comment } from '$lib/types';
afterEach(cleanup);
function makeComment(id: string): Comment {
return {
id,
authorId: 'user-1',
authorName: 'Alice',
content: 'Hello',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
replies: []
};
}
const doc = { id: 'doc-1', title: 'Test' };
const baseProps = {
doc,
canComment: true,
currentUserId: 'user-1',
canAdmin: false,
height: 300,
activeTab: 'discussion' as const
};
describe('DocumentBottomPanel discussion badge', () => {
it('always shows a badge on the Discussion tab', async () => {
render(DocumentBottomPanel, { ...baseProps, comments: [], open: true });
await expect.element(page.getByTestId('discussion-count-badge')).toBeInTheDocument();
await expect.element(page.getByTestId('discussion-count-badge')).toHaveTextContent('0');
});
it('shows the correct count when comments exist', async () => {
render(DocumentBottomPanel, {
...baseProps,
comments: [makeComment('c-1'), makeComment('c-2')],
open: true
});
await expect.element(page.getByTestId('discussion-count-badge')).toHaveTextContent('2');
});
});

View File

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

View File

@@ -90,7 +90,7 @@ $effect(() => {
{#if data.incompleteCount > 0}
<a
href="/enrich"
class="mb-6 flex items-center justify-between rounded-sm border border-brand-mint/40 bg-brand-mint/10 px-6 py-4 transition-colors hover:bg-brand-mint/20"
class="mb-6 flex items-center justify-between rounded-sm border border-accent/40 bg-accent-bg px-6 py-4 transition-colors hover:bg-accent/20"
>
<div class="flex items-center gap-4">
<img
@@ -100,16 +100,16 @@ $effect(() => {
class="h-6 w-6 opacity-60"
/>
<div>
<p class="font-sans text-xs font-bold tracking-widest text-brand-navy uppercase">
<p class="font-sans text-xs font-bold tracking-widest text-ink uppercase">
{m.enrich_needs_metadata_title()}
</p>
<p class="mt-0.5 font-serif text-sm text-brand-navy/70">
<p class="mt-0.5 font-serif text-sm text-ink-2">
{m.enrich_needs_metadata_count({ count: data.incompleteCount })}
</p>
</div>
</div>
<span
class="font-sans text-xs font-bold tracking-widest text-brand-navy uppercase transition-colors hover:text-brand-navy/70"
class="font-sans text-xs font-bold tracking-widest text-ink uppercase transition-colors hover:text-ink-2"
>
{m.enrich_needs_metadata_cta()}
</span>

View File

@@ -142,32 +142,17 @@ $effect(() => {
<div
role="button"
tabindex="0"
class="mb-4 flex cursor-pointer items-center justify-center gap-3 border border-dashed px-6 text-sm transition-all duration-200 {isDragging
class="mb-4 flex cursor-pointer flex-col items-center justify-center gap-2 border border-dashed px-6 transition-all duration-200 {isDragging
? 'border-primary bg-accent-bg py-10 text-primary'
: windowDragging
? 'border-primary/60 bg-accent-bg/50 py-10 text-primary/80'
: 'border-ink/20 py-3 text-ink-3 hover:border-primary hover:text-primary'}"
: 'border-ink/20 py-6 text-ink-3 hover:border-primary hover:text-primary'}"
ondragover={handleDragOver}
ondragleave={handleDragLeave}
ondrop={handleDrop}
onclick={() => fileInput.click()}
onkeydown={(e) => e.key === 'Enter' && fileInput.click()}
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4 shrink-0 opacity-50"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
{#if isUploading}
<div class="flex w-48 flex-col items-center gap-1">
<div class="h-1.5 w-full overflow-hidden rounded-full bg-ink/10">
@@ -179,10 +164,16 @@ $effect(() => {
<span class="font-sans text-xs text-ink-3">{uploadProgress}%</span>
</div>
{:else}
<div class="flex flex-col items-center gap-0.5">
<span class="font-sans font-medium">{m.upload_drop_hint()}</span>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Copy-Item-MD.svg"
alt=""
aria-hidden="true"
class="h-8 w-8 opacity-40"
/>
<div class="flex flex-col items-center gap-0.5 text-center">
<span class="font-sans text-sm text-ink-2">{m.upload_drop_hint()}</span>
<span class="font-sans text-xs text-ink-3">{m.upload_accepted_types()}</span>
<span class="font-sans text-xs text-ink-3/70 italic">{m.upload_filename_hint()}</span>
<span class="font-sans text-xs text-ink-3 italic">{m.upload_filename_hint()}</span>
</div>
{/if}
</div>