//! Discover/search page with filterable, paginated items and projects. use axum::extract::{Query, State}; use axum::response::IntoResponse; use axum::Json; use serde::{Deserialize, Serialize}; use sqlx::PgPool; use tower_sessions::Session; use crate::{ auth::MaybeUserUnverified, constants, db::{self, discover::DiscoverFilters, DiscoverSort, ItemType}, error::Result, helpers::get_csrf_token, templates::*, types::*, AppState, }; /// Deserialize an empty string as `None` instead of failing to parse. /// /// HTML form inputs send `field=` (empty string) when blank, which fails /// serde's default `Option` parsing. This treats `""` as `None`. fn empty_string_as_none<'de, D, T>(deserializer: D) -> std::result::Result, D::Error> where D: serde::Deserializer<'de>, T: std::str::FromStr, T::Err: std::fmt::Display, { let opt = Option::::deserialize(deserializer)?; match opt { None => Ok(None), Some(s) if s.is_empty() => Ok(None), Some(s) => s.parse::().map(Some).map_err(serde::de::Error::custom), } } /// Query parameters for the discover/search page. #[derive(Debug, Deserialize)] pub struct DiscoverQuery { pub q: Option, pub item_type: Option, pub tag: Option, pub category: Option, #[serde(default, deserialize_with = "empty_string_as_none")] pub min_price: Option, #[serde(default, deserialize_with = "empty_string_as_none")] pub max_price: Option, pub sort: Option, #[serde(default, deserialize_with = "empty_string_as_none")] pub page: Option, pub mode: Option, // "items" (default) or "projects" pub ai_tier: Option, pub has_source: Option, } /// Build a sliding window of page numbers for pagination controls. fn build_pagination_range(current_page: u32, total_pages: u32) -> Vec { if total_pages <= constants::PAGINATION_WINDOW_SIZE { (1..=total_pages).collect() } else { let start = current_page.saturating_sub(2).max(1); let end = (start + 4).min(total_pages); let start = end.saturating_sub(4).max(1); (start..=end).collect() } } /// Shared result data for both the full discover page and the HTMX partial. struct DiscoverData { items: Vec, projects: Vec, mode: String, total_count: u32, current_page: u32, total_pages: u32, pagination_range: Vec, showing_start: u32, showing_end: u32, } /// Fetch items or projects with pagination; shared by both handlers. async fn fetch_discover_data(pool: &PgPool, query: &DiscoverQuery) -> Result { let page = query.page.unwrap_or(1).max(1); let limit = constants::DISCOVER_PAGE_SIZE as i64; let offset = ((page - 1) as i64) * limit; let mode = query.mode.as_deref().unwrap_or("projects"); let item_type_filter: Option = query.item_type.as_deref() .filter(|s| !s.is_empty()) .and_then(|s| s.parse().ok()); let tag_filter = query.tag.as_deref().filter(|s| !s.is_empty()); let search_filter = query.q.as_deref().filter(|s| !s.trim().is_empty()); let category_filter = query.category.as_deref().filter(|s| !s.is_empty()); let ai_tier_filter: Option = query.ai_tier.as_deref() .filter(|s| !s.is_empty()) .and_then(|s| s.parse().ok()); let has_source_code = query.has_source.as_deref() == Some("1"); let (items, projects, total_count) = if mode == "projects" { let sort_filter: Option = query.sort.as_deref() .filter(|s| !s.is_empty()) .and_then(|s| s.parse().ok()); let db_projects = db::discover::discover_projects( pool, search_filter, category_filter, sort_filter, has_source_code, limit, offset, ) .await?; let total = db::discover::count_discover_projects( pool, search_filter, category_filter, has_source_code, ) .await?; let projects: Vec = db_projects.into_iter().map(DiscoverProject::from).collect(); (vec![], projects, total as u32) } else { let sort_filter: Option = query.sort.as_deref() .filter(|s| !s.is_empty()) .and_then(|s| s.parse().ok()); let filters = DiscoverFilters { search: search_filter, item_type: item_type_filter, tag: tag_filter, min_price: query.min_price, max_price: query.max_price, sort_by: sort_filter, ai_tier: ai_tier_filter, }; let db_items = db::discover::discover_items(pool, &filters, limit, offset).await?; let total = db::discover::count_discover_items(pool, &filters).await?; let items: Vec = db_items.into_iter().map(DiscoverItem::from).collect(); (items, vec![], total as u32) }; let total_pages = ((total_count as f64) / (limit as f64)).ceil() as u32; let pagination_range = build_pagination_range(page, total_pages); let result_count = if mode == "projects" { projects.len() as u32 } else { items.len() as u32 }; // Reuse the i64 `offset` (computed overflow-safe above) for the "showing // X–Y" labels and saturate into u32, rather than recomputing // `(page - 1) * DISCOVER_PAGE_SIZE` in u32 — which overflows for a large `?page=`. let showing_start = if result_count == 0 { 0 } else { offset.saturating_add(1).clamp(0, u32::MAX as i64) as u32 }; let showing_end = offset .saturating_add(result_count as i64) .clamp(0, u32::MAX as i64) as u32; Ok(DiscoverData { items, projects, mode: mode.to_string(), total_count, current_page: page, total_pages, pagination_range, showing_start, showing_end, }) } #[cfg(test)] mod tests { use super::*; #[test] fn pagination_small_total() { assert_eq!(build_pagination_range(1, 3), vec![1, 2, 3]); assert_eq!(build_pagination_range(2, 5), vec![1, 2, 3, 4, 5]); } #[test] fn pagination_large_at_start() { assert_eq!(build_pagination_range(1, 20), vec![1, 2, 3, 4, 5]); assert_eq!(build_pagination_range(2, 20), vec![1, 2, 3, 4, 5]); } #[test] fn pagination_large_at_middle() { assert_eq!(build_pagination_range(10, 20), vec![8, 9, 10, 11, 12]); } #[test] fn pagination_large_at_end() { assert_eq!(build_pagination_range(20, 20), vec![16, 17, 18, 19, 20]); assert_eq!(build_pagination_range(19, 20), vec![16, 17, 18, 19, 20]); } #[test] fn pagination_zero_pages() { assert_eq!(build_pagination_range(1, 0), Vec::::new()); } #[test] fn pagination_single_page() { assert_eq!(build_pagination_range(1, 1), vec![1]); } } /// Query parameters for the tag tree browser. #[derive(Debug, Deserialize)] pub struct TagTreeQuery { pub parent: Option, } /// Browse the tag hierarchy with breadcrumb navigation. #[tracing::instrument(skip_all, name = "discover::tag_tree")] pub(super) async fn tag_tree( State(state): State, session: Session, MaybeUserUnverified(maybe_user): MaybeUserUnverified, Query(query): Query, ) -> Result { let csrf_token = get_csrf_token(&session).await; // Resolve parent tag from ?parent=slug (dot-notation, e.g. "audio.genre") let parent_tag = if let Some(ref slug) = query.parent { db::tags::get_tag_by_slug(&state.db, slug).await? } else { None }; let parent_id = parent_tag.as_ref().map(|t| t.id); // Fetch children at this level let children = db::tags::get_child_tags(&state.db, parent_id).await?; // Fetch item counts for all tags let tag_counts = db::tags::get_all_tag_counts(&state.db).await?; // Batch-fetch child counts for all children (single query instead of N+1) let child_ids: Vec<_> = children.iter().map(|c| c.id).collect(); let grandchild_counts = db::tags::count_children_by_parents(&state.db, &child_ids).await?; let categories: Vec = children.iter().map(|child| { TagTreeNode { name: child.name.clone(), slug: child.slug.to_string(), item_count: *tag_counts.get(&child.id).unwrap_or(&0) as u32, child_count: *grandchild_counts.get(&child.id).unwrap_or(&0) as usize, } }).collect(); // Build breadcrumbs from ancestor chain let (breadcrumbs, current_tag) = if let Some(ref pt) = parent_tag { let ancestors = db::tags::get_tag_ancestors(&state.db, pt.id).await?; // ancestors includes the tag itself as the last element. // We want all ancestors except the current tag as breadcrumbs, // and the current tag as current_tag. let bc: Vec = ancestors .iter() .filter(|a| a.id != pt.id) .map(|a| TagBreadcrumb { name: a.name.clone(), slug: a.slug.to_string(), }) .collect(); let ct = TagBreadcrumb { name: pt.name.clone(), slug: pt.slug.to_string(), }; (bc, Some(ct)) } else { (vec![], None) }; Ok(TagTreeTemplate { csrf_token, session_user: maybe_user, categories, breadcrumbs, current_tag, }) } /// Render the discover page with filterable, paginated items or projects. #[tracing::instrument(skip_all, name = "discover::discover")] pub(super) async fn discover( State(state): State, session: Session, MaybeUserUnverified(maybe_user): MaybeUserUnverified, Query(query): Query, ) -> Result { let csrf_token = get_csrf_token(&session).await; let search_filter = query.q.as_deref().filter(|s| !s.trim().is_empty()); let tag_filter = query.tag.as_deref().filter(|s| !s.is_empty()); let item_type_filter: Option = query.item_type.as_deref() .filter(|s| !s.is_empty()) .and_then(|s| s.parse().ok()); let has_source_code = query.has_source.as_deref() == Some("1"); let data = fetch_discover_data(&state.db, &query).await?; // Build type and tag filters (items mode only) let category_filter = query.category.as_deref().filter(|s| !s.is_empty()); // Build category filters (projects mode only) let category_filters = if data.mode == "projects" { let cat_counts = db::categories::get_category_counts( &state.db, search_filter, ) .await?; let mut filters: Vec = vec![FilterCategory { name: "All".to_string(), value: String::new(), count: data.total_count, active: category_filter.is_none(), id: String::new(), following: false, }]; for cc in cat_counts { filters.push(FilterCategory { name: cc.name, value: cc.slug.to_string(), count: cc.count as u32, active: category_filter == Some(cc.slug.as_str()), id: String::new(), following: false, }); } filters } else { vec![] }; let (type_filters, tag_filters, ai_tier_filters, price_counts) = if data.mode == "items" { // Run all facet queries in parallel to reduce DB round-trips let viewer_id = maybe_user.as_ref().map(|u| u.id); let (type_counts, tag_counts, followed_tag_ids, ai_counts, price_counts) = tokio::try_join!( db::discover::get_item_type_counts( &state.db, search_filter, tag_filter, query.min_price, query.max_price, ), db::tags::get_tag_counts(&state.db, search_filter, item_type_filter), async { if let Some(uid) = viewer_id { db::follows::get_followed_tag_ids(&state.db, uid).await } else { Ok(std::collections::HashSet::new()) } }, db::discover::get_ai_tier_counts( &state.db, search_filter, item_type_filter, tag_filter, ), db::discover::get_price_range_counts( &state.db, search_filter, item_type_filter, tag_filter, ), )?; let mut type_filters: Vec = vec![FilterCategory { name: "All".to_string(), value: String::new(), count: data.total_count, active: item_type_filter.is_none(), id: String::new(), following: false, }]; for tc in type_counts { let active = item_type_filter.is_some_and(|t| t.to_string() == tc.category); type_filters.push(FilterCategory { value: tc.category.clone(), name: tc.category, count: tc.count as u32, active, id: String::new(), following: false, }); } let mut tag_filters: Vec = vec![FilterCategory { name: "All".to_string(), value: String::new(), count: data.total_count, active: tag_filter.is_none(), id: String::new(), following: false, }]; for tc in tag_counts.iter().take(10) { tag_filters.push(FilterCategory { name: tc.tag_name.clone(), value: tc.tag_slug.to_string(), count: tc.count as u32, active: tag_filter == Some(tc.tag_slug.as_str()), id: tc.tag_id.to_string(), following: followed_tag_ids.contains(&tc.tag_id), }); } // Per `about/generative-ai.md` § "How Fans Use This", the three // filter options are "Everything" / "Human-led" (Handmade ∪ // Assisted) / "Handmade only". Aggregate the per-tier counts // into option-sized counts before passing to the template. let mut handmade_count: u32 = 0; let mut assisted_count: u32 = 0; for ac in &ai_counts { match ac.category.as_str() { "handmade" => handmade_count = ac.count as u32, "assisted" => assisted_count = ac.count as u32, _ => {} } } let ai_tier_filter_str = query.ai_tier.as_deref().filter(|s| !s.is_empty()); let ai_tier_filters: Vec = vec![ FilterCategory { name: "Everything".to_string(), value: String::new(), count: data.total_count, active: ai_tier_filter_str.is_none(), id: String::new(), following: false, }, FilterCategory { name: db::AiTierFilter::HumanLed.label().to_string(), value: db::AiTierFilter::HumanLed.to_string(), count: handmade_count + assisted_count, active: ai_tier_filter_str == Some(db::AiTierFilter::HumanLed.to_string().as_str()), id: String::new(), following: false, }, FilterCategory { name: db::AiTierFilter::HandmadeOnly.label().to_string(), value: db::AiTierFilter::HandmadeOnly.to_string(), count: handmade_count, active: ai_tier_filter_str == Some(db::AiTierFilter::HandmadeOnly.to_string().as_str()), id: String::new(), following: false, }, ]; (type_filters, tag_filters, ai_tier_filters, price_counts) } else { (vec![], vec![], vec![], db::DbPriceRangeCounts::default()) }; let price_filters = vec![ PriceFilter { label: "Free".to_string(), count: price_counts.free as u32 }, PriceFilter { label: "Under $25".to_string(), count: price_counts.under_25 as u32 }, PriceFilter { label: "$25-50".to_string(), count: price_counts.range_25_50 as u32 }, PriceFilter { label: "$50-100".to_string(), count: price_counts.range_50_100 as u32 }, PriceFilter { label: "$100+".to_string(), count: price_counts.over_100 as u32 }, ]; let current_type = query.item_type.unwrap_or_default(); let current_tag = query.tag.unwrap_or_default(); let current_category = query.category.unwrap_or_default(); let current_ai_tier = query.ai_tier.unwrap_or_default(); let active_filter_count = [ !current_type.is_empty(), !current_tag.is_empty(), !current_category.is_empty(), !current_ai_tier.is_empty(), has_source_code, query.min_price.is_some(), query.max_price.is_some(), ].iter().filter(|&&v| v).count() as u32; let is_authenticated = maybe_user.is_some(); Ok(DiscoverTemplate { csrf_token, session_user: maybe_user, items: data.items, projects: data.projects, mode: data.mode, type_filters, tag_filters, category_filters, price_filters, total_items: data.total_count, current_page: data.current_page, total_pages: data.total_pages, search_query: query.q.unwrap_or_default(), sort_by: query.sort.unwrap_or_default(), current_type, current_tag, current_category, pagination_range: data.pagination_range, showing_start: data.showing_start, showing_end: data.showing_end, ai_tier_filters, current_ai_tier, has_source: has_source_code, active_filter_count, is_authenticated, }) } /// Return discover results as an HTMX partial for filtering and pagination. #[tracing::instrument(skip_all, name = "discover::discover_results")] pub(super) async fn discover_results( State(state): State, MaybeUserUnverified(maybe_user): MaybeUserUnverified, Query(query): Query, ) -> Result { let data = fetch_discover_data(&state.db, &query).await?; Ok(DiscoverResultsTemplate { items: data.items, projects: data.projects, mode: data.mode, total_items: data.total_count, current_page: data.current_page, total_pages: data.total_pages, pagination_range: data.pagination_range, showing_start: data.showing_start, showing_end: data.showing_end, current_category: query.category.unwrap_or_default(), is_authenticated: maybe_user.is_some(), }) } /// Query parameters for search suggestions. #[derive(Debug, Deserialize)] pub struct SuggestionsQuery { pub q: Option, } /// JSON response for a search suggestion. #[derive(Debug, Serialize)] pub struct SearchSuggestion { pub label: String, pub category: String, pub url: String, } /// Return search suggestions (tags, projects, creators) as JSON. #[tracing::instrument(skip_all, name = "discover::search_suggestions")] pub(super) async fn search_suggestions_handler( State(state): State, Query(query): Query, ) -> Result { let q = query.q.unwrap_or_default(); let rows = db::discover::search_suggestions(&state.db, &q).await?; let suggestions: Vec = rows .into_iter() .map(|r| SearchSuggestion { label: r.label, category: r.category, url: r.url, }) .collect(); Ok(Json(suggestions)) }