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

- 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:
Admin
2026-03-10 20:05:31 +05:00
parent 718bfa6691
commit 09cdda2a07
14 changed files with 1028 additions and 206 deletions

View 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');
}
};

View File

@@ -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) });