diff --git a/ui/src/lib/components/CommentsSection.svelte b/ui/src/lib/components/CommentsSection.svelte index dfda4bb..a757c98 100644 --- a/ui/src/lib/components/CommentsSection.svelte +++ b/ui/src/lib/components/CommentsSection.svelte @@ -6,10 +6,12 @@ import * as m from '$lib/paraglide/messages.js'; let { slug, + chapter = 0, isLoggedIn = false, currentUserId = '' }: { slug: string; + chapter?: number; // 0 = book-level, N = chapter N isLoggedIn?: boolean; currentUserId?: string; } = $props(); @@ -47,7 +49,7 @@ loadError = ''; try { const res = await fetch( - `/api/comments/${encodeURIComponent(slug)}?sort=${sort}` + `/api/comments/${encodeURIComponent(slug)}?sort=${sort}${chapter > 0 ? `&chapter=${chapter}` : ''}` ); if (!res.ok) throw new Error(`${res.status}`); const data = await res.json(); @@ -85,7 +87,7 @@ const res = await fetch(`/api/comments/${encodeURIComponent(slug)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ body: text }) + body: JSON.stringify({ body: text, ...(chapter > 0 ? { chapter } : {}) }) }); if (res.status === 401) { postError = 'You must be logged in to comment.'; return; } if (!res.ok) { diff --git a/ui/src/lib/server/pocketbase.ts b/ui/src/lib/server/pocketbase.ts index ed59e88..fe5a9ef 100644 --- a/ui/src/lib/server/pocketbase.ts +++ b/ui/src/lib/server/pocketbase.ts @@ -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 { 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 { + 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 { 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, diff --git a/ui/src/routes/api/comments/[slug]/+server.ts b/ui/src/routes/api/comments/[slug]/+server.ts index cce8cf2..711da7c 100644 --- a/ui/src/routes/api/comments/[slug]/+server.ts +++ b/ui/src/routes/api/comments/[slug]/+server.ts @@ -21,9 +21,10 @@ 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'; + const chapter = parseInt(url.searchParams.get('chapter') ?? '0', 10) || 0; try { - const topLevel = await listComments(slug, sort); + const topLevel = await listComments(slug, sort, chapter); // Fetch replies for all top-level comments in parallel const repliesPerComment = await Promise.all(topLevel.map((c) => listReplies(c.id))); @@ -75,7 +76,7 @@ export const POST: RequestHandler = async ({ params, request, locals }) => { if (!locals.user) error(401, 'Login required to comment'); const { slug } = params; - let body: { body?: string; parent_id?: string }; + let body: { body?: string; parent_id?: string; chapter?: number }; try { body = await request.json(); } catch { @@ -86,8 +87,8 @@ 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; + const chapter = typeof body.chapter === 'number' ? body.chapter : 0; try { const comment = await createComment( @@ -95,7 +96,8 @@ export const POST: RequestHandler = async ({ params, request, locals }) => { text, locals.user.id, locals.user.username, - parentId + parentId, + chapter ); return json(comment, { status: 201 }); } catch (e) { diff --git a/ui/src/routes/books/[slug]/+page.server.ts b/ui/src/routes/books/[slug]/+page.server.ts index 35dbf6b..f9e7ae3 100644 --- a/ui/src/routes/books/[slug]/+page.server.ts +++ b/ui/src/routes/books/[slug]/+page.server.ts @@ -1,6 +1,6 @@ import { error } from '@sveltejs/kit'; import type { PageServerLoad } from './$types'; -import { getBook, listChapterIdx, getProgress, isBookSaved } from '$lib/server/pocketbase'; +import { getBook, listChapterIdx, getProgress, isBookSaved, countReadersThisWeek } from '$lib/server/pocketbase'; import { log } from '$lib/server/logger'; import { backendFetch, type BookPreviewResponse } from '$lib/server/scraper'; @@ -15,12 +15,13 @@ export const load: PageServerLoad = async ({ params, locals }) => { if (book) { // Book is in the library — normal path - let chapters, progress, saved; + let chapters, progress, saved, readersThisWeek; try { - [chapters, progress, saved] = await Promise.all([ + [chapters, progress, saved, readersThisWeek] = await Promise.all([ listChapterIdx(slug), getProgress(locals.sessionId, slug, locals.user?.id), - isBookSaved(locals.sessionId, slug, locals.user?.id) + isBookSaved(locals.sessionId, slug, locals.user?.id), + countReadersThisWeek(slug) ]); } catch (e) { log.error('books', 'failed to load book page data', { slug, err: String(e) }); @@ -33,6 +34,7 @@ export const load: PageServerLoad = async ({ params, locals }) => { inLib: true, saved, lastChapter: progress?.chapter ?? null, + readersThisWeek, isAdmin: locals.user?.role === 'admin', isLoggedIn: !!locals.user, currentUserId: locals.user?.id ?? '', diff --git a/ui/src/routes/books/[slug]/+page.svelte b/ui/src/routes/books/[slug]/+page.svelte index 39b6c5d..afbc749 100644 --- a/ui/src/routes/books/[slug]/+page.svelte +++ b/ui/src/routes/books/[slug]/+page.svelte @@ -203,6 +203,12 @@ {#each genres as genre} {genre} {/each} + {#if data.readersThisWeek && data.readersThisWeek > 0} + + + {data.readersThisWeek} reading this week + + {/if} diff --git a/ui/src/routes/books/[slug]/chapters/[n]/+page.svelte b/ui/src/routes/books/[slug]/chapters/[n]/+page.svelte index faa27ab..eca687a 100644 --- a/ui/src/routes/books/[slug]/chapters/[n]/+page.svelte +++ b/ui/src/routes/books/[slug]/chapters/[n]/+page.svelte @@ -3,6 +3,7 @@ import { goto } from '$app/navigation'; import { page } from '$app/state'; import AudioPlayer from '$lib/components/AudioPlayer.svelte'; + import CommentsSection from '$lib/components/CommentsSection.svelte'; import type { PageData } from './$types'; import * as m from '$lib/paraglide/messages.js'; @@ -337,3 +338,13 @@ {/if} + + +
+ +