Skip to main content

max / makenotwork

50.0 KB · 1384 lines History Blame Raw
1 //! Discover/search page with filterable, paginated items and projects.
2
3 use axum::Json;
4 use axum::extract::State;
5 use axum::http::HeaderMap;
6 use axum::response::IntoResponse;
7 use axum_extra::extract::Query;
8 use serde::{Deserialize, Serialize};
9 use sqlx::PgPool;
10 use tower_sessions::Session;
11
12 use std::collections::HashMap;
13 use std::sync::{Arc, Mutex, OnceLock};
14 use std::time::{Duration, Instant};
15
16 use crate::{
17 auth::MaybeUserUnverified,
18 constants,
19 db::{self, AiTierFilter, DiscoverSort, ItemType, discover::DiscoverFilters},
20 error::Result,
21 helpers::get_csrf_token,
22 templates::{DiscoverResultsTemplate, DiscoverTemplate, TagTreeTemplate},
23 types::{
24 DiscoverItem, DiscoverProject, FilterCategory, PriceBucket, SidebarView, TagBreadcrumb,
25 TagChip, TagCrumb, TagDrillRow, TagTreeNode,
26 },
27 };
28
29 /// Build the sidebar view model.
30 ///
31 /// Shared by the full page and the `/discover/results` partial. The partial
32 /// needs it because the sidebar is swapped out-of-band on every filter change:
33 /// its counts describe the current filter, so leaving them behind would show
34 /// numbers that no longer match the results beside them.
35 async fn build_sidebar(
36 db: &PgPool,
37 query: &DiscoverQuery,
38 data: &DiscoverData,
39 viewer_id: Option<db::UserId>,
40 ) -> Result<SidebarView> {
41 let f = query.filter_selection();
42 let search_filter = f.search;
43 let tag_filter = f.tags;
44 let item_type_filter = f.item_types;
45 let has_source_code = f.has_source_code;
46
47 // Build type and tag filters (items mode only)
48 let category_filter = f.category;
49
50 // Build category filters (projects mode only)
51 let category_filters = if data.mode == "projects" {
52 let cat_counts = db::categories::get_category_counts(db, search_filter).await?;
53
54 let mut filters: Vec<FilterCategory> = vec![FilterCategory {
55 name: "All".to_string(),
56 value: String::new(),
57 count: data.total_count,
58 active: category_filter.is_none(),
59 id: String::new(),
60 following: false,
61 }];
62 for cc in cat_counts {
63 filters.push(FilterCategory {
64 name: cc.name,
65 value: cc.slug.to_string(),
66 count: cc.count as u32,
67 active: category_filter == Some(cc.slug.as_str()),
68 id: String::new(),
69 following: false,
70 });
71 }
72 filters
73 } else {
74 vec![]
75 };
76
77 // The applied bounds, rendered back into the number inputs. Without this the
78 // inputs come back blank, hx-include resends them empty, and the price filter
79 // silently disappears on the next interaction.
80 let (shown_min, shown_max) = sanitize_price_range(query.min_price, query.max_price);
81 let current_min_price = shown_min.map(|v| v.to_string()).unwrap_or_default();
82 let current_max_price = shown_max.map(|v| v.to_string()).unwrap_or_default();
83
84 // Built before the template literal partially moves `query`.
85 let browse_url_prefix = query.browse_base_url();
86 let browse_url_root = query.browse_root_url();
87
88 let mut tag_chips: Vec<TagChip> = Vec::new();
89 let mut tag_drill: Vec<TagDrillRow> = Vec::new();
90 let mut tag_crumbs: Vec<TagCrumb> = Vec::new();
91
92 let (type_filters, tag_filters, ai_tier_filters, price_counts) = if data.mode == "items" {
93 // The four facet counts are viewer-independent -> served from a short-TTL
94 // memo (ultra-fuzz Run 12 Performance). Only `followed_tag_ids` is
95 // per-viewer, so it is computed fresh (and only when logged in).
96 let facet_filters = DiscoverFilters {
97 search: search_filter,
98 item_types: &item_type_filter,
99 tags: &tag_filter,
100 min_price: query.min_price,
101 max_price: query.max_price,
102 sort_by: None,
103 ai_tier: f.ai_tier,
104 };
105 let (type_counts, tag_counts, ai_counts, price_counts) =
106 cached_facets(db, &facet_filters).await?;
107
108 // The drill-down cursor. Not cached alongside the facets: it varies by
109 // an axis the facet key does not carry, and it is one cheap indexed
110 // query over a ~120-row table.
111 let browse_cursor = query.browse.as_deref().filter(|s| !s.is_empty());
112 let drill_rows =
113 db::tags::tag_children_with_counts(db, browse_cursor, &facet_filters).await?;
114
115 // Follow state for every rung on screen. Still scoped to the rendered
116 // rows rather than the viewer's whole followed set (fuzz 2026-07-06
117 // C5-1), and skipped entirely for anonymous viewers, but no longer
118 // narrowed to assignable rows, since following is hierarchical and a
119 // branch is now a legitimate follow target.
120 let followed: std::collections::HashSet<db::TagId> = match viewer_id {
121 Some(uid) => {
122 let ids: Vec<_> = drill_rows.iter().map(|r| r.tag_id).collect();
123 db::follows::following_subset(db, uid, &ids).await?
124 }
125 None => std::collections::HashSet::new(),
126 };
127
128 tag_drill = drill_rows
129 .into_iter()
130 .map(|r| TagDrillRow {
131 selected: tag_filter.iter().any(|t| t == &r.tag_slug),
132 following: followed.contains(&r.tag_id),
133 tag_id: r.tag_id.to_string(),
134 slug: r.tag_slug,
135 label: r.tag_name,
136 count: r.count as u32,
137 assignable: r.assignable,
138 has_children: r.has_children,
139 })
140 .collect();
141
142 // Breadcrumbs are derived from the cursor's own dot-path rather than
143 // queried: tagtree::ancestors is exactly this, and every ancestor of a
144 // valid slug is itself a valid slug.
145 tag_crumbs = browse_cursor
146 .map(|cursor| {
147 tagtree::ancestors(cursor)
148 .into_iter()
149 .chain(std::iter::once(cursor))
150 .map(|slug| TagCrumb {
151 slug: slug.to_string(),
152 label: tagtree::leaf(slug).replace('-', " "),
153 })
154 .collect()
155 })
156 .unwrap_or_default();
157
158 // Chips carry a precomputed dismiss query so the template stays free of
159 // list manipulation, and so removal is a plain link that works without JS.
160 let chip_names: std::collections::HashMap<String, String> =
161 db::tags::tag_names_for_slugs(db, &tag_filter)
162 .await?
163 .into_iter()
164 .collect();
165 tag_chips = tag_filter
166 .iter()
167 .map(|slug| {
168 let remove_query = tag_filter
169 .iter()
170 .filter(|other| *other != slug)
171 .map(|other| format!("tag={}", urlencoding::encode(other)))
172 .collect::<Vec<_>>()
173 .join("&");
174 TagChip {
175 label: chip_names
176 .get(slug)
177 .cloned()
178 .unwrap_or_else(|| tagtree::leaf(slug).replace('-', " ")),
179 context: tagtree::parent(slug).unwrap_or("").to_string(),
180 slug: slug.clone(),
181 remove_query,
182 }
183 })
184 .collect();
185
186 let mut type_filters: Vec<FilterCategory> = vec![FilterCategory {
187 name: "All".to_string(),
188 value: String::new(),
189 count: data.total_count,
190 active: item_type_filter.is_empty(),
191 id: String::new(),
192 following: false,
193 }];
194 for tc in type_counts {
195 let active = item_type_filter
196 .iter()
197 .any(|t| t.to_string() == tc.category);
198 type_filters.push(FilterCategory {
199 value: tc.category.clone(),
200 name: tc.category,
201 count: tc.count as u32,
202 active,
203 id: String::new(),
204 following: false,
205 });
206 }
207
208 let mut tag_filters: Vec<FilterCategory> = vec![FilterCategory {
209 name: "All".to_string(),
210 value: String::new(),
211 count: data.total_count,
212 active: tag_filter.is_empty(),
213 id: String::new(),
214 following: false,
215 }];
216 for tc in tag_counts.iter().take(10) {
217 tag_filters.push(FilterCategory {
218 name: tc.tag_name.clone(),
219 value: tc.tag_slug.clone(),
220 count: tc.count as u32,
221 active: tag_filter.iter().any(|t| t == &tc.tag_slug),
222 id: tc.tag_id.to_string(),
223 following: false,
224 });
225 }
226
227 // Per `about/generative-ai.md` § "How Fans Use This", the three
228 // filter options are "Everything" / "Human-led" (Handmade ∪
229 // Assisted) / "Handmade only". Aggregate the per-tier counts
230 // into option-sized counts before passing to the template.
231 let mut handmade_count: u32 = 0;
232 let mut assisted_count: u32 = 0;
233 for ac in &ai_counts {
234 match ac.category.as_str() {
235 "handmade" => handmade_count = ac.count as u32,
236 "assisted" => assisted_count = ac.count as u32,
237 _ => {}
238 }
239 }
240 let ai_tier_filter_str = query.ai_tier.as_deref().filter(|s| !s.is_empty());
241 let ai_tier_filters: Vec<FilterCategory> = vec![
242 FilterCategory {
243 name: "Everything".to_string(),
244 value: String::new(),
245 count: data.total_count,
246 active: ai_tier_filter_str.is_none(),
247 id: String::new(),
248 following: false,
249 },
250 FilterCategory {
251 name: db::AiTierFilter::HumanLed.label().to_string(),
252 value: db::AiTierFilter::HumanLed.to_string(),
253 count: handmade_count + assisted_count,
254 active: ai_tier_filter_str == Some(db::AiTierFilter::HumanLed.to_string().as_str()),
255 id: String::new(),
256 following: false,
257 },
258 FilterCategory {
259 name: db::AiTierFilter::HandmadeOnly.label().to_string(),
260 value: db::AiTierFilter::HandmadeOnly.to_string(),
261 count: handmade_count,
262 active: ai_tier_filter_str
263 == Some(db::AiTierFilter::HandmadeOnly.to_string().as_str()),
264 id: String::new(),
265 following: false,
266 },
267 ];
268
269 (type_filters, tag_filters, ai_tier_filters, price_counts)
270 } else {
271 (vec![], vec![], vec![], Vec::<i64>::new())
272 };
273
274 // Both the counts and the clickable ranges come from db::discover::PRICE_BUCKETS,
275 // so a bucket can never display a number its own link fails to reproduce.
276 let price_base_params = query.params_without_price();
277 let (applied_min, applied_max) = sanitize_price_range(query.min_price, query.max_price);
278 let price_buckets: Vec<PriceBucket> = db::discover::PRICE_BUCKETS
279 .iter()
280 .zip(price_counts.iter())
281 .map(|((label, min, max), count)| {
282 let mut parts = price_base_params.clone();
283 parts.push(format!("min_price={min}"));
284 if let Some(v) = *max {
285 parts.push(format!("max_price={v}"));
286 }
287 PriceBucket {
288 label: label.to_string(),
289 count: *count as u32,
290 url: format!("/discover?{}", parts.join("&")),
291 active: applied_min == Some(*min) && applied_max == *max,
292 }
293 })
294 .collect();
295
296 // The mobile filter badge counts *groups* with an active selection, not
297 // individual values: picking three tags is still one filter in use.
298 let current_types: Vec<String> = dedup_nonempty(&query.item_type)
299 .into_iter()
300 .map(str::to_string)
301 .collect();
302 let current_tags: Vec<String> = dedup_nonempty(&query.tag)
303 .into_iter()
304 .map(str::to_string)
305 .collect();
306 let current_category = query.category.clone().unwrap_or_default();
307 let current_ai_tier = query.ai_tier.clone().unwrap_or_default();
308
309 // A selection is only carried by a hidden input when no visible control
310 // represents it. The drill-down shows one rung at a time, so a tag chosen
311 // from elsewhere in the tree has no checkbox on screen; likewise a type
312 // whose count dropped out of the facet list. Rendering a hidden input for a
313 // value that also has a checked checkbox would submit it twice.
314 let visible_tag_slugs: std::collections::HashSet<&str> =
315 tag_drill.iter().map(|r| r.slug.as_str()).collect();
316 let hidden_tags: Vec<String> = current_tags
317 .iter()
318 .filter(|t| !visible_tag_slugs.contains(t.as_str()))
319 .cloned()
320 .collect();
321 let visible_type_values: std::collections::HashSet<&str> =
322 type_filters.iter().map(|t| t.value.as_str()).collect();
323 let hidden_types: Vec<String> = current_types
324 .iter()
325 .filter(|t| !visible_type_values.contains(t.as_str()))
326 .cloned()
327 .collect();
328
329 let active_filter_count = [
330 !current_types.is_empty(),
331 !current_tags.is_empty(),
332 !current_category.is_empty(),
333 !current_ai_tier.is_empty(),
334 has_source_code,
335 query.min_price.is_some(),
336 query.max_price.is_some(),
337 ]
338 .iter()
339 .filter(|&&v| v)
340 .count() as u32;
341
342 Ok(SidebarView {
343 type_filters,
344 tag_filters,
345 category_filters,
346 price_buckets,
347 ai_tier_filters,
348 tag_chips,
349 tag_drill,
350 tag_crumbs,
351 hidden_tags,
352 hidden_types,
353 current_types,
354 current_tags,
355 current_min_price,
356 current_max_price,
357 current_category,
358 current_ai_tier,
359 browse_url_prefix,
360 browse_url_root,
361 has_source: has_source_code,
362 active_filter_count,
363 viewer_authenticated: viewer_id.is_some(),
364 browse_cursor: query.browse.clone().unwrap_or_default(),
365 })
366 }
367
368 /// The four viewer-independent discover facet results (item-type, tag, ai-tier, and
369 /// price-range counts). `followed_tag_ids` is deliberately excluded, it is
370 /// per-viewer and always computed fresh.
371 type FacetBundle = (
372 Vec<db::DbItemTypeCount>,
373 Vec<db::DbTagCount>,
374 Vec<db::DbItemTypeCount>,
375 // Bucket counts, positional and in `db::discover::PRICE_BUCKETS` order.
376 Vec<i64>,
377 );
378
379 /// Cache key: the filter inputs every facet query keys off. Same filters ->
380 /// identical viewer-independent counts, so they can be shared across viewers.
381 ///
382 /// `ai_tier` is part of the key. It was omitted while no facet query applied the
383 /// AI-tier filter, which made it harmless; the moment the facets started
384 /// cross-applying it, leaving it out would have served one tier's counts to
385 /// another for up to `FACET_CACHE_TTL`.
386 /// The leading `String` is the database name. The memo is a process-global, so
387 /// without it two pools pointed at different databases would share counts. In
388 /// production there is one database and this is a constant; under test every
389 /// case gets its own template clone, and omitting it silently served one test's
390 /// facet counts to another.
391 type FacetKey = (
392 String,
393 Option<String>,
394 Vec<ItemType>,
395 Vec<String>,
396 Option<i32>,
397 Option<i32>,
398 Option<AiTierFilter>,
399 );
400
401 /// Short TTL for the facet memo. The discover facets are the hottest, most
402 /// cacheable aggregate on the busiest public page; recomputing five full-catalog
403 /// scans on every anonymous hit is the crate's largest DB amplifier (ultra-fuzz
404 /// Run 12 Performance). Expiry-only invalidation (no bust on catalog change), a
405 /// count that is up to a minute stale on the discover sidebar is harmless.
406 const FACET_CACHE_TTL: Duration = Duration::from_mins(1);
407 /// Bound the memo so a wide spread of filter combinations can't grow it without
408 /// limit; on overflow, drop expired entries first, then clear if still full.
409 const FACET_CACHE_MAX: usize = 512;
410
411 static FACET_CACHE: OnceLock<Mutex<HashMap<FacetKey, (Instant, FacetBundle)>>> = OnceLock::new();
412
413 /// The four viewer-independent discover facets, memoized for [`FACET_CACHE_TTL`].
414 /// On a miss the four queries still run (concurrently); the memo makes the common
415 /// case, repeated anonymous loads of the same filter view, a single map lookup
416 /// instead of five full-catalog aggregate scans holding five pool connections.
417 async fn cached_facets(
418 db: &PgPool,
419 filters: &db::discover::DiscoverFilters<'_>,
420 ) -> Result<FacetBundle> {
421 let key: FacetKey = (
422 db.connect_options()
423 .get_database()
424 .unwrap_or_default()
425 .to_string(),
426 filters.search.map(str::to_string),
427 filters.item_types.to_vec(),
428 filters.tags.to_vec(),
429 filters.min_price,
430 filters.max_price,
431 filters.ai_tier,
432 );
433
434 let cache = FACET_CACHE.get_or_init(|| Mutex::new(HashMap::new()));
435 if let Ok(guard) = cache.lock()
436 && let Some((at, bundle)) = guard.get(&key)
437 && at.elapsed() < FACET_CACHE_TTL
438 {
439 return Ok(bundle.clone());
440 }
441
442 let bundle: FacetBundle = tokio::try_join!(
443 db::discover::get_item_type_counts(db, filters),
444 db::tags::get_tag_counts(db, filters),
445 db::discover::get_ai_tier_counts(db, filters),
446 db::discover::get_price_range_counts(db, filters),
447 )?;
448
449 if let Ok(mut guard) = cache.lock() {
450 if guard.len() >= FACET_CACHE_MAX {
451 guard.retain(|_, (at, _)| at.elapsed() < FACET_CACHE_TTL);
452 if guard.len() >= FACET_CACHE_MAX {
453 guard.clear();
454 }
455 }
456 guard.insert(key, (Instant::now(), bundle.clone()));
457 }
458
459 Ok(bundle)
460 }
461
462 /// Timestamped index, so the TTL check and the shared handle travel together.
463 type CachedTagIndex = Option<(Instant, Arc<tagtree::TagIndex>)>;
464
465 /// Memoized [`tagtree::TagIndex`] over every tag slug, backing the sidebar's
466 /// tag typeahead.
467 ///
468 /// The index is memory-only and has to be rebuilt from Postgres; `rebuild`
469 /// exists for exactly this. It is refreshed wholesale on a TTL rather than
470 /// mutated, which also sidesteps `TagIndex::remove` leaving orphaned segments
471 /// behind (tagtree lib.rs:817) and degrading the segment-prefix gate over time.
472 ///
473 /// The taxonomy is ~120 tags and changes only by migration, so five minutes of
474 /// staleness in an autocomplete is not worth invalidation machinery.
475 static TAG_INDEX: OnceLock<Mutex<CachedTagIndex>> = OnceLock::new();
476
477 const TAG_INDEX_TTL: Duration = Duration::from_mins(5);
478
479 async fn cached_tag_index(db: &PgPool) -> Result<Arc<tagtree::TagIndex>> {
480 let cell = TAG_INDEX.get_or_init(|| Mutex::new(None));
481 if let Ok(guard) = cell.lock()
482 && let Some((at, index)) = guard.as_ref()
483 && at.elapsed() < TAG_INDEX_TTL
484 {
485 return Ok(Arc::clone(index));
486 }
487
488 let slugs = db::tags::all_tag_slugs(db).await?;
489 let index = Arc::new(tagtree::TagIndex::new(slugs));
490
491 if let Ok(mut guard) = cell.lock() {
492 *guard = Some((Instant::now(), Arc::clone(&index)));
493 }
494 Ok(index)
495 }
496
497 /// How many typeahead results to return. Enough to fill the dropdown without
498 /// turning `suggest_fuzzy`'s full-corpus scan into a page-weight problem.
499 const TAG_SUGGEST_LIMIT: usize = 8;
500
501 /// Tag typeahead for the discover sidebar.
502 ///
503 /// Prefix matching first (path-prefix, then segment-prefix), falling back to
504 /// fuzzy only when prefix matching underfills. That ordering is tagtree's, and
505 /// it is why typing "elec" finds `audio.genre.electronic` without the caller
506 /// knowing which level it lives at.
507 pub(super) async fn tag_suggestions_handler(
508 State(db): State<PgPool>,
509 Query(query): Query<SuggestionsQuery>,
510 ) -> Result<impl IntoResponse> {
511 let raw = query.q.unwrap_or_default();
512 let input = raw.trim();
513 if input.is_empty() {
514 return Ok(Json(Vec::<TagSuggestion>::new()));
515 }
516
517 let index = cached_tag_index(&db).await?;
518 let (hits, _exact) = index.suggest_with_status(input, TAG_SUGGEST_LIMIT);
519 let mut slugs: Vec<String> = hits.into_iter().map(str::to_string).collect();
520 if slugs.is_empty() {
521 slugs = index
522 .suggest_fuzzy(input, TAG_SUGGEST_LIMIT)
523 .into_iter()
524 .map(str::to_string)
525 .collect();
526 }
527
528 // Only depth-3+ tags can be assigned to an item, so only they can filter
529 // anything; offering a category here would produce an empty result set.
530 slugs.retain(|s| tagtree::depth(s) >= 3);
531
532 let names: std::collections::HashMap<String, String> =
533 db::tags::tag_names_for_slugs(&db, &slugs)
534 .await?
535 .into_iter()
536 .collect();
537
538 let suggestions: Vec<TagSuggestion> = slugs
539 .into_iter()
540 .map(|slug| {
541 let label = names
542 .get(&slug)
543 .cloned()
544 .unwrap_or_else(|| tagtree::leaf(&slug).replace('-', " "));
545 // The parent path orients an otherwise ambiguous leaf: "Format"
546 // appears under audio, software, writing, and video.
547 let context = tagtree::parent(&slug).unwrap_or("").to_string();
548 TagSuggestion {
549 slug,
550 label,
551 context,
552 }
553 })
554 .collect();
555
556 Ok(Json(suggestions))
557 }
558
559 /// Deserialize an empty string as `None` instead of failing to parse.
560 ///
561 /// HTML form inputs send `field=` (empty string) when blank, which fails
562 /// serde's default `Option<i32>` parsing. This treats `""` as `None`.
563 fn empty_string_as_none<'de, D, T>(deserializer: D) -> std::result::Result<Option<T>, D::Error>
564 where
565 D: serde::Deserializer<'de>,
566 T: std::str::FromStr,
567 T::Err: std::fmt::Display,
568 {
569 let opt = Option::<String>::deserialize(deserializer)?;
570 match opt {
571 None => Ok(None),
572 Some(s) if s.is_empty() => Ok(None),
573 Some(s) => s.parse::<T>().map(Some).map_err(serde::de::Error::custom),
574 }
575 }
576
577 /// Query parameters for the discover/search page.
578 #[derive(Debug, Deserialize)]
579 pub(super) struct DiscoverQuery {
580 pub q: Option<String>,
581 /// Repeated `item_type=` params, OR'd together. A single value (what the
582 /// pre-multi-select UI sends) parses as a one-element vec, so old links and
583 /// bookmarks keep working.
584 #[serde(default)]
585 pub item_type: Vec<String>,
586 /// Repeated `tag=` params, OR'd together; each matches its own subtree.
587 #[serde(default)]
588 pub tag: Vec<String>,
589 pub category: Option<String>,
590 #[serde(default, deserialize_with = "empty_string_as_none")]
591 pub min_price: Option<i32>,
592 #[serde(default, deserialize_with = "empty_string_as_none")]
593 pub max_price: Option<i32>,
594 pub sort: Option<String>,
595 #[serde(default, deserialize_with = "empty_string_as_none")]
596 pub page: Option<u32>,
597 pub mode: Option<String>, // "items" (default) or "projects"
598 pub ai_tier: Option<String>,
599 pub has_source: Option<String>,
600 /// Drill-down cursor: which tag's children the sidebar is showing. Distinct
601 /// from `tag`, which is the selection. Browsing into a category does not
602 /// filter, and selecting a leaf does not move the cursor, so the two can be
603 /// operated independently.
604 pub browse: Option<String>,
605 }
606
607 impl DiscoverQuery {
608 /// Rebuild the canonical full-page URL for this filter selection, for the
609 /// HTMX history headers.
610 ///
611 /// Blank values are dropped rather than echoed: every filter request carries
612 /// the whole `.discover-filter` set via `hx-include`, so a verbatim
613 /// round-trip would put `/discover?q=&tag=&category=&min_price=` in the
614 /// address bar. Prices are sanitized so the URL states what was actually
615 /// applied, and `page=1` is left implicit.
616 fn to_page_url(&self) -> String {
617 let mut parts = self.filter_params();
618 if let Some(b) = self
619 .browse
620 .as_ref()
621 .map(|s| s.trim())
622 .filter(|s| !s.is_empty())
623 {
624 parts.push(format!("browse={}", urlencoding::encode(b)));
625 }
626 if let Some(p) = self.page.filter(|&p| p > 1) {
627 parts.push(format!("page={p}"));
628 }
629
630 if parts.is_empty() {
631 "/discover".to_string()
632 } else {
633 format!("/discover?{}", parts.join("&"))
634 }
635 }
636
637 /// Base URL for moving the drill-down cursor, ending ready for a `browse=`
638 /// value to be appended.
639 ///
640 /// Navigating the tree preserves the current selection and drops `page`,
641 /// since a cursor move lands you on a different list and page 4 of the old
642 /// one is meaningless.
643 fn browse_base_url(&self) -> String {
644 let parts = self.filter_params();
645 if parts.is_empty() {
646 "/discover?browse=".to_string()
647 } else {
648 format!("/discover?{}&browse=", parts.join("&"))
649 }
650 }
651
652 /// The URL that clears the cursor back to the tag roots, selection intact.
653 fn browse_root_url(&self) -> String {
654 let parts = self.filter_params();
655 if parts.is_empty() {
656 "/discover".to_string()
657 } else {
658 format!("/discover?{}", parts.join("&"))
659 }
660 }
661
662 /// Filter params with the price range omitted, for building the price
663 /// bucket links: a bucket replaces the range rather than adding to it.
664 fn params_without_price(&self) -> Vec<String> {
665 let (min, max) = sanitize_price_range(self.min_price, self.max_price);
666 self.filter_params()
667 .into_iter()
668 .filter(|p| {
669 !(min.is_some_and(|v| *p == format!("min_price={v}"))
670 || max.is_some_and(|v| *p == format!("max_price={v}")))
671 })
672 .collect()
673 }
674
675 /// Every filter param except the cursor and the page, in canonical order.
676 fn filter_params(&self) -> Vec<String> {
677 fn push_str(parts: &mut Vec<String>, key: &str, value: Option<&String>) {
678 if let Some(v) = value.map(|s| s.trim()).filter(|s| !s.is_empty()) {
679 parts.push(format!("{key}={}", urlencoding::encode(v)));
680 }
681 }
682
683 // Multi-valued facets emit one param per selection, deduped and blank-free,
684 // so the address bar states exactly the filter that was applied.
685 fn push_each(parts: &mut Vec<String>, key: &str, values: &[String]) {
686 for v in dedup_nonempty(values) {
687 parts.push(format!("{key}={}", urlencoding::encode(v)));
688 }
689 }
690
691 let mut parts = Vec::new();
692 push_str(&mut parts, "mode", self.mode.as_ref());
693 push_str(&mut parts, "q", self.q.as_ref());
694 push_each(&mut parts, "item_type", &self.item_type);
695 push_each(&mut parts, "tag", &self.tag);
696 push_str(&mut parts, "category", self.category.as_ref());
697 push_str(&mut parts, "ai_tier", self.ai_tier.as_ref());
698 push_str(&mut parts, "has_source", self.has_source.as_ref());
699 push_str(&mut parts, "sort", self.sort.as_ref());
700
701 let (min_price, max_price) = sanitize_price_range(self.min_price, self.max_price);
702 if let Some(v) = min_price {
703 parts.push(format!("min_price={v}"));
704 }
705 if let Some(v) = max_price {
706 parts.push(format!("max_price={v}"));
707 }
708
709 parts
710 }
711 }
712
713 /// Shared result data for both the full discover page and the HTMX partial.
714 struct DiscoverData {
715 items: Vec<DiscoverItem>,
716 projects: Vec<DiscoverProject>,
717 mode: String,
718 total_count: u32,
719 current_page: u32,
720 total_pages: u32,
721 pagination_range: Vec<u32>,
722 showing_start: u32,
723 showing_end: u32,
724 /// A search term was applied, so the copy addresses a search rather than a
725 /// filter.
726 ///
727 /// Taken from the same `filter_selection()` the query used, not from the raw
728 /// `?q=`: a whitespace-only term is browsing, and reading it separately here
729 /// would have the copy disagree with the list under it.
730 is_search: bool,
731 /// The rendered count line, e.g. "247 results" or "1 item".
732 count_label: String,
733 }
734
735 /// The count line above the results.
736 ///
737 /// Says "results" for a search and names the thing for a browse, because those
738 /// are different claims. Browsing, the number really is how many items match the
739 /// filters. Searching, membership is any typed word (migration 179), so most of
740 /// a large number can be partial matches; "247 items" would assert 247 things
741 /// matched, which is not what was counted. "247 results" describes the list,
742 /// and the tier heading and per-row counts describe its quality.
743 ///
744 /// Built here rather than in the templates because two of them render this same
745 /// `#total-count` span, the page and the out-of-band partial. Any difference
746 /// between them shows up as the text changing on the first HTMX swap, which is
747 /// the failure mode that already bit this element once (it used to sit outside
748 /// `#results-container` and go stale). One string, no way to disagree.
749 fn results_count_label(total: u32, mode: &str, is_search: bool) -> String {
750 let noun = match (is_search, mode, total) {
751 (true, _, 1) => "result",
752 (true, _, _) => "results",
753 (false, "projects", 1) => "project",
754 (false, "projects", _) => "projects",
755 (false, _, 1) => "item",
756 (false, _, _) => "items",
757 };
758 format!("{total} {noun}")
759 }
760
761 /// Clamp the discover price filters to a sane range. A negative bound is
762 /// meaningless (prices are non-negative cents) and is dropped; an inverted range
763 /// (min > max) can only match nothing, so both bounds are dropped rather than
764 /// issuing an empty-by-construction query.
765 fn sanitize_price_range(min: Option<i32>, max: Option<i32>) -> (Option<i32>, Option<i32>) {
766 let min = min.filter(|&v| v >= 0);
767 let max = max.filter(|&v| v >= 0);
768 if let (Some(lo), Some(hi)) = (min, max)
769 && lo > hi
770 {
771 return (None, None);
772 }
773 (min, max)
774 }
775
776 #[cfg(test)]
777 mod count_label_tests {
778 use super::results_count_label;
779
780 /// Browsing, the number is genuinely how many items match the filters.
781 #[test]
782 fn browsing_names_the_thing_being_counted() {
783 assert_eq!(results_count_label(247, "items", false), "247 items");
784 assert_eq!(results_count_label(12, "projects", false), "12 projects");
785 }
786
787 /// Searching, membership is any typed word, so most of a large number can be
788 /// partial matches. "247 items" would assert 247 things matched; "247
789 /// results" only describes the list, which is all that was counted.
790 #[test]
791 fn searching_describes_the_list_rather_than_claiming_matches() {
792 assert_eq!(results_count_label(247, "items", true), "247 results");
793 assert_eq!(results_count_label(247, "projects", true), "247 results");
794 }
795
796 #[test]
797 fn one_of_something_is_singular() {
798 assert_eq!(results_count_label(1, "items", false), "1 item");
799 assert_eq!(results_count_label(1, "projects", false), "1 project");
800 assert_eq!(results_count_label(1, "items", true), "1 result");
801 }
802
803 /// Zero renders through the empty state, but the label is still built, and
804 /// "0 result" would read as a typo next to it.
805 #[test]
806 fn zero_is_plural() {
807 assert_eq!(results_count_label(0, "items", false), "0 items");
808 assert_eq!(results_count_label(0, "items", true), "0 results");
809 }
810 }
811
812 #[cfg(test)]
813 mod price_range_tests {
814 use super::sanitize_price_range;
815
816 #[test]
817 fn drops_negatives_and_inverted_ranges() {
818 assert_eq!(
819 sanitize_price_range(Some(100), Some(500)),
820 (Some(100), Some(500))
821 );
822 assert_eq!(sanitize_price_range(Some(-1), Some(500)), (None, Some(500)));
823 assert_eq!(sanitize_price_range(Some(100), Some(-5)), (Some(100), None));
824 // inverted range can only match nothing -> drop both
825 assert_eq!(sanitize_price_range(Some(500), Some(100)), (None, None));
826 assert_eq!(sanitize_price_range(None, None), (None, None));
827 }
828 }
829
830 /// Every `DiscoverQuery` field, paired with whether the rendered page must
831 /// carry a form control submitting under that name.
832 ///
833 /// This exists because a form's control names are a contract with its handler
834 /// and nothing checks it: a rename pass once rewrote `name="has_source"` to
835 /// `name="sidebar.has_source"` and the entire suite still passed, with the
836 /// filter silently inert.
837 ///
838 /// The exhaustive destructure is the point. Adding a field to `DiscoverQuery`
839 /// stops this compiling, which forces a decision about whether the new
840 /// parameter needs a control rather than letting it be silently unreachable.
841 #[cfg(test)]
842 fn query_param_contract() -> Vec<(&'static str, bool)> {
843 let DiscoverQuery {
844 q: _,
845 item_type: _,
846 tag: _,
847 category: _,
848 min_price: _,
849 max_price: _,
850 sort: _,
851 page: _,
852 mode: _,
853 ai_tier: _,
854 has_source: _,
855 browse: _,
856 } = DiscoverQuery {
857 q: None,
858 item_type: Vec::new(),
859 tag: Vec::new(),
860 category: None,
861 min_price: None,
862 max_price: None,
863 sort: None,
864 page: None,
865 mode: None,
866 ai_tier: None,
867 has_source: None,
868 browse: None,
869 };
870
871 vec![
872 ("q", true),
873 ("item_type", true),
874 ("tag", true),
875 ("category", true),
876 ("min_price", true),
877 ("max_price", true),
878 ("sort", true),
879 // Pagination is rendered as links carrying hx-vals, not a control.
880 ("page", false),
881 ("mode", true),
882 ("ai_tier", true),
883 ("has_source", true),
884 // The drill-down cursor moves by link, never by form submission.
885 ("browse", false),
886 ]
887 }
888
889 #[cfg(test)]
890 mod query_contract_tests {
891 use super::*;
892
893 #[test]
894 fn every_query_param_is_accounted_for() {
895 let contract = query_param_contract();
896 assert_eq!(
897 contract.len(),
898 12,
899 "DiscoverQuery gained or lost a field; decide whether it needs a control"
900 );
901 // Names must be unique, or a duplicate would mask a missing one.
902 let mut names: Vec<_> = contract.iter().map(|(n, _)| *n).collect();
903 names.sort_unstable();
904 let before = names.len();
905 names.dedup();
906 assert_eq!(before, names.len(), "duplicate param name in the contract");
907 }
908 }
909
910 #[cfg(test)]
911 mod page_url_tests {
912 use super::DiscoverQuery;
913
914 fn query() -> DiscoverQuery {
915 DiscoverQuery {
916 q: None,
917 item_type: Vec::new(),
918 tag: Vec::new(),
919 category: None,
920 min_price: None,
921 max_price: None,
922 sort: None,
923 page: None,
924 mode: None,
925 ai_tier: None,
926 has_source: None,
927 browse: None,
928 }
929 }
930
931 #[test]
932 fn bare_query_is_the_bare_page() {
933 assert_eq!(query().to_page_url(), "/discover");
934 }
935
936 #[test]
937 fn blank_filters_are_dropped() {
938 // hx-include ships every filter on every request, so most arrive blank.
939 let q = DiscoverQuery {
940 q: Some(String::new()),
941 tag: vec![" ".to_string()],
942 category: Some(String::new()),
943 mode: Some("items".to_string()),
944 item_type: vec!["preset".to_string()],
945 ..query()
946 };
947 assert_eq!(q.to_page_url(), "/discover?mode=items&item_type=preset");
948 }
949
950 #[test]
951 fn multi_select_facets_emit_one_param_per_value() {
952 let q = DiscoverQuery {
953 mode: Some("items".to_string()),
954 tag: vec![
955 "audio.genre.electronic".to_string(),
956 "audio.mood.dark".to_string(),
957 ],
958 item_type: vec!["audio".to_string(), "sample".to_string()],
959 ..query()
960 };
961 assert_eq!(
962 q.to_page_url(),
963 "/discover?mode=items&item_type=audio&item_type=sample\
964 &tag=audio.genre.electronic&tag=audio.mood.dark"
965 );
966 }
967
968 #[test]
969 fn repeated_facet_values_are_deduped_in_the_url() {
970 // A doubled selection must not inflate the URL or the SQL bind arrays.
971 let q = DiscoverQuery {
972 tag: vec!["a.b.c".to_string(), "a.b.c".to_string(), String::new()],
973 ..query()
974 };
975 assert_eq!(q.to_page_url(), "/discover?tag=a.b.c");
976 }
977
978 #[test]
979 fn values_are_percent_encoded() {
980 let q = DiscoverQuery {
981 q: Some("field recording & tape".to_string()),
982 ..query()
983 };
984 assert_eq!(
985 q.to_page_url(),
986 "/discover?q=field%20recording%20%26%20tape"
987 );
988 }
989
990 #[test]
991 fn first_page_stays_implicit() {
992 let q = DiscoverQuery {
993 page: Some(1),
994 ..query()
995 };
996 assert_eq!(q.to_page_url(), "/discover");
997
998 let q = DiscoverQuery {
999 page: Some(3),
1000 ..query()
1001 };
1002 assert_eq!(q.to_page_url(), "/discover?page=3");
1003 }
1004
1005 #[test]
1006 fn url_states_the_prices_that_were_actually_applied() {
1007 // An inverted range is dropped from the query, so it must not linger in the URL.
1008 let q = DiscoverQuery {
1009 min_price: Some(500),
1010 max_price: Some(100),
1011 ..query()
1012 };
1013 assert_eq!(q.to_page_url(), "/discover");
1014
1015 let q = DiscoverQuery {
1016 min_price: Some(100),
1017 max_price: Some(500),
1018 ..query()
1019 };
1020 assert_eq!(q.to_page_url(), "/discover?min_price=100&max_price=500");
1021 }
1022 }
1023
1024 /// Fetch items or projects with pagination; shared by both handlers.
1025 /// The normalized filter selection parsed from a `DiscoverQuery`: empty strings
1026 /// collapse to `None` and enum-valued params are parsed. Both the data fetch and
1027 /// the filter-chip rendering need exactly this and derived it independently
1028 /// (audit Run 17 Architecture), the logic now lives in one place.
1029 struct DiscoverFilterSelection<'a> {
1030 item_types: Vec<ItemType>,
1031 tags: Vec<String>,
1032 search: Option<&'a str>,
1033 category: Option<&'a str>,
1034 ai_tier: Option<db::AiTierFilter>,
1035 has_source_code: bool,
1036 }
1037
1038 impl DiscoverQuery {
1039 fn filter_selection(&self) -> DiscoverFilterSelection<'_> {
1040 DiscoverFilterSelection {
1041 // Blank entries are dropped: `hx-include` ships every filter input
1042 // on every request, so an unselected control arrives as `item_type=`.
1043 // Unparseable values are dropped rather than erroring, matching the
1044 // prior single-value behaviour.
1045 item_types: dedup_nonempty(&self.item_type)
1046 .into_iter()
1047 .filter_map(|s| s.parse().ok())
1048 .collect(),
1049 tags: dedup_nonempty(&self.tag)
1050 .into_iter()
1051 .map(str::to_string)
1052 .collect(),
1053 search: self.q.as_deref().filter(|s| !s.trim().is_empty()),
1054 category: self.category.as_deref().filter(|s| !s.is_empty()),
1055 ai_tier: self
1056 .ai_tier
1057 .as_deref()
1058 .filter(|s| !s.is_empty())
1059 .and_then(|s| s.parse().ok()),
1060 has_source_code: self.has_source.as_deref() == Some("1"),
1061 }
1062 }
1063 }
1064
1065 /// Drop blank entries and duplicates while preserving order.
1066 ///
1067 /// Duplicates are dropped so a doubled `?tag=x&tag=x` cannot inflate the bind
1068 /// arrays; order is preserved so the pushed URL is stable across a round-trip
1069 /// and doesn't churn browser history.
1070 fn dedup_nonempty(values: &[String]) -> Vec<&str> {
1071 let mut seen = std::collections::HashSet::new();
1072 values
1073 .iter()
1074 .map(|s| s.trim())
1075 .filter(|s| !s.is_empty())
1076 .filter(|s| seen.insert(*s))
1077 .collect()
1078 }
1079
1080 async fn fetch_discover_data(pool: &PgPool, query: &DiscoverQuery) -> Result<DiscoverData> {
1081 // Clamp the upper bound too (UX MINOR, Run #23): an unbounded page yields a
1082 // giant OFFSET = one expensive deep scan per request. Matches the git/admin
1083 // list handlers.
1084 let page = query.page.unwrap_or(1).clamp(1, 1_000_000_000);
1085 let limit = constants::DISCOVER_PAGE_SIZE as i64;
1086 let offset = ((page - 1) as i64) * limit;
1087 let mode = query.mode.as_deref().unwrap_or("projects");
1088
1089 let f = query.filter_selection();
1090 let item_type_filter = f.item_types;
1091 let tag_filter = f.tags;
1092 let search_filter = f.search;
1093 let category_filter = f.category;
1094 let ai_tier_filter = f.ai_tier;
1095 let has_source_code = f.has_source_code;
1096
1097 let (items, projects, total_count) = if mode == "projects" {
1098 let sort_filter: Option<DiscoverSort> = query
1099 .sort
1100 .as_deref()
1101 .filter(|s| !s.is_empty())
1102 .and_then(|s| s.parse().ok());
1103
1104 let db_projects = db::discover::discover_projects(
1105 pool,
1106 search_filter,
1107 category_filter,
1108 sort_filter,
1109 has_source_code,
1110 limit,
1111 offset,
1112 )
1113 .await?;
1114
1115 let total = db::discover::count_discover_projects(
1116 pool,
1117 search_filter,
1118 category_filter,
1119 has_source_code,
1120 )
1121 .await?;
1122
1123 let projects: Vec<DiscoverProject> = crate::types::discover_projects_view(db_projects);
1124 (vec![], projects, total as u32)
1125 } else {
1126 let sort_filter: Option<DiscoverSort> = query
1127 .sort
1128 .as_deref()
1129 .filter(|s| !s.is_empty())
1130 .and_then(|s| s.parse().ok());
1131
1132 let (min_price, max_price) = sanitize_price_range(query.min_price, query.max_price);
1133 let filters = DiscoverFilters {
1134 search: search_filter,
1135 item_types: &item_type_filter,
1136 tags: &tag_filter,
1137 min_price,
1138 max_price,
1139 sort_by: sort_filter,
1140 ai_tier: ai_tier_filter,
1141 };
1142
1143 let db_items = db::discover::discover_items(pool, &filters, limit, offset).await?;
1144 let total = db::discover::count_discover_items(pool, &filters).await?;
1145
1146 let items: Vec<DiscoverItem> = crate::types::discover_items_view(db_items);
1147 (items, vec![], total as u32)
1148 };
1149
1150 let total_pages = ((total_count as f64) / (limit as f64)).ceil() as u32;
1151 let pagination_range = super::pagination::build_pagination_range(page, total_pages);
1152 let result_count = if mode == "projects" {
1153 projects.len() as u32
1154 } else {
1155 items.len() as u32
1156 };
1157 // Reuse the i64 `offset` (computed overflow-safe above) for the "showing
1158 // X–Y" labels and saturate into u32, rather than recomputing
1159 // `(page - 1) * DISCOVER_PAGE_SIZE` in u32, which overflows for a large `?page=`.
1160 let showing_start = if result_count == 0 {
1161 0
1162 } else {
1163 offset.saturating_add(1).clamp(0, u32::MAX as i64) as u32
1164 };
1165 let showing_end = offset
1166 .saturating_add(result_count as i64)
1167 .clamp(0, u32::MAX as i64) as u32;
1168
1169 Ok(DiscoverData {
1170 items,
1171 projects,
1172 mode: mode.to_string(),
1173 total_count,
1174 current_page: page,
1175 total_pages,
1176 pagination_range,
1177 showing_start,
1178 showing_end,
1179 is_search: search_filter.is_some(),
1180 count_label: results_count_label(total_count, mode, search_filter.is_some()),
1181 })
1182 }
1183
1184 /// Query parameters for the tag tree browser.
1185 #[derive(Debug, Deserialize)]
1186 pub(super) struct TagTreeQuery {
1187 pub parent: Option<String>,
1188 }
1189
1190 /// Browse the tag hierarchy with breadcrumb navigation.
1191 #[tracing::instrument(skip_all, name = "discover::tag_tree")]
1192 pub(super) async fn tag_tree(
1193 State(db): State<PgPool>,
1194 session: Session,
1195 MaybeUserUnverified(maybe_user): MaybeUserUnverified,
1196 Query(query): Query<TagTreeQuery>,
1197 ) -> Result<impl IntoResponse> {
1198 let csrf_token = get_csrf_token(&session).await;
1199
1200 // Resolve parent tag from ?parent=slug (dot-notation, e.g. "audio.genre")
1201 let parent_tag = if let Some(ref slug) = query.parent {
1202 db::tags::get_tag_by_slug(&db, slug).await?
1203 } else {
1204 None
1205 };
1206
1207 let parent_id = parent_tag.as_ref().map(|t| t.id);
1208
1209 // Fetch children at this level
1210 let children = db::tags::get_child_tags(&db, parent_id).await?;
1211
1212 // Item counts + child counts, both scoped to just this level's children
1213 // rather than aggregating over every tag in the catalog (fuzz 2026-07-06 C5-2).
1214 let child_ids: Vec<_> = children.iter().map(|c| c.id).collect();
1215 let tag_counts = db::tags::item_counts_by_tag(&db, &child_ids).await?;
1216 let grandchild_counts = db::tags::count_children_by_parents(&db, &child_ids).await?;
1217
1218 let categories: Vec<TagTreeNode> = children
1219 .iter()
1220 .map(|child| TagTreeNode {
1221 name: child.name.clone(),
1222 slug: child.slug.clone(),
1223 item_count: *tag_counts.get(&child.id).unwrap_or(&0) as u32,
1224 child_count: *grandchild_counts.get(&child.id).unwrap_or(&0) as usize,
1225 })
1226 .collect();
1227
1228 // Build breadcrumbs from ancestor chain
1229 let (breadcrumbs, current_tag) = if let Some(ref pt) = parent_tag {
1230 let ancestors = db::tags::get_tag_ancestors(&db, pt.id).await?;
1231 // ancestors includes the tag itself as the last element.
1232 // We want all ancestors except the current tag as breadcrumbs,
1233 // and the current tag as current_tag.
1234 let bc: Vec<TagBreadcrumb> = ancestors
1235 .iter()
1236 .filter(|a| a.id != pt.id)
1237 .map(|a| TagBreadcrumb {
1238 name: a.name.clone(),
1239 slug: a.slug.clone(),
1240 })
1241 .collect();
1242 let ct = TagBreadcrumb {
1243 name: pt.name.clone(),
1244 slug: pt.slug.clone(),
1245 };
1246 (bc, Some(ct))
1247 } else {
1248 (vec![], None)
1249 };
1250
1251 Ok(TagTreeTemplate {
1252 csrf_token,
1253 session_user: maybe_user,
1254 categories,
1255 breadcrumbs,
1256 current_tag,
1257 })
1258 }
1259
1260 /// Render the discover page with filterable, paginated items or projects.
1261 #[tracing::instrument(skip_all, name = "discover::discover")]
1262 pub(super) async fn discover(
1263 State(db): State<PgPool>,
1264 session: Session,
1265 MaybeUserUnverified(maybe_user): MaybeUserUnverified,
1266 Query(query): Query<DiscoverQuery>,
1267 ) -> Result<impl IntoResponse> {
1268 let csrf_token = get_csrf_token(&session).await;
1269 let data = fetch_discover_data(&db, &query).await?;
1270 let sidebar = build_sidebar(&db, &query, &data, maybe_user.as_ref().map(|u| u.id)).await?;
1271 let is_authenticated = maybe_user.is_some();
1272
1273 Ok(DiscoverTemplate {
1274 csrf_token,
1275 session_user: maybe_user,
1276 items: data.items,
1277 projects: data.projects,
1278 mode: data.mode,
1279 total_items: data.total_count,
1280 current_page: data.current_page,
1281 total_pages: data.total_pages,
1282 search_query: query.q.clone().unwrap_or_default(),
1283 is_search: data.is_search,
1284 count_label: data.count_label,
1285 sort_by: query.sort.clone().unwrap_or_default(),
1286 pagination_range: data.pagination_range,
1287 showing_start: data.showing_start,
1288 showing_end: data.showing_end,
1289 sidebar,
1290 is_authenticated,
1291 oob_sidebar: false,
1292 })
1293 }
1294
1295 /// Return discover results as an HTMX partial for filtering and pagination.
1296 #[tracing::instrument(skip_all, name = "discover::discover_results")]
1297 pub(super) async fn discover_results(
1298 State(db): State<PgPool>,
1299 MaybeUserUnverified(maybe_user): MaybeUserUnverified,
1300 headers: HeaderMap,
1301 Query(query): Query<DiscoverQuery>,
1302 ) -> Result<impl IntoResponse> {
1303 let data = fetch_discover_data(&db, &query).await?;
1304 // The sidebar rides along on every results request and is swapped
1305 // out-of-band, so its counts always describe the results beside them.
1306 let sidebar = build_sidebar(&db, &query, &data, maybe_user.as_ref().map(|u| u.id)).await?;
1307
1308 // Mirror the filter state into the address bar so a filtered view can be
1309 // shared, bookmarked, and reloaded. A discrete filter click pushes a history
1310 // entry, so Back undoes one filter; the search box fires on a debounce while
1311 // typing, so it replaces instead of burying the page under one entry per
1312 // keystroke pause. HX-Trigger carries the id of the element that fired.
1313 let history_header = match headers.get("HX-Trigger").and_then(|v| v.to_str().ok()) {
1314 Some("search-input") => "HX-Replace-Url",
1315 _ => "HX-Push-Url",
1316 };
1317 let page_url = query.to_page_url();
1318
1319 Ok((
1320 [(history_header, page_url)],
1321 DiscoverResultsTemplate {
1322 items: data.items,
1323 projects: data.projects,
1324 mode: data.mode,
1325 total_items: data.total_count,
1326 current_page: data.current_page,
1327 total_pages: data.total_pages,
1328 pagination_range: data.pagination_range,
1329 showing_start: data.showing_start,
1330 showing_end: data.showing_end,
1331 current_category: query.category.clone().unwrap_or_default(),
1332 is_search: data.is_search,
1333 count_label: data.count_label,
1334 is_authenticated: maybe_user.is_some(),
1335 sidebar,
1336 oob_sidebar: true,
1337 },
1338 ))
1339 }
1340
1341 /// Query parameters for search suggestions.
1342 #[derive(Debug, Deserialize)]
1343 pub(super) struct SuggestionsQuery {
1344 pub q: Option<String>,
1345 }
1346
1347 /// JSON response for a tag typeahead hit.
1348 ///
1349 /// `context` is the parent path, which the dropdown needs in order to
1350 /// disambiguate: "Format" exists under audio, software, writing, and video.
1351 #[derive(Debug, Serialize)]
1352 pub(super) struct TagSuggestion {
1353 pub slug: String,
1354 pub label: String,
1355 pub context: String,
1356 }
1357
1358 /// JSON response for a search suggestion.
1359 #[derive(Debug, Serialize)]
1360 pub(super) struct SearchSuggestion {
1361 pub label: String,
1362 pub category: String,
1363 pub url: String,
1364 }
1365
1366 /// Return search suggestions (tags, projects, creators) as JSON.
1367 #[tracing::instrument(skip_all, name = "discover::search_suggestions")]
1368 pub(super) async fn search_suggestions_handler(
1369 State(db): State<PgPool>,
1370 Query(query): Query<SuggestionsQuery>,
1371 ) -> Result<impl IntoResponse> {
1372 let q = query.q.unwrap_or_default();
1373 let rows = db::discover::search_suggestions(&db, &q).await?;
1374 let suggestions: Vec<SearchSuggestion> = rows
1375 .into_iter()
1376 .map(|r| SearchSuggestion {
1377 label: r.label,
1378 category: r.category,
1379 url: r.url,
1380 })
1381 .collect();
1382 Ok(Json(suggestions))
1383 }
1384