+1
-1
crates/server/src/api/auth.rs
+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
+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
+1
crates/server/src/api/mod.rs
+4
crates/server/src/api/review.rs
+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
+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
+
}
+4
crates/server/src/lib.rs
+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
+1
crates/server/src/repository/mod.rs
+172
crates/server/src/repository/search.rs
+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
+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
+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
+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
+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
+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
+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
+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">
+19
-14
web/src/lib/api.ts
+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
+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
+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
+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
+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
+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;