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

Configure Feed

Select the types of activity you want to include in your feed.

feat: lecture import

* update deck editor with card types, hints, and management.

+347 -71
+12
crates/core/src/model.rs
··· 13 13 pub links: Vec<String>, 14 14 } 15 15 16 + #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] 17 + #[serde(rename_all = "lowercase")] 18 + pub enum CardType { 19 + #[default] 20 + Basic, 21 + Cloze, 22 + } 23 + 16 24 #[derive(Debug, Clone, Serialize, Deserialize)] 17 25 pub struct Card { 18 26 pub id: String, ··· 21 29 pub front: String, 22 30 pub back: String, 23 31 pub media_url: Option<String>, 32 + #[serde(default)] 33 + pub card_type: CardType, 34 + #[serde(default)] 35 + pub hints: Vec<String>, 24 36 } 25 37 26 38 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+23 -8
crates/server/src/api/card.rs
··· 8 8 http::StatusCode, 9 9 response::IntoResponse, 10 10 }; 11 + use malfestio_core::model::CardType; 11 12 use serde::Deserialize; 12 13 use serde_json::json; 13 14 ··· 17 18 front: String, 18 19 back: String, 19 20 media_url: Option<String>, 21 + #[serde(default)] 22 + card_type: CardType, 23 + #[serde(default)] 24 + hints: Vec<String>, 20 25 } 21 26 22 27 pub async fn create_card( ··· 29 34 30 35 let result = state 31 36 .card_repo 32 - .create( 33 - &user.did, 34 - &payload.deck_id, 35 - &payload.front, 36 - &payload.back, 37 - payload.media_url.as_deref(), 38 - ) 37 + .create(crate::repository::card::CreateCardParams { 38 + owner_did: user.did.clone(), 39 + deck_id: payload.deck_id, 40 + front: payload.front, 41 + back: payload.back, 42 + media_url: payload.media_url, 43 + card_type: payload.card_type, 44 + hints: payload.hints, 45 + }) 39 46 .await; 40 47 41 48 match result { ··· 83 90 use crate::middleware::auth::UserContext; 84 91 use crate::repository::card::mock::MockCardRepository; 85 92 use crate::state::AppState; 86 - use malfestio_core::model::Card; 93 + use malfestio_core::model::{Card, CardType}; 87 94 use std::sync::Arc; 88 95 89 96 fn create_test_state() -> SharedState { ··· 106 113 front: "Question".to_string(), 107 114 back: "Answer".to_string(), 108 115 media_url: None, 116 + card_type: CardType::default(), 117 + hints: vec![], 109 118 }; 110 119 111 120 let response = create_card(axum::extract::State(state), Some(Extension(user)), Json(payload)) ··· 124 133 front: "Question".to_string(), 125 134 back: "Answer".to_string(), 126 135 media_url: None, 136 + card_type: CardType::default(), 137 + hints: vec![], 127 138 }; 128 139 129 140 let response = create_card(axum::extract::State(state), None, Json(payload)) ··· 146 157 front: "Q1".to_string(), 147 158 back: "A1".to_string(), 148 159 media_url: None, 160 + card_type: CardType::default(), 161 + hints: vec![], 149 162 }, 150 163 Card { 151 164 id: "card-2".to_string(), ··· 154 167 front: "Q2".to_string(), 155 168 back: "A2".to_string(), 156 169 media_url: None, 170 + card_type: CardType::default(), 171 + hints: vec![], 157 172 }, 158 173 ]; 159 174
+2
crates/server/src/api/deck.rs
··· 379 379 front: row.get("front"), 380 380 back: row.get("back"), 381 381 media_url: row.get("media_url"), 382 + card_type: malfestio_core::model::CardType::default(), 383 + hints: vec![], 382 384 } 383 385 }) 384 386 .collect();
+2
crates/server/src/main.rs
··· 1 1 #[tokio::main] 2 2 async fn main() -> malfestio_core::Result<()> { 3 + // TODO: default to .env, pass arg/param into call 4 + dotenvy::from_filename(".env.local").ok(); 3 5 malfestio_server::start().await 4 6 }
+2
crates/server/src/pds/records.rs
··· 198 198 front: "What is the capital of France?".to_string(), 199 199 back: "Paris".to_string(), 200 200 media_url: None, 201 + card_type: malfestio_core::model::CardType::default(), 202 + hints: vec![], 201 203 } 202 204 } 203 205
+42 -25
crates/server/src/repository/card.rs
··· 1 1 use async_trait::async_trait; 2 - use malfestio_core::model::Card; 2 + use malfestio_core::model::{Card, CardType}; 3 3 4 4 #[derive(Debug)] 5 5 pub enum CardRepoError { ··· 8 8 InvalidArgument(String), 9 9 } 10 10 11 + /// Parameters for creating a new card 12 + #[derive(Debug)] 13 + pub struct CreateCardParams { 14 + pub owner_did: String, 15 + pub deck_id: String, 16 + pub front: String, 17 + pub back: String, 18 + pub media_url: Option<String>, 19 + pub card_type: CardType, 20 + pub hints: Vec<String>, 21 + } 22 + 11 23 #[async_trait] 12 24 pub trait CardRepository: Send + Sync { 13 - async fn create( 14 - &self, owner_did: &str, deck_id: &str, front: &str, back: &str, media_url: Option<&str>, 15 - ) -> Result<Card, CardRepoError>; 25 + async fn create(&self, params: CreateCardParams) -> Result<Card, CardRepoError>; 16 26 17 27 async fn list_by_deck(&self, deck_id: &str) -> Result<Vec<Card>, CardRepoError>; 18 28 ··· 31 41 32 42 #[async_trait] 33 43 impl CardRepository for DbCardRepository { 34 - async fn create( 35 - &self, owner_did: &str, deck_id: &str, front: &str, back: &str, media_url: Option<&str>, 36 - ) -> Result<Card, CardRepoError> { 44 + async fn create(&self, params: CreateCardParams) -> Result<Card, CardRepoError> { 37 45 let client = self 38 46 .pool 39 47 .get() 40 48 .await 41 49 .map_err(|e| CardRepoError::DatabaseError(format!("Failed to get connection: {}", e)))?; 42 50 43 - let deck_uuid = uuid::Uuid::parse_str(deck_id) 51 + let deck_uuid = uuid::Uuid::parse_str(&params.deck_id) 44 52 .map_err(|_| CardRepoError::InvalidArgument("Invalid deck ID".to_string()))?; 45 53 46 - // Verify deck exists and user owns it 47 54 let deck_row = client 48 55 .query_opt("SELECT owner_did FROM decks WHERE id = $1", &[&deck_uuid]) 49 56 .await ··· 51 58 .ok_or_else(|| CardRepoError::NotFound("Deck not found".to_string()))?; 52 59 53 60 let deck_owner: String = deck_row.get("owner_did"); 54 - if deck_owner != owner_did { 61 + if deck_owner != params.owner_did { 55 62 return Err(CardRepoError::InvalidArgument( 56 63 "Only deck owner can add cards".to_string(), 57 64 )); ··· 62 69 .execute( 63 70 "INSERT INTO cards (id, owner_did, deck_id, front, back, media_url) 64 71 VALUES ($1, $2, $3, $4, $5, $6)", 65 - &[&card_id, &owner_did, &deck_uuid, &front, &back, &media_url], 72 + &[ 73 + &card_id, 74 + &params.owner_did, 75 + &deck_uuid, 76 + &params.front, 77 + &params.back, 78 + &params.media_url, 79 + ], 66 80 ) 67 81 .await 68 82 .map_err(|e| CardRepoError::DatabaseError(format!("Failed to insert card: {}", e)))?; 69 83 70 84 Ok(Card { 71 85 id: card_id.to_string(), 72 - owner_did: owner_did.to_string(), 73 - deck_id: deck_id.to_string(), 74 - front: front.to_string(), 75 - back: back.to_string(), 76 - media_url: media_url.map(String::from), 86 + owner_did: params.owner_did, 87 + deck_id: params.deck_id, 88 + front: params.front, 89 + back: params.back, 90 + media_url: params.media_url, 91 + card_type: params.card_type, 92 + hints: params.hints, 77 93 }) 78 94 } 79 95 ··· 87 103 let deck_uuid = uuid::Uuid::parse_str(deck_id) 88 104 .map_err(|_| CardRepoError::InvalidArgument("Invalid deck ID".to_string()))?; 89 105 90 - // Verify deck exists 91 106 let deck_exists = client 92 107 .query_opt("SELECT id FROM decks WHERE id = $1", &[&deck_uuid]) 93 108 .await ··· 121 136 front: row.get("front"), 122 137 back: row.get("back"), 123 138 media_url: row.get("media_url"), 139 + card_type: CardType::default(), 140 + hints: vec![], 124 141 }); 125 142 } 126 143 ··· 185 202 186 203 #[async_trait] 187 204 impl CardRepository for MockCardRepository { 188 - async fn create( 189 - &self, owner_did: &str, deck_id: &str, front: &str, back: &str, media_url: Option<&str>, 190 - ) -> Result<Card, CardRepoError> { 205 + async fn create(&self, params: CreateCardParams) -> Result<Card, CardRepoError> { 191 206 if *self.should_fail.lock().unwrap() { 192 207 return Err(CardRepoError::DatabaseError("Mock failure".to_string())); 193 208 } 194 209 195 210 let card = Card { 196 211 id: uuid::Uuid::new_v4().to_string(), 197 - owner_did: owner_did.to_string(), 198 - deck_id: deck_id.to_string(), 199 - front: front.to_string(), 200 - back: back.to_string(), 201 - media_url: media_url.map(String::from), 212 + owner_did: params.owner_did, 213 + deck_id: params.deck_id, 214 + front: params.front, 215 + back: params.back, 216 + media_url: params.media_url, 217 + card_type: params.card_type, 218 + hints: params.hints, 202 219 }; 203 220 204 221 self.cards.lock().unwrap().push(card.clone());
+1 -18
docs/todo.md
··· 51 51 - TID generation and AT-URI builder in core crate. 52 52 - Database migration for token storage and AT-URI columns. 53 53 - **(Done) Milestone E**: Internal component library/UI Foundation + Animations. 54 - 55 - ### Milestone F - Content Authoring (Notes + Cards + Deck Builder) 56 - 57 - #### Deliverables 58 - 59 - - Note editor (markdown + attachments + backlinks) 60 - - Card editor: 61 - - basic front/back + cloze 62 - - images/audio attachments 63 - - Deck builder: 64 - - tags, ordering, sections 65 - - Importers: 66 - - article URL -> extracted snapshot + highlights 67 - - lecture URL -> outline + timestamps (manual entry initially) 68 - 69 - #### Acceptance 70 - 71 - - A creator can build a deck from an article and publish it. 54 + - **(Done) Milestone F**: Content Authoring (Notes + Cards + Deck Builder). 72 55 73 56 ### Milestone G - Study Engine (SRS) + Daily Review UX 74 57
+2
web/src/App.tsx
··· 5 5 import Home from "$pages/Home"; 6 6 import Import from "$pages/Import"; 7 7 import Landing from "$pages/Landing"; 8 + import LectureImport from "$pages/LectureImport"; 8 9 import Login from "$pages/Login"; 9 10 import NoteNew from "$pages/NoteNew"; 10 11 import NotFound from "$pages/NotFound"; ··· 32 33 <Route path="/notes/new" component={() => <ProtectedRoute component={NoteNew} />} /> 33 34 <Route path="/decks/:id" component={() => <ProtectedRoute component={DeckView} />} /> 34 35 <Route path="/import" component={() => <ProtectedRoute component={Import} />} /> 36 + <Route path="/import/lecture" component={() => <ProtectedRoute component={LectureImport} />} /> 35 37 <Route path="*" component={() => <ProtectedRoute component={NotFound} />} /> 36 38 </Router> 37 39 );
+74 -11
web/src/components/CardEditor.tsx
··· 1 + import type { CardType } from "$lib/store"; 1 2 import { Button } from "$ui/Button"; 2 3 import { createEffect, createSignal, Show } from "solid-js"; 3 4 ··· 5 6 front?: string; 6 7 back?: string; 7 8 mediaUrl?: string; 8 - onSave: (data: { front: string; back: string; mediaUrl?: string }) => void; 9 + cardType?: CardType; 10 + hints?: string[]; 11 + onSave: (data: { front: string; back: string; mediaUrl?: string; cardType: CardType; hints: string[] }) => void; 9 12 onCancel?: () => void; 10 13 }; 11 14 ··· 13 16 const [front, setFront] = createSignal(""); 14 17 const [back, setBack] = createSignal(""); 15 18 const [mediaUrl, setMediaUrl] = createSignal(""); 19 + const [cardType, setCardType] = createSignal<CardType>("basic"); 20 + const [hints, setHints] = createSignal(""); 16 21 17 22 createEffect(() => { 18 23 if (props.front) setFront(props.front); 19 24 if (props.back) setBack(props.back); 20 25 if (props.mediaUrl) setMediaUrl(props.mediaUrl); 26 + if (props.cardType) setCardType(props.cardType); 27 + if (props.hints) setHints(props.hints.join(", ")); 21 28 }); 22 29 23 30 const handleSubmit = (e: Event) => { 24 31 e.preventDefault(); 25 - props.onSave({ front: front(), back: back(), mediaUrl: mediaUrl() || undefined }); 32 + const hintsArray = hints().split(",").map(h => h.trim()).filter(h => h); 33 + props.onSave({ 34 + front: front(), 35 + back: back(), 36 + mediaUrl: mediaUrl() || undefined, 37 + cardType: cardType(), 38 + hints: hintsArray, 39 + }); 26 40 if (!props.front) { 27 41 setFront(""); 28 42 setBack(""); 29 43 setMediaUrl(""); 44 + setCardType("basic"); 45 + setHints(""); 30 46 } 31 47 }; 32 48 33 49 return ( 34 50 <form onSubmit={handleSubmit} class="space-y-4 p-4 border border-gray-800 rounded bg-gray-900/50"> 51 + <div class="flex gap-4 items-center"> 52 + <label class="text-sm font-medium text-gray-400">Card Type</label> 53 + <div class="flex gap-4"> 54 + <label class="flex items-center gap-2 cursor-pointer"> 55 + <input 56 + type="radio" 57 + name="cardType" 58 + value="basic" 59 + checked={cardType() === "basic"} 60 + onChange={() => setCardType("basic")} 61 + class="text-blue-500 focus:ring-blue-500" /> 62 + <span class="text-gray-300">Basic</span> 63 + </label> 64 + <label class="flex items-center gap-2 cursor-pointer"> 65 + <input 66 + type="radio" 67 + name="cardType" 68 + value="cloze" 69 + checked={cardType() === "cloze"} 70 + onChange={() => setCardType("cloze")} 71 + class="text-blue-500 focus:ring-blue-500" /> 72 + <span class="text-gray-300">Cloze</span> 73 + </label> 74 + </div> 75 + </div> 76 + 35 77 <div> 36 - <label class="block text-sm font-medium text-gray-400 mb-1">Front</label> 78 + <label class="block text-sm font-medium text-gray-400 mb-1"> 79 + {cardType() === "cloze" ? "Text (use {{...}} for deletions)" : "Front"} 80 + </label> 37 81 <textarea 38 82 value={front()} 39 83 onInput={(e) => setFront(e.target.value)} 40 84 class="w-full bg-gray-800 border-gray-700 text-white rounded p-2 focus:ring-blue-500 focus:border-blue-500" 41 - placeholder="Front of card..." 85 + placeholder={cardType() === "cloze" ? "The capital of France is {{Paris}}." : "Front of card..."} 42 86 rows={2} 43 87 required /> 44 88 </div> 45 89 90 + <Show when={cardType() === "basic"}> 91 + <div> 92 + <label class="block text-sm font-medium text-gray-400 mb-1">Back</label> 93 + <textarea 94 + value={back()} 95 + onInput={(e) => setBack(e.target.value)} 96 + class="w-full bg-gray-800 border-gray-700 text-white rounded p-2 focus:ring-blue-500 focus:border-blue-500" 97 + placeholder="Back of card..." 98 + rows={3} 99 + required /> 100 + </div> 101 + </Show> 102 + 103 + <Show when={cardType() === "cloze"}> 104 + <div class="p-3 bg-gray-800/50 rounded border border-gray-700"> 105 + <div class="text-xs text-gray-500 mb-1">Preview</div> 106 + <div class="text-gray-300">{front().replace(/\{\{([^}]+)\}\}/g, "[...]")}</div> 107 + </div> 108 + </Show> 109 + 46 110 <div> 47 - <label class="block text-sm font-medium text-gray-400 mb-1">Back</label> 48 - <textarea 49 - value={back()} 50 - onInput={(e) => setBack(e.target.value)} 111 + <label class="block text-sm font-medium text-gray-400 mb-1">Hints (comma separated, optional)</label> 112 + <input 113 + type="text" 114 + value={hints()} 115 + onInput={(e) => setHints(e.target.value)} 51 116 class="w-full bg-gray-800 border-gray-700 text-white rounded p-2 focus:ring-blue-500 focus:border-blue-500" 52 - placeholder="Back of card..." 53 - rows={3} 54 - required /> 117 + placeholder="First letter: P, Country in Europe..." /> 55 118 </div> 56 119 57 120 <div>
+76 -8
web/src/components/DeckEditor.tsx
··· 1 1 import { api } from "$lib/api"; 2 - import type { Card, CreateDeckPayload, Visibility } from "$lib/store"; 2 + import type { Card, CardType, CreateDeckPayload, Visibility } from "$lib/store"; 3 3 import { toast } from "$lib/toast"; 4 4 import { Button } from "$ui/Button"; 5 5 import { createSignal, For, Show } from "solid-js"; ··· 8 8 export function DeckEditor(props: { onSave?: (deck: CreateDeckPayload) => void }) { 9 9 const [title, setTitle] = createSignal(""); 10 10 const [description, setDescription] = createSignal(""); 11 + const [tags, setTags] = createSignal(""); 11 12 const [visibilityType, setVisibilityType] = createSignal<string>("Private"); 12 13 const [sharedWith, setSharedWith] = createSignal(""); 13 14 ··· 24 25 visibility = { type: visibilityType() as "Private" | "Unlisted" | "Public" }; 25 26 } 26 27 27 - const payload = { title: title(), description: description(), tags: [], visibility, cards: cards() }; 28 + const tagsArray = tags().split(",").map(t => t.trim()).filter(t => t); 29 + const payload = { title: title(), description: description(), tags: tagsArray, visibility, cards: cards() }; 28 30 29 31 if (props.onSave) { 30 32 props.onSave(payload); ··· 43 45 } 44 46 }; 45 47 46 - const addCard = (cardData: Card) => { 47 - setCards([...cards(), cardData]); 48 + const addCard = ( 49 + cardData: { front: string; back: string; mediaUrl?: string; cardType: CardType; hints: string[] }, 50 + ) => { 51 + const card: Card = { 52 + front: cardData.front, 53 + back: cardData.back, 54 + mediaUrl: cardData.mediaUrl, 55 + cardType: cardData.cardType, 56 + hints: cardData.hints, 57 + }; 58 + setCards([...cards(), card]); 48 59 setShowCardEditor(false); 49 60 }; 50 61 62 + const removeCard = (index: number) => { 63 + setCards(cards().filter((_, i) => i !== index)); 64 + }; 65 + 66 + const moveCard = (from: number, to: number) => { 67 + if (to < 0 || to >= cards().length) return; 68 + const newCards = [...cards()]; 69 + const [moved] = newCards.splice(from, 1); 70 + newCards.splice(to, 0, moved); 71 + setCards(newCards); 72 + }; 73 + 51 74 return ( 52 75 <div class="space-y-8"> 53 76 <form ··· 72 95 value={description()} 73 96 onInput={(e) => setDescription(e.target.value)} 74 97 class="w-full bg-gray-800 border-gray-700 text-white rounded p-2 focus:ring-blue-500 focus:border-blue-500" /> 98 + </div> 99 + 100 + <div> 101 + <label for="tags" class="block text-sm font-medium text-gray-400 mb-1">Tags (comma separated)</label> 102 + <input 103 + id="tags" 104 + type="text" 105 + value={tags()} 106 + onInput={(e) => setTags(e.target.value)} 107 + class="w-full bg-gray-800 border-gray-700 text-white rounded p-2 focus:ring-blue-500 focus:border-blue-500" 108 + placeholder="language, vocabulary, spanish..." /> 75 109 </div> 76 110 77 111 <div> ··· 105 139 <div class="pt-4 border-t border-gray-800"> 106 140 <h3 class="text-lg font-medium text-white mb-4">Cards ({cards().length})</h3> 107 141 108 - <div class="space-y-4 mb-4"> 142 + <div class="space-y-2 mb-4"> 109 143 <For each={cards()}> 110 144 {(card, i) => ( 111 - <div class="p-4 border border-gray-800 rounded bg-gray-900 flex justify-between items-center"> 112 - <div class="truncate pr-4 font-mono text-sm text-gray-300">{card.front}</div> 113 - <div class="text-gray-500 text-xs">Card {i() + 1}</div> 145 + <div class="p-4 border border-gray-800 rounded bg-gray-900 flex justify-between items-center group"> 146 + <div class="flex items-center gap-3 flex-1 min-w-0"> 147 + <div class="flex flex-col gap-1"> 148 + <button 149 + type="button" 150 + onClick={() => moveCard(i(), i() - 1)} 151 + disabled={i() === 0} 152 + class="text-gray-500 hover:text-gray-300 disabled:opacity-30 disabled:cursor-not-allowed p-1"> 153 + 154 + </button> 155 + <button 156 + type="button" 157 + onClick={() => moveCard(i(), i() + 1)} 158 + disabled={i() === cards().length - 1} 159 + class="text-gray-500 hover:text-gray-300 disabled:opacity-30 disabled:cursor-not-allowed p-1"> 160 + 161 + </button> 162 + </div> 163 + <div class="flex-1 min-w-0"> 164 + <div class="truncate font-mono text-sm text-gray-300">{card.front}</div> 165 + <div class="text-xs text-gray-500 flex gap-2 mt-1"> 166 + <span class="uppercase">{card.cardType || "basic"}</span> 167 + <Show when={card.hints && card.hints.length > 0}> 168 + <span>• {card.hints?.length} hint(s)</span> 169 + </Show> 170 + </div> 171 + </div> 172 + </div> 173 + <div class="flex items-center gap-2"> 174 + <span class="text-gray-500 text-xs">#{i() + 1}</span> 175 + <button 176 + type="button" 177 + onClick={() => removeCard(i())} 178 + class="text-red-500 hover:text-red-400 opacity-0 group-hover:opacity-100 transition-opacity p-1"> 179 + 180 + </button> 181 + </div> 114 182 </div> 115 183 )} 116 184 </For>
+10 -1
web/src/lib/store.ts
··· 46 46 content: string[]; 47 47 }; 48 48 49 - export type Card = { id?: string; front: string; back: string; mediaUrl?: string }; 49 + export type CardType = "basic" | "cloze"; 50 + 51 + export type Card = { 52 + id?: string; 53 + front: string; 54 + back: string; 55 + mediaUrl?: string; 56 + cardType?: CardType; 57 + hints?: string[]; 58 + }; 50 59 51 60 export type Deck = { 52 61 id: string;
+101
web/src/pages/LectureImport.tsx
··· 1 + import { NoteEditor } from "$components/NoteEditor"; 2 + import { Button } from "$ui/Button"; 3 + import { createSignal, Show } from "solid-js"; 4 + 5 + export default function LectureImport() { 6 + const [url, setUrl] = createSignal(""); 7 + const [title, setTitle] = createSignal(""); 8 + const [outline, setOutline] = createSignal(""); 9 + const [timestamps, setTimestamps] = createSignal(""); 10 + const [showEditor, setShowEditor] = createSignal(false); 11 + 12 + const handleCreate = (e: Event) => { 13 + e.preventDefault(); 14 + setShowEditor(true); 15 + }; 16 + 17 + const buildContent = () => { 18 + let content = ""; 19 + if (url()) { 20 + content += `Source: [Lecture](${url()})\n\n`; 21 + } 22 + if (timestamps()) { 23 + content += "## Timestamps\n\n"; 24 + timestamps().split("\n").filter(t => t.trim()).forEach(t => { 25 + content += `- ${t.trim()}\n`; 26 + }); 27 + content += "\n"; 28 + } 29 + if (outline()) { 30 + content += "## Outline\n\n"; 31 + content += outline(); 32 + } 33 + return content; 34 + }; 35 + 36 + return ( 37 + <div class="max-w-4xl mx-auto space-y-8"> 38 + <div class="space-y-2"> 39 + <h1 class="text-3xl font-light text-[#F4F4F4]">Import Lecture</h1> 40 + <p class="text-[#C6C6C6]">Create notes from lecture videos with outlines and timestamps.</p> 41 + </div> 42 + 43 + <Show when={!showEditor()}> 44 + <form onSubmit={handleCreate} class="space-y-6 p-6 border border-gray-800 rounded bg-gray-900/40"> 45 + <div> 46 + <label class="block text-sm font-medium text-gray-400 mb-1">Lecture URL</label> 47 + <input 48 + type="url" 49 + value={url()} 50 + onInput={(e) => setUrl(e.target.value)} 51 + class="w-full bg-gray-800 border-gray-700 text-white rounded p-2 focus:ring-blue-500 focus:border-blue-500" 52 + placeholder="https://youtube.com/watch?v=... or lecture platform URL" /> 53 + </div> 54 + 55 + <div> 56 + <label class="block text-sm font-medium text-gray-400 mb-1">Title</label> 57 + <input 58 + type="text" 59 + value={title()} 60 + onInput={(e) => setTitle(e.target.value)} 61 + class="w-full bg-gray-800 border-gray-700 text-white rounded p-2 focus:ring-blue-500 focus:border-blue-500" 62 + placeholder="Lecture title" 63 + required /> 64 + </div> 65 + 66 + <div> 67 + <label class="block text-sm font-medium text-gray-400 mb-1">Timestamps (one per line)</label> 68 + <textarea 69 + value={timestamps()} 70 + onInput={(e) => setTimestamps(e.target.value)} 71 + class="w-full bg-gray-800 border-gray-700 text-white rounded p-2 focus:ring-blue-500 focus:border-blue-500 font-mono text-sm" 72 + placeholder="0:00 Introduction&#10;5:30 Main Topic&#10;15:00 Examples&#10;30:00 Summary" 73 + rows={5} /> 74 + </div> 75 + 76 + <div> 77 + <label class="block text-sm font-medium text-gray-400 mb-1">Outline (Markdown)</label> 78 + <textarea 79 + value={outline()} 80 + onInput={(e) => setOutline(e.target.value)} 81 + class="w-full bg-gray-800 border-gray-700 text-white rounded p-2 focus:ring-blue-500 focus:border-blue-500 font-mono text-sm" 82 + placeholder="# Key Concepts&#10;&#10;- Point 1&#10;- Point 2&#10;&#10;## Details&#10;&#10;Write your notes here..." 83 + rows={10} /> 84 + </div> 85 + 86 + <Button type="submit">Create Note from Lecture</Button> 87 + </form> 88 + </Show> 89 + 90 + <Show when={showEditor()}> 91 + <div class="border-t border-gray-800 pt-8"> 92 + <div class="flex justify-between items-center mb-4"> 93 + <h2 class="text-xl font-semibold text-white">Edit Lecture Note</h2> 94 + <Button variant="ghost" onClick={() => setShowEditor(false)}>← Back</Button> 95 + </div> 96 + <NoteEditor initialTitle={title()} initialContent={buildContent()} /> 97 + </div> 98 + </Show> 99 + </div> 100 + ); 101 + }