svelte lol
This commit is contained in:
232
f0ck-svelte/src/lib/components/item/Comments.svelte
Normal file
232
f0ck-svelte/src/lib/components/item/Comments.svelte
Normal file
@@ -0,0 +1,232 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user