diff --git a/app-core/Sources/AppCore/Blog/BlogTab.swift b/app-core/Sources/AppCore/Blog/BlogTab.swift index d13ada2a..3e7d9d3a 100644 --- a/app-core/Sources/AppCore/Blog/BlogTab.swift +++ b/app-core/Sources/AppCore/Blog/BlogTab.swift @@ -5,12 +5,12 @@ import TmbrCore struct BlogTab: View { - @Environment(\.refreshBlog) - private var refreshBlog - @Preferences(\.selectedLanguages) private var selectedLanguages + @State + private var searchText = "" + @State private var showEditor = false @@ -19,37 +19,41 @@ struct BlogTab: View { var body: some View { NavigationStack { - PostsList(selectedLanguages: selectedLanguages) - .navigationTitle("Blog") - .toolbar { + PostsList( + languages: selectedLanguages, + searchText: searchText + ) + .feed(.posts) + .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() } #if os(iOS) .sheet(isPresented: $showLanguageFilter) { LanguageFilterView() @@ -61,16 +65,33 @@ struct BlogTab: View { } } +// MARK: - Unified list (browse and search) + private struct PostsList: View { - @Query private var posts: [PostRecord] + @Feed(PostsQuery.self) + private var feed + + /// The view's paging state — the source of truth for what to fetch next. Reset on every + /// new intent, updated from the receipt of each committed fetch. + @State + private var page: FeedPage? - @Environment(\.refreshBlog) - private var refreshBlog + @Query + private var posts: [PostRecord] - init(selectedLanguages: Set) { + private let languages: Set + + private let searchText: String + + init( + languages: Set, + searchText: String + ) { + self.languages = languages + self.searchText = searchText _posts = Query( - filter: PostRecord.list(languages: selectedLanguages), + filter: PostRecord.blog(term: searchText, languages: languages), sort: \PostRecord.createdAt, order: .reverse ) @@ -79,7 +100,7 @@ private struct PostsList: View { var body: some View { List { if !posts.isEmpty { - BlogStatusLine() + FeedStatusLine() } ForEach(posts) { post in NavigationLink(value: post) { @@ -88,14 +109,19 @@ private struct PostsList: View { date: post.publishedAt ?? post.createdAt ) } + .onAppear { [createdAt = post.createdAt] in + Task { await fetch(after: createdAt) } + } } if !posts.isEmpty { - BlogLoadMoreCell() + FeedLoadMoreCell() } } .overlay { if posts.isEmpty { - BlogEmptyView() + FeedEmptyView { + ContentUnavailableView("No posts yet", systemImage: "doc.text") + } } } .navigationDestination(for: PostRecord.self) { post in @@ -106,6 +132,39 @@ private struct PostsList: View { published: post.publishedAt ) } - .refreshable { await refreshBlog() } + .refreshable { + await fetch(page: nil) + } + // Runs on appear and restarts on any term or filter change (the id is the page-1 + // query) — the model supersedes in-flight requests, so the last input wins. + .task(id: query()) { + await fetch(page: nil) + } + } + + /// Fetches for the given paging state — `nil` is page 1 — and folds the receipt back in. + /// The `?? page` fallback is the failure policy: a failed page 1 falls back to `nil` + /// (no dead sessions), while a failed next page falls back to its own pre-call state, + /// so a later row appearance retries it. + private func fetch(page: FeedPage?) async { + self.page = await feed.fetch(query(page: page)) ?? page + } + + /// Row-driven paging: a row at or past the fetched frontier is unverified local cache — + /// fetch the next page for it. The `nextCursor` check gates exhaustion: without it, an + /// exhausted page would build a cursor-less query and restart the session as page 1. + private func fetch(after createdAt: Date) async { + guard let page, page.nextCursor != nil, + let frontier = page.frontier, + createdAt <= frontier else { return } + await fetch(page: page) + } + + private func query(page: FeedPage? = nil) -> PostsQuery { + PostsQuery( + cursor: page?.nextCursor, + languages: languages, + term: searchText + ) } } diff --git a/app-persistence/Sources/AppPersistence/PostRecord.swift b/app-persistence/Sources/AppPersistence/PostRecord.swift index 225e5781..ea341519 100644 --- a/app-persistence/Sources/AppPersistence/PostRecord.swift +++ b/app-persistence/Sources/AppPersistence/PostRecord.swift @@ -86,10 +86,22 @@ public extension PostRecord { /// Predicate for in-tab text search. Matches posts whose title or content contains `term` /// (case-insensitive, locale-aware), filtered by the active language selection. static func search(term: String, languages: Set) -> Predicate { - let codes = languages.map(\.rawValue) + let list = Self.list(languages: languages) return #Predicate { post in (post.title.localizedStandardContains(term) || post.content.localizedStandardContains(term)) && - (codes.isEmpty || post.languageRaw.isEmpty || codes.contains(post.languageRaw)) + list.evaluate(post) + } + } + + /// Predicate for the blog list — text search when `term` is non-empty, plain list + /// otherwise; the language filter always applies. Mirrors what the server returns for the + /// same term + languages, so one pair of inputs drives both the local `@Query` and the + /// network fetch. + static func blog(term: String, languages: Set) -> Predicate { + if !term.isEmpty { + search(term: term, languages: languages) + } else { + list(languages: languages) } } }