atproto Thingiverse but good
10

Configure Feed

Select the types of activity you want to include in your feed.

PM-66: Search page

Orual (Jun 29, 2026, 7:06 PM EDT) 8b256c3c 936e91d4

+505 -14
+35 -4
assets/styling/placeholders.css
··· 1 - /* Honest placeholder route: /search. Reserved surface with clearly-labeled 2 - "not built yet" content, inside the shell content region. The /publish route 3 - is a real wizard now and owns publish.css. */ 1 + /* Search route styles. The /publish route is a real wizard now and owns 2 + publish.css; this file remains for the small route surface that replaced the 3 + old placeholder. */ 4 4 5 5 .search-page { 6 6 flex: 1; 7 7 min-height: 0; 8 8 width: 100%; 9 - max-width: 56rem; 9 + max-width: 1180px; 10 10 margin: 0 auto; 11 11 padding: clamp(0.75rem, 2vw, 1.75rem) clamp(1rem, 3vw, 2.5rem); 12 12 display: flex; ··· 32 32 color: var(--color-muted); 33 33 font-size: 0.9rem; 34 34 } 35 + 36 + .search-results-grid { 37 + display: grid; 38 + grid-template-columns: repeat(3, minmax(0, 1fr)); 39 + gap: 1rem; 40 + scroll-margin-top: 1rem; 41 + } 42 + 43 + .search-state-card, 44 + .search-load-more-error { 45 + max-width: 44rem; 46 + } 47 + 48 + .search-pagination { 49 + display: flex; 50 + align-items: center; 51 + gap: 0.75rem; 52 + padding-block: 0.25rem; 53 + } 54 + 55 + .search-pagination-loading { 56 + flex-direction: column; 57 + align-items: flex-start; 58 + max-width: 20rem; 59 + } 60 + 61 + @media (max-width: 820px) { 62 + .search-results-grid { 63 + grid-template-columns: 1fr; 64 + } 65 + }
+30
src/appview/tests.rs
··· 608 608 } 609 609 610 610 #[tokio::test] 611 + async fn search_things_offset_cursor_paginates() { 612 + let state = state().await; 613 + seed_identity(&state.pool, DID_A, "alice.com").await; 614 + seed_thing(&state.pool, DID_A, "r1", "Modular enclosure alpha", &[], 0).await; 615 + seed_thing(&state.pool, DID_A, "r2", "Modular enclosure beta", &[], 0).await; 616 + seed_thing(&state.pool, DID_A, "r3", "Modular enclosure gamma", &[], 0).await; 617 + 618 + let page1 = views::search_things(&state, "enclosure", 2, None, None) 619 + .await 620 + .unwrap(); 621 + assert_eq!(page1.items.len(), 2); 622 + let cursor = page1.cursor.expect("first page should expose a cursor"); 623 + 624 + let page2 = views::search_things(&state, "enclosure", 2, Some(cursor.as_ref()), None) 625 + .await 626 + .unwrap(); 627 + assert_eq!(page2.items.len(), 1); 628 + assert!(page2.cursor.is_none()); 629 + 630 + let combined = page1 631 + .items 632 + .into_iter() 633 + .chain(page2.items) 634 + .map(|item| item.thing.uri) 635 + .collect::<Vec<_>>(); 636 + let unique = combined.iter().collect::<HashSet<_>>(); 637 + assert_eq!(unique.len(), 3, "search pagination duplicated an item"); 638 + } 639 + 640 + #[tokio::test] 611 641 async fn malformed_ranked_cursor_is_rejected() { 612 642 let state = state().await; 613 643 let search_err = views::search_things(&state, "x", 10, Some("not-a-number"), None)
+440 -10
src/search.rs
··· 1 1 use dioxus::prelude::*; 2 + use jacquard_common::xrpc::XrpcClient; 3 + use polymodel_api::space_polymodel::library; 2 4 3 5 use crate::Route; 6 + use crate::client::PolymodelClient; 7 + use crate::profile::profile_href; 8 + use crate::thing_card::ThingCard; 9 + use crate::thing_detail::thing_detail_href; 10 + 11 + const SEARCH_LIMIT: i64 = 24; 12 + 13 + #[derive(Clone, Debug, PartialEq, Eq)] 14 + struct SearchPageResult { 15 + query: String, 16 + items: Vec<library::ThingViewBasic>, 17 + cursor: Option<String>, 18 + } 4 19 5 - /// Honest placeholder for directed catalog search. The global shell search 6 - /// field routes here with `?q=`. Search indexing and filtering are not built 7 - /// yet, so this page captures the query without ever fabricating results. 20 + #[derive(Clone, Debug, PartialEq, Eq)] 21 + enum LoadMoreState { 22 + Idle, 23 + Loading, 24 + Error(String), 25 + } 26 + 27 + #[derive(Clone, Debug, PartialEq, Eq)] 28 + struct SearchResults { 29 + query: String, 30 + items: Vec<library::ThingViewBasic>, 31 + cursor: Option<String>, 32 + load_more: LoadMoreState, 33 + } 34 + 35 + #[derive(Clone, Debug, PartialEq, Eq)] 36 + enum SearchState { 37 + Idle, 38 + Loading, 39 + Error(String), 40 + Empty, 41 + Populated(SearchResults), 42 + } 43 + 44 + fn search_state( 45 + query: &str, 46 + pending: bool, 47 + result: Option<&Result<SearchPageResult, String>>, 48 + current: Option<&SearchResults>, 49 + ) -> SearchState { 50 + let query = query.trim(); 51 + if query.is_empty() { 52 + return SearchState::Idle; 53 + } 54 + 55 + if let Some(results) = current.filter(|results| results.query == query) { 56 + return SearchState::Populated(results.clone()); 57 + } 58 + 59 + if pending && result.is_none() { 60 + return SearchState::Loading; 61 + } 62 + 63 + match result { 64 + Some(Ok(page)) if page.query != query => SearchState::Loading, 65 + Some(Ok(page)) if page.items.is_empty() => SearchState::Empty, 66 + Some(Ok(page)) => SearchState::Populated(SearchResults { 67 + query: page.query.clone(), 68 + items: page.items.clone(), 69 + cursor: page.cursor.clone(), 70 + load_more: LoadMoreState::Idle, 71 + }), 72 + Some(Err(error)) => SearchState::Error(error.clone()), 73 + None => SearchState::Loading, 74 + } 75 + } 76 + 77 + fn apply_load_more_result( 78 + state: &mut SearchResults, 79 + requested_query: &str, 80 + page: Result<SearchPageResult, String>, 81 + ) { 82 + if state.query != requested_query { 83 + return; 84 + } 85 + 86 + match page { 87 + Ok(page) if page.query == state.query => { 88 + state.items.extend(page.items); 89 + state.cursor = page.cursor; 90 + state.load_more = LoadMoreState::Idle; 91 + } 92 + Ok(_) => {} 93 + Err(error) => state.load_more = LoadMoreState::Error(error), 94 + } 95 + } 96 + 8 97 #[allow(clippy::useless_format)] 9 98 #[component] 10 99 pub(crate) fn Search(q: String) -> Element { 11 - let query = q.trim().to_string(); 100 + let client = use_context::<PolymodelClient>(); 101 + let route = use_route::<Route>(); 102 + let route_query = match route { 103 + Route::Search { q } => q.trim().to_string(), 104 + _ => q.trim().to_string(), 105 + }; 106 + let mut active_query = use_signal(|| route_query.clone()); 107 + let mut results = use_signal(|| None::<SearchResults>); 108 + 109 + if *active_query.read() != route_query { 110 + active_query.set(route_query.clone()); 111 + results.set(None); 112 + } 113 + 114 + let resource_client = client.clone(); 115 + let mut search = use_resource(move || { 116 + let client = resource_client.clone(); 117 + let query = active_query.read().clone(); 118 + async move { load_search_page(client, query, None).await } 119 + }); 120 + 121 + let search_result = search.read(); 122 + let state = search_state( 123 + &route_query, 124 + search.pending(), 125 + search_result.as_ref(), 126 + results.read().as_ref(), 127 + ); 128 + 129 + let page_note = if route_query.is_empty() { 130 + "Catalog search uses the local appview index. Start from the header search field." 131 + } else { 132 + "Catalog search uses the local appview index and returns projects from the Polymodel library." 133 + }; 12 134 13 135 rsx! { 14 136 main { class: "search-page", 15 137 section { class: "search-intro blueprint-panel", aria_label: "Search", 16 - span { class: "status-pill status-muted", "Not wired up yet" } 138 + span { class: "status-pill status-muted", "Catalog search" } 17 139 h1 { "Search" } 18 - if query.is_empty() { 19 - p { class: "product-lede", "Directed catalog search is on the way. Run a query from the header and it will be captured here." } 140 + if route_query.is_empty() { 141 + p { class: "product-lede", "Search the Polymodel catalog by project name, tags, authors, and indexed description text." } 20 142 } else { 21 - p { class: "product-lede", "Searched for \"{query}\". Search indexing is not built yet, so there are no results to show — the query is captured here rather than faked." } 143 + p { class: "product-lede", "Results for \"{route_query}\"" } 22 144 } 23 - p { class: "search-note", "Until search ships, browse the catalog from the home feed." } 24 - Link { class: "button button-secondary", to: Route::Browse {}, "Back to browse" } 145 + p { class: "search-note", "{page_note}" } 146 + } 147 + 148 + match state { 149 + SearchState::Idle => rsx! { 150 + section { class: "state-card empty-state search-state-card", aria_label: "Search instructions", 151 + span { class: "status-pill status-muted", "Ready" } 152 + h2 { "Run a catalog search" } 153 + p { "Use the header search field to look up printable projects in the indexed Polymodel catalog." } 154 + Link { class: "button button-secondary", to: Route::Browse {}, "Browse instead" } 155 + } 156 + }, 157 + SearchState::Loading => rsx! { 158 + section { class: "search-results-grid", aria_label: "Search results loading", 159 + for index in 0..6 { 160 + div { key: "search-skeleton-{index}", class: "state-card loading-state", 161 + span { class: "status-pill", "Searching" } 162 + div { class: "skeleton-line skeleton-wide" } 163 + div { class: "skeleton-line" } 164 + div { class: "skeleton-box" } 165 + } 166 + } 167 + } 168 + }, 169 + SearchState::Error(error) => rsx! { 170 + section { class: "state-card error-state search-state-card", aria_label: "Search error", 171 + h2 { "Search failed" } 172 + p { "{error}" } 173 + button { class: "button button-secondary", onclick: move |_| search.restart(), "Retry" } 174 + } 175 + }, 176 + SearchState::Empty => rsx! { 177 + section { class: "state-card empty-state search-state-card", aria_label: "No search results", 178 + span { class: "status-pill status-muted", "No matches" } 179 + h2 { "No projects match \"{route_query}\"" } 180 + p { "Try a broader term, a material, or a project category. The local index answered; it just came back empty." } 181 + Link { class: "button button-secondary", to: Route::Browse {}, "Back to browse" } 182 + } 183 + }, 184 + SearchState::Populated(search_results) => rsx! { 185 + section { class: "search-results-grid", aria_label: "Search results", 186 + for thing in search_results.items.clone() { 187 + SearchThingCard { key: "{thing.uri}", thing } 188 + } 189 + } 190 + {search_pagination( 191 + client.clone(), 192 + route_query.clone(), 193 + results, 194 + search_results.clone(), 195 + search_results.cursor.clone(), 196 + search_results.load_more.clone(), 197 + )} 198 + }, 25 199 } 26 200 } 27 201 } 28 202 } 203 + 204 + async fn load_search_page( 205 + client: PolymodelClient, 206 + query: String, 207 + cursor: Option<String>, 208 + ) -> Result<SearchPageResult, String> { 209 + let query = query.trim().to_string(); 210 + if query.is_empty() { 211 + return Ok(SearchPageResult { 212 + query, 213 + items: Vec::new(), 214 + cursor: None, 215 + }); 216 + } 217 + 218 + let request = library::search_things::SearchThings::new() 219 + .q(query.clone()) 220 + .limit(SEARCH_LIMIT) 221 + .maybe_cursor(cursor.map(Into::into)) 222 + .build(); 223 + let response = client 224 + .send(request) 225 + .await 226 + .map_err(|error| format!("Search request failed: {error}"))?; 227 + let output = response 228 + .into_output() 229 + .map_err(|error| format!("Search decode failed: {error}"))?; 230 + Ok(SearchPageResult { 231 + query, 232 + items: output 233 + .value 234 + .items 235 + .into_iter() 236 + .map(|item| item.thing) 237 + .collect(), 238 + cursor: output.value.cursor.map(|cursor| cursor.to_string()), 239 + }) 240 + } 241 + 242 + #[component] 243 + fn SearchThingCard(thing: library::ThingViewBasic) -> Element { 244 + let detail_href = thing_detail_href(&thing.uri); 245 + let creator_href = profile_href(&thing.author); 246 + rsx! { ThingCard { thing, detail_href, creator_href } } 247 + } 248 + 249 + fn search_pagination( 250 + client: PolymodelClient, 251 + query: String, 252 + results: Signal<Option<SearchResults>>, 253 + current_results: SearchResults, 254 + cursor: Option<String>, 255 + load_more: LoadMoreState, 256 + ) -> Element { 257 + match (cursor, load_more) { 258 + (Some(cursor), LoadMoreState::Idle) => rsx! { 259 + div { class: "search-pagination", aria_label: "Search pagination", 260 + button { 261 + class: "button button-secondary", 262 + onclick: move |_| { 263 + let client = client.clone(); 264 + let query = query.clone(); 265 + let cursor = cursor.clone(); 266 + let current_results = current_results.clone(); 267 + let mut results = results; 268 + results.set(Some(SearchResults { 269 + load_more: LoadMoreState::Loading, 270 + ..current_results 271 + })); 272 + spawn(async move { 273 + let page = load_search_page(client, query.clone(), Some(cursor)).await; 274 + results.with_mut(|state| { 275 + let Some(state) = state else { return; }; 276 + apply_load_more_result(state, &query, page); 277 + }); 278 + }); 279 + }, 280 + "Load more" 281 + } 282 + } 283 + }, 284 + (Some(_), LoadMoreState::Loading) => rsx! { 285 + div { class: "search-pagination search-pagination-loading", aria_live: "polite", 286 + span { class: "status-pill", "Loading more" } 287 + div { class: "skeleton-line skeleton-wide" } 288 + } 289 + }, 290 + (Some(cursor), LoadMoreState::Error(error)) => rsx! { 291 + div { class: "state-card error-state search-load-more-error", aria_label: "Load more failed", 292 + h2 { "Could not load more results" } 293 + p { "{error}" } 294 + button { 295 + class: "button button-secondary", 296 + onclick: move |_| { 297 + let client = client.clone(); 298 + let query = query.clone(); 299 + let cursor = cursor.clone(); 300 + let current_results = current_results.clone(); 301 + let mut results = results; 302 + results.set(Some(SearchResults { 303 + load_more: LoadMoreState::Loading, 304 + ..current_results 305 + })); 306 + spawn(async move { 307 + let page = load_search_page(client, query.clone(), Some(cursor)).await; 308 + results.with_mut(|state| { 309 + let Some(state) = state else { return; }; 310 + apply_load_more_result(state, &query, page); 311 + }); 312 + }); 313 + }, 314 + "Retry load more" 315 + } 316 + } 317 + }, 318 + (None, _) => rsx! {}, 319 + } 320 + } 321 + 322 + #[cfg(test)] 323 + mod tests { 324 + use super::*; 325 + use crate::thing_card::thing_card_fixtures; 326 + 327 + fn page( 328 + query: &str, 329 + items: Vec<library::ThingViewBasic>, 330 + cursor: Option<&str>, 331 + ) -> SearchPageResult { 332 + SearchPageResult { 333 + query: query.to_string(), 334 + items, 335 + cursor: cursor.map(str::to_string), 336 + } 337 + } 338 + 339 + #[test] 340 + fn search_state_distinguishes_idle_loading_empty_error_and_populated() { 341 + assert_eq!(search_state(" ", false, None, None), SearchState::Idle); 342 + assert_eq!(search_state("cube", true, None, None), SearchState::Loading); 343 + assert_eq!( 344 + search_state("cube", false, Some(&Ok(page("cube", vec![], None))), None), 345 + SearchState::Empty 346 + ); 347 + assert_eq!( 348 + search_state( 349 + "cube", 350 + false, 351 + Some(&Err("index unavailable".to_string())), 352 + None, 353 + ), 354 + SearchState::Error("index unavailable".to_string()) 355 + ); 356 + 357 + match search_state( 358 + "cube", 359 + false, 360 + Some(&Ok(page("cube", thing_card_fixtures(), Some("search:24")))), 361 + None, 362 + ) { 363 + SearchState::Populated(results) => { 364 + assert_eq!(results.items.len(), 3); 365 + assert_eq!(results.cursor.as_deref(), Some("search:24")); 366 + assert_eq!(results.load_more, LoadMoreState::Idle); 367 + } 368 + state => panic!("expected populated state, got {state:?}"), 369 + } 370 + } 371 + 372 + #[test] 373 + fn search_state_preserves_loaded_results_during_pagination_states() { 374 + let base = SearchResults { 375 + query: "cube".into(), 376 + items: thing_card_fixtures(), 377 + cursor: Some("search:24".into()), 378 + load_more: LoadMoreState::Loading, 379 + }; 380 + assert_eq!( 381 + search_state("cube", true, None, Some(&base)), 382 + SearchState::Populated(base.clone()) 383 + ); 384 + 385 + let errored = SearchResults { 386 + load_more: LoadMoreState::Error("next page failed".into()), 387 + ..base.clone() 388 + }; 389 + assert_eq!( 390 + search_state("cube", false, None, Some(&errored)), 391 + SearchState::Populated(errored.clone()) 392 + ); 393 + 394 + let finished = SearchResults { 395 + cursor: None, 396 + load_more: LoadMoreState::Idle, 397 + ..base 398 + }; 399 + assert_eq!( 400 + search_state("cube", false, None, Some(&finished)), 401 + SearchState::Populated(finished) 402 + ); 403 + } 404 + 405 + #[test] 406 + fn search_state_ignores_results_from_previous_query() { 407 + let current = SearchResults { 408 + query: "cube".into(), 409 + items: thing_card_fixtures(), 410 + cursor: None, 411 + load_more: LoadMoreState::Idle, 412 + }; 413 + assert_eq!( 414 + search_state("enclosure", true, None, Some(&current)), 415 + SearchState::Loading 416 + ); 417 + assert_eq!( 418 + search_state( 419 + "enclosure", 420 + false, 421 + Some(&Ok(page("cube", thing_card_fixtures(), None))), 422 + None, 423 + ), 424 + SearchState::Loading 425 + ); 426 + } 427 + 428 + #[test] 429 + fn load_more_completion_is_bound_to_requested_query() { 430 + let mut state = SearchResults { 431 + query: "enclosure".into(), 432 + items: thing_card_fixtures(), 433 + cursor: Some("search:24".into()), 434 + load_more: LoadMoreState::Loading, 435 + }; 436 + 437 + apply_load_more_result(&mut state, "cube", Err("old request failed".into())); 438 + assert_eq!(state.load_more, LoadMoreState::Loading); 439 + assert_eq!(state.items.len(), 3); 440 + 441 + apply_load_more_result( 442 + &mut state, 443 + "cube", 444 + Ok(page("cube", thing_card_fixtures(), None)), 445 + ); 446 + assert_eq!(state.load_more, LoadMoreState::Loading); 447 + assert_eq!(state.items.len(), 3); 448 + 449 + apply_load_more_result( 450 + &mut state, 451 + "enclosure", 452 + Ok(page("enclosure", thing_card_fixtures(), None)), 453 + ); 454 + assert_eq!(state.load_more, LoadMoreState::Idle); 455 + assert_eq!(state.items.len(), 6); 456 + assert!(state.cursor.is_none()); 457 + } 458 + }