Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions app-api/Sources/AppApi/Sync/SyncLoaders.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ public typealias BooksLoader = RequestLoader<PageQuery, PageResult<BookRespo
public typealias MoviesLoader = RequestLoader<PageQuery, PageResult<MovieResponse>>
public typealias PodcastsLoader = RequestLoader<PageQuery, PageResult<PodcastResponse>>
public typealias PlaylistsLoader = RequestLoader<PageQuery, PageResult<PlaylistResponse>>
public typealias PostsLoader = RequestLoader<PageQuery, PageResult<PostResponse>>
public typealias PostsLoader = RequestLoader<PostsQuery, PageResult<PostResponse>>
public typealias OrphansLoader = RequestLoader<OrphanPageQuery, PageResult<PreviewResponse>>
public typealias CategoriesLoader = RequestLoader<Void, [CategoryResponse]>
public typealias DeletionsLoader = RequestLoader<SinceQuery, [DeletionRecord]>
Expand Down Expand Up @@ -138,15 +138,15 @@ public extension DeletionsLoader {

// MARK: - Search loaders (unauthenticated — Reader app)
//
// One-shot loaders: call `.load(from: query)` with the search query, get results back.
// No pagination — search endpoints return an unpaginated array.
// Catalogue uses a dedicated search endpoint. Quotes reuses its paginated list endpoint with
// an optional `term` field — same pattern as PostsLoader.

public typealias PostSearchLoader = RequestLoader<PostSearchQuery, PageResult<PostResponse>>
public typealias QuotesLoader = RequestLoader<QuotesQuery, PageResult<QuoteResponse>>
public typealias CatalogueSearchLoader = RequestLoader<CatalogueSearchQuery, CatalogueSearchResponse>

public extension PostSearchLoader {
static func searchPosts(baseURL: URL, session: URLSession = .shared) -> Self {
PostSearchLoader(request: PostSearchRequest.postSearch(baseURL: baseURL), session: session)
public extension QuotesLoader {
static func quotes(baseURL: URL, session: URLSession = .shared) -> Self {
QuotesLoader(request: QuotesRequest.quotes(baseURL: baseURL), session: session)
}
}

Expand Down
14 changes: 7 additions & 7 deletions app-api/Sources/AppApi/Sync/SyncRequests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ public extension Request where Self == PlaylistsRequest {
static func playlistQuery(baseURL: URL) -> Self { .query(baseURL: baseURL, path: "api/playlists") }
}

public typealias PostsRequest = BasicRequest<PageQuery, PageResult<PostResponse>>
public typealias PostsRequest = BasicRequest<PostsQuery, PageResult<PostResponse>>
public extension Request where Self == PostsRequest {
static func postQuery(baseURL: URL) -> Self { .query(baseURL: baseURL, path: "api/posts") }
}
Expand All @@ -62,13 +62,13 @@ public extension Request where Self == DeletionsRequest {

// MARK: - Search requests
//
// Unpaginated one-shot requests — send the search query, get a complete result array back.
// Each uses `.query(baseURL:path:)` so the `Input` struct is URL-encoded into query parameters
// via `QueryItemEncoder`.
// Catalogue search uses a dedicated endpoint; quotes and blog reuse their paginated list endpoints
// with an optional `term` field. Each uses `.query(baseURL:path:)` so the Input struct is
// URL-encoded into query parameters via `QueryItemEncoder`.

public typealias PostSearchRequest = BasicRequest<PostSearchQuery, PageResult<PostResponse>>
public extension Request where Self == PostSearchRequest {
static func postSearch(baseURL: URL) -> Self { .query(baseURL: baseURL, path: "api/posts") }
public typealias QuotesRequest = BasicRequest<QuotesQuery, PageResult<QuoteResponse>>
public extension Request where Self == QuotesRequest {
static func quotes(baseURL: URL) -> Self { .query(baseURL: baseURL, path: "api/quotes") }
}

public typealias CatalogueSearchRequest = BasicRequest<CatalogueSearchQuery, CatalogueSearchResponse>
Expand Down
21 changes: 21 additions & 0 deletions app-core/Sources/AppCore/Blog/Actions/BlogSearchAction.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import Foundation

/// Fires a blog search for `term` — the env-injected seam for per-tab text search.
///
/// One key, a different body per app (Reader fetch+upsert, Author no-op, Personal no-op),
/// all funnelled through `BlogModel.search(term:)`.
@MainActor
public struct BlogSearchAction: Sendable {

private let body: @MainActor (String) async -> Void

nonisolated public init(_ body: @escaping @MainActor (String) async -> Void = { _ in }) {
self.body = body
}

public init(model: BlogModel) {
self.init { term in await model.search(term: term) }
}

public func callAsFunction(term: String) async { await body(term) }
}
5 changes: 4 additions & 1 deletion app-core/Sources/AppCore/Blog/BlogEnvironment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ import SwiftUI
public extension EnvironmentValues {
@Entry
var refreshBlog: BlogRefreshAction = BlogRefreshAction()

@Entry
var loadBlog: BlogPageLoadAction = BlogPageLoadAction()

@Entry
var searchBlog: BlogSearchAction = BlogSearchAction()
}
28 changes: 27 additions & 1 deletion app-core/Sources/AppCore/Blog/BlogModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,16 +38,22 @@ public final class BlogModel {
// MARK: - Dependencies

private let _refresh: @Sendable () async throws -> Date?

private let _loadMore: @Sendable () async throws -> Bool

/// Per-app seam for blog search. Called with the current search term; should fetch
/// matching posts from the server and upsert them into the local store.
private let _search: @Sendable (String) async throws -> Void

public init(
refresh: @escaping @Sendable () async throws -> Date? = { nil },
loadMore: @escaping @Sendable () async throws -> Bool = { false },
search: @escaping @Sendable (String) async throws -> Void = { _ in },
lastFetched: Date? = nil
) {
self._refresh = refresh
self._loadMore = loadMore
self._search = search
self.lastFetched = lastFetched
}

Expand All @@ -69,6 +75,26 @@ public final class BlogModel {
}
}

// MARK: - Search

/// Fires the injected search closure with `term`. Sets `loading = .refresh` while in flight
/// so the status line shows activity alongside the `@Query`-driven local results.
/// Resets `hasMore` optimistically so `BlogLoadMoreCell` can page through search results.
public func search(term: String) async {
guard loading == nil else { return }
loading = .refresh
hasMore = true // reset — load-more will settle it after the first search page
defer { loading = nil }
do {
try await _search(term)
lastError = nil
} catch {
Logger.blog.error("Blog search failed: \(error)")
lastError = LoadError(error)
hasMore = false
}
}

// MARK: - Load more (subsequent pages)

/// Fetches the next page if one is available. Never surfaces its error to the UI —
Expand Down
87 changes: 61 additions & 26 deletions app-core/Sources/AppCore/Blog/BlogTab.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,15 @@ struct BlogTab: View {
@Environment(\.refreshBlog)
private var refreshBlog

@Environment(\.searchBlog)
private var searchBlog

@Preferences(\.selectedLanguages)
private var selectedLanguages

@State
private var searchText = ""

@State
private var showEditor = false

Expand All @@ -19,37 +25,52 @@ struct BlogTab: View {

var body: some View {
NavigationStack {
PostsList(selectedLanguages: selectedLanguages)
.navigationTitle("Blog")
.toolbar {
PostsList(
term: searchText.isEmpty ? nil : searchText,
selectedLanguages: selectedLanguages
)
.navigationTitle("Blog")
.searchable(text: $searchText, prompt: "Search posts…")
.toolbar {
#if os(iOS)
ToolbarItem(placement: .topBarLeading) {
Button { showLanguageFilter = true } label: {
Image(systemName: "line.3.horizontal.decrease")
}
}
ToolbarItem(placement: .topBarLeading) {
AuthoringButton(systemImage: "square.and.pencil") { showEditor = true }
ToolbarItem(placement: .topBarLeading) {
Button { showLanguageFilter = true } label: {
Image(systemName: "line.3.horizontal.decrease")
}
}
ToolbarItem(placement: .topBarLeading) {
AuthoringButton(systemImage: "square.and.pencil") { showEditor = true }
}
#else
ToolbarItem(placement: .automatic) {
Button { showLanguageFilter = true } label: {
Image(systemName: "line.3.horizontal.decrease")
}
.popover(isPresented: $showLanguageFilter) {
LanguageFilterView()
}
ToolbarItem(placement: .automatic) {
Button { showLanguageFilter = true } label: {
Image(systemName: "line.3.horizontal.decrease")
}
ToolbarItem(placement: .automatic) {
AuthoringButton(systemImage: "square.and.pencil") { showEditor = true }
.popover(isPresented: $showLanguageFilter) {
LanguageFilterView()
}
}
ToolbarItem(placement: .automatic) {
AuthoringButton(systemImage: "square.and.pencil") { showEditor = true }
}
#endif
ToolbarItem(placement: .primaryAction) {
AccountButton()
}
ToolbarItem(placement: .primaryAction) {
AccountButton()
}
}
}
.task { await refreshBlog() }
// Network search is debounced 300 ms. When the term is cleared, fire an immediate
// refresh so browse pagination resets cleanly (no stale search cursor).
.task(id: searchText) {
if searchText.isEmpty {
await refreshBlog()
return
}
try? await Task.sleep(for: .milliseconds(300))
guard !Task.isCancelled else { return }
await searchBlog(term: searchText)
}
#if os(iOS)
.sheet(isPresented: $showLanguageFilter) {
LanguageFilterView()
Expand All @@ -61,16 +82,22 @@ struct BlogTab: View {
}
}

// MARK: - Unified list (browse and search)

private struct PostsList: View {

@Query private var posts: [PostRecord]

@Environment(\.refreshBlog)
private var refreshBlog

init(selectedLanguages: Set<Language>) {
let isSearching: Bool

init(term: String?, selectedLanguages: Set<Language>) {
isSearching = term != nil
_posts = Query(
filter: PostRecord.list(languages: selectedLanguages),
filter: term.map { PostRecord.search(term: $0, languages: selectedLanguages) }
?? PostRecord.list(languages: selectedLanguages),
sort: \PostRecord.createdAt,
order: .reverse
)
Expand All @@ -95,7 +122,11 @@ private struct PostsList: View {
}
.overlay {
if posts.isEmpty {
BlogEmptyView()
if isSearching {
ContentUnavailableView.search
} else {
BlogEmptyView()
}
}
}
.navigationDestination(for: PostRecord.self) { post in
Expand All @@ -106,6 +137,10 @@ private struct PostsList: View {
published: post.publishedAt
)
}
.refreshable { await refreshBlog() }
.refreshable {
if !isSearching {
await refreshBlog()
}
}
}
}
3 changes: 2 additions & 1 deletion app-core/Sources/AppCore/Blog/View+Blog.swift
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import SwiftUI

public extension View {
/// Injects the Blog model + its refresh action (the seam) for the tab subtree.
/// Injects the Blog model + its refresh and search actions for the tab subtree.
func blog(_ model: BlogModel) -> some View {
environment(model)
.environment(\.refreshBlog, BlogRefreshAction(model: model))
.environment(\.loadBlog, BlogPageLoadAction(model: model))
.environment(\.searchBlog, BlogSearchAction(model: model))
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import Foundation

/// Loads the next page of catalogue search results — the env-injected seam for search pagination.
@MainActor
public struct CataloguePageLoadAction: Sendable {

private let body: @MainActor () async -> Void

nonisolated public init(_ body: @escaping @MainActor () async -> Void = {}) {
self.body = body
}

public init(model: CatalogueModel) {
self.init { await model.loadMore() }
}

public func callAsFunction() async { await body() }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import Foundation

/// Fires a catalogue search for `term` — the env-injected seam for per-tab text search.
@MainActor
public struct CatalogueSearchAction: Sendable {

private let body: @MainActor (String) async -> Void

nonisolated public init(_ body: @escaping @MainActor (String) async -> Void = { _ in }) {
self.body = body
}

public init(model: CatalogueModel) {
self.init { term in await model.search(term: term) }
}

public func callAsFunction(term: String) async { await body(term) }
}
2 changes: 2 additions & 0 deletions app-core/Sources/AppCore/Catalogue/CatalogueEnvironment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import Foundation
public extension EnvironmentValues {
@Entry var refreshCatalogue: CatalogueRefreshAction = CatalogueRefreshAction()
@Entry var selectCategories: SelectCategoriesAction = SelectCategoriesAction()
@Entry var searchCatalogue: CatalogueSearchAction = CatalogueSearchAction()
@Entry var loadCatalogue: CataloguePageLoadAction = CataloguePageLoadAction()

// Networking config — `apiBaseURL == nil` is the on/off gate: Personal injects nothing
// → @Loader returns nil, @Upserter returns a no-op syncer.
Expand Down
12 changes: 6 additions & 6 deletions app-core/Sources/AppCore/Catalogue/CatalogueItemDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,17 +55,17 @@ struct CatalogueItemDetailView: View {
private var typeSection: some View {
switch item.category {
case .song:
SongDetailSection(previewID: item.id)
SongDetailSection(item: item)
case .album:
AlbumDetailSection(previewID: item.id)
AlbumDetailSection(item: item)
case .playlist:
PlaylistDetailSection(previewID: item.id)
PlaylistDetailSection(item: item)
case .book:
BookDetailSection(previewID: item.id)
BookDetailSection(item: item)
case .podcast:
PodcastDetailSection(previewID: item.id)
PodcastDetailSection(item: item)
case .movie:
MovieDetailSection(previewID: item.id)
MovieDetailSection(item: item)
case nil:
OrphanDetailSection(item: item)
}
Expand Down
Loading