feat: book commenting system with upvote/downvote + fix profile SSR crash
Some checks failed
CI / Scraper / Test (push) Successful in 10s
CI / Scraper / Lint (push) Successful in 12s
CI / Scraper / Lint (pull_request) Successful in 8s
CI / Scraper / Test (pull_request) Successful in 19s
CI / UI / Build (push) Successful in 32s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Build (pull_request) Successful in 15s
CI / UI / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (push) Failing after 11s
CI / Scraper / Docker Push (push) Successful in 45s
iOS CI / Build (push) Failing after 2m44s
iOS CI / Test (push) Has been skipped
iOS CI / Build (pull_request) Failing after 5m46s
iOS CI / Test (pull_request) Has been skipped

- 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
This commit is contained in:
Admin
2026-03-10 18:05:41 +05:00
parent 0f6639aae7
commit 83a5910a59
13 changed files with 978 additions and 8 deletions

View File

@@ -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 {

View File

@@ -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

View File

@@ -27,6 +27,8 @@ struct BookDetailView: View {
metaSection(book: book)
Divider().padding(.horizontal)
chapterSection(book: book)
Divider().padding(.horizontal)
CommentsView(slug: slug)
}
}
}

View File

@@ -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<String> = []
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))
}
}