+17
crates/core/src/model.rs
+17
crates/core/src/model.rs
···
55
55
pub published_at: Option<String>,
56
56
pub fork_of: Option<String>,
57
57
}
58
+
59
+
#[derive(Debug, Clone, Serialize, Deserialize)]
60
+
pub struct Comment {
61
+
pub id: String,
62
+
pub deck_id: String,
63
+
pub author_did: String,
64
+
pub content: String,
65
+
pub parent_id: Option<String>,
66
+
pub created_at: String,
67
+
}
68
+
69
+
#[derive(Debug, Clone, Serialize, Deserialize)]
70
+
pub struct Follow {
71
+
pub follower_did: String,
72
+
pub subject_did: String,
73
+
pub created_at: String,
74
+
}
+170
-2
crates/server/src/api/deck.rs
+170
-2
crates/server/src/api/deck.rs
···
182
182
State(state): State<SharedState>, ctx: Option<axum::Extension<UserContext>>, Path(id): Path<String>,
183
183
) -> impl IntoResponse {
184
184
let user_did = ctx.map(|Extension(u)| u.did);
185
-
186
185
let pool = &state.pool;
187
186
let client = match pool.get().await {
188
187
Ok(client) => client,
···
428
427
}
429
428
}
430
429
} else {
431
-
// Unpublish - just update local visibility
432
430
let (new_visibility, published_at) = (
433
431
serde_json::to_value(&Visibility::Private).unwrap(),
434
432
None::<chrono::DateTime<chrono::Utc>>,
···
461
459
Json(deck).into_response()
462
460
}
463
461
}
462
+
463
+
pub async fn fork_deck(
464
+
State(state): State<SharedState>, ctx: Option<axum::Extension<UserContext>>, Path(id): Path<String>,
465
+
) -> impl IntoResponse {
466
+
let user = match ctx {
467
+
Some(axum::Extension(user)) => user,
468
+
None => return (StatusCode::UNAUTHORIZED, Json(json!({"error": "Unauthorized"}))).into_response(),
469
+
};
470
+
471
+
let pool = &state.pool;
472
+
let mut client = match pool.get().await {
473
+
Ok(client) => client,
474
+
Err(e) => {
475
+
tracing::error!("Failed to get database connection: {}", e);
476
+
return (
477
+
StatusCode::INTERNAL_SERVER_ERROR,
478
+
Json(json!({"error": "Database connection failed"})),
479
+
)
480
+
.into_response();
481
+
}
482
+
};
483
+
484
+
let original_deck_uuid = match uuid::Uuid::parse_str(&id) {
485
+
Ok(uuid) => uuid,
486
+
Err(_) => return (StatusCode::BAD_REQUEST, Json(json!({"error": "Invalid deck ID"}))).into_response(),
487
+
};
488
+
489
+
let transaction = match client.transaction().await {
490
+
Ok(tx) => tx,
491
+
Err(e) => {
492
+
tracing::error!("Failed to start transaction: {}", e);
493
+
return (
494
+
StatusCode::INTERNAL_SERVER_ERROR,
495
+
Json(json!({"error": "Database error"})),
496
+
)
497
+
.into_response();
498
+
}
499
+
};
500
+
501
+
let original_deck_row = match transaction
502
+
.query_opt(
503
+
"SELECT owner_did, title, description, tags, visibility FROM decks WHERE id = $1",
504
+
&[&original_deck_uuid],
505
+
)
506
+
.await
507
+
{
508
+
Ok(Some(row)) => row,
509
+
Ok(None) => return (StatusCode::NOT_FOUND, Json(json!({"error": "Deck not found"}))).into_response(),
510
+
Err(e) => {
511
+
tracing::error!("Failed to query deck: {}", e);
512
+
return (
513
+
StatusCode::INTERNAL_SERVER_ERROR,
514
+
Json(json!({"error": "Failed to retrieve deck"})),
515
+
)
516
+
.into_response();
517
+
}
518
+
};
519
+
520
+
let visibility_json: serde_json::Value = original_deck_row.get("visibility");
521
+
let visibility: Visibility = serde_json::from_value(visibility_json).unwrap_or(Visibility::Private);
522
+
523
+
let can_fork = match visibility {
524
+
Visibility::Public | Visibility::Unlisted => true,
525
+
Visibility::SharedWith(dids) => dids.contains(&user.did),
526
+
Visibility::Private => {
527
+
let owner: String = original_deck_row.get("owner_did");
528
+
owner == user.did
529
+
}
530
+
};
531
+
532
+
if !can_fork {
533
+
return (
534
+
StatusCode::FORBIDDEN,
535
+
Json(json!({"error": "Cannot fork private deck"})),
536
+
)
537
+
.into_response();
538
+
}
539
+
540
+
let new_deck_id = uuid::Uuid::new_v4();
541
+
let title: String = original_deck_row.get("title");
542
+
let description: String = original_deck_row.get("description");
543
+
let tags: Vec<String> = original_deck_row.get("tags");
544
+
545
+
if let Err(e) = transaction
546
+
.execute(
547
+
"INSERT INTO decks (id, owner_did, title, description, tags, visibility, fork_of)
548
+
VALUES ($1, $2, $3, $4, $5, $6, $7)",
549
+
&[
550
+
&new_deck_id,
551
+
&user.did,
552
+
&format!("Fork of {}", title),
553
+
&description,
554
+
&tags,
555
+
&serde_json::to_value(&Visibility::Private).unwrap(),
556
+
&original_deck_uuid,
557
+
],
558
+
)
559
+
.await
560
+
{
561
+
tracing::error!("Failed to create forked deck: {}", e);
562
+
return (
563
+
StatusCode::INTERNAL_SERVER_ERROR,
564
+
Json(json!({"error": "Failed to create deck"})),
565
+
)
566
+
.into_response();
567
+
}
568
+
569
+
let original_cards = match transaction
570
+
.query(
571
+
"SELECT front, back, media_url FROM cards WHERE deck_id = $1",
572
+
&[&original_deck_uuid],
573
+
)
574
+
.await
575
+
{
576
+
Ok(rows) => rows,
577
+
Err(e) => {
578
+
tracing::error!("Failed to fetch original cards: {}", e);
579
+
return (
580
+
StatusCode::INTERNAL_SERVER_ERROR,
581
+
Json(json!({"error": "Failed to fetch cards"})),
582
+
)
583
+
.into_response();
584
+
}
585
+
};
586
+
587
+
for row in original_cards {
588
+
let card_id = uuid::Uuid::new_v4();
589
+
let front: String = row.get("front");
590
+
let back: String = row.get("back");
591
+
let media_url: Option<String> = row.get("media_url");
592
+
593
+
if let Err(e) = transaction
594
+
.execute(
595
+
"INSERT INTO cards (id, owner_did, deck_id, front, back, media_url)
596
+
VALUES ($1, $2, $3, $4, $5, $6)",
597
+
&[&card_id, &user.did, &new_deck_id, &front, &back, &media_url],
598
+
)
599
+
.await
600
+
{
601
+
tracing::error!("Failed to fork card: {}", e);
602
+
return (
603
+
StatusCode::INTERNAL_SERVER_ERROR,
604
+
Json(json!({"error": "Failed to fork cards"})),
605
+
)
606
+
.into_response();
607
+
}
608
+
}
609
+
610
+
if let Err(e) = transaction.commit().await {
611
+
tracing::error!("Failed to commit transaction: {}", e);
612
+
return (
613
+
StatusCode::INTERNAL_SERVER_ERROR,
614
+
Json(json!({"error": "Transaction failed"})),
615
+
)
616
+
.into_response();
617
+
}
618
+
619
+
let deck = Deck {
620
+
id: new_deck_id.to_string(),
621
+
owner_did: user.did,
622
+
title: format!("Fork of {}", title),
623
+
description,
624
+
tags,
625
+
visibility: Visibility::Private,
626
+
published_at: None,
627
+
fork_of: Some(id),
628
+
};
629
+
630
+
(StatusCode::CREATED, Json(deck)).into_response()
631
+
}
+87
crates/server/src/api/feed.rs
+87
crates/server/src/api/feed.rs
···
1
+
use crate::middleware::auth::UserContext;
2
+
use crate::state::SharedState;
3
+
4
+
use axum::{
5
+
Json,
6
+
extract::{Extension, State},
7
+
http::StatusCode,
8
+
response::IntoResponse,
9
+
};
10
+
use serde_json::json;
11
+
12
+
pub async fn get_feed_follows(
13
+
State(state): State<SharedState>, ctx: Option<Extension<UserContext>>,
14
+
) -> impl IntoResponse {
15
+
let user = match ctx {
16
+
Some(Extension(user)) => user,
17
+
None => return (StatusCode::UNAUTHORIZED, Json(json!({"error": "Unauthorized"}))).into_response(),
18
+
};
19
+
20
+
match state.social_repo.get_feed_follows(&user.did).await {
21
+
Ok(decks) => Json(decks).into_response(),
22
+
Err(e) => {
23
+
tracing::error!("Failed to get feed: {:?}", e);
24
+
(
25
+
StatusCode::INTERNAL_SERVER_ERROR,
26
+
Json(json!({"error": "Failed to retrieve feed"})),
27
+
)
28
+
.into_response()
29
+
}
30
+
}
31
+
}
32
+
33
+
pub async fn get_feed_trending(State(state): State<SharedState>) -> impl IntoResponse {
34
+
match state.social_repo.get_feed_trending().await {
35
+
Ok(decks) => Json(decks).into_response(),
36
+
Err(e) => {
37
+
tracing::error!("Failed to get trending: {:?}", e);
38
+
(
39
+
StatusCode::INTERNAL_SERVER_ERROR,
40
+
Json(json!({"error": "Failed to retrieve trending feed"})),
41
+
)
42
+
.into_response()
43
+
}
44
+
}
45
+
}
46
+
47
+
#[cfg(test)]
48
+
mod tests {
49
+
use super::*;
50
+
use crate::repository::card::mock::MockCardRepository;
51
+
use crate::repository::note::mock::MockNoteRepository;
52
+
use crate::repository::oauth::mock::MockOAuthRepository;
53
+
use crate::repository::review::mock::MockReviewRepository;
54
+
use crate::repository::social::{SocialRepository, mock::MockSocialRepository};
55
+
use crate::state::AppState;
56
+
use std::sync::Arc;
57
+
58
+
fn create_test_state_with_social(social_repo: Arc<dyn SocialRepository>) -> SharedState {
59
+
let pool = crate::db::create_mock_pool();
60
+
let card_repo = Arc::new(MockCardRepository::new()) as Arc<dyn crate::repository::card::CardRepository>;
61
+
let note_repo = Arc::new(MockNoteRepository::new()) as Arc<dyn crate::repository::note::NoteRepository>;
62
+
let oauth_repo = Arc::new(MockOAuthRepository::new()) as Arc<dyn crate::repository::oauth::OAuthRepository>;
63
+
let review_repo = Arc::new(MockReviewRepository::new()) as Arc<dyn crate::repository::review::ReviewRepository>;
64
+
65
+
Arc::new(AppState { pool, card_repo, note_repo, oauth_repo, review_repo, social_repo })
66
+
}
67
+
68
+
#[tokio::test]
69
+
async fn test_get_feed_follows_success() {
70
+
let social_repo = Arc::new(MockSocialRepository::new());
71
+
let state = create_test_state_with_social(social_repo);
72
+
let user = UserContext { did: "did:plc:test".to_string(), handle: "test.handle".to_string() };
73
+
let response = get_feed_follows(State(state), Some(Extension(user)))
74
+
.await
75
+
.into_response();
76
+
77
+
assert_eq!(response.status(), StatusCode::OK);
78
+
}
79
+
80
+
#[tokio::test]
81
+
async fn test_get_feed_trending_success() {
82
+
let social_repo = Arc::new(MockSocialRepository::new());
83
+
let state = create_test_state_with_social(social_repo);
84
+
let response = get_feed_trending(State(state)).await.into_response();
85
+
assert_eq!(response.status(), StatusCode::OK);
86
+
}
87
+
}
+2
crates/server/src/api/mod.rs
+2
crates/server/src/api/mod.rs
+3
-1
crates/server/src/api/review.rs
+3
-1
crates/server/src/api/review.rs
···
147
147
let card_repo = Arc::new(MockCardRepository::new()) as Arc<dyn crate::repository::card::CardRepository>;
148
148
let note_repo = Arc::new(MockNoteRepository::new()) as Arc<dyn crate::repository::note::NoteRepository>;
149
149
let oauth_repo = Arc::new(MockOAuthRepository::new()) as Arc<dyn crate::repository::oauth::OAuthRepository>;
150
+
let social_repo = Arc::new(crate::repository::social::mock::MockSocialRepository::new())
151
+
as Arc<dyn crate::repository::social::SocialRepository>;
150
152
151
-
Arc::new(AppState { pool, card_repo, note_repo, oauth_repo, review_repo })
153
+
Arc::new(AppState { pool, card_repo, note_repo, oauth_repo, review_repo, social_repo })
152
154
}
153
155
154
156
#[tokio::test]
+9
crates/server/src/lib.rs
+9
crates/server/src/lib.rs
···
48
48
.route("/me", get(api::auth::me))
49
49
.route("/decks", post(api::deck::create_deck))
50
50
.route("/decks/{id}/publish", post(api::deck::publish_deck))
51
+
.route("/decks/{id}/fork", post(api::deck::fork_deck))
51
52
.route("/notes", post(api::note::create_note))
52
53
.route("/cards", post(api::card::create_card))
53
54
.route("/review/due", get(api::review::get_due_cards))
54
55
.route("/review/submit", post(api::review::submit_review))
55
56
.route("/review/stats", get(api::review::get_stats))
57
+
.route("/social/follow/{did}", post(api::social::follow))
58
+
.route("/social/unfollow/{did}", post(api::social::unfollow))
59
+
.route("/decks/{id}/comments", post(api::social::add_comment))
60
+
.route("/feeds/follows", get(api::feed::get_feed_follows))
56
61
.layer(axum_middleware::from_fn(middleware::auth::auth_middleware));
57
62
58
63
let optional_auth_routes = Router::new()
···
61
66
.route("/decks/{id}/cards", get(api::card::list_cards))
62
67
.route("/notes", get(api::note::list_notes))
63
68
.route("/notes/{id}", get(api::note::get_note))
69
+
.route("/social/followers/{did}", get(api::social::get_followers))
70
+
.route("/social/following/{did}", get(api::social::get_following))
71
+
.route("/decks/{id}/comments", get(api::social::get_comments))
72
+
.route("/feeds/trending", get(api::feed::get_feed_trending))
64
73
.layer(axum_middleware::from_fn(middleware::auth::optional_auth_middleware));
65
74
66
75
let oauth_routes = Router::new()
+1
crates/server/src/repository/mod.rs
+1
crates/server/src/repository/mod.rs
+9
-4
crates/server/src/state.rs
+9
-4
crates/server/src/state.rs
···
3
3
use crate::repository::note::{DbNoteRepository, NoteRepository};
4
4
use crate::repository::oauth::{DbOAuthRepository, OAuthRepository};
5
5
use crate::repository::review::{DbReviewRepository, ReviewRepository};
6
+
use crate::repository::social::{DbSocialRepository, SocialRepository};
7
+
6
8
use std::sync::Arc;
7
9
8
10
pub type SharedState = Arc<AppState>;
···
13
15
pub note_repo: Arc<dyn NoteRepository>,
14
16
pub oauth_repo: Arc<dyn OAuthRepository>,
15
17
pub review_repo: Arc<dyn ReviewRepository>,
18
+
pub social_repo: Arc<dyn SocialRepository>,
16
19
}
17
20
18
21
impl AppState {
···
21
24
let note_repo = Arc::new(DbNoteRepository::new(pool.clone())) as Arc<dyn NoteRepository>;
22
25
let oauth_repo = Arc::new(DbOAuthRepository::new(pool.clone())) as Arc<dyn OAuthRepository>;
23
26
let review_repo = Arc::new(DbReviewRepository::new(pool.clone())) as Arc<dyn ReviewRepository>;
27
+
let social_repo = Arc::new(DbSocialRepository::new(pool.clone())) as Arc<dyn SocialRepository>;
24
28
25
-
Arc::new(Self { pool, card_repo, note_repo, oauth_repo, review_repo })
29
+
Arc::new(Self { pool, card_repo, note_repo, oauth_repo, review_repo, social_repo })
26
30
}
27
31
28
32
#[cfg(test)]
···
30
34
pool: DbPool, card_repo: Arc<dyn CardRepository>, note_repo: Arc<dyn NoteRepository>,
31
35
oauth_repo: Arc<dyn OAuthRepository>,
32
36
) -> SharedState {
33
-
let review_repo =
34
-
Arc::new(crate::repository::review::mock::MockReviewRepository::new()) as Arc<dyn ReviewRepository>;
35
-
Arc::new(Self { pool, card_repo, note_repo, oauth_repo, review_repo })
37
+
use crate::repository;
38
+
let review_repo = Arc::new(repository::review::mock::MockReviewRepository::new()) as Arc<dyn ReviewRepository>;
39
+
let social_repo = Arc::new(repository::social::mock::MockSocialRepository::new()) as Arc<dyn SocialRepository>;
40
+
Arc::new(Self { pool, card_repo, note_repo, oauth_repo, review_repo, social_repo })
36
41
}
37
42
}
+111
-1
docs/core-user-journeys.md
+111
-1
docs/core-user-journeys.md
···
1
1
# Core User Journeys
2
2
3
-
This document outlines the five core user journeys for the initial product version.
3
+
This document outlines the core user journeys and detailed user flows for Malfestio.
4
4
5
5
## 1. Import Source & Publish Deck
6
6
7
7
**Goal**: A creator builds a study deck from an external resource and shares it.
8
+
9
+
### High-Level Workflow
8
10
9
11
1. **Import**: User inputs a URL (Article) or pastes text.
10
12
2. **Generate**: System extracts metadata (and optionally snapshots content).
···
16
18
5. **Publish**: User sets visibility (e.g., Public) and publishes the Deck.
17
19
6. **Result**: The Deck is now a shareable Artifact (ATProto record).
18
20
21
+
### Detailed Flows
22
+
23
+
#### Content Import
24
+
25
+
**Import Article**:
26
+
27
+
1. Header → "Import"
28
+
2. Enter article URL
29
+
3. Submit → article parsed, deck/note created
30
+
31
+
**Import Lecture**:
32
+
33
+
1. Import page → "Lecture Import" tab
34
+
2. Enter lecture URL
35
+
3. Submit → lecture content extracted
36
+
37
+
#### Note Management
38
+
39
+
**Create Note**:
40
+
41
+
1. Header → "Notes" → "New Note"
42
+
2. Fill: title, body (markdown), tags
43
+
3. Add wikilinks with `[[Note Title]]`
44
+
4. Set visibility
45
+
5. Submit → note created
46
+
47
+
**View Notes**:
48
+
49
+
1. Header → "Notes"
50
+
2. Browse notes with backlink navigation
51
+
52
+
#### Deck Management
53
+
54
+
**Create Deck**:
55
+
56
+
1. Library (`/`) → "Create Deck"
57
+
2. Fill: title, description, tags
58
+
3. Set visibility (Private/Unlisted/Public/SharedWith)
59
+
4. Add cards (front/back, optional hints, card type)
60
+
5. Submit → deck created, redirected to Library
61
+
62
+
**View Deck**:
63
+
64
+
1. Library → click deck card
65
+
2. View title, description, tags, card list
66
+
3. Options: Edit, Study, Back to Library
67
+
19
68
## 2. Daily Study Loop
20
69
21
70
**Goal**: A learner maintains their knowledge using Spaced Repetition (SRS).
71
+
72
+
### High-Level Workflow
22
73
23
74
1. **Session Start**: User opens the app/daily study mode.
24
75
2. **Review Queue**: System presents cards due for review based on SRS algorithm (e.g., SM-2).
···
31
82
6. **Progress**: User sees feedback (cards done, streak incremented).
32
83
* *Note: All grading/progress data is strictly private.*
33
84
85
+
### Detailed Flows
86
+
87
+
#### Daily Review
88
+
89
+
1. Navigate to `/review` or click "Review" in header
90
+
2. View study stats: due count, streak, reviewed today
91
+
3. Click "Start Study Session"
92
+
4. Card front shown → press **Space** to flip
93
+
5. View answer → grade with **1-5** keys
94
+
6. Repeat until all due cards complete
95
+
7. View completion message and updated stats
96
+
97
+
#### Deck-Specific Review
98
+
99
+
1. Navigate to deck view (`/decks/:id`)
100
+
2. Click "Study Deck"
101
+
3. Review only cards from that deck
102
+
4. Same keyboard controls apply
103
+
104
+
#### Progress Tracking
105
+
106
+
* **Due count**: Cards needing review today
107
+
108
+
* **Streak**: Consecutive days studied
109
+
* **Reviewed today**: Cards completed this session
110
+
* **Interval growth**: SM-2 algorithm increases intervals for mastered cards
111
+
112
+
#### Keyboard Shortcuts
113
+
114
+
| Key | Action |
115
+
| ----- | -------------- |
116
+
| Space | Flip card |
117
+
| 1 | Grade: Again |
118
+
| 2 | Grade: Hard |
119
+
| 3 | Grade: Good |
120
+
| 4 | Grade: Easy |
121
+
| 5 | Grade: Perfect |
122
+
| E | Quick edit |
123
+
| Esc | Exit session |
124
+
34
125
## 3. Social Collaboration (Follow/Fork)
35
126
36
127
**Goal**: A learner discovers content and improves it.
128
+
129
+
### High-Level Workflow
37
130
38
131
1. **Discovery**:
39
132
* User follows a Curator.
···
50
143
51
144
**Goal**: Community interaction while maintaining safety.
52
145
146
+
### High-Level Workflow
147
+
53
148
1. **Context**: A User is viewing a public Card or Deck.
54
149
2. **Discuss**: User adds a **Comment** (threaded) asking for clarification.
55
150
3. **Report** (Unhappy Path):
···
62
157
63
158
**Goal**: Deep study of long-form audio/video content.
64
159
160
+
### High-Level Workflow
161
+
65
162
1. **Import**: User provides a Lecture URL (e.g., YouTube/Video).
66
163
2. **Structure**:
67
164
* User creates an **Outline** of the lecture.
···
69
166
3. **Link**:
70
167
* User creates Cards specific to timestamped segments.
71
168
* Clicking context on a Card jumps video to the specific timestamp.
169
+
170
+
## Authentication
171
+
172
+
### Login
173
+
174
+
1. Navigate to `/login`
175
+
2. Enter Bluesky handle and app password
176
+
3. Submit → redirected to Library
177
+
178
+
### Logout
179
+
180
+
1. Click avatar in header → "Logout"
181
+
2. → redirected to Landing page
+2
-30
docs/todo.md
+2
-30
docs/todo.md
···
49
49
- OAuth 2.1 client flow (PKCE, DPoP, handle/DID resolution, token refresh).
50
50
- PDS client for `putRecord`, `deleteRecord`, `uploadBlob`.
51
51
- TID generation and AT-URI builder in core crate.
52
-
- Database migration for token storage and AT-URI columns.
53
52
- **(Done) Milestone E**: Internal component library/UI Foundation + Animations.
54
53
- **(Done) Milestone F**: Content Authoring (Notes + Cards + Deck Builder).
55
-
56
54
- **(Done) Milestone G**: Study Engine (SRS) + Daily Review UX.
57
55
- SM-2 spaced repetition scheduler.
58
-
- Review repository with due card queries and stats tracking.
59
-
- API endpoints: `/review/due`, `/review/submit`, `/review/stats`.
60
-
- StudySession component with keyboard-first review.
61
-
- ReviewStats component for progress display.
62
-
63
-
### Milestone H - Social Layer v1 (Follow, Feed, Fork, Comments)
64
-
65
-
#### Deliverables
66
-
67
-
- Follow graph + notifications
68
-
- Feeds:
69
-
- "New decks from follows"
70
-
- "Trending this week" (simple scoring)
71
-
- Forking workflow:
72
-
- fork deck -> edit -> republish
73
-
- Threaded comments on decks/cards
74
-
75
-
#### Acceptance
76
-
77
-
- A user can follow a curator and see new published decks in a feed.
56
+
- **(Done) Milestone H**: Social Layer v1: Follow graph, Feeds (Follows/Trending), Forking workflow, and Threaded comments.
78
57
79
58
### Milestone I - Search + Discovery + Taxonomy
80
59
···
95
74
96
75
#### Deliverables
97
76
77
+
- Look into [Ozone](https://github.com/bluesky-social/ozone)
98
78
- Reporting pipeline + review queue
99
79
- Rate limits + spam heuristics
100
80
- Takedown/visibility states (shadowed, removed, quarantined)
···
132
112
- Backups + restore drills
133
113
- Load test targets (study session + feed + search)
134
114
- Beta program + feedback loop + roadmap iteration
135
-
136
-
#### Acceptance
137
-
138
-
- You can run this as a real product with confidence.
139
-
140
-
## Lexicon Definitions
141
-
142
-
Authoritative Lexicon definitions are located in the [`lexicons/`](../lexicons) directory.
143
115
144
116
## Open Questions (Parked Decisions)
145
117
-107
docs/user-flows.md
-107
docs/user-flows.md
···
1
-
# User Flows
2
-
3
-
User experience pathways for Malfestio.
4
-
5
-
## Authentication
6
-
7
-
### Login
8
-
9
-
1. Navigate to `/login`
10
-
2. Enter Bluesky handle and app password
11
-
3. Submit → redirected to Library
12
-
13
-
### Logout
14
-
15
-
1. Click avatar in header → "Logout"
16
-
2. → redirected to Landing page
17
-
18
-
## Deck Management
19
-
20
-
### Create Deck
21
-
22
-
1. Library (`/`) → "Create Deck"
23
-
2. Fill: title, description, tags
24
-
3. Set visibility (Private/Unlisted/Public/SharedWith)
25
-
4. Add cards (front/back, optional hints, card type)
26
-
5. Submit → deck created, redirected to Library
27
-
28
-
### View Deck
29
-
30
-
1. Library → click deck card
31
-
2. View title, description, tags, card list
32
-
3. Options: Edit, Study, Back to Library
33
-
34
-
### Study Deck
35
-
36
-
1. Deck View → "Study Deck"
37
-
2. Study session with keyboard controls
38
-
3. Grade cards (1-5), view progress
39
-
4. Session complete → return to deck
40
-
41
-
## Note Management
42
-
43
-
### Create Note
44
-
45
-
1. Header → "Notes" → "New Note"
46
-
2. Fill: title, body (markdown), tags
47
-
3. Add wikilinks with `[[Note Title]]`
48
-
4. Set visibility
49
-
5. Submit → note created
50
-
51
-
### View Notes
52
-
53
-
1. Header → "Notes"
54
-
2. Browse notes with backlink navigation
55
-
56
-
## Content Import
57
-
58
-
### Import Article
59
-
60
-
1. Header → "Import"
61
-
2. Enter article URL
62
-
3. Submit → article parsed, deck/note created
63
-
64
-
### Import Lecture
65
-
66
-
1. Import page → "Lecture Import" tab
67
-
2. Enter lecture URL
68
-
3. Submit → lecture content extracted
69
-
70
-
## Study Session
71
-
72
-
### Daily Review
73
-
74
-
1. Navigate to `/review` or click "Review" in header
75
-
2. View study stats: due count, streak, reviewed today
76
-
3. Click "Start Study Session"
77
-
4. Card front shown → press **Space** to flip
78
-
5. View answer → grade with **1-5** keys
79
-
6. Repeat until all due cards complete
80
-
7. View completion message and updated stats
81
-
82
-
### Deck-Specific Review
83
-
84
-
1. Navigate to deck view (`/decks/:id`)
85
-
2. Click "Study Deck"
86
-
3. Review only cards from that deck
87
-
4. Same keyboard controls apply
88
-
89
-
### Progress Tracking
90
-
91
-
- **Due count**: Cards needing review today
92
-
- **Streak**: Consecutive days studied
93
-
- **Reviewed today**: Cards completed this session
94
-
- **Interval growth**: SM-2 algorithm increases intervals for mastered cards
95
-
96
-
### Keyboard Shortcuts
97
-
98
-
| Key | Action |
99
-
| ----- | -------------- |
100
-
| Space | Flip card |
101
-
| 1 | Grade: Again |
102
-
| 2 | Grade: Hard |
103
-
| 3 | Grade: Good |
104
-
| 4 | Grade: Easy |
105
-
| 5 | Grade: Perfect |
106
-
| E | Quick edit |
107
-
| Esc | Exit session |
+2
web/src/App.tsx
+2
web/src/App.tsx
···
2
2
import { authStore } from "$lib/store";
3
3
import DeckNew from "$pages/DeckNew";
4
4
import DeckView from "$pages/DeckView";
5
+
import Feed from "$pages/Feed";
5
6
import Home from "$pages/Home";
6
7
import Import from "$pages/Import";
7
8
import Landing from "$pages/Landing";
···
37
38
<Route path="/import/lecture" component={() => <ProtectedRoute component={LectureImport} />} />
38
39
<Route path="/review" component={() => <ProtectedRoute component={Review} />} />
39
40
<Route path="/review/:deckId" component={() => <ProtectedRoute component={Review} />} />
41
+
<Route path="/feed" component={() => <ProtectedRoute component={Feed} />} />
40
42
<Route path="*" component={() => <ProtectedRoute component={NotFound} />} />
41
43
</Router>
42
44
);
+10
web/src/lib/api.ts
+10
web/src/lib/api.ts
···
35
35
submitReview: (cardId: string, grade: number) =>
36
36
apiFetch("/review/submit", { method: "POST", body: JSON.stringify({ card_id: cardId, grade }) }),
37
37
getStats: () => apiFetch("/review/stats", { method: "GET" }),
38
+
follow: (did: string) => apiFetch(`/social/follow/${did}`, { method: "POST" }),
39
+
unfollow: (did: string) => apiFetch(`/social/unfollow/${did}`, { method: "POST" }),
40
+
getFollowers: (did: string) => apiFetch(`/social/followers/${did}`, { method: "GET" }),
41
+
getFollowing: (did: string) => apiFetch(`/social/following/${did}`, { method: "GET" }),
42
+
addComment: (deckId: string, content: string, parentId?: string) =>
43
+
apiFetch(`/decks/${deckId}/comments`, { method: "POST", body: JSON.stringify({ content, parent_id: parentId }) }),
44
+
getComments: (deckId: string) => apiFetch(`/decks/${deckId}/comments`, { method: "GET" }),
45
+
getFeedFollows: () => apiFetch("/feeds/follows", { method: "GET" }),
46
+
getFeedTrending: () => apiFetch("/feeds/trending", { method: "GET" }),
47
+
forkDeck: (deckId: string) => apiFetch(`/decks/${deckId}/fork`, { method: "POST" }),
38
48
};
+45
-2
web/src/pages/DeckView.tsx
+45
-2
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";
1
4
import { api } from "$lib/api";
2
5
import type { Visibility } from "$lib/store";
3
6
import { A, useParams } from "@solidjs/router";
···
15
18
16
19
type Card = { id: string; front: string; back?: string };
17
20
21
+
// TODO: use api.ts
18
22
const fetchDeck = async (id: string): Promise<Deck | null> => {
19
23
const res = await api.get(`/decks/${id}`);
20
24
if (!res.ok) return null;
21
25
return res.json();
22
26
};
23
27
28
+
// TODO: use api.ts
24
29
const fetchCards = async (id: string): Promise<Card[]> => {
25
30
const res = await api.get(`/decks/${id}/cards`);
26
31
if (!res.ok) return [];
···
29
34
30
35
const DeckView: Component = () => {
31
36
const params = useParams();
32
-
33
37
const [deck] = createResource(() => params.id, fetchDeck);
34
38
const [cards] = createResource(() => params.id, fetchCards);
35
39
40
+
const handleFork = async () => {
41
+
if (!deck()) return;
42
+
// TODO: use modal
43
+
if (confirm(`Fork "${deck()?.title}"?`)) {
44
+
try {
45
+
const res = await api.forkDeck(deck()!.id);
46
+
if (res.ok) {
47
+
const newDeck = await res.json();
48
+
// TODO: use toast
49
+
alert("Deck forked successfully!");
50
+
// TODO: useNavigate
51
+
// navigate(`/decks/${newDeck.id}`);
52
+
window.location.href = `/decks/${newDeck.id}`;
53
+
} else {
54
+
// TODO: use toast
55
+
alert("Failed to fork deck.");
56
+
}
57
+
} catch (e) {
58
+
console.error(e);
59
+
// TODO: use toast
60
+
alert("Error forking deck.");
61
+
}
62
+
}
63
+
};
64
+
36
65
return (
37
66
<div class="max-w-4xl mx-auto px-6 py-12">
38
67
<Show when={deck.loading}>
···
54
83
{deck()?.visibility.type}
55
84
</span>
56
85
</Show>
86
+
</div>
87
+
88
+
<div class="flex items-center gap-4 mb-6">
89
+
<div class="text-[#C6C6C6] font-light">By {deck()?.owner_did}</div>
90
+
<FollowButton did={deck()?.owner_did || ""} />
57
91
</div>
58
92
59
93
<p class="text-[#C6C6C6] mb-6 font-light">{deck()?.description}</p>
···
67
101
</div>
68
102
69
103
<div class="flex gap-4 border-t border-[#393939] pt-6">
70
-
{/* Placeholder for Study Action */}
71
104
<button class="bg-[#0F62FE] hover:bg-[#0353E9] text-white px-6 py-3 font-medium text-sm transition-colors">
72
105
Study Deck (Coming Soon)
73
106
</button>
107
+
<Button
108
+
onClick={handleFork}
109
+
variant="secondary"
110
+
class="border border-[#393939] text-[#F4F4F4] hover:bg-[#262626] px-6 py-3 font-medium text-sm transition-colors">
111
+
Fork Deck
112
+
</Button>
74
113
<A
75
114
href="/"
76
115
class="px-6 py-3 border border-[#393939] text-[#F4F4F4] hover:bg-[#262626] font-medium text-sm transition-colors">
···
115
154
</div>
116
155
</Show>
117
156
</div>
157
+
</div>
158
+
159
+
<div class="mt-12 pt-8 border-t border-[#393939]">
160
+
<CommentSection deckId={deck()!.id} />
118
161
</div>
119
162
</Show>
120
163
</div>
+89
web/src/pages/Feed.tsx
+89
web/src/pages/Feed.tsx
···
1
+
import { FollowButton } from "$components/social/FollowButton";
2
+
import { Button } from "$components/ui/Button";
3
+
import { Card } from "$components/ui/Card";
4
+
import { Tabs } from "$components/ui/Tabs";
5
+
import { api } from "$lib/api";
6
+
import { A } from "@solidjs/router";
7
+
import { createResource, For, Match, Show, Switch } from "solid-js";
8
+
9
+
type Deck = { id: string; title: string; description: string; owner_did: string; published_at: string; tags: string[] };
10
+
11
+
export default function Feed() {
12
+
const [followsFeed] = createResource(async () => {
13
+
const res = await api.getFeedFollows();
14
+
return res.ok ? (await res.json() as Deck[]) : [];
15
+
});
16
+
17
+
const [valuableFeed] = createResource(async () => {
18
+
const res = await api.getFeedTrending();
19
+
return res.ok ? (await res.json() as Deck[]) : [];
20
+
});
21
+
22
+
const DeckItem = (props: { deck: Deck }) => (
23
+
<Card class="mb-4">
24
+
<div class="flex justify-between items-start">
25
+
<div>
26
+
<h3 class="text-xl font-bold mb-1">{props.deck.title}</h3>
27
+
<p class="text-sm text-gray-400 mb-2">
28
+
By {props.deck.owner_did} • {new Date(props.deck.published_at).toLocaleDateString()}
29
+
</p>
30
+
<p class="mb-3">{props.deck.description}</p>
31
+
<div class="flex gap-2 mb-3">
32
+
<For each={props.deck.tags}>
33
+
{(tag) => <span class="bg-gray-800 px-2 py-1 rounded text-xs">{tag}</span>}
34
+
</For>
35
+
</div>
36
+
</div>
37
+
<div class="ml-4">
38
+
<FollowButton did={props.deck.owner_did} />
39
+
</div>
40
+
</div>
41
+
<div class="flex gap-2 items-center mt-2">
42
+
<A href={`/decks/${props.deck.id}`} class="no-underline">
43
+
<Button variant="secondary" size="sm">View</Button>
44
+
</A>
45
+
<Button
46
+
variant="ghost"
47
+
size="sm"
48
+
onClick={() => {
49
+
// TODO: use modal or toast
50
+
if (confirm("Fork this deck?")) {
51
+
api.forkDeck(props.deck.id).then(() => alert("Forked successfully!"));
52
+
}
53
+
}}>
54
+
Fork
55
+
</Button>
56
+
</div>
57
+
</Card>
58
+
);
59
+
60
+
return (
61
+
<div class="container mx-auto p-4 max-w-3xl">
62
+
<h1 class="text-3xl font-bold mb-6">Discovery</h1>
63
+
<Tabs tabs={[{ id: "following", label: "Following" }, { id: "trending", label: "Trending" }]}>
64
+
{(activeTab) => (
65
+
<Switch>
66
+
<Match when={activeTab() === "following"}>
67
+
<div class="mt-4">
68
+
<Show when={followsFeed()}>
69
+
{feed => (
70
+
<Show
71
+
when={feed().length > 0}
72
+
fallback={<div class="text-gray-500 py-8 text-center">No updates from followed users.</div>}>
73
+
<For each={feed()}>{(deck) => <DeckItem deck={deck} />}</For>
74
+
</Show>
75
+
)}
76
+
</Show>
77
+
</div>
78
+
</Match>
79
+
<Match when={activeTab() === "trending"}>
80
+
<div class="mt-4">
81
+
<For each={valuableFeed()}>{(deck) => <DeckItem deck={deck} />}</For>
82
+
</div>
83
+
</Match>
84
+
</Switch>
85
+
)}
86
+
</Tabs>
87
+
</div>
88
+
);
89
+
}