Skip to main content

max / makenotwork

18.4 KB · 543 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::AiTier> = 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 let showing_start = if result_count == 0 { 0 } else { (page - 1) * constants::DISCOVER_PAGE_SIZE + 1 };
160 let showing_end = (page - 1) * constants::DISCOVER_PAGE_SIZE + result_count;
161
162 Ok(DiscoverData {
163 items,
164 projects,
165 mode: mode.to_string(),
166 total_count,
167 current_page: page,
168 total_pages,
169 pagination_range,
170 showing_start,
171 showing_end,
172 })
173 }
174
175 #[cfg(test)]
176 mod tests {
177 use super::*;
178
179 #[test]
180 fn pagination_small_total() {
181 assert_eq!(build_pagination_range(1, 3), vec![1, 2, 3]);
182 assert_eq!(build_pagination_range(2, 5), vec![1, 2, 3, 4, 5]);
183 }
184
185 #[test]
186 fn pagination_large_at_start() {
187 assert_eq!(build_pagination_range(1, 20), vec![1, 2, 3, 4, 5]);
188 assert_eq!(build_pagination_range(2, 20), vec![1, 2, 3, 4, 5]);
189 }
190
191 #[test]
192 fn pagination_large_at_middle() {
193 assert_eq!(build_pagination_range(10, 20), vec![8, 9, 10, 11, 12]);
194 }
195
196 #[test]
197 fn pagination_large_at_end() {
198 assert_eq!(build_pagination_range(20, 20), vec![16, 17, 18, 19, 20]);
199 assert_eq!(build_pagination_range(19, 20), vec![16, 17, 18, 19, 20]);
200 }
201
202 #[test]
203 fn pagination_zero_pages() {
204 assert_eq!(build_pagination_range(1, 0), Vec::<u32>::new());
205 }
206
207 #[test]
208 fn pagination_single_page() {
209 assert_eq!(build_pagination_range(1, 1), vec![1]);
210 }
211 }
212
213 /// Query parameters for the tag tree browser.
214 #[derive(Debug, Deserialize)]
215 pub struct TagTreeQuery {
216 pub parent: Option<String>,
217 }
218
219 /// Browse the tag hierarchy with breadcrumb navigation.
220 #[tracing::instrument(skip_all, name = "discover::tag_tree")]
221 pub(super) async fn tag_tree(
222 State(state): State<AppState>,
223 session: Session,
224 MaybeUserUnverified(maybe_user): MaybeUserUnverified,
225 Query(query): Query<TagTreeQuery>,
226 ) -> Result<impl IntoResponse> {
227 let csrf_token = get_csrf_token(&session).await;
228
229 // Resolve parent tag from ?parent=slug (dot-notation, e.g. "audio.genre")
230 let parent_tag = if let Some(ref slug) = query.parent {
231 db::tags::get_tag_by_slug(&state.db, slug).await?
232 } else {
233 None
234 };
235
236 let parent_id = parent_tag.as_ref().map(|t| t.id);
237
238 // Fetch children at this level
239 let children = db::tags::get_child_tags(&state.db, parent_id).await?;
240
241 // Fetch item counts for all tags
242 let tag_counts = db::tags::get_all_tag_counts(&state.db).await?;
243
244 // Batch-fetch child counts for all children (single query instead of N+1)
245 let child_ids: Vec<_> = children.iter().map(|c| c.id).collect();
246 let grandchild_counts = db::tags::count_children_by_parents(&state.db, &child_ids).await?;
247
248 let categories: Vec<TagTreeNode> = children.iter().map(|child| {
249 TagTreeNode {
250 name: child.name.clone(),
251 slug: child.slug.to_string(),
252 item_count: *tag_counts.get(&child.id).unwrap_or(&0) as u32,
253 child_count: *grandchild_counts.get(&child.id).unwrap_or(&0) as usize,
254 }
255 }).collect();
256
257 // Build breadcrumbs from ancestor chain
258 let (breadcrumbs, current_tag) = if let Some(ref pt) = parent_tag {
259 let ancestors = db::tags::get_tag_ancestors(&state.db, pt.id).await?;
260 // ancestors includes the tag itself as the last element.
261 // We want all ancestors except the current tag as breadcrumbs,
262 // and the current tag as current_tag.
263 let bc: Vec<TagBreadcrumb> = ancestors
264 .iter()
265 .filter(|a| a.id != pt.id)
266 .map(|a| TagBreadcrumb {
267 name: a.name.clone(),
268 slug: a.slug.to_string(),
269 })
270 .collect();
271 let ct = TagBreadcrumb {
272 name: pt.name.clone(),
273 slug: pt.slug.to_string(),
274 };
275 (bc, Some(ct))
276 } else {
277 (vec![], None)
278 };
279
280 Ok(TagTreeTemplate {
281 csrf_token,
282 session_user: maybe_user,
283 categories,
284 breadcrumbs,
285 current_tag,
286 })
287 }
288
289 /// Render the discover page with filterable, paginated items or projects.
290 #[tracing::instrument(skip_all, name = "discover::discover")]
291 pub(super) async fn discover(
292 State(state): State<AppState>,
293 session: Session,
294 MaybeUserUnverified(maybe_user): MaybeUserUnverified,
295 Query(query): Query<DiscoverQuery>,
296 ) -> Result<impl IntoResponse> {
297 let csrf_token = get_csrf_token(&session).await;
298 let search_filter = query.q.as_deref().filter(|s| !s.trim().is_empty());
299 let tag_filter = query.tag.as_deref().filter(|s| !s.is_empty());
300 let item_type_filter: Option<ItemType> = query.item_type.as_deref()
301 .filter(|s| !s.is_empty())
302 .and_then(|s| s.parse().ok());
303 let has_source_code = query.has_source.as_deref() == Some("1");
304 let data = fetch_discover_data(&state.db, &query).await?;
305
306 // Build type and tag filters (items mode only)
307 let category_filter = query.category.as_deref().filter(|s| !s.is_empty());
308
309 // Build category filters (projects mode only)
310 let category_filters = if data.mode == "projects" {
311 let cat_counts = db::categories::get_category_counts(
312 &state.db,
313 search_filter,
314 )
315 .await?;
316
317 let mut filters: Vec<FilterCategory> = vec![FilterCategory {
318 name: "All".to_string(),
319 value: String::new(),
320 count: data.total_count,
321 active: category_filter.is_none(),
322 id: String::new(),
323 following: false,
324 }];
325 for cc in cat_counts {
326 filters.push(FilterCategory {
327 name: cc.name,
328 value: cc.slug.to_string(),
329 count: cc.count as u32,
330 active: category_filter == Some(cc.slug.as_str()),
331 id: String::new(),
332 following: false,
333 });
334 }
335 filters
336 } else {
337 vec![]
338 };
339
340 let (type_filters, tag_filters, ai_tier_filters, price_counts) = if data.mode == "items" {
341 // Run all facet queries in parallel to reduce DB round-trips
342 let viewer_id = maybe_user.as_ref().map(|u| u.id);
343 let (type_counts, tag_counts, followed_tag_ids, ai_counts, price_counts) = tokio::try_join!(
344 db::discover::get_item_type_counts(
345 &state.db, search_filter, tag_filter, query.min_price, query.max_price,
346 ),
347 db::tags::get_tag_counts(&state.db, search_filter, item_type_filter),
348 async {
349 if let Some(uid) = viewer_id {
350 db::follows::get_followed_tag_ids(&state.db, uid).await
351 } else {
352 Ok(std::collections::HashSet::new())
353 }
354 },
355 db::discover::get_ai_tier_counts(
356 &state.db, search_filter, item_type_filter, tag_filter,
357 ),
358 db::discover::get_price_range_counts(
359 &state.db, search_filter, item_type_filter, tag_filter,
360 ),
361 )?;
362
363 let mut type_filters: Vec<FilterCategory> = vec![FilterCategory {
364 name: "All".to_string(),
365 value: String::new(),
366 count: data.total_count,
367 active: item_type_filter.is_none(),
368 id: String::new(),
369 following: false,
370 }];
371 for tc in type_counts {
372 let active = item_type_filter.is_some_and(|t| t.to_string() == tc.category);
373 type_filters.push(FilterCategory {
374 value: tc.category.clone(),
375 name: tc.category,
376 count: tc.count as u32,
377 active,
378 id: String::new(),
379 following: false,
380 });
381 }
382
383 let mut tag_filters: Vec<FilterCategory> = vec![FilterCategory {
384 name: "All".to_string(),
385 value: String::new(),
386 count: data.total_count,
387 active: tag_filter.is_none(),
388 id: String::new(),
389 following: false,
390 }];
391 for tc in tag_counts.iter().take(10) {
392 tag_filters.push(FilterCategory {
393 name: tc.tag_name.clone(),
394 value: tc.tag_slug.to_string(),
395 count: tc.count as u32,
396 active: tag_filter == Some(tc.tag_slug.as_str()),
397 id: tc.tag_id.to_string(),
398 following: followed_tag_ids.contains(&tc.tag_id),
399 });
400 }
401
402 let ai_tier_filter_str = query.ai_tier.as_deref().filter(|s| !s.is_empty());
403 let mut ai_tier_filters: Vec<FilterCategory> = vec![FilterCategory {
404 name: "All".to_string(),
405 value: String::new(),
406 count: data.total_count,
407 active: ai_tier_filter_str.is_none(),
408 id: String::new(),
409 following: false,
410 }];
411 for ac in ai_counts {
412 let label = match ac.category.as_str() {
413 "handmade" => "Handmade",
414 "assisted" => "Assisted",
415 "generated" => "Generated",
416 other => other,
417 };
418 ai_tier_filters.push(FilterCategory {
419 value: ac.category.clone(),
420 name: label.to_string(),
421 count: ac.count as u32,
422 active: ai_tier_filter_str == Some(ac.category.as_str()),
423 id: String::new(),
424 following: false,
425 });
426 }
427
428 (type_filters, tag_filters, ai_tier_filters, price_counts)
429 } else {
430 (vec![], vec![], vec![], db::DbPriceRangeCounts::default())
431 };
432
433 let price_filters = vec![
434 PriceFilter { label: "Free".to_string(), count: price_counts.free as u32 },
435 PriceFilter { label: "Under $25".to_string(), count: price_counts.under_25 as u32 },
436 PriceFilter { label: "$25-50".to_string(), count: price_counts.range_25_50 as u32 },
437 PriceFilter { label: "$50-100".to_string(), count: price_counts.range_50_100 as u32 },
438 PriceFilter { label: "$100+".to_string(), count: price_counts.over_100 as u32 },
439 ];
440
441 let current_type = query.item_type.unwrap_or_default();
442 let current_tag = query.tag.unwrap_or_default();
443 let current_category = query.category.unwrap_or_default();
444 let current_ai_tier = query.ai_tier.unwrap_or_default();
445
446 let active_filter_count = [
447 !current_type.is_empty(),
448 !current_tag.is_empty(),
449 !current_category.is_empty(),
450 !current_ai_tier.is_empty(),
451 has_source_code,
452 query.min_price.is_some(),
453 query.max_price.is_some(),
454 ].iter().filter(|&&v| v).count() as u32;
455
456 let is_authenticated = maybe_user.is_some();
457
458 Ok(DiscoverTemplate {
459 csrf_token,
460 session_user: maybe_user,
461 items: data.items,
462 projects: data.projects,
463 mode: data.mode,
464 type_filters,
465 tag_filters,
466 category_filters,
467 price_filters,
468 total_items: data.total_count,
469 current_page: data.current_page,
470 total_pages: data.total_pages,
471 search_query: query.q.unwrap_or_default(),
472 sort_by: query.sort.unwrap_or_default(),
473 current_type,
474 current_tag,
475 current_category,
476 pagination_range: data.pagination_range,
477 showing_start: data.showing_start,
478 showing_end: data.showing_end,
479 ai_tier_filters,
480 current_ai_tier,
481 has_source: has_source_code,
482 active_filter_count,
483 is_authenticated,
484 })
485 }
486
487 /// Return discover results as an HTMX partial for filtering and pagination.
488 #[tracing::instrument(skip_all, name = "discover::discover_results")]
489 pub(super) async fn discover_results(
490 State(state): State<AppState>,
491 MaybeUserUnverified(maybe_user): MaybeUserUnverified,
492 Query(query): Query<DiscoverQuery>,
493 ) -> Result<impl IntoResponse> {
494 let data = fetch_discover_data(&state.db, &query).await?;
495
496 Ok(DiscoverResultsTemplate {
497 items: data.items,
498 projects: data.projects,
499 mode: data.mode,
500 total_items: data.total_count,
501 current_page: data.current_page,
502 total_pages: data.total_pages,
503 pagination_range: data.pagination_range,
504 showing_start: data.showing_start,
505 showing_end: data.showing_end,
506 current_category: query.category.unwrap_or_default(),
507 is_authenticated: maybe_user.is_some(),
508 })
509 }
510
511 /// Query parameters for search suggestions.
512 #[derive(Debug, Deserialize)]
513 pub struct SuggestionsQuery {
514 pub q: Option<String>,
515 }
516
517 /// JSON response for a search suggestion.
518 #[derive(Debug, Serialize)]
519 pub struct SearchSuggestion {
520 pub label: String,
521 pub category: String,
522 pub url: String,
523 }
524
525 /// Return search suggestions (tags, projects, creators) as JSON.
526 #[tracing::instrument(skip_all, name = "discover::search_suggestions")]
527 pub(super) async fn search_suggestions_handler(
528 State(state): State<AppState>,
529 Query(query): Query<SuggestionsQuery>,
530 ) -> Result<impl IntoResponse> {
531 let q = query.q.unwrap_or_default();
532 let rows = db::discover::search_suggestions(&state.db, &q).await?;
533 let suggestions: Vec<SearchSuggestion> = rows
534 .into_iter()
535 .map(|r| SearchSuggestion {
536 label: r.label,
537 category: r.category,
538 url: r.url,
539 })
540 .collect();
541 Ok(Json(suggestions))
542 }
543