Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b11f4ab6b4 | ||
|
|
3e4b1c0484 | ||
|
|
b5bc6ff3de | ||
|
|
8d4bba7964 | ||
|
|
2e5fe54615 | ||
|
|
81265510ef | ||
|
|
4d3c093612 | ||
|
|
937ba052fc | ||
|
|
479d201da9 | ||
|
|
1242cc7eb3 | ||
|
|
0b6dbeb042 | ||
|
|
c06877069f | ||
|
|
261c738fc0 | ||
|
|
5528abe4b0 | ||
|
|
09cdda2a07 | ||
|
|
718bfa6691 | ||
|
|
e11e866e27 | ||
|
|
23345e22e6 |
@@ -74,3 +74,6 @@ jobs:
|
||||
tags: |
|
||||
${{ secrets.DOCKER_USER }}/libnovel-scraper:latest
|
||||
${{ secrets.DOCKER_USER }}/libnovel-scraper:${{ gitea.sha }}
|
||||
build-args: |
|
||||
VERSION=${{ gitea.sha }}
|
||||
COMMIT=${{ gitea.sha }}
|
||||
|
||||
@@ -65,3 +65,6 @@ jobs:
|
||||
tags: |
|
||||
${{ secrets.DOCKER_USER }}/libnovel-ui:latest
|
||||
${{ secrets.DOCKER_USER }}/libnovel-ui:${{ gitea.sha }}
|
||||
build-args: |
|
||||
BUILD_VERSION=${{ gitea.sha }}
|
||||
BUILD_COMMIT=${{ gitea.sha }}
|
||||
|
||||
@@ -63,3 +63,6 @@ jobs:
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
VERSION=${{ steps.meta.outputs.version }}
|
||||
COMMIT=${{ gitea.sha }}
|
||||
|
||||
@@ -66,3 +66,6 @@ jobs:
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
BUILD_VERSION=${{ steps.meta.outputs.version }}
|
||||
BUILD_COMMIT=${{ gitea.sha }}
|
||||
|
||||
@@ -82,6 +82,9 @@ services:
|
||||
build:
|
||||
context: ./scraper
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
VERSION: "${GIT_TAG:-dev}"
|
||||
COMMIT: "${GIT_COMMIT:-unknown}"
|
||||
#container_name: libnovel-scraper
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
@@ -131,6 +134,9 @@ services:
|
||||
build:
|
||||
context: ./ui
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
BUILD_VERSION: "${GIT_TAG:-dev}"
|
||||
BUILD_COMMIT: "${GIT_COMMIT:-unknown}"
|
||||
# container_name: libnovel-ui
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
|
||||
@@ -24,10 +24,16 @@
|
||||
7D81DEB2EEFF9CA5079AEEF7 /* BookDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 837F83AA12B59924FDF16617 /* BookDetailViewModel.swift */; };
|
||||
94D0C4B15734B4056BF3B127 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B820081FA4817765A39939A /* ContentView.swift */; };
|
||||
9B2D6F241E707312AB80DC31 /* AuthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CEF6782A2A28B2A485CBD48 /* AuthView.swift */; };
|
||||
A1C3F2B84D9E72A1BC054F17 /* CommentsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2E8D1C74A3F91D0E5C72A38 /* CommentsView.swift */; };
|
||||
A2F1C3B84E9D71A0BC164F28 /* AccountMenuSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3E9D2C85A4F02E1F63B5A49 /* AccountMenuSheet.swift */; };
|
||||
A9B95BAD7CE2DCD1DDDABD4C /* AudioPlayerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB13E89E50529E3081533A66 /* AudioPlayerService.swift */; };
|
||||
AA11BB22CC33DD44EE55FF66 /* UserProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA11BB22CC33DD44EE55FF67 /* UserProfileView.swift */; };
|
||||
BB22CC33DD44EE55FF66AA11 /* UserProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB22CC33DD44EE55FF66AA12 /* UserProfileViewModel.swift */; };
|
||||
BE7805A4E78037A82B12AE56 /* PlayerViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF49C3AEF9D010F9FEDAB1FC /* PlayerViews.swift */; };
|
||||
C3D7A2E15F8B04C9AB163D50 /* AvatarCropView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4F1B8A26E3C97D0F52A4B71 /* AvatarCropView.swift */; };
|
||||
C807AD8D627CF6BED47D517C /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AB2E843D93461074A89A171 /* HomeViewModel.swift */; };
|
||||
CFDAA4776344B075A1E3CD6B /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 09584EAB68A07B47F876A062 /* Kingfisher */; };
|
||||
D5E2A1C96F3B08D0F74C6B50 /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4F0A3B75E2D19C0E85A7B61 /* SearchView.swift */; };
|
||||
E1F564399D1325F6A1B2B84F /* LibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C21107BECA55C07416E0CB8B /* LibraryView.swift */; };
|
||||
E2572692178FD17145FDAF77 /* Color+App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D83BB88C4306BE7A4F947CB /* Color+App.swift */; };
|
||||
EF3C57C400BF05CBEAC1F7FE /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6268D60803940CBD38FB921 /* HomeView.swift */; };
|
||||
@@ -67,10 +73,16 @@
|
||||
937A589F84FD412BBB6FBC45 /* ProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewModel.swift; sourceTree = "<group>"; };
|
||||
9812F5FE30ED657FB40ABD7A /* BrowseViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowseViewModel.swift; sourceTree = "<group>"; };
|
||||
9D83BB88C4306BE7A4F947CB /* Color+App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+App.swift"; sourceTree = "<group>"; };
|
||||
AA11BB22CC33DD44EE55FF67 /* UserProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileView.swift; sourceTree = "<group>"; };
|
||||
B2E8D1C74A3F91D0E5C72A38 /* CommentsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentsView.swift; sourceTree = "<group>"; };
|
||||
B3E9D2C85A4F02E1F63B5A49 /* AccountMenuSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountMenuSheet.swift; sourceTree = "<group>"; };
|
||||
B4C918833E173D6B44D06955 /* LibNovelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibNovelTests.swift; sourceTree = "<group>"; };
|
||||
B593F179EC3E9112126B540B /* APIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClient.swift; sourceTree = "<group>"; };
|
||||
BB22CC33DD44EE55FF66AA12 /* UserProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileViewModel.swift; sourceTree = "<group>"; };
|
||||
C0B17D50389C6C98FC78BDBC /* ProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileView.swift; sourceTree = "<group>"; };
|
||||
C21107BECA55C07416E0CB8B /* LibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryView.swift; sourceTree = "<group>"; };
|
||||
C4F0A3B75E2D19C0E85A7B61 /* SearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = "<group>"; };
|
||||
D4F1B8A26E3C97D0F52A4B71 /* AvatarCropView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarCropView.swift; sourceTree = "<group>"; };
|
||||
D6268D60803940CBD38FB921 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = "<group>"; };
|
||||
DB13E89E50529E3081533A66 /* AudioPlayerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayerService.swift; sourceTree = "<group>"; };
|
||||
DF49C3AEF9D010F9FEDAB1FC /* PlayerViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerViews.swift; sourceTree = "<group>"; };
|
||||
@@ -111,6 +123,7 @@
|
||||
FA994FD601E79EC811D822A4 /* Library */,
|
||||
89F2CB14192E7D7565A588E0 /* Player */,
|
||||
3DB66C5703A4CCAFFA1B7AFE /* Profile */,
|
||||
E6A2B4C07F1D38E0A95B3C72 /* Search */,
|
||||
);
|
||||
path = Views;
|
||||
sourceTree = "<group>";
|
||||
@@ -127,6 +140,9 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
C0B17D50389C6C98FC78BDBC /* ProfileView.swift */,
|
||||
D4F1B8A26E3C97D0F52A4B71 /* AvatarCropView.swift */,
|
||||
B3E9D2C85A4F02E1F63B5A49 /* AccountMenuSheet.swift */,
|
||||
AA11BB22CC33DD44EE55FF67 /* UserProfileView.swift */,
|
||||
);
|
||||
path = Profile;
|
||||
sourceTree = "<group>";
|
||||
@@ -240,6 +256,7 @@
|
||||
3AB2E843D93461074A89A171 /* HomeViewModel.swift */,
|
||||
FC338B05EA6DB22900712000 /* LibraryViewModel.swift */,
|
||||
937A589F84FD412BBB6FBC45 /* ProfileViewModel.swift */,
|
||||
BB22CC33DD44EE55FF66AA12 /* UserProfileViewModel.swift */,
|
||||
);
|
||||
path = ViewModels;
|
||||
sourceTree = "<group>";
|
||||
@@ -253,6 +270,14 @@
|
||||
path = Services;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
E6A2B4C07F1D38E0A95B3C72 /* Search */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
C4F0A3B75E2D19C0E85A7B61 /* SearchView.swift */,
|
||||
);
|
||||
path = Search;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
FA994FD601E79EC811D822A4 /* Library */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -265,6 +290,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
39DE056C37FBC5EED8771821 /* BookDetailView.swift */,
|
||||
B2E8D1C74A3F91D0E5C72A38 /* CommentsView.swift */,
|
||||
);
|
||||
path = BookDetail;
|
||||
sourceTree = "<group>";
|
||||
@@ -391,6 +417,7 @@
|
||||
5D8D783259EF54C773788AAB /* AuthStore.swift in Sources */,
|
||||
9B2D6F241E707312AB80DC31 /* AuthView.swift in Sources */,
|
||||
749292A18C57FA41EC88A30B /* BookDetailView.swift in Sources */,
|
||||
A1C3F2B84D9E72A1BC054F17 /* CommentsView.swift in Sources */,
|
||||
7D81DEB2EEFF9CA5079AEEF7 /* BookDetailViewModel.swift in Sources */,
|
||||
08DFB5F626BA769556C8D145 /* BrowseView.swift in Sources */,
|
||||
2790B8C051BE389D83645047 /* BrowseViewModel.swift in Sources */,
|
||||
@@ -408,8 +435,13 @@
|
||||
F4FDA3C44752EB979235C042 /* NavDestination.swift in Sources */,
|
||||
BE7805A4E78037A82B12AE56 /* PlayerViews.swift in Sources */,
|
||||
64D80AACB8E1967B17921EE3 /* ProfileView.swift in Sources */,
|
||||
C3D7A2E15F8B04C9AB163D50 /* AvatarCropView.swift in Sources */,
|
||||
A2F1C3B84E9D71A0BC164F28 /* AccountMenuSheet.swift in Sources */,
|
||||
58E440CE4360D755401D1672 /* ProfileViewModel.swift in Sources */,
|
||||
AA11BB22CC33DD44EE55FF66 /* UserProfileView.swift in Sources */,
|
||||
BB22CC33DD44EE55FF66AA11 /* UserProfileViewModel.swift in Sources */,
|
||||
367C88FFC11701D2BAD8CCD0 /* RootTabView.swift in Sources */,
|
||||
D5E2A1C96F3B08D0F74C6B50 /* SearchView.swift in Sources */,
|
||||
41FB51553F1F1AEBFEA91C0A /* String+App.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
@@ -556,11 +588,11 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_IDENTITY = "Apple Distribution";
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = GHZXC6FVMU;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = GHZXC6FVMU;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = LibNovel/Resources/Info.plist;
|
||||
@@ -573,6 +605,7 @@
|
||||
MARKETING_VERSION = 1.0.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.kalekber.LibNovel;
|
||||
PROVISIONING_PROFILE = "af592c3a-f60b-4ac1-a14f-30b8a206017f";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "LibNovel Distribution";
|
||||
SDKROOT = iphoneos;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
|
||||
@@ -8,17 +8,15 @@ struct RootTabView: View {
|
||||
|
||||
@State private var selectedTab: Tab = .home
|
||||
@State private var showFullPlayer: Bool = false
|
||||
@State private var showCompactControls: Bool = false
|
||||
|
||||
/// Live drag offset while the user is dragging the full player down.
|
||||
@State private var fullPlayerDragOffset: CGFloat = 0
|
||||
|
||||
enum Tab: Hashable {
|
||||
case home, library, browse, profile
|
||||
case home, library, browse, search
|
||||
}
|
||||
|
||||
/// Height of the mini player bar (progress line 2pt + vertical padding 20pt + content ~44pt)
|
||||
private let miniPlayerBarHeight: CGFloat = AppLayout.miniPlayerBarHeight
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .bottom) {
|
||||
TabView(selection: $selectedTab) {
|
||||
@@ -34,24 +32,28 @@ struct RootTabView: View {
|
||||
.tabItem { Label("Discover", systemImage: "sparkles") }
|
||||
.tag(Tab.browse)
|
||||
|
||||
ProfileView()
|
||||
.tabItem { Label("Profile", systemImage: "gear") }
|
||||
.tag(Tab.profile)
|
||||
}
|
||||
// Reserve space for the mini-player above the tab bar so scroll content
|
||||
// never slides beneath it.
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
if audioPlayer.isActive {
|
||||
Color.clear.frame(height: miniPlayerBarHeight)
|
||||
}
|
||||
SearchView()
|
||||
.tabItem { Label("Search", systemImage: "magnifyingglass") }
|
||||
.tag(Tab.search)
|
||||
}
|
||||
|
||||
// Mini-player pinned above the tab bar (hidden while full player is open)
|
||||
// Floating circular player button (hidden while full player is open)
|
||||
if audioPlayer.isActive && !showFullPlayer {
|
||||
MiniPlayerView(showFullPlayer: $showFullPlayer)
|
||||
.padding(.bottom, tabBarHeight)
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
.animation(.spring(response: 0.35, dampingFraction: 0.8), value: audioPlayer.isActive)
|
||||
ZStack {
|
||||
// Compact controls overlay (bottom sheet)
|
||||
if showCompactControls {
|
||||
CompactPlayerControls(isPresented: $showCompactControls)
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
}
|
||||
|
||||
// Floating button (always on top)
|
||||
FloatingPlayerButton(
|
||||
showFullPlayer: $showFullPlayer,
|
||||
showControls: $showCompactControls
|
||||
)
|
||||
}
|
||||
.transition(.scale.combined(with: .opacity))
|
||||
.animation(.spring(response: 0.35, dampingFraction: 0.8), value: audioPlayer.isActive)
|
||||
}
|
||||
|
||||
// Full player — slides up from the bottom as a custom overlay (not a sheet)
|
||||
@@ -92,14 +94,6 @@ struct RootTabView: View {
|
||||
}
|
||||
}
|
||||
.animation(.spring(response: 0.45, dampingFraction: 0.85), value: showFullPlayer)
|
||||
}
|
||||
|
||||
// Approximate safe-area-aware tab bar height
|
||||
private var tabBarHeight: CGFloat {
|
||||
let window = UIApplication.shared.connectedScenes
|
||||
.compactMap { $0 as? UIWindowScene }
|
||||
.first?.windows.first(where: \.isKeyWindow)
|
||||
let bottomInset = window?.safeAreaInsets.bottom ?? 0
|
||||
return 49 + bottomInset // 49pt is the standard iOS tab bar height
|
||||
.animation(.spring(response: 0.3, dampingFraction: 0.7), value: showCompactControls)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import SwiftUI
|
||||
enum NavDestination: Hashable {
|
||||
case book(String) // slug
|
||||
case chapter(String, Int) // slug + chapter number
|
||||
case userProfile(String) // username
|
||||
}
|
||||
|
||||
// MARK: - View extensions for shared navigation + error alert patterns
|
||||
@@ -45,6 +46,8 @@ private struct AppNavigationDestinationModifier: ViewModifier {
|
||||
.navigationTransition(.zoom(sourceID: slug, in: zoomNamespace))
|
||||
case .chapter(let slug, let n):
|
||||
ChapterReaderView(slug: slug, chapterNumber: n)
|
||||
case .userProfile(let username):
|
||||
UserProfileView(username: username)
|
||||
}
|
||||
}
|
||||
// Expose namespace to child views via environment
|
||||
@@ -55,6 +58,7 @@ private struct AppNavigationDestinationModifier: ViewModifier {
|
||||
switch dest {
|
||||
case .book(let slug): BookDetailView(slug: slug)
|
||||
case .chapter(let slug, let n): ChapterReaderView(slug: slug, chapterNumber: n)
|
||||
case .userProfile(let username): UserProfileView(username: username)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,11 +39,3 @@ extension String {
|
||||
return self
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - App-wide layout constants
|
||||
|
||||
enum AppLayout {
|
||||
/// Height of the persistent mini-player bar:
|
||||
/// 12pt vertical padding (top) + 56pt cover height + 12pt vertical padding (bottom) + 12pt horizontal margin.
|
||||
static let miniPlayerBarHeight: CGFloat = 92
|
||||
}
|
||||
|
||||
@@ -273,10 +273,13 @@ struct BookComment: Identifiable, Codable, Hashable {
|
||||
var upvotes: Int
|
||||
var downvotes: Int
|
||||
let created: String
|
||||
let parentId: String // empty = top-level; non-empty = reply
|
||||
var replies: [BookComment]? // populated client-side from the API response
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, slug, username, body, upvotes, downvotes, created
|
||||
case id, slug, username, body, upvotes, downvotes, created, replies
|
||||
case userId = "user_id"
|
||||
case parentId = "parent_id"
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
@@ -289,16 +292,99 @@ struct BookComment: Identifiable, Codable, Hashable {
|
||||
upvotes = try c.decodeIfPresent(Int.self, forKey: .upvotes) ?? 0
|
||||
downvotes = try c.decodeIfPresent(Int.self, forKey: .downvotes) ?? 0
|
||||
created = try c.decodeIfPresent(String.self, forKey: .created) ?? ""
|
||||
parentId = try c.decodeIfPresent(String.self, forKey: .parentId) ?? ""
|
||||
replies = try c.decodeIfPresent([BookComment].self, forKey: .replies)
|
||||
}
|
||||
}
|
||||
|
||||
struct CommentsResponse: Decodable {
|
||||
let comments: [BookComment]
|
||||
let myVotes: [String: String]
|
||||
let avatarUrls: [String: String]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case comments
|
||||
case myVotes = "myVotes"
|
||||
case avatarUrls = "avatarUrls"
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
comments = try c.decode([BookComment].self, forKey: .comments)
|
||||
myVotes = try c.decodeIfPresent([String: String].self, forKey: .myVotes) ?? [:]
|
||||
avatarUrls = try c.decodeIfPresent([String: String].self, forKey: .avatarUrls) ?? [:]
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - User Profile (public)
|
||||
|
||||
struct PublicUserProfile: Decodable, Identifiable {
|
||||
let id: String
|
||||
let username: String
|
||||
let avatarUrl: String?
|
||||
let created: String
|
||||
let followerCount: Int
|
||||
let followingCount: Int
|
||||
let isSubscribed: Bool
|
||||
let isSelf: Bool
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, username, created
|
||||
case avatarUrl = "avatarUrl"
|
||||
case followerCount = "followerCount"
|
||||
case followingCount = "followingCount"
|
||||
case isSubscribed = "isSubscribed"
|
||||
case isSelf = "isSelf"
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try c.decode(String.self, forKey: .id)
|
||||
username = try c.decode(String.self, forKey: .username)
|
||||
avatarUrl = try c.decodeIfPresent(String.self, forKey: .avatarUrl)
|
||||
created = try c.decodeIfPresent(String.self, forKey: .created) ?? ""
|
||||
followerCount = try c.decodeIfPresent(Int.self, forKey: .followerCount) ?? 0
|
||||
followingCount = try c.decodeIfPresent(Int.self, forKey: .followingCount) ?? 0
|
||||
isSubscribed = try c.decodeIfPresent(Bool.self, forKey: .isSubscribed) ?? false
|
||||
isSelf = try c.decodeIfPresent(Bool.self, forKey: .isSelf) ?? false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Subscription Feed
|
||||
|
||||
struct SubscriptionFeedItem: Identifiable, Decodable {
|
||||
var id: String { book.id + readerUsername }
|
||||
let book: Book
|
||||
let readerUsername: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case book
|
||||
case readerUsername = "readerUsername"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Public User Library
|
||||
|
||||
struct PublicLibraryItem: Decodable, Identifiable {
|
||||
var id: String { book.id }
|
||||
let book: Book
|
||||
let lastChapter: Int?
|
||||
let saved: Bool
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case book
|
||||
case lastChapter = "last_chapter"
|
||||
case saved
|
||||
}
|
||||
}
|
||||
|
||||
struct PublicUserLibraryResponse: Decodable {
|
||||
let currentlyReading: [PublicLibraryItem]
|
||||
let library: [PublicLibraryItem]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case currentlyReading = "currently_reading"
|
||||
case library
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -90,6 +90,19 @@ actor APIClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// Like `fetch` but discards the response body — use for endpoints that return 204 No Content.
|
||||
func fetchVoid(_ path: String, method: String = "GET", body: Encodable? = nil) async throws {
|
||||
let req = try makeRequest(path, method: method, body: body)
|
||||
let (data, response) = try await session.data(for: req)
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw APIError.invalidResponse
|
||||
}
|
||||
guard (200..<300).contains(http.statusCode) else {
|
||||
let rawBody = String(data: data, encoding: .utf8) ?? "<non-utf8 data, \(data.count) bytes>"
|
||||
throw APIError.httpError(http.statusCode, rawBody)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Auth
|
||||
|
||||
struct LoginRequest: Encodable {
|
||||
@@ -176,6 +189,10 @@ actor APIClient {
|
||||
let _: EmptyResponse = try await fetch("/api/progress/\(slug)", method: "POST", body: Body(chapter: chapter))
|
||||
}
|
||||
|
||||
func deleteProgress(slug: String) async throws {
|
||||
let _: EmptyResponse = try await fetch("/api/progress/\(slug)", method: "DELETE")
|
||||
}
|
||||
|
||||
func audioTime(slug: String, chapter: Int) async throws -> Double? {
|
||||
struct Response: Decodable { let audioTime: Double?; enum CodingKeys: String, CodingKey { case audioTime = "audio_time" } }
|
||||
let r: Response = try await fetch("/api/progress/audio-time?slug=\(slug)&chapter=\(chapter)")
|
||||
@@ -322,16 +339,43 @@ actor APIClient {
|
||||
return result.avatarURL
|
||||
}
|
||||
|
||||
// MARK: - Comments
|
||||
// MARK: - User Profiles & Subscriptions
|
||||
|
||||
func fetchComments(slug: String) async throws -> CommentsResponse {
|
||||
try await fetch("/api/comments/\(slug)")
|
||||
func fetchUserProfile(username: String) async throws -> PublicUserProfile {
|
||||
try await fetch("/api/users/\(username)")
|
||||
}
|
||||
|
||||
struct PostCommentBody: Encodable { let body: String }
|
||||
@discardableResult
|
||||
func subscribeUser(username: String) async throws -> Bool {
|
||||
struct Response: Decodable { let subscribed: Bool }
|
||||
let r: Response = try await fetch("/api/users/\(username)/subscribe", method: "POST")
|
||||
return r.subscribed
|
||||
}
|
||||
|
||||
func postComment(slug: String, body: String) async throws -> BookComment {
|
||||
try await fetch("/api/comments/\(slug)", method: "POST", body: PostCommentBody(body: body))
|
||||
@discardableResult
|
||||
func unsubscribeUser(username: String) async throws -> Bool {
|
||||
struct Response: Decodable { let subscribed: Bool }
|
||||
let r: Response = try await fetch("/api/users/\(username)/subscribe", method: "DELETE")
|
||||
return r.subscribed
|
||||
}
|
||||
|
||||
func fetchUserLibrary(username: String) async throws -> PublicUserLibraryResponse {
|
||||
try await fetch("/api/users/\(username)/library")
|
||||
}
|
||||
|
||||
// MARK: - Comments
|
||||
|
||||
func fetchComments(slug: String, sort: String = "top") async throws -> CommentsResponse {
|
||||
try await fetch("/api/comments/\(slug)?sort=\(sort)")
|
||||
}
|
||||
|
||||
struct PostCommentBody: Encodable {
|
||||
let body: String
|
||||
let parent_id: String?
|
||||
}
|
||||
|
||||
func postComment(slug: String, body: String, parentId: String? = nil) async throws -> BookComment {
|
||||
try await fetch("/api/comments/\(slug)", method: "POST", body: PostCommentBody(body: body, parent_id: parentId))
|
||||
}
|
||||
|
||||
struct VoteBody: Encodable { let vote: String }
|
||||
@@ -339,7 +383,12 @@ actor APIClient {
|
||||
/// Cast, change, or toggle-off a vote on a comment.
|
||||
/// Returns the updated BookComment (with refreshed upvotes/downvotes counts).
|
||||
func voteComment(commentId: String, vote: String) async throws -> BookComment {
|
||||
try await fetch("/api/comments/\(commentId)/vote", method: "POST", body: VoteBody(vote: vote))
|
||||
try await fetch("/api/comment/\(commentId)/vote", method: "POST", body: VoteBody(vote: vote))
|
||||
}
|
||||
|
||||
/// Delete a comment (and its replies) by ID. Only the owner can delete.
|
||||
func deleteComment(commentId: String) async throws {
|
||||
try await fetchVoid("/api/comment/\(commentId)", method: "DELETE")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -353,11 +402,21 @@ struct HomeDataResponse: Decodable {
|
||||
let continueReading: [ContinueItem]
|
||||
let recentlyUpdated: [Book]
|
||||
let stats: HomeStats
|
||||
let subscriptionFeed: [SubscriptionFeedItem]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case continueReading = "continue_reading"
|
||||
case recentlyUpdated = "recently_updated"
|
||||
case stats
|
||||
case subscriptionFeed = "subscription_feed"
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
continueReading = try c.decodeIfPresent([ContinueItem].self, forKey: .continueReading) ?? []
|
||||
recentlyUpdated = try c.decodeIfPresent([Book].self, forKey: .recentlyUpdated) ?? []
|
||||
stats = try c.decode(HomeStats.self, forKey: .stats)
|
||||
subscriptionFeed = try c.decodeIfPresent([SubscriptionFeedItem].self, forKey: .subscriptionFeed) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>audio</string>
|
||||
<string>fetch</string>
|
||||
<string>processing</string>
|
||||
</array>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict/>
|
||||
|
||||
@@ -5,6 +5,7 @@ final class HomeViewModel: ObservableObject {
|
||||
@Published var continueReading: [ContinueReadingItem] = []
|
||||
@Published var recentlyUpdated: [Book] = []
|
||||
@Published var stats: HomeStats?
|
||||
@Published var subscriptionFeed: [SubscriptionFeedItem] = []
|
||||
@Published var isLoading = false
|
||||
@Published var error: String?
|
||||
|
||||
@@ -18,6 +19,7 @@ final class HomeViewModel: ObservableObject {
|
||||
}
|
||||
recentlyUpdated = data.recentlyUpdated
|
||||
stats = data.stats
|
||||
subscriptionFeed = data.subscriptionFeed
|
||||
} catch {
|
||||
if !(error is CancellationError) {
|
||||
self.error = error.localizedDescription
|
||||
|
||||
87
ios/LibNovel/LibNovel/ViewModels/UserProfileViewModel.swift
Normal file
87
ios/LibNovel/LibNovel/ViewModels/UserProfileViewModel.swift
Normal file
@@ -0,0 +1,87 @@
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class UserProfileViewModel: ObservableObject {
|
||||
let username: String
|
||||
|
||||
@Published var profile: PublicUserProfile?
|
||||
@Published var currentlyReading: [PublicLibraryItem] = []
|
||||
@Published var library: [PublicLibraryItem] = []
|
||||
@Published var isLoading = false
|
||||
@Published var isTogglingSubscribe = false
|
||||
@Published var error: String?
|
||||
|
||||
init(username: String) {
|
||||
self.username = username
|
||||
}
|
||||
|
||||
func load() async {
|
||||
guard !isLoading else { return }
|
||||
isLoading = true
|
||||
error = nil
|
||||
do {
|
||||
async let profileFetch = APIClient.shared.fetchUserProfile(username: username)
|
||||
async let libraryFetch = APIClient.shared.fetchUserLibrary(username: username)
|
||||
let (p, lib) = try await (profileFetch, libraryFetch)
|
||||
profile = p
|
||||
currentlyReading = lib.currentlyReading
|
||||
library = lib.library
|
||||
} catch let apiError as APIError {
|
||||
switch apiError {
|
||||
case .httpError(404, _): error = "User not found."
|
||||
default: error = apiError.localizedDescription
|
||||
}
|
||||
} catch {
|
||||
if !(error is CancellationError) {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func toggleSubscribe() async {
|
||||
guard let p = profile, !p.isSelf, !isTogglingSubscribe else { return }
|
||||
isTogglingSubscribe = true
|
||||
defer { isTogglingSubscribe = false }
|
||||
do {
|
||||
if p.isSubscribed {
|
||||
try await APIClient.shared.unsubscribeUser(username: username)
|
||||
profile = PublicUserProfile(
|
||||
id: p.id, username: p.username, avatarUrl: p.avatarUrl,
|
||||
created: p.created,
|
||||
followerCount: max(0, p.followerCount - 1),
|
||||
followingCount: p.followingCount,
|
||||
isSubscribed: false, isSelf: p.isSelf
|
||||
)
|
||||
} else {
|
||||
try await APIClient.shared.subscribeUser(username: username)
|
||||
profile = PublicUserProfile(
|
||||
id: p.id, username: p.username, avatarUrl: p.avatarUrl,
|
||||
created: p.created,
|
||||
followerCount: p.followerCount + 1,
|
||||
followingCount: p.followingCount,
|
||||
isSubscribed: true, isSelf: p.isSelf
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Convenience memberwise init for PublicUserProfile (used in optimistic updates)
|
||||
|
||||
private extension PublicUserProfile {
|
||||
init(id: String, username: String, avatarUrl: String?, created: String,
|
||||
followerCount: Int, followingCount: Int, isSubscribed: Bool, isSelf: Bool) {
|
||||
// Encode then decode to go through the standard Decodable path without duplicating code
|
||||
var dict: [String: Any] = [
|
||||
"id": id, "username": username, "created": created,
|
||||
"followerCount": followerCount, "followingCount": followingCount,
|
||||
"isSubscribed": isSubscribed, "isSelf": isSelf
|
||||
]
|
||||
if let url = avatarUrl { dict["avatarUrl"] = url }
|
||||
let data = try! JSONSerialization.data(withJSONObject: dict)
|
||||
self = try! JSONDecoder().decode(PublicUserProfile.self, from: data)
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,7 @@ struct BookDetailView: View {
|
||||
@EnvironmentObject var authStore: AuthStore
|
||||
@EnvironmentObject var audioPlayer: AudioPlayerService
|
||||
@State private var summaryExpanded = false
|
||||
@State private var chapterPage = 0
|
||||
private let pageSize = 50
|
||||
@State private var showChapters = false
|
||||
|
||||
init(slug: String) {
|
||||
self.slug = slug
|
||||
@@ -17,7 +16,6 @@ struct BookDetailView: View {
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .top) {
|
||||
// Scroll content
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
if vm.isLoading {
|
||||
@@ -26,7 +24,7 @@ struct BookDetailView: View {
|
||||
heroSection(book: book)
|
||||
metaSection(book: book)
|
||||
Divider().padding(.horizontal)
|
||||
chapterSection(book: book)
|
||||
chaptersRow(book: book)
|
||||
Divider().padding(.horizontal)
|
||||
CommentsView(slug: slug)
|
||||
}
|
||||
@@ -35,9 +33,18 @@ struct BookDetailView: View {
|
||||
.ignoresSafeArea(edges: .top)
|
||||
}
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.appNavigationDestination()
|
||||
.toolbar { bookmarkButton }
|
||||
.task { await vm.load() }
|
||||
.errorAlert($vm.error)
|
||||
.sheet(isPresented: $showChapters) {
|
||||
BookChaptersSheet(
|
||||
slug: slug,
|
||||
chapters: vm.chapters,
|
||||
lastChapter: vm.lastChapter,
|
||||
totalChapters: vm.book?.totalChapters ?? 0
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Hero
|
||||
@@ -61,9 +68,7 @@ struct BookDetailView: View {
|
||||
)
|
||||
)
|
||||
|
||||
// Cover + info column centered
|
||||
VStack(spacing: 16) {
|
||||
// Isolated cover with 3D-style shadow
|
||||
KFImage(URL(string: book.cover))
|
||||
.resizable()
|
||||
.placeholder {
|
||||
@@ -76,7 +81,6 @@ struct BookDetailView: View {
|
||||
.shadow(color: .black.opacity(0.55), radius: 18, x: 0, y: 10)
|
||||
.shadow(color: .black.opacity(0.3), radius: 6, x: 0, y: 3)
|
||||
|
||||
// Title + author
|
||||
VStack(spacing: 6) {
|
||||
Text(book.title)
|
||||
.font(.title3.bold())
|
||||
@@ -90,7 +94,6 @@ struct BookDetailView: View {
|
||||
.foregroundStyle(.white.opacity(0.75))
|
||||
}
|
||||
|
||||
// Genre tags
|
||||
if !book.genres.isEmpty {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(book.genres.prefix(3), id: \.self) { genre in
|
||||
@@ -99,7 +102,6 @@ struct BookDetailView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Status badge
|
||||
if !book.status.isEmpty {
|
||||
StatusBadge(status: book.status)
|
||||
}
|
||||
@@ -110,22 +112,22 @@ struct BookDetailView: View {
|
||||
.frame(minHeight: 320)
|
||||
}
|
||||
|
||||
// MARK: - Meta section (summary + CTAs)
|
||||
// MARK: - Meta section (stats + summary + CTAs)
|
||||
|
||||
@ViewBuilder
|
||||
private func metaSection(book: Book) -> some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
// Quick stats row
|
||||
HStack(spacing: 0) {
|
||||
MetaStat(value: "\(book.totalChapters)", label: "Chapters",
|
||||
icon: "doc.text")
|
||||
MetaStat(value: "\(book.totalChapters)", label: "Chapters", icon: "doc.text")
|
||||
Divider().frame(height: 36)
|
||||
MetaStat(value: book.status.capitalized.isEmpty ? "—" : book.status.capitalized,
|
||||
label: "Status", icon: "flag")
|
||||
MetaStat(
|
||||
value: book.status.capitalized.isEmpty ? "—" : book.status.capitalized,
|
||||
label: "Status", icon: "flag"
|
||||
)
|
||||
if book.ranking > 0 {
|
||||
Divider().frame(height: 36)
|
||||
MetaStat(value: "#\(book.ranking)", label: "Rank",
|
||||
icon: "chart.bar.fill")
|
||||
MetaStat(value: "#\(book.ranking)", label: "Rank", icon: "chart.bar.fill")
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 16)
|
||||
@@ -169,7 +171,7 @@ struct BookDetailView: View {
|
||||
.tint(.amber)
|
||||
|
||||
NavigationLink(value: NavDestination.chapter(slug, 1)) {
|
||||
Label("Ch.1", systemImage: "arrow.counterclockwise")
|
||||
Label("From Ch.1", systemImage: "arrow.counterclockwise")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
@@ -189,78 +191,49 @@ struct BookDetailView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Chapter list
|
||||
// MARK: - Compact chapters row (tap → sheet)
|
||||
|
||||
@ViewBuilder
|
||||
private func chapterSection(book: Book) -> some View {
|
||||
let chapters = vm.chapters
|
||||
let total = chapters.count
|
||||
let start = chapterPage * pageSize
|
||||
let end = min(start + pageSize, total)
|
||||
let pageChapters = Array(chapters[start..<end])
|
||||
private func chaptersRow(book: Book) -> some View {
|
||||
Button {
|
||||
showChapters = true
|
||||
} label: {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "list.number")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.amber)
|
||||
.frame(width: 28)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Chapters")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
if !vm.chapters.isEmpty {
|
||||
let last = vm.lastChapter
|
||||
let total = vm.chapters.count
|
||||
Text(last != nil && last! > 0
|
||||
? "Reading Ch.\(last!) of \(total)"
|
||||
: "\(total) chapter\(total == 1 ? "" : "s")")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else if vm.isLoading {
|
||||
Text("Loading…")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
// Section header
|
||||
HStack {
|
||||
Text("Chapters")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
if total > 0 {
|
||||
Text("\(start + 1)–\(end) of \(total)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 14)
|
||||
|
||||
if vm.isLoading {
|
||||
ProgressView().frame(maxWidth: .infinity).padding()
|
||||
} else {
|
||||
ForEach(pageChapters) { ch in
|
||||
NavigationLink(value: NavDestination.chapter(slug, ch.number)) {
|
||||
ChapterRow(chapter: ch, isCurrent: ch.number == vm.lastChapter,
|
||||
totalChapters: total)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
Divider().padding(.leading)
|
||||
}
|
||||
}
|
||||
|
||||
// Pagination bar
|
||||
if total > pageSize {
|
||||
HStack {
|
||||
Button {
|
||||
withAnimation { chapterPage -= 1 }
|
||||
} label: {
|
||||
Image(systemName: "chevron.left")
|
||||
Text("Previous")
|
||||
}
|
||||
.disabled(chapterPage == 0)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text("Page \(chapterPage + 1) of \((total + pageSize - 1) / pageSize)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
withAnimation { chapterPage += 1 }
|
||||
} label: {
|
||||
Text("Next")
|
||||
Image(systemName: "chevron.right")
|
||||
}
|
||||
.disabled(end >= total)
|
||||
}
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.amber)
|
||||
.padding()
|
||||
}
|
||||
|
||||
Color.clear.frame(height: 32)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
// MARK: - Bookmark toolbar
|
||||
@@ -278,20 +251,149 @@ struct BookDetailView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Chapter row
|
||||
// MARK: - Chapters list sheet
|
||||
|
||||
struct BookChaptersSheet: View {
|
||||
let slug: String
|
||||
let chapters: [ChapterIndex]
|
||||
let lastChapter: Int?
|
||||
let totalChapters: Int
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var searchText = ""
|
||||
@State private var scrollToCurrentOnAppear = true
|
||||
|
||||
private var filtered: [ChapterIndex] {
|
||||
guard !searchText.isEmpty else { return chapters }
|
||||
let q = searchText.lowercased()
|
||||
return chapters.filter {
|
||||
"chapter \($0.number)".contains(q) ||
|
||||
$0.title.lowercased().contains(q)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(spacing: 0) {
|
||||
// Search bar
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "magnifyingglass").foregroundStyle(.secondary)
|
||||
TextField("Search chapters…", text: $searchText)
|
||||
.autocorrectionDisabled()
|
||||
if !searchText.isEmpty {
|
||||
Button { searchText = "" } label: {
|
||||
Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.background(Color(.systemGray6), in: RoundedRectangle(cornerRadius: 10))
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 10)
|
||||
|
||||
Divider()
|
||||
|
||||
// Jump-to-current banner (shown when user has progress and not searching)
|
||||
if let last = lastChapter, last > 0, searchText.isEmpty {
|
||||
Button {
|
||||
scrollToCurrentOnAppear = true
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "arrow.down.circle.fill")
|
||||
.foregroundStyle(.amber)
|
||||
Text("Jump to Ch.\(last)")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.amber)
|
||||
Spacer()
|
||||
let pct = totalChapters > 0
|
||||
? Int(Double(last) / Double(totalChapters) * 100)
|
||||
: 0
|
||||
Text("\(pct)% read")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Divider()
|
||||
}
|
||||
|
||||
if chapters.isEmpty {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else if filtered.isEmpty {
|
||||
VStack(spacing: 10) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.font(.largeTitle)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("No chapters match \"\(searchText)\"")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else {
|
||||
ScrollViewReader { proxy in
|
||||
List {
|
||||
ForEach(filtered) { ch in
|
||||
NavigationLink(value: NavDestination.chapter(slug, ch.number)) {
|
||||
ChapterRow(
|
||||
chapter: ch,
|
||||
isCurrent: ch.number == lastChapter,
|
||||
totalChapters: chapters.count
|
||||
)
|
||||
}
|
||||
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 16))
|
||||
.id(ch.number)
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.appNavigationDestination()
|
||||
.onAppear {
|
||||
if scrollToCurrentOnAppear, let last = lastChapter, last > 0 {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
|
||||
withAnimation {
|
||||
proxy.scrollTo(last, anchor: .center)
|
||||
}
|
||||
}
|
||||
scrollToCurrentOnAppear = false
|
||||
}
|
||||
}
|
||||
.onChange(of: scrollToCurrentOnAppear) { _, jump in
|
||||
if jump, let last = lastChapter, last > 0 {
|
||||
withAnimation {
|
||||
proxy.scrollTo(last, anchor: .center)
|
||||
}
|
||||
scrollToCurrentOnAppear = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Chapters")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Done") { dismiss() }
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
}
|
||||
}
|
||||
.presentationDetents([.large])
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Chapter row (reused by sheet)
|
||||
|
||||
private struct ChapterRow: View {
|
||||
let chapter: ChapterIndex
|
||||
let isCurrent: Bool
|
||||
let totalChapters: Int
|
||||
|
||||
private var progressFraction: Double {
|
||||
guard totalChapters > 1 else { return 0 }
|
||||
return Double(chapter.number) / Double(totalChapters)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
HStack(spacing: 12) {
|
||||
// Number badge
|
||||
ZStack {
|
||||
Circle()
|
||||
@@ -299,6 +401,7 @@ private struct ChapterRow: View {
|
||||
Text("\(chapter.number)")
|
||||
.font(.caption2.bold().monospacedDigit())
|
||||
.foregroundStyle(isCurrent ? .black : .secondary)
|
||||
.minimumScaleFactor(0.6)
|
||||
}
|
||||
.frame(width: 32, height: 32)
|
||||
|
||||
@@ -316,11 +419,7 @@ private struct ChapterRow: View {
|
||||
.fontWeight(isCurrent ? .semibold : .regular)
|
||||
.foregroundStyle(isCurrent ? .amber : .primary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
if !chapter.dateLabel.isEmpty {
|
||||
Text(chapter.dateLabel)
|
||||
.font(.caption2)
|
||||
@@ -328,6 +427,8 @@ private struct ChapterRow: View {
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
|
||||
@@ -8,6 +8,7 @@ class CommentsViewModel: ObservableObject {
|
||||
|
||||
@Published var comments: [BookComment] = []
|
||||
@Published var myVotes: [String: String] = [:] // commentId → "up" | "down"
|
||||
@Published var avatarUrls: [String: String] = [:] // userId → presigned URL
|
||||
@Published var isLoading = true
|
||||
@Published var error: String?
|
||||
|
||||
@@ -15,7 +16,16 @@ class CommentsViewModel: ObservableObject {
|
||||
@Published var isPosting = false
|
||||
@Published var postError: String?
|
||||
|
||||
@Published var sort: CommentSortOrder = .top
|
||||
|
||||
// Reply state
|
||||
@Published var replyingToId: String? = nil
|
||||
@Published var replyBody = ""
|
||||
@Published var isPostingReply = false
|
||||
@Published var replyError: String?
|
||||
|
||||
private var votingIds: Set<String> = []
|
||||
private var deletingIds: Set<String> = []
|
||||
|
||||
init(slug: String) {
|
||||
self.slug = slug
|
||||
@@ -25,9 +35,10 @@ class CommentsViewModel: ObservableObject {
|
||||
isLoading = true
|
||||
error = nil
|
||||
do {
|
||||
let response = try await APIClient.shared.fetchComments(slug: slug)
|
||||
let response = try await APIClient.shared.fetchComments(slug: slug, sort: sort.rawValue)
|
||||
comments = response.comments
|
||||
myVotes = response.myVotes
|
||||
avatarUrls = response.avatarUrls
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
@@ -44,7 +55,8 @@ class CommentsViewModel: ObservableObject {
|
||||
isPosting = true
|
||||
postError = nil
|
||||
do {
|
||||
let created = try await APIClient.shared.postComment(slug: slug, body: text)
|
||||
var created = try await APIClient.shared.postComment(slug: slug, body: text)
|
||||
created.replies = []
|
||||
comments.insert(created, at: 0)
|
||||
newBody = ""
|
||||
} catch let apiError as APIError {
|
||||
@@ -58,17 +70,100 @@ class CommentsViewModel: ObservableObject {
|
||||
isPosting = false
|
||||
}
|
||||
|
||||
func vote(commentId: String, vote: String) async {
|
||||
func postReply(parentId: String) async {
|
||||
let text = replyBody.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty, !isPostingReply else { return }
|
||||
if text.count > 2000 {
|
||||
replyError = "Reply too long (max 2000 characters)."
|
||||
return
|
||||
}
|
||||
isPostingReply = true
|
||||
replyError = nil
|
||||
do {
|
||||
let created = try await APIClient.shared.postComment(slug: slug, body: text, parentId: parentId)
|
||||
if let idx = comments.firstIndex(where: { $0.id == parentId }) {
|
||||
var parent = comments[idx]
|
||||
var replies = parent.replies ?? []
|
||||
replies.append(created)
|
||||
parent.replies = replies
|
||||
comments[idx] = parent
|
||||
}
|
||||
replyBody = ""
|
||||
replyingToId = nil
|
||||
} catch let apiError as APIError {
|
||||
switch apiError {
|
||||
case .httpError(401, _): replyError = "You must be logged in to reply."
|
||||
default: replyError = apiError.localizedDescription
|
||||
}
|
||||
} catch {
|
||||
replyError = error.localizedDescription
|
||||
}
|
||||
isPostingReply = false
|
||||
}
|
||||
|
||||
func deleteComment(commentId: String, parentId: String? = nil) async {
|
||||
guard !deletingIds.contains(commentId) else { return }
|
||||
deletingIds.insert(commentId)
|
||||
|
||||
// Optimistic removal — update the UI immediately before the network call
|
||||
var removedComment: BookComment?
|
||||
var removedAtIndex: Int?
|
||||
if let parentId {
|
||||
if let idx = comments.firstIndex(where: { $0.id == parentId }) {
|
||||
var parent = comments[idx]
|
||||
removedComment = parent.replies?.first(where: { $0.id == commentId })
|
||||
removedAtIndex = idx
|
||||
parent.replies = (parent.replies ?? []).filter { $0.id != commentId }
|
||||
comments[idx] = parent
|
||||
}
|
||||
} else {
|
||||
removedAtIndex = comments.firstIndex(where: { $0.id == commentId })
|
||||
removedComment = removedAtIndex.map { comments[$0] }
|
||||
comments.removeAll { $0.id == commentId }
|
||||
}
|
||||
|
||||
do {
|
||||
try await APIClient.shared.deleteComment(commentId: commentId)
|
||||
} catch {
|
||||
// Revert the optimistic removal on failure
|
||||
if let removed = removedComment {
|
||||
if let parentId, let idx = removedAtIndex {
|
||||
var parent = comments[idx]
|
||||
var replies = parent.replies ?? []
|
||||
replies.append(removed)
|
||||
replies.sort { $0.created < $1.created }
|
||||
parent.replies = replies
|
||||
comments[idx] = parent
|
||||
} else if let idx = removedAtIndex {
|
||||
comments.insert(removed, at: min(idx, comments.count))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
deletingIds.remove(commentId)
|
||||
}
|
||||
|
||||
func vote(commentId: String, vote: String, parentId: String? = nil) async {
|
||||
guard !votingIds.contains(commentId) else { return }
|
||||
votingIds.insert(commentId)
|
||||
defer { votingIds.remove(commentId) }
|
||||
do {
|
||||
let updated = try await APIClient.shared.voteComment(commentId: commentId, vote: vote)
|
||||
// Update the comment in the list
|
||||
if let idx = comments.firstIndex(where: { $0.id == commentId }) {
|
||||
comments[idx] = updated
|
||||
if let parentId {
|
||||
if let idx = comments.firstIndex(where: { $0.id == parentId }) {
|
||||
var parent = comments[idx]
|
||||
if let rIdx = parent.replies?.firstIndex(where: { $0.id == commentId }) {
|
||||
parent.replies![rIdx] = updated
|
||||
}
|
||||
comments[idx] = parent
|
||||
}
|
||||
} else {
|
||||
if let idx = comments.firstIndex(where: { $0.id == commentId }) {
|
||||
var c = updated
|
||||
c.replies = comments[idx].replies
|
||||
comments[idx] = c
|
||||
}
|
||||
}
|
||||
// Toggle myVotes
|
||||
let prev = myVotes[commentId]
|
||||
if prev == vote {
|
||||
myVotes.removeValue(forKey: commentId)
|
||||
@@ -76,12 +171,29 @@ class CommentsViewModel: ObservableObject {
|
||||
myVotes[commentId] = vote
|
||||
}
|
||||
} catch {
|
||||
// Silently ignore vote errors — don't disrupt the UI
|
||||
// Silently ignore vote errors
|
||||
}
|
||||
}
|
||||
|
||||
func isVoting(_ commentId: String) -> Bool {
|
||||
votingIds.contains(commentId)
|
||||
func isVoting(_ commentId: String) -> Bool { votingIds.contains(commentId) }
|
||||
func isDeleting(_ commentId: String) -> Bool { deletingIds.contains(commentId) }
|
||||
|
||||
func setSort(_ newSort: CommentSortOrder) {
|
||||
guard newSort != sort else { return }
|
||||
sort = newSort
|
||||
Task { await load() }
|
||||
}
|
||||
}
|
||||
|
||||
enum CommentSortOrder: String, CaseIterable {
|
||||
case top = "top"
|
||||
case new = "new"
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .top: return "Top"
|
||||
case .new: return "New"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,16 +209,30 @@ struct CommentsView: View {
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
// Section header
|
||||
// Section header + sort picker
|
||||
HStack {
|
||||
Text("Comments")
|
||||
.font(.headline)
|
||||
if !vm.isLoading && !vm.comments.isEmpty {
|
||||
Text("(\(vm.comments.count))")
|
||||
let total = vm.comments.reduce(0) { $0 + 1 + ($1.replies?.count ?? 0) }
|
||||
if !vm.isLoading && total > 0 {
|
||||
Text("(\(total))")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
// Sort picker
|
||||
if !vm.isLoading && !vm.comments.isEmpty {
|
||||
Picker("Sort", selection: Binding(
|
||||
get: { vm.sort },
|
||||
set: { vm.setSort($0) }
|
||||
)) {
|
||||
ForEach(CommentSortOrder.allCases, id: \.self) { s in
|
||||
Text(s.label).tag(s)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.frame(width: 120)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 14)
|
||||
@@ -135,13 +261,7 @@ struct CommentsView: View {
|
||||
.padding()
|
||||
} else {
|
||||
ForEach(vm.comments) { comment in
|
||||
CommentRow(
|
||||
comment: comment,
|
||||
myVote: vm.myVotes[comment.id],
|
||||
isVoting: vm.isVoting(comment.id)
|
||||
) { vote in
|
||||
Task { await vm.vote(commentId: comment.id, vote: vote) }
|
||||
}
|
||||
commentThread(comment: comment)
|
||||
Divider().padding(.leading, 16)
|
||||
}
|
||||
}
|
||||
@@ -151,61 +271,198 @@ struct CommentsView: View {
|
||||
.task { await vm.load() }
|
||||
}
|
||||
|
||||
// MARK: - Post form
|
||||
// MARK: - Comment thread (top-level + replies)
|
||||
|
||||
@ViewBuilder
|
||||
private var postForm: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
private func commentThread(comment: BookComment) -> some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
CommentRow(
|
||||
comment: comment,
|
||||
myVote: vm.myVotes[comment.id],
|
||||
isVoting: vm.isVoting(comment.id),
|
||||
isDeleting: vm.isDeleting(comment.id),
|
||||
isOwner: authStore.user?.id == comment.userId,
|
||||
isLoggedIn: authStore.isAuthenticated,
|
||||
isReplyingTo: vm.replyingToId == comment.id,
|
||||
avatarUrl: vm.avatarUrls[comment.userId],
|
||||
onVote: { v in Task { await vm.vote(commentId: comment.id, vote: v) } },
|
||||
onDelete: { Task { await vm.deleteComment(commentId: comment.id) } },
|
||||
onReply: {
|
||||
if vm.replyingToId == comment.id {
|
||||
vm.replyingToId = nil
|
||||
vm.replyBody = ""
|
||||
vm.replyError = nil
|
||||
} else {
|
||||
vm.replyingToId = comment.id
|
||||
vm.replyBody = ""
|
||||
vm.replyError = nil
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Inline reply form
|
||||
if vm.replyingToId == comment.id {
|
||||
replyForm(parentId: comment.id)
|
||||
.padding(.leading, 32)
|
||||
.padding(.trailing, 16)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
|
||||
// Replies
|
||||
if let replies = comment.replies, !replies.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
ForEach(replies) { reply in
|
||||
CommentRow(
|
||||
comment: reply,
|
||||
myVote: vm.myVotes[reply.id],
|
||||
isVoting: vm.isVoting(reply.id),
|
||||
isDeleting: vm.isDeleting(reply.id),
|
||||
isOwner: authStore.user?.id == reply.userId,
|
||||
isLoggedIn: authStore.isAuthenticated,
|
||||
isReplyingTo: false,
|
||||
isReply: true,
|
||||
avatarUrl: vm.avatarUrls[reply.userId],
|
||||
onVote: { v in Task { await vm.vote(commentId: reply.id, vote: v, parentId: comment.id) } },
|
||||
onDelete: { Task { await vm.deleteComment(commentId: reply.id, parentId: comment.id) } },
|
||||
onReply: nil
|
||||
)
|
||||
if reply.id != replies.last?.id {
|
||||
Divider().padding(.leading, 48)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.leading, 24)
|
||||
.overlay(alignment: .leading) {
|
||||
Rectangle()
|
||||
.fill(Color(.systemGray4))
|
||||
.frame(width: 2)
|
||||
.padding(.leading, 16)
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Reply form
|
||||
|
||||
@ViewBuilder
|
||||
private func replyForm(parentId: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
ZStack(alignment: .topLeading) {
|
||||
if vm.newBody.isEmpty {
|
||||
Text("Write a comment…")
|
||||
.font(.subheadline)
|
||||
if vm.replyBody.isEmpty {
|
||||
Text("Write a reply…")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
.padding(.top, 8)
|
||||
.padding(.top, 6)
|
||||
.padding(.leading, 4)
|
||||
}
|
||||
TextEditor(text: $vm.newBody)
|
||||
.font(.subheadline)
|
||||
.frame(minHeight: 72, maxHeight: 160)
|
||||
TextEditor(text: $vm.replyBody)
|
||||
.font(.caption)
|
||||
.frame(minHeight: 56, maxHeight: 120)
|
||||
.scrollContentBackground(.hidden)
|
||||
}
|
||||
.padding(10)
|
||||
.background(Color(.systemGray6), in: RoundedRectangle(cornerRadius: 10))
|
||||
.padding(8)
|
||||
.background(Color(.systemGray6), in: RoundedRectangle(cornerRadius: 8))
|
||||
|
||||
HStack {
|
||||
let count = vm.newBody.count
|
||||
let count = vm.replyBody.count
|
||||
Text("\(count)/2000")
|
||||
.font(.caption2)
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(count > 2000 ? .red : .tertiary)
|
||||
.foregroundStyle(count > 2000 ? Color.red : Color.secondary)
|
||||
|
||||
Spacer()
|
||||
|
||||
if let err = vm.postError {
|
||||
Text(err)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.red)
|
||||
.lineLimit(1)
|
||||
if let err = vm.replyError {
|
||||
Text(err).font(.caption2).foregroundStyle(.red).lineLimit(1)
|
||||
}
|
||||
|
||||
Button("Cancel") {
|
||||
vm.replyingToId = nil
|
||||
vm.replyBody = ""
|
||||
vm.replyError = nil
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Button {
|
||||
Task { await vm.postComment() }
|
||||
Task { await vm.postReply(parentId: parentId) }
|
||||
} label: {
|
||||
if vm.isPosting {
|
||||
ProgressView().controlSize(.small)
|
||||
if vm.isPostingReply {
|
||||
ProgressView().controlSize(.mini)
|
||||
} else {
|
||||
Text("Post")
|
||||
.fontWeight(.semibold)
|
||||
Text("Reply").fontWeight(.semibold).font(.caption)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.amber)
|
||||
.controlSize(.small)
|
||||
.disabled(vm.isPosting || vm.newBody.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || vm.newBody.count > 2000)
|
||||
.controlSize(.mini)
|
||||
.disabled(vm.isPostingReply || vm.replyBody.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || vm.replyBody.count > 2000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Post form
|
||||
|
||||
@ViewBuilder
|
||||
private var postForm: some View {
|
||||
if authStore.isAuthenticated {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ZStack(alignment: .topLeading) {
|
||||
if vm.newBody.isEmpty {
|
||||
Text("Write a comment…")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.tertiary)
|
||||
.padding(.top, 8)
|
||||
.padding(.leading, 4)
|
||||
}
|
||||
TextEditor(text: $vm.newBody)
|
||||
.font(.subheadline)
|
||||
.frame(minHeight: 72, maxHeight: 160)
|
||||
.scrollContentBackground(.hidden)
|
||||
}
|
||||
.padding(10)
|
||||
.background(Color(.systemGray6), in: RoundedRectangle(cornerRadius: 10))
|
||||
|
||||
HStack {
|
||||
let count = vm.newBody.count
|
||||
Text("\(count)/2000")
|
||||
.font(.caption2)
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(count > 2000 ? Color.red : Color.secondary)
|
||||
|
||||
Spacer()
|
||||
|
||||
if let err = vm.postError {
|
||||
Text(err)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.red)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
Button {
|
||||
Task { await vm.postComment() }
|
||||
} label: {
|
||||
if vm.isPosting {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Text("Post")
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.amber)
|
||||
.controlSize(.small)
|
||||
.disabled(vm.isPosting || vm.newBody.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || vm.newBody.count > 2000)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text("Log in to leave a comment.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Loading skeleton
|
||||
|
||||
@ViewBuilder
|
||||
@@ -238,14 +495,28 @@ private struct CommentRow: View {
|
||||
let comment: BookComment
|
||||
let myVote: String?
|
||||
let isVoting: Bool
|
||||
let isDeleting: Bool
|
||||
let isOwner: Bool
|
||||
let isLoggedIn: Bool
|
||||
let isReplyingTo: Bool
|
||||
var isReply: Bool = false
|
||||
var avatarUrl: String? = nil
|
||||
let onVote: (String) -> Void
|
||||
let onDelete: () -> Void
|
||||
let onReply: (() -> Void)?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
// Username + date
|
||||
HStack(spacing: 6) {
|
||||
Text(comment.username.isEmpty ? "Anonymous" : comment.username)
|
||||
.font(.subheadline.weight(.medium))
|
||||
// Avatar + Username + date
|
||||
HStack(spacing: 8) {
|
||||
avatarView
|
||||
NavigationLink(value: NavDestination.userProfile(comment.username.isEmpty ? "" : comment.username)) {
|
||||
Text(comment.username.isEmpty ? "Anonymous" : comment.username)
|
||||
.font(isReply ? .caption.weight(.medium) : .subheadline.weight(.medium))
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(comment.username.isEmpty)
|
||||
Text("·")
|
||||
.foregroundStyle(.tertiary)
|
||||
Text(formattedDate(comment.created))
|
||||
@@ -256,16 +527,14 @@ private struct CommentRow: View {
|
||||
|
||||
// Body
|
||||
Text(comment.body)
|
||||
.font(.subheadline)
|
||||
.font(isReply ? .caption : .subheadline)
|
||||
.foregroundStyle(.primary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
// Vote row
|
||||
HStack(spacing: 16) {
|
||||
// Actions
|
||||
HStack(spacing: 14) {
|
||||
// Upvote
|
||||
Button {
|
||||
onVote("up")
|
||||
} label: {
|
||||
Button { onVote("up") } label: {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: myVote == "up" ? "hand.thumbsup.fill" : "hand.thumbsup")
|
||||
.font(.caption)
|
||||
@@ -277,9 +546,7 @@ private struct CommentRow: View {
|
||||
.disabled(isVoting)
|
||||
|
||||
// Downvote
|
||||
Button {
|
||||
onVote("down")
|
||||
} label: {
|
||||
Button { onVote("down") } label: {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: myVote == "down" ? "hand.thumbsdown.fill" : "hand.thumbsdown")
|
||||
.font(.caption)
|
||||
@@ -290,12 +557,68 @@ private struct CommentRow: View {
|
||||
}
|
||||
.disabled(isVoting)
|
||||
|
||||
// Reply button (top-level only, logged in)
|
||||
if let onReply, isLoggedIn {
|
||||
Button { onReply() } label: {
|
||||
HStack(spacing: 3) {
|
||||
Image(systemName: "arrowshape.turn.up.left")
|
||||
.font(.caption)
|
||||
Text("Reply")
|
||||
.font(.caption)
|
||||
}
|
||||
.foregroundStyle(isReplyingTo ? Color.amber : .secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Delete (owner only)
|
||||
if isOwner {
|
||||
Button(role: .destructive) { onDelete() } label: {
|
||||
Image(systemName: "trash")
|
||||
.font(.caption)
|
||||
}
|
||||
.disabled(isDeleting)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.opacity(isVoting ? 0.6 : 1)
|
||||
.opacity(isDeleting ? 0.5 : 1)
|
||||
.animation(.easeInOut(duration: 0.15), value: isDeleting)
|
||||
}
|
||||
|
||||
private var avatarSize: CGFloat { isReply ? 20 : 24 }
|
||||
|
||||
@ViewBuilder
|
||||
private var avatarView: some View {
|
||||
if let url = avatarUrl, let imageUrl = URL(string: url) {
|
||||
AsyncImage(url: imageUrl) { phase in
|
||||
switch phase {
|
||||
case .success(let image):
|
||||
image.resizable().scaledToFill()
|
||||
default:
|
||||
initialsView
|
||||
}
|
||||
}
|
||||
.frame(width: avatarSize, height: avatarSize)
|
||||
.clipShape(Circle())
|
||||
} else {
|
||||
initialsView
|
||||
}
|
||||
}
|
||||
|
||||
private var initialsView: some View {
|
||||
let name = comment.username.isEmpty ? "?" : comment.username
|
||||
let letters = String(name.prefix(2)).uppercased()
|
||||
return ZStack {
|
||||
Circle()
|
||||
.fill(Color(.systemGray4))
|
||||
.frame(width: avatarSize, height: avatarSize)
|
||||
Text(letters)
|
||||
.font(.system(size: avatarSize * 0.42, weight: .semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private func formattedDate(_ iso: String) -> String {
|
||||
|
||||
@@ -92,6 +92,11 @@ struct BrowseView: View {
|
||||
}
|
||||
.navigationTitle("Discover")
|
||||
.appNavigationDestination()
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
AvatarToolbarButton()
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showFilters) {
|
||||
BrowseFiltersView(vm: vm)
|
||||
}
|
||||
|
||||
@@ -68,12 +68,13 @@ struct ChapterReaderView: View {
|
||||
|
||||
}
|
||||
.navigationBarHidden(true) // we draw our own chrome
|
||||
.toolbar(.hidden, for: .tabBar) // hide tab bar in reader (Apple Books style)
|
||||
.ignoresSafeArea(edges: .top)
|
||||
.preferredColorScheme(readerSettings.settings.theme.colorScheme)
|
||||
.task(id: currentChapter) { await vm.load() }
|
||||
.sheet(isPresented: $showSettingsPanel) {
|
||||
ReaderSettingsPanel(store: readerSettings, isPresented: $showSettingsPanel)
|
||||
.presentationDetents([.height(380)])
|
||||
.presentationDetents([.height(480)])
|
||||
.presentationDragIndicator(.visible)
|
||||
.presentationCornerRadius(20)
|
||||
}
|
||||
@@ -125,30 +126,7 @@ struct ChapterReaderView: View {
|
||||
.blur(radius: 0)
|
||||
|
||||
HStack(spacing: 0) {
|
||||
// Back button
|
||||
Button {
|
||||
dismiss()
|
||||
} label: {
|
||||
Image(systemName: "chevron.left")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(readerSettings.settings.theme.textColor.opacity(0.85))
|
||||
.frame(width: 44, height: 44)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Chapter title (truncated)
|
||||
if let content = vm.content {
|
||||
Text(content.chapter.title.strippingTrailingDate())
|
||||
.font(.subheadline.weight(.medium))
|
||||
.foregroundStyle(readerSettings.settings.theme.textColor.opacity(0.7))
|
||||
.lineLimit(1)
|
||||
.frame(maxWidth: 220)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Aa settings button
|
||||
// Left: Aa settings button
|
||||
Button {
|
||||
withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) {
|
||||
showSettingsPanel.toggle()
|
||||
@@ -160,6 +138,19 @@ struct ChapterReaderView: View {
|
||||
.frame(width: 44, height: 44)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Center: Chapter title (truncated)
|
||||
if let content = vm.content {
|
||||
Text(content.chapter.title.strippingTrailingDate())
|
||||
.font(.subheadline.weight(.medium))
|
||||
.foregroundStyle(readerSettings.settings.theme.textColor.opacity(0.7))
|
||||
.lineLimit(1)
|
||||
.frame(maxWidth: 200)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// ToC button
|
||||
Button {
|
||||
showToCSheet = true
|
||||
@@ -169,6 +160,16 @@ struct ChapterReaderView: View {
|
||||
.foregroundStyle(readerSettings.settings.theme.textColor.opacity(0.85))
|
||||
.frame(width: 44, height: 44)
|
||||
}
|
||||
|
||||
// X dismiss button (rightmost)
|
||||
Button {
|
||||
dismiss()
|
||||
} label: {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(readerSettings.settings.theme.textColor.opacity(0.85))
|
||||
.frame(width: 44, height: 44)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
@@ -268,12 +269,7 @@ struct ChapterReaderView: View {
|
||||
}
|
||||
.frame(height: 60)
|
||||
|
||||
// Mini player spacer if active
|
||||
if audioPlayer.isActive {
|
||||
Color.clear.frame(height: AppLayout.miniPlayerBarHeight)
|
||||
}
|
||||
|
||||
// Home indicator area
|
||||
// Home indicator area (no mini player spacer — tab bar and mini player are hidden in reader)
|
||||
Color.clear.frame(height: safeAreaBottom)
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.2), value: chromeVisible)
|
||||
@@ -806,6 +802,7 @@ struct ReaderSettingsPanel: View {
|
||||
@Binding var isPresented: Bool
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
// Font size row
|
||||
|
||||
@@ -907,6 +904,7 @@ struct ReaderSettingsPanel: View {
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
}
|
||||
|
||||
private func adjustFontSize(_ delta: CGFloat) {
|
||||
|
||||
@@ -9,25 +9,28 @@ struct HomeView: View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
|
||||
// Large hero continue card (most recent in-progress book)
|
||||
if let hero = vm.continueReading.first {
|
||||
HeroContinueCard(item: hero)
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom, 28)
|
||||
}
|
||||
|
||||
// Continue reading shelf (remaining items after the hero)
|
||||
let shelf = vm.continueReading.dropFirst()
|
||||
if !shelf.isEmpty {
|
||||
// Continue reading — all in-progress books as a horizontal shelf (Apple Books style)
|
||||
if !vm.continueReading.isEmpty {
|
||||
ShelfHeader(title: "Continue Reading")
|
||||
.padding(.top, 8)
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(alignment: .top, spacing: 14) {
|
||||
ForEach(Array(shelf)) { item in
|
||||
NavigationLink(value: NavDestination.book(item.book.slug)) {
|
||||
HStack(alignment: .top, spacing: 16) {
|
||||
ForEach(vm.continueReading) { item in
|
||||
NavigationLink(value: NavDestination.chapter(item.book.slug, item.chapter)) {
|
||||
ContinueReadingCard(item: item)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.contextMenu {
|
||||
ContinueReadingContextMenu(
|
||||
item: item,
|
||||
onMarkFinished: {
|
||||
Task { await markAsFinished(item.book) }
|
||||
},
|
||||
onRemove: {
|
||||
Task { await removeFromLibrary(item.book.slug) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
@@ -53,6 +56,34 @@ struct HomeView: View {
|
||||
ShelfBookCard(book: book)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.contextMenu {
|
||||
ShareLink(item: shareURL(for: book)) {
|
||||
Label("Share", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
.padding(.bottom, 28)
|
||||
}
|
||||
|
||||
// Subscription feed shelf
|
||||
if !vm.subscriptionFeed.isEmpty {
|
||||
ShelfHeader(title: "From People You Follow")
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(alignment: .top, spacing: 14) {
|
||||
ForEach(vm.subscriptionFeed) { item in
|
||||
NavigationLink(value: NavDestination.book(item.book.slug)) {
|
||||
SubscriptionFeedCard(item: item)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.contextMenu {
|
||||
ShareLink(item: shareURL(for: item.book)) {
|
||||
Label("Share", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
@@ -62,7 +93,7 @@ struct HomeView: View {
|
||||
}
|
||||
|
||||
// Empty state
|
||||
if vm.continueReading.isEmpty && vm.recentlyUpdated.isEmpty && !vm.isLoading {
|
||||
if vm.continueReading.isEmpty && vm.recentlyUpdated.isEmpty && vm.subscriptionFeed.isEmpty && !vm.isLoading {
|
||||
EmptyStateView(
|
||||
icon: "books.vertical",
|
||||
title: "Your library is empty",
|
||||
@@ -86,97 +117,36 @@ struct HomeView: View {
|
||||
.refreshable { await vm.load() }
|
||||
.task { await vm.load() }
|
||||
.errorAlert($vm.error)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
AvatarToolbarButton()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Hero card (full-width, Apple Books "Now Playing" style)
|
||||
|
||||
private struct HeroContinueCard: View {
|
||||
let item: ContinueReadingItem
|
||||
|
||||
var body: some View {
|
||||
NavigationLink(value: NavDestination.chapter(item.book.slug, item.chapter)) {
|
||||
ZStack(alignment: .bottomLeading) {
|
||||
// Blurred background
|
||||
AsyncCoverImage(url: item.book.cover, isBackground: true)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 220)
|
||||
.blur(radius: 22)
|
||||
.clipped()
|
||||
// Depth gradient: subtle amber tint at top, deep shadow at bottom
|
||||
.overlay(
|
||||
LinearGradient(
|
||||
stops: [
|
||||
.init(color: Color(red: 0.18, green: 0.12, blue: 0.02).opacity(0.55), location: 0),
|
||||
.init(color: .black.opacity(0.15), location: 0.35),
|
||||
.init(color: .black.opacity(0.78), location: 1)
|
||||
],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
)
|
||||
|
||||
// Content: cover on left, info stacked on right
|
||||
HStack(alignment: .bottom, spacing: 14) {
|
||||
AsyncCoverImage(url: item.book.cover)
|
||||
.frame(width: 96, height: 138)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
.shadow(color: .black.opacity(0.55), radius: 12, y: 6)
|
||||
.bookCoverZoomSource(slug: item.book.slug)
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
// Progress indicator
|
||||
if item.book.totalChapters > 0 {
|
||||
let pct = min(1.0, Double(item.chapter) / Double(item.book.totalChapters))
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .leading) {
|
||||
Capsule().fill(Color.white.opacity(0.2))
|
||||
Capsule().fill(Color.amber.opacity(0.85))
|
||||
.frame(width: geo.size.width * pct)
|
||||
}
|
||||
}
|
||||
.frame(height: 3)
|
||||
.frame(maxWidth: 140)
|
||||
|
||||
Text("\(Int(pct * 100))% complete")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.white.opacity(0.55))
|
||||
}
|
||||
|
||||
Text(item.book.title)
|
||||
.font(.headline)
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(2)
|
||||
|
||||
Text(item.book.author)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.white.opacity(0.65))
|
||||
.lineLimit(1)
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "play.fill")
|
||||
.font(.caption.bold())
|
||||
Text("Continue Ch.\(item.chapter)")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
}
|
||||
.foregroundStyle(.black.opacity(0.85))
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 9)
|
||||
.background(Capsule().fill(Color.amber))
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.bottom, 18)
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
.shadow(color: .black.opacity(0.25), radius: 14, y: 5)
|
||||
|
||||
private func markAsFinished(_ book: Book) async {
|
||||
do {
|
||||
try await APIClient.shared.setProgress(slug: book.slug, chapter: book.totalChapters)
|
||||
await vm.load() // Refresh home
|
||||
} catch {
|
||||
vm.error = error.localizedDescription
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private func removeFromLibrary(_ slug: String) async {
|
||||
do {
|
||||
try await APIClient.shared.deleteProgress(slug: slug)
|
||||
await vm.load() // Refresh home
|
||||
} catch {
|
||||
vm.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
private func shareURL(for book: Book) -> URL {
|
||||
let baseURL = Bundle.main.object(forInfoDictionaryKey: "LIBNOVEL_BASE_URL") as? String
|
||||
?? "https://v2.libnovel.kalekber.cc"
|
||||
return URL(string: "\(baseURL)/books/\(book.slug)")!
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,7 +163,7 @@ private struct ShelfHeader: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Horizontal shelf: continue reading card
|
||||
// MARK: - Horizontal shelf: continue reading card (Apple Books style)
|
||||
|
||||
private struct ContinueReadingCard: View {
|
||||
let item: ContinueReadingItem
|
||||
@@ -204,34 +174,54 @@ private struct ContinueReadingCard: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
// Cover
|
||||
ZStack(alignment: .bottomLeading) {
|
||||
AsyncCoverImage(url: item.book.cover)
|
||||
.frame(width: 110, height: 158)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.frame(width: 130, height: 188)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
.shadow(color: .black.opacity(0.18), radius: 6, y: 3)
|
||||
.bookCoverZoomSource(slug: item.book.slug)
|
||||
|
||||
// Progress arc ring + chapter badge
|
||||
ZStack {
|
||||
Circle()
|
||||
.stroke(Color.white.opacity(0.18), lineWidth: 2.5)
|
||||
Circle()
|
||||
.trim(from: 0, to: progressFraction)
|
||||
.stroke(Color.amber, style: StrokeStyle(lineWidth: 2.5, lineCap: .round))
|
||||
.rotationEffect(.degrees(-90))
|
||||
Text("Ch.\(item.chapter)")
|
||||
// "Continue" pill badge at bottom-left
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "play.fill")
|
||||
.font(.system(size: 8, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
.minimumScaleFactor(0.6)
|
||||
Text("Ch.\(item.chapter)")
|
||||
.font(.system(size: 10, weight: .bold))
|
||||
}
|
||||
.frame(width: 36, height: 36)
|
||||
.background(.ultraThinMaterial, in: Circle())
|
||||
.padding(5)
|
||||
.foregroundStyle(.black.opacity(0.85))
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 5)
|
||||
.background(Capsule().fill(Color.amber))
|
||||
.padding(8)
|
||||
}
|
||||
|
||||
// Title
|
||||
Text(item.book.title)
|
||||
.font(.caption.bold())
|
||||
.lineLimit(2)
|
||||
.frame(width: 110, alignment: .leading)
|
||||
.frame(width: 130, alignment: .leading)
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
// Progress bar — show at least a 4pt sliver so early chapters aren't invisible
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .leading) {
|
||||
Capsule()
|
||||
.fill(Color.secondary.opacity(0.2))
|
||||
Capsule()
|
||||
.fill(Color.amber.opacity(0.85))
|
||||
.frame(width: max(4, geo.size.width * progressFraction))
|
||||
}
|
||||
}
|
||||
.frame(width: 130, height: 3)
|
||||
|
||||
// Percent label — floor at 1% so early chapters don't display "0%"
|
||||
Text("\(max(1, Int(progressFraction * 100)))% complete")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(width: 130)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,6 +252,37 @@ private struct ShelfBookCard: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Horizontal shelf: subscription feed card
|
||||
|
||||
private struct SubscriptionFeedCard: View {
|
||||
let item: SubscriptionFeedItem
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
AsyncCoverImage(url: item.book.cover)
|
||||
.frame(width: 110, height: 158)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.shadow(color: .black.opacity(0.12), radius: 4, y: 2)
|
||||
.bookCoverZoomSource(slug: item.book.slug)
|
||||
|
||||
Text(item.book.title)
|
||||
.font(.caption.bold())
|
||||
.lineLimit(2)
|
||||
.frame(width: 110, alignment: .leading)
|
||||
|
||||
// Tappable "via @username" attribution
|
||||
NavigationLink(value: NavDestination.userProfile(item.readerUsername)) {
|
||||
Text("via @\(item.readerUsername)")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(Color.amber)
|
||||
.lineLimit(1)
|
||||
.frame(width: 110, alignment: .leading)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Stats strip (compact inline)
|
||||
|
||||
private struct StatsStrip: View {
|
||||
@@ -301,3 +322,51 @@ private struct StatPill: View {
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Context menus
|
||||
|
||||
private struct ContinueReadingContextMenu: View {
|
||||
let item: ContinueReadingItem
|
||||
let onMarkFinished: () -> Void
|
||||
let onRemove: () -> Void
|
||||
|
||||
private var isFinished: Bool {
|
||||
guard item.book.totalChapters > 0 else { return false }
|
||||
return item.chapter >= item.book.totalChapters
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
// Share book
|
||||
ShareLink(item: shareURL) {
|
||||
Label("Share", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
// Mark as finished (only show if not already finished)
|
||||
if !isFinished {
|
||||
Button {
|
||||
onMarkFinished()
|
||||
} label: {
|
||||
Label("Mark as Finished", systemImage: "checkmark.circle")
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
// Remove from library (destructive)
|
||||
Button(role: .destructive) {
|
||||
onRemove()
|
||||
} label: {
|
||||
Label("Remove from Library", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var shareURL: URL {
|
||||
let baseURL = Bundle.main.object(forInfoDictionaryKey: "LIBNOVEL_BASE_URL") as? String
|
||||
?? "https://v2.libnovel.kalekber.cc"
|
||||
return URL(string: "\(baseURL)/books/\(item.book.slug)")!
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,23 +192,39 @@ struct LibraryView: View {
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 60)
|
||||
} else {
|
||||
// 3-column grid
|
||||
// 2-column grid (matches Discover)
|
||||
LazyVGrid(
|
||||
columns: [
|
||||
GridItem(.flexible(), spacing: 12),
|
||||
GridItem(.flexible(), spacing: 12),
|
||||
GridItem(.flexible(), spacing: 12)
|
||||
],
|
||||
spacing: 20
|
||||
spacing: 16
|
||||
) {
|
||||
ForEach(filtered) { item in
|
||||
NavigationLink(value: NavDestination.book(item.book.slug)) {
|
||||
LibraryBookCard(item: item)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.contextMenu {
|
||||
BookContextMenu(
|
||||
book: item.book,
|
||||
isFinished: isCompleted(item),
|
||||
onMarkFinished: {
|
||||
Task {
|
||||
await markAsFinished(item.book)
|
||||
}
|
||||
},
|
||||
onRemove: {
|
||||
Task {
|
||||
await removeFromLibrary(item.book.slug)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom, 24)
|
||||
}
|
||||
}
|
||||
@@ -220,6 +236,11 @@ struct LibraryView: View {
|
||||
.refreshable { await vm.load() }
|
||||
.task { await vm.load() }
|
||||
.errorAlert($vm.error)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
AvatarToolbarButton()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,6 +254,24 @@ struct LibraryView: View {
|
||||
return "No completed books yet."
|
||||
}
|
||||
}
|
||||
|
||||
private func markAsFinished(_ book: Book) async {
|
||||
do {
|
||||
try await APIClient.shared.setProgress(slug: book.slug, chapter: book.totalChapters)
|
||||
await vm.load() // Refresh library
|
||||
} catch {
|
||||
vm.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
private func removeFromLibrary(_ slug: String) async {
|
||||
do {
|
||||
try await APIClient.shared.deleteProgress(slug: slug)
|
||||
await vm.load() // Refresh library
|
||||
} catch {
|
||||
vm.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Library book card (3-column)
|
||||
@@ -286,14 +325,14 @@ private struct LibraryBookCard: View {
|
||||
|
||||
// Title
|
||||
Text(item.book.title)
|
||||
.font(.caption.bold())
|
||||
.font(.subheadline.bold())
|
||||
.lineLimit(2)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
// Chapter badge if present
|
||||
if let ch = item.lastChapter {
|
||||
Text(isCompleted ? "Finished" : "Ch.\(ch)")
|
||||
.font(.caption2)
|
||||
.font(.caption)
|
||||
.foregroundStyle(isCompleted ? Color.amber : .secondary)
|
||||
}
|
||||
}
|
||||
@@ -318,3 +357,48 @@ private struct ProgressArc: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Book context menu
|
||||
|
||||
private struct BookContextMenu: View {
|
||||
let book: Book
|
||||
let isFinished: Bool
|
||||
let onMarkFinished: () -> Void
|
||||
let onRemove: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
// Share book
|
||||
ShareLink(item: shareURL) {
|
||||
Label("Share", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
// Mark as finished (only show if not already finished)
|
||||
if !isFinished {
|
||||
Button {
|
||||
onMarkFinished()
|
||||
} label: {
|
||||
Label("Mark as Finished", systemImage: "checkmark.circle")
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
// Remove from library (destructive)
|
||||
Button(role: .destructive) {
|
||||
onRemove()
|
||||
} label: {
|
||||
Label("Remove from Library", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var shareURL: URL {
|
||||
// Share the book detail page URL
|
||||
let baseURL = Bundle.main.object(forInfoDictionaryKey: "LIBNOVEL_BASE_URL") as? String
|
||||
?? "https://v2.libnovel.kalekber.cc"
|
||||
return URL(string: "\(baseURL)/books/\(book.slug)")!
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,291 @@ import SwiftUI
|
||||
import Kingfisher // used directly for blurred background in FullPlayerView
|
||||
import AVKit // for AVRoutePickerView (AirPlay)
|
||||
|
||||
// MARK: - Mini player bar (pinned above tab bar)
|
||||
// MARK: - Floating circular player button (modern FAB design)
|
||||
|
||||
struct MiniPlayerView: View {
|
||||
// MARK: - Floating circular player button (modern FAB design)
|
||||
|
||||
struct FloatingPlayerButton: View {
|
||||
@Binding var showFullPlayer: Bool
|
||||
@Binding var showControls: Bool
|
||||
@EnvironmentObject var audioPlayer: AudioPlayerService
|
||||
|
||||
/// Persistent position stored in UserDefaults
|
||||
@AppStorage("floatingPlayerX") private var savedX: Double = -1
|
||||
@AppStorage("floatingPlayerY") private var savedY: Double = -1
|
||||
|
||||
@State private var position: CGPoint = .zero
|
||||
@State private var dragOffset: CGSize = .zero
|
||||
|
||||
private let buttonSize: CGFloat = 64
|
||||
|
||||
private var progressFraction: CGFloat {
|
||||
guard audioPlayer.duration > 0 else { return 0 }
|
||||
return CGFloat(audioPlayer.currentTime / audioPlayer.duration)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
ZStack {
|
||||
// Circular cover with glassmorphic background
|
||||
ZStack {
|
||||
// Progress ring
|
||||
Circle()
|
||||
.trim(from: 0, to: progressFraction)
|
||||
.stroke(Color.amber, style: StrokeStyle(lineWidth: 3, lineCap: .round))
|
||||
.rotationEffect(.degrees(-90))
|
||||
.animation(.easeInOut(duration: 0.3), value: audioPlayer.currentTime)
|
||||
|
||||
// Cover image
|
||||
AsyncCoverImage(url: audioPlayer.coverURL)
|
||||
.frame(width: buttonSize - 8, height: buttonSize - 8)
|
||||
.clipShape(Circle())
|
||||
|
||||
// Play/pause icon overlay (small, centered)
|
||||
if audioPlayer.status == .ready {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(.ultraThinMaterial)
|
||||
.frame(width: 28, height: 28)
|
||||
|
||||
Image(systemName: audioPlayer.isPlaying ? "pause.fill" : "play.fill")
|
||||
.font(.system(size: 11, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
.offset(x: audioPlayer.isPlaying ? 0 : 1)
|
||||
}
|
||||
} else if audioPlayer.status == .generating {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(.ultraThinMaterial)
|
||||
.frame(width: 28, height: 28)
|
||||
ProgressView()
|
||||
.tint(.white)
|
||||
.scaleEffect(0.7)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(width: buttonSize, height: buttonSize)
|
||||
.background(
|
||||
Circle()
|
||||
.fill(.ultraThinMaterial)
|
||||
.overlay(
|
||||
Circle()
|
||||
.fill(Color.black.opacity(0.2))
|
||||
)
|
||||
)
|
||||
.shadow(color: .black.opacity(0.3), radius: 12, y: 4)
|
||||
.position(
|
||||
x: position.x + dragOffset.width,
|
||||
y: position.y + dragOffset.height
|
||||
)
|
||||
.gesture(
|
||||
DragGesture()
|
||||
.onChanged { value in
|
||||
dragOffset = value.translation
|
||||
}
|
||||
.onEnded { value in
|
||||
// Update persistent position
|
||||
let newX = position.x + value.translation.width
|
||||
let newY = position.y + value.translation.height
|
||||
|
||||
// Clamp to screen bounds with padding
|
||||
let padding: CGFloat = buttonSize / 2 + 8
|
||||
position.x = max(padding, min(geo.size.width - padding, newX))
|
||||
position.y = max(padding, min(geo.size.height - padding, newY))
|
||||
|
||||
dragOffset = .zero
|
||||
|
||||
// Save to UserDefaults
|
||||
savedX = position.x
|
||||
savedY = position.y
|
||||
}
|
||||
)
|
||||
.onTapGesture {
|
||||
withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) {
|
||||
showControls.toggle()
|
||||
}
|
||||
}
|
||||
.onLongPressGesture(minimumDuration: 0.5) {
|
||||
showFullPlayer = true
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
// Initialize position from saved or default to bottom-right
|
||||
if savedX < 0 || savedY < 0 {
|
||||
position = CGPoint(
|
||||
x: geo.size.width - buttonSize / 2 - 20,
|
||||
y: geo.size.height - buttonSize / 2 - 100
|
||||
)
|
||||
} else {
|
||||
position = CGPoint(x: savedX, y: savedY)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Compact player controls overlay
|
||||
|
||||
struct CompactPlayerControls: View {
|
||||
@Binding var isPresented: Bool
|
||||
@EnvironmentObject var audioPlayer: AudioPlayerService
|
||||
|
||||
private var progressFraction: CGFloat {
|
||||
guard audioPlayer.duration > 0 else { return 0 }
|
||||
return CGFloat(audioPlayer.currentTime / audioPlayer.duration)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
Spacer()
|
||||
|
||||
VStack(spacing: 16) {
|
||||
// Drag handle
|
||||
Capsule()
|
||||
.fill(Color.white.opacity(0.3))
|
||||
.frame(width: 36, height: 4)
|
||||
.padding(.top, 12)
|
||||
|
||||
// Track info
|
||||
VStack(spacing: 6) {
|
||||
Text(chapterLabel)
|
||||
.font(.headline)
|
||||
.lineLimit(1)
|
||||
Text(audioPlayer.bookTitle)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
|
||||
// Progress bar with time labels
|
||||
VStack(spacing: 8) {
|
||||
HStack(spacing: 12) {
|
||||
Text(formatTime(audioPlayer.currentTime))
|
||||
.font(.caption.monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .leading) {
|
||||
// Track background
|
||||
RoundedRectangle(cornerRadius: 2)
|
||||
.fill(.white.opacity(0.2))
|
||||
.frame(height: 4)
|
||||
|
||||
// Progress fill
|
||||
RoundedRectangle(cornerRadius: 2)
|
||||
.fill(Color.amber)
|
||||
.frame(width: geo.size.width * progressFraction, height: 4)
|
||||
}
|
||||
}
|
||||
.frame(height: 4)
|
||||
|
||||
Text(formatTime(audioPlayer.duration))
|
||||
.font(.caption.monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
}
|
||||
|
||||
// Control buttons
|
||||
HStack(spacing: 32) {
|
||||
// Previous chapter
|
||||
Button {
|
||||
if let prev = audioPlayer.prevChapter {
|
||||
NotificationCenter.default.post(
|
||||
name: .skipToPrevChapter,
|
||||
object: nil,
|
||||
userInfo: ["prev": prev]
|
||||
)
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "backward.end.fill")
|
||||
.font(.system(size: 24, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 50, height: 50)
|
||||
}
|
||||
.disabled(audioPlayer.prevChapter == nil)
|
||||
.opacity(audioPlayer.prevChapter == nil ? 0.4 : 1.0)
|
||||
|
||||
// Play/pause
|
||||
Button {
|
||||
audioPlayer.togglePlayPause()
|
||||
} label: {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color.amber)
|
||||
.frame(width: 60, height: 60)
|
||||
|
||||
Image(systemName: audioPlayer.isPlaying ? "pause.fill" : "play.fill")
|
||||
.font(.system(size: 24, weight: .bold))
|
||||
.foregroundStyle(.black)
|
||||
.offset(x: audioPlayer.isPlaying ? 0 : 2)
|
||||
}
|
||||
}
|
||||
.disabled(audioPlayer.status != .ready)
|
||||
|
||||
// Next chapter
|
||||
Button {
|
||||
if let next = audioPlayer.nextChapter {
|
||||
NotificationCenter.default.post(
|
||||
name: .skipToNextChapter,
|
||||
object: nil,
|
||||
userInfo: ["next": next]
|
||||
)
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "forward.end.fill")
|
||||
.font(.system(size: 24, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 50, height: 50)
|
||||
}
|
||||
.disabled(audioPlayer.nextChapter == nil)
|
||||
.opacity(audioPlayer.nextChapter == nil ? 0.4 : 1.0)
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
|
||||
// Bottom safe area padding
|
||||
Color.clear.frame(height: 20)
|
||||
}
|
||||
.background(
|
||||
.ultraThinMaterial,
|
||||
in: RoundedRectangle(cornerRadius: 24, style: .continuous)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 24, style: .continuous)
|
||||
.fill(Color.black.opacity(0.3))
|
||||
)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.bottom, 16)
|
||||
.shadow(color: .black.opacity(0.4), radius: 20, y: -8)
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
.onTapGesture {
|
||||
// Tap outside to dismiss
|
||||
withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) {
|
||||
isPresented = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var chapterLabel: String {
|
||||
let raw = audioPlayer.chapterTitle.isEmpty
|
||||
? "Chapter \(audioPlayer.chapter)"
|
||||
: audioPlayer.chapterTitle
|
||||
return raw.strippingTrailingDate()
|
||||
}
|
||||
|
||||
private func formatTime(_ seconds: Double) -> String {
|
||||
guard seconds.isFinite && seconds >= 0 else { return "0:00" }
|
||||
let mins = Int(seconds) / 60
|
||||
let secs = Int(seconds) % 60
|
||||
return String(format: "%d:%02d", mins, secs)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Legacy mini player (kept for reference, will be removed)
|
||||
|
||||
struct MiniPlayerView_Legacy: View {
|
||||
@Binding var showFullPlayer: Bool
|
||||
@EnvironmentObject var audioPlayer: AudioPlayerService
|
||||
|
||||
|
||||
341
ios/LibNovel/LibNovel/Views/Profile/AccountMenuSheet.swift
Normal file
341
ios/LibNovel/LibNovel/Views/Profile/AccountMenuSheet.swift
Normal file
@@ -0,0 +1,341 @@
|
||||
import SwiftUI
|
||||
import PhotosUI
|
||||
import Kingfisher
|
||||
|
||||
// MARK: - AvatarNavButton
|
||||
// Drop this into any NavigationStack toolbar to get an avatar button that opens the account sheet.
|
||||
//
|
||||
// Usage:
|
||||
// .toolbar { AvatarToolbarButton() }
|
||||
|
||||
struct AvatarToolbarButton: View {
|
||||
@EnvironmentObject private var authStore: AuthStore
|
||||
@State private var showAccount = false
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
showAccount = true
|
||||
} label: {
|
||||
AvatarThumb(urlString: authStore.user?.avatarURL, size: 30)
|
||||
}
|
||||
.sheet(isPresented: $showAccount) {
|
||||
AccountMenuSheet()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - AvatarThumb
|
||||
// Reusable small circular avatar (used by both toolbar button and the sheet header).
|
||||
|
||||
struct AvatarThumb: View {
|
||||
let urlString: String?
|
||||
let size: CGFloat
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let str = urlString, let url = URL(string: str) {
|
||||
KFImage(url)
|
||||
.placeholder { placeholderCircle }
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
} else {
|
||||
placeholderCircle
|
||||
}
|
||||
}
|
||||
.frame(width: size, height: size)
|
||||
.clipShape(Circle())
|
||||
.overlay(Circle().stroke(Color.amber.opacity(0.6), lineWidth: 1.5))
|
||||
}
|
||||
|
||||
private var placeholderCircle: some View {
|
||||
Circle()
|
||||
.fill(Color(.systemGray4))
|
||||
.overlay(
|
||||
Image(systemName: "person.fill")
|
||||
.font(.system(size: size * 0.5))
|
||||
.foregroundStyle(Color.amber)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - AccountMenuSheet
|
||||
|
||||
struct AccountMenuSheet: View {
|
||||
@EnvironmentObject private var authStore: AuthStore
|
||||
@StateObject private var vm = ProfileViewModel()
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var showChangePassword = false
|
||||
|
||||
// Avatar upload
|
||||
@State private var photoPickerItem: PhotosPickerItem?
|
||||
@State private var pendingCropImage: UIImage?
|
||||
@State private var avatarURL: String? = nil
|
||||
@State private var avatarUploading = false
|
||||
@State private var avatarError: String?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
// ── User header ────────────────────────────────────────────
|
||||
Section {
|
||||
HStack(spacing: 16) {
|
||||
avatarPicker
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(authStore.user?.username ?? "")
|
||||
.font(.headline)
|
||||
Text(authStore.user?.role.capitalized ?? "")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
if let err = avatarError {
|
||||
Text(err)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
|
||||
// ── Reading settings ───────────────────────────────────────
|
||||
Section("Reading Settings") {
|
||||
voicePicker
|
||||
speedSlider
|
||||
Toggle("Auto-advance chapter", isOn: Binding(
|
||||
get: { authStore.settings.autoNext },
|
||||
set: { newVal in
|
||||
Task {
|
||||
var s = authStore.settings
|
||||
s.autoNext = newVal
|
||||
await authStore.saveSettings(s)
|
||||
}
|
||||
}
|
||||
))
|
||||
.tint(.amber)
|
||||
}
|
||||
|
||||
// ── Sessions ───────────────────────────────────────────────
|
||||
Section("Active Sessions") {
|
||||
if vm.sessionsLoading {
|
||||
ProgressView()
|
||||
} else {
|
||||
ForEach(vm.sessions) { session in
|
||||
SessionRow(session: session) {
|
||||
Task { await vm.revokeSession(id: session.id) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Account ────────────────────────────────────────────────
|
||||
Section("Account") {
|
||||
Button("Change Password") { showChangePassword = true }
|
||||
Button("Sign Out", role: .destructive) {
|
||||
dismiss()
|
||||
Task { await authStore.logout() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Account")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Done") { dismiss() }
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
}
|
||||
.task { await vm.loadSessions() }
|
||||
.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)
|
||||
}
|
||||
.presentationDetents([.large])
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
|
||||
// MARK: - Avatar upload
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
private func uploadCroppedData(_ data: Data) async {
|
||||
avatarUploading = true
|
||||
avatarError = nil
|
||||
defer { avatarUploading = false }
|
||||
do {
|
||||
let url = try await APIClient.shared.uploadAvatar(data, mimeType: "image/jpeg")
|
||||
avatarURL = url
|
||||
await authStore.validateToken()
|
||||
} catch {
|
||||
avatarError = "Upload failed: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Avatar picker
|
||||
|
||||
@ViewBuilder
|
||||
private var avatarPicker: some View {
|
||||
PhotosPicker(selection: $photoPickerItem,
|
||||
matching: .images,
|
||||
photoLibrary: .shared()) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color(.systemGray5))
|
||||
.frame(width: 72, height: 72)
|
||||
|
||||
if avatarUploading {
|
||||
ProgressView()
|
||||
.frame(width: 72, height: 72)
|
||||
} else if let urlStr = avatarURL ?? authStore.user?.avatarURL,
|
||||
let url = URL(string: urlStr) {
|
||||
KFImage(url)
|
||||
.placeholder {
|
||||
Image(systemName: "person.circle.fill")
|
||||
.font(.system(size: 52))
|
||||
.foregroundStyle(.amber)
|
||||
}
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.frame(width: 72, height: 72)
|
||||
.clipShape(Circle())
|
||||
} else {
|
||||
Image(systemName: "person.circle.fill")
|
||||
.font(.system(size: 52))
|
||||
.foregroundStyle(.amber)
|
||||
.frame(width: 72, height: 72)
|
||||
}
|
||||
|
||||
// Camera badge
|
||||
if !avatarUploading {
|
||||
VStack {
|
||||
Spacer()
|
||||
HStack {
|
||||
Spacer()
|
||||
ZStack {
|
||||
Circle().fill(Color.amber).frame(width: 22, height: 22)
|
||||
Image(systemName: "camera.fill")
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.foregroundStyle(.black)
|
||||
}
|
||||
.offset(x: 2, y: 2)
|
||||
}
|
||||
}
|
||||
.frame(width: 72, height: 72)
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.onChange(of: photoPickerItem) { _, item in
|
||||
guard let item else { return }
|
||||
Task { await loadImageForCrop(item) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Voice picker
|
||||
|
||||
@ViewBuilder
|
||||
private var voicePicker: some View {
|
||||
Picker("TTS Voice", selection: Binding(
|
||||
get: { authStore.settings.voice },
|
||||
set: { newVoice in
|
||||
Task {
|
||||
var s = authStore.settings
|
||||
s.voice = newVoice
|
||||
await authStore.saveSettings(s)
|
||||
}
|
||||
}
|
||||
)) {
|
||||
if vm.voices.isEmpty {
|
||||
Text("Default").tag("af_bella")
|
||||
} else {
|
||||
ForEach(vm.voices, id: \.self) { v in
|
||||
Text(v).tag(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
.task { await vm.loadVoices() }
|
||||
}
|
||||
|
||||
// MARK: - Speed slider
|
||||
|
||||
@ViewBuilder
|
||||
private var speedSlider: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Text("Playback Speed")
|
||||
Spacer()
|
||||
Text("\(authStore.settings.speed, specifier: "%.1f")×")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Slider(
|
||||
value: Binding(
|
||||
get: { authStore.settings.speed },
|
||||
set: { newSpeed in
|
||||
Task {
|
||||
var s = authStore.settings
|
||||
s.speed = newSpeed
|
||||
await authStore.saveSettings(s)
|
||||
}
|
||||
}
|
||||
),
|
||||
in: 0.5...2.0, step: 0.25
|
||||
)
|
||||
.tint(.amber)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Session row (local copy — mirrors ProfileView.SessionRow)
|
||||
|
||||
private struct SessionRow: View {
|
||||
let session: UserSession
|
||||
let onRevoke: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Image(systemName: "iphone")
|
||||
Text(session.userAgent.isEmpty ? "Unknown device" : session.userAgent)
|
||||
.font(.subheadline)
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
if session.isCurrent {
|
||||
Text("This device")
|
||||
.font(.caption2.bold())
|
||||
.foregroundStyle(.amber)
|
||||
} else {
|
||||
Button("Revoke", role: .destructive, action: onRevoke)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
Text("Last seen: \(session.lastSeen.prefix(10))")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CropImageItem (Identifiable wrapper for the sheet)
|
||||
|
||||
private struct CropImageItem: Identifiable {
|
||||
let id = UUID()
|
||||
let image: UIImage
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - AvatarCropView
|
||||
// A sheet that lets the user pan and pinch a photo to fill a 1:1 square crop region.
|
||||
// A sheet that lets the user pan and pinch a photo to fill a 1:1 circular crop region.
|
||||
// Call: .sheet(item: $cropImage) { AvatarCropView(image: $0.image, onConfirm: { croppedData in … }) }
|
||||
|
||||
struct AvatarCropView: View {
|
||||
@@ -9,15 +9,18 @@ struct AvatarCropView: View {
|
||||
let onConfirm: (Data) -> Void
|
||||
let onCancel: () -> Void
|
||||
|
||||
// Crop square side length (points) — matched to the web 400 px target
|
||||
// Crop circle diameter (points)
|
||||
private let cropSize: CGFloat = 280
|
||||
|
||||
// Pan/zoom state
|
||||
// Pan/zoom state — all in screen points, relative to the image's natural fill-fitted frame
|
||||
@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
|
||||
|
||||
// Container size captured from GeometryReader
|
||||
@State private var containerSize: CGSize = .zero
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
GeometryReader { geo in
|
||||
@@ -29,23 +32,27 @@ struct AvatarCropView: View {
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.frame(width: geo.size.width, height: geo.size.height)
|
||||
.scaleEffect(scale)
|
||||
.scaleEffect(scale, anchor: .center)
|
||||
.offset(offset)
|
||||
.gesture(
|
||||
SimultaneousGesture(
|
||||
MagnificationGesture()
|
||||
.onChanged { value in
|
||||
scale = max(1.0, lastScale * value)
|
||||
let proposed = lastScale * value
|
||||
scale = max(minScale(in: geo.size), proposed)
|
||||
}
|
||||
.onEnded { _ in
|
||||
lastScale = scale
|
||||
clampOffset(in: geo.size)
|
||||
lastOffset = offset
|
||||
},
|
||||
DragGesture()
|
||||
.onChanged { value in
|
||||
offset = CGSize(
|
||||
let proposed = CGSize(
|
||||
width: lastOffset.width + value.translation.width,
|
||||
height: lastOffset.height + value.translation.height
|
||||
)
|
||||
offset = clampedOffset(proposed, in: geo.size)
|
||||
}
|
||||
.onEnded { _ in
|
||||
lastOffset = offset
|
||||
@@ -54,10 +61,14 @@ struct AvatarCropView: View {
|
||||
)
|
||||
.clipped()
|
||||
|
||||
// Dim overlay with transparent crop square cut out
|
||||
// Dim overlay with transparent crop circle cut out
|
||||
CropOverlay(cropSize: cropSize, containerSize: geo.size)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
.onAppear {
|
||||
containerSize = geo.size
|
||||
fitImageInitially(in: geo.size)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Crop Photo")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
@@ -76,43 +87,141 @@ struct AvatarCropView: View {
|
||||
}
|
||||
.toolbarColorScheme(.dark, for: .navigationBar)
|
||||
}
|
||||
.onAppear { fitImageInitially() }
|
||||
}
|
||||
|
||||
// MARK: - Initial fit
|
||||
|
||||
private func fitImageInitially(in size: CGSize) {
|
||||
// The image is displayed with .scaledToFill() in the container (size).
|
||||
// That means one dimension equals the container and the other overflows.
|
||||
// We want the image to be just large enough that the crop circle is fully
|
||||
// covered — i.e. the fill-fitted image's shorter displayed dimension >= cropSize.
|
||||
//
|
||||
// .scaledToFill fills the container, so the image already covers the container.
|
||||
// The minimum scale that covers the crop square is therefore 1.0 (image already
|
||||
// fills container which is >= cropSize on both axes).
|
||||
// We keep scale = 1.0 and centre the offset.
|
||||
scale = 1.0
|
||||
lastScale = 1.0
|
||||
offset = .zero
|
||||
lastOffset = .zero
|
||||
}
|
||||
|
||||
// MARK: - Clamping helpers
|
||||
|
||||
/// Minimum scale: the image (at .scaledToFill in container) must cover the crop square.
|
||||
/// At scale=1 the image already fills the container; cropSize <= container dimension,
|
||||
/// so 1.0 is always sufficient. We cap at 1.0 to prevent zooming out below fill.
|
||||
private func minScale(in containerSize: CGSize) -> CGFloat {
|
||||
return 1.0
|
||||
}
|
||||
|
||||
/// The displayed (fill-fitted) image size in the container at the given user scale.
|
||||
private func displayedImageSize(in containerSize: CGSize, userScale: CGFloat) -> CGSize {
|
||||
let imgAspect = image.size.width / image.size.height
|
||||
let containerAspect = containerSize.width / containerSize.height
|
||||
|
||||
// .scaledToFill base size before user scale
|
||||
let baseWidth: CGFloat
|
||||
let baseHeight: CGFloat
|
||||
if imgAspect > containerAspect {
|
||||
// image is wider — height fills container
|
||||
baseHeight = containerSize.height
|
||||
baseWidth = baseHeight * imgAspect
|
||||
} else {
|
||||
// image is taller — width fills container
|
||||
baseWidth = containerSize.width
|
||||
baseHeight = baseWidth / imgAspect
|
||||
}
|
||||
return CGSize(width: baseWidth * userScale, height: baseHeight * userScale)
|
||||
}
|
||||
|
||||
/// Maximum offset so the crop square is always covered by the image.
|
||||
private func clampedOffset(_ proposed: CGSize, in containerSize: CGSize) -> CGSize {
|
||||
let displayed = displayedImageSize(in: containerSize, userScale: scale)
|
||||
// Half of how much the image overflows the container on each axis
|
||||
let maxX = max(0, (displayed.width - cropSize) / 2)
|
||||
let maxY = max(0, (displayed.height - cropSize) / 2)
|
||||
return CGSize(
|
||||
width: min(maxX, max(-maxX, proposed.width)),
|
||||
height: min(maxY, max(-maxY, proposed.height))
|
||||
)
|
||||
}
|
||||
|
||||
private func clampOffset(in containerSize: CGSize) {
|
||||
offset = clampedOffset(offset, in: containerSize)
|
||||
}
|
||||
|
||||
// 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 size = containerSize.width > 0 ? containerSize : CGSize(width: 390, height: 844)
|
||||
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)
|
||||
// --- Step 1: compute the fill-fitted base display size ---
|
||||
let imgAspect = image.size.width / image.size.height
|
||||
let containerAspect = size.width / size.height
|
||||
|
||||
let baseDisplayW: CGFloat
|
||||
let baseDisplayH: CGFloat
|
||||
if imgAspect > containerAspect {
|
||||
baseDisplayH = size.height
|
||||
baseDisplayW = baseDisplayH * imgAspect
|
||||
} else {
|
||||
baseDisplayW = size.width
|
||||
baseDisplayH = baseDisplayW / imgAspect
|
||||
}
|
||||
|
||||
// Displayed image size after user zoom
|
||||
let displayW = baseDisplayW * scale
|
||||
let displayH = baseDisplayH * scale
|
||||
|
||||
// --- Step 2: the crop square centre is the container centre ---
|
||||
// The image centre (after offset) in container coords:
|
||||
let imageCentreX = size.width / 2 + offset.width
|
||||
let imageCentreY = size.height / 2 + offset.height
|
||||
|
||||
// Top-left of the crop square in container coords:
|
||||
let cropOriginX = (size.width - cropSize) / 2
|
||||
let cropOriginY = (size.height - cropSize) / 2
|
||||
|
||||
// Top-left of the crop square relative to the image's top-left in display space:
|
||||
let imageOriginX = imageCentreX - displayW / 2
|
||||
let imageOriginY = imageCentreY - displayH / 2
|
||||
|
||||
let cropInImageX = cropOriginX - imageOriginX // pixels in display space
|
||||
let cropInImageY = cropOriginY - imageOriginY
|
||||
|
||||
// --- Step 3: convert display-space coords to image pixel coords ---
|
||||
let displayToPixelX = image.size.width / displayW
|
||||
let displayToPixelY = image.size.height / displayH
|
||||
|
||||
let pixelX = cropInImageX * displayToPixelX
|
||||
let pixelY = cropInImageY * displayToPixelY
|
||||
let pixelW = cropSize * displayToPixelX
|
||||
let pixelH = cropSize * displayToPixelY
|
||||
|
||||
let cropRect = CGRect(x: pixelX, y: pixelY, width: pixelW, height: pixelH)
|
||||
.intersection(CGRect(origin: .zero, size: image.size))
|
||||
|
||||
guard cropRect.width > 0, cropRect.height > 0 else {
|
||||
// Fallback: use entire image
|
||||
if let jpeg = image.jpegData(compressionQuality: 0.9) { onConfirm(jpeg) }
|
||||
return
|
||||
}
|
||||
|
||||
// --- Step 4: render cropped region into 400×400 ---
|
||||
let renderer = UIGraphicsImageRenderer(size: outputSize)
|
||||
let cropped = renderer.image { _ in
|
||||
// Draw only the cropRect portion of the image scaled to fill outputSize
|
||||
let destRect = CGRect(origin: .zero, size: outputSize)
|
||||
// UIImage.draw(in:) draws the full image; we use CGImage cropping instead
|
||||
if let cgImg = image.cgImage?.cropping(to: cropRect) {
|
||||
let croppedUI = UIImage(cgImage: cgImg, scale: image.scale, orientation: image.imageOrientation)
|
||||
croppedUI.draw(in: destRect)
|
||||
} else {
|
||||
image.draw(in: destRect)
|
||||
}
|
||||
}
|
||||
|
||||
if let jpeg = cropped.jpegData(compressionQuality: 0.9) {
|
||||
@@ -131,7 +240,7 @@ private struct CropOverlay: 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
|
||||
// Cut out the crop circle in the centre
|
||||
let origin = CGPoint(
|
||||
x: (size.width - cropSize) / 2,
|
||||
y: (size.height - cropSize) / 2
|
||||
|
||||
@@ -20,64 +20,7 @@ struct ProfileView: View {
|
||||
// ── User header ────────────────────────────────────────────
|
||||
Section {
|
||||
HStack(spacing: 16) {
|
||||
// Tappable avatar circle
|
||||
PhotosPicker(selection: $photoPickerItem,
|
||||
matching: .images,
|
||||
photoLibrary: .shared()) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color(.systemGray5))
|
||||
.frame(width: 72, height: 72)
|
||||
|
||||
if avatarUploading {
|
||||
ProgressView()
|
||||
.frame(width: 72, height: 72)
|
||||
} else if let urlStr = avatarURL ?? authStore.user?.avatarURL,
|
||||
let url = URL(string: urlStr) {
|
||||
KFImage(url)
|
||||
.placeholder {
|
||||
Image(systemName: "person.circle.fill")
|
||||
.font(.system(size: 52))
|
||||
.foregroundStyle(.amber)
|
||||
}
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.frame(width: 72, height: 72)
|
||||
.clipShape(Circle())
|
||||
} else {
|
||||
Image(systemName: "person.circle.fill")
|
||||
.font(.system(size: 52))
|
||||
.foregroundStyle(.amber)
|
||||
.frame(width: 72, height: 72)
|
||||
}
|
||||
|
||||
// Camera overlay badge
|
||||
if !avatarUploading {
|
||||
VStack {
|
||||
Spacer()
|
||||
HStack {
|
||||
Spacer()
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color.amber)
|
||||
.frame(width: 22, height: 22)
|
||||
Image(systemName: "camera.fill")
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.foregroundStyle(.black)
|
||||
}
|
||||
.offset(x: 2, y: 2)
|
||||
}
|
||||
}
|
||||
.frame(width: 72, height: 72)
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.onChange(of: photoPickerItem) { _, item in
|
||||
guard let item else { return }
|
||||
Task { await loadImageForCrop(item) }
|
||||
}
|
||||
|
||||
avatarPicker
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(authStore.user?.username ?? "")
|
||||
.font(.headline)
|
||||
@@ -136,6 +79,7 @@ struct ProfileView: View {
|
||||
.task {
|
||||
await vm.loadSessions()
|
||||
}
|
||||
|
||||
.sheet(isPresented: $showChangePassword) {
|
||||
ChangePasswordView()
|
||||
}
|
||||
@@ -181,6 +125,68 @@ struct ProfileView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Avatar picker
|
||||
|
||||
@ViewBuilder
|
||||
private var avatarPicker: some View {
|
||||
PhotosPicker(selection: $photoPickerItem,
|
||||
matching: .images,
|
||||
photoLibrary: .shared()) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color(.systemGray5))
|
||||
.frame(width: 72, height: 72)
|
||||
|
||||
if avatarUploading {
|
||||
ProgressView()
|
||||
.frame(width: 72, height: 72)
|
||||
} else if let urlStr = avatarURL ?? authStore.user?.avatarURL,
|
||||
let url = URL(string: urlStr) {
|
||||
KFImage(url)
|
||||
.placeholder {
|
||||
Image(systemName: "person.circle.fill")
|
||||
.font(.system(size: 52))
|
||||
.foregroundStyle(.amber)
|
||||
}
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.frame(width: 72, height: 72)
|
||||
.clipShape(Circle())
|
||||
} else {
|
||||
Image(systemName: "person.circle.fill")
|
||||
.font(.system(size: 52))
|
||||
.foregroundStyle(.amber)
|
||||
.frame(width: 72, height: 72)
|
||||
}
|
||||
|
||||
// Camera overlay badge
|
||||
if !avatarUploading {
|
||||
VStack {
|
||||
Spacer()
|
||||
HStack {
|
||||
Spacer()
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color.amber)
|
||||
.frame(width: 22, height: 22)
|
||||
Image(systemName: "camera.fill")
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.foregroundStyle(.black)
|
||||
}
|
||||
.offset(x: 2, y: 2)
|
||||
}
|
||||
}
|
||||
.frame(width: 72, height: 72)
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.onChange(of: photoPickerItem) { _, item in
|
||||
guard let item else { return }
|
||||
Task { await loadImageForCrop(item) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Voice picker
|
||||
|
||||
@ViewBuilder
|
||||
|
||||
209
ios/LibNovel/LibNovel/Views/Profile/UserProfileView.swift
Normal file
209
ios/LibNovel/LibNovel/Views/Profile/UserProfileView.swift
Normal file
@@ -0,0 +1,209 @@
|
||||
import SwiftUI
|
||||
|
||||
struct UserProfileView: View {
|
||||
let username: String
|
||||
|
||||
@StateObject private var vm: UserProfileViewModel
|
||||
@EnvironmentObject private var authStore: AuthStore
|
||||
|
||||
init(username: String) {
|
||||
self.username = username
|
||||
_vm = StateObject(wrappedValue: UserProfileViewModel(username: username))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
if vm.isLoading && vm.profile == nil {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 60)
|
||||
} else if let profile = vm.profile {
|
||||
profileHeader(profile)
|
||||
.padding(.bottom, 28)
|
||||
|
||||
if !vm.currentlyReading.isEmpty {
|
||||
ShelfHeader(title: "Currently Reading")
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(alignment: .top, spacing: 14) {
|
||||
ForEach(vm.currentlyReading) { item in
|
||||
NavigationLink(value: NavDestination.book(item.book.slug)) {
|
||||
ProfileBookCard(item: item)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
.padding(.bottom, 28)
|
||||
}
|
||||
|
||||
if !vm.library.isEmpty {
|
||||
ShelfHeader(title: "Library")
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(alignment: .top, spacing: 14) {
|
||||
ForEach(vm.library) { item in
|
||||
NavigationLink(value: NavDestination.book(item.book.slug)) {
|
||||
ProfileBookCard(item: item)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
.padding(.bottom, 28)
|
||||
}
|
||||
|
||||
if vm.currentlyReading.isEmpty && vm.library.isEmpty && !vm.isLoading {
|
||||
EmptyStateView(
|
||||
icon: "books.vertical",
|
||||
title: "No books yet",
|
||||
message: "\(username) hasn't read anything yet."
|
||||
)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 20)
|
||||
}
|
||||
} else if let err = vm.error {
|
||||
EmptyStateView(icon: "exclamationmark.triangle", title: "Error", message: err)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 60)
|
||||
}
|
||||
|
||||
Color.clear.frame(height: 20)
|
||||
}
|
||||
}
|
||||
.navigationTitle("@\(username)")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task { await vm.load() }
|
||||
.refreshable { await vm.load() }
|
||||
.errorAlert($vm.error)
|
||||
}
|
||||
|
||||
// MARK: - Profile header
|
||||
|
||||
@ViewBuilder
|
||||
private func profileHeader(_ profile: PublicUserProfile) -> some View {
|
||||
VStack(alignment: .center, spacing: 16) {
|
||||
AvatarThumb(urlString: profile.avatarUrl, size: 80)
|
||||
|
||||
VStack(spacing: 4) {
|
||||
Text("@\(profile.username)")
|
||||
.font(.title3.bold())
|
||||
if !profile.created.isEmpty {
|
||||
Text("Joined \(shortDate(profile.created))")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
// Stats row
|
||||
HStack(spacing: 32) {
|
||||
VStack(spacing: 2) {
|
||||
Text("\(profile.followerCount)")
|
||||
.font(.subheadline.bold().monospacedDigit())
|
||||
Text("Followers")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
VStack(spacing: 2) {
|
||||
Text("\(profile.followingCount)")
|
||||
.font(.subheadline.bold().monospacedDigit())
|
||||
Text("Following")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
// Follow button — only shown for other users (not self)
|
||||
if !profile.isSelf && authStore.isAuthenticated {
|
||||
Button {
|
||||
Task { await vm.toggleSubscribe() }
|
||||
} label: {
|
||||
if vm.isTogglingSubscribe {
|
||||
ProgressView().controlSize(.small)
|
||||
.frame(width: 120, height: 34)
|
||||
} else if profile.isSubscribed {
|
||||
Label("Following", systemImage: "checkmark")
|
||||
.font(.subheadline.bold())
|
||||
.frame(width: 120, height: 34)
|
||||
} else {
|
||||
Text("Follow")
|
||||
.font(.subheadline.bold())
|
||||
.frame(width: 120, height: 34)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(profile.isSubscribed ? Color(.systemGray4) : .amber)
|
||||
.disabled(vm.isTogglingSubscribe)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 24)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
private func shortDate(_ iso: String) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSSZ"
|
||||
if let date = formatter.date(from: iso) {
|
||||
let out = DateFormatter()
|
||||
out.dateStyle = .medium
|
||||
out.timeStyle = .none
|
||||
return out.string(from: date)
|
||||
}
|
||||
return String(iso.prefix(10))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Shelf header
|
||||
|
||||
private struct ShelfHeader: View {
|
||||
let title: String
|
||||
var body: some View {
|
||||
Text(title)
|
||||
.font(.title3.bold())
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 10)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Book card for profile shelves
|
||||
|
||||
private struct ProfileBookCard: View {
|
||||
let item: PublicLibraryItem
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
ZStack(alignment: .bottomLeading) {
|
||||
AsyncCoverImage(url: item.book.cover)
|
||||
.frame(width: 110, height: 158)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.shadow(color: .black.opacity(0.12), radius: 4, y: 2)
|
||||
|
||||
// Chapter badge (if reading)
|
||||
if let ch = item.lastChapter, ch > 0 {
|
||||
Text("Ch.\(ch)")
|
||||
.font(.system(size: 10, weight: .bold))
|
||||
.foregroundStyle(.black.opacity(0.85))
|
||||
.padding(.horizontal, 7)
|
||||
.padding(.vertical, 4)
|
||||
.background(Capsule().fill(Color.amber))
|
||||
.padding(6)
|
||||
}
|
||||
}
|
||||
|
||||
Text(item.book.title)
|
||||
.font(.caption.bold())
|
||||
.lineLimit(2)
|
||||
.frame(width: 110, alignment: .leading)
|
||||
|
||||
Text(item.book.author)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.frame(width: 110, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
247
ios/LibNovel/LibNovel/Views/Search/SearchView.swift
Normal file
247
ios/LibNovel/LibNovel/Views/Search/SearchView.swift
Normal file
@@ -0,0 +1,247 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - SearchView
|
||||
// Dedicated search tab modelled after Apple Books' Search screen.
|
||||
// Shows a prominent search bar; while idle displays recent searches and
|
||||
// trending/popular novels; after a query shows a results grid.
|
||||
|
||||
struct SearchView: View {
|
||||
@StateObject private var vm = SearchViewModel()
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(spacing: 0) {
|
||||
// ── Search bar ──────────────────────────────────────────────
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.foregroundStyle(.secondary)
|
||||
TextField("Search novels, authors…", text: $vm.query)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.submitLabel(.search)
|
||||
.onSubmit { vm.submitSearch() }
|
||||
if !vm.query.isEmpty {
|
||||
Button { vm.clear() } label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.background(Color(.systemGray6), in: RoundedRectangle(cornerRadius: 10))
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom, 12)
|
||||
|
||||
Divider()
|
||||
|
||||
// ── Content ─────────────────────────────────────────────────
|
||||
if vm.query.isEmpty && vm.results.isEmpty {
|
||||
idleContent
|
||||
} else if vm.isLoading {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else if vm.results.isEmpty {
|
||||
EmptyStateView(
|
||||
icon: "magnifyingglass",
|
||||
title: "No results",
|
||||
message: "Try a different title or author name."
|
||||
)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else {
|
||||
resultsGrid
|
||||
}
|
||||
}
|
||||
.navigationTitle("Search")
|
||||
.appNavigationDestination()
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
AvatarToolbarButton()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Idle screen (recent searches + popular)
|
||||
|
||||
@ViewBuilder
|
||||
private var idleContent: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 24) {
|
||||
// Recent searches
|
||||
if !vm.recentSearches.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack {
|
||||
Text("Recent")
|
||||
.font(.title3.bold())
|
||||
Spacer()
|
||||
Button("Clear") { vm.clearRecent() }
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.amber)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 10)
|
||||
|
||||
ForEach(vm.recentSearches, id: \.self) { term in
|
||||
Button {
|
||||
vm.query = term
|
||||
vm.submitSearch()
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "clock")
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 20)
|
||||
Text(term)
|
||||
.foregroundStyle(.primary)
|
||||
Spacer()
|
||||
Image(systemName: "arrow.up.left")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 11)
|
||||
}
|
||||
Divider().padding(.leading, 44)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Popular / trending novels (loaded from browse popular)
|
||||
if !vm.popular.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("Popular")
|
||||
.font(.title3.bold())
|
||||
.padding(.horizontal)
|
||||
|
||||
LazyVGrid(
|
||||
columns: [
|
||||
GridItem(.flexible(), spacing: 12),
|
||||
GridItem(.flexible(), spacing: 12)
|
||||
],
|
||||
spacing: 16
|
||||
) {
|
||||
ForEach(vm.popular) { novel in
|
||||
NavigationLink(value: NavDestination.book(novel.slug)) {
|
||||
SearchNovelCard(novel: novel)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
}
|
||||
|
||||
Color.clear.frame(height: 20)
|
||||
}
|
||||
.padding(.top, 16)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Results grid
|
||||
|
||||
@ViewBuilder
|
||||
private var resultsGrid: some View {
|
||||
ScrollView {
|
||||
LazyVGrid(
|
||||
columns: [
|
||||
GridItem(.flexible(), spacing: 12),
|
||||
GridItem(.flexible(), spacing: 12)
|
||||
],
|
||||
spacing: 16
|
||||
) {
|
||||
ForEach(vm.results) { novel in
|
||||
NavigationLink(value: NavDestination.book(novel.slug)) {
|
||||
SearchNovelCard(novel: novel)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Search novel card (compact 2-column)
|
||||
|
||||
private struct SearchNovelCard: View {
|
||||
let novel: BrowseNovel
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
AsyncCoverImage(url: novel.cover)
|
||||
.frame(maxWidth: .infinity)
|
||||
.aspectRatio(2/3, contentMode: .fit)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.shadow(color: .black.opacity(0.12), radius: 4, y: 2)
|
||||
.bookCoverZoomSource(slug: novel.slug)
|
||||
|
||||
Text(novel.title)
|
||||
.font(.subheadline.bold())
|
||||
.lineLimit(2)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SearchViewModel
|
||||
|
||||
@MainActor
|
||||
final class SearchViewModel: ObservableObject {
|
||||
@Published var query: String = ""
|
||||
@Published var results: [BrowseNovel] = []
|
||||
@Published var popular: [BrowseNovel] = []
|
||||
@Published var isLoading = false
|
||||
|
||||
// Persisted in UserDefaults (max 10 recent terms)
|
||||
@Published var recentSearches: [String] = []
|
||||
|
||||
private let recentKey = "searchRecentTerms"
|
||||
|
||||
init() {
|
||||
recentSearches = (UserDefaults.standard.stringArray(forKey: recentKey) ?? [])
|
||||
Task { await loadPopular() }
|
||||
}
|
||||
|
||||
func submitSearch() {
|
||||
let term = query.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !term.isEmpty else { return }
|
||||
saveRecent(term)
|
||||
Task { await runSearch(term) }
|
||||
}
|
||||
|
||||
func clear() {
|
||||
query = ""
|
||||
results = []
|
||||
}
|
||||
|
||||
func clearRecent() {
|
||||
recentSearches = []
|
||||
UserDefaults.standard.removeObject(forKey: recentKey)
|
||||
}
|
||||
|
||||
private func runSearch(_ term: String) async {
|
||||
isLoading = true
|
||||
do {
|
||||
let result = try await APIClient.shared.search(query: term)
|
||||
results = result.results
|
||||
} catch {
|
||||
results = []
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
private func loadPopular() async {
|
||||
do {
|
||||
let result = try await APIClient.shared.browse(page: 1, genre: "all", sort: "popular", status: "all")
|
||||
popular = Array(result.novels.prefix(12))
|
||||
} catch {}
|
||||
}
|
||||
|
||||
private func saveRecent(_ term: String) {
|
||||
var list = recentSearches.filter { $0 != term }
|
||||
list.insert(term, at: 0)
|
||||
if list.count > 10 { list = Array(list.prefix(10)) }
|
||||
recentSearches = list
|
||||
UserDefaults.standard.set(list, forKey: recentKey)
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,14 @@ RUN go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
# Build-time version info — injected by docker-compose or CI via --build-arg.
|
||||
ARG VERSION=dev
|
||||
ARG COMMIT=unknown
|
||||
|
||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
|
||||
go build -ldflags="-s -w" -o /scraper ./cmd/scraper
|
||||
go build \
|
||||
-ldflags="-s -w -X main.Version=${VERSION} -X main.Commit=${COMMIT}" \
|
||||
-o /scraper ./cmd/scraper
|
||||
|
||||
# ── Runtime stage ──────────────────────────────────────────────────────────────
|
||||
FROM alpine:3.21
|
||||
|
||||
@@ -47,6 +47,13 @@ import (
|
||||
"github.com/libnovel/scraper/internal/storage"
|
||||
)
|
||||
|
||||
// Build-time version info — injected via -ldflags during docker build.
|
||||
// Falls back to "dev" / "unknown" when built without -ldflags (local dev).
|
||||
var (
|
||||
Version = "dev"
|
||||
Commit = "unknown"
|
||||
)
|
||||
|
||||
func main() {
|
||||
logLevel := slog.LevelInfo
|
||||
if v := os.Getenv("LOG_LEVEL"); v != "" {
|
||||
@@ -183,7 +190,7 @@ func run(log *slog.Logger) error {
|
||||
"pocketbase_url", pbCfg.BaseURL,
|
||||
"pocketbase_email", pbCfg.AdminEmail,
|
||||
)
|
||||
srv := server.New(addr, oCfg, nf, log, store, kokoroURL, kokoroVoice)
|
||||
srv := server.New(addr, oCfg, nf, log, store, kokoroURL, kokoroVoice, Version, Commit)
|
||||
return srv.ListenAndServe(ctx)
|
||||
|
||||
case "save-browse":
|
||||
|
||||
@@ -43,6 +43,8 @@ type Server struct {
|
||||
running bool
|
||||
kokoroURL string // Kokoro-FastAPI base URL, e.g. http://kokoro:8880
|
||||
kokoroVoice string // default voice, e.g. af_bella
|
||||
version string // semver tag, e.g. "v1.2.3" (set via ldflags)
|
||||
commit string // short git SHA (set via ldflags)
|
||||
|
||||
// voiceMu guards cachedVoices.
|
||||
voiceMu sync.RWMutex
|
||||
@@ -74,7 +76,7 @@ type browseCacheEntry struct {
|
||||
}
|
||||
|
||||
// New creates a new Server.
|
||||
func New(addr string, oCfg orchestrator.Config, novel scraper.NovelScraper, log *slog.Logger, store storage.Store, kokoroURL, kokoroVoice string) *Server {
|
||||
func New(addr string, oCfg orchestrator.Config, novel scraper.NovelScraper, log *slog.Logger, store storage.Store, kokoroURL, kokoroVoice, version, commit string) *Server {
|
||||
return &Server{
|
||||
addr: addr,
|
||||
oCfg: oCfg,
|
||||
@@ -83,6 +85,8 @@ func New(addr string, oCfg orchestrator.Config, novel scraper.NovelScraper, log
|
||||
store: store,
|
||||
kokoroURL: kokoroURL,
|
||||
kokoroVoice: kokoroVoice,
|
||||
version: version,
|
||||
commit: commit,
|
||||
audioJobIDs: make(map[string]string),
|
||||
browseInFlight: make(map[string]struct{}),
|
||||
browseMemCache: make(map[string]browseCacheEntry),
|
||||
@@ -138,6 +142,7 @@ func (s *Server) voices() []string {
|
||||
func (s *Server) ListenAndServe(ctx context.Context) error {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /health", s.handleHealth)
|
||||
mux.HandleFunc("GET /api/version", s.handleVersion)
|
||||
mux.HandleFunc("POST /scrape", s.handleScrapeCatalogue)
|
||||
mux.HandleFunc("POST /scrape/book", s.handleScrapeBook)
|
||||
mux.HandleFunc("POST /scrape/book/range", s.handleScrapeBookRange)
|
||||
@@ -222,7 +227,19 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
|
||||
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
"status": "ok",
|
||||
"version": s.version,
|
||||
"commit": s.commit,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleVersion(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
"version": s.version,
|
||||
"commit": s.commit,
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Session cookie helpers ───────────────────────────────────────────────────
|
||||
|
||||
@@ -436,6 +436,7 @@ func (s *PocketBaseStore) EnsureCollections(ctx context.Context) error {
|
||||
{"name": "upvotes", "type": "number"},
|
||||
{"name": "downvotes", "type": "number"},
|
||||
{"name": "created", "type": "date"},
|
||||
{"name": "parent_id", "type": "text"}, // empty = top-level; set = reply to that comment ID
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -448,6 +449,16 @@ func (s *PocketBaseStore) EnsureCollections(ctx context.Context) error {
|
||||
{"name": "vote", "type": "text", "required": true}, // "up" | "down"
|
||||
},
|
||||
},
|
||||
{
|
||||
// follower_id follows followee_id
|
||||
"name": "user_subscriptions",
|
||||
"type": "base",
|
||||
"fields": []map[string]interface{}{
|
||||
{"name": "follower_id", "type": "text", "required": true},
|
||||
{"name": "followee_id", "type": "text", "required": true},
|
||||
{"name": "created", "type": "date"},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, col := range collections {
|
||||
name, _ := col["name"].(string)
|
||||
@@ -486,6 +497,8 @@ var migrations = []migration{
|
||||
{"progress", "user_id", "text"},
|
||||
// avatar_url stores the MinIO presign path for the user's profile picture.
|
||||
{"app_users", "avatar_url", "text"},
|
||||
// parent_id enables 1-level comment nesting (replies). Empty = top-level comment.
|
||||
{"book_comments", "parent_id", "text"},
|
||||
}
|
||||
|
||||
// EnsureMigrations idempotently adds any fields that are missing from existing
|
||||
|
||||
@@ -213,7 +213,8 @@ create_collection "book_comments" '{
|
||||
{"name": "body", "type": "text", "required": true},
|
||||
{"name": "upvotes", "type": "number"},
|
||||
{"name": "downvotes", "type": "number"},
|
||||
{"name": "created", "type": "date"}
|
||||
{"name": "created", "type": "date"},
|
||||
{"name": "parent_id", "type": "text"}
|
||||
]
|
||||
}'
|
||||
|
||||
|
||||
@@ -5,6 +5,15 @@ COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
|
||||
# Build-time version info — injected by docker-compose or CI via --build-arg.
|
||||
ARG BUILD_VERSION=dev
|
||||
ARG BUILD_COMMIT=unknown
|
||||
|
||||
# Expose as PUBLIC_ env vars so SvelteKit's $env/dynamic/public can read them.
|
||||
ENV PUBLIC_BUILD_VERSION=$BUILD_VERSION
|
||||
ENV PUBLIC_BUILD_COMMIT=$BUILD_COMMIT
|
||||
|
||||
RUN npm run build
|
||||
|
||||
# ── Runtime image ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { onDestroy } from 'svelte';
|
||||
import Cropper from 'cropperjs';
|
||||
import 'cropperjs/dist/cropper.css';
|
||||
|
||||
@@ -11,31 +11,54 @@
|
||||
|
||||
let { file, onconfirm, oncancel }: Props = $props();
|
||||
|
||||
let imgEl: HTMLImageElement;
|
||||
let imgEl: HTMLImageElement | undefined = $state();
|
||||
let cropper: Cropper | null = null;
|
||||
let objectUrl = $state('');
|
||||
let objectUrl = '';
|
||||
|
||||
onMount(() => {
|
||||
// Initialize cropper once the img element is bound and the file is known.
|
||||
// Use a $effect so it runs after the DOM is ready (replaces onMount).
|
||||
$effect(() => {
|
||||
if (!imgEl || !file) return;
|
||||
|
||||
// Create the object URL and set src directly on the element (not via reactive
|
||||
// state) so cropperjs sees the correct src before the image load event fires.
|
||||
objectUrl = URL.createObjectURL(file);
|
||||
cropper = new Cropper(imgEl, {
|
||||
aspectRatio: 1,
|
||||
viewMode: 1,
|
||||
dragMode: 'move',
|
||||
autoCropArea: 0.8,
|
||||
restore: false,
|
||||
guides: false,
|
||||
center: true,
|
||||
highlight: false,
|
||||
cropBoxMovable: true,
|
||||
cropBoxResizable: true,
|
||||
toggleDragModeOnDblclick: false,
|
||||
background: false
|
||||
});
|
||||
imgEl.src = objectUrl;
|
||||
|
||||
// Cropperjs must be initialised inside the image's load event so it can
|
||||
// measure the natural dimensions — if we call new Cropper() before the image
|
||||
// has loaded, the crop canvas is blank/invisible.
|
||||
const handleLoad = () => {
|
||||
cropper = new Cropper(imgEl!, {
|
||||
aspectRatio: 1,
|
||||
viewMode: 1,
|
||||
dragMode: 'move',
|
||||
autoCropArea: 0.8,
|
||||
restore: false,
|
||||
guides: false,
|
||||
center: true,
|
||||
highlight: false,
|
||||
cropBoxMovable: true,
|
||||
cropBoxResizable: true,
|
||||
toggleDragModeOnDblclick: false,
|
||||
background: false
|
||||
});
|
||||
};
|
||||
|
||||
imgEl.addEventListener('load', handleLoad, { once: true });
|
||||
|
||||
return () => {
|
||||
imgEl?.removeEventListener('load', handleLoad);
|
||||
cropper?.destroy();
|
||||
cropper = null;
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
objectUrl = '';
|
||||
};
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
cropper?.destroy();
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||
});
|
||||
|
||||
function confirm() {
|
||||
@@ -62,13 +85,14 @@
|
||||
<div class="bg-zinc-900 rounded-2xl border border-zinc-700 shadow-2xl w-full max-w-sm flex flex-col gap-4 p-5">
|
||||
<h2 class="text-base font-semibold text-zinc-100">Crop profile picture</h2>
|
||||
|
||||
<!-- Cropper image container -->
|
||||
<div class="rounded-xl overflow-hidden bg-zinc-800" style="max-height: 340px;">
|
||||
<!-- Cropper image container — overflow must be visible so cropperjs can
|
||||
render the crop canvas outside the natural image bounds. The fixed
|
||||
height gives cropperjs a stable container to size itself against. -->
|
||||
<div class="rounded-xl bg-zinc-800" style="height: 300px; position: relative;">
|
||||
<img
|
||||
bind:this={imgEl}
|
||||
src={objectUrl}
|
||||
alt="Crop preview"
|
||||
style="display:block; max-width:100%;"
|
||||
style="display:block; max-width:100%; max-height:100%;"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -8,39 +8,60 @@
|
||||
upvotes: number;
|
||||
downvotes: number;
|
||||
created: string;
|
||||
parent_id?: string;
|
||||
replies?: BookComment[];
|
||||
}
|
||||
|
||||
let {
|
||||
slug,
|
||||
isLoggedIn = false
|
||||
isLoggedIn = false,
|
||||
currentUserId = ''
|
||||
}: {
|
||||
slug: string;
|
||||
isLoggedIn?: boolean;
|
||||
currentUserId?: string;
|
||||
} = $props();
|
||||
|
||||
// ── State ─────────────────────────────────────────────────────────────────
|
||||
let comments = $state<BookComment[]>([]);
|
||||
let myVotes = $state<Record<string, 'up' | 'down'>>({});
|
||||
let avatarUrls = $state<Record<string, string>>({});
|
||||
let loading = $state(true);
|
||||
let loadError = $state('');
|
||||
|
||||
// Top-level new comment
|
||||
let newBody = $state('');
|
||||
let posting = $state(false);
|
||||
let postError = $state('');
|
||||
|
||||
// Sort
|
||||
let sort = $state<'new' | 'top'>('top');
|
||||
|
||||
// Reply state: which comment is being replied to
|
||||
let replyingTo = $state<string | null>(null); // comment id
|
||||
let replyBody = $state('');
|
||||
let replyPosting = $state(false);
|
||||
let replyError = $state('');
|
||||
|
||||
// Delete in-flight set
|
||||
let deletingIds = $state(new Set<string>());
|
||||
|
||||
// Per-comment vote inflight set (prevents double-clicks)
|
||||
let votingIds = $state(new Set<string>());
|
||||
|
||||
// ── Load comments on mount ────────────────────────────────────────────────
|
||||
// ── Load comments ─────────────────────────────────────────────────────────
|
||||
async function loadComments() {
|
||||
loading = true;
|
||||
loadError = '';
|
||||
try {
|
||||
const res = await fetch(`/api/comments/${encodeURIComponent(slug)}`);
|
||||
const res = await fetch(
|
||||
`/api/comments/${encodeURIComponent(slug)}?sort=${sort}`
|
||||
);
|
||||
if (!res.ok) throw new Error(`${res.status}`);
|
||||
const data = await res.json();
|
||||
comments = data.comments ?? [];
|
||||
myVotes = data.myVotes ?? {};
|
||||
avatarUrls = data.avatarUrls ?? {};
|
||||
} catch (e) {
|
||||
loadError = 'Failed to load comments.';
|
||||
} finally {
|
||||
@@ -48,19 +69,24 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Run once on component mount via $effect
|
||||
$effect(() => {
|
||||
loadComments();
|
||||
});
|
||||
|
||||
// ── Post comment ──────────────────────────────────────────────────────────
|
||||
// Re-load when sort changes (after initial mount)
|
||||
let firstLoad = true;
|
||||
$effect(() => {
|
||||
// Read sort to create a dependency
|
||||
const _ = sort;
|
||||
if (firstLoad) { firstLoad = false; return; }
|
||||
loadComments();
|
||||
});
|
||||
|
||||
// ── Post top-level comment ────────────────────────────────────────────────
|
||||
async function postComment() {
|
||||
const text = newBody.trim();
|
||||
if (!text || posting) return;
|
||||
if (text.length > 2000) {
|
||||
postError = 'Comment is too long (max 2000 characters).';
|
||||
return;
|
||||
}
|
||||
if (text.length > 2000) { postError = 'Comment is too long (max 2000 characters).'; return; }
|
||||
posting = true;
|
||||
postError = '';
|
||||
try {
|
||||
@@ -69,17 +95,20 @@
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ body: text })
|
||||
});
|
||||
if (res.status === 401) {
|
||||
postError = 'You must be logged in to comment.';
|
||||
return;
|
||||
}
|
||||
if (res.status === 401) { postError = 'You must be logged in to comment.'; return; }
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
postError = err.message ?? 'Failed to post comment.';
|
||||
return;
|
||||
}
|
||||
const created: BookComment = await res.json();
|
||||
comments = [created, ...comments];
|
||||
created.replies = [];
|
||||
// Prepend for 'new', or re-sort for 'top'
|
||||
if (sort === 'new') {
|
||||
comments = [created, ...comments];
|
||||
} else {
|
||||
comments = [created, ...comments]; // new comment has 0 score, goes to end after sort would happen
|
||||
}
|
||||
newBody = '';
|
||||
} catch {
|
||||
postError = 'Failed to post comment.';
|
||||
@@ -88,20 +117,88 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ── Post reply ────────────────────────────────────────────────────────────
|
||||
async function postReply(parentId: string) {
|
||||
const text = replyBody.trim();
|
||||
if (!text || replyPosting) return;
|
||||
if (text.length > 2000) { replyError = 'Reply is too long (max 2000 characters).'; return; }
|
||||
replyPosting = true;
|
||||
replyError = '';
|
||||
try {
|
||||
const res = await fetch(`/api/comments/${encodeURIComponent(slug)}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ body: text, parent_id: parentId })
|
||||
});
|
||||
if (res.status === 401) { replyError = 'You must be logged in to reply.'; return; }
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
replyError = err.message ?? 'Failed to post reply.';
|
||||
return;
|
||||
}
|
||||
const created: BookComment = await res.json();
|
||||
// Append to the parent's replies list
|
||||
comments = comments.map((c) => {
|
||||
if (c.id !== parentId) return c;
|
||||
return { ...c, replies: [...(c.replies ?? []), created] };
|
||||
});
|
||||
replyBody = '';
|
||||
replyingTo = null;
|
||||
} catch {
|
||||
replyError = 'Failed to post reply.';
|
||||
} finally {
|
||||
replyPosting = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Delete ────────────────────────────────────────────────────────────────
|
||||
async function deleteComment(commentId: string, parentId?: string) {
|
||||
if (deletingIds.has(commentId)) return;
|
||||
deletingIds = new Set([...deletingIds, commentId]);
|
||||
try {
|
||||
const res = await fetch(`/api/comment/${commentId}`, { method: 'DELETE' });
|
||||
if (!res.ok) return;
|
||||
if (parentId) {
|
||||
// Remove reply from parent
|
||||
comments = comments.map((c) => {
|
||||
if (c.id !== parentId) return c;
|
||||
return { ...c, replies: (c.replies ?? []).filter((r) => r.id !== commentId) };
|
||||
});
|
||||
} else {
|
||||
// Remove top-level comment
|
||||
comments = comments.filter((c) => c.id !== commentId);
|
||||
}
|
||||
} finally {
|
||||
const next = new Set(deletingIds);
|
||||
next.delete(commentId);
|
||||
deletingIds = next;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Vote ──────────────────────────────────────────────────────────────────
|
||||
async function vote(commentId: string, v: 'up' | 'down') {
|
||||
async function vote(commentId: string, v: 'up' | 'down', parentId?: string) {
|
||||
if (votingIds.has(commentId)) return;
|
||||
votingIds = new Set([...votingIds, commentId]);
|
||||
try {
|
||||
const res = await fetch(`/api/comments/${commentId}/vote`, {
|
||||
const res = await fetch(`/api/comment/${commentId}/vote`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ vote: v })
|
||||
});
|
||||
if (!res.ok) return;
|
||||
const updated: BookComment = await res.json();
|
||||
// Update comment in list
|
||||
comments = comments.map((c) => (c.id === commentId ? updated : c));
|
||||
// Update comment in list (handle both top-level and replies)
|
||||
if (parentId) {
|
||||
comments = comments.map((c) => {
|
||||
if (c.id !== parentId) return c;
|
||||
return {
|
||||
...c,
|
||||
replies: (c.replies ?? []).map((r) => (r.id === commentId ? updated : r))
|
||||
};
|
||||
});
|
||||
} else {
|
||||
comments = comments.map((c) => (c.id === commentId ? { ...updated, replies: c.replies } : c));
|
||||
}
|
||||
// Update myVotes: toggle off if same, else set new vote
|
||||
const prev = myVotes[commentId];
|
||||
if (prev === v) {
|
||||
@@ -119,13 +216,24 @@
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
function initials(username: string): string {
|
||||
const name = username.trim() || '?';
|
||||
return name.slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
try {
|
||||
return new Date(iso).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
const date = new Date(iso);
|
||||
const now = Date.now();
|
||||
const diffMs = now - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60_000);
|
||||
if (diffMins < 1) return 'just now';
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
if (diffDays < 30) return `${diffDays}d ago`;
|
||||
return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
@@ -133,15 +241,46 @@
|
||||
|
||||
const charCount = $derived(newBody.length);
|
||||
const charOver = $derived(charCount > 2000);
|
||||
const replyCharCount = $derived(replyBody.length);
|
||||
const replyCharOver = $derived(replyCharCount > 2000);
|
||||
|
||||
const totalCount = $derived(
|
||||
comments.reduce((n, c) => n + 1 + (c.replies?.length ?? 0), 0)
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="mt-10">
|
||||
<h2 class="text-base font-semibold text-zinc-200 mb-4">
|
||||
Comments
|
||||
<!-- Header + sort controls -->
|
||||
<div class="flex items-center justify-between gap-3 mb-4 flex-wrap">
|
||||
<h2 class="text-base font-semibold text-zinc-200">
|
||||
Comments
|
||||
{#if !loading && totalCount > 0}
|
||||
<span class="text-zinc-500 font-normal text-sm ml-1">({totalCount})</span>
|
||||
{/if}
|
||||
</h2>
|
||||
|
||||
<!-- Sort tabs -->
|
||||
{#if !loading && comments.length > 0}
|
||||
<span class="text-zinc-500 font-normal text-sm ml-1">({comments.length})</span>
|
||||
<div class="flex items-center gap-1 text-xs rounded-lg bg-zinc-800/60 p-1">
|
||||
<button
|
||||
onclick={() => (sort = 'top')}
|
||||
class="px-2.5 py-1 rounded-md transition-colors {sort === 'top'
|
||||
? 'bg-zinc-700 text-zinc-100'
|
||||
: 'text-zinc-500 hover:text-zinc-300'}"
|
||||
>
|
||||
Top
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (sort = 'new')}
|
||||
class="px-2.5 py-1 rounded-md transition-colors {sort === 'new'
|
||||
? 'bg-zinc-700 text-zinc-100'
|
||||
: 'text-zinc-500 hover:text-zinc-300'}"
|
||||
>
|
||||
New
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<!-- Post form -->
|
||||
<div class="mb-6">
|
||||
@@ -202,10 +341,24 @@
|
||||
{#each comments as comment (comment.id)}
|
||||
{@const myVote = myVotes[comment.id]}
|
||||
{@const voting = votingIds.has(comment.id)}
|
||||
<div class="rounded-lg bg-zinc-800/50 border border-zinc-700/50 px-4 py-3 flex flex-col gap-2">
|
||||
<!-- Header: username + date -->
|
||||
{@const deleting = deletingIds.has(comment.id)}
|
||||
{@const isOwner = isLoggedIn && currentUserId === comment.user_id}
|
||||
|
||||
<div class="rounded-lg bg-zinc-800/50 border border-zinc-700/50 px-4 py-3 flex flex-col gap-2 {deleting ? 'opacity-50' : ''}">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span class="text-sm font-medium text-zinc-200">{comment.username || 'Anonymous'}</span>
|
||||
{#if avatarUrls[comment.user_id]}
|
||||
<img src={avatarUrls[comment.user_id]} alt={comment.username} class="w-6 h-6 rounded-full object-cover flex-shrink-0" />
|
||||
{:else}
|
||||
<div class="w-6 h-6 rounded-full bg-zinc-700 flex items-center justify-center flex-shrink-0">
|
||||
<span class="text-[9px] font-semibold text-zinc-300 leading-none">{initials(comment.username)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if comment.username}
|
||||
<a href="/users/{comment.username}" class="text-sm font-medium text-zinc-200 hover:text-amber-400 transition-colors">{comment.username}</a>
|
||||
{:else}
|
||||
<span class="text-sm font-medium text-zinc-400">Anonymous</span>
|
||||
{/if}
|
||||
<span class="text-zinc-600 text-xs">·</span>
|
||||
<span class="text-xs text-zinc-500">{formatDate(comment.created)}</span>
|
||||
</div>
|
||||
@@ -213,17 +366,15 @@
|
||||
<!-- Body -->
|
||||
<p class="text-sm text-zinc-300 leading-relaxed whitespace-pre-wrap break-words">{comment.body}</p>
|
||||
|
||||
<!-- Vote row -->
|
||||
<div class="flex items-center gap-3 pt-1">
|
||||
<!-- Actions row: votes + reply + delete -->
|
||||
<div class="flex items-center gap-3 pt-1 flex-wrap">
|
||||
<!-- Upvote -->
|
||||
<button
|
||||
onclick={() => vote(comment.id, 'up')}
|
||||
disabled={voting}
|
||||
title="Upvote"
|
||||
class="flex items-center gap-1 text-xs transition-colors disabled:opacity-50
|
||||
{myVote === 'up'
|
||||
? 'text-amber-400'
|
||||
: 'text-zinc-500 hover:text-zinc-300'}"
|
||||
{myVote === 'up' ? 'text-amber-400' : 'text-zinc-500 hover:text-zinc-300'}"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M14 10h4.764a2 2 0 011.789 2.894l-3.5 7A2 2 0 0115.263 21h-4.017c-.163 0-.326-.02-.485-.06L7 20m7-10V5a2 2 0 00-2-2h-.095c-.5 0-.905.405-.905.905 0 .714-.211 1.412-.608 2.006L7 11v9m7-10h-2M7 20H5a2 2 0 01-2-2v-6a2 2 0 012-2h2.5"/>
|
||||
@@ -237,16 +388,171 @@
|
||||
disabled={voting}
|
||||
title="Downvote"
|
||||
class="flex items-center gap-1 text-xs transition-colors disabled:opacity-50
|
||||
{myVote === 'down'
|
||||
? 'text-red-400'
|
||||
: 'text-zinc-500 hover:text-zinc-300'}"
|
||||
{myVote === 'down' ? 'text-red-400' : 'text-zinc-500 hover:text-zinc-300'}"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10 14H5.236a2 2 0 01-1.789-2.894l3.5-7A2 2 0 018.736 3h4.018a2 2 0 01.485.06l3.76.94m-7 10v5a2 2 0 002 2h.096c.5 0 .905-.405.905-.904 0-.715.211-1.413.608-2.008L17 13V4m-7 10h2m5-10h2a2 2 0 012 2v6a2 2 0 01-2 2h-2.5"/>
|
||||
</svg>
|
||||
<span class="tabular-nums">{comment.downvotes ?? 0}</span>
|
||||
</button>
|
||||
|
||||
<!-- Reply button -->
|
||||
{#if isLoggedIn}
|
||||
<button
|
||||
onclick={() => {
|
||||
if (replyingTo === comment.id) {
|
||||
replyingTo = null;
|
||||
replyBody = '';
|
||||
replyError = '';
|
||||
} else {
|
||||
replyingTo = comment.id;
|
||||
replyBody = '';
|
||||
replyError = '';
|
||||
}
|
||||
}}
|
||||
class="flex items-center gap-1 text-xs transition-colors
|
||||
{replyingTo === comment.id
|
||||
? 'text-amber-400'
|
||||
: 'text-zinc-500 hover:text-zinc-300'}"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"/>
|
||||
</svg>
|
||||
Reply
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Delete (owner only) -->
|
||||
{#if isOwner}
|
||||
<button
|
||||
onclick={() => deleteComment(comment.id)}
|
||||
disabled={deleting}
|
||||
class="flex items-center gap-1 text-xs text-zinc-600 hover:text-red-400 transition-colors disabled:opacity-50 ml-auto"
|
||||
title="Delete comment"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
||||
</svg>
|
||||
Delete
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Inline reply form -->
|
||||
{#if replyingTo === comment.id}
|
||||
<div class="mt-1 flex flex-col gap-2 pl-2 border-l-2 border-zinc-700">
|
||||
<textarea
|
||||
bind:value={replyBody}
|
||||
placeholder="Write a reply…"
|
||||
rows="2"
|
||||
class="w-full px-3 py-2 rounded-lg bg-zinc-900 border border-zinc-700 text-zinc-200 text-sm placeholder-zinc-500 resize-none focus:outline-none focus:border-amber-400 transition-colors"
|
||||
></textarea>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-xs {replyCharOver ? 'text-red-400' : 'text-zinc-600'} tabular-nums">
|
||||
{replyCharCount}/2000
|
||||
</span>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if replyError}
|
||||
<span class="text-xs text-red-400">{replyError}</span>
|
||||
{/if}
|
||||
<button
|
||||
onclick={() => { replyingTo = null; replyBody = ''; replyError = ''; }}
|
||||
class="px-3 py-1 rounded-lg text-xs text-zinc-400 hover:text-zinc-200 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onclick={() => postReply(comment.id)}
|
||||
disabled={replyPosting || !replyBody.trim() || replyCharOver}
|
||||
class="px-3 py-1.5 rounded-lg text-xs font-medium transition-colors
|
||||
{replyPosting || !replyBody.trim() || replyCharOver
|
||||
? 'bg-zinc-700 text-zinc-500 cursor-not-allowed'
|
||||
: 'bg-amber-400 text-zinc-900 hover:bg-amber-300'}"
|
||||
>
|
||||
{replyPosting ? 'Posting…' : 'Reply'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Replies -->
|
||||
{#if comment.replies && comment.replies.length > 0}
|
||||
<div class="mt-1 flex flex-col gap-2 pl-3 border-l-2 border-zinc-700/60">
|
||||
{#each comment.replies as reply (reply.id)}
|
||||
{@const replyVote = myVotes[reply.id]}
|
||||
{@const replyVoting = votingIds.has(reply.id)}
|
||||
{@const replyDeleting = deletingIds.has(reply.id)}
|
||||
{@const replyIsOwner = isLoggedIn && currentUserId === reply.user_id}
|
||||
|
||||
<div class="rounded-md bg-zinc-800/30 px-3 py-2.5 flex flex-col gap-1.5 {replyDeleting ? 'opacity-50' : ''}">
|
||||
<!-- Reply header -->
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
{#if avatarUrls[reply.user_id]}
|
||||
<img src={avatarUrls[reply.user_id]} alt={reply.username} class="w-5 h-5 rounded-full object-cover flex-shrink-0" />
|
||||
{:else}
|
||||
<div class="w-5 h-5 rounded-full bg-zinc-700 flex items-center justify-center flex-shrink-0">
|
||||
<span class="text-[8px] font-semibold text-zinc-300 leading-none">{initials(reply.username)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if reply.username}
|
||||
<a href="/users/{reply.username}" class="text-xs font-medium text-zinc-300 hover:text-amber-400 transition-colors">{reply.username}</a>
|
||||
{:else}
|
||||
<span class="text-xs font-medium text-zinc-400">Anonymous</span>
|
||||
{/if}
|
||||
<span class="text-zinc-600 text-xs">·</span>
|
||||
<span class="text-xs text-zinc-500">{formatDate(reply.created)}</span>
|
||||
</div>
|
||||
|
||||
<!-- Reply body -->
|
||||
<p class="text-sm text-zinc-300 leading-relaxed whitespace-pre-wrap break-words">{reply.body}</p>
|
||||
|
||||
<!-- Reply actions -->
|
||||
<div class="flex items-center gap-3 pt-0.5">
|
||||
<button
|
||||
onclick={() => vote(reply.id, 'up', comment.id)}
|
||||
disabled={replyVoting}
|
||||
title="Upvote"
|
||||
class="flex items-center gap-1 text-xs transition-colors disabled:opacity-50
|
||||
{replyVote === 'up' ? 'text-amber-400' : 'text-zinc-500 hover:text-zinc-300'}"
|
||||
>
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M14 10h4.764a2 2 0 011.789 2.894l-3.5 7A2 2 0 0115.263 21h-4.017c-.163 0-.326-.02-.485-.06L7 20m7-10V5a2 2 0 00-2-2h-.095c-.5 0-.905.405-.905.905 0 .714-.211 1.412-.608 2.006L7 11v9m7-10h-2M7 20H5a2 2 0 01-2-2v-6a2 2 0 012-2h2.5"/>
|
||||
</svg>
|
||||
<span class="tabular-nums">{reply.upvotes ?? 0}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onclick={() => vote(reply.id, 'down', comment.id)}
|
||||
disabled={replyVoting}
|
||||
title="Downvote"
|
||||
class="flex items-center gap-1 text-xs transition-colors disabled:opacity-50
|
||||
{replyVote === 'down' ? 'text-red-400' : 'text-zinc-500 hover:text-zinc-300'}"
|
||||
>
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10 14H5.236a2 2 0 01-1.789-2.894l3.5-7A2 2 0 018.736 3h4.018a2 2 0 01.485.06l3.76.94m-7 10v5a2 2 0 002 2h.096c.5 0 .905-.405.905-.904 0-.715.211-1.413.608-2.008L17 13V4m-7 10h2m5-10h2a2 2 0 012 2v6a2 2 0 01-2 2h-2.5"/>
|
||||
</svg>
|
||||
<span class="tabular-nums">{reply.downvotes ?? 0}</span>
|
||||
</button>
|
||||
|
||||
{#if replyIsOwner}
|
||||
<button
|
||||
onclick={() => deleteComment(reply.id, comment.id)}
|
||||
disabled={replyDeleting}
|
||||
class="flex items-center gap-1 text-xs text-zinc-600 hover:text-red-400 transition-colors disabled:opacity-50 ml-auto"
|
||||
title="Delete reply"
|
||||
>
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
||||
</svg>
|
||||
Delete
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -127,6 +127,14 @@ async function pbPatch(path: string, body: unknown): Promise<Response> {
|
||||
});
|
||||
}
|
||||
|
||||
async function pbDelete(path: string): Promise<Response> {
|
||||
const token = await getToken();
|
||||
return fetch(`${PB_URL}${path}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
}
|
||||
|
||||
interface PBList<T> {
|
||||
items: T[];
|
||||
totalItems: number;
|
||||
@@ -290,6 +298,38 @@ export async function setProgress(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete progress entry for a specific book (removes from library/continue reading).
|
||||
*/
|
||||
export async function deleteProgress(
|
||||
sessionId: string,
|
||||
slug: string,
|
||||
userId?: string
|
||||
): Promise<void> {
|
||||
const existing = await listOne<Progress & { id: string }>(
|
||||
'progress',
|
||||
progressFilter(sessionId, slug, userId)
|
||||
);
|
||||
|
||||
if (!existing) {
|
||||
log.debug('pocketbase', 'deleteProgress: no record found', { sessionId, slug, userId });
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await pbDelete(`/api/collections/progress/records/${existing.id}`);
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
log.error('pocketbase', 'deleteProgress failed', {
|
||||
slug,
|
||||
id: existing.id,
|
||||
status: res.status,
|
||||
body
|
||||
});
|
||||
throw new Error(`Failed to delete progress: ${res.status}`);
|
||||
}
|
||||
log.info('pocketbase', 'deleteProgress success', { slug, id: existing.id });
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge anonymous session progress into a user account on login/register.
|
||||
*
|
||||
@@ -823,6 +863,7 @@ export interface BookComment {
|
||||
upvotes: number;
|
||||
downvotes: number;
|
||||
created: string;
|
||||
parent_id?: string; // empty / absent = top-level; set = reply
|
||||
}
|
||||
|
||||
export interface CommentVote {
|
||||
@@ -833,14 +874,54 @@ export interface CommentVote {
|
||||
vote: 'up' | 'down';
|
||||
}
|
||||
|
||||
export type CommentSort = 'top' | 'new';
|
||||
|
||||
/**
|
||||
* List comments for a book, newest first, up to 100.
|
||||
* List top-level comments for a book.
|
||||
* sort='top' → by net score (upvotes − downvotes) desc, then newest
|
||||
* sort='new' → newest first (default)
|
||||
* Replies (parent_id != "") are NOT included — fetch them separately.
|
||||
*/
|
||||
export async function listComments(slug: string): Promise<BookComment[]> {
|
||||
export async function listComments(
|
||||
slug: string,
|
||||
sort: CommentSort = 'new'
|
||||
): Promise<BookComment[]> {
|
||||
const token = await getToken();
|
||||
const filter = encodeURIComponent(`slug="${slug.replace(/"/g, '\\"')}"`);
|
||||
const slugEsc = slug.replace(/"/g, '\\"');
|
||||
// Only top-level comments (parent_id is empty or missing)
|
||||
const filter = encodeURIComponent(`slug="${slugEsc}"&&(parent_id=""||parent_id=null)`);
|
||||
// PocketBase sorts: for 'top' we still fetch all and re-sort in JS because
|
||||
// PocketBase doesn't support computed sort fields. For 'new' we push the
|
||||
// sort down to the DB so large result sets are still paged correctly.
|
||||
const pbSort = sort === 'new' ? '&sort=-created' : '&sort=-created';
|
||||
const res = await fetch(
|
||||
`${PB_URL}/api/collections/book_comments/records?filter=${filter}&sort=-created&perPage=100`,
|
||||
`${PB_URL}/api/collections/book_comments/records?filter=${filter}${pbSort}&perPage=200`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } }
|
||||
);
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
let items = (data.items ?? []) as BookComment[];
|
||||
if (sort === 'top') {
|
||||
items = items.sort((a, b) => {
|
||||
const scoreB = (b.upvotes ?? 0) - (b.downvotes ?? 0);
|
||||
const scoreA = (a.upvotes ?? 0) - (a.downvotes ?? 0);
|
||||
if (scoreB !== scoreA) return scoreB - scoreA;
|
||||
// tie-break: newest first
|
||||
return new Date(b.created).getTime() - new Date(a.created).getTime();
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* List replies (1-level deep) for a single parent comment.
|
||||
* Always sorted oldest-first so the conversation reads naturally.
|
||||
*/
|
||||
export async function listReplies(parentId: string): Promise<BookComment[]> {
|
||||
const token = await getToken();
|
||||
const filter = encodeURIComponent(`parent_id="${parentId.replace(/"/g, '\\"')}"`);
|
||||
const res = await fetch(
|
||||
`${PB_URL}/api/collections/book_comments/records?filter=${filter}&sort=created&perPage=100`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } }
|
||||
);
|
||||
if (!res.ok) return [];
|
||||
@@ -850,12 +931,14 @@ export async function listComments(slug: string): Promise<BookComment[]> {
|
||||
|
||||
/**
|
||||
* Create a new comment. Returns the created record.
|
||||
* Pass parentId to create a reply; omit / pass undefined for a top-level comment.
|
||||
*/
|
||||
export async function createComment(
|
||||
slug: string,
|
||||
body: string,
|
||||
userId: string | undefined,
|
||||
username: string
|
||||
username: string,
|
||||
parentId?: string
|
||||
): Promise<BookComment> {
|
||||
const token = await getToken();
|
||||
const res = await fetch(`${PB_URL}/api/collections/book_comments/records`, {
|
||||
@@ -868,6 +951,7 @@ export async function createComment(
|
||||
username,
|
||||
upvotes: 0,
|
||||
downvotes: 0,
|
||||
parent_id: parentId ?? '',
|
||||
created: new Date().toISOString()
|
||||
})
|
||||
});
|
||||
@@ -878,6 +962,49 @@ export async function createComment(
|
||||
return res.json() as Promise<BookComment>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a comment (and optionally its replies) by ID.
|
||||
* Only the comment owner (matched by userId) may delete.
|
||||
* Throws if the comment doesn't exist or the user doesn't own it.
|
||||
*/
|
||||
export async function deleteComment(commentId: string, userId: string): Promise<void> {
|
||||
const token = await getToken();
|
||||
|
||||
// Fetch the comment to verify ownership
|
||||
const getRes = await fetch(`${PB_URL}/api/collections/book_comments/records/${commentId}`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
if (!getRes.ok) throw new Error(`Comment not found: ${commentId}`);
|
||||
const comment = (await getRes.json()) as BookComment;
|
||||
if (comment.user_id !== userId) throw new Error('Not authorized to delete this comment');
|
||||
|
||||
// Delete any replies first
|
||||
const repliesFilter = encodeURIComponent(`parent_id="${commentId.replace(/"/g, '\\"')}"`);
|
||||
const repliesRes = await fetch(
|
||||
`${PB_URL}/api/collections/book_comments/records?filter=${repliesFilter}&perPage=100`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } }
|
||||
);
|
||||
if (repliesRes.ok) {
|
||||
const repliesData = await repliesRes.json();
|
||||
const replies = (repliesData.items ?? []) as BookComment[];
|
||||
await Promise.all(
|
||||
replies.map((r) =>
|
||||
fetch(`${PB_URL}/api/collections/book_comments/records/${r.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Delete the comment itself
|
||||
const delRes = await fetch(`${PB_URL}/api/collections/book_comments/records/${commentId}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
if (!delRes.ok) throw new Error(`deleteComment failed: ${delRes.status}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an existing vote by this voter (identified by user_id or session_id) on a comment.
|
||||
*/
|
||||
@@ -992,3 +1119,231 @@ export async function getMyVotes(
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// ─── User subscriptions ───────────────────────────────────────────────────────
|
||||
|
||||
export interface UserSubscription {
|
||||
id: string;
|
||||
follower_id: string;
|
||||
followee_id: string;
|
||||
created: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the subscription record if follower_id follows followee_id, else null.
|
||||
*/
|
||||
export async function getSubscription(
|
||||
followerId: string,
|
||||
followeeId: string
|
||||
): Promise<UserSubscription | null> {
|
||||
const filter = encodeURIComponent(`follower_id="${followerId}"&&followee_id="${followeeId}"`);
|
||||
const res = await pbGet<{ items: UserSubscription[]; totalItems: number }>(
|
||||
`/api/collections/user_subscriptions/records?filter=${filter}&perPage=1`
|
||||
).catch(() => null);
|
||||
return res?.items?.[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe follower_id to followee_id. No-ops if already subscribed.
|
||||
* Returns the subscription record.
|
||||
*/
|
||||
export async function subscribe(followerId: string, followeeId: string): Promise<void> {
|
||||
const existing = await getSubscription(followerId, followeeId);
|
||||
if (existing) return;
|
||||
const res = await pbPost('/api/collections/user_subscriptions/records', {
|
||||
follower_id: followerId,
|
||||
followee_id: followeeId,
|
||||
created: new Date().toISOString()
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
throw new Error(`Failed to subscribe: ${res.status} — ${body}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe follower_id from followee_id. No-ops if not subscribed.
|
||||
*/
|
||||
export async function unsubscribe(followerId: string, followeeId: string): Promise<void> {
|
||||
const existing = await getSubscription(followerId, followeeId);
|
||||
if (!existing) return;
|
||||
const token = await getToken();
|
||||
await fetch(`${PB_URL}/api/collections/user_subscriptions/records/${existing.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the list of user IDs that followerId is subscribed to.
|
||||
*/
|
||||
export async function getFollowingIds(followerId: string): Promise<string[]> {
|
||||
const items = await listAll<UserSubscription>(
|
||||
'user_subscriptions',
|
||||
`follower_id="${followerId}"`,
|
||||
'-created'
|
||||
).catch(() => [] as UserSubscription[]);
|
||||
return items.map((s) => s.followee_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the count of subscribers (followers) for a given user.
|
||||
*/
|
||||
export async function getFollowerCount(followeeId: string): Promise<number> {
|
||||
return countCollection('user_subscriptions', `followee_id="${followeeId}"`).catch(() => 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the count of accounts a user is following.
|
||||
*/
|
||||
export async function getFollowingCount(followerId: string): Promise<number> {
|
||||
return countCollection('user_subscriptions', `follower_id="${followerId}"`).catch(() => 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Public profile data for a user.
|
||||
*/
|
||||
export interface PublicProfile {
|
||||
id: string;
|
||||
username: string;
|
||||
avatar_url?: string;
|
||||
created: string;
|
||||
followerCount: number;
|
||||
followingCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a user's public profile (no sensitive fields) by username.
|
||||
*/
|
||||
export async function getPublicProfile(username: string): Promise<PublicProfile | null> {
|
||||
const user = await getUserByUsername(username);
|
||||
if (!user) return null;
|
||||
const [followerCount, followingCount] = await Promise.all([
|
||||
getFollowerCount(user.id),
|
||||
getFollowingCount(user.id)
|
||||
]);
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
avatar_url: user.avatar_url,
|
||||
created: user.created,
|
||||
followerCount,
|
||||
followingCount
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a user's public library: books they have saved or are reading.
|
||||
* Only includes books with progress or explicit saves (user_library).
|
||||
*/
|
||||
export async function getUserPublicLibrary(
|
||||
userId: string
|
||||
): Promise<Array<{ book: Book; chapter: number | null; saved: boolean }>> {
|
||||
const [allBooks, progressList, savedEntries] = await Promise.all([
|
||||
listBooks(),
|
||||
listAll<Progress>('progress', `user_id="${userId}"`, '-updated').catch(() => [] as Progress[]),
|
||||
listAll<{ id: string; slug: string; saved_at: string }>(
|
||||
'user_library',
|
||||
`user_id="${userId}"`,
|
||||
'-saved_at'
|
||||
).catch(() => [] as { id: string; slug: string; saved_at: string }[])
|
||||
]);
|
||||
|
||||
const bookMap = new Map<string, Book>(allBooks.map((b) => [b.slug, b]));
|
||||
const result: Array<{ book: Book; chapter: number | null; saved: boolean }> = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
// Books with progress first (most recently read)
|
||||
for (const p of progressList) {
|
||||
const book = bookMap.get(p.slug);
|
||||
if (!book || seen.has(p.slug)) continue;
|
||||
seen.add(p.slug);
|
||||
result.push({ book, chapter: p.chapter, saved: false });
|
||||
}
|
||||
|
||||
// Saved-only books next
|
||||
for (const e of savedEntries) {
|
||||
const book = bookMap.get(e.slug);
|
||||
if (!book || seen.has(e.slug)) continue;
|
||||
seen.add(e.slug);
|
||||
result.push({ book, chapter: null, saved: true });
|
||||
}
|
||||
|
||||
// Mark saved flag for books that are both in progress AND saved
|
||||
const savedSlugs = new Set(savedEntries.map((e) => e.slug));
|
||||
return result.map((r) => ({ ...r, saved: savedSlugs.has(r.book.slug) }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the currently-reading books (books with progress, not completed)
|
||||
* for a given user ID.
|
||||
*/
|
||||
export async function getUserCurrentlyReading(
|
||||
userId: string
|
||||
): Promise<Array<{ book: Book; chapter: number }>> {
|
||||
const [allBooks, progressList] = await Promise.all([
|
||||
listBooks(),
|
||||
listAll<Progress>('progress', `user_id="${userId}"`, '-updated').catch(() => [] as Progress[])
|
||||
]);
|
||||
const bookMap = new Map<string, Book>(allBooks.map((b) => [b.slug, b]));
|
||||
return progressList
|
||||
.filter((p) => {
|
||||
const book = bookMap.get(p.slug);
|
||||
return book && p.chapter > 0 && p.chapter < book.total_chapters;
|
||||
})
|
||||
.slice(0, 10)
|
||||
.map((p) => ({ book: bookMap.get(p.slug)!, chapter: p.chapter }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns recently-updated books from ALL users that followerId is subscribed to.
|
||||
* Deduplicates across followed users; sorts by most recently updated.
|
||||
*/
|
||||
export async function getSubscriptionFeed(
|
||||
followerId: string,
|
||||
limit = 12
|
||||
): Promise<Array<{ book: Book; readerUsername: string }>> {
|
||||
const followingIds = await getFollowingIds(followerId);
|
||||
if (followingIds.length === 0) return [];
|
||||
|
||||
// Fetch all users we follow (for display names)
|
||||
const token = await getToken();
|
||||
const userFetches = followingIds.map((id) =>
|
||||
fetch(`${PB_URL}/api/collections/app_users/records/${id}`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
})
|
||||
.then((r) => (r.ok ? (r.json() as Promise<User>) : null))
|
||||
.catch(() => null)
|
||||
);
|
||||
const users = (await Promise.all(userFetches)).filter(Boolean) as User[];
|
||||
const userMap = new Map<string, User>(users.map((u) => [u.id, u]));
|
||||
|
||||
// Fetch progress for each followed user
|
||||
const progressFetches = followingIds.map((id) =>
|
||||
listAll<Progress>('progress', `user_id="${id}"`, '-updated').catch(() => [] as Progress[])
|
||||
);
|
||||
const allProgressArrays = await Promise.all(progressFetches);
|
||||
|
||||
const allBooks = await listBooks();
|
||||
const bookMap = new Map<string, Book>(allBooks.map((b) => [b.slug, b]));
|
||||
|
||||
// Merge: per slug take the most-recent progress entry
|
||||
const seen = new Set<string>();
|
||||
const feed: Array<{ book: Book; readerUsername: string; updated: string }> = [];
|
||||
|
||||
for (let i = 0; i < followingIds.length; i++) {
|
||||
const uid = followingIds[i];
|
||||
const username = userMap.get(uid)?.username ?? 'unknown';
|
||||
for (const p of allProgressArrays[i]) {
|
||||
if (seen.has(p.slug)) continue;
|
||||
const book = bookMap.get(p.slug);
|
||||
if (!book) continue;
|
||||
seen.add(p.slug);
|
||||
feed.push({ book, readerUsername: username, updated: p.updated });
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by most recently read across all followed users
|
||||
feed.sort((a, b) => b.updated.localeCompare(a.updated));
|
||||
return feed.slice(0, limit).map(({ book, readerUsername }) => ({ book, readerUsername }));
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { LayoutData } from './$types';
|
||||
import { audioStore } from '$lib/audio.svelte';
|
||||
import { env } from '$env/dynamic/public';
|
||||
|
||||
let { children, data }: { children: Snippet; data: LayoutData } = $props();
|
||||
|
||||
@@ -395,6 +396,9 @@
|
||||
<a href="/privacy" class="hover:text-zinc-500 transition-colors">Privacy</a>
|
||||
<a href="/dmca" class="hover:text-zinc-500 transition-colors">DMCA</a>
|
||||
<span>© {new Date().getFullYear()} libnovel</span>
|
||||
{#if env.PUBLIC_BUILD_VERSION && env.PUBLIC_BUILD_VERSION !== 'dev'}
|
||||
<span class="text-zinc-800">{env.PUBLIC_BUILD_VERSION}+{env.PUBLIC_BUILD_COMMIT?.slice(0, 7)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -3,7 +3,8 @@ import {
|
||||
listBooks,
|
||||
recentlyAddedBooks,
|
||||
allProgress,
|
||||
getHomeStats
|
||||
getHomeStats,
|
||||
getSubscriptionFeed
|
||||
} from '$lib/server/pocketbase';
|
||||
import { log } from '$lib/server/logger';
|
||||
import type { Book, Progress } from '$lib/server/pocketbase';
|
||||
@@ -38,9 +39,18 @@ export const load: PageServerLoad = async ({ locals }) => {
|
||||
const inProgressSlugs = new Set(continueReading.map((c) => c.book.slug));
|
||||
const recentlyUpdated = recentBooks.filter((b) => !inProgressSlugs.has(b.slug)).slice(0, 6);
|
||||
|
||||
// Subscription feed — only when logged in
|
||||
const subscriptionFeed = locals.user
|
||||
? await getSubscriptionFeed(locals.user.id, 12).catch((e) => {
|
||||
log.error('home', 'failed to load subscription feed', { err: String(e) });
|
||||
return [] as Awaited<ReturnType<typeof getSubscriptionFeed>>;
|
||||
})
|
||||
: [];
|
||||
|
||||
return {
|
||||
continueReading,
|
||||
recentlyUpdated,
|
||||
subscriptionFeed,
|
||||
stats: {
|
||||
...stats,
|
||||
booksInProgress: continueReading.length
|
||||
|
||||
@@ -147,3 +147,56 @@
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- From Subscriptions -->
|
||||
{#if data.subscriptionFeed.length > 0}
|
||||
<section class="mb-10">
|
||||
<div class="flex items-baseline justify-between mb-3">
|
||||
<h2 class="text-lg font-bold text-zinc-100">From People You Follow</h2>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||
{#each data.subscriptionFeed as { book, readerUsername }}
|
||||
{@const genres = parseGenres(book.genres)}
|
||||
<a
|
||||
href="/books/{book.slug}"
|
||||
class="group flex flex-col rounded-lg overflow-hidden bg-zinc-800 hover:bg-zinc-700 transition-colors border border-zinc-700 hover:border-zinc-500"
|
||||
>
|
||||
<div class="aspect-[2/3] bg-zinc-900 overflow-hidden">
|
||||
{#if book.cover}
|
||||
<img
|
||||
src={book.cover}
|
||||
alt={book.title}
|
||||
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
loading="lazy"
|
||||
/>
|
||||
{:else}
|
||||
<div class="w-full h-full flex items-center justify-center text-zinc-600">
|
||||
<svg class="w-10 h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
||||
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="p-2 flex flex-col gap-1">
|
||||
<h3 class="text-xs font-semibold text-zinc-100 line-clamp-2 leading-snug">{book.title ?? ''}</h3>
|
||||
{#if book.author}
|
||||
<p class="text-xs text-zinc-400 truncate">{book.author}</p>
|
||||
{/if}
|
||||
<!-- Reader attribution -->
|
||||
<p class="text-xs text-zinc-600 truncate mt-0.5">
|
||||
via <span class="text-amber-500/70">{readerUsername}</span>
|
||||
</p>
|
||||
{#if genres.length > 0}
|
||||
<div class="flex flex-wrap gap-1 mt-auto pt-1">
|
||||
{#each genres.slice(0, 1) as genre}
|
||||
<span class="text-xs px-1 py-0.5 rounded bg-zinc-900 text-zinc-500">{genre}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
@@ -104,7 +104,7 @@ export const GET: RequestHandler = async ({ params, url, locals }) => {
|
||||
const res = await fetch(presignUrl);
|
||||
if (!res.ok) throw new Error(`MinIO returned ${res.status}`);
|
||||
const markdown = await res.text();
|
||||
html = await marked(markdown, { async: true });
|
||||
html = marked(markdown) as string;
|
||||
} catch (e) {
|
||||
log.error('api/chapter', 'failed to fetch chapter content', { slug, n, err: String(e) });
|
||||
}
|
||||
|
||||
26
ui/src/routes/api/comment/[id]/+server.ts
Normal file
26
ui/src/routes/api/comment/[id]/+server.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { deleteComment } from '$lib/server/pocketbase';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
/**
|
||||
* DELETE /api/comment/[id]
|
||||
* Deletes a comment and its replies. Only the comment owner may delete.
|
||||
* Requires authentication.
|
||||
*/
|
||||
export const DELETE: RequestHandler = async ({ params, locals }) => {
|
||||
if (!locals.user) error(401, 'Login required');
|
||||
|
||||
const { id } = params;
|
||||
|
||||
try {
|
||||
await deleteComment(id, locals.user.id);
|
||||
return new Response(null, { status: 204 });
|
||||
} catch (e) {
|
||||
const msg = String(e);
|
||||
if (msg.includes('Not authorized')) error(403, 'Not authorized to delete this comment');
|
||||
if (msg.includes('not found')) error(404, 'Comment not found');
|
||||
log.error('api/comment/[id]', 'deleteComment failed', { id, err: msg });
|
||||
error(500, 'Failed to delete comment');
|
||||
}
|
||||
};
|
||||
@@ -4,7 +4,7 @@ import { voteComment } from '$lib/server/pocketbase';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
/**
|
||||
* POST /api/comments/[id]/vote
|
||||
* POST /api/comment/[id]/vote
|
||||
* Body: { vote: 'up' | 'down' }
|
||||
* Casts, changes, or toggles off a vote on a comment.
|
||||
* Works for both authenticated and anonymous users (session-scoped).
|
||||
@@ -27,7 +27,7 @@ export const POST: RequestHandler = async ({ params, request, locals }) => {
|
||||
const updated = await voteComment(id, body.vote, locals.sessionId, locals.user?.id);
|
||||
return json(updated);
|
||||
} catch (e) {
|
||||
log.error('api/comments/[id]/vote', 'voteComment failed', { id, err: String(e) });
|
||||
log.error('api/comment/[id]/vote', 'voteComment failed', { id, err: String(e) });
|
||||
error(500, 'Failed to record vote');
|
||||
}
|
||||
};
|
||||
@@ -1,23 +1,62 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { listComments, createComment, getMyVotes } from '$lib/server/pocketbase';
|
||||
import {
|
||||
listComments,
|
||||
listReplies,
|
||||
createComment,
|
||||
getMyVotes,
|
||||
type CommentSort
|
||||
} from '$lib/server/pocketbase';
|
||||
import { presignAvatarUrl } from '$lib/server/minio';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
/**
|
||||
* GET /api/comments/[slug]
|
||||
* Returns comments for a book + the current visitor's votes.
|
||||
* Response: { comments: BookComment[], myVotes: Record<string, 'up'|'down'> }
|
||||
* GET /api/comments/[slug]?sort=new|top
|
||||
* Returns top-level comments + their replies + current visitor's votes + avatar URLs.
|
||||
* Response: { comments: BookComment[], myVotes: Record<string, 'up'|'down'>, avatarUrls: Record<string, string> }
|
||||
* Each top-level comment has a `replies` array attached.
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ params, locals }) => {
|
||||
export const GET: RequestHandler = async ({ params, url, locals }) => {
|
||||
const { slug } = params;
|
||||
const sortParam = url.searchParams.get('sort') ?? 'new';
|
||||
const sort: CommentSort = sortParam === 'top' ? 'top' : 'new';
|
||||
|
||||
try {
|
||||
const comments = await listComments(slug);
|
||||
const myVotes = await getMyVotes(
|
||||
comments.map((c) => c.id),
|
||||
locals.sessionId,
|
||||
locals.user?.id
|
||||
const topLevel = await listComments(slug, sort);
|
||||
|
||||
// Fetch replies for all top-level comments in parallel
|
||||
const repliesPerComment = await Promise.all(topLevel.map((c) => listReplies(c.id)));
|
||||
const allReplies = repliesPerComment.flat();
|
||||
|
||||
// Build comment+reply list for vote lookup
|
||||
const allIds = [...topLevel.map((c) => c.id), ...allReplies.map((r) => r.id)];
|
||||
const myVotes = await getMyVotes(allIds, locals.sessionId, locals.user?.id);
|
||||
|
||||
// Attach replies to each top-level comment
|
||||
const comments = topLevel.map((c, i) => ({
|
||||
...c,
|
||||
replies: repliesPerComment[i]
|
||||
}));
|
||||
|
||||
// Batch-resolve avatar presign URLs for all unique user_ids
|
||||
const allComments = [...topLevel, ...allReplies];
|
||||
const uniqueUserIds = [...new Set(allComments.map((c) => c.user_id).filter(Boolean))];
|
||||
const avatarEntries = await Promise.all(
|
||||
uniqueUserIds.map(async (userId) => {
|
||||
try {
|
||||
const url = await presignAvatarUrl(userId);
|
||||
return [userId, url] as [string, string | null];
|
||||
} catch {
|
||||
return [userId, null] as [string, null];
|
||||
}
|
||||
})
|
||||
);
|
||||
return json({ comments, myVotes });
|
||||
const avatarUrls: Record<string, string> = {};
|
||||
for (const [userId, url] of avatarEntries) {
|
||||
if (url) avatarUrls[userId] = url;
|
||||
}
|
||||
|
||||
return json({ comments, myVotes, avatarUrls });
|
||||
} catch (e) {
|
||||
log.error('api/comments/[slug]', 'listComments failed', { slug, err: String(e) });
|
||||
error(500, 'Failed to load comments');
|
||||
@@ -26,14 +65,14 @@ export const GET: RequestHandler = async ({ params, locals }) => {
|
||||
|
||||
/**
|
||||
* POST /api/comments/[slug]
|
||||
* Body: { body: string }
|
||||
* Creates a new comment. Requires authentication.
|
||||
* Body: { body: string, parent_id?: string }
|
||||
* Creates a new comment or reply. Requires authentication.
|
||||
*/
|
||||
export const POST: RequestHandler = async ({ params, request, locals }) => {
|
||||
if (!locals.user) error(401, 'Login required to comment');
|
||||
|
||||
const { slug } = params;
|
||||
let body: { body?: string };
|
||||
let body: { body?: string; parent_id?: string };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
@@ -44,8 +83,17 @@ export const POST: RequestHandler = async ({ params, request, locals }) => {
|
||||
if (!text) error(400, 'Comment body is required');
|
||||
if (text.length > 2000) error(400, 'Comment is too long (max 2000 characters)');
|
||||
|
||||
// Enforce 1-level depth: parent_id must be a top-level comment
|
||||
const parentId = body.parent_id?.trim() || undefined;
|
||||
|
||||
try {
|
||||
const comment = await createComment(slug, text, locals.user.id, locals.user.username);
|
||||
const comment = await createComment(
|
||||
slug,
|
||||
text,
|
||||
locals.user.id,
|
||||
locals.user.username,
|
||||
parentId
|
||||
);
|
||||
return json(comment, { status: 201 });
|
||||
} catch (e) {
|
||||
log.error('api/comments/[slug]', 'createComment failed', { slug, err: String(e) });
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { listBooks, recentlyAddedBooks, allProgress, getHomeStats } from '$lib/server/pocketbase';
|
||||
import {
|
||||
listBooks,
|
||||
recentlyAddedBooks,
|
||||
allProgress,
|
||||
getHomeStats,
|
||||
getSubscriptionFeed
|
||||
} from '$lib/server/pocketbase';
|
||||
import { log } from '$lib/server/logger';
|
||||
import type { Book, Progress } from '$lib/server/pocketbase';
|
||||
|
||||
/**
|
||||
* GET /api/home
|
||||
* Returns home screen data: continue-reading list, recently updated books, and stats.
|
||||
* Returns home screen data: continue-reading list, recently updated books, stats,
|
||||
* and subscription feed (books recently read by followed users).
|
||||
* Requires authentication (enforced by layout guard).
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ locals }) => {
|
||||
@@ -36,6 +43,12 @@ export const GET: RequestHandler = async ({ locals }) => {
|
||||
const inProgressSlugs = new Set(continueReading.map((c) => c.book.slug));
|
||||
const recentlyUpdated = recentBooks.filter((b) => !inProgressSlugs.has(b.slug)).slice(0, 6);
|
||||
|
||||
// Subscription feed — only available for logged-in users with following
|
||||
let subscriptionFeed: Array<{ book: Book; readerUsername: string }> = [];
|
||||
if (locals.user?.id) {
|
||||
subscriptionFeed = await getSubscriptionFeed(locals.user.id).catch(() => []);
|
||||
}
|
||||
|
||||
return json({
|
||||
continue_reading: continueReading,
|
||||
recently_updated: recentlyUpdated,
|
||||
@@ -43,6 +56,10 @@ export const GET: RequestHandler = async ({ locals }) => {
|
||||
totalBooks: stats.totalBooks,
|
||||
totalChapters: stats.totalChapters,
|
||||
booksInProgress: continueReading.length
|
||||
}
|
||||
},
|
||||
subscription_feed: subscriptionFeed.map((item) => ({
|
||||
book: item.book,
|
||||
readerUsername: item.readerUsername
|
||||
}))
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { setProgress } from '$lib/server/pocketbase';
|
||||
import { setProgress, deleteProgress } from '$lib/server/pocketbase';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
/**
|
||||
@@ -32,3 +32,23 @@ export const POST: RequestHandler = async ({ params, request, locals }) => {
|
||||
|
||||
return json({ ok: true });
|
||||
};
|
||||
|
||||
/**
|
||||
* DELETE /api/progress/[slug]
|
||||
* Removes reading progress for a specific book (removes from library/continue reading).
|
||||
*/
|
||||
export const DELETE: RequestHandler = async ({ params, locals }) => {
|
||||
const { slug } = params;
|
||||
|
||||
try {
|
||||
await deleteProgress(locals.sessionId, slug, locals.user?.id);
|
||||
} catch (e) {
|
||||
log.error('api/progress/[slug]', 'deleteProgress failed', {
|
||||
slug,
|
||||
err: String(e)
|
||||
});
|
||||
error(500, 'Failed to delete progress');
|
||||
}
|
||||
|
||||
return json({ ok: true });
|
||||
};
|
||||
|
||||
46
ui/src/routes/api/users/[username]/+server.ts
Normal file
46
ui/src/routes/api/users/[username]/+server.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getPublicProfile, getSubscription } from '$lib/server/pocketbase';
|
||||
import { presignAvatarUrl } from '$lib/server/minio';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
/**
|
||||
* GET /api/users/[username]
|
||||
* Returns public profile info + whether the current user is subscribed.
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ params, locals }) => {
|
||||
const { username } = params;
|
||||
|
||||
try {
|
||||
const profile = await getPublicProfile(username);
|
||||
if (!profile) error(404, `User "${username}" not found`);
|
||||
|
||||
// Resolve avatar presigned URL if set
|
||||
let avatarUrl: string | null = null;
|
||||
if (profile.avatar_url) {
|
||||
avatarUrl = await presignAvatarUrl(profile.id).catch(() => null);
|
||||
}
|
||||
|
||||
// Is the current logged-in user subscribed?
|
||||
let isSubscribed = false;
|
||||
if (locals.user && locals.user.id !== profile.id) {
|
||||
const sub = await getSubscription(locals.user.id, profile.id).catch(() => null);
|
||||
isSubscribed = !!sub;
|
||||
}
|
||||
|
||||
return json({
|
||||
id: profile.id,
|
||||
username: profile.username,
|
||||
avatarUrl,
|
||||
created: profile.created,
|
||||
followerCount: profile.followerCount,
|
||||
followingCount: profile.followingCount,
|
||||
isSubscribed,
|
||||
isSelf: locals.user?.id === profile.id
|
||||
});
|
||||
} catch (e) {
|
||||
if ((e as { status?: number }).status === 404) throw e;
|
||||
log.error('api/users', 'failed to load profile', { username, err: String(e) });
|
||||
error(500, 'Failed to load profile');
|
||||
}
|
||||
};
|
||||
43
ui/src/routes/api/users/[username]/library/+server.ts
Normal file
43
ui/src/routes/api/users/[username]/library/+server.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import {
|
||||
getUserByUsername,
|
||||
getUserPublicLibrary,
|
||||
getUserCurrentlyReading
|
||||
} from '$lib/server/pocketbase';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
/**
|
||||
* GET /api/users/[username]/library
|
||||
* Returns the public library + currently-reading list for a user.
|
||||
* Does not require authentication — all data is public.
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ params }) => {
|
||||
const { username } = params;
|
||||
|
||||
const user = await getUserByUsername(username).catch(() => null);
|
||||
if (!user) error(404, `User "${username}" not found`);
|
||||
|
||||
try {
|
||||
const [currentlyReading, library] = await Promise.all([
|
||||
getUserCurrentlyReading(user.id),
|
||||
getUserPublicLibrary(user.id)
|
||||
]);
|
||||
|
||||
return json({
|
||||
currently_reading: currentlyReading.map((item) => ({
|
||||
book: item.book,
|
||||
last_chapter: item.chapter,
|
||||
saved: false
|
||||
})),
|
||||
library: library.map((item) => ({
|
||||
book: item.book,
|
||||
last_chapter: item.chapter,
|
||||
saved: item.saved
|
||||
}))
|
||||
});
|
||||
} catch (e) {
|
||||
log.error('api/users/library', 'failed to load library', { username, err: String(e) });
|
||||
error(500, 'Failed to load library');
|
||||
}
|
||||
};
|
||||
48
ui/src/routes/api/users/[username]/subscribe/+server.ts
Normal file
48
ui/src/routes/api/users/[username]/subscribe/+server.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import {
|
||||
getUserByUsername,
|
||||
subscribe,
|
||||
unsubscribe,
|
||||
getSubscription
|
||||
} from '$lib/server/pocketbase';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
/**
|
||||
* POST /api/users/[username]/subscribe — subscribe to a user
|
||||
* DELETE /api/users/[username]/subscribe — unsubscribe
|
||||
* Requires authentication.
|
||||
*/
|
||||
export const POST: RequestHandler = async ({ params, locals }) => {
|
||||
if (!locals.user) error(401, 'Login required');
|
||||
|
||||
const { username } = params;
|
||||
const target = await getUserByUsername(username).catch(() => null);
|
||||
if (!target) error(404, `User "${username}" not found`);
|
||||
if (locals.user.id === target.id) error(400, 'Cannot subscribe to yourself');
|
||||
|
||||
try {
|
||||
await subscribe(locals.user.id, target.id);
|
||||
const sub = await getSubscription(locals.user.id, target.id);
|
||||
return json({ subscribed: true, subId: sub?.id ?? null });
|
||||
} catch (e) {
|
||||
log.error('api/users/subscribe', 'subscribe failed', { username, err: String(e) });
|
||||
error(500, 'Failed to subscribe');
|
||||
}
|
||||
};
|
||||
|
||||
export const DELETE: RequestHandler = async ({ params, locals }) => {
|
||||
if (!locals.user) error(401, 'Login required');
|
||||
|
||||
const { username } = params;
|
||||
const target = await getUserByUsername(username).catch(() => null);
|
||||
if (!target) error(404, `User "${username}" not found`);
|
||||
|
||||
try {
|
||||
await unsubscribe(locals.user.id, target.id);
|
||||
return json({ subscribed: false });
|
||||
} catch (e) {
|
||||
log.error('api/users/subscribe', 'unsubscribe failed', { username, err: String(e) });
|
||||
error(500, 'Failed to unsubscribe');
|
||||
}
|
||||
};
|
||||
@@ -43,7 +43,9 @@ export const load: PageServerLoad = async ({ params, locals }) => {
|
||||
inLib: true,
|
||||
saved,
|
||||
lastChapter: progress?.chapter ?? null,
|
||||
isAdmin: locals.user?.role === 'admin'
|
||||
isAdmin: locals.user?.role === 'admin',
|
||||
isLoggedIn: !!locals.user,
|
||||
currentUserId: locals.user?.id ?? ''
|
||||
};
|
||||
}
|
||||
|
||||
@@ -93,7 +95,9 @@ export const load: PageServerLoad = async ({ params, locals }) => {
|
||||
inLib: preview.in_lib,
|
||||
saved: false,
|
||||
lastChapter: null,
|
||||
isAdmin: locals.user?.role === 'admin'
|
||||
isAdmin: locals.user?.role === 'admin',
|
||||
isLoggedIn: !!locals.user,
|
||||
currentUserId: locals.user?.id ?? ''
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof Error && 'status' in e) throw e;
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import type { PageData } from './$types';
|
||||
import CommentsSection from '$lib/components/CommentsSection.svelte';
|
||||
|
||||
@@ -33,57 +31,12 @@
|
||||
|
||||
const genres = $derived(parseGenres(data.book.genres));
|
||||
|
||||
// Paginate chapter list — 50 on mobile, 100 on sm+ (≥640px)
|
||||
let pageSize = $state(50);
|
||||
|
||||
onMount(() => {
|
||||
const mq = window.matchMedia('(min-width: 640px)');
|
||||
pageSize = mq.matches ? 100 : 50;
|
||||
const handler = (e: MediaQueryListEvent) => { pageSize = e.matches ? 100 : 50; };
|
||||
mq.addEventListener('change', handler);
|
||||
return () => mq.removeEventListener('change', handler);
|
||||
});
|
||||
|
||||
// Start on the page that contains the current chapter (if any)
|
||||
function pageForChapter(chapterNum: number | null, list: typeof chapterList): number {
|
||||
if (!chapterNum || list.length === 0) return 0;
|
||||
const idx = list.findIndex((c) => c.number === chapterNum);
|
||||
if (idx === -1) return 0;
|
||||
return Math.floor(idx / pageSize);
|
||||
}
|
||||
|
||||
let page = $state(pageForChapter(data.lastChapter, data.inLib ? data.chapters : (data.previewChapters ?? [])));
|
||||
|
||||
// Use preview chapters if the book is not in the library
|
||||
// Use preview chapters if the book is not in the library (needed for chapter count)
|
||||
const chapterList = $derived(
|
||||
data.inLib
|
||||
? data.chapters
|
||||
: (data.previewChapters ?? [])
|
||||
);
|
||||
const totalPages = $derived(Math.ceil(chapterList.length / pageSize));
|
||||
const visibleChapters = $derived(
|
||||
chapterList.slice(page * pageSize, (page + 1) * pageSize)
|
||||
);
|
||||
|
||||
// ── Chapter list polling ──────────────────────────────────────────────────
|
||||
// When the book was just added to the library via preview (inLib=true but
|
||||
// no chapters yet), poll until the background WriteChapterRefs completes.
|
||||
let pollingChapters = $state(data.inLib && data.chapters.length === 0);
|
||||
|
||||
onMount(() => {
|
||||
if (!pollingChapters) return;
|
||||
let attempts = 0;
|
||||
const MAX_ATTEMPTS = 20; // ~10 seconds
|
||||
const timer = setInterval(async () => {
|
||||
attempts++;
|
||||
await invalidateAll();
|
||||
if (data.chapters.length > 0 || attempts >= MAX_ATTEMPTS) {
|
||||
pollingChapters = false;
|
||||
clearInterval(timer);
|
||||
}
|
||||
}, 500);
|
||||
return () => clearInterval(timer);
|
||||
});
|
||||
|
||||
// ── Admin: rescrape ───────────────────────────────────────────────────────
|
||||
let scraping = $state(false);
|
||||
@@ -138,26 +91,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function scrapeFromChapter(n: number) {
|
||||
if (rangeScraping || !data.book.source_url) return;
|
||||
rangeScraping = true;
|
||||
rangeResult = '';
|
||||
try {
|
||||
const res = await fetch('/api/scrape/range', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url: data.book.source_url, from: n })
|
||||
});
|
||||
if (res.ok) rangeResult = 'queued';
|
||||
else if (res.status === 409) rangeResult = 'busy';
|
||||
else rangeResult = 'error';
|
||||
} catch {
|
||||
rangeResult = 'error';
|
||||
} finally {
|
||||
rangeScraping = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Summary expand/collapse ───────────────────────────────────────────────
|
||||
let summaryExpanded = $state(false);
|
||||
|
||||
@@ -342,101 +275,36 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════ Chapter list ══ -->
|
||||
<div>
|
||||
<!-- Header row: title + pagination -->
|
||||
<div class="flex items-center justify-between mb-3 flex-wrap gap-2">
|
||||
<h2 class="text-base font-semibold text-zinc-200">
|
||||
Chapters
|
||||
<!-- ══════════════════════════════════════════════════ Chapters row ══ -->
|
||||
<div class="flex flex-col divide-y divide-zinc-800 border border-zinc-800 rounded-xl overflow-hidden mb-6">
|
||||
<!-- Chapters row: links to the full chapter list page -->
|
||||
<a
|
||||
href="/books/{data.book.slug}/chapters"
|
||||
class="flex items-center gap-3 px-4 py-3.5 hover:bg-zinc-800/60 transition-colors group"
|
||||
>
|
||||
<svg class="w-4 h-4 text-amber-400 flex-shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 10h16M4 14h10"/>
|
||||
</svg>
|
||||
<div class="flex flex-col min-w-0 flex-1">
|
||||
<span class="text-sm font-semibold text-zinc-200">Chapters</span>
|
||||
{#if chapterList.length > 0}
|
||||
<span class="text-zinc-500 font-normal text-sm ml-1">({chapterList.length})</span>
|
||||
{/if}
|
||||
</h2>
|
||||
|
||||
{#if totalPages > 1}
|
||||
<div class="flex gap-2 items-center text-sm">
|
||||
<button
|
||||
onclick={() => (page = Math.max(0, page - 1))}
|
||||
disabled={page === 0}
|
||||
class="px-2 py-1 rounded bg-zinc-800 text-zinc-300 disabled:opacity-40 hover:bg-zinc-700 transition-colors"
|
||||
>
|
||||
←
|
||||
</button>
|
||||
<span class="text-zinc-500 text-xs tabular-nums">
|
||||
{page * pageSize + 1}–{Math.min((page + 1) * pageSize, chapterList.length)} of {chapterList.length}
|
||||
</span>
|
||||
<button
|
||||
onclick={() => (page = Math.min(totalPages - 1, page + 1))}
|
||||
disabled={page === totalPages - 1}
|
||||
class="px-2 py-1 rounded bg-zinc-800 text-zinc-300 disabled:opacity-40 hover:bg-zinc-700 transition-colors"
|
||||
>
|
||||
→
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Chapter rows -->
|
||||
{#if pollingChapters}
|
||||
<div class="flex items-center gap-3 py-4 text-zinc-500 text-sm">
|
||||
<svg class="w-4 h-4 animate-spin flex-shrink-0" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
Indexing chapter list…
|
||||
</div>
|
||||
<div class="flex flex-col gap-0.5 opacity-40 pointer-events-none">
|
||||
{#each Array(8) as _}
|
||||
<div class="h-9 rounded bg-zinc-800 animate-pulse"></div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if chapterList.length === 0}
|
||||
<p class="text-zinc-500 text-sm">No chapters available yet.</p>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-0.5">
|
||||
{#each visibleChapters as chapter}
|
||||
{@const isCurrent = data.lastChapter === chapter.number}
|
||||
{@const chapterUrl = data.inLib
|
||||
? `/books/${data.book.slug}/chapters/${chapter.number}`
|
||||
: `/books/${data.book.slug}/chapters/${chapter.number}?preview=1&chapter_url=${encodeURIComponent((chapter as { url?: string }).url ?? '')}&title=${encodeURIComponent(chapter.title ?? '')}`}
|
||||
<div class="flex items-center gap-2 px-3 py-2.5 rounded hover:bg-zinc-800/70 transition-colors group {isCurrent ? 'bg-zinc-800' : ''}">
|
||||
<a href={chapterUrl} class="flex items-center gap-2 flex-1 min-w-0">
|
||||
<!-- Chapter number -->
|
||||
<span class="text-sm font-mono w-10 text-right flex-shrink-0 {isCurrent ? 'text-amber-400' : 'text-zinc-600'}">
|
||||
{chapter.number}
|
||||
</span>
|
||||
<!-- Title -->
|
||||
<span class="text-base {isCurrent ? 'text-amber-300' : 'text-zinc-300 group-hover:text-zinc-100'} truncate min-w-0 flex-1 transition-colors">
|
||||
{chapter.title || `Chapter ${chapter.number}`}
|
||||
</span>
|
||||
<!-- Date label — desktop only -->
|
||||
{#if (chapter as { date_label?: string }).date_label}
|
||||
<span class="text-sm text-zinc-600 flex-shrink-0 max-sm:hidden">· {(chapter as { date_label?: string }).date_label}</span>
|
||||
{/if}
|
||||
<!-- "reading" badge -->
|
||||
{#if isCurrent}
|
||||
<span class="text-sm text-amber-500 flex-shrink-0 font-medium">reading</span>
|
||||
{/if}
|
||||
</a>
|
||||
<!-- Admin: scrape from this chapter up (hover-only) -->
|
||||
{#if data.isAdmin && data.book.source_url && data.inLib}
|
||||
<button
|
||||
onclick={() => scrapeFromChapter(chapter.number)}
|
||||
disabled={rangeScraping}
|
||||
class="opacity-0 group-hover:opacity-100 shrink-0 text-xs px-1.5 py-0.5 rounded bg-amber-500/10 text-amber-500 hover:bg-amber-500/30 transition-all border border-amber-500/20 disabled:opacity-30"
|
||||
title="Scrape from chapter {chapter.number} up"
|
||||
>
|
||||
↑ here
|
||||
</button>
|
||||
<span class="text-xs text-zinc-500">
|
||||
{#if data.lastChapter && data.lastChapter > 0}
|
||||
Reading ch.{data.lastChapter} of {chapterList.length}
|
||||
{:else}
|
||||
{chapterList.length} chapter{chapterList.length === 1 ? '' : 's'}
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<svg class="w-4 h-4 text-zinc-600 group-hover:text-zinc-400 transition-colors flex-shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
<!-- ── Admin panel (collapsed by default) ── -->
|
||||
<!-- Admin panel (collapsed by default, admin only) -->
|
||||
{#if data.isAdmin && data.book.source_url}
|
||||
<div class="mt-6 border border-zinc-800 rounded-lg overflow-hidden">
|
||||
<div>
|
||||
<button
|
||||
onclick={() => (adminOpen = !adminOpen)}
|
||||
class="w-full flex items-center gap-2 px-4 py-2.5 text-xs font-medium text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800/50 transition-colors text-left"
|
||||
@@ -528,4 +396,4 @@
|
||||
</div>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════ Comments ══ -->
|
||||
<CommentsSection slug={data.book.slug} isLoggedIn={true} />
|
||||
<CommentsSection slug={data.book.slug} isLoggedIn={data.isLoggedIn} currentUserId={data.currentUserId} />
|
||||
|
||||
32
ui/src/routes/books/[slug]/chapters/+page.server.ts
Normal file
32
ui/src/routes/books/[slug]/chapters/+page.server.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { getBook, listChapterIdx, getProgress } from '$lib/server/pocketbase';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
export const load: PageServerLoad = async ({ params, locals }) => {
|
||||
const { slug } = params;
|
||||
|
||||
const book = await getBook(slug).catch((e) => {
|
||||
log.error('chapters', 'getBook failed', { slug, err: String(e) });
|
||||
return null;
|
||||
});
|
||||
|
||||
if (!book) error(404, `Book "${slug}" not found`);
|
||||
|
||||
let chapters, progress;
|
||||
try {
|
||||
[chapters, progress] = await Promise.all([
|
||||
listChapterIdx(slug),
|
||||
getProgress(locals.sessionId, slug, locals.user?.id)
|
||||
]);
|
||||
} catch (e) {
|
||||
log.error('chapters', 'failed to load chapters', { slug, err: String(e) });
|
||||
throw error(500, 'Failed to load chapters');
|
||||
}
|
||||
|
||||
return {
|
||||
book: { slug: book.slug, title: book.title, cover: book.cover ?? '', totalChapters: book.total_chapters },
|
||||
chapters,
|
||||
lastChapter: progress?.chapter ?? null
|
||||
};
|
||||
};
|
||||
203
ui/src/routes/books/[slug]/chapters/+page.svelte
Normal file
203
ui/src/routes/books/[slug]/chapters/+page.svelte
Normal file
@@ -0,0 +1,203 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
import type { ChapterIdx } from '$lib/server/pocketbase';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
const PAGE_SIZE = 100;
|
||||
|
||||
// ── Search ──────────────────────────────────────────────────────────────────
|
||||
let searchQuery = $state('');
|
||||
|
||||
const filtered = $derived(
|
||||
(() => {
|
||||
const q = searchQuery.trim().toLowerCase();
|
||||
if (!q) return data.chapters;
|
||||
return data.chapters.filter(
|
||||
(c: ChapterIdx) =>
|
||||
String(c.number).includes(q) ||
|
||||
c.title.toLowerCase().includes(q)
|
||||
);
|
||||
})()
|
||||
);
|
||||
|
||||
// ── Page groups (only shown when not searching) ──────────────────────────
|
||||
const totalGroups = $derived(Math.ceil(data.chapters.length / PAGE_SIZE));
|
||||
|
||||
// Which group the current chapter is in (0-indexed)
|
||||
const currentGroup = $derived(
|
||||
data.lastChapter
|
||||
? Math.floor(
|
||||
(data.chapters.findIndex((c: ChapterIdx) => c.number === data.lastChapter)) /
|
||||
PAGE_SIZE
|
||||
)
|
||||
: 0
|
||||
);
|
||||
|
||||
let activeGroup = $state(0);
|
||||
|
||||
// On mount, jump to the group containing the current chapter
|
||||
$effect(() => {
|
||||
if (data.lastChapter && currentGroup >= 0) {
|
||||
activeGroup = currentGroup;
|
||||
}
|
||||
});
|
||||
|
||||
const visibleChapters = $derived(
|
||||
searchQuery.trim()
|
||||
? filtered
|
||||
: data.chapters.slice(activeGroup * PAGE_SIZE, (activeGroup + 1) * PAGE_SIZE)
|
||||
);
|
||||
|
||||
function groupLabel(i: number): string {
|
||||
const from = i * PAGE_SIZE + 1;
|
||||
const to = Math.min((i + 1) * PAGE_SIZE, data.chapters.length);
|
||||
return `${from}–${to}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.book.title} — Chapters — libnovel</title>
|
||||
</svelte:head>
|
||||
|
||||
<!-- ── Back link + title ─────────────────────────────────────────────────── -->
|
||||
<div class="flex items-center gap-3 mb-5">
|
||||
<a
|
||||
href="/books/{data.book.slug}"
|
||||
class="flex items-center gap-1.5 text-zinc-400 hover:text-zinc-200 transition-colors text-sm"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7"/>
|
||||
</svg>
|
||||
Back
|
||||
</a>
|
||||
<span class="text-zinc-700">/</span>
|
||||
<h1 class="text-base font-semibold text-zinc-200 truncate">{data.book.title}</h1>
|
||||
</div>
|
||||
|
||||
<!-- ── Search bar ───────────────────────────────────────────────────────── -->
|
||||
<div class="relative mb-4">
|
||||
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-500 pointer-events-none" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<circle cx="11" cy="11" r="8"/><path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-4.35-4.35"/>
|
||||
</svg>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search chapters…"
|
||||
bind:value={searchQuery}
|
||||
class="w-full pl-9 pr-4 py-2.5 rounded-lg bg-zinc-800 border border-zinc-700 text-zinc-200 placeholder-zinc-500 text-sm focus:outline-none focus:border-amber-400 transition-colors"
|
||||
/>
|
||||
{#if searchQuery}
|
||||
<button
|
||||
onclick={() => (searchQuery = '')}
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- ── Page-group selector (hidden while searching) ──────────────────────── -->
|
||||
{#if !searchQuery && totalGroups > 1}
|
||||
<div class="flex flex-wrap gap-1.5 mb-4">
|
||||
{#each Array(totalGroups) as _, i}
|
||||
<button
|
||||
onclick={() => (activeGroup = i)}
|
||||
class="px-2.5 py-1 rounded text-xs font-medium transition-colors
|
||||
{activeGroup === i
|
||||
? 'bg-amber-400 text-zinc-900'
|
||||
: 'bg-zinc-800 text-zinc-400 hover:bg-zinc-700 hover:text-zinc-200'}
|
||||
{currentGroup === i && activeGroup !== i ? 'ring-1 ring-amber-400/50' : ''}"
|
||||
>
|
||||
{groupLabel(i)}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ── Jump-to-current banner ──────────────────────────────────────────── -->
|
||||
{#if data.lastChapter && data.lastChapter > 0 && !searchQuery && activeGroup !== currentGroup}
|
||||
<button
|
||||
onclick={() => (activeGroup = currentGroup)}
|
||||
class="flex items-center gap-2 w-full px-3 py-2 mb-3 rounded-lg bg-amber-400/10 border border-amber-400/25 text-amber-400 text-sm hover:bg-amber-400/20 transition-colors"
|
||||
>
|
||||
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
Jump to Ch.{data.lastChapter}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- ── Chapter list ───────────────────────────────────────────────────── -->
|
||||
{#if visibleChapters.length === 0}
|
||||
{#if searchQuery}
|
||||
<p class="text-zinc-500 text-sm py-8 text-center">No chapters match "{searchQuery}"</p>
|
||||
{:else}
|
||||
<p class="text-zinc-500 text-sm">No chapters available yet.</p>
|
||||
{/if}
|
||||
{:else}
|
||||
<!-- Result count while searching -->
|
||||
{#if searchQuery}
|
||||
<p class="text-xs text-zinc-500 mb-2">{visibleChapters.length} result{visibleChapters.length === 1 ? '' : 's'}</p>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-col gap-0.5">
|
||||
{#each visibleChapters as chapter}
|
||||
{@const isCurrent = data.lastChapter === chapter.number}
|
||||
<a
|
||||
href="/books/{data.book.slug}/chapters/{chapter.number}"
|
||||
id="ch-{chapter.number}"
|
||||
class="flex items-center gap-3 px-3 py-2.5 rounded transition-colors group
|
||||
{isCurrent ? 'bg-zinc-800' : 'hover:bg-zinc-800/60'}"
|
||||
>
|
||||
<!-- Number badge -->
|
||||
<span
|
||||
class="w-9 text-right text-sm font-mono flex-shrink-0
|
||||
{isCurrent ? 'text-amber-400 font-semibold' : 'text-zinc-600'}"
|
||||
>
|
||||
{chapter.number}
|
||||
</span>
|
||||
|
||||
<!-- Title -->
|
||||
<span
|
||||
class="flex-1 min-w-0 text-sm truncate transition-colors
|
||||
{isCurrent ? 'text-amber-300 font-medium' : 'text-zinc-300 group-hover:text-zinc-100'}"
|
||||
>
|
||||
{chapter.title || `Chapter ${chapter.number}`}
|
||||
</span>
|
||||
|
||||
<!-- Date — desktop only -->
|
||||
{#if chapter.date_label}
|
||||
<span class="hidden sm:block text-xs text-zinc-600 flex-shrink-0">
|
||||
{chapter.date_label}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<!-- Reading indicator -->
|
||||
{#if isCurrent}
|
||||
<span class="text-xs text-amber-500 font-medium flex-shrink-0">reading</span>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Bottom page-group nav (mirrors top, for long lists) -->
|
||||
{#if !searchQuery && totalGroups > 1}
|
||||
<div class="flex flex-wrap gap-1.5 mt-5 pt-4 border-t border-zinc-800">
|
||||
{#each Array(totalGroups) as _, i}
|
||||
<button
|
||||
onclick={() => { activeGroup = i; window.scrollTo({ top: 0, behavior: 'smooth' }); }}
|
||||
class="px-2.5 py-1 rounded text-xs font-medium transition-colors
|
||||
{activeGroup === i
|
||||
? 'bg-amber-400 text-zinc-900'
|
||||
: 'bg-zinc-800 text-zinc-400 hover:bg-zinc-700 hover:text-zinc-200'}
|
||||
{currentGroup === i && activeGroup !== i ? 'ring-1 ring-amber-400/50' : ''}"
|
||||
>
|
||||
{groupLabel(i)}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -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.';
|
||||
|
||||
59
ui/src/routes/users/[username]/+page.server.ts
Normal file
59
ui/src/routes/users/[username]/+page.server.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
import {
|
||||
getPublicProfile,
|
||||
getSubscription,
|
||||
getUserPublicLibrary,
|
||||
getUserCurrentlyReading
|
||||
} from '$lib/server/pocketbase';
|
||||
import { presignAvatarUrl } from '$lib/server/minio';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
export const load: PageServerLoad = async ({ params, locals }) => {
|
||||
const { username } = params;
|
||||
|
||||
const profile = await getPublicProfile(username).catch(() => null);
|
||||
if (!profile) error(404, `User "${username}" not found`);
|
||||
|
||||
// Resolve avatar
|
||||
let avatarUrl: string | null = null;
|
||||
if (profile.avatar_url) {
|
||||
avatarUrl = await presignAvatarUrl(profile.id).catch(() => null);
|
||||
}
|
||||
|
||||
// Subscription state for the logged-in visitor
|
||||
let isSubscribed = false;
|
||||
const isSelf = locals.user?.id === profile.id;
|
||||
if (locals.user && !isSelf) {
|
||||
const sub = await getSubscription(locals.user.id, profile.id).catch(() => null);
|
||||
isSubscribed = !!sub;
|
||||
}
|
||||
|
||||
// Load public library + currently reading in parallel
|
||||
const [library, currentlyReading] = await Promise.all([
|
||||
getUserPublicLibrary(profile.id).catch((e) => {
|
||||
log.error('users/profile', 'getUserPublicLibrary failed', { username, err: String(e) });
|
||||
return [] as Awaited<ReturnType<typeof getUserPublicLibrary>>;
|
||||
}),
|
||||
getUserCurrentlyReading(profile.id).catch((e) => {
|
||||
log.error('users/profile', 'getUserCurrentlyReading failed', { username, err: String(e) });
|
||||
return [] as Awaited<ReturnType<typeof getUserCurrentlyReading>>;
|
||||
})
|
||||
]);
|
||||
|
||||
return {
|
||||
profile: {
|
||||
id: profile.id,
|
||||
username: profile.username,
|
||||
created: profile.created,
|
||||
followerCount: profile.followerCount,
|
||||
followingCount: profile.followingCount
|
||||
},
|
||||
avatarUrl,
|
||||
isSubscribed,
|
||||
isSelf,
|
||||
isLoggedIn: !!locals.user,
|
||||
library,
|
||||
currentlyReading
|
||||
};
|
||||
};
|
||||
224
ui/src/routes/users/[username]/+page.svelte
Normal file
224
ui/src/routes/users/[username]/+page.svelte
Normal file
@@ -0,0 +1,224 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
// ── Subscribe / unsubscribe ──────────────────────────────────────────────────
|
||||
let subscribed = $state(data.isSubscribed);
|
||||
let followerCount = $state(data.profile.followerCount);
|
||||
let subLoading = $state(false);
|
||||
|
||||
async function toggleSubscribe() {
|
||||
if (subLoading) return;
|
||||
subLoading = true;
|
||||
try {
|
||||
const method = subscribed ? 'DELETE' : 'POST';
|
||||
const res = await fetch(`/api/users/${data.profile.username}/subscribe`, { method });
|
||||
if (res.ok) {
|
||||
subscribed = !subscribed;
|
||||
followerCount += subscribed ? 1 : -1;
|
||||
}
|
||||
} finally {
|
||||
subLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
function initials(username: string): string {
|
||||
return username.slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
function joinDate(iso: string): string {
|
||||
try {
|
||||
return new Date(iso).toLocaleDateString(undefined, { year: 'numeric', month: 'long' });
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function parseGenres(genres: string[] | string | null | undefined): string[] {
|
||||
if (!genres) return [];
|
||||
if (Array.isArray(genres)) return genres;
|
||||
try {
|
||||
const parsed = JSON.parse(genres);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.profile.username} — libnovel</title>
|
||||
</svelte:head>
|
||||
|
||||
<!-- ── Header ────────────────────────────────────────────────────────────── -->
|
||||
<div class="flex items-start gap-5 mb-8">
|
||||
<!-- Avatar -->
|
||||
<div class="flex-shrink-0">
|
||||
{#if data.avatarUrl}
|
||||
<img
|
||||
src={data.avatarUrl}
|
||||
alt={data.profile.username}
|
||||
class="w-20 h-20 rounded-full object-cover ring-2 ring-zinc-700"
|
||||
/>
|
||||
{:else}
|
||||
<div class="w-20 h-20 rounded-full bg-zinc-700 flex items-center justify-center text-2xl font-bold text-zinc-300 ring-2 ring-zinc-600">
|
||||
{initials(data.profile.username)}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<h1 class="text-xl font-bold text-zinc-100 mb-0.5">{data.profile.username}</h1>
|
||||
<p class="text-xs text-zinc-500 mb-3">Joined {joinDate(data.profile.created)}</p>
|
||||
|
||||
<!-- Stats row -->
|
||||
<div class="flex gap-5 text-sm mb-4">
|
||||
<span>
|
||||
<span class="font-semibold text-zinc-100">{followerCount}</span>
|
||||
<span class="text-zinc-500 ml-1">followers</span>
|
||||
</span>
|
||||
<span>
|
||||
<span class="font-semibold text-zinc-100">{data.profile.followingCount}</span>
|
||||
<span class="text-zinc-500 ml-1">following</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Subscribe button — only shown to logged-in visitors viewing someone else's profile -->
|
||||
{#if data.isLoggedIn && !data.isSelf}
|
||||
<button
|
||||
onclick={toggleSubscribe}
|
||||
disabled={subLoading}
|
||||
class="px-4 py-1.5 rounded-lg text-sm font-medium transition-colors disabled:opacity-50
|
||||
{subscribed
|
||||
? 'bg-zinc-700 text-zinc-300 hover:bg-zinc-600 border border-zinc-600'
|
||||
: 'bg-amber-400 text-zinc-900 hover:bg-amber-300'}"
|
||||
>
|
||||
{#if subLoading}
|
||||
…
|
||||
{:else if subscribed}
|
||||
Following
|
||||
{:else}
|
||||
Follow
|
||||
{/if}
|
||||
</button>
|
||||
{:else if !data.isLoggedIn}
|
||||
<a
|
||||
href="/login"
|
||||
class="inline-block px-4 py-1.5 rounded-lg text-sm font-medium bg-amber-400 text-zinc-900 hover:bg-amber-300 transition-colors"
|
||||
>
|
||||
Follow
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Currently Reading ─────────────────────────────────────────────────── -->
|
||||
{#if data.currentlyReading.length > 0}
|
||||
<section class="mb-10">
|
||||
<h2 class="text-base font-semibold text-zinc-200 mb-3">Currently Reading</h2>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||
{#each data.currentlyReading as { book, chapter }}
|
||||
<a
|
||||
href="/books/{book.slug}"
|
||||
class="group flex flex-col rounded-lg overflow-hidden bg-zinc-800 hover:bg-zinc-700 transition-colors border border-zinc-700 hover:border-zinc-500"
|
||||
>
|
||||
<div class="aspect-[2/3] bg-zinc-900 overflow-hidden relative">
|
||||
{#if book.cover}
|
||||
<img
|
||||
src={book.cover}
|
||||
alt={book.title}
|
||||
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
loading="lazy"
|
||||
/>
|
||||
{:else}
|
||||
<div class="w-full h-full flex items-center justify-center text-zinc-600">
|
||||
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
<span class="absolute bottom-1.5 right-1.5 text-xs bg-amber-400 text-zinc-900 font-bold px-1.5 py-0.5 rounded">
|
||||
ch.{chapter}
|
||||
</span>
|
||||
</div>
|
||||
<div class="p-2">
|
||||
<h3 class="text-xs font-semibold text-zinc-100 line-clamp-2 leading-snug">{book.title}</h3>
|
||||
{#if book.author}
|
||||
<p class="text-xs text-zinc-500 truncate mt-0.5">{book.author}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- ── Library ───────────────────────────────────────────────────────────── -->
|
||||
{#if data.library.length > 0}
|
||||
<section class="mb-10">
|
||||
<h2 class="text-base font-semibold text-zinc-200 mb-3">
|
||||
Library
|
||||
<span class="text-zinc-500 font-normal text-sm ml-1">({data.library.length})</span>
|
||||
</h2>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||
{#each data.library as { book, chapter, saved }}
|
||||
{@const genres = parseGenres(book.genres)}
|
||||
<a
|
||||
href="/books/{book.slug}"
|
||||
class="group flex flex-col rounded-lg overflow-hidden bg-zinc-800 hover:bg-zinc-700 transition-colors border border-zinc-700 hover:border-zinc-500"
|
||||
>
|
||||
<div class="aspect-[2/3] bg-zinc-900 overflow-hidden relative">
|
||||
{#if book.cover}
|
||||
<img
|
||||
src={book.cover}
|
||||
alt={book.title}
|
||||
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
loading="lazy"
|
||||
/>
|
||||
{:else}
|
||||
<div class="w-full h-full flex items-center justify-center text-zinc-600">
|
||||
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
{#if chapter}
|
||||
<span class="absolute bottom-1.5 right-1.5 text-xs bg-zinc-900/80 text-zinc-300 font-medium px-1.5 py-0.5 rounded">
|
||||
ch.{chapter}
|
||||
</span>
|
||||
{/if}
|
||||
{#if saved && !chapter}
|
||||
<span class="absolute top-1.5 right-1.5">
|
||||
<svg class="w-3.5 h-3.5 text-amber-400" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M5 3a2 2 0 00-2 2v16l9-4 9 4V5a2 2 0 00-2-2H5z"/>
|
||||
</svg>
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="p-2">
|
||||
<h3 class="text-xs font-semibold text-zinc-100 line-clamp-2 leading-snug">{book.title}</h3>
|
||||
{#if book.author}
|
||||
<p class="text-xs text-zinc-500 truncate mt-0.5">{book.author}</p>
|
||||
{/if}
|
||||
{#if genres.length > 0}
|
||||
<p class="text-xs text-zinc-600 truncate mt-0.5">{genres[0]}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- ── Empty state ───────────────────────────────────────────────────────── -->
|
||||
{#if data.library.length === 0 && data.currentlyReading.length === 0}
|
||||
<div class="py-16 text-center text-zinc-500">
|
||||
<svg class="w-10 h-10 mx-auto mb-3 text-zinc-700" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
<p class="text-sm">No books in library yet.</p>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -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