+1
-1
web/src/components/CardEditor.tsx
+1
-1
web/src/components/CardEditor.tsx
+15
-13
web/src/components/DeckEditor.tsx
+15
-13
web/src/components/DeckEditor.tsx
···
1
+
import { fadeIn, scaleIn } from "$lib/animations";
1
2
import { api } from "$lib/api";
2
-
import type { Card, CardType, CreateDeckPayload, Visibility } from "$lib/store";
3
+
import type { Card, CardType, CreateDeckPayload, Visibility } from "$lib/model";
3
4
import { toast } from "$lib/toast";
4
5
import { Button } from "$ui/Button";
5
6
import { createSignal, For, Show } from "solid-js";
7
+
import { Motion } from "solid-motionone";
6
8
import { CardEditor } from "./CardEditor";
7
9
10
+
type CardData = Card & { hints: string[]; cardType: CardType };
11
+
8
12
export function DeckEditor(props: { onSave?: (deck: CreateDeckPayload) => void }) {
9
13
const [title, setTitle] = createSignal("");
10
14
const [description, setDescription] = createSignal("");
11
15
const [tags, setTags] = createSignal("");
12
-
const [visibilityType, setVisibilityType] = createSignal<string>("Private");
16
+
const [visibilityType, setVisibilityType] = createSignal<Visibility["type"]>("Private");
13
17
const [sharedWith, setSharedWith] = createSignal("");
14
18
15
19
const [cards, setCards] = createSignal<Card[]>([]);
···
22
26
if (visibilityType() === "SharedWith") {
23
27
visibility = { type: "SharedWith", content: sharedWith().split(",").map(s => s.trim()).filter(s => s) };
24
28
} else {
25
-
visibility = { type: visibilityType() as "Private" | "Unlisted" | "Public" };
29
+
visibility = { type: visibilityType() as Exclude<Visibility["type"], "SharedWith"> };
26
30
}
27
31
28
32
const tagsArray = tags().split(",").map(t => t.trim()).filter(t => t);
···
45
49
}
46
50
};
47
51
48
-
const addCard = (
49
-
cardData: { front: string; back: string; mediaUrl?: string; cardType: CardType; hints: string[] },
50
-
) => {
52
+
const addCard = (cardData: CardData) => {
51
53
const card: Card = {
52
54
front: cardData.front,
53
55
back: cardData.back,
···
59
61
setShowCardEditor(false);
60
62
};
61
63
62
-
const removeCard = (index: number) => {
63
-
setCards(cards().filter((_, i) => i !== index));
64
-
};
64
+
const removeCard = (index: number) => setCards(cards().filter((_, i) => i !== index));
65
65
66
66
const moveCard = (from: number, to: number) => {
67
67
if (to < 0 || to >= cards().length) return;
···
113
113
<select
114
114
id="visibility"
115
115
value={visibilityType()}
116
-
onChange={(e) => setVisibilityType(e.target.value)}
116
+
onChange={(e) => setVisibilityType(e.target.value as Visibility["type"])}
117
117
class="w-full bg-gray-800 border-gray-700 text-white rounded p-2 focus:ring-blue-500 focus:border-blue-500"
118
118
aria-label="Visibility">
119
119
<option value="Private">Private</option>
···
124
124
</div>
125
125
126
126
<Show when={visibilityType() === "SharedWith"}>
127
-
<div>
127
+
<Motion.div {...fadeIn}>
128
128
<label class="block text-sm font-medium text-gray-400 mb-1">Share with DIDs (comma separated)</label>
129
129
<input
130
130
type="text"
···
132
132
onInput={(e) => setSharedWith(e.target.value)}
133
133
class="w-full bg-gray-800 border-gray-700 text-white rounded p-2 focus:ring-blue-500 focus:border-blue-500"
134
134
placeholder="did:plc:..., did:plc:..." />
135
-
</div>
135
+
</Motion.div>
136
136
</Show>
137
137
</div>
138
138
···
191
191
Add Card
192
192
</Button>
193
193
}>
194
-
<CardEditor onSave={addCard} onCancel={() => setShowCardEditor(false)} />
194
+
<Motion.div {...scaleIn}>
195
+
<CardEditor onSave={addCard} onCancel={() => setShowCardEditor(false)} />
196
+
</Motion.div>
195
197
</Show>
196
198
</div>
197
199
+33
-33
web/src/components/ReviewStats.tsx
+33
-33
web/src/components/ReviewStats.tsx
···
1
1
import { fadeIn } from "$lib/animations";
2
2
import { api } from "$lib/api";
3
-
import type { ReviewCard, StudyStats } from "$lib/store";
3
+
import type { ReviewCard, StudyStats } from "$lib/model";
4
4
import { Skeleton } from "$ui/Skeleton";
5
-
import type { Component } from "solid-js";
5
+
import { type Component, Show } from "solid-js";
6
6
import { Motion } from "solid-motionone";
7
7
8
8
type ReviewStatsProps = { stats: StudyStats | null; loading?: boolean };
···
10
10
export const ReviewStats: Component<ReviewStatsProps> = (props) => {
11
11
return (
12
12
<Motion.div {...fadeIn} class="bg-gray-900 rounded-xl p-6 border border-gray-800">
13
-
{/* TODO: use solid conditional components instead of ternary */}
14
-
{props.loading
15
-
? (
13
+
<Show
14
+
when={!props.loading}
15
+
fallback={
16
16
<div class="space-y-4">
17
17
<Skeleton class="h-6 w-32" />
18
18
<Skeleton class="h-4 w-48" />
19
19
<Skeleton class="h-4 w-40" />
20
20
</div>
21
-
)
22
-
: props.stats
23
-
? (
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">🔥 {props.stats.current_streak} day streak</span>
29
-
</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">{props.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">{props.stats.reviewed_today}</p>
38
-
<p class="text-sm text-gray-400">Reviewed</p>
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>
39
29
</div>
40
-
<div class="bg-gray-800 rounded-lg p-4">
41
-
<p class="text-3xl font-bold text-purple-400">{props.stats.total_reviews}</p>
42
-
<p class="text-sm text-gray-400">Total</p>
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>
43
44
</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>
44
48
</div>
45
-
46
-
{props.stats.longest_streak > 0 && (
47
-
<p class="text-sm text-gray-500 text-center">Longest streak: {props.stats.longest_streak} days</p>
48
-
)}
49
-
</div>
50
-
)
51
-
: <p class="text-gray-400">No stats available</p>}
49
+
)}
50
+
</Show>
51
+
</Show>
52
52
</Motion.div>
53
53
);
54
54
};
+2
-14
web/src/components/StudySession.tsx
+2
-14
web/src/components/StudySession.tsx
···
1
1
import { scaleIn, slideInUp } from "$lib/animations";
2
2
import { api } from "$lib/api";
3
-
import type { Grade, ReviewCard } from "$lib/store";
3
+
import type { Grade, ReviewCard } from "$lib/model";
4
4
import { Button } from "$ui/Button";
5
5
import { Dialog } from "$ui/Dialog";
6
6
import { ProgressBar } from "$ui/ProgressBar";
···
27
27
const currentCard = () => props.cards[currentIndex()];
28
28
const progress = () => ((currentIndex() + 1) / props.cards.length) * 100;
29
29
const isComplete = () => currentIndex() >= props.cards.length;
30
-
31
-
const handleFlip = () => {
32
-
if (!isFlipped()) {
33
-
setIsFlipped(true);
34
-
}
35
-
};
30
+
const handleFlip = () => !isFlipped() ? setIsFlipped(true) : void 0;
36
31
37
32
const handleGrade = async (grade: Grade) => {
38
33
const card = currentCard();
···
94
89
window.removeEventListener("keydown", handleKeyDown);
95
90
});
96
91
97
-
// Check for completion
98
92
createEffect(() => {
99
93
if (isComplete()) {
100
94
props.onComplete();
···
114
108
<ProgressBar value={progress()} color="green" size="md" />
115
109
</div>
116
110
117
-
{/* Card */}
118
111
<Show when={currentCard()}>
119
112
{(card) => (
120
113
<Motion.div {...scaleIn} class="w-full max-w-2xl">
···
122
115
onClick={handleFlip}
123
116
class="relative min-h-[400px] rounded-2xl cursor-pointer perspective-1000"
124
117
style={{ "transform-style": "preserve-3d" }}>
125
-
{/* Front */}
126
118
<div
127
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 ${
128
120
isFlipped() ? "rotate-y-180" : ""
···
135
127
</Show>
136
128
</div>
137
129
138
-
{/* Back */}
139
130
<div
140
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 ${
141
132
isFlipped() ? "" : "rotate-y-180"
···
154
145
)}
155
146
</Show>
156
147
157
-
{/* Grade Buttons */}
158
148
<Show when={isFlipped()}>
159
149
<Motion.div {...slideInUp} class="w-full max-w-2xl mt-8">
160
150
<p class="text-center text-gray-400 text-sm mb-4">How well did you know this?</p>
···
176
166
</Motion.div>
177
167
</Show>
178
168
179
-
{/* Keyboard Hints */}
180
169
<div class="fixed bottom-4 left-1/2 -translate-x-1/2 text-gray-600 text-xs flex gap-4">
181
170
<span>Space: Flip</span>
182
171
<span>1-5: Grade</span>
···
184
173
<span>Esc: Exit</span>
185
174
</div>
186
175
187
-
{/* Edit Dialog */}
188
176
<Dialog open={showEditDialog()} onClose={() => setShowEditDialog(false)} title="Edit Card">
189
177
<Show when={currentCard()}>
190
178
{(card) => (
+60
web/src/lib/model.ts
+60
web/src/lib/model.ts
···
1
+
export type User = { did: string; handle: string };
2
+
3
+
export type Visibility = { type: "Private" } | { type: "Unlisted" } | { type: "Public" } | {
4
+
type: "SharedWith";
5
+
content: string[];
6
+
};
7
+
8
+
export type CardType = "basic" | "cloze";
9
+
10
+
export type Card = {
11
+
id?: string;
12
+
front: string;
13
+
back: string;
14
+
mediaUrl?: string;
15
+
cardType?: CardType;
16
+
hints?: string[];
17
+
};
18
+
19
+
export type Deck = {
20
+
id: string;
21
+
owner_did: string;
22
+
title: string;
23
+
description: string;
24
+
tags: string[];
25
+
visibility: Visibility;
26
+
published_at?: string;
27
+
fork_of?: string;
28
+
};
29
+
30
+
export type CreateDeckPayload = {
31
+
title: string;
32
+
description: string;
33
+
tags: string[];
34
+
visibility: Visibility;
35
+
cards: Card[];
36
+
};
37
+
38
+
export type Grade = 0 | 1 | 2 | 3 | 4 | 5;
39
+
40
+
export type ReviewCard = {
41
+
review_id: string;
42
+
card_id: string;
43
+
deck_id: string;
44
+
deck_title: string;
45
+
front: string;
46
+
back: string;
47
+
media_url?: string;
48
+
hints: string[];
49
+
due_at: string;
50
+
};
51
+
52
+
export type StudyStats = {
53
+
due_count: number;
54
+
current_streak: number;
55
+
longest_streak: number;
56
+
reviewed_today: number;
57
+
total_reviews: number;
58
+
};
59
+
60
+
export type ReviewResponse = { ease_factor: number; interval_days: number; repetitions: number; due_at: string };
+1
-61
web/src/lib/store.ts
+1
-61
web/src/lib/store.ts
···
1
1
import { createRoot, createSignal } from "solid-js";
2
-
3
-
export type User = { did: string; handle: string };
2
+
import type { User } from "./model";
4
3
5
4
export type AuthState = {
6
5
user: User | null;
···
40
39
}
41
40
42
41
export const authStore = createRoot(createAuthStore);
43
-
44
-
export type Visibility = { type: "Private" } | { type: "Unlisted" } | { type: "Public" } | {
45
-
type: "SharedWith";
46
-
content: string[];
47
-
};
48
-
49
-
export type CardType = "basic" | "cloze";
50
-
51
-
export type Card = {
52
-
id?: string;
53
-
front: string;
54
-
back: string;
55
-
mediaUrl?: string;
56
-
cardType?: CardType;
57
-
hints?: string[];
58
-
};
59
-
60
-
export type Deck = {
61
-
id: string;
62
-
owner_did: string;
63
-
title: string;
64
-
description: string;
65
-
tags: string[];
66
-
visibility: Visibility;
67
-
published_at?: string;
68
-
fork_of?: string;
69
-
};
70
-
71
-
export type CreateDeckPayload = {
72
-
title: string;
73
-
description: string;
74
-
tags: string[];
75
-
visibility: Visibility;
76
-
cards: Card[];
77
-
};
78
-
79
-
export type Grade = 0 | 1 | 2 | 3 | 4 | 5;
80
-
81
-
export type ReviewCard = {
82
-
review_id: string;
83
-
card_id: string;
84
-
deck_id: string;
85
-
deck_title: string;
86
-
front: string;
87
-
back: string;
88
-
media_url?: string;
89
-
hints: string[];
90
-
due_at: string;
91
-
};
92
-
93
-
export type StudyStats = {
94
-
due_count: number;
95
-
current_streak: number;
96
-
longest_streak: number;
97
-
reviewed_today: number;
98
-
total_reviews: number;
99
-
};
100
-
101
-
export type ReviewResponse = { ease_factor: number; interval_days: number; repetitions: number; due_at: string };
+2
-1
web/src/pages/DeckNew.tsx
+2
-1
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/store";
3
+
import type { Card, 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
12
13
try {
13
14
const { cards, ...deckPayload } = data;
14
15
const res = await api.post("/decks", deckPayload);
+94
-99
web/src/pages/DeckView.tsx
+94
-99
web/src/pages/DeckView.tsx
···
2
2
import { FollowButton } from "$components/social/FollowButton";
3
3
import { Button } from "$components/ui/Button";
4
4
import { api } from "$lib/api";
5
-
import type { Visibility } from "$lib/store";
5
+
import type { Card, Deck } from "$lib/model";
6
6
import { A, useParams } from "@solidjs/router";
7
7
import type { Component } from "solid-js";
8
8
import { createResource, For, Show } from "solid-js";
9
-
10
-
type Deck = {
11
-
id: string;
12
-
title: string;
13
-
description: string;
14
-
tags: string[];
15
-
visibility: Visibility;
16
-
owner_did: string;
17
-
};
18
-
19
-
type Card = { id: string; front: string; back?: string };
20
9
21
10
// TODO: use api.ts
22
11
const fetchDeck = async (id: string): Promise<Deck | null> => {
···
64
53
65
54
return (
66
55
<div class="max-w-4xl mx-auto px-6 py-12">
67
-
<Show when={deck.loading}>
68
-
<div class="text-[#8D8D8D] font-light">Loading deck...</div>
69
-
</Show>
56
+
<Show when={!deck.loading} fallback={<div class="text-[#8D8D8D] font-light">Loading deck...</div>}>
57
+
<Show
58
+
when={deck()}
59
+
fallback={
60
+
<div class="p-8 border border-red-900/50 bg-red-900/10 text-red-400">
61
+
Deck not found or you don't have access.
62
+
</div>
63
+
}>
64
+
{deckValue => (
65
+
<>
66
+
<div class="mb-12">
67
+
<div class="flex justify-between items-start mb-4">
68
+
<h1 class="text-4xl font-light text-[#F4F4F4] tracking-tight">{deckValue().title}</h1>
69
+
<Show when={deckValue().visibility.type !== "Public"}>
70
+
<span class="text-xs uppercase font-bold tracking-widest px-2 py-1 bg-[#393939] text-[#C6C6C6]">
71
+
{deckValue().visibility.type}
72
+
</span>
73
+
</Show>
74
+
</div>
70
75
71
-
<Show when={!deck.loading && deck() === null}>
72
-
<div class="p-8 border border-red-900/50 bg-red-900/10 text-red-400">
73
-
Deck not found or you don't have access.
74
-
</div>
75
-
</Show>
76
+
<div class="flex items-center gap-4 mb-6">
77
+
<div class="text-[#C6C6C6] font-light">By {deckValue().owner_did}</div>
78
+
<FollowButton did={deckValue().owner_did || ""} />
79
+
</div>
76
80
77
-
<Show when={deck()}>
78
-
<div class="mb-12">
79
-
<div class="flex justify-between items-start mb-4">
80
-
<h1 class="text-4xl font-light text-[#F4F4F4] tracking-tight">{deck()?.title}</h1>
81
-
<Show when={deck()?.visibility.type !== "Public"}>
82
-
<span class="text-xs uppercase font-bold tracking-widest px-2 py-1 bg-[#393939] text-[#C6C6C6]">
83
-
{deck()?.visibility.type}
84
-
</span>
85
-
</Show>
86
-
</div>
81
+
<p class="text-[#C6C6C6] mb-6 font-light">{deckValue().description}</p>
87
82
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 || ""} />
91
-
</div>
83
+
<Show when={deckValue().tags.length > 0}>
84
+
<div class="flex gap-2 mb-8">
85
+
<For each={deckValue().tags}>
86
+
{(tag) => (
87
+
<span class="text-xs text-[#8D8D8D] bg-[#161616] px-2 py-1 border border-[#393939]">
88
+
#{tag}
89
+
</span>
90
+
)}
91
+
</For>
92
+
</div>
93
+
</Show>
92
94
93
-
<p class="text-[#C6C6C6] mb-6 font-light">{deck()?.description}</p>
95
+
<div class="flex gap-4 border-t border-[#393939] pt-6">
96
+
<button class="bg-[#0F62FE] hover:bg-[#0353E9] text-white px-6 py-3 font-medium text-sm transition-colors">
97
+
Study Deck (Coming Soon)
98
+
</button>
99
+
<Button
100
+
onClick={handleFork}
101
+
variant="secondary"
102
+
class="border border-[#393939] text-[#F4F4F4] hover:bg-[#262626] px-6 py-3 font-medium text-sm transition-colors">
103
+
Fork Deck
104
+
</Button>
105
+
<A
106
+
href="/"
107
+
class="px-6 py-3 border border-[#393939] text-[#F4F4F4] hover:bg-[#262626] font-medium text-sm transition-colors">
108
+
Back to Library
109
+
</A>
110
+
</div>
111
+
</div>
112
+
<div>
113
+
<h2 class="text-xl font-medium text-[#F4F4F4] mb-6 border-b border-[#393939] pb-4">
114
+
Cards <Show when={cards()}>{value => value().length}</Show>
115
+
</h2>
94
116
95
-
<div class="flex gap-2 mb-8">
96
-
<For each={deck()?.tags}>
97
-
{(tag) => (
98
-
<span class="text-xs text-[#8D8D8D] bg-[#161616] px-2 py-1 border border-[#393939]">#{tag}</span>
99
-
)}
100
-
</For>
101
-
</div>
117
+
<Show when={cards.loading}>
118
+
<div class="text-[#8D8D8D] font-light text-sm">Loading cards...</div>
119
+
</Show>
102
120
103
-
<div class="flex gap-4 border-t border-[#393939] pt-6">
104
-
<button class="bg-[#0F62FE] hover:bg-[#0353E9] text-white px-6 py-3 font-medium text-sm transition-colors">
105
-
Study Deck (Coming Soon)
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>
113
-
<A
114
-
href="/"
115
-
class="px-6 py-3 border border-[#393939] text-[#F4F4F4] hover:bg-[#262626] font-medium text-sm transition-colors">
116
-
Back to Library
117
-
</A>
118
-
</div>
119
-
</div>
120
-
121
-
<div>
122
-
<h2 class="text-xl font-medium text-[#F4F4F4] mb-6 border-b border-[#393939] pb-4">
123
-
Cards ({cards()?.length || 0})
124
-
</h2>
125
-
126
-
<Show when={cards.loading}>
127
-
<div class="text-[#8D8D8D] font-light text-sm">Loading cards...</div>
128
-
</Show>
129
-
130
-
<div class="grid gap-4">
131
-
<For each={cards()}>
132
-
{(card, i) => (
133
-
<div class="p-6 bg-[#262626] border border-[#393939] hover:border-[#525252] transition-colors group">
134
-
<div class="flex justify-between items-start mb-2 text-xs text-[#8D8D8D] font-mono">
135
-
<span class="opacity-50">CARD {i() + 1}</span>
136
-
</div>
137
-
<div class="grid md:grid-cols-2 gap-8">
138
-
<div class="prose prose-invert prose-sm max-w-none">
139
-
<div class="text-[10px] uppercase tracking-widest text-[#525252] mb-1">Front</div>
140
-
<div class="text-[#E0E0E0]">{card.front}</div>
141
-
</div>
142
-
<div class="prose prose-invert prose-sm max-w-none md:border-l md:border-[#393939] md:pl-8">
143
-
<div class="text-[10px] uppercase tracking-widest text-[#525252] mb-1">Back</div>
144
-
<div class="text-[#C6C6C6]">{card.back || <span class="italic opacity-50">Empty</span>}</div>
145
-
</div>
146
-
</div>
121
+
<div class="grid gap-4">
122
+
<For
123
+
each={cards()}
124
+
fallback={
125
+
<div class="text-center py-12 border border-dashed border-[#393939] text-[#8D8D8D] font-light italic">
126
+
No cards in this deck.
127
+
</div>
128
+
}>
129
+
{(card, i) => (
130
+
<div class="p-6 bg-[#262626] border border-[#393939] hover:border-[#525252] transition-colors group">
131
+
<div class="flex justify-between items-start mb-2 text-xs text-[#8D8D8D] font-mono">
132
+
<span class="opacity-50">CARD {i() + 1}</span>
133
+
</div>
134
+
<div class="grid md:grid-cols-2 gap-8">
135
+
<div class="prose prose-invert prose-sm max-w-none">
136
+
<div class="text-[10px] uppercase tracking-widest text-[#525252] mb-1">Front</div>
137
+
<div class="text-[#E0E0E0]">{card.front}</div>
138
+
</div>
139
+
<div class="prose prose-invert prose-sm max-w-none md:border-l md:border-[#393939] md:pl-8">
140
+
<div class="text-[10px] uppercase tracking-widest text-[#525252] mb-1">Back</div>
141
+
<div class="text-[#C6C6C6]">
142
+
{card.back || <span class="italic opacity-50">Empty</span>}
143
+
</div>
144
+
</div>
145
+
</div>
146
+
</div>
147
+
)}
148
+
</For>
147
149
</div>
148
-
)}
149
-
</For>
150
-
151
-
<Show when={!cards.loading && cards()?.length === 0}>
152
-
<div class="text-center py-12 border border-dashed border-[#393939] text-[#8D8D8D] font-light italic">
153
-
No cards in this deck.
154
150
</div>
155
-
</Show>
156
-
</div>
157
-
</div>
158
-
159
-
<div class="mt-12 pt-8 border-t border-[#393939]">
160
-
<CommentSection deckId={deck()!.id} />
161
-
</div>
151
+
<div class="mt-12 pt-8 border-t border-[#393939]">
152
+
<CommentSection deckId={deckValue().id} />
153
+
</div>
154
+
</>
155
+
)}
156
+
</Show>
162
157
</Show>
163
158
</div>
164
159
);
+16
-8
web/src/pages/Feed.tsx
+16
-8
web/src/pages/Feed.tsx
···
3
3
import { Card } from "$components/ui/Card";
4
4
import { Tabs } from "$components/ui/Tabs";
5
5
import { api } from "$lib/api";
6
+
import type { Deck } from "$lib/model";
6
7
import { A } from "@solidjs/router";
7
8
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
9
11
10
export default function Feed() {
12
11
const [followsFeed] = createResource(async () => {
···
25
24
<div>
26
25
<h3 class="text-xl font-bold mb-1">{props.deck.title}</h3>
27
26
<p class="text-sm text-gray-400 mb-2">
28
-
By {props.deck.owner_did} • {new Date(props.deck.published_at).toLocaleDateString()}
27
+
By {props.deck.owner_did} •{" "}
28
+
<Show when={props.deck.published_at} fallback="Draft">
29
+
{published_at => new Date(published_at()).toLocaleDateString()}
30
+
</Show>
29
31
</p>
30
32
<p class="mb-3">{props.deck.description}</p>
31
33
<div class="flex gap-2 mb-3">
···
67
69
<div class="mt-4">
68
70
<Show when={followsFeed()}>
69
71
{feed => (
70
-
<Show
71
-
when={feed().length > 0}
72
+
<For
73
+
each={feed()}
72
74
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
+
{(deck) => <DeckItem deck={deck} />}
76
+
</For>
75
77
)}
76
78
</Show>
77
79
</div>
78
80
</Match>
79
81
<Match when={activeTab() === "trending"}>
80
82
<div class="mt-4">
81
-
<For each={valuableFeed()}>{(deck) => <DeckItem deck={deck} />}</For>
83
+
<Show when={valuableFeed()}>
84
+
{feed => (
85
+
<For each={feed()} fallback={<div class="text-gray-500 py-8 text-center">No trending decks.</div>}>
86
+
{(deck) => <DeckItem deck={deck} />}
87
+
</For>
88
+
)}
89
+
</Show>
82
90
</div>
83
91
</Match>
84
92
</Switch>
+47
-53
web/src/pages/Home.tsx
+47
-53
web/src/pages/Home.tsx
···
1
1
import { api } from "$lib/api";
2
-
import type { Visibility } from "$lib/store";
2
+
import type { Deck } from "$lib/model";
3
3
import { A } from "@solidjs/router";
4
4
import type { Component } from "solid-js";
5
5
import { createResource, For, Show } from "solid-js";
6
6
7
-
type Deck = {
8
-
id: string;
9
-
title: string;
10
-
description: string;
11
-
tags: string[];
12
-
visibility: Visibility;
13
-
owner_did: string;
14
-
};
15
-
7
+
// TODO: use api.ts
16
8
const fetchDecks = async (): Promise<Deck[]> => {
17
9
const res = await api.get("/decks");
18
10
if (!res.ok) return [];
19
11
return res.json();
20
12
};
21
13
22
-
const DeckCard: Component<{ deck: Deck }> = (props) => {
23
-
return (
24
-
<div class="bg-[#262626] border border-[#393939] p-4 hover:border-[#0F62FE] transition-colors group relative h-full flex flex-col">
25
-
<div class="flex justify-between items-start mb-2">
26
-
<h3 class="text-lg font-normal text-[#F4F4F4] group-hover:text-[#0F62FE] transition-colors line-clamp-1">
27
-
{props.deck.title}
28
-
</h3>
29
-
<Show when={props.deck.visibility.type !== "Public"}>
30
-
<span class="text-[10px] uppercase font-bold tracking-widest px-2 py-0.5 bg-[#393939] text-[#C6C6C6]">
31
-
{props.deck.visibility.type}
32
-
</span>
33
-
</Show>
34
-
</div>
35
-
<p class="text-sm text-[#C6C6C6] mb-6 line-clamp-2 flex-grow font-light">{props.deck.description}</p>
14
+
const DeckCard: Component<{ deck: Deck }> = (props) => (
15
+
<div class="bg-[#262626] border border-[#393939] p-4 hover:border-[#0F62FE] transition-colors group relative h-full flex flex-col">
16
+
<div class="flex justify-between items-start mb-2">
17
+
<h3 class="text-lg font-normal text-[#F4F4F4] group-hover:text-[#0F62FE] transition-colors line-clamp-1">
18
+
{props.deck.title}
19
+
</h3>
20
+
<Show when={props.deck.visibility.type !== "Public"}>
21
+
<span class="text-[10px] uppercase font-bold tracking-widest px-2 py-0.5 bg-[#393939] text-[#C6C6C6]">
22
+
{props.deck.visibility.type}
23
+
</span>
24
+
</Show>
25
+
</div>
26
+
<p class="text-sm text-[#C6C6C6] mb-6 line-clamp-2 flex-grow font-light">{props.deck.description}</p>
36
27
37
-
<div class="flex items-center gap-2 mb-4 flex-wrap">
38
-
<For each={props.deck.tags}>
39
-
{(tag) => <span class="text-xs text-[#8D8D8D] bg-[#161616] px-2 py-0.5 border border-[#393939]">#{tag}</span>}
40
-
</For>
41
-
</div>
28
+
<div class="flex items-center gap-2 mb-4 flex-wrap">
29
+
<For each={props.deck.tags}>
30
+
{(tag) => <span class="text-xs text-[#8D8D8D] bg-[#161616] px-2 py-0.5 border border-[#393939]">#{tag}</span>}
31
+
</For>
32
+
</div>
42
33
43
-
<div class="flex justify-end pt-4 border-t border-[#393939] mt-auto">
44
-
<A
45
-
href={`/decks/${props.deck.id}`}
46
-
class="text-sm font-medium text-[#0F62FE] hover:text-[#0353E9] flex items-center gap-1">
47
-
View Deck <span class="group-hover:translate-x-1 transition-transform">→</span>
48
-
</A>
49
-
</div>
34
+
<div class="flex justify-end pt-4 border-t border-[#393939] mt-auto">
35
+
<A
36
+
href={`/decks/${props.deck.id}`}
37
+
class="text-sm font-medium text-[#0F62FE] hover:text-[#0353E9] flex items-center gap-1">
38
+
View Deck <span class="group-hover:translate-x-1 transition-transform">→</span>
39
+
</A>
50
40
</div>
51
-
);
52
-
};
41
+
</div>
42
+
);
53
43
54
44
const Home: Component = () => {
55
45
const [decks] = createResource(fetchDecks);
···
69
59
</div>
70
60
71
61
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
72
-
<Show when={decks.loading}>
73
-
<div class="col-span-full h-32 flex items-center justify-center text-[#8D8D8D] font-light">
74
-
Loading library...
75
-
</div>
76
-
</Show>
77
-
78
-
<Show when={!decks.loading && decks()?.length === 0}>
79
-
<div class="col-span-full py-16 text-center border border-dashed border-[#393939] bg-[#262626]/50">
80
-
<h3 class="text-lg font-medium text-[#F4F4F4] mb-2">No decks found</h3>
81
-
<p class="text-sm text-[#C6C6C6] max-w-sm mx-auto font-light">
82
-
Create your first deck to get started with spaced repetition learning.
83
-
</p>
84
-
</div>
62
+
<Show
63
+
when={!decks.loading}
64
+
fallback={
65
+
<div class="col-span-full h-32 flex items-center justify-center text-[#8D8D8D] font-light">
66
+
Loading library...
67
+
</div>
68
+
}>
69
+
<For
70
+
each={decks()}
71
+
fallback={
72
+
<div class="col-span-full py-16 text-center border border-dashed border-[#393939] bg-[#262626]/50">
73
+
<h3 class="text-lg font-medium text-[#F4F4F4] mb-2">No decks found</h3>
74
+
<p class="text-sm text-[#C6C6C6] max-w-sm mx-auto font-light">
75
+
Create your first deck to get started with spaced repetition learning.
76
+
</p>
77
+
</div>
78
+
}>
79
+
{(deck) => <DeckCard deck={deck} />}
80
+
</For>
85
81
</Show>
86
-
87
-
<For each={decks()}>{(deck) => <DeckCard deck={deck} />}</For>
88
82
</div>
89
83
</div>
90
84
);
+17
-11
web/src/pages/Review.tsx
+17
-11
web/src/pages/Review.tsx
···
1
1
import { fetchDueCards, fetchStudyStats, ReviewStats } from "$components/ReviewStats";
2
2
import { StudySession } from "$components/StudySession";
3
3
import { fadeIn } from "$lib/animations";
4
-
import type { ReviewCard, StudyStats as StudyStatsType } from "$lib/store";
4
+
import type { ReviewCard, StudyStats as StudyStatsType } from "$lib/model";
5
5
import { Button } from "$ui/Button";
6
6
import { Skeleton } from "$ui/Skeleton";
7
7
import { useNavigate, useParams } from "@solidjs/router";
···
66
66
when={cards().length > 0}
67
67
fallback={
68
68
<div class="text-center py-8">
69
+
{/* TODO: replace with an icon */}
69
70
<p class="text-4xl mb-4">🎉</p>
70
-
<h2 class="text-xl font-semibold text-white mb-2">
71
-
{sessionComplete() ? "Session Complete!" : "All Caught Up!"}
72
-
</h2>
73
-
<p class="text-gray-400 mb-6">
74
-
{sessionComplete()
75
-
? "Great job! You've reviewed all your due cards."
76
-
: "You have no cards due for review right now."}
77
-
</p>
71
+
<Show
72
+
when={sessionComplete()}
73
+
fallback={
74
+
<>
75
+
<h2 class="text-xl font-semibold text-white mb-2">All Caught Up!</h2>
76
+
<p class="text-gray-400 mb-6">You have no cards due for review right now.</p>
77
+
</>
78
+
}>
79
+
<>
80
+
<h2 class="text-xl font-semibold text-white mb-2">Session Complete!</h2>
81
+
<p class="text-gray-400 mb-6">Great job! You've reviewed all your due cards.</p>
82
+
</>
83
+
</Show>
78
84
<Button onClick={() => navigate("/")} variant="secondary">Back to Library</Button>
79
85
</div>
80
86
}>
···
89
95
</Show>
90
96
</div>
91
97
92
-
<div class="mt-8 bg-gray-900/50 rounded-xl p-6 border border-gray-800/50">
98
+
<Motion.div {...fadeIn} class="mt-8 bg-gray-900/50 rounded-xl p-6 border border-gray-800/50">
93
99
<h3 class="text-sm font-semibold text-gray-400 mb-4">Keyboard Shortcuts</h3>
94
100
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
95
101
<div class="flex items-center gap-2">
···
109
115
<span class="text-gray-400">Exit session</span>
110
116
</div>
111
117
</div>
112
-
</div>
118
+
</Motion.div>
113
119
</Motion.div>
114
120
</Show>
115
121
);