feat: add avatars to comments (web + iOS) with replies, delete, sort, and crop fix
Some checks failed
CI / Scraper / Test (push) Successful in 10s
CI / UI / Build (push) Failing after 9s
CI / Scraper / Lint (pull_request) Successful in 7s
CI / UI / Build (pull_request) Failing after 7s
CI / UI / Docker Push (push) Has been skipped
CI / UI / Docker Push (pull_request) Has been skipped
CI / Scraper / Lint (push) Successful in 28s
CI / Scraper / Test (pull_request) Successful in 20s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / Scraper / Docker Push (push) Successful in 39s
iOS CI / Build (push) Successful in 2m16s
iOS CI / Test (push) Has been cancelled
iOS CI / Build (pull_request) Successful in 5m35s
iOS CI / Test (pull_request) Successful in 5m50s
Some checks failed
CI / Scraper / Test (push) Successful in 10s
CI / UI / Build (push) Failing after 9s
CI / Scraper / Lint (pull_request) Successful in 7s
CI / UI / Build (pull_request) Failing after 7s
CI / UI / Docker Push (push) Has been skipped
CI / UI / Docker Push (pull_request) Has been skipped
CI / Scraper / Lint (push) Successful in 28s
CI / Scraper / Test (pull_request) Successful in 20s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / Scraper / Docker Push (push) Successful in 39s
iOS CI / Build (push) Successful in 2m16s
iOS CI / Test (push) Has been cancelled
iOS CI / Build (pull_request) Successful in 5m35s
iOS CI / Test (pull_request) Successful in 5m50s
- Batch-resolve avatar presign URLs server-side in GET /api/comments/[slug]; returns avatarUrls map alongside comments and myVotes - CommentsSection.svelte: show avatar image or initials fallback (24px top-level, 20px replies) next to each comment/reply username - iOS CommentsResponse gains avatarUrls field; CommentsViewModel stores and populates it on load; CommentRow renders AsyncImage with initials fallback - Also includes: comment replies (1-level nesting), delete, sort (Top/New), parent_id schema migration, and AvatarCropModal cropperjs fix
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { onDestroy } from 'svelte';
|
||||
import Cropper from 'cropperjs';
|
||||
import 'cropperjs/dist/cropper.css';
|
||||
|
||||
@@ -11,31 +11,54 @@
|
||||
|
||||
let { file, onconfirm, oncancel }: Props = $props();
|
||||
|
||||
let imgEl: HTMLImageElement;
|
||||
let imgEl: HTMLImageElement | undefined = $state();
|
||||
let cropper: Cropper | null = null;
|
||||
let objectUrl = $state('');
|
||||
let objectUrl = '';
|
||||
|
||||
onMount(() => {
|
||||
// Initialize cropper once the img element is bound and the file is known.
|
||||
// Use a $effect so it runs after the DOM is ready (replaces onMount).
|
||||
$effect(() => {
|
||||
if (!imgEl || !file) return;
|
||||
|
||||
// Create the object URL and set src directly on the element (not via reactive
|
||||
// state) so cropperjs sees the correct src before the image load event fires.
|
||||
objectUrl = URL.createObjectURL(file);
|
||||
cropper = new Cropper(imgEl, {
|
||||
aspectRatio: 1,
|
||||
viewMode: 1,
|
||||
dragMode: 'move',
|
||||
autoCropArea: 0.8,
|
||||
restore: false,
|
||||
guides: false,
|
||||
center: true,
|
||||
highlight: false,
|
||||
cropBoxMovable: true,
|
||||
cropBoxResizable: true,
|
||||
toggleDragModeOnDblclick: false,
|
||||
background: false
|
||||
});
|
||||
imgEl.src = objectUrl;
|
||||
|
||||
// Cropperjs must be initialised inside the image's load event so it can
|
||||
// measure the natural dimensions — if we call new Cropper() before the image
|
||||
// has loaded, the crop canvas is blank/invisible.
|
||||
const handleLoad = () => {
|
||||
cropper = new Cropper(imgEl!, {
|
||||
aspectRatio: 1,
|
||||
viewMode: 1,
|
||||
dragMode: 'move',
|
||||
autoCropArea: 0.8,
|
||||
restore: false,
|
||||
guides: false,
|
||||
center: true,
|
||||
highlight: false,
|
||||
cropBoxMovable: true,
|
||||
cropBoxResizable: true,
|
||||
toggleDragModeOnDblclick: false,
|
||||
background: false
|
||||
});
|
||||
};
|
||||
|
||||
imgEl.addEventListener('load', handleLoad, { once: true });
|
||||
|
||||
return () => {
|
||||
imgEl?.removeEventListener('load', handleLoad);
|
||||
cropper?.destroy();
|
||||
cropper = null;
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
objectUrl = '';
|
||||
};
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
cropper?.destroy();
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||
});
|
||||
|
||||
function confirm() {
|
||||
@@ -62,13 +85,14 @@
|
||||
<div class="bg-zinc-900 rounded-2xl border border-zinc-700 shadow-2xl w-full max-w-sm flex flex-col gap-4 p-5">
|
||||
<h2 class="text-base font-semibold text-zinc-100">Crop profile picture</h2>
|
||||
|
||||
<!-- Cropper image container -->
|
||||
<div class="rounded-xl overflow-hidden bg-zinc-800" style="max-height: 340px;">
|
||||
<!-- Cropper image container — overflow must be visible so cropperjs can
|
||||
render the crop canvas outside the natural image bounds. The fixed
|
||||
height gives cropperjs a stable container to size itself against. -->
|
||||
<div class="rounded-xl bg-zinc-800" style="height: 300px; position: relative;">
|
||||
<img
|
||||
bind:this={imgEl}
|
||||
src={objectUrl}
|
||||
alt="Crop preview"
|
||||
style="display:block; max-width:100%;"
|
||||
style="display:block; max-width:100%; max-height:100%;"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -8,39 +8,60 @@
|
||||
upvotes: number;
|
||||
downvotes: number;
|
||||
created: string;
|
||||
parent_id?: string;
|
||||
replies?: BookComment[];
|
||||
}
|
||||
|
||||
let {
|
||||
slug,
|
||||
isLoggedIn = false
|
||||
isLoggedIn = false,
|
||||
currentUserId = ''
|
||||
}: {
|
||||
slug: string;
|
||||
isLoggedIn?: boolean;
|
||||
currentUserId?: string;
|
||||
} = $props();
|
||||
|
||||
// ── State ─────────────────────────────────────────────────────────────────
|
||||
let comments = $state<BookComment[]>([]);
|
||||
let myVotes = $state<Record<string, 'up' | 'down'>>({});
|
||||
let avatarUrls = $state<Record<string, string>>({});
|
||||
let loading = $state(true);
|
||||
let loadError = $state('');
|
||||
|
||||
// Top-level new comment
|
||||
let newBody = $state('');
|
||||
let posting = $state(false);
|
||||
let postError = $state('');
|
||||
|
||||
// Sort
|
||||
let sort = $state<'new' | 'top'>('top');
|
||||
|
||||
// Reply state: which comment is being replied to
|
||||
let replyingTo = $state<string | null>(null); // comment id
|
||||
let replyBody = $state('');
|
||||
let replyPosting = $state(false);
|
||||
let replyError = $state('');
|
||||
|
||||
// Delete in-flight set
|
||||
let deletingIds = $state(new Set<string>());
|
||||
|
||||
// Per-comment vote inflight set (prevents double-clicks)
|
||||
let votingIds = $state(new Set<string>());
|
||||
|
||||
// ── Load comments on mount ────────────────────────────────────────────────
|
||||
// ── Load comments ─────────────────────────────────────────────────────────
|
||||
async function loadComments() {
|
||||
loading = true;
|
||||
loadError = '';
|
||||
try {
|
||||
const res = await fetch(`/api/comments/${encodeURIComponent(slug)}`);
|
||||
const res = await fetch(
|
||||
`/api/comments/${encodeURIComponent(slug)}?sort=${sort}`
|
||||
);
|
||||
if (!res.ok) throw new Error(`${res.status}`);
|
||||
const data = await res.json();
|
||||
comments = data.comments ?? [];
|
||||
myVotes = data.myVotes ?? {};
|
||||
avatarUrls = data.avatarUrls ?? {};
|
||||
} catch (e) {
|
||||
loadError = 'Failed to load comments.';
|
||||
} finally {
|
||||
@@ -48,19 +69,24 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Run once on component mount via $effect
|
||||
$effect(() => {
|
||||
loadComments();
|
||||
});
|
||||
|
||||
// ── Post comment ──────────────────────────────────────────────────────────
|
||||
// Re-load when sort changes (after initial mount)
|
||||
let firstLoad = true;
|
||||
$effect(() => {
|
||||
// Read sort to create a dependency
|
||||
const _ = sort;
|
||||
if (firstLoad) { firstLoad = false; return; }
|
||||
loadComments();
|
||||
});
|
||||
|
||||
// ── Post top-level comment ────────────────────────────────────────────────
|
||||
async function postComment() {
|
||||
const text = newBody.trim();
|
||||
if (!text || posting) return;
|
||||
if (text.length > 2000) {
|
||||
postError = 'Comment is too long (max 2000 characters).';
|
||||
return;
|
||||
}
|
||||
if (text.length > 2000) { postError = 'Comment is too long (max 2000 characters).'; return; }
|
||||
posting = true;
|
||||
postError = '';
|
||||
try {
|
||||
@@ -69,17 +95,20 @@
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ body: text })
|
||||
});
|
||||
if (res.status === 401) {
|
||||
postError = 'You must be logged in to comment.';
|
||||
return;
|
||||
}
|
||||
if (res.status === 401) { postError = 'You must be logged in to comment.'; return; }
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
postError = err.message ?? 'Failed to post comment.';
|
||||
return;
|
||||
}
|
||||
const created: BookComment = await res.json();
|
||||
comments = [created, ...comments];
|
||||
created.replies = [];
|
||||
// Prepend for 'new', or re-sort for 'top'
|
||||
if (sort === 'new') {
|
||||
comments = [created, ...comments];
|
||||
} else {
|
||||
comments = [created, ...comments]; // new comment has 0 score, goes to end after sort would happen
|
||||
}
|
||||
newBody = '';
|
||||
} catch {
|
||||
postError = 'Failed to post comment.';
|
||||
@@ -88,8 +117,66 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ── Post reply ────────────────────────────────────────────────────────────
|
||||
async function postReply(parentId: string) {
|
||||
const text = replyBody.trim();
|
||||
if (!text || replyPosting) return;
|
||||
if (text.length > 2000) { replyError = 'Reply is too long (max 2000 characters).'; return; }
|
||||
replyPosting = true;
|
||||
replyError = '';
|
||||
try {
|
||||
const res = await fetch(`/api/comments/${encodeURIComponent(slug)}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ body: text, parent_id: parentId })
|
||||
});
|
||||
if (res.status === 401) { replyError = 'You must be logged in to reply.'; return; }
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
replyError = err.message ?? 'Failed to post reply.';
|
||||
return;
|
||||
}
|
||||
const created: BookComment = await res.json();
|
||||
// Append to the parent's replies list
|
||||
comments = comments.map((c) => {
|
||||
if (c.id !== parentId) return c;
|
||||
return { ...c, replies: [...(c.replies ?? []), created] };
|
||||
});
|
||||
replyBody = '';
|
||||
replyingTo = null;
|
||||
} catch {
|
||||
replyError = 'Failed to post reply.';
|
||||
} finally {
|
||||
replyPosting = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Delete ────────────────────────────────────────────────────────────────
|
||||
async function deleteComment(commentId: string, parentId?: string) {
|
||||
if (deletingIds.has(commentId)) return;
|
||||
deletingIds = new Set([...deletingIds, commentId]);
|
||||
try {
|
||||
const res = await fetch(`/api/comments/${commentId}`, { method: 'DELETE' });
|
||||
if (!res.ok) return;
|
||||
if (parentId) {
|
||||
// Remove reply from parent
|
||||
comments = comments.map((c) => {
|
||||
if (c.id !== parentId) return c;
|
||||
return { ...c, replies: (c.replies ?? []).filter((r) => r.id !== commentId) };
|
||||
});
|
||||
} else {
|
||||
// Remove top-level comment
|
||||
comments = comments.filter((c) => c.id !== commentId);
|
||||
}
|
||||
} finally {
|
||||
const next = new Set(deletingIds);
|
||||
next.delete(commentId);
|
||||
deletingIds = next;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Vote ──────────────────────────────────────────────────────────────────
|
||||
async function vote(commentId: string, v: 'up' | 'down') {
|
||||
async function vote(commentId: string, v: 'up' | 'down', parentId?: string) {
|
||||
if (votingIds.has(commentId)) return;
|
||||
votingIds = new Set([...votingIds, commentId]);
|
||||
try {
|
||||
@@ -100,8 +187,18 @@
|
||||
});
|
||||
if (!res.ok) return;
|
||||
const updated: BookComment = await res.json();
|
||||
// Update comment in list
|
||||
comments = comments.map((c) => (c.id === commentId ? updated : c));
|
||||
// Update comment in list (handle both top-level and replies)
|
||||
if (parentId) {
|
||||
comments = comments.map((c) => {
|
||||
if (c.id !== parentId) return c;
|
||||
return {
|
||||
...c,
|
||||
replies: (c.replies ?? []).map((r) => (r.id === commentId ? updated : r))
|
||||
};
|
||||
});
|
||||
} else {
|
||||
comments = comments.map((c) => (c.id === commentId ? { ...updated, replies: c.replies } : c));
|
||||
}
|
||||
// Update myVotes: toggle off if same, else set new vote
|
||||
const prev = myVotes[commentId];
|
||||
if (prev === v) {
|
||||
@@ -119,13 +216,24 @@
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
function initials(username: string): string {
|
||||
const name = username.trim() || '?';
|
||||
return name.slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
try {
|
||||
return new Date(iso).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
const date = new Date(iso);
|
||||
const now = Date.now();
|
||||
const diffMs = now - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60_000);
|
||||
if (diffMins < 1) return 'just now';
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
if (diffDays < 30) return `${diffDays}d ago`;
|
||||
return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
@@ -133,15 +241,46 @@
|
||||
|
||||
const charCount = $derived(newBody.length);
|
||||
const charOver = $derived(charCount > 2000);
|
||||
const replyCharCount = $derived(replyBody.length);
|
||||
const replyCharOver = $derived(replyCharCount > 2000);
|
||||
|
||||
const totalCount = $derived(
|
||||
comments.reduce((n, c) => n + 1 + (c.replies?.length ?? 0), 0)
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="mt-10">
|
||||
<h2 class="text-base font-semibold text-zinc-200 mb-4">
|
||||
Comments
|
||||
<!-- Header + sort controls -->
|
||||
<div class="flex items-center justify-between gap-3 mb-4 flex-wrap">
|
||||
<h2 class="text-base font-semibold text-zinc-200">
|
||||
Comments
|
||||
{#if !loading && totalCount > 0}
|
||||
<span class="text-zinc-500 font-normal text-sm ml-1">({totalCount})</span>
|
||||
{/if}
|
||||
</h2>
|
||||
|
||||
<!-- Sort tabs -->
|
||||
{#if !loading && comments.length > 0}
|
||||
<span class="text-zinc-500 font-normal text-sm ml-1">({comments.length})</span>
|
||||
<div class="flex items-center gap-1 text-xs rounded-lg bg-zinc-800/60 p-1">
|
||||
<button
|
||||
onclick={() => (sort = 'top')}
|
||||
class="px-2.5 py-1 rounded-md transition-colors {sort === 'top'
|
||||
? 'bg-zinc-700 text-zinc-100'
|
||||
: 'text-zinc-500 hover:text-zinc-300'}"
|
||||
>
|
||||
Top
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (sort = 'new')}
|
||||
class="px-2.5 py-1 rounded-md transition-colors {sort === 'new'
|
||||
? 'bg-zinc-700 text-zinc-100'
|
||||
: 'text-zinc-500 hover:text-zinc-300'}"
|
||||
>
|
||||
New
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<!-- Post form -->
|
||||
<div class="mb-6">
|
||||
@@ -202,9 +341,19 @@
|
||||
{#each comments as comment (comment.id)}
|
||||
{@const myVote = myVotes[comment.id]}
|
||||
{@const voting = votingIds.has(comment.id)}
|
||||
<div class="rounded-lg bg-zinc-800/50 border border-zinc-700/50 px-4 py-3 flex flex-col gap-2">
|
||||
<!-- Header: username + date -->
|
||||
{@const deleting = deletingIds.has(comment.id)}
|
||||
{@const isOwner = isLoggedIn && currentUserId === comment.user_id}
|
||||
|
||||
<div class="rounded-lg bg-zinc-800/50 border border-zinc-700/50 px-4 py-3 flex flex-col gap-2 {deleting ? 'opacity-50' : ''}">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
{#if avatarUrls[comment.user_id]}
|
||||
<img src={avatarUrls[comment.user_id]} alt={comment.username} class="w-6 h-6 rounded-full object-cover flex-shrink-0" />
|
||||
{:else}
|
||||
<div class="w-6 h-6 rounded-full bg-zinc-700 flex items-center justify-center flex-shrink-0">
|
||||
<span class="text-[9px] font-semibold text-zinc-300 leading-none">{initials(comment.username)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
<span class="text-sm font-medium text-zinc-200">{comment.username || 'Anonymous'}</span>
|
||||
<span class="text-zinc-600 text-xs">·</span>
|
||||
<span class="text-xs text-zinc-500">{formatDate(comment.created)}</span>
|
||||
@@ -213,17 +362,15 @@
|
||||
<!-- Body -->
|
||||
<p class="text-sm text-zinc-300 leading-relaxed whitespace-pre-wrap break-words">{comment.body}</p>
|
||||
|
||||
<!-- Vote row -->
|
||||
<div class="flex items-center gap-3 pt-1">
|
||||
<!-- Actions row: votes + reply + delete -->
|
||||
<div class="flex items-center gap-3 pt-1 flex-wrap">
|
||||
<!-- Upvote -->
|
||||
<button
|
||||
onclick={() => vote(comment.id, 'up')}
|
||||
disabled={voting}
|
||||
title="Upvote"
|
||||
class="flex items-center gap-1 text-xs transition-colors disabled:opacity-50
|
||||
{myVote === 'up'
|
||||
? 'text-amber-400'
|
||||
: 'text-zinc-500 hover:text-zinc-300'}"
|
||||
{myVote === 'up' ? 'text-amber-400' : 'text-zinc-500 hover:text-zinc-300'}"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M14 10h4.764a2 2 0 011.789 2.894l-3.5 7A2 2 0 0115.263 21h-4.017c-.163 0-.326-.02-.485-.06L7 20m7-10V5a2 2 0 00-2-2h-.095c-.5 0-.905.405-.905.905 0 .714-.211 1.412-.608 2.006L7 11v9m7-10h-2M7 20H5a2 2 0 01-2-2v-6a2 2 0 012-2h2.5"/>
|
||||
@@ -237,16 +384,167 @@
|
||||
disabled={voting}
|
||||
title="Downvote"
|
||||
class="flex items-center gap-1 text-xs transition-colors disabled:opacity-50
|
||||
{myVote === 'down'
|
||||
? 'text-red-400'
|
||||
: 'text-zinc-500 hover:text-zinc-300'}"
|
||||
{myVote === 'down' ? 'text-red-400' : 'text-zinc-500 hover:text-zinc-300'}"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10 14H5.236a2 2 0 01-1.789-2.894l3.5-7A2 2 0 018.736 3h4.018a2 2 0 01.485.06l3.76.94m-7 10v5a2 2 0 002 2h.096c.5 0 .905-.405.905-.904 0-.715.211-1.413.608-2.008L17 13V4m-7 10h2m5-10h2a2 2 0 012 2v6a2 2 0 01-2 2h-2.5"/>
|
||||
</svg>
|
||||
<span class="tabular-nums">{comment.downvotes ?? 0}</span>
|
||||
</button>
|
||||
|
||||
<!-- Reply button -->
|
||||
{#if isLoggedIn}
|
||||
<button
|
||||
onclick={() => {
|
||||
if (replyingTo === comment.id) {
|
||||
replyingTo = null;
|
||||
replyBody = '';
|
||||
replyError = '';
|
||||
} else {
|
||||
replyingTo = comment.id;
|
||||
replyBody = '';
|
||||
replyError = '';
|
||||
}
|
||||
}}
|
||||
class="flex items-center gap-1 text-xs transition-colors
|
||||
{replyingTo === comment.id
|
||||
? 'text-amber-400'
|
||||
: 'text-zinc-500 hover:text-zinc-300'}"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"/>
|
||||
</svg>
|
||||
Reply
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Delete (owner only) -->
|
||||
{#if isOwner}
|
||||
<button
|
||||
onclick={() => deleteComment(comment.id)}
|
||||
disabled={deleting}
|
||||
class="flex items-center gap-1 text-xs text-zinc-600 hover:text-red-400 transition-colors disabled:opacity-50 ml-auto"
|
||||
title="Delete comment"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
||||
</svg>
|
||||
Delete
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Inline reply form -->
|
||||
{#if replyingTo === comment.id}
|
||||
<div class="mt-1 flex flex-col gap-2 pl-2 border-l-2 border-zinc-700">
|
||||
<textarea
|
||||
bind:value={replyBody}
|
||||
placeholder="Write a reply…"
|
||||
rows="2"
|
||||
class="w-full px-3 py-2 rounded-lg bg-zinc-900 border border-zinc-700 text-zinc-200 text-sm placeholder-zinc-500 resize-none focus:outline-none focus:border-amber-400 transition-colors"
|
||||
></textarea>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-xs {replyCharOver ? 'text-red-400' : 'text-zinc-600'} tabular-nums">
|
||||
{replyCharCount}/2000
|
||||
</span>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if replyError}
|
||||
<span class="text-xs text-red-400">{replyError}</span>
|
||||
{/if}
|
||||
<button
|
||||
onclick={() => { replyingTo = null; replyBody = ''; replyError = ''; }}
|
||||
class="px-3 py-1 rounded-lg text-xs text-zinc-400 hover:text-zinc-200 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onclick={() => postReply(comment.id)}
|
||||
disabled={replyPosting || !replyBody.trim() || replyCharOver}
|
||||
class="px-3 py-1.5 rounded-lg text-xs font-medium transition-colors
|
||||
{replyPosting || !replyBody.trim() || replyCharOver
|
||||
? 'bg-zinc-700 text-zinc-500 cursor-not-allowed'
|
||||
: 'bg-amber-400 text-zinc-900 hover:bg-amber-300'}"
|
||||
>
|
||||
{replyPosting ? 'Posting…' : 'Reply'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Replies -->
|
||||
{#if comment.replies && comment.replies.length > 0}
|
||||
<div class="mt-1 flex flex-col gap-2 pl-3 border-l-2 border-zinc-700/60">
|
||||
{#each comment.replies as reply (reply.id)}
|
||||
{@const replyVote = myVotes[reply.id]}
|
||||
{@const replyVoting = votingIds.has(reply.id)}
|
||||
{@const replyDeleting = deletingIds.has(reply.id)}
|
||||
{@const replyIsOwner = isLoggedIn && currentUserId === reply.user_id}
|
||||
|
||||
<div class="rounded-md bg-zinc-800/30 px-3 py-2.5 flex flex-col gap-1.5 {replyDeleting ? 'opacity-50' : ''}">
|
||||
<!-- Reply header -->
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
{#if avatarUrls[reply.user_id]}
|
||||
<img src={avatarUrls[reply.user_id]} alt={reply.username} class="w-5 h-5 rounded-full object-cover flex-shrink-0" />
|
||||
{:else}
|
||||
<div class="w-5 h-5 rounded-full bg-zinc-700 flex items-center justify-center flex-shrink-0">
|
||||
<span class="text-[8px] font-semibold text-zinc-300 leading-none">{initials(reply.username)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
<span class="text-xs font-medium text-zinc-300">{reply.username || 'Anonymous'}</span>
|
||||
<span class="text-zinc-600 text-xs">·</span>
|
||||
<span class="text-xs text-zinc-500">{formatDate(reply.created)}</span>
|
||||
</div>
|
||||
|
||||
<!-- Reply body -->
|
||||
<p class="text-sm text-zinc-300 leading-relaxed whitespace-pre-wrap break-words">{reply.body}</p>
|
||||
|
||||
<!-- Reply actions -->
|
||||
<div class="flex items-center gap-3 pt-0.5">
|
||||
<button
|
||||
onclick={() => vote(reply.id, 'up', comment.id)}
|
||||
disabled={replyVoting}
|
||||
title="Upvote"
|
||||
class="flex items-center gap-1 text-xs transition-colors disabled:opacity-50
|
||||
{replyVote === 'up' ? 'text-amber-400' : 'text-zinc-500 hover:text-zinc-300'}"
|
||||
>
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M14 10h4.764a2 2 0 011.789 2.894l-3.5 7A2 2 0 0115.263 21h-4.017c-.163 0-.326-.02-.485-.06L7 20m7-10V5a2 2 0 00-2-2h-.095c-.5 0-.905.405-.905.905 0 .714-.211 1.412-.608 2.006L7 11v9m7-10h-2M7 20H5a2 2 0 01-2-2v-6a2 2 0 012-2h2.5"/>
|
||||
</svg>
|
||||
<span class="tabular-nums">{reply.upvotes ?? 0}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onclick={() => vote(reply.id, 'down', comment.id)}
|
||||
disabled={replyVoting}
|
||||
title="Downvote"
|
||||
class="flex items-center gap-1 text-xs transition-colors disabled:opacity-50
|
||||
{replyVote === 'down' ? 'text-red-400' : 'text-zinc-500 hover:text-zinc-300'}"
|
||||
>
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10 14H5.236a2 2 0 01-1.789-2.894l3.5-7A2 2 0 018.736 3h4.018a2 2 0 01.485.06l3.76.94m-7 10v5a2 2 0 002 2h.096c.5 0 .905-.405.905-.904 0-.715.211-1.413.608-2.008L17 13V4m-7 10h2m5-10h2a2 2 0 012 2v6a2 2 0 01-2 2h-2.5"/>
|
||||
</svg>
|
||||
<span class="tabular-nums">{reply.downvotes ?? 0}</span>
|
||||
</button>
|
||||
|
||||
{#if replyIsOwner}
|
||||
<button
|
||||
onclick={() => deleteComment(reply.id, comment.id)}
|
||||
disabled={replyDeleting}
|
||||
class="flex items-center gap-1 text-xs text-zinc-600 hover:text-red-400 transition-colors disabled:opacity-50 ml-auto"
|
||||
title="Delete reply"
|
||||
>
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
||||
</svg>
|
||||
Delete
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -823,6 +823,7 @@ export interface BookComment {
|
||||
upvotes: number;
|
||||
downvotes: number;
|
||||
created: string;
|
||||
parent_id?: string; // empty / absent = top-level; set = reply
|
||||
}
|
||||
|
||||
export interface CommentVote {
|
||||
@@ -833,14 +834,54 @@ export interface CommentVote {
|
||||
vote: 'up' | 'down';
|
||||
}
|
||||
|
||||
export type CommentSort = 'top' | 'new';
|
||||
|
||||
/**
|
||||
* List comments for a book, newest first, up to 100.
|
||||
* List top-level comments for a book.
|
||||
* sort='top' → by net score (upvotes − downvotes) desc, then newest
|
||||
* sort='new' → newest first (default)
|
||||
* Replies (parent_id != "") are NOT included — fetch them separately.
|
||||
*/
|
||||
export async function listComments(slug: string): Promise<BookComment[]> {
|
||||
export async function listComments(
|
||||
slug: string,
|
||||
sort: CommentSort = 'new'
|
||||
): Promise<BookComment[]> {
|
||||
const token = await getToken();
|
||||
const filter = encodeURIComponent(`slug="${slug.replace(/"/g, '\\"')}"`);
|
||||
const slugEsc = slug.replace(/"/g, '\\"');
|
||||
// Only top-level comments (parent_id is empty or missing)
|
||||
const filter = encodeURIComponent(`slug="${slugEsc}"&&(parent_id=""||parent_id=null)`);
|
||||
// PocketBase sorts: for 'top' we still fetch all and re-sort in JS because
|
||||
// PocketBase doesn't support computed sort fields. For 'new' we push the
|
||||
// sort down to the DB so large result sets are still paged correctly.
|
||||
const pbSort = sort === 'new' ? '&sort=-created' : '&sort=-created';
|
||||
const res = await fetch(
|
||||
`${PB_URL}/api/collections/book_comments/records?filter=${filter}&sort=-created&perPage=100`,
|
||||
`${PB_URL}/api/collections/book_comments/records?filter=${filter}${pbSort}&perPage=200`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } }
|
||||
);
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
let items = (data.items ?? []) as BookComment[];
|
||||
if (sort === 'top') {
|
||||
items = items.sort((a, b) => {
|
||||
const scoreB = (b.upvotes ?? 0) - (b.downvotes ?? 0);
|
||||
const scoreA = (a.upvotes ?? 0) - (a.downvotes ?? 0);
|
||||
if (scoreB !== scoreA) return scoreB - scoreA;
|
||||
// tie-break: newest first
|
||||
return new Date(b.created).getTime() - new Date(a.created).getTime();
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* List replies (1-level deep) for a single parent comment.
|
||||
* Always sorted oldest-first so the conversation reads naturally.
|
||||
*/
|
||||
export async function listReplies(parentId: string): Promise<BookComment[]> {
|
||||
const token = await getToken();
|
||||
const filter = encodeURIComponent(`parent_id="${parentId.replace(/"/g, '\\"')}"`);
|
||||
const res = await fetch(
|
||||
`${PB_URL}/api/collections/book_comments/records?filter=${filter}&sort=created&perPage=100`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } }
|
||||
);
|
||||
if (!res.ok) return [];
|
||||
@@ -850,12 +891,14 @@ export async function listComments(slug: string): Promise<BookComment[]> {
|
||||
|
||||
/**
|
||||
* Create a new comment. Returns the created record.
|
||||
* Pass parentId to create a reply; omit / pass undefined for a top-level comment.
|
||||
*/
|
||||
export async function createComment(
|
||||
slug: string,
|
||||
body: string,
|
||||
userId: string | undefined,
|
||||
username: string
|
||||
username: string,
|
||||
parentId?: string
|
||||
): Promise<BookComment> {
|
||||
const token = await getToken();
|
||||
const res = await fetch(`${PB_URL}/api/collections/book_comments/records`, {
|
||||
@@ -868,6 +911,7 @@ export async function createComment(
|
||||
username,
|
||||
upvotes: 0,
|
||||
downvotes: 0,
|
||||
parent_id: parentId ?? '',
|
||||
created: new Date().toISOString()
|
||||
})
|
||||
});
|
||||
@@ -878,6 +922,49 @@ export async function createComment(
|
||||
return res.json() as Promise<BookComment>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a comment (and optionally its replies) by ID.
|
||||
* Only the comment owner (matched by userId) may delete.
|
||||
* Throws if the comment doesn't exist or the user doesn't own it.
|
||||
*/
|
||||
export async function deleteComment(commentId: string, userId: string): Promise<void> {
|
||||
const token = await getToken();
|
||||
|
||||
// Fetch the comment to verify ownership
|
||||
const getRes = await fetch(`${PB_URL}/api/collections/book_comments/records/${commentId}`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
if (!getRes.ok) throw new Error(`Comment not found: ${commentId}`);
|
||||
const comment = (await getRes.json()) as BookComment;
|
||||
if (comment.user_id !== userId) throw new Error('Not authorized to delete this comment');
|
||||
|
||||
// Delete any replies first
|
||||
const repliesFilter = encodeURIComponent(`parent_id="${commentId.replace(/"/g, '\\"')}"`);
|
||||
const repliesRes = await fetch(
|
||||
`${PB_URL}/api/collections/book_comments/records?filter=${repliesFilter}&perPage=100`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } }
|
||||
);
|
||||
if (repliesRes.ok) {
|
||||
const repliesData = await repliesRes.json();
|
||||
const replies = (repliesData.items ?? []) as BookComment[];
|
||||
await Promise.all(
|
||||
replies.map((r) =>
|
||||
fetch(`${PB_URL}/api/collections/book_comments/records/${r.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Delete the comment itself
|
||||
const delRes = await fetch(`${PB_URL}/api/collections/book_comments/records/${commentId}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
if (!delRes.ok) throw new Error(`deleteComment failed: ${delRes.status}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an existing vote by this voter (identified by user_id or session_id) on a comment.
|
||||
*/
|
||||
|
||||
25
ui/src/routes/api/comments/[id]/+server.ts
Normal file
25
ui/src/routes/api/comments/[id]/+server.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { deleteComment } from '$lib/server/pocketbase';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
/**
|
||||
* DELETE /api/comments/[id]
|
||||
* Deletes a comment (and its replies) by ID.
|
||||
* Requires authentication — only the comment owner can delete their own comment.
|
||||
*/
|
||||
export const DELETE: RequestHandler = async ({ params, locals }) => {
|
||||
if (!locals.user) error(401, 'Login required');
|
||||
|
||||
const { id } = params;
|
||||
try {
|
||||
await deleteComment(id, locals.user.id);
|
||||
return json({ ok: true });
|
||||
} catch (e) {
|
||||
const msg = String(e);
|
||||
if (msg.includes('Not authorized')) error(403, 'Not authorized to delete this comment');
|
||||
if (msg.includes('not found')) error(404, 'Comment not found');
|
||||
log.error('api/comments/[id]', 'deleteComment failed', { id, err: msg });
|
||||
error(500, 'Failed to delete comment');
|
||||
}
|
||||
};
|
||||
@@ -1,23 +1,62 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { listComments, createComment, getMyVotes } from '$lib/server/pocketbase';
|
||||
import {
|
||||
listComments,
|
||||
listReplies,
|
||||
createComment,
|
||||
getMyVotes,
|
||||
type CommentSort
|
||||
} from '$lib/server/pocketbase';
|
||||
import { presignAvatarUrl } from '$lib/server/minio';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
/**
|
||||
* GET /api/comments/[slug]
|
||||
* Returns comments for a book + the current visitor's votes.
|
||||
* Response: { comments: BookComment[], myVotes: Record<string, 'up'|'down'> }
|
||||
* GET /api/comments/[slug]?sort=new|top
|
||||
* Returns top-level comments + their replies + current visitor's votes + avatar URLs.
|
||||
* Response: { comments: BookComment[], myVotes: Record<string, 'up'|'down'>, avatarUrls: Record<string, string> }
|
||||
* Each top-level comment has a `replies` array attached.
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ params, locals }) => {
|
||||
export const GET: RequestHandler = async ({ params, url, locals }) => {
|
||||
const { slug } = params;
|
||||
const sortParam = url.searchParams.get('sort') ?? 'new';
|
||||
const sort: CommentSort = sortParam === 'top' ? 'top' : 'new';
|
||||
|
||||
try {
|
||||
const comments = await listComments(slug);
|
||||
const myVotes = await getMyVotes(
|
||||
comments.map((c) => c.id),
|
||||
locals.sessionId,
|
||||
locals.user?.id
|
||||
const topLevel = await listComments(slug, sort);
|
||||
|
||||
// Fetch replies for all top-level comments in parallel
|
||||
const repliesPerComment = await Promise.all(topLevel.map((c) => listReplies(c.id)));
|
||||
const allReplies = repliesPerComment.flat();
|
||||
|
||||
// Build comment+reply list for vote lookup
|
||||
const allIds = [...topLevel.map((c) => c.id), ...allReplies.map((r) => r.id)];
|
||||
const myVotes = await getMyVotes(allIds, locals.sessionId, locals.user?.id);
|
||||
|
||||
// Attach replies to each top-level comment
|
||||
const comments = topLevel.map((c, i) => ({
|
||||
...c,
|
||||
replies: repliesPerComment[i]
|
||||
}));
|
||||
|
||||
// Batch-resolve avatar presign URLs for all unique user_ids
|
||||
const allComments = [...topLevel, ...allReplies];
|
||||
const uniqueUserIds = [...new Set(allComments.map((c) => c.user_id).filter(Boolean))];
|
||||
const avatarEntries = await Promise.all(
|
||||
uniqueUserIds.map(async (userId) => {
|
||||
try {
|
||||
const url = await presignAvatarUrl(userId);
|
||||
return [userId, url] as [string, string | null];
|
||||
} catch {
|
||||
return [userId, null] as [string, null];
|
||||
}
|
||||
})
|
||||
);
|
||||
return json({ comments, myVotes });
|
||||
const avatarUrls: Record<string, string> = {};
|
||||
for (const [userId, url] of avatarEntries) {
|
||||
if (url) avatarUrls[userId] = url;
|
||||
}
|
||||
|
||||
return json({ comments, myVotes, avatarUrls });
|
||||
} catch (e) {
|
||||
log.error('api/comments/[slug]', 'listComments failed', { slug, err: String(e) });
|
||||
error(500, 'Failed to load comments');
|
||||
@@ -26,14 +65,14 @@ export const GET: RequestHandler = async ({ params, locals }) => {
|
||||
|
||||
/**
|
||||
* POST /api/comments/[slug]
|
||||
* Body: { body: string }
|
||||
* Creates a new comment. Requires authentication.
|
||||
* Body: { body: string, parent_id?: string }
|
||||
* Creates a new comment or reply. Requires authentication.
|
||||
*/
|
||||
export const POST: RequestHandler = async ({ params, request, locals }) => {
|
||||
if (!locals.user) error(401, 'Login required to comment');
|
||||
|
||||
const { slug } = params;
|
||||
let body: { body?: string };
|
||||
let body: { body?: string; parent_id?: string };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
@@ -44,8 +83,17 @@ export const POST: RequestHandler = async ({ params, request, locals }) => {
|
||||
if (!text) error(400, 'Comment body is required');
|
||||
if (text.length > 2000) error(400, 'Comment is too long (max 2000 characters)');
|
||||
|
||||
// Enforce 1-level depth: parent_id must be a top-level comment
|
||||
const parentId = body.parent_id?.trim() || undefined;
|
||||
|
||||
try {
|
||||
const comment = await createComment(slug, text, locals.user.id, locals.user.username);
|
||||
const comment = await createComment(
|
||||
slug,
|
||||
text,
|
||||
locals.user.id,
|
||||
locals.user.username,
|
||||
parentId
|
||||
);
|
||||
return json(comment, { status: 201 });
|
||||
} catch (e) {
|
||||
log.error('api/comments/[slug]', 'createComment failed', { slug, err: String(e) });
|
||||
|
||||
@@ -43,7 +43,9 @@ export const load: PageServerLoad = async ({ params, locals }) => {
|
||||
inLib: true,
|
||||
saved,
|
||||
lastChapter: progress?.chapter ?? null,
|
||||
isAdmin: locals.user?.role === 'admin'
|
||||
isAdmin: locals.user?.role === 'admin',
|
||||
isLoggedIn: !!locals.user,
|
||||
currentUserId: locals.user?.id ?? ''
|
||||
};
|
||||
}
|
||||
|
||||
@@ -93,7 +95,9 @@ export const load: PageServerLoad = async ({ params, locals }) => {
|
||||
inLib: preview.in_lib,
|
||||
saved: false,
|
||||
lastChapter: null,
|
||||
isAdmin: locals.user?.role === 'admin'
|
||||
isAdmin: locals.user?.role === 'admin',
|
||||
isLoggedIn: !!locals.user,
|
||||
currentUserId: locals.user?.id ?? ''
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof Error && 'status' in e) throw e;
|
||||
|
||||
@@ -528,4 +528,4 @@
|
||||
</div>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════ Comments ══ -->
|
||||
<CommentsSection slug={data.book.slug} isLoggedIn={true} />
|
||||
<CommentsSection slug={data.book.slug} isLoggedIn={data.isLoggedIn} currentUserId={data.currentUserId} />
|
||||
|
||||
Reference in New Issue
Block a user