learn and share notes on atproto (wip) 🦉
malfestio.stormlightlabs.org/
readability
solid
axum
atproto
srs
1import { scaleIn, slideInUp } from "$lib/animations";
2import { api } from "$lib/api";
3import type { Grade, ReviewCard } from "$lib/model";
4import { Button } from "$ui/Button";
5import { Dialog } from "$ui/Dialog";
6import { ProgressBar } from "$ui/ProgressBar";
7import { type Component, createEffect, createSignal, For, onCleanup, onMount, Show } from "solid-js";
8import { Motion } from "solid-motionone";
9
10type StudySessionProps = { cards: ReviewCard[]; onComplete: () => void; onExit: () => void };
11
12const 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
21export const StudySession: Component<StudySessionProps> = (props) => {
22 const [currentIndex, setCurrentIndex] = createSignal(0);
23 const [isFlipped, setIsFlipped] = createSignal(false);
24 const [isSubmitting, setIsSubmitting] = createSignal(false);
25 const [showEditDialog, setShowEditDialog] = createSignal(false);
26
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();
34 if (!card || isSubmitting()) return;
35
36 setIsSubmitting(true);
37 try {
38 const response = await api.submitReview(card.card_id, grade);
39 if (response.ok) {
40 await response.json();
41 setIsFlipped(false);
42 setCurrentIndex((i) => i + 1);
43 }
44 } catch (err) {
45 console.error("Failed to submit review:", err);
46 } finally {
47 setIsSubmitting(false);
48 }
49 };
50
51 const handleKeyDown = (e: KeyboardEvent) => {
52 if (showEditDialog()) return;
53
54 switch (e.key) {
55 case " ":
56 e.preventDefault();
57 handleFlip();
58 break;
59 case "1":
60 if (isFlipped()) handleGrade(0);
61 break;
62 case "2":
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":
78 case "E":
79 setShowEditDialog(true);
80 break;
81 case "Escape":
82 props.onExit();
83 break;
84 }
85 };
86
87 onMount(() => {
88 window.addEventListener("keydown", handleKeyDown);
89 });
90
91 onCleanup(() => {
92 window.removeEventListener("keydown", handleKeyDown);
93 });
94
95 createEffect(() => {
96 if (isComplete()) {
97 props.onComplete();
98 }
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">
107 ✕ Exit <span class="text-xs text-gray-500">(Esc)</span>
108 </button>
109 </div>
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
186 <Dialog open={showEditDialog()} onClose={() => setShowEditDialog(false)} title="Edit Card">
187 <Show when={currentCard()}>
188 {(card) => (
189 <div class="space-y-4">
190 <div>
191 <label class="block text-sm text-gray-400 mb-1">Front</label>
192 <p class="text-white bg-gray-800 p-3 rounded">{card().front}</p>
193 </div>
194 <div>
195 <label class="block text-sm text-gray-400 mb-1">Back</label>
196 <p class="text-white bg-gray-800 p-3 rounded">{card().back}</p>
197 </div>
198 <p class="text-gray-500 text-sm">Full editing coming soon.</p>
199 <Button onClick={() => setShowEditDialog(false)} variant="secondary" class="w-full">Close</Button>
200 </div>
201 )}
202 </Show>
203 </Dialog>
204 </div>
205 );
206};