+39
-68
web/src/components/ReviewStats.tsx
+39
-68
web/src/components/ReviewStats.tsx
···
1
1
import { fadeIn } from "$lib/animations";
2
-
import { api } from "$lib/api";
3
-
import type { ReviewCard, StudyStats } from "$lib/model";
2
+
import type { StudyStats } from "$lib/model";
4
3
import { Skeleton } from "$ui/Skeleton";
5
4
import { type Component, Show } from "solid-js";
6
5
import { Motion } from "solid-motionone";
7
6
8
7
type ReviewStatsProps = { stats: StudyStats | null; loading?: boolean };
9
8
10
-
export const ReviewStats: Component<ReviewStatsProps> = (props) => {
11
-
return (
12
-
<Motion.div {...fadeIn} class="bg-gray-900 rounded-xl p-6 border border-gray-800">
13
-
<Show
14
-
when={!props.loading}
15
-
fallback={
9
+
export const ReviewStats: Component<ReviewStatsProps> = (props) => (
10
+
<Motion.div {...fadeIn} class="bg-gray-900 rounded-xl p-6 border border-gray-800">
11
+
<Show
12
+
when={!props.loading}
13
+
fallback={
14
+
<div class="space-y-4">
15
+
<Skeleton class="h-6 w-32" />
16
+
<Skeleton class="h-4 w-48" />
17
+
<Skeleton class="h-4 w-40" />
18
+
</div>
19
+
}>
20
+
<Show when={props.stats} fallback={<p class="text-gray-400">No stats available</p>}>
21
+
{stats => (
16
22
<div class="space-y-4">
17
-
<Skeleton class="h-6 w-32" />
18
-
<Skeleton class="h-4 w-48" />
19
-
<Skeleton class="h-4 w-40" />
20
-
</div>
21
-
}>
22
-
<Show when={props.stats} fallback={<p class="text-gray-400">No stats available</p>}>
23
-
{stats => (
24
-
<div class="space-y-4">
25
-
<div class="flex items-center justify-between">
26
-
<h3 class="text-lg font-semibold text-white">Study Progress</h3>
27
-
{/* TODO: fire icon */}
28
-
<span class="text-2xl">🔥 {stats().current_streak} day streak</span>
23
+
<div class="flex items-center justify-between">
24
+
<h3 class="text-lg font-semibold text-white">Study Progress</h3>
25
+
{/* TODO: fire icon */}
26
+
<span class="text-2xl">🔥 {stats().current_streak} day streak</span>
27
+
</div>
28
+
29
+
<div class="grid grid-cols-3 gap-4 text-center">
30
+
<div class="bg-gray-800 rounded-lg p-4">
31
+
<p class="text-3xl font-bold text-blue-400">{stats().due_count}</p>
32
+
<p class="text-sm text-gray-400">Due Today</p>
33
+
</div>
34
+
<div class="bg-gray-800 rounded-lg p-4">
35
+
<p class="text-3xl font-bold text-green-400">{stats().reviewed_today}</p>
36
+
<p class="text-sm text-gray-400">Reviewed</p>
29
37
</div>
30
-
31
-
<div class="grid grid-cols-3 gap-4 text-center">
32
-
<div class="bg-gray-800 rounded-lg p-4">
33
-
<p class="text-3xl font-bold text-blue-400">{stats().due_count}</p>
34
-
<p class="text-sm text-gray-400">Due Today</p>
35
-
</div>
36
-
<div class="bg-gray-800 rounded-lg p-4">
37
-
<p class="text-3xl font-bold text-green-400">{stats().reviewed_today}</p>
38
-
<p class="text-sm text-gray-400">Reviewed</p>
39
-
</div>
40
-
<div class="bg-gray-800 rounded-lg p-4">
41
-
<p class="text-3xl font-bold text-purple-400">{stats().total_reviews}</p>
42
-
<p class="text-sm text-gray-400">Total</p>
43
-
</div>
38
+
<div class="bg-gray-800 rounded-lg p-4">
39
+
<p class="text-3xl font-bold text-purple-400">{stats().total_reviews}</p>
40
+
<p class="text-sm text-gray-400">Total</p>
44
41
</div>
45
-
<Show when={stats().longest_streak > 0}>
46
-
<p class="text-sm text-gray-500 text-center">Longest streak: {stats().longest_streak} days</p>
47
-
</Show>
48
42
</div>
49
-
)}
50
-
</Show>
43
+
<Show when={stats().longest_streak > 0}>
44
+
<p class="text-sm text-gray-500 text-center">Longest streak: {stats().longest_streak} days</p>
45
+
</Show>
46
+
</div>
47
+
)}
51
48
</Show>
52
-
</Motion.div>
53
-
);
54
-
};
55
-
56
-
// TODO: move this to api.ts
57
-
export async function fetchStudyStats(): Promise<StudyStats | null> {
58
-
try {
59
-
const response = await api.getStats();
60
-
if (response.ok) {
61
-
return response.json();
62
-
}
63
-
} catch (err) {
64
-
console.error("Failed to fetch stats:", err);
65
-
}
66
-
return null;
67
-
}
68
-
69
-
// TODO: move this to api.ts
70
-
export async function fetchDueCards(deckId?: string): Promise<ReviewCard[]> {
71
-
try {
72
-
const response = await api.getDueCards(deckId);
73
-
if (response.ok) {
74
-
return response.json();
75
-
}
76
-
} catch (err) {
77
-
console.error("Failed to fetch due cards:", err);
78
-
}
79
-
return [];
80
-
}
49
+
</Show>
50
+
</Motion.div>
51
+
);
+30
-4
web/src/lib/api.ts
+30
-4
web/src/lib/api.ts
···
1
+
import type { CreateDeckPayload } from "./model";
1
2
import { authStore } from "./store";
2
3
3
4
const API_BASE = "/api";
···
32
33
if (deckId) params.set("deck_id", deckId);
33
34
return apiFetch(`/review/due?${params}`, { method: "GET" });
34
35
},
35
-
submitReview: (cardId: string, grade: number) =>
36
-
apiFetch("/review/submit", { method: "POST", body: JSON.stringify({ card_id: cardId, grade }) }),
36
+
submitReview: (cardId: string, grade: number) => {
37
+
return apiFetch("/review/submit", { method: "POST", body: JSON.stringify({ card_id: cardId, grade }) });
38
+
},
37
39
getStats: () => apiFetch("/review/stats", { method: "GET" }),
38
40
follow: (did: string) => apiFetch(`/social/follow/${did}`, { method: "POST" }),
39
41
unfollow: (did: string) => apiFetch(`/social/unfollow/${did}`, { method: "POST" }),
40
42
getFollowers: (did: string) => apiFetch(`/social/followers/${did}`, { method: "GET" }),
41
43
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
+
addComment: (deckId: string, content: string, parentId?: string) => {
45
+
return apiFetch(`/decks/${deckId}/comments`, {
46
+
method: "POST",
47
+
body: JSON.stringify({ content, parent_id: parentId }),
48
+
});
49
+
},
44
50
getComments: (deckId: string) => apiFetch(`/decks/${deckId}/comments`, { method: "GET" }),
45
51
getFeedFollows: () => apiFetch("/feeds/follows", { method: "GET" }),
46
52
getFeedTrending: () => apiFetch("/feeds/trending", { method: "GET" }),
47
53
forkDeck: (deckId: string) => apiFetch(`/decks/${deckId}/fork`, { method: "POST" }),
54
+
getDecks: () => apiFetch("/decks", { method: "GET" }),
55
+
getDeck: (id: string) => apiFetch(`/decks/${id}`, { method: "GET" }),
56
+
getDeckCards: (id: string) => apiFetch(`/decks/${id}/cards`, { method: "GET" }),
57
+
createDeck: async (payload: CreateDeckPayload) => {
58
+
const { cards, ...deckPayload } = payload;
59
+
const res = await apiFetch("/decks", { method: "POST", body: JSON.stringify(deckPayload) });
60
+
if (!res.ok) return res;
61
+
62
+
const deck = await res.json();
63
+
if (cards && cards.length > 0) {
64
+
await Promise.all(cards.map((c) =>
65
+
apiFetch("/cards", {
66
+
method: "POST",
67
+
body: JSON.stringify({ deck_id: deck.id, front: c.front, back: c.back, media_url: c.mediaUrl }),
68
+
})
69
+
));
70
+
}
71
+
72
+
return { ok: true, json: async () => deck };
73
+
},
48
74
};
+11
web/src/lib/model.ts
+11
web/src/lib/model.ts
···
58
58
};
59
59
60
60
export type ReviewResponse = { ease_factor: number; interval_days: number; repetitions: number; due_at: string };
61
+
62
+
export type Comment = {
63
+
id: string;
64
+
deck_id: string;
65
+
author_did: string;
66
+
content: string;
67
+
parent_id: string | null;
68
+
created_at: string;
69
+
};
70
+
71
+
export type CommentNode = { comment: Comment; children: CommentNode[] };
+2
-14
web/src/pages/DeckNew.tsx
+2
-14
web/src/pages/DeckNew.tsx
···
1
1
import { DeckEditor } from "$components/DeckEditor";
2
2
import { api } from "$lib/api";
3
-
import type { Card, CreateDeckPayload } from "$lib/model";
3
+
import type { CreateDeckPayload } from "$lib/model";
4
4
import { toast } from "$lib/toast";
5
5
import { useNavigate } from "@solidjs/router";
6
6
import type { Component } from "solid-js";
···
9
9
const navigate = useNavigate();
10
10
11
11
const handleSave = async (data: CreateDeckPayload) => {
12
-
// TODO: some of this can be in api.ts
13
12
try {
14
-
const { cards, ...deckPayload } = data;
15
-
const res = await api.post("/decks", deckPayload);
16
-
13
+
const res = await api.createDeck(data);
17
14
if (res.ok) {
18
15
const deck = await res.json();
19
-
20
-
if (cards && cards.length > 0) {
21
-
await Promise.all(
22
-
cards.map((c: Card) =>
23
-
api.post("/cards", { deck_id: deck.id, front: c.front, back: c.back, media_url: c.mediaUrl })
24
-
),
25
-
);
26
-
}
27
-
28
16
toast.success("Deck created successfully");
29
17
navigate(`/decks/${deck.id}`);
30
18
} else {
+8
-16
web/src/pages/DeckView.tsx
+8
-16
web/src/pages/DeckView.tsx
···
7
7
import type { Component } from "solid-js";
8
8
import { createResource, For, Show } from "solid-js";
9
9
10
-
// TODO: use api.ts
11
-
const fetchDeck = async (id: string): Promise<Deck | null> => {
12
-
const res = await api.get(`/decks/${id}`);
13
-
if (!res.ok) return null;
14
-
return res.json();
15
-
};
16
-
17
-
// TODO: use api.ts
18
-
const fetchCards = async (id: string): Promise<Card[]> => {
19
-
const res = await api.get(`/decks/${id}/cards`);
20
-
if (!res.ok) return [];
21
-
return res.json();
22
-
};
23
-
24
10
const DeckView: Component = () => {
25
11
const params = useParams();
26
-
const [deck] = createResource(() => params.id, fetchDeck);
27
-
const [cards] = createResource(() => params.id, fetchCards);
12
+
const [deck] = createResource(() => params.id, async (id) => {
13
+
const res = await api.getDeck(id);
14
+
return res.ok ? (await res.json() as Deck) : null;
15
+
});
16
+
const [cards] = createResource(() => params.id, async (id) => {
17
+
const res = await api.getDeckCards(id);
18
+
return res.ok ? (await res.json() as Card[]) : [];
19
+
});
28
20
29
21
const handleFork = async () => {
30
22
if (!deck()) return;
+4
-8
web/src/pages/Home.tsx
+4
-8
web/src/pages/Home.tsx
···
4
4
import type { Component } from "solid-js";
5
5
import { createResource, For, Show } from "solid-js";
6
6
7
-
// TODO: use api.ts
8
-
const fetchDecks = async (): Promise<Deck[]> => {
9
-
const res = await api.get("/decks");
10
-
if (!res.ok) return [];
11
-
return res.json();
12
-
};
13
-
14
7
const DeckCard: Component<{ deck: Deck }> = (props) => (
15
8
<div class="bg-[#262626] border border-[#393939] p-4 hover:border-[#0F62FE] transition-colors group relative h-full flex flex-col">
16
9
<div class="flex justify-between items-start mb-2">
···
42
35
);
43
36
44
37
const Home: Component = () => {
45
-
const [decks] = createResource(fetchDecks);
38
+
const [decks] = createResource(async () => {
39
+
const res = await api.getDecks();
40
+
return res.ok ? (await res.json() as Deck[]) : [];
41
+
});
46
42
47
43
return (
48
44
<div class="max-w-7xl mx-auto px-0 py-8">
+7
-6
web/src/pages/Review.tsx
+7
-6
web/src/pages/Review.tsx
···
1
-
import { fetchDueCards, fetchStudyStats, ReviewStats } from "$components/ReviewStats";
1
+
import { ReviewStats } from "$components/ReviewStats";
2
2
import { StudySession } from "$components/StudySession";
3
3
import { fadeIn } from "$lib/animations";
4
+
import { api } from "$lib/api";
4
5
import type { ReviewCard, StudyStats as StudyStatsType } from "$lib/model";
5
6
import { Button } from "$ui/Button";
6
7
import { Skeleton } from "$ui/Skeleton";
···
19
20
const [sessionComplete, setSessionComplete] = createSignal(false);
20
21
21
22
onMount(async () => {
22
-
const [statsData, cardsData] = await Promise.all([fetchStudyStats(), fetchDueCards(params.deckId)]);
23
-
setStats(statsData);
24
-
setCards(cardsData);
23
+
const [statsRes, cardsRes] = await Promise.all([api.getStats(), api.getDueCards(params.deckId)]);
24
+
if (statsRes.ok) setStats(await statsRes.json());
25
+
if (cardsRes.ok) setCards(await cardsRes.json());
25
26
setLoading(false);
26
27
});
27
28
···
35
36
const handleComplete = async () => {
36
37
setSessionActive(false);
37
38
setSessionComplete(true);
38
-
const newStats = await fetchStudyStats();
39
-
setStats(newStats);
39
+
const res = await api.getStats();
40
+
if (res.ok) setStats(await res.json());
40
41
};
41
42
42
43
const handleExit = () => {