feat(ios): replace profile tab with search tab, add avatar button opening account sheet
Some checks failed
CI / Scraper / Lint (pull_request) Failing after 6s
CI / Scraper / Test (pull_request) Successful in 19s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Build (pull_request) Successful in 23s
CI / UI / Docker Push (pull_request) Has been skipped
iOS CI / Build (push) Successful in 1m44s
iOS CI / Build (pull_request) Successful in 1m36s
iOS CI / Test (push) Successful in 5m51s
iOS CI / Test (pull_request) Successful in 4m19s
Some checks failed
CI / Scraper / Lint (pull_request) Failing after 6s
CI / Scraper / Test (pull_request) Successful in 19s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Build (pull_request) Successful in 23s
CI / UI / Docker Push (pull_request) Has been skipped
iOS CI / Build (push) Successful in 1m44s
iOS CI / Build (pull_request) Successful in 1m36s
iOS CI / Test (push) Successful in 5m51s
iOS CI / Test (pull_request) Successful in 4m19s
This commit is contained in:
@@ -13,7 +13,7 @@ struct RootTabView: View {
|
|||||||
@State private var fullPlayerDragOffset: CGFloat = 0
|
@State private var fullPlayerDragOffset: CGFloat = 0
|
||||||
|
|
||||||
enum Tab: Hashable {
|
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)
|
/// Height of the mini player bar (progress line 2pt + vertical padding 20pt + content ~44pt)
|
||||||
@@ -34,9 +34,9 @@ struct RootTabView: View {
|
|||||||
.tabItem { Label("Discover", systemImage: "sparkles") }
|
.tabItem { Label("Discover", systemImage: "sparkles") }
|
||||||
.tag(Tab.browse)
|
.tag(Tab.browse)
|
||||||
|
|
||||||
ProfileView()
|
SearchView()
|
||||||
.tabItem { Label("Profile", systemImage: "gear") }
|
.tabItem { Label("Search", systemImage: "magnifyingglass") }
|
||||||
.tag(Tab.profile)
|
.tag(Tab.search)
|
||||||
}
|
}
|
||||||
// Reserve space for the mini-player above the tab bar so scroll content
|
// Reserve space for the mini-player above the tab bar so scroll content
|
||||||
// never slides beneath it.
|
// never slides beneath it.
|
||||||
|
|||||||
@@ -92,6 +92,11 @@ struct BrowseView: View {
|
|||||||
}
|
}
|
||||||
.navigationTitle("Discover")
|
.navigationTitle("Discover")
|
||||||
.appNavigationDestination()
|
.appNavigationDestination()
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
AvatarToolbarButton()
|
||||||
|
}
|
||||||
|
}
|
||||||
.sheet(isPresented: $showFilters) {
|
.sheet(isPresented: $showFilters) {
|
||||||
BrowseFiltersView(vm: vm)
|
BrowseFiltersView(vm: vm)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,6 +86,11 @@ struct HomeView: View {
|
|||||||
.refreshable { await vm.load() }
|
.refreshable { await vm.load() }
|
||||||
.task { await vm.load() }
|
.task { await vm.load() }
|
||||||
.errorAlert($vm.error)
|
.errorAlert($vm.error)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
AvatarToolbarButton()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -220,6 +220,11 @@ struct LibraryView: View {
|
|||||||
.refreshable { await vm.load() }
|
.refreshable { await vm.load() }
|
||||||
.task { await vm.load() }
|
.task { await vm.load() }
|
||||||
.errorAlert($vm.error)
|
.errorAlert($vm.error)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
AvatarToolbarButton()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
249
ios/LibNovel/LibNovel/Views/Search/SearchView.swift
Normal file
249
ios/LibNovel/LibNovel/Views/Search/SearchView.swift
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
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),
|
||||||
|
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),
|
||||||
|
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 3-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(.caption.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user