- 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>
335 lines
9.0 KiB
Svelte
335 lines
9.0 KiB
Svelte
<script lang="ts">
|
|
import { onMount, untrack } from 'svelte';
|
|
import { m } from '$lib/paraglide/messages.js';
|
|
import type { Comment, CommentReply } from '$lib/types';
|
|
|
|
type Props = {
|
|
documentId: string;
|
|
annotationId?: string | null;
|
|
initialComments?: Comment[];
|
|
loadOnMount?: boolean;
|
|
canComment: boolean;
|
|
currentUserId: string | null;
|
|
canAdmin: boolean;
|
|
onCountChange?: (count: number) => void;
|
|
};
|
|
|
|
let {
|
|
documentId,
|
|
annotationId = null,
|
|
initialComments = [],
|
|
loadOnMount = false,
|
|
canComment,
|
|
currentUserId,
|
|
canAdmin,
|
|
onCountChange
|
|
}: Props = $props();
|
|
|
|
let comments: Comment[] = $state(untrack(() => [...initialComments]));
|
|
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);
|
|
|
|
const commentsBase = $derived(
|
|
annotationId
|
|
? `/api/documents/${documentId}/annotations/${annotationId}/comments`
|
|
: `/api/documents/${documentId}/comments`
|
|
);
|
|
|
|
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 res = await fetch(commentsBase, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ content: text })
|
|
});
|
|
if (res.ok) {
|
|
newText = '';
|
|
await reload();
|
|
}
|
|
} finally {
|
|
posting = false;
|
|
}
|
|
}
|
|
|
|
async function postReply(threadId: string) {
|
|
const text = replyText.trim();
|
|
if (!text || posting) return;
|
|
posting = true;
|
|
try {
|
|
const res = await fetch(`${commentsBase}/${threadId}/replies`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ content: text })
|
|
});
|
|
if (res.ok) {
|
|
replyText = '';
|
|
replyingTo = null;
|
|
await reload();
|
|
}
|
|
} finally {
|
|
posting = false;
|
|
}
|
|
}
|
|
|
|
async function saveEdit(commentId: string) {
|
|
const text = editText.trim();
|
|
if (!text || posting) return;
|
|
posting = true;
|
|
try {
|
|
const res = await fetch(`/api/documents/${documentId}/comments/${commentId}`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ content: text })
|
|
});
|
|
if (res.ok) {
|
|
editingId = null;
|
|
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;
|
|
}
|
|
|
|
function cancelEdit() {
|
|
editingId = null;
|
|
editText = '';
|
|
}
|
|
|
|
function startReply(threadId: string) {
|
|
replyingTo = threadId;
|
|
replyText = '';
|
|
}
|
|
|
|
function cancelReply() {
|
|
replyingTo = null;
|
|
replyText = '';
|
|
}
|
|
|
|
onMount(() => {
|
|
if (loadOnMount) {
|
|
reload();
|
|
} else {
|
|
const total = initialComments.reduce((s, c) => s + 1 + c.replies.length, 0);
|
|
onCountChange?.(total);
|
|
}
|
|
});
|
|
</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">
|
|
<textarea
|
|
class="w-full resize-none rounded border border-line px-3 py-2 font-serif text-sm text-ink focus:ring-1 focus:ring-accent focus:outline-none"
|
|
rows={3}
|
|
bind:value={editText}
|
|
></textarea>
|
|
<div class="flex items-center gap-3">
|
|
<button
|
|
class="rounded bg-primary px-3 py-1.5 font-sans text-xs font-medium text-white 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">{comment.content}</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-accent transition-colors hover:text-ink"
|
|
onclick={() => startReply(threadId)}
|
|
>
|
|
{m.comment_btn_reply()}
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
{/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 -->
|
|
<div>
|
|
{@render commentEntry(thread, thread.id, thread.replies.length === 0)}
|
|
</div>
|
|
|
|
<!-- Replies -->
|
|
{#each thread.replies as reply, ri (reply.id)}
|
|
<div class="mt-3 ml-6 border-l-2 border-line pl-4">
|
|
{@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">
|
|
<textarea
|
|
class="w-full resize-none rounded border border-line px-3 py-2 font-serif text-sm text-ink focus:ring-1 focus:ring-accent focus:outline-none"
|
|
rows={3}
|
|
placeholder={m.comment_placeholder()}
|
|
bind:value={replyText}
|
|
></textarea>
|
|
<div class="flex items-center gap-3">
|
|
<button
|
|
class="rounded bg-primary px-3 py-1.5 font-sans text-xs font-medium text-white 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}
|
|
<div class={comments.length > 0 ? 'border-t border-line pt-4' : ''}>
|
|
<div class="flex flex-col gap-2">
|
|
<textarea
|
|
class="w-full resize-none rounded border border-line px-3 py-2 font-serif text-sm text-ink focus:ring-1 focus:ring-accent focus:outline-none"
|
|
rows={3}
|
|
placeholder={m.comment_placeholder()}
|
|
bind:value={newText}
|
|
></textarea>
|
|
<div>
|
|
<button
|
|
class="rounded bg-primary px-3 py-1.5 font-sans text-xs font-medium text-white hover:bg-primary/80 disabled:opacity-40"
|
|
disabled={posting || !newText.trim()}
|
|
onclick={postComment}
|
|
>
|
|
{m.comment_btn_post()}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|