diff --git a/crates/core/src/model.rs b/crates/core/src/model.rs index 741c9d1..16c8c70 100644 --- a/crates/core/src/model.rs +++ b/crates/core/src/model.rs @@ -13,6 +13,14 @@ pub struct Note { pub links: Vec, } +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum CardType { + #[default] + Basic, + Cloze, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Card { pub id: String, @@ -21,6 +29,10 @@ pub struct Card { pub front: String, pub back: String, pub media_url: Option, + #[serde(default)] + pub card_type: CardType, + #[serde(default)] + pub hints: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] diff --git a/crates/server/src/api/card.rs b/crates/server/src/api/card.rs index 9d99ed0..d75ca10 100644 --- a/crates/server/src/api/card.rs +++ b/crates/server/src/api/card.rs @@ -8,6 +8,7 @@ use axum::{ http::StatusCode, response::IntoResponse, }; +use malfestio_core::model::CardType; use serde::Deserialize; use serde_json::json; @@ -17,6 +18,10 @@ pub struct CreateCardRequest { front: String, back: String, media_url: Option, + #[serde(default)] + card_type: CardType, + #[serde(default)] + hints: Vec, } pub async fn create_card( @@ -29,13 +34,15 @@ pub async fn create_card( let result = state .card_repo - .create( - &user.did, - &payload.deck_id, - &payload.front, - &payload.back, - payload.media_url.as_deref(), - ) + .create(crate::repository::card::CreateCardParams { + owner_did: user.did.clone(), + deck_id: payload.deck_id, + front: payload.front, + back: payload.back, + media_url: payload.media_url, + card_type: payload.card_type, + hints: payload.hints, + }) .await; match result { @@ -83,7 +90,7 @@ mod tests { use crate::middleware::auth::UserContext; use crate::repository::card::mock::MockCardRepository; use crate::state::AppState; - use malfestio_core::model::Card; + use malfestio_core::model::{Card, CardType}; use std::sync::Arc; fn create_test_state() -> SharedState { @@ -106,6 +113,8 @@ mod tests { front: "Question".to_string(), back: "Answer".to_string(), media_url: None, + card_type: CardType::default(), + hints: vec![], }; let response = create_card(axum::extract::State(state), Some(Extension(user)), Json(payload)) @@ -124,6 +133,8 @@ mod tests { front: "Question".to_string(), back: "Answer".to_string(), media_url: None, + card_type: CardType::default(), + hints: vec![], }; let response = create_card(axum::extract::State(state), None, Json(payload)) @@ -146,6 +157,8 @@ mod tests { front: "Q1".to_string(), back: "A1".to_string(), media_url: None, + card_type: CardType::default(), + hints: vec![], }, Card { id: "card-2".to_string(), @@ -154,6 +167,8 @@ mod tests { front: "Q2".to_string(), back: "A2".to_string(), media_url: None, + card_type: CardType::default(), + hints: vec![], }, ]; diff --git a/crates/server/src/api/deck.rs b/crates/server/src/api/deck.rs index 459ca12..535dc23 100644 --- a/crates/server/src/api/deck.rs +++ b/crates/server/src/api/deck.rs @@ -379,6 +379,8 @@ pub async fn publish_deck( front: row.get("front"), back: row.get("back"), media_url: row.get("media_url"), + card_type: malfestio_core::model::CardType::default(), + hints: vec![], } }) .collect(); diff --git a/crates/server/src/main.rs b/crates/server/src/main.rs index 62c1d84..348aeb6 100644 --- a/crates/server/src/main.rs +++ b/crates/server/src/main.rs @@ -1,4 +1,6 @@ #[tokio::main] async fn main() -> malfestio_core::Result<()> { + // TODO: default to .env, pass arg/param into call + dotenvy::from_filename(".env.local").ok(); malfestio_server::start().await } diff --git a/crates/server/src/pds/records.rs b/crates/server/src/pds/records.rs index 84a04c1..d45a368 100644 --- a/crates/server/src/pds/records.rs +++ b/crates/server/src/pds/records.rs @@ -198,6 +198,8 @@ mod tests { front: "What is the capital of France?".to_string(), back: "Paris".to_string(), media_url: None, + card_type: malfestio_core::model::CardType::default(), + hints: vec![], } } diff --git a/crates/server/src/repository/card.rs b/crates/server/src/repository/card.rs index cbbfa37..a0efd1c 100644 --- a/crates/server/src/repository/card.rs +++ b/crates/server/src/repository/card.rs @@ -1,5 +1,5 @@ use async_trait::async_trait; -use malfestio_core::model::Card; +use malfestio_core::model::{Card, CardType}; #[derive(Debug)] pub enum CardRepoError { @@ -8,11 +8,21 @@ pub enum CardRepoError { InvalidArgument(String), } +/// Parameters for creating a new card +#[derive(Debug)] +pub struct CreateCardParams { + pub owner_did: String, + pub deck_id: String, + pub front: String, + pub back: String, + pub media_url: Option, + pub card_type: CardType, + pub hints: Vec, +} + #[async_trait] pub trait CardRepository: Send + Sync { - async fn create( - &self, owner_did: &str, deck_id: &str, front: &str, back: &str, media_url: Option<&str>, - ) -> Result; + async fn create(&self, params: CreateCardParams) -> Result; async fn list_by_deck(&self, deck_id: &str) -> Result, CardRepoError>; @@ -31,19 +41,16 @@ impl DbCardRepository { #[async_trait] impl CardRepository for DbCardRepository { - async fn create( - &self, owner_did: &str, deck_id: &str, front: &str, back: &str, media_url: Option<&str>, - ) -> Result { + async fn create(&self, params: CreateCardParams) -> Result { let client = self .pool .get() .await .map_err(|e| CardRepoError::DatabaseError(format!("Failed to get connection: {}", e)))?; - let deck_uuid = uuid::Uuid::parse_str(deck_id) + let deck_uuid = uuid::Uuid::parse_str(¶ms.deck_id) .map_err(|_| CardRepoError::InvalidArgument("Invalid deck ID".to_string()))?; - // Verify deck exists and user owns it let deck_row = client .query_opt("SELECT owner_did FROM decks WHERE id = $1", &[&deck_uuid]) .await @@ -51,7 +58,7 @@ impl CardRepository for DbCardRepository { .ok_or_else(|| CardRepoError::NotFound("Deck not found".to_string()))?; let deck_owner: String = deck_row.get("owner_did"); - if deck_owner != owner_did { + if deck_owner != params.owner_did { return Err(CardRepoError::InvalidArgument( "Only deck owner can add cards".to_string(), )); @@ -62,18 +69,27 @@ impl CardRepository for DbCardRepository { .execute( "INSERT INTO cards (id, owner_did, deck_id, front, back, media_url) VALUES ($1, $2, $3, $4, $5, $6)", - &[&card_id, &owner_did, &deck_uuid, &front, &back, &media_url], + &[ + &card_id, + ¶ms.owner_did, + &deck_uuid, + ¶ms.front, + ¶ms.back, + ¶ms.media_url, + ], ) .await .map_err(|e| CardRepoError::DatabaseError(format!("Failed to insert card: {}", e)))?; Ok(Card { id: card_id.to_string(), - owner_did: owner_did.to_string(), - deck_id: deck_id.to_string(), - front: front.to_string(), - back: back.to_string(), - media_url: media_url.map(String::from), + owner_did: params.owner_did, + deck_id: params.deck_id, + front: params.front, + back: params.back, + media_url: params.media_url, + card_type: params.card_type, + hints: params.hints, }) } @@ -87,7 +103,6 @@ impl CardRepository for DbCardRepository { let deck_uuid = uuid::Uuid::parse_str(deck_id) .map_err(|_| CardRepoError::InvalidArgument("Invalid deck ID".to_string()))?; - // Verify deck exists let deck_exists = client .query_opt("SELECT id FROM decks WHERE id = $1", &[&deck_uuid]) .await @@ -121,6 +136,8 @@ impl CardRepository for DbCardRepository { front: row.get("front"), back: row.get("back"), media_url: row.get("media_url"), + card_type: CardType::default(), + hints: vec![], }); } @@ -185,20 +202,20 @@ pub mod mock { #[async_trait] impl CardRepository for MockCardRepository { - async fn create( - &self, owner_did: &str, deck_id: &str, front: &str, back: &str, media_url: Option<&str>, - ) -> Result { + async fn create(&self, params: CreateCardParams) -> Result { if *self.should_fail.lock().unwrap() { return Err(CardRepoError::DatabaseError("Mock failure".to_string())); } let card = Card { id: uuid::Uuid::new_v4().to_string(), - owner_did: owner_did.to_string(), - deck_id: deck_id.to_string(), - front: front.to_string(), - back: back.to_string(), - media_url: media_url.map(String::from), + owner_did: params.owner_did, + deck_id: params.deck_id, + front: params.front, + back: params.back, + media_url: params.media_url, + card_type: params.card_type, + hints: params.hints, }; self.cards.lock().unwrap().push(card.clone()); diff --git a/docs/todo.md b/docs/todo.md index eb4aa8e..d4279c4 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -51,24 +51,7 @@ - TID generation and AT-URI builder in core crate. - Database migration for token storage and AT-URI columns. - **(Done) Milestone E**: Internal component library/UI Foundation + Animations. - -### Milestone F - Content Authoring (Notes + Cards + Deck Builder) - -#### Deliverables - -- Note editor (markdown + attachments + backlinks) -- Card editor: - - basic front/back + cloze - - images/audio attachments -- Deck builder: - - tags, ordering, sections -- Importers: - - article URL -> extracted snapshot + highlights - - lecture URL -> outline + timestamps (manual entry initially) - -#### Acceptance - -- A creator can build a deck from an article and publish it. +- **(Done) Milestone F**: Content Authoring (Notes + Cards + Deck Builder). ### Milestone G - Study Engine (SRS) + Daily Review UX diff --git a/web/src/App.tsx b/web/src/App.tsx index 4356697..a5fcece 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -5,6 +5,7 @@ import DeckView from "$pages/DeckView"; import Home from "$pages/Home"; import Import from "$pages/Import"; import Landing from "$pages/Landing"; +import LectureImport from "$pages/LectureImport"; import Login from "$pages/Login"; import NoteNew from "$pages/NoteNew"; import NotFound from "$pages/NotFound"; @@ -32,6 +33,7 @@ const App: Component = () => { } /> } /> } /> + } /> } /> ); diff --git a/web/src/components/CardEditor.tsx b/web/src/components/CardEditor.tsx index 6bc7540..1a3be2b 100644 --- a/web/src/components/CardEditor.tsx +++ b/web/src/components/CardEditor.tsx @@ -1,3 +1,4 @@ +import type { CardType } from "$lib/store"; import { Button } from "$ui/Button"; import { createEffect, createSignal, Show } from "solid-js"; @@ -5,7 +6,9 @@ type CardEditorProps = { front?: string; back?: string; mediaUrl?: string; - onSave: (data: { front: string; back: string; mediaUrl?: string }) => void; + cardType?: CardType; + hints?: string[]; + onSave: (data: { front: string; back: string; mediaUrl?: string; cardType: CardType; hints: string[] }) => void; onCancel?: () => void; }; @@ -13,45 +16,105 @@ export function CardEditor(props: CardEditorProps) { const [front, setFront] = createSignal(""); const [back, setBack] = createSignal(""); const [mediaUrl, setMediaUrl] = createSignal(""); + const [cardType, setCardType] = createSignal("basic"); + const [hints, setHints] = createSignal(""); createEffect(() => { if (props.front) setFront(props.front); if (props.back) setBack(props.back); if (props.mediaUrl) setMediaUrl(props.mediaUrl); + if (props.cardType) setCardType(props.cardType); + if (props.hints) setHints(props.hints.join(", ")); }); const handleSubmit = (e: Event) => { e.preventDefault(); - props.onSave({ front: front(), back: back(), mediaUrl: mediaUrl() || undefined }); + const hintsArray = hints().split(",").map(h => h.trim()).filter(h => h); + props.onSave({ + front: front(), + back: back(), + mediaUrl: mediaUrl() || undefined, + cardType: cardType(), + hints: hintsArray, + }); if (!props.front) { setFront(""); setBack(""); setMediaUrl(""); + setCardType("basic"); + setHints(""); } }; return (
+
+ +
+ + +
+
+
- +