From b17d17837535a5bc8a8bf0c7b4ab615a45fbb5d4 Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Tue, 30 Dec 2025 11:08:53 -0600 Subject: [PATCH] feat: Implement full-text search API --- crates/server/src/api/auth.rs | 2 +- crates/server/src/api/feed.rs | 4 + crates/server/src/api/mod.rs | 1 + crates/server/src/api/review.rs | 4 + crates/server/src/api/search.rs | 156 ++++++++++++++++ crates/server/src/api/social.rs | 3 + crates/server/src/lib.rs | 4 + crates/server/src/repository/mod.rs | 1 + crates/server/src/repository/search.rs | 172 ++++++++++++++++++ crates/server/src/state.rs | 6 + docs/todo.md | 18 +- .../007_2025_12_30_full_text_search.sql | 66 +++++++ web/src/App.tsx | 4 + web/src/components/SearchInput.test.tsx | 46 +++++ web/src/components/SearchInput.tsx | 37 ++++ web/src/components/layout/Header.tsx | 2 + web/src/components/social/CommentSection.tsx | 40 ++-- web/src/lib/api.ts | 33 ++-- web/src/lib/model.test.ts | 53 ++++++ web/src/lib/model.ts | 20 ++ web/src/pages/Discovery.tsx | 65 +++++++ web/src/pages/Search.test.tsx | 113 ++++++++++++ web/src/pages/Search.tsx | 116 ++++++++++++ 23 files changed, 922 insertions(+), 44 deletions(-) create mode 100644 crates/server/src/api/search.rs create mode 100644 crates/server/src/repository/search.rs create mode 100644 migrations/007_2025_12_30_full_text_search.sql create mode 100644 web/src/components/SearchInput.test.tsx create mode 100644 web/src/components/SearchInput.tsx create mode 100644 web/src/lib/model.test.ts create mode 100644 web/src/pages/Discovery.tsx create mode 100644 web/src/pages/Search.test.tsx create mode 100644 web/src/pages/Search.tsx diff --git a/crates/server/src/api/auth.rs b/crates/server/src/api/auth.rs index 2a894ba..4a0784b 100644 --- a/crates/server/src/api/auth.rs +++ b/crates/server/src/api/auth.rs @@ -18,7 +18,7 @@ pub struct LoginResponse { handle: String, } -/// TODO: Make PDS URL configurable (bluesky users can use their own PDS) +/// TODO: Find user's PDS URL pub async fn login(State(state): State, Json(payload): Json) -> impl IntoResponse { let client = reqwest::Client::new(); let pds_url = &state.config.pds_url; diff --git a/crates/server/src/api/feed.rs b/crates/server/src/api/feed.rs index 9b43122..96b9d96 100644 --- a/crates/server/src/api/feed.rs +++ b/crates/server/src/api/feed.rs @@ -66,6 +66,9 @@ mod tests { as Arc; let config = crate::state::AppConfig { pds_url: "https://bsky.social".to_string() }; + let search_repo = Arc::new(crate::repository::search::mock::MockSearchRepository::new()) + as Arc; + let repos = crate::state::Repositories { card: card_repo, note: note_repo, @@ -73,6 +76,7 @@ mod tests { review: review_repo, social: social_repo, deck: deck_repo, + search: search_repo, }; AppState::new(pool, repos, config) diff --git a/crates/server/src/api/mod.rs b/crates/server/src/api/mod.rs index 758a272..71f861c 100644 --- a/crates/server/src/api/mod.rs +++ b/crates/server/src/api/mod.rs @@ -6,4 +6,5 @@ pub mod importer; pub mod note; pub mod oauth; pub mod review; +pub mod search; pub mod social; diff --git a/crates/server/src/api/review.rs b/crates/server/src/api/review.rs index 1ada522..16697ff 100644 --- a/crates/server/src/api/review.rs +++ b/crates/server/src/api/review.rs @@ -154,6 +154,9 @@ mod tests { as Arc; let config = crate::state::AppConfig { pds_url: "https://bsky.social".to_string() }; + let search_repo = Arc::new(crate::repository::search::mock::MockSearchRepository::new()) + as Arc; + let repos = crate::state::Repositories { card: card_repo, note: note_repo, @@ -161,6 +164,7 @@ mod tests { review: review_repo, social: social_repo, deck: deck_repo, + search: search_repo, }; AppState::new(pool, repos, config) diff --git a/crates/server/src/api/search.rs b/crates/server/src/api/search.rs new file mode 100644 index 0000000..fc9787c --- /dev/null +++ b/crates/server/src/api/search.rs @@ -0,0 +1,156 @@ +use crate::middleware::auth::UserContext; +use crate::state::SharedState; +use axum::{ + Json, + extract::{Extension, Query, State}, + http::StatusCode, + response::IntoResponse, +}; +use serde::Deserialize; +use serde_json::json; + +#[derive(Deserialize)] +pub struct SearchQuery { + q: String, + #[serde(default = "default_limit")] + limit: i64, + #[serde(default = "default_offset")] + offset: i64, +} + +fn default_limit() -> i64 { + 20 +} + +fn default_offset() -> i64 { + 0 +} + +/// GET /api/search?q=... +/// Search for decks, cards, and notes using full-text search +/// +/// TODO: filter by user +pub async fn search( + State(state): State, ctx: Option>, Query(query): Query, +) -> impl IntoResponse { + let user_did = ctx.map(|Extension(u)| u.did); + + match state + .search_repo + .search(&query.q, query.limit, query.offset, user_did.as_deref()) + .await + { + Ok(results) => Json(results).into_response(), + Err(e) => { + tracing::error!("Search failed: {:?}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "Search failed"})), + ) + .into_response() + } + } +} + +/// GET /api/discovery +/// Get discovery info like top tags +pub async fn discovery(State(state): State) -> impl IntoResponse { + match state.search_repo.get_top_tags(10).await { + Ok(tags) => Json(json!({ "top_tags": tags })).into_response(), + Err(e) => { + tracing::error!("Discovery failed: {:?}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "Discovery failed"})), + ) + .into_response() + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::repository::card::mock::MockCardRepository; + use crate::repository::deck::mock::MockDeckRepository; + use crate::repository::note::mock::MockNoteRepository; + use crate::repository::oauth::mock::MockOAuthRepository; + use crate::repository::review::mock::MockReviewRepository; + use crate::repository::search::mock::MockSearchRepository; + use crate::repository::search::{SearchRepository, SearchResult}; + use crate::repository::social::mock::MockSocialRepository; + use crate::state::AppState; + use std::sync::Arc; + + fn create_test_state_with_search(search_repo: Arc) -> SharedState { + let pool = crate::db::create_mock_pool(); + let card_repo = Arc::new(MockCardRepository::new()) as Arc; + let note_repo = Arc::new(MockNoteRepository::new()) as Arc; + let oauth_repo = Arc::new(MockOAuthRepository::new()) as Arc; + let review_repo = Arc::new(MockReviewRepository::new()) as Arc; + let social_repo = Arc::new(MockSocialRepository::new()) as Arc; + let deck_repo = Arc::new(MockDeckRepository::new()) as Arc; + + let config = crate::state::AppConfig { pds_url: "https://bsky.social".to_string() }; + let auth_cache = Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new())); + let search_repo_trait = search_repo.clone() as Arc; + + Arc::new(AppState { + pool, + card_repo, + note_repo, + oauth_repo, + review_repo, + social_repo, + deck_repo, + search_repo: search_repo_trait, + config, + auth_cache, + }) + } + + #[tokio::test] + async fn test_search_handler_passes_viewer_did() { + let search_repo = Arc::new(MockSearchRepository::new()); + search_repo + .add_result(SearchResult { + item_type: "deck".to_string(), + item_id: "private-deck".to_string(), + creator_did: "did:alice".to_string(), + data: serde_json::json!({ "title": "Secret", "visibility": { "type": "Private" } }), + rank: 1.0, + }) + .await; + + let state = create_test_state_with_search(search_repo.clone()); + let auth_ctx = Extension(UserContext { did: "did:alice".to_string(), handle: "alice.test".to_string() }); + let response = search( + State(state.clone()), + Some(auth_ctx), + Query(SearchQuery { q: "private".to_string(), limit: 10, offset: 0 }), + ) + .await + .into_response(); + + let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let results: Vec = serde_json::from_slice(&body).unwrap(); + + assert_eq!(results.len(), 1, "Alice should see her private deck"); + assert_eq!(results[0].item_id, "private-deck"); + + let response_anon = search( + State(state.clone()), + None, + Query(SearchQuery { q: "private".to_string(), limit: 10, offset: 0 }), + ) + .await + .into_response(); + + let body_anon = axum::body::to_bytes(response_anon.into_body(), usize::MAX) + .await + .unwrap(); + let results_anon: Vec = serde_json::from_slice(&body_anon).unwrap(); + + assert_eq!(results_anon.len(), 0, "Anonymous user should not see private deck"); + } +} diff --git a/crates/server/src/api/social.rs b/crates/server/src/api/social.rs index 2c0be3e..9503bec 100644 --- a/crates/server/src/api/social.rs +++ b/crates/server/src/api/social.rs @@ -190,6 +190,8 @@ mod tests { let deck_repo = Arc::new(crate::repository::deck::mock::MockDeckRepository::new()) as Arc; let config = crate::state::AppConfig { pds_url: "https://bsky.social".to_string() }; + let search_repo = Arc::new(crate::repository::search::mock::MockSearchRepository::new()) + as Arc; let auth_cache = Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new())); Arc::new(AppState { @@ -200,6 +202,7 @@ mod tests { review_repo, social_repo, deck_repo, + search_repo, config, auth_cache, }) diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs index 73fb704..be3f152 100644 --- a/crates/server/src/lib.rs +++ b/crates/server/src/lib.rs @@ -50,6 +50,7 @@ pub async fn start() -> malfestio_core::Result<()> { let review_repo = std::sync::Arc::new(repository::review::DbReviewRepository::new(pool.clone())); let social_repo = std::sync::Arc::new(repository::social::DbSocialRepository::new(pool.clone())); + let search_repo = std::sync::Arc::new(repository::search::DbSearchRepository::new(pool.clone())); let pds_url = std::env::var("PDS_URL").unwrap_or_else(|_| "https://bsky.social".to_string()); let config = state::AppConfig { pds_url }; @@ -60,6 +61,7 @@ pub async fn start() -> malfestio_core::Result<()> { note: note_repo, review: review_repo, social: social_repo, + search: search_repo, }; let state = state::AppState::new(pool, repos, config); @@ -94,6 +96,8 @@ pub async fn start() -> malfestio_core::Result<()> { .route("/social/following/{did}", get(api::social::get_following)) .route("/decks/{id}/comments", get(api::social::get_comments)) .route("/feeds/trending", get(api::feed::get_feed_trending)) + .route("/search", get(api::search::search)) + .route("/discovery", get(api::search::discovery)) .layer(axum_middleware::from_fn_with_state( state.clone(), middleware::auth::optional_auth_middleware, diff --git a/crates/server/src/repository/mod.rs b/crates/server/src/repository/mod.rs index 7122106..5de24e8 100644 --- a/crates/server/src/repository/mod.rs +++ b/crates/server/src/repository/mod.rs @@ -3,4 +3,5 @@ pub mod deck; pub mod note; pub mod oauth; pub mod review; +pub mod search; pub mod social; diff --git a/crates/server/src/repository/search.rs b/crates/server/src/repository/search.rs new file mode 100644 index 0000000..fa365e5 --- /dev/null +++ b/crates/server/src/repository/search.rs @@ -0,0 +1,172 @@ +use crate::db::DbPool; +use malfestio_core::Result; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct SearchResult { + pub item_type: String, + pub item_id: String, + pub creator_did: String, + pub data: serde_json::Value, + pub rank: f32, +} + +#[async_trait::async_trait] +pub trait SearchRepository: Send + Sync { + async fn search(&self, query: &str, limit: i64, offset: i64, viewer_did: Option<&str>) + -> Result>; + async fn get_top_tags(&self, limit: i64) -> Result>; +} + +pub struct DbSearchRepository { + pool: DbPool, +} + +impl DbSearchRepository { + pub fn new(pool: DbPool) -> Self { + Self { pool } + } +} + +#[async_trait::async_trait] +impl SearchRepository for DbSearchRepository { + async fn search( + &self, query: &str, limit: i64, offset: i64, viewer_did: Option<&str>, + ) -> Result> { + let client = self + .pool + .get() + .await + .map_err(|e| malfestio_core::Error::Database(e.to_string()))?; + + // TODO: implement shared-with logic. + let sql = " + SELECT + item_type, + item_id, + creator_did, + data, + ts_rank(tsv_content, websearch_to_tsquery('english', $1)) as rank + FROM search_items + WHERE tsv_content @@ websearch_to_tsquery('english', $1) + AND ( + visibility->>'type' = 'Public' + OR (creator_did = $4) + ) + ORDER BY rank DESC + LIMIT $2 OFFSET $3 + "; + + let rows = client + .query(sql, &[&query, &limit, &offset, &viewer_did]) + .await + .map_err(|e| malfestio_core::Error::Database(e.to_string()))?; + + let results = rows + .iter() + .map(|row| SearchResult { + item_type: row.get("item_type"), + item_id: row.get("item_id"), + creator_did: row.get("creator_did"), + data: row.get("data"), + rank: row.get("rank"), + }) + .collect(); + + Ok(results) + } + + async fn get_top_tags(&self, limit: i64) -> Result> { + let client = self + .pool + .get() + .await + .map_err(|e| malfestio_core::Error::Database(e.to_string()))?; + + let sql = " + SELECT tag, count(*) as count + FROM ( + SELECT unnest(tags) as tag FROM decks WHERE visibility->>'type' = 'Public' + UNION ALL + SELECT unnest(tags) as tag FROM notes WHERE visibility->>'type' = 'Public' + ) as all_tags + GROUP BY tag + ORDER BY count DESC + LIMIT $1 + "; + + let rows = client + .query(sql, &[&limit]) + .await + .map_err(|e| malfestio_core::Error::Database(e.to_string()))?; + + let results = rows.iter().map(|row| (row.get("tag"), row.get("count"))).collect(); + + Ok(results) + } +} + +#[cfg(test)] +pub mod mock { + use super::*; + use std::sync::Arc; + use tokio::sync::Mutex; + + #[derive(Clone)] + pub struct MockSearchRepository { + pub search_results: Arc>>, + } + + impl MockSearchRepository { + pub fn new() -> Self { + Self { search_results: Arc::new(Mutex::new(vec![])) } + } + + pub async fn add_result(&self, result: SearchResult) { + let mut results = self.search_results.lock().await; + results.push(result); + } + } + + impl Default for MockSearchRepository { + fn default() -> Self { + Self::new() + } + } + + #[async_trait::async_trait] + impl SearchRepository for MockSearchRepository { + async fn search( + &self, query: &str, limit: i64, offset: i64, viewer_did: Option<&str>, + ) -> Result> { + let results = self.search_results.lock().await; + + let filtered: Vec = results + .iter() + .filter(|r| { + let matches_query = r.item_id.to_lowercase().contains(&query.to_lowercase()); + + let is_public = r + .data + .get("visibility") + .and_then(|v| v.get("type")) + .and_then(|t| t.as_str()) + == Some("Public"); + + let matches_auth = viewer_did.map_or(is_public, |did| r.creator_did == did || is_public); + + matches_query && matches_auth + }) + .skip(offset as usize) + .take(limit as usize) + .cloned() + .collect(); + + Ok(filtered) + } + + async fn get_top_tags(&self, _limit: i64) -> Result> { + Ok(vec![]) + } + } +} diff --git a/crates/server/src/state.rs b/crates/server/src/state.rs index 8f50caa..0c885e9 100644 --- a/crates/server/src/state.rs +++ b/crates/server/src/state.rs @@ -5,6 +5,7 @@ use crate::repository::deck::DeckRepository; use crate::repository::note::NoteRepository; use crate::repository::oauth::OAuthRepository; use crate::repository::review::ReviewRepository; +use crate::repository::search::SearchRepository; use crate::repository::social::SocialRepository; use std::collections::HashMap; @@ -28,6 +29,7 @@ pub struct Repositories { pub note: Arc, pub review: Arc, pub social: Arc, + pub search: Arc, } pub struct AppState { @@ -38,6 +40,7 @@ pub struct AppState { pub oauth_repo: Arc, pub review_repo: Arc, pub social_repo: Arc, + pub search_repo: Arc, pub config: AppConfig, pub auth_cache: AuthCache, } @@ -53,6 +56,7 @@ impl AppState { note_repo: repos.note, review_repo: repos.review, social_repo: repos.social, + search_repo: repos.search, config, auth_cache, }) @@ -66,6 +70,7 @@ impl AppState { use crate::repository; let review_repo = Arc::new(repository::review::mock::MockReviewRepository::new()) as Arc; let social_repo = Arc::new(repository::social::mock::MockSocialRepository::new()) as Arc; + let search_repo = Arc::new(repository::search::mock::MockSearchRepository::new()) as Arc; let deck_repo = Arc::new(repository::deck::mock::MockDeckRepository::new()) as Arc; let config = AppConfig { pds_url: "https://bsky.social".to_string() }; @@ -75,6 +80,7 @@ impl AppState { oauth: oauth_repo, review: review_repo, social: social_repo, + search: search_repo, deck: deck_repo, }; diff --git a/docs/todo.md b/docs/todo.md index bc7c594..165bc08 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -54,21 +54,9 @@ - **(Done) Milestone G**: Study Engine (SRS) + Daily Review UX. - SM-2 spaced repetition scheduler. - **(Done) Milestone H**: Social Layer v1: Follow graph, Feeds (Follows/Trending), Forking workflow, and Threaded comments. - -### Milestone I - Search + Discovery + Taxonomy - -#### Deliverables - -- Full-text search over: - - deck title/description, card text, note text, source metadata -- Tag taxonomy: - - user tags + curator tags + system tags -- Discovery pages: - - top tags, featured paths, editor picks - -#### Acceptance - -- Search is fast (<200ms typical) and results feel relevant. +- **(Done) Milestone I**: Search + Discovery + Taxonomy. + - Full-text search with pg_trgm/unaccent, visibility filtering, and unified search index. + - Tag taxonomy and Discovery page with top tags. ### Milestone J - Moderation + Abuse Resistance diff --git a/migrations/007_2025_12_30_full_text_search.sql b/migrations/007_2025_12_30_full_text_search.sql new file mode 100644 index 0000000..76cc1d2 --- /dev/null +++ b/migrations/007_2025_12_30_full_text_search.sql @@ -0,0 +1,66 @@ +CREATE EXTENSION IF NOT EXISTS pg_trgm; +CREATE EXTENSION IF NOT EXISTS unaccent; + +DROP MATERIALIZED VIEW IF EXISTS search_items; + +CREATE MATERIALIZED VIEW search_items AS +SELECT + 'deck' AS item_type, + id::text AS item_id, + owner_did AS creator_did, + setweight(to_tsvector('english', unaccent(coalesce(title, ''))), 'A') || + setweight(to_tsvector('english', unaccent(coalesce(description, ''))), 'B') AS tsv_content, + jsonb_build_object( + 'id', id, + 'title', title, + 'description', description, + 'owner_did', owner_did + ) AS data, + visibility +FROM decks +UNION ALL +SELECT + 'card' AS item_type, + c.id::text AS item_id, + c.owner_did AS creator_did, + setweight(to_tsvector('english', unaccent(coalesce(c.front, ''))), 'A') || + setweight(to_tsvector('english', unaccent(coalesce(c.back, ''))), 'B') AS tsv_content, + jsonb_build_object( + 'id', c.id, + 'deck_id', c.deck_id, + 'front', c.front, + 'back', c.back, + 'owner_did', c.owner_did + ) AS data, + d.visibility +FROM cards c +JOIN decks d ON c.deck_id = d.id +UNION ALL +SELECT + 'note' AS item_type, + id::text AS item_id, + owner_did AS creator_did, + setweight(to_tsvector('english', unaccent(coalesce(title, ''))), 'A') || + setweight(to_tsvector('english', unaccent(coalesce(body, ''))), 'B') AS tsv_content, + jsonb_build_object( + 'id', id, + 'title', title, + 'owner_did', owner_did + ) AS data, + visibility +FROM notes; + +CREATE UNIQUE INDEX idx_search_items_unique ON search_items (item_type, item_id); + +CREATE INDEX idx_search_items_tsv ON search_items USING GIN (tsv_content); + +CREATE INDEX idx_search_items_meta ON search_items (item_type, creator_did); + +CREATE INDEX idx_search_items_visibility ON search_items USING GIN (visibility); + +CREATE OR REPLACE FUNCTION refresh_search_items() +RETURNS void AS $$ +BEGIN + REFRESH MATERIALIZED VIEW CONCURRENTLY search_items; +END; +$$ LANGUAGE plpgsql; diff --git a/web/src/App.tsx b/web/src/App.tsx index 216724b..0b63e6e 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -2,6 +2,7 @@ import { AppLayout } from "$components/layout/AppLayout"; import { authStore } from "$lib/store"; import DeckNew from "$pages/DeckNew"; import DeckView from "$pages/DeckView"; +import Discovery from "$pages/Discovery"; import Feed from "$pages/Feed"; import Home from "$pages/Home"; import Import from "$pages/Import"; @@ -11,6 +12,7 @@ import Login from "$pages/Login"; import NoteNew from "$pages/NoteNew"; import NotFound from "$pages/NotFound"; import Review from "$pages/Review"; +import Search from "$pages/Search"; import { Route, Router } from "@solidjs/router"; import type { Component } from "solid-js"; import { Show } from "solid-js"; @@ -39,6 +41,8 @@ const App: Component = () => { } /> } /> } /> + } /> + } /> } /> ); diff --git a/web/src/components/SearchInput.test.tsx b/web/src/components/SearchInput.test.tsx new file mode 100644 index 0000000..f168d59 --- /dev/null +++ b/web/src/components/SearchInput.test.tsx @@ -0,0 +1,46 @@ +import { cleanup, fireEvent, render, screen } from "@solidjs/testing-library"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { SearchInput } from "./SearchInput"; + +const navigateMock = vi.fn(); +vi.mock("@solidjs/router", () => ({ useNavigate: () => navigateMock })); + +describe("SearchInput", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + it("renders correctly", () => { + render(() => ); + expect(screen.getByPlaceholderText("Search decks, cards...")).toBeInTheDocument(); + }); + + it("updates query on input", () => { + render(() => ); + const input = screen.getByPlaceholderText("Search decks, cards...") as HTMLInputElement; + fireEvent.input(input, { target: { value: "test query" } }); + expect(input.value).toBe("test query"); + }); + + it("navigates on submit with query", () => { + render(() => ); + const input = screen.getByPlaceholderText("Search decks, cards..."); + fireEvent.input(input, { target: { value: "test" } }); + fireEvent.submit(input.closest("form")!); + expect(navigateMock).toHaveBeenCalledWith("/search?q=test"); + }); + + it("does not navigate on empty submit", () => { + render(() => ); + const input = screen.getByPlaceholderText("Search decks, cards..."); + fireEvent.submit(input.closest("form")!); + expect(navigateMock).not.toHaveBeenCalled(); + }); + + it("initializes with initialQuery prop", () => { + render(() => ); + const input = screen.getByPlaceholderText("Search decks, cards...") as HTMLInputElement; + expect(input.value).toBe("initial"); + }); +}); diff --git a/web/src/components/SearchInput.tsx b/web/src/components/SearchInput.tsx new file mode 100644 index 0000000..5a2f64b --- /dev/null +++ b/web/src/components/SearchInput.tsx @@ -0,0 +1,37 @@ +import { useNavigate } from "@solidjs/router"; +import clsx from "clsx"; +import type { Component } from "solid-js"; +import { createSignal } from "solid-js"; + +interface SearchInputProps { + class?: string; + initialQuery?: string; +} + +export const SearchInput: Component = (props) => { + const [query, setQuery] = createSignal(props.initialQuery || ""); + const navigate = useNavigate(); + + const handleSearch = (e: Event) => { + e.preventDefault(); + if (query().trim()) { + navigate(`/search?q=${encodeURIComponent(query())}`); + } + }; + + return ( +
+
+
+ + setQuery(e.currentTarget.value)} /> +
+ + ); +}; diff --git a/web/src/components/layout/Header.tsx b/web/src/components/layout/Header.tsx index 6b6609a..2197c5c 100644 --- a/web/src/components/layout/Header.tsx +++ b/web/src/components/layout/Header.tsx @@ -17,6 +17,8 @@ export const Header: Component = () => {
diff --git a/web/src/components/social/CommentSection.tsx b/web/src/components/social/CommentSection.tsx index 177177a..4acd861 100644 --- a/web/src/components/social/CommentSection.tsx +++ b/web/src/components/social/CommentSection.tsx @@ -34,14 +34,22 @@ export function CommentSection(props: CommentSectionProps) { return []; }); - const [newComment, setNewComment] = createSignal(""); + const [mainComment, setMainComment] = createSignal(""); + const [replyComment, setReplyComment] = createSignal(""); const [replyTo, setReplyTo] = createSignal(null); const submitComment = async (parentId?: string) => { - if (!newComment().trim()) return; - await api.addComment(props.deckId, newComment(), parentId); - setNewComment(""); - setReplyTo(null); + const content = parentId ? replyComment() : mainComment(); + if (!content.trim()) return; + + await api.addComment(props.deckId, content, parentId); + + if (parentId) { + setReplyComment(""); + setReplyTo(null); + } else { + setMainComment(""); + } refetch(); }; @@ -51,7 +59,14 @@ export function CommentSection(props: CommentSectionProps) {
{node.node.comment.content}
{new Date(node.node.comment.created_at).toLocaleString()} - +
@@ -59,8 +74,8 @@ export function CommentSection(props: CommentSectionProps) { setNewComment(e.currentTarget.value)} + value={replyComment()} + onInput={(e) => setReplyComment(e.currentTarget.value)} placeholder="Write a reply..." /> @@ -81,13 +96,10 @@ export function CommentSection(props: CommentSectionProps) { class="border rounded p-2 flex-1 w-full" rows={2} placeholder="Add a comment..." - // TODO: separate state - value={replyTo() ? "" : newComment()} - onInput={(e) => { - if (!replyTo()) setNewComment(e.currentTarget.value); - }} /> + value={mainComment()} + onInput={(e) => setMainComment(e.currentTarget.value)} />
- +
diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index a18378b..228ba45 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -28,25 +28,11 @@ export async function apiFetch(path: string, options: RequestInit = {}) { export const api = { get: (path: string) => apiFetch(path, { method: "GET" }), post: (path: string, body: unknown) => apiFetch(path, { method: "POST", body: JSON.stringify(body) }), - getDueCards: (deckId?: string, limit = 20) => { - const params = new URLSearchParams({ limit: String(limit) }); - if (deckId) params.set("deck_id", deckId); - return apiFetch(`/review/due?${params}`, { method: "GET" }); - }, - submitReview: (cardId: string, grade: number) => { - return apiFetch("/review/submit", { method: "POST", body: JSON.stringify({ card_id: cardId, grade }) }); - }, getStats: () => apiFetch("/review/stats", { method: "GET" }), follow: (did: string) => apiFetch(`/social/follow/${did}`, { method: "POST" }), unfollow: (did: string) => apiFetch(`/social/unfollow/${did}`, { method: "POST" }), getFollowers: (did: string) => apiFetch(`/social/followers/${did}`, { method: "GET" }), getFollowing: (did: string) => apiFetch(`/social/following/${did}`, { method: "GET" }), - addComment: (deckId: string, content: string, parentId?: string) => { - return apiFetch(`/decks/${deckId}/comments`, { - method: "POST", - body: JSON.stringify({ content, parent_id: parentId }), - }); - }, getComments: (deckId: string) => apiFetch(`/decks/${deckId}/comments`, { method: "GET" }), getFeedFollows: () => apiFetch("/feeds/follows", { method: "GET" }), getFeedTrending: () => apiFetch("/feeds/trending", { method: "GET" }), @@ -54,6 +40,7 @@ export const api = { getDecks: () => apiFetch("/decks", { method: "GET" }), getDeck: (id: string) => apiFetch(`/decks/${id}`, { method: "GET" }), getDeckCards: (id: string) => apiFetch(`/decks/${id}/cards`, { method: "GET" }), + getDiscovery: () => apiFetch("/discovery", { method: "GET" }), createDeck: async (payload: CreateDeckPayload) => { const { cards, ...deckPayload } = payload; const res = await apiFetch("/decks", { method: "POST", body: JSON.stringify(deckPayload) }); @@ -71,4 +58,22 @@ export const api = { return { ok: true, json: async () => deck }; }, + addComment: (deckId: string, content: string, parentId?: string) => { + return apiFetch(`/decks/${deckId}/comments`, { + method: "POST", + body: JSON.stringify({ content, parent_id: parentId }), + }); + }, + search: (query: string, limit = 20, offset = 0) => { + const params = new URLSearchParams({ q: query, limit: String(limit), offset: String(offset) }); + return apiFetch(`/search?${params}`, { method: "GET" }); + }, + getDueCards: (deckId?: string, limit = 20) => { + const params = new URLSearchParams({ limit: String(limit) }); + if (deckId) params.set("deck_id", deckId); + return apiFetch(`/review/due?${params}`, { method: "GET" }); + }, + submitReview: (cardId: string, grade: number) => { + return apiFetch("/review/submit", { method: "POST", body: JSON.stringify({ card_id: cardId, grade }) }); + }, }; diff --git a/web/src/lib/model.test.ts b/web/src/lib/model.test.ts new file mode 100644 index 0000000..3910412 --- /dev/null +++ b/web/src/lib/model.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { asCard, asDeck, asNote, type SearchResult } from "./model"; + +describe("Type Guards", () => { + const deckResult: SearchResult = { + item_type: "deck", + item_id: "deck1", + creator_did: "did:test", + data: { + id: "deck1", + owner_did: "did:test", + title: "Test Deck", + description: "Description", + tags: [], + visibility: { type: "Public" }, + }, + rank: 1, + }; + + const cardResult: SearchResult = { + item_type: "card", + item_id: "card1", + creator_did: "did:test", + data: { front: "Front", back: "Back", deck_id: "deck1" }, + rank: 1, + }; + + const noteResult: SearchResult = { + item_type: "note", + item_id: "note1", + creator_did: "did:test", + data: { id: "note1", title: "Test Note", owner_did: "did:test" }, + rank: 1, + }; + + it("asDeck correctly identifies decks", () => { + expect(asDeck(deckResult)).toBe(deckResult); + expect(asDeck(cardResult)).toBeUndefined(); + expect(asDeck(noteResult)).toBeUndefined(); + }); + + it("asCard correctly identifies cards", () => { + expect(asCard(cardResult)).toBe(cardResult); + expect(asCard(deckResult)).toBeUndefined(); + expect(asCard(noteResult)).toBeUndefined(); + }); + + it("asNote correctly identifies notes", () => { + expect(asNote(noteResult)).toBe(noteResult); + expect(asNote(deckResult)).toBeUndefined(); + expect(asNote(cardResult)).toBeUndefined(); + }); +}); diff --git a/web/src/lib/model.ts b/web/src/lib/model.ts index f80e89b..4a7ffb5 100644 --- a/web/src/lib/model.ts +++ b/web/src/lib/model.ts @@ -69,3 +69,23 @@ export type Comment = { }; export type CommentNode = { comment: Comment; children: CommentNode[] }; + +export type FeedFollows = { decks: Deck[] }; + +export type SearchResult = { item_type: "deck"; item_id: string; creator_did: string; data: Deck; rank: number } | { + item_type: "card"; + item_id: string; + creator_did: string; + data: Card & { deck_id: string }; + rank: number; +} | { + item_type: "note"; + item_id: string; + creator_did: string; + data: { id: string; title: string; owner_did: string }; + rank: number; +}; + +export const asDeck = (r: SearchResult) => (r.item_type === "deck" ? r : undefined); +export const asCard = (r: SearchResult) => (r.item_type === "card" ? r : undefined); +export const asNote = (r: SearchResult) => (r.item_type === "note" ? r : undefined); diff --git a/web/src/pages/Discovery.tsx b/web/src/pages/Discovery.tsx new file mode 100644 index 0000000..9ce656d --- /dev/null +++ b/web/src/pages/Discovery.tsx @@ -0,0 +1,65 @@ +import { SearchInput } from "$components/SearchInput"; +import { api } from "$lib/api"; +import { A } from "@solidjs/router"; +import type { Component } from "solid-js"; +import { createResource, For, Show } from "solid-js"; + +// TODO: type discovery response +const Discovery: Component = () => { + const [data] = createResource(async () => { + const res = await api.getDiscovery(); + if (res.ok) return await res.json(); + return { top_tags: [] }; + }); + + return ( +
+
+

+ Discover Malfestio +

+

Explore community decks and popular topics

+
+ +
+
+ +
+

+
+ Top Tags +

+ + +
+
+ }> +
+ + {(tag: [string, number]) => ( + + + #{tag[0]} + + + {tag[1]} + + + )} + + +

No tags found yet. Create some decks!

+
+
+
+
+
+ ); +}; + +export default Discovery; diff --git a/web/src/pages/Search.test.tsx b/web/src/pages/Search.test.tsx new file mode 100644 index 0000000..9616353 --- /dev/null +++ b/web/src/pages/Search.test.tsx @@ -0,0 +1,113 @@ +import { api } from "$lib/api"; +import { cleanup, render, screen, waitFor } from "@solidjs/testing-library"; +import { JSX } from "solid-js"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import Search from "./Search"; + +vi.mock("$lib/api", () => ({ api: { search: vi.fn() } })); + +const { mockSearchParams } = vi.hoisted(() => ({ mockSearchParams: { q: "" } })); + +vi.mock( + "@solidjs/router", + () => ({ + useSearchParams: () => [mockSearchParams], + A: (props: { href: string; children: JSX.Element }) => {props.children}, + useNavigate: () => vi.fn(), + }), +); + +describe("Search", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + const mockSearchResults = [{ + item_type: "deck", + item_id: "deck1", + creator_did: "did:test:1", + data: { + id: "deck1", + owner_did: "did:test:1", + title: "Test Deck", + description: "A test deck", + tags: ["test"], + visibility: { type: "Public" }, + }, + rank: 0.9, + }, { + item_type: "card", + item_id: "card1", + creator_did: "did:test:1", + data: { id: "card1", deck_id: "deck1", front: "Card Front", back: "Card Back", owner_did: "did:test:1" }, + rank: 0.8, + }, { + item_type: "note", + item_id: "note1", + creator_did: "did:test:1", + data: { id: "note1", title: "Test Note", owner_did: "did:test:1" }, + rank: 0.7, + }]; + + it("renders search results correctly", async () => { + mockSearchParams.q = "test"; + vi.mocked(api.search).mockResolvedValue( + { ok: true, json: () => Promise.resolve(mockSearchResults) } as unknown as Response, + ); + + render(() => ); + + // Verify loading state or results + await waitFor(() => expect(screen.getByText("Search Results")).toBeInTheDocument()); + + // Verify Deck result + await waitFor(() => expect(screen.getByText("Test Deck")).toBeInTheDocument()); + expect(screen.getByText("A test deck")).toBeInTheDocument(); + + // Verify Card result + expect(screen.getByText("Card Front")).toBeInTheDocument(); + expect(screen.getByText("Card Back")).toBeInTheDocument(); + + // Verify Note result + expect(screen.getByText("Test Note")).toBeInTheDocument(); + }); + + it("shows empty state when no results", async () => { + mockSearchParams.q = "nonexistent"; + vi.mocked(api.search).mockResolvedValue({ ok: true, json: () => Promise.resolve([]) } as unknown as Response); + + render(() => ); + + await waitFor(() => expect(screen.getByText("No results found for \"nonexistent\"")).toBeInTheDocument()); + }); + + it("handles loading state", async () => { + mockSearchParams.q = "loading"; + vi.mocked(api.search).mockReturnValue(new Promise(() => {})); + + render(() => ); + + expect(api.search).toHaveBeenCalledWith("loading"); + }); + + it("generates correct links for results", async () => { + mockSearchParams.q = "test"; + vi.mocked(api.search).mockResolvedValue( + { ok: true, json: () => Promise.resolve(mockSearchResults) } as unknown as Response, + ); + + render(() => ); + + await waitFor(() => expect(screen.getByText("Test Deck")).toBeInTheDocument()); + + const deckLink = screen.getByText("Test Deck").closest("a"); + expect(deckLink).toHaveAttribute("href", "/decks/deck1"); + + const cardLink = screen.getByText("Card in Deck").closest("a"); + expect(cardLink).toHaveAttribute("href", "/decks/deck1"); + + const noteLink = screen.getByText("Test Note").closest("a"); + expect(noteLink).toHaveAttribute("href", "/notes/note1"); + }); +}); diff --git a/web/src/pages/Search.tsx b/web/src/pages/Search.tsx new file mode 100644 index 0000000..068c9f3 --- /dev/null +++ b/web/src/pages/Search.tsx @@ -0,0 +1,116 @@ +import { SearchInput } from "$components/SearchInput"; +import { Card } from "$components/ui/Card"; +import { api } from "$lib/api"; +import { asCard, asDeck, asNote, type SearchResult } from "$lib/model"; +import { A, useSearchParams } from "@solidjs/router"; +import type { Component } from "solid-js"; +import { createResource, For, Match, Show, Switch } from "solid-js"; + +const Search: Component = () => { + const [searchParams] = useSearchParams(); + const query = () => { + const q = searchParams.q; + return Array.isArray(q) ? q[0] : q || ""; + }; + + const [results] = createResource(query, async (q) => { + if (!q) return []; + const res = await api.search(q); + if (res.ok) return await res.json() as SearchResult[]; + return []; + }); + + return ( +
+
+

Search Results

+
+ +
+
+ + +
+
+
+ + + +
+ + + +
+ + {(result) => ( + +
+
+ +
+ + +
+ + +
+ +
+
+ + + {(item) => ( + <> + + {item().data.title} + +

{item().data.description}

+ + )} +
+ + {(item) => ( + <> + + Card in Deck + +

+ Front: {item().data.front} +

+

+ Back: {item().data.back} +

+ + )} +
+ + {(item) => ( + <> + + {item().data.title} + +
+ Content match +
+ + )} +
+
+
+ Result Type: {result.item_type} • Score: {result.rank.toFixed(2)} +
+
+
+ + )} + +
+
+ ); +}; + +export default Search; -- 2.51.2