Skip to main content

max / makenotwork

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