Compare commits

...

8 Commits

Author SHA1 Message Date
Admin
718bfa6691 fix(ui): bundle marked into server output via ssr.noExternal
Some checks failed
CI / UI / Build (push) Successful in 17s
CI / Scraper / Lint (pull_request) Successful in 16s
CI / Scraper / Test (pull_request) Successful in 18s
CI / UI / Build (pull_request) Successful in 16s
Release / Scraper / Test (push) Successful in 20s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (pull_request) Has been skipped
Release / UI / Build (push) Successful in 23s
CI / UI / Docker Push (push) Successful in 36s
Release / UI / Docker (push) Successful in 34s
Release / Scraper / Docker (push) Successful in 49s
iOS CI / Build (pull_request) Failing after 6m15s
iOS CI / Test (pull_request) Has been skipped
Production Docker image has no node_modules at runtime; marked was being
externalized by adapter-node (it is in dependencies), causing ERR_MODULE_NOT_FOUND
when +page.server.ts and +server.ts imported it. Adding it to ssr.noExternal
forces Vite to inline it into the server bundle.
2026-03-10 19:01:43 +05:00
Admin
e11e866e27 fix(ui): bundle marked by switching from async to sync API
Some checks failed
CI / Scraper / Test (pull_request) Successful in 12s
CI / Scraper / Lint (pull_request) Successful in 17s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Build (push) Successful in 27s
CI / UI / Build (pull_request) Successful in 18s
CI / UI / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (push) Successful in 30s
iOS CI / Build (pull_request) Failing after 1m58s
iOS CI / Test (pull_request) Has been skipped
marked({ async: true }) triggers a dynamic internal require inside marked
that vite/rollup treats as external, causing ERR_MODULE_NOT_FOUND at runtime
in the adapter-node Docker image which ships no node_modules.
Switching to the synchronous marked() call makes rollup inline the full
library into the server chunk.
2026-03-10 18:27:00 +05:00
Admin
23345e22e6 fix(ui): fix chapter page SSR crash by lazy-loading marked
Some checks failed
CI / Scraper / Lint (pull_request) Successful in 13s
CI / UI / Build (push) Successful in 22s
CI / Scraper / Test (pull_request) Successful in 16s
Release / Scraper / Test (push) Successful in 15s
CI / UI / Build (pull_request) Successful in 24s
CI / Scraper / Docker Push (pull_request) Has been skipped
Release / UI / Build (push) Successful in 22s
CI / UI / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (push) Successful in 30s
Release / UI / Docker (push) Successful in 42s
Release / Scraper / Docker (push) Successful in 1m19s
iOS CI / Build (pull_request) Failing after 1m54s
iOS CI / Test (pull_request) Has been skipped
Static import of 'marked' was included in the SSR bundle causing
ERR_MODULE_NOT_FOUND on first server render. The import is only
ever used inside onMount (client-only fallback path), so replace
with a dynamic import() at the call site.
2026-03-10 18:20:35 +05:00
Admin
c7b3495a23 fix(ios): wire avatar upload to presign flow with crop UI and cold-launch fix
Some checks failed
CI / Scraper / Lint (pull_request) Successful in 8s
CI / Scraper / Test (pull_request) Successful in 20s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Build (pull_request) Successful in 28s
CI / UI / Docker Push (pull_request) Has been skipped
Release / Scraper / Test (push) Successful in 9s
Release / UI / Build (push) Successful in 28s
Release / Scraper / Docker (push) Successful in 24s
Release / UI / Docker (push) Successful in 3m6s
iOS CI / Build (push) Failing after 2m3s
iOS CI / Test (push) Has been skipped
iOS CI / Build (pull_request) Failing after 59s
iOS CI / Test (pull_request) Has been skipped
- Add AvatarCropView: fullscreen pan/pinch sheet with circular crop overlay,
  outputs 400×400 JPEG at 0.9 quality matching the web crop modal
- ProfileView: picker now shows crop sheet before uploading instead of direct upload
- AuthStore.validateToken: exchange raw MinIO key from /api/auth/me for a
  presigned GET URL so avatar renders correctly on cold launch / re-login
- APIClient: add fetchAvatarPresignedURL() calling GET /api/profile/avatar
- Models: add memberwise init to AppUser for avatar URL replacement
2026-03-10 18:17:01 +05:00
Admin
83a5910a59 feat: book commenting system with upvote/downvote + fix profile SSR crash
Some checks failed
CI / Scraper / Test (push) Successful in 10s
CI / Scraper / Lint (push) Successful in 12s
CI / Scraper / Lint (pull_request) Successful in 8s
CI / Scraper / Test (pull_request) Successful in 19s
CI / UI / Build (push) Successful in 32s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Build (pull_request) Successful in 15s
CI / UI / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (push) Failing after 11s
CI / Scraper / Docker Push (push) Successful in 45s
iOS CI / Build (push) Failing after 2m44s
iOS CI / Test (push) Has been skipped
iOS CI / Build (pull_request) Failing after 5m46s
iOS CI / Test (pull_request) Has been skipped
- Add book_comments and comment_votes PocketBase collections (pb-init.sh + pocketbase.go EnsureCollections)
- Web: CommentsSection.svelte with post form, vote buttons, lazy-loaded per-book
- API routes: GET/POST /api/comments/[slug], POST /api/comments/[id]/vote
- iOS: BookComment + CommentsResponse models, fetchComments/postComment/voteComment in APIClient, CommentsView + CommentsViewModel wired into BookDetailView
- Fix profile page SSR crash (ERR_MODULE_NOT_FOUND cropperjs): lazy-load AvatarCropModal via dynamic import guarded by browser, move URL.createObjectURL into onMount
2026-03-10 18:05:41 +05:00
Admin
0f6639aae7 feat(ui): avatar crop modal, health endpoint, leaner Dockerfile
All checks were successful
CI / Scraper / Lint (pull_request) Successful in 8s
CI / Scraper / Test (pull_request) Successful in 17s
CI / UI / Build (pull_request) Successful in 24s
Release / Scraper / Test (push) Successful in 17s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (pull_request) Has been skipped
CI / UI / Build (push) Successful in 46s
Release / UI / Build (push) Successful in 15s
Release / UI / Docker (push) Successful in 41s
Release / Scraper / Docker (push) Successful in 5m32s
CI / UI / Docker Push (push) Successful in 8m13s
iOS CI / Build (pull_request) Successful in 8m38s
iOS CI / Test (pull_request) Successful in 13m23s
- Add AvatarCropModal.svelte using cropperjs v1: 1:1 crop, 400×400 output,
  JPEG/WebP output, dark glassmorphic UI
- Rewrite profile page avatar upload to use presigned PUT flow (POST→PUT→PATCH)
  instead of sending raw FormData directly; crop modal opens on file select
- Add GET /health → {status:ok} for Docker healthcheck
- Simplify Dockerfile: drop runtime npm ci (adapter-node bundles all deps)
- Fix docker-compose UI healthcheck: /health route, 127.0.0.1 to avoid
  IPv6 localhost resolution failure in alpine busybox wget
2026-03-10 17:46:37 +05:00
Admin
88a25bc33e refactor(ios): cleanup pass — dead code removal and component consolidation
All checks were successful
CI / Scraper / Lint (pull_request) Successful in 16s
CI / Scraper / Test (pull_request) Successful in 20s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Build (pull_request) Successful in 21s
CI / UI / Docker Push (pull_request) Has been skipped
iOS CI / Build (push) Successful in 7m18s
iOS CI / Build (pull_request) Successful in 5m22s
iOS CI / Test (push) Successful in 10m55s
iOS CI / Test (pull_request) Successful in 9m32s
- Remove dead structs: ReadingProgress, HomeData, PBList (Models.swift)
- Remove unused APIClient members: fetchRaw, setSessionId, BrowseParams; simplify logout(); drop no-op CodingKeys from BrowseResponse
- Remove absolutePrevChapter/absoluteNextChapter computed props (replaced by prevChapter/nextChapter); replace URLSession cover prefetch with Kingfisher
- Drop scrollOffset state var and dead subtitle block in ChapterRow (BookDetailView)
- Remove never-set chaptersLoading published prop (BookDetailViewModel)
- Add unified ChipButton(.filled/.outlined) to CommonViews replacing three near-identical pill chip types
- Replace FilterChipView+SortChip in LibraryView and FilterChip in BrowseView with ChipButton
- Replace inline KFImage usage in HomeView with AsyncCoverImage
- Fix deprecated .navigationBarHidden(true) → .toolbar(.hidden, for: .navigationBar) in AuthView
2026-03-10 17:13:45 +05:00
Admin
73ad4ece49 ci: add release-scraper workflow triggered on v* tags
Some checks failed
CI / Scraper / Lint (pull_request) Successful in 16s
CI / Scraper / Test (pull_request) Successful in 16s
CI / UI / Build (pull_request) Successful in 23s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (pull_request) Has been skipped
iOS CI / Build (pull_request) Failing after 11m25s
iOS CI / Test (pull_request) Has been skipped
2026-03-10 17:08:24 +05:00
32 changed files with 1465 additions and 159 deletions

View File

@@ -0,0 +1,65 @@
name: Release / Scraper
on:
push:
tags:
- "v*"
concurrency:
group: ${{ gitea.workflow }}-${{ gitea.ref }}
cancel-in-progress: true
jobs:
# ── lint & test ──────────────────────────────────────────────────────────────
test:
name: Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: scraper/go.mod
cache-dependency-path: scraper/go.sum
- name: go vet
working-directory: scraper
run: |
go vet ./...
go vet -tags integration ./...
- name: Run tests
working-directory: scraper
run: go test -short -race -count=1 -timeout=60s ./...
# ── docker build & push ──────────────────────────────────────────────────────
docker:
name: Docker
runs-on: ubuntu-latest
needs: [test]
steps:
- uses: actions/checkout@v4
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ secrets.DOCKER_USER }}/libnovel-scraper
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest
- name: Build and push
uses: docker/build-push-action@v6
with:
context: scraper
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

View File

@@ -149,7 +149,7 @@ services:
ports:
- "${UI_PORT:-5252}:3000"
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/"]
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"]
interval: 15s
timeout: 5s
retries: 3

View File

@@ -178,6 +178,14 @@ struct AppUser: Codable, Identifiable {
case avatarURL = "avatar_url"
}
init(id: String, username: String, role: String, created: String, avatarURL: String?) {
self.id = id
self.username = username
self.role = role
self.created = created
self.avatarURL = avatarURL
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
id = try c.decode(String.self, forKey: .id)
@@ -254,6 +262,46 @@ struct BookBrief: Codable {
let cover: String
}
// MARK: - Comments
struct BookComment: Identifiable, Codable, Hashable {
let id: String
let slug: String
let userId: String
let username: String
let body: String
var upvotes: Int
var downvotes: Int
let created: String
enum CodingKeys: String, CodingKey {
case id, slug, username, body, upvotes, downvotes, created
case userId = "user_id"
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
id = try c.decode(String.self, forKey: .id)
slug = try c.decodeIfPresent(String.self, forKey: .slug) ?? ""
userId = try c.decodeIfPresent(String.self, forKey: .userId) ?? ""
username = try c.decodeIfPresent(String.self, forKey: .username) ?? ""
body = try c.decodeIfPresent(String.self, forKey: .body) ?? ""
upvotes = try c.decodeIfPresent(Int.self, forKey: .upvotes) ?? 0
downvotes = try c.decodeIfPresent(Int.self, forKey: .downvotes) ?? 0
created = try c.decodeIfPresent(String.self, forKey: .created) ?? ""
}
}
struct CommentsResponse: Decodable {
let comments: [BookComment]
let myVotes: [String: String]
enum CodingKeys: String, CodingKey {
case comments
case myVotes = "myVotes"
}
}
// MARK: - Audio
enum NextPrefetchStatus {

View File

@@ -313,6 +313,34 @@ actor APIClient {
)
return result.avatarURL
}
/// Fetches a fresh presigned GET URL for the current user's avatar.
/// Returns nil if the user has no avatar set.
/// Used on cold launch / session restore to convert the stored raw key into a viewable URL.
func fetchAvatarPresignedURL() async throws -> String? {
let result: AvatarResponse = try await fetch("/api/profile/avatar")
return result.avatarURL
}
// MARK: - Comments
func fetchComments(slug: String) async throws -> CommentsResponse {
try await fetch("/api/comments/\(slug)")
}
struct PostCommentBody: Encodable { let body: String }
func postComment(slug: String, body: String) async throws -> BookComment {
try await fetch("/api/comments/\(slug)", method: "POST", body: PostCommentBody(body: body))
}
struct VoteBody: Encodable { let vote: String }
/// Cast, change, or toggle-off a vote on a comment.
/// Returns the updated BookComment (with refreshed upvotes/downvotes counts).
func voteComment(commentId: String, vote: String) async throws -> BookComment {
try await fetch("/api/comments/\(commentId)/vote", method: "POST", body: VoteBody(vote: vote))
}
}
// MARK: - Response types

View File

@@ -100,7 +100,20 @@ final class AuthStore: ObservableObject {
do {
async let me: AppUser = APIClient.shared.fetch("/api/auth/me")
async let s: UserSettings = APIClient.shared.settings()
let (restoredUser, restoredSettings) = try await (me, s)
var (restoredUser, restoredSettings) = try await (me, s)
// /api/auth/me returns the raw MinIO object key for avatar_url, not a presigned URL.
// Exchange the key for a fresh presigned GET URL so KFImage can display it.
if let key = restoredUser.avatarURL, !key.hasPrefix("http") {
if let presignedURL = try? await APIClient.shared.fetchAvatarPresignedURL() {
restoredUser = AppUser(
id: restoredUser.id,
username: restoredUser.username,
role: restoredUser.role,
created: restoredUser.created,
avatarURL: presignedURL
)
}
}
user = restoredUser
settings = restoredSettings
} catch let e as APIError {

View File

@@ -96,7 +96,7 @@ struct AuthView: View {
Spacer()
}
.navigationBarHidden(true)
.toolbar(.hidden, for: .navigationBar)
}
.onChange(of: mode) { _, _ in
authStore.error = nil

View File

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

View File

@@ -0,0 +1,320 @@
import SwiftUI
// MARK: - ViewModel
@MainActor
class CommentsViewModel: ObservableObject {
let slug: String
@Published var comments: [BookComment] = []
@Published var myVotes: [String: String] = [:] // commentId "up" | "down"
@Published var isLoading = true
@Published var error: String?
@Published var newBody = ""
@Published var isPosting = false
@Published var postError: String?
private var votingIds: Set<String> = []
init(slug: String) {
self.slug = slug
}
func load() async {
isLoading = true
error = nil
do {
let response = try await APIClient.shared.fetchComments(slug: slug)
comments = response.comments
myVotes = response.myVotes
} catch {
self.error = error.localizedDescription
}
isLoading = false
}
func postComment() async {
let text = newBody.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty, !isPosting else { return }
if text.count > 2000 {
postError = "Comment too long (max 2000 characters)."
return
}
isPosting = true
postError = nil
do {
let created = try await APIClient.shared.postComment(slug: slug, body: text)
comments.insert(created, at: 0)
newBody = ""
} catch let apiError as APIError {
switch apiError {
case .httpError(401, _): postError = "You must be logged in to comment."
default: postError = apiError.localizedDescription
}
} catch {
postError = error.localizedDescription
}
isPosting = false
}
func vote(commentId: String, vote: String) async {
guard !votingIds.contains(commentId) else { return }
votingIds.insert(commentId)
defer { votingIds.remove(commentId) }
do {
let updated = try await APIClient.shared.voteComment(commentId: commentId, vote: vote)
// Update the comment in the list
if let idx = comments.firstIndex(where: { $0.id == commentId }) {
comments[idx] = updated
}
// Toggle myVotes
let prev = myVotes[commentId]
if prev == vote {
myVotes.removeValue(forKey: commentId)
} else {
myVotes[commentId] = vote
}
} catch {
// Silently ignore vote errors don't disrupt the UI
}
}
func isVoting(_ commentId: String) -> Bool {
votingIds.contains(commentId)
}
}
// MARK: - CommentsView
struct CommentsView: View {
@StateObject private var vm: CommentsViewModel
@EnvironmentObject private var authStore: AuthStore
init(slug: String) {
_vm = StateObject(wrappedValue: CommentsViewModel(slug: slug))
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
// Section header
HStack {
Text("Comments")
.font(.headline)
if !vm.isLoading && !vm.comments.isEmpty {
Text("(\(vm.comments.count))")
.font(.subheadline)
.foregroundStyle(.secondary)
}
Spacer()
}
.padding(.horizontal)
.padding(.vertical, 14)
Divider().padding(.horizontal)
// Post form
postForm
.padding(.horizontal)
.padding(.vertical, 12)
Divider().padding(.horizontal)
// Comment list
if vm.isLoading {
loadingPlaceholder
} else if let err = vm.error {
Text(err)
.font(.subheadline)
.foregroundStyle(.red)
.padding()
} else if vm.comments.isEmpty {
Text("No comments yet. Be the first!")
.font(.subheadline)
.foregroundStyle(.secondary)
.padding()
} else {
ForEach(vm.comments) { comment in
CommentRow(
comment: comment,
myVote: vm.myVotes[comment.id],
isVoting: vm.isVoting(comment.id)
) { vote in
Task { await vm.vote(commentId: comment.id, vote: vote) }
}
Divider().padding(.leading, 16)
}
}
Color.clear.frame(height: 16)
}
.task { await vm.load() }
}
// MARK: - Post form
@ViewBuilder
private var postForm: some View {
VStack(alignment: .leading, spacing: 8) {
ZStack(alignment: .topLeading) {
if vm.newBody.isEmpty {
Text("Write a comment…")
.font(.subheadline)
.foregroundStyle(.tertiary)
.padding(.top, 8)
.padding(.leading, 4)
}
TextEditor(text: $vm.newBody)
.font(.subheadline)
.frame(minHeight: 72, maxHeight: 160)
.scrollContentBackground(.hidden)
}
.padding(10)
.background(Color(.systemGray6), in: RoundedRectangle(cornerRadius: 10))
HStack {
let count = vm.newBody.count
Text("\(count)/2000")
.font(.caption2)
.monospacedDigit()
.foregroundStyle(count > 2000 ? .red : .tertiary)
Spacer()
if let err = vm.postError {
Text(err)
.font(.caption2)
.foregroundStyle(.red)
.lineLimit(1)
}
Button {
Task { await vm.postComment() }
} label: {
if vm.isPosting {
ProgressView().controlSize(.small)
} else {
Text("Post")
.fontWeight(.semibold)
}
}
.buttonStyle(.borderedProminent)
.tint(.amber)
.controlSize(.small)
.disabled(vm.isPosting || vm.newBody.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || vm.newBody.count > 2000)
}
}
}
// MARK: - Loading skeleton
@ViewBuilder
private var loadingPlaceholder: some View {
VStack(spacing: 12) {
ForEach(0..<3, id: \.self) { _ in
VStack(alignment: .leading, spacing: 8) {
RoundedRectangle(cornerRadius: 4)
.fill(Color(.systemGray5))
.frame(width: 100, height: 12)
RoundedRectangle(cornerRadius: 4)
.fill(Color(.systemGray6))
.frame(maxWidth: .infinity)
.frame(height: 12)
RoundedRectangle(cornerRadius: 4)
.fill(Color(.systemGray6))
.frame(width: 200, height: 12)
}
.padding(.horizontal)
.redacted(reason: .placeholder)
}
}
.padding(.vertical, 12)
}
}
// MARK: - CommentRow
private struct CommentRow: View {
let comment: BookComment
let myVote: String?
let isVoting: Bool
let onVote: (String) -> Void
var body: some View {
VStack(alignment: .leading, spacing: 6) {
// Username + date
HStack(spacing: 6) {
Text(comment.username.isEmpty ? "Anonymous" : comment.username)
.font(.subheadline.weight(.medium))
Text("·")
.foregroundStyle(.tertiary)
Text(formattedDate(comment.created))
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
}
// Body
Text(comment.body)
.font(.subheadline)
.foregroundStyle(.primary)
.fixedSize(horizontal: false, vertical: true)
// Vote row
HStack(spacing: 16) {
// Upvote
Button {
onVote("up")
} label: {
HStack(spacing: 4) {
Image(systemName: myVote == "up" ? "hand.thumbsup.fill" : "hand.thumbsup")
.font(.caption)
Text("\(comment.upvotes)")
.font(.caption.monospacedDigit())
}
.foregroundStyle(myVote == "up" ? Color.amber : .secondary)
}
.disabled(isVoting)
// Downvote
Button {
onVote("down")
} label: {
HStack(spacing: 4) {
Image(systemName: myVote == "down" ? "hand.thumbsdown.fill" : "hand.thumbsdown")
.font(.caption)
Text("\(comment.downvotes)")
.font(.caption.monospacedDigit())
}
.foregroundStyle(myVote == "down" ? .red : .secondary)
}
.disabled(isVoting)
Spacer()
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.opacity(isVoting ? 0.6 : 1)
}
private func formattedDate(_ iso: String) -> String {
// PocketBase returns "2006-01-02 15:04:05.999Z" format
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
if let date = formatter.date(from: iso) {
let rel = RelativeDateTimeFormatter()
rel.unitsStyle = .abbreviated
return rel.localizedString(for: date, relativeTo: Date())
}
// Fallback: try space-separated format
let df = DateFormatter()
df.dateFormat = "yyyy-MM-dd HH:mm:ss.SSSZ"
if let date = df.date(from: iso) {
let rel = RelativeDateTimeFormatter()
rel.unitsStyle = .abbreviated
return rel.localizedString(for: date, relativeTo: Date())
}
return String(iso.prefix(10))
}
}

View File

@@ -29,13 +29,13 @@ struct BrowseView: View {
// Filter chips row
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
FilterChip(label: "Sort: \(vm.sort.capitalized)", isActive: vm.sort != "popular") {
ChipButton(label: "Sort: \(vm.sort.capitalized)", isSelected: vm.sort != "popular", style: .outlined) {
showFilters = true
}
FilterChip(label: "Genre: \(vm.genre == "all" ? "All" : vm.genre.capitalized)", isActive: vm.genre != "all") {
ChipButton(label: "Genre: \(vm.genre == "all" ? "All" : vm.genre.capitalized)", isSelected: vm.genre != "all", style: .outlined) {
showFilters = true
}
FilterChip(label: "Status: \(vm.status == "all" ? "All" : vm.status.capitalized)", isActive: vm.status != "all") {
ChipButton(label: "Status: \(vm.status == "all" ? "All" : vm.status.capitalized)", isSelected: vm.status != "all", style: .outlined) {
showFilters = true
}
}
@@ -103,25 +103,6 @@ struct BrowseView: View {
}
}
// MARK: - Filter chip
private struct FilterChip: View {
let label: String
let isActive: Bool
let action: () -> Void
var body: some View {
Button(action: action) {
Text(label)
.font(.caption.bold())
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(isActive ? Color.amber.opacity(0.15) : Color(.systemGray6), in: Capsule())
.foregroundStyle(isActive ? .amber : .primary)
.overlay(Capsule().strokeBorder(isActive ? Color.amber : .clear, lineWidth: 1))
}
}
}
// MARK: - Browse card
private struct BrowseCard: View {

View File

@@ -1,5 +1,4 @@
import SwiftUI
import Kingfisher
struct HomeView: View {
@StateObject private var vm = HomeViewModel()
@@ -100,9 +99,7 @@ private struct HeroContinueCard: View {
NavigationLink(value: NavDestination.chapter(item.book.slug, item.chapter)) {
ZStack(alignment: .bottomLeading) {
// Blurred background
KFImage(URL(string: item.book.cover))
.resizable()
.scaledToFill()
AsyncCoverImage(url: item.book.cover, isBackground: true)
.frame(maxWidth: .infinity)
.frame(height: 220)
.blur(radius: 22)
@@ -122,13 +119,7 @@ private struct HeroContinueCard: View {
// Content: cover on left, info stacked on right
HStack(alignment: .bottom, spacing: 14) {
KFImage(URL(string: item.book.cover))
.resizable()
.placeholder {
RoundedRectangle(cornerRadius: 10)
.fill(Color(.systemGray5))
}
.scaledToFill()
AsyncCoverImage(url: item.book.cover)
.frame(width: 96, height: 138)
.clipShape(RoundedRectangle(cornerRadius: 10))
.shadow(color: .black.opacity(0.55), radius: 12, y: 6)
@@ -215,14 +206,7 @@ private struct ContinueReadingCard: View {
var body: some View {
VStack(alignment: .leading, spacing: 6) {
ZStack(alignment: .bottomTrailing) {
KFImage(URL(string: item.book.cover))
.resizable()
.placeholder {
RoundedRectangle(cornerRadius: 8)
.fill(Color(.systemGray5))
.overlay(Image(systemName: "book.closed").foregroundStyle(.secondary))
}
.scaledToFill()
AsyncCoverImage(url: item.book.cover)
.frame(width: 110, height: 158)
.clipShape(RoundedRectangle(cornerRadius: 8))
@@ -258,14 +242,7 @@ private struct ShelfBookCard: View {
var body: some View {
VStack(alignment: .leading, spacing: 6) {
KFImage(URL(string: book.cover))
.resizable()
.placeholder {
RoundedRectangle(cornerRadius: 8)
.fill(Color(.systemGray5))
.overlay(Image(systemName: "book.closed").foregroundStyle(.secondary))
}
.scaledToFill()
AsyncCoverImage(url: book.cover)
.frame(width: 110, height: 158)
.clipShape(RoundedRectangle(cornerRadius: 8))
.shadow(color: .black.opacity(0.12), radius: 4, y: 2)

View File

@@ -235,56 +235,6 @@ struct LibraryView: View {
}
}
// MARK: - Genre filter chip
private struct FilterChipView: View {
let label: String
let isSelected: Bool
let action: () -> Void
var body: some View {
Button(action: action) {
Text(label)
.font(.caption.weight(isSelected ? .semibold : .regular))
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(
Capsule()
.fill(isSelected ? Color.amber : Color(.systemGray5))
)
.foregroundStyle(isSelected ? .white : .primary)
}
.buttonStyle(.plain)
}
}
// MARK: - Sort chip
private struct SortChip: View {
let label: String
let isSelected: Bool
let action: () -> Void
var body: some View {
Button(action: action) {
Text(label)
.font(.subheadline.weight(isSelected ? .semibold : .regular))
.padding(.horizontal, 14)
.padding(.vertical, 6)
.background(
Capsule()
.fill(isSelected ? Color.amber.opacity(0.15) : Color(.systemGray6))
.overlay(
Capsule()
.stroke(isSelected ? Color.amber : .clear, lineWidth: 1.5)
)
)
.foregroundStyle(isSelected ? .amber : .primary)
}
.buttonStyle(.plain)
}
}
// MARK: - Library book card (3-column)
private struct LibraryBookCard: View {

View File

@@ -47,7 +47,7 @@ struct MiniPlayerView: View {
// Previous chapter button
if audioPlayer.status == .ready {
Button {
if let prev = audioPlayer.absolutePrevChapter {
if let prev = audioPlayer.prevChapter {
NotificationCenter.default.post(
name: .skipToPrevChapter,
object: nil,
@@ -62,8 +62,8 @@ struct MiniPlayerView: View {
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.disabled(audioPlayer.absolutePrevChapter == nil)
.opacity(audioPlayer.absolutePrevChapter == nil ? 0.4 : 1.0)
.disabled(audioPlayer.prevChapter == nil)
.opacity(audioPlayer.prevChapter == nil ? 0.4 : 1.0)
}
// Status indicator or play/pause control
@@ -92,7 +92,7 @@ struct MiniPlayerView: View {
// Next chapter button
if audioPlayer.status == .ready {
Button {
if let next = audioPlayer.absoluteNextChapter {
if let next = audioPlayer.nextChapter {
NotificationCenter.default.post(
name: .skipToNextChapter,
object: nil,
@@ -124,8 +124,8 @@ struct MiniPlayerView: View {
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.disabled(audioPlayer.absoluteNextChapter == nil)
.opacity(audioPlayer.absoluteNextChapter == nil ? 0.4 : 1.0)
.disabled(audioPlayer.nextChapter == nil)
.opacity(audioPlayer.nextChapter == nil ? 0.4 : 1.0)
}
}
}
@@ -342,9 +342,9 @@ struct FullPlayerView: View {
PlayerChapterSkipButton(
systemName: "backward.end.fill",
size: 30,
disabled: audioPlayer.absolutePrevChapter == nil
disabled: audioPlayer.prevChapter == nil
) {
if let prev = audioPlayer.absolutePrevChapter {
if let prev = audioPlayer.prevChapter {
onDismiss()
NotificationCenter.default.post(
name: .skipToPrevChapter, object: nil,
@@ -364,10 +364,10 @@ struct FullPlayerView: View {
PlayerChapterSkipButton(
systemName: "forward.end.fill",
size: 30,
disabled: audioPlayer.absoluteNextChapter == nil,
disabled: audioPlayer.nextChapter == nil,
prefetching: audioPlayer.nextPrefetchStatus == .prefetching
) {
if let next = audioPlayer.absoluteNextChapter {
if let next = audioPlayer.nextChapter {
onDismiss()
NotificationCenter.default.post(
name: .skipToNextChapter, object: nil,

View File

@@ -0,0 +1,161 @@
import SwiftUI
// MARK: - AvatarCropView
// A sheet that lets the user pan and pinch a photo to fill a 1:1 square crop region.
// Call: .sheet(item: $cropImage) { AvatarCropView(image: $0.image, onConfirm: { croppedData in }) }
struct AvatarCropView: View {
let image: UIImage
let onConfirm: (Data) -> Void
let onCancel: () -> Void
// Crop square side length (points) matched to the web 400 px target
private let cropSize: CGFloat = 280
// Pan/zoom state
@State private var scale: CGFloat = 1.0
@State private var lastScale: CGFloat = 1.0
@State private var offset: CGSize = .zero
@State private var lastOffset: CGSize = .zero
var body: some View {
NavigationStack {
GeometryReader { geo in
ZStack {
Color.black.ignoresSafeArea()
// Draggable / pinchable image
Image(uiImage: image)
.resizable()
.scaledToFill()
.frame(width: geo.size.width, height: geo.size.height)
.scaleEffect(scale)
.offset(offset)
.gesture(
SimultaneousGesture(
MagnificationGesture()
.onChanged { value in
scale = max(1.0, lastScale * value)
}
.onEnded { _ in
lastScale = scale
},
DragGesture()
.onChanged { value in
offset = CGSize(
width: lastOffset.width + value.translation.width,
height: lastOffset.height + value.translation.height
)
}
.onEnded { _ in
lastOffset = offset
}
)
)
.clipped()
// Dim overlay with transparent crop square cut out
CropOverlay(cropSize: cropSize, containerSize: geo.size)
.allowsHitTesting(false)
}
}
.navigationTitle("Crop Photo")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Cancel", action: onCancel)
.foregroundStyle(.white)
}
ToolbarItem(placement: .topBarTrailing) {
Button("Use Photo") {
confirmCrop()
}
.fontWeight(.semibold)
.foregroundStyle(.amber)
}
}
.toolbarColorScheme(.dark, for: .navigationBar)
}
.onAppear { fitImageInitially() }
}
// MARK: - Crop
private func fitImageInitially() {
// Scale image so its shorter dimension fills the crop square
let imgAspect = image.size.width / image.size.height
if imgAspect > 1 {
// wider than tall fit height to cropSize
scale = cropSize / image.size.height * (image.size.height / image.size.width)
} else {
scale = 1.0
}
scale = max(1.0, scale)
lastScale = scale
}
private func confirmCrop() {
// Render image at current pan/zoom into a 400×400 bitmap
let outputSize = CGSize(width: 400, height: 400)
let renderer = UIGraphicsImageRenderer(size: outputSize)
let cropped = renderer.image { ctx in
// We need to map from the SwiftUI transform back to image pixels.
// We render the raw UIImage into the output rect, applying the same
// scale / offset proportionally (normalised by crop square / container).
let screenCropSize: CGFloat = cropSize
// Scale factor: pixels per SwiftUI point in the output
let outputScale = outputSize.width / screenCropSize
ctx.cgContext.translateBy(x: outputSize.width / 2, y: outputSize.height / 2)
ctx.cgContext.scaleBy(x: scale * outputScale, y: scale * outputScale)
ctx.cgContext.translateBy(
x: -image.size.width / 2 + (offset.width * outputScale / scale),
y: -image.size.height / 2 + (offset.height * outputScale / scale)
)
image.draw(at: .zero)
}
if let jpeg = cropped.jpegData(compressionQuality: 0.9) {
onConfirm(jpeg)
}
}
}
// MARK: - Crop overlay
private struct CropOverlay: View {
let cropSize: CGFloat
let containerSize: CGSize
var body: some View {
Canvas { context, size in
// Fill entire canvas with semi-transparent black
context.fill(Path(CGRect(origin: .zero, size: size)), with: .color(.black.opacity(0.55)))
// Cut out the crop square in the centre
let origin = CGPoint(
x: (size.width - cropSize) / 2,
y: (size.height - cropSize) / 2
)
let cropRect = CGRect(origin: origin, size: CGSize(width: cropSize, height: cropSize))
context.blendMode = .destinationOut
context.fill(Path(ellipseIn: cropRect), with: .color(.white))
}
.compositingGroup()
.overlay {
// Amber circle border around the crop region
let origin = CGPoint(
x: (containerSize.width - cropSize) / 2,
y: (containerSize.height - cropSize) / 2
)
Circle()
.stroke(Color.amber.opacity(0.8), lineWidth: 2)
.frame(width: cropSize, height: cropSize)
.position(
x: origin.x + cropSize / 2,
y: origin.y + cropSize / 2
)
}
.frame(width: containerSize.width, height: containerSize.height)
.allowsHitTesting(false)
}
}

View File

@@ -9,6 +9,7 @@ struct ProfileView: View {
// Avatar upload state
@State private var photoPickerItem: PhotosPickerItem?
@State private var pendingCropImage: UIImage? // image waiting to be cropped
@State private var avatarURL: String? = nil
@State private var avatarUploading = false
@State private var avatarError: String?
@@ -74,7 +75,7 @@ struct ProfileView: View {
.buttonStyle(.plain)
.onChange(of: photoPickerItem) { _, item in
guard let item else { return }
Task { await uploadPickedPhoto(item) }
Task { await loadImageForCrop(item) }
}
VStack(alignment: .leading, spacing: 3) {
@@ -138,35 +139,40 @@ struct ProfileView: View {
.sheet(isPresented: $showChangePassword) {
ChangePasswordView()
}
.sheet(item: Binding(
get: { pendingCropImage.map { CropImageItem(image: $0) } },
set: { if $0 == nil { pendingCropImage = nil } }
)) { item in
AvatarCropView(image: item.image) { croppedData in
pendingCropImage = nil
Task { await uploadCroppedData(croppedData) }
} onCancel: {
pendingCropImage = nil
}
}
.errorAlert($vm.error)
}
}
// MARK: - Avatar upload
private func uploadPickedPhoto(_ item: PhotosPickerItem) async {
/// Step 1: Load the raw image from the picker and show the crop sheet.
private func loadImageForCrop(_ item: PhotosPickerItem) async {
guard let data = try? await item.loadTransferable(type: Data.self),
let image = UIImage(data: data) else {
avatarError = "Could not read image"
return
}
pendingCropImage = image
}
/// Step 2: Called by AvatarCropView once the user confirms. Upload the cropped JPEG.
private func uploadCroppedData(_ data: Data) async {
avatarUploading = true
avatarError = nil
defer { avatarUploading = false }
do {
guard let data = try await item.loadTransferable(type: Data.self) else {
avatarError = "Could not read image"
return
}
// Compress to JPEG for consistent handling
let mimeType: String
let uploadData: Data
if let uiImage = UIImage(data: data),
let jpeg = uiImage.jpegData(compressionQuality: 0.85) {
uploadData = jpeg
mimeType = "image/jpeg"
} else {
uploadData = data
mimeType = "image/png"
}
let url = try await APIClient.shared.uploadAvatar(uploadData, mimeType: mimeType)
let url = try await APIClient.shared.uploadAvatar(data, mimeType: "image/jpeg")
avatarURL = url
// Refresh user record so the new avatar persists across sessions
await authStore.validateToken()
@@ -318,3 +324,10 @@ struct ChangePasswordView: View {
}
}
}
// MARK: - Crop image item (Identifiable wrapper for .sheet(item:))
private struct CropImageItem: Identifiable {
let id = UUID()
let image: UIImage
}

View File

@@ -16,6 +16,9 @@
// started(date), finished(date), error_message(text)
// user_sessions — user_id(text), session_id(text,unique), user_agent(text),
// ip(text), created_at(date), last_seen(date)
// book_comments — slug(text), user_id(text), username(text), body(text),
// upvotes(number), downvotes(number), created(date)
// comment_votes — comment_id(text), user_id(text), session_id(text), vote(text: up|down)
package storage
import (
@@ -422,6 +425,29 @@ func (s *PocketBaseStore) EnsureCollections(ctx context.Context) error {
{"name": "last_seen", "type": "date"},
},
},
{
"name": "book_comments",
"type": "base",
"fields": []map[string]interface{}{
{"name": "slug", "type": "text", "required": true},
{"name": "user_id", "type": "text"},
{"name": "username", "type": "text"},
{"name": "body", "type": "text", "required": true},
{"name": "upvotes", "type": "number"},
{"name": "downvotes", "type": "number"},
{"name": "created", "type": "date"},
},
},
{
"name": "comment_votes",
"type": "base",
"fields": []map[string]interface{}{
{"name": "comment_id", "type": "text", "required": true},
{"name": "user_id", "type": "text"},
{"name": "session_id", "type": "text", "required": true},
{"name": "vote", "type": "text", "required": true}, // "up" | "down"
},
},
}
for _, col := range collections {
name, _ := col["name"].(string)

13
scripts/.runner Normal file
View File

@@ -0,0 +1,13 @@
{
"WARNING": "This file is automatically generated by act-runner. Do not edit it manually unless you know what you are doing. Removing this file will cause act runner to re-register as a new runner.",
"id": 11,
"uuid": "d5d04e0a-572c-46c0-83be-405508948391",
"name": "runner-mac-1",
"token": "ddf214ce148b4673a186f29cb684b407cb8c2ecc",
"address": "https://gitea.kalekber.cc/",
"labels": [
"macos-latest:host",
"macos-14:host"
],
"ephemeral": false
}

View File

@@ -203,4 +203,29 @@ ensure_field "progress" "audio_time" "number"
ensure_field "user_settings" "user_id" "text"
ensure_field "app_users" "avatar_url" "text"
create_collection "book_comments" '{
"name": "book_comments",
"type": "base",
"fields": [
{"name": "slug", "type": "text", "required": true},
{"name": "user_id", "type": "text"},
{"name": "username", "type": "text"},
{"name": "body", "type": "text", "required": true},
{"name": "upvotes", "type": "number"},
{"name": "downvotes", "type": "number"},
{"name": "created", "type": "date"}
]
}'
create_collection "comment_votes" '{
"name": "comment_votes",
"type": "base",
"fields": [
{"name": "comment_id", "type": "text", "required": true},
{"name": "user_id", "type": "text"},
{"name": "session_id", "type": "text", "required": true},
{"name": "vote", "type": "text", "required": true}
]
}'
log "all collections ready"

View File

@@ -8,16 +8,11 @@ COPY . .
RUN npm run build
# ── Runtime image ──────────────────────────────────────────────────────────────
# adapter-node bundles all dependencies into build/ — no npm install needed.
FROM node:22-alpine
WORKDIR /app
# adapter-node produces a standalone build/
COPY --from=builder /app/build ./build
COPY --from=builder /app/package.json ./
COPY --from=builder /app/package-lock.json ./
# Install production dependencies (e.g. marked) that are imported at runtime
RUN npm ci --omit=dev
ENV NODE_ENV=production
ENV PORT=3000
@@ -25,3 +20,4 @@ ENV HOST=0.0.0.0
EXPOSE $PORT
CMD ["node", "build"]

7
ui/package-lock.json generated
View File

@@ -10,6 +10,7 @@
"dependencies": {
"@aws-sdk/client-s3": "^3.1005.0",
"@aws-sdk/s3-request-presigner": "^3.1005.0",
"cropperjs": "^1.6.2",
"marked": "^17.0.3",
"pocketbase": "^0.26.8"
},
@@ -3114,6 +3115,12 @@
"node": ">= 0.6"
}
},
"node_modules/cropperjs": {
"version": "1.6.2",
"resolved": "https://registry.npmjs.org/cropperjs/-/cropperjs-1.6.2.tgz",
"integrity": "sha512-nhymn9GdnV3CqiEHJVai54TULFAE3VshJTXSqSJKa8yXAKyBKDWdhHarnlIPrshJ0WMFTGuFvG02YjLXfPiuOA==",
"license": "MIT"
},
"node_modules/deepmerge": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",

View File

@@ -27,6 +27,7 @@
"dependencies": {
"@aws-sdk/client-s3": "^3.1005.0",
"@aws-sdk/s3-request-presigner": "^3.1005.0",
"cropperjs": "^1.6.2",
"marked": "^17.0.3",
"pocketbase": "^0.26.8"
}

View File

@@ -0,0 +1,92 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import Cropper from 'cropperjs';
import 'cropperjs/dist/cropper.css';
interface Props {
file: File;
onconfirm: (blob: Blob, mimeType: string) => void;
oncancel: () => void;
}
let { file, onconfirm, oncancel }: Props = $props();
let imgEl: HTMLImageElement;
let cropper: Cropper | null = null;
let objectUrl = $state('');
onMount(() => {
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
});
});
onDestroy(() => {
cropper?.destroy();
URL.revokeObjectURL(objectUrl);
});
function confirm() {
if (!cropper) return;
const canvas = cropper.getCroppedCanvas({ width: 400, height: 400 });
const mimeType = file.type === 'image/webp' ? 'image/webp' : 'image/jpeg';
canvas.toBlob(
(blob: Blob | null) => {
if (blob) onconfirm(blob, mimeType);
},
mimeType,
0.9
);
}
</script>
<!-- Backdrop -->
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm p-4"
role="dialog"
aria-modal="true"
aria-label="Crop profile picture"
>
<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;">
<img
bind:this={imgEl}
src={objectUrl}
alt="Crop preview"
style="display:block; max-width:100%;"
/>
</div>
<p class="text-xs text-zinc-500 text-center">Drag to reposition · pinch or scroll to zoom · drag corners to resize</p>
<div class="flex gap-3">
<button
onclick={oncancel}
class="flex-1 py-2 rounded-lg border border-zinc-600 text-zinc-300 text-sm font-medium hover:bg-zinc-700 transition-colors"
>
Cancel
</button>
<button
onclick={confirm}
class="flex-1 py-2 rounded-lg bg-amber-400 text-zinc-900 text-sm font-semibold hover:bg-amber-300 transition-colors"
>
Use photo
</button>
</div>
</div>
</div>

View File

@@ -0,0 +1,254 @@
<script lang="ts">
interface BookComment {
id: string;
slug: string;
user_id: string;
username: string;
body: string;
upvotes: number;
downvotes: number;
created: string;
}
let {
slug,
isLoggedIn = false
}: {
slug: string;
isLoggedIn?: boolean;
} = $props();
// ── State ─────────────────────────────────────────────────────────────────
let comments = $state<BookComment[]>([]);
let myVotes = $state<Record<string, 'up' | 'down'>>({});
let loading = $state(true);
let loadError = $state('');
let newBody = $state('');
let posting = $state(false);
let postError = $state('');
// Per-comment vote inflight set (prevents double-clicks)
let votingIds = $state(new Set<string>());
// ── Load comments on mount ────────────────────────────────────────────────
async function loadComments() {
loading = true;
loadError = '';
try {
const res = await fetch(`/api/comments/${encodeURIComponent(slug)}`);
if (!res.ok) throw new Error(`${res.status}`);
const data = await res.json();
comments = data.comments ?? [];
myVotes = data.myVotes ?? {};
} catch (e) {
loadError = 'Failed to load comments.';
} finally {
loading = false;
}
}
// Run once on component mount via $effect
$effect(() => {
loadComments();
});
// ── Post comment ──────────────────────────────────────────────────────────
async function postComment() {
const text = newBody.trim();
if (!text || posting) return;
if (text.length > 2000) {
postError = 'Comment is too long (max 2000 characters).';
return;
}
posting = true;
postError = '';
try {
const res = await fetch(`/api/comments/${encodeURIComponent(slug)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: text })
});
if (res.status === 401) {
postError = 'You must be logged in to comment.';
return;
}
if (!res.ok) {
const err = await res.json().catch(() => ({}));
postError = err.message ?? 'Failed to post comment.';
return;
}
const created: BookComment = await res.json();
comments = [created, ...comments];
newBody = '';
} catch {
postError = 'Failed to post comment.';
} finally {
posting = false;
}
}
// ── Vote ──────────────────────────────────────────────────────────────────
async function vote(commentId: string, v: 'up' | 'down') {
if (votingIds.has(commentId)) return;
votingIds = new Set([...votingIds, commentId]);
try {
const res = await fetch(`/api/comments/${commentId}/vote`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ vote: v })
});
if (!res.ok) return;
const updated: BookComment = await res.json();
// Update comment in list
comments = comments.map((c) => (c.id === commentId ? updated : c));
// Update myVotes: toggle off if same, else set new vote
const prev = myVotes[commentId];
if (prev === v) {
const next = { ...myVotes };
delete next[commentId];
myVotes = next;
} else {
myVotes = { ...myVotes, [commentId]: v };
}
} finally {
const next = new Set(votingIds);
next.delete(commentId);
votingIds = next;
}
}
// ── Helpers ───────────────────────────────────────────────────────────────
function formatDate(iso: string): string {
try {
return new Date(iso).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric'
});
} catch {
return iso;
}
}
const charCount = $derived(newBody.length);
const charOver = $derived(charCount > 2000);
</script>
<div class="mt-10">
<h2 class="text-base font-semibold text-zinc-200 mb-4">
Comments
{#if !loading && comments.length > 0}
<span class="text-zinc-500 font-normal text-sm ml-1">({comments.length})</span>
{/if}
</h2>
<!-- Post form -->
<div class="mb-6">
{#if isLoggedIn}
<div class="flex flex-col gap-2">
<textarea
bind:value={newBody}
placeholder="Write a comment…"
rows="3"
class="w-full px-3 py-2 rounded-lg bg-zinc-800 border border-zinc-700 text-zinc-200 text-sm placeholder-zinc-500 resize-none focus:outline-none focus:border-amber-400 transition-colors"
></textarea>
<div class="flex items-center justify-between gap-3">
<span class="text-xs {charOver ? 'text-red-400' : 'text-zinc-600'} tabular-nums">
{charCount}/2000
</span>
<div class="flex items-center gap-3">
{#if postError}
<span class="text-xs text-red-400">{postError}</span>
{/if}
<button
onclick={postComment}
disabled={posting || !newBody.trim() || charOver}
class="px-4 py-1.5 rounded-lg text-sm font-medium transition-colors
{posting || !newBody.trim() || charOver
? 'bg-zinc-700 text-zinc-500 cursor-not-allowed'
: 'bg-amber-400 text-zinc-900 hover:bg-amber-300'}"
>
{posting ? 'Posting…' : 'Post'}
</button>
</div>
</div>
</div>
{:else}
<p class="text-sm text-zinc-500">
<a href="/auth/login" class="text-amber-400 hover:text-amber-300 transition-colors">Log in</a>
to leave a comment.
</p>
{/if}
</div>
<!-- Comment list -->
{#if loading}
<div class="flex flex-col gap-3">
{#each Array(3) as _}
<div class="rounded-lg bg-zinc-800/50 p-4 animate-pulse">
<div class="h-3 w-24 bg-zinc-700 rounded mb-3"></div>
<div class="h-3 w-full bg-zinc-700/60 rounded mb-2"></div>
<div class="h-3 w-3/4 bg-zinc-700/60 rounded"></div>
</div>
{/each}
</div>
{:else if loadError}
<p class="text-sm text-red-400">{loadError}</p>
{:else if comments.length === 0}
<p class="text-sm text-zinc-500">No comments yet. Be the first!</p>
{:else}
<div class="flex flex-col gap-3">
{#each comments as comment (comment.id)}
{@const myVote = myVotes[comment.id]}
{@const voting = votingIds.has(comment.id)}
<div class="rounded-lg bg-zinc-800/50 border border-zinc-700/50 px-4 py-3 flex flex-col gap-2">
<!-- Header: username + date -->
<div class="flex items-center gap-2 flex-wrap">
<span class="text-sm font-medium text-zinc-200">{comment.username || 'Anonymous'}</span>
<span class="text-zinc-600 text-xs">&middot;</span>
<span class="text-xs text-zinc-500">{formatDate(comment.created)}</span>
</div>
<!-- Body -->
<p class="text-sm text-zinc-300 leading-relaxed whitespace-pre-wrap break-words">{comment.body}</p>
<!-- Vote row -->
<div class="flex items-center gap-3 pt-1">
<!-- Upvote -->
<button
onclick={() => vote(comment.id, 'up')}
disabled={voting}
title="Upvote"
class="flex items-center gap-1 text-xs transition-colors disabled:opacity-50
{myVote === 'up'
? 'text-amber-400'
: 'text-zinc-500 hover:text-zinc-300'}"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M14 10h4.764a2 2 0 011.789 2.894l-3.5 7A2 2 0 0115.263 21h-4.017c-.163 0-.326-.02-.485-.06L7 20m7-10V5a2 2 0 00-2-2h-.095c-.5 0-.905.405-.905.905 0 .714-.211 1.412-.608 2.006L7 11v9m7-10h-2M7 20H5a2 2 0 01-2-2v-6a2 2 0 012-2h2.5"/>
</svg>
<span class="tabular-nums">{comment.upvotes ?? 0}</span>
</button>
<!-- Downvote -->
<button
onclick={() => vote(comment.id, 'down')}
disabled={voting}
title="Downvote"
class="flex items-center gap-1 text-xs transition-colors disabled:opacity-50
{myVote === 'down'
? 'text-red-400'
: 'text-zinc-500 hover:text-zinc-300'}"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M10 14H5.236a2 2 0 01-1.789-2.894l3.5-7A2 2 0 018.736 3h4.018a2 2 0 01.485.06l3.76.94m-7 10v5a2 2 0 002 2h.096c.5 0 .905-.405.905-.904 0-.715.211-1.413.608-2.008L17 13V4m-7 10h2m5-10h2a2 2 0 012 2v6a2 2 0 01-2 2h-2.5"/>
</svg>
<span class="tabular-nums">{comment.downvotes ?? 0}</span>
</button>
</div>
</div>
{/each}
</div>
{/if}
</div>

View File

@@ -811,3 +811,184 @@ export async function updateUserAvatarUrl(userId: string, avatarUrl: string): Pr
throw new Error(`updateUserAvatarUrl failed: ${res.status} ${body}`);
}
}
// ─── Comments ─────────────────────────────────────────────────────────────────
export interface BookComment {
id: string;
slug: string;
user_id: string;
username: string;
body: string;
upvotes: number;
downvotes: number;
created: string;
}
export interface CommentVote {
id: string;
comment_id: string;
user_id: string;
session_id: string;
vote: 'up' | 'down';
}
/**
* List comments for a book, newest first, up to 100.
*/
export async function listComments(slug: string): Promise<BookComment[]> {
const token = await getToken();
const filter = encodeURIComponent(`slug="${slug.replace(/"/g, '\\"')}"`);
const res = await fetch(
`${PB_URL}/api/collections/book_comments/records?filter=${filter}&sort=-created&perPage=100`,
{ headers: { Authorization: `Bearer ${token}` } }
);
if (!res.ok) return [];
const data = await res.json();
return (data.items ?? []) as BookComment[];
}
/**
* Create a new comment. Returns the created record.
*/
export async function createComment(
slug: string,
body: string,
userId: string | undefined,
username: string
): Promise<BookComment> {
const token = await getToken();
const res = await fetch(`${PB_URL}/api/collections/book_comments/records`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
slug,
body,
user_id: userId ?? '',
username,
upvotes: 0,
downvotes: 0,
created: new Date().toISOString()
})
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`createComment failed: ${res.status} ${text}`);
}
return res.json() as Promise<BookComment>;
}
/**
* Get an existing vote by this voter (identified by user_id or session_id) on a comment.
*/
export async function getCommentVote(
commentId: string,
sessionId: string,
userId?: string
): Promise<CommentVote | null> {
const token = await getToken();
const voterFilter = userId
? `comment_id="${commentId}"&&user_id="${userId}"`
: `comment_id="${commentId}"&&session_id="${sessionId}"`;
const res = await fetch(
`${PB_URL}/api/collections/comment_votes/records?filter=${encodeURIComponent(voterFilter)}&perPage=1`,
{ headers: { Authorization: `Bearer ${token}` } }
);
if (!res.ok) return null;
const data = await res.json();
const items = (data.items ?? []) as CommentVote[];
return items[0] ?? null;
}
/**
* Cast or change a vote on a comment. Handles:
* - New vote: creates vote record, increments counter.
* - Same vote again: removes it (toggle off), decrements counter.
* - Changed vote: updates record, adjusts both counters.
* Returns the updated comment.
*/
export async function voteComment(
commentId: string,
vote: 'up' | 'down',
sessionId: string,
userId?: string
): Promise<BookComment> {
const token = await getToken();
// Fetch current comment
const commentRes = await fetch(`${PB_URL}/api/collections/book_comments/records/${commentId}`, {
headers: { Authorization: `Bearer ${token}` }
});
if (!commentRes.ok) throw new Error(`Comment not found: ${commentId}`);
const comment = (await commentRes.json()) as BookComment;
const existing = await getCommentVote(commentId, sessionId, userId);
let upDelta = 0;
let downDelta = 0;
if (!existing) {
// New vote
await fetch(`${PB_URL}/api/collections/comment_votes/records`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ comment_id: commentId, user_id: userId ?? '', session_id: sessionId, vote })
});
vote === 'up' ? upDelta++ : downDelta++;
} else if (existing.vote === vote) {
// Toggle off — remove vote
await fetch(`${PB_URL}/api/collections/comment_votes/records/${existing.id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` }
});
vote === 'up' ? upDelta-- : downDelta--;
} else {
// Changed vote
await fetch(`${PB_URL}/api/collections/comment_votes/records/${existing.id}`, {
method: 'PATCH',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ vote })
});
if (vote === 'up') { upDelta++; downDelta--; }
else { upDelta--; downDelta++; }
}
// Patch comment counters
const patchRes = await fetch(`${PB_URL}/api/collections/book_comments/records/${commentId}`, {
method: 'PATCH',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
upvotes: Math.max(0, (comment.upvotes ?? 0) + upDelta),
downvotes: Math.max(0, (comment.downvotes ?? 0) + downDelta)
})
});
if (!patchRes.ok) throw new Error(`Failed to update vote counts on comment ${commentId}`);
return patchRes.json() as Promise<BookComment>;
}
/**
* Fetch votes cast by this session/user, keyed by comment_id.
* Returns a map of commentId → 'up' | 'down'.
*/
export async function getMyVotes(
commentIds: string[],
sessionId: string,
userId?: string
): Promise<Record<string, 'up' | 'down'>> {
if (commentIds.length === 0) return {};
const token = await getToken();
const idFilter = commentIds.map((id) => `comment_id="${id}"`).join('||');
const voterPart = userId ? `user_id="${userId}"` : `session_id="${sessionId}"`;
const filter = encodeURIComponent(`(${idFilter})&&${voterPart}`);
const res = await fetch(
`${PB_URL}/api/collections/comment_votes/records?filter=${filter}&perPage=200`,
{ headers: { Authorization: `Bearer ${token}` } }
);
if (!res.ok) return {};
const data = await res.json();
const map: Record<string, 'up' | 'down'> = {};
for (const v of (data.items ?? []) as CommentVote[]) {
map[v.comment_id] = v.vote as 'up' | 'down';
}
return map;
}

View File

@@ -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) });
}

View File

@@ -0,0 +1,33 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { voteComment } from '$lib/server/pocketbase';
import { log } from '$lib/server/logger';
/**
* POST /api/comments/[id]/vote
* Body: { vote: 'up' | 'down' }
* Casts, changes, or toggles off a vote on a comment.
* Works for both authenticated and anonymous users (session-scoped).
* Returns the updated comment.
*/
export const POST: RequestHandler = async ({ params, request, locals }) => {
const { id } = params;
let body: { vote?: string };
try {
body = await request.json();
} catch {
error(400, 'Invalid JSON body');
}
if (body.vote !== 'up' && body.vote !== 'down') {
error(400, 'vote must be "up" or "down"');
}
try {
const updated = await voteComment(id, body.vote, locals.sessionId, locals.user?.id);
return json(updated);
} catch (e) {
log.error('api/comments/[id]/vote', 'voteComment failed', { id, err: String(e) });
error(500, 'Failed to record vote');
}
};

View File

@@ -0,0 +1,54 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { listComments, createComment, getMyVotes } from '$lib/server/pocketbase';
import { log } from '$lib/server/logger';
/**
* GET /api/comments/[slug]
* Returns comments for a book + the current visitor's votes.
* Response: { comments: BookComment[], myVotes: Record<string, 'up'|'down'> }
*/
export const GET: RequestHandler = async ({ params, locals }) => {
const { slug } = params;
try {
const comments = await listComments(slug);
const myVotes = await getMyVotes(
comments.map((c) => c.id),
locals.sessionId,
locals.user?.id
);
return json({ comments, myVotes });
} catch (e) {
log.error('api/comments/[slug]', 'listComments failed', { slug, err: String(e) });
error(500, 'Failed to load comments');
}
};
/**
* POST /api/comments/[slug]
* Body: { body: string }
* Creates a new comment. Requires authentication.
*/
export const POST: RequestHandler = async ({ params, request, locals }) => {
if (!locals.user) error(401, 'Login required to comment');
const { slug } = params;
let body: { body?: string };
try {
body = await request.json();
} catch {
error(400, 'Invalid JSON body');
}
const text = (body.body ?? '').trim();
if (!text) error(400, 'Comment body is required');
if (text.length > 2000) error(400, 'Comment is too long (max 2000 characters)');
try {
const comment = await createComment(slug, text, locals.user.id, locals.user.username);
return json(comment, { status: 201 });
} catch (e) {
log.error('api/comments/[slug]', 'createComment failed', { slug, err: String(e) });
error(500, 'Failed to post comment');
}
};

View File

@@ -2,6 +2,7 @@
import { onMount } from 'svelte';
import { invalidateAll } from '$app/navigation';
import type { PageData } from './$types';
import CommentsSection from '$lib/components/CommentsSection.svelte';
let { data }: { data: PageData } = $props();
@@ -525,3 +526,6 @@
</div>
{/if}
</div>
<!-- ══════════════════════════════════════════════════ Comments ══ -->
<CommentsSection slug={data.book.slug} isLoggedIn={true} />

View File

@@ -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) });

View File

@@ -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.';

View File

@@ -0,0 +1,6 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = () => {
return json({ status: 'ok' });
};

View File

@@ -3,6 +3,7 @@
import { invalidateAll } from '$app/navigation';
import type { PageData, ActionData } from './$types';
import { audioStore } from '$lib/audio.svelte';
import { browser } from '$app/environment';
let { data, form }: { data: PageData; form: ActionData } = $props();
@@ -12,32 +13,71 @@
let avatarError = $state('');
let fileInput: HTMLInputElement | null = null;
async function handleAvatarChange(e: Event) {
// Crop modal state
let cropFile = $state<File | null>(null);
function handleAvatarChange(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
// Reset input so the same file can be re-selected after cancel
if (fileInput) fileInput.value = '';
cropFile = file;
}
async function handleCropConfirm(blob: Blob, mimeType: string) {
cropFile = null;
avatarUploading = true;
avatarError = '';
try {
const fd = new FormData();
fd.append('file', file);
const res = await fetch('/api/profile/avatar', { method: 'POST', body: fd });
if (!res.ok) {
const body = await res.json().catch(() => ({})) as { message?: string };
avatarError = body.message ?? `Upload failed (${res.status})`;
// Step 1: get presigned PUT URL
const presignRes = await fetch('/api/profile/avatar', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mime_type: mimeType })
});
if (!presignRes.ok) {
const body = await presignRes.json().catch(() => ({})) as { message?: string };
avatarError = body.message ?? `Failed to prepare upload (${presignRes.status})`;
return;
}
const result = await res.json() as { avatar_url: string | null };
const { upload_url, key } = await presignRes.json() as { upload_url: string; key: string };
// Step 2: PUT blob directly to MinIO
const putRes = await fetch(upload_url, {
method: 'PUT',
headers: { 'Content-Type': mimeType },
body: blob
});
if (!putRes.ok) {
avatarError = `Upload failed (${putRes.status})`;
return;
}
// Step 3: record key in PocketBase and get fresh presigned GET URL
const patchRes = await fetch('/api/profile/avatar', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key })
});
if (!patchRes.ok) {
const body = await patchRes.json().catch(() => ({})) as { message?: string };
avatarError = body.message ?? `Failed to save avatar (${patchRes.status})`;
return;
}
const result = await patchRes.json() as { avatar_url: string | null };
avatarUrl = result.avatar_url;
} catch {
avatarError = 'Network error during upload';
} finally {
avatarUploading = false;
if (fileInput) fileInput.value = '';
}
}
function handleCropCancel() {
cropFile = null;
}
// ── Settings ────────────────────────────────────────────────────────────────
let voices = $state<string[]>([]);
let voicesLoaded = $state(false);
@@ -173,6 +213,16 @@
<title>Profile — libnovel</title>
</svelte:head>
{#if cropFile && browser}
{#await import('$lib/components/AvatarCropModal.svelte') then { default: AvatarCropModal }}
<AvatarCropModal
file={cropFile}
onconfirm={handleCropConfirm}
oncancel={handleCropCancel}
/>
{/await}
{/if}
<!-- Hidden logout form used when user ends their own session -->
<form id="logout-form" method="POST" action="/logout" class="hidden"></form>
@@ -210,14 +260,14 @@
{/if}
</div>
</button>
<input
bind:this={fileInput}
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
class="hidden"
onchange={handleAvatarChange}
/>
</div>
<input
bind:this={fileInput}
type="file"
accept="image/jpeg,image/png,image/webp"
class="hidden"
onchange={handleAvatarChange}
/>
</div>
<div>
<h1 class="text-2xl font-bold text-zinc-100">{data.user.username}</h1>

View File

@@ -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']
}
});