feat(reader): chapter comments + readers-this-week count
Some checks failed
CI / Backend (push) Successful in 48s
CI / UI (push) Failing after 22s
Release / Check ui (push) Failing after 33s
Release / Docker / ui (push) Has been skipped
Release / Test backend (push) Successful in 53s
Release / Docker / caddy (push) Successful in 48s
CI / Backend (pull_request) Successful in 44s
CI / UI (pull_request) Failing after 44s
Release / Docker / runner (push) Failing after 46s
Release / Docker / backend (push) Successful in 1m54s
Release / Gitea Release (push) Has been skipped

- CommentsSection now accepts a chapter prop and scopes comments to that chapter
- Chapter reader page mounts CommentsSection with current chapter number
- Book detail page shows rolling 7-day unique reader count badge
- API GET/POST pass chapter param; pocketbase listComments filters by chapter

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Admin
2026-03-30 19:34:12 +05:00
parent c6597c8d19
commit 76de5eb491
6 changed files with 67 additions and 21 deletions

View File

@@ -1130,6 +1130,7 @@ export async function updateUserAvatarUrl(userId: string, avatarUrl: string): Pr
export interface PBBookComment {
id: string;
slug: string;
chapter?: number; // 0 or absent = book-level; N = chapter N
user_id: string;
username: string;
body: string;
@@ -1150,25 +1151,26 @@ export interface CommentVote {
export type CommentSort = 'top' | 'new';
/**
* List top-level comments for a book.
* List top-level comments for a book or a specific chapter.
* chapter=0 (default) → book-level comments only
* chapter=N → comments for chapter N only
* 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,
sort: CommentSort = 'new'
sort: CommentSort = 'new',
chapter = 0
): Promise<PBBookComment[]> {
const token = await getToken();
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 chapterFilter = chapter > 0
? `&&chapter=${chapter}`
: `&&(chapter=0||chapter=null)`;
const filter = encodeURIComponent(`slug="${slugEsc}"${chapterFilter}&&(parent_id=""||parent_id=null)`);
const res = await fetch(
`${PB_URL}/api/collections/book_comments/records?filter=${filter}${pbSort}&perPage=200`,
`${PB_URL}/api/collections/book_comments/records?filter=${filter}&sort=-created&perPage=200`,
{ headers: { Authorization: `Bearer ${token}` } }
);
if (!res.ok) return [];
@@ -1179,13 +1181,32 @@ export async function listComments(
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;
}
/**
* Count unique readers for a book in the last 7 days.
* Uses progress.updated timestamp; counts both session-based and user-based.
*/
export async function countReadersThisWeek(slug: string): Promise<number> {
const token = await getToken();
const cutoff = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
const filter = encodeURIComponent(`slug="${slug.replace(/"/g, '\\"')}"&&updated>"${cutoff}"`);
const res = await fetch(
`${PB_URL}/api/collections/progress/records?filter=${filter}&perPage=500&fields=user_id,session_id`,
{ headers: { Authorization: `Bearer ${token}` } }
);
if (!res.ok) return 0;
const data = await res.json();
const items = (data.items ?? []) as { user_id?: string; session_id?: string }[];
// Deduplicate: prefer user_id when present, fall back to session_id
const unique = new Set(items.map((r) => r.user_id || r.session_id || '').filter(Boolean));
return unique.size;
}
/**
* List replies (1-level deep) for a single parent comment.
* Always sorted oldest-first so the conversation reads naturally.
@@ -1211,7 +1232,8 @@ export async function createComment(
body: string,
userId: string | undefined,
username: string,
parentId?: string
parentId?: string,
chapter = 0
): Promise<PBBookComment> {
const token = await getToken();
const res = await fetch(`${PB_URL}/api/collections/book_comments/records`, {
@@ -1219,6 +1241,7 @@ export async function createComment(
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
slug,
chapter,
body,
user_id: userId ?? '',
username,