Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
718bfa6691 | ||
|
|
e11e866e27 | ||
|
|
23345e22e6 | ||
|
|
c7b3495a23 | ||
|
|
83a5910a59 | ||
|
|
0f6639aae7 | ||
|
|
88a25bc33e | ||
|
|
73ad4ece49 |
65
.gitea/workflows/release-scraper.yaml
Normal file
65
.gitea/workflows/release-scraper.yaml
Normal 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 }}
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -96,7 +96,7 @@ struct AuthView: View {
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.navigationBarHidden(true)
|
||||
.toolbar(.hidden, for: .navigationBar)
|
||||
}
|
||||
.onChange(of: mode) { _, _ in
|
||||
authStore.error = nil
|
||||
|
||||
@@ -27,6 +27,8 @@ struct BookDetailView: View {
|
||||
metaSection(book: book)
|
||||
Divider().padding(.horizontal)
|
||||
chapterSection(book: book)
|
||||
Divider().padding(.horizontal)
|
||||
CommentsView(slug: slug)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
320
ios/LibNovel/LibNovel/Views/BookDetail/CommentsView.swift
Normal file
320
ios/LibNovel/LibNovel/Views/BookDetail/CommentsView.swift
Normal file
@@ -0,0 +1,320 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - ViewModel
|
||||
|
||||
@MainActor
|
||||
class CommentsViewModel: ObservableObject {
|
||||
let slug: String
|
||||
|
||||
@Published var comments: [BookComment] = []
|
||||
@Published var myVotes: [String: String] = [:] // commentId → "up" | "down"
|
||||
@Published var isLoading = true
|
||||
@Published var error: String?
|
||||
|
||||
@Published var newBody = ""
|
||||
@Published var isPosting = false
|
||||
@Published var postError: String?
|
||||
|
||||
private var votingIds: Set<String> = []
|
||||
|
||||
init(slug: String) {
|
||||
self.slug = slug
|
||||
}
|
||||
|
||||
func load() async {
|
||||
isLoading = true
|
||||
error = nil
|
||||
do {
|
||||
let response = try await APIClient.shared.fetchComments(slug: slug)
|
||||
comments = response.comments
|
||||
myVotes = response.myVotes
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func postComment() async {
|
||||
let text = newBody.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty, !isPosting else { return }
|
||||
if text.count > 2000 {
|
||||
postError = "Comment too long (max 2000 characters)."
|
||||
return
|
||||
}
|
||||
isPosting = true
|
||||
postError = nil
|
||||
do {
|
||||
let created = try await APIClient.shared.postComment(slug: slug, body: text)
|
||||
comments.insert(created, at: 0)
|
||||
newBody = ""
|
||||
} catch let apiError as APIError {
|
||||
switch apiError {
|
||||
case .httpError(401, _): postError = "You must be logged in to comment."
|
||||
default: postError = apiError.localizedDescription
|
||||
}
|
||||
} catch {
|
||||
postError = error.localizedDescription
|
||||
}
|
||||
isPosting = false
|
||||
}
|
||||
|
||||
func vote(commentId: String, vote: String) async {
|
||||
guard !votingIds.contains(commentId) else { return }
|
||||
votingIds.insert(commentId)
|
||||
defer { votingIds.remove(commentId) }
|
||||
do {
|
||||
let updated = try await APIClient.shared.voteComment(commentId: commentId, vote: vote)
|
||||
// Update the comment in the list
|
||||
if let idx = comments.firstIndex(where: { $0.id == commentId }) {
|
||||
comments[idx] = updated
|
||||
}
|
||||
// Toggle myVotes
|
||||
let prev = myVotes[commentId]
|
||||
if prev == vote {
|
||||
myVotes.removeValue(forKey: commentId)
|
||||
} else {
|
||||
myVotes[commentId] = vote
|
||||
}
|
||||
} catch {
|
||||
// Silently ignore vote errors — don't disrupt the UI
|
||||
}
|
||||
}
|
||||
|
||||
func isVoting(_ commentId: String) -> Bool {
|
||||
votingIds.contains(commentId)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CommentsView
|
||||
|
||||
struct CommentsView: View {
|
||||
@StateObject private var vm: CommentsViewModel
|
||||
@EnvironmentObject private var authStore: AuthStore
|
||||
|
||||
init(slug: String) {
|
||||
_vm = StateObject(wrappedValue: CommentsViewModel(slug: slug))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
// Section header
|
||||
HStack {
|
||||
Text("Comments")
|
||||
.font(.headline)
|
||||
if !vm.isLoading && !vm.comments.isEmpty {
|
||||
Text("(\(vm.comments.count))")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 14)
|
||||
|
||||
Divider().padding(.horizontal)
|
||||
|
||||
// Post form
|
||||
postForm
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 12)
|
||||
|
||||
Divider().padding(.horizontal)
|
||||
|
||||
// Comment list
|
||||
if vm.isLoading {
|
||||
loadingPlaceholder
|
||||
} else if let err = vm.error {
|
||||
Text(err)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.red)
|
||||
.padding()
|
||||
} else if vm.comments.isEmpty {
|
||||
Text("No comments yet. Be the first!")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding()
|
||||
} else {
|
||||
ForEach(vm.comments) { comment in
|
||||
CommentRow(
|
||||
comment: comment,
|
||||
myVote: vm.myVotes[comment.id],
|
||||
isVoting: vm.isVoting(comment.id)
|
||||
) { vote in
|
||||
Task { await vm.vote(commentId: comment.id, vote: vote) }
|
||||
}
|
||||
Divider().padding(.leading, 16)
|
||||
}
|
||||
}
|
||||
|
||||
Color.clear.frame(height: 16)
|
||||
}
|
||||
.task { await vm.load() }
|
||||
}
|
||||
|
||||
// MARK: - Post form
|
||||
|
||||
@ViewBuilder
|
||||
private var postForm: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ZStack(alignment: .topLeading) {
|
||||
if vm.newBody.isEmpty {
|
||||
Text("Write a comment…")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.tertiary)
|
||||
.padding(.top, 8)
|
||||
.padding(.leading, 4)
|
||||
}
|
||||
TextEditor(text: $vm.newBody)
|
||||
.font(.subheadline)
|
||||
.frame(minHeight: 72, maxHeight: 160)
|
||||
.scrollContentBackground(.hidden)
|
||||
}
|
||||
.padding(10)
|
||||
.background(Color(.systemGray6), in: RoundedRectangle(cornerRadius: 10))
|
||||
|
||||
HStack {
|
||||
let count = vm.newBody.count
|
||||
Text("\(count)/2000")
|
||||
.font(.caption2)
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(count > 2000 ? .red : .tertiary)
|
||||
|
||||
Spacer()
|
||||
|
||||
if let err = vm.postError {
|
||||
Text(err)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.red)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
Button {
|
||||
Task { await vm.postComment() }
|
||||
} label: {
|
||||
if vm.isPosting {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Text("Post")
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.amber)
|
||||
.controlSize(.small)
|
||||
.disabled(vm.isPosting || vm.newBody.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || vm.newBody.count > 2000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Loading skeleton
|
||||
|
||||
@ViewBuilder
|
||||
private var loadingPlaceholder: some View {
|
||||
VStack(spacing: 12) {
|
||||
ForEach(0..<3, id: \.self) { _ in
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(Color(.systemGray5))
|
||||
.frame(width: 100, height: 12)
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(Color(.systemGray6))
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 12)
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(Color(.systemGray6))
|
||||
.frame(width: 200, height: 12)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.redacted(reason: .placeholder)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CommentRow
|
||||
|
||||
private struct CommentRow: View {
|
||||
let comment: BookComment
|
||||
let myVote: String?
|
||||
let isVoting: Bool
|
||||
let onVote: (String) -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
// Username + date
|
||||
HStack(spacing: 6) {
|
||||
Text(comment.username.isEmpty ? "Anonymous" : comment.username)
|
||||
.font(.subheadline.weight(.medium))
|
||||
Text("·")
|
||||
.foregroundStyle(.tertiary)
|
||||
Text(formattedDate(comment.created))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
}
|
||||
|
||||
// Body
|
||||
Text(comment.body)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.primary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
// Vote row
|
||||
HStack(spacing: 16) {
|
||||
// Upvote
|
||||
Button {
|
||||
onVote("up")
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: myVote == "up" ? "hand.thumbsup.fill" : "hand.thumbsup")
|
||||
.font(.caption)
|
||||
Text("\(comment.upvotes)")
|
||||
.font(.caption.monospacedDigit())
|
||||
}
|
||||
.foregroundStyle(myVote == "up" ? Color.amber : .secondary)
|
||||
}
|
||||
.disabled(isVoting)
|
||||
|
||||
// Downvote
|
||||
Button {
|
||||
onVote("down")
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: myVote == "down" ? "hand.thumbsdown.fill" : "hand.thumbsdown")
|
||||
.font(.caption)
|
||||
Text("\(comment.downvotes)")
|
||||
.font(.caption.monospacedDigit())
|
||||
}
|
||||
.foregroundStyle(myVote == "down" ? .red : .secondary)
|
||||
}
|
||||
.disabled(isVoting)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.opacity(isVoting ? 0.6 : 1)
|
||||
}
|
||||
|
||||
private func formattedDate(_ iso: String) -> String {
|
||||
// PocketBase returns "2006-01-02 15:04:05.999Z" format
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
if let date = formatter.date(from: iso) {
|
||||
let rel = RelativeDateTimeFormatter()
|
||||
rel.unitsStyle = .abbreviated
|
||||
return rel.localizedString(for: date, relativeTo: Date())
|
||||
}
|
||||
// Fallback: try space-separated format
|
||||
let df = DateFormatter()
|
||||
df.dateFormat = "yyyy-MM-dd HH:mm:ss.SSSZ"
|
||||
if let date = df.date(from: iso) {
|
||||
let rel = RelativeDateTimeFormatter()
|
||||
rel.unitsStyle = .abbreviated
|
||||
return rel.localizedString(for: date, relativeTo: Date())
|
||||
}
|
||||
return String(iso.prefix(10))
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
161
ios/LibNovel/LibNovel/Views/Profile/AvatarCropView.swift
Normal file
161
ios/LibNovel/LibNovel/Views/Profile/AvatarCropView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
13
scripts/.runner
Normal 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
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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
7
ui/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
92
ui/src/lib/components/AvatarCropModal.svelte
Normal file
92
ui/src/lib/components/AvatarCropModal.svelte
Normal 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>
|
||||
254
ui/src/lib/components/CommentsSection.svelte
Normal file
254
ui/src/lib/components/CommentsSection.svelte
Normal file
@@ -0,0 +1,254 @@
|
||||
<script lang="ts">
|
||||
interface BookComment {
|
||||
id: string;
|
||||
slug: string;
|
||||
user_id: string;
|
||||
username: string;
|
||||
body: string;
|
||||
upvotes: number;
|
||||
downvotes: number;
|
||||
created: string;
|
||||
}
|
||||
|
||||
let {
|
||||
slug,
|
||||
isLoggedIn = false
|
||||
}: {
|
||||
slug: string;
|
||||
isLoggedIn?: boolean;
|
||||
} = $props();
|
||||
|
||||
// ── State ─────────────────────────────────────────────────────────────────
|
||||
let comments = $state<BookComment[]>([]);
|
||||
let myVotes = $state<Record<string, 'up' | 'down'>>({});
|
||||
let loading = $state(true);
|
||||
let loadError = $state('');
|
||||
|
||||
let newBody = $state('');
|
||||
let posting = $state(false);
|
||||
let postError = $state('');
|
||||
|
||||
// Per-comment vote inflight set (prevents double-clicks)
|
||||
let votingIds = $state(new Set<string>());
|
||||
|
||||
// ── Load comments on mount ────────────────────────────────────────────────
|
||||
async function loadComments() {
|
||||
loading = true;
|
||||
loadError = '';
|
||||
try {
|
||||
const res = await fetch(`/api/comments/${encodeURIComponent(slug)}`);
|
||||
if (!res.ok) throw new Error(`${res.status}`);
|
||||
const data = await res.json();
|
||||
comments = data.comments ?? [];
|
||||
myVotes = data.myVotes ?? {};
|
||||
} catch (e) {
|
||||
loadError = 'Failed to load comments.';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Run once on component mount via $effect
|
||||
$effect(() => {
|
||||
loadComments();
|
||||
});
|
||||
|
||||
// ── Post comment ──────────────────────────────────────────────────────────
|
||||
async function postComment() {
|
||||
const text = newBody.trim();
|
||||
if (!text || posting) return;
|
||||
if (text.length > 2000) {
|
||||
postError = 'Comment is too long (max 2000 characters).';
|
||||
return;
|
||||
}
|
||||
posting = true;
|
||||
postError = '';
|
||||
try {
|
||||
const res = await fetch(`/api/comments/${encodeURIComponent(slug)}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ body: text })
|
||||
});
|
||||
if (res.status === 401) {
|
||||
postError = 'You must be logged in to comment.';
|
||||
return;
|
||||
}
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
postError = err.message ?? 'Failed to post comment.';
|
||||
return;
|
||||
}
|
||||
const created: BookComment = await res.json();
|
||||
comments = [created, ...comments];
|
||||
newBody = '';
|
||||
} catch {
|
||||
postError = 'Failed to post comment.';
|
||||
} finally {
|
||||
posting = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Vote ──────────────────────────────────────────────────────────────────
|
||||
async function vote(commentId: string, v: 'up' | 'down') {
|
||||
if (votingIds.has(commentId)) return;
|
||||
votingIds = new Set([...votingIds, commentId]);
|
||||
try {
|
||||
const res = await fetch(`/api/comments/${commentId}/vote`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ vote: v })
|
||||
});
|
||||
if (!res.ok) return;
|
||||
const updated: BookComment = await res.json();
|
||||
// Update comment in list
|
||||
comments = comments.map((c) => (c.id === commentId ? updated : c));
|
||||
// Update myVotes: toggle off if same, else set new vote
|
||||
const prev = myVotes[commentId];
|
||||
if (prev === v) {
|
||||
const next = { ...myVotes };
|
||||
delete next[commentId];
|
||||
myVotes = next;
|
||||
} else {
|
||||
myVotes = { ...myVotes, [commentId]: v };
|
||||
}
|
||||
} finally {
|
||||
const next = new Set(votingIds);
|
||||
next.delete(commentId);
|
||||
votingIds = next;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
function formatDate(iso: string): string {
|
||||
try {
|
||||
return new Date(iso).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
const charCount = $derived(newBody.length);
|
||||
const charOver = $derived(charCount > 2000);
|
||||
</script>
|
||||
|
||||
<div class="mt-10">
|
||||
<h2 class="text-base font-semibold text-zinc-200 mb-4">
|
||||
Comments
|
||||
{#if !loading && comments.length > 0}
|
||||
<span class="text-zinc-500 font-normal text-sm ml-1">({comments.length})</span>
|
||||
{/if}
|
||||
</h2>
|
||||
|
||||
<!-- Post form -->
|
||||
<div class="mb-6">
|
||||
{#if isLoggedIn}
|
||||
<div class="flex flex-col gap-2">
|
||||
<textarea
|
||||
bind:value={newBody}
|
||||
placeholder="Write a comment…"
|
||||
rows="3"
|
||||
class="w-full px-3 py-2 rounded-lg bg-zinc-800 border border-zinc-700 text-zinc-200 text-sm placeholder-zinc-500 resize-none focus:outline-none focus:border-amber-400 transition-colors"
|
||||
></textarea>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span class="text-xs {charOver ? 'text-red-400' : 'text-zinc-600'} tabular-nums">
|
||||
{charCount}/2000
|
||||
</span>
|
||||
<div class="flex items-center gap-3">
|
||||
{#if postError}
|
||||
<span class="text-xs text-red-400">{postError}</span>
|
||||
{/if}
|
||||
<button
|
||||
onclick={postComment}
|
||||
disabled={posting || !newBody.trim() || charOver}
|
||||
class="px-4 py-1.5 rounded-lg text-sm font-medium transition-colors
|
||||
{posting || !newBody.trim() || charOver
|
||||
? 'bg-zinc-700 text-zinc-500 cursor-not-allowed'
|
||||
: 'bg-amber-400 text-zinc-900 hover:bg-amber-300'}"
|
||||
>
|
||||
{posting ? 'Posting…' : 'Post'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-sm text-zinc-500">
|
||||
<a href="/auth/login" class="text-amber-400 hover:text-amber-300 transition-colors">Log in</a>
|
||||
to leave a comment.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Comment list -->
|
||||
{#if loading}
|
||||
<div class="flex flex-col gap-3">
|
||||
{#each Array(3) as _}
|
||||
<div class="rounded-lg bg-zinc-800/50 p-4 animate-pulse">
|
||||
<div class="h-3 w-24 bg-zinc-700 rounded mb-3"></div>
|
||||
<div class="h-3 w-full bg-zinc-700/60 rounded mb-2"></div>
|
||||
<div class="h-3 w-3/4 bg-zinc-700/60 rounded"></div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if loadError}
|
||||
<p class="text-sm text-red-400">{loadError}</p>
|
||||
{:else if comments.length === 0}
|
||||
<p class="text-sm text-zinc-500">No comments yet. Be the first!</p>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-3">
|
||||
{#each comments as comment (comment.id)}
|
||||
{@const myVote = myVotes[comment.id]}
|
||||
{@const voting = votingIds.has(comment.id)}
|
||||
<div class="rounded-lg bg-zinc-800/50 border border-zinc-700/50 px-4 py-3 flex flex-col gap-2">
|
||||
<!-- Header: username + date -->
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span class="text-sm font-medium text-zinc-200">{comment.username || 'Anonymous'}</span>
|
||||
<span class="text-zinc-600 text-xs">·</span>
|
||||
<span class="text-xs text-zinc-500">{formatDate(comment.created)}</span>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<p class="text-sm text-zinc-300 leading-relaxed whitespace-pre-wrap break-words">{comment.body}</p>
|
||||
|
||||
<!-- Vote row -->
|
||||
<div class="flex items-center gap-3 pt-1">
|
||||
<!-- Upvote -->
|
||||
<button
|
||||
onclick={() => vote(comment.id, 'up')}
|
||||
disabled={voting}
|
||||
title="Upvote"
|
||||
class="flex items-center gap-1 text-xs transition-colors disabled:opacity-50
|
||||
{myVote === 'up'
|
||||
? 'text-amber-400'
|
||||
: 'text-zinc-500 hover:text-zinc-300'}"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M14 10h4.764a2 2 0 011.789 2.894l-3.5 7A2 2 0 0115.263 21h-4.017c-.163 0-.326-.02-.485-.06L7 20m7-10V5a2 2 0 00-2-2h-.095c-.5 0-.905.405-.905.905 0 .714-.211 1.412-.608 2.006L7 11v9m7-10h-2M7 20H5a2 2 0 01-2-2v-6a2 2 0 012-2h2.5"/>
|
||||
</svg>
|
||||
<span class="tabular-nums">{comment.upvotes ?? 0}</span>
|
||||
</button>
|
||||
|
||||
<!-- Downvote -->
|
||||
<button
|
||||
onclick={() => vote(comment.id, 'down')}
|
||||
disabled={voting}
|
||||
title="Downvote"
|
||||
class="flex items-center gap-1 text-xs transition-colors disabled:opacity-50
|
||||
{myVote === 'down'
|
||||
? 'text-red-400'
|
||||
: 'text-zinc-500 hover:text-zinc-300'}"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10 14H5.236a2 2 0 01-1.789-2.894l3.5-7A2 2 0 018.736 3h4.018a2 2 0 01.485.06l3.76.94m-7 10v5a2 2 0 002 2h.096c.5 0 .905-.405.905-.904 0-.715.211-1.413.608-2.008L17 13V4m-7 10h2m5-10h2a2 2 0 012 2v6a2 2 0 01-2 2h-2.5"/>
|
||||
</svg>
|
||||
<span class="tabular-nums">{comment.downvotes ?? 0}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -811,3 +811,184 @@ export async function updateUserAvatarUrl(userId: string, avatarUrl: string): Pr
|
||||
throw new Error(`updateUserAvatarUrl failed: ${res.status} ${body}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 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;
|
||||
}
|
||||
|
||||
@@ -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) });
|
||||
}
|
||||
|
||||
33
ui/src/routes/api/comments/[id]/vote/+server.ts
Normal file
33
ui/src/routes/api/comments/[id]/vote/+server.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { voteComment } from '$lib/server/pocketbase';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
/**
|
||||
* POST /api/comments/[id]/vote
|
||||
* Body: { vote: 'up' | 'down' }
|
||||
* Casts, changes, or toggles off a vote on a comment.
|
||||
* Works for both authenticated and anonymous users (session-scoped).
|
||||
* Returns the updated comment.
|
||||
*/
|
||||
export const POST: RequestHandler = async ({ params, request, locals }) => {
|
||||
const { id } = params;
|
||||
let body: { vote?: string };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
error(400, 'Invalid JSON body');
|
||||
}
|
||||
|
||||
if (body.vote !== 'up' && body.vote !== 'down') {
|
||||
error(400, 'vote must be "up" or "down"');
|
||||
}
|
||||
|
||||
try {
|
||||
const updated = await voteComment(id, body.vote, locals.sessionId, locals.user?.id);
|
||||
return json(updated);
|
||||
} catch (e) {
|
||||
log.error('api/comments/[id]/vote', 'voteComment failed', { id, err: String(e) });
|
||||
error(500, 'Failed to record vote');
|
||||
}
|
||||
};
|
||||
54
ui/src/routes/api/comments/[slug]/+server.ts
Normal file
54
ui/src/routes/api/comments/[slug]/+server.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { listComments, createComment, getMyVotes } from '$lib/server/pocketbase';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
/**
|
||||
* GET /api/comments/[slug]
|
||||
* Returns comments for a book + the current visitor's votes.
|
||||
* Response: { comments: BookComment[], myVotes: Record<string, 'up'|'down'> }
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ params, locals }) => {
|
||||
const { slug } = params;
|
||||
try {
|
||||
const comments = await listComments(slug);
|
||||
const myVotes = await getMyVotes(
|
||||
comments.map((c) => c.id),
|
||||
locals.sessionId,
|
||||
locals.user?.id
|
||||
);
|
||||
return json({ comments, myVotes });
|
||||
} catch (e) {
|
||||
log.error('api/comments/[slug]', 'listComments failed', { slug, err: String(e) });
|
||||
error(500, 'Failed to load comments');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /api/comments/[slug]
|
||||
* Body: { body: string }
|
||||
* Creates a new comment. Requires authentication.
|
||||
*/
|
||||
export const POST: RequestHandler = async ({ params, request, locals }) => {
|
||||
if (!locals.user) error(401, 'Login required to comment');
|
||||
|
||||
const { slug } = params;
|
||||
let body: { body?: string };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
error(400, 'Invalid JSON body');
|
||||
}
|
||||
|
||||
const text = (body.body ?? '').trim();
|
||||
if (!text) error(400, 'Comment body is required');
|
||||
if (text.length > 2000) error(400, 'Comment is too long (max 2000 characters)');
|
||||
|
||||
try {
|
||||
const comment = await createComment(slug, text, locals.user.id, locals.user.username);
|
||||
return json(comment, { status: 201 });
|
||||
} catch (e) {
|
||||
log.error('api/comments/[slug]', 'createComment failed', { slug, err: String(e) });
|
||||
error(500, 'Failed to post comment');
|
||||
}
|
||||
};
|
||||
@@ -2,6 +2,7 @@
|
||||
import { onMount } from 'svelte';
|
||||
import { 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} />
|
||||
|
||||
@@ -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.';
|
||||
|
||||
6
ui/src/routes/health/+server.ts
Normal file
6
ui/src/routes/health/+server.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const GET: RequestHandler = () => {
|
||||
return json({ status: 'ok' });
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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