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
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:
@@ -254,6 +254,46 @@ struct BookBrief: Codable {
|
|||||||
let cover: String
|
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
|
// MARK: - Audio
|
||||||
|
|
||||||
enum NextPrefetchStatus {
|
enum NextPrefetchStatus {
|
||||||
|
|||||||
@@ -313,6 +313,34 @@ actor APIClient {
|
|||||||
)
|
)
|
||||||
return result.avatarURL
|
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
|
// MARK: - Response types
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ struct BookDetailView: View {
|
|||||||
metaSection(book: book)
|
metaSection(book: book)
|
||||||
Divider().padding(.horizontal)
|
Divider().padding(.horizontal)
|
||||||
chapterSection(book: book)
|
chapterSection(book: book)
|
||||||
|
Divider().padding(.horizontal)
|
||||||
|
CommentsView(slug: slug)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
320
ios/LibNovel/LibNovel/Views/BookDetail/CommentsView.swift
Normal file
320
ios/LibNovel/LibNovel/Views/BookDetail/CommentsView.swift
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,9 @@
|
|||||||
// started(date), finished(date), error_message(text)
|
// started(date), finished(date), error_message(text)
|
||||||
// user_sessions — user_id(text), session_id(text,unique), user_agent(text),
|
// user_sessions — user_id(text), session_id(text,unique), user_agent(text),
|
||||||
// ip(text), created_at(date), last_seen(date)
|
// 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
|
package storage
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -422,6 +425,29 @@ func (s *PocketBaseStore) EnsureCollections(ctx context.Context) error {
|
|||||||
{"name": "last_seen", "type": "date"},
|
{"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 {
|
for _, col := range collections {
|
||||||
name, _ := col["name"].(string)
|
name, _ := col["name"].(string)
|
||||||
|
|||||||
@@ -203,4 +203,29 @@ ensure_field "progress" "audio_time" "number"
|
|||||||
ensure_field "user_settings" "user_id" "text"
|
ensure_field "user_settings" "user_id" "text"
|
||||||
ensure_field "app_users" "avatar_url" "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"
|
log "all collections ready"
|
||||||
|
|||||||
@@ -13,9 +13,10 @@
|
|||||||
|
|
||||||
let imgEl: HTMLImageElement;
|
let imgEl: HTMLImageElement;
|
||||||
let cropper: Cropper | null = null;
|
let cropper: Cropper | null = null;
|
||||||
const objectUrl = URL.createObjectURL(file);
|
let objectUrl = $state('');
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
objectUrl = URL.createObjectURL(file);
|
||||||
cropper = new Cropper(imgEl, {
|
cropper = new Cropper(imgEl, {
|
||||||
aspectRatio: 1,
|
aspectRatio: 1,
|
||||||
viewMode: 1,
|
viewMode: 1,
|
||||||
|
|||||||
254
ui/src/lib/components/CommentsSection.svelte
Normal file
254
ui/src/lib/components/CommentsSection.svelte
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
interface BookComment {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
user_id: string;
|
||||||
|
username: string;
|
||||||
|
body: string;
|
||||||
|
upvotes: number;
|
||||||
|
downvotes: number;
|
||||||
|
created: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
slug,
|
||||||
|
isLoggedIn = false
|
||||||
|
}: {
|
||||||
|
slug: string;
|
||||||
|
isLoggedIn?: boolean;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
// ── State ─────────────────────────────────────────────────────────────────
|
||||||
|
let comments = $state<BookComment[]>([]);
|
||||||
|
let myVotes = $state<Record<string, 'up' | 'down'>>({});
|
||||||
|
let loading = $state(true);
|
||||||
|
let loadError = $state('');
|
||||||
|
|
||||||
|
let newBody = $state('');
|
||||||
|
let posting = $state(false);
|
||||||
|
let postError = $state('');
|
||||||
|
|
||||||
|
// Per-comment vote inflight set (prevents double-clicks)
|
||||||
|
let votingIds = $state(new Set<string>());
|
||||||
|
|
||||||
|
// ── Load comments on mount ────────────────────────────────────────────────
|
||||||
|
async function loadComments() {
|
||||||
|
loading = true;
|
||||||
|
loadError = '';
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/comments/${encodeURIComponent(slug)}`);
|
||||||
|
if (!res.ok) throw new Error(`${res.status}`);
|
||||||
|
const data = await res.json();
|
||||||
|
comments = data.comments ?? [];
|
||||||
|
myVotes = data.myVotes ?? {};
|
||||||
|
} catch (e) {
|
||||||
|
loadError = 'Failed to load comments.';
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run once on component mount via $effect
|
||||||
|
$effect(() => {
|
||||||
|
loadComments();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Post comment ──────────────────────────────────────────────────────────
|
||||||
|
async function postComment() {
|
||||||
|
const text = newBody.trim();
|
||||||
|
if (!text || posting) return;
|
||||||
|
if (text.length > 2000) {
|
||||||
|
postError = 'Comment is too long (max 2000 characters).';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
posting = true;
|
||||||
|
postError = '';
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/comments/${encodeURIComponent(slug)}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ body: text })
|
||||||
|
});
|
||||||
|
if (res.status === 401) {
|
||||||
|
postError = 'You must be logged in to comment.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
postError = err.message ?? 'Failed to post comment.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const created: BookComment = await res.json();
|
||||||
|
comments = [created, ...comments];
|
||||||
|
newBody = '';
|
||||||
|
} catch {
|
||||||
|
postError = 'Failed to post comment.';
|
||||||
|
} finally {
|
||||||
|
posting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Vote ──────────────────────────────────────────────────────────────────
|
||||||
|
async function vote(commentId: string, v: 'up' | 'down') {
|
||||||
|
if (votingIds.has(commentId)) return;
|
||||||
|
votingIds = new Set([...votingIds, commentId]);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/comments/${commentId}/vote`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ vote: v })
|
||||||
|
});
|
||||||
|
if (!res.ok) return;
|
||||||
|
const updated: BookComment = await res.json();
|
||||||
|
// Update comment in list
|
||||||
|
comments = comments.map((c) => (c.id === commentId ? updated : c));
|
||||||
|
// Update myVotes: toggle off if same, else set new vote
|
||||||
|
const prev = myVotes[commentId];
|
||||||
|
if (prev === v) {
|
||||||
|
const next = { ...myVotes };
|
||||||
|
delete next[commentId];
|
||||||
|
myVotes = next;
|
||||||
|
} else {
|
||||||
|
myVotes = { ...myVotes, [commentId]: v };
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
const next = new Set(votingIds);
|
||||||
|
next.delete(commentId);
|
||||||
|
votingIds = next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
function formatDate(iso: string): string {
|
||||||
|
try {
|
||||||
|
return new Date(iso).toLocaleDateString(undefined, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric'
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const charCount = $derived(newBody.length);
|
||||||
|
const charOver = $derived(charCount > 2000);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="mt-10">
|
||||||
|
<h2 class="text-base font-semibold text-zinc-200 mb-4">
|
||||||
|
Comments
|
||||||
|
{#if !loading && comments.length > 0}
|
||||||
|
<span class="text-zinc-500 font-normal text-sm ml-1">({comments.length})</span>
|
||||||
|
{/if}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<!-- Post form -->
|
||||||
|
<div class="mb-6">
|
||||||
|
{#if isLoggedIn}
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<textarea
|
||||||
|
bind:value={newBody}
|
||||||
|
placeholder="Write a comment…"
|
||||||
|
rows="3"
|
||||||
|
class="w-full px-3 py-2 rounded-lg bg-zinc-800 border border-zinc-700 text-zinc-200 text-sm placeholder-zinc-500 resize-none focus:outline-none focus:border-amber-400 transition-colors"
|
||||||
|
></textarea>
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<span class="text-xs {charOver ? 'text-red-400' : 'text-zinc-600'} tabular-nums">
|
||||||
|
{charCount}/2000
|
||||||
|
</span>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
{#if postError}
|
||||||
|
<span class="text-xs text-red-400">{postError}</span>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
onclick={postComment}
|
||||||
|
disabled={posting || !newBody.trim() || charOver}
|
||||||
|
class="px-4 py-1.5 rounded-lg text-sm font-medium transition-colors
|
||||||
|
{posting || !newBody.trim() || charOver
|
||||||
|
? 'bg-zinc-700 text-zinc-500 cursor-not-allowed'
|
||||||
|
: 'bg-amber-400 text-zinc-900 hover:bg-amber-300'}"
|
||||||
|
>
|
||||||
|
{posting ? 'Posting…' : 'Post'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="text-sm text-zinc-500">
|
||||||
|
<a href="/auth/login" class="text-amber-400 hover:text-amber-300 transition-colors">Log in</a>
|
||||||
|
to leave a comment.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Comment list -->
|
||||||
|
{#if loading}
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
{#each Array(3) as _}
|
||||||
|
<div class="rounded-lg bg-zinc-800/50 p-4 animate-pulse">
|
||||||
|
<div class="h-3 w-24 bg-zinc-700 rounded mb-3"></div>
|
||||||
|
<div class="h-3 w-full bg-zinc-700/60 rounded mb-2"></div>
|
||||||
|
<div class="h-3 w-3/4 bg-zinc-700/60 rounded"></div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else if loadError}
|
||||||
|
<p class="text-sm text-red-400">{loadError}</p>
|
||||||
|
{:else if comments.length === 0}
|
||||||
|
<p class="text-sm text-zinc-500">No comments yet. Be the first!</p>
|
||||||
|
{:else}
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
{#each comments as comment (comment.id)}
|
||||||
|
{@const myVote = myVotes[comment.id]}
|
||||||
|
{@const voting = votingIds.has(comment.id)}
|
||||||
|
<div class="rounded-lg bg-zinc-800/50 border border-zinc-700/50 px-4 py-3 flex flex-col gap-2">
|
||||||
|
<!-- Header: username + date -->
|
||||||
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
|
<span class="text-sm font-medium text-zinc-200">{comment.username || 'Anonymous'}</span>
|
||||||
|
<span class="text-zinc-600 text-xs">·</span>
|
||||||
|
<span class="text-xs text-zinc-500">{formatDate(comment.created)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Body -->
|
||||||
|
<p class="text-sm text-zinc-300 leading-relaxed whitespace-pre-wrap break-words">{comment.body}</p>
|
||||||
|
|
||||||
|
<!-- Vote row -->
|
||||||
|
<div class="flex items-center gap-3 pt-1">
|
||||||
|
<!-- Upvote -->
|
||||||
|
<button
|
||||||
|
onclick={() => vote(comment.id, 'up')}
|
||||||
|
disabled={voting}
|
||||||
|
title="Upvote"
|
||||||
|
class="flex items-center gap-1 text-xs transition-colors disabled:opacity-50
|
||||||
|
{myVote === 'up'
|
||||||
|
? 'text-amber-400'
|
||||||
|
: 'text-zinc-500 hover:text-zinc-300'}"
|
||||||
|
>
|
||||||
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M14 10h4.764a2 2 0 011.789 2.894l-3.5 7A2 2 0 0115.263 21h-4.017c-.163 0-.326-.02-.485-.06L7 20m7-10V5a2 2 0 00-2-2h-.095c-.5 0-.905.405-.905.905 0 .714-.211 1.412-.608 2.006L7 11v9m7-10h-2M7 20H5a2 2 0 01-2-2v-6a2 2 0 012-2h2.5"/>
|
||||||
|
</svg>
|
||||||
|
<span class="tabular-nums">{comment.upvotes ?? 0}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Downvote -->
|
||||||
|
<button
|
||||||
|
onclick={() => vote(comment.id, 'down')}
|
||||||
|
disabled={voting}
|
||||||
|
title="Downvote"
|
||||||
|
class="flex items-center gap-1 text-xs transition-colors disabled:opacity-50
|
||||||
|
{myVote === 'down'
|
||||||
|
? 'text-red-400'
|
||||||
|
: 'text-zinc-500 hover:text-zinc-300'}"
|
||||||
|
>
|
||||||
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M10 14H5.236a2 2 0 01-1.789-2.894l3.5-7A2 2 0 018.736 3h4.018a2 2 0 01.485.06l3.76.94m-7 10v5a2 2 0 002 2h.096c.5 0 .905-.405.905-.904 0-.715.211-1.413.608-2.008L17 13V4m-7 10h2m5-10h2a2 2 0 012 2v6a2 2 0 01-2 2h-2.5"/>
|
||||||
|
</svg>
|
||||||
|
<span class="tabular-nums">{comment.downvotes ?? 0}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -811,3 +811,184 @@ export async function updateUserAvatarUrl(userId: string, avatarUrl: string): Pr
|
|||||||
throw new Error(`updateUserAvatarUrl failed: ${res.status} ${body}`);
|
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<BookComment[]> {
|
||||||
|
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<BookComment> {
|
||||||
|
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<BookComment>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<CommentVote | null> {
|
||||||
|
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<BookComment> {
|
||||||
|
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<BookComment>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<Record<string, 'up' | 'down'>> {
|
||||||
|
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<string, 'up' | 'down'> = {};
|
||||||
|
for (const v of (data.items ?? []) as CommentVote[]) {
|
||||||
|
map[v.comment_id] = v.vote as 'up' | 'down';
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|||||||
33
ui/src/routes/api/comments/[id]/vote/+server.ts
Normal file
33
ui/src/routes/api/comments/[id]/vote/+server.ts
Normal file
@@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
54
ui/src/routes/api/comments/[slug]/+server.ts
Normal file
54
ui/src/routes/api/comments/[slug]/+server.ts
Normal file
@@ -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<string, 'up'|'down'> }
|
||||||
|
*/
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { invalidateAll } from '$app/navigation';
|
import { invalidateAll } from '$app/navigation';
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
|
import CommentsSection from '$lib/components/CommentsSection.svelte';
|
||||||
|
|
||||||
let { data }: { data: PageData } = $props();
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
@@ -525,3 +526,6 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ══════════════════════════════════════════════════ Comments ══ -->
|
||||||
|
<CommentsSection slug={data.book.slug} isLoggedIn={true} />
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { invalidateAll } from '$app/navigation';
|
import { invalidateAll } from '$app/navigation';
|
||||||
import type { PageData, ActionData } from './$types';
|
import type { PageData, ActionData } from './$types';
|
||||||
import { audioStore } from '$lib/audio.svelte';
|
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();
|
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||||
|
|
||||||
@@ -213,12 +213,14 @@
|
|||||||
<title>Profile — libnovel</title>
|
<title>Profile — libnovel</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
{#if cropFile}
|
{#if cropFile && browser}
|
||||||
<AvatarCropModal
|
{#await import('$lib/components/AvatarCropModal.svelte') then { default: AvatarCropModal }}
|
||||||
file={cropFile}
|
<AvatarCropModal
|
||||||
onconfirm={handleCropConfirm}
|
file={cropFile}
|
||||||
oncancel={handleCropCancel}
|
onconfirm={handleCropConfirm}
|
||||||
/>
|
oncancel={handleCropCancel}
|
||||||
|
/>
|
||||||
|
{/await}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Hidden logout form used when user ends their own session -->
|
<!-- Hidden logout form used when user ends their own session -->
|
||||||
|
|||||||
Reference in New Issue
Block a user