From 83a5910a59ce8c4efe991b8ff504d6eb53c876f7 Mon Sep 17 00:00:00 2001 From: Admin Date: Tue, 10 Mar 2026 18:05:41 +0500 Subject: [PATCH] feat: book commenting system with upvote/downvote + fix profile SSR crash - Add book_comments and comment_votes PocketBase collections (pb-init.sh + pocketbase.go EnsureCollections) - Web: CommentsSection.svelte with post form, vote buttons, lazy-loaded per-book - API routes: GET/POST /api/comments/[slug], POST /api/comments/[id]/vote - iOS: BookComment + CommentsResponse models, fetchComments/postComment/voteComment in APIClient, CommentsView + CommentsViewModel wired into BookDetailView - Fix profile page SSR crash (ERR_MODULE_NOT_FOUND cropperjs): lazy-load AvatarCropModal via dynamic import guarded by browser, move URL.createObjectURL into onMount --- ios/LibNovel/LibNovel/Models/Models.swift | 40 +++ .../LibNovel/Networking/APIClient.swift | 28 ++ .../Views/BookDetail/BookDetailView.swift | 2 + .../Views/BookDetail/CommentsView.swift | 320 ++++++++++++++++++ scraper/internal/storage/pocketbase.go | 26 ++ scripts/pb-init.sh | 25 ++ ui/src/lib/components/AvatarCropModal.svelte | 3 +- ui/src/lib/components/CommentsSection.svelte | 254 ++++++++++++++ ui/src/lib/server/pocketbase.ts | 181 ++++++++++ .../routes/api/comments/[id]/vote/+server.ts | 33 ++ ui/src/routes/api/comments/[slug]/+server.ts | 54 +++ ui/src/routes/books/[slug]/+page.svelte | 4 + ui/src/routes/profile/+page.svelte | 16 +- 13 files changed, 978 insertions(+), 8 deletions(-) create mode 100644 ios/LibNovel/LibNovel/Views/BookDetail/CommentsView.swift create mode 100644 ui/src/lib/components/CommentsSection.svelte create mode 100644 ui/src/routes/api/comments/[id]/vote/+server.ts create mode 100644 ui/src/routes/api/comments/[slug]/+server.ts diff --git a/ios/LibNovel/LibNovel/Models/Models.swift b/ios/LibNovel/LibNovel/Models/Models.swift index d925716..a9933bc 100644 --- a/ios/LibNovel/LibNovel/Models/Models.swift +++ b/ios/LibNovel/LibNovel/Models/Models.swift @@ -254,6 +254,46 @@ struct BookBrief: Codable { let cover: String } +// MARK: - Comments + +struct BookComment: Identifiable, Codable, Hashable { + let id: String + let slug: String + let userId: String + let username: String + let body: String + var upvotes: Int + var downvotes: Int + let created: String + + enum CodingKeys: String, CodingKey { + case id, slug, username, body, upvotes, downvotes, created + case userId = "user_id" + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decode(String.self, forKey: .id) + slug = try c.decodeIfPresent(String.self, forKey: .slug) ?? "" + userId = try c.decodeIfPresent(String.self, forKey: .userId) ?? "" + username = try c.decodeIfPresent(String.self, forKey: .username) ?? "" + body = try c.decodeIfPresent(String.self, forKey: .body) ?? "" + upvotes = try c.decodeIfPresent(Int.self, forKey: .upvotes) ?? 0 + downvotes = try c.decodeIfPresent(Int.self, forKey: .downvotes) ?? 0 + created = try c.decodeIfPresent(String.self, forKey: .created) ?? "" + } +} + +struct CommentsResponse: Decodable { + let comments: [BookComment] + let myVotes: [String: String] + + enum CodingKeys: String, CodingKey { + case comments + case myVotes = "myVotes" + } +} + // MARK: - Audio enum NextPrefetchStatus { diff --git a/ios/LibNovel/LibNovel/Networking/APIClient.swift b/ios/LibNovel/LibNovel/Networking/APIClient.swift index 42f0baf..e85f28d 100644 --- a/ios/LibNovel/LibNovel/Networking/APIClient.swift +++ b/ios/LibNovel/LibNovel/Networking/APIClient.swift @@ -313,6 +313,34 @@ actor APIClient { ) return result.avatarURL } + + /// Fetches a fresh presigned GET URL for the current user's avatar. + /// Returns nil if the user has no avatar set. + /// Used on cold launch / session restore to convert the stored raw key into a viewable URL. + func fetchAvatarPresignedURL() async throws -> String? { + let result: AvatarResponse = try await fetch("/api/profile/avatar") + return result.avatarURL + } + + // MARK: - Comments + + func fetchComments(slug: String) async throws -> CommentsResponse { + try await fetch("/api/comments/\(slug)") + } + + struct PostCommentBody: Encodable { let body: String } + + func postComment(slug: String, body: String) async throws -> BookComment { + try await fetch("/api/comments/\(slug)", method: "POST", body: PostCommentBody(body: body)) + } + + struct VoteBody: Encodable { let vote: String } + + /// Cast, change, or toggle-off a vote on a comment. + /// Returns the updated BookComment (with refreshed upvotes/downvotes counts). + func voteComment(commentId: String, vote: String) async throws -> BookComment { + try await fetch("/api/comments/\(commentId)/vote", method: "POST", body: VoteBody(vote: vote)) + } } // MARK: - Response types diff --git a/ios/LibNovel/LibNovel/Views/BookDetail/BookDetailView.swift b/ios/LibNovel/LibNovel/Views/BookDetail/BookDetailView.swift index 73d80e1..ad00a3c 100644 --- a/ios/LibNovel/LibNovel/Views/BookDetail/BookDetailView.swift +++ b/ios/LibNovel/LibNovel/Views/BookDetail/BookDetailView.swift @@ -27,6 +27,8 @@ struct BookDetailView: View { metaSection(book: book) Divider().padding(.horizontal) chapterSection(book: book) + Divider().padding(.horizontal) + CommentsView(slug: slug) } } } diff --git a/ios/LibNovel/LibNovel/Views/BookDetail/CommentsView.swift b/ios/LibNovel/LibNovel/Views/BookDetail/CommentsView.swift new file mode 100644 index 0000000..8c50a24 --- /dev/null +++ b/ios/LibNovel/LibNovel/Views/BookDetail/CommentsView.swift @@ -0,0 +1,320 @@ +import SwiftUI + +// MARK: - ViewModel + +@MainActor +class CommentsViewModel: ObservableObject { + let slug: String + + @Published var comments: [BookComment] = [] + @Published var myVotes: [String: String] = [:] // commentId → "up" | "down" + @Published var isLoading = true + @Published var error: String? + + @Published var newBody = "" + @Published var isPosting = false + @Published var postError: String? + + private var votingIds: Set = [] + + init(slug: String) { + self.slug = slug + } + + func load() async { + isLoading = true + error = nil + do { + let response = try await APIClient.shared.fetchComments(slug: slug) + comments = response.comments + myVotes = response.myVotes + } catch { + self.error = error.localizedDescription + } + isLoading = false + } + + func postComment() async { + let text = newBody.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty, !isPosting else { return } + if text.count > 2000 { + postError = "Comment too long (max 2000 characters)." + return + } + isPosting = true + postError = nil + do { + let created = try await APIClient.shared.postComment(slug: slug, body: text) + comments.insert(created, at: 0) + newBody = "" + } catch let apiError as APIError { + switch apiError { + case .httpError(401, _): postError = "You must be logged in to comment." + default: postError = apiError.localizedDescription + } + } catch { + postError = error.localizedDescription + } + isPosting = false + } + + func vote(commentId: String, vote: String) async { + guard !votingIds.contains(commentId) else { return } + votingIds.insert(commentId) + defer { votingIds.remove(commentId) } + do { + let updated = try await APIClient.shared.voteComment(commentId: commentId, vote: vote) + // Update the comment in the list + if let idx = comments.firstIndex(where: { $0.id == commentId }) { + comments[idx] = updated + } + // Toggle myVotes + let prev = myVotes[commentId] + if prev == vote { + myVotes.removeValue(forKey: commentId) + } else { + myVotes[commentId] = vote + } + } catch { + // Silently ignore vote errors — don't disrupt the UI + } + } + + func isVoting(_ commentId: String) -> Bool { + votingIds.contains(commentId) + } +} + +// MARK: - CommentsView + +struct CommentsView: View { + @StateObject private var vm: CommentsViewModel + @EnvironmentObject private var authStore: AuthStore + + init(slug: String) { + _vm = StateObject(wrappedValue: CommentsViewModel(slug: slug)) + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + // Section header + HStack { + Text("Comments") + .font(.headline) + if !vm.isLoading && !vm.comments.isEmpty { + Text("(\(vm.comments.count))") + .font(.subheadline) + .foregroundStyle(.secondary) + } + Spacer() + } + .padding(.horizontal) + .padding(.vertical, 14) + + Divider().padding(.horizontal) + + // Post form + postForm + .padding(.horizontal) + .padding(.vertical, 12) + + Divider().padding(.horizontal) + + // Comment list + if vm.isLoading { + loadingPlaceholder + } else if let err = vm.error { + Text(err) + .font(.subheadline) + .foregroundStyle(.red) + .padding() + } else if vm.comments.isEmpty { + Text("No comments yet. Be the first!") + .font(.subheadline) + .foregroundStyle(.secondary) + .padding() + } else { + ForEach(vm.comments) { comment in + CommentRow( + comment: comment, + myVote: vm.myVotes[comment.id], + isVoting: vm.isVoting(comment.id) + ) { vote in + Task { await vm.vote(commentId: comment.id, vote: vote) } + } + Divider().padding(.leading, 16) + } + } + + Color.clear.frame(height: 16) + } + .task { await vm.load() } + } + + // MARK: - Post form + + @ViewBuilder + private var postForm: some View { + VStack(alignment: .leading, spacing: 8) { + ZStack(alignment: .topLeading) { + if vm.newBody.isEmpty { + Text("Write a comment…") + .font(.subheadline) + .foregroundStyle(.tertiary) + .padding(.top, 8) + .padding(.leading, 4) + } + TextEditor(text: $vm.newBody) + .font(.subheadline) + .frame(minHeight: 72, maxHeight: 160) + .scrollContentBackground(.hidden) + } + .padding(10) + .background(Color(.systemGray6), in: RoundedRectangle(cornerRadius: 10)) + + HStack { + let count = vm.newBody.count + Text("\(count)/2000") + .font(.caption2) + .monospacedDigit() + .foregroundStyle(count > 2000 ? .red : .tertiary) + + Spacer() + + if let err = vm.postError { + Text(err) + .font(.caption2) + .foregroundStyle(.red) + .lineLimit(1) + } + + Button { + Task { await vm.postComment() } + } label: { + if vm.isPosting { + ProgressView().controlSize(.small) + } else { + Text("Post") + .fontWeight(.semibold) + } + } + .buttonStyle(.borderedProminent) + .tint(.amber) + .controlSize(.small) + .disabled(vm.isPosting || vm.newBody.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || vm.newBody.count > 2000) + } + } + } + + // MARK: - Loading skeleton + + @ViewBuilder + private var loadingPlaceholder: some View { + VStack(spacing: 12) { + ForEach(0..<3, id: \.self) { _ in + VStack(alignment: .leading, spacing: 8) { + RoundedRectangle(cornerRadius: 4) + .fill(Color(.systemGray5)) + .frame(width: 100, height: 12) + RoundedRectangle(cornerRadius: 4) + .fill(Color(.systemGray6)) + .frame(maxWidth: .infinity) + .frame(height: 12) + RoundedRectangle(cornerRadius: 4) + .fill(Color(.systemGray6)) + .frame(width: 200, height: 12) + } + .padding(.horizontal) + .redacted(reason: .placeholder) + } + } + .padding(.vertical, 12) + } +} + +// MARK: - CommentRow + +private struct CommentRow: View { + let comment: BookComment + let myVote: String? + let isVoting: Bool + let onVote: (String) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + // Username + date + HStack(spacing: 6) { + Text(comment.username.isEmpty ? "Anonymous" : comment.username) + .font(.subheadline.weight(.medium)) + Text("·") + .foregroundStyle(.tertiary) + Text(formattedDate(comment.created)) + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + } + + // Body + Text(comment.body) + .font(.subheadline) + .foregroundStyle(.primary) + .fixedSize(horizontal: false, vertical: true) + + // Vote row + HStack(spacing: 16) { + // Upvote + Button { + onVote("up") + } label: { + HStack(spacing: 4) { + Image(systemName: myVote == "up" ? "hand.thumbsup.fill" : "hand.thumbsup") + .font(.caption) + Text("\(comment.upvotes)") + .font(.caption.monospacedDigit()) + } + .foregroundStyle(myVote == "up" ? Color.amber : .secondary) + } + .disabled(isVoting) + + // Downvote + Button { + onVote("down") + } label: { + HStack(spacing: 4) { + Image(systemName: myVote == "down" ? "hand.thumbsdown.fill" : "hand.thumbsdown") + .font(.caption) + Text("\(comment.downvotes)") + .font(.caption.monospacedDigit()) + } + .foregroundStyle(myVote == "down" ? .red : .secondary) + } + .disabled(isVoting) + + Spacer() + } + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .opacity(isVoting ? 0.6 : 1) + } + + private func formattedDate(_ iso: String) -> String { + // PocketBase returns "2006-01-02 15:04:05.999Z" format + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = formatter.date(from: iso) { + let rel = RelativeDateTimeFormatter() + rel.unitsStyle = .abbreviated + return rel.localizedString(for: date, relativeTo: Date()) + } + // Fallback: try space-separated format + let df = DateFormatter() + df.dateFormat = "yyyy-MM-dd HH:mm:ss.SSSZ" + if let date = df.date(from: iso) { + let rel = RelativeDateTimeFormatter() + rel.unitsStyle = .abbreviated + return rel.localizedString(for: date, relativeTo: Date()) + } + return String(iso.prefix(10)) + } +} diff --git a/scraper/internal/storage/pocketbase.go b/scraper/internal/storage/pocketbase.go index 4f37232..0cc779e 100644 --- a/scraper/internal/storage/pocketbase.go +++ b/scraper/internal/storage/pocketbase.go @@ -16,6 +16,9 @@ // started(date), finished(date), error_message(text) // user_sessions — user_id(text), session_id(text,unique), user_agent(text), // ip(text), created_at(date), last_seen(date) +// book_comments — slug(text), user_id(text), username(text), body(text), +// upvotes(number), downvotes(number), created(date) +// comment_votes — comment_id(text), user_id(text), session_id(text), vote(text: up|down) package storage import ( @@ -422,6 +425,29 @@ func (s *PocketBaseStore) EnsureCollections(ctx context.Context) error { {"name": "last_seen", "type": "date"}, }, }, + { + "name": "book_comments", + "type": "base", + "fields": []map[string]interface{}{ + {"name": "slug", "type": "text", "required": true}, + {"name": "user_id", "type": "text"}, + {"name": "username", "type": "text"}, + {"name": "body", "type": "text", "required": true}, + {"name": "upvotes", "type": "number"}, + {"name": "downvotes", "type": "number"}, + {"name": "created", "type": "date"}, + }, + }, + { + "name": "comment_votes", + "type": "base", + "fields": []map[string]interface{}{ + {"name": "comment_id", "type": "text", "required": true}, + {"name": "user_id", "type": "text"}, + {"name": "session_id", "type": "text", "required": true}, + {"name": "vote", "type": "text", "required": true}, // "up" | "down" + }, + }, } for _, col := range collections { name, _ := col["name"].(string) diff --git a/scripts/pb-init.sh b/scripts/pb-init.sh index ad2c01d..2d97a33 100755 --- a/scripts/pb-init.sh +++ b/scripts/pb-init.sh @@ -203,4 +203,29 @@ ensure_field "progress" "audio_time" "number" ensure_field "user_settings" "user_id" "text" ensure_field "app_users" "avatar_url" "text" +create_collection "book_comments" '{ + "name": "book_comments", + "type": "base", + "fields": [ + {"name": "slug", "type": "text", "required": true}, + {"name": "user_id", "type": "text"}, + {"name": "username", "type": "text"}, + {"name": "body", "type": "text", "required": true}, + {"name": "upvotes", "type": "number"}, + {"name": "downvotes", "type": "number"}, + {"name": "created", "type": "date"} + ] +}' + +create_collection "comment_votes" '{ + "name": "comment_votes", + "type": "base", + "fields": [ + {"name": "comment_id", "type": "text", "required": true}, + {"name": "user_id", "type": "text"}, + {"name": "session_id", "type": "text", "required": true}, + {"name": "vote", "type": "text", "required": true} + ] +}' + log "all collections ready" diff --git a/ui/src/lib/components/AvatarCropModal.svelte b/ui/src/lib/components/AvatarCropModal.svelte index 7e0c44e..f1bf081 100644 --- a/ui/src/lib/components/AvatarCropModal.svelte +++ b/ui/src/lib/components/AvatarCropModal.svelte @@ -13,9 +13,10 @@ let imgEl: HTMLImageElement; let cropper: Cropper | null = null; - const objectUrl = URL.createObjectURL(file); + let objectUrl = $state(''); onMount(() => { + objectUrl = URL.createObjectURL(file); cropper = new Cropper(imgEl, { aspectRatio: 1, viewMode: 1, diff --git a/ui/src/lib/components/CommentsSection.svelte b/ui/src/lib/components/CommentsSection.svelte new file mode 100644 index 0000000..f021b35 --- /dev/null +++ b/ui/src/lib/components/CommentsSection.svelte @@ -0,0 +1,254 @@ + + +
+

+ Comments + {#if !loading && comments.length > 0} + ({comments.length}) + {/if} +

+ + +
+ {#if isLoggedIn} +
+ +
+ + {charCount}/2000 + +
+ {#if postError} + {postError} + {/if} + +
+
+
+ {:else} +

+ Log in + to leave a comment. +

+ {/if} +
+ + + {#if loading} +
+ {#each Array(3) as _} +
+
+
+
+
+ {/each} +
+ {:else if loadError} +

{loadError}

+ {:else if comments.length === 0} +

No comments yet. Be the first!

+ {:else} +
+ {#each comments as comment (comment.id)} + {@const myVote = myVotes[comment.id]} + {@const voting = votingIds.has(comment.id)} +
+ +
+ {comment.username || 'Anonymous'} + · + {formatDate(comment.created)} +
+ + +

{comment.body}

+ + +
+ + + + + +
+
+ {/each} +
+ {/if} +
diff --git a/ui/src/lib/server/pocketbase.ts b/ui/src/lib/server/pocketbase.ts index be74b04..3eccc57 100644 --- a/ui/src/lib/server/pocketbase.ts +++ b/ui/src/lib/server/pocketbase.ts @@ -811,3 +811,184 @@ export async function updateUserAvatarUrl(userId: string, avatarUrl: string): Pr throw new Error(`updateUserAvatarUrl failed: ${res.status} ${body}`); } } + +// ─── Comments ───────────────────────────────────────────────────────────────── + +export interface BookComment { + id: string; + slug: string; + user_id: string; + username: string; + body: string; + upvotes: number; + downvotes: number; + created: string; +} + +export interface CommentVote { + id: string; + comment_id: string; + user_id: string; + session_id: string; + vote: 'up' | 'down'; +} + +/** + * List comments for a book, newest first, up to 100. + */ +export async function listComments(slug: string): Promise { + const token = await getToken(); + const filter = encodeURIComponent(`slug="${slug.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 []; + const data = await res.json(); + return (data.items ?? []) as BookComment[]; +} + +/** + * Create a new comment. Returns the created record. + */ +export async function createComment( + slug: string, + body: string, + userId: string | undefined, + username: string +): Promise { + const token = await getToken(); + const res = await fetch(`${PB_URL}/api/collections/book_comments/records`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + slug, + body, + user_id: userId ?? '', + username, + upvotes: 0, + downvotes: 0, + created: new Date().toISOString() + }) + }); + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error(`createComment failed: ${res.status} ${text}`); + } + return res.json() as Promise; +} + +/** + * Get an existing vote by this voter (identified by user_id or session_id) on a comment. + */ +export async function getCommentVote( + commentId: string, + sessionId: string, + userId?: string +): Promise { + const token = await getToken(); + const voterFilter = userId + ? `comment_id="${commentId}"&&user_id="${userId}"` + : `comment_id="${commentId}"&&session_id="${sessionId}"`; + const res = await fetch( + `${PB_URL}/api/collections/comment_votes/records?filter=${encodeURIComponent(voterFilter)}&perPage=1`, + { headers: { Authorization: `Bearer ${token}` } } + ); + if (!res.ok) return null; + const data = await res.json(); + const items = (data.items ?? []) as CommentVote[]; + return items[0] ?? null; +} + +/** + * Cast or change a vote on a comment. Handles: + * - New vote: creates vote record, increments counter. + * - Same vote again: removes it (toggle off), decrements counter. + * - Changed vote: updates record, adjusts both counters. + * Returns the updated comment. + */ +export async function voteComment( + commentId: string, + vote: 'up' | 'down', + sessionId: string, + userId?: string +): Promise { + const token = await getToken(); + + // Fetch current comment + const commentRes = await fetch(`${PB_URL}/api/collections/book_comments/records/${commentId}`, { + headers: { Authorization: `Bearer ${token}` } + }); + if (!commentRes.ok) throw new Error(`Comment not found: ${commentId}`); + const comment = (await commentRes.json()) as BookComment; + + const existing = await getCommentVote(commentId, sessionId, userId); + + let upDelta = 0; + let downDelta = 0; + + if (!existing) { + // New vote + await fetch(`${PB_URL}/api/collections/comment_votes/records`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ comment_id: commentId, user_id: userId ?? '', session_id: sessionId, vote }) + }); + vote === 'up' ? upDelta++ : downDelta++; + } else if (existing.vote === vote) { + // Toggle off — remove vote + await fetch(`${PB_URL}/api/collections/comment_votes/records/${existing.id}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${token}` } + }); + vote === 'up' ? upDelta-- : downDelta--; + } else { + // Changed vote + await fetch(`${PB_URL}/api/collections/comment_votes/records/${existing.id}`, { + method: 'PATCH', + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ vote }) + }); + if (vote === 'up') { upDelta++; downDelta--; } + else { upDelta--; downDelta++; } + } + + // Patch comment counters + const patchRes = await fetch(`${PB_URL}/api/collections/book_comments/records/${commentId}`, { + method: 'PATCH', + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + upvotes: Math.max(0, (comment.upvotes ?? 0) + upDelta), + downvotes: Math.max(0, (comment.downvotes ?? 0) + downDelta) + }) + }); + if (!patchRes.ok) throw new Error(`Failed to update vote counts on comment ${commentId}`); + return patchRes.json() as Promise; +} + +/** + * Fetch votes cast by this session/user, keyed by comment_id. + * Returns a map of commentId → 'up' | 'down'. + */ +export async function getMyVotes( + commentIds: string[], + sessionId: string, + userId?: string +): Promise> { + if (commentIds.length === 0) return {}; + const token = await getToken(); + const idFilter = commentIds.map((id) => `comment_id="${id}"`).join('||'); + const voterPart = userId ? `user_id="${userId}"` : `session_id="${sessionId}"`; + const filter = encodeURIComponent(`(${idFilter})&&${voterPart}`); + const res = await fetch( + `${PB_URL}/api/collections/comment_votes/records?filter=${filter}&perPage=200`, + { headers: { Authorization: `Bearer ${token}` } } + ); + if (!res.ok) return {}; + const data = await res.json(); + const map: Record = {}; + for (const v of (data.items ?? []) as CommentVote[]) { + map[v.comment_id] = v.vote as 'up' | 'down'; + } + return map; +} diff --git a/ui/src/routes/api/comments/[id]/vote/+server.ts b/ui/src/routes/api/comments/[id]/vote/+server.ts new file mode 100644 index 0000000..13e743e --- /dev/null +++ b/ui/src/routes/api/comments/[id]/vote/+server.ts @@ -0,0 +1,33 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { voteComment } from '$lib/server/pocketbase'; +import { log } from '$lib/server/logger'; + +/** + * POST /api/comments/[id]/vote + * Body: { vote: 'up' | 'down' } + * Casts, changes, or toggles off a vote on a comment. + * Works for both authenticated and anonymous users (session-scoped). + * Returns the updated comment. + */ +export const POST: RequestHandler = async ({ params, request, locals }) => { + const { id } = params; + let body: { vote?: string }; + try { + body = await request.json(); + } catch { + error(400, 'Invalid JSON body'); + } + + if (body.vote !== 'up' && body.vote !== 'down') { + error(400, 'vote must be "up" or "down"'); + } + + try { + const updated = await voteComment(id, body.vote, locals.sessionId, locals.user?.id); + return json(updated); + } catch (e) { + log.error('api/comments/[id]/vote', 'voteComment failed', { id, err: String(e) }); + error(500, 'Failed to record vote'); + } +}; diff --git a/ui/src/routes/api/comments/[slug]/+server.ts b/ui/src/routes/api/comments/[slug]/+server.ts new file mode 100644 index 0000000..0e54c1a --- /dev/null +++ b/ui/src/routes/api/comments/[slug]/+server.ts @@ -0,0 +1,54 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { listComments, createComment, getMyVotes } from '$lib/server/pocketbase'; +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 } + */ +export const GET: RequestHandler = async ({ params, locals }) => { + const { slug } = params; + try { + const comments = await listComments(slug); + const myVotes = await getMyVotes( + comments.map((c) => c.id), + locals.sessionId, + locals.user?.id + ); + return json({ comments, myVotes }); + } catch (e) { + log.error('api/comments/[slug]', 'listComments failed', { slug, err: String(e) }); + error(500, 'Failed to load comments'); + } +}; + +/** + * POST /api/comments/[slug] + * Body: { body: string } + * Creates a new comment. 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 }; + try { + body = await request.json(); + } catch { + error(400, 'Invalid JSON body'); + } + + const text = (body.body ?? '').trim(); + if (!text) error(400, 'Comment body is required'); + if (text.length > 2000) error(400, 'Comment is too long (max 2000 characters)'); + + try { + const comment = await createComment(slug, text, locals.user.id, locals.user.username); + return json(comment, { status: 201 }); + } catch (e) { + log.error('api/comments/[slug]', 'createComment failed', { slug, err: String(e) }); + error(500, 'Failed to post comment'); + } +}; diff --git a/ui/src/routes/books/[slug]/+page.svelte b/ui/src/routes/books/[slug]/+page.svelte index c1f070b..4b3f8c5 100644 --- a/ui/src/routes/books/[slug]/+page.svelte +++ b/ui/src/routes/books/[slug]/+page.svelte @@ -2,6 +2,7 @@ import { onMount } from 'svelte'; import { invalidateAll } from '$app/navigation'; import type { PageData } from './$types'; + import CommentsSection from '$lib/components/CommentsSection.svelte'; let { data }: { data: PageData } = $props(); @@ -525,3 +526,6 @@ {/if} + + + diff --git a/ui/src/routes/profile/+page.svelte b/ui/src/routes/profile/+page.svelte index 9a1657d..b59593e 100644 --- a/ui/src/routes/profile/+page.svelte +++ b/ui/src/routes/profile/+page.svelte @@ -3,7 +3,7 @@ import { invalidateAll } from '$app/navigation'; import type { PageData, ActionData } from './$types'; import { audioStore } from '$lib/audio.svelte'; - import AvatarCropModal from '$lib/components/AvatarCropModal.svelte'; + import { browser } from '$app/environment'; let { data, form }: { data: PageData; form: ActionData } = $props(); @@ -213,12 +213,14 @@ Profile — libnovel -{#if cropFile} - +{#if cropFile && browser} + {#await import('$lib/components/AvatarCropModal.svelte') then { default: AvatarCropModal }} + + {/await} {/if}