+7
-1
crates/server/src/api/card.rs
+7
-1
crates/server/src/api/card.rs
···
106
#[tokio::test]
107
async fn test_create_card_success() {
108
let state = create_test_state();
109
-
let user = UserContext { did: "did:plc:test123".to_string(), handle: "test.handle".to_string() };
110
111
let payload = CreateCardRequest {
112
deck_id: "550e8400-e29b-41d4-a716-446655440000".to_string(),
···
106
#[tokio::test]
107
async fn test_create_card_success() {
108
let state = create_test_state();
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
+
};
116
117
let payload = CreateCardRequest {
118
deck_id: "550e8400-e29b-41d4-a716-446655440000".to_string(),
+8
-2
crates/server/src/api/deck.rs
+8
-2
crates/server/src/api/deck.rs
···
176
}
177
};
178
179
-
match crate::pds::publish::publish_deck_to_pds(state.oauth_repo.clone(), &user.did, &deck, &cards).await {
180
Ok(result) => {
181
deck_at_uri = Some(result.deck_at_uri.clone());
182
···
292
Arc::new(crate::repository::oauth::mock::MockOAuthRepository::new()),
293
);
294
295
-
let user = UserContext { did: "did:plc:alice".to_string(), handle: "alice.bsky.social".to_string() };
296
297
let payload = CreateDeckRequest {
298
title: "My New Deck".to_string(),
···
176
}
177
};
178
179
+
match crate::pds::publish::publish_deck_to_pds(state.oauth_repo.clone(), &user, &deck, &cards).await {
180
Ok(result) => {
181
deck_at_uri = Some(result.deck_at_uri.clone());
182
···
292
Arc::new(crate::repository::oauth::mock::MockOAuthRepository::new()),
293
);
294
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
+
};
302
303
let payload = CreateDeckRequest {
304
title: "My New Deck".to_string(),
+7
-1
crates/server/src/api/feed.rs
+7
-1
crates/server/src/api/feed.rs
···
89
async fn test_get_feed_follows_success() {
90
let social_repo = Arc::new(MockSocialRepository::new());
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() };
93
let response = get_feed_follows(State(state), Some(Extension(user)))
94
.await
95
.into_response();
···
89
async fn test_get_feed_follows_success() {
90
let social_repo = Arc::new(MockSocialRepository::new());
91
let state = create_test_state_with_social(social_repo);
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
+
};
99
let response = get_feed_follows(State(state), Some(Extension(user)))
100
.await
101
.into_response();
+21
-3
crates/server/src/api/note.rs
+21
-3
crates/server/src/api/note.rs
···
152
#[tokio::test]
153
async fn test_create_note_success() {
154
let state = create_test_state();
155
-
let user = UserContext { did: "did:plc:test123".to_string(), handle: "test.handle".to_string() };
156
157
let payload = CreateNoteRequest {
158
title: "Test Note".to_string(),
···
254
255
let state = AppState::new_with_repos(pool, card_repo, note_repo, oauth_repo);
256
257
-
let owner = UserContext { did: "did:plc:owner".to_string(), handle: "owner.handle".to_string() };
258
259
let response = get_note(
260
axum::extract::State(state.clone()),
···
266
267
assert_eq!(response.status(), StatusCode::OK);
268
269
-
let other_user = UserContext { did: "did:plc:other".to_string(), handle: "other.handle".to_string() };
270
let response = get_note(axum::extract::State(state), Some(Extension(other_user)), Path(note_id))
271
.await
272
.into_response();
···
152
#[tokio::test]
153
async fn test_create_note_success() {
154
let state = create_test_state();
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
+
};
162
163
let payload = CreateNoteRequest {
164
title: "Test Note".to_string(),
···
260
261
let state = AppState::new_with_repos(pool, card_repo, note_repo, oauth_repo);
262
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
+
};
270
271
let response = get_note(
272
axum::extract::State(state.clone()),
···
278
279
assert_eq!(response.status(), StatusCode::OK);
280
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
+
};
288
let response = get_note(axum::extract::State(state), Some(Extension(other_user)), Path(note_id))
289
.await
290
.into_response();
+21
-3
crates/server/src/api/preferences.rs
+21
-3
crates/server/src/api/preferences.rs
···
148
let prefs_repo = Arc::new(MockPreferencesRepository::new()) as Arc<dyn PreferencesRepository>;
149
let state = create_test_state_with_prefs(prefs_repo);
150
151
-
let user = UserContext { did: "did:plc:test".to_string(), handle: "test.handle".to_string() };
152
let response = get_preferences(State(state), Some(Extension(user)))
153
.await
154
.into_response();
···
161
let prefs_repo = Arc::new(MockPreferencesRepository::new()) as Arc<dyn PreferencesRepository>;
162
let state = create_test_state_with_prefs(prefs_repo);
163
164
-
let user = UserContext { did: "did:plc:test".to_string(), handle: "test.handle".to_string() };
165
let payload = UpdatePreferencesRequest {
166
persona: Some("creator".to_string()),
167
complete_onboarding: Some(true),
···
181
let prefs_repo = Arc::new(MockPreferencesRepository::new()) as Arc<dyn PreferencesRepository>;
182
let state = create_test_state_with_prefs(prefs_repo);
183
184
-
let user = UserContext { did: "did:plc:test".to_string(), handle: "test.handle".to_string() };
185
let payload = UpdatePreferencesRequest {
186
persona: Some("invalid".to_string()),
187
complete_onboarding: None,
···
148
let prefs_repo = Arc::new(MockPreferencesRepository::new()) as Arc<dyn PreferencesRepository>;
149
let state = create_test_state_with_prefs(prefs_repo);
150
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
+
};
158
let response = get_preferences(State(state), Some(Extension(user)))
159
.await
160
.into_response();
···
167
let prefs_repo = Arc::new(MockPreferencesRepository::new()) as Arc<dyn PreferencesRepository>;
168
let state = create_test_state_with_prefs(prefs_repo);
169
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
+
};
177
let payload = UpdatePreferencesRequest {
178
persona: Some("creator".to_string()),
179
complete_onboarding: Some(true),
···
193
let prefs_repo = Arc::new(MockPreferencesRepository::new()) as Arc<dyn PreferencesRepository>;
194
let state = create_test_state_with_prefs(prefs_repo);
195
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
+
};
203
let payload = UpdatePreferencesRequest {
204
persona: Some("invalid".to_string()),
205
complete_onboarding: None,
+28
-4
crates/server/src/api/review.rs
+28
-4
crates/server/src/api/review.rs
···
201
let review_repo = Arc::new(MockReviewRepository::with_cards(cards)) as Arc<dyn ReviewRepository>;
202
let state = create_test_state_with_review(review_repo);
203
204
-
let user = UserContext { did: "did:plc:test".to_string(), handle: "test.handle".to_string() };
205
let response = get_due_cards(
206
State(state),
207
Some(Extension(user)),
···
218
let review_repo = Arc::new(MockReviewRepository::new()) as Arc<dyn ReviewRepository>;
219
let state = create_test_state_with_review(review_repo);
220
221
-
let user = UserContext { did: "did:plc:test".to_string(), handle: "test.handle".to_string() };
222
let payload = SubmitReviewRequest { card_id: "card-1".to_string(), grade: 3 };
223
224
let response = submit_review(State(state), Some(Extension(user)), Json(payload))
···
233
let review_repo = Arc::new(MockReviewRepository::new()) as Arc<dyn ReviewRepository>;
234
let state = create_test_state_with_review(review_repo);
235
236
-
let user = UserContext { did: "did:plc:test".to_string(), handle: "test.handle".to_string() };
237
let payload = SubmitReviewRequest { card_id: "card-1".to_string(), grade: 10 };
238
239
let response = submit_review(State(state), Some(Extension(user)), Json(payload))
···
248
let review_repo = Arc::new(MockReviewRepository::new()) as Arc<dyn ReviewRepository>;
249
let state = create_test_state_with_review(review_repo);
250
251
-
let user = UserContext { did: "did:plc:test".to_string(), handle: "test.handle".to_string() };
252
let response = get_stats(State(state), Some(Extension(user))).await.into_response();
253
254
assert_eq!(response.status(), StatusCode::OK);
···
201
let review_repo = Arc::new(MockReviewRepository::with_cards(cards)) as Arc<dyn ReviewRepository>;
202
let state = create_test_state_with_review(review_repo);
203
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
+
};
211
let response = get_due_cards(
212
State(state),
213
Some(Extension(user)),
···
224
let review_repo = Arc::new(MockReviewRepository::new()) as Arc<dyn ReviewRepository>;
225
let state = create_test_state_with_review(review_repo);
226
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
+
};
234
let payload = SubmitReviewRequest { card_id: "card-1".to_string(), grade: 3 };
235
236
let response = submit_review(State(state), Some(Extension(user)), Json(payload))
···
245
let review_repo = Arc::new(MockReviewRepository::new()) as Arc<dyn ReviewRepository>;
246
let state = create_test_state_with_review(review_repo);
247
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
+
};
255
let payload = SubmitReviewRequest { card_id: "card-1".to_string(), grade: 10 };
256
257
let response = submit_review(State(state), Some(Extension(user)), Json(payload))
···
266
let review_repo = Arc::new(MockReviewRepository::new()) as Arc<dyn ReviewRepository>;
267
let state = create_test_state_with_review(review_repo);
268
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
+
};
276
let response = get_stats(State(state), Some(Extension(user))).await.into_response();
277
278
assert_eq!(response.status(), StatusCode::OK);
+7
-1
crates/server/src/api/search.rs
+7
-1
crates/server/src/api/search.rs
···
134
.await;
135
136
let state = create_test_state_with_search(search_repo.clone());
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
+
});
144
let response = search(
145
State(state.clone()),
146
Some(auth_ctx),
+20
-2
crates/server/src/middleware/auth.rs
+20
-2
crates/server/src/middleware/auth.rs
···
10
use serde_json::json;
11
use std::time::{Duration, Instant};
12
13
#[derive(Clone, Debug)]
14
pub struct UserContext {
15
pub did: String,
16
pub handle: String,
17
}
18
19
/// Cache expiry time (5 minutes)
···
190
let body: serde_json::Value = response.json().await.unwrap_or_default();
191
let did = body["did"].as_str().unwrap_or("").to_string();
192
let handle = body["handle"].as_str().unwrap_or("").to_string();
193
-
let user_ctx = UserContext { did: did.clone(), handle };
194
195
tracing::debug!("PDS verification successful for DID: {}", did);
196
···
262
let did = body["did"].as_str().unwrap_or("").to_string();
263
let handle = body["handle"].as_str().unwrap_or("").to_string();
264
265
-
req.extensions_mut().insert(UserContext { did, handle });
266
}
267
_ => {}
268
}
···
10
use serde_json::json;
11
use std::time::{Duration, Instant};
12
13
+
/// User context extracted from authentication.
14
+
///
15
+
/// Contains the user's identity and authentication details needed for PDS operations.
16
#[derive(Clone, Debug)]
17
pub struct UserContext {
18
pub did: String,
19
pub handle: String,
20
+
pub access_token: String,
21
+
pub pds_url: String,
22
+
pub has_dpop: bool,
23
}
24
25
/// Cache expiry time (5 minutes)
···
196
let body: serde_json::Value = response.json().await.unwrap_or_default();
197
let did = body["did"].as_str().unwrap_or("").to_string();
198
let handle = body["handle"].as_str().unwrap_or("").to_string();
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
+
};
206
207
tracing::debug!("PDS verification successful for DID: {}", did);
208
···
274
let did = body["did"].as_str().unwrap_or("").to_string();
275
let handle = body["handle"].as_str().unwrap_or("").to_string();
276
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
+
});
284
}
285
_ => {}
286
}
+98
-23
crates/server/src/pds/client.rs
+98
-23
crates/server/src/pds/client.rs
···
7
use serde::{Deserialize, Serialize};
8
9
/// A client for interacting with a user's PDS.
10
pub struct PdsClient {
11
http_client: reqwest::Client,
12
pds_url: String,
13
access_token: String,
14
-
dpop_keypair: DpopKeypair,
15
}
16
17
/// Request body for putRecord XRPC.
···
101
impl std::error::Error for PdsError {}
102
103
impl PdsClient {
104
-
/// Create a new PDS client.
105
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 }
107
}
108
109
/// Create or update a record in the repository.
···
118
&self, did: &str, collection: &str, rkey: &str, record: serde_json::Value,
119
) -> Result<AtUri, PdsError> {
120
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
124
let request = PutRecordRequest {
125
repo: did.to_string(),
···
131
validate: Some(true),
132
};
133
134
-
let response = self
135
-
.http_client
136
-
.post(&url)
137
-
.header("Authorization", format!("DPoP {}", self.access_token))
138
-
.header("DPoP", dpop_proof)
139
.json(&request)
140
.send()
141
.await
···
148
pub async fn delete_record(&self, did: &str, collection: &str, rkey: &str) -> Result<(), PdsError> {
149
let url = format!("{}/xrpc/com.atproto.repo.deleteRecord", self.pds_url);
150
151
-
let dpop_proof = self.dpop_keypair.generate_proof("POST", &url, Some(&self.access_token));
152
-
153
let request = DeleteRecordRequest {
154
repo: did.to_string(),
155
collection: collection.to_string(),
···
158
swap_commit: None,
159
};
160
161
-
let response = self
162
-
.http_client
163
-
.post(&url)
164
-
.header("Authorization", format!("DPoP {}", self.access_token))
165
-
.header("DPoP", dpop_proof)
166
.json(&request)
167
.send()
168
.await
···
181
pub async fn upload_blob(&self, data: Vec<u8>, mime_type: &str) -> Result<BlobRef, PdsError> {
182
let url = format!("{}/xrpc/com.atproto.repo.uploadBlob", self.pds_url);
183
184
-
let dpop_proof = self.dpop_keypair.generate_proof("POST", &url, Some(&self.access_token));
185
186
-
let response = self
187
-
.http_client
188
-
.post(&url)
189
-
.header("Authorization", format!("DPoP {}", self.access_token))
190
-
.header("DPoP", dpop_proof)
191
.header("Content-Type", mime_type)
192
.body(data)
193
.send()
···
298
299
let err = PdsError::NetworkError("Connection refused".to_string());
300
assert!(err.to_string().contains("Connection refused"));
301
}
302
}
···
7
use serde::{Deserialize, Serialize};
8
9
/// A client for interacting with a user's PDS.
10
+
///
11
+
/// Supports both DPoP-bound tokens (OAuth) and Bearer tokens (app passwords).
12
pub struct PdsClient {
13
http_client: reqwest::Client,
14
pds_url: String,
15
access_token: String,
16
+
dpop_keypair: Option<DpopKeypair>,
17
}
18
19
/// Request body for putRecord XRPC.
···
103
impl std::error::Error for PdsError {}
104
105
impl PdsClient {
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")]
122
pub fn new(pds_url: String, access_token: String, dpop_keypair: DpopKeypair) -> Self {
123
+
Self::new_with_dpop(pds_url, access_token, dpop_keypair)
124
}
125
126
/// Create or update a record in the repository.
···
135
&self, did: &str, collection: &str, rkey: &str, record: serde_json::Value,
136
) -> Result<AtUri, PdsError> {
137
let url = format!("{}/xrpc/com.atproto.repo.putRecord", self.pds_url);
138
139
let request = PutRecordRequest {
140
repo: did.to_string(),
···
146
validate: Some(true),
147
};
148
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
164
.json(&request)
165
.send()
166
.await
···
173
pub async fn delete_record(&self, did: &str, collection: &str, rkey: &str) -> Result<(), PdsError> {
174
let url = format!("{}/xrpc/com.atproto.repo.deleteRecord", self.pds_url);
175
176
let request = DeleteRecordRequest {
177
repo: did.to_string(),
178
collection: collection.to_string(),
···
181
swap_commit: None,
182
};
183
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
199
.json(&request)
200
.send()
201
.await
···
214
pub async fn upload_blob(&self, data: Vec<u8>, mime_type: &str) -> Result<BlobRef, PdsError> {
215
let url = format!("{}/xrpc/com.atproto.repo.uploadBlob", self.pds_url);
216
217
+
let mut request_builder = self.http_client.post(&url);
218
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
232
.header("Content-Type", mime_type)
233
.body(data)
234
.send()
···
339
340
let err = PdsError::NetworkError("Connection refused".to_string());
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());
376
}
377
}
+35
-16
crates/server/src/pds/publish.rs
+35
-16
crates/server/src/pds/publish.rs
···
2
//!
3
//! Encapsulates the logic for publishing records to a user's PDS.
4
5
use crate::pds::client::{PdsClient, PdsError};
6
use crate::pds::records::{prepare_card_record, prepare_deck_record};
7
-
use crate::repository::oauth::{OAuthRepoError, OAuthRepository, StoredToken};
8
use malfestio_core::model::{Card, Deck};
9
use std::sync::Arc;
10
···
63
/// Publish a deck and its cards to the user's PDS.
64
///
65
/// 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
70
///
71
/// Note: Cards are published with an empty deck_ref since we don't have the
72
/// deck's AT-URI yet. This is acceptable per the Lexicon - the deck holds
73
/// the authoritative list of card references.
74
pub async fn publish_deck_to_pds(
75
-
oauth_repo: Arc<dyn OAuthRepository>, did: &str, deck: &Deck, cards: &[Card],
76
) -> 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
-
);
85
86
let mut card_at_uris = Vec::with_capacity(cards.len());
87
for card in cards {
88
let prepared = prepare_card_record(card, "");
89
let at_uri = pds_client
90
-
.put_record(did, &prepared.collection, &prepared.rkey, prepared.record)
91
.await?;
92
card_at_uris.push(at_uri.to_string());
93
}
94
95
let prepared = prepare_deck_record(deck, card_at_uris.clone());
96
let deck_at_uri = pds_client
97
-
.put_record(did, &prepared.collection, &prepared.rkey, prepared.record)
98
.await?;
99
100
Ok(PublishDeckResult { deck_at_uri: deck_at_uri.to_string(), card_at_uris })
···
2
//!
3
//! Encapsulates the logic for publishing records to a user's PDS.
4
5
+
use crate::middleware::auth::UserContext;
6
use crate::pds::client::{PdsClient, PdsError};
7
use crate::pds::records::{prepare_card_record, prepare_deck_record};
8
+
use crate::repository::oauth::{OAuthRepoError, OAuthRepository};
9
use malfestio_core::model::{Card, Deck};
10
use std::sync::Arc;
11
···
64
/// Publish a deck and its cards to the user's PDS.
65
///
66
/// This function:
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
72
///
73
/// Note: Cards are published with an empty deck_ref since we don't have the
74
/// deck's AT-URI yet. This is acceptable per the Lexicon - the deck holds
75
/// the authoritative list of card references.
76
pub async fn publish_deck_to_pds(
77
+
oauth_repo: Arc<dyn OAuthRepository>, user_ctx: &UserContext, deck: &Deck, cards: &[Card],
78
) -> Result<PublishDeckResult, PublishError> {
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
+
};
104
105
let mut card_at_uris = Vec::with_capacity(cards.len());
106
for card in cards {
107
let prepared = prepare_card_record(card, "");
108
let at_uri = pds_client
109
+
.put_record(&user_ctx.did, &prepared.collection, &prepared.rkey, prepared.record)
110
.await?;
111
card_at_uris.push(at_uri.to_string());
112
}
113
114
let prepared = prepare_deck_record(deck, card_at_uris.clone());
115
let deck_at_uri = pds_client
116
+
.put_record(&user_ctx.did, &prepared.collection, &prepared.rkey, prepared.record)
117
.await?;
118
119
Ok(PublishDeckResult { deck_at_uri: deck_at_uri.to_string(), card_at_uris })
+1
web/package.json
+1
web/package.json
+10
web/pnpm-lock.yaml
+10
web/pnpm-lock.yaml
···
69
'@iconify-json/bi':
70
specifier: ^1.2.7
71
version: 1.2.7
72
'@resvg/resvg-js':
73
specifier: ^2.6.2
74
version: 2.6.2
···
499
500
'@iconify-json/bi@1.2.7':
501
resolution: {integrity: sha512-IPz8WNxmLkH1I9msl+0Q4OnmjjvP4uU0Z61a4i4sqonB6vKSbMGUWuGn8/YuuszlReVj8rf+3gNv5JU8Xoljyg==}
502
503
'@iconify/types@2.0.0':
504
resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==}
···
2586
'@humanwhocodes/retry@0.4.3': {}
2587
2588
'@iconify-json/bi@1.2.7':
2589
dependencies:
2590
'@iconify/types': 2.0.0
2591
···
69
'@iconify-json/bi':
70
specifier: ^1.2.7
71
version: 1.2.7
72
+
'@iconify-json/ri':
73
+
specifier: ^1.2.7
74
+
version: 1.2.7
75
'@resvg/resvg-js':
76
specifier: ^2.6.2
77
version: 2.6.2
···
502
503
'@iconify-json/bi@1.2.7':
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==}
508
509
'@iconify/types@2.0.0':
510
resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==}
···
2592
'@humanwhocodes/retry@0.4.3': {}
2593
2594
'@iconify-json/bi@1.2.7':
2595
+
dependencies:
2596
+
'@iconify/types': 2.0.0
2597
+
2598
+
'@iconify-json/ri@1.2.7':
2599
dependencies:
2600
'@iconify/types': 2.0.0
2601
+75
-65
web/src/components/StudySession.tsx
+75
-65
web/src/components/StudySession.tsx
···
10
type StudySessionProps = { cards: ReviewCard[]; onComplete: () => void; onExit: () => void };
11
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" },
19
};
20
21
export const StudySession: Component<StudySessionProps> = (props) => {
···
27
const currentCard = () => props.cards[currentIndex()];
28
const progress = () => ((currentIndex() + 1) / props.cards.length) * 100;
29
const isComplete = () => currentIndex() >= props.cards.length;
30
-
const handleFlip = () => !isFlipped() ? setIsFlipped(true) : void 0;
31
32
const handleGrade = async (grade: Grade) => {
33
const card = currentCard();
···
63
if (isFlipped()) handleGrade(1);
64
break;
65
case "3":
66
if (isFlipped()) handleGrade(3);
67
break;
68
-
case "4":
69
if (isFlipped()) handleGrade(4);
70
break;
71
-
case "5":
72
if (isFlipped()) handleGrade(5);
73
break;
74
case "e":
···
96
});
97
98
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="flex items-center justify-between mb-2">
103
<span class="text-gray-400 text-sm">Card {currentIndex() + 1} of {props.cards.length}</span>
104
<button onClick={() => props.onExit()} class="text-gray-400 hover:text-white text-sm flex items-center gap-1">
···
108
<ProgressBar value={progress()} color="green" size="md" />
109
</div>
110
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" }}>
118
<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>
129
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>
140
</div>
141
-
</Show>
142
</div>
143
</div>
144
</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>
168
169
<div class="fixed bottom-4 left-1/2 -translate-x-1/2 text-gray-600 text-xs flex gap-4">
170
<span>Space: Flip</span>
171
-
<span>1-5: Grade</span>
172
-
<span>E: Edit</span>
173
<span>Esc: Exit</span>
174
</div>
175
···
10
type StudySessionProps = { cards: ReviewCard[]; onComplete: () => void; onExit: () => void };
11
12
const GRADE_LABELS: { [key in Grade]: { label: string; color: string; key: string } } = {
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
};
20
21
export const StudySession: Component<StudySessionProps> = (props) => {
···
27
const currentCard = () => props.cards[currentIndex()];
28
const progress = () => ((currentIndex() + 1) / props.cards.length) * 100;
29
const isComplete = () => currentIndex() >= props.cards.length;
30
+
const handleFlip = () => setIsFlipped((f) => !f);
31
32
const handleGrade = async (grade: Grade) => {
33
const card = currentCard();
···
63
if (isFlipped()) handleGrade(1);
64
break;
65
case "3":
66
+
if (isFlipped()) handleGrade(2);
67
+
break;
68
+
case "4":
69
if (isFlipped()) handleGrade(3);
70
break;
71
+
case "5":
72
if (isFlipped()) handleGrade(4);
73
break;
74
+
case "6":
75
if (isFlipped()) handleGrade(5);
76
break;
77
case "e":
···
99
});
100
101
return (
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">
104
<div class="flex items-center justify-between mb-2">
105
<span class="text-gray-400 text-sm">Card {currentIndex() + 1} of {props.cards.length}</span>
106
<button onClick={() => props.onExit()} class="text-gray-400 hover:text-white text-sm flex items-center gap-1">
···
110
<ProgressBar value={progress()} color="green" size="md" />
111
</div>
112
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]">
117
<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>
134
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>
145
</div>
146
+
</div>
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>
172
</div>
173
</Motion.div>
174
+
</Show>
175
+
</div>
176
177
<div class="fixed bottom-4 left-1/2 -translate-x-1/2 text-gray-600 text-xs flex gap-4">
178
<span>Space: Flip</span>
179
+
<Show when={isFlipped()}>
180
+
<span>1-6: Grade</span>
181
+
<span>E: Edit</span>
182
+
</Show>
183
<span>Esc: Exit</span>
184
</div>
185
+29
-3
web/src/components/tests/StudySession.test.tsx
+29
-3
web/src/components/tests/StudySession.test.tsx
···
68
expect(await screen.findByText("How well did you know this?")).toBeInTheDocument();
69
});
70
71
-
it("shows keyboard hints", () => {
72
const onComplete = vi.fn();
73
const onExit = vi.fn();
74
75
render(() => <StudySession cards={mockCards} onComplete={onComplete} onExit={onExit} />);
76
77
expect(screen.getByText("Space: Flip")).toBeInTheDocument();
78
-
expect(screen.getByText("1-5: Grade")).toBeInTheDocument();
79
-
expect(screen.getByText("E: Edit")).toBeInTheDocument();
80
expect(screen.getByText("Esc: Exit")).toBeInTheDocument();
81
});
82
83
it("calls onExit when exit button is clicked", () => {
···
68
expect(await screen.findByText("How well did you know this?")).toBeInTheDocument();
69
});
70
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 () => {
88
const onComplete = vi.fn();
89
const onExit = vi.fn();
90
91
render(() => <StudySession cards={mockCards} onComplete={onComplete} onExit={onExit} />);
92
93
expect(screen.getByText("Space: Flip")).toBeInTheDocument();
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();
107
});
108
109
it("calls onExit when exit button is clicked", () => {
+171
-133
web/src/pages/DeckView.tsx
+171
-133
web/src/pages/DeckView.tsx
···
1
import { CommentSection } from "$components/social/CommentSection";
2
import { FollowButton } from "$components/social/FollowButton";
3
import { Button } from "$components/ui/Button";
4
import { Card } from "$components/ui/Card";
5
import { Dialog } from "$components/ui/Dialog";
···
7
import { Skeleton } from "$components/ui/Skeleton";
8
import { Tag } from "$components/ui/Tag";
9
import { api } from "$lib/api";
10
-
import type { Card as CardType, Deck } from "$lib/model";
11
import { toast } from "$lib/toast";
12
import { A, useNavigate, useParams } from "@solidjs/router";
13
import type { Component } from "solid-js";
···
44
const res = await api.getDeckCards(id);
45
return res.ok ? ((await res.json()) as CardType[]) : [];
46
});
47
48
const handleFork = async () => {
49
if (deck()) {
···
65
}
66
};
67
68
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
-
}>
87
<Show
88
-
when={deck()}
89
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
-
} />
99
}>
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
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>
118
119
-
<p class="text-[#C6C6C6] mb-6 font-light">{deckValue().description}</p>
120
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>
126
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>
139
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>
147
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>
171
</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>}
176
</div>
177
</div>
178
-
</div>
179
-
</Card>
180
-
</Motion.div>
181
-
)}
182
-
</For>
183
-
</div>
184
-
</Show>
185
-
</Motion.div>
186
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
-
)}
196
</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>
215
);
216
};
217
···
1
import { CommentSection } from "$components/social/CommentSection";
2
import { FollowButton } from "$components/social/FollowButton";
3
+
import { StudySession } from "$components/StudySession";
4
import { Button } from "$components/ui/Button";
5
import { Card } from "$components/ui/Card";
6
import { Dialog } from "$components/ui/Dialog";
···
8
import { Skeleton } from "$components/ui/Skeleton";
9
import { Tag } from "$components/ui/Tag";
10
import { api } from "$lib/api";
11
+
import type { Card as CardType, Deck, ReviewCard } from "$lib/model";
12
import { toast } from "$lib/toast";
13
import { A, useNavigate, useParams } from "@solidjs/router";
14
import type { Component } from "solid-js";
···
45
const res = await api.getDeckCards(id);
46
return res.ok ? ((await res.json()) as CardType[]) : [];
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);
55
56
const handleFork = async () => {
57
if (deck()) {
···
73
}
74
};
75
76
+
const handleStudyComplete = () => {
77
+
setIsStudying(false);
78
+
refetchDueCards();
79
+
toast.success("Session complete!");
80
+
};
81
+
82
return (
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">
100
<Show
101
+
when={!deck.loading}
102
fallback={
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>
112
}>
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>
139
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>
144
145
+
<p class="text-[#C6C6C6] mb-6 font-light">{deckValue().description}</p>
146
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>
152
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>
176
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>
185
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>
204
</div>
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>
215
</div>
216
</div>
217
+
</Card>
218
+
</Motion.div>
219
+
)}
220
+
</For>
221
+
</div>
222
+
</Show>
223
+
</Motion.div>
224
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>
235
</Show>
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>
253
);
254
};
255
+75
-5
web/src/pages/tests/DeckView.test.tsx
+75
-5
web/src/pages/tests/DeckView.test.tsx
···
10
vi.mock(
11
"$lib/api",
12
() => ({
13
-
api: { getDeck: vi.fn(), getDeckCards: vi.fn(), forkDeck: vi.fn(), getComments: vi.fn(), addComment: vi.fn() },
14
}),
15
);
16
···
49
vi.mocked(api.getDeckCards).mockResolvedValue(
50
{ ok: true, json: () => Promise.resolve(mockCards) } as unknown as Response,
51
);
52
vi.mocked(api.getComments).mockResolvedValue({ ok: true, json: () => Promise.resolve([]) } as unknown as Response);
53
54
render(() => <DeckView />);
···
66
vi.mocked(api.getDeckCards).mockResolvedValue(
67
{ ok: true, json: () => Promise.resolve(mockCards) } as unknown as Response,
68
);
69
vi.mocked(api.forkDeck).mockResolvedValue(
70
{ ok: true, json: () => Promise.resolve({ id: "456" }) } as unknown as Response,
71
);
···
75
76
await waitFor(() => expect(screen.getByText("Test Deck")).toBeInTheDocument());
77
78
-
const forkButton = screen.getByText("Fork Deck", { selector: "button" });
79
fireEvent.click(forkButton);
80
81
-
const dialog = screen.getByRole("dialog");
82
expect(within(dialog).getByText(/Are you sure you want to fork/)).toBeInTheDocument();
83
84
const confirmButton = within(dialog).getByRole("button", { name: /Fork Deck/i });
···
98
vi.mocked(api.getDeckCards).mockResolvedValue(
99
{ ok: true, json: () => Promise.resolve(mockCards) } as unknown as Response,
100
);
101
vi.mocked(api.forkDeck).mockResolvedValue({ ok: false } as unknown as Response);
102
vi.mocked(api.getComments).mockResolvedValue({ ok: true, json: () => Promise.resolve([]) } as unknown as Response);
103
···
105
106
await waitFor(() => expect(screen.getByText("Test Deck")).toBeInTheDocument());
107
108
-
const forkButton = screen.getByText("Fork Deck", { selector: "button" });
109
fireEvent.click(forkButton);
110
111
-
const dialog = screen.getByRole("dialog");
112
const confirmButton = within(dialog).getByRole("button", { name: /Fork Deck/i });
113
fireEvent.click(confirmButton);
114
···
121
122
it("renders not found state when deck returns error", async () => {
123
vi.mocked(api.getDeck).mockResolvedValue({ ok: false } as unknown as Response);
124
render(() => <DeckView />);
125
await waitFor(() => expect(screen.getByText(/Deck not found/i)).toBeInTheDocument());
126
});
127
});
···
10
vi.mock(
11
"$lib/api",
12
() => ({
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
+
},
22
}),
23
);
24
···
57
vi.mocked(api.getDeckCards).mockResolvedValue(
58
{ ok: true, json: () => Promise.resolve(mockCards) } as unknown as Response,
59
);
60
+
vi.mocked(api.getDueCards).mockResolvedValue({ ok: true, json: () => Promise.resolve([]) } as unknown as Response);
61
vi.mocked(api.getComments).mockResolvedValue({ ok: true, json: () => Promise.resolve([]) } as unknown as Response);
62
63
render(() => <DeckView />);
···
75
vi.mocked(api.getDeckCards).mockResolvedValue(
76
{ ok: true, json: () => Promise.resolve(mockCards) } as unknown as Response,
77
);
78
+
vi.mocked(api.getDueCards).mockResolvedValue({ ok: true, json: () => Promise.resolve([]) } as unknown as Response);
79
vi.mocked(api.forkDeck).mockResolvedValue(
80
{ ok: true, json: () => Promise.resolve({ id: "456" }) } as unknown as Response,
81
);
···
85
86
await waitFor(() => expect(screen.getByText("Test Deck")).toBeInTheDocument());
87
88
+
const forkButton = screen.getByRole("button", { name: /Fork Deck/i });
89
fireEvent.click(forkButton);
90
91
+
const dialog = await screen.findByRole("dialog");
92
expect(within(dialog).getByText(/Are you sure you want to fork/)).toBeInTheDocument();
93
94
const confirmButton = within(dialog).getByRole("button", { name: /Fork Deck/i });
···
108
vi.mocked(api.getDeckCards).mockResolvedValue(
109
{ ok: true, json: () => Promise.resolve(mockCards) } as unknown as Response,
110
);
111
+
vi.mocked(api.getDueCards).mockResolvedValue({ ok: true, json: () => Promise.resolve([]) } as unknown as Response);
112
vi.mocked(api.forkDeck).mockResolvedValue({ ok: false } as unknown as Response);
113
vi.mocked(api.getComments).mockResolvedValue({ ok: true, json: () => Promise.resolve([]) } as unknown as Response);
114
···
116
117
await waitFor(() => expect(screen.getByText("Test Deck")).toBeInTheDocument());
118
119
+
const forkButton = screen.getByRole("button", { name: /Fork Deck/i });
120
fireEvent.click(forkButton);
121
122
+
const dialog = await screen.findByRole("dialog");
123
const confirmButton = within(dialog).getByRole("button", { name: /Fork Deck/i });
124
fireEvent.click(confirmButton);
125
···
132
133
it("renders not found state when deck returns error", async () => {
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);
136
render(() => <DeckView />);
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();
196
});
197
});