refactor(comments): flat compact comment thread matching spec design
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) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
CI / E2E Tests (pull_request) Has been cancelled

Rework CommentThread.svelte to match the annotation-transcription spec:
- Flat message list (no nested reply threading)
- Compact inline style: orange left border, tinted background
- Chat bubble icon (💬) with comment count header
- Avatar circles with author initials
- Quoted text extracted and rendered as italic left-bordered snippet
- Simple MentionEditor input at bottom (keeps @mention support)
- Removed: reply-to-specific threading, edit/delete buttons, nesting

Remove dead components no longer used after annotate mode removal:
- AnnotationCommentPanel, AnnotationSidePanel, AnnotateHintStrip
- PanelDiscussion, PanelHistory, PanelMetadata, PanelTranscription
- Associated spec files

Simplify prop chain: remove currentUserId, canAdmin, targetCommentId
from CommentThread, TranscriptionBlock, TranscriptionEditView.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-05 22:18:24 +02:00
parent 193bd73af1
commit f09b605752
14 changed files with 104 additions and 1361 deletions

View File

@@ -1,22 +0,0 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
type Props = {
annotateMode: boolean;
};
let { annotateMode }: Props = $props();
</script>
{#if annotateMode}
<div
data-testid="annotate-hint-strip"
class="hidden h-[29px] items-center gap-2 border-t border-dashed px-3.5 md:flex"
style="background: rgba(1,40,81,0.05); border-color: rgba(1,40,81,0.20)"
>
<span class="text-[16px] font-bold tracking-wide text-primary uppercase"
>{m.doc_panel_annotate()}</span
>
<span class="text-[16px] text-ink-2">{m.doc_panel_annotate_hint()}</span>
</div>
{/if}

View File

@@ -1,27 +0,0 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import AnnotateHintStrip from './AnnotateHintStrip.svelte';
afterEach(cleanup);
describe('AnnotateHintStrip', () => {
it('is absent from the DOM when annotateMode is false', async () => {
render(AnnotateHintStrip, { annotateMode: false });
const strip = page.getByTestId('annotate-hint-strip');
await expect.element(strip).not.toBeInTheDocument();
});
it('is present in the DOM when annotateMode is true', async () => {
render(AnnotateHintStrip, { annotateMode: true });
const strip = page.getByTestId('annotate-hint-strip');
await expect.element(strip).toBeInTheDocument();
});
it('has hidden md:flex class to hide below 768px', async () => {
render(AnnotateHintStrip, { annotateMode: true });
const strip = page.getByTestId('annotate-hint-strip');
await expect.element(strip).toHaveClass('hidden');
await expect.element(strip).toHaveClass('md:flex');
});
});

View File

@@ -1,90 +0,0 @@
<script lang="ts">
import CommentThread from './CommentThread.svelte';
import { m } from '$lib/paraglide/messages.js';
type Props = {
documentId: string;
annotationId: string;
canComment: boolean;
currentUserId: string | null;
canAdmin: boolean;
onClose: () => void;
onCountChange?: (count: number) => void;
};
let {
documentId,
annotationId,
canComment,
currentUserId,
canAdmin,
onClose,
onCountChange
}: Props = $props();
</script>
<!-- Desktop / tablet panel (≥ sm): absolute overlay on the right side -->
<div
class="absolute top-0 right-0 z-50 hidden h-full w-80 flex-col border-l border-line bg-surface shadow-2xl sm:flex"
>
<div class="flex shrink-0 items-center justify-between border-b border-line px-4 py-3">
<h3 class="font-sans text-xs font-bold tracking-widest text-ink uppercase">
{m.comment_panel_title()}
</h3>
<button
onclick={onClose}
aria-label={m.comment_panel_close()}
class="rounded p-1 text-ink-3 transition-colors hover:bg-muted hover:text-ink"
>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="flex-1 overflow-y-auto p-4">
<CommentThread
documentId={documentId}
annotationId={annotationId}
canComment={canComment}
currentUserId={currentUserId}
canAdmin={canAdmin}
loadOnMount={true}
onCountChange={onCountChange}
/>
</div>
</div>
<!-- Mobile modal (< sm): fixed full-screen with slide-up sheet -->
<div class="fixed inset-0 z-50 flex flex-col sm:hidden">
<!-- Semi-transparent backdrop -->
<div class="flex-1 bg-black/40" onclick={onClose} role="presentation"></div>
<!-- Slide-up panel -->
<div class="flex max-h-[80vh] flex-col rounded-t-2xl bg-surface shadow-2xl">
<div class="flex shrink-0 items-center justify-between border-b border-line px-4 py-3">
<h3 class="font-sans text-xs font-bold tracking-widest text-ink uppercase">
{m.comment_panel_title()}
</h3>
<button
onclick={onClose}
aria-label={m.comment_panel_close()}
class="rounded p-1 text-ink-3 transition-colors hover:bg-muted hover:text-ink"
>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="flex-1 overflow-y-auto p-4">
<CommentThread
documentId={documentId}
annotationId={annotationId}
canComment={canComment}
currentUserId={currentUserId}
canAdmin={canAdmin}
loadOnMount={true}
onCountChange={onCountChange}
/>
</div>
</div>
</div>

View File

@@ -1,68 +0,0 @@
<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;
targetCommentId?: string | null;
onClose: () => void;
};
let {
documentId,
activeAnnotationId,
activeAnnotationPage,
canComment,
currentUserId,
canAdmin,
targetCommentId = null,
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-line bg-surface 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-line px-4 py-3">
<span class="font-sans text-xs font-medium text-ink">
{m.doc_panel_discussion_annotation_tab({ page: String(activeAnnotationPage ?? '?') })}
</span>
<button
onclick={onClose}
aria-label={m.comment_panel_close()}
class="rounded p-1 text-ink-3 transition-colors hover:bg-muted hover:text-ink"
>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</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}
targetCommentId={targetCommentId}
loadOnMount={true}
/>
{/key}
{/if}
</div>
</div>

View File

@@ -1,76 +0,0 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import AnnotationSidePanel from './AnnotationSidePanel.svelte';
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => []
})
);
const baseProps = {
documentId: 'doc-1',
activeAnnotationPage: 1,
canComment: true,
currentUserId: 'user-1',
canAdmin: false,
onClose: vi.fn()
};
describe('AnnotationSidePanel visibility', () => {
it('is hidden (translated off-screen) when activeAnnotationId is null', async () => {
render(AnnotationSidePanel, { ...baseProps, activeAnnotationId: null });
const panel = document.querySelector('[data-testid="annotation-side-panel"]');
expect(panel?.classList.contains('translate-x-full')).toBe(true);
expect(panel?.classList.contains('translate-x-0')).toBe(false);
});
it('is visible when activeAnnotationId is set', async () => {
render(AnnotationSidePanel, { ...baseProps, activeAnnotationId: 'ann-1' });
const panel = document.querySelector('[data-testid="annotation-side-panel"]');
expect(panel?.classList.contains('translate-x-0')).toBe(true);
expect(panel?.classList.contains('translate-x-full')).toBe(false);
});
});
describe('AnnotationSidePanel close button', () => {
it('calls onClose when the close button is clicked', async () => {
const onClose = vi.fn();
render(AnnotationSidePanel, { ...baseProps, activeAnnotationId: 'ann-1', onClose });
await page.getByRole('button', { name: /schließen/i }).click();
expect(onClose).toHaveBeenCalledOnce();
});
});
describe('AnnotationSidePanel targetCommentId forwarding', () => {
it('renders CommentThread when annotation is active', async () => {
render(AnnotationSidePanel, {
...baseProps,
activeAnnotationId: 'ann-1',
targetCommentId: 'comment-42'
});
// CommentThread renders inside the panel when activeAnnotationId is set
const panel = document.querySelector('[data-testid="annotation-side-panel"]');
expect(panel).not.toBeNull();
expect(panel?.classList.contains('translate-x-0')).toBe(true);
});
it('does not render CommentThread when annotation is null', async () => {
render(AnnotationSidePanel, {
...baseProps,
activeAnnotationId: null,
targetCommentId: 'comment-42'
});
// Panel is hidden and no fetch should have been triggered for comments
const panel = document.querySelector('[data-testid="annotation-side-panel"]');
expect(panel?.classList.contains('translate-x-full')).toBe(true);
});
});

View File

@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onMount, tick, 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, CommentReply } from '$lib/types'; import type { Comment } 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 { renderBody, extractContent } from '$lib/utils/mention';
import type { MentionDTO } from '$lib/types'; import type { MentionDTO } from '$lib/types';
@@ -13,9 +13,6 @@ type Props = {
initialComments?: Comment[]; initialComments?: Comment[];
loadOnMount?: boolean; loadOnMount?: boolean;
canComment: boolean; canComment: boolean;
currentUserId: string | null;
canAdmin: boolean;
targetCommentId?: string | null;
quotedText?: string | null; quotedText?: string | null;
showCompose?: boolean; showCompose?: boolean;
onCountChange?: (count: number) => void; onCountChange?: (count: number) => void;
@@ -28,25 +25,25 @@ let {
initialComments = [], initialComments = [],
loadOnMount = false, loadOnMount = false,
canComment, canComment,
currentUserId,
canAdmin,
targetCommentId = null,
quotedText = null, quotedText = null,
showCompose = true, showCompose = true,
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 highlightedCommentId: string | null = $state(untrack(() => targetCommentId ?? null));
let newText: string = $state(''); let newText: string = $state('');
let replyingTo: string | null = $state(null);
let replyText: string = $state('');
let editingId: string | null = $state(null);
let editText: string = $state('');
let posting: boolean = $state(false); let posting: boolean = $state(false);
let newMentionCandidates: MentionDTO[] = $state([]); let newMentionCandidates: MentionDTO[] = $state([]);
let replyMentionCandidates: MentionDTO[] = $state([]);
let editMentionCandidates: MentionDTO[] = $state([]);
const commentsBase = $derived( const commentsBase = $derived(
blockId blockId
@@ -56,7 +53,10 @@ const commentsBase = $derived(
: `/api/documents/${documentId}/comments` : `/api/documents/${documentId}/comments`
); );
// Pre-fill comment box with quoted text when selection changes const flatMessages = $derived(
comments.flatMap((thread) => [thread as FlatMessage, ...(thread.replies as FlatMessage[])])
);
$effect(() => { $effect(() => {
if (quotedText && quotedText.trim()) { if (quotedText && quotedText.trim()) {
newText = `> "${quotedText}"\n\n`; newText = `> "${quotedText}"\n\n`;
@@ -78,8 +78,18 @@ function wasEdited(c: { createdAt: string; updatedAt: string }): boolean {
return c.updatedAt > c.createdAt; return c.updatedAt > c.createdAt;
} }
function canModify(c: { authorId: string | null }): boolean { function getInitials(name: string): string {
return (currentUserId != null && c.authorId === currentUserId) || canAdmin; 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() {
@@ -116,270 +126,94 @@ async function postComment() {
} }
} }
async function postReply(threadId: string) { onMount(() => {
const text = replyText.trim();
if (!text || posting) return;
posting = true;
try {
const { content, mentionedUserIds } = extractContent(text, replyMentionCandidates);
const res = await fetch(`${commentsBase}/${threadId}/replies`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content, mentionedUserIds })
});
if (res.ok) {
replyText = '';
replyMentionCandidates = [];
replyingTo = null;
await reload();
}
} finally {
posting = false;
}
}
async function saveEdit(commentId: string) {
const text = editText.trim();
if (!text || posting) return;
posting = true;
try {
const { content, mentionedUserIds } = extractContent(text, editMentionCandidates);
const res = await fetch(`/api/documents/${documentId}/comments/${commentId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content, mentionedUserIds })
});
if (res.ok) {
editingId = null;
editMentionCandidates = [];
await reload();
}
} finally {
posting = false;
}
}
async function deleteComment(commentId: string) {
if (posting) return;
posting = true;
try {
const res = await fetch(`/api/documents/${documentId}/comments/${commentId}`, {
method: 'DELETE'
});
if (res.ok) {
await reload();
}
} finally {
posting = false;
}
}
function startEdit(comment: Comment | CommentReply) {
editingId = comment.id;
editText = comment.content;
editMentionCandidates = [];
}
function cancelEdit() {
editingId = null;
editText = '';
}
function startReply(threadId: string) {
replyingTo = threadId;
replyText = '';
}
function cancelReply() {
replyingTo = null;
replyText = '';
}
onMount(async () => {
if (loadOnMount) { if (loadOnMount) {
reload(); reload();
} else { } else {
const total = initialComments.reduce((s, c) => s + 1 + c.replies.length, 0); const total = initialComments.reduce((s, c) => s + 1 + c.replies.length, 0);
onCountChange?.(total); onCountChange?.(total);
} }
if (targetCommentId) {
await tick();
requestAnimationFrame(() => {
const el = document.querySelector(`[data-comment-id="${targetCommentId}"]`);
el?.scrollIntoView({ behavior: 'smooth', block: 'center' });
});
// Remove highlight on first user interaction
const clearHighlight = () => {
highlightedCommentId = null;
document.removeEventListener('click', clearHighlight, true);
document.removeEventListener('keydown', clearHighlight, true);
document.removeEventListener('scroll', clearHighlight, true);
};
document.addEventListener('click', clearHighlight, true);
document.addEventListener('keydown', clearHighlight, true);
document.addEventListener('scroll', clearHighlight, true);
}
}); });
</script> </script>
<!-- {#if flatMessages.length > 0}
Renders a single comment or reply entry. <div
showReplyButton: whether the "Reply" button appears (only on last item in a thread). class="rounded border-l-2 border-orange-400 bg-orange-50 p-2 dark:border-orange-600 dark:bg-orange-950/20"
--> >
{#snippet commentEntry(comment: Comment | CommentReply, threadId: string, showReplyButton: boolean)} <div
{#if editingId === comment.id} class="mb-2 flex items-center gap-1.5 font-sans text-xs font-semibold text-orange-700 dark:text-orange-400"
<div class="flex flex-col gap-2"> >
<MentionEditor <svg
bind:value={editText} class="h-3.5 w-3.5"
bind:mentionCandidates={editMentionCandidates} viewBox="0 0 24 24"
rows={3} fill="none"
disabled={posting} stroke="currentColor"
onsubmit={() => saveEdit(comment.id)} stroke-width="2"
/>
<div class="flex items-center gap-3">
<button
class="rounded bg-primary px-3 py-1.5 font-sans text-xs font-medium text-primary-fg hover:bg-primary/80 disabled:opacity-40"
disabled={posting}
onclick={() => saveEdit(comment.id)}
>
{m.btn_save()}
</button>
<button
class="font-sans text-xs text-ink-3 transition-colors hover:text-ink"
onclick={cancelEdit}
>
{m.btn_cancel()}
</button>
</div>
</div>
{:else}
<div class="flex items-start justify-between gap-2">
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<span class="font-sans text-xs font-semibold text-ink">{comment.authorName}</span>
{#if wasEdited(comment)}
<span class="font-sans text-xs text-ink-3"
>{timeAgo(comment.updatedAt)} {m.comment_edited_label()}</span
>
{:else}
<span class="font-sans text-xs text-ink-3">{timeAgo(comment.createdAt)}</span>
{/if}
</div>
<p class="mt-1 font-serif text-sm leading-relaxed text-ink-2">
<!-- eslint-disable-next-line svelte/no-at-html-tags -- renderBody escapes all HTML before injecting mention links -->
{@html renderBody(comment.content, comment.mentionDTOs ?? [])}
</p>
</div>
{#if canModify(comment)}
<div class="flex shrink-0 items-center gap-2">
<button
class="font-sans text-xs text-ink-3 transition-colors hover:text-ink"
onclick={() => startEdit(comment)}
>
{m.btn_edit()}
</button>
<button
class="font-sans text-xs text-ink-3 transition-colors hover:text-ink"
onclick={() => deleteComment(comment.id)}
>
{m.btn_delete()}
</button>
</div>
{/if}
</div>
{#if showReplyButton && canComment}
<div class="mt-1">
<button
class="font-sans text-xs font-medium text-primary transition-colors hover:text-ink-2"
onclick={() => startReply(threadId)}
>
{m.comment_btn_reply()}
</button>
</div>
{/if}
{/if}
{/snippet}
<div class="space-y-4">
{#each comments as thread, ti (thread.id)}
<div class={ti > 0 ? 'border-t border-line pt-4' : ''}>
<!-- Root comment -->
<div
data-comment-id={thread.id}
class={highlightedCommentId === thread.id
? 'rounded outline-2 outline-offset-1 outline-accent transition-shadow outline-dotted'
: ''}
> >
{@render commentEntry(thread, thread.id, thread.replies.length === 0)} <path
</div> 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 011.037-.443 48.282 48.282 0 005.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 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z"
/>
</svg>
{flatMessages.length}
{flatMessages.length === 1 ? 'Kommentar' : 'Kommentare'}
</div>
<!-- Replies --> <div class="space-y-2">
{#each thread.replies as reply, ri (reply.id)} {#each flatMessages as msg (msg.id)}
<div {@const parsed = extractQuote(msg.content)}
data-comment-id={reply.id} <div class="flex gap-2">
class="mt-3 ml-6 border-l-2 border-line pl-4 {highlightedCommentId === reply.id <div
? 'rounded outline-2 outline-offset-1 outline-accent transition-shadow outline-dotted' class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary text-[10px] font-bold text-primary-fg"
: ''}" >
> {getInitials(msg.authorName)}
{@render commentEntry(reply, thread.id, ri === thread.replies.length - 1)} </div>
</div> <div class="min-w-0 flex-1">
{/each} <div class="flex items-center gap-1.5">
<span class="font-sans text-xs font-semibold text-ink">{msg.authorName}</span>
<!-- Reply compose box --> {#if wasEdited(msg)}
{#if replyingTo === thread.id} <span class="font-sans text-[10px] text-ink-3"
<div class="mt-3 ml-6 flex flex-col gap-2"> >{timeAgo(msg.updatedAt)} {m.comment_edited_label()}</span
<MentionEditor >
bind:value={replyText} {:else}
bind:mentionCandidates={replyMentionCandidates} <span class="font-sans text-[10px] text-ink-3">{timeAgo(msg.createdAt)}</span>
rows={3} {/if}
placeholder={m.comment_placeholder()} </div>
disabled={posting} {#if parsed.quote}
onsubmit={() => postReply(thread.id)} <div class="my-1 border-l-2 border-line pl-2 font-serif text-xs text-ink-3 italic">
/> &ldquo;{parsed.quote}&rdquo;
<div class="flex items-center gap-3"> </div>
<button {/if}
class="rounded bg-primary px-3 py-1.5 font-sans text-xs font-medium text-primary-fg hover:bg-primary/80 disabled:opacity-40" <p class="font-serif text-xs leading-relaxed text-ink-2">
disabled={posting} <!-- eslint-disable-next-line svelte/no-at-html-tags -- renderBody escapes all HTML before injecting mention links -->
onclick={() => postReply(thread.id)} {@html renderBody(parsed.body, msg.mentionDTOs ?? [])}
> </p>
{m.comment_btn_post()}
</button>
<button
class="font-sans text-xs text-ink-3 transition-colors hover:text-ink"
onclick={cancelReply}
>
{m.btn_cancel()}
</button>
</div> </div>
</div> </div>
{/if} {/each}
</div> </div>
{/each} </div>
{/if}
<!-- New top-level comment --> {#if canComment && showCompose}
{#if canComment && showCompose} <div class="mt-2 flex gap-2">
<div class={comments.length > 0 ? 'border-t border-line pt-4' : ''}> <div class="flex-1">
<div class="flex flex-col gap-2"> <MentionEditor
<MentionEditor bind:value={newText}
bind:value={newText} bind:mentionCandidates={newMentionCandidates}
bind:mentionCandidates={newMentionCandidates} rows={1}
rows={3} placeholder={m.comment_placeholder()}
placeholder={m.comment_placeholder()} disabled={posting}
disabled={posting} onsubmit={postComment}
onsubmit={postComment} />
/>
<div>
<button
class="rounded bg-primary px-3 py-1.5 font-sans text-xs font-medium text-primary-fg hover:bg-primary/80 disabled:opacity-40"
disabled={posting || !newText.trim()}
onclick={postComment}
>
{m.comment_btn_post()}
</button>
</div>
</div>
</div> </div>
{/if} <button
</div> class="self-end rounded bg-primary px-2.5 py-1.5 font-sans text-xs font-medium text-primary-fg hover:bg-primary/80 disabled:opacity-40"
disabled={posting || !newText.trim()}
onclick={postComment}
>
{m.comment_btn_post()}
</button>
</div>
{/if}

View File

@@ -1,36 +0,0 @@
<script lang="ts">
import CommentThread from './CommentThread.svelte';
import type { Comment } from '$lib/types';
type Props = {
documentId: string;
initialComments: Comment[];
canComment: boolean;
currentUserId: string | null;
canAdmin: boolean;
targetCommentId?: string | null;
onCountChange?: (count: number) => void;
};
let {
documentId,
initialComments,
canComment,
currentUserId,
canAdmin,
targetCommentId = null,
onCountChange
}: Props = $props();
</script>
<div class="flex-1 overflow-y-auto p-6">
<CommentThread
documentId={documentId}
initialComments={initialComments}
canComment={canComment}
currentUserId={currentUserId}
canAdmin={canAdmin}
targetCommentId={targetCommentId}
onCountChange={onCountChange}
/>
</div>

View File

@@ -1,519 +0,0 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { diffWords } from 'diff';
let { documentId }: { documentId: string } = $props();
type VersionSummary = {
id: string;
savedAt: string;
editorName: string;
changedFields: string[];
};
type SnapshotDoc = {
title?: string;
documentDate?: string;
location?: string;
documentLocation?: string;
transcription?: string;
summary?: string;
sender?: { id: string; firstName: string; lastName: string } | null;
receivers?: { id: string; firstName: string; lastName: string }[];
tags?: { id: string; name: string }[];
};
type DiffEntry =
| {
kind: 'text';
field: string;
label: string;
parts: { value: string; added?: boolean; removed?: boolean }[];
}
| { kind: 'scalar'; field: string; label: string; oldVal: string; newVal: string }
| { kind: 'relation'; field: string; label: string; removed: string[]; added: string[] };
let historyLoaded = $state(false);
let historyLoading = $state(false);
let versions = $state<VersionSummary[]>([]);
let compareMode = $state(false);
let compareA = $state('');
let compareB = $state('');
let selectedVersionId = $state<string | null>(null);
let diffEntries = $state<DiffEntry[]>([]);
let diffLoading = $state(false);
let noDiff = $state(false);
const fieldLabels: Record<string, () => string> = {
title: m.history_field_title,
documentDate: m.history_field_document_date,
location: m.history_field_location,
documentLocation: m.history_field_document_location,
transcription: m.history_field_transcription,
summary: m.history_field_summary,
sender: m.history_field_sender,
receivers: m.history_field_receivers,
tags: m.history_field_tags
};
const TEXT_FIELDS = ['title', 'summary', 'transcription'] as const;
const SCALAR_FIELDS = ['documentDate', 'location', 'documentLocation'] as const;
function parseSnapshot(raw: string): SnapshotDoc {
try {
return JSON.parse(raw) as SnapshotDoc;
} catch {
return {};
}
}
function personLabel(p: { firstName: string; lastName: string }): string {
return `${p.firstName} ${p.lastName}`.trim();
}
const DIFF_CONTEXT_WORDS = 4;
type DiffPart = { value: string; added?: boolean; removed?: boolean };
function trimContextParts(parts: DiffPart[]): DiffPart[] {
return parts.flatMap((part, i) => {
if (part.added || part.removed) return [part];
const tokens = part.value.split(/(\s+)/).filter(Boolean);
const wordCount = tokens.filter((t) => /\S/.test(t)).length;
if (wordCount <= DIFF_CONTEXT_WORDS * 2) return [part];
function keepFirst(n: number): string {
let count = 0;
const out: string[] = [];
for (const t of tokens) {
out.push(t);
if (/\S/.test(t) && ++count >= n) break;
}
return out.join('');
}
function keepLast(n: number): string {
let count = 0;
const out: string[] = [];
for (const t of [...tokens].reverse()) {
out.unshift(t);
if (/\S/.test(t) && ++count >= n) break;
}
return out.join('');
}
const isFirst = i === 0;
const isLast = i === parts.length - 1;
if (isFirst) return [{ value: '… ' + keepLast(DIFF_CONTEXT_WORDS) }];
if (isLast) return [{ value: keepFirst(DIFF_CONTEXT_WORDS) + ' …' }];
return [{ value: keepFirst(DIFF_CONTEXT_WORDS) + ' … ' + keepLast(DIFF_CONTEXT_WORDS) }];
});
}
function buildDiff(older: SnapshotDoc | null, newer: SnapshotDoc): DiffEntry[] {
const entries: DiffEntry[] = [];
for (const field of TEXT_FIELDS) {
const a = older?.[field] ?? '';
const b = newer[field] ?? '';
if (a === b) continue;
const parts = trimContextParts(diffWords(a, b));
entries.push({ kind: 'text', field, label: fieldLabels[field](), parts });
}
for (const field of SCALAR_FIELDS) {
const a = older?.[field] ?? '';
const b = newer[field] ?? '';
if (a === b) continue;
entries.push({ kind: 'scalar', field, label: fieldLabels[field](), oldVal: a, newVal: b });
}
const senderA = older?.sender ? personLabel(older.sender) : '';
const senderB = newer.sender ? personLabel(newer.sender) : '';
if (senderA !== senderB) {
entries.push({
kind: 'relation',
field: 'sender',
label: fieldLabels['sender'](),
removed: senderA ? [senderA] : [],
added: senderB ? [senderB] : []
});
}
const receiversA = new Set((older?.receivers ?? []).map(personLabel));
const receiversB = new Set((newer.receivers ?? []).map(personLabel));
const removedReceivers = [...receiversA].filter((r) => !receiversB.has(r));
const addedReceivers = [...receiversB].filter((r) => !receiversA.has(r));
if (removedReceivers.length > 0 || addedReceivers.length > 0) {
entries.push({
kind: 'relation',
field: 'receivers',
label: fieldLabels['receivers'](),
removed: removedReceivers,
added: addedReceivers
});
}
const tagsA = new Set((older?.tags ?? []).map((t) => t.name));
const tagsB = new Set((newer.tags ?? []).map((t) => t.name));
const removedTags = [...tagsA].filter((t) => !tagsB.has(t));
const addedTags = [...tagsB].filter((t) => !tagsA.has(t));
if (removedTags.length > 0 || addedTags.length > 0) {
entries.push({
kind: 'relation',
field: 'tags',
label: fieldLabels['tags'](),
removed: removedTags,
added: addedTags
});
}
return entries;
}
async function fetchSnapshot(versionId: string): Promise<SnapshotDoc> {
const res = await fetch(`/api/documents/${documentId}/versions/${versionId}`);
if (!res.ok) throw new Error('Failed to fetch version');
const v = await res.json();
return parseSnapshot(v.snapshot);
}
async function loadHistory() {
if (historyLoaded) return;
historyLoading = true;
try {
const res = await fetch(`/api/documents/${documentId}/versions`);
if (res.ok) {
versions = await res.json();
}
historyLoaded = true;
} catch {
// ignore
} finally {
historyLoading = false;
}
}
async function selectVersion(versionId: string) {
if (selectedVersionId === versionId) {
selectedVersionId = null;
diffEntries = [];
noDiff = false;
return;
}
selectedVersionId = versionId;
diffEntries = [];
noDiff = false;
diffLoading = true;
try {
const idx = versions.findIndex((v) => v.id === versionId);
const newerSnap = await fetchSnapshot(versionId);
const olderSnap = idx > 0 ? await fetchSnapshot(versions[idx - 1].id) : null;
const entries = buildDiff(olderSnap, newerSnap);
if (entries.length === 0) {
noDiff = true;
} else {
diffEntries = entries;
}
} catch {
// ignore
} finally {
diffLoading = false;
}
}
async function applyCompare() {
if (!compareA || !compareB || compareA === compareB) return;
selectedVersionId = null;
diffEntries = [];
noDiff = false;
diffLoading = true;
try {
const [snapA, snapB] = await Promise.all([fetchSnapshot(compareA), fetchSnapshot(compareB)]);
const entries = buildDiff(snapA, snapB);
if (entries.length === 0) {
noDiff = true;
} else {
diffEntries = entries;
}
} catch {
// ignore
} finally {
diffLoading = false;
}
}
function formatDateTime(iso: string): string {
try {
return new Intl.DateTimeFormat('de-DE', {
day: 'numeric',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
}).format(new Date(iso));
} catch {
return iso;
}
}
function versionLabel(v: VersionSummary, index: number): string {
return `Version ${index + 1}${v.editorName}${formatDateTime(v.savedAt)}`;
}
// Load history when this panel mounts.
$effect(() => {
loadHistory();
});
</script>
<div class="space-y-4 p-6">
{#if historyLoading}
<p class="font-sans text-xs text-ink-3">{m.history_loading()}</p>
{:else if !historyLoaded}
<!-- initial state before effect runs — show nothing -->
{:else if versions.length === 0}
<p class="font-serif text-sm text-ink-3 italic">{m.history_empty()}</p>
{:else}
<!-- Compare mode toggle -->
<div class="flex justify-end">
<button
onclick={() => {
compareMode = !compareMode;
diffEntries = [];
noDiff = false;
selectedVersionId = null;
}}
class="font-sans text-xs font-medium transition {compareMode
? 'text-ink underline'
: 'text-ink-3 hover:text-ink'}"
>
{m.history_compare_mode()}
</button>
</div>
{#if compareMode}
<div class="space-y-2">
<div>
<label for="compare-a" class="mb-1 block font-sans text-[10px] text-ink-3 uppercase"
>{m.history_compare_select_a()}</label
>
<select
id="compare-a"
bind:value={compareA}
class="w-full rounded border border-line bg-surface px-2 py-1 font-sans text-xs text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>
<option value=""></option>
{#each versions as v, i (v.id)}
<option value={v.id}>{versionLabel(v, i)}</option>
{/each}
</select>
</div>
<div>
<label for="compare-b" class="mb-1 block font-sans text-[10px] text-ink-3 uppercase"
>{m.history_compare_select_b()}</label
>
<select
id="compare-b"
bind:value={compareB}
class="w-full rounded border border-line bg-surface px-2 py-1 font-sans text-xs text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>
<option value=""></option>
{#each versions as v, i (v.id)}
<option value={v.id}>{versionLabel(v, i)}</option>
{/each}
</select>
</div>
<button
onclick={applyCompare}
disabled={!compareA || !compareB || compareA === compareB}
class="w-full rounded bg-primary px-3 py-1.5 font-sans text-xs font-medium text-primary-fg transition hover:bg-primary/80 disabled:cursor-not-allowed disabled:opacity-40"
>
{m.history_compare_apply()}
</button>
</div>
<!-- Diff panel for compare mode -->
{#if diffLoading}
<p class="font-sans text-xs text-ink-3">{m.history_loading()}</p>
{:else if noDiff}
<div
data-testid="history-diff"
class="rounded-sm border border-line bg-surface p-4 font-serif text-sm text-ink-3 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-line bg-surface p-4"
>
{#each diffEntries as entry (entry.field)}
<div>
<span
class="mb-1.5 block font-sans text-[10px] font-bold tracking-wide text-ink-3 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-ink-3"
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 with inline diff below each selected item -->
<ul class="divide-y divide-line">
{#each versions as v, i (v.id)}
<li>
<button
onclick={() => selectVersion(v.id)}
data-testid="history-version"
class="w-full py-2 text-left transition hover:bg-muted {selectedVersionId ===
v.id
? 'border-l-2 border-accent pl-2'
: 'pl-0'}"
>
<div class="flex items-baseline justify-between gap-2">
<span class="font-sans text-xs font-medium text-ink">
Version {i + 1}
</span>
<span class="font-sans text-[10px] text-ink-3">
{formatDateTime(v.savedAt)}
</span>
</div>
<span class="font-sans text-[11px] text-ink-2">{v.editorName}</span>
{#if v.changedFields && v.changedFields.length > 0}
<div class="mt-1 flex flex-wrap gap-1">
{#each v.changedFields as field (field)}
<span
class="rounded bg-muted px-1.5 py-0.5 font-sans text-[10px] tracking-wide text-ink-2 uppercase"
>
{fieldLabels[field] ? fieldLabels[field]() : field}
</span>
{/each}
</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-ink-3">{m.history_loading()}</p>
{:else if noDiff}
<div
data-testid="history-diff"
class="mb-2 rounded-sm border border-line bg-surface p-4 font-serif text-sm text-ink-3 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-line bg-surface p-4"
>
{#each diffEntries as entry (entry.field)}
<div>
<span
class="mb-1.5 block font-sans text-[10px] font-bold tracking-wide text-ink-3 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-ink-3"
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}
{/if}
</div>

View File

@@ -1,198 +0,0 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { formatDate } from '$lib/utils/date';
type Person = { id: string; firstName: string; lastName: string; alias?: string | null };
type Tag = { id: string; name: string };
type Doc = {
documentDate?: string | null;
location?: string | null;
documentLocation?: string | null;
tags?: Tag[] | null;
sender?: Person | null;
receivers?: Person[] | null;
};
let { doc }: { doc: Doc } = $props();
</script>
<div class="space-y-10 p-6">
<!-- DETAILS GROUP -->
<div>
<h3
class="mb-4 border-b border-line pb-2 font-sans text-xs font-bold tracking-widest text-ink uppercase"
>
{m.doc_section_details()}
</h3>
<div class="space-y-5">
<!-- Date -->
<div class="flex items-start">
<span class="mt-0.5 w-8 text-accent">
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Calendar/Calendar-Add-MD.svg"
alt=""
aria-hidden="true"
class="h-5 w-5"
/>
</span>
<div>
<span class="block font-serif text-lg text-ink">
{doc.documentDate ? formatDate(doc.documentDate) : '—'}
</span>
<span class="font-sans text-xs text-ink-2">{m.doc_label_document_date()}</span>
</div>
</div>
<!-- Creation Location -->
<div class="flex items-start">
<span class="mt-0.5 w-8 text-accent">
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Location-MD.svg"
alt=""
aria-hidden="true"
class="h-5 w-5"
/>
</span>
<div>
<span class="block font-serif text-lg text-ink">
{doc.location ? doc.location : '—'}
</span>
<span class="font-sans text-xs text-ink-2">{m.doc_label_creation_location()}</span>
</div>
</div>
<!-- Physical Archive Location -->
{#if doc.documentLocation}
<div class="flex items-start">
<span class="mt-0.5 w-8 text-accent">
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Folder-MD.svg"
alt=""
aria-hidden="true"
class="h-5 w-5"
/>
</span>
<div>
<span class="block font-serif text-lg text-ink">
{doc.documentLocation}
</span>
<span class="font-sans text-xs text-ink-2"
>{m.doc_label_archive_location_original()}</span
>
</div>
</div>
{/if}
<!-- Tags -->
{#if doc.tags && doc.tags.length > 0}
<div class="flex items-start">
<span class="mt-0.5 w-8 text-accent">
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Bookmark/Bookmark-Outline-MD.svg"
alt=""
aria-hidden="true"
class="h-5 w-5"
/>
</span>
<div class="flex-1">
<div class="mb-1 flex flex-wrap gap-2">
{#each doc.tags as tag (tag.id)}
<a
href="/?tag={encodeURIComponent(tag.name)}"
class="inline-flex items-center rounded bg-muted px-2 py-0.5 text-xs font-bold tracking-wide text-ink uppercase transition-colors hover:bg-primary hover:text-primary-fg"
title={m.doc_tag_filter_title({ name: tag.name })}
>
{tag.name}
</a>
{/each}
</div>
<span class="font-sans text-xs text-ink-2">{m.form_label_tags()}</span>
</div>
</div>
{/if}
</div>
</div>
<!-- PERSONEN GROUP -->
<div>
<h3
class="mb-4 border-b border-line pb-2 font-sans text-xs font-bold tracking-widest text-ink uppercase"
>
{m.doc_section_persons()}
</h3>
<div class="mb-6">
<span class="mb-2 block font-sans text-xs text-ink-3 uppercase">{m.form_label_sender()}</span>
{#if doc.sender}
<a
href="/persons/{doc.sender.id}"
class="group block rounded border border-line bg-muted p-3 transition hover:border-accent hover:bg-accent/10"
>
<div class="flex items-center gap-3">
<div
class="flex h-8 w-8 items-center justify-center rounded-full bg-primary font-serif text-sm text-primary-fg"
>
{doc.sender.firstName[0]}{doc.sender.lastName[0]}
</div>
<div>
<p class="font-serif text-ink group-hover:underline">
{doc.sender.firstName}
{doc.sender.lastName}
</p>
{#if doc.sender.alias}
<p class="font-sans text-xs text-ink-2">{doc.sender.alias}</p>
{/if}
</div>
</div>
</a>
{:else}
<span class="font-serif text-sm text-ink-3 italic">{m.doc_sender_not_specified()}</span>
{/if}
</div>
<div>
<span class="mb-2 block font-sans text-xs text-ink-3 uppercase"
>{m.form_label_receivers()}</span
>
{#if doc.receivers && doc.receivers.length > 0}
<div class="space-y-2">
{#each doc.receivers as receiver (receiver.id)}
<div
class="group flex items-center justify-between rounded border border-line bg-surface p-3 transition hover:border-primary"
>
<a href="/persons/{receiver.id}" class="flex min-w-0 flex-1 items-center gap-3">
<div
class="flex h-6 w-6 items-center justify-center rounded-full bg-muted font-serif text-xs text-ink-2"
>
{receiver.firstName[0]}{receiver.lastName[0]}
</div>
<span class="truncate font-serif text-sm text-ink">
{receiver.firstName}
{receiver.lastName}
</span>
</a>
{#if doc.sender}
<a
href="/korrespondenz?senderId={doc.sender.id}&receiverId={receiver.id}"
class="text-ink-3 transition hover:text-accent"
title={m.doc_conversation_title()}
>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Chat-MD.svg"
alt=""
aria-hidden="true"
class="h-5 w-5"
/>
</a>
{/if}
</div>
{/each}
</div>
{:else}
<span class="font-serif text-sm text-ink-3 italic">{m.doc_no_receivers()}</span>
{/if}
</div>
</div>
</div>

View File

@@ -1,38 +0,0 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
type Doc = {
summary?: string | null;
transcription?: string | null;
};
let { doc }: { doc: Doc } = $props();
</script>
<div class="flex justify-center px-6 py-8">
<div class="w-full max-w-prose space-y-8">
{#if !doc.summary && !doc.transcription}
<p class="font-serif text-sm text-ink-3 italic"></p>
{/if}
{#if doc.summary}
<div>
<span class="mb-3 block font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.doc_label_summary()}
</span>
<p class="font-serif text-base leading-relaxed text-ink">{doc.summary}</p>
</div>
{/if}
{#if doc.transcription}
<div>
<span class="mb-3 block font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.form_label_transcription()}
</span>
<p class="font-serif text-base leading-relaxed whitespace-pre-wrap text-ink">
{doc.transcription}
</p>
</div>
{/if}
</div>
</div>

View File

@@ -13,7 +13,6 @@ type Props = {
active: boolean; active: boolean;
saveState: SaveState; saveState: SaveState;
canComment: boolean; canComment: boolean;
currentUserId: string | null;
onTextChange: (text: string) => void; onTextChange: (text: string) => void;
onFocus: () => void; onFocus: () => void;
onDeleteClick: () => void; onDeleteClick: () => void;
@@ -29,7 +28,6 @@ let {
active, active,
saveState, saveState,
canComment, canComment,
currentUserId,
onTextChange, onTextChange,
onFocus, onFocus,
onDeleteClick, onDeleteClick,
@@ -204,8 +202,6 @@ function captureSelectionAndOpenComments() {
blockId={blockId} blockId={blockId}
loadOnMount={true} loadOnMount={true}
canComment={canComment} canComment={canComment}
currentUserId={currentUserId}
canAdmin={false}
quotedText={selectedQuote} quotedText={selectedQuote}
showCompose={commentOpen} showCompose={commentOpen}
/> />

View File

@@ -15,7 +15,6 @@ function renderBlock(overrides: Record<string, unknown> = {}) {
active: false, active: false,
saveState: 'idle' as const, saveState: 'idle' as const,
canComment: true, canComment: true,
currentUserId: 'user-1',
onTextChange: vi.fn(), onTextChange: vi.fn(),
onFocus: vi.fn(), onFocus: vi.fn(),
onDeleteClick: vi.fn(), onDeleteClick: vi.fn(),

View File

@@ -10,21 +10,12 @@ type Props = {
documentId: string; documentId: string;
blocks: TranscriptionBlockData[]; blocks: TranscriptionBlockData[];
canComment: boolean; canComment: boolean;
currentUserId: string | null;
onBlockFocus: (blockId: string) => void; onBlockFocus: (blockId: string) => void;
onSaveBlock: (blockId: string, text: string) => Promise<void>; onSaveBlock: (blockId: string, text: string) => Promise<void>;
onDeleteBlock: (blockId: string) => Promise<void>; onDeleteBlock: (blockId: string) => Promise<void>;
}; };
let { let { documentId, blocks, canComment, onBlockFocus, onSaveBlock, onDeleteBlock }: Props = $props();
documentId,
blocks,
canComment,
currentUserId,
onBlockFocus,
onSaveBlock,
onDeleteBlock
}: Props = $props();
let activeBlockId: string | null = $state(null); let activeBlockId: string | null = $state(null);
let saveStates = new SvelteMap<string, SaveState>(); let saveStates = new SvelteMap<string, SaveState>();
@@ -165,7 +156,6 @@ $effect(() => {
active={activeBlockId === block.id} active={activeBlockId === block.id}
saveState={getSaveState(block.id)} saveState={getSaveState(block.id)}
canComment={canComment} canComment={canComment}
currentUserId={currentUserId}
onTextChange={(text) => handleTextChange(block.id, text)} onTextChange={(text) => handleTextChange(block.id, text)}
onFocus={() => handleFocus(block.id)} onFocus={() => handleFocus(block.id)}
onDeleteClick={() => handleDelete(block.id)} onDeleteClick={() => handleDelete(block.id)}

View File

@@ -9,7 +9,6 @@ let { data } = $props();
const doc = $derived(data.document); const doc = $derived(data.document);
const canWrite = $derived(data.canWrite ?? false); const canWrite = $derived(data.canWrite ?? false);
const currentUserId = $derived((data.user?.id as string | undefined) ?? null);
// ── File loading ────────────────────────────────────────────────────────────── // ── File loading ──────────────────────────────────────────────────────────────
@@ -218,7 +217,6 @@ onMount(() => {
documentId={doc.id} documentId={doc.id}
blocks={transcriptionBlocks} blocks={transcriptionBlocks}
canComment={canWrite} canComment={canWrite}
currentUserId={currentUserId}
onBlockFocus={handleBlockFocus} onBlockFocus={handleBlockFocus}
onSaveBlock={saveBlock} onSaveBlock={saveBlock}
onDeleteBlock={deleteBlock} onDeleteBlock={deleteBlock}