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
Comments were only visible after clicking "Kommentieren". Now: - Comment list always renders (CommentThread with loadOnMount=true) - Compose box hidden by default (showCompose prop on CommentThread) - Clicking "Kommentieren" sets commentOpen=true → shows compose box - Closing hides compose box but comments remain visible This separates "viewing comments" (always) from "writing a comment" (on demand via Kommentieren button). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
386 lines
11 KiB
Svelte
386 lines
11 KiB
Svelte
<script lang="ts">
|
|
import { onMount, tick, untrack } from 'svelte';
|
|
import { m } from '$lib/paraglide/messages.js';
|
|
import type { Comment, CommentReply } from '$lib/types';
|
|
import MentionEditor from '$lib/components/MentionEditor.svelte';
|
|
import { renderBody, extractContent } from '$lib/utils/mention';
|
|
import type { MentionDTO } from '$lib/types';
|
|
|
|
type Props = {
|
|
documentId: string;
|
|
annotationId?: string | null;
|
|
blockId?: string | null;
|
|
initialComments?: Comment[];
|
|
loadOnMount?: boolean;
|
|
canComment: boolean;
|
|
currentUserId: string | null;
|
|
canAdmin: boolean;
|
|
targetCommentId?: string | null;
|
|
quotedText?: string | null;
|
|
showCompose?: boolean;
|
|
onCountChange?: (count: number) => void;
|
|
};
|
|
|
|
let {
|
|
documentId,
|
|
annotationId = null,
|
|
blockId = null,
|
|
initialComments = [],
|
|
loadOnMount = false,
|
|
canComment,
|
|
currentUserId,
|
|
canAdmin,
|
|
targetCommentId = null,
|
|
quotedText = null,
|
|
showCompose = true,
|
|
onCountChange
|
|
}: Props = $props();
|
|
|
|
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
|
|
? `/api/documents/${documentId}/transcription-blocks/${blockId}/comments`
|
|
: annotationId
|
|
? `/api/documents/${documentId}/annotations/${annotationId}/comments`
|
|
: `/api/documents/${documentId}/comments`
|
|
);
|
|
|
|
// Pre-fill comment box with quoted text when selection changes
|
|
$effect(() => {
|
|
if (quotedText && quotedText.trim()) {
|
|
newText = `> "${quotedText}"\n\n`;
|
|
}
|
|
});
|
|
|
|
function timeAgo(iso: string): string {
|
|
const diff = Date.now() - new Date(iso).getTime();
|
|
const minutes = Math.floor(diff / 60000);
|
|
if (minutes < 1) return 'gerade eben';
|
|
if (minutes < 60) return `vor ${minutes} Minute${minutes === 1 ? '' : 'n'}`;
|
|
const hours = Math.floor(minutes / 60);
|
|
if (hours < 24) return `vor ${hours} Stunde${hours === 1 ? '' : 'n'}`;
|
|
const days = Math.floor(hours / 24);
|
|
return `vor ${days} Tag${days === 1 ? '' : 'en'}`;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
async function reload() {
|
|
try {
|
|
const res = await fetch(commentsBase);
|
|
if (res.ok) {
|
|
comments = await res.json();
|
|
const total = comments.reduce((s, c) => s + 1 + c.replies.length, 0);
|
|
onCountChange?.(total);
|
|
}
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
|
|
async function postComment() {
|
|
const text = newText.trim();
|
|
if (!text || posting) return;
|
|
posting = true;
|
|
try {
|
|
const { content, mentionedUserIds } = extractContent(text, newMentionCandidates);
|
|
const res = await fetch(commentsBase, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ content, mentionedUserIds })
|
|
});
|
|
if (res.ok) {
|
|
newText = '';
|
|
newMentionCandidates = [];
|
|
await reload();
|
|
}
|
|
} finally {
|
|
posting = false;
|
|
}
|
|
}
|
|
|
|
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 () => {
|
|
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>
|
|
<span class="font-sans text-xs text-ink-3">{timeAgo(comment.createdAt)}</span>
|
|
{#if wasEdited(comment)}
|
|
<span class="font-sans text-xs text-ink-3">
|
|
{m.comment_edited_label()}
|
|
{timeAgo(comment.updatedAt)}
|
|
</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)}
|
|
</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>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/each}
|
|
|
|
<!-- 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>
|
|
</div>
|
|
{/if}
|
|
</div>
|