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

feat: content discovery, remote deck preview, and data export functionality with corresponding endpoints

+776 -45
+25 -1
crates/server/src/api/deck.rs
··· 4 4 use crate::repository::deck::{CreateDeckParams, DeckRepoError, UpdateDeckParams}; 5 5 use axum::{ 6 6 Json, 7 - extract::{Extension, Path, State}, 7 + extract::{Extension, Path, Query, State}, 8 8 http::StatusCode, 9 9 response::IntoResponse, 10 10 }; ··· 48 48 ( 49 49 StatusCode::INTERNAL_SERVER_ERROR, 50 50 Json(json!({"error": "Failed to create deck"})), 51 + ) 52 + .into_response() 53 + } 54 + } 55 + } 56 + 57 + #[derive(Deserialize)] 58 + pub struct RemoteDeckQuery { 59 + uri: String, 60 + } 61 + 62 + pub async fn fetch_remote_deck( 63 + State(state): State<SharedState>, Query(query): Query<RemoteDeckQuery>, 64 + ) -> impl IntoResponse { 65 + match state.deck_repo.get_remote_deck(&query.uri).await { 66 + Ok((deck, cards)) => Json(json!({ "deck": deck, "cards": cards })).into_response(), 67 + Err(DeckRepoError::NotFound(_)) => { 68 + (StatusCode::NOT_FOUND, Json(json!({"error": "Deck not found"}))).into_response() 69 + } 70 + Err(e) => { 71 + tracing::error!("Failed to fetch remote deck: {:?}", e); 72 + ( 73 + StatusCode::INTERNAL_SERVER_ERROR, 74 + Json(json!({"error": "Failed to fetch remote deck"})), 51 75 ) 52 76 .into_response() 53 77 }
+41
crates/server/src/api/export.rs
··· 1 + use crate::middleware::auth::UserContext; 2 + use crate::state::SharedState; 3 + use axum::{ 4 + Json, 5 + extract::{Extension, Path, State}, 6 + http::StatusCode, 7 + response::IntoResponse, 8 + }; 9 + use serde_json::json; 10 + 11 + /// GET /api/export/:collection 12 + /// Export data for the authenticated user 13 + pub async fn export_collection( 14 + State(state): State<SharedState>, Extension(user): Extension<UserContext>, Path(collection): Path<String>, 15 + ) -> impl IntoResponse { 16 + match collection.as_str() { 17 + "decks" => match state.deck_repo.get_decks_by_user(&user.did).await { 18 + Ok(decks) => Json(json!(decks)).into_response(), 19 + Err(e) => { 20 + tracing::error!("Failed to export decks: {:?}", e); 21 + ( 22 + StatusCode::INTERNAL_SERVER_ERROR, 23 + Json(json!({"error": "Failed to export decks"})), 24 + ) 25 + .into_response() 26 + } 27 + }, 28 + "notes" => match state.note_repo.get_notes_by_user(&user.did).await { 29 + Ok(notes) => Json(json!(notes)).into_response(), 30 + Err(e) => { 31 + tracing::error!("Failed to export notes: {:?}", e); 32 + ( 33 + StatusCode::INTERNAL_SERVER_ERROR, 34 + Json(json!({"error": "Failed to export notes"})), 35 + ) 36 + .into_response() 37 + } 38 + }, 39 + _ => (StatusCode::BAD_REQUEST, Json(json!({"error": "Invalid collection"}))).into_response(), 40 + } 41 + }
+1
crates/server/src/api/mod.rs
··· 1 1 pub mod auth; 2 2 pub mod card; 3 3 pub mod deck; 4 + pub mod export; 4 5 pub mod feed; 5 6 pub mod importer; 6 7 pub mod note;
+10 -3
crates/server/src/api/search.rs
··· 16 16 limit: i64, 17 17 #[serde(default = "default_offset")] 18 18 offset: i64, 19 + source: Option<String>, 19 20 } 20 21 21 22 fn default_limit() -> i64 { ··· 37 38 38 39 match state 39 40 .search_repo 40 - .search(&query.q, query.limit, query.offset, user_did.as_deref()) 41 + .search( 42 + &query.q, 43 + query.limit, 44 + query.offset, 45 + user_did.as_deref(), 46 + query.source.as_deref(), 47 + ) 41 48 .await 42 49 { 43 50 Ok(results) => Json(results).into_response(), ··· 130 137 let response = search( 131 138 State(state.clone()), 132 139 Some(auth_ctx), 133 - Query(SearchQuery { q: "private".to_string(), limit: 10, offset: 0 }), 140 + Query(SearchQuery { q: "private".to_string(), limit: 10, offset: 0, source: None }), 134 141 ) 135 142 .await 136 143 .into_response(); ··· 144 151 let response_anon = search( 145 152 State(state.clone()), 146 153 None, 147 - Query(SearchQuery { q: "private".to_string(), limit: 10, offset: 0 }), 154 + Query(SearchQuery { q: "private".to_string(), limit: 10, offset: 0, source: None }), 148 155 ) 149 156 .await 150 157 .into_response();
+2
crates/server/src/lib.rs
··· 85 85 .route("/feeds/follows", get(api::feed::get_feed_follows)) 86 86 .route("/preferences", get(api::preferences::get_preferences)) 87 87 .route("/preferences", axum::routing::put(api::preferences::update_preferences)) 88 + .route("/export/{collection}", get(api::export::export_collection)) 88 89 .layer(axum_middleware::from_fn_with_state( 89 90 state.clone(), 90 91 middleware::auth::auth_middleware, ··· 103 104 .route("/search", get(api::search::search)) 104 105 .route("/discovery", get(api::search::discovery)) 105 106 .route("/users/{did}/profile", get(api::users::get_profile)) 107 + .route("/remote/deck", get(api::deck::fetch_remote_deck)) 106 108 .layer(axum_middleware::from_fn_with_state( 107 109 state.clone(), 108 110 middleware::auth::optional_auth_middleware,
+114 -1
crates/server/src/repository/deck.rs
··· 34 34 async fn list_visible(&self, viewer_did: Option<&str>) -> Result<Vec<Deck>, DeckRepoError>; 35 35 async fn update(&self, params: UpdateDeckParams) -> Result<Deck, DeckRepoError>; 36 36 async fn fork(&self, original_deck_id: &str, user_did: &str) -> Result<Deck, DeckRepoError>; 37 + async fn get_decks_by_user(&self, owner_did: &str) -> Result<Vec<Deck>, DeckRepoError>; 38 + async fn get_remote_deck(&self, at_uri: &str) -> Result<(Deck, Vec<malfestio_core::model::Card>), DeckRepoError>; 37 39 } 38 40 39 41 pub struct DbDeckRepository { ··· 221 223 } 222 224 .map_err(|e| DeckRepoError::DatabaseError(format!("Failed to update deck: {}", e)))?; 223 225 224 - // Return updated deck 225 226 self.get(&params.deck_id).await 226 227 } 227 228 ··· 313 314 fork_of: Some(original_deck_id.to_string()), 314 315 }) 315 316 } 317 + 318 + async fn get_decks_by_user(&self, owner_did: &str) -> Result<Vec<Deck>, DeckRepoError> { 319 + let client = self 320 + .pool 321 + .get() 322 + .await 323 + .map_err(|e| DeckRepoError::DatabaseError(format!("Failed to get connection: {}", e)))?; 324 + 325 + let rows = client 326 + .query( 327 + "SELECT id, owner_did, title, description, tags, visibility, published_at, fork_of 328 + FROM decks 329 + WHERE owner_did = $1", 330 + &[&owner_did], 331 + ) 332 + .await 333 + .map_err(|e| DeckRepoError::DatabaseError(format!("Failed to retrieve decks: {}", e)))?; 334 + 335 + let mut decks = Vec::new(); 336 + for row in rows { 337 + let visibility_json: serde_json::Value = row.get("visibility"); 338 + let visibility: Visibility = serde_json::from_value(visibility_json).unwrap_or(Visibility::Private); 339 + let fork_of: Option<Uuid> = row.get("fork_of"); 340 + 341 + decks.push(Deck { 342 + id: row.get::<_, Uuid>("id").to_string(), 343 + owner_did: row.get("owner_did"), 344 + title: row.get("title"), 345 + description: row.get("description"), 346 + tags: row.get("tags"), 347 + visibility, 348 + published_at: row 349 + .get::<_, Option<chrono::DateTime<chrono::Utc>>>("published_at") 350 + .map(|dt| dt.to_rfc3339()), 351 + fork_of: fork_of.map(|u| u.to_string()), 352 + }); 353 + } 354 + Ok(decks) 355 + } 356 + 357 + async fn get_remote_deck(&self, at_uri: &str) -> Result<(Deck, Vec<malfestio_core::model::Card>), DeckRepoError> { 358 + let client = self 359 + .pool 360 + .get() 361 + .await 362 + .map_err(|e| DeckRepoError::DatabaseError(format!("Failed to get connection: {}", e)))?; 363 + 364 + let deck_row = client 365 + .query_opt( 366 + "SELECT did, title, description, tags FROM indexed_decks WHERE at_uri = $1 AND deleted_at IS NULL", 367 + &[&at_uri], 368 + ) 369 + .await 370 + .map_err(|e| DeckRepoError::DatabaseError(format!("Failed to query remote deck: {}", e)))? 371 + .ok_or_else(|| DeckRepoError::NotFound("Remote deck not found".to_string()))?; 372 + 373 + let deck = Deck { 374 + id: at_uri.to_string(), 375 + owner_did: deck_row.get("did"), 376 + title: deck_row.get("title"), 377 + description: deck_row.get("description"), 378 + tags: deck_row.get("tags"), 379 + visibility: Visibility::Public, 380 + published_at: None, 381 + fork_of: None, 382 + }; 383 + 384 + let card_rows = client 385 + .query( 386 + "SELECT at_uri, did, front, back, media_url, hints FROM indexed_cards WHERE deck_ref = $1 AND deleted_at IS NULL", 387 + &[&at_uri], 388 + ) 389 + .await 390 + .map_err(|e| DeckRepoError::DatabaseError(format!("Failed to query remote cards: {}", e)))?; 391 + 392 + let mut cards = Vec::new(); 393 + for row in card_rows { 394 + let hints: Vec<String> = row.get("hints"); 395 + cards.push(malfestio_core::model::Card { 396 + id: row.get("at_uri"), 397 + owner_did: row.get("did"), 398 + deck_id: at_uri.to_string(), 399 + front: row.get("front"), 400 + back: row.get("back"), 401 + media_url: row.get("media_url"), 402 + hints, 403 + // TODO: support other card types 404 + card_type: malfestio_core::model::CardType::Basic, 405 + }); 406 + } 407 + 408 + Ok((deck, cards)) 409 + } 316 410 } 317 411 318 412 #[cfg(test)] ··· 403 497 fork_of: Some(original_deck_id.to_string()), 404 498 }; 405 499 decks.push(deck.clone()); 500 + decks.push(deck.clone()); 406 501 Ok(deck) 502 + } 503 + 504 + async fn get_decks_by_user(&self, owner_did: &str) -> Result<Vec<Deck>, DeckRepoError> { 505 + let decks = self.decks.lock().unwrap(); 506 + let user_decks = decks.iter().filter(|d| d.owner_did == owner_did).cloned().collect(); 507 + Ok(user_decks) 508 + } 509 + 510 + async fn get_remote_deck( 511 + &self, at_uri: &str, 512 + ) -> Result<(Deck, Vec<malfestio_core::model::Card>), DeckRepoError> { 513 + let decks = self.decks.lock().unwrap(); 514 + let deck = decks 515 + .iter() 516 + .find(|d| d.id == at_uri) 517 + .cloned() 518 + .ok_or_else(|| DeckRepoError::NotFound("Deck not found".to_string()))?; 519 + Ok((deck, vec![])) 407 520 } 408 521 } 409 522 }
+48 -2
crates/server/src/repository/note.rs
··· 14 14 async fn create( 15 15 &self, owner_did: &str, title: &str, body: &str, tags: Vec<String>, visibility: Visibility, 16 16 ) -> Result<Note, NoteRepoError>; 17 - 18 17 async fn list(&self, viewer_did: Option<&str>) -> Result<Vec<Note>, NoteRepoError>; 19 - 20 18 async fn get(&self, id: &str, viewer_did: Option<&str>) -> Result<Note, NoteRepoError>; 19 + async fn get_notes_by_user(&self, owner_did: &str) -> Result<Vec<Note>, NoteRepoError>; 21 20 } 22 21 23 22 pub struct DbNoteRepository { ··· 177 176 links, 178 177 }) 179 178 } 179 + 180 + async fn get_notes_by_user(&self, owner_did: &str) -> Result<Vec<Note>, NoteRepoError> { 181 + let client = self 182 + .pool 183 + .get() 184 + .await 185 + .map_err(|e| NoteRepoError::DatabaseError(format!("Failed to get connection: {}", e)))?; 186 + 187 + let rows = client 188 + .query( 189 + "SELECT id, owner_did, title, body, tags, visibility, published_at, links, created_at, updated_at 190 + FROM notes 191 + WHERE owner_did = $1", 192 + &[&owner_did], 193 + ) 194 + .await 195 + .map_err(|e| NoteRepoError::DatabaseError(format!("Failed to retrieve notes: {}", e)))?; 196 + 197 + let mut notes = Vec::new(); 198 + for row in rows { 199 + let visibility_json: serde_json::Value = row.get("visibility"); 200 + let visibility: Visibility = serde_json::from_value(visibility_json) 201 + .map_err(|e| NoteRepoError::SerializationError(format!("Failed to deserialize visibility: {}", e)))?; 202 + let id: uuid::Uuid = row.get("id"); 203 + let links: Vec<String> = row.get("links"); 204 + 205 + notes.push(Note { 206 + id: id.to_string(), 207 + owner_did: row.get("owner_did"), 208 + title: row.get("title"), 209 + body: row.get("body"), 210 + tags: row.get("tags"), 211 + visibility, 212 + published_at: row 213 + .get::<_, Option<chrono::DateTime<chrono::Utc>>>("published_at") 214 + .map(|dt| dt.to_rfc3339()), 215 + links, 216 + }); 217 + } 218 + Ok(notes) 219 + } 180 220 } 181 221 182 222 #[cfg(test)] ··· 282 322 } 283 323 284 324 Ok(note.clone()) 325 + } 326 + 327 + async fn get_notes_by_user(&self, owner_did: &str) -> Result<Vec<Note>, NoteRepoError> { 328 + let notes = self.notes.lock().unwrap(); 329 + let user_notes = notes.iter().filter(|n| n.owner_did == owner_did).cloned().collect(); 330 + Ok(user_notes) 285 331 } 286 332 } 287 333 }
+10 -7
crates/server/src/repository/search.rs
··· 14 14 15 15 #[async_trait::async_trait] 16 16 pub trait SearchRepository: Send + Sync { 17 - async fn search(&self, query: &str, limit: i64, offset: i64, viewer_did: Option<&str>) 18 - -> Result<Vec<SearchResult>>; 17 + async fn search( 18 + &self, query: &str, limit: i64, offset: i64, viewer_did: Option<&str>, source: Option<&str>, 19 + ) -> Result<Vec<SearchResult>>; 19 20 async fn get_top_tags(&self, limit: i64) -> Result<Vec<(String, i64)>>; 20 21 } 21 22 ··· 32 33 #[async_trait::async_trait] 33 34 impl SearchRepository for DbSearchRepository { 34 35 async fn search( 35 - &self, query: &str, limit: i64, offset: i64, viewer_did: Option<&str>, 36 + &self, query: &str, limit: i64, offset: i64, viewer_did: Option<&str>, source: Option<&str>, 36 37 ) -> Result<Vec<SearchResult>> { 37 38 let client = self 38 39 .pool ··· 41 42 .map_err(|e| malfestio_core::Error::Database(e.to_string()))?; 42 43 43 44 // TODO: implement shared-with logic. 45 + // If source is provided, filter by it. 44 46 let sql = " 45 47 SELECT 46 48 item_type, ··· 55 57 visibility->>'type' = 'Public' 56 58 OR (creator_did = $4) 57 59 ) 60 + AND ($5::text IS NULL OR source = $5) 58 61 ORDER BY rank DESC 59 62 LIMIT $2 OFFSET $3 60 63 "; 61 64 62 65 let rows = client 63 - .query(sql, &[&query, &limit, &offset, &viewer_did]) 66 + .query(sql, &[&query, &limit, &offset, &viewer_did, &source]) 64 67 .await 65 68 .map_err(|e| malfestio_core::Error::Database(e.to_string()))?; 66 69 ··· 140 143 #[async_trait::async_trait] 141 144 impl SearchRepository for MockSearchRepository { 142 145 async fn search( 143 - &self, query: &str, limit: i64, offset: i64, viewer_did: Option<&str>, 146 + &self, query: &str, limit: i64, offset: i64, viewer_did: Option<&str>, source: Option<&str>, 144 147 ) -> Result<Vec<SearchResult>> { 145 148 let results = self.search_results.lock().await; 146 149 ··· 157 160 == Some("Public"); 158 161 159 162 let matches_auth = viewer_did.map_or(is_public, |did| r.creator_did == did || is_public); 160 - 161 - matches_query && matches_auth 163 + let matches_source = source.is_none_or(|s| r.source == s); 164 + matches_query && matches_auth && matches_source 162 165 }) 163 166 .skip(offset as usize) 164 167 .take(limit as usize)
+2 -25
docs/todo.md
··· 31 31 - SEO & Meta: Open Graph / Twitter Card meta tags, Sitemap.xml generation, robots.txt configuration. 32 32 - Onboarding Flow & Help Center/FAQ 33 33 - Empty States 34 - 35 - ### Milestone K - AppView Indexing 36 - 37 - #### Deliverables 38 - 39 - **Firehose Enhancement:** 40 - 41 - - [x] Upgrade firehose consumer to store full record content (not just metadata) 42 - - [x] Add `indexed_decks`, `indexed_cards`, `indexed_notes` tables for remote content 43 - - [x] Track latest processed revision per repo; handle deletions 44 - 45 - **Search & Discovery:** 46 - 47 - - [x] Implement search over indexed remote records (extend `search_items` view) 48 - - [x] User profile aggregation: follower counts, deck counts from federated sources 49 - 50 - **Export & Interop:** 51 - 52 - - [ ] Export local records as valid Lexicon JSON (`/api/export/:collection`) 53 - - [ ] Read-only "federated library" view showing remote decks 54 - 55 - #### Acceptance 56 - 57 - - A deck published from Malfestio can be discovered via another AT Protocol client. 58 - - Remote decks from followed users appear in search results. 34 + - **(Done) Milestone K**: AppView Indexing. 35 + - Firehose Enhancement (full record storage), Extended Search & Discovery (remote records), and Export/Interop features (JSON export, remote deck view). 59 36 60 37 ### Milestone L - ATProto Integration Pass 61 38
+6
web/src/App.tsx
··· 4 4 import { authStore, prefStore } from "$lib/store"; 5 5 import About from "$pages/About"; 6 6 import DeckNew from "$pages/DeckNew"; 7 + import DeckPreview from "$pages/DeckPreview"; 7 8 import DeckView from "$pages/DeckView"; 8 9 import Discovery from "$pages/Discovery"; 9 10 import Feed from "$pages/Feed"; ··· 12 13 import Import from "$pages/Import"; 13 14 import Landing from "$pages/Landing"; 14 15 import LectureImport from "$pages/LectureImport"; 16 + import Library from "$pages/Library"; 15 17 import Login from "$pages/Login"; 16 18 import NoteNew from "$pages/NoteNew"; 17 19 import NotFound from "$pages/NotFound"; 18 20 import Review from "$pages/Review"; 19 21 import Search from "$pages/Search"; 22 + import Settings from "$pages/Settings"; 20 23 import { Route, Router } from "@solidjs/router"; 21 24 import type { Component, ParentComponent } from "solid-js"; 22 25 import { createEffect, createSignal, onMount, Show } from "solid-js"; ··· 68 71 <Route path="/feed" component={Feed} /> 69 72 <Route path="/search" component={Search} /> 70 73 <Route path="/discovery" component={Discovery} /> 74 + <Route path="/library" component={Library} /> 75 + <Route path="/library/preview" component={DeckPreview} /> 76 + <Route path="/settings" component={Settings} /> 71 77 <Route path="*" component={NotFound} /> 72 78 </Route> 73 79 </Router>
+4 -2
web/src/components/layout/Header.tsx
··· 15 15 <div class="flex items-center gap-6"> 16 16 <A href="/" class="text-xl font-bold text-white tracking-tight">Malfestio</A> 17 17 <nav class="hidden md:flex items-center gap-4 text-sm font-medium text-gray-400"> 18 - <A href="/decks" activeClass="text-blue-500" class="hover:text-white transition-colors">Decks</A> 19 - <A href="/review" activeClass="text-blue-500" class="hover:text-white transition-colors">Review</A> 18 + <A href="/home" activeClass="text-blue-500" class="hover:text-white transition-colors">Decks</A> 19 + <A href="/study" activeClass="text-blue-500" class="hover:text-white transition-colors">Review</A> 20 20 <A href="/discovery" activeClass="text-blue-500" class="hover:text-white transition-colors">Discovery</A> 21 + <A href="/library" activeClass="text-blue-500" class="hover:text-white transition-colors">Library</A> 21 22 <A href="/feed" activeClass="text-blue-500" class="hover:text-white transition-colors">Feed</A> 23 + <A href="/settings" activeClass="text-blue-500" class="hover:text-white transition-colors">Settings</A> 22 24 </nav> 23 25 </div> 24 26 <div class="flex items-center gap-4">
+58
web/src/components/tests/DeckPreview.test.tsx
··· 1 + import { api } from "$lib/api"; 2 + import DeckPreview from "$pages/DeckPreview"; 3 + import { cleanup, render, screen, waitFor } from "@solidjs/testing-library"; 4 + import type { JSX } from "solid-js"; 5 + import { afterEach, describe, expect, it, type Mock, vi } from "vitest"; 6 + 7 + vi.mock("$lib/api", () => ({ api: { getRemoteDeck: vi.fn() } })); 8 + 9 + vi.mock( 10 + "@solidjs/router", 11 + () => ({ 12 + useSearchParams: () => [{ uri: "at://did:plc:test/app.malfestio.deck/123" }], 13 + useNavigate: () => vi.fn(), 14 + A: (props: { href: string; children: JSX.Element }) => <a href={props.href}>{props.children}</a>, 15 + }), 16 + ); 17 + 18 + describe("DeckPreview", () => { 19 + afterEach(cleanup); 20 + 21 + it("renders loading state initially", () => { 22 + (api.getRemoteDeck as Mock).mockReturnValue(new Promise(() => {})); 23 + const { container } = render(() => <DeckPreview />); 24 + expect(container.querySelector(".animate-spin")).toBeInTheDocument(); 25 + }); 26 + 27 + it("renders deck details when loaded", async () => { 28 + (api.getRemoteDeck as Mock).mockResolvedValue({ 29 + ok: true, 30 + json: async () => ({ 31 + deck: { 32 + id: "at://did:plc:test/app.malfestio.deck/123", 33 + owner_did: "did:plc:test", 34 + title: "Remote Deck", 35 + description: "A test deck", 36 + tags: ["test"], 37 + visibility: { type: "Public" }, 38 + }, 39 + cards: [{ id: "card1", front: "Question", back: "Answer", deck_id: "deck1", owner_did: "did:plc:test" }], 40 + }), 41 + }); 42 + 43 + render(() => <DeckPreview />); 44 + 45 + await waitFor(() => expect(screen.getByText("Remote Deck")).toBeInTheDocument()); 46 + expect(screen.getByText("By did:plc:test")).toBeInTheDocument(); 47 + expect(screen.getByText("Question")).toBeInTheDocument(); 48 + expect(screen.getByText("Answer")).toBeInTheDocument(); 49 + }); 50 + 51 + it("renders error state when fetch fails", async () => { 52 + (api.getRemoteDeck as Mock).mockResolvedValue({ ok: false }); 53 + 54 + render(() => <DeckPreview />); 55 + 56 + await waitFor(() => expect(screen.getByText("Could not load the requested remote deck.")).toBeInTheDocument()); 57 + }); 58 + });
+56
web/src/components/tests/Library.test.tsx
··· 1 + import { api } from "$lib/api"; 2 + import Library from "$pages/Library"; 3 + import { cleanup, render, screen, waitFor } from "@solidjs/testing-library"; 4 + import type { JSX } from "solid-js"; 5 + import { afterEach, describe, expect, it, type Mock, vi } from "vitest"; 6 + 7 + vi.mock("$lib/api", () => ({ api: { search: vi.fn() } })); 8 + 9 + vi.mock( 10 + "@solidjs/router", 11 + () => ({ A: (props: { href: string; children: JSX.Element }) => <a href={props.href}>{props.children}</a> }), 12 + ); 13 + 14 + describe("Library page", () => { 15 + afterEach(cleanup); 16 + 17 + it("renders loading state initially", () => { 18 + (api.search as Mock).mockReturnValue(new Promise(() => {})); 19 + const { container } = render(() => <Library />); 20 + expect(container.querySelector(".animate-spin")).toBeInTheDocument(); 21 + }); 22 + 23 + it("renders remote decks when loaded", async () => { 24 + (api.search as Mock).mockResolvedValue({ 25 + ok: true, 26 + json: 27 + async () => [{ 28 + item_type: "deck", 29 + item_id: "deck1", 30 + creator_did: "did:plc:other", 31 + data: { 32 + title: "Federated Deck", 33 + description: "From another server", 34 + tags: ["remote"], 35 + at_uri: "at://did:plc:other/app.malfestio.deck/deck1", 36 + }, 37 + rank: 1, 38 + source: "remote", 39 + }], 40 + }); 41 + 42 + render(() => <Library />); 43 + 44 + await waitFor(() => expect(screen.getByText("Federated Deck")).toBeInTheDocument()); 45 + expect(screen.getByText("by did:plc:othe...")).toBeInTheDocument(); 46 + expect(screen.getByText("#remote")).toBeInTheDocument(); 47 + }); 48 + 49 + it("renders empty state when no content", async () => { 50 + (api.search as Mock).mockResolvedValue({ ok: true, json: async () => [] }); 51 + 52 + render(() => <Library />); 53 + 54 + await waitFor(() => expect(screen.getByText("No federated content found")).toBeInTheDocument()); 55 + }); 56 + });
+49
web/src/components/tests/Settings.test.tsx
··· 1 + import { api } from "$lib/api"; 2 + import Settings from "$pages/Settings"; 3 + import { cleanup, fireEvent, render, screen, waitFor } from "@solidjs/testing-library"; 4 + import { afterEach, describe, expect, it, type Mock, vi } from "vitest"; 5 + 6 + vi.mock("$lib/api", () => ({ api: { exportData: vi.fn() } })); 7 + 8 + vi.stubGlobal("URL", { createObjectURL: vi.fn(() => "blob:test"), revokeObjectURL: vi.fn() }); 9 + 10 + describe("Settings page", () => { 11 + afterEach(cleanup); 12 + 13 + it("renders export buttons", () => { 14 + render(() => <Settings />); 15 + expect(screen.getByRole("heading", { name: "Export Data" })).toBeInTheDocument(); 16 + expect(screen.getByRole("button", { name: "Export Decks" })).toBeInTheDocument(); 17 + expect(screen.getByRole("button", { name: "Export Notes" })).toBeInTheDocument(); 18 + }); 19 + 20 + it("handles deck export", async () => { 21 + (api.exportData as Mock).mockResolvedValue({ 22 + ok: true, 23 + blob: async () => new Blob(["test"], { type: "application/json" }), 24 + }); 25 + 26 + render(() => <Settings />); 27 + 28 + const exportBtn = screen.getByRole("button", { name: "Export Decks" }); 29 + fireEvent.click(exportBtn); 30 + 31 + expect(screen.getByText("Exporting...")).toBeInTheDocument(); 32 + await waitFor(() => expect(api.exportData).toHaveBeenCalledWith("decks")); 33 + }); 34 + 35 + it("handles notes export", async () => { 36 + (api.exportData as Mock).mockResolvedValue({ 37 + ok: true, 38 + blob: async () => new Blob(["test"], { type: "application/json" }), 39 + }); 40 + 41 + render(() => <Settings />); 42 + 43 + const exportBtn = screen.getByRole("button", { name: "Export Notes" }); 44 + fireEvent.click(exportBtn); 45 + 46 + expect(screen.getByText("Exporting...")).toBeInTheDocument(); 47 + await waitFor(() => expect(api.exportData).toHaveBeenCalledWith("notes")); 48 + }); 49 + });
+3 -3
web/src/components/ui/Toast.tsx
··· 1 + import { toast, toasts, type ToastType } from "$lib/toast"; 1 2 import { For, Match, Switch } from "solid-js"; 2 3 import type { Component } from "solid-js"; 3 - import { toast, toasts, type ToastType } from "../../lib/toast"; 4 4 5 5 const borderColors: Record<ToastType, string> = { 6 6 success: "border-l-4 border-green-500", ··· 34 34 borderColors[t.type] 35 35 }`} 36 36 role="alert"> 37 - <div class="flex-shrink-0"> 37 + <div class="shrink-0"> 38 38 <Switch fallback={<InfoIcon />}> 39 39 <Match when={t.type === "success"}> 40 40 <svg ··· 79 79 <div class="flex-1 text-sm">{t.message}</div> 80 80 <button 81 81 onClick={() => toast.remove(t.id)} 82 - class="flex-shrink-0 text-gray-400 hover:text-white focus:outline-none" 82 + class="shrink-0 text-gray-400 hover:text-white focus:outline-none" 83 83 aria-label="Close"> 84 84 <svg 85 85 xmlns="http://www.w3.org/2000/svg"
+4 -1
web/src/lib/api.ts
··· 43 43 getPreferences: () => apiFetch("/preferences", { method: "GET" }), 44 44 getDiscovery: () => apiFetch("/discovery", { method: "GET" }), 45 45 getUserProfile: (did: string) => apiFetch(`/users/${did}/profile`, { method: "GET" }), 46 + getRemoteDeck: (uri: string) => apiFetch(`/remote/deck?uri=${encodeURIComponent(uri)}`, { method: "GET" }), 47 + exportData: (collection: "decks" | "notes") => apiFetch(`/export/${collection}`, { method: "GET" }), 46 48 createDeck: async (payload: CreateDeckPayload) => { 47 49 const { cards, ...deckPayload } = payload; 48 50 const res = await apiFetch("/decks", { method: "POST", body: JSON.stringify(deckPayload) }); ··· 69 71 body: JSON.stringify({ content, parent_id: parentId }), 70 72 }); 71 73 }, 72 - search: (query: string, limit = 20, offset = 0) => { 74 + search: (query: string, limit = 20, offset = 0, source?: "local" | "remote") => { 73 75 const params = new URLSearchParams({ q: query, limit: String(limit), offset: String(offset) }); 76 + if (source) params.set("source", source); 74 77 return apiFetch(`/search?${params}`, { method: "GET" }); 75 78 }, 76 79 getDueCards: (deckId?: string, limit = 20) => {
+123
web/src/pages/DeckPreview.tsx
··· 1 + import { FollowButton } from "$components/social/FollowButton"; 2 + import { Button } from "$components/ui/Button"; 3 + import { Card } from "$components/ui/Card"; 4 + import { EmptyState } from "$components/ui/EmptyState"; 5 + import { Tag } from "$components/ui/Tag"; 6 + import { api } from "$lib/api"; 7 + import type { Card as CardType, Deck } from "$lib/model"; 8 + import { toast } from "$lib/toast"; 9 + import { useNavigate, useSearchParams } from "@solidjs/router"; 10 + import type { Component } from "solid-js"; 11 + import { createResource, For, Show } from "solid-js"; 12 + import { Motion } from "solid-motionone"; 13 + 14 + type RemoteDeckResponse = { deck: Deck; cards: CardType[] }; 15 + 16 + const DeckPreview: Component = () => { 17 + const [searchParams] = useSearchParams(); 18 + const navigate = useNavigate(); 19 + const uri = () => searchParams.uri as string; 20 + 21 + const [data] = createResource(uri, async (u) => { 22 + if (!u) return null; 23 + const res = await api.getRemoteDeck(u); 24 + // TODO: Toast on error 25 + if (!res.ok) return null; 26 + return (await res.json()) as RemoteDeckResponse; 27 + }); 28 + 29 + const handleFork = async () => { 30 + // TODO: Implement `forkRemoteDeck` or update `forkDeck` to handle AT-URIs. 31 + toast.error("Forking remote decks is not yet supported."); 32 + }; 33 + 34 + return ( 35 + <div class="max-w-4xl mx-auto px-6 py-12"> 36 + <Show 37 + when={!data.loading} 38 + fallback={ 39 + <div class="flex justify-center"> 40 + <div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" /> 41 + </div> 42 + }> 43 + <Show 44 + when={data()} 45 + fallback={ 46 + <EmptyState 47 + title="Deck not found" 48 + description="Could not load the requested remote deck." 49 + action={ 50 + <Button variant="secondary" onClick={() => navigate("/library")}>Back to Library</Button> 51 + } /> 52 + }> 53 + {(deckData) => ( 54 + <Motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.4 }}> 55 + <div class="mb-12"> 56 + <div class="flex justify-between items-start mb-4"> 57 + <h1 class="text-4xl text-slate-100 font-bold tracking-tight">{deckData().deck.title}</h1> 58 + <Tag label="Remote" color="blue" /> 59 + </div> 60 + 61 + <div class="flex items-center gap-4 mb-6"> 62 + <div class="text-slate-400 font-light">By {deckData().deck.owner_did}</div> 63 + <FollowButton did={deckData().deck.owner_did || ""} /> 64 + </div> 65 + 66 + <p class="text-slate-300 mb-6 font-light">{deckData().deck.description}</p> 67 + 68 + <div class="flex gap-2 mb-8 flex-wrap"> 69 + <For each={deckData().deck.tags}>{(tag) => <Tag label={`#${tag}`} color="gray" />}</For> 70 + </div> 71 + 72 + <div class="flex gap-4 border-t border-slate-700 pt-6"> 73 + <Button onClick={handleFork} variant="secondary"> 74 + <span class="mr-2">Fork to Library</span> 75 + </Button> 76 + <Button 77 + variant="ghost" 78 + onClick={() => navigate("/library")}> 79 + Back to Library 80 + </Button> 81 + </div> 82 + </div> 83 + 84 + <h2 class="text-xl font-medium text-slate-200 mb-6 border-b border-slate-700 pb-4"> 85 + Cards <span class="text-slate-500">({deckData().cards.length})</span> 86 + </h2> 87 + 88 + <div class="grid gap-4"> 89 + <For each={deckData().cards}> 90 + {(card, i) => ( 91 + <Card class="hover:border-slate-600 transition-colors"> 92 + <div class="flex justify-between items-start mb-2 text-xs text-slate-500 font-mono"> 93 + <span class="opacity-50">CARD {i() + 1}</span> 94 + </div> 95 + <div class="grid md:grid-cols-2 gap-8"> 96 + <div> 97 + <div class="text-[10px] uppercase tracking-widest text-slate-500 mb-1">Front</div> 98 + <div class="text-slate-200">{card.front}</div> 99 + </div> 100 + <div class="md:border-l md:border-slate-700 md:pl-8"> 101 + <div class="text-[10px] uppercase tracking-widest text-slate-500 mb-1">Back</div> 102 + <div class="text-slate-400">{card.back}</div> 103 + </div> 104 + </div> 105 + </Card> 106 + )} 107 + </For> 108 + <Show when={deckData().cards.length === 0}> 109 + <div class="text-center py-12 text-slate-500"> 110 + No cards indexed for this deck because we strictly respect remote privacy settings or the deck is 111 + empty. 112 + </div> 113 + </Show> 114 + </div> 115 + </Motion.div> 116 + )} 117 + </Show> 118 + </Show> 119 + </div> 120 + ); 121 + }; 122 + 123 + export default DeckPreview;
+128
web/src/pages/Library.tsx
··· 1 + import { Card } from "$components/ui/Card"; 2 + import { EmptyState } from "$components/ui/EmptyState"; 3 + import { api } from "$lib/api"; 4 + import { A } from "@solidjs/router"; 5 + import type { Component } from "solid-js"; 6 + import { createResource, For, Show } from "solid-js"; 7 + 8 + type LibraryItemData = { 9 + title?: string; 10 + description?: string; 11 + tags?: string[]; 12 + at_uri?: string; 13 + front?: string; 14 + back?: string; 15 + body?: string; 16 + }; 17 + 18 + type LibraryItemKind = "deck" | "note" | "card"; 19 + 20 + type LibraryItemSource = "local" | "remote"; 21 + 22 + type LibraryItem = { 23 + item_type: LibraryItemKind; 24 + item_id: string; 25 + creator_did: string; 26 + data: LibraryItemData; 27 + rank: number; 28 + source: LibraryItemSource; 29 + }; 30 + 31 + const fetchFederatedContent = async () => { 32 + const res = await api.search("", 50, 0, "remote"); 33 + if (!res.ok) return []; 34 + return (await res.json()) as LibraryItem[]; 35 + }; 36 + 37 + const LibraryItemCard: Component<{ item: LibraryItem }> = (props) => { 38 + return ( 39 + <Card class="h-full flex flex-col hover:border-blue-400 dark:hover:border-blue-500 transition-colors"> 40 + <div class="p-6 flex-1 space-y-4"> 41 + <div class="flex items-start justify-between"> 42 + <div class="space-y-1"> 43 + <h3 class="text-lg font-semibold text-slate-900 dark:text-white line-clamp-1">{props.item.data.title}</h3> 44 + <p class="text-sm text-slate-500 dark:text-slate-400 font-mono"> 45 + by {props.item.creator_did.slice(0, 12)}... 46 + </p> 47 + </div> 48 + <span class="inline-flex items-center rounded-full bg-indigo-50 dark:bg-indigo-900/30 px-2 py-1 text-xs font-medium text-indigo-700 dark:text-indigo-300 ring-1 ring-inset ring-indigo-700/10"> 49 + Remote 50 + </span> 51 + </div> 52 + 53 + <p class="text-sm text-slate-600 dark:text-slate-300 line-clamp-3"> 54 + {props.item.data.description || "No description provided."} 55 + </p> 56 + 57 + <Show when={props.item.data.tags}> 58 + {tags => ( 59 + <div class="pt-4 flex items-center justify-between border-t border-slate-100 dark:border-slate-700 mt-auto"> 60 + <div class="text-xs text-slate-500"> 61 + <For each={tags()}>{(tag) => `#${tag}`}</For> 62 + </div> 63 + </div> 64 + )} 65 + </Show> 66 + </div> 67 + </Card> 68 + ); 69 + }; 70 + 71 + const Library: Component = () => { 72 + const [items] = createResource(fetchFederatedContent); 73 + 74 + return ( 75 + <div class="max-w-7xl mx-auto p-6 space-y-8"> 76 + <header class="space-y-4"> 77 + <h1 class="text-3xl font-bold tracking-tight text-slate-900 dark:text-white">Federated Library</h1> 78 + <p class="text-slate-600 dark:text-slate-400"> 79 + Discover content from across the AT Protocol network. These decks are indexed from other users and PDS 80 + instances. 81 + </p> 82 + </header> 83 + 84 + <Show 85 + when={!items.loading} 86 + fallback={ 87 + <div class="flex justify-center p-12"> 88 + <div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" /> 89 + </div> 90 + }> 91 + <Show 92 + when={items() && items()!.length > 0} 93 + fallback={ 94 + <EmptyState 95 + title="No federated content found" 96 + description="We couldn't find any remote decks in the index yet. Try following some users!" 97 + action={ 98 + <button 99 + onClick={() => window.location.href = "/discovery"} 100 + class="text-sm font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300"> 101 + Go to Discovery 102 + </button> 103 + } /> 104 + }> 105 + <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> 106 + <For each={items()}> 107 + {(item) => ( 108 + <Show when={item.item_type === "deck"}> 109 + <Show when={item.data.at_uri} fallback={<LibraryItemCard item={item} />}> 110 + {at_uri => ( 111 + <A 112 + href={`/library/preview?uri=${encodeURIComponent(at_uri())}`} 113 + class="block h-full no-underline"> 114 + <LibraryItemCard item={item} /> 115 + </A> 116 + )} 117 + </Show> 118 + </Show> 119 + )} 120 + </For> 121 + </div> 122 + </Show> 123 + </Show> 124 + </div> 125 + ); 126 + }; 127 + 128 + export default Library;
+92
web/src/pages/Settings.tsx
··· 1 + import { api } from "$lib/api"; 2 + import type { Component } from "solid-js"; 3 + import { createSignal, Show } from "solid-js"; 4 + 5 + const Settings: Component = () => { 6 + const [exportingDecks, setExportingDecks] = createSignal(false); 7 + const [exportingNotes, setExportingNotes] = createSignal(false); 8 + const [exportError, setExportError] = createSignal<string | null>(null); 9 + 10 + const handleExport = async (collection: "decks" | "notes") => { 11 + if (collection === "decks") setExportingDecks(true); 12 + else setExportingNotes(true); 13 + setExportError(null); 14 + 15 + try { 16 + const res = await api.exportData(collection); 17 + if (!res.ok) throw new Error(`Failed to export ${collection}`); 18 + 19 + const blob = await res.blob(); 20 + const url = window.URL.createObjectURL(blob); 21 + const a = document.createElement("a"); 22 + a.href = url; 23 + a.download = `malfestio_${collection}_export.json`; 24 + document.body.appendChild(a); 25 + a.click(); 26 + window.URL.revokeObjectURL(url); 27 + a.remove(); 28 + } catch (e) { 29 + console.error(e); 30 + setExportError(`Failed to export ${collection}. Please try again.`); 31 + } finally { 32 + if (collection === "decks") setExportingDecks(false); 33 + else setExportingNotes(false); 34 + } 35 + }; 36 + 37 + return ( 38 + <div class="max-w-4xl mx-auto p-6 space-y-8"> 39 + <header class="space-y-4"> 40 + <h1 class="text-3xl font-bold tracking-tight text-slate-900 dark:text-white">Settings</h1> 41 + <p class="text-slate-600 dark:text-slate-400">Manage your account preferences and data.</p> 42 + </header> 43 + 44 + <div class="grid gap-6"> 45 + {/* Export Section */} 46 + <section class="p-6 bg-white dark:bg-slate-800 rounded-xl shadow-sm border border-slate-200 dark:border-slate-700"> 47 + <h2 class="text-xl font-semibold text-slate-900 dark:text-white mb-4">Export Data</h2> 48 + <p class="text-sm text-slate-600 dark:text-slate-400 mb-6"> 49 + Download your data as JSON files. This includes all your content but excludes media files which are linked 50 + remotely. 51 + </p> 52 + 53 + <Show when={exportError()}> 54 + <div class="mb-4 p-4 text-sm text-red-600 bg-red-50 dark:bg-red-900/20 dark:text-red-400 rounded-lg border border-red-200 dark:border-red-900/50"> 55 + {exportError()} 56 + </div> 57 + </Show> 58 + 59 + <div class="flex gap-4"> 60 + <button 61 + onClick={() => handleExport("decks")} 62 + disabled={exportingDecks()} 63 + class="inline-flex items-center justify-center rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"> 64 + {exportingDecks() ? "Exporting..." : "Export Decks"} 65 + </button> 66 + <button 67 + onClick={() => handleExport("notes")} 68 + disabled={exportingNotes()} 69 + class="inline-flex items-center justify-center rounded-lg bg-white dark:bg-slate-700 px-4 py-2 text-sm font-medium text-slate-700 dark:text-slate-200 border border-slate-300 dark:border-slate-600 hover:bg-slate-50 dark:hover:bg-slate-600 focus:outline-none focus:ring-2 focus:ring-slate-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"> 70 + {exportingNotes() ? "Exporting..." : "Export Notes"} 71 + </button> 72 + </div> 73 + </section> 74 + 75 + {/* Preferences Section - Placeholder for now as per plan */} 76 + <section class="p-6 bg-white dark:bg-slate-800 rounded-xl shadow-sm border border-slate-200 dark:border-slate-700 opacity-50 pointer-events-none"> 77 + <div class="flex justify-between items-center mb-4"> 78 + <h2 class="text-xl font-semibold text-slate-900 dark:text-white">Preferences</h2> 79 + <span class="text-xs font-medium px-2 py-1 rounded-full bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300"> 80 + Coming Soon 81 + </span> 82 + </div> 83 + <p class="text-sm text-slate-600 dark:text-slate-400"> 84 + Advanced theme settings and default visibility options will be available here. 85 + </p> 86 + </section> 87 + </div> 88 + </div> 89 + ); 90 + }; 91 + 92 + export default Settings;