chore: migrate to v3 and adopt Doppler for secrets management #3
@@ -19,7 +19,9 @@
|
|||||||
58E440CE4360D755401D1672 /* ProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 937A589F84FD412BBB6FBC45 /* ProfileViewModel.swift */; };
|
58E440CE4360D755401D1672 /* ProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 937A589F84FD412BBB6FBC45 /* ProfileViewModel.swift */; };
|
||||||
5D8D783259EF54C773788AAB /* AuthStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = F219788AE5ACBD6F240674F5 /* AuthStore.swift */; };
|
5D8D783259EF54C773788AAB /* AuthStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = F219788AE5ACBD6F240674F5 /* AuthStore.swift */; };
|
||||||
64D80AACB8E1967B17921EE3 /* ProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0B17D50389C6C98FC78BDBC /* ProfileView.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 */; };
|
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 */; };
|
7C74C10317D389121922A5E3 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 5A776719B77EDDB5E44743B0 /* Assets.xcassets */; };
|
||||||
7D81DEB2EEFF9CA5079AEEF7 /* BookDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 837F83AA12B59924FDF16617 /* BookDetailViewModel.swift */; };
|
7D81DEB2EEFF9CA5079AEEF7 /* BookDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 837F83AA12B59924FDF16617 /* BookDetailViewModel.swift */; };
|
||||||
94D0C4B15734B4056BF3B127 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B820081FA4817765A39939A /* ContentView.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; };
|
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 = "<group>"; };
|
2D5C115992F1CE2326236765 /* RootTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootTabView.swift; sourceTree = "<group>"; };
|
||||||
39DE056C37FBC5EED8771821 /* BookDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookDetailView.swift; sourceTree = "<group>"; };
|
39DE056C37FBC5EED8771821 /* BookDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookDetailView.swift; sourceTree = "<group>"; };
|
||||||
|
B2E8D1C74A3F91D0E5C72A38 /* CommentsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentsView.swift; sourceTree = "<group>"; };
|
||||||
3AB2E843D93461074A89A171 /* HomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewModel.swift; sourceTree = "<group>"; };
|
3AB2E843D93461074A89A171 /* HomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewModel.swift; sourceTree = "<group>"; };
|
||||||
4B820081FA4817765A39939A /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
4B820081FA4817765A39939A /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
||||||
4F56C8E2BC3614530B81569D /* LibNovelApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibNovelApp.swift; sourceTree = "<group>"; };
|
4F56C8E2BC3614530B81569D /* LibNovelApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibNovelApp.swift; sourceTree = "<group>"; };
|
||||||
@@ -70,6 +73,7 @@
|
|||||||
B4C918833E173D6B44D06955 /* LibNovelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibNovelTests.swift; sourceTree = "<group>"; };
|
B4C918833E173D6B44D06955 /* LibNovelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibNovelTests.swift; sourceTree = "<group>"; };
|
||||||
B593F179EC3E9112126B540B /* APIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClient.swift; sourceTree = "<group>"; };
|
B593F179EC3E9112126B540B /* APIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClient.swift; sourceTree = "<group>"; };
|
||||||
C0B17D50389C6C98FC78BDBC /* ProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileView.swift; sourceTree = "<group>"; };
|
C0B17D50389C6C98FC78BDBC /* ProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileView.swift; sourceTree = "<group>"; };
|
||||||
|
D4F1B8A26E3C97D0F52A4B71 /* AvatarCropView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarCropView.swift; sourceTree = "<group>"; };
|
||||||
C21107BECA55C07416E0CB8B /* LibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryView.swift; sourceTree = "<group>"; };
|
C21107BECA55C07416E0CB8B /* LibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryView.swift; sourceTree = "<group>"; };
|
||||||
D6268D60803940CBD38FB921 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = "<group>"; };
|
D6268D60803940CBD38FB921 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = "<group>"; };
|
||||||
DB13E89E50529E3081533A66 /* AudioPlayerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayerService.swift; sourceTree = "<group>"; };
|
DB13E89E50529E3081533A66 /* AudioPlayerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayerService.swift; sourceTree = "<group>"; };
|
||||||
@@ -127,6 +131,7 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
C0B17D50389C6C98FC78BDBC /* ProfileView.swift */,
|
C0B17D50389C6C98FC78BDBC /* ProfileView.swift */,
|
||||||
|
D4F1B8A26E3C97D0F52A4B71 /* AvatarCropView.swift */,
|
||||||
);
|
);
|
||||||
path = Profile;
|
path = Profile;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -265,6 +270,7 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
39DE056C37FBC5EED8771821 /* BookDetailView.swift */,
|
39DE056C37FBC5EED8771821 /* BookDetailView.swift */,
|
||||||
|
B2E8D1C74A3F91D0E5C72A38 /* CommentsView.swift */,
|
||||||
);
|
);
|
||||||
path = BookDetail;
|
path = BookDetail;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -391,6 +397,7 @@
|
|||||||
5D8D783259EF54C773788AAB /* AuthStore.swift in Sources */,
|
5D8D783259EF54C773788AAB /* AuthStore.swift in Sources */,
|
||||||
9B2D6F241E707312AB80DC31 /* AuthView.swift in Sources */,
|
9B2D6F241E707312AB80DC31 /* AuthView.swift in Sources */,
|
||||||
749292A18C57FA41EC88A30B /* BookDetailView.swift in Sources */,
|
749292A18C57FA41EC88A30B /* BookDetailView.swift in Sources */,
|
||||||
|
A1C3F2B84D9E72A1BC054F17 /* CommentsView.swift in Sources */,
|
||||||
7D81DEB2EEFF9CA5079AEEF7 /* BookDetailViewModel.swift in Sources */,
|
7D81DEB2EEFF9CA5079AEEF7 /* BookDetailViewModel.swift in Sources */,
|
||||||
08DFB5F626BA769556C8D145 /* BrowseView.swift in Sources */,
|
08DFB5F626BA769556C8D145 /* BrowseView.swift in Sources */,
|
||||||
2790B8C051BE389D83645047 /* BrowseViewModel.swift in Sources */,
|
2790B8C051BE389D83645047 /* BrowseViewModel.swift in Sources */,
|
||||||
@@ -408,6 +415,7 @@
|
|||||||
F4FDA3C44752EB979235C042 /* NavDestination.swift in Sources */,
|
F4FDA3C44752EB979235C042 /* NavDestination.swift in Sources */,
|
||||||
BE7805A4E78037A82B12AE56 /* PlayerViews.swift in Sources */,
|
BE7805A4E78037A82B12AE56 /* PlayerViews.swift in Sources */,
|
||||||
64D80AACB8E1967B17921EE3 /* ProfileView.swift in Sources */,
|
64D80AACB8E1967B17921EE3 /* ProfileView.swift in Sources */,
|
||||||
|
C3D7A2E15F8B04C9AB163D50 /* AvatarCropView.swift in Sources */,
|
||||||
58E440CE4360D755401D1672 /* ProfileViewModel.swift in Sources */,
|
58E440CE4360D755401D1672 /* ProfileViewModel.swift in Sources */,
|
||||||
367C88FFC11701D2BAD8CCD0 /* RootTabView.swift in Sources */,
|
367C88FFC11701D2BAD8CCD0 /* RootTabView.swift in Sources */,
|
||||||
41FB51553F1F1AEBFEA91C0A /* String+App.swift in Sources */,
|
41FB51553F1F1AEBFEA91C0A /* String+App.swift in Sources */,
|
||||||
|
|||||||
@@ -273,10 +273,13 @@ struct BookComment: Identifiable, Codable, Hashable {
|
|||||||
var upvotes: Int
|
var upvotes: Int
|
||||||
var downvotes: Int
|
var downvotes: Int
|
||||||
let created: String
|
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 {
|
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 userId = "user_id"
|
||||||
|
case parentId = "parent_id"
|
||||||
}
|
}
|
||||||
|
|
||||||
init(from decoder: Decoder) throws {
|
init(from decoder: Decoder) throws {
|
||||||
@@ -289,16 +292,27 @@ struct BookComment: Identifiable, Codable, Hashable {
|
|||||||
upvotes = try c.decodeIfPresent(Int.self, forKey: .upvotes) ?? 0
|
upvotes = try c.decodeIfPresent(Int.self, forKey: .upvotes) ?? 0
|
||||||
downvotes = try c.decodeIfPresent(Int.self, forKey: .downvotes) ?? 0
|
downvotes = try c.decodeIfPresent(Int.self, forKey: .downvotes) ?? 0
|
||||||
created = try c.decodeIfPresent(String.self, forKey: .created) ?? ""
|
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 {
|
struct CommentsResponse: Decodable {
|
||||||
let comments: [BookComment]
|
let comments: [BookComment]
|
||||||
let myVotes: [String: String]
|
let myVotes: [String: String]
|
||||||
|
let avatarUrls: [String: String]
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case comments
|
case comments
|
||||||
case myVotes = "myVotes"
|
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) ?? [:]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -324,14 +324,17 @@ actor APIClient {
|
|||||||
|
|
||||||
// MARK: - Comments
|
// MARK: - Comments
|
||||||
|
|
||||||
func fetchComments(slug: String) async throws -> CommentsResponse {
|
func fetchComments(slug: String, sort: String = "top") async throws -> CommentsResponse {
|
||||||
try await fetch("/api/comments/\(slug)")
|
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 {
|
func postComment(slug: String, body: String, parentId: String? = nil) async throws -> BookComment {
|
||||||
try await fetch("/api/comments/\(slug)", method: "POST", body: PostCommentBody(body: body))
|
try await fetch("/api/comments/\(slug)", method: "POST", body: PostCommentBody(body: body, parent_id: parentId))
|
||||||
}
|
}
|
||||||
|
|
||||||
struct VoteBody: Encodable { let vote: String }
|
struct VoteBody: Encodable { let vote: String }
|
||||||
@@ -341,6 +344,12 @@ actor APIClient {
|
|||||||
func voteComment(commentId: String, vote: String) async throws -> BookComment {
|
func voteComment(commentId: String, vote: String) async throws -> BookComment {
|
||||||
try await fetch("/api/comments/\(commentId)/vote", method: "POST", body: VoteBody(vote: vote))
|
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
|
// MARK: - Response types
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ class CommentsViewModel: ObservableObject {
|
|||||||
|
|
||||||
@Published var comments: [BookComment] = []
|
@Published var comments: [BookComment] = []
|
||||||
@Published var myVotes: [String: String] = [:] // commentId → "up" | "down"
|
@Published var myVotes: [String: String] = [:] // commentId → "up" | "down"
|
||||||
|
@Published var avatarUrls: [String: String] = [:] // userId → presigned URL
|
||||||
@Published var isLoading = true
|
@Published var isLoading = true
|
||||||
@Published var error: String?
|
@Published var error: String?
|
||||||
|
|
||||||
@@ -15,7 +16,16 @@ class CommentsViewModel: ObservableObject {
|
|||||||
@Published var isPosting = false
|
@Published var isPosting = false
|
||||||
@Published var postError: String?
|
@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<String> = []
|
private var votingIds: Set<String> = []
|
||||||
|
private var deletingIds: Set<String> = []
|
||||||
|
|
||||||
init(slug: String) {
|
init(slug: String) {
|
||||||
self.slug = slug
|
self.slug = slug
|
||||||
@@ -25,9 +35,10 @@ class CommentsViewModel: ObservableObject {
|
|||||||
isLoading = true
|
isLoading = true
|
||||||
error = nil
|
error = nil
|
||||||
do {
|
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
|
comments = response.comments
|
||||||
myVotes = response.myVotes
|
myVotes = response.myVotes
|
||||||
|
avatarUrls = response.avatarUrls
|
||||||
} catch {
|
} catch {
|
||||||
self.error = error.localizedDescription
|
self.error = error.localizedDescription
|
||||||
}
|
}
|
||||||
@@ -44,7 +55,8 @@ class CommentsViewModel: ObservableObject {
|
|||||||
isPosting = true
|
isPosting = true
|
||||||
postError = nil
|
postError = nil
|
||||||
do {
|
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)
|
comments.insert(created, at: 0)
|
||||||
newBody = ""
|
newBody = ""
|
||||||
} catch let apiError as APIError {
|
} catch let apiError as APIError {
|
||||||
@@ -58,17 +70,78 @@ class CommentsViewModel: ObservableObject {
|
|||||||
isPosting = false
|
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 }
|
guard !votingIds.contains(commentId) else { return }
|
||||||
votingIds.insert(commentId)
|
votingIds.insert(commentId)
|
||||||
defer { votingIds.remove(commentId) }
|
defer { votingIds.remove(commentId) }
|
||||||
do {
|
do {
|
||||||
let updated = try await APIClient.shared.voteComment(commentId: commentId, vote: vote)
|
let updated = try await APIClient.shared.voteComment(commentId: commentId, vote: vote)
|
||||||
// Update the comment in the list
|
if let parentId {
|
||||||
if let idx = comments.firstIndex(where: { $0.id == commentId }) {
|
if let idx = comments.firstIndex(where: { $0.id == parentId }) {
|
||||||
comments[idx] = updated
|
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]
|
let prev = myVotes[commentId]
|
||||||
if prev == vote {
|
if prev == vote {
|
||||||
myVotes.removeValue(forKey: commentId)
|
myVotes.removeValue(forKey: commentId)
|
||||||
@@ -76,12 +149,29 @@ class CommentsViewModel: ObservableObject {
|
|||||||
myVotes[commentId] = vote
|
myVotes[commentId] = vote
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Silently ignore vote errors — don't disrupt the UI
|
// Silently ignore vote errors
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func isVoting(_ commentId: String) -> Bool {
|
func isVoting(_ commentId: String) -> Bool { votingIds.contains(commentId) }
|
||||||
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 {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
// Section header
|
// Section header + sort picker
|
||||||
HStack {
|
HStack {
|
||||||
Text("Comments")
|
Text("Comments")
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
if !vm.isLoading && !vm.comments.isEmpty {
|
let total = vm.comments.reduce(0) { $0 + 1 + ($1.replies?.count ?? 0) }
|
||||||
Text("(\(vm.comments.count))")
|
if !vm.isLoading && total > 0 {
|
||||||
|
Text("(\(total))")
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
Spacer()
|
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(.horizontal)
|
||||||
.padding(.vertical, 14)
|
.padding(.vertical, 14)
|
||||||
@@ -135,13 +239,7 @@ struct CommentsView: View {
|
|||||||
.padding()
|
.padding()
|
||||||
} else {
|
} else {
|
||||||
ForEach(vm.comments) { comment in
|
ForEach(vm.comments) { comment in
|
||||||
CommentRow(
|
commentThread(comment: comment)
|
||||||
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)
|
Divider().padding(.leading, 16)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -151,61 +249,198 @@ struct CommentsView: View {
|
|||||||
.task { await vm.load() }
|
.task { await vm.load() }
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Post form
|
// MARK: - Comment thread (top-level + replies)
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var postForm: some View {
|
private func commentThread(comment: BookComment) -> some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
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) {
|
ZStack(alignment: .topLeading) {
|
||||||
if vm.newBody.isEmpty {
|
if vm.replyBody.isEmpty {
|
||||||
Text("Write a comment…")
|
Text("Write a reply…")
|
||||||
.font(.subheadline)
|
.font(.caption)
|
||||||
.foregroundStyle(.tertiary)
|
.foregroundStyle(.tertiary)
|
||||||
.padding(.top, 8)
|
.padding(.top, 6)
|
||||||
.padding(.leading, 4)
|
.padding(.leading, 4)
|
||||||
}
|
}
|
||||||
TextEditor(text: $vm.newBody)
|
TextEditor(text: $vm.replyBody)
|
||||||
.font(.subheadline)
|
.font(.caption)
|
||||||
.frame(minHeight: 72, maxHeight: 160)
|
.frame(minHeight: 56, maxHeight: 120)
|
||||||
.scrollContentBackground(.hidden)
|
.scrollContentBackground(.hidden)
|
||||||
}
|
}
|
||||||
.padding(10)
|
.padding(8)
|
||||||
.background(Color(.systemGray6), in: RoundedRectangle(cornerRadius: 10))
|
.background(Color(.systemGray6), in: RoundedRectangle(cornerRadius: 8))
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
let count = vm.newBody.count
|
let count = vm.replyBody.count
|
||||||
Text("\(count)/2000")
|
Text("\(count)/2000")
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.monospacedDigit()
|
.monospacedDigit()
|
||||||
.foregroundStyle(count > 2000 ? .red : .tertiary)
|
.foregroundStyle(count > 2000 ? Color.red : Color.secondary)
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
if let err = vm.postError {
|
if let err = vm.replyError {
|
||||||
Text(err)
|
Text(err).font(.caption2).foregroundStyle(.red).lineLimit(1)
|
||||||
.font(.caption2)
|
|
||||||
.foregroundStyle(.red)
|
|
||||||
.lineLimit(1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Button("Cancel") {
|
||||||
|
vm.replyingToId = nil
|
||||||
|
vm.replyBody = ""
|
||||||
|
vm.replyError = nil
|
||||||
|
}
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
Task { await vm.postComment() }
|
Task { await vm.postReply(parentId: parentId) }
|
||||||
} label: {
|
} label: {
|
||||||
if vm.isPosting {
|
if vm.isPostingReply {
|
||||||
ProgressView().controlSize(.small)
|
ProgressView().controlSize(.mini)
|
||||||
} else {
|
} else {
|
||||||
Text("Post")
|
Text("Reply").fontWeight(.semibold).font(.caption)
|
||||||
.fontWeight(.semibold)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.borderedProminent)
|
||||||
.tint(.amber)
|
.tint(.amber)
|
||||||
.controlSize(.small)
|
.controlSize(.mini)
|
||||||
.disabled(vm.isPosting || vm.newBody.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || vm.newBody.count > 2000)
|
.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
|
// MARK: - Loading skeleton
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
@@ -238,14 +473,23 @@ private struct CommentRow: View {
|
|||||||
let comment: BookComment
|
let comment: BookComment
|
||||||
let myVote: String?
|
let myVote: String?
|
||||||
let isVoting: Bool
|
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 onVote: (String) -> Void
|
||||||
|
let onDelete: () -> Void
|
||||||
|
let onReply: (() -> Void)?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
// Username + date
|
// Avatar + Username + date
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: 8) {
|
||||||
|
avatarView
|
||||||
Text(comment.username.isEmpty ? "Anonymous" : comment.username)
|
Text(comment.username.isEmpty ? "Anonymous" : comment.username)
|
||||||
.font(.subheadline.weight(.medium))
|
.font(isReply ? .caption.weight(.medium) : .subheadline.weight(.medium))
|
||||||
Text("·")
|
Text("·")
|
||||||
.foregroundStyle(.tertiary)
|
.foregroundStyle(.tertiary)
|
||||||
Text(formattedDate(comment.created))
|
Text(formattedDate(comment.created))
|
||||||
@@ -256,16 +500,14 @@ private struct CommentRow: View {
|
|||||||
|
|
||||||
// Body
|
// Body
|
||||||
Text(comment.body)
|
Text(comment.body)
|
||||||
.font(.subheadline)
|
.font(isReply ? .caption : .subheadline)
|
||||||
.foregroundStyle(.primary)
|
.foregroundStyle(.primary)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
|
||||||
// Vote row
|
// Actions
|
||||||
HStack(spacing: 16) {
|
HStack(spacing: 14) {
|
||||||
// Upvote
|
// Upvote
|
||||||
Button {
|
Button { onVote("up") } label: {
|
||||||
onVote("up")
|
|
||||||
} label: {
|
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
Image(systemName: myVote == "up" ? "hand.thumbsup.fill" : "hand.thumbsup")
|
Image(systemName: myVote == "up" ? "hand.thumbsup.fill" : "hand.thumbsup")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
@@ -277,9 +519,7 @@ private struct CommentRow: View {
|
|||||||
.disabled(isVoting)
|
.disabled(isVoting)
|
||||||
|
|
||||||
// Downvote
|
// Downvote
|
||||||
Button {
|
Button { onVote("down") } label: {
|
||||||
onVote("down")
|
|
||||||
} label: {
|
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
Image(systemName: myVote == "down" ? "hand.thumbsdown.fill" : "hand.thumbsdown")
|
Image(systemName: myVote == "down" ? "hand.thumbsdown.fill" : "hand.thumbsdown")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
@@ -290,12 +530,68 @@ private struct CommentRow: View {
|
|||||||
}
|
}
|
||||||
.disabled(isVoting)
|
.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()
|
Spacer()
|
||||||
|
|
||||||
|
// Delete (owner only)
|
||||||
|
if isOwner {
|
||||||
|
Button(role: .destructive) { onDelete() } label: {
|
||||||
|
Image(systemName: "trash")
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
.disabled(isDeleting)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.padding(.vertical, 12)
|
.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 {
|
private func formattedDate(_ iso: String) -> String {
|
||||||
|
|||||||
@@ -20,64 +20,7 @@ struct ProfileView: View {
|
|||||||
// ── User header ────────────────────────────────────────────
|
// ── User header ────────────────────────────────────────────
|
||||||
Section {
|
Section {
|
||||||
HStack(spacing: 16) {
|
HStack(spacing: 16) {
|
||||||
// Tappable avatar circle
|
avatarPicker
|
||||||
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) }
|
|
||||||
}
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 3) {
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
Text(authStore.user?.username ?? "")
|
Text(authStore.user?.username ?? "")
|
||||||
.font(.headline)
|
.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
|
// MARK: - Voice picker
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
|
|||||||
@@ -436,6 +436,7 @@ func (s *PocketBaseStore) EnsureCollections(ctx context.Context) error {
|
|||||||
{"name": "upvotes", "type": "number"},
|
{"name": "upvotes", "type": "number"},
|
||||||
{"name": "downvotes", "type": "number"},
|
{"name": "downvotes", "type": "number"},
|
||||||
{"name": "created", "type": "date"},
|
{"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"},
|
{"progress", "user_id", "text"},
|
||||||
// avatar_url stores the MinIO presign path for the user's profile picture.
|
// avatar_url stores the MinIO presign path for the user's profile picture.
|
||||||
{"app_users", "avatar_url", "text"},
|
{"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
|
// EnsureMigrations idempotently adds any fields that are missing from existing
|
||||||
|
|||||||
@@ -213,7 +213,8 @@ create_collection "book_comments" '{
|
|||||||
{"name": "body", "type": "text", "required": true},
|
{"name": "body", "type": "text", "required": true},
|
||||||
{"name": "upvotes", "type": "number"},
|
{"name": "upvotes", "type": "number"},
|
||||||
{"name": "downvotes", "type": "number"},
|
{"name": "downvotes", "type": "number"},
|
||||||
{"name": "created", "type": "date"}
|
{"name": "created", "type": "date"},
|
||||||
|
{"name": "parent_id", "type": "text"}
|
||||||
]
|
]
|
||||||
}'
|
}'
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onDestroy } from 'svelte';
|
||||||
import Cropper from 'cropperjs';
|
import Cropper from 'cropperjs';
|
||||||
import 'cropperjs/dist/cropper.css';
|
import 'cropperjs/dist/cropper.css';
|
||||||
|
|
||||||
@@ -11,31 +11,54 @@
|
|||||||
|
|
||||||
let { file, onconfirm, oncancel }: Props = $props();
|
let { file, onconfirm, oncancel }: Props = $props();
|
||||||
|
|
||||||
let imgEl: HTMLImageElement;
|
let imgEl: HTMLImageElement | undefined = $state();
|
||||||
let cropper: Cropper | null = null;
|
let cropper: Cropper | null = null;
|
||||||
let objectUrl = $state('');
|
let objectUrl = '';
|
||||||
|
|
||||||
onMount(() => {
|
// Initialize cropper once the img element is bound and the file is known.
|
||||||
|
// Use a $effect so it runs after the DOM is ready (replaces onMount).
|
||||||
|
$effect(() => {
|
||||||
|
if (!imgEl || !file) return;
|
||||||
|
|
||||||
|
// Create the object URL and set src directly on the element (not via reactive
|
||||||
|
// state) so cropperjs sees the correct src before the image load event fires.
|
||||||
objectUrl = URL.createObjectURL(file);
|
objectUrl = URL.createObjectURL(file);
|
||||||
cropper = new Cropper(imgEl, {
|
imgEl.src = objectUrl;
|
||||||
aspectRatio: 1,
|
|
||||||
viewMode: 1,
|
// Cropperjs must be initialised inside the image's load event so it can
|
||||||
dragMode: 'move',
|
// measure the natural dimensions — if we call new Cropper() before the image
|
||||||
autoCropArea: 0.8,
|
// has loaded, the crop canvas is blank/invisible.
|
||||||
restore: false,
|
const handleLoad = () => {
|
||||||
guides: false,
|
cropper = new Cropper(imgEl!, {
|
||||||
center: true,
|
aspectRatio: 1,
|
||||||
highlight: false,
|
viewMode: 1,
|
||||||
cropBoxMovable: true,
|
dragMode: 'move',
|
||||||
cropBoxResizable: true,
|
autoCropArea: 0.8,
|
||||||
toggleDragModeOnDblclick: false,
|
restore: false,
|
||||||
background: false
|
guides: false,
|
||||||
});
|
center: true,
|
||||||
|
highlight: false,
|
||||||
|
cropBoxMovable: true,
|
||||||
|
cropBoxResizable: true,
|
||||||
|
toggleDragModeOnDblclick: false,
|
||||||
|
background: false
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
imgEl.addEventListener('load', handleLoad, { once: true });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
imgEl?.removeEventListener('load', handleLoad);
|
||||||
|
cropper?.destroy();
|
||||||
|
cropper = null;
|
||||||
|
URL.revokeObjectURL(objectUrl);
|
||||||
|
objectUrl = '';
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
cropper?.destroy();
|
cropper?.destroy();
|
||||||
URL.revokeObjectURL(objectUrl);
|
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||||
});
|
});
|
||||||
|
|
||||||
function confirm() {
|
function confirm() {
|
||||||
@@ -62,13 +85,14 @@
|
|||||||
<div class="bg-zinc-900 rounded-2xl border border-zinc-700 shadow-2xl w-full max-w-sm flex flex-col gap-4 p-5">
|
<div class="bg-zinc-900 rounded-2xl border border-zinc-700 shadow-2xl w-full max-w-sm flex flex-col gap-4 p-5">
|
||||||
<h2 class="text-base font-semibold text-zinc-100">Crop profile picture</h2>
|
<h2 class="text-base font-semibold text-zinc-100">Crop profile picture</h2>
|
||||||
|
|
||||||
<!-- Cropper image container -->
|
<!-- Cropper image container — overflow must be visible so cropperjs can
|
||||||
<div class="rounded-xl overflow-hidden bg-zinc-800" style="max-height: 340px;">
|
render the crop canvas outside the natural image bounds. The fixed
|
||||||
|
height gives cropperjs a stable container to size itself against. -->
|
||||||
|
<div class="rounded-xl bg-zinc-800" style="height: 300px; position: relative;">
|
||||||
<img
|
<img
|
||||||
bind:this={imgEl}
|
bind:this={imgEl}
|
||||||
src={objectUrl}
|
|
||||||
alt="Crop preview"
|
alt="Crop preview"
|
||||||
style="display:block; max-width:100%;"
|
style="display:block; max-width:100%; max-height:100%;"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -8,39 +8,60 @@
|
|||||||
upvotes: number;
|
upvotes: number;
|
||||||
downvotes: number;
|
downvotes: number;
|
||||||
created: string;
|
created: string;
|
||||||
|
parent_id?: string;
|
||||||
|
replies?: BookComment[];
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
slug,
|
slug,
|
||||||
isLoggedIn = false
|
isLoggedIn = false,
|
||||||
|
currentUserId = ''
|
||||||
}: {
|
}: {
|
||||||
slug: string;
|
slug: string;
|
||||||
isLoggedIn?: boolean;
|
isLoggedIn?: boolean;
|
||||||
|
currentUserId?: string;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
// ── State ─────────────────────────────────────────────────────────────────
|
// ── State ─────────────────────────────────────────────────────────────────
|
||||||
let comments = $state<BookComment[]>([]);
|
let comments = $state<BookComment[]>([]);
|
||||||
let myVotes = $state<Record<string, 'up' | 'down'>>({});
|
let myVotes = $state<Record<string, 'up' | 'down'>>({});
|
||||||
|
let avatarUrls = $state<Record<string, string>>({});
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let loadError = $state('');
|
let loadError = $state('');
|
||||||
|
|
||||||
|
// Top-level new comment
|
||||||
let newBody = $state('');
|
let newBody = $state('');
|
||||||
let posting = $state(false);
|
let posting = $state(false);
|
||||||
let postError = $state('');
|
let postError = $state('');
|
||||||
|
|
||||||
|
// Sort
|
||||||
|
let sort = $state<'new' | 'top'>('top');
|
||||||
|
|
||||||
|
// Reply state: which comment is being replied to
|
||||||
|
let replyingTo = $state<string | null>(null); // comment id
|
||||||
|
let replyBody = $state('');
|
||||||
|
let replyPosting = $state(false);
|
||||||
|
let replyError = $state('');
|
||||||
|
|
||||||
|
// Delete in-flight set
|
||||||
|
let deletingIds = $state(new Set<string>());
|
||||||
|
|
||||||
// Per-comment vote inflight set (prevents double-clicks)
|
// Per-comment vote inflight set (prevents double-clicks)
|
||||||
let votingIds = $state(new Set<string>());
|
let votingIds = $state(new Set<string>());
|
||||||
|
|
||||||
// ── Load comments on mount ────────────────────────────────────────────────
|
// ── Load comments ─────────────────────────────────────────────────────────
|
||||||
async function loadComments() {
|
async function loadComments() {
|
||||||
loading = true;
|
loading = true;
|
||||||
loadError = '';
|
loadError = '';
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/comments/${encodeURIComponent(slug)}`);
|
const res = await fetch(
|
||||||
|
`/api/comments/${encodeURIComponent(slug)}?sort=${sort}`
|
||||||
|
);
|
||||||
if (!res.ok) throw new Error(`${res.status}`);
|
if (!res.ok) throw new Error(`${res.status}`);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
comments = data.comments ?? [];
|
comments = data.comments ?? [];
|
||||||
myVotes = data.myVotes ?? {};
|
myVotes = data.myVotes ?? {};
|
||||||
|
avatarUrls = data.avatarUrls ?? {};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
loadError = 'Failed to load comments.';
|
loadError = 'Failed to load comments.';
|
||||||
} finally {
|
} finally {
|
||||||
@@ -48,19 +69,24 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run once on component mount via $effect
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
loadComments();
|
loadComments();
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Post comment ──────────────────────────────────────────────────────────
|
// Re-load when sort changes (after initial mount)
|
||||||
|
let firstLoad = true;
|
||||||
|
$effect(() => {
|
||||||
|
// Read sort to create a dependency
|
||||||
|
const _ = sort;
|
||||||
|
if (firstLoad) { firstLoad = false; return; }
|
||||||
|
loadComments();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Post top-level comment ────────────────────────────────────────────────
|
||||||
async function postComment() {
|
async function postComment() {
|
||||||
const text = newBody.trim();
|
const text = newBody.trim();
|
||||||
if (!text || posting) return;
|
if (!text || posting) return;
|
||||||
if (text.length > 2000) {
|
if (text.length > 2000) { postError = 'Comment is too long (max 2000 characters).'; return; }
|
||||||
postError = 'Comment is too long (max 2000 characters).';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
posting = true;
|
posting = true;
|
||||||
postError = '';
|
postError = '';
|
||||||
try {
|
try {
|
||||||
@@ -69,17 +95,20 @@
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ body: text })
|
body: JSON.stringify({ body: text })
|
||||||
});
|
});
|
||||||
if (res.status === 401) {
|
if (res.status === 401) { postError = 'You must be logged in to comment.'; return; }
|
||||||
postError = 'You must be logged in to comment.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.json().catch(() => ({}));
|
const err = await res.json().catch(() => ({}));
|
||||||
postError = err.message ?? 'Failed to post comment.';
|
postError = err.message ?? 'Failed to post comment.';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const created: BookComment = await res.json();
|
const created: BookComment = await res.json();
|
||||||
comments = [created, ...comments];
|
created.replies = [];
|
||||||
|
// Prepend for 'new', or re-sort for 'top'
|
||||||
|
if (sort === 'new') {
|
||||||
|
comments = [created, ...comments];
|
||||||
|
} else {
|
||||||
|
comments = [created, ...comments]; // new comment has 0 score, goes to end after sort would happen
|
||||||
|
}
|
||||||
newBody = '';
|
newBody = '';
|
||||||
} catch {
|
} catch {
|
||||||
postError = 'Failed to post comment.';
|
postError = 'Failed to post comment.';
|
||||||
@@ -88,8 +117,66 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Post reply ────────────────────────────────────────────────────────────
|
||||||
|
async function postReply(parentId: string) {
|
||||||
|
const text = replyBody.trim();
|
||||||
|
if (!text || replyPosting) return;
|
||||||
|
if (text.length > 2000) { replyError = 'Reply is too long (max 2000 characters).'; return; }
|
||||||
|
replyPosting = true;
|
||||||
|
replyError = '';
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/comments/${encodeURIComponent(slug)}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ body: text, parent_id: parentId })
|
||||||
|
});
|
||||||
|
if (res.status === 401) { replyError = 'You must be logged in to reply.'; return; }
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
replyError = err.message ?? 'Failed to post reply.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const created: BookComment = await res.json();
|
||||||
|
// Append to the parent's replies list
|
||||||
|
comments = comments.map((c) => {
|
||||||
|
if (c.id !== parentId) return c;
|
||||||
|
return { ...c, replies: [...(c.replies ?? []), created] };
|
||||||
|
});
|
||||||
|
replyBody = '';
|
||||||
|
replyingTo = null;
|
||||||
|
} catch {
|
||||||
|
replyError = 'Failed to post reply.';
|
||||||
|
} finally {
|
||||||
|
replyPosting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Delete ────────────────────────────────────────────────────────────────
|
||||||
|
async function deleteComment(commentId: string, parentId?: string) {
|
||||||
|
if (deletingIds.has(commentId)) return;
|
||||||
|
deletingIds = new Set([...deletingIds, commentId]);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/comments/${commentId}`, { method: 'DELETE' });
|
||||||
|
if (!res.ok) return;
|
||||||
|
if (parentId) {
|
||||||
|
// Remove reply from parent
|
||||||
|
comments = comments.map((c) => {
|
||||||
|
if (c.id !== parentId) return c;
|
||||||
|
return { ...c, replies: (c.replies ?? []).filter((r) => r.id !== commentId) };
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Remove top-level comment
|
||||||
|
comments = comments.filter((c) => c.id !== commentId);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
const next = new Set(deletingIds);
|
||||||
|
next.delete(commentId);
|
||||||
|
deletingIds = next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Vote ──────────────────────────────────────────────────────────────────
|
// ── Vote ──────────────────────────────────────────────────────────────────
|
||||||
async function vote(commentId: string, v: 'up' | 'down') {
|
async function vote(commentId: string, v: 'up' | 'down', parentId?: string) {
|
||||||
if (votingIds.has(commentId)) return;
|
if (votingIds.has(commentId)) return;
|
||||||
votingIds = new Set([...votingIds, commentId]);
|
votingIds = new Set([...votingIds, commentId]);
|
||||||
try {
|
try {
|
||||||
@@ -100,8 +187,18 @@
|
|||||||
});
|
});
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
const updated: BookComment = await res.json();
|
const updated: BookComment = await res.json();
|
||||||
// Update comment in list
|
// Update comment in list (handle both top-level and replies)
|
||||||
comments = comments.map((c) => (c.id === commentId ? updated : c));
|
if (parentId) {
|
||||||
|
comments = comments.map((c) => {
|
||||||
|
if (c.id !== parentId) return c;
|
||||||
|
return {
|
||||||
|
...c,
|
||||||
|
replies: (c.replies ?? []).map((r) => (r.id === commentId ? updated : r))
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
comments = comments.map((c) => (c.id === commentId ? { ...updated, replies: c.replies } : c));
|
||||||
|
}
|
||||||
// Update myVotes: toggle off if same, else set new vote
|
// Update myVotes: toggle off if same, else set new vote
|
||||||
const prev = myVotes[commentId];
|
const prev = myVotes[commentId];
|
||||||
if (prev === v) {
|
if (prev === v) {
|
||||||
@@ -119,13 +216,24 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
function initials(username: string): string {
|
||||||
|
const name = username.trim() || '?';
|
||||||
|
return name.slice(0, 2).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
function formatDate(iso: string): string {
|
function formatDate(iso: string): string {
|
||||||
try {
|
try {
|
||||||
return new Date(iso).toLocaleDateString(undefined, {
|
const date = new Date(iso);
|
||||||
year: 'numeric',
|
const now = Date.now();
|
||||||
month: 'short',
|
const diffMs = now - date.getTime();
|
||||||
day: 'numeric'
|
const diffMins = Math.floor(diffMs / 60_000);
|
||||||
});
|
if (diffMins < 1) return 'just now';
|
||||||
|
if (diffMins < 60) return `${diffMins}m ago`;
|
||||||
|
const diffHours = Math.floor(diffMins / 60);
|
||||||
|
if (diffHours < 24) return `${diffHours}h ago`;
|
||||||
|
const diffDays = Math.floor(diffHours / 24);
|
||||||
|
if (diffDays < 30) return `${diffDays}d ago`;
|
||||||
|
return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
|
||||||
} catch {
|
} catch {
|
||||||
return iso;
|
return iso;
|
||||||
}
|
}
|
||||||
@@ -133,15 +241,46 @@
|
|||||||
|
|
||||||
const charCount = $derived(newBody.length);
|
const charCount = $derived(newBody.length);
|
||||||
const charOver = $derived(charCount > 2000);
|
const charOver = $derived(charCount > 2000);
|
||||||
|
const replyCharCount = $derived(replyBody.length);
|
||||||
|
const replyCharOver = $derived(replyCharCount > 2000);
|
||||||
|
|
||||||
|
const totalCount = $derived(
|
||||||
|
comments.reduce((n, c) => n + 1 + (c.replies?.length ?? 0), 0)
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="mt-10">
|
<div class="mt-10">
|
||||||
<h2 class="text-base font-semibold text-zinc-200 mb-4">
|
<!-- Header + sort controls -->
|
||||||
Comments
|
<div class="flex items-center justify-between gap-3 mb-4 flex-wrap">
|
||||||
|
<h2 class="text-base font-semibold text-zinc-200">
|
||||||
|
Comments
|
||||||
|
{#if !loading && totalCount > 0}
|
||||||
|
<span class="text-zinc-500 font-normal text-sm ml-1">({totalCount})</span>
|
||||||
|
{/if}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<!-- Sort tabs -->
|
||||||
{#if !loading && comments.length > 0}
|
{#if !loading && comments.length > 0}
|
||||||
<span class="text-zinc-500 font-normal text-sm ml-1">({comments.length})</span>
|
<div class="flex items-center gap-1 text-xs rounded-lg bg-zinc-800/60 p-1">
|
||||||
|
<button
|
||||||
|
onclick={() => (sort = 'top')}
|
||||||
|
class="px-2.5 py-1 rounded-md transition-colors {sort === 'top'
|
||||||
|
? 'bg-zinc-700 text-zinc-100'
|
||||||
|
: 'text-zinc-500 hover:text-zinc-300'}"
|
||||||
|
>
|
||||||
|
Top
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => (sort = 'new')}
|
||||||
|
class="px-2.5 py-1 rounded-md transition-colors {sort === 'new'
|
||||||
|
? 'bg-zinc-700 text-zinc-100'
|
||||||
|
: 'text-zinc-500 hover:text-zinc-300'}"
|
||||||
|
>
|
||||||
|
New
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</h2>
|
</div>
|
||||||
|
|
||||||
<!-- Post form -->
|
<!-- Post form -->
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
@@ -202,9 +341,19 @@
|
|||||||
{#each comments as comment (comment.id)}
|
{#each comments as comment (comment.id)}
|
||||||
{@const myVote = myVotes[comment.id]}
|
{@const myVote = myVotes[comment.id]}
|
||||||
{@const voting = votingIds.has(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">
|
{@const deleting = deletingIds.has(comment.id)}
|
||||||
<!-- Header: username + date -->
|
{@const isOwner = isLoggedIn && currentUserId === comment.user_id}
|
||||||
|
|
||||||
|
<div class="rounded-lg bg-zinc-800/50 border border-zinc-700/50 px-4 py-3 flex flex-col gap-2 {deleting ? 'opacity-50' : ''}">
|
||||||
|
<!-- Header -->
|
||||||
<div class="flex items-center gap-2 flex-wrap">
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
|
{#if avatarUrls[comment.user_id]}
|
||||||
|
<img src={avatarUrls[comment.user_id]} alt={comment.username} class="w-6 h-6 rounded-full object-cover flex-shrink-0" />
|
||||||
|
{:else}
|
||||||
|
<div class="w-6 h-6 rounded-full bg-zinc-700 flex items-center justify-center flex-shrink-0">
|
||||||
|
<span class="text-[9px] font-semibold text-zinc-300 leading-none">{initials(comment.username)}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
<span class="text-sm font-medium text-zinc-200">{comment.username || 'Anonymous'}</span>
|
<span class="text-sm font-medium text-zinc-200">{comment.username || 'Anonymous'}</span>
|
||||||
<span class="text-zinc-600 text-xs">·</span>
|
<span class="text-zinc-600 text-xs">·</span>
|
||||||
<span class="text-xs text-zinc-500">{formatDate(comment.created)}</span>
|
<span class="text-xs text-zinc-500">{formatDate(comment.created)}</span>
|
||||||
@@ -213,17 +362,15 @@
|
|||||||
<!-- Body -->
|
<!-- Body -->
|
||||||
<p class="text-sm text-zinc-300 leading-relaxed whitespace-pre-wrap break-words">{comment.body}</p>
|
<p class="text-sm text-zinc-300 leading-relaxed whitespace-pre-wrap break-words">{comment.body}</p>
|
||||||
|
|
||||||
<!-- Vote row -->
|
<!-- Actions row: votes + reply + delete -->
|
||||||
<div class="flex items-center gap-3 pt-1">
|
<div class="flex items-center gap-3 pt-1 flex-wrap">
|
||||||
<!-- Upvote -->
|
<!-- Upvote -->
|
||||||
<button
|
<button
|
||||||
onclick={() => vote(comment.id, 'up')}
|
onclick={() => vote(comment.id, 'up')}
|
||||||
disabled={voting}
|
disabled={voting}
|
||||||
title="Upvote"
|
title="Upvote"
|
||||||
class="flex items-center gap-1 text-xs transition-colors disabled:opacity-50
|
class="flex items-center gap-1 text-xs transition-colors disabled:opacity-50
|
||||||
{myVote === 'up'
|
{myVote === 'up' ? 'text-amber-400' : 'text-zinc-500 hover:text-zinc-300'}"
|
||||||
? '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">
|
<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"/>
|
<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"/>
|
||||||
@@ -237,16 +384,167 @@
|
|||||||
disabled={voting}
|
disabled={voting}
|
||||||
title="Downvote"
|
title="Downvote"
|
||||||
class="flex items-center gap-1 text-xs transition-colors disabled:opacity-50
|
class="flex items-center gap-1 text-xs transition-colors disabled:opacity-50
|
||||||
{myVote === 'down'
|
{myVote === 'down' ? 'text-red-400' : 'text-zinc-500 hover:text-zinc-300'}"
|
||||||
? '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">
|
<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"/>
|
<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>
|
</svg>
|
||||||
<span class="tabular-nums">{comment.downvotes ?? 0}</span>
|
<span class="tabular-nums">{comment.downvotes ?? 0}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<!-- Reply button -->
|
||||||
|
{#if isLoggedIn}
|
||||||
|
<button
|
||||||
|
onclick={() => {
|
||||||
|
if (replyingTo === comment.id) {
|
||||||
|
replyingTo = null;
|
||||||
|
replyBody = '';
|
||||||
|
replyError = '';
|
||||||
|
} else {
|
||||||
|
replyingTo = comment.id;
|
||||||
|
replyBody = '';
|
||||||
|
replyError = '';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
class="flex items-center gap-1 text-xs transition-colors
|
||||||
|
{replyingTo === comment.id
|
||||||
|
? '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="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"/>
|
||||||
|
</svg>
|
||||||
|
Reply
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Delete (owner only) -->
|
||||||
|
{#if isOwner}
|
||||||
|
<button
|
||||||
|
onclick={() => deleteComment(comment.id)}
|
||||||
|
disabled={deleting}
|
||||||
|
class="flex items-center gap-1 text-xs text-zinc-600 hover:text-red-400 transition-colors disabled:opacity-50 ml-auto"
|
||||||
|
title="Delete comment"
|
||||||
|
>
|
||||||
|
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
||||||
|
</svg>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Inline reply form -->
|
||||||
|
{#if replyingTo === comment.id}
|
||||||
|
<div class="mt-1 flex flex-col gap-2 pl-2 border-l-2 border-zinc-700">
|
||||||
|
<textarea
|
||||||
|
bind:value={replyBody}
|
||||||
|
placeholder="Write a reply…"
|
||||||
|
rows="2"
|
||||||
|
class="w-full px-3 py-2 rounded-lg bg-zinc-900 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-2">
|
||||||
|
<span class="text-xs {replyCharOver ? 'text-red-400' : 'text-zinc-600'} tabular-nums">
|
||||||
|
{replyCharCount}/2000
|
||||||
|
</span>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
{#if replyError}
|
||||||
|
<span class="text-xs text-red-400">{replyError}</span>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
onclick={() => { replyingTo = null; replyBody = ''; replyError = ''; }}
|
||||||
|
class="px-3 py-1 rounded-lg text-xs text-zinc-400 hover:text-zinc-200 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => postReply(comment.id)}
|
||||||
|
disabled={replyPosting || !replyBody.trim() || replyCharOver}
|
||||||
|
class="px-3 py-1.5 rounded-lg text-xs font-medium transition-colors
|
||||||
|
{replyPosting || !replyBody.trim() || replyCharOver
|
||||||
|
? 'bg-zinc-700 text-zinc-500 cursor-not-allowed'
|
||||||
|
: 'bg-amber-400 text-zinc-900 hover:bg-amber-300'}"
|
||||||
|
>
|
||||||
|
{replyPosting ? 'Posting…' : 'Reply'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Replies -->
|
||||||
|
{#if comment.replies && comment.replies.length > 0}
|
||||||
|
<div class="mt-1 flex flex-col gap-2 pl-3 border-l-2 border-zinc-700/60">
|
||||||
|
{#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}
|
||||||
|
|
||||||
|
<div class="rounded-md bg-zinc-800/30 px-3 py-2.5 flex flex-col gap-1.5 {replyDeleting ? 'opacity-50' : ''}">
|
||||||
|
<!-- Reply header -->
|
||||||
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
|
{#if avatarUrls[reply.user_id]}
|
||||||
|
<img src={avatarUrls[reply.user_id]} alt={reply.username} class="w-5 h-5 rounded-full object-cover flex-shrink-0" />
|
||||||
|
{:else}
|
||||||
|
<div class="w-5 h-5 rounded-full bg-zinc-700 flex items-center justify-center flex-shrink-0">
|
||||||
|
<span class="text-[8px] font-semibold text-zinc-300 leading-none">{initials(reply.username)}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<span class="text-xs font-medium text-zinc-300">{reply.username || 'Anonymous'}</span>
|
||||||
|
<span class="text-zinc-600 text-xs">·</span>
|
||||||
|
<span class="text-xs text-zinc-500">{formatDate(reply.created)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Reply body -->
|
||||||
|
<p class="text-sm text-zinc-300 leading-relaxed whitespace-pre-wrap break-words">{reply.body}</p>
|
||||||
|
|
||||||
|
<!-- Reply actions -->
|
||||||
|
<div class="flex items-center gap-3 pt-0.5">
|
||||||
|
<button
|
||||||
|
onclick={() => vote(reply.id, 'up', comment.id)}
|
||||||
|
disabled={replyVoting}
|
||||||
|
title="Upvote"
|
||||||
|
class="flex items-center gap-1 text-xs transition-colors disabled:opacity-50
|
||||||
|
{replyVote === 'up' ? 'text-amber-400' : 'text-zinc-500 hover:text-zinc-300'}"
|
||||||
|
>
|
||||||
|
<svg class="w-3 h-3" 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">{reply.upvotes ?? 0}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onclick={() => vote(reply.id, 'down', comment.id)}
|
||||||
|
disabled={replyVoting}
|
||||||
|
title="Downvote"
|
||||||
|
class="flex items-center gap-1 text-xs transition-colors disabled:opacity-50
|
||||||
|
{replyVote === 'down' ? 'text-red-400' : 'text-zinc-500 hover:text-zinc-300'}"
|
||||||
|
>
|
||||||
|
<svg class="w-3 h-3" 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">{reply.downvotes ?? 0}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if replyIsOwner}
|
||||||
|
<button
|
||||||
|
onclick={() => deleteComment(reply.id, comment.id)}
|
||||||
|
disabled={replyDeleting}
|
||||||
|
class="flex items-center gap-1 text-xs text-zinc-600 hover:text-red-400 transition-colors disabled:opacity-50 ml-auto"
|
||||||
|
title="Delete reply"
|
||||||
|
>
|
||||||
|
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
||||||
|
</svg>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -823,6 +823,7 @@ export interface BookComment {
|
|||||||
upvotes: number;
|
upvotes: number;
|
||||||
downvotes: number;
|
downvotes: number;
|
||||||
created: string;
|
created: string;
|
||||||
|
parent_id?: string; // empty / absent = top-level; set = reply
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CommentVote {
|
export interface CommentVote {
|
||||||
@@ -833,14 +834,54 @@ export interface CommentVote {
|
|||||||
vote: 'up' | 'down';
|
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<BookComment[]> {
|
export async function listComments(
|
||||||
|
slug: string,
|
||||||
|
sort: CommentSort = 'new'
|
||||||
|
): Promise<BookComment[]> {
|
||||||
const token = await getToken();
|
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(
|
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<BookComment[]> {
|
||||||
|
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}` } }
|
{ headers: { Authorization: `Bearer ${token}` } }
|
||||||
);
|
);
|
||||||
if (!res.ok) return [];
|
if (!res.ok) return [];
|
||||||
@@ -850,12 +891,14 @@ export async function listComments(slug: string): Promise<BookComment[]> {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new comment. Returns the created record.
|
* 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(
|
export async function createComment(
|
||||||
slug: string,
|
slug: string,
|
||||||
body: string,
|
body: string,
|
||||||
userId: string | undefined,
|
userId: string | undefined,
|
||||||
username: string
|
username: string,
|
||||||
|
parentId?: string
|
||||||
): Promise<BookComment> {
|
): Promise<BookComment> {
|
||||||
const token = await getToken();
|
const token = await getToken();
|
||||||
const res = await fetch(`${PB_URL}/api/collections/book_comments/records`, {
|
const res = await fetch(`${PB_URL}/api/collections/book_comments/records`, {
|
||||||
@@ -868,6 +911,7 @@ export async function createComment(
|
|||||||
username,
|
username,
|
||||||
upvotes: 0,
|
upvotes: 0,
|
||||||
downvotes: 0,
|
downvotes: 0,
|
||||||
|
parent_id: parentId ?? '',
|
||||||
created: new Date().toISOString()
|
created: new Date().toISOString()
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
@@ -878,6 +922,49 @@ export async function createComment(
|
|||||||
return res.json() as Promise<BookComment>;
|
return res.json() as Promise<BookComment>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<void> {
|
||||||
|
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.
|
* Get an existing vote by this voter (identified by user_id or session_id) on a comment.
|
||||||
*/
|
*/
|
||||||
|
|||||||
25
ui/src/routes/api/comments/[id]/+server.ts
Normal file
25
ui/src/routes/api/comments/[id]/+server.ts
Normal file
@@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,23 +1,62 @@
|
|||||||
import { json, error } from '@sveltejs/kit';
|
import { json, error } from '@sveltejs/kit';
|
||||||
import type { RequestHandler } from './$types';
|
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';
|
import { log } from '$lib/server/logger';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/comments/[slug]
|
* GET /api/comments/[slug]?sort=new|top
|
||||||
* Returns comments for a book + the current visitor's votes.
|
* Returns top-level comments + their replies + current visitor's votes + avatar URLs.
|
||||||
* Response: { comments: BookComment[], myVotes: Record<string, 'up'|'down'> }
|
* Response: { comments: BookComment[], myVotes: Record<string, 'up'|'down'>, avatarUrls: Record<string, string> }
|
||||||
|
* 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 { slug } = params;
|
||||||
|
const sortParam = url.searchParams.get('sort') ?? 'new';
|
||||||
|
const sort: CommentSort = sortParam === 'top' ? 'top' : 'new';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const comments = await listComments(slug);
|
const topLevel = await listComments(slug, sort);
|
||||||
const myVotes = await getMyVotes(
|
|
||||||
comments.map((c) => c.id),
|
// Fetch replies for all top-level comments in parallel
|
||||||
locals.sessionId,
|
const repliesPerComment = await Promise.all(topLevel.map((c) => listReplies(c.id)));
|
||||||
locals.user?.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<string, string> = {};
|
||||||
|
for (const [userId, url] of avatarEntries) {
|
||||||
|
if (url) avatarUrls[userId] = url;
|
||||||
|
}
|
||||||
|
|
||||||
|
return json({ comments, myVotes, avatarUrls });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log.error('api/comments/[slug]', 'listComments failed', { slug, err: String(e) });
|
log.error('api/comments/[slug]', 'listComments failed', { slug, err: String(e) });
|
||||||
error(500, 'Failed to load comments');
|
error(500, 'Failed to load comments');
|
||||||
@@ -26,14 +65,14 @@ export const GET: RequestHandler = async ({ params, locals }) => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/comments/[slug]
|
* POST /api/comments/[slug]
|
||||||
* Body: { body: string }
|
* Body: { body: string, parent_id?: string }
|
||||||
* Creates a new comment. Requires authentication.
|
* Creates a new comment or reply. Requires authentication.
|
||||||
*/
|
*/
|
||||||
export const POST: RequestHandler = async ({ params, request, locals }) => {
|
export const POST: RequestHandler = async ({ params, request, locals }) => {
|
||||||
if (!locals.user) error(401, 'Login required to comment');
|
if (!locals.user) error(401, 'Login required to comment');
|
||||||
|
|
||||||
const { slug } = params;
|
const { slug } = params;
|
||||||
let body: { body?: string };
|
let body: { body?: string; parent_id?: string };
|
||||||
try {
|
try {
|
||||||
body = await request.json();
|
body = await request.json();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -44,8 +83,17 @@ export const POST: RequestHandler = async ({ params, request, locals }) => {
|
|||||||
if (!text) error(400, 'Comment body is required');
|
if (!text) error(400, 'Comment body is required');
|
||||||
if (text.length > 2000) error(400, 'Comment is too long (max 2000 characters)');
|
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 {
|
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 });
|
return json(comment, { status: 201 });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log.error('api/comments/[slug]', 'createComment failed', { slug, err: String(e) });
|
log.error('api/comments/[slug]', 'createComment failed', { slug, err: String(e) });
|
||||||
|
|||||||
@@ -43,7 +43,9 @@ export const load: PageServerLoad = async ({ params, locals }) => {
|
|||||||
inLib: true,
|
inLib: true,
|
||||||
saved,
|
saved,
|
||||||
lastChapter: progress?.chapter ?? null,
|
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,
|
inLib: preview.in_lib,
|
||||||
saved: false,
|
saved: false,
|
||||||
lastChapter: null,
|
lastChapter: null,
|
||||||
isAdmin: locals.user?.role === 'admin'
|
isAdmin: locals.user?.role === 'admin',
|
||||||
|
isLoggedIn: !!locals.user,
|
||||||
|
currentUserId: locals.user?.id ?? ''
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof Error && 'status' in e) throw e;
|
if (e instanceof Error && 'status' in e) throw e;
|
||||||
|
|||||||
@@ -528,4 +528,4 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ══════════════════════════════════════════════════ Comments ══ -->
|
<!-- ══════════════════════════════════════════════════ Comments ══ -->
|
||||||
<CommentsSection slug={data.book.slug} isLoggedIn={true} />
|
<CommentsSection slug={data.book.slug} isLoggedIn={data.isLoggedIn} currentUserId={data.currentUserId} />
|
||||||
|
|||||||
Reference in New Issue
Block a user