Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b6dbeb042 | ||
|
|
c06877069f | ||
|
|
261c738fc0 | ||
|
|
5528abe4b0 | ||
|
|
09cdda2a07 | ||
|
|
718bfa6691 | ||
|
|
e11e866e27 | ||
|
|
23345e22e6 |
@@ -74,3 +74,6 @@ jobs:
|
||||
tags: |
|
||||
${{ secrets.DOCKER_USER }}/libnovel-scraper:latest
|
||||
${{ secrets.DOCKER_USER }}/libnovel-scraper:${{ gitea.sha }}
|
||||
build-args: |
|
||||
VERSION=${{ gitea.sha }}
|
||||
COMMIT=${{ gitea.sha }}
|
||||
|
||||
@@ -65,3 +65,6 @@ jobs:
|
||||
tags: |
|
||||
${{ secrets.DOCKER_USER }}/libnovel-ui:latest
|
||||
${{ secrets.DOCKER_USER }}/libnovel-ui:${{ gitea.sha }}
|
||||
build-args: |
|
||||
BUILD_VERSION=${{ gitea.sha }}
|
||||
BUILD_COMMIT=${{ gitea.sha }}
|
||||
|
||||
@@ -63,3 +63,6 @@ jobs:
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
VERSION=${{ steps.meta.outputs.version }}
|
||||
COMMIT=${{ gitea.sha }}
|
||||
|
||||
@@ -66,3 +66,6 @@ jobs:
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
BUILD_VERSION=${{ steps.meta.outputs.version }}
|
||||
BUILD_COMMIT=${{ gitea.sha }}
|
||||
|
||||
@@ -82,6 +82,9 @@ services:
|
||||
build:
|
||||
context: ./scraper
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
VERSION: "${GIT_TAG:-dev}"
|
||||
COMMIT: "${GIT_COMMIT:-unknown}"
|
||||
#container_name: libnovel-scraper
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
@@ -131,6 +134,9 @@ services:
|
||||
build:
|
||||
context: ./ui
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
BUILD_VERSION: "${GIT_TAG:-dev}"
|
||||
BUILD_COMMIT: "${GIT_COMMIT:-unknown}"
|
||||
# container_name: libnovel-ui
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
|
||||
@@ -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 = "<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>"; };
|
||||
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>"; };
|
||||
@@ -70,6 +73,7 @@
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
@@ -127,6 +131,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
C0B17D50389C6C98FC78BDBC /* ProfileView.swift */,
|
||||
D4F1B8A26E3C97D0F52A4B71 /* AvatarCropView.swift */,
|
||||
);
|
||||
path = Profile;
|
||||
sourceTree = "<group>";
|
||||
@@ -265,6 +270,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
39DE056C37FBC5EED8771821 /* BookDetailView.swift */,
|
||||
B2E8D1C74A3F91D0E5C72A38 /* CommentsView.swift */,
|
||||
);
|
||||
path = BookDetail;
|
||||
sourceTree = "<group>";
|
||||
@@ -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 */,
|
||||
|
||||
@@ -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) ?? [:]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
@@ -339,7 +342,13 @@ actor APIClient {
|
||||
/// Cast, change, or toggle-off a vote on a comment.
|
||||
/// Returns the updated BookComment (with refreshed upvotes/downvotes counts).
|
||||
func voteComment(commentId: String, vote: String) async throws -> BookComment {
|
||||
try await fetch("/api/comments/\(commentId)/vote", method: "POST", body: VoteBody(vote: vote))
|
||||
try await fetch("/api/comment/\(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/comment/\(commentId)", method: "DELETE")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<String> = []
|
||||
private var deletingIds: Set<String> = []
|
||||
|
||||
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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -9,8 +9,14 @@ RUN go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
# Build-time version info — injected by docker-compose or CI via --build-arg.
|
||||
ARG VERSION=dev
|
||||
ARG COMMIT=unknown
|
||||
|
||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
|
||||
go build -ldflags="-s -w" -o /scraper ./cmd/scraper
|
||||
go build \
|
||||
-ldflags="-s -w -X main.Version=${VERSION} -X main.Commit=${COMMIT}" \
|
||||
-o /scraper ./cmd/scraper
|
||||
|
||||
# ── Runtime stage ──────────────────────────────────────────────────────────────
|
||||
FROM alpine:3.21
|
||||
|
||||
@@ -47,6 +47,13 @@ import (
|
||||
"github.com/libnovel/scraper/internal/storage"
|
||||
)
|
||||
|
||||
// Build-time version info — injected via -ldflags during docker build.
|
||||
// Falls back to "dev" / "unknown" when built without -ldflags (local dev).
|
||||
var (
|
||||
Version = "dev"
|
||||
Commit = "unknown"
|
||||
)
|
||||
|
||||
func main() {
|
||||
logLevel := slog.LevelInfo
|
||||
if v := os.Getenv("LOG_LEVEL"); v != "" {
|
||||
@@ -183,7 +190,7 @@ func run(log *slog.Logger) error {
|
||||
"pocketbase_url", pbCfg.BaseURL,
|
||||
"pocketbase_email", pbCfg.AdminEmail,
|
||||
)
|
||||
srv := server.New(addr, oCfg, nf, log, store, kokoroURL, kokoroVoice)
|
||||
srv := server.New(addr, oCfg, nf, log, store, kokoroURL, kokoroVoice, Version, Commit)
|
||||
return srv.ListenAndServe(ctx)
|
||||
|
||||
case "save-browse":
|
||||
|
||||
@@ -43,6 +43,8 @@ type Server struct {
|
||||
running bool
|
||||
kokoroURL string // Kokoro-FastAPI base URL, e.g. http://kokoro:8880
|
||||
kokoroVoice string // default voice, e.g. af_bella
|
||||
version string // semver tag, e.g. "v1.2.3" (set via ldflags)
|
||||
commit string // short git SHA (set via ldflags)
|
||||
|
||||
// voiceMu guards cachedVoices.
|
||||
voiceMu sync.RWMutex
|
||||
@@ -74,7 +76,7 @@ type browseCacheEntry struct {
|
||||
}
|
||||
|
||||
// New creates a new Server.
|
||||
func New(addr string, oCfg orchestrator.Config, novel scraper.NovelScraper, log *slog.Logger, store storage.Store, kokoroURL, kokoroVoice string) *Server {
|
||||
func New(addr string, oCfg orchestrator.Config, novel scraper.NovelScraper, log *slog.Logger, store storage.Store, kokoroURL, kokoroVoice, version, commit string) *Server {
|
||||
return &Server{
|
||||
addr: addr,
|
||||
oCfg: oCfg,
|
||||
@@ -83,6 +85,8 @@ func New(addr string, oCfg orchestrator.Config, novel scraper.NovelScraper, log
|
||||
store: store,
|
||||
kokoroURL: kokoroURL,
|
||||
kokoroVoice: kokoroVoice,
|
||||
version: version,
|
||||
commit: commit,
|
||||
audioJobIDs: make(map[string]string),
|
||||
browseInFlight: make(map[string]struct{}),
|
||||
browseMemCache: make(map[string]browseCacheEntry),
|
||||
@@ -138,6 +142,7 @@ func (s *Server) voices() []string {
|
||||
func (s *Server) ListenAndServe(ctx context.Context) error {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /health", s.handleHealth)
|
||||
mux.HandleFunc("GET /api/version", s.handleVersion)
|
||||
mux.HandleFunc("POST /scrape", s.handleScrapeCatalogue)
|
||||
mux.HandleFunc("POST /scrape/book", s.handleScrapeBook)
|
||||
mux.HandleFunc("POST /scrape/book/range", s.handleScrapeBookRange)
|
||||
@@ -222,7 +227,19 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
|
||||
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
"status": "ok",
|
||||
"version": s.version,
|
||||
"commit": s.commit,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleVersion(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
"version": s.version,
|
||||
"commit": s.commit,
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Session cookie helpers ───────────────────────────────────────────────────
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"}
|
||||
]
|
||||
}'
|
||||
|
||||
|
||||
@@ -5,6 +5,15 @@ COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
|
||||
# Build-time version info — injected by docker-compose or CI via --build-arg.
|
||||
ARG BUILD_VERSION=dev
|
||||
ARG BUILD_COMMIT=unknown
|
||||
|
||||
# Expose as PUBLIC_ env vars so SvelteKit's $env/dynamic/public can read them.
|
||||
ENV PUBLIC_BUILD_VERSION=$BUILD_VERSION
|
||||
ENV PUBLIC_BUILD_COMMIT=$BUILD_COMMIT
|
||||
|
||||
RUN npm run build
|
||||
|
||||
# ── Runtime image ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { onDestroy } from 'svelte';
|
||||
import Cropper from 'cropperjs';
|
||||
import 'cropperjs/dist/cropper.css';
|
||||
|
||||
@@ -11,31 +11,54 @@
|
||||
|
||||
let { file, onconfirm, oncancel }: Props = $props();
|
||||
|
||||
let imgEl: HTMLImageElement;
|
||||
let imgEl: HTMLImageElement | undefined = $state();
|
||||
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);
|
||||
cropper = new Cropper(imgEl, {
|
||||
aspectRatio: 1,
|
||||
viewMode: 1,
|
||||
dragMode: 'move',
|
||||
autoCropArea: 0.8,
|
||||
restore: false,
|
||||
guides: false,
|
||||
center: true,
|
||||
highlight: false,
|
||||
cropBoxMovable: true,
|
||||
cropBoxResizable: true,
|
||||
toggleDragModeOnDblclick: false,
|
||||
background: false
|
||||
});
|
||||
imgEl.src = objectUrl;
|
||||
|
||||
// Cropperjs must be initialised inside the image's load event so it can
|
||||
// measure the natural dimensions — if we call new Cropper() before the image
|
||||
// has loaded, the crop canvas is blank/invisible.
|
||||
const handleLoad = () => {
|
||||
cropper = new Cropper(imgEl!, {
|
||||
aspectRatio: 1,
|
||||
viewMode: 1,
|
||||
dragMode: 'move',
|
||||
autoCropArea: 0.8,
|
||||
restore: 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(() => {
|
||||
cropper?.destroy();
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||
});
|
||||
|
||||
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">
|
||||
<h2 class="text-base font-semibold text-zinc-100">Crop profile picture</h2>
|
||||
|
||||
<!-- Cropper image container -->
|
||||
<div class="rounded-xl overflow-hidden bg-zinc-800" style="max-height: 340px;">
|
||||
<!-- Cropper image container — overflow must be visible so cropperjs can
|
||||
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
|
||||
bind:this={imgEl}
|
||||
src={objectUrl}
|
||||
alt="Crop preview"
|
||||
style="display:block; max-width:100%;"
|
||||
style="display:block; max-width:100%; max-height:100%;"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -8,39 +8,60 @@
|
||||
upvotes: number;
|
||||
downvotes: number;
|
||||
created: string;
|
||||
parent_id?: string;
|
||||
replies?: BookComment[];
|
||||
}
|
||||
|
||||
let {
|
||||
slug,
|
||||
isLoggedIn = false
|
||||
isLoggedIn = false,
|
||||
currentUserId = ''
|
||||
}: {
|
||||
slug: string;
|
||||
isLoggedIn?: boolean;
|
||||
currentUserId?: string;
|
||||
} = $props();
|
||||
|
||||
// ── State ─────────────────────────────────────────────────────────────────
|
||||
let comments = $state<BookComment[]>([]);
|
||||
let myVotes = $state<Record<string, 'up' | 'down'>>({});
|
||||
let avatarUrls = $state<Record<string, string>>({});
|
||||
let loading = $state(true);
|
||||
let loadError = $state('');
|
||||
|
||||
// Top-level new comment
|
||||
let newBody = $state('');
|
||||
let posting = $state(false);
|
||||
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)
|
||||
let votingIds = $state(new Set<string>());
|
||||
|
||||
// ── Load comments on mount ────────────────────────────────────────────────
|
||||
// ── Load comments ─────────────────────────────────────────────────────────
|
||||
async function loadComments() {
|
||||
loading = true;
|
||||
loadError = '';
|
||||
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}`);
|
||||
const data = await res.json();
|
||||
comments = data.comments ?? [];
|
||||
myVotes = data.myVotes ?? {};
|
||||
avatarUrls = data.avatarUrls ?? {};
|
||||
} catch (e) {
|
||||
loadError = 'Failed to load comments.';
|
||||
} finally {
|
||||
@@ -48,19 +69,24 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Run once on component mount via $effect
|
||||
$effect(() => {
|
||||
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() {
|
||||
const text = newBody.trim();
|
||||
if (!text || posting) return;
|
||||
if (text.length > 2000) {
|
||||
postError = 'Comment is too long (max 2000 characters).';
|
||||
return;
|
||||
}
|
||||
if (text.length > 2000) { postError = 'Comment is too long (max 2000 characters).'; return; }
|
||||
posting = true;
|
||||
postError = '';
|
||||
try {
|
||||
@@ -69,17 +95,20 @@
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ body: text })
|
||||
});
|
||||
if (res.status === 401) {
|
||||
postError = 'You must be logged in to comment.';
|
||||
return;
|
||||
}
|
||||
if (res.status === 401) { postError = 'You must be logged in to comment.'; return; }
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
postError = err.message ?? 'Failed to post comment.';
|
||||
return;
|
||||
}
|
||||
const created: BookComment = await res.json();
|
||||
comments = [created, ...comments];
|
||||
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 = '';
|
||||
} catch {
|
||||
postError = 'Failed to post comment.';
|
||||
@@ -88,20 +117,88 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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/comment/${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 ──────────────────────────────────────────────────────────────────
|
||||
async function vote(commentId: string, v: 'up' | 'down') {
|
||||
async function vote(commentId: string, v: 'up' | 'down', parentId?: string) {
|
||||
if (votingIds.has(commentId)) return;
|
||||
votingIds = new Set([...votingIds, commentId]);
|
||||
try {
|
||||
const res = await fetch(`/api/comments/${commentId}/vote`, {
|
||||
const res = await fetch(`/api/comment/${commentId}/vote`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ vote: v })
|
||||
});
|
||||
if (!res.ok) return;
|
||||
const updated: BookComment = await res.json();
|
||||
// Update comment in list
|
||||
comments = comments.map((c) => (c.id === commentId ? updated : c));
|
||||
// Update comment in list (handle both top-level and replies)
|
||||
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
|
||||
const prev = myVotes[commentId];
|
||||
if (prev === v) {
|
||||
@@ -119,13 +216,24 @@
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
function initials(username: string): string {
|
||||
const name = username.trim() || '?';
|
||||
return name.slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
try {
|
||||
return new Date(iso).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
const date = new Date(iso);
|
||||
const now = Date.now();
|
||||
const diffMs = now - date.getTime();
|
||||
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 {
|
||||
return iso;
|
||||
}
|
||||
@@ -133,15 +241,46 @@
|
||||
|
||||
const charCount = $derived(newBody.length);
|
||||
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>
|
||||
|
||||
<div class="mt-10">
|
||||
<h2 class="text-base font-semibold text-zinc-200 mb-4">
|
||||
Comments
|
||||
<!-- Header + sort controls -->
|
||||
<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}
|
||||
<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}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<!-- Post form -->
|
||||
<div class="mb-6">
|
||||
@@ -202,9 +341,19 @@
|
||||
{#each comments as comment (comment.id)}
|
||||
{@const myVote = myVotes[comment.id]}
|
||||
{@const voting = votingIds.has(comment.id)}
|
||||
<div class="rounded-lg bg-zinc-800/50 border border-zinc-700/50 px-4 py-3 flex flex-col gap-2">
|
||||
<!-- Header: username + date -->
|
||||
{@const deleting = deletingIds.has(comment.id)}
|
||||
{@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">
|
||||
{#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-zinc-600 text-xs">·</span>
|
||||
<span class="text-xs text-zinc-500">{formatDate(comment.created)}</span>
|
||||
@@ -213,17 +362,15 @@
|
||||
<!-- Body -->
|
||||
<p class="text-sm text-zinc-300 leading-relaxed whitespace-pre-wrap break-words">{comment.body}</p>
|
||||
|
||||
<!-- Vote row -->
|
||||
<div class="flex items-center gap-3 pt-1">
|
||||
<!-- Actions row: votes + reply + delete -->
|
||||
<div class="flex items-center gap-3 pt-1 flex-wrap">
|
||||
<!-- Upvote -->
|
||||
<button
|
||||
onclick={() => vote(comment.id, 'up')}
|
||||
disabled={voting}
|
||||
title="Upvote"
|
||||
class="flex items-center gap-1 text-xs transition-colors disabled:opacity-50
|
||||
{myVote === 'up'
|
||||
? 'text-amber-400'
|
||||
: 'text-zinc-500 hover:text-zinc-300'}"
|
||||
{myVote === 'up' ? 'text-amber-400' : 'text-zinc-500 hover:text-zinc-300'}"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M14 10h4.764a2 2 0 011.789 2.894l-3.5 7A2 2 0 0115.263 21h-4.017c-.163 0-.326-.02-.485-.06L7 20m7-10V5a2 2 0 00-2-2h-.095c-.5 0-.905.405-.905.905 0 .714-.211 1.412-.608 2.006L7 11v9m7-10h-2M7 20H5a2 2 0 01-2-2v-6a2 2 0 012-2h2.5"/>
|
||||
@@ -237,16 +384,167 @@
|
||||
disabled={voting}
|
||||
title="Downvote"
|
||||
class="flex items-center gap-1 text-xs transition-colors disabled:opacity-50
|
||||
{myVote === 'down'
|
||||
? 'text-red-400'
|
||||
: 'text-zinc-500 hover:text-zinc-300'}"
|
||||
{myVote === 'down' ? 'text-red-400' : 'text-zinc-500 hover:text-zinc-300'}"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10 14H5.236a2 2 0 01-1.789-2.894l3.5-7A2 2 0 018.736 3h4.018a2 2 0 01.485.06l3.76.94m-7 10v5a2 2 0 002 2h.096c.5 0 .905-.405.905-.904 0-.715.211-1.413.608-2.008L17 13V4m-7 10h2m5-10h2a2 2 0 012 2v6a2 2 0 01-2 2h-2.5"/>
|
||||
</svg>
|
||||
<span class="tabular-nums">{comment.downvotes ?? 0}</span>
|
||||
</button>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<!-- 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>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -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<BookComment[]> {
|
||||
export async function listComments(
|
||||
slug: string,
|
||||
sort: CommentSort = 'new'
|
||||
): Promise<BookComment[]> {
|
||||
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<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}` } }
|
||||
);
|
||||
if (!res.ok) return [];
|
||||
@@ -850,12 +891,14 @@ export async function listComments(slug: string): Promise<BookComment[]> {
|
||||
|
||||
/**
|
||||
* 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<BookComment> {
|
||||
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<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.
|
||||
*/
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { LayoutData } from './$types';
|
||||
import { audioStore } from '$lib/audio.svelte';
|
||||
import { env } from '$env/dynamic/public';
|
||||
|
||||
let { children, data }: { children: Snippet; data: LayoutData } = $props();
|
||||
|
||||
@@ -395,6 +396,9 @@
|
||||
<a href="/privacy" class="hover:text-zinc-500 transition-colors">Privacy</a>
|
||||
<a href="/dmca" class="hover:text-zinc-500 transition-colors">DMCA</a>
|
||||
<span>© {new Date().getFullYear()} libnovel</span>
|
||||
{#if env.PUBLIC_BUILD_VERSION && env.PUBLIC_BUILD_VERSION !== 'dev'}
|
||||
<span class="text-zinc-800">{env.PUBLIC_BUILD_VERSION}+{env.PUBLIC_BUILD_COMMIT?.slice(0, 7)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -104,7 +104,7 @@ export const GET: RequestHandler = async ({ params, url, locals }) => {
|
||||
const res = await fetch(presignUrl);
|
||||
if (!res.ok) throw new Error(`MinIO returned ${res.status}`);
|
||||
const markdown = await res.text();
|
||||
html = await marked(markdown, { async: true });
|
||||
html = marked(markdown) as string;
|
||||
} catch (e) {
|
||||
log.error('api/chapter', 'failed to fetch chapter content', { slug, n, err: String(e) });
|
||||
}
|
||||
|
||||
26
ui/src/routes/api/comment/[id]/+server.ts
Normal file
26
ui/src/routes/api/comment/[id]/+server.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { deleteComment } from '$lib/server/pocketbase';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
/**
|
||||
* DELETE /api/comment/[id]
|
||||
* Deletes a comment and its replies. Only the comment owner may delete.
|
||||
* Requires authentication.
|
||||
*/
|
||||
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 new Response(null, { status: 204 });
|
||||
} 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/comment/[id]', 'deleteComment failed', { id, err: msg });
|
||||
error(500, 'Failed to delete comment');
|
||||
}
|
||||
};
|
||||
@@ -4,7 +4,7 @@ import { voteComment } from '$lib/server/pocketbase';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
/**
|
||||
* POST /api/comments/[id]/vote
|
||||
* POST /api/comment/[id]/vote
|
||||
* Body: { vote: 'up' | 'down' }
|
||||
* Casts, changes, or toggles off a vote on a comment.
|
||||
* Works for both authenticated and anonymous users (session-scoped).
|
||||
@@ -27,7 +27,7 @@ export const POST: RequestHandler = async ({ params, request, locals }) => {
|
||||
const updated = await voteComment(id, body.vote, locals.sessionId, locals.user?.id);
|
||||
return json(updated);
|
||||
} catch (e) {
|
||||
log.error('api/comments/[id]/vote', 'voteComment failed', { id, err: String(e) });
|
||||
log.error('api/comment/[id]/vote', 'voteComment failed', { id, err: String(e) });
|
||||
error(500, 'Failed to record vote');
|
||||
}
|
||||
};
|
||||
@@ -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<string, 'up'|'down'> }
|
||||
* GET /api/comments/[slug]?sort=new|top
|
||||
* Returns top-level comments + their replies + current visitor's votes + avatar URLs.
|
||||
* 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 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<string, string> = {};
|
||||
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) });
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -528,4 +528,4 @@
|
||||
</div>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════ Comments ══ -->
|
||||
<CommentsSection slug={data.book.slug} isLoggedIn={true} />
|
||||
<CommentsSection slug={data.book.slug} isLoggedIn={data.isLoggedIn} currentUserId={data.currentUserId} />
|
||||
|
||||
@@ -114,7 +114,7 @@ export const load: PageServerLoad = async ({ params, url, locals }) => {
|
||||
const res = await fetch(presignUrl);
|
||||
if (!res.ok) throw new Error(`MinIO returned ${res.status}`);
|
||||
const markdown = await res.text();
|
||||
html = await marked(markdown, { async: true });
|
||||
html = marked(markdown) as string;
|
||||
} catch (e) {
|
||||
// Don't hard-fail — show empty content with error message
|
||||
log.error('chapter', 'failed to fetch chapter content', { slug, n, err: String(e) });
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import { onMount } from 'svelte';
|
||||
import AudioPlayer from '$lib/components/AudioPlayer.svelte';
|
||||
import type { PageData } from './$types';
|
||||
import { marked } from 'marked';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
@@ -42,6 +41,7 @@
|
||||
if (!res.ok) throw new Error(`status ${res.status}`);
|
||||
const d = (await res.json()) as { text?: string };
|
||||
if (d.text) {
|
||||
const { marked } = await import('marked');
|
||||
html = await marked(d.text, { async: true });
|
||||
} else {
|
||||
fetchError = 'Chapter content not available.';
|
||||
|
||||
@@ -3,5 +3,11 @@ import tailwindcss from '@tailwindcss/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), sveltekit()]
|
||||
plugins: [tailwindcss(), sveltekit()],
|
||||
ssr: {
|
||||
// Force these packages to be bundled into the server output rather than
|
||||
// treated as external requires. The production Docker image has no
|
||||
// node_modules, so anything used in server-side code must be inlined.
|
||||
noExternal: ['marked']
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user