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

@@ -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.
*/