diff --git a/RubyEvents.xcodeproj/project.pbxproj b/RubyEvents.xcodeproj/project.pbxproj index 17c031b..381d1ec 100644 --- a/RubyEvents.xcodeproj/project.pbxproj +++ b/RubyEvents.xcodeproj/project.pbxproj @@ -10,6 +10,7 @@ 973DD3652D95F8BE008E0AED /* CachedAsyncImage in Frameworks */ = {isa = PBXBuildFile; productRef = 973DD3642D95F8BE008E0AED /* CachedAsyncImage */; }; 97613B4C2D0635C600220031 /* HotwireNative in Frameworks */ = {isa = PBXBuildFile; productRef = 97613B4B2D0635C600220031 /* HotwireNative */; }; 97613B572D06512900220031 /* YouTubePlayerKit in Frameworks */ = {isa = PBXBuildFile; productRef = 97613B562D06512900220031 /* YouTubePlayerKit */; }; + 97AAF2552FFF19EC009DB8C2 /* Typesense in Frameworks */ = {isa = PBXBuildFile; productRef = 97AAF2542FFF19EC009DB8C2 /* Typesense */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -43,6 +44,7 @@ buildActionMask = 2147483647; files = ( 97613B572D06512900220031 /* YouTubePlayerKit in Frameworks */, + 97AAF2552FFF19EC009DB8C2 /* Typesense in Frameworks */, 973DD3652D95F8BE008E0AED /* CachedAsyncImage in Frameworks */, 97613B4C2D0635C600220031 /* HotwireNative in Frameworks */, ); @@ -90,6 +92,7 @@ 97613B4B2D0635C600220031 /* HotwireNative */, 97613B562D06512900220031 /* YouTubePlayerKit */, 973DD3642D95F8BE008E0AED /* CachedAsyncImage */, + 97AAF2542FFF19EC009DB8C2 /* Typesense */, ); productName = RubyEvents; productReference = 97613B162D0634F500220031 /* RubyEvents.app */; @@ -123,6 +126,7 @@ 97613B4A2D0635C600220031 /* XCRemoteSwiftPackageReference "hotwire-native-ios" */, 97613B552D06512900220031 /* XCRemoteSwiftPackageReference "YouTubePlayerKit" */, 973DD3632D95F8BE008E0AED /* XCRemoteSwiftPackageReference "CachedAsyncImage" */, + 97AAF2532FFF19EC009DB8C2 /* XCRemoteSwiftPackageReference "typesense-swift" */, ); preferredProjectObjectVersion = 77; productRefGroup = 97613B172D0634F500220031 /* Products */; @@ -386,6 +390,14 @@ minimumVersion = 1.9.0; }; }; + 97AAF2532FFF19EC009DB8C2 /* XCRemoteSwiftPackageReference "typesense-swift" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/typesense/typesense-swift"; + requirement = { + branch = master; + kind = branch; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -404,6 +416,11 @@ package = 97613B552D06512900220031 /* XCRemoteSwiftPackageReference "YouTubePlayerKit" */; productName = YouTubePlayerKit; }; + 97AAF2542FFF19EC009DB8C2 /* Typesense */ = { + isa = XCSwiftPackageProductDependency; + package = 97AAF2532FFF19EC009DB8C2 /* XCRemoteSwiftPackageReference "typesense-swift" */; + productName = Typesense; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 97613B0E2D0634F500220031 /* Project object */; diff --git a/RubyEvents.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/RubyEvents.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 9f1703e..36f705d 100644 --- a/RubyEvents.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/RubyEvents.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,6 +1,15 @@ { - "originHash" : "c7e739a9235f7569c4841d8050af06b35acc10a3591a95cc1c3d4e2f75e46cb2", + "originHash" : "98daefcb7a9409d791c632e30170c0914ed15b50c831c51339fe6c278c527796", "pins" : [ + { + "identity" : "anycodable", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Flight-School/AnyCodable", + "state" : { + "revision" : "862808b2070cd908cb04f9aafe7de83d35f81b05", + "version" : "0.6.7" + } + }, { "identity" : "cachedasyncimage", "kind" : "remoteSourceControl", @@ -19,6 +28,15 @@ "version" : "1.1.3" } }, + { + "identity" : "typesense-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/typesense/typesense-swift", + "state" : { + "branch" : "master", + "revision" : "0d9335697a73d19a25a21e14678e8251314a33af" + } + }, { "identity" : "youtubeplayerkit", "kind" : "remoteSourceControl", diff --git a/RubyEvents.xcodeproj/project.xcworkspace/xcuserdata/marcoroth.xcuserdatad/UserInterfaceState.xcuserstate b/RubyEvents.xcodeproj/project.xcworkspace/xcuserdata/marcoroth.xcuserdatad/UserInterfaceState.xcuserstate index b82f64c..08ad15d 100644 Binary files a/RubyEvents.xcodeproj/project.xcworkspace/xcuserdata/marcoroth.xcuserdatad/UserInterfaceState.xcuserstate and b/RubyEvents.xcodeproj/project.xcworkspace/xcuserdata/marcoroth.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/RubyEvents/controllers/TabBarController.swift b/RubyEvents/controllers/TabBarController.swift index 9602282..06a8c68 100644 --- a/RubyEvents/controllers/TabBarController.swift +++ b/RubyEvents/controllers/TabBarController.swift @@ -6,6 +6,7 @@ // import HotwireNative +import SwiftUI import UIKit struct TabBarConfigurationItem { @@ -24,6 +25,7 @@ class TabBarController: UITabBarController { var configuration: TabBarConfiguration? var navigators: [Navigator] = [] var isSetup: Bool = false + private var searchPlaceholder: UIViewController? init(app: App) { self.app = app @@ -110,12 +112,6 @@ class TabBarController: UITabBarController { icon: "music.mic", url: Router.instance.talks_url(), position: 2 - ), - TabBarConfigurationItem( - title: "Speakers", - icon: "person.fill", - url: Router.instance.speakers_url(), - position: 3 ) ] ) @@ -138,17 +134,26 @@ class TabBarController: UITabBarController { tabBarItems.append(fixedTabBarItemFrom(item: item)) } - viewControllers = navigators.map(\.rootViewController) + var controllers: [UIViewController] = navigators.map(\.rootViewController) tabBarItems.enumerated().forEach { index, item in - if let viewController = self.viewControllers?[index] { -// viewController.tabBarController?.tabBar.tintColor = .white -// viewController.tabBarController?.tabBar.barTintColor = .black - viewController.tabBarItem = item + if index < controllers.count { + controllers[index].tabBarItem = item } } - // NavBar styling + let searchController = UIViewController() + searchController.tabBarItem = UITabBarItem( + title: "Search", + image: UIImage(systemName: "magnifyingglass"), + tag: controllers.count + ) + self.searchPlaceholder = searchController + controllers.append(searchController) + + viewControllers = controllers + self.delegate = self + let navigationBarAppearance = UINavigationBarAppearance() navigationBarAppearance.configureWithDefaultBackground() UINavigationBar.appearance().standardAppearance = navigationBarAppearance @@ -157,7 +162,6 @@ class TabBarController: UITabBarController { UINavigationBar.appearance().isTranslucent = true navigationBarAppearance.titleTextAttributes = [.foregroundColor: UIColor.black] - // TabBar styling let tabBarAppearance = UITabBarAppearance() tabBarAppearance.configureWithDefaultBackground() tabBarAppearance.backgroundColor = UIColor.white @@ -182,4 +186,33 @@ class TabBarController: UITabBarController { required init?(coder _: NSCoder) { fatalError("init(coder:) has not been implemented") } + + override func viewDidLoad() { + super.viewDidLoad() + } + + private func presentSearch() { + let searchView = SearchView( + navigator: currentNavigator, + onDismiss: { [weak self] in self?.dismiss(animated: true) } + ) + + let hosting = UIHostingController(rootView: searchView) + hosting.modalPresentationStyle = .pageSheet + if let sheet = hosting.sheetPresentationController { + sheet.detents = [.large()] + sheet.prefersGrabberVisible = true + } + present(hosting, animated: true) + } +} + +extension TabBarController: UITabBarControllerDelegate { + func tabBarController(_ tabBarController: UITabBarController, shouldSelect viewController: UIViewController) -> Bool { + if viewController === searchPlaceholder { + presentSearch() + return false + } + return true + } } diff --git a/RubyEvents/search/SearchModels.swift b/RubyEvents/search/SearchModels.swift new file mode 100644 index 0000000..117c609 --- /dev/null +++ b/RubyEvents/search/SearchModels.swift @@ -0,0 +1,139 @@ +// +// SearchModels.swift +// RubyEvents +// + +import Foundation + +// Config from /hotwire/native/v1/search_config.json + +struct SearchConfig: Decodable { + struct Node: Decodable { + let host: String + let port: Int + let `protocol`: String + } + + struct CollectionConfig: Decodable { + let collection: String + let query_by: String + let query_by_weights: String? + let filter_by: String? + } + + let nodes: [Node] + let nearest_node: Node? + let search_api_key: String? + let per_page: Int? + let talks: CollectionConfig + let speakers: CollectionConfig + let events: CollectionConfig +} + +struct SearchTalk: Codable { + let id: String? + let title: String + let slug: String + let thumbnail_url: String? + let duration_in_seconds: Int32? + let video_provider: String? + let video_id: String? + let event_name: String? + let speakers: [SearchDocSpeaker]? + + struct SearchDocSpeaker: Codable { + let id: Int64? + let name: String + let slug: String + } + + private var derivedVideoURL: String? { + switch video_provider { + case "mp4": return video_id + case "youtube": return video_id.map { "https://www.youtube.com/watch?v=\($0)" } + case "vimeo": return video_id.map { "https://vimeo.com/video/\($0)" } + default: return nil + } + } + + func toTalk() -> Talk { + Talk( + id: Int64(id ?? "") ?? 0, + title: title, + speakers: (speakers ?? []).map { Speaker(id: $0.id ?? 0, name: $0.name, slug: $0.slug, avatar_url: nil) }, + duration_in_seconds: duration_in_seconds, + event_name: event_name ?? "", + url: Router.instance.root_url().appendingPathComponent("/talks/\(slug)"), + thumbnail_url: thumbnail_url.flatMap { URL(string: $0) }, + slug: slug + // TODO: native player — add when the player branch's Talk model is integrated: + // , video_provider: video_provider, video_id: video_id, video_url: derivedVideoURL + ) + } +} + +struct SearchSpeaker: Codable { + let id: String? + let name: String + let slug: String + let avatar_url: String? + let github_handle: String? + + var avatarURL: URL? { avatar_url.flatMap { URL(string: $0) } } + var profileURL: URL { Router.instance.speaker_url(slug: slug) } +} + +struct SearchEvent: Codable { + let id: String? + let name: String + let slug: String + let city: String? + let country_name: String? + let avatar_url: String? + let start_date_timestamp: Int64? + let end_date_timestamp: Int64? + + var eventURL: URL { Router.instance.root_url().appendingPathComponent("/events/\(slug)") } + + var avatarURL: URL? { + guard let avatar_url, !avatar_url.isEmpty else { return nil } + if avatar_url.hasPrefix("http") { return URL(string: avatar_url) } + + return URL(string: Router.instance.root_url().appendingPathComponent(avatar_url).absoluteString) + } + + var locationText: String? { + let parts = [city, country_name].compactMap { $0 }.filter { !$0.isEmpty } + + return parts.isEmpty ? nil : parts.joined(separator: ", ") + } + + var dateText: String? { + guard let start = start_date_timestamp, start > 0 else { return nil } + let startDate = Date(timeIntervalSince1970: TimeInterval(start)) + + let full = DateFormatter() + full.dateFormat = "MMM d, yyyy" + + if let end = end_date_timestamp, end > 0 { + let endDate = Date(timeIntervalSince1970: TimeInterval(end)) + + if Calendar.current.isDate(startDate, inSameDayAs: endDate) { + return full.string(from: startDate) + } + + let short = DateFormatter() + short.dateFormat = "MMM d" + + return "\(short.string(from: startDate)) – \(full.string(from: endDate))" + } + + return full.string(from: startDate) + } + + var subtitle: String? { + [dateText, locationText].compactMap { $0 }.first.flatMap { _ in + [dateText, locationText].compactMap { $0 }.joined(separator: " · ") + } + } +} diff --git a/RubyEvents/search/SearchView.swift b/RubyEvents/search/SearchView.swift new file mode 100644 index 0000000..b749474 --- /dev/null +++ b/RubyEvents/search/SearchView.swift @@ -0,0 +1,319 @@ +// +// SearchView.swift +// RubyEvents +// + +import HotwireNative +import SwiftUI + +enum SearchScope: String, CaseIterable, Identifiable { + case all, speakers, events, talks + + var id: String { rawValue } + + var title: String { + switch self { + case .all: return "All" + case .speakers: return "Speakers" + case .events: return "Events" + case .talks: return "Talks" + } + } +} + +struct SearchView: View { + @StateObject private var viewModel = SearchViewModel() + var navigator: Navigator? + var onDismiss: () -> Void + + @State private var scope: SearchScope = .all + @FocusState private var searchFocused: Bool + + private var showSpeakers: Bool { scope == .all || scope == .speakers } + private var showEvents: Bool { scope == .all || scope == .events } + private var showTalks: Bool { scope == .all || scope == .talks } + + private var visibleResultsEmpty: Bool { + (!showSpeakers || viewModel.speakerResults.isEmpty) + && (!showEvents || viewModel.eventResults.isEmpty) + && (!showTalks || viewModel.talkResults.isEmpty) + } + + private let previewLimit = 4 + private var displayedSpeakers: [SearchSpeaker] { scope == .all ? Array(viewModel.speakerResults.prefix(previewLimit)) : viewModel.speakerResults } + private var displayedEvents: [SearchEvent] { scope == .all ? Array(viewModel.eventResults.prefix(previewLimit)) : viewModel.eventResults } + private var displayedTalks: [Talk] { scope == .all ? Array(viewModel.talkResults.prefix(previewLimit)) : viewModel.talkResults } + + var body: some View { + NavigationStack { + ScrollView { + LazyVStack(alignment: .leading, spacing: 0) { + if showSpeakers, !viewModel.speakerResults.isEmpty { + sectionHeader("Speakers", total: viewModel.speakerResults.count, seeAll: .speakers) + ForEach(displayedSpeakers, id: \.slug) { speaker in + SpeakerResultRow(speaker: speaker, onSelect: { route(speaker.profileURL) }) + Divider().padding(.leading, 68) + } + } + + if showEvents, !viewModel.eventResults.isEmpty { + sectionHeader("Events", total: viewModel.eventResults.count, seeAll: .events) + ForEach(displayedEvents, id: \.slug) { event in + EventResultRow(event: event, onSelect: { route(event.eventURL) }) + Divider().padding(.leading, 16) + } + } + + if showTalks, !viewModel.talkResults.isEmpty { + sectionHeader("Talks", total: viewModel.talkResults.count, seeAll: .talks) + ForEach(displayedTalks) { talk in + TalkResultRow(talk: talk, navigator: navigator) + Divider().padding(.leading, 16) + } + } + } + } + .overlay { + if visibleResultsEmpty { + emptyState + } + } + .navigationTitle("") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button(action: onDismiss) { + Image(systemName: "xmark.circle.fill") + .font(.title3) + .foregroundStyle(.secondary, Color(.tertiarySystemFill)) + } + .accessibilityLabel("Close") + } + } + .searchable( + text: $viewModel.query, + placement: .navigationBarDrawer(displayMode: .always), + prompt: "Search talks, speakers, events" + ) + .searchScopes($scope) { + ForEach(SearchScope.allCases) { scope in + Text(scope.title).tag(scope) + } + } + .modifier(SearchAutoFocus(focused: $searchFocused)) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + } + .task { await viewModel.loadConfigIfNeeded() } + .onAppear { + searchFocused = true + } + } + + private func route(_ url: URL) { + onDismiss() + navigator?.route(url) + } + + private func sectionHeader(_ title: String, total: Int, seeAll: SearchScope) -> some View { + HStack(alignment: .firstTextBaseline) { + Text(title) + .font(.headline) + .foregroundStyle(.primary) + + Spacer() + + if scope == .all, total > previewLimit { + Button("See all \(total)") { + withAnimation { scope = seeAll } + } + .font(.subheadline) + } + } + .padding(.horizontal, 16) + .padding(.top, 16) + .padding(.bottom, 8) + } + + @ViewBuilder + private var emptyState: some View { + if let error = viewModel.errorMessage { + VStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle") + .font(.largeTitle) + .foregroundStyle(.orange) + Text("Search unavailable") + .font(.headline) + Text(error) + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + } + } else if viewModel.isSearching { + ProgressView() + } else if viewModel.hasSearched { + emptyMessage("No results", subtitle: "Try a different search") + } else { + emptyMessage("Search Ruby talks", subtitle: "By title, speaker, event, topic…") + } + } + + private func emptyMessage(_ title: String, subtitle: String) -> some View { + VStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .font(.largeTitle) + .foregroundStyle(.secondary) + Text(title).font(.headline) + Text(subtitle).font(.subheadline).foregroundStyle(.secondary) + } + } +} + +private struct SpeakerResultRow: View { + let speaker: SearchSpeaker + var onSelect: () -> Void + + var body: some View { + Button(action: onSelect) { + HStack(spacing: 12) { + Avatar(name: speaker.name, url: speaker.avatar_url) + .frame(width: 40, height: 40) + .clipShape(Circle()) + + VStack(alignment: .leading, spacing: 2) { + Text(speaker.name) + .font(.subheadline.weight(.medium)) + .foregroundStyle(.primary) + .lineLimit(1) + if let handle = speaker.github_handle, !handle.isEmpty { + Text("@\(handle)") + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + + Spacer(minLength: 0) + Image(systemName: "chevron.right").font(.caption).foregroundStyle(.tertiary) + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } +} + +private struct EventResultRow: View { + let event: SearchEvent + var onSelect: () -> Void + + var body: some View { + Button(action: onSelect) { + HStack(spacing: 12) { + AsyncImage(url: event.avatarURL) { image in + image.resizable().scaledToFill() + } placeholder: { + Image(systemName: "calendar") + .font(.headline) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(.secondarySystemBackground)) + } + .frame(width: 40, height: 40) + .clipShape(RoundedRectangle(cornerRadius: 8)) + + VStack(alignment: .leading, spacing: 2) { + Text(event.name) + .font(.subheadline.weight(.medium)) + .foregroundStyle(.primary) + .lineLimit(1) + if let subtitle = event.subtitle { + Text(subtitle) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + + Spacer(minLength: 0) + Image(systemName: "chevron.right").font(.caption).foregroundStyle(.tertiary) + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } +} + +private struct SearchAutoFocus: ViewModifier { + var focused: FocusState.Binding + + func body(content: Content) -> some View { + if #available(iOS 18.0, *) { + content.searchFocused(focused) + } else { + content + } + } +} + +private struct TalkResultRow: View { + let talk: Talk + var navigator: Navigator? + + // TODO: native player — re-enable when the player branch is integrated. + // @State private var showPlayer = false + + var body: some View { + Button(action: { + // TODO: native player — when integrated, prefer: + // if talk.opensNativeScreen { showPlayer = true } else if let url = talk.url { navigator?.route(url) } + if let url = talk.url { + navigator?.route(url) + } + }) { + HStack(spacing: 12) { + AsyncImage(url: talk.thumbnail_url) { image in + image.resizable().scaledToFill() + } placeholder: { + Rectangle().fill(Color(.secondarySystemBackground)) + } + .frame(width: 120, height: 68) + .clipShape(RoundedRectangle(cornerRadius: 8)) + + VStack(alignment: .leading, spacing: 4) { + Text(talk.title) + .font(.subheadline.weight(.medium)) + .foregroundStyle(.primary) + .lineLimit(2) + .multilineTextAlignment(.leading) + + Text(talk.speakers.first.map { "\($0.name) • \(talk.event_name)" } ?? talk.event_name) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + + Spacer(minLength: 0) + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + // TODO: native player — re-enable when the player branch is integrated. + // .fullScreenCover(isPresented: $showPlayer) { + // if let params = talk.playerParams { + // TalkPlayerScreen( + // params: params, + // navigator: navigator, + // onProgress: { _ in }, + // onDismiss: { showPlayer = false } + // ) + // } + // } + } +} diff --git a/RubyEvents/search/SearchViewModel.swift b/RubyEvents/search/SearchViewModel.swift new file mode 100644 index 0000000..dbe2640 --- /dev/null +++ b/RubyEvents/search/SearchViewModel.swift @@ -0,0 +1,134 @@ +// +// SearchViewModel.swift +// RubyEvents +// + +import Combine +import Foundation +import Typesense + +@MainActor +final class SearchViewModel: ObservableObject { + @Published var query = "" + @Published var talkResults: [Talk] = [] + @Published var speakerResults: [SearchSpeaker] = [] + @Published var eventResults: [SearchEvent] = [] + @Published var isSearching = false + @Published var hasSearched = false + @Published var errorMessage: String? + + var isEmpty: Bool { talkResults.isEmpty && speakerResults.isEmpty && eventResults.isEmpty } + + private var client: Client? + private var config: SearchConfig? + private var searchTask: Task? + private var cancellables = Set() + + init() { + $query + .debounce(for: .milliseconds(300), scheduler: RunLoop.main) + .removeDuplicates() + .sink { [weak self] value in self?.performSearch(value) } + .store(in: &cancellables) + } + + func loadConfigIfNeeded() async { + guard client == nil else { return } + + let endpoint = Router.instance.root_url() + .appendingPathComponent("/hotwire/native/v1/search_config.json") + .absoluteString + + do { + let config: SearchConfig = try await APIService.shared.fetchData(from: endpoint) + self.config = config + + guard let key = config.search_api_key, !key.isEmpty else { + errorMessage = "Search isn't configured on the server (missing search key)." + return + } + let nodes = config.nodes.map { Node(host: $0.host, port: String($0.port), nodeProtocol: $0.protocol) } + client = Client(config: Configuration(nodes: nodes, apiKey: key)) + errorMessage = nil + } catch { + errorMessage = "Couldn't load search config: \(error.localizedDescription)" + } + } + + private func performSearch(_ value: String) { + searchTask?.cancel() + + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + talkResults = [] + speakerResults = [] + eventResults = [] + hasSearched = false + return + } + + searchTask = Task { [weak self] in await self?.search(trimmed) } + } + + private func search(_ query: String) async { + guard client != nil, config != nil else { return } + + isSearching = true + defer { isSearching = false } + + async let talks = searchTalks(query) + async let speakers = searchSpeakers(query) + async let events = searchEvents(query) + + let (t, s, e) = await (talks, speakers, events) + guard !Task.isCancelled else { return } + + talkResults = t + speakerResults = s + eventResults = e + hasSearched = true + } + + private let fetchLimit = 40 + + private func searchTalks(_ query: String) async -> [Talk] { + guard let client, let c = config?.talks else { return [] } + do { + let params = SearchParameters( + q: query, + queryBy: c.query_by, + queryByWeights: c.query_by_weights, filterBy: c.filter_by, + sortBy: "_text_match:desc,recency_score:desc,date_timestamp:desc", + perPage: fetchLimit + ) + let (result, _) = try await client.collection(name: c.collection).documents().search(params, for: SearchTalk.self) + return (result?.hits ?? []).compactMap { $0.document?.toTalk() } + } catch { + return [] + } + } + + private func searchSpeakers(_ query: String) async -> [SearchSpeaker] { + guard let client, let c = config?.speakers else { return [] } + do { + let params = SearchParameters(q: query, queryBy: c.query_by, queryByWeights: c.query_by_weights, perPage: fetchLimit) + let (result, _) = try await client.collection(name: c.collection).documents().search(params, for: SearchSpeaker.self) + return (result?.hits ?? []).compactMap { $0.document } + } catch { + print("⚠️ Typesense speakers search failed:", error) + return [] + } + } + + private func searchEvents(_ query: String) async -> [SearchEvent] { + guard let client, let c = config?.events else { return [] } + do { + let params = SearchParameters(q: query, queryBy: c.query_by, queryByWeights: c.query_by_weights, perPage: fetchLimit) + let (result, _) = try await client.collection(name: c.collection).documents().search(params, for: SearchEvent.self) + return (result?.hits ?? []).compactMap { $0.document } + } catch { + print("⚠️ Typesense events search failed:", error) + return [] + } + } +}