Skip to content
Closed
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
20 changes: 20 additions & 0 deletions app-api/Sources/AppApi/Sync/SyncLoaders.swift
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,26 @@ 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.

public typealias PostSearchLoader = RequestLoader<PostSearchQuery, PageResult<PostResponse>>
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 CatalogueSearchLoader {
static func searchCatalogue(baseURL: URL, session: URLSession = .shared) -> Self {
CatalogueSearchLoader(request: CatalogueSearchRequest.catalogueSearch(baseURL: baseURL), session: session)
}
}

// MARK: - Single-item loaders (unauthenticated — Reader app)

// MARK: - Single-item loaders
Expand Down
16 changes: 16 additions & 0 deletions app-api/Sources/AppApi/Sync/SyncRequests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,22 @@ public extension Request where Self == DeletionsRequest {
static func deletionQuery(baseURL: URL) -> Self { .query(baseURL: baseURL, path: "api/sync/deletions") }
}

// 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`.

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 CatalogueSearchRequest = BasicRequest<CatalogueSearchQuery, CatalogueSearchResponse>
public extension Request where Self == CatalogueSearchRequest {
static func catalogueSearch(baseURL: URL) -> Self { .query(baseURL: baseURL, path: "api/catalogue/search") }
}

// MARK: - Single-item requests
//
// The item id is the `Input` type (not baked into the URL), so one loader instance can serve
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()
}
24 changes: 23 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,22 @@ public final class BlogModel {
}
}

// MARK: - Search

/// Fires the injected search closure with `term`. Sets `loading = .refresh` while in flight
/// so the tab can show a spinner alongside the `@Query`-driven local results.
public func search(term: String) async {
guard loading == nil else { return }
loading = .refresh
defer { loading = nil }
do {
try await _search(term)
} catch {
Logger.blog.error("Blog search failed: \(error)")
lastError = LoadError(error)
}
}

// MARK: - Load more (subsequent pages)

/// Fetches the next page if one is available. Never surfaces its error to the UI —
Expand Down
107 changes: 85 additions & 22 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,51 @@ struct BlogTab: View {

var body: some View {
NavigationStack {
PostsList(selectedLanguages: selectedLanguages)
.navigationTitle("Blog")
.toolbar {
Group {
if searchText.isEmpty {
PostsList(selectedLanguages: selectedLanguages)
} else {
PostsSearchList(term: 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() }
// Fire the network search whenever the term changes (debounced 300 ms).
.task(id: searchText) {
guard !searchText.isEmpty else { 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,6 +81,8 @@ struct BlogTab: View {
}
}

// MARK: - Browse list (no search term)

private struct PostsList: View {

@Query private var posts: [PostRecord]
Expand Down Expand Up @@ -109,3 +131,44 @@ private struct PostsList: View {
.refreshable { await refreshBlog() }
}
}

// MARK: - Search list (active search term)

private struct PostsSearchList: View {

@Query private var posts: [PostRecord]

init(term: String, selectedLanguages: Set<Language>) {
_posts = Query(
filter: PostRecord.search(term: term, languages: selectedLanguages),
sort: \PostRecord.createdAt,
order: .reverse
)
}

var body: some View {
List {
ForEach(posts) { post in
NavigationLink(value: post) {
PostCell(
title: post.title,
date: post.publishedAt ?? post.createdAt
)
}
}
}
.overlay {
if posts.isEmpty {
ContentUnavailableView.search
}
}
.navigationDestination(for: PostRecord.self) { post in
PostReaderView(
title: post.title,
content: post.content,
created: post.createdAt,
published: post.publishedAt
)
}
}
}
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

/// 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) }
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import Foundation
public extension EnvironmentValues {
@Entry var refreshCatalogue: CatalogueRefreshAction = CatalogueRefreshAction()
@Entry var selectCategories: SelectCategoriesAction = SelectCategoriesAction()
@Entry var searchCatalogue: CatalogueSearchAction = CatalogueSearchAction()

// 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
25 changes: 24 additions & 1 deletion app-core/Sources/AppCore/Catalogue/CatalogueModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,16 @@ public final class CatalogueModel {

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

public init(refresh: @escaping @Sendable () async throws -> Void = {}) {
/// Per-app seam for catalogue search. Called with the current search term; should fetch
/// matching catalogue items and notes 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 -> Void = {},
search: @escaping @Sendable (String) async throws -> Void = { _ in }
) {
self._refresh = refresh
self._search = search
}

// MARK: - Refresh
Expand All @@ -45,4 +53,19 @@ public final class CatalogueModel {
lastError = LoadError(error)
}
}

// MARK: - Search

/// Fires the injected search closure with `term`. Sets `loading = .refresh` while in flight.
public func search(term: String) async {
guard loading == nil else { return }
loading = .refresh
defer { loading = nil }
do {
try await _search(term)
} catch {
Logger.catalogue.error("Catalogue search failed: \(error)")
lastError = LoadError(error)
}
}
}
Loading