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

feat: Add study session functionality to the DeckView page

* bi-directional card flipping in study session

* update UserContext for app password pub

+7 -1
crates/server/src/api/card.rs
··· 106 106 #[tokio::test] 107 107 async fn test_create_card_success() { 108 108 let state = create_test_state(); 109 - let user = UserContext { did: "did:plc:test123".to_string(), handle: "test.handle".to_string() }; 109 + let user = UserContext { 110 + did: "did:plc:test123".to_string(), 111 + handle: "test.handle".to_string(), 112 + access_token: "test_token".to_string(), 113 + pds_url: "https://bsky.social".to_string(), 114 + has_dpop: false, 115 + }; 110 116 111 117 let payload = CreateCardRequest { 112 118 deck_id: "550e8400-e29b-41d4-a716-446655440000".to_string(),
+8 -2
crates/server/src/api/deck.rs
··· 176 176 } 177 177 }; 178 178 179 - match crate::pds::publish::publish_deck_to_pds(state.oauth_repo.clone(), &user.did, &deck, &cards).await { 179 + match crate::pds::publish::publish_deck_to_pds(state.oauth_repo.clone(), &user, &deck, &cards).await { 180 180 Ok(result) => { 181 181 deck_at_uri = Some(result.deck_at_uri.clone()); 182 182 ··· 292 292 Arc::new(crate::repository::oauth::mock::MockOAuthRepository::new()), 293 293 ); 294 294 295 - let user = UserContext { did: "did:plc:alice".to_string(), handle: "alice.bsky.social".to_string() }; 295 + let user = UserContext { 296 + did: "did:plc:alice".to_string(), 297 + handle: "alice.bsky.social".to_string(), 298 + access_token: "test_token".to_string(), 299 + pds_url: "https://bsky.social".to_string(), 300 + has_dpop: false, 301 + }; 296 302 297 303 let payload = CreateDeckRequest { 298 304 title: "My New Deck".to_string(),
+7 -1
crates/server/src/api/feed.rs
··· 89 89 async fn test_get_feed_follows_success() { 90 90 let social_repo = Arc::new(MockSocialRepository::new()); 91 91 let state = create_test_state_with_social(social_repo); 92 - let user = UserContext { did: "did:plc:test".to_string(), handle: "test.handle".to_string() }; 92 + let user = UserContext { 93 + did: "did:plc:test".to_string(), 94 + handle: "test.handle".to_string(), 95 + access_token: "test_token".to_string(), 96 + pds_url: "https://bsky.social".to_string(), 97 + has_dpop: false, 98 + }; 93 99 let response = get_feed_follows(State(state), Some(Extension(user))) 94 100 .await 95 101 .into_response();
+21 -3
crates/server/src/api/note.rs
··· 152 152 #[tokio::test] 153 153 async fn test_create_note_success() { 154 154 let state = create_test_state(); 155 - let user = UserContext { did: "did:plc:test123".to_string(), handle: "test.handle".to_string() }; 155 + let user = UserContext { 156 + did: "did:plc:test123".to_string(), 157 + handle: "test.handle".to_string(), 158 + access_token: "test_token".to_string(), 159 + pds_url: "https://bsky.social".to_string(), 160 + has_dpop: false, 161 + }; 156 162 157 163 let payload = CreateNoteRequest { 158 164 title: "Test Note".to_string(), ··· 254 260 255 261 let state = AppState::new_with_repos(pool, card_repo, note_repo, oauth_repo); 256 262 257 - let owner = UserContext { did: "did:plc:owner".to_string(), handle: "owner.handle".to_string() }; 263 + let owner = UserContext { 264 + did: "did:plc:owner".to_string(), 265 + handle: "owner.handle".to_string(), 266 + access_token: "test_token".to_string(), 267 + pds_url: "https://bsky.social".to_string(), 268 + has_dpop: false, 269 + }; 258 270 259 271 let response = get_note( 260 272 axum::extract::State(state.clone()), ··· 266 278 267 279 assert_eq!(response.status(), StatusCode::OK); 268 280 269 - let other_user = UserContext { did: "did:plc:other".to_string(), handle: "other.handle".to_string() }; 281 + let other_user = UserContext { 282 + did: "did:plc:other".to_string(), 283 + handle: "other.handle".to_string(), 284 + access_token: "test_token".to_string(), 285 + pds_url: "https://bsky.social".to_string(), 286 + has_dpop: false, 287 + }; 270 288 let response = get_note(axum::extract::State(state), Some(Extension(other_user)), Path(note_id)) 271 289 .await 272 290 .into_response();
+21 -3
crates/server/src/api/preferences.rs
··· 148 148 let prefs_repo = Arc::new(MockPreferencesRepository::new()) as Arc<dyn PreferencesRepository>; 149 149 let state = create_test_state_with_prefs(prefs_repo); 150 150 151 - let user = UserContext { did: "did:plc:test".to_string(), handle: "test.handle".to_string() }; 151 + let user = UserContext { 152 + did: "did:plc:test".to_string(), 153 + handle: "test.handle".to_string(), 154 + access_token: "test_token".to_string(), 155 + pds_url: "https://bsky.social".to_string(), 156 + has_dpop: false, 157 + }; 152 158 let response = get_preferences(State(state), Some(Extension(user))) 153 159 .await 154 160 .into_response(); ··· 161 167 let prefs_repo = Arc::new(MockPreferencesRepository::new()) as Arc<dyn PreferencesRepository>; 162 168 let state = create_test_state_with_prefs(prefs_repo); 163 169 164 - let user = UserContext { did: "did:plc:test".to_string(), handle: "test.handle".to_string() }; 170 + let user = UserContext { 171 + did: "did:plc:test".to_string(), 172 + handle: "test.handle".to_string(), 173 + access_token: "test_token".to_string(), 174 + pds_url: "https://bsky.social".to_string(), 175 + has_dpop: false, 176 + }; 165 177 let payload = UpdatePreferencesRequest { 166 178 persona: Some("creator".to_string()), 167 179 complete_onboarding: Some(true), ··· 181 193 let prefs_repo = Arc::new(MockPreferencesRepository::new()) as Arc<dyn PreferencesRepository>; 182 194 let state = create_test_state_with_prefs(prefs_repo); 183 195 184 - let user = UserContext { did: "did:plc:test".to_string(), handle: "test.handle".to_string() }; 196 + let user = UserContext { 197 + did: "did:plc:test".to_string(), 198 + handle: "test.handle".to_string(), 199 + access_token: "test_token".to_string(), 200 + pds_url: "https://bsky.social".to_string(), 201 + has_dpop: false, 202 + }; 185 203 let payload = UpdatePreferencesRequest { 186 204 persona: Some("invalid".to_string()), 187 205 complete_onboarding: None,
+28 -4
crates/server/src/api/review.rs
··· 201 201 let review_repo = Arc::new(MockReviewRepository::with_cards(cards)) as Arc<dyn ReviewRepository>; 202 202 let state = create_test_state_with_review(review_repo); 203 203 204 - let user = UserContext { did: "did:plc:test".to_string(), handle: "test.handle".to_string() }; 204 + let user = UserContext { 205 + did: "did:plc:test".to_string(), 206 + handle: "test.handle".to_string(), 207 + access_token: "test_token".to_string(), 208 + pds_url: "https://bsky.social".to_string(), 209 + has_dpop: false, 210 + }; 205 211 let response = get_due_cards( 206 212 State(state), 207 213 Some(Extension(user)), ··· 218 224 let review_repo = Arc::new(MockReviewRepository::new()) as Arc<dyn ReviewRepository>; 219 225 let state = create_test_state_with_review(review_repo); 220 226 221 - let user = UserContext { did: "did:plc:test".to_string(), handle: "test.handle".to_string() }; 227 + let user = UserContext { 228 + did: "did:plc:test".to_string(), 229 + handle: "test.handle".to_string(), 230 + access_token: "test_token".to_string(), 231 + pds_url: "https://bsky.social".to_string(), 232 + has_dpop: false, 233 + }; 222 234 let payload = SubmitReviewRequest { card_id: "card-1".to_string(), grade: 3 }; 223 235 224 236 let response = submit_review(State(state), Some(Extension(user)), Json(payload)) ··· 233 245 let review_repo = Arc::new(MockReviewRepository::new()) as Arc<dyn ReviewRepository>; 234 246 let state = create_test_state_with_review(review_repo); 235 247 236 - let user = UserContext { did: "did:plc:test".to_string(), handle: "test.handle".to_string() }; 248 + let user = UserContext { 249 + did: "did:plc:test".to_string(), 250 + handle: "test.handle".to_string(), 251 + access_token: "test_token".to_string(), 252 + pds_url: "https://bsky.social".to_string(), 253 + has_dpop: false, 254 + }; 237 255 let payload = SubmitReviewRequest { card_id: "card-1".to_string(), grade: 10 }; 238 256 239 257 let response = submit_review(State(state), Some(Extension(user)), Json(payload)) ··· 248 266 let review_repo = Arc::new(MockReviewRepository::new()) as Arc<dyn ReviewRepository>; 249 267 let state = create_test_state_with_review(review_repo); 250 268 251 - let user = UserContext { did: "did:plc:test".to_string(), handle: "test.handle".to_string() }; 269 + let user = UserContext { 270 + did: "did:plc:test".to_string(), 271 + handle: "test.handle".to_string(), 272 + access_token: "test_token".to_string(), 273 + pds_url: "https://bsky.social".to_string(), 274 + has_dpop: false, 275 + }; 252 276 let response = get_stats(State(state), Some(Extension(user))).await.into_response(); 253 277 254 278 assert_eq!(response.status(), StatusCode::OK);
+7 -1
crates/server/src/api/search.rs
··· 134 134 .await; 135 135 136 136 let state = create_test_state_with_search(search_repo.clone()); 137 - let auth_ctx = Extension(UserContext { did: "did:alice".to_string(), handle: "alice.test".to_string() }); 137 + let auth_ctx = Extension(UserContext { 138 + did: "did:alice".to_string(), 139 + handle: "alice.test".to_string(), 140 + access_token: "test_token".to_string(), 141 + pds_url: "https://bsky.social".to_string(), 142 + has_dpop: false, 143 + }); 138 144 let response = search( 139 145 State(state.clone()), 140 146 Some(auth_ctx),
+21 -3
crates/server/src/api/social.rs
··· 216 216 async fn test_follow_success() { 217 217 let social_repo = Arc::new(MockSocialRepository::new()); 218 218 let state = create_test_state_with_social(social_repo.clone()); 219 - let user = UserContext { did: "did:plc:follower".to_string(), handle: "follower".to_string() }; 219 + let user = UserContext { 220 + did: "did:plc:follower".to_string(), 221 + handle: "follower".to_string(), 222 + access_token: "test_token".to_string(), 223 + pds_url: "https://bsky.social".to_string(), 224 + has_dpop: false, 225 + }; 220 226 221 227 let response = follow(State(state), Some(Extension(user)), Path("did:plc:subject".to_string())) 222 228 .await ··· 234 240 social_repo.follow("did:plc:follower", "did:plc:subject").await.unwrap(); 235 241 236 242 let state = create_test_state_with_social(social_repo.clone()); 237 - let user = UserContext { did: "did:plc:follower".to_string(), handle: "follower".to_string() }; 243 + let user = UserContext { 244 + did: "did:plc:follower".to_string(), 245 + handle: "follower".to_string(), 246 + access_token: "test_token".to_string(), 247 + pds_url: "https://bsky.social".to_string(), 248 + has_dpop: false, 249 + }; 238 250 239 251 let response = unfollow(State(state), Some(Extension(user)), Path("did:plc:subject".to_string())) 240 252 .await ··· 272 284 async fn test_add_comment_success() { 273 285 let social_repo = Arc::new(MockSocialRepository::new()); 274 286 let state = create_test_state_with_social(social_repo.clone()); 275 - let user = UserContext { did: "did:plc:author".to_string(), handle: "author".to_string() }; 287 + let user = UserContext { 288 + did: "did:plc:author".to_string(), 289 + handle: "author".to_string(), 290 + access_token: "test_token".to_string(), 291 + pds_url: "https://bsky.social".to_string(), 292 + has_dpop: false, 293 + }; 276 294 277 295 let payload = AddCommentRequest { content: "Great deck!".to_string(), parent_id: None }; 278 296
+20 -2
crates/server/src/middleware/auth.rs
··· 10 10 use serde_json::json; 11 11 use std::time::{Duration, Instant}; 12 12 13 + /// User context extracted from authentication. 14 + /// 15 + /// Contains the user's identity and authentication details needed for PDS operations. 13 16 #[derive(Clone, Debug)] 14 17 pub struct UserContext { 15 18 pub did: String, 16 19 pub handle: String, 20 + pub access_token: String, 21 + pub pds_url: String, 22 + pub has_dpop: bool, 17 23 } 18 24 19 25 /// Cache expiry time (5 minutes) ··· 190 196 let body: serde_json::Value = response.json().await.unwrap_or_default(); 191 197 let did = body["did"].as_str().unwrap_or("").to_string(); 192 198 let handle = body["handle"].as_str().unwrap_or("").to_string(); 193 - let user_ctx = UserContext { did: did.clone(), handle }; 199 + let user_ctx = UserContext { 200 + did: did.clone(), 201 + handle, 202 + access_token: token.to_string(), 203 + pds_url: target_pds_url.to_string(), 204 + has_dpop: stored_token.is_some(), 205 + }; 194 206 195 207 tracing::debug!("PDS verification successful for DID: {}", did); 196 208 ··· 262 274 let did = body["did"].as_str().unwrap_or("").to_string(); 263 275 let handle = body["handle"].as_str().unwrap_or("").to_string(); 264 276 265 - req.extensions_mut().insert(UserContext { did, handle }); 277 + req.extensions_mut().insert(UserContext { 278 + did, 279 + handle, 280 + access_token: token.to_string(), 281 + pds_url: pds_url.clone(), 282 + has_dpop: false, 283 + }); 266 284 } 267 285 _ => {} 268 286 }
+98 -23
crates/server/src/pds/client.rs
··· 7 7 use serde::{Deserialize, Serialize}; 8 8 9 9 /// A client for interacting with a user's PDS. 10 + /// 11 + /// Supports both DPoP-bound tokens (OAuth) and Bearer tokens (app passwords). 10 12 pub struct PdsClient { 11 13 http_client: reqwest::Client, 12 14 pds_url: String, 13 15 access_token: String, 14 - dpop_keypair: DpopKeypair, 16 + dpop_keypair: Option<DpopKeypair>, 15 17 } 16 18 17 19 /// Request body for putRecord XRPC. ··· 101 103 impl std::error::Error for PdsError {} 102 104 103 105 impl PdsClient { 104 - /// Create a new PDS client. 106 + /// Create a new PDS client with DPoP support (OAuth tokens). 107 + /// 108 + /// Uses DPoP proof-of-possession for enhanced security. 109 + pub fn new_with_dpop(pds_url: String, access_token: String, dpop_keypair: DpopKeypair) -> Self { 110 + Self { http_client: reqwest::Client::new(), pds_url, access_token, dpop_keypair: Some(dpop_keypair) } 111 + } 112 + 113 + /// Create a new PDS client with Bearer authentication (app password tokens). 114 + /// 115 + /// Uses standard Bearer token authentication without DPoP. 116 + pub fn new_bearer(pds_url: String, access_token: String) -> Self { 117 + Self { http_client: reqwest::Client::new(), pds_url, access_token, dpop_keypair: None } 118 + } 119 + 120 + /// Create a new PDS client (deprecated - use new_with_dpop or new_bearer). 121 + #[deprecated(since = "0.1.0", note = "Use new_with_dpop or new_bearer instead")] 105 122 pub fn new(pds_url: String, access_token: String, dpop_keypair: DpopKeypair) -> Self { 106 - Self { http_client: reqwest::Client::new(), pds_url, access_token, dpop_keypair } 123 + Self::new_with_dpop(pds_url, access_token, dpop_keypair) 107 124 } 108 125 109 126 /// Create or update a record in the repository. ··· 118 135 &self, did: &str, collection: &str, rkey: &str, record: serde_json::Value, 119 136 ) -> Result<AtUri, PdsError> { 120 137 let url = format!("{}/xrpc/com.atproto.repo.putRecord", self.pds_url); 121 - 122 - let dpop_proof = self.dpop_keypair.generate_proof("POST", &url, Some(&self.access_token)); 123 138 124 139 let request = PutRecordRequest { 125 140 repo: did.to_string(), ··· 131 146 validate: Some(true), 132 147 }; 133 148 134 - let response = self 135 - .http_client 136 - .post(&url) 137 - .header("Authorization", format!("DPoP {}", self.access_token)) 138 - .header("DPoP", dpop_proof) 149 + let mut request_builder = self.http_client.post(&url); 150 + 151 + // Conditionally add DPoP or Bearer authentication 152 + if let Some(ref dpop_keypair) = self.dpop_keypair { 153 + // OAuth with DPoP 154 + let dpop_proof = dpop_keypair.generate_proof("POST", &url, Some(&self.access_token)); 155 + request_builder = request_builder 156 + .header("Authorization", format!("DPoP {}", self.access_token)) 157 + .header("DPoP", dpop_proof); 158 + } else { 159 + // App password with Bearer 160 + request_builder = request_builder.header("Authorization", format!("Bearer {}", self.access_token)); 161 + } 162 + 163 + let response = request_builder 139 164 .json(&request) 140 165 .send() 141 166 .await ··· 148 173 pub async fn delete_record(&self, did: &str, collection: &str, rkey: &str) -> Result<(), PdsError> { 149 174 let url = format!("{}/xrpc/com.atproto.repo.deleteRecord", self.pds_url); 150 175 151 - let dpop_proof = self.dpop_keypair.generate_proof("POST", &url, Some(&self.access_token)); 152 - 153 176 let request = DeleteRecordRequest { 154 177 repo: did.to_string(), 155 178 collection: collection.to_string(), ··· 158 181 swap_commit: None, 159 182 }; 160 183 161 - let response = self 162 - .http_client 163 - .post(&url) 164 - .header("Authorization", format!("DPoP {}", self.access_token)) 165 - .header("DPoP", dpop_proof) 184 + let mut request_builder = self.http_client.post(&url); 185 + 186 + // Conditionally add DPoP or Bearer authentication 187 + if let Some(ref dpop_keypair) = self.dpop_keypair { 188 + // OAuth with DPoP 189 + let dpop_proof = dpop_keypair.generate_proof("POST", &url, Some(&self.access_token)); 190 + request_builder = request_builder 191 + .header("Authorization", format!("DPoP {}", self.access_token)) 192 + .header("DPoP", dpop_proof); 193 + } else { 194 + // App password with Bearer 195 + request_builder = request_builder.header("Authorization", format!("Bearer {}", self.access_token)); 196 + } 197 + 198 + let response = request_builder 166 199 .json(&request) 167 200 .send() 168 201 .await ··· 181 214 pub async fn upload_blob(&self, data: Vec<u8>, mime_type: &str) -> Result<BlobRef, PdsError> { 182 215 let url = format!("{}/xrpc/com.atproto.repo.uploadBlob", self.pds_url); 183 216 184 - let dpop_proof = self.dpop_keypair.generate_proof("POST", &url, Some(&self.access_token)); 217 + let mut request_builder = self.http_client.post(&url); 185 218 186 - let response = self 187 - .http_client 188 - .post(&url) 189 - .header("Authorization", format!("DPoP {}", self.access_token)) 190 - .header("DPoP", dpop_proof) 219 + // Conditionally add DPoP or Bearer authentication 220 + if let Some(ref dpop_keypair) = self.dpop_keypair { 221 + // OAuth with DPoP 222 + let dpop_proof = dpop_keypair.generate_proof("POST", &url, Some(&self.access_token)); 223 + request_builder = request_builder 224 + .header("Authorization", format!("DPoP {}", self.access_token)) 225 + .header("DPoP", dpop_proof); 226 + } else { 227 + // App password with Bearer 228 + request_builder = request_builder.header("Authorization", format!("Bearer {}", self.access_token)); 229 + } 230 + 231 + let response = request_builder 191 232 .header("Content-Type", mime_type) 192 233 .body(data) 193 234 .send() ··· 298 339 299 340 let err = PdsError::NetworkError("Connection refused".to_string()); 300 341 assert!(err.to_string().contains("Connection refused")); 342 + } 343 + 344 + #[test] 345 + fn test_pds_client_new_with_dpop() { 346 + use crate::oauth::dpop::DpopKeypair; 347 + 348 + let keypair = DpopKeypair::generate(); 349 + let client = PdsClient::new_with_dpop("https://bsky.social".to_string(), "test_token".to_string(), keypair); 350 + 351 + assert_eq!(client.pds_url, "https://bsky.social"); 352 + assert_eq!(client.access_token, "test_token"); 353 + assert!(client.dpop_keypair.is_some()); 354 + } 355 + 356 + #[test] 357 + fn test_pds_client_new_bearer() { 358 + let client = PdsClient::new_bearer("https://bsky.social".to_string(), "test_token".to_string()); 359 + 360 + assert_eq!(client.pds_url, "https://bsky.social"); 361 + assert_eq!(client.access_token, "test_token"); 362 + assert!(client.dpop_keypair.is_none()); 363 + } 364 + 365 + #[test] 366 + #[allow(deprecated)] 367 + fn test_pds_client_new_deprecated() { 368 + use crate::oauth::dpop::DpopKeypair; 369 + 370 + let keypair = DpopKeypair::generate(); 371 + let client = PdsClient::new("https://bsky.social".to_string(), "test_token".to_string(), keypair); 372 + 373 + assert_eq!(client.pds_url, "https://bsky.social"); 374 + assert_eq!(client.access_token, "test_token"); 375 + assert!(client.dpop_keypair.is_some()); 301 376 } 302 377 }
+35 -16
crates/server/src/pds/publish.rs
··· 2 2 //! 3 3 //! Encapsulates the logic for publishing records to a user's PDS. 4 4 5 + use crate::middleware::auth::UserContext; 5 6 use crate::pds::client::{PdsClient, PdsError}; 6 7 use crate::pds::records::{prepare_card_record, prepare_deck_record}; 7 - use crate::repository::oauth::{OAuthRepoError, OAuthRepository, StoredToken}; 8 + use crate::repository::oauth::{OAuthRepoError, OAuthRepository}; 8 9 use malfestio_core::model::{Card, Deck}; 9 10 use std::sync::Arc; 10 11 ··· 63 64 /// Publish a deck and its cards to the user's PDS. 64 65 /// 65 66 /// This function: 66 - /// 1. Retrieves OAuth tokens for the user 67 - /// 2. Creates a PDS client 68 - /// 3. Publishes each card (with placeholder deck ref initially) 69 - /// 4. Publishes the deck with card AT-URIs 67 + /// 1. Tries to use OAuth tokens with DPoP if available 68 + /// 2. Falls back to current session (supports app passwords with Bearer auth) 69 + /// 3. Creates a PDS client with appropriate authentication 70 + /// 4. Publishes each card (with placeholder deck ref initially) 71 + /// 5. Publishes the deck with card AT-URIs 70 72 /// 71 73 /// Note: Cards are published with an empty deck_ref since we don't have the 72 74 /// deck's AT-URI yet. This is acceptable per the Lexicon - the deck holds 73 75 /// the authoritative list of card references. 74 76 pub async fn publish_deck_to_pds( 75 - oauth_repo: Arc<dyn OAuthRepository>, did: &str, deck: &Deck, cards: &[Card], 77 + oauth_repo: Arc<dyn OAuthRepository>, user_ctx: &UserContext, deck: &Deck, cards: &[Card], 76 78 ) -> Result<PublishDeckResult, PublishError> { 77 - let stored_token: StoredToken = oauth_repo.get_tokens(did).await?; 78 - let dpop_keypair = stored_token.dpop_keypair().ok_or(PublishError::InvalidKeypair)?; 79 - 80 - let pds_client = PdsClient::new( 81 - stored_token.pds_url.clone(), 82 - stored_token.access_token.clone(), 83 - dpop_keypair, 84 - ); 79 + let pds_client = if user_ctx.has_dpop { 80 + if let Ok(stored_token) = oauth_repo.get_tokens(&user_ctx.did).await { 81 + if let Some(dpop_keypair) = stored_token.dpop_keypair() { 82 + tracing::info!("Using stored OAuth tokens with DPoP for publishing"); 83 + PdsClient::new_with_dpop( 84 + stored_token.pds_url.clone(), 85 + stored_token.access_token.clone(), 86 + dpop_keypair, 87 + ) 88 + } else { 89 + tracing::info!( 90 + "Current session has DPoP flag but stored token lacks keypair, using current session with Bearer auth" 91 + ); 92 + PdsClient::new_bearer(user_ctx.pds_url.clone(), user_ctx.access_token.clone()) 93 + } 94 + } else { 95 + tracing::info!( 96 + "Current session has DPoP flag but no stored tokens found, using current session with Bearer auth" 97 + ); 98 + PdsClient::new_bearer(user_ctx.pds_url.clone(), user_ctx.access_token.clone()) 99 + } 100 + } else { 101 + tracing::info!("Using current session with Bearer auth for publishing (app password)"); 102 + PdsClient::new_bearer(user_ctx.pds_url.clone(), user_ctx.access_token.clone()) 103 + }; 85 104 86 105 let mut card_at_uris = Vec::with_capacity(cards.len()); 87 106 for card in cards { 88 107 let prepared = prepare_card_record(card, ""); 89 108 let at_uri = pds_client 90 - .put_record(did, &prepared.collection, &prepared.rkey, prepared.record) 109 + .put_record(&user_ctx.did, &prepared.collection, &prepared.rkey, prepared.record) 91 110 .await?; 92 111 card_at_uris.push(at_uri.to_string()); 93 112 } 94 113 95 114 let prepared = prepare_deck_record(deck, card_at_uris.clone()); 96 115 let deck_at_uri = pds_client 97 - .put_record(did, &prepared.collection, &prepared.rkey, prepared.record) 116 + .put_record(&user_ctx.did, &prepared.collection, &prepared.rkey, prepared.record) 98 117 .await?; 99 118 100 119 Ok(PublishDeckResult { deck_at_uri: deck_at_uri.to_string(), card_at_uris })
+1
web/package.json
··· 34 34 "@egoist/tailwindcss-icons": "^1.9.0", 35 35 "@eslint/js": "^9.39.2", 36 36 "@iconify-json/bi": "^1.2.7", 37 + "@iconify-json/ri": "^1.2.7", 37 38 "@resvg/resvg-js": "^2.6.2", 38 39 "@solidjs/testing-library": "^0.8.10", 39 40 "@testing-library/jest-dom": "^6.9.1",
+10
web/pnpm-lock.yaml
··· 69 69 '@iconify-json/bi': 70 70 specifier: ^1.2.7 71 71 version: 1.2.7 72 + '@iconify-json/ri': 73 + specifier: ^1.2.7 74 + version: 1.2.7 72 75 '@resvg/resvg-js': 73 76 specifier: ^2.6.2 74 77 version: 2.6.2 ··· 499 502 500 503 '@iconify-json/bi@1.2.7': 501 504 resolution: {integrity: sha512-IPz8WNxmLkH1I9msl+0Q4OnmjjvP4uU0Z61a4i4sqonB6vKSbMGUWuGn8/YuuszlReVj8rf+3gNv5JU8Xoljyg==} 505 + 506 + '@iconify-json/ri@1.2.7': 507 + resolution: {integrity: sha512-j/Fkb8GlWY5y/zLj1BGxWRtDzuJFrI7562zLw+iQVEykieBgew43+r8qAvtSajvb75MfUIHjsNOYQPRD8FfLfw==} 502 508 503 509 '@iconify/types@2.0.0': 504 510 resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} ··· 2586 2592 '@humanwhocodes/retry@0.4.3': {} 2587 2593 2588 2594 '@iconify-json/bi@1.2.7': 2595 + dependencies: 2596 + '@iconify/types': 2.0.0 2597 + 2598 + '@iconify-json/ri@1.2.7': 2589 2599 dependencies: 2590 2600 '@iconify/types': 2.0.0 2591 2601
+75 -65
web/src/components/StudySession.tsx
··· 10 10 type StudySessionProps = { cards: ReviewCard[]; onComplete: () => void; onExit: () => void }; 11 11 12 12 const GRADE_LABELS: { [key in Grade]: { label: string; color: string; key: string } } = { 13 - 0: { label: "Again", color: "bg-red-600 hover:bg-red-500", key: "1" }, 14 - 1: { label: "Hard", color: "bg-orange-600 hover:bg-orange-500", key: "2" }, 15 - 2: { label: "Okay", color: "bg-yellow-600 hover:bg-yellow-500", key: "3" }, 16 - 3: { label: "Good", color: "bg-green-600 hover:bg-green-500", key: "4" }, 17 - 4: { label: "Easy", color: "bg-emerald-600 hover:bg-emerald-500", key: "5" }, 18 - 5: { label: "Perfect", color: "bg-cyan-600 hover:bg-cyan-500", key: "5" }, 13 + 0: { label: "Again", color: "text-red-500", key: "1" }, 14 + 1: { label: "Hard", color: "text-orange-500", key: "2" }, 15 + 2: { label: "Okay", color: "text-yellow-500", key: "3" }, 16 + 3: { label: "Good", color: "text-green-500", key: "4" }, 17 + 4: { label: "Easy", color: "text-emerald-500", key: "5" }, 18 + 5: { label: "Perfect", color: "text-cyan-500", key: "6" }, 19 19 }; 20 20 21 21 export const StudySession: Component<StudySessionProps> = (props) => { ··· 27 27 const currentCard = () => props.cards[currentIndex()]; 28 28 const progress = () => ((currentIndex() + 1) / props.cards.length) * 100; 29 29 const isComplete = () => currentIndex() >= props.cards.length; 30 - const handleFlip = () => !isFlipped() ? setIsFlipped(true) : void 0; 30 + const handleFlip = () => setIsFlipped((f) => !f); 31 31 32 32 const handleGrade = async (grade: Grade) => { 33 33 const card = currentCard(); ··· 63 63 if (isFlipped()) handleGrade(1); 64 64 break; 65 65 case "3": 66 + if (isFlipped()) handleGrade(2); 67 + break; 68 + case "4": 66 69 if (isFlipped()) handleGrade(3); 67 70 break; 68 - case "4": 71 + case "5": 69 72 if (isFlipped()) handleGrade(4); 70 73 break; 71 - case "5": 74 + case "6": 72 75 if (isFlipped()) handleGrade(5); 73 76 break; 74 77 case "e": ··· 96 99 }); 97 100 98 101 return ( 99 - <div class="min-h-screen bg-gray-950 flex flex-col items-center justify-center p-4"> 100 - {/* Progress Header */} 101 - <div class="w-full max-w-2xl mb-8"> 102 + <div class="fixed inset-0 z-50 h-screen w-screen bg-gray-950 grid grid-rows-[auto_1fr_160px] overflow-hidden"> 103 + <div class="w-full max-w-2xl mx-auto p-4 flex flex-col justify-end"> 102 104 <div class="flex items-center justify-between mb-2"> 103 105 <span class="text-gray-400 text-sm">Card {currentIndex() + 1} of {props.cards.length}</span> 104 106 <button onClick={() => props.onExit()} class="text-gray-400 hover:text-white text-sm flex items-center gap-1"> ··· 108 110 <ProgressBar value={progress()} color="green" size="md" /> 109 111 </div> 110 112 111 - <Show when={currentCard()}> 112 - {(card) => ( 113 - <Motion.div {...scaleIn} class="w-full max-w-2xl"> 114 - <div 115 - onClick={handleFlip} 116 - class="relative min-h-[400px] rounded-2xl cursor-pointer perspective-1000" 117 - style={{ "transform-style": "preserve-3d" }}> 113 + <div class="flex items-center justify-center p-4"> 114 + <Show when={currentCard()} keyed> 115 + {(card) => ( 116 + <Motion.div {...scaleIn} class="w-full max-w-2xl h-[400px]"> 118 117 <div 119 - class={`absolute inset-0 rounded-2xl bg-gradient-to-br from-gray-800 to-gray-900 border border-gray-700 p-8 flex flex-col items-center justify-center backface-hidden transition-transform duration-400 ${ 120 - isFlipped() ? "rotate-y-180" : "" 121 - }`} 122 - style={{ "backface-visibility": "hidden" }}> 123 - <span class="text-xs text-gray-500 mb-4">{card().deck_title}</span> 124 - <p class="text-2xl text-white text-center font-medium">{card().front}</p> 125 - <Show when={!isFlipped()}> 126 - <p class="text-gray-500 mt-8 text-sm">Press Space or click to reveal</p> 127 - </Show> 128 - </div> 118 + onClick={handleFlip} 119 + class="relative w-full h-full cursor-pointer" 120 + style={{ "perspective": "1000px" }}> 121 + <div 122 + class="relative w-full h-full transition-transform duration-500" 123 + style={{ 124 + "transform-style": "preserve-3d", 125 + "transform": isFlipped() ? "rotateY(180deg)" : "rotateY(0deg)", 126 + }}> 127 + <div 128 + class="absolute inset-0 rounded-2xl bg-linear-to-br from-gray-800 to-gray-900 border border-gray-700 p-8 flex flex-col items-center justify-center" 129 + style={{ "backface-visibility": "hidden" }}> 130 + <span class="text-xs text-gray-500 mb-4">{card.deck_title}</span> 131 + <p class="text-2xl text-white text-center font-medium">{card.front}</p> 132 + <p class="text-gray-500 mt-8 text-sm animate-pulse">Press Space or click to reveal</p> 133 + </div> 129 134 130 - <div 131 - class={`absolute inset-0 rounded-2xl bg-gradient-to-br from-gray-800 to-gray-900 border border-gray-700 p-8 flex flex-col items-center justify-center backface-hidden transition-transform duration-400 ${ 132 - isFlipped() ? "" : "rotate-y-180" 133 - }`} 134 - style={{ "backface-visibility": "hidden", transform: "rotateY(180deg)" }}> 135 - <span class="text-xs text-gray-500 mb-4">Answer</span> 136 - <p class="text-2xl text-white text-center font-medium">{card().back}</p> 137 - <Show when={card().hints.length > 0}> 138 - <div class="mt-4 text-sm text-gray-400"> 139 - <For each={card().hints}>{(hint) => <p class="italic">💡 {hint}</p>}</For> 135 + <div 136 + class="absolute inset-0 rounded-2xl bg-linear-to-br from-gray-800 to-gray-900 border border-gray-700 p-8 flex flex-col items-center justify-center" 137 + style={{ "backface-visibility": "hidden", "transform": "rotateY(180deg)" }}> 138 + <span class="text-xs text-gray-500 mb-4">Answer</span> 139 + <p class="text-2xl text-white text-center font-medium">{card.back}</p> 140 + <Show when={card.hints.length > 0}> 141 + <div class="mt-4 text-sm text-gray-400"> 142 + <For each={card.hints}>{(hint) => <p class="italic">💡 {hint}</p>}</For> 143 + </div> 144 + </Show> 140 145 </div> 141 - </Show> 146 + </div> 142 147 </div> 148 + </Motion.div> 149 + )} 150 + </Show> 151 + </div> 152 + 153 + <div class="flex items-start justify-center p-4"> 154 + <Show when={isFlipped()}> 155 + <Motion.div {...slideInUp} class="w-full max-w-2xl"> 156 + <p class="text-center text-gray-400 text-sm mb-4">How well did you know this?</p> 157 + <div class="grid grid-cols-6 gap-2"> 158 + <For each={[0, 1, 2, 3, 4, 5] as Grade[]}> 159 + {(grade) => ( 160 + <button 161 + onClick={() => handleGrade(grade)} 162 + disabled={isSubmitting()} 163 + class="py-3 px-2 rounded-lg font-medium transition-colors bg-gray-800 hover:bg-gray-700 disabled:opacity-50 border border-transparent hover:border-gray-600 group"> 164 + <span 165 + class={`block text-lg transition-transform group-hover:scale-110 ${GRADE_LABELS[grade].color}`}> 166 + {GRADE_LABELS[grade].label} 167 + </span> 168 + <span class="block text-xs opacity-75 text-gray-400">({GRADE_LABELS[grade].key})</span> 169 + </button> 170 + )} 171 + </For> 143 172 </div> 144 173 </Motion.div> 145 - )} 146 - </Show> 147 - 148 - <Show when={isFlipped()}> 149 - <Motion.div {...slideInUp} class="w-full max-w-2xl mt-8"> 150 - <p class="text-center text-gray-400 text-sm mb-4">How well did you know this?</p> 151 - <div class="grid grid-cols-5 gap-2"> 152 - <For each={[0, 1, 3, 4, 5] as Grade[]}> 153 - {(grade) => ( 154 - <button 155 - onClick={() => handleGrade(grade)} 156 - disabled={isSubmitting()} 157 - class={`py-3 px-2 rounded-lg text-white font-medium transition-colors ${ 158 - GRADE_LABELS[grade].color 159 - } disabled:opacity-50`}> 160 - <span class="block text-lg">{GRADE_LABELS[grade].label}</span> 161 - <span class="block text-xs opacity-75">({GRADE_LABELS[grade].key})</span> 162 - </button> 163 - )} 164 - </For> 165 - </div> 166 - </Motion.div> 167 - </Show> 174 + </Show> 175 + </div> 168 176 169 177 <div class="fixed bottom-4 left-1/2 -translate-x-1/2 text-gray-600 text-xs flex gap-4"> 170 178 <span>Space: Flip</span> 171 - <span>1-5: Grade</span> 172 - <span>E: Edit</span> 179 + <Show when={isFlipped()}> 180 + <span>1-6: Grade</span> 181 + <span>E: Edit</span> 182 + </Show> 173 183 <span>Esc: Exit</span> 174 184 </div> 175 185
+29 -3
web/src/components/tests/StudySession.test.tsx
··· 68 68 expect(await screen.findByText("How well did you know this?")).toBeInTheDocument(); 69 69 }); 70 70 71 - it("shows keyboard hints", () => { 71 + it("flips back to front on second click", async () => { 72 + const onComplete = vi.fn(); 73 + const onExit = vi.fn(); 74 + 75 + render(() => <StudySession cards={mockCards} onComplete={onComplete} onExit={onExit} />); 76 + 77 + const cardElement = screen.getByText("What is 2+2?").closest("div[class*='cursor-pointer']"); 78 + if (cardElement) fireEvent.click(cardElement); 79 + 80 + expect(await screen.findByText("How well did you know this?")).toBeInTheDocument(); 81 + 82 + if (cardElement) fireEvent.click(cardElement); 83 + expect(await screen.findByText("Press Space or click to reveal")).toBeInTheDocument(); 84 + expect(screen.queryByText("How well did you know this?")).not.toBeInTheDocument(); 85 + }); 86 + 87 + it("shows keyboard hints conditionally", async () => { 72 88 const onComplete = vi.fn(); 73 89 const onExit = vi.fn(); 74 90 75 91 render(() => <StudySession cards={mockCards} onComplete={onComplete} onExit={onExit} />); 76 92 77 93 expect(screen.getByText("Space: Flip")).toBeInTheDocument(); 78 - expect(screen.getByText("1-5: Grade")).toBeInTheDocument(); 79 - expect(screen.getByText("E: Edit")).toBeInTheDocument(); 80 94 expect(screen.getByText("Esc: Exit")).toBeInTheDocument(); 95 + 96 + // Initially hidden 97 + expect(screen.queryByText("1-6: Grade")).not.toBeInTheDocument(); 98 + expect(screen.queryByText("E: Edit")).not.toBeInTheDocument(); 99 + 100 + // Flip card 101 + const cardElement = screen.getByText("What is 2+2?").closest("div[class*='cursor-pointer']"); 102 + if (cardElement) fireEvent.click(cardElement); 103 + 104 + // Now visible 105 + expect(await screen.findByText("1-6: Grade")).toBeInTheDocument(); 106 + expect(screen.getByText("E: Edit")).toBeInTheDocument(); 81 107 }); 82 108 83 109 it("calls onExit when exit button is clicked", () => {
+171 -133
web/src/pages/DeckView.tsx
··· 1 1 import { CommentSection } from "$components/social/CommentSection"; 2 2 import { FollowButton } from "$components/social/FollowButton"; 3 + import { StudySession } from "$components/StudySession"; 3 4 import { Button } from "$components/ui/Button"; 4 5 import { Card } from "$components/ui/Card"; 5 6 import { Dialog } from "$components/ui/Dialog"; ··· 7 8 import { Skeleton } from "$components/ui/Skeleton"; 8 9 import { Tag } from "$components/ui/Tag"; 9 10 import { api } from "$lib/api"; 10 - import type { Card as CardType, Deck } from "$lib/model"; 11 + import type { Card as CardType, Deck, ReviewCard } from "$lib/model"; 11 12 import { toast } from "$lib/toast"; 12 13 import { A, useNavigate, useParams } from "@solidjs/router"; 13 14 import type { Component } from "solid-js"; ··· 44 45 const res = await api.getDeckCards(id); 45 46 return res.ok ? ((await res.json()) as CardType[]) : []; 46 47 }); 48 + 49 + const [dueCards, { refetch: refetchDueCards }] = createResource(() => params.id, async (id) => { 50 + const res = await api.getDueCards(id); 51 + return res.ok ? ((await res.json()) as ReviewCard[]) : []; 52 + }); 53 + 54 + const [isStudying, setIsStudying] = createSignal(false); 47 55 48 56 const handleFork = async () => { 49 57 if (deck()) { ··· 65 73 } 66 74 }; 67 75 76 + const handleStudyComplete = () => { 77 + setIsStudying(false); 78 + refetchDueCards(); 79 + toast.success("Session complete!"); 80 + }; 81 + 68 82 return ( 69 - <Motion.div 70 - initial={{ opacity: 0 }} 71 - animate={{ opacity: 1 }} 72 - transition={{ duration: 0.3 }} 73 - class="max-w-4xl mx-auto px-6 py-12"> 74 - <Show 75 - when={!deck.loading} 76 - fallback={ 77 - <div class="space-y-6"> 78 - <Skeleton width="60%" height="2.5rem" /> 79 - <Skeleton width="40%" height="1rem" /> 80 - <Skeleton width="100%" height="1rem" /> 81 - <div class="flex gap-2"> 82 - <Skeleton width="4rem" height="1.5rem" rounded="full" /> 83 - <Skeleton width="3rem" height="1.5rem" rounded="full" /> 84 - </div> 85 - </div> 86 - }> 83 + <Show 84 + when={!isStudying()} 85 + fallback={ 86 + <Show when={dueCards()}> 87 + {(cards) => ( 88 + <StudySession 89 + cards={cards()} 90 + onComplete={handleStudyComplete} 91 + onExit={() => setIsStudying(false)} /> 92 + )} 93 + </Show> 94 + }> 95 + <Motion.div 96 + initial={{ opacity: 0 }} 97 + animate={{ opacity: 1 }} 98 + transition={{ duration: 0.3 }} 99 + class="max-w-4xl mx-auto px-6 py-12"> 87 100 <Show 88 - when={deck()} 101 + when={!deck.loading} 89 102 fallback={ 90 - <EmptyState 91 - title="Deck not found" 92 - description="This deck doesn't exist or you don't have access to view it." 93 - icon={<span class="i-bi-exclamation-triangle text-4xl text-red-400" />} 94 - action={ 95 - <A href="/"> 96 - <Button variant="secondary">Back to Library</Button> 97 - </A> 98 - } /> 103 + <div class="space-y-6"> 104 + <Skeleton width="60%" height="2.5rem" /> 105 + <Skeleton width="40%" height="1rem" /> 106 + <Skeleton width="100%" height="1rem" /> 107 + <div class="flex gap-2"> 108 + <Skeleton width="4rem" height="1.5rem" rounded="full" /> 109 + <Skeleton width="3rem" height="1.5rem" rounded="full" /> 110 + </div> 111 + </div> 99 112 }> 100 - {(deckValue) => ( 101 - <> 102 - <Motion.div 103 - initial={{ opacity: 0, y: 20 }} 104 - animate={{ opacity: 1, y: 0 }} 105 - transition={{ duration: 0.4 }} 106 - class="mb-12"> 107 - <div class="flex justify-between items-start mb-4"> 108 - <h1 class="text-4xl text-[#F4F4F4] tracking-tight">{deckValue().title}</h1> 109 - <Show when={deckValue().visibility.type !== "Public"}> 110 - <Tag label={deckValue().visibility.type} color="gray" /> 111 - </Show> 112 - </div> 113 + <Show 114 + when={deck()} 115 + fallback={ 116 + <EmptyState 117 + title="Deck not found" 118 + description="This deck doesn't exist or you don't have access to view it." 119 + icon={<span class="i-bi-exclamation-triangle text-4xl text-red-400" />} 120 + action={ 121 + <A href="/"> 122 + <Button variant="secondary">Back to Library</Button> 123 + </A> 124 + } /> 125 + }> 126 + {(deckValue) => ( 127 + <> 128 + <Motion.div 129 + initial={{ opacity: 0, y: 20 }} 130 + animate={{ opacity: 1, y: 0 }} 131 + transition={{ duration: 0.4 }} 132 + class="mb-12"> 133 + <div class="flex justify-between items-start mb-4"> 134 + <h1 class="text-4xl text-[#F4F4F4] tracking-tight">{deckValue().title}</h1> 135 + <Show when={deckValue().visibility.type !== "Public"}> 136 + <Tag label={deckValue().visibility.type} color="gray" /> 137 + </Show> 138 + </div> 113 139 114 - <div class="flex items-center gap-4 mb-6"> 115 - <div class="text-[#C6C6C6] font-light">By {deckValue().owner_did}</div> 116 - <FollowButton did={deckValue().owner_did || ""} /> 117 - </div> 140 + <div class="flex items-center gap-4 mb-6"> 141 + <div class="text-[#C6C6C6] font-light">By {deckValue().owner_did}</div> 142 + <FollowButton did={deckValue().owner_did || ""} /> 143 + </div> 118 144 119 - <p class="text-[#C6C6C6] mb-6 font-light">{deckValue().description}</p> 145 + <p class="text-[#C6C6C6] mb-6 font-light">{deckValue().description}</p> 120 146 121 - <Show when={deckValue().tags.length > 0}> 122 - <div class="flex gap-2 mb-8 flex-wrap"> 123 - <For each={deckValue().tags}>{(tag) => <Tag label={`#${tag}`} color="blue" />}</For> 124 - </div> 125 - </Show> 147 + <Show when={deckValue().tags.length > 0}> 148 + <div class="flex gap-2 mb-8 flex-wrap"> 149 + <For each={deckValue().tags}>{(tag) => <Tag label={`#${tag}`} color="blue" />}</For> 150 + </div> 151 + </Show> 126 152 127 - <div class="flex gap-4 border-t border-[#393939] pt-6"> 128 - <Button disabled> 129 - <span class="i-bi-play-fill" /> Study Deck 130 - </Button> 131 - <Button onClick={() => setShowForkDialog(true)} variant="secondary"> 132 - <span class="i-bi-box-arrow-up-right" /> Fork Deck 133 - </Button> 134 - <A href="/"> 135 - <Button variant="ghost">Back to Library</Button> 136 - </A> 137 - </div> 138 - </Motion.div> 153 + <div class="flex gap-4 border-t border-[#393939] pt-6"> 154 + <Button 155 + disabled={!dueCards() || dueCards()?.length === 0} 156 + onClick={() => setIsStudying(true)} 157 + class="flex items-center gap-2"> 158 + <span class="i-bi-play-fill" /> 159 + <span> 160 + Study Deck 161 + <Show when={dueCards() && dueCards()!.length > 0}>{` (${dueCards()!.length} due)`}</Show> 162 + </span> 163 + </Button> 164 + <Button onClick={() => setShowForkDialog(true)} variant="secondary" class="flex items-center gap-2"> 165 + <span class="i-ri-git-fork-line" /> 166 + <span>Fork Deck</span> 167 + </Button> 168 + <A href="/"> 169 + <Button variant="ghost" class="flex items-center gap-2"> 170 + <span class="i-bi-arrow-left" /> 171 + <span>Back to Library</span> 172 + </Button> 173 + </A> 174 + </div> 175 + </Motion.div> 139 176 140 - <Motion.div 141 - initial={{ opacity: 0, y: 20 }} 142 - animate={{ opacity: 1, y: 0 }} 143 - transition={{ duration: 0.4, delay: 0.1 }}> 144 - <h2 class="text-xl font-medium text-[#F4F4F4] mb-6 border-b border-[#393939] pb-4"> 145 - Cards <Show when={cards()}>{(value) => <span class="text-[#8D8D8D]">({value().length})</span>}</Show> 146 - </h2> 177 + <Motion.div 178 + initial={{ opacity: 0, y: 20 }} 179 + animate={{ opacity: 1, y: 0 }} 180 + transition={{ duration: 0.4, delay: 0.1 }}> 181 + <h2 class="text-xl font-medium text-[#F4F4F4] mb-6 border-b border-[#393939] pb-4"> 182 + Cards{" "} 183 + <Show when={cards()}>{(value) => <span class="text-[#8D8D8D]">({value().length})</span>}</Show> 184 + </h2> 147 185 148 - <Show when={!cards.loading} fallback={<Index each={Array(3)}>{() => <CardSkeleton />}</Index>}> 149 - <div class="grid gap-4"> 150 - <For 151 - each={cards()} 152 - fallback={ 153 - <EmptyState 154 - title="No cards in this deck" 155 - description="Add some cards to start studying." 156 - icon={<span class="i-bi-card-text text-4xl text-[#525252]" />} /> 157 - }> 158 - {(card, i) => ( 159 - <Motion.div 160 - initial={{ opacity: 0, y: 10 }} 161 - animate={{ opacity: 1, y: 0 }} 162 - transition={{ duration: 0.3, delay: i() * 0.03 }}> 163 - <Card class="hover:border-[#525252] transition-colors"> 164 - <div class="flex justify-between items-start mb-2 text-xs text-[#8D8D8D] font-mono"> 165 - <span class="opacity-50">CARD {i() + 1}</span> 166 - </div> 167 - <div class="grid md:grid-cols-2 gap-8"> 168 - <div> 169 - <div class="text-[10px] uppercase tracking-widest text-[#525252] mb-1">Front</div> 170 - <div class="text-[#E0E0E0]">{card.front}</div> 186 + <Show when={!cards.loading} fallback={<Index each={Array(3)}>{() => <CardSkeleton />}</Index>}> 187 + <div class="grid gap-4"> 188 + <For 189 + each={cards()} 190 + fallback={ 191 + <EmptyState 192 + title="No cards in this deck" 193 + description="Add some cards to start studying." 194 + icon={<span class="i-bi-card-text text-4xl text-[#525252]" />} /> 195 + }> 196 + {(card, i) => ( 197 + <Motion.div 198 + initial={{ opacity: 0, y: 10 }} 199 + animate={{ opacity: 1, y: 0 }} 200 + transition={{ duration: 0.3, delay: i() * 0.03 }}> 201 + <Card class="hover:border-[#525252] transition-colors"> 202 + <div class="flex justify-between items-start mb-2 text-xs text-[#8D8D8D] font-mono"> 203 + <span class="opacity-50">CARD {i() + 1}</span> 171 204 </div> 172 - <div class="md:border-l md:border-[#393939] md:pl-8"> 173 - <div class="text-[10px] uppercase tracking-widest text-[#525252] mb-1">Back</div> 174 - <div class="text-[#C6C6C6]"> 175 - {card.back || <span class="italic opacity-50">Empty</span>} 205 + <div class="grid md:grid-cols-2 gap-8"> 206 + <div> 207 + <div class="text-[10px] uppercase tracking-widest text-[#525252] mb-1">Front</div> 208 + <div class="text-[#E0E0E0]">{card.front}</div> 209 + </div> 210 + <div class="md:border-l md:border-[#393939] md:pl-8"> 211 + <div class="text-[10px] uppercase tracking-widest text-[#525252] mb-1">Back</div> 212 + <div class="text-[#C6C6C6]"> 213 + {card.back || <span class="italic opacity-50">Empty</span>} 214 + </div> 176 215 </div> 177 216 </div> 178 - </div> 179 - </Card> 180 - </Motion.div> 181 - )} 182 - </For> 183 - </div> 184 - </Show> 185 - </Motion.div> 217 + </Card> 218 + </Motion.div> 219 + )} 220 + </For> 221 + </div> 222 + </Show> 223 + </Motion.div> 186 224 187 - <Motion.div 188 - initial={{ opacity: 0 }} 189 - animate={{ opacity: 1 }} 190 - transition={{ duration: 0.4, delay: 0.2 }} 191 - class="mt-12 pt-8 border-t border-[#393939]"> 192 - <CommentSection deckId={deckValue().id} /> 193 - </Motion.div> 194 - </> 195 - )} 225 + <Motion.div 226 + initial={{ opacity: 0 }} 227 + animate={{ opacity: 1 }} 228 + transition={{ duration: 0.4, delay: 0.2 }} 229 + class="mt-12 pt-8 border-t border-[#393939]"> 230 + <CommentSection deckId={deckValue().id} /> 231 + </Motion.div> 232 + </> 233 + )} 234 + </Show> 196 235 </Show> 197 - </Show> 198 - 199 - <Dialog 200 - open={showForkDialog()} 201 - onClose={() => setShowForkDialog(false)} 202 - title="Fork Deck" 203 - actions={ 204 - <> 205 - <Button variant="ghost" onClick={() => setShowForkDialog(false)}>Cancel</Button> 206 - <Button variant="primary" onClick={handleFork}>Fork Deck</Button> 207 - </> 208 - }> 209 - <p class="text-[#C6C6C6]">Are you sure you want to fork "{deck()?.title}"?</p> 210 - <p class="text-sm text-[#8D8D8D] mt-2"> 211 - This will create a copy of this deck in your library that you can study and edit. 212 - </p> 213 - </Dialog> 214 - </Motion.div> 236 + <Dialog 237 + open={showForkDialog()} 238 + onClose={() => setShowForkDialog(false)} 239 + title="Fork Deck" 240 + actions={ 241 + <> 242 + <Button variant="ghost" onClick={() => setShowForkDialog(false)}>Cancel</Button> 243 + <Button variant="primary" onClick={handleFork}>Fork Deck</Button> 244 + </> 245 + }> 246 + <p class="text-[#C6C6C6]">Are you sure you want to fork "{deck()?.title}"?</p> 247 + <p class="text-sm text-[#8D8D8D] mt-2"> 248 + This will create a copy of this deck in your library that you can study and edit. 249 + </p> 250 + </Dialog> 251 + </Motion.div> 252 + </Show> 215 253 ); 216 254 }; 217 255
+75 -5
web/src/pages/tests/DeckView.test.tsx
··· 10 10 vi.mock( 11 11 "$lib/api", 12 12 () => ({ 13 - api: { getDeck: vi.fn(), getDeckCards: vi.fn(), forkDeck: vi.fn(), getComments: vi.fn(), addComment: vi.fn() }, 13 + api: { 14 + getDeck: vi.fn(), 15 + getDeckCards: vi.fn(), 16 + forkDeck: vi.fn(), 17 + getComments: vi.fn(), 18 + addComment: vi.fn(), 19 + getDueCards: vi.fn(), 20 + submitReview: vi.fn(), 21 + }, 14 22 }), 15 23 ); 16 24 ··· 49 57 vi.mocked(api.getDeckCards).mockResolvedValue( 50 58 { ok: true, json: () => Promise.resolve(mockCards) } as unknown as Response, 51 59 ); 60 + vi.mocked(api.getDueCards).mockResolvedValue({ ok: true, json: () => Promise.resolve([]) } as unknown as Response); 52 61 vi.mocked(api.getComments).mockResolvedValue({ ok: true, json: () => Promise.resolve([]) } as unknown as Response); 53 62 54 63 render(() => <DeckView />); ··· 66 75 vi.mocked(api.getDeckCards).mockResolvedValue( 67 76 { ok: true, json: () => Promise.resolve(mockCards) } as unknown as Response, 68 77 ); 78 + vi.mocked(api.getDueCards).mockResolvedValue({ ok: true, json: () => Promise.resolve([]) } as unknown as Response); 69 79 vi.mocked(api.forkDeck).mockResolvedValue( 70 80 { ok: true, json: () => Promise.resolve({ id: "456" }) } as unknown as Response, 71 81 ); ··· 75 85 76 86 await waitFor(() => expect(screen.getByText("Test Deck")).toBeInTheDocument()); 77 87 78 - const forkButton = screen.getByText("Fork Deck", { selector: "button" }); 88 + const forkButton = screen.getByRole("button", { name: /Fork Deck/i }); 79 89 fireEvent.click(forkButton); 80 90 81 - const dialog = screen.getByRole("dialog"); 91 + const dialog = await screen.findByRole("dialog"); 82 92 expect(within(dialog).getByText(/Are you sure you want to fork/)).toBeInTheDocument(); 83 93 84 94 const confirmButton = within(dialog).getByRole("button", { name: /Fork Deck/i }); ··· 98 108 vi.mocked(api.getDeckCards).mockResolvedValue( 99 109 { ok: true, json: () => Promise.resolve(mockCards) } as unknown as Response, 100 110 ); 111 + vi.mocked(api.getDueCards).mockResolvedValue({ ok: true, json: () => Promise.resolve([]) } as unknown as Response); 101 112 vi.mocked(api.forkDeck).mockResolvedValue({ ok: false } as unknown as Response); 102 113 vi.mocked(api.getComments).mockResolvedValue({ ok: true, json: () => Promise.resolve([]) } as unknown as Response); 103 114 ··· 105 116 106 117 await waitFor(() => expect(screen.getByText("Test Deck")).toBeInTheDocument()); 107 118 108 - const forkButton = screen.getByText("Fork Deck", { selector: "button" }); 119 + const forkButton = screen.getByRole("button", { name: /Fork Deck/i }); 109 120 fireEvent.click(forkButton); 110 121 111 - const dialog = screen.getByRole("dialog"); 122 + const dialog = await screen.findByRole("dialog"); 112 123 const confirmButton = within(dialog).getByRole("button", { name: /Fork Deck/i }); 113 124 fireEvent.click(confirmButton); 114 125 ··· 121 132 122 133 it("renders not found state when deck returns error", async () => { 123 134 vi.mocked(api.getDeck).mockResolvedValue({ ok: false } as unknown as Response); 135 + vi.mocked(api.getDueCards).mockResolvedValue({ ok: true, json: () => Promise.resolve([]) } as unknown as Response); 124 136 render(() => <DeckView />); 125 137 await waitFor(() => expect(screen.getByText(/Deck not found/i)).toBeInTheDocument()); 138 + }); 139 + it("renders study button with due cards count", async () => { 140 + vi.mocked(api.getDeck).mockResolvedValue( 141 + { ok: true, json: () => Promise.resolve(mockDeck) } as unknown as Response, 142 + ); 143 + vi.mocked(api.getDeckCards).mockResolvedValue( 144 + { ok: true, json: () => Promise.resolve(mockCards) } as unknown as Response, 145 + ); 146 + vi.mocked(api.getDueCards).mockResolvedValue( 147 + { 148 + ok: true, 149 + json: () => Promise.resolve([{ review_id: "r1", card_id: "c1", deck_id: "123", front: "F", back: "B" }]), 150 + } as unknown as Response, 151 + ); 152 + vi.mocked(api.getComments).mockResolvedValue({ ok: true, json: () => Promise.resolve([]) } as unknown as Response); 153 + 154 + render(() => <DeckView />); 155 + 156 + await waitFor(() => expect(screen.getByText("Test Deck")).toBeInTheDocument()); 157 + 158 + const studyButton = await screen.findByRole("button", { name: /Study Deck \(1 due\)/i }); 159 + expect(studyButton).toBeInTheDocument(); 160 + expect(studyButton).not.toBeDisabled(); 161 + }); 162 + 163 + it("enters study mode when study button is clicked", async () => { 164 + vi.mocked(api.getDeck).mockResolvedValue( 165 + { ok: true, json: () => Promise.resolve(mockDeck) } as unknown as Response, 166 + ); 167 + vi.mocked(api.getDeckCards).mockResolvedValue( 168 + { ok: true, json: () => Promise.resolve(mockCards) } as unknown as Response, 169 + ); 170 + vi.mocked(api.getDueCards).mockResolvedValue( 171 + { 172 + ok: true, 173 + json: () => 174 + Promise.resolve([{ 175 + review_id: "r1", 176 + card_id: "c1", 177 + deck_id: "123", 178 + front: "Study Front", 179 + back: "Study Back", 180 + deck_title: "Test Deck", 181 + hints: [], 182 + }]), 183 + } as unknown as Response, 184 + ); 185 + vi.mocked(api.getComments).mockResolvedValue({ ok: true, json: () => Promise.resolve([]) } as unknown as Response); 186 + 187 + render(() => <DeckView />); 188 + 189 + await waitFor(() => expect(screen.getByText("Test Deck")).toBeInTheDocument()); 190 + 191 + const studyButton = await screen.findByRole("button", { name: /Study Deck \(1 due\)/i }); 192 + fireEvent.click(studyButton); 193 + 194 + await waitFor(() => expect(screen.getByText("Card 1 of 1")).toBeInTheDocument()); 195 + expect(screen.getByText("Study Front")).toBeInTheDocument(); 126 196 }); 127 197 });