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,7 +1,7 @@
<script lang="ts">
import { onMount, tick, untrack } from 'svelte';
import { onMount, untrack } from 'svelte';
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 { renderBody, extractContent } from '$lib/utils/mention';
import type { MentionDTO } from '$lib/types';
@@ -13,9 +13,6 @@ type Props = {
initialComments?: Comment[];
loadOnMount?: boolean;
canComment: boolean;
currentUserId: string | null;
canAdmin: boolean;
targetCommentId?: string | null;
quotedText?: string | null;
showCompose?: boolean;
onCountChange?: (count: number) => void;
@@ -28,25 +25,25 @@ let {
initialComments = [],
loadOnMount = false,
canComment,
currentUserId,
canAdmin,
targetCommentId = null,
quotedText = null,
showCompose = true,
onCountChange
}: 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 highlightedCommentId: string | null = $state(untrack(() => targetCommentId ?? null));
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 newMentionCandidates: MentionDTO[] = $state([]);
let replyMentionCandidates: MentionDTO[] = $state([]);
let editMentionCandidates: MentionDTO[] = $state([]);
const commentsBase = $derived(
blockId
@@ -56,7 +53,10 @@ const commentsBase = $derived(
: `/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(() => {
if (quotedText && quotedText.trim()) {
newText = `> "${quotedText}"\n\n`;
@@ -78,8 +78,18 @@ function wasEdited(c: { createdAt: string; updatedAt: string }): boolean {
return c.updatedAt > c.createdAt;
}
function canModify(c: { authorId: string | null }): boolean {
return (currentUserId != null && c.authorId === currentUserId) || canAdmin;
function getInitials(name: string): string {
return name
.split(/\s+/)
.slice(0, 2)
.map((w) => w.charAt(0).toUpperCase())
.join('');
}
function extractQuote(content: string): { quote: string | null; body: string } {
const match = content.match(/^>\s*"(.+?)"\s*\n\n?([\s\S]*)$/);
if (match) return { quote: match[1], body: match[2] };
return { quote: null, body: content };
}
async function reload() {
@@ -116,270 +126,94 @@ async function postComment() {
}
}
async function postReply(threadId: string) {
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 () => {
onMount(() => {
if (loadOnMount) {
reload();
} else {
const total = initialComments.reduce((s, c) => s + 1 + c.replies.length, 0);
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>
<!--
Renders a single comment or reply entry.
showReplyButton: whether the "Reply" button appears (only on last item in a thread).
-->
{#snippet commentEntry(comment: Comment | CommentReply, threadId: string, showReplyButton: boolean)}
{#if editingId === comment.id}
<div class="flex flex-col gap-2">
<MentionEditor
bind:value={editText}
bind:mentionCandidates={editMentionCandidates}
rows={3}
disabled={posting}
onsubmit={() => saveEdit(comment.id)}
/>
<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'
: ''}
{#if flatMessages.length > 0}
<div
class="rounded border-l-2 border-orange-400 bg-orange-50 p-2 dark:border-orange-600 dark:bg-orange-950/20"
>
<div
class="mb-2 flex items-center gap-1.5 font-sans text-xs font-semibold text-orange-700 dark:text-orange-400"
>
<svg
class="h-3.5 w-3.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
{@render commentEntry(thread, thread.id, thread.replies.length === 0)}
</div>
<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 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 -->
{#each thread.replies as reply, ri (reply.id)}
<div
data-comment-id={reply.id}
class="mt-3 ml-6 border-l-2 border-line pl-4 {highlightedCommentId === reply.id
? 'rounded outline-2 outline-offset-1 outline-accent transition-shadow outline-dotted'
: ''}"
>
{@render commentEntry(reply, thread.id, ri === thread.replies.length - 1)}
</div>
{/each}
<!-- Reply compose box -->
{#if replyingTo === thread.id}
<div class="mt-3 ml-6 flex flex-col gap-2">
<MentionEditor
bind:value={replyText}
bind:mentionCandidates={replyMentionCandidates}
rows={3}
placeholder={m.comment_placeholder()}
disabled={posting}
onsubmit={() => postReply(thread.id)}
/>
<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={() => postReply(thread.id)}
>
{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 class="space-y-2">
{#each flatMessages as msg (msg.id)}
{@const parsed = extractQuote(msg.content)}
<div class="flex gap-2">
<div
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary text-[10px] font-bold text-primary-fg"
>
{getInitials(msg.authorName)}
</div>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-1.5">
<span class="font-sans text-xs font-semibold text-ink">{msg.authorName}</span>
{#if wasEdited(msg)}
<span class="font-sans text-[10px] text-ink-3"
>{timeAgo(msg.updatedAt)} {m.comment_edited_label()}</span
>
{:else}
<span class="font-sans text-[10px] text-ink-3">{timeAgo(msg.createdAt)}</span>
{/if}
</div>
{#if parsed.quote}
<div class="my-1 border-l-2 border-line pl-2 font-serif text-xs text-ink-3 italic">
&ldquo;{parsed.quote}&rdquo;
</div>
{/if}
<p class="font-serif text-xs leading-relaxed text-ink-2">
<!-- eslint-disable-next-line svelte/no-at-html-tags -- renderBody escapes all HTML before injecting mention links -->
{@html renderBody(parsed.body, msg.mentionDTOs ?? [])}
</p>
</div>
</div>
{/if}
{/each}
</div>
{/each}
</div>
{/if}
<!-- New top-level comment -->
{#if canComment && showCompose}
<div class={comments.length > 0 ? 'border-t border-line pt-4' : ''}>
<div class="flex flex-col gap-2">
<MentionEditor
bind:value={newText}
bind:mentionCandidates={newMentionCandidates}
rows={3}
placeholder={m.comment_placeholder()}
disabled={posting}
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>
{#if canComment && showCompose}
<div class="mt-2 flex gap-2">
<div class="flex-1">
<MentionEditor
bind:value={newText}
bind:mentionCandidates={newMentionCandidates}
rows={1}
placeholder={m.comment_placeholder()}
disabled={posting}
onsubmit={postComment}
/>
</div>
{/if}
</div>
<button
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}