Skip to main content

max / makenotwork

54.9 KB · 1497 lines History Blame Raw
1 //! Discover/search page with filterable, paginated items and projects.
2
3 use crate::extractors::ValidatedExtraQuery;
4 use axum::Json;
5 use axum::extract::State;
6 use axum::http::HeaderMap;
7 use axum::response::IntoResponse;
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.map(PriceDollars::cents),
101 max_price: query.max_price.map(PriceDollars::cents),
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={}", PriceDollars::from_cents(*min)));
284 if let Some(v) = *max {
285 parts.push(format!("max_price={}", PriceDollars::from_cents(v)));
286 }
287 PriceBucket {
288 label: label.to_string(),
289 count: *count as u32,
290 url: format!("/discover?{}", parts.join("&")),
291 active: applied_min.map(PriceDollars::cents) == Some(*min)
292 && applied_max.map(PriceDollars::cents) == *max,
293 }
294 })
295 .collect();
296
297 // The mobile filter badge counts *groups* with an active selection, not
298 // individual values: picking three tags is still one filter in use.
299 let current_types: Vec<String> = dedup_nonempty(&query.item_type)
300 .into_iter()
301 .map(str::to_string)
302 .collect();
303 let current_tags: Vec<String> = dedup_nonempty(&query.tag)
304 .into_iter()
305 .map(str::to_string)
306 .collect();
307 let current_category = query.category.clone().unwrap_or_default();
308 let current_ai_tier = query.ai_tier.clone().unwrap_or_default();
309
310 // A selection is only carried by a hidden input when no visible control
311 // represents it. The drill-down shows one rung at a time, so a tag chosen
312 // from elsewhere in the tree has no checkbox on screen; likewise a type
313 // whose count dropped out of the facet list. Rendering a hidden input for a
314 // value that also has a checked checkbox would submit it twice.
315 let visible_tag_slugs: std::collections::HashSet<&str> =
316 tag_drill.iter().map(|r| r.slug.as_str()).collect();
317 let hidden_tags: Vec<String> = current_tags
318 .iter()
319 .filter(|t| !visible_tag_slugs.contains(t.as_str()))
320 .cloned()
321 .collect();
322 let visible_type_values: std::collections::HashSet<&str> =
323 type_filters.iter().map(|t| t.value.as_str()).collect();
324 let hidden_types: Vec<String> = current_types
325 .iter()
326 .filter(|t| !visible_type_values.contains(t.as_str()))
327 .cloned()
328 .collect();
329
330 let active_filter_count = [
331 !current_types.is_empty(),
332 !current_tags.is_empty(),
333 !current_category.is_empty(),
334 !current_ai_tier.is_empty(),
335 has_source_code,
336 query.min_price.is_some(),
337 query.max_price.is_some(),
338 ]
339 .iter()
340 .filter(|&&v| v)
341 .count() as u32;
342
343 Ok(SidebarView {
344 type_filters,
345 tag_filters,
346 category_filters,
347 price_buckets,
348 ai_tier_filters,
349 tag_chips,
350 tag_drill,
351 tag_crumbs,
352 hidden_tags,
353 hidden_types,
354 current_types,
355 current_tags,
356 current_min_price,
357 current_max_price,
358 current_category,
359 current_ai_tier,
360 browse_url_prefix,
361 browse_url_root,
362 has_source: has_source_code,
363 active_filter_count,
364 viewer_authenticated: viewer_id.is_some(),
365 browse_cursor: query.browse.clone().unwrap_or_default(),
366 })
367 }
368
369 /// The four viewer-independent discover facet results (item-type, tag, ai-tier, and
370 /// price-range counts). `followed_tag_ids` is deliberately excluded, it is
371 /// per-viewer and always computed fresh.
372 type FacetBundle = (
373 Vec<db::DbItemTypeCount>,
374 Vec<db::DbTagCount>,
375 Vec<db::DbItemTypeCount>,
376 // Bucket counts, positional and in `db::discover::PRICE_BUCKETS` order.
377 Vec<i64>,
378 );
379
380 /// Cache key: the filter inputs every facet query keys off. Same filters ->
381 /// identical viewer-independent counts, so they can be shared across viewers.
382 ///
383 /// `ai_tier` is part of the key. It was omitted while no facet query applied the
384 /// AI-tier filter, which made it harmless; the moment the facets started
385 /// cross-applying it, leaving it out would have served one tier's counts to
386 /// another for up to `FACET_CACHE_TTL`.
387 /// The leading `String` is the database name. The memo is a process-global, so
388 /// without it two pools pointed at different databases would share counts. In
389 /// production there is one database and this is a constant; under test every
390 /// case gets its own template clone, and omitting it silently served one test's
391 /// facet counts to another.
392 type FacetKey = (
393 String,
394 Option<String>,
395 Vec<ItemType>,
396 Vec<String>,
397 Option<i32>,
398 Option<i32>,
399 Option<AiTierFilter>,
400 );
401
402 /// Short TTL for the facet memo. The discover facets are the hottest, most
403 /// cacheable aggregate on the busiest public page; recomputing five full-catalog
404 /// scans on every anonymous hit is the crate's largest DB amplifier (ultra-fuzz
405 /// Run 12 Performance). Expiry-only invalidation (no bust on catalog change), a
406 /// count that is up to a minute stale on the discover sidebar is harmless.
407 const FACET_CACHE_TTL: Duration = Duration::from_mins(1);
408 /// Bound the memo so a wide spread of filter combinations can't grow it without
409 /// limit; on overflow, drop expired entries first, then clear if still full.
410 const FACET_CACHE_MAX: usize = 512;
411
412 static FACET_CACHE: OnceLock<Mutex<HashMap<FacetKey, (Instant, FacetBundle)>>> = OnceLock::new();
413
414 /// The four viewer-independent discover facets, memoized for [`FACET_CACHE_TTL`].
415 /// On a miss the four queries still run (concurrently); the memo makes the common
416 /// case, repeated anonymous loads of the same filter view, a single map lookup
417 /// instead of five full-catalog aggregate scans holding five pool connections.
418 async fn cached_facets(
419 db: &PgPool,
420 filters: &db::discover::DiscoverFilters<'_>,
421 ) -> Result<FacetBundle> {
422 let key: FacetKey = (
423 db.connect_options()
424 .get_database()
425 .unwrap_or_default()
426 .to_string(),
427 filters.search.map(str::to_string),
428 filters.item_types.to_vec(),
429 filters.tags.to_vec(),
430 filters.min_price,
431 filters.max_price,
432 filters.ai_tier,
433 );
434
435 let cache = FACET_CACHE.get_or_init(|| Mutex::new(HashMap::new()));
436 if let Ok(guard) = cache.lock()
437 && let Some((at, bundle)) = guard.get(&key)
438 && at.elapsed() < FACET_CACHE_TTL
439 {
440 return Ok(bundle.clone());
441 }
442
443 let bundle: FacetBundle = tokio::try_join!(
444 db::discover::get_item_type_counts(db, filters),
445 db::tags::get_tag_counts(db, filters),
446 db::discover::get_ai_tier_counts(db, filters),
447 db::discover::get_price_range_counts(db, filters),
448 )?;
449
450 if let Ok(mut guard) = cache.lock() {
451 if guard.len() >= FACET_CACHE_MAX {
452 guard.retain(|_, (at, _)| at.elapsed() < FACET_CACHE_TTL);
453 if guard.len() >= FACET_CACHE_MAX {
454 guard.clear();
455 }
456 }
457 guard.insert(key, (Instant::now(), bundle.clone()));
458 }
459
460 Ok(bundle)
461 }
462
463 /// Timestamped index, so the TTL check and the shared handle travel together.
464 type CachedTagIndex = Option<(Instant, Arc<tagtree::TagIndex>)>;
465
466 /// Memoized [`tagtree::TagIndex`] over every tag slug, backing the sidebar's
467 /// tag typeahead.
468 ///
469 /// The index is memory-only and has to be rebuilt from Postgres; `rebuild`
470 /// exists for exactly this. It is refreshed wholesale on a TTL rather than
471 /// mutated, which also sidesteps `TagIndex::remove` leaving orphaned segments
472 /// behind (tagtree lib.rs:817) and degrading the segment-prefix gate over time.
473 ///
474 /// The taxonomy is ~120 tags and changes only by migration, so five minutes of
475 /// staleness in an autocomplete is not worth invalidation machinery.
476 static TAG_INDEX: OnceLock<Mutex<CachedTagIndex>> = OnceLock::new();
477
478 const TAG_INDEX_TTL: Duration = Duration::from_mins(5);
479
480 async fn cached_tag_index(db: &PgPool) -> Result<Arc<tagtree::TagIndex>> {
481 let cell = TAG_INDEX.get_or_init(|| Mutex::new(None));
482 if let Ok(guard) = cell.lock()
483 && let Some((at, index)) = guard.as_ref()
484 && at.elapsed() < TAG_INDEX_TTL
485 {
486 return Ok(Arc::clone(index));
487 }
488
489 let slugs = db::tags::all_tag_slugs(db).await?;
490 let index = Arc::new(tagtree::TagIndex::new(slugs));
491
492 if let Ok(mut guard) = cell.lock() {
493 *guard = Some((Instant::now(), Arc::clone(&index)));
494 }
495 Ok(index)
496 }
497
498 /// How many typeahead results to return. Enough to fill the dropdown without
499 /// turning `suggest_fuzzy`'s full-corpus scan into a page-weight problem.
500 const TAG_SUGGEST_LIMIT: usize = 8;
501
502 /// Tag typeahead for the discover sidebar.
503 ///
504 /// Prefix matching first (path-prefix, then segment-prefix), falling back to
505 /// fuzzy only when prefix matching underfills. That ordering is tagtree's, and
506 /// it is why typing "elec" finds `audio.genre.electronic` without the caller
507 /// knowing which level it lives at.
508 pub(super) async fn tag_suggestions_handler(
509 State(db): State<PgPool>,
510 ValidatedExtraQuery(query): ValidatedExtraQuery<SuggestionsQuery>,
511 ) -> Result<impl IntoResponse> {
512 let raw = query.q.unwrap_or_default();
513 let input = raw.trim();
514 if input.is_empty() {
515 return Ok(Json(Vec::<TagSuggestion>::new()));
516 }
517
518 let index = cached_tag_index(&db).await?;
519 let (hits, _exact) = index.suggest_with_status(input, TAG_SUGGEST_LIMIT);
520 let mut slugs: Vec<String> = hits.into_iter().map(str::to_string).collect();
521 if slugs.is_empty() {
522 slugs = index
523 .suggest_fuzzy(input, TAG_SUGGEST_LIMIT)
524 .into_iter()
525 .map(str::to_string)
526 .collect();
527 }
528
529 // Only depth-3+ tags can be assigned to an item, so only they can filter
530 // anything; offering a category here would produce an empty result set.
531 slugs.retain(|s| tagtree::depth(s) >= 3);
532
533 let names: std::collections::HashMap<String, String> =
534 db::tags::tag_names_for_slugs(&db, &slugs)
535 .await?
536 .into_iter()
537 .collect();
538
539 let suggestions: Vec<TagSuggestion> = slugs
540 .into_iter()
541 .map(|slug| {
542 let label = names
543 .get(&slug)
544 .cloned()
545 .unwrap_or_else(|| tagtree::leaf(&slug).replace('-', " "));
546 // The parent path orients an otherwise ambiguous leaf: "Format"
547 // appears under audio, software, writing, and video.
548 let context = tagtree::parent(&slug).unwrap_or("").to_string();
549 TagSuggestion {
550 slug,
551 label,
552 context,
553 }
554 })
555 .collect();
556
557 Ok(Json(suggestions))
558 }
559
560 /// Deserialize an empty string as `None` instead of failing to parse.
561 ///
562 /// HTML form inputs send `field=` (empty string) when blank, which fails
563 /// serde's default `Option<i32>` parsing. This treats `""` as `None`.
564 fn empty_string_as_none<'de, D, T>(deserializer: D) -> std::result::Result<Option<T>, D::Error>
565 where
566 D: serde::Deserializer<'de>,
567 T: std::str::FromStr,
568 T::Err: std::fmt::Display,
569 {
570 let opt = Option::<String>::deserialize(deserializer)?;
571 match opt {
572 None => Ok(None),
573 Some(s) if s.is_empty() => Ok(None),
574 Some(s) => s.parse::<T>().map(Some).map_err(serde::de::Error::custom),
575 }
576 }
577
578 /// A price bound from the query string: dollars on the wire, cents in the field.
579 ///
580 /// The wire format is dollars (`?max_price=24.99`) because a visitor types this
581 /// filter by hand and reads it back out of the address bar; the catalog stores
582 /// cents. Doing the conversion here, once, at the edge is what stops the two
583 /// units meeting: typing 20 used to filter for items under twenty cents, with
584 /// nothing on screen saying so (loose-wire g2-16).
585 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
586 pub(super) struct PriceDollars(i32);
587
588 impl PriceDollars {
589 /// The bound in cents, which is the only unit the queries speak.
590 fn cents(self) -> i32 {
591 self.0
592 }
593
594 fn from_cents(cents: i32) -> Self {
595 Self(cents)
596 }
597 }
598
599 impl std::str::FromStr for PriceDollars {
600 type Err = String;
601
602 /// Delegates to the canonical dollars parser, so "$1,250" off a clipboard
603 /// works here exactly as it does in the pricing forms, and a rejection
604 /// carries that parser's wording. `ValidatedQuery` turns the rejection into
605 /// the branded error page rather than a bare axum 400.
606 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
607 crate::pricing::parse_dollars_to_cents("Price", Some(s))
608 .map(Self)
609 .map_err(|e| e.user_message())
610 }
611 }
612
613 impl std::fmt::Display for PriceDollars {
614 /// Whole dollars stay whole, so the common bucket links read `min_price=25`
615 /// rather than `min_price=25.00`.
616 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
617 if self.0 % 100 == 0 {
618 write!(f, "{}", self.0 / 100)
619 } else {
620 write!(f, "{}", crate::formatting::format_dollars_plain(self.0))
621 }
622 }
623 }
624
625 /// Query parameters for the discover/search page.
626 #[derive(Debug, Deserialize)]
627 pub(super) struct DiscoverQuery {
628 pub q: Option<String>,
629 /// Repeated `item_type=` params, OR'd together. A single value (what the
630 /// pre-multi-select UI sends) parses as a one-element vec, so old links and
631 /// bookmarks keep working.
632 #[serde(default)]
633 pub item_type: Vec<String>,
634 /// Repeated `tag=` params, OR'd together; each matches its own subtree.
635 #[serde(default)]
636 pub tag: Vec<String>,
637 pub category: Option<String>,
638 #[serde(default, deserialize_with = "empty_string_as_none")]
639 pub min_price: Option<PriceDollars>,
640 #[serde(default, deserialize_with = "empty_string_as_none")]
641 pub max_price: Option<PriceDollars>,
642 pub sort: Option<String>,
643 #[serde(default, deserialize_with = "empty_string_as_none")]
644 pub page: Option<u32>,
645 /// `"items"` or `"projects"`. Unset means projects, except when a tag is
646 /// selected, which means items: see the handler for why. The comment here
647 /// used to say items was the default and it never was.
648 pub mode: Option<String>,
649 pub ai_tier: Option<String>,
650 pub has_source: Option<String>,
651 /// Drill-down cursor: which tag's children the sidebar is showing. Distinct
652 /// from `tag`, which is the selection. Browsing into a category does not
653 /// filter, and selecting a leaf does not move the cursor, so the two can be
654 /// operated independently.
655 pub browse: Option<String>,
656 }
657
658 impl DiscoverQuery {
659 /// Rebuild the canonical full-page URL for this filter selection, for the
660 /// HTMX history headers.
661 ///
662 /// Blank values are dropped rather than echoed: every filter request carries
663 /// the whole `.discover-filter` set via `hx-include`, so a verbatim
664 /// round-trip would put `/discover?q=&tag=&category=&min_price=` in the
665 /// address bar. Prices are sanitized so the URL states what was actually
666 /// applied, and `page=1` is left implicit.
667 fn to_page_url(&self) -> String {
668 let mut parts = self.filter_params();
669 if let Some(b) = self
670 .browse
671 .as_ref()
672 .map(|s| s.trim())
673 .filter(|s| !s.is_empty())
674 {
675 parts.push(format!("browse={}", urlencoding::encode(b)));
676 }
677 if let Some(p) = self.page.filter(|&p| p > 1) {
678 parts.push(format!("page={p}"));
679 }
680
681 if parts.is_empty() {
682 "/discover".to_string()
683 } else {
684 format!("/discover?{}", parts.join("&"))
685 }
686 }
687
688 /// Base URL for moving the drill-down cursor, ending ready for a `browse=`
689 /// value to be appended.
690 ///
691 /// Navigating the tree preserves the current selection and drops `page`,
692 /// since a cursor move lands you on a different list and page 4 of the old
693 /// one is meaningless.
694 fn browse_base_url(&self) -> String {
695 let parts = self.filter_params();
696 if parts.is_empty() {
697 "/discover?browse=".to_string()
698 } else {
699 format!("/discover?{}&browse=", parts.join("&"))
700 }
701 }
702
703 /// The URL that clears the cursor back to the tag roots, selection intact.
704 fn browse_root_url(&self) -> String {
705 let parts = self.filter_params();
706 if parts.is_empty() {
707 "/discover".to_string()
708 } else {
709 format!("/discover?{}", parts.join("&"))
710 }
711 }
712
713 /// Filter params with the price range omitted, for building the price
714 /// bucket links: a bucket replaces the range rather than adding to it.
715 fn params_without_price(&self) -> Vec<String> {
716 let (min, max) = sanitize_price_range(self.min_price, self.max_price);
717 self.filter_params()
718 .into_iter()
719 .filter(|p| {
720 !(min.is_some_and(|v| *p == format!("min_price={v}"))
721 || max.is_some_and(|v| *p == format!("max_price={v}")))
722 })
723 .collect()
724 }
725
726 /// Every filter param except the cursor and the page, in canonical order.
727 fn filter_params(&self) -> Vec<String> {
728 fn push_str(parts: &mut Vec<String>, key: &str, value: Option<&String>) {
729 if let Some(v) = value.map(|s| s.trim()).filter(|s| !s.is_empty()) {
730 parts.push(format!("{key}={}", urlencoding::encode(v)));
731 }
732 }
733
734 // Multi-valued facets emit one param per selection, deduped and blank-free,
735 // so the address bar states exactly the filter that was applied.
736 fn push_each(parts: &mut Vec<String>, key: &str, values: &[String]) {
737 for v in dedup_nonempty(values) {
738 parts.push(format!("{key}={}", urlencoding::encode(v)));
739 }
740 }
741
742 let mut parts = Vec::new();
743 push_str(&mut parts, "mode", self.mode.as_ref());
744 push_str(&mut parts, "q", self.q.as_ref());
745 push_each(&mut parts, "item_type", &self.item_type);
746 push_each(&mut parts, "tag", &self.tag);
747 push_str(&mut parts, "category", self.category.as_ref());
748 push_str(&mut parts, "ai_tier", self.ai_tier.as_ref());
749 push_str(&mut parts, "has_source", self.has_source.as_ref());
750 push_str(&mut parts, "sort", self.sort.as_ref());
751
752 let (min_price, max_price) = sanitize_price_range(self.min_price, self.max_price);
753 if let Some(v) = min_price {
754 parts.push(format!("min_price={v}"));
755 }
756 if let Some(v) = max_price {
757 parts.push(format!("max_price={v}"));
758 }
759
760 parts
761 }
762 }
763
764 /// Shared result data for both the full discover page and the HTMX partial.
765 struct DiscoverData {
766 items: Vec<DiscoverItem>,
767 projects: Vec<DiscoverProject>,
768 mode: String,
769 total_count: u32,
770 current_page: u32,
771 total_pages: u32,
772 pagination_range: Vec<u32>,
773 showing_start: u32,
774 showing_end: u32,
775 /// A search term was applied, so the copy addresses a search rather than a
776 /// filter.
777 ///
778 /// Taken from the same `filter_selection()` the query used, not from the raw
779 /// `?q=`: a whitespace-only term is browsing, and reading it separately here
780 /// would have the copy disagree with the list under it.
781 is_search: bool,
782 /// The rendered count line, e.g. "247 results" or "1 item".
783 count_label: String,
784 }
785
786 /// The count line above the results.
787 ///
788 /// Says "results" for a search and names the thing for a browse, because those
789 /// are different claims. Browsing, the number really is how many items match the
790 /// filters. Searching, membership is any typed word (migration 179), so most of
791 /// a large number can be partial matches; "247 items" would assert 247 things
792 /// matched, which is not what was counted. "247 results" describes the list,
793 /// and the tier heading and per-row counts describe its quality.
794 ///
795 /// Built here rather than in the templates because two of them render this same
796 /// `#total-count` span, the page and the out-of-band partial. Any difference
797 /// between them shows up as the text changing on the first HTMX swap, which is
798 /// the failure mode that already bit this element once (it used to sit outside
799 /// `#results-container` and go stale). One string, no way to disagree.
800 fn results_count_label(total: u32, mode: &str, is_search: bool) -> String {
801 let noun = match (is_search, mode, total) {
802 (true, _, 1) => "result",
803 (true, _, _) => "results",
804 (false, "projects", 1) => "project",
805 (false, "projects", _) => "projects",
806 (false, _, 1) => "item",
807 (false, _, _) => "items",
808 };
809 format!("{total} {noun}")
810 }
811
812 /// Clamp the discover price filters to a sane range. A negative bound is
813 /// meaningless (prices are non-negative cents) and is dropped; an inverted range
814 /// (min > max) can only match nothing, so both bounds are dropped rather than
815 /// issuing an empty-by-construction query.
816 fn sanitize_price_range(
817 min: Option<PriceDollars>,
818 max: Option<PriceDollars>,
819 ) -> (Option<PriceDollars>, Option<PriceDollars>) {
820 let min = min.filter(|v| v.cents() >= 0);
821 let max = max.filter(|v| v.cents() >= 0);
822 if let (Some(lo), Some(hi)) = (min, max)
823 && lo.cents() > hi.cents()
824 {
825 return (None, None);
826 }
827 (min, max)
828 }
829
830 #[cfg(test)]
831 mod count_label_tests {
832 use super::results_count_label;
833
834 /// Browsing, the number is genuinely how many items match the filters.
835 #[test]
836 fn browsing_names_the_thing_being_counted() {
837 assert_eq!(results_count_label(247, "items", false), "247 items");
838 assert_eq!(results_count_label(12, "projects", false), "12 projects");
839 }
840
841 /// Searching, membership is any typed word, so most of a large number can be
842 /// partial matches. "247 items" would assert 247 things matched; "247
843 /// results" only describes the list, which is all that was counted.
844 #[test]
845 fn searching_describes_the_list_rather_than_claiming_matches() {
846 assert_eq!(results_count_label(247, "items", true), "247 results");
847 assert_eq!(results_count_label(247, "projects", true), "247 results");
848 }
849
850 #[test]
851 fn one_of_something_is_singular() {
852 assert_eq!(results_count_label(1, "items", false), "1 item");
853 assert_eq!(results_count_label(1, "projects", false), "1 project");
854 assert_eq!(results_count_label(1, "items", true), "1 result");
855 }
856
857 /// Zero renders through the empty state, but the label is still built, and
858 /// "0 result" would read as a typo next to it.
859 #[test]
860 fn zero_is_plural() {
861 assert_eq!(results_count_label(0, "items", false), "0 items");
862 assert_eq!(results_count_label(0, "items", true), "0 results");
863 }
864 }
865
866 #[cfg(test)]
867 mod price_range_tests {
868 use super::{PriceDollars, sanitize_price_range};
869
870 fn c(cents: i32) -> Option<PriceDollars> {
871 Some(PriceDollars::from_cents(cents))
872 }
873
874 #[test]
875 fn drops_negatives_and_inverted_ranges() {
876 assert_eq!(sanitize_price_range(c(100), c(500)), (c(100), c(500)));
877 assert_eq!(sanitize_price_range(c(-1), c(500)), (None, c(500)));
878 assert_eq!(sanitize_price_range(c(100), c(-5)), (c(100), None));
879 // inverted range can only match nothing -> drop both
880 assert_eq!(sanitize_price_range(c(500), c(100)), (None, None));
881 assert_eq!(sanitize_price_range(None, None), (None, None));
882 }
883 }
884
885 #[cfg(test)]
886 mod price_dollars_tests {
887 use super::PriceDollars;
888
889 /// The bug this type exists for: what the visitor types is dollars.
890 #[test]
891 fn parses_dollars_not_cents() {
892 assert_eq!("20".parse::<PriceDollars>().unwrap().cents(), 2000);
893 assert_eq!("24.99".parse::<PriceDollars>().unwrap().cents(), 2499);
894 assert_eq!("0".parse::<PriceDollars>().unwrap().cents(), 0);
895 }
896
897 /// Pasted decoration is the pricing parser's job, and this inherits it.
898 #[test]
899 fn accepts_pasted_decoration() {
900 assert_eq!("$1,250".parse::<PriceDollars>().unwrap().cents(), 125_000);
901 }
902
903 /// A rejection here is what `ValidatedQuery` turns into the branded page.
904 #[test]
905 fn rejects_junk_and_negatives() {
906 assert!("abc".parse::<PriceDollars>().is_err());
907 assert!("-5".parse::<PriceDollars>().is_err());
908 }
909
910 /// Round-trips through the URL: whole dollars stay whole, cents survive.
911 #[test]
912 fn displays_back_as_typed() {
913 assert_eq!(PriceDollars::from_cents(2500).to_string(), "25");
914 assert_eq!(PriceDollars::from_cents(2499).to_string(), "24.99");
915 assert_eq!(PriceDollars::from_cents(0).to_string(), "0");
916 }
917 }
918
919 /// Every `DiscoverQuery` field, paired with whether the rendered page must
920 /// carry a form control submitting under that name.
921 ///
922 /// This exists because a form's control names are a contract with its handler
923 /// and nothing checks it: a rename pass once rewrote `name="has_source"` to
924 /// `name="sidebar.has_source"` and the entire suite still passed, with the
925 /// filter silently inert.
926 ///
927 /// The exhaustive destructure is the point. Adding a field to `DiscoverQuery`
928 /// stops this compiling, which forces a decision about whether the new
929 /// parameter needs a control rather than letting it be silently unreachable.
930 #[cfg(test)]
931 fn query_param_contract() -> Vec<(&'static str, bool)> {
932 let DiscoverQuery {
933 q: _,
934 item_type: _,
935 tag: _,
936 category: _,
937 min_price: _,
938 max_price: _,
939 sort: _,
940 page: _,
941 mode: _,
942 ai_tier: _,
943 has_source: _,
944 browse: _,
945 } = DiscoverQuery {
946 q: None,
947 item_type: Vec::new(),
948 tag: Vec::new(),
949 category: None,
950 min_price: None,
951 max_price: None,
952 sort: None,
953 page: None,
954 mode: None,
955 ai_tier: None,
956 has_source: None,
957 browse: None,
958 };
959
960 vec![
961 ("q", true),
962 ("item_type", true),
963 ("tag", true),
964 ("category", true),
965 ("min_price", true),
966 ("max_price", true),
967 ("sort", true),
968 // Pagination is rendered as links carrying hx-vals, not a control.
969 ("page", false),
970 ("mode", true),
971 ("ai_tier", true),
972 ("has_source", true),
973 // The drill-down cursor moves by link, never by form submission.
974 ("browse", false),
975 ]
976 }
977
978 #[cfg(test)]
979 mod query_contract_tests {
980 use super::*;
981
982 #[test]
983 fn every_query_param_is_accounted_for() {
984 let contract = query_param_contract();
985 assert_eq!(
986 contract.len(),
987 12,
988 "DiscoverQuery gained or lost a field; decide whether it needs a control"
989 );
990 // Names must be unique, or a duplicate would mask a missing one.
991 let mut names: Vec<_> = contract.iter().map(|(n, _)| *n).collect();
992 names.sort_unstable();
993 let before = names.len();
994 names.dedup();
995 assert_eq!(before, names.len(), "duplicate param name in the contract");
996 }
997 }
998
999 #[cfg(test)]
1000 mod page_url_tests {
1001 use super::{DiscoverQuery, PriceDollars};
1002
1003 fn query() -> DiscoverQuery {
1004 DiscoverQuery {
1005 q: None,
1006 item_type: Vec::new(),
1007 tag: Vec::new(),
1008 category: None,
1009 min_price: None,
1010 max_price: None,
1011 sort: None,
1012 page: None,
1013 mode: None,
1014 ai_tier: None,
1015 has_source: None,
1016 browse: None,
1017 }
1018 }
1019
1020 #[test]
1021 fn bare_query_is_the_bare_page() {
1022 assert_eq!(query().to_page_url(), "/discover");
1023 }
1024
1025 #[test]
1026 fn blank_filters_are_dropped() {
1027 // hx-include ships every filter on every request, so most arrive blank.
1028 let q = DiscoverQuery {
1029 q: Some(String::new()),
1030 tag: vec![" ".to_string()],
1031 category: Some(String::new()),
1032 mode: Some("items".to_string()),
1033 item_type: vec!["preset".to_string()],
1034 ..query()
1035 };
1036 assert_eq!(q.to_page_url(), "/discover?mode=items&item_type=preset");
1037 }
1038
1039 #[test]
1040 fn multi_select_facets_emit_one_param_per_value() {
1041 let q = DiscoverQuery {
1042 mode: Some("items".to_string()),
1043 tag: vec![
1044 "audio.genre.electronic".to_string(),
1045 "audio.mood.dark".to_string(),
1046 ],
1047 item_type: vec!["audio".to_string(), "sample".to_string()],
1048 ..query()
1049 };
1050 assert_eq!(
1051 q.to_page_url(),
1052 "/discover?mode=items&item_type=audio&item_type=sample\
1053 &tag=audio.genre.electronic&tag=audio.mood.dark"
1054 );
1055 }
1056
1057 #[test]
1058 fn repeated_facet_values_are_deduped_in_the_url() {
1059 // A doubled selection must not inflate the URL or the SQL bind arrays.
1060 let q = DiscoverQuery {
1061 tag: vec!["a.b.c".to_string(), "a.b.c".to_string(), String::new()],
1062 ..query()
1063 };
1064 assert_eq!(q.to_page_url(), "/discover?tag=a.b.c");
1065 }
1066
1067 #[test]
1068 fn values_are_percent_encoded() {
1069 let q = DiscoverQuery {
1070 q: Some("field recording & tape".to_string()),
1071 ..query()
1072 };
1073 assert_eq!(
1074 q.to_page_url(),
1075 "/discover?q=field%20recording%20%26%20tape"
1076 );
1077 }
1078
1079 #[test]
1080 fn first_page_stays_implicit() {
1081 let q = DiscoverQuery {
1082 page: Some(1),
1083 ..query()
1084 };
1085 assert_eq!(q.to_page_url(), "/discover");
1086
1087 let q = DiscoverQuery {
1088 page: Some(3),
1089 ..query()
1090 };
1091 assert_eq!(q.to_page_url(), "/discover?page=3");
1092 }
1093
1094 #[test]
1095 fn url_states_the_prices_that_were_actually_applied() {
1096 // An inverted range is dropped from the query, so it must not linger in the URL.
1097 let q = DiscoverQuery {
1098 min_price: Some(PriceDollars::from_cents(500)),
1099 max_price: Some(PriceDollars::from_cents(100)),
1100 ..query()
1101 };
1102 assert_eq!(q.to_page_url(), "/discover");
1103
1104 // And the bounds are written back in dollars, the unit they arrived in.
1105 let q = DiscoverQuery {
1106 min_price: Some(PriceDollars::from_cents(100)),
1107 max_price: Some(PriceDollars::from_cents(2499)),
1108 ..query()
1109 };
1110 assert_eq!(q.to_page_url(), "/discover?min_price=1&max_price=24.99");
1111 }
1112 }
1113
1114 /// Fetch items or projects with pagination; shared by both handlers.
1115 /// The normalized filter selection parsed from a `DiscoverQuery`: empty strings
1116 /// collapse to `None` and enum-valued params are parsed. Both the data fetch and
1117 /// the filter-chip rendering need exactly this and derived it independently
1118 /// (audit Run 17 Architecture), the logic now lives in one place.
1119 struct DiscoverFilterSelection<'a> {
1120 item_types: Vec<ItemType>,
1121 tags: Vec<String>,
1122 search: Option<&'a str>,
1123 category: Option<&'a str>,
1124 ai_tier: Option<db::AiTierFilter>,
1125 has_source_code: bool,
1126 }
1127
1128 impl DiscoverQuery {
1129 fn filter_selection(&self) -> DiscoverFilterSelection<'_> {
1130 DiscoverFilterSelection {
1131 // Blank entries are dropped: `hx-include` ships every filter input
1132 // on every request, so an unselected control arrives as `item_type=`.
1133 // Unparseable values are dropped rather than erroring, matching the
1134 // prior single-value behaviour.
1135 item_types: dedup_nonempty(&self.item_type)
1136 .into_iter()
1137 .filter_map(|s| s.parse().ok())
1138 .collect(),
1139 tags: dedup_nonempty(&self.tag)
1140 .into_iter()
1141 .map(str::to_string)
1142 .collect(),
1143 search: self.q.as_deref().filter(|s| !s.trim().is_empty()),
1144 category: self.category.as_deref().filter(|s| !s.is_empty()),
1145 ai_tier: self
1146 .ai_tier
1147 .as_deref()
1148 .filter(|s| !s.is_empty())
1149 .and_then(|s| s.parse().ok()),
1150 has_source_code: self.has_source.as_deref() == Some("1"),
1151 }
1152 }
1153 }
1154
1155 /// Drop blank entries and duplicates while preserving order.
1156 ///
1157 /// Duplicates are dropped so a doubled `?tag=x&tag=x` cannot inflate the bind
1158 /// arrays; order is preserved so the pushed URL is stable across a round-trip
1159 /// and doesn't churn browser history.
1160 fn dedup_nonempty(values: &[String]) -> Vec<&str> {
1161 let mut seen = std::collections::HashSet::new();
1162 values
1163 .iter()
1164 .map(|s| s.trim())
1165 .filter(|s| !s.is_empty())
1166 .filter(|s| seen.insert(*s))
1167 .collect()
1168 }
1169
1170 async fn fetch_discover_data(pool: &PgPool, query: &DiscoverQuery) -> Result<DiscoverData> {
1171 // Clamp the upper bound too (UX MINOR, Run #23): an unbounded page yields a
1172 // giant OFFSET = one expensive deep scan per request. Matches the git/admin
1173 // list handlers.
1174 let page = query.page.unwrap_or(1).clamp(1, 1_000_000_000);
1175 let limit = constants::DISCOVER_PAGE_SIZE as i64;
1176 let offset = ((page - 1) as i64) * limit;
1177 let f = query.filter_selection();
1178
1179 // A tag is an item-level facet and nothing else: `discover_projects` takes
1180 // no tag argument, and the sidebar only builds tag filters in items mode
1181 // (see `build_sidebar`). So a tag selected in projects mode is a filter the
1182 // URL claims and the query cannot apply, which renders as the full
1183 // unfiltered project list and reads as "this tag matches everything".
1184 //
1185 // That is the common path rather than a corner. Six templates link
1186 // `/discover?tag={slug}` with no mode (item, project, both players, and the
1187 // two reader views), so every tag click from a content page landed there.
1188 // Defaulting to items whenever a tag is selected fixes all of them at once,
1189 // and shared or hand-typed links with it.
1190 let mode = query.mode.as_deref().unwrap_or(if f.tags.is_empty() {
1191 "projects"
1192 } else {
1193 "items"
1194 });
1195
1196 let item_type_filter = f.item_types;
1197 let tag_filter = f.tags;
1198 let search_filter = f.search;
1199 let category_filter = f.category;
1200 let ai_tier_filter = f.ai_tier;
1201 let has_source_code = f.has_source_code;
1202
1203 let (items, projects, total_count) = if mode == "projects" {
1204 let sort_filter: Option<DiscoverSort> = query
1205 .sort
1206 .as_deref()
1207 .filter(|s| !s.is_empty())
1208 .and_then(|s| s.parse().ok());
1209
1210 let db_projects = db::discover::discover_projects(
1211 pool,
1212 search_filter,
1213 category_filter,
1214 sort_filter,
1215 has_source_code,
1216 limit,
1217 offset,
1218 )
1219 .await?;
1220
1221 let total = db::discover::count_discover_projects(
1222 pool,
1223 search_filter,
1224 category_filter,
1225 has_source_code,
1226 )
1227 .await?;
1228
1229 let projects: Vec<DiscoverProject> = crate::types::discover_projects_view(db_projects);
1230 (vec![], projects, total as u32)
1231 } else {
1232 let sort_filter: Option<DiscoverSort> = query
1233 .sort
1234 .as_deref()
1235 .filter(|s| !s.is_empty())
1236 .and_then(|s| s.parse().ok());
1237
1238 let (min_price, max_price) = sanitize_price_range(query.min_price, query.max_price);
1239 let filters = DiscoverFilters {
1240 search: search_filter,
1241 item_types: &item_type_filter,
1242 tags: &tag_filter,
1243 min_price: min_price.map(PriceDollars::cents),
1244 max_price: max_price.map(PriceDollars::cents),
1245 sort_by: sort_filter,
1246 ai_tier: ai_tier_filter,
1247 };
1248
1249 let db_items = db::discover::discover_items(pool, &filters, limit, offset).await?;
1250 let total = db::discover::count_discover_items(pool, &filters).await?;
1251
1252 let items: Vec<DiscoverItem> = crate::types::discover_items_view(db_items);
1253 (items, vec![], total as u32)
1254 };
1255
1256 let total_pages = ((total_count as f64) / (limit as f64)).ceil() as u32;
1257 let pagination_range = super::pagination::build_pagination_range(page, total_pages);
1258 let result_count = if mode == "projects" {
1259 projects.len() as u32
1260 } else {
1261 items.len() as u32
1262 };
1263 // Reuse the i64 `offset` (computed overflow-safe above) for the "showing
1264 // X–Y" labels and saturate into u32, rather than recomputing
1265 // `(page - 1) * DISCOVER_PAGE_SIZE` in u32, which overflows for a large `?page=`.
1266 let showing_start = if result_count == 0 {
1267 0
1268 } else {
1269 offset.saturating_add(1).clamp(0, u32::MAX as i64) as u32
1270 };
1271 // An out-of-range `?page=` returns nothing, and the raw offset would then render
1272 // as "Showing 0-24999950 of 2". With no rows on the page there is no range to
1273 // show, so both ends collapse to zero.
1274 let showing_end = if result_count == 0 {
1275 0
1276 } else {
1277 offset
1278 .saturating_add(result_count as i64)
1279 .clamp(0, u32::MAX as i64) as u32
1280 };
1281
1282 Ok(DiscoverData {
1283 items,
1284 projects,
1285 mode: mode.to_string(),
1286 total_count,
1287 current_page: page,
1288 total_pages,
1289 pagination_range,
1290 showing_start,
1291 showing_end,
1292 is_search: search_filter.is_some(),
1293 count_label: results_count_label(total_count, mode, search_filter.is_some()),
1294 })
1295 }
1296
1297 /// Query parameters for the tag tree browser.
1298 #[derive(Debug, Deserialize)]
1299 pub(super) struct TagTreeQuery {
1300 pub parent: Option<String>,
1301 }
1302
1303 /// Browse the tag hierarchy with breadcrumb navigation.
1304 #[tracing::instrument(skip_all, name = "discover::tag_tree")]
1305 pub(super) async fn tag_tree(
1306 State(db): State<PgPool>,
1307 session: Session,
1308 MaybeUserUnverified(maybe_user): MaybeUserUnverified,
1309 ValidatedExtraQuery(query): ValidatedExtraQuery<TagTreeQuery>,
1310 ) -> Result<impl IntoResponse> {
1311 let csrf_token = get_csrf_token(&session).await;
1312
1313 // Resolve parent tag from ?parent=slug (dot-notation, e.g. "audio.genre")
1314 let parent_tag = if let Some(ref slug) = query.parent {
1315 db::tags::get_tag_by_slug(&db, slug).await?
1316 } else {
1317 None
1318 };
1319
1320 let parent_id = parent_tag.as_ref().map(|t| t.id);
1321
1322 // Fetch children at this level
1323 let children = db::tags::get_child_tags(&db, parent_id).await?;
1324
1325 // Item counts + child counts, both scoped to just this level's children
1326 // rather than aggregating over every tag in the catalog (fuzz 2026-07-06 C5-2).
1327 let child_ids: Vec<_> = children.iter().map(|c| c.id).collect();
1328 let tag_counts = db::tags::item_counts_by_tag(&db, &child_ids).await?;
1329 let grandchild_counts = db::tags::count_children_by_parents(&db, &child_ids).await?;
1330
1331 let categories: Vec<TagTreeNode> = children
1332 .iter()
1333 .map(|child| TagTreeNode {
1334 name: child.name.clone(),
1335 slug: child.slug.clone(),
1336 item_count: *tag_counts.get(&child.id).unwrap_or(&0) as u32,
1337 child_count: *grandchild_counts.get(&child.id).unwrap_or(&0) as usize,
1338 })
1339 .collect();
1340
1341 // Build breadcrumbs from ancestor chain
1342 let (breadcrumbs, current_tag) = if let Some(ref pt) = parent_tag {
1343 let ancestors = db::tags::get_tag_ancestors(&db, pt.id).await?;
1344 // ancestors includes the tag itself as the last element.
1345 // We want all ancestors except the current tag as breadcrumbs,
1346 // and the current tag as current_tag.
1347 let bc: Vec<TagBreadcrumb> = ancestors
1348 .iter()
1349 .filter(|a| a.id != pt.id)
1350 .map(|a| TagBreadcrumb {
1351 name: a.name.clone(),
1352 slug: a.slug.clone(),
1353 })
1354 .collect();
1355 let ct = TagBreadcrumb {
1356 name: pt.name.clone(),
1357 slug: pt.slug.clone(),
1358 };
1359 (bc, Some(ct))
1360 } else {
1361 (vec![], None)
1362 };
1363
1364 Ok(TagTreeTemplate {
1365 csrf_token,
1366 session_user: maybe_user,
1367 categories,
1368 breadcrumbs,
1369 current_tag,
1370 })
1371 }
1372
1373 /// Render the discover page with filterable, paginated items or projects.
1374 #[tracing::instrument(skip_all, name = "discover::discover")]
1375 pub(super) async fn discover(
1376 State(db): State<PgPool>,
1377 session: Session,
1378 MaybeUserUnverified(maybe_user): MaybeUserUnverified,
1379 ValidatedExtraQuery(query): ValidatedExtraQuery<DiscoverQuery>,
1380 ) -> Result<impl IntoResponse> {
1381 let csrf_token = get_csrf_token(&session).await;
1382 let data = fetch_discover_data(&db, &query).await?;
1383 let sidebar = build_sidebar(&db, &query, &data, maybe_user.as_ref().map(|u| u.id)).await?;
1384 let is_authenticated = maybe_user.is_some();
1385
1386 Ok(DiscoverTemplate {
1387 csrf_token,
1388 session_user: maybe_user,
1389 items: data.items,
1390 projects: data.projects,
1391 mode: data.mode,
1392 total_items: data.total_count,
1393 current_page: data.current_page,
1394 total_pages: data.total_pages,
1395 search_query: query.q.clone().unwrap_or_default(),
1396 is_search: data.is_search,
1397 count_label: data.count_label,
1398 sort_by: query.sort.clone().unwrap_or_default(),
1399 pagination_range: data.pagination_range,
1400 showing_start: data.showing_start,
1401 showing_end: data.showing_end,
1402 sidebar,
1403 is_authenticated,
1404 oob_sidebar: false,
1405 })
1406 }
1407
1408 /// Return discover results as an HTMX partial for filtering and pagination.
1409 #[tracing::instrument(skip_all, name = "discover::discover_results")]
1410 pub(super) async fn discover_results(
1411 State(db): State<PgPool>,
1412 MaybeUserUnverified(maybe_user): MaybeUserUnverified,
1413 headers: HeaderMap,
1414 ValidatedExtraQuery(query): ValidatedExtraQuery<DiscoverQuery>,
1415 ) -> Result<impl IntoResponse> {
1416 let data = fetch_discover_data(&db, &query).await?;
1417 // The sidebar rides along on every results request and is swapped
1418 // out-of-band, so its counts always describe the results beside them.
1419 let sidebar = build_sidebar(&db, &query, &data, maybe_user.as_ref().map(|u| u.id)).await?;
1420
1421 // Mirror the filter state into the address bar so a filtered view can be
1422 // shared, bookmarked, and reloaded. A discrete filter click pushes a history
1423 // entry, so Back undoes one filter; the search box fires on a debounce while
1424 // typing, so it replaces instead of burying the page under one entry per
1425 // keystroke pause. HX-Trigger carries the id of the element that fired.
1426 let history_header = match headers.get("HX-Trigger").and_then(|v| v.to_str().ok()) {
1427 Some("search-input") => "HX-Replace-Url",
1428 _ => "HX-Push-Url",
1429 };
1430 let page_url = query.to_page_url();
1431
1432 Ok((
1433 [(history_header, page_url)],
1434 DiscoverResultsTemplate {
1435 items: data.items,
1436 projects: data.projects,
1437 mode: data.mode,
1438 total_items: data.total_count,
1439 current_page: data.current_page,
1440 total_pages: data.total_pages,
1441 pagination_range: data.pagination_range,
1442 showing_start: data.showing_start,
1443 showing_end: data.showing_end,
1444 current_category: query.category.clone().unwrap_or_default(),
1445 is_search: data.is_search,
1446 count_label: data.count_label,
1447 is_authenticated: maybe_user.is_some(),
1448 sidebar,
1449 oob_sidebar: true,
1450 },
1451 ))
1452 }
1453
1454 /// Query parameters for search suggestions.
1455 #[derive(Debug, Deserialize)]
1456 pub(super) struct SuggestionsQuery {
1457 pub q: Option<String>,
1458 }
1459
1460 /// JSON response for a tag typeahead hit.
1461 ///
1462 /// `context` is the parent path, which the dropdown needs in order to
1463 /// disambiguate: "Format" exists under audio, software, writing, and video.
1464 #[derive(Debug, Serialize)]
1465 pub(super) struct TagSuggestion {
1466 pub slug: String,
1467 pub label: String,
1468 pub context: String,
1469 }
1470
1471 /// JSON response for a search suggestion.
1472 #[derive(Debug, Serialize)]
1473 pub(super) struct SearchSuggestion {
1474 pub label: String,
1475 pub category: String,
1476 pub url: String,
1477 }
1478
1479 /// Return search suggestions (tags, projects, creators) as JSON.
1480 #[tracing::instrument(skip_all, name = "discover::search_suggestions")]
1481 pub(super) async fn search_suggestions_handler(
1482 State(db): State<PgPool>,
1483 ValidatedExtraQuery(query): ValidatedExtraQuery<SuggestionsQuery>,
1484 ) -> Result<impl IntoResponse> {
1485 let q = query.q.unwrap_or_default();
1486 let rows = db::discover::search_suggestions(&db, &q).await?;
1487 let suggestions: Vec<SearchSuggestion> = rows
1488 .into_iter()
1489 .map(|r| SearchSuggestion {
1490 label: r.label,
1491 category: r.category,
1492 url: r.url,
1493 })
1494 .collect();
1495 Ok(Json(suggestions))
1496 }
1497