Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76de5eb491 |
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 ?? '',
|
||||
|
||||
@@ -203,6 +203,12 @@
|
||||
{#each genres as genre}
|
||||
<span class="text-xs px-2 py-0.5 rounded bg-(--color-surface-2) text-(--color-muted) border border-(--color-border)">{genre}</span>
|
||||
{/each}
|
||||
{#if data.readersThisWeek && data.readersThisWeek > 0}
|
||||
<span class="text-xs px-2 py-0.5 rounded bg-(--color-surface-2) text-(--color-muted) border border-(--color-border) flex items-center gap-1">
|
||||
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20"><path d="M10 12a2 2 0 100-4 2 2 0 000 4z"/><path fill-rule="evenodd" d="M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z" clip-rule="evenodd"/></svg>
|
||||
{data.readersThisWeek} reading this week
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Summary with expand toggle -->
|
||||
|
||||
@@ -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 @@
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Chapter comments -->
|
||||
<div class="mt-12">
|
||||
<CommentsSection
|
||||
slug={data.book.slug}
|
||||
chapter={data.chapter.number}
|
||||
isLoggedIn={!!page.data.user}
|
||||
currentUserId={page.data.user?.id ?? ''}
|
||||
/>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user