233 lines
7.7 KiB
Svelte
233 lines
7.7 KiB
Svelte
<script lang="ts">
|
|
import { getComments, postComment, deleteComment } from '$lib/api/actions';
|
|
import { session, isModOrAdmin } from '$lib/stores/session';
|
|
import { flash } from '$lib/stores/flash';
|
|
import { timeAgo, formatDate } from '$lib/utils/timeago';
|
|
import { onSSE } from '$lib/stores/sse';
|
|
import { onDestroy } from 'svelte';
|
|
import type { Comment } from '$lib/types/comment';
|
|
|
|
interface Props {
|
|
itemId: number;
|
|
}
|
|
let { itemId }: Props = $props();
|
|
|
|
// ── State ─────────────────────────────────────────────────────────────────
|
|
let comments = $state<Comment[]>([]);
|
|
let loading = $state(true);
|
|
let error = $state('');
|
|
let text = $state('');
|
|
let replyTo = $state<Comment | null>(null);
|
|
let submitting = $state(false);
|
|
|
|
// ── Load ──────────────────────────────────────────────────────────────────
|
|
async function load() {
|
|
loading = true;
|
|
error = '';
|
|
try {
|
|
const data = await getComments(itemId);
|
|
comments = data.comments ?? [];
|
|
} catch {
|
|
error = 'Failed to load comments.';
|
|
} finally {
|
|
loading = false;
|
|
}
|
|
}
|
|
|
|
// Run on mount and when itemId changes
|
|
$effect(() => { load(); });
|
|
|
|
// ── Live updates via SSE ──────────────────────────────────────────────────
|
|
const unsubSSE = onSSE((event) => {
|
|
if (event.type !== 'comments') return;
|
|
const d = event.data as { item_id: number; type?: string };
|
|
if (d.item_id !== itemId) return;
|
|
// Reload the full comment list on any comment activity for this item
|
|
load();
|
|
});
|
|
onDestroy(unsubSSE);
|
|
|
|
// ── Post comment ──────────────────────────────────────────────────────────
|
|
async function submit(e: Event) {
|
|
e.preventDefault();
|
|
if (!text.trim()) return;
|
|
submitting = true;
|
|
try {
|
|
const res = await postComment({
|
|
itemId,
|
|
content: text.trim(),
|
|
replyTo: replyTo?.id ?? null
|
|
});
|
|
if (res.success) {
|
|
text = '';
|
|
replyTo = null;
|
|
await load();
|
|
flash('Comment posted', 'success');
|
|
} else {
|
|
flash(res.message ?? res.msg ?? 'Failed to post', 'error');
|
|
}
|
|
} catch {
|
|
flash('Network error', 'error');
|
|
} finally {
|
|
submitting = false;
|
|
}
|
|
}
|
|
|
|
// ── Delete comment ────────────────────────────────────────────────────────
|
|
async function handleDelete(comment: Comment) {
|
|
if (!confirm('Delete this comment?')) return;
|
|
try {
|
|
const res = await deleteComment(comment.id);
|
|
if (res.success) {
|
|
await load();
|
|
flash('Deleted', 'success');
|
|
} else {
|
|
flash(res.msg ?? 'Failed to delete', 'error');
|
|
}
|
|
} catch {
|
|
flash('Network error', 'error');
|
|
}
|
|
}
|
|
|
|
function canDelete(comment: Comment) {
|
|
return $session && ($isModOrAdmin || $session.user === comment.username);
|
|
}
|
|
|
|
function avatarSrc(comment: Comment): string {
|
|
if (comment.avatar_file) return `/a/${comment.avatar_file}`;
|
|
if (comment.avatar) return `/t/${comment.avatar}.webp`;
|
|
return '/a/default.png';
|
|
}
|
|
</script>
|
|
|
|
<section class="flex flex-col gap-3">
|
|
<!-- Header -->
|
|
<div class="flex items-center justify-between">
|
|
<span class="text-xs font-semibold uppercase tracking-wider opacity-50">
|
|
Comments {#if comments.length > 0}<span class="opacity-60">({comments.length})</span>{/if}
|
|
</span>
|
|
<button onclick={load} class="btn btn-xs btn-ghost opacity-50 hover:opacity-100" aria-label="Refresh">
|
|
<i class="fa-solid fa-rotate-right text-xs" aria-hidden="true"></i>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Comment form -->
|
|
{#if $session}
|
|
<form onsubmit={submit} class="flex flex-col gap-2">
|
|
{#if replyTo}
|
|
<div class="flex items-center gap-2 text-xs bg-base-300 rounded px-2 py-1">
|
|
<i class="fa-solid fa-reply opacity-50" aria-hidden="true"></i>
|
|
<span class="opacity-70 truncate flex-1">Replying to {replyTo.display_name || replyTo.username}</span>
|
|
<button type="button" onclick={() => replyTo = null} class="opacity-50 hover:opacity-100">✕</button>
|
|
</div>
|
|
{/if}
|
|
<div class="flex gap-2">
|
|
<textarea
|
|
bind:value={text}
|
|
placeholder="Write a comment…"
|
|
rows="2"
|
|
class="textarea textarea-bordered flex-1 text-sm resize-none min-h-[3rem]"
|
|
disabled={submitting}
|
|
onkeydown={(e) => { if (e.key === 'Enter' && e.ctrlKey) submit(e); }}
|
|
></textarea>
|
|
<button
|
|
type="submit"
|
|
class="btn btn-primary btn-sm self-end"
|
|
disabled={submitting || !text.trim()}
|
|
>
|
|
{#if submitting}
|
|
<span class="loading loading-spinner loading-xs"></span>
|
|
{:else}
|
|
<i class="fa-solid fa-paper-plane" aria-hidden="true"></i>
|
|
{/if}
|
|
</button>
|
|
</div>
|
|
<p class="text-xs opacity-30">Ctrl+Enter to submit</p>
|
|
</form>
|
|
{:else}
|
|
<p class="text-xs opacity-40">
|
|
<a href="/login" class="link link-primary">Log in</a> to comment.
|
|
</p>
|
|
{/if}
|
|
|
|
<!-- Comments list -->
|
|
{#if loading}
|
|
<div class="flex justify-center py-4">
|
|
<span class="loading loading-dots loading-sm opacity-40"></span>
|
|
</div>
|
|
{:else if error}
|
|
<p class="text-xs text-error opacity-70">{error}</p>
|
|
{:else if comments.length === 0}
|
|
<p class="text-xs opacity-30 text-center py-2">No comments yet.</p>
|
|
{:else}
|
|
<ul class="flex flex-col gap-2">
|
|
{#each comments as comment (comment.id)}
|
|
<li
|
|
class="group flex gap-2 text-sm {comment.is_deleted ? 'opacity-40' : ''}"
|
|
id="comment-{comment.id}"
|
|
>
|
|
<!-- Avatar -->
|
|
<a href="/user/{comment.username?.toLowerCase()}" class="flex-shrink-0 mt-0.5">
|
|
<div class="avatar">
|
|
<div class="w-7 rounded-full border border-base-300">
|
|
<img src={avatarSrc(comment)} alt={comment.username} loading="lazy" />
|
|
</div>
|
|
</div>
|
|
</a>
|
|
|
|
<!-- Body -->
|
|
<div class="flex-1 min-w-0">
|
|
<div class="flex items-baseline gap-1.5 flex-wrap">
|
|
<a
|
|
href="/user/{comment.username?.toLowerCase()}"
|
|
class="font-semibold text-xs hover:text-primary transition-colors"
|
|
style={comment.username_color ? `color: ${comment.username_color}` : ''}
|
|
>
|
|
{comment.display_name || comment.username}
|
|
</a>
|
|
<time
|
|
datetime={comment.stamp_full}
|
|
title={formatDate(comment.stamp_full)}
|
|
class="text-xs opacity-40"
|
|
>
|
|
{comment.stamp}
|
|
</time>
|
|
</div>
|
|
|
|
{#if comment.is_deleted}
|
|
<p class="text-xs italic opacity-50">— deleted —</p>
|
|
{:else}
|
|
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
|
<div class="prose prose-sm max-w-none text-xs leading-relaxed break-words">
|
|
{@html comment.content}
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Actions -->
|
|
{#if !comment.is_deleted}
|
|
<div class="flex items-center gap-2 mt-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
{#if $session}
|
|
<button
|
|
onclick={() => { replyTo = comment; document.querySelector('textarea')?.focus(); }}
|
|
class="btn btn-ghost btn-xs gap-1 text-xs"
|
|
>
|
|
<i class="fa-solid fa-reply text-[10px]" aria-hidden="true"></i> Reply
|
|
</button>
|
|
{/if}
|
|
{#if canDelete(comment)}
|
|
<button
|
|
onclick={() => handleDelete(comment)}
|
|
class="btn btn-ghost btn-xs text-error gap-1 text-xs"
|
|
>
|
|
<i class="fa-solid fa-xmark text-[10px]" aria-hidden="true"></i> Delete
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
{/if}
|
|
</section>
|