learn and share notes on atproto (wip) 🦉 malfestio.stormlightlabs.org/
readability solid axum atproto srs
at main 206 lines 8.1 kB view raw
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};