Skip to main content

max / makenotwork

56.8 KB · 1528 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 /// The typed value and the filters the box sent along with it.
503 ///
504 /// Read off the raw query rather than through serde, because the two halves
505 /// want different treatment: the typed value is one string under the described
506 /// field's own name, and the rest is an opaque bag that travels straight back
507 /// out on every candidate's pick. Deserializing the bag into [`DiscoverQuery`]
508 /// and re-serializing it would be a round trip through eleven typed members for
509 /// values this route never reads.
510 ///
511 /// Only the names the description says ride are kept
512 /// ([`discover_typeahead::FILTERS`](crate::quasi::discover_typeahead::FILTERS)),
513 /// so nothing else a caller appends reaches the markup. Blanks are dropped for
514 /// [`DiscoverQuery::filter_params`]' reason: every filter request carries the
515 /// whole control set, so echoing them would put `tag=&category=` in each pick.
516 fn typeahead_query(raw: Option<&str>) -> (String, quasi_router::Params) {
517 use crate::quasi::discover_typeahead;
518
519 let mut typed = String::new();
520 let mut view = quasi_router::Params::new();
521 for (name, value) in url::form_urlencoded::parse(raw.unwrap_or_default().as_bytes()) {
522 if name == discover_typeahead::FIELD {
523 typed = value.into_owned();
524 } else if discover_typeahead::FILTERS.contains(&name.as_ref()) && !value.trim().is_empty() {
525 view.insert(name, value);
526 }
527 }
528 (typed, view)
529 }
530
531 /// Tag typeahead for the discover sidebar.
532 ///
533 /// Prefix matching first (path-prefix, then segment-prefix), falling back to
534 /// fuzzy only when prefix matching underfills. That ordering is tagtree's, and
535 /// it is why typing "elec" finds `audio.genre.electronic` without the caller
536 /// knowing which level it lives at.
537 ///
538 /// Answers the described suggestion list rather than JSON (N8, `1503db12`).
539 /// What changed is who draws the dropdown: the markup is
540 /// [`Outcome::Suggestions`](quasi_router::Outcome::Suggestions) rendered by
541 /// quasi-webview, so the ~110 lines of `page-discover.js` that built the rows,
542 /// tracked the highlight and added the facet by hand are gone. The route's own
543 /// job — which tags, in which order — is unchanged.
544 pub(super) async fn tag_suggestions_handler(
545 State(db): State<PgPool>,
546 axum::extract::RawQuery(raw): axum::extract::RawQuery,
547 ) -> Result<impl IntoResponse> {
548 let (raw_input, view) = typeahead_query(raw.as_deref());
549 let input = raw_input.trim();
550 if input.is_empty() {
551 // An empty list rather than an empty document: the answer replaces the
552 // list the field owns, so nothing to offer has to mean nothing there.
553 return Ok(axum::response::Html(String::new()));
554 }
555
556 let index = cached_tag_index(&db).await?;
557 let (hits, _exact) = index.suggest_with_status(input, TAG_SUGGEST_LIMIT);
558 let mut slugs: Vec<String> = hits.into_iter().map(str::to_string).collect();
559 if slugs.is_empty() {
560 slugs = index
561 .suggest_fuzzy(input, TAG_SUGGEST_LIMIT)
562 .into_iter()
563 .map(str::to_string)
564 .collect();
565 }
566
567 // Only depth-3+ tags can be assigned to an item, so only they can filter
568 // anything; offering a category here would produce an empty result set.
569 slugs.retain(|s| tagtree::depth(s) >= 3);
570
571 let names: std::collections::HashMap<String, String> =
572 db::tags::tag_names_for_slugs(&db, &slugs)
573 .await?
574 .into_iter()
575 .collect();
576
577 let hits: Vec<crate::quasi::discover_typeahead::Hit> = slugs
578 .into_iter()
579 .map(|slug| {
580 let label = names
581 .get(&slug)
582 .cloned()
583 .unwrap_or_else(|| tagtree::leaf(&slug).replace('-', " "));
584 // The parent path orients an otherwise ambiguous leaf: "Format"
585 // appears under audio, software, writing, and video.
586 let context = tagtree::parent(&slug).unwrap_or("").to_string();
587 crate::quasi::discover_typeahead::Hit {
588 slug,
589 label,
590 context,
591 }
592 })
593 .collect();
594
595 Ok(axum::response::Html(
596 crate::quasi::discover_typeahead::tag_suggestions(&hits, &view),
597 ))
598 }
599
600 /// Deserialize an empty string as `None` instead of failing to parse.
601 ///
602 /// HTML form inputs send `field=` (empty string) when blank, which fails
603 /// serde's default `Option<i32>` parsing. This treats `""` as `None`.
604 fn empty_string_as_none<'de, D, T>(deserializer: D) -> std::result::Result<Option<T>, D::Error>
605 where
606 D: serde::Deserializer<'de>,
607 T: std::str::FromStr,
608 T::Err: std::fmt::Display,
609 {
610 let opt = Option::<String>::deserialize(deserializer)?;
611 match opt {
612 None => Ok(None),
613 Some(s) if s.is_empty() => Ok(None),
614 Some(s) => s.parse::<T>().map(Some).map_err(serde::de::Error::custom),
615 }
616 }
617
618 /// A price bound from the query string: dollars on the wire, cents in the field.
619 ///
620 /// The wire format is dollars (`?max_price=24.99`) because a visitor types this
621 /// filter by hand and reads it back out of the address bar; the catalog stores
622 /// cents. Doing the conversion here, once, at the edge is what stops the two
623 /// units meeting: typing 20 used to filter for items under twenty cents, with
624 /// nothing on screen saying so (loose-wire g2-16).
625 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
626 pub(super) struct PriceDollars(i32);
627
628 impl PriceDollars {
629 /// The bound in cents, which is the only unit the queries speak.
630 fn cents(self) -> i32 {
631 self.0
632 }
633
634 fn from_cents(cents: i32) -> Self {
635 Self(cents)
636 }
637 }
638
639 impl std::str::FromStr for PriceDollars {
640 type Err = String;
641
642 /// Delegates to the canonical dollars parser, so "$1,250" off a clipboard
643 /// works here exactly as it does in the pricing forms, and a rejection
644 /// carries that parser's wording. `ValidatedQuery` turns the rejection into
645 /// the branded error page rather than a bare axum 400.
646 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
647 crate::pricing::parse_dollars_to_cents("Price", Some(s))
648 .map(Self)
649 .map_err(|e| e.user_message())
650 }
651 }
652
653 impl std::fmt::Display for PriceDollars {
654 /// Whole dollars stay whole, so the common bucket links read `min_price=25`
655 /// rather than `min_price=25.00`.
656 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
657 if self.0 % 100 == 0 {
658 write!(f, "{}", self.0 / 100)
659 } else {
660 write!(f, "{}", crate::formatting::format_dollars_plain(self.0))
661 }
662 }
663 }
664
665 /// Query parameters for the discover/search page.
666 #[derive(Debug, Deserialize)]
667 pub(super) struct DiscoverQuery {
668 pub q: Option<String>,
669 /// Repeated `item_type=` params, OR'd together. A single value (what the
670 /// pre-multi-select UI sends) parses as a one-element vec, so old links and
671 /// bookmarks keep working.
672 #[serde(default)]
673 pub item_type: Vec<String>,
674 /// Repeated `tag=` params, OR'd together; each matches its own subtree.
675 #[serde(default)]
676 pub tag: Vec<String>,
677 pub category: Option<String>,
678 #[serde(default, deserialize_with = "empty_string_as_none")]
679 pub min_price: Option<PriceDollars>,
680 #[serde(default, deserialize_with = "empty_string_as_none")]
681 pub max_price: Option<PriceDollars>,
682 pub sort: Option<String>,
683 #[serde(default, deserialize_with = "empty_string_as_none")]
684 pub page: Option<u32>,
685 /// `"items"` or `"projects"`. Unset means projects, except when a tag is
686 /// selected, which means items: see the handler for why. The comment here
687 /// used to say items was the default and it never was.
688 pub mode: Option<String>,
689 pub ai_tier: Option<String>,
690 pub has_source: Option<String>,
691 /// Drill-down cursor: which tag's children the sidebar is showing. Distinct
692 /// from `tag`, which is the selection. Browsing into a category does not
693 /// filter, and selecting a leaf does not move the cursor, so the two can be
694 /// operated independently.
695 pub browse: Option<String>,
696 }
697
698 impl DiscoverQuery {
699 /// Rebuild the canonical full-page URL for this filter selection, for the
700 /// HTMX history headers.
701 ///
702 /// Blank values are dropped rather than echoed: every filter request carries
703 /// the whole `.discover-filter` set via `hx-include`, so a verbatim
704 /// round-trip would put `/discover?q=&tag=&category=&min_price=` in the
705 /// address bar. Prices are sanitized so the URL states what was actually
706 /// applied, and `page=1` is left implicit.
707 fn to_page_url(&self) -> String {
708 let mut parts = self.filter_params();
709 if let Some(b) = self
710 .browse
711 .as_ref()
712 .map(|s| s.trim())
713 .filter(|s| !s.is_empty())
714 {
715 parts.push(format!("browse={}", urlencoding::encode(b)));
716 }
717 if let Some(p) = self.page.filter(|&p| p > 1) {
718 parts.push(format!("page={p}"));
719 }
720
721 if parts.is_empty() {
722 "/discover".to_string()
723 } else {
724 format!("/discover?{}", parts.join("&"))
725 }
726 }
727
728 /// Base URL for moving the drill-down cursor, ending ready for a `browse=`
729 /// value to be appended.
730 ///
731 /// Navigating the tree preserves the current selection and drops `page`,
732 /// since a cursor move lands you on a different list and page 4 of the old
733 /// one is meaningless.
734 fn browse_base_url(&self) -> String {
735 let parts = self.filter_params();
736 if parts.is_empty() {
737 "/discover?browse=".to_string()
738 } else {
739 format!("/discover?{}&browse=", parts.join("&"))
740 }
741 }
742
743 /// The URL that clears the cursor back to the tag roots, selection intact.
744 fn browse_root_url(&self) -> String {
745 let parts = self.filter_params();
746 if parts.is_empty() {
747 "/discover".to_string()
748 } else {
749 format!("/discover?{}", parts.join("&"))
750 }
751 }
752
753 /// Filter params with the price range omitted, for building the price
754 /// bucket links: a bucket replaces the range rather than adding to it.
755 fn params_without_price(&self) -> Vec<String> {
756 let (min, max) = sanitize_price_range(self.min_price, self.max_price);
757 self.filter_params()
758 .into_iter()
759 .filter(|p| {
760 !(min.is_some_and(|v| *p == format!("min_price={v}"))
761 || max.is_some_and(|v| *p == format!("max_price={v}")))
762 })
763 .collect()
764 }
765
766 /// Every filter param except the cursor and the page, in canonical order.
767 fn filter_params(&self) -> Vec<String> {
768 fn push_str(parts: &mut Vec<String>, key: &str, value: Option<&String>) {
769 if let Some(v) = value.map(|s| s.trim()).filter(|s| !s.is_empty()) {
770 parts.push(format!("{key}={}", urlencoding::encode(v)));
771 }
772 }
773
774 // Multi-valued facets emit one param per selection, deduped and blank-free,
775 // so the address bar states exactly the filter that was applied.
776 fn push_each(parts: &mut Vec<String>, key: &str, values: &[String]) {
777 for v in dedup_nonempty(values) {
778 parts.push(format!("{key}={}", urlencoding::encode(v)));
779 }
780 }
781
782 let mut parts = Vec::new();
783 push_str(&mut parts, "mode", self.mode.as_ref());
784 push_str(&mut parts, "q", self.q.as_ref());
785 push_each(&mut parts, "item_type", &self.item_type);
786 push_each(&mut parts, "tag", &self.tag);
787 push_str(&mut parts, "category", self.category.as_ref());
788 push_str(&mut parts, "ai_tier", self.ai_tier.as_ref());
789 push_str(&mut parts, "has_source", self.has_source.as_ref());
790 push_str(&mut parts, "sort", self.sort.as_ref());
791
792 let (min_price, max_price) = sanitize_price_range(self.min_price, self.max_price);
793 if let Some(v) = min_price {
794 parts.push(format!("min_price={v}"));
795 }
796 if let Some(v) = max_price {
797 parts.push(format!("max_price={v}"));
798 }
799
800 parts
801 }
802 }
803
804 /// Shared result data for both the full discover page and the HTMX partial.
805 struct DiscoverData {
806 items: Vec<DiscoverItem>,
807 projects: Vec<DiscoverProject>,
808 mode: String,
809 total_count: u32,
810 current_page: u32,
811 total_pages: u32,
812 pagination_range: Vec<u32>,
813 showing_start: u32,
814 showing_end: u32,
815 /// A search term was applied, so the copy addresses a search rather than a
816 /// filter.
817 ///
818 /// Taken from the same `filter_selection()` the query used, not from the raw
819 /// `?q=`: a whitespace-only term is browsing, and reading it separately here
820 /// would have the copy disagree with the list under it.
821 is_search: bool,
822 /// The rendered count line, e.g. "247 results" or "1 item".
823 count_label: String,
824 }
825
826 /// The count line above the results.
827 ///
828 /// Says "results" for a search and names the thing for a browse, because those
829 /// are different claims. Browsing, the number really is how many items match the
830 /// filters. Searching, membership is any typed word (migration 179), so most of
831 /// a large number can be partial matches; "247 items" would assert 247 things
832 /// matched, which is not what was counted. "247 results" describes the list,
833 /// and the tier heading and per-row counts describe its quality.
834 ///
835 /// Built here rather than in the templates because two of them render this same
836 /// `#total-count` span, the page and the out-of-band partial. Any difference
837 /// between them shows up as the text changing on the first HTMX swap, which is
838 /// the failure mode that already bit this element once (it used to sit outside
839 /// `#results-container` and go stale). One string, no way to disagree.
840 fn results_count_label(total: u32, mode: &str, is_search: bool) -> String {
841 let noun = match (is_search, mode, total) {
842 (true, _, 1) => "result",
843 (true, _, _) => "results",
844 (false, "projects", 1) => "project",
845 (false, "projects", _) => "projects",
846 (false, _, 1) => "item",
847 (false, _, _) => "items",
848 };
849 format!("{total} {noun}")
850 }
851
852 /// Clamp the discover price filters to a sane range. A negative bound is
853 /// meaningless (prices are non-negative cents) and is dropped; an inverted range
854 /// (min > max) can only match nothing, so both bounds are dropped rather than
855 /// issuing an empty-by-construction query.
856 fn sanitize_price_range(
857 min: Option<PriceDollars>,
858 max: Option<PriceDollars>,
859 ) -> (Option<PriceDollars>, Option<PriceDollars>) {
860 let min = min.filter(|v| v.cents() >= 0);
861 let max = max.filter(|v| v.cents() >= 0);
862 if let (Some(lo), Some(hi)) = (min, max)
863 && lo.cents() > hi.cents()
864 {
865 return (None, None);
866 }
867 (min, max)
868 }
869
870 #[cfg(test)]
871 mod count_label_tests {
872 use super::results_count_label;
873
874 /// Browsing, the number is genuinely how many items match the filters.
875 #[test]
876 fn browsing_names_the_thing_being_counted() {
877 assert_eq!(results_count_label(247, "items", false), "247 items");
878 assert_eq!(results_count_label(12, "projects", false), "12 projects");
879 }
880
881 /// Searching, membership is any typed word, so most of a large number can be
882 /// partial matches. "247 items" would assert 247 things matched; "247
883 /// results" only describes the list, which is all that was counted.
884 #[test]
885 fn searching_describes_the_list_rather_than_claiming_matches() {
886 assert_eq!(results_count_label(247, "items", true), "247 results");
887 assert_eq!(results_count_label(247, "projects", true), "247 results");
888 }
889
890 #[test]
891 fn one_of_something_is_singular() {
892 assert_eq!(results_count_label(1, "items", false), "1 item");
893 assert_eq!(results_count_label(1, "projects", false), "1 project");
894 assert_eq!(results_count_label(1, "items", true), "1 result");
895 }
896
897 /// Zero renders through the empty state, but the label is still built, and
898 /// "0 result" would read as a typo next to it.
899 #[test]
900 fn zero_is_plural() {
901 assert_eq!(results_count_label(0, "items", false), "0 items");
902 assert_eq!(results_count_label(0, "items", true), "0 results");
903 }
904 }
905
906 #[cfg(test)]
907 mod price_range_tests {
908 use super::{PriceDollars, sanitize_price_range};
909
910 fn c(cents: i32) -> Option<PriceDollars> {
911 Some(PriceDollars::from_cents(cents))
912 }
913
914 #[test]
915 fn drops_negatives_and_inverted_ranges() {
916 assert_eq!(sanitize_price_range(c(100), c(500)), (c(100), c(500)));
917 assert_eq!(sanitize_price_range(c(-1), c(500)), (None, c(500)));
918 assert_eq!(sanitize_price_range(c(100), c(-5)), (c(100), None));
919 // inverted range can only match nothing -> drop both
920 assert_eq!(sanitize_price_range(c(500), c(100)), (None, None));
921 assert_eq!(sanitize_price_range(None, None), (None, None));
922 }
923 }
924
925 #[cfg(test)]
926 mod price_dollars_tests {
927 use super::PriceDollars;
928
929 /// The bug this type exists for: what the visitor types is dollars.
930 #[test]
931 fn parses_dollars_not_cents() {
932 assert_eq!("20".parse::<PriceDollars>().unwrap().cents(), 2000);
933 assert_eq!("24.99".parse::<PriceDollars>().unwrap().cents(), 2499);
934 assert_eq!("0".parse::<PriceDollars>().unwrap().cents(), 0);
935 }
936
937 /// Pasted decoration is the pricing parser's job, and this inherits it.
938 #[test]
939 fn accepts_pasted_decoration() {
940 assert_eq!("$1,250".parse::<PriceDollars>().unwrap().cents(), 125_000);
941 }
942
943 /// A rejection here is what `ValidatedQuery` turns into the branded page.
944 #[test]
945 fn rejects_junk_and_negatives() {
946 assert!("abc".parse::<PriceDollars>().is_err());
947 assert!("-5".parse::<PriceDollars>().is_err());
948 }
949
950 /// Round-trips through the URL: whole dollars stay whole, cents survive.
951 #[test]
952 fn displays_back_as_typed() {
953 assert_eq!(PriceDollars::from_cents(2500).to_string(), "25");
954 assert_eq!(PriceDollars::from_cents(2499).to_string(), "24.99");
955 assert_eq!(PriceDollars::from_cents(0).to_string(), "0");
956 }
957 }
958
959 /// Every `DiscoverQuery` field, paired with whether the rendered page must
960 /// carry a form control submitting under that name.
961 ///
962 /// This exists because a form's control names are a contract with its handler
963 /// and nothing checks it: a rename pass once rewrote `name="has_source"` to
964 /// `name="sidebar.has_source"` and the entire suite still passed, with the
965 /// filter silently inert.
966 ///
967 /// The exhaustive destructure is the point. Adding a field to `DiscoverQuery`
968 /// stops this compiling, which forces a decision about whether the new
969 /// parameter needs a control rather than letting it be silently unreachable.
970 #[cfg(test)]
971 fn query_param_contract() -> Vec<(&'static str, bool)> {
972 let DiscoverQuery {
973 q: _,
974 item_type: _,
975 tag: _,
976 category: _,
977 min_price: _,
978 max_price: _,
979 sort: _,
980 page: _,
981 mode: _,
982 ai_tier: _,
983 has_source: _,
984 browse: _,
985 } = DiscoverQuery {
986 q: None,
987 item_type: Vec::new(),
988 tag: Vec::new(),
989 category: None,
990 min_price: None,
991 max_price: None,
992 sort: None,
993 page: None,
994 mode: None,
995 ai_tier: None,
996 has_source: None,
997 browse: None,
998 };
999
1000 vec![
1001 ("q", true),
1002 ("item_type", true),
1003 ("tag", true),
1004 ("category", true),
1005 ("min_price", true),
1006 ("max_price", true),
1007 ("sort", true),
1008 // Pagination is rendered as links carrying hx-vals, not a control.
1009 ("page", false),
1010 ("mode", true),
1011 ("ai_tier", true),
1012 ("has_source", true),
1013 // The drill-down cursor moves by link, never by form submission.
1014 ("browse", false),
1015 ]
1016 }
1017
1018 #[cfg(test)]
1019 mod query_contract_tests {
1020 use super::*;
1021
1022 #[test]
1023 fn every_query_param_is_accounted_for() {
1024 let contract = query_param_contract();
1025 assert_eq!(
1026 contract.len(),
1027 12,
1028 "DiscoverQuery gained or lost a field; decide whether it needs a control"
1029 );
1030 // Names must be unique, or a duplicate would mask a missing one.
1031 let mut names: Vec<_> = contract.iter().map(|(n, _)| *n).collect();
1032 names.sort_unstable();
1033 let before = names.len();
1034 names.dedup();
1035 assert_eq!(before, names.len(), "duplicate param name in the contract");
1036 }
1037 }
1038
1039 #[cfg(test)]
1040 mod page_url_tests {
1041 use super::{DiscoverQuery, PriceDollars};
1042
1043 fn query() -> DiscoverQuery {
1044 DiscoverQuery {
1045 q: None,
1046 item_type: Vec::new(),
1047 tag: Vec::new(),
1048 category: None,
1049 min_price: None,
1050 max_price: None,
1051 sort: None,
1052 page: None,
1053 mode: None,
1054 ai_tier: None,
1055 has_source: None,
1056 browse: None,
1057 }
1058 }
1059
1060 #[test]
1061 fn bare_query_is_the_bare_page() {
1062 assert_eq!(query().to_page_url(), "/discover");
1063 }
1064
1065 #[test]
1066 fn blank_filters_are_dropped() {
1067 // hx-include ships every filter on every request, so most arrive blank.
1068 let q = DiscoverQuery {
1069 q: Some(String::new()),
1070 tag: vec![" ".to_string()],
1071 category: Some(String::new()),
1072 mode: Some("items".to_string()),
1073 item_type: vec!["preset".to_string()],
1074 ..query()
1075 };
1076 assert_eq!(q.to_page_url(), "/discover?mode=items&item_type=preset");
1077 }
1078
1079 #[test]
1080 fn multi_select_facets_emit_one_param_per_value() {
1081 let q = DiscoverQuery {
1082 mode: Some("items".to_string()),
1083 tag: vec![
1084 "audio.genre.electronic".to_string(),
1085 "audio.mood.dark".to_string(),
1086 ],
1087 item_type: vec!["audio".to_string(), "sample".to_string()],
1088 ..query()
1089 };
1090 assert_eq!(
1091 q.to_page_url(),
1092 "/discover?mode=items&item_type=audio&item_type=sample\
1093 &tag=audio.genre.electronic&tag=audio.mood.dark"
1094 );
1095 }
1096
1097 #[test]
1098 fn repeated_facet_values_are_deduped_in_the_url() {
1099 // A doubled selection must not inflate the URL or the SQL bind arrays.
1100 let q = DiscoverQuery {
1101 tag: vec!["a.b.c".to_string(), "a.b.c".to_string(), String::new()],
1102 ..query()
1103 };
1104 assert_eq!(q.to_page_url(), "/discover?tag=a.b.c");
1105 }
1106
1107 #[test]
1108 fn values_are_percent_encoded() {
1109 let q = DiscoverQuery {
1110 q: Some("field recording & tape".to_string()),
1111 ..query()
1112 };
1113 assert_eq!(
1114 q.to_page_url(),
1115 "/discover?q=field%20recording%20%26%20tape"
1116 );
1117 }
1118
1119 #[test]
1120 fn first_page_stays_implicit() {
1121 let q = DiscoverQuery {
1122 page: Some(1),
1123 ..query()
1124 };
1125 assert_eq!(q.to_page_url(), "/discover");
1126
1127 let q = DiscoverQuery {
1128 page: Some(3),
1129 ..query()
1130 };
1131 assert_eq!(q.to_page_url(), "/discover?page=3");
1132 }
1133
1134 #[test]
1135 fn url_states_the_prices_that_were_actually_applied() {
1136 // An inverted range is dropped from the query, so it must not linger in the URL.
1137 let q = DiscoverQuery {
1138 min_price: Some(PriceDollars::from_cents(500)),
1139 max_price: Some(PriceDollars::from_cents(100)),
1140 ..query()
1141 };
1142 assert_eq!(q.to_page_url(), "/discover");
1143
1144 // And the bounds are written back in dollars, the unit they arrived in.
1145 let q = DiscoverQuery {
1146 min_price: Some(PriceDollars::from_cents(100)),
1147 max_price: Some(PriceDollars::from_cents(2499)),
1148 ..query()
1149 };
1150 assert_eq!(q.to_page_url(), "/discover?min_price=1&max_price=24.99");
1151 }
1152 }
1153
1154 /// Fetch items or projects with pagination; shared by both handlers.
1155 /// The normalized filter selection parsed from a `DiscoverQuery`: empty strings
1156 /// collapse to `None` and enum-valued params are parsed. Both the data fetch and
1157 /// the filter-chip rendering need exactly this and derived it independently
1158 /// (audit Run 17 Architecture), the logic now lives in one place.
1159 struct DiscoverFilterSelection<'a> {
1160 item_types: Vec<ItemType>,
1161 tags: Vec<String>,
1162 search: Option<&'a str>,
1163 category: Option<&'a str>,
1164 ai_tier: Option<db::AiTierFilter>,
1165 has_source_code: bool,
1166 }
1167
1168 impl DiscoverQuery {
1169 fn filter_selection(&self) -> DiscoverFilterSelection<'_> {
1170 DiscoverFilterSelection {
1171 // Blank entries are dropped: `hx-include` ships every filter input
1172 // on every request, so an unselected control arrives as `item_type=`.
1173 // Unparseable values are dropped rather than erroring, matching the
1174 // prior single-value behaviour.
1175 item_types: dedup_nonempty(&self.item_type)
1176 .into_iter()
1177 .filter_map(|s| s.parse().ok())
1178 .collect(),
1179 tags: dedup_nonempty(&self.tag)
1180 .into_iter()
1181 .map(str::to_string)
1182 .collect(),
1183 search: self.q.as_deref().filter(|s| !s.trim().is_empty()),
1184 category: self.category.as_deref().filter(|s| !s.is_empty()),
1185 ai_tier: self
1186 .ai_tier
1187 .as_deref()
1188 .filter(|s| !s.is_empty())
1189 .and_then(|s| s.parse().ok()),
1190 has_source_code: self.has_source.as_deref() == Some("1"),
1191 }
1192 }
1193 }
1194
1195 /// Drop blank entries and duplicates while preserving order.
1196 ///
1197 /// Duplicates are dropped so a doubled `?tag=x&tag=x` cannot inflate the bind
1198 /// arrays; order is preserved so the pushed URL is stable across a round-trip
1199 /// and doesn't churn browser history.
1200 fn dedup_nonempty(values: &[String]) -> Vec<&str> {
1201 let mut seen = std::collections::HashSet::new();
1202 values
1203 .iter()
1204 .map(|s| s.trim())
1205 .filter(|s| !s.is_empty())
1206 .filter(|s| seen.insert(*s))
1207 .collect()
1208 }
1209
1210 async fn fetch_discover_data(pool: &PgPool, query: &DiscoverQuery) -> Result<DiscoverData> {
1211 // Clamp the upper bound too (UX MINOR, Run #23): an unbounded page yields a
1212 // giant OFFSET = one expensive deep scan per request. Matches the git/admin
1213 // list handlers.
1214 let page = query.page.unwrap_or(1).clamp(1, 1_000_000_000);
1215 let limit = constants::DISCOVER_PAGE_SIZE as i64;
1216 let offset = ((page - 1) as i64) * limit;
1217 let f = query.filter_selection();
1218
1219 // A tag is an item-level facet and nothing else: `discover_projects` takes
1220 // no tag argument, and the sidebar only builds tag filters in items mode
1221 // (see `build_sidebar`). So a tag selected in projects mode is a filter the
1222 // URL claims and the query cannot apply, which renders as the full
1223 // unfiltered project list and reads as "this tag matches everything".
1224 //
1225 // That is the common path rather than a corner. Six templates link
1226 // `/discover?tag={slug}` with no mode (item, project, both players, and the
1227 // two reader views), so every tag click from a content page landed there.
1228 // Defaulting to items whenever a tag is selected fixes all of them at once,
1229 // and shared or hand-typed links with it.
1230 let mode = query.mode.as_deref().unwrap_or(if f.tags.is_empty() {
1231 "projects"
1232 } else {
1233 "items"
1234 });
1235
1236 let item_type_filter = f.item_types;
1237 let tag_filter = f.tags;
1238 let search_filter = f.search;
1239 let category_filter = f.category;
1240 let ai_tier_filter = f.ai_tier;
1241 let has_source_code = f.has_source_code;
1242
1243 let (items, projects, total_count) = if mode == "projects" {
1244 let sort_filter: Option<DiscoverSort> = query
1245 .sort
1246 .as_deref()
1247 .filter(|s| !s.is_empty())
1248 .and_then(|s| s.parse().ok());
1249
1250 let db_projects = db::discover::discover_projects(
1251 pool,
1252 search_filter,
1253 category_filter,
1254 sort_filter,
1255 has_source_code,
1256 limit,
1257 offset,
1258 )
1259 .await?;
1260
1261 let total = db::discover::count_discover_projects(
1262 pool,
1263 search_filter,
1264 category_filter,
1265 has_source_code,
1266 )
1267 .await?;
1268
1269 let projects: Vec<DiscoverProject> = crate::types::discover_projects_view(db_projects);
1270 (vec![], projects, total as u32)
1271 } else {
1272 let sort_filter: Option<DiscoverSort> = query
1273 .sort
1274 .as_deref()
1275 .filter(|s| !s.is_empty())
1276 .and_then(|s| s.parse().ok());
1277
1278 let (min_price, max_price) = sanitize_price_range(query.min_price, query.max_price);
1279 let filters = DiscoverFilters {
1280 search: search_filter,
1281 item_types: &item_type_filter,
1282 tags: &tag_filter,
1283 min_price: min_price.map(PriceDollars::cents),
1284 max_price: max_price.map(PriceDollars::cents),
1285 sort_by: sort_filter,
1286 ai_tier: ai_tier_filter,
1287 };
1288
1289 let db_items = db::discover::discover_items(pool, &filters, limit, offset).await?;
1290 let total = db::discover::count_discover_items(pool, &filters).await?;
1291
1292 let items: Vec<DiscoverItem> = crate::types::discover_items_view(db_items);
1293 (items, vec![], total as u32)
1294 };
1295
1296 let total_pages = ((total_count as f64) / (limit as f64)).ceil() as u32;
1297 let pagination_range = super::pagination::build_pagination_range(page, total_pages);
1298 let result_count = if mode == "projects" {
1299 projects.len() as u32
1300 } else {
1301 items.len() as u32
1302 };
1303 // Reuse the i64 `offset` (computed overflow-safe above) for the "showing
1304 // X–Y" labels and saturate into u32, rather than recomputing
1305 // `(page - 1) * DISCOVER_PAGE_SIZE` in u32, which overflows for a large `?page=`.
1306 let showing_start = if result_count == 0 {
1307 0
1308 } else {
1309 offset.saturating_add(1).clamp(0, u32::MAX as i64) as u32
1310 };
1311 // An out-of-range `?page=` returns nothing, and the raw offset would then render
1312 // as "Showing 0-24999950 of 2". With no rows on the page there is no range to
1313 // show, so both ends collapse to zero.
1314 let showing_end = if result_count == 0 {
1315 0
1316 } else {
1317 offset
1318 .saturating_add(result_count as i64)
1319 .clamp(0, u32::MAX as i64) as u32
1320 };
1321
1322 Ok(DiscoverData {
1323 items,
1324 projects,
1325 mode: mode.to_string(),
1326 total_count,
1327 current_page: page,
1328 total_pages,
1329 pagination_range,
1330 showing_start,
1331 showing_end,
1332 is_search: search_filter.is_some(),
1333 count_label: results_count_label(total_count, mode, search_filter.is_some()),
1334 })
1335 }
1336
1337 /// Query parameters for the tag tree browser.
1338 #[derive(Debug, Deserialize)]
1339 pub(super) struct TagTreeQuery {
1340 pub parent: Option<String>,
1341 }
1342
1343 /// Browse the tag hierarchy with breadcrumb navigation.
1344 #[tracing::instrument(skip_all, name = "discover::tag_tree")]
1345 pub(super) async fn tag_tree(
1346 State(db): State<PgPool>,
1347 session: Session,
1348 MaybeUserUnverified(maybe_user): MaybeUserUnverified,
1349 ValidatedExtraQuery(query): ValidatedExtraQuery<TagTreeQuery>,
1350 ) -> Result<impl IntoResponse> {
1351 let csrf_token = get_csrf_token(&session).await;
1352
1353 // Resolve parent tag from ?parent=slug (dot-notation, e.g. "audio.genre")
1354 let parent_tag = if let Some(ref slug) = query.parent {
1355 db::tags::get_tag_by_slug(&db, slug).await?
1356 } else {
1357 None
1358 };
1359
1360 let parent_id = parent_tag.as_ref().map(|t| t.id);
1361
1362 // Fetch children at this level
1363 let children = db::tags::get_child_tags(&db, parent_id).await?;
1364
1365 // Item counts + child counts, both scoped to just this level's children
1366 // rather than aggregating over every tag in the catalog (fuzz 2026-07-06 C5-2).
1367 let child_ids: Vec<_> = children.iter().map(|c| c.id).collect();
1368 let tag_counts = db::tags::item_counts_by_tag(&db, &child_ids).await?;
1369 let grandchild_counts = db::tags::count_children_by_parents(&db, &child_ids).await?;
1370
1371 let categories: Vec<TagTreeNode> = children
1372 .iter()
1373 .map(|child| TagTreeNode {
1374 name: child.name.clone(),
1375 slug: child.slug.clone(),
1376 item_count: *tag_counts.get(&child.id).unwrap_or(&0) as u32,
1377 child_count: *grandchild_counts.get(&child.id).unwrap_or(&0) as usize,
1378 })
1379 .collect();
1380
1381 // Build breadcrumbs from ancestor chain
1382 let (breadcrumbs, current_tag) = if let Some(ref pt) = parent_tag {
1383 let ancestors = db::tags::get_tag_ancestors(&db, pt.id).await?;
1384 // ancestors includes the tag itself as the last element.
1385 // We want all ancestors except the current tag as breadcrumbs,
1386 // and the current tag as current_tag.
1387 let bc: Vec<TagBreadcrumb> = ancestors
1388 .iter()
1389 .filter(|a| a.id != pt.id)
1390 .map(|a| TagBreadcrumb {
1391 name: a.name.clone(),
1392 slug: a.slug.clone(),
1393 })
1394 .collect();
1395 let ct = TagBreadcrumb {
1396 name: pt.name.clone(),
1397 slug: pt.slug.clone(),
1398 };
1399 (bc, Some(ct))
1400 } else {
1401 (vec![], None)
1402 };
1403
1404 Ok(TagTreeTemplate {
1405 csrf_token,
1406 session_user: maybe_user,
1407 categories,
1408 breadcrumbs,
1409 current_tag,
1410 })
1411 }
1412
1413 /// Render the discover page with filterable, paginated items or projects.
1414 #[tracing::instrument(skip_all, name = "discover::discover")]
1415 pub(super) async fn discover(
1416 State(db): State<PgPool>,
1417 session: Session,
1418 MaybeUserUnverified(maybe_user): MaybeUserUnverified,
1419 ValidatedExtraQuery(query): ValidatedExtraQuery<DiscoverQuery>,
1420 ) -> Result<impl IntoResponse> {
1421 let csrf_token = get_csrf_token(&session).await;
1422 let data = fetch_discover_data(&db, &query).await?;
1423 let sidebar = build_sidebar(&db, &query, &data, maybe_user.as_ref().map(|u| u.id)).await?;
1424 let is_authenticated = maybe_user.is_some();
1425
1426 Ok(DiscoverTemplate {
1427 csrf_token,
1428 session_user: maybe_user,
1429 items: data.items,
1430 projects: data.projects,
1431 mode: data.mode,
1432 total_items: data.total_count,
1433 current_page: data.current_page,
1434 total_pages: data.total_pages,
1435 search_query: query.q.clone().unwrap_or_default(),
1436 is_search: data.is_search,
1437 count_label: data.count_label,
1438 sort_by: query.sort.clone().unwrap_or_default(),
1439 pagination_range: data.pagination_range,
1440 showing_start: data.showing_start,
1441 showing_end: data.showing_end,
1442 sidebar,
1443 is_authenticated,
1444 oob_sidebar: false,
1445 })
1446 }
1447
1448 /// Return discover results as an HTMX partial for filtering and pagination.
1449 #[tracing::instrument(skip_all, name = "discover::discover_results")]
1450 pub(super) async fn discover_results(
1451 State(db): State<PgPool>,
1452 MaybeUserUnverified(maybe_user): MaybeUserUnverified,
1453 headers: HeaderMap,
1454 ValidatedExtraQuery(query): ValidatedExtraQuery<DiscoverQuery>,
1455 ) -> Result<impl IntoResponse> {
1456 let data = fetch_discover_data(&db, &query).await?;
1457 // The sidebar rides along on every results request and is swapped
1458 // out-of-band, so its counts always describe the results beside them.
1459 let sidebar = build_sidebar(&db, &query, &data, maybe_user.as_ref().map(|u| u.id)).await?;
1460
1461 // Mirror the filter state into the address bar so a filtered view can be
1462 // shared, bookmarked, and reloaded. A discrete filter click pushes a history
1463 // entry, so Back undoes one filter; the search box fires on a debounce while
1464 // typing, so it replaces instead of burying the page under one entry per
1465 // keystroke pause. HX-Source names the element that fired, as `tag#id`;
1466 // htmx 4 renamed the header from HX-Trigger and widened the value, which
1467 // used to be the bare id.
1468 let history_header = match headers.get("HX-Source").and_then(|v| v.to_str().ok()) {
1469 Some("input#search-input") => "HX-Replace-Url",
1470 _ => "HX-Push-Url",
1471 };
1472 let page_url = query.to_page_url();
1473
1474 Ok((
1475 [(history_header, page_url)],
1476 DiscoverResultsTemplate {
1477 items: data.items,
1478 projects: data.projects,
1479 mode: data.mode,
1480 total_items: data.total_count,
1481 current_page: data.current_page,
1482 total_pages: data.total_pages,
1483 pagination_range: data.pagination_range,
1484 showing_start: data.showing_start,
1485 showing_end: data.showing_end,
1486 current_category: query.category.clone().unwrap_or_default(),
1487 is_search: data.is_search,
1488 count_label: data.count_label,
1489 is_authenticated: maybe_user.is_some(),
1490 sidebar,
1491 oob_sidebar: true,
1492 },
1493 ))
1494 }
1495
1496 /// Query parameters for search suggestions.
1497 #[derive(Debug, Deserialize)]
1498 pub(super) struct SuggestionsQuery {
1499 pub q: Option<String>,
1500 }
1501
1502 /// JSON response for a search suggestion.
1503 #[derive(Debug, Serialize)]
1504 pub(super) struct SearchSuggestion {
1505 pub label: String,
1506 pub category: String,
1507 pub url: String,
1508 }
1509
1510 /// Return search suggestions (tags, projects, creators) as JSON.
1511 #[tracing::instrument(skip_all, name = "discover::search_suggestions")]
1512 pub(super) async fn search_suggestions_handler(
1513 State(db): State<PgPool>,
1514 ValidatedExtraQuery(query): ValidatedExtraQuery<SuggestionsQuery>,
1515 ) -> Result<impl IntoResponse> {
1516 let q = query.q.unwrap_or_default();
1517 let rows = db::discover::search_suggestions(&db, &q).await?;
1518 let suggestions: Vec<SearchSuggestion> = rows
1519 .into_iter()
1520 .map(|r| SearchSuggestion {
1521 label: r.label,
1522 category: r.category,
1523 url: r.url,
1524 })
1525 .collect();
1526 Ok(Json(suggestions))
1527 }
1528