learn and share notes on atproto (wip) 🦉 malfestio.stormlightlabs.org/
readability solid axum atproto srs

feat: Implement full-text search API

+1 -1
crates/server/src/api/auth.rs
··· 18 18 handle: String, 19 19 } 20 20 21 - /// TODO: Make PDS URL configurable (bluesky users can use their own PDS) 21 + /// TODO: Find user's PDS URL 22 22 pub async fn login(State(state): State<SharedState>, Json(payload): Json<LoginRequest>) -> impl IntoResponse { 23 23 let client = reqwest::Client::new(); 24 24 let pds_url = &state.config.pds_url;
+4
crates/server/src/api/feed.rs
··· 66 66 as Arc<dyn crate::repository::deck::DeckRepository>; 67 67 let config = crate::state::AppConfig { pds_url: "https://bsky.social".to_string() }; 68 68 69 + let search_repo = Arc::new(crate::repository::search::mock::MockSearchRepository::new()) 70 + as Arc<dyn crate::repository::search::SearchRepository>; 71 + 69 72 let repos = crate::state::Repositories { 70 73 card: card_repo, 71 74 note: note_repo, ··· 73 76 review: review_repo, 74 77 social: social_repo, 75 78 deck: deck_repo, 79 + search: search_repo, 76 80 }; 77 81 78 82 AppState::new(pool, repos, config)
+1
crates/server/src/api/mod.rs
··· 6 6 pub mod note; 7 7 pub mod oauth; 8 8 pub mod review; 9 + pub mod search; 9 10 pub mod social;
+4
crates/server/src/api/review.rs
··· 154 154 as Arc<dyn crate::repository::deck::DeckRepository>; 155 155 let config = crate::state::AppConfig { pds_url: "https://bsky.social".to_string() }; 156 156 157 + let search_repo = Arc::new(crate::repository::search::mock::MockSearchRepository::new()) 158 + as Arc<dyn crate::repository::search::SearchRepository>; 159 + 157 160 let repos = crate::state::Repositories { 158 161 card: card_repo, 159 162 note: note_repo, ··· 161 164 review: review_repo, 162 165 social: social_repo, 163 166 deck: deck_repo, 167 + search: search_repo, 164 168 }; 165 169 166 170 AppState::new(pool, repos, config)
+156
crates/server/src/api/search.rs
··· 1 + use crate::middleware::auth::UserContext; 2 + use crate::state::SharedState; 3 + use axum::{ 4 + Json, 5 + extract::{Extension, Query, State}, 6 + http::StatusCode, 7 + response::IntoResponse, 8 + }; 9 + use serde::Deserialize; 10 + use serde_json::json; 11 + 12 + #[derive(Deserialize)] 13 + pub struct SearchQuery { 14 + q: String, 15 + #[serde(default = "default_limit")] 16 + limit: i64, 17 + #[serde(default = "default_offset")] 18 + offset: i64, 19 + } 20 + 21 + fn default_limit() -> i64 { 22 + 20 23 + } 24 + 25 + fn default_offset() -> i64 { 26 + 0 27 + } 28 + 29 + /// GET /api/search?q=... 30 + /// Search for decks, cards, and notes using full-text search 31 + /// 32 + /// TODO: filter by user 33 + pub async fn search( 34 + State(state): State<SharedState>, ctx: Option<Extension<UserContext>>, Query(query): Query<SearchQuery>, 35 + ) -> impl IntoResponse { 36 + let user_did = ctx.map(|Extension(u)| u.did); 37 + 38 + match state 39 + .search_repo 40 + .search(&query.q, query.limit, query.offset, user_did.as_deref()) 41 + .await 42 + { 43 + Ok(results) => Json(results).into_response(), 44 + Err(e) => { 45 + tracing::error!("Search failed: {:?}", e); 46 + ( 47 + StatusCode::INTERNAL_SERVER_ERROR, 48 + Json(json!({"error": "Search failed"})), 49 + ) 50 + .into_response() 51 + } 52 + } 53 + } 54 + 55 + /// GET /api/discovery 56 + /// Get discovery info like top tags 57 + pub async fn discovery(State(state): State<SharedState>) -> impl IntoResponse { 58 + match state.search_repo.get_top_tags(10).await { 59 + Ok(tags) => Json(json!({ "top_tags": tags })).into_response(), 60 + Err(e) => { 61 + tracing::error!("Discovery failed: {:?}", e); 62 + ( 63 + StatusCode::INTERNAL_SERVER_ERROR, 64 + Json(json!({"error": "Discovery failed"})), 65 + ) 66 + .into_response() 67 + } 68 + } 69 + } 70 + 71 + #[cfg(test)] 72 + mod tests { 73 + use super::*; 74 + use crate::repository::card::mock::MockCardRepository; 75 + use crate::repository::deck::mock::MockDeckRepository; 76 + use crate::repository::note::mock::MockNoteRepository; 77 + use crate::repository::oauth::mock::MockOAuthRepository; 78 + use crate::repository::review::mock::MockReviewRepository; 79 + use crate::repository::search::mock::MockSearchRepository; 80 + use crate::repository::search::{SearchRepository, SearchResult}; 81 + use crate::repository::social::mock::MockSocialRepository; 82 + use crate::state::AppState; 83 + use std::sync::Arc; 84 + 85 + fn create_test_state_with_search(search_repo: Arc<MockSearchRepository>) -> SharedState { 86 + let pool = crate::db::create_mock_pool(); 87 + let card_repo = Arc::new(MockCardRepository::new()) as Arc<dyn crate::repository::card::CardRepository>; 88 + let note_repo = Arc::new(MockNoteRepository::new()) as Arc<dyn crate::repository::note::NoteRepository>; 89 + let oauth_repo = Arc::new(MockOAuthRepository::new()) as Arc<dyn crate::repository::oauth::OAuthRepository>; 90 + let review_repo = Arc::new(MockReviewRepository::new()) as Arc<dyn crate::repository::review::ReviewRepository>; 91 + let social_repo = Arc::new(MockSocialRepository::new()) as Arc<dyn crate::repository::social::SocialRepository>; 92 + let deck_repo = Arc::new(MockDeckRepository::new()) as Arc<dyn crate::repository::deck::DeckRepository>; 93 + 94 + let config = crate::state::AppConfig { pds_url: "https://bsky.social".to_string() }; 95 + let auth_cache = Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new())); 96 + let search_repo_trait = search_repo.clone() as Arc<dyn SearchRepository>; 97 + 98 + Arc::new(AppState { 99 + pool, 100 + card_repo, 101 + note_repo, 102 + oauth_repo, 103 + review_repo, 104 + social_repo, 105 + deck_repo, 106 + search_repo: search_repo_trait, 107 + config, 108 + auth_cache, 109 + }) 110 + } 111 + 112 + #[tokio::test] 113 + async fn test_search_handler_passes_viewer_did() { 114 + let search_repo = Arc::new(MockSearchRepository::new()); 115 + search_repo 116 + .add_result(SearchResult { 117 + item_type: "deck".to_string(), 118 + item_id: "private-deck".to_string(), 119 + creator_did: "did:alice".to_string(), 120 + data: serde_json::json!({ "title": "Secret", "visibility": { "type": "Private" } }), 121 + rank: 1.0, 122 + }) 123 + .await; 124 + 125 + let state = create_test_state_with_search(search_repo.clone()); 126 + let auth_ctx = Extension(UserContext { did: "did:alice".to_string(), handle: "alice.test".to_string() }); 127 + let response = search( 128 + State(state.clone()), 129 + Some(auth_ctx), 130 + Query(SearchQuery { q: "private".to_string(), limit: 10, offset: 0 }), 131 + ) 132 + .await 133 + .into_response(); 134 + 135 + let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap(); 136 + let results: Vec<SearchResult> = serde_json::from_slice(&body).unwrap(); 137 + 138 + assert_eq!(results.len(), 1, "Alice should see her private deck"); 139 + assert_eq!(results[0].item_id, "private-deck"); 140 + 141 + let response_anon = search( 142 + State(state.clone()), 143 + None, 144 + Query(SearchQuery { q: "private".to_string(), limit: 10, offset: 0 }), 145 + ) 146 + .await 147 + .into_response(); 148 + 149 + let body_anon = axum::body::to_bytes(response_anon.into_body(), usize::MAX) 150 + .await 151 + .unwrap(); 152 + let results_anon: Vec<SearchResult> = serde_json::from_slice(&body_anon).unwrap(); 153 + 154 + assert_eq!(results_anon.len(), 0, "Anonymous user should not see private deck"); 155 + } 156 + }
+3
crates/server/src/api/social.rs
··· 190 190 let deck_repo = Arc::new(crate::repository::deck::mock::MockDeckRepository::new()) 191 191 as Arc<dyn crate::repository::deck::DeckRepository>; 192 192 let config = crate::state::AppConfig { pds_url: "https://bsky.social".to_string() }; 193 + let search_repo = Arc::new(crate::repository::search::mock::MockSearchRepository::new()) 194 + as Arc<dyn crate::repository::search::SearchRepository>; 193 195 let auth_cache = Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new())); 194 196 195 197 Arc::new(AppState { ··· 200 202 review_repo, 201 203 social_repo, 202 204 deck_repo, 205 + search_repo, 203 206 config, 204 207 auth_cache, 205 208 })
+4
crates/server/src/lib.rs
··· 50 50 let review_repo = std::sync::Arc::new(repository::review::DbReviewRepository::new(pool.clone())); 51 51 let social_repo = std::sync::Arc::new(repository::social::DbSocialRepository::new(pool.clone())); 52 52 53 + let search_repo = std::sync::Arc::new(repository::search::DbSearchRepository::new(pool.clone())); 53 54 let pds_url = std::env::var("PDS_URL").unwrap_or_else(|_| "https://bsky.social".to_string()); 54 55 let config = state::AppConfig { pds_url }; 55 56 ··· 60 61 note: note_repo, 61 62 review: review_repo, 62 63 social: social_repo, 64 + search: search_repo, 63 65 }; 64 66 65 67 let state = state::AppState::new(pool, repos, config); ··· 94 96 .route("/social/following/{did}", get(api::social::get_following)) 95 97 .route("/decks/{id}/comments", get(api::social::get_comments)) 96 98 .route("/feeds/trending", get(api::feed::get_feed_trending)) 99 + .route("/search", get(api::search::search)) 100 + .route("/discovery", get(api::search::discovery)) 97 101 .layer(axum_middleware::from_fn_with_state( 98 102 state.clone(), 99 103 middleware::auth::optional_auth_middleware,
+1
crates/server/src/repository/mod.rs
··· 3 3 pub mod note; 4 4 pub mod oauth; 5 5 pub mod review; 6 + pub mod search; 6 7 pub mod social;
+172
crates/server/src/repository/search.rs
··· 1 + use crate::db::DbPool; 2 + use malfestio_core::Result; 3 + use serde::{Deserialize, Serialize}; 4 + 5 + #[derive(Debug, Serialize, Deserialize, Clone)] 6 + pub struct SearchResult { 7 + pub item_type: String, 8 + pub item_id: String, 9 + pub creator_did: String, 10 + pub data: serde_json::Value, 11 + pub rank: f32, 12 + } 13 + 14 + #[async_trait::async_trait] 15 + pub trait SearchRepository: Send + Sync { 16 + async fn search(&self, query: &str, limit: i64, offset: i64, viewer_did: Option<&str>) 17 + -> Result<Vec<SearchResult>>; 18 + async fn get_top_tags(&self, limit: i64) -> Result<Vec<(String, i64)>>; 19 + } 20 + 21 + pub struct DbSearchRepository { 22 + pool: DbPool, 23 + } 24 + 25 + impl DbSearchRepository { 26 + pub fn new(pool: DbPool) -> Self { 27 + Self { pool } 28 + } 29 + } 30 + 31 + #[async_trait::async_trait] 32 + impl SearchRepository for DbSearchRepository { 33 + async fn search( 34 + &self, query: &str, limit: i64, offset: i64, viewer_did: Option<&str>, 35 + ) -> Result<Vec<SearchResult>> { 36 + let client = self 37 + .pool 38 + .get() 39 + .await 40 + .map_err(|e| malfestio_core::Error::Database(e.to_string()))?; 41 + 42 + // TODO: implement shared-with logic. 43 + let sql = " 44 + SELECT 45 + item_type, 46 + item_id, 47 + creator_did, 48 + data, 49 + ts_rank(tsv_content, websearch_to_tsquery('english', $1)) as rank 50 + FROM search_items 51 + WHERE tsv_content @@ websearch_to_tsquery('english', $1) 52 + AND ( 53 + visibility->>'type' = 'Public' 54 + OR (creator_did = $4) 55 + ) 56 + ORDER BY rank DESC 57 + LIMIT $2 OFFSET $3 58 + "; 59 + 60 + let rows = client 61 + .query(sql, &[&query, &limit, &offset, &viewer_did]) 62 + .await 63 + .map_err(|e| malfestio_core::Error::Database(e.to_string()))?; 64 + 65 + let results = rows 66 + .iter() 67 + .map(|row| SearchResult { 68 + item_type: row.get("item_type"), 69 + item_id: row.get("item_id"), 70 + creator_did: row.get("creator_did"), 71 + data: row.get("data"), 72 + rank: row.get("rank"), 73 + }) 74 + .collect(); 75 + 76 + Ok(results) 77 + } 78 + 79 + async fn get_top_tags(&self, limit: i64) -> Result<Vec<(String, i64)>> { 80 + let client = self 81 + .pool 82 + .get() 83 + .await 84 + .map_err(|e| malfestio_core::Error::Database(e.to_string()))?; 85 + 86 + let sql = " 87 + SELECT tag, count(*) as count 88 + FROM ( 89 + SELECT unnest(tags) as tag FROM decks WHERE visibility->>'type' = 'Public' 90 + UNION ALL 91 + SELECT unnest(tags) as tag FROM notes WHERE visibility->>'type' = 'Public' 92 + ) as all_tags 93 + GROUP BY tag 94 + ORDER BY count DESC 95 + LIMIT $1 96 + "; 97 + 98 + let rows = client 99 + .query(sql, &[&limit]) 100 + .await 101 + .map_err(|e| malfestio_core::Error::Database(e.to_string()))?; 102 + 103 + let results = rows.iter().map(|row| (row.get("tag"), row.get("count"))).collect(); 104 + 105 + Ok(results) 106 + } 107 + } 108 + 109 + #[cfg(test)] 110 + pub mod mock { 111 + use super::*; 112 + use std::sync::Arc; 113 + use tokio::sync::Mutex; 114 + 115 + #[derive(Clone)] 116 + pub struct MockSearchRepository { 117 + pub search_results: Arc<Mutex<Vec<SearchResult>>>, 118 + } 119 + 120 + impl MockSearchRepository { 121 + pub fn new() -> Self { 122 + Self { search_results: Arc::new(Mutex::new(vec![])) } 123 + } 124 + 125 + pub async fn add_result(&self, result: SearchResult) { 126 + let mut results = self.search_results.lock().await; 127 + results.push(result); 128 + } 129 + } 130 + 131 + impl Default for MockSearchRepository { 132 + fn default() -> Self { 133 + Self::new() 134 + } 135 + } 136 + 137 + #[async_trait::async_trait] 138 + impl SearchRepository for MockSearchRepository { 139 + async fn search( 140 + &self, query: &str, limit: i64, offset: i64, viewer_did: Option<&str>, 141 + ) -> Result<Vec<SearchResult>> { 142 + let results = self.search_results.lock().await; 143 + 144 + let filtered: Vec<SearchResult> = results 145 + .iter() 146 + .filter(|r| { 147 + let matches_query = r.item_id.to_lowercase().contains(&query.to_lowercase()); 148 + 149 + let is_public = r 150 + .data 151 + .get("visibility") 152 + .and_then(|v| v.get("type")) 153 + .and_then(|t| t.as_str()) 154 + == Some("Public"); 155 + 156 + let matches_auth = viewer_did.map_or(is_public, |did| r.creator_did == did || is_public); 157 + 158 + matches_query && matches_auth 159 + }) 160 + .skip(offset as usize) 161 + .take(limit as usize) 162 + .cloned() 163 + .collect(); 164 + 165 + Ok(filtered) 166 + } 167 + 168 + async fn get_top_tags(&self, _limit: i64) -> Result<Vec<(String, i64)>> { 169 + Ok(vec![]) 170 + } 171 + } 172 + }
+6
crates/server/src/state.rs
··· 5 5 use crate::repository::note::NoteRepository; 6 6 use crate::repository::oauth::OAuthRepository; 7 7 use crate::repository::review::ReviewRepository; 8 + use crate::repository::search::SearchRepository; 8 9 use crate::repository::social::SocialRepository; 9 10 10 11 use std::collections::HashMap; ··· 28 29 pub note: Arc<dyn NoteRepository>, 29 30 pub review: Arc<dyn ReviewRepository>, 30 31 pub social: Arc<dyn SocialRepository>, 32 + pub search: Arc<dyn SearchRepository>, 31 33 } 32 34 33 35 pub struct AppState { ··· 38 40 pub oauth_repo: Arc<dyn OAuthRepository>, 39 41 pub review_repo: Arc<dyn ReviewRepository>, 40 42 pub social_repo: Arc<dyn SocialRepository>, 43 + pub search_repo: Arc<dyn SearchRepository>, 41 44 pub config: AppConfig, 42 45 pub auth_cache: AuthCache, 43 46 } ··· 53 56 note_repo: repos.note, 54 57 review_repo: repos.review, 55 58 social_repo: repos.social, 59 + search_repo: repos.search, 56 60 config, 57 61 auth_cache, 58 62 }) ··· 66 70 use crate::repository; 67 71 let review_repo = Arc::new(repository::review::mock::MockReviewRepository::new()) as Arc<dyn ReviewRepository>; 68 72 let social_repo = Arc::new(repository::social::mock::MockSocialRepository::new()) as Arc<dyn SocialRepository>; 73 + let search_repo = Arc::new(repository::search::mock::MockSearchRepository::new()) as Arc<dyn SearchRepository>; 69 74 let deck_repo = Arc::new(repository::deck::mock::MockDeckRepository::new()) as Arc<dyn DeckRepository>; 70 75 let config = AppConfig { pds_url: "https://bsky.social".to_string() }; 71 76 ··· 75 80 oauth: oauth_repo, 76 81 review: review_repo, 77 82 social: social_repo, 83 + search: search_repo, 78 84 deck: deck_repo, 79 85 }; 80 86
+3 -15
docs/todo.md
··· 54 54 - **(Done) Milestone G**: Study Engine (SRS) + Daily Review UX. 55 55 - SM-2 spaced repetition scheduler. 56 56 - **(Done) Milestone H**: Social Layer v1: Follow graph, Feeds (Follows/Trending), Forking workflow, and Threaded comments. 57 - 58 - ### Milestone I - Search + Discovery + Taxonomy 59 - 60 - #### Deliverables 61 - 62 - - Full-text search over: 63 - - deck title/description, card text, note text, source metadata 64 - - Tag taxonomy: 65 - - user tags + curator tags + system tags 66 - - Discovery pages: 67 - - top tags, featured paths, editor picks 68 - 69 - #### Acceptance 70 - 71 - - Search is fast (<200ms typical) and results feel relevant. 57 + - **(Done) Milestone I**: Search + Discovery + Taxonomy. 58 + - Full-text search with pg_trgm/unaccent, visibility filtering, and unified search index. 59 + - Tag taxonomy and Discovery page with top tags. 72 60 73 61 ### Milestone J - Moderation + Abuse Resistance 74 62
+66
migrations/007_2025_12_30_full_text_search.sql
··· 1 + CREATE EXTENSION IF NOT EXISTS pg_trgm; 2 + CREATE EXTENSION IF NOT EXISTS unaccent; 3 + 4 + DROP MATERIALIZED VIEW IF EXISTS search_items; 5 + 6 + CREATE MATERIALIZED VIEW search_items AS 7 + SELECT 8 + 'deck' AS item_type, 9 + id::text AS item_id, 10 + owner_did AS creator_did, 11 + setweight(to_tsvector('english', unaccent(coalesce(title, ''))), 'A') || 12 + setweight(to_tsvector('english', unaccent(coalesce(description, ''))), 'B') AS tsv_content, 13 + jsonb_build_object( 14 + 'id', id, 15 + 'title', title, 16 + 'description', description, 17 + 'owner_did', owner_did 18 + ) AS data, 19 + visibility 20 + FROM decks 21 + UNION ALL 22 + SELECT 23 + 'card' AS item_type, 24 + c.id::text AS item_id, 25 + c.owner_did AS creator_did, 26 + setweight(to_tsvector('english', unaccent(coalesce(c.front, ''))), 'A') || 27 + setweight(to_tsvector('english', unaccent(coalesce(c.back, ''))), 'B') AS tsv_content, 28 + jsonb_build_object( 29 + 'id', c.id, 30 + 'deck_id', c.deck_id, 31 + 'front', c.front, 32 + 'back', c.back, 33 + 'owner_did', c.owner_did 34 + ) AS data, 35 + d.visibility 36 + FROM cards c 37 + JOIN decks d ON c.deck_id = d.id 38 + UNION ALL 39 + SELECT 40 + 'note' AS item_type, 41 + id::text AS item_id, 42 + owner_did AS creator_did, 43 + setweight(to_tsvector('english', unaccent(coalesce(title, ''))), 'A') || 44 + setweight(to_tsvector('english', unaccent(coalesce(body, ''))), 'B') AS tsv_content, 45 + jsonb_build_object( 46 + 'id', id, 47 + 'title', title, 48 + 'owner_did', owner_did 49 + ) AS data, 50 + visibility 51 + FROM notes; 52 + 53 + CREATE UNIQUE INDEX idx_search_items_unique ON search_items (item_type, item_id); 54 + 55 + CREATE INDEX idx_search_items_tsv ON search_items USING GIN (tsv_content); 56 + 57 + CREATE INDEX idx_search_items_meta ON search_items (item_type, creator_did); 58 + 59 + CREATE INDEX idx_search_items_visibility ON search_items USING GIN (visibility); 60 + 61 + CREATE OR REPLACE FUNCTION refresh_search_items() 62 + RETURNS void AS $$ 63 + BEGIN 64 + REFRESH MATERIALIZED VIEW CONCURRENTLY search_items; 65 + END; 66 + $$ LANGUAGE plpgsql;
+4
web/src/App.tsx
··· 2 2 import { authStore } from "$lib/store"; 3 3 import DeckNew from "$pages/DeckNew"; 4 4 import DeckView from "$pages/DeckView"; 5 + import Discovery from "$pages/Discovery"; 5 6 import Feed from "$pages/Feed"; 6 7 import Home from "$pages/Home"; 7 8 import Import from "$pages/Import"; ··· 11 12 import NoteNew from "$pages/NoteNew"; 12 13 import NotFound from "$pages/NotFound"; 13 14 import Review from "$pages/Review"; 15 + import Search from "$pages/Search"; 14 16 import { Route, Router } from "@solidjs/router"; 15 17 import type { Component } from "solid-js"; 16 18 import { Show } from "solid-js"; ··· 39 41 <Route path="/review" component={() => <ProtectedRoute component={Review} />} /> 40 42 <Route path="/review/:deckId" component={() => <ProtectedRoute component={Review} />} /> 41 43 <Route path="/feed" component={() => <ProtectedRoute component={Feed} />} /> 44 + <Route path="/search" component={() => <ProtectedRoute component={Search} />} /> 45 + <Route path="/discovery" component={() => <ProtectedRoute component={Discovery} />} /> 42 46 <Route path="*" component={() => <ProtectedRoute component={NotFound} />} /> 43 47 </Router> 44 48 );
+46
web/src/components/SearchInput.test.tsx
··· 1 + import { cleanup, fireEvent, render, screen } from "@solidjs/testing-library"; 2 + import { afterEach, describe, expect, it, vi } from "vitest"; 3 + import { SearchInput } from "./SearchInput"; 4 + 5 + const navigateMock = vi.fn(); 6 + vi.mock("@solidjs/router", () => ({ useNavigate: () => navigateMock })); 7 + 8 + describe("SearchInput", () => { 9 + afterEach(() => { 10 + cleanup(); 11 + vi.clearAllMocks(); 12 + }); 13 + 14 + it("renders correctly", () => { 15 + render(() => <SearchInput />); 16 + expect(screen.getByPlaceholderText("Search decks, cards...")).toBeInTheDocument(); 17 + }); 18 + 19 + it("updates query on input", () => { 20 + render(() => <SearchInput />); 21 + const input = screen.getByPlaceholderText("Search decks, cards...") as HTMLInputElement; 22 + fireEvent.input(input, { target: { value: "test query" } }); 23 + expect(input.value).toBe("test query"); 24 + }); 25 + 26 + it("navigates on submit with query", () => { 27 + render(() => <SearchInput />); 28 + const input = screen.getByPlaceholderText("Search decks, cards..."); 29 + fireEvent.input(input, { target: { value: "test" } }); 30 + fireEvent.submit(input.closest("form")!); 31 + expect(navigateMock).toHaveBeenCalledWith("/search?q=test"); 32 + }); 33 + 34 + it("does not navigate on empty submit", () => { 35 + render(() => <SearchInput />); 36 + const input = screen.getByPlaceholderText("Search decks, cards..."); 37 + fireEvent.submit(input.closest("form")!); 38 + expect(navigateMock).not.toHaveBeenCalled(); 39 + }); 40 + 41 + it("initializes with initialQuery prop", () => { 42 + render(() => <SearchInput initialQuery="initial" />); 43 + const input = screen.getByPlaceholderText("Search decks, cards...") as HTMLInputElement; 44 + expect(input.value).toBe("initial"); 45 + }); 46 + });
+37
web/src/components/SearchInput.tsx
··· 1 + import { useNavigate } from "@solidjs/router"; 2 + import clsx from "clsx"; 3 + import type { Component } from "solid-js"; 4 + import { createSignal } from "solid-js"; 5 + 6 + interface SearchInputProps { 7 + class?: string; 8 + initialQuery?: string; 9 + } 10 + 11 + export const SearchInput: Component<SearchInputProps> = (props) => { 12 + const [query, setQuery] = createSignal(props.initialQuery || ""); 13 + const navigate = useNavigate(); 14 + 15 + const handleSearch = (e: Event) => { 16 + e.preventDefault(); 17 + if (query().trim()) { 18 + navigate(`/search?q=${encodeURIComponent(query())}`); 19 + } 20 + }; 21 + 22 + return ( 23 + <form onSubmit={handleSearch} class={clsx("relative", props.class)}> 24 + <div class="relative"> 25 + <div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none"> 26 + <div class="i-bi-search text-gray-400" /> 27 + </div> 28 + <input 29 + type="search" 30 + class="block w-full p-2 pl-10 text-sm border border-gray-300 rounded-lg bg-gray-50 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" 31 + placeholder="Search decks, cards..." 32 + value={query()} 33 + onInput={(e) => setQuery(e.currentTarget.value)} /> 34 + </div> 35 + </form> 36 + ); 37 + };
+2
web/src/components/layout/Header.tsx
··· 17 17 <nav class="hidden md:flex items-center gap-4 text-sm font-medium text-gray-400"> 18 18 <A href="/decks" activeClass="text-blue-500" class="hover:text-white transition-colors">Decks</A> 19 19 <A href="/review" activeClass="text-blue-500" class="hover:text-white transition-colors">Review</A> 20 + <A href="/discovery" activeClass="text-blue-500" class="hover:text-white transition-colors">Discovery</A> 21 + <A href="/feed" activeClass="text-blue-500" class="hover:text-white transition-colors">Feed</A> 20 22 </nav> 21 23 </div> 22 24 <div class="flex items-center gap-4">
+26 -14
web/src/components/social/CommentSection.tsx
··· 34 34 return []; 35 35 }); 36 36 37 - const [newComment, setNewComment] = createSignal(""); 37 + const [mainComment, setMainComment] = createSignal(""); 38 + const [replyComment, setReplyComment] = createSignal(""); 38 39 const [replyTo, setReplyTo] = createSignal<string | null>(null); 39 40 40 41 const submitComment = async (parentId?: string) => { 41 - if (!newComment().trim()) return; 42 - await api.addComment(props.deckId, newComment(), parentId); 43 - setNewComment(""); 44 - setReplyTo(null); 42 + const content = parentId ? replyComment() : mainComment(); 43 + if (!content.trim()) return; 44 + 45 + await api.addComment(props.deckId, content, parentId); 46 + 47 + if (parentId) { 48 + setReplyComment(""); 49 + setReplyTo(null); 50 + } else { 51 + setMainComment(""); 52 + } 45 53 refetch(); 46 54 }; 47 55 ··· 51 59 <div class="my-1">{node.node.comment.content}</div> 52 60 <div class="text-xs text-gray-500 flex gap-2"> 53 61 <span>{new Date(node.node.comment.created_at).toLocaleString()}</span> 54 - <button class="text-blue-500 hover:underline" onClick={() => setReplyTo(node.node.comment.id)}>Reply</button> 62 + <button 63 + class="text-blue-500 hover:underline" 64 + onClick={() => { 65 + setReplyTo(node.node.comment.id); 66 + setReplyComment(""); 67 + }}> 68 + Reply 69 + </button> 55 70 </div> 56 71 57 72 <Show when={replyTo() === node.node.comment.id}> ··· 59 74 <input 60 75 type="text" 61 76 class="border rounded p-1 flex-1 text-sm" 62 - value={newComment()} 63 - onInput={(e) => setNewComment(e.currentTarget.value)} 77 + value={replyComment()} 78 + onInput={(e) => setReplyComment(e.currentTarget.value)} 64 79 placeholder="Write a reply..." /> 65 80 <Button size="sm" onClick={() => submitComment(node.node.comment.id)}>Post</Button> 66 81 <Button size="sm" variant="ghost" onClick={() => setReplyTo(null)}>Cancel</Button> ··· 81 96 class="border rounded p-2 flex-1 w-full" 82 97 rows={2} 83 98 placeholder="Add a comment..." 84 - // TODO: separate state 85 - value={replyTo() ? "" : newComment()} 86 - onInput={(e) => { 87 - if (!replyTo()) setNewComment(e.currentTarget.value); 88 - }} /> 99 + value={mainComment()} 100 + onInput={(e) => setMainComment(e.currentTarget.value)} /> 89 101 <div class="flex flex-col justify-end"> 90 - <Button onClick={() => submitComment()} disabled={!!replyTo()}>Post</Button> 102 + <Button onClick={() => submitComment()} disabled={false}>Post</Button> 91 103 </div> 92 104 </div> 93 105 </Show>
+19 -14
web/src/lib/api.ts
··· 28 28 export const api = { 29 29 get: (path: string) => apiFetch(path, { method: "GET" }), 30 30 post: (path: string, body: unknown) => apiFetch(path, { method: "POST", body: JSON.stringify(body) }), 31 - getDueCards: (deckId?: string, limit = 20) => { 32 - const params = new URLSearchParams({ limit: String(limit) }); 33 - if (deckId) params.set("deck_id", deckId); 34 - return apiFetch(`/review/due?${params}`, { method: "GET" }); 35 - }, 36 - submitReview: (cardId: string, grade: number) => { 37 - return apiFetch("/review/submit", { method: "POST", body: JSON.stringify({ card_id: cardId, grade }) }); 38 - }, 39 31 getStats: () => apiFetch("/review/stats", { method: "GET" }), 40 32 follow: (did: string) => apiFetch(`/social/follow/${did}`, { method: "POST" }), 41 33 unfollow: (did: string) => apiFetch(`/social/unfollow/${did}`, { method: "POST" }), 42 34 getFollowers: (did: string) => apiFetch(`/social/followers/${did}`, { method: "GET" }), 43 35 getFollowing: (did: string) => apiFetch(`/social/following/${did}`, { method: "GET" }), 44 - addComment: (deckId: string, content: string, parentId?: string) => { 45 - return apiFetch(`/decks/${deckId}/comments`, { 46 - method: "POST", 47 - body: JSON.stringify({ content, parent_id: parentId }), 48 - }); 49 - }, 50 36 getComments: (deckId: string) => apiFetch(`/decks/${deckId}/comments`, { method: "GET" }), 51 37 getFeedFollows: () => apiFetch("/feeds/follows", { method: "GET" }), 52 38 getFeedTrending: () => apiFetch("/feeds/trending", { method: "GET" }), ··· 54 40 getDecks: () => apiFetch("/decks", { method: "GET" }), 55 41 getDeck: (id: string) => apiFetch(`/decks/${id}`, { method: "GET" }), 56 42 getDeckCards: (id: string) => apiFetch(`/decks/${id}/cards`, { method: "GET" }), 43 + getDiscovery: () => apiFetch("/discovery", { method: "GET" }), 57 44 createDeck: async (payload: CreateDeckPayload) => { 58 45 const { cards, ...deckPayload } = payload; 59 46 const res = await apiFetch("/decks", { method: "POST", body: JSON.stringify(deckPayload) }); ··· 70 57 } 71 58 72 59 return { ok: true, json: async () => deck }; 60 + }, 61 + addComment: (deckId: string, content: string, parentId?: string) => { 62 + return apiFetch(`/decks/${deckId}/comments`, { 63 + method: "POST", 64 + body: JSON.stringify({ content, parent_id: parentId }), 65 + }); 66 + }, 67 + search: (query: string, limit = 20, offset = 0) => { 68 + const params = new URLSearchParams({ q: query, limit: String(limit), offset: String(offset) }); 69 + return apiFetch(`/search?${params}`, { method: "GET" }); 70 + }, 71 + getDueCards: (deckId?: string, limit = 20) => { 72 + const params = new URLSearchParams({ limit: String(limit) }); 73 + if (deckId) params.set("deck_id", deckId); 74 + return apiFetch(`/review/due?${params}`, { method: "GET" }); 75 + }, 76 + submitReview: (cardId: string, grade: number) => { 77 + return apiFetch("/review/submit", { method: "POST", body: JSON.stringify({ card_id: cardId, grade }) }); 73 78 }, 74 79 };
+53
web/src/lib/model.test.ts
··· 1 + import { describe, expect, it } from "vitest"; 2 + import { asCard, asDeck, asNote, type SearchResult } from "./model"; 3 + 4 + describe("Type Guards", () => { 5 + const deckResult: SearchResult = { 6 + item_type: "deck", 7 + item_id: "deck1", 8 + creator_did: "did:test", 9 + data: { 10 + id: "deck1", 11 + owner_did: "did:test", 12 + title: "Test Deck", 13 + description: "Description", 14 + tags: [], 15 + visibility: { type: "Public" }, 16 + }, 17 + rank: 1, 18 + }; 19 + 20 + const cardResult: SearchResult = { 21 + item_type: "card", 22 + item_id: "card1", 23 + creator_did: "did:test", 24 + data: { front: "Front", back: "Back", deck_id: "deck1" }, 25 + rank: 1, 26 + }; 27 + 28 + const noteResult: SearchResult = { 29 + item_type: "note", 30 + item_id: "note1", 31 + creator_did: "did:test", 32 + data: { id: "note1", title: "Test Note", owner_did: "did:test" }, 33 + rank: 1, 34 + }; 35 + 36 + it("asDeck correctly identifies decks", () => { 37 + expect(asDeck(deckResult)).toBe(deckResult); 38 + expect(asDeck(cardResult)).toBeUndefined(); 39 + expect(asDeck(noteResult)).toBeUndefined(); 40 + }); 41 + 42 + it("asCard correctly identifies cards", () => { 43 + expect(asCard(cardResult)).toBe(cardResult); 44 + expect(asCard(deckResult)).toBeUndefined(); 45 + expect(asCard(noteResult)).toBeUndefined(); 46 + }); 47 + 48 + it("asNote correctly identifies notes", () => { 49 + expect(asNote(noteResult)).toBe(noteResult); 50 + expect(asNote(deckResult)).toBeUndefined(); 51 + expect(asNote(cardResult)).toBeUndefined(); 52 + }); 53 + });
+20
web/src/lib/model.ts
··· 69 69 }; 70 70 71 71 export type CommentNode = { comment: Comment; children: CommentNode[] }; 72 + 73 + export type FeedFollows = { decks: Deck[] }; 74 + 75 + export type SearchResult = { item_type: "deck"; item_id: string; creator_did: string; data: Deck; rank: number } | { 76 + item_type: "card"; 77 + item_id: string; 78 + creator_did: string; 79 + data: Card & { deck_id: string }; 80 + rank: number; 81 + } | { 82 + item_type: "note"; 83 + item_id: string; 84 + creator_did: string; 85 + data: { id: string; title: string; owner_did: string }; 86 + rank: number; 87 + }; 88 + 89 + export const asDeck = (r: SearchResult) => (r.item_type === "deck" ? r : undefined); 90 + export const asCard = (r: SearchResult) => (r.item_type === "card" ? r : undefined); 91 + export const asNote = (r: SearchResult) => (r.item_type === "note" ? r : undefined);
+65
web/src/pages/Discovery.tsx
··· 1 + import { SearchInput } from "$components/SearchInput"; 2 + import { api } from "$lib/api"; 3 + import { A } from "@solidjs/router"; 4 + import type { Component } from "solid-js"; 5 + import { createResource, For, Show } from "solid-js"; 6 + 7 + // TODO: type discovery response 8 + const Discovery: Component = () => { 9 + const [data] = createResource(async () => { 10 + const res = await api.getDiscovery(); 11 + if (res.ok) return await res.json(); 12 + return { top_tags: [] }; 13 + }); 14 + 15 + return ( 16 + <div class="container mx-auto p-4 space-y-8"> 17 + <div class="text-center space-y-4"> 18 + <h1 class="text-4xl font-extrabold bg-linear-to-r from-blue-600 to-purple-600 dark:from-blue-400 dark:to-purple-400 text-transparent bg-clip-text"> 19 + Discover Malfestio 20 + </h1> 21 + <p class="text-xl text-gray-600 dark:text-gray-300">Explore community decks and popular topics</p> 22 + <div class="max-w-2xl mx-auto"> 23 + <SearchInput /> 24 + </div> 25 + </div> 26 + 27 + <div class="space-y-4"> 28 + <h2 class="text-2xl font-bold flex items-center gap-2"> 29 + <div class="i-bi-tags-fill text-purple-500" /> 30 + Top Tags 31 + </h2> 32 + 33 + <Show 34 + when={!data.loading} 35 + fallback={ 36 + <div class="flex gap-2"> 37 + <div class="h-8 w-24 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" /> 38 + </div> 39 + }> 40 + <div class="flex flex-wrap gap-3"> 41 + <For each={data()?.top_tags}> 42 + {(tag: [string, number]) => ( 43 + <A 44 + href={`/search?q=${encodeURIComponent(tag[0])}`} 45 + class="px-4 py-2 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-full hover:shadow-md hover:border-blue-500 dark:hover:border-blue-500 transition-all flex items-center gap-2 group"> 46 + <span class="font-medium text-gray-700 dark:text-gray-200 group-hover:text-blue-600 dark:group-hover:text-blue-400"> 47 + #{tag[0]} 48 + </span> 49 + <span class="text-xs text-gray-400 bg-gray-100 dark:bg-gray-700 px-1.5 py-0.5 rounded-full"> 50 + {tag[1]} 51 + </span> 52 + </A> 53 + )} 54 + </For> 55 + <Show when={data()?.top_tags.length === 0}> 56 + <p class="text-gray-500">No tags found yet. Create some decks!</p> 57 + </Show> 58 + </div> 59 + </Show> 60 + </div> 61 + </div> 62 + ); 63 + }; 64 + 65 + export default Discovery;
+113
web/src/pages/Search.test.tsx
··· 1 + import { api } from "$lib/api"; 2 + import { cleanup, render, screen, waitFor } from "@solidjs/testing-library"; 3 + import { JSX } from "solid-js"; 4 + import { afterEach, describe, expect, it, vi } from "vitest"; 5 + import Search from "./Search"; 6 + 7 + vi.mock("$lib/api", () => ({ api: { search: vi.fn() } })); 8 + 9 + const { mockSearchParams } = vi.hoisted(() => ({ mockSearchParams: { q: "" } })); 10 + 11 + vi.mock( 12 + "@solidjs/router", 13 + () => ({ 14 + useSearchParams: () => [mockSearchParams], 15 + A: (props: { href: string; children: JSX.Element }) => <a href={props.href}>{props.children}</a>, 16 + useNavigate: () => vi.fn(), 17 + }), 18 + ); 19 + 20 + describe("Search", () => { 21 + afterEach(() => { 22 + cleanup(); 23 + vi.clearAllMocks(); 24 + }); 25 + 26 + const mockSearchResults = [{ 27 + item_type: "deck", 28 + item_id: "deck1", 29 + creator_did: "did:test:1", 30 + data: { 31 + id: "deck1", 32 + owner_did: "did:test:1", 33 + title: "Test Deck", 34 + description: "A test deck", 35 + tags: ["test"], 36 + visibility: { type: "Public" }, 37 + }, 38 + rank: 0.9, 39 + }, { 40 + item_type: "card", 41 + item_id: "card1", 42 + creator_did: "did:test:1", 43 + data: { id: "card1", deck_id: "deck1", front: "Card Front", back: "Card Back", owner_did: "did:test:1" }, 44 + rank: 0.8, 45 + }, { 46 + item_type: "note", 47 + item_id: "note1", 48 + creator_did: "did:test:1", 49 + data: { id: "note1", title: "Test Note", owner_did: "did:test:1" }, 50 + rank: 0.7, 51 + }]; 52 + 53 + it("renders search results correctly", async () => { 54 + mockSearchParams.q = "test"; 55 + vi.mocked(api.search).mockResolvedValue( 56 + { ok: true, json: () => Promise.resolve(mockSearchResults) } as unknown as Response, 57 + ); 58 + 59 + render(() => <Search />); 60 + 61 + // Verify loading state or results 62 + await waitFor(() => expect(screen.getByText("Search Results")).toBeInTheDocument()); 63 + 64 + // Verify Deck result 65 + await waitFor(() => expect(screen.getByText("Test Deck")).toBeInTheDocument()); 66 + expect(screen.getByText("A test deck")).toBeInTheDocument(); 67 + 68 + // Verify Card result 69 + expect(screen.getByText("Card Front")).toBeInTheDocument(); 70 + expect(screen.getByText("Card Back")).toBeInTheDocument(); 71 + 72 + // Verify Note result 73 + expect(screen.getByText("Test Note")).toBeInTheDocument(); 74 + }); 75 + 76 + it("shows empty state when no results", async () => { 77 + mockSearchParams.q = "nonexistent"; 78 + vi.mocked(api.search).mockResolvedValue({ ok: true, json: () => Promise.resolve([]) } as unknown as Response); 79 + 80 + render(() => <Search />); 81 + 82 + await waitFor(() => expect(screen.getByText("No results found for \"nonexistent\"")).toBeInTheDocument()); 83 + }); 84 + 85 + it("handles loading state", async () => { 86 + mockSearchParams.q = "loading"; 87 + vi.mocked(api.search).mockReturnValue(new Promise(() => {})); 88 + 89 + render(() => <Search />); 90 + 91 + expect(api.search).toHaveBeenCalledWith("loading"); 92 + }); 93 + 94 + it("generates correct links for results", async () => { 95 + mockSearchParams.q = "test"; 96 + vi.mocked(api.search).mockResolvedValue( 97 + { ok: true, json: () => Promise.resolve(mockSearchResults) } as unknown as Response, 98 + ); 99 + 100 + render(() => <Search />); 101 + 102 + await waitFor(() => expect(screen.getByText("Test Deck")).toBeInTheDocument()); 103 + 104 + const deckLink = screen.getByText("Test Deck").closest("a"); 105 + expect(deckLink).toHaveAttribute("href", "/decks/deck1"); 106 + 107 + const cardLink = screen.getByText("Card in Deck").closest("a"); 108 + expect(cardLink).toHaveAttribute("href", "/decks/deck1"); 109 + 110 + const noteLink = screen.getByText("Test Note").closest("a"); 111 + expect(noteLink).toHaveAttribute("href", "/notes/note1"); 112 + }); 113 + });
+116
web/src/pages/Search.tsx
··· 1 + import { SearchInput } from "$components/SearchInput"; 2 + import { Card } from "$components/ui/Card"; 3 + import { api } from "$lib/api"; 4 + import { asCard, asDeck, asNote, type SearchResult } from "$lib/model"; 5 + import { A, useSearchParams } from "@solidjs/router"; 6 + import type { Component } from "solid-js"; 7 + import { createResource, For, Match, Show, Switch } from "solid-js"; 8 + 9 + const Search: Component = () => { 10 + const [searchParams] = useSearchParams(); 11 + const query = () => { 12 + const q = searchParams.q; 13 + return Array.isArray(q) ? q[0] : q || ""; 14 + }; 15 + 16 + const [results] = createResource(query, async (q) => { 17 + if (!q) return []; 18 + const res = await api.search(q); 19 + if (res.ok) return await res.json() as SearchResult[]; 20 + return []; 21 + }); 22 + 23 + return ( 24 + <div class="container mx-auto p-4 space-y-6"> 25 + <div class="flex flex-col md:flex-row gap-4 items-center justify-between"> 26 + <h1 class="text-2xl font-bold">Search Results</h1> 27 + <div class="w-full md:w-96"> 28 + <SearchInput initialQuery={query()} /> 29 + </div> 30 + </div> 31 + 32 + <Show when={results.loading}> 33 + <div class="flex justify-center p-8"> 34 + <div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600" /> 35 + </div> 36 + </Show> 37 + 38 + <Show when={!results.loading && results()?.length === 0}> 39 + <div class="text-center text-gray-500 p-8"> 40 + <div class="i-bi-search text-4xl mb-2 mx-auto" /> 41 + <p>No results found for "{query()}"</p> 42 + </div> 43 + </Show> 44 + 45 + <div class="grid grid-cols-1 gap-4"> 46 + <For each={results()}> 47 + {(result) => ( 48 + <Card class="p-4 hover:shadow-md transition-shadow"> 49 + <div class="flex items-start gap-4"> 50 + <div class="mt-1"> 51 + <Show when={result.item_type === "deck"}> 52 + <div class="i-bi-collection text-xl text-blue-500" title="Deck" /> 53 + </Show> 54 + <Show when={result.item_type === "card"}> 55 + <div class="i-bi-card-text text-xl text-green-500" title="Card" /> 56 + </Show> 57 + <Show when={result.item_type === "note"}> 58 + <div class="i-bi-journal-text text-xl text-yellow-500" title="Note" /> 59 + </Show> 60 + </div> 61 + <div class="flex-1"> 62 + <Switch> 63 + <Match when={asDeck(result)}> 64 + {(item) => ( 65 + <> 66 + <A href={`/decks/${item().data.id}`} class="text-lg font-semibold hover:underline"> 67 + {item().data.title} 68 + </A> 69 + <p class="text-gray-600 dark:text-gray-300 text-sm mt-1">{item().data.description}</p> 70 + </> 71 + )} 72 + </Match> 73 + <Match when={asCard(result)}> 74 + {(item) => ( 75 + <> 76 + <A 77 + href={`/decks/${item().data.deck_id}`} 78 + class="text-lg font-semibold hover:underline block mb-1"> 79 + Card in Deck 80 + </A> 81 + <p class="text-sm"> 82 + <span class="font-medium">Front:</span> {item().data.front} 83 + </p> 84 + <p class="text-sm text-gray-500"> 85 + <span class="font-medium">Back:</span> {item().data.back} 86 + </p> 87 + </> 88 + )} 89 + </Match> 90 + <Match when={asNote(result)}> 91 + {(item) => ( 92 + <> 93 + <A href={`/notes/${item().data.id}`} class="text-lg font-semibold hover:underline"> 94 + {item().data.title} 95 + </A> 96 + <div class="prose prose-sm dark:prose-invert mt-2 max-h-24 overflow-hidden truncate"> 97 + Content match 98 + </div> 99 + </> 100 + )} 101 + </Match> 102 + </Switch> 103 + <div class="mt-2 text-xs text-gray-400"> 104 + Result Type: {result.item_type} • Score: {result.rank.toFixed(2)} 105 + </div> 106 + </div> 107 + </div> 108 + </Card> 109 + )} 110 + </For> 111 + </div> 112 + </div> 113 + ); 114 + }; 115 + 116 + export default Search;