Files
familienarchiv/frontend/src/lib/components/CommentThread.svelte
2026-03-24 11:02:38 +01:00

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>