diff --git a/ios/LibNovel/LibNovel.xcodeproj/project.pbxproj b/ios/LibNovel/LibNovel.xcodeproj/project.pbxproj index f4c9882..2eb44fb 100644 --- a/ios/LibNovel/LibNovel.xcodeproj/project.pbxproj +++ b/ios/LibNovel/LibNovel.xcodeproj/project.pbxproj @@ -19,7 +19,9 @@ 58E440CE4360D755401D1672 /* ProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 937A589F84FD412BBB6FBC45 /* ProfileViewModel.swift */; }; 5D8D783259EF54C773788AAB /* AuthStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = F219788AE5ACBD6F240674F5 /* AuthStore.swift */; }; 64D80AACB8E1967B17921EE3 /* ProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0B17D50389C6C98FC78BDBC /* ProfileView.swift */; }; + C3D7A2E15F8B04C9AB163D50 /* AvatarCropView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4F1B8A26E3C97D0F52A4B71 /* AvatarCropView.swift */; }; 749292A18C57FA41EC88A30B /* BookDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39DE056C37FBC5EED8771821 /* BookDetailView.swift */; }; + A1C3F2B84D9E72A1BC054F17 /* CommentsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2E8D1C74A3F91D0E5C72A38 /* CommentsView.swift */; }; 7C74C10317D389121922A5E3 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 5A776719B77EDDB5E44743B0 /* Assets.xcassets */; }; 7D81DEB2EEFF9CA5079AEEF7 /* BookDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 837F83AA12B59924FDF16617 /* BookDetailViewModel.swift */; }; 94D0C4B15734B4056BF3B127 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B820081FA4817765A39939A /* ContentView.swift */; }; @@ -53,6 +55,7 @@ 235967A21B386BE13F56F3F8 /* LibNovelTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = LibNovelTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 2D5C115992F1CE2326236765 /* RootTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootTabView.swift; sourceTree = ""; }; 39DE056C37FBC5EED8771821 /* BookDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookDetailView.swift; sourceTree = ""; }; + B2E8D1C74A3F91D0E5C72A38 /* CommentsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentsView.swift; sourceTree = ""; }; 3AB2E843D93461074A89A171 /* HomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewModel.swift; sourceTree = ""; }; 4B820081FA4817765A39939A /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 4F56C8E2BC3614530B81569D /* LibNovelApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibNovelApp.swift; sourceTree = ""; }; @@ -70,6 +73,7 @@ B4C918833E173D6B44D06955 /* LibNovelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibNovelTests.swift; sourceTree = ""; }; B593F179EC3E9112126B540B /* APIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClient.swift; sourceTree = ""; }; C0B17D50389C6C98FC78BDBC /* ProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileView.swift; sourceTree = ""; }; + D4F1B8A26E3C97D0F52A4B71 /* AvatarCropView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarCropView.swift; sourceTree = ""; }; C21107BECA55C07416E0CB8B /* LibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryView.swift; sourceTree = ""; }; D6268D60803940CBD38FB921 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = ""; }; DB13E89E50529E3081533A66 /* AudioPlayerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayerService.swift; sourceTree = ""; }; @@ -127,6 +131,7 @@ isa = PBXGroup; children = ( C0B17D50389C6C98FC78BDBC /* ProfileView.swift */, + D4F1B8A26E3C97D0F52A4B71 /* AvatarCropView.swift */, ); path = Profile; sourceTree = ""; @@ -265,6 +270,7 @@ isa = PBXGroup; children = ( 39DE056C37FBC5EED8771821 /* BookDetailView.swift */, + B2E8D1C74A3F91D0E5C72A38 /* CommentsView.swift */, ); path = BookDetail; sourceTree = ""; @@ -391,6 +397,7 @@ 5D8D783259EF54C773788AAB /* AuthStore.swift in Sources */, 9B2D6F241E707312AB80DC31 /* AuthView.swift in Sources */, 749292A18C57FA41EC88A30B /* BookDetailView.swift in Sources */, + A1C3F2B84D9E72A1BC054F17 /* CommentsView.swift in Sources */, 7D81DEB2EEFF9CA5079AEEF7 /* BookDetailViewModel.swift in Sources */, 08DFB5F626BA769556C8D145 /* BrowseView.swift in Sources */, 2790B8C051BE389D83645047 /* BrowseViewModel.swift in Sources */, @@ -408,6 +415,7 @@ F4FDA3C44752EB979235C042 /* NavDestination.swift in Sources */, BE7805A4E78037A82B12AE56 /* PlayerViews.swift in Sources */, 64D80AACB8E1967B17921EE3 /* ProfileView.swift in Sources */, + C3D7A2E15F8B04C9AB163D50 /* AvatarCropView.swift in Sources */, 58E440CE4360D755401D1672 /* ProfileViewModel.swift in Sources */, 367C88FFC11701D2BAD8CCD0 /* RootTabView.swift in Sources */, 41FB51553F1F1AEBFEA91C0A /* String+App.swift in Sources */, diff --git a/ios/LibNovel/LibNovel/Models/Models.swift b/ios/LibNovel/LibNovel/Models/Models.swift index fa8c5e3..567977c 100644 --- a/ios/LibNovel/LibNovel/Models/Models.swift +++ b/ios/LibNovel/LibNovel/Models/Models.swift @@ -273,10 +273,13 @@ struct BookComment: Identifiable, Codable, Hashable { var upvotes: Int var downvotes: Int let created: String + let parentId: String // empty = top-level; non-empty = reply + var replies: [BookComment]? // populated client-side from the API response enum CodingKeys: String, CodingKey { - case id, slug, username, body, upvotes, downvotes, created + case id, slug, username, body, upvotes, downvotes, created, replies case userId = "user_id" + case parentId = "parent_id" } init(from decoder: Decoder) throws { @@ -289,16 +292,27 @@ struct BookComment: Identifiable, Codable, Hashable { 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) ?? "" + parentId = try c.decodeIfPresent(String.self, forKey: .parentId) ?? "" + replies = try c.decodeIfPresent([BookComment].self, forKey: .replies) } } struct CommentsResponse: Decodable { let comments: [BookComment] let myVotes: [String: String] + let avatarUrls: [String: String] enum CodingKeys: String, CodingKey { case comments case myVotes = "myVotes" + case avatarUrls = "avatarUrls" + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + comments = try c.decode([BookComment].self, forKey: .comments) + myVotes = try c.decodeIfPresent([String: String].self, forKey: .myVotes) ?? [:] + avatarUrls = try c.decodeIfPresent([String: String].self, forKey: .avatarUrls) ?? [:] } } diff --git a/ios/LibNovel/LibNovel/Networking/APIClient.swift b/ios/LibNovel/LibNovel/Networking/APIClient.swift index e85f28d..b1555a9 100644 --- a/ios/LibNovel/LibNovel/Networking/APIClient.swift +++ b/ios/LibNovel/LibNovel/Networking/APIClient.swift @@ -324,14 +324,17 @@ actor APIClient { // MARK: - Comments - func fetchComments(slug: String) async throws -> CommentsResponse { - try await fetch("/api/comments/\(slug)") + func fetchComments(slug: String, sort: String = "top") async throws -> CommentsResponse { + try await fetch("/api/comments/\(slug)?sort=\(sort)") } - struct PostCommentBody: Encodable { let body: String } + struct PostCommentBody: Encodable { + let body: String + let parent_id: String? + } - func postComment(slug: String, body: String) async throws -> BookComment { - try await fetch("/api/comments/\(slug)", method: "POST", body: PostCommentBody(body: body)) + func postComment(slug: String, body: String, parentId: String? = nil) async throws -> BookComment { + try await fetch("/api/comments/\(slug)", method: "POST", body: PostCommentBody(body: body, parent_id: parentId)) } struct VoteBody: Encodable { let vote: String } @@ -341,6 +344,12 @@ actor APIClient { func voteComment(commentId: String, vote: String) async throws -> BookComment { try await fetch("/api/comments/\(commentId)/vote", method: "POST", body: VoteBody(vote: vote)) } + + /// Delete a comment (and its replies) by ID. Only the owner can delete. + func deleteComment(commentId: String) async throws { + struct Empty: Decodable {} + let _: Empty = try await fetch("/api/comments/\(commentId)", method: "DELETE") + } } // MARK: - Response types diff --git a/ios/LibNovel/LibNovel/Views/BookDetail/CommentsView.swift b/ios/LibNovel/LibNovel/Views/BookDetail/CommentsView.swift index 8c50a24..462341a 100644 --- a/ios/LibNovel/LibNovel/Views/BookDetail/CommentsView.swift +++ b/ios/LibNovel/LibNovel/Views/BookDetail/CommentsView.swift @@ -8,6 +8,7 @@ class CommentsViewModel: ObservableObject { @Published var comments: [BookComment] = [] @Published var myVotes: [String: String] = [:] // commentId → "up" | "down" + @Published var avatarUrls: [String: String] = [:] // userId → presigned URL @Published var isLoading = true @Published var error: String? @@ -15,7 +16,16 @@ class CommentsViewModel: ObservableObject { @Published var isPosting = false @Published var postError: String? + @Published var sort: CommentSortOrder = .top + + // Reply state + @Published var replyingToId: String? = nil + @Published var replyBody = "" + @Published var isPostingReply = false + @Published var replyError: String? + private var votingIds: Set = [] + private var deletingIds: Set = [] init(slug: String) { self.slug = slug @@ -25,9 +35,10 @@ class CommentsViewModel: ObservableObject { isLoading = true error = nil do { - let response = try await APIClient.shared.fetchComments(slug: slug) + let response = try await APIClient.shared.fetchComments(slug: slug, sort: sort.rawValue) comments = response.comments myVotes = response.myVotes + avatarUrls = response.avatarUrls } catch { self.error = error.localizedDescription } @@ -44,7 +55,8 @@ class CommentsViewModel: ObservableObject { isPosting = true postError = nil do { - let created = try await APIClient.shared.postComment(slug: slug, body: text) + var created = try await APIClient.shared.postComment(slug: slug, body: text) + created.replies = [] comments.insert(created, at: 0) newBody = "" } catch let apiError as APIError { @@ -58,17 +70,78 @@ class CommentsViewModel: ObservableObject { isPosting = false } - func vote(commentId: String, vote: String) async { + func postReply(parentId: String) async { + let text = replyBody.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty, !isPostingReply else { return } + if text.count > 2000 { + replyError = "Reply too long (max 2000 characters)." + return + } + isPostingReply = true + replyError = nil + do { + let created = try await APIClient.shared.postComment(slug: slug, body: text, parentId: parentId) + if let idx = comments.firstIndex(where: { $0.id == parentId }) { + var parent = comments[idx] + var replies = parent.replies ?? [] + replies.append(created) + parent.replies = replies + comments[idx] = parent + } + replyBody = "" + replyingToId = nil + } catch let apiError as APIError { + switch apiError { + case .httpError(401, _): replyError = "You must be logged in to reply." + default: replyError = apiError.localizedDescription + } + } catch { + replyError = error.localizedDescription + } + isPostingReply = false + } + + func deleteComment(commentId: String, parentId: String? = nil) async { + guard !deletingIds.contains(commentId) else { return } + deletingIds.insert(commentId) + defer { deletingIds.remove(commentId) } + do { + try await APIClient.shared.deleteComment(commentId: commentId) + if let parentId { + if let idx = comments.firstIndex(where: { $0.id == parentId }) { + var parent = comments[idx] + parent.replies = (parent.replies ?? []).filter { $0.id != commentId } + comments[idx] = parent + } + } else { + comments.removeAll { $0.id == commentId } + } + } catch { + // Silently ignore — comment may have already been deleted + } + } + + func vote(commentId: String, vote: String, parentId: String? = nil) 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 + if let parentId { + if let idx = comments.firstIndex(where: { $0.id == parentId }) { + var parent = comments[idx] + if let rIdx = parent.replies?.firstIndex(where: { $0.id == commentId }) { + parent.replies![rIdx] = updated + } + comments[idx] = parent + } + } else { + if let idx = comments.firstIndex(where: { $0.id == commentId }) { + var c = updated + c.replies = comments[idx].replies + comments[idx] = c + } } - // Toggle myVotes let prev = myVotes[commentId] if prev == vote { myVotes.removeValue(forKey: commentId) @@ -76,12 +149,29 @@ class CommentsViewModel: ObservableObject { myVotes[commentId] = vote } } catch { - // Silently ignore vote errors — don't disrupt the UI + // Silently ignore vote errors } } - func isVoting(_ commentId: String) -> Bool { - votingIds.contains(commentId) + func isVoting(_ commentId: String) -> Bool { votingIds.contains(commentId) } + func isDeleting(_ commentId: String) -> Bool { deletingIds.contains(commentId) } + + func setSort(_ newSort: CommentSortOrder) { + guard newSort != sort else { return } + sort = newSort + Task { await load() } + } +} + +enum CommentSortOrder: String, CaseIterable { + case top = "top" + case new = "new" + + var label: String { + switch self { + case .top: return "Top" + case .new: return "New" + } } } @@ -97,16 +187,30 @@ struct CommentsView: View { var body: some View { VStack(alignment: .leading, spacing: 0) { - // Section header + // Section header + sort picker HStack { Text("Comments") .font(.headline) - if !vm.isLoading && !vm.comments.isEmpty { - Text("(\(vm.comments.count))") + let total = vm.comments.reduce(0) { $0 + 1 + ($1.replies?.count ?? 0) } + if !vm.isLoading && total > 0 { + Text("(\(total))") .font(.subheadline) .foregroundStyle(.secondary) } Spacer() + // Sort picker + if !vm.isLoading && !vm.comments.isEmpty { + Picker("Sort", selection: Binding( + get: { vm.sort }, + set: { vm.setSort($0) } + )) { + ForEach(CommentSortOrder.allCases, id: \.self) { s in + Text(s.label).tag(s) + } + } + .pickerStyle(.segmented) + .frame(width: 120) + } } .padding(.horizontal) .padding(.vertical, 14) @@ -135,13 +239,7 @@ struct CommentsView: View { .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) } - } + commentThread(comment: comment) Divider().padding(.leading, 16) } } @@ -151,61 +249,198 @@ struct CommentsView: View { .task { await vm.load() } } - // MARK: - Post form + // MARK: - Comment thread (top-level + replies) @ViewBuilder - private var postForm: some View { - VStack(alignment: .leading, spacing: 8) { + private func commentThread(comment: BookComment) -> some View { + VStack(alignment: .leading, spacing: 0) { + CommentRow( + comment: comment, + myVote: vm.myVotes[comment.id], + isVoting: vm.isVoting(comment.id), + isDeleting: vm.isDeleting(comment.id), + isOwner: authStore.user?.id == comment.userId, + isLoggedIn: authStore.isAuthenticated, + isReplyingTo: vm.replyingToId == comment.id, + avatarUrl: vm.avatarUrls[comment.userId], + onVote: { v in Task { await vm.vote(commentId: comment.id, vote: v) } }, + onDelete: { Task { await vm.deleteComment(commentId: comment.id) } }, + onReply: { + if vm.replyingToId == comment.id { + vm.replyingToId = nil + vm.replyBody = "" + vm.replyError = nil + } else { + vm.replyingToId = comment.id + vm.replyBody = "" + vm.replyError = nil + } + } + ) + + // Inline reply form + if vm.replyingToId == comment.id { + replyForm(parentId: comment.id) + .padding(.leading, 32) + .padding(.trailing, 16) + .padding(.bottom, 8) + } + + // Replies + if let replies = comment.replies, !replies.isEmpty { + VStack(alignment: .leading, spacing: 0) { + ForEach(replies) { reply in + CommentRow( + comment: reply, + myVote: vm.myVotes[reply.id], + isVoting: vm.isVoting(reply.id), + isDeleting: vm.isDeleting(reply.id), + isOwner: authStore.user?.id == reply.userId, + isLoggedIn: authStore.isAuthenticated, + isReplyingTo: false, + isReply: true, + avatarUrl: vm.avatarUrls[reply.userId], + onVote: { v in Task { await vm.vote(commentId: reply.id, vote: v, parentId: comment.id) } }, + onDelete: { Task { await vm.deleteComment(commentId: reply.id, parentId: comment.id) } }, + onReply: nil + ) + if reply.id != replies.last?.id { + Divider().padding(.leading, 48) + } + } + } + .padding(.leading, 24) + .overlay(alignment: .leading) { + Rectangle() + .fill(Color(.systemGray4)) + .frame(width: 2) + .padding(.leading, 16) + .padding(.vertical, 4) + } + } + } + } + + // MARK: - Reply form + + @ViewBuilder + private func replyForm(parentId: String) -> some View { + VStack(alignment: .leading, spacing: 6) { ZStack(alignment: .topLeading) { - if vm.newBody.isEmpty { - Text("Write a comment…") - .font(.subheadline) + if vm.replyBody.isEmpty { + Text("Write a reply…") + .font(.caption) .foregroundStyle(.tertiary) - .padding(.top, 8) + .padding(.top, 6) .padding(.leading, 4) } - TextEditor(text: $vm.newBody) - .font(.subheadline) - .frame(minHeight: 72, maxHeight: 160) + TextEditor(text: $vm.replyBody) + .font(.caption) + .frame(minHeight: 56, maxHeight: 120) .scrollContentBackground(.hidden) } - .padding(10) - .background(Color(.systemGray6), in: RoundedRectangle(cornerRadius: 10)) + .padding(8) + .background(Color(.systemGray6), in: RoundedRectangle(cornerRadius: 8)) HStack { - let count = vm.newBody.count + let count = vm.replyBody.count Text("\(count)/2000") .font(.caption2) .monospacedDigit() - .foregroundStyle(count > 2000 ? .red : .tertiary) + .foregroundStyle(count > 2000 ? Color.red : Color.secondary) Spacer() - if let err = vm.postError { - Text(err) - .font(.caption2) - .foregroundStyle(.red) - .lineLimit(1) + if let err = vm.replyError { + Text(err).font(.caption2).foregroundStyle(.red).lineLimit(1) } + Button("Cancel") { + vm.replyingToId = nil + vm.replyBody = "" + vm.replyError = nil + } + .font(.caption) + .foregroundStyle(.secondary) + Button { - Task { await vm.postComment() } + Task { await vm.postReply(parentId: parentId) } } label: { - if vm.isPosting { - ProgressView().controlSize(.small) + if vm.isPostingReply { + ProgressView().controlSize(.mini) } else { - Text("Post") - .fontWeight(.semibold) + Text("Reply").fontWeight(.semibold).font(.caption) } } .buttonStyle(.borderedProminent) .tint(.amber) - .controlSize(.small) - .disabled(vm.isPosting || vm.newBody.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || vm.newBody.count > 2000) + .controlSize(.mini) + .disabled(vm.isPostingReply || vm.replyBody.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || vm.replyBody.count > 2000) } } } + // MARK: - Post form + + @ViewBuilder + private var postForm: some View { + if authStore.isAuthenticated { + 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 ? Color.red : Color.secondary) + + 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) + } + } + } else { + Text("Log in to leave a comment.") + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + // MARK: - Loading skeleton @ViewBuilder @@ -238,14 +473,23 @@ private struct CommentRow: View { let comment: BookComment let myVote: String? let isVoting: Bool + let isDeleting: Bool + let isOwner: Bool + let isLoggedIn: Bool + let isReplyingTo: Bool + var isReply: Bool = false + var avatarUrl: String? = nil let onVote: (String) -> Void + let onDelete: () -> Void + let onReply: (() -> Void)? var body: some View { VStack(alignment: .leading, spacing: 6) { - // Username + date - HStack(spacing: 6) { + // Avatar + Username + date + HStack(spacing: 8) { + avatarView Text(comment.username.isEmpty ? "Anonymous" : comment.username) - .font(.subheadline.weight(.medium)) + .font(isReply ? .caption.weight(.medium) : .subheadline.weight(.medium)) Text("·") .foregroundStyle(.tertiary) Text(formattedDate(comment.created)) @@ -256,16 +500,14 @@ private struct CommentRow: View { // Body Text(comment.body) - .font(.subheadline) + .font(isReply ? .caption : .subheadline) .foregroundStyle(.primary) .fixedSize(horizontal: false, vertical: true) - // Vote row - HStack(spacing: 16) { + // Actions + HStack(spacing: 14) { // Upvote - Button { - onVote("up") - } label: { + Button { onVote("up") } label: { HStack(spacing: 4) { Image(systemName: myVote == "up" ? "hand.thumbsup.fill" : "hand.thumbsup") .font(.caption) @@ -277,9 +519,7 @@ private struct CommentRow: View { .disabled(isVoting) // Downvote - Button { - onVote("down") - } label: { + Button { onVote("down") } label: { HStack(spacing: 4) { Image(systemName: myVote == "down" ? "hand.thumbsdown.fill" : "hand.thumbsdown") .font(.caption) @@ -290,12 +530,68 @@ private struct CommentRow: View { } .disabled(isVoting) + // Reply button (top-level only, logged in) + if let onReply, isLoggedIn { + Button { onReply() } label: { + HStack(spacing: 3) { + Image(systemName: "arrowshape.turn.up.left") + .font(.caption) + Text("Reply") + .font(.caption) + } + .foregroundStyle(isReplyingTo ? Color.amber : .secondary) + } + } + Spacer() + + // Delete (owner only) + if isOwner { + Button(role: .destructive) { onDelete() } label: { + Image(systemName: "trash") + .font(.caption) + } + .disabled(isDeleting) + } } } .padding(.horizontal, 16) .padding(.vertical, 12) - .opacity(isVoting ? 0.6 : 1) + .opacity(isDeleting ? 0.5 : 1) + .animation(.easeInOut(duration: 0.15), value: isDeleting) + } + + private var avatarSize: CGFloat { isReply ? 20 : 24 } + + @ViewBuilder + private var avatarView: some View { + if let url = avatarUrl, let imageUrl = URL(string: url) { + AsyncImage(url: imageUrl) { phase in + switch phase { + case .success(let image): + image.resizable().scaledToFill() + default: + initialsView + } + } + .frame(width: avatarSize, height: avatarSize) + .clipShape(Circle()) + } else { + initialsView + } + } + + private var initialsView: some View { + let name = comment.username.isEmpty ? "?" : comment.username + let letters = String(name.prefix(2)).uppercased() + return ZStack { + Circle() + .fill(Color(.systemGray4)) + .frame(width: avatarSize, height: avatarSize) + Text(letters) + .font(.system(size: avatarSize * 0.42, weight: .semibold)) + .foregroundStyle(.secondary) + } } private func formattedDate(_ iso: String) -> String { diff --git a/ios/LibNovel/LibNovel/Views/Profile/ProfileView.swift b/ios/LibNovel/LibNovel/Views/Profile/ProfileView.swift index 667bb3c..f429efe 100644 --- a/ios/LibNovel/LibNovel/Views/Profile/ProfileView.swift +++ b/ios/LibNovel/LibNovel/Views/Profile/ProfileView.swift @@ -20,64 +20,7 @@ struct ProfileView: View { // ── User header ──────────────────────────────────────────── Section { HStack(spacing: 16) { - // Tappable avatar circle - PhotosPicker(selection: $photoPickerItem, - matching: .images, - photoLibrary: .shared()) { - ZStack { - Circle() - .fill(Color(.systemGray5)) - .frame(width: 72, height: 72) - - if avatarUploading { - ProgressView() - .frame(width: 72, height: 72) - } else if let urlStr = avatarURL ?? authStore.user?.avatarURL, - let url = URL(string: urlStr) { - KFImage(url) - .placeholder { - Image(systemName: "person.circle.fill") - .font(.system(size: 52)) - .foregroundStyle(.amber) - } - .resizable() - .scaledToFill() - .frame(width: 72, height: 72) - .clipShape(Circle()) - } else { - Image(systemName: "person.circle.fill") - .font(.system(size: 52)) - .foregroundStyle(.amber) - .frame(width: 72, height: 72) - } - - // Camera overlay badge - if !avatarUploading { - VStack { - Spacer() - HStack { - Spacer() - ZStack { - Circle() - .fill(Color.amber) - .frame(width: 22, height: 22) - Image(systemName: "camera.fill") - .font(.system(size: 10, weight: .semibold)) - .foregroundStyle(.black) - } - .offset(x: 2, y: 2) - } - } - .frame(width: 72, height: 72) - } - } - } - .buttonStyle(.plain) - .onChange(of: photoPickerItem) { _, item in - guard let item else { return } - Task { await loadImageForCrop(item) } - } - + avatarPicker VStack(alignment: .leading, spacing: 3) { Text(authStore.user?.username ?? "") .font(.headline) @@ -181,6 +124,68 @@ struct ProfileView: View { } } + // MARK: - Avatar picker + + @ViewBuilder + private var avatarPicker: some View { + PhotosPicker(selection: $photoPickerItem, + matching: .images, + photoLibrary: .shared()) { + ZStack { + Circle() + .fill(Color(.systemGray5)) + .frame(width: 72, height: 72) + + if avatarUploading { + ProgressView() + .frame(width: 72, height: 72) + } else if let urlStr = avatarURL ?? authStore.user?.avatarURL, + let url = URL(string: urlStr) { + KFImage(url) + .placeholder { + Image(systemName: "person.circle.fill") + .font(.system(size: 52)) + .foregroundStyle(.amber) + } + .resizable() + .scaledToFill() + .frame(width: 72, height: 72) + .clipShape(Circle()) + } else { + Image(systemName: "person.circle.fill") + .font(.system(size: 52)) + .foregroundStyle(.amber) + .frame(width: 72, height: 72) + } + + // Camera overlay badge + if !avatarUploading { + VStack { + Spacer() + HStack { + Spacer() + ZStack { + Circle() + .fill(Color.amber) + .frame(width: 22, height: 22) + Image(systemName: "camera.fill") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(.black) + } + .offset(x: 2, y: 2) + } + } + .frame(width: 72, height: 72) + } + } + } + .buttonStyle(.plain) + .onChange(of: photoPickerItem) { _, item in + guard let item else { return } + Task { await loadImageForCrop(item) } + } + } + // MARK: - Voice picker @ViewBuilder diff --git a/scraper/internal/storage/pocketbase.go b/scraper/internal/storage/pocketbase.go index 0cc779e..3eef72b 100644 --- a/scraper/internal/storage/pocketbase.go +++ b/scraper/internal/storage/pocketbase.go @@ -436,6 +436,7 @@ func (s *PocketBaseStore) EnsureCollections(ctx context.Context) error { {"name": "upvotes", "type": "number"}, {"name": "downvotes", "type": "number"}, {"name": "created", "type": "date"}, + {"name": "parent_id", "type": "text"}, // empty = top-level; set = reply to that comment ID }, }, { @@ -486,6 +487,8 @@ var migrations = []migration{ {"progress", "user_id", "text"}, // avatar_url stores the MinIO presign path for the user's profile picture. {"app_users", "avatar_url", "text"}, + // parent_id enables 1-level comment nesting (replies). Empty = top-level comment. + {"book_comments", "parent_id", "text"}, } // EnsureMigrations idempotently adds any fields that are missing from existing diff --git a/scripts/pb-init.sh b/scripts/pb-init.sh index 2d97a33..480bbee 100755 --- a/scripts/pb-init.sh +++ b/scripts/pb-init.sh @@ -213,7 +213,8 @@ create_collection "book_comments" '{ {"name": "body", "type": "text", "required": true}, {"name": "upvotes", "type": "number"}, {"name": "downvotes", "type": "number"}, - {"name": "created", "type": "date"} + {"name": "created", "type": "date"}, + {"name": "parent_id", "type": "text"} ] }' diff --git a/ui/src/lib/components/AvatarCropModal.svelte b/ui/src/lib/components/AvatarCropModal.svelte index f1bf081..2fc280d 100644 --- a/ui/src/lib/components/AvatarCropModal.svelte +++ b/ui/src/lib/components/AvatarCropModal.svelte @@ -1,5 +1,5 @@
-

- Comments + +
+

+ Comments + {#if !loading && totalCount > 0} + ({totalCount}) + {/if} +

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

+
@@ -202,9 +341,19 @@ {#each comments as comment (comment.id)} {@const myVote = myVotes[comment.id]} {@const voting = votingIds.has(comment.id)} -
- + {@const deleting = deletingIds.has(comment.id)} + {@const isOwner = isLoggedIn && currentUserId === comment.user_id} + +
+
+ {#if avatarUrls[comment.user_id]} + {comment.username} + {:else} +
+ {initials(comment.username)} +
+ {/if} {comment.username || 'Anonymous'} · {formatDate(comment.created)} @@ -213,17 +362,15 @@

{comment.body}

- -
+ +
+ + + {#if isLoggedIn} + + {/if} + + + {#if isOwner} + + {/if}
+ + + {#if replyingTo === comment.id} +
+ +
+ + {replyCharCount}/2000 + +
+ {#if replyError} + {replyError} + {/if} + + +
+
+
+ {/if} + + + {#if comment.replies && comment.replies.length > 0} +
+ {#each comment.replies as reply (reply.id)} + {@const replyVote = myVotes[reply.id]} + {@const replyVoting = votingIds.has(reply.id)} + {@const replyDeleting = deletingIds.has(reply.id)} + {@const replyIsOwner = isLoggedIn && currentUserId === reply.user_id} + +
+ +
+ {#if avatarUrls[reply.user_id]} + {reply.username} + {:else} +
+ {initials(reply.username)} +
+ {/if} + {reply.username || 'Anonymous'} + · + {formatDate(reply.created)} +
+ + +

{reply.body}

+ + +
+ + + + + {#if replyIsOwner} + + {/if} +
+
+ {/each} +
+ {/if}
{/each}
diff --git a/ui/src/lib/server/pocketbase.ts b/ui/src/lib/server/pocketbase.ts index 3eccc57..687cd8b 100644 --- a/ui/src/lib/server/pocketbase.ts +++ b/ui/src/lib/server/pocketbase.ts @@ -823,6 +823,7 @@ export interface BookComment { upvotes: number; downvotes: number; created: string; + parent_id?: string; // empty / absent = top-level; set = reply } export interface CommentVote { @@ -833,14 +834,54 @@ export interface CommentVote { vote: 'up' | 'down'; } +export type CommentSort = 'top' | 'new'; + /** - * List comments for a book, newest first, up to 100. + * List top-level comments for a book. + * 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): Promise { +export async function listComments( + slug: string, + sort: CommentSort = 'new' +): Promise { const token = await getToken(); - const filter = encodeURIComponent(`slug="${slug.replace(/"/g, '\\"')}"`); + 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 res = await fetch( - `${PB_URL}/api/collections/book_comments/records?filter=${filter}&sort=-created&perPage=100`, + `${PB_URL}/api/collections/book_comments/records?filter=${filter}${pbSort}&perPage=200`, + { headers: { Authorization: `Bearer ${token}` } } + ); + if (!res.ok) return []; + const data = await res.json(); + let items = (data.items ?? []) as BookComment[]; + if (sort === 'top') { + items = items.sort((a, b) => { + 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; +} + +/** + * List replies (1-level deep) for a single parent comment. + * Always sorted oldest-first so the conversation reads naturally. + */ +export async function listReplies(parentId: string): Promise { + const token = await getToken(); + const filter = encodeURIComponent(`parent_id="${parentId.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 []; @@ -850,12 +891,14 @@ export async function listComments(slug: string): Promise { /** * Create a new comment. Returns the created record. + * Pass parentId to create a reply; omit / pass undefined for a top-level comment. */ export async function createComment( slug: string, body: string, userId: string | undefined, - username: string + username: string, + parentId?: string ): Promise { const token = await getToken(); const res = await fetch(`${PB_URL}/api/collections/book_comments/records`, { @@ -868,6 +911,7 @@ export async function createComment( username, upvotes: 0, downvotes: 0, + parent_id: parentId ?? '', created: new Date().toISOString() }) }); @@ -878,6 +922,49 @@ export async function createComment( return res.json() as Promise; } +/** + * Delete a comment (and optionally its replies) by ID. + * Only the comment owner (matched by userId) may delete. + * Throws if the comment doesn't exist or the user doesn't own it. + */ +export async function deleteComment(commentId: string, userId: string): Promise { + const token = await getToken(); + + // Fetch the comment to verify ownership + const getRes = await fetch(`${PB_URL}/api/collections/book_comments/records/${commentId}`, { + headers: { Authorization: `Bearer ${token}` } + }); + if (!getRes.ok) throw new Error(`Comment not found: ${commentId}`); + const comment = (await getRes.json()) as BookComment; + if (comment.user_id !== userId) throw new Error('Not authorized to delete this comment'); + + // Delete any replies first + const repliesFilter = encodeURIComponent(`parent_id="${commentId.replace(/"/g, '\\"')}"`); + const repliesRes = await fetch( + `${PB_URL}/api/collections/book_comments/records?filter=${repliesFilter}&perPage=100`, + { headers: { Authorization: `Bearer ${token}` } } + ); + if (repliesRes.ok) { + const repliesData = await repliesRes.json(); + const replies = (repliesData.items ?? []) as BookComment[]; + await Promise.all( + replies.map((r) => + fetch(`${PB_URL}/api/collections/book_comments/records/${r.id}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${token}` } + }) + ) + ); + } + + // Delete the comment itself + const delRes = await fetch(`${PB_URL}/api/collections/book_comments/records/${commentId}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${token}` } + }); + if (!delRes.ok) throw new Error(`deleteComment failed: ${delRes.status}`); +} + /** * Get an existing vote by this voter (identified by user_id or session_id) on a comment. */ diff --git a/ui/src/routes/api/comments/[id]/+server.ts b/ui/src/routes/api/comments/[id]/+server.ts new file mode 100644 index 0000000..37df021 --- /dev/null +++ b/ui/src/routes/api/comments/[id]/+server.ts @@ -0,0 +1,25 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { deleteComment } from '$lib/server/pocketbase'; +import { log } from '$lib/server/logger'; + +/** + * DELETE /api/comments/[id] + * Deletes a comment (and its replies) by ID. + * Requires authentication — only the comment owner can delete their own comment. + */ +export const DELETE: RequestHandler = async ({ params, locals }) => { + if (!locals.user) error(401, 'Login required'); + + const { id } = params; + try { + await deleteComment(id, locals.user.id); + return json({ ok: true }); + } catch (e) { + const msg = String(e); + if (msg.includes('Not authorized')) error(403, 'Not authorized to delete this comment'); + if (msg.includes('not found')) error(404, 'Comment not found'); + log.error('api/comments/[id]', 'deleteComment failed', { id, err: msg }); + error(500, 'Failed to delete comment'); + } +}; diff --git a/ui/src/routes/api/comments/[slug]/+server.ts b/ui/src/routes/api/comments/[slug]/+server.ts index 0e54c1a..b90e559 100644 --- a/ui/src/routes/api/comments/[slug]/+server.ts +++ b/ui/src/routes/api/comments/[slug]/+server.ts @@ -1,23 +1,62 @@ import { json, error } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; -import { listComments, createComment, getMyVotes } from '$lib/server/pocketbase'; +import { + listComments, + listReplies, + createComment, + getMyVotes, + type CommentSort +} from '$lib/server/pocketbase'; +import { presignAvatarUrl } from '$lib/server/minio'; 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 } + * GET /api/comments/[slug]?sort=new|top + * Returns top-level comments + their replies + current visitor's votes + avatar URLs. + * Response: { comments: BookComment[], myVotes: Record, avatarUrls: Record } + * Each top-level comment has a `replies` array attached. */ -export const GET: RequestHandler = async ({ params, locals }) => { +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'; + try { - const comments = await listComments(slug); - const myVotes = await getMyVotes( - comments.map((c) => c.id), - locals.sessionId, - locals.user?.id + const topLevel = await listComments(slug, sort); + + // Fetch replies for all top-level comments in parallel + const repliesPerComment = await Promise.all(topLevel.map((c) => listReplies(c.id))); + const allReplies = repliesPerComment.flat(); + + // Build comment+reply list for vote lookup + const allIds = [...topLevel.map((c) => c.id), ...allReplies.map((r) => r.id)]; + const myVotes = await getMyVotes(allIds, locals.sessionId, locals.user?.id); + + // Attach replies to each top-level comment + const comments = topLevel.map((c, i) => ({ + ...c, + replies: repliesPerComment[i] + })); + + // Batch-resolve avatar presign URLs for all unique user_ids + const allComments = [...topLevel, ...allReplies]; + const uniqueUserIds = [...new Set(allComments.map((c) => c.user_id).filter(Boolean))]; + const avatarEntries = await Promise.all( + uniqueUserIds.map(async (userId) => { + try { + const url = await presignAvatarUrl(userId); + return [userId, url] as [string, string | null]; + } catch { + return [userId, null] as [string, null]; + } + }) ); - return json({ comments, myVotes }); + const avatarUrls: Record = {}; + for (const [userId, url] of avatarEntries) { + if (url) avatarUrls[userId] = url; + } + + return json({ comments, myVotes, avatarUrls }); } catch (e) { log.error('api/comments/[slug]', 'listComments failed', { slug, err: String(e) }); error(500, 'Failed to load comments'); @@ -26,14 +65,14 @@ export const GET: RequestHandler = async ({ params, locals }) => { /** * POST /api/comments/[slug] - * Body: { body: string } - * Creates a new comment. Requires authentication. + * Body: { body: string, parent_id?: string } + * Creates a new comment or reply. 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 }; + let body: { body?: string; parent_id?: string }; try { body = await request.json(); } catch { @@ -44,8 +83,17 @@ 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; + try { - const comment = await createComment(slug, text, locals.user.id, locals.user.username); + const comment = await createComment( + slug, + text, + locals.user.id, + locals.user.username, + parentId + ); return json(comment, { status: 201 }); } catch (e) { log.error('api/comments/[slug]', 'createComment failed', { slug, err: String(e) }); diff --git a/ui/src/routes/books/[slug]/+page.server.ts b/ui/src/routes/books/[slug]/+page.server.ts index 67a2ccf..099cd8b 100644 --- a/ui/src/routes/books/[slug]/+page.server.ts +++ b/ui/src/routes/books/[slug]/+page.server.ts @@ -43,7 +43,9 @@ export const load: PageServerLoad = async ({ params, locals }) => { inLib: true, saved, lastChapter: progress?.chapter ?? null, - isAdmin: locals.user?.role === 'admin' + isAdmin: locals.user?.role === 'admin', + isLoggedIn: !!locals.user, + currentUserId: locals.user?.id ?? '' }; } @@ -93,7 +95,9 @@ export const load: PageServerLoad = async ({ params, locals }) => { inLib: preview.in_lib, saved: false, lastChapter: null, - isAdmin: locals.user?.role === 'admin' + isAdmin: locals.user?.role === 'admin', + isLoggedIn: !!locals.user, + currentUserId: locals.user?.id ?? '' }; } catch (e) { if (e instanceof Error && 'status' in e) throw e; diff --git a/ui/src/routes/books/[slug]/+page.svelte b/ui/src/routes/books/[slug]/+page.svelte index 4b3f8c5..f1e4d8c 100644 --- a/ui/src/routes/books/[slug]/+page.svelte +++ b/ui/src/routes/books/[slug]/+page.svelte @@ -528,4 +528,4 @@
- +