diff --git a/crates/server/src/api/deck.rs b/crates/server/src/api/deck.rs index 8c44d53..0260267 100644 --- a/crates/server/src/api/deck.rs +++ b/crates/server/src/api/deck.rs @@ -4,7 +4,7 @@ use crate::state::SharedState; use crate::repository::deck::{CreateDeckParams, DeckRepoError, UpdateDeckParams}; use axum::{ Json, - extract::{Extension, Path, State}, + extract::{Extension, Path, Query, State}, http::StatusCode, response::IntoResponse, }; @@ -54,6 +54,30 @@ pub async fn create_deck( } } +#[derive(Deserialize)] +pub struct RemoteDeckQuery { + uri: String, +} + +pub async fn fetch_remote_deck( + State(state): State, Query(query): Query, +) -> impl IntoResponse { + match state.deck_repo.get_remote_deck(&query.uri).await { + Ok((deck, cards)) => Json(json!({ "deck": deck, "cards": cards })).into_response(), + Err(DeckRepoError::NotFound(_)) => { + (StatusCode::NOT_FOUND, Json(json!({"error": "Deck not found"}))).into_response() + } + Err(e) => { + tracing::error!("Failed to fetch remote deck: {:?}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "Failed to fetch remote deck"})), + ) + .into_response() + } + } +} + pub async fn list_decks( State(state): State, ctx: Option>, ) -> impl IntoResponse { diff --git a/crates/server/src/api/export.rs b/crates/server/src/api/export.rs new file mode 100644 index 0000000..4e7e2e9 --- /dev/null +++ b/crates/server/src/api/export.rs @@ -0,0 +1,41 @@ +use crate::middleware::auth::UserContext; +use crate::state::SharedState; +use axum::{ + Json, + extract::{Extension, Path, State}, + http::StatusCode, + response::IntoResponse, +}; +use serde_json::json; + +/// GET /api/export/:collection +/// Export data for the authenticated user +pub async fn export_collection( + State(state): State, Extension(user): Extension, Path(collection): Path, +) -> impl IntoResponse { + match collection.as_str() { + "decks" => match state.deck_repo.get_decks_by_user(&user.did).await { + Ok(decks) => Json(json!(decks)).into_response(), + Err(e) => { + tracing::error!("Failed to export decks: {:?}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "Failed to export decks"})), + ) + .into_response() + } + }, + "notes" => match state.note_repo.get_notes_by_user(&user.did).await { + Ok(notes) => Json(json!(notes)).into_response(), + Err(e) => { + tracing::error!("Failed to export notes: {:?}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "Failed to export notes"})), + ) + .into_response() + } + }, + _ => (StatusCode::BAD_REQUEST, Json(json!({"error": "Invalid collection"}))).into_response(), + } +} diff --git a/crates/server/src/api/mod.rs b/crates/server/src/api/mod.rs index 7d90e82..dc8d04d 100644 --- a/crates/server/src/api/mod.rs +++ b/crates/server/src/api/mod.rs @@ -1,6 +1,7 @@ pub mod auth; pub mod card; pub mod deck; +pub mod export; pub mod feed; pub mod importer; pub mod note; diff --git a/crates/server/src/api/search.rs b/crates/server/src/api/search.rs index e1275be..363587f 100644 --- a/crates/server/src/api/search.rs +++ b/crates/server/src/api/search.rs @@ -16,6 +16,7 @@ pub struct SearchQuery { limit: i64, #[serde(default = "default_offset")] offset: i64, + source: Option, } fn default_limit() -> i64 { @@ -37,7 +38,13 @@ pub async fn search( match state .search_repo - .search(&query.q, query.limit, query.offset, user_did.as_deref()) + .search( + &query.q, + query.limit, + query.offset, + user_did.as_deref(), + query.source.as_deref(), + ) .await { Ok(results) => Json(results).into_response(), @@ -130,7 +137,7 @@ mod tests { let response = search( State(state.clone()), Some(auth_ctx), - Query(SearchQuery { q: "private".to_string(), limit: 10, offset: 0 }), + Query(SearchQuery { q: "private".to_string(), limit: 10, offset: 0, source: None }), ) .await .into_response(); @@ -144,7 +151,7 @@ mod tests { let response_anon = search( State(state.clone()), None, - Query(SearchQuery { q: "private".to_string(), limit: 10, offset: 0 }), + Query(SearchQuery { q: "private".to_string(), limit: 10, offset: 0, source: None }), ) .await .into_response(); diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs index d275ff0..0b70bde 100644 --- a/crates/server/src/lib.rs +++ b/crates/server/src/lib.rs @@ -85,6 +85,7 @@ pub async fn start() -> malfestio_core::Result<()> { .route("/feeds/follows", get(api::feed::get_feed_follows)) .route("/preferences", get(api::preferences::get_preferences)) .route("/preferences", axum::routing::put(api::preferences::update_preferences)) + .route("/export/{collection}", get(api::export::export_collection)) .layer(axum_middleware::from_fn_with_state( state.clone(), middleware::auth::auth_middleware, @@ -103,6 +104,7 @@ pub async fn start() -> malfestio_core::Result<()> { .route("/search", get(api::search::search)) .route("/discovery", get(api::search::discovery)) .route("/users/{did}/profile", get(api::users::get_profile)) + .route("/remote/deck", get(api::deck::fetch_remote_deck)) .layer(axum_middleware::from_fn_with_state( state.clone(), middleware::auth::optional_auth_middleware, diff --git a/crates/server/src/repository/deck.rs b/crates/server/src/repository/deck.rs index 653db51..e9e9bb7 100644 --- a/crates/server/src/repository/deck.rs +++ b/crates/server/src/repository/deck.rs @@ -34,6 +34,8 @@ pub trait DeckRepository: Send + Sync { async fn list_visible(&self, viewer_did: Option<&str>) -> Result, DeckRepoError>; async fn update(&self, params: UpdateDeckParams) -> Result; async fn fork(&self, original_deck_id: &str, user_did: &str) -> Result; + async fn get_decks_by_user(&self, owner_did: &str) -> Result, DeckRepoError>; + async fn get_remote_deck(&self, at_uri: &str) -> Result<(Deck, Vec), DeckRepoError>; } pub struct DbDeckRepository { @@ -221,7 +223,6 @@ impl DeckRepository for DbDeckRepository { } .map_err(|e| DeckRepoError::DatabaseError(format!("Failed to update deck: {}", e)))?; - // Return updated deck self.get(¶ms.deck_id).await } @@ -313,6 +314,99 @@ impl DeckRepository for DbDeckRepository { fork_of: Some(original_deck_id.to_string()), }) } + + async fn get_decks_by_user(&self, owner_did: &str) -> Result, DeckRepoError> { + let client = self + .pool + .get() + .await + .map_err(|e| DeckRepoError::DatabaseError(format!("Failed to get connection: {}", e)))?; + + let rows = client + .query( + "SELECT id, owner_did, title, description, tags, visibility, published_at, fork_of + FROM decks + WHERE owner_did = $1", + &[&owner_did], + ) + .await + .map_err(|e| DeckRepoError::DatabaseError(format!("Failed to retrieve decks: {}", e)))?; + + let mut decks = Vec::new(); + for row in rows { + let visibility_json: serde_json::Value = row.get("visibility"); + let visibility: Visibility = serde_json::from_value(visibility_json).unwrap_or(Visibility::Private); + let fork_of: Option = row.get("fork_of"); + + decks.push(Deck { + id: row.get::<_, Uuid>("id").to_string(), + owner_did: row.get("owner_did"), + title: row.get("title"), + description: row.get("description"), + tags: row.get("tags"), + visibility, + published_at: row + .get::<_, Option>>("published_at") + .map(|dt| dt.to_rfc3339()), + fork_of: fork_of.map(|u| u.to_string()), + }); + } + Ok(decks) + } + + async fn get_remote_deck(&self, at_uri: &str) -> Result<(Deck, Vec), DeckRepoError> { + let client = self + .pool + .get() + .await + .map_err(|e| DeckRepoError::DatabaseError(format!("Failed to get connection: {}", e)))?; + + let deck_row = client + .query_opt( + "SELECT did, title, description, tags FROM indexed_decks WHERE at_uri = $1 AND deleted_at IS NULL", + &[&at_uri], + ) + .await + .map_err(|e| DeckRepoError::DatabaseError(format!("Failed to query remote deck: {}", e)))? + .ok_or_else(|| DeckRepoError::NotFound("Remote deck not found".to_string()))?; + + let deck = Deck { + id: at_uri.to_string(), + owner_did: deck_row.get("did"), + title: deck_row.get("title"), + description: deck_row.get("description"), + tags: deck_row.get("tags"), + visibility: Visibility::Public, + published_at: None, + fork_of: None, + }; + + let card_rows = client + .query( + "SELECT at_uri, did, front, back, media_url, hints FROM indexed_cards WHERE deck_ref = $1 AND deleted_at IS NULL", + &[&at_uri], + ) + .await + .map_err(|e| DeckRepoError::DatabaseError(format!("Failed to query remote cards: {}", e)))?; + + let mut cards = Vec::new(); + for row in card_rows { + let hints: Vec = row.get("hints"); + cards.push(malfestio_core::model::Card { + id: row.get("at_uri"), + owner_did: row.get("did"), + deck_id: at_uri.to_string(), + front: row.get("front"), + back: row.get("back"), + media_url: row.get("media_url"), + hints, + // TODO: support other card types + card_type: malfestio_core::model::CardType::Basic, + }); + } + + Ok((deck, cards)) + } } #[cfg(test)] @@ -403,7 +497,26 @@ pub mod mock { fork_of: Some(original_deck_id.to_string()), }; decks.push(deck.clone()); + decks.push(deck.clone()); Ok(deck) } + + async fn get_decks_by_user(&self, owner_did: &str) -> Result, DeckRepoError> { + let decks = self.decks.lock().unwrap(); + let user_decks = decks.iter().filter(|d| d.owner_did == owner_did).cloned().collect(); + Ok(user_decks) + } + + async fn get_remote_deck( + &self, at_uri: &str, + ) -> Result<(Deck, Vec), DeckRepoError> { + let decks = self.decks.lock().unwrap(); + let deck = decks + .iter() + .find(|d| d.id == at_uri) + .cloned() + .ok_or_else(|| DeckRepoError::NotFound("Deck not found".to_string()))?; + Ok((deck, vec![])) + } } } diff --git a/crates/server/src/repository/note.rs b/crates/server/src/repository/note.rs index ee17ef3..998e9c5 100644 --- a/crates/server/src/repository/note.rs +++ b/crates/server/src/repository/note.rs @@ -14,10 +14,9 @@ pub trait NoteRepository: Send + Sync { async fn create( &self, owner_did: &str, title: &str, body: &str, tags: Vec, visibility: Visibility, ) -> Result; - async fn list(&self, viewer_did: Option<&str>) -> Result, NoteRepoError>; - async fn get(&self, id: &str, viewer_did: Option<&str>) -> Result; + async fn get_notes_by_user(&self, owner_did: &str) -> Result, NoteRepoError>; } pub struct DbNoteRepository { @@ -177,6 +176,47 @@ impl NoteRepository for DbNoteRepository { links, }) } + + async fn get_notes_by_user(&self, owner_did: &str) -> Result, NoteRepoError> { + let client = self + .pool + .get() + .await + .map_err(|e| NoteRepoError::DatabaseError(format!("Failed to get connection: {}", e)))?; + + let rows = client + .query( + "SELECT id, owner_did, title, body, tags, visibility, published_at, links, created_at, updated_at + FROM notes + WHERE owner_did = $1", + &[&owner_did], + ) + .await + .map_err(|e| NoteRepoError::DatabaseError(format!("Failed to retrieve notes: {}", e)))?; + + let mut notes = Vec::new(); + for row in rows { + let visibility_json: serde_json::Value = row.get("visibility"); + let visibility: Visibility = serde_json::from_value(visibility_json) + .map_err(|e| NoteRepoError::SerializationError(format!("Failed to deserialize visibility: {}", e)))?; + let id: uuid::Uuid = row.get("id"); + let links: Vec = row.get("links"); + + notes.push(Note { + id: id.to_string(), + owner_did: row.get("owner_did"), + title: row.get("title"), + body: row.get("body"), + tags: row.get("tags"), + visibility, + published_at: row + .get::<_, Option>>("published_at") + .map(|dt| dt.to_rfc3339()), + links, + }); + } + Ok(notes) + } } #[cfg(test)] @@ -283,5 +323,11 @@ pub mod mock { Ok(note.clone()) } + + async fn get_notes_by_user(&self, owner_did: &str) -> Result, NoteRepoError> { + let notes = self.notes.lock().unwrap(); + let user_notes = notes.iter().filter(|n| n.owner_did == owner_did).cloned().collect(); + Ok(user_notes) + } } } diff --git a/crates/server/src/repository/search.rs b/crates/server/src/repository/search.rs index 8f302a7..0ab10f6 100644 --- a/crates/server/src/repository/search.rs +++ b/crates/server/src/repository/search.rs @@ -14,8 +14,9 @@ pub struct SearchResult { #[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 search( + &self, query: &str, limit: i64, offset: i64, viewer_did: Option<&str>, source: Option<&str>, + ) -> Result>; async fn get_top_tags(&self, limit: i64) -> Result>; } @@ -32,7 +33,7 @@ impl DbSearchRepository { #[async_trait::async_trait] impl SearchRepository for DbSearchRepository { async fn search( - &self, query: &str, limit: i64, offset: i64, viewer_did: Option<&str>, + &self, query: &str, limit: i64, offset: i64, viewer_did: Option<&str>, source: Option<&str>, ) -> Result> { let client = self .pool @@ -41,6 +42,7 @@ impl SearchRepository for DbSearchRepository { .map_err(|e| malfestio_core::Error::Database(e.to_string()))?; // TODO: implement shared-with logic. + // If source is provided, filter by it. let sql = " SELECT item_type, @@ -55,12 +57,13 @@ impl SearchRepository for DbSearchRepository { visibility->>'type' = 'Public' OR (creator_did = $4) ) + AND ($5::text IS NULL OR source = $5) ORDER BY rank DESC LIMIT $2 OFFSET $3 "; let rows = client - .query(sql, &[&query, &limit, &offset, &viewer_did]) + .query(sql, &[&query, &limit, &offset, &viewer_did, &source]) .await .map_err(|e| malfestio_core::Error::Database(e.to_string()))?; @@ -140,7 +143,7 @@ pub mod mock { #[async_trait::async_trait] impl SearchRepository for MockSearchRepository { async fn search( - &self, query: &str, limit: i64, offset: i64, viewer_did: Option<&str>, + &self, query: &str, limit: i64, offset: i64, viewer_did: Option<&str>, source: Option<&str>, ) -> Result> { let results = self.search_results.lock().await; @@ -157,8 +160,8 @@ pub mod mock { == Some("Public"); let matches_auth = viewer_did.map_or(is_public, |did| r.creator_did == did || is_public); - - matches_query && matches_auth + let matches_source = source.is_none_or(|s| r.source == s); + matches_query && matches_auth && matches_source }) .skip(offset as usize) .take(limit as usize) diff --git a/docs/todo.md b/docs/todo.md index 27f6589..443c001 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -31,31 +31,8 @@ - SEO & Meta: Open Graph / Twitter Card meta tags, Sitemap.xml generation, robots.txt configuration. - Onboarding Flow & Help Center/FAQ - Empty States - -### Milestone K - AppView Indexing - -#### Deliverables - -**Firehose Enhancement:** - -- [x] Upgrade firehose consumer to store full record content (not just metadata) -- [x] Add `indexed_decks`, `indexed_cards`, `indexed_notes` tables for remote content -- [x] Track latest processed revision per repo; handle deletions - -**Search & Discovery:** - -- [x] Implement search over indexed remote records (extend `search_items` view) -- [x] User profile aggregation: follower counts, deck counts from federated sources - -**Export & Interop:** - -- [ ] Export local records as valid Lexicon JSON (`/api/export/:collection`) -- [ ] Read-only "federated library" view showing remote decks - -#### Acceptance - -- A deck published from Malfestio can be discovered via another AT Protocol client. -- Remote decks from followed users appear in search results. +- **(Done) Milestone K**: AppView Indexing. + - Firehose Enhancement (full record storage), Extended Search & Discovery (remote records), and Export/Interop features (JSON export, remote deck view). ### Milestone L - ATProto Integration Pass diff --git a/web/src/App.tsx b/web/src/App.tsx index 47e2a23..1c1b4d5 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -4,6 +4,7 @@ import type { Persona } from "$lib/model"; import { authStore, prefStore } from "$lib/store"; import About from "$pages/About"; import DeckNew from "$pages/DeckNew"; +import DeckPreview from "$pages/DeckPreview"; import DeckView from "$pages/DeckView"; import Discovery from "$pages/Discovery"; import Feed from "$pages/Feed"; @@ -12,11 +13,13 @@ import Home from "$pages/Home"; import Import from "$pages/Import"; import Landing from "$pages/Landing"; import LectureImport from "$pages/LectureImport"; +import Library from "$pages/Library"; 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 Settings from "$pages/Settings"; import { Route, Router } from "@solidjs/router"; import type { Component, ParentComponent } from "solid-js"; import { createEffect, createSignal, onMount, Show } from "solid-js"; @@ -68,6 +71,9 @@ const App: Component = () => { + + + diff --git a/web/src/components/layout/Header.tsx b/web/src/components/layout/Header.tsx index 2197c5c..aabf337 100644 --- a/web/src/components/layout/Header.tsx +++ b/web/src/components/layout/Header.tsx @@ -15,10 +15,12 @@ export const Header: Component = () => {
diff --git a/web/src/components/tests/DeckPreview.test.tsx b/web/src/components/tests/DeckPreview.test.tsx new file mode 100644 index 0000000..2a00566 --- /dev/null +++ b/web/src/components/tests/DeckPreview.test.tsx @@ -0,0 +1,58 @@ +import { api } from "$lib/api"; +import DeckPreview from "$pages/DeckPreview"; +import { cleanup, render, screen, waitFor } from "@solidjs/testing-library"; +import type { JSX } from "solid-js"; +import { afterEach, describe, expect, it, type Mock, vi } from "vitest"; + +vi.mock("$lib/api", () => ({ api: { getRemoteDeck: vi.fn() } })); + +vi.mock( + "@solidjs/router", + () => ({ + useSearchParams: () => [{ uri: "at://did:plc:test/app.malfestio.deck/123" }], + useNavigate: () => vi.fn(), + A: (props: { href: string; children: JSX.Element }) => {props.children}, + }), +); + +describe("DeckPreview", () => { + afterEach(cleanup); + + it("renders loading state initially", () => { + (api.getRemoteDeck as Mock).mockReturnValue(new Promise(() => {})); + const { container } = render(() => ); + expect(container.querySelector(".animate-spin")).toBeInTheDocument(); + }); + + it("renders deck details when loaded", async () => { + (api.getRemoteDeck as Mock).mockResolvedValue({ + ok: true, + json: async () => ({ + deck: { + id: "at://did:plc:test/app.malfestio.deck/123", + owner_did: "did:plc:test", + title: "Remote Deck", + description: "A test deck", + tags: ["test"], + visibility: { type: "Public" }, + }, + cards: [{ id: "card1", front: "Question", back: "Answer", deck_id: "deck1", owner_did: "did:plc:test" }], + }), + }); + + render(() => ); + + await waitFor(() => expect(screen.getByText("Remote Deck")).toBeInTheDocument()); + expect(screen.getByText("By did:plc:test")).toBeInTheDocument(); + expect(screen.getByText("Question")).toBeInTheDocument(); + expect(screen.getByText("Answer")).toBeInTheDocument(); + }); + + it("renders error state when fetch fails", async () => { + (api.getRemoteDeck as Mock).mockResolvedValue({ ok: false }); + + render(() => ); + + await waitFor(() => expect(screen.getByText("Could not load the requested remote deck.")).toBeInTheDocument()); + }); +}); diff --git a/web/src/components/tests/Library.test.tsx b/web/src/components/tests/Library.test.tsx new file mode 100644 index 0000000..bbb957a --- /dev/null +++ b/web/src/components/tests/Library.test.tsx @@ -0,0 +1,56 @@ +import { api } from "$lib/api"; +import Library from "$pages/Library"; +import { cleanup, render, screen, waitFor } from "@solidjs/testing-library"; +import type { JSX } from "solid-js"; +import { afterEach, describe, expect, it, type Mock, vi } from "vitest"; + +vi.mock("$lib/api", () => ({ api: { search: vi.fn() } })); + +vi.mock( + "@solidjs/router", + () => ({ A: (props: { href: string; children: JSX.Element }) => {props.children} }), +); + +describe("Library page", () => { + afterEach(cleanup); + + it("renders loading state initially", () => { + (api.search as Mock).mockReturnValue(new Promise(() => {})); + const { container } = render(() => ); + expect(container.querySelector(".animate-spin")).toBeInTheDocument(); + }); + + it("renders remote decks when loaded", async () => { + (api.search as Mock).mockResolvedValue({ + ok: true, + json: + async () => [{ + item_type: "deck", + item_id: "deck1", + creator_did: "did:plc:other", + data: { + title: "Federated Deck", + description: "From another server", + tags: ["remote"], + at_uri: "at://did:plc:other/app.malfestio.deck/deck1", + }, + rank: 1, + source: "remote", + }], + }); + + render(() => ); + + await waitFor(() => expect(screen.getByText("Federated Deck")).toBeInTheDocument()); + expect(screen.getByText("by did:plc:othe...")).toBeInTheDocument(); + expect(screen.getByText("#remote")).toBeInTheDocument(); + }); + + it("renders empty state when no content", async () => { + (api.search as Mock).mockResolvedValue({ ok: true, json: async () => [] }); + + render(() => ); + + await waitFor(() => expect(screen.getByText("No federated content found")).toBeInTheDocument()); + }); +}); diff --git a/web/src/components/tests/Settings.test.tsx b/web/src/components/tests/Settings.test.tsx new file mode 100644 index 0000000..235016c --- /dev/null +++ b/web/src/components/tests/Settings.test.tsx @@ -0,0 +1,49 @@ +import { api } from "$lib/api"; +import Settings from "$pages/Settings"; +import { cleanup, fireEvent, render, screen, waitFor } from "@solidjs/testing-library"; +import { afterEach, describe, expect, it, type Mock, vi } from "vitest"; + +vi.mock("$lib/api", () => ({ api: { exportData: vi.fn() } })); + +vi.stubGlobal("URL", { createObjectURL: vi.fn(() => "blob:test"), revokeObjectURL: vi.fn() }); + +describe("Settings page", () => { + afterEach(cleanup); + + it("renders export buttons", () => { + render(() => ); + expect(screen.getByRole("heading", { name: "Export Data" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Export Decks" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Export Notes" })).toBeInTheDocument(); + }); + + it("handles deck export", async () => { + (api.exportData as Mock).mockResolvedValue({ + ok: true, + blob: async () => new Blob(["test"], { type: "application/json" }), + }); + + render(() => ); + + const exportBtn = screen.getByRole("button", { name: "Export Decks" }); + fireEvent.click(exportBtn); + + expect(screen.getByText("Exporting...")).toBeInTheDocument(); + await waitFor(() => expect(api.exportData).toHaveBeenCalledWith("decks")); + }); + + it("handles notes export", async () => { + (api.exportData as Mock).mockResolvedValue({ + ok: true, + blob: async () => new Blob(["test"], { type: "application/json" }), + }); + + render(() => ); + + const exportBtn = screen.getByRole("button", { name: "Export Notes" }); + fireEvent.click(exportBtn); + + expect(screen.getByText("Exporting...")).toBeInTheDocument(); + await waitFor(() => expect(api.exportData).toHaveBeenCalledWith("notes")); + }); +}); diff --git a/web/src/components/ui/Toast.tsx b/web/src/components/ui/Toast.tsx index 7df0b2f..273445b 100644 --- a/web/src/components/ui/Toast.tsx +++ b/web/src/components/ui/Toast.tsx @@ -1,6 +1,6 @@ +import { toast, toasts, type ToastType } from "$lib/toast"; import { For, Match, Switch } from "solid-js"; import type { Component } from "solid-js"; -import { toast, toasts, type ToastType } from "../../lib/toast"; const borderColors: Record = { success: "border-l-4 border-green-500", @@ -34,7 +34,7 @@ export const Toaster: Component = () => { borderColors[t.type] }`} role="alert"> -
+
}> {
{t.message}
+ } /> + }> + {(deckData) => ( + +
+
+

{deckData().deck.title}

+ +
+ +
+
By {deckData().deck.owner_did}
+ +
+ +

{deckData().deck.description}

+ +
+ {(tag) => } +
+ +
+ + +
+
+ +

+ Cards ({deckData().cards.length}) +

+ +
+ + {(card, i) => ( + +
+ CARD {i() + 1} +
+
+
+
Front
+
{card.front}
+
+
+
Back
+
{card.back}
+
+
+
+ )} +
+ +
+ No cards indexed for this deck because we strictly respect remote privacy settings or the deck is + empty. +
+
+
+
+ )} + + +
+ ); +}; + +export default DeckPreview; diff --git a/web/src/pages/Library.tsx b/web/src/pages/Library.tsx new file mode 100644 index 0000000..7be2ce3 --- /dev/null +++ b/web/src/pages/Library.tsx @@ -0,0 +1,128 @@ +import { Card } from "$components/ui/Card"; +import { EmptyState } from "$components/ui/EmptyState"; +import { api } from "$lib/api"; +import { A } from "@solidjs/router"; +import type { Component } from "solid-js"; +import { createResource, For, Show } from "solid-js"; + +type LibraryItemData = { + title?: string; + description?: string; + tags?: string[]; + at_uri?: string; + front?: string; + back?: string; + body?: string; +}; + +type LibraryItemKind = "deck" | "note" | "card"; + +type LibraryItemSource = "local" | "remote"; + +type LibraryItem = { + item_type: LibraryItemKind; + item_id: string; + creator_did: string; + data: LibraryItemData; + rank: number; + source: LibraryItemSource; +}; + +const fetchFederatedContent = async () => { + const res = await api.search("", 50, 0, "remote"); + if (!res.ok) return []; + return (await res.json()) as LibraryItem[]; +}; + +const LibraryItemCard: Component<{ item: LibraryItem }> = (props) => { + return ( + +
+
+
+

{props.item.data.title}

+

+ by {props.item.creator_did.slice(0, 12)}... +

+
+ + Remote + +
+ +

+ {props.item.data.description || "No description provided."} +

+ + + {tags => ( +
+
+ {(tag) => `#${tag}`} +
+
+ )} +
+
+
+ ); +}; + +const Library: Component = () => { + const [items] = createResource(fetchFederatedContent); + + return ( +
+
+

Federated Library

+

+ Discover content from across the AT Protocol network. These decks are indexed from other users and PDS + instances. +

+
+ + +
+
+ }> + 0} + fallback={ + window.location.href = "/discovery"} + class="text-sm font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300"> + Go to Discovery + + } /> + }> +
+ + {(item) => ( + + }> + {at_uri => ( + + + + )} + + + )} + +
+
+
+
+ ); +}; + +export default Library; diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx new file mode 100644 index 0000000..19781a5 --- /dev/null +++ b/web/src/pages/Settings.tsx @@ -0,0 +1,92 @@ +import { api } from "$lib/api"; +import type { Component } from "solid-js"; +import { createSignal, Show } from "solid-js"; + +const Settings: Component = () => { + const [exportingDecks, setExportingDecks] = createSignal(false); + const [exportingNotes, setExportingNotes] = createSignal(false); + const [exportError, setExportError] = createSignal(null); + + const handleExport = async (collection: "decks" | "notes") => { + if (collection === "decks") setExportingDecks(true); + else setExportingNotes(true); + setExportError(null); + + try { + const res = await api.exportData(collection); + if (!res.ok) throw new Error(`Failed to export ${collection}`); + + const blob = await res.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `malfestio_${collection}_export.json`; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + a.remove(); + } catch (e) { + console.error(e); + setExportError(`Failed to export ${collection}. Please try again.`); + } finally { + if (collection === "decks") setExportingDecks(false); + else setExportingNotes(false); + } + }; + + return ( +
+
+

Settings

+

Manage your account preferences and data.

+
+ +
+ {/* Export Section */} +
+

Export Data

+

+ Download your data as JSON files. This includes all your content but excludes media files which are linked + remotely. +

+ + +
+ {exportError()} +
+
+ +
+ + +
+
+ + {/* Preferences Section - Placeholder for now as per plan */} +
+
+

Preferences

+ + Coming Soon + +
+

+ Advanced theme settings and default visibility options will be available here. +

+
+
+
+ ); +}; + +export default Settings;