Skip to main content

max / makenotwork

19.6 KB · 567 lines History Blame Raw
1 //! Discover/search page with filterable, paginated items and projects.
2
3 use axum::extract::{Query, State};
4 use axum::response::IntoResponse;
5 use axum::Json;
6 use serde::{Deserialize, Serialize};
7 use sqlx::PgPool;
8 use tower_sessions::Session;
9
10 use crate::{
11 auth::MaybeUserUnverified,
12 constants,
13 db::{self, discover::DiscoverFilters, DiscoverSort, ItemType},
14 error::Result,
15 helpers::get_csrf_token,
16 templates::*,
17 types::*,
18 AppState,
19 };
20
21 /// Deserialize an empty string as `None` instead of failing to parse.
22 ///
23 /// HTML form inputs send `field=` (empty string) when blank, which fails
24 /// serde's default `Option<i32>` parsing. This treats `""` as `None`.
25 fn empty_string_as_none<'de, D, T>(deserializer: D) -> std::result::Result<Option<T>, D::Error>
26 where
27 D: serde::Deserializer<'de>,
28 T: std::str::FromStr,
29 T::Err: std::fmt::Display,
30 {
31 let opt = Option::<String>::deserialize(deserializer)?;
32 match opt {
33 None => Ok(None),
34 Some(s) if s.is_empty() => Ok(None),
35 Some(s) => s.parse::<T>().map(Some).map_err(serde::de::Error::custom),
36 }
37 }
38
39 /// Query parameters for the discover/search page.
40 #[derive(Debug, Deserialize)]
41 pub struct DiscoverQuery {
42 pub q: Option<String>,
43 pub item_type: Option<String>,
44 pub tag: Option<String>,
45 pub category: Option<String>,
46 #[serde(default, deserialize_with = "empty_string_as_none")]
47 pub min_price: Option<i32>,
48 #[serde(default, deserialize_with = "empty_string_as_none")]
49 pub max_price: Option<i32>,
50 pub sort: Option<String>,
51 #[serde(default, deserialize_with = "empty_string_as_none")]
52 pub page: Option<u32>,
53 pub mode: Option<String>, // "items" (default) or "projects"
54 pub ai_tier: Option<String>,
55 pub has_source: Option<String>,
56 }
57
58 /// Build a sliding window of page numbers for pagination controls.
59 fn build_pagination_range(current_page: u32, total_pages: u32) -> Vec<u32> {
60 if total_pages <= constants::PAGINATION_WINDOW_SIZE {
61 (1..=total_pages).collect()
62 } else {
63 let start = current_page.saturating_sub(2).max(1);
64 let end = (start + 4).min(total_pages);
65 let start = end.saturating_sub(4).max(1);
66 (start..=end).collect()
67 }
68 }
69
70 /// Shared result data for both the full discover page and the HTMX partial.
71 struct DiscoverData {
72 items: Vec<DiscoverItem>,
73 projects: Vec<DiscoverProject>,
74 mode: String,
75 total_count: u32,
76 current_page: u32,
77 total_pages: u32,
78 pagination_range: Vec<u32>,
79 showing_start: u32,
80 showing_end: u32,
81 }
82
83 /// Fetch items or projects with pagination; shared by both handlers.
84 async fn fetch_discover_data(pool: &PgPool, query: &DiscoverQuery) -> Result<DiscoverData> {
85 let page = query.page.unwrap_or(1).max(1);
86 let limit = constants::DISCOVER_PAGE_SIZE as i64;
87 let offset = ((page - 1) as i64) * limit;
88 let mode = query.mode.as_deref().unwrap_or("projects");
89
90 let item_type_filter: Option<ItemType> = query.item_type.as_deref()
91 .filter(|s| !s.is_empty())
92 .and_then(|s| s.parse().ok());
93 let tag_filter = query.tag.as_deref().filter(|s| !s.is_empty());
94 let search_filter = query.q.as_deref().filter(|s| !s.trim().is_empty());
95
96 let category_filter = query.category.as_deref().filter(|s| !s.is_empty());
97
98 let ai_tier_filter: Option<db::AiTierFilter> = query.ai_tier.as_deref()
99 .filter(|s| !s.is_empty())
100 .and_then(|s| s.parse().ok());
101
102 let has_source_code = query.has_source.as_deref() == Some("1");
103
104 let (items, projects, total_count) = if mode == "projects" {
105 let sort_filter: Option<DiscoverSort> = query.sort.as_deref()
106 .filter(|s| !s.is_empty())
107 .and_then(|s| s.parse().ok());
108
109 let db_projects = db::discover::discover_projects(
110 pool,
111 search_filter,
112 category_filter,
113 sort_filter,
114 has_source_code,
115 limit,
116 offset,
117 )
118 .await?;
119
120 let total = db::discover::count_discover_projects(
121 pool,
122 search_filter,
123 category_filter,
124 has_source_code,
125 )
126 .await?;
127
128 let projects: Vec<DiscoverProject> = db_projects.into_iter().map(DiscoverProject::from).collect();
129 (vec![], projects, total as u32)
130 } else {
131 let sort_filter: Option<DiscoverSort> = query.sort.as_deref()
132 .filter(|s| !s.is_empty())
133 .and_then(|s| s.parse().ok());
134
135 let filters = DiscoverFilters {
136 search: search_filter,
137 item_type: item_type_filter,
138 tag: tag_filter,
139 min_price: query.min_price,
140 max_price: query.max_price,
141 sort_by: sort_filter,
142 ai_tier: ai_tier_filter,
143 };
144
145 let db_items = db::discover::discover_items(pool, &filters, limit, offset).await?;
146 let total = db::discover::count_discover_items(pool, &filters).await?;
147
148 let items: Vec<DiscoverItem> = db_items.into_iter().map(DiscoverItem::from).collect();
149 (items, vec![], total as u32)
150 };
151
152 let total_pages = ((total_count as f64) / (limit as f64)).ceil() as u32;
153 let pagination_range = build_pagination_range(page, total_pages);
154 let result_count = if mode == "projects" {
155 projects.len() as u32
156 } else {
157 items.len() as u32
158 };
159 // Reuse the i64 `offset` (computed overflow-safe above) for the "showing
160 // X–Y" labels and saturate into u32, rather than recomputing
161 // `(page - 1) * DISCOVER_PAGE_SIZE` in u32 — which overflows for a large `?page=`.
162 let showing_start = if result_count == 0 {
163 0
164 } else {
165 offset.saturating_add(1).clamp(0, u32::MAX as i64) as u32
166 };
167 let showing_end = offset
168 .saturating_add(result_count as i64)
169 .clamp(0, u32::MAX as i64) as u32;
170
171 Ok(DiscoverData {
172 items,
173 projects,
174 mode: mode.to_string(),
175 total_count,
176 current_page: page,
177 total_pages,
178 pagination_range,
179 showing_start,
180 showing_end,
181 })
182 }
183
184 #[cfg(test)]
185 mod tests {
186 use super::*;
187
188 #[test]
189 fn pagination_small_total() {
190 assert_eq!(build_pagination_range(1, 3), vec![1, 2, 3]);
191 assert_eq!(build_pagination_range(2, 5), vec![1, 2, 3, 4, 5]);
192 }
193
194 #[test]
195 fn pagination_large_at_start() {
196 assert_eq!(build_pagination_range(1, 20), vec![1, 2, 3, 4, 5]);
197 assert_eq!(build_pagination_range(2, 20), vec![1, 2, 3, 4, 5]);
198 }
199
200 #[test]
201 fn pagination_large_at_middle() {
202 assert_eq!(build_pagination_range(10, 20), vec![8, 9, 10, 11, 12]);
203 }
204
205 #[test]
206 fn pagination_large_at_end() {
207 assert_eq!(build_pagination_range(20, 20), vec![16, 17, 18, 19, 20]);
208 assert_eq!(build_pagination_range(19, 20), vec![16, 17, 18, 19, 20]);
209 }
210
211 #[test]
212 fn pagination_zero_pages() {
213 assert_eq!(build_pagination_range(1, 0), Vec::<u32>::new());
214 }
215
216 #[test]
217 fn pagination_single_page() {
218 assert_eq!(build_pagination_range(1, 1), vec![1]);
219 }
220 }
221
222 /// Query parameters for the tag tree browser.
223 #[derive(Debug, Deserialize)]
224 pub struct TagTreeQuery {
225 pub parent: Option<String>,
226 }
227
228 /// Browse the tag hierarchy with breadcrumb navigation.
229 #[tracing::instrument(skip_all, name = "discover::tag_tree")]
230 pub(super) async fn tag_tree(
231 State(state): State<AppState>,
232 session: Session,
233 MaybeUserUnverified(maybe_user): MaybeUserUnverified,
234 Query(query): Query<TagTreeQuery>,
235 ) -> Result<impl IntoResponse> {
236 let csrf_token = get_csrf_token(&session).await;
237
238 // Resolve parent tag from ?parent=slug (dot-notation, e.g. "audio.genre")
239 let parent_tag = if let Some(ref slug) = query.parent {
240 db::tags::get_tag_by_slug(&state.db, slug).await?
241 } else {
242 None
243 };
244
245 let parent_id = parent_tag.as_ref().map(|t| t.id);
246
247 // Fetch children at this level
248 let children = db::tags::get_child_tags(&state.db, parent_id).await?;
249
250 // Fetch item counts for all tags
251 let tag_counts = db::tags::get_all_tag_counts(&state.db).await?;
252
253 // Batch-fetch child counts for all children (single query instead of N+1)
254 let child_ids: Vec<_> = children.iter().map(|c| c.id).collect();
255 let grandchild_counts = db::tags::count_children_by_parents(&state.db, &child_ids).await?;
256
257 let categories: Vec<TagTreeNode> = children.iter().map(|child| {
258 TagTreeNode {
259 name: child.name.clone(),
260 slug: child.slug.to_string(),
261 item_count: *tag_counts.get(&child.id).unwrap_or(&0) as u32,
262 child_count: *grandchild_counts.get(&child.id).unwrap_or(&0) as usize,
263 }
264 }).collect();
265
266 // Build breadcrumbs from ancestor chain
267 let (breadcrumbs, current_tag) = if let Some(ref pt) = parent_tag {
268 let ancestors = db::tags::get_tag_ancestors(&state.db, pt.id).await?;
269 // ancestors includes the tag itself as the last element.
270 // We want all ancestors except the current tag as breadcrumbs,
271 // and the current tag as current_tag.
272 let bc: Vec<TagBreadcrumb> = ancestors
273 .iter()
274 .filter(|a| a.id != pt.id)
275 .map(|a| TagBreadcrumb {
276 name: a.name.clone(),
277 slug: a.slug.to_string(),
278 })
279 .collect();
280 let ct = TagBreadcrumb {
281 name: pt.name.clone(),
282 slug: pt.slug.to_string(),
283 };
284 (bc, Some(ct))
285 } else {
286 (vec![], None)
287 };
288
289 Ok(TagTreeTemplate {
290 csrf_token,
291 session_user: maybe_user,
292 categories,
293 breadcrumbs,
294 current_tag,
295 })
296 }
297
298 /// Render the discover page with filterable, paginated items or projects.
299 #[tracing::instrument(skip_all, name = "discover::discover")]
300 pub(super) async fn discover(
301 State(state): State<AppState>,
302 session: Session,
303 MaybeUserUnverified(maybe_user): MaybeUserUnverified,
304 Query(query): Query<DiscoverQuery>,
305 ) -> Result<impl IntoResponse> {
306 let csrf_token = get_csrf_token(&session).await;
307 let search_filter = query.q.as_deref().filter(|s| !s.trim().is_empty());
308 let tag_filter = query.tag.as_deref().filter(|s| !s.is_empty());
309 let item_type_filter: Option<ItemType> = query.item_type.as_deref()
310 .filter(|s| !s.is_empty())
311 .and_then(|s| s.parse().ok());
312 let has_source_code = query.has_source.as_deref() == Some("1");
313 let data = fetch_discover_data(&state.db, &query).await?;
314
315 // Build type and tag filters (items mode only)
316 let category_filter = query.category.as_deref().filter(|s| !s.is_empty());
317
318 // Build category filters (projects mode only)
319 let category_filters = if data.mode == "projects" {
320 let cat_counts = db::categories::get_category_counts(
321 &state.db,
322 search_filter,
323 )
324 .await?;
325
326 let mut filters: Vec<FilterCategory> = vec![FilterCategory {
327 name: "All".to_string(),
328 value: String::new(),
329 count: data.total_count,
330 active: category_filter.is_none(),
331 id: String::new(),
332 following: false,
333 }];
334 for cc in cat_counts {
335 filters.push(FilterCategory {
336 name: cc.name,
337 value: cc.slug.to_string(),
338 count: cc.count as u32,
339 active: category_filter == Some(cc.slug.as_str()),
340 id: String::new(),
341 following: false,
342 });
343 }
344 filters
345 } else {
346 vec![]
347 };
348
349 let (type_filters, tag_filters, ai_tier_filters, price_counts) = if data.mode == "items" {
350 // Run all facet queries in parallel to reduce DB round-trips
351 let viewer_id = maybe_user.as_ref().map(|u| u.id);
352 let (type_counts, tag_counts, followed_tag_ids, ai_counts, price_counts) = tokio::try_join!(
353 db::discover::get_item_type_counts(
354 &state.db, search_filter, tag_filter, query.min_price, query.max_price,
355 ),
356 db::tags::get_tag_counts(&state.db, search_filter, item_type_filter),
357 async {
358 if let Some(uid) = viewer_id {
359 db::follows::get_followed_tag_ids(&state.db, uid).await
360 } else {
361 Ok(std::collections::HashSet::new())
362 }
363 },
364 db::discover::get_ai_tier_counts(
365 &state.db, search_filter, item_type_filter, tag_filter,
366 ),
367 db::discover::get_price_range_counts(
368 &state.db, search_filter, item_type_filter, tag_filter,
369 ),
370 )?;
371
372 let mut type_filters: Vec<FilterCategory> = vec![FilterCategory {
373 name: "All".to_string(),
374 value: String::new(),
375 count: data.total_count,
376 active: item_type_filter.is_none(),
377 id: String::new(),
378 following: false,
379 }];
380 for tc in type_counts {
381 let active = item_type_filter.is_some_and(|t| t.to_string() == tc.category);
382 type_filters.push(FilterCategory {
383 value: tc.category.clone(),
384 name: tc.category,
385 count: tc.count as u32,
386 active,
387 id: String::new(),
388 following: false,
389 });
390 }
391
392 let mut tag_filters: Vec<FilterCategory> = vec![FilterCategory {
393 name: "All".to_string(),
394 value: String::new(),
395 count: data.total_count,
396 active: tag_filter.is_none(),
397 id: String::new(),
398 following: false,
399 }];
400 for tc in tag_counts.iter().take(10) {
401 tag_filters.push(FilterCategory {
402 name: tc.tag_name.clone(),
403 value: tc.tag_slug.to_string(),
404 count: tc.count as u32,
405 active: tag_filter == Some(tc.tag_slug.as_str()),
406 id: tc.tag_id.to_string(),
407 following: followed_tag_ids.contains(&tc.tag_id),
408 });
409 }
410
411 // Per `about/generative-ai.md` § "How Fans Use This", the three
412 // filter options are "Everything" / "Human-led" (Handmade ∪
413 // Assisted) / "Handmade only". Aggregate the per-tier counts
414 // into option-sized counts before passing to the template.
415 let mut handmade_count: u32 = 0;
416 let mut assisted_count: u32 = 0;
417 for ac in &ai_counts {
418 match ac.category.as_str() {
419 "handmade" => handmade_count = ac.count as u32,
420 "assisted" => assisted_count = ac.count as u32,
421 _ => {}
422 }
423 }
424 let ai_tier_filter_str = query.ai_tier.as_deref().filter(|s| !s.is_empty());
425 let ai_tier_filters: Vec<FilterCategory> = vec![
426 FilterCategory {
427 name: "Everything".to_string(),
428 value: String::new(),
429 count: data.total_count,
430 active: ai_tier_filter_str.is_none(),
431 id: String::new(),
432 following: false,
433 },
434 FilterCategory {
435 name: db::AiTierFilter::HumanLed.label().to_string(),
436 value: db::AiTierFilter::HumanLed.to_string(),
437 count: handmade_count + assisted_count,
438 active: ai_tier_filter_str == Some(db::AiTierFilter::HumanLed.to_string().as_str()),
439 id: String::new(),
440 following: false,
441 },
442 FilterCategory {
443 name: db::AiTierFilter::HandmadeOnly.label().to_string(),
444 value: db::AiTierFilter::HandmadeOnly.to_string(),
445 count: handmade_count,
446 active: ai_tier_filter_str == Some(db::AiTierFilter::HandmadeOnly.to_string().as_str()),
447 id: String::new(),
448 following: false,
449 },
450 ];
451
452 (type_filters, tag_filters, ai_tier_filters, price_counts)
453 } else {
454 (vec![], vec![], vec![], db::DbPriceRangeCounts::default())
455 };
456
457 let price_filters = vec![
458 PriceFilter { label: "Free".to_string(), count: price_counts.free as u32 },
459 PriceFilter { label: "Under $25".to_string(), count: price_counts.under_25 as u32 },
460 PriceFilter { label: "$25-50".to_string(), count: price_counts.range_25_50 as u32 },
461 PriceFilter { label: "$50-100".to_string(), count: price_counts.range_50_100 as u32 },
462 PriceFilter { label: "$100+".to_string(), count: price_counts.over_100 as u32 },
463 ];
464
465 let current_type = query.item_type.unwrap_or_default();
466 let current_tag = query.tag.unwrap_or_default();
467 let current_category = query.category.unwrap_or_default();
468 let current_ai_tier = query.ai_tier.unwrap_or_default();
469
470 let active_filter_count = [
471 !current_type.is_empty(),
472 !current_tag.is_empty(),
473 !current_category.is_empty(),
474 !current_ai_tier.is_empty(),
475 has_source_code,
476 query.min_price.is_some(),
477 query.max_price.is_some(),
478 ].iter().filter(|&&v| v).count() as u32;
479
480 let is_authenticated = maybe_user.is_some();
481
482 Ok(DiscoverTemplate {
483 csrf_token,
484 session_user: maybe_user,
485 items: data.items,
486 projects: data.projects,
487 mode: data.mode,
488 type_filters,
489 tag_filters,
490 category_filters,
491 price_filters,
492 total_items: data.total_count,
493 current_page: data.current_page,
494 total_pages: data.total_pages,
495 search_query: query.q.unwrap_or_default(),
496 sort_by: query.sort.unwrap_or_default(),
497 current_type,
498 current_tag,
499 current_category,
500 pagination_range: data.pagination_range,
501 showing_start: data.showing_start,
502 showing_end: data.showing_end,
503 ai_tier_filters,
504 current_ai_tier,
505 has_source: has_source_code,
506 active_filter_count,
507 is_authenticated,
508 })
509 }
510
511 /// Return discover results as an HTMX partial for filtering and pagination.
512 #[tracing::instrument(skip_all, name = "discover::discover_results")]
513 pub(super) async fn discover_results(
514 State(state): State<AppState>,
515 MaybeUserUnverified(maybe_user): MaybeUserUnverified,
516 Query(query): Query<DiscoverQuery>,
517 ) -> Result<impl IntoResponse> {
518 let data = fetch_discover_data(&state.db, &query).await?;
519
520 Ok(DiscoverResultsTemplate {
521 items: data.items,
522 projects: data.projects,
523 mode: data.mode,
524 total_items: data.total_count,
525 current_page: data.current_page,
526 total_pages: data.total_pages,
527 pagination_range: data.pagination_range,
528 showing_start: data.showing_start,
529 showing_end: data.showing_end,
530 current_category: query.category.unwrap_or_default(),
531 is_authenticated: maybe_user.is_some(),
532 })
533 }
534
535 /// Query parameters for search suggestions.
536 #[derive(Debug, Deserialize)]
537 pub struct SuggestionsQuery {
538 pub q: Option<String>,
539 }
540
541 /// JSON response for a search suggestion.
542 #[derive(Debug, Serialize)]
543 pub struct SearchSuggestion {
544 pub label: String,
545 pub category: String,
546 pub url: String,
547 }
548
549 /// Return search suggestions (tags, projects, creators) as JSON.
550 #[tracing::instrument(skip_all, name = "discover::search_suggestions")]
551 pub(super) async fn search_suggestions_handler(
552 State(state): State<AppState>,
553 Query(query): Query<SuggestionsQuery>,
554 ) -> Result<impl IntoResponse> {
555 let q = query.q.unwrap_or_default();
556 let rows = db::discover::search_suggestions(&state.db, &q).await?;
557 let suggestions: Vec<SearchSuggestion> = rows
558 .into_iter()
559 .map(|r| SearchSuggestion {
560 label: r.label,
561 category: r.category,
562 url: r.url,
563 })
564 .collect();
565 Ok(Json(suggestions))
566 }
567