Skip to main content

max / multithreaded

1.8 KB · 60 lines History Blame Raw
1 //! Search handler — full-text fuzzy search returning HTMX fragments.
2
3 use axum::{
4 extract::Query,
5 http::StatusCode,
6 response::{IntoResponse, Response},
7 };
8 use serde::Deserialize;
9
10 use crate::templates::*;
11 use crate::AppState;
12
13 #[derive(Deserialize)]
14 pub(super) struct SearchQuery {
15 pub(super) q: Option<String>,
16 pub(super) scope: Option<String>,
17 }
18
19 /// GET /search?q=...&scope=... — returns HTML fragment for HTMX swap.
20 #[tracing::instrument(skip_all)]
21 pub(super) async fn search_handler(
22 axum::extract::State(state): axum::extract::State<AppState>,
23 Query(query): Query<SearchQuery>,
24 ) -> Result<impl IntoResponse, Response> {
25 let q = query.q.as_deref().unwrap_or("").trim();
26
27 if q.is_empty() || q.len() < 2 {
28 return Ok(SearchResultsFragment { results: vec![] });
29 }
30
31 // Sanitize: limit length
32 let q = if q.len() > 200 { &q[..200] } else { q };
33
34 let scope = query.scope.as_deref().filter(|s| !s.is_empty());
35
36 let db_results = mt_db::queries::search_threads(&state.db, q, scope, 20)
37 .await
38 .map_err(|e| {
39 tracing::error!(error = ?e, "db error searching");
40 StatusCode::INTERNAL_SERVER_ERROR.into_response()
41 })?;
42
43 let results = db_results
44 .into_iter()
45 .map(|r| SearchResultViewRow {
46 thread_id: r.thread_id.to_string(),
47 thread_title: r.thread_title,
48 author_username: r.author_username,
49 community_name: r.community_name,
50 community_slug: r.community_slug,
51 category_name: r.category_name,
52 category_slug: r.category_slug,
53 snippet: r.snippet,
54 last_activity: mt_core::time_format::relative_timestamp(r.last_activity_at),
55 })
56 .collect();
57
58 Ok(SearchResultsFragment { results })
59 }
60