395 lines
11 KiB
Svelte
395 lines
11 KiB
Svelte
<script lang="ts">
|
|
import { onMount, untrack } from 'svelte';
|
|
import { m } from '$lib/paraglide/messages.js';
|
|
|
|
type CommentReply = {
|
|
id: string;
|
|
authorId: string | null;
|
|
authorName: string;
|
|
content: string;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
};
|
|
|
|
type Comment = {
|
|
id: string;
|
|
authorId: string | null;
|
|
authorName: string;
|
|
content: string;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
replies: CommentReply[];
|
|
};
|
|
|
|
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();
|
|
}
|
|
});
|
|
</script>
|
|
|
|
<div class="space-y-4">
|
|
{#each comments as thread, ti (thread.id)}
|
|
<div class={ti > 0 ? 'border-t border-brand-sand pt-4' : ''}>
|
|
<!-- Root comment -->
|
|
<div>
|
|
{#if editingId === thread.id}
|
|
<div class="flex flex-col gap-2">
|
|
<textarea
|
|
class="w-full resize-none rounded border border-brand-sand px-3 py-2 font-serif text-sm text-brand-navy focus:ring-1 focus:ring-brand-mint focus:outline-none"
|
|
rows={3}
|
|
bind:value={editText}
|
|
></textarea>
|
|
<div class="flex items-center gap-3">
|
|
<button
|
|
class="rounded bg-brand-navy px-3 py-1.5 font-sans text-xs font-medium text-white hover:bg-brand-navy/80 disabled:opacity-40"
|
|
disabled={posting}
|
|
onclick={() => saveEdit(thread.id)}
|
|
>
|
|
{m.btn_save()}
|
|
</button>
|
|
<button
|
|
class="font-sans text-xs text-gray-400 transition-colors hover:text-brand-navy"
|
|
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-brand-navy"
|
|
>{thread.authorName}</span
|
|
>
|
|
<span class="font-sans text-xs text-gray-400">{timeAgo(thread.createdAt)}</span>
|
|
{#if wasEdited(thread)}
|
|
<span class="font-sans text-xs text-gray-400">
|
|
{m.comment_edited_label()}
|
|
{timeAgo(thread.updatedAt)}
|
|
</span>
|
|
{/if}
|
|
</div>
|
|
<p class="mt-1 font-serif text-sm leading-relaxed text-gray-700">{thread.content}</p>
|
|
</div>
|
|
{#if canModify(thread)}
|
|
<div class="flex shrink-0 items-center gap-2">
|
|
<button
|
|
class="font-sans text-xs text-gray-400 transition-colors hover:text-brand-navy"
|
|
onclick={() => startEdit(thread)}
|
|
>
|
|
{m.btn_edit()}
|
|
</button>
|
|
<button
|
|
class="font-sans text-xs text-gray-400 transition-colors hover:text-brand-navy"
|
|
onclick={() => deleteComment(thread.id)}
|
|
>
|
|
{m.btn_delete()}
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
<!-- Reply button on root comment only if there are no replies -->
|
|
{#if thread.replies.length === 0 && canComment}
|
|
<div class="mt-1">
|
|
<button
|
|
class="font-sans text-xs font-medium text-brand-mint transition-colors hover:text-brand-navy"
|
|
onclick={() => startReply(thread.id)}
|
|
>
|
|
{m.comment_btn_reply()}
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Replies -->
|
|
{#each thread.replies as reply, ri (reply.id)}
|
|
<div class="mt-3 ml-6 border-l-2 border-brand-sand pl-4">
|
|
{#if editingId === reply.id}
|
|
<div class="flex flex-col gap-2">
|
|
<textarea
|
|
class="w-full resize-none rounded border border-brand-sand px-3 py-2 font-serif text-sm text-brand-navy focus:ring-1 focus:ring-brand-mint focus:outline-none"
|
|
rows={3}
|
|
bind:value={editText}
|
|
></textarea>
|
|
<div class="flex items-center gap-3">
|
|
<button
|
|
class="rounded bg-brand-navy px-3 py-1.5 font-sans text-xs font-medium text-white hover:bg-brand-navy/80 disabled:opacity-40"
|
|
disabled={posting}
|
|
onclick={() => saveEdit(reply.id)}
|
|
>
|
|
{m.btn_save()}
|
|
</button>
|
|
<button
|
|
class="font-sans text-xs text-gray-400 transition-colors hover:text-brand-navy"
|
|
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-brand-navy"
|
|
>{reply.authorName}</span
|
|
>
|
|
<span class="font-sans text-xs text-gray-400">{timeAgo(reply.createdAt)}</span>
|
|
{#if wasEdited(reply)}
|
|
<span class="font-sans text-xs text-gray-400">
|
|
{m.comment_edited_label()}
|
|
{timeAgo(reply.updatedAt)}
|
|
</span>
|
|
{/if}
|
|
</div>
|
|
<p class="mt-1 font-serif text-sm leading-relaxed text-gray-700">{reply.content}</p>
|
|
</div>
|
|
{#if canModify(reply)}
|
|
<div class="flex shrink-0 items-center gap-2">
|
|
<button
|
|
class="font-sans text-xs text-gray-400 transition-colors hover:text-brand-navy"
|
|
onclick={() => startEdit(reply)}
|
|
>
|
|
{m.btn_edit()}
|
|
</button>
|
|
<button
|
|
class="font-sans text-xs text-gray-400 transition-colors hover:text-brand-navy"
|
|
onclick={() => deleteComment(reply.id)}
|
|
>
|
|
{m.btn_delete()}
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
<!-- Reply button only on the last reply -->
|
|
{#if ri === thread.replies.length - 1 && canComment}
|
|
<div class="mt-1">
|
|
<button
|
|
class="font-sans text-xs font-medium text-brand-mint transition-colors hover:text-brand-navy"
|
|
onclick={() => startReply(thread.id)}
|
|
>
|
|
{m.comment_btn_reply()}
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
</div>
|
|
{/each}
|
|
|
|
<!-- Reply textarea (shown when replyingTo === thread.id) -->
|
|
{#if replyingTo === thread.id}
|
|
<div class="mt-3 ml-6 flex flex-col gap-2">
|
|
<textarea
|
|
class="w-full resize-none rounded border border-brand-sand px-3 py-2 font-serif text-sm text-brand-navy focus:ring-1 focus:ring-brand-mint focus:outline-none"
|
|
rows={3}
|
|
placeholder={m.comment_placeholder()}
|
|
bind:value={replyText}
|
|
></textarea>
|
|
<div class="flex items-center gap-3">
|
|
<button
|
|
class="rounded bg-brand-navy px-3 py-1.5 font-sans text-xs font-medium text-white hover:bg-brand-navy/80 disabled:opacity-40"
|
|
disabled={posting}
|
|
onclick={() => postReply(thread.id)}
|
|
>
|
|
{m.comment_btn_post()}
|
|
</button>
|
|
<button
|
|
class="font-sans text-xs text-gray-400 transition-colors hover:text-brand-navy"
|
|
onclick={cancelReply}
|
|
>
|
|
{m.btn_cancel()}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/each}
|
|
|
|
<!-- New top-level comment textarea -->
|
|
{#if canComment}
|
|
<div class={comments.length > 0 ? 'border-t border-brand-sand pt-4' : ''}>
|
|
<div class="flex flex-col gap-2">
|
|
<textarea
|
|
class="w-full resize-none rounded border border-brand-sand px-3 py-2 font-serif text-sm text-brand-navy focus:ring-1 focus:ring-brand-mint focus:outline-none"
|
|
rows={3}
|
|
placeholder={m.comment_placeholder()}
|
|
bind:value={newText}
|
|
></textarea>
|
|
<div>
|
|
<button
|
|
class="rounded bg-brand-navy px-3 py-1.5 font-sans text-xs font-medium text-white hover:bg-brand-navy/80 disabled:opacity-40"
|
|
disabled={posting || !newText.trim()}
|
|
onclick={postComment}
|
|
>
|
|
{m.comment_btn_post()}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|