diff options
Diffstat (limited to 'src/client/pages/StudyPage.tsx')
| -rw-r--r-- | src/client/pages/StudyPage.tsx | 482 |
1 files changed, 210 insertions, 272 deletions
diff --git a/src/client/pages/StudyPage.tsx b/src/client/pages/StudyPage.tsx index b6c9a3b..cec11d3 100644 --- a/src/client/pages/StudyPage.tsx +++ b/src/client/pages/StudyPage.tsx @@ -2,42 +2,24 @@ import { faCheck, faChevronLeft, faCircleCheck, - faSpinner, } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useAtomValue } from "jotai"; +import { + Suspense, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { Link, useParams } from "wouter"; import { ApiClientError, apiClient } from "../api"; -import { shuffle } from "../utils/shuffle"; +import { studyDataAtomFamily } from "../atoms"; +import { ErrorBoundary } from "../components/ErrorBoundary"; +import { LoadingSpinner } from "../components/LoadingSpinner"; import { renderCard } from "../utils/templateRenderer"; -interface Card { - id: string; - deckId: string; - noteId: string; - isReversed: boolean; - front: string; - back: string; - state: number; - due: string; - stability: number; - difficulty: number; - reps: number; - lapses: number; - /** Note type templates for rendering */ - noteType: { - frontTemplate: string; - backTemplate: string; - }; - /** Field values as a name-value map for template rendering */ - fieldValuesMap: Record<string, string>; -} - -interface Deck { - id: string; - name: string; -} - type Rating = 1 | 2 | 3 | 4; const RatingLabels: Record<Rating, string> = { @@ -54,59 +36,17 @@ const RatingStyles: Record<Rating, string> = { 4: "bg-easy hover:bg-easy/90 focus:ring-easy/30", }; -export function StudyPage() { - const { deckId } = useParams<{ deckId: string }>(); - const [deck, setDeck] = useState<Deck | null>(null); - const [cards, setCards] = useState<Card[]>([]); +function StudySession({ deckId }: { deckId: string }) { + const { deck, cards } = useAtomValue(studyDataAtomFamily(deckId)); + + // Session state (kept as useState - transient UI state) const [currentIndex, setCurrentIndex] = useState(0); const [isFlipped, setIsFlipped] = useState(false); - const [isLoading, setIsLoading] = useState(true); const [isSubmitting, setIsSubmitting] = useState(false); - const [error, setError] = useState<string | null>(null); + const [submitError, setSubmitError] = useState<string | null>(null); const [completedCount, setCompletedCount] = useState(0); const cardStartTimeRef = useRef<number>(Date.now()); - const fetchDeck = useCallback(async () => { - if (!deckId) return; - - const res = await apiClient.rpc.api.decks[":id"].$get({ - param: { id: deckId }, - }); - const data = await apiClient.handleResponse<{ deck: Deck }>(res); - setDeck(data.deck); - }, [deckId]); - - const fetchDueCards = useCallback(async () => { - if (!deckId) return; - - const res = await apiClient.rpc.api.decks[":deckId"].study.$get({ - param: { deckId }, - }); - const data = await apiClient.handleResponse<{ cards: Card[] }>(res); - setCards(shuffle(data.cards)); - }, [deckId]); - - const fetchData = useCallback(async () => { - setIsLoading(true); - setError(null); - - try { - await Promise.all([fetchDeck(), fetchDueCards()]); - } catch (err) { - if (err instanceof ApiClientError) { - setError(err.message); - } else { - setError("Failed to load study session. Please try again."); - } - } finally { - setIsLoading(false); - } - }, [fetchDeck, fetchDueCards]); - - useEffect(() => { - fetchData(); - }, [fetchData]); - // biome-ignore lint/correctness/useExhaustiveDependencies: Reset timer when card changes useEffect(() => { cardStartTimeRef.current = Date.now(); @@ -118,13 +58,13 @@ export function StudyPage() { const handleRating = useCallback( async (rating: Rating) => { - if (!deckId || isSubmitting) return; + if (isSubmitting) return; const currentCard = cards[currentIndex]; if (!currentCard) return; setIsSubmitting(true); - setError(null); + setSubmitError(null); const durationMs = Date.now() - cardStartTimeRef.current; @@ -142,9 +82,9 @@ export function StudyPage() { setCurrentIndex((prev) => prev + 1); } catch (err) { if (err instanceof ApiClientError) { - setError(err.message); + setSubmitError(err.message); } else { - setError("Failed to submit review. Please try again."); + setSubmitError("Failed to submit review. Please try again."); } } finally { setIsSubmitting(false); @@ -187,7 +127,7 @@ export function StudyPage() { const currentCard = cards[currentIndex]; const isSessionComplete = currentIndex >= cards.length && cards.length > 0; - const hasNoCards = !isLoading && cards.length === 0; + const hasNoCards = cards.length === 0; const remainingCards = cards.length - currentIndex; // Compute rendered card content for both legacy and note-based cards @@ -209,6 +149,189 @@ export function StudyPage() { return { front: currentCard.front, back: currentCard.back }; }, [currentCard]); + return ( + <div className="flex-1 flex flex-col animate-fade-in"> + {/* Submit Error */} + {submitError && ( + <div + role="alert" + className="bg-error/5 border border-error/20 rounded-xl p-4 flex items-center justify-between mb-4" + > + <span className="text-error">{submitError}</span> + <button + type="button" + onClick={() => setSubmitError(null)} + className="text-error hover:text-error/80 font-medium text-sm" + > + Dismiss + </button> + </div> + )} + + {/* Study Header */} + <div className="flex items-center justify-between mb-6"> + <h1 className="font-display text-xl font-medium text-slate truncate"> + {deck.name} + </h1> + {!isSessionComplete && !hasNoCards && ( + <span + data-testid="remaining-count" + className="bg-ivory text-slate px-3 py-1 rounded-full text-sm font-medium" + > + {remainingCards} remaining + </span> + )} + </div> + + {/* No Cards State */} + {hasNoCards && ( + <div + data-testid="no-cards" + className="flex-1 flex items-center justify-center" + > + <div className="text-center py-12 px-6 bg-white rounded-2xl border border-border/50 shadow-card max-w-sm w-full"> + <div className="w-16 h-16 mx-auto mb-4 bg-success/10 rounded-2xl flex items-center justify-center"> + <FontAwesomeIcon + icon={faCheck} + className="w-8 h-8 text-success" + aria-hidden="true" + /> + </div> + <h2 className="font-display text-xl font-medium text-slate mb-2"> + All caught up! + </h2> + <p className="text-muted text-sm mb-6"> + No cards due for review right now + </p> + <Link + href={`/decks/${deckId}`} + className="inline-flex items-center gap-2 bg-primary hover:bg-primary-dark text-white font-medium py-2.5 px-5 rounded-lg transition-all duration-200" + > + Back to Deck + </Link> + </div> + </div> + )} + + {/* Session Complete State */} + {isSessionComplete && ( + <div + data-testid="session-complete" + className="flex-1 flex items-center justify-center" + > + <div className="text-center py-12 px-6 bg-white rounded-2xl border border-border/50 shadow-lg max-w-sm w-full animate-scale-in"> + <div className="w-20 h-20 mx-auto mb-6 bg-success/10 rounded-full flex items-center justify-center"> + <FontAwesomeIcon + icon={faCircleCheck} + className="w-10 h-10 text-success" + aria-hidden="true" + /> + </div> + <h2 className="font-display text-2xl font-semibold text-ink mb-2"> + Session Complete! + </h2> + <p className="text-muted mb-1">You reviewed</p> + <p className="text-4xl font-display font-bold text-primary mb-1"> + <span data-testid="completed-count">{completedCount}</span> + </p> + <p className="text-muted mb-8"> + card{completedCount !== 1 ? "s" : ""} + </p> + <div className="flex flex-col sm:flex-row gap-3 justify-center"> + <Link + href={`/decks/${deckId}`} + className="inline-flex items-center justify-center gap-2 bg-primary hover:bg-primary-dark text-white font-medium py-2.5 px-5 rounded-lg transition-all duration-200" + > + Back to Deck + </Link> + <Link + href="/" + className="inline-flex items-center justify-center gap-2 bg-ivory hover:bg-border text-slate font-medium py-2.5 px-5 rounded-lg transition-all duration-200" + > + All Decks + </Link> + </div> + </div> + </div> + )} + + {/* Active Study Card */} + {currentCard && cardContent && !isSessionComplete && ( + <div data-testid="study-card" className="flex-1 flex flex-col"> + {/* Card */} + <button + type="button" + data-testid="card-container" + onClick={!isFlipped ? handleFlip : undefined} + aria-label={ + isFlipped ? "Card showing answer" : "Click to reveal answer" + } + disabled={isFlipped} + className={`flex-1 min-h-[280px] bg-white rounded-2xl border border-border/50 shadow-card p-8 flex flex-col items-center justify-center text-center transition-all duration-300 ${ + !isFlipped + ? "cursor-pointer hover:shadow-lg hover:border-primary/30 active:scale-[0.99]" + : "bg-ivory/50" + }`} + > + {!isFlipped ? ( + <> + <p + data-testid="card-front" + className="text-xl md:text-2xl text-ink font-medium whitespace-pre-wrap break-words leading-relaxed" + > + {cardContent.front} + </p> + <p className="mt-8 text-muted text-sm flex items-center gap-2"> + <kbd className="px-2 py-0.5 bg-ivory rounded text-xs font-mono"> + Space + </kbd> + <span>or tap to reveal</span> + </p> + </> + ) : ( + <p + data-testid="card-back" + className="text-xl md:text-2xl text-ink font-medium whitespace-pre-wrap break-words leading-relaxed animate-fade-in" + > + {cardContent.back} + </p> + )} + </button> + + {/* Rating Buttons */} + {isFlipped && ( + <div + data-testid="rating-buttons" + className="mt-6 grid grid-cols-4 gap-2 animate-slide-up" + > + {([1, 2, 3, 4] as Rating[]).map((rating) => ( + <button + key={rating} + type="button" + data-testid={`rating-${rating}`} + onClick={() => handleRating(rating)} + disabled={isSubmitting} + className={`py-4 px-2 rounded-xl text-white font-medium transition-all duration-200 focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed active:scale-[0.97] ${RatingStyles[rating]}`} + > + <span className="block text-base font-semibold"> + {RatingLabels[rating]} + </span> + <span className="block text-xs opacity-80 mt-0.5"> + {rating} + </span> + </button> + ))} + </div> + )} + </div> + )} + </div> + ); +} + +export function StudyPage() { + const { deckId } = useParams<{ deckId: string }>(); + if (!deckId) { return ( <div className="min-h-screen bg-cream flex items-center justify-center"> @@ -246,196 +369,11 @@ export function StudyPage() { {/* Main Content */} <main className="flex-1 flex flex-col max-w-2xl mx-auto w-full px-4 py-6"> - {/* Loading State */} - {isLoading && ( - <div className="flex-1 flex items-center justify-center"> - <FontAwesomeIcon - icon={faSpinner} - className="h-8 w-8 text-primary animate-spin" - aria-hidden="true" - /> - </div> - )} - - {/* Error State */} - {error && ( - <div - role="alert" - className="bg-error/5 border border-error/20 rounded-xl p-4 flex items-center justify-between mb-4" - > - <span className="text-error">{error}</span> - <button - type="button" - onClick={fetchData} - className="text-error hover:text-error/80 font-medium text-sm" - > - Retry - </button> - </div> - )} - - {/* Study Content */} - {!isLoading && !error && deck && ( - <div className="flex-1 flex flex-col animate-fade-in"> - {/* Study Header */} - <div className="flex items-center justify-between mb-6"> - <h1 className="font-display text-xl font-medium text-slate truncate"> - {deck.name} - </h1> - {!isSessionComplete && !hasNoCards && ( - <span - data-testid="remaining-count" - className="bg-ivory text-slate px-3 py-1 rounded-full text-sm font-medium" - > - {remainingCards} remaining - </span> - )} - </div> - - {/* No Cards State */} - {hasNoCards && ( - <div - data-testid="no-cards" - className="flex-1 flex items-center justify-center" - > - <div className="text-center py-12 px-6 bg-white rounded-2xl border border-border/50 shadow-card max-w-sm w-full"> - <div className="w-16 h-16 mx-auto mb-4 bg-success/10 rounded-2xl flex items-center justify-center"> - <FontAwesomeIcon - icon={faCheck} - className="w-8 h-8 text-success" - aria-hidden="true" - /> - </div> - <h2 className="font-display text-xl font-medium text-slate mb-2"> - All caught up! - </h2> - <p className="text-muted text-sm mb-6"> - No cards due for review right now - </p> - <Link - href={`/decks/${deckId}`} - className="inline-flex items-center gap-2 bg-primary hover:bg-primary-dark text-white font-medium py-2.5 px-5 rounded-lg transition-all duration-200" - > - Back to Deck - </Link> - </div> - </div> - )} - - {/* Session Complete State */} - {isSessionComplete && ( - <div - data-testid="session-complete" - className="flex-1 flex items-center justify-center" - > - <div className="text-center py-12 px-6 bg-white rounded-2xl border border-border/50 shadow-lg max-w-sm w-full animate-scale-in"> - <div className="w-20 h-20 mx-auto mb-6 bg-success/10 rounded-full flex items-center justify-center"> - <FontAwesomeIcon - icon={faCircleCheck} - className="w-10 h-10 text-success" - aria-hidden="true" - /> - </div> - <h2 className="font-display text-2xl font-semibold text-ink mb-2"> - Session Complete! - </h2> - <p className="text-muted mb-1">You reviewed</p> - <p className="text-4xl font-display font-bold text-primary mb-1"> - <span data-testid="completed-count">{completedCount}</span> - </p> - <p className="text-muted mb-8"> - card{completedCount !== 1 ? "s" : ""} - </p> - <div className="flex flex-col sm:flex-row gap-3 justify-center"> - <Link - href={`/decks/${deckId}`} - className="inline-flex items-center justify-center gap-2 bg-primary hover:bg-primary-dark text-white font-medium py-2.5 px-5 rounded-lg transition-all duration-200" - > - Back to Deck - </Link> - <Link - href="/" - className="inline-flex items-center justify-center gap-2 bg-ivory hover:bg-border text-slate font-medium py-2.5 px-5 rounded-lg transition-all duration-200" - > - All Decks - </Link> - </div> - </div> - </div> - )} - - {/* Active Study Card */} - {currentCard && cardContent && !isSessionComplete && ( - <div data-testid="study-card" className="flex-1 flex flex-col"> - {/* Card */} - <button - type="button" - data-testid="card-container" - onClick={!isFlipped ? handleFlip : undefined} - aria-label={ - isFlipped ? "Card showing answer" : "Click to reveal answer" - } - disabled={isFlipped} - className={`flex-1 min-h-[280px] bg-white rounded-2xl border border-border/50 shadow-card p-8 flex flex-col items-center justify-center text-center transition-all duration-300 ${ - !isFlipped - ? "cursor-pointer hover:shadow-lg hover:border-primary/30 active:scale-[0.99]" - : "bg-ivory/50" - }`} - > - {!isFlipped ? ( - <> - <p - data-testid="card-front" - className="text-xl md:text-2xl text-ink font-medium whitespace-pre-wrap break-words leading-relaxed" - > - {cardContent.front} - </p> - <p className="mt-8 text-muted text-sm flex items-center gap-2"> - <kbd className="px-2 py-0.5 bg-ivory rounded text-xs font-mono"> - Space - </kbd> - <span>or tap to reveal</span> - </p> - </> - ) : ( - <p - data-testid="card-back" - className="text-xl md:text-2xl text-ink font-medium whitespace-pre-wrap break-words leading-relaxed animate-fade-in" - > - {cardContent.back} - </p> - )} - </button> - - {/* Rating Buttons */} - {isFlipped && ( - <div - data-testid="rating-buttons" - className="mt-6 grid grid-cols-4 gap-2 animate-slide-up" - > - {([1, 2, 3, 4] as Rating[]).map((rating) => ( - <button - key={rating} - type="button" - data-testid={`rating-${rating}`} - onClick={() => handleRating(rating)} - disabled={isSubmitting} - className={`py-4 px-2 rounded-xl text-white font-medium transition-all duration-200 focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed active:scale-[0.97] ${RatingStyles[rating]}`} - > - <span className="block text-base font-semibold"> - {RatingLabels[rating]} - </span> - <span className="block text-xs opacity-80 mt-0.5"> - {rating} - </span> - </button> - ))} - </div> - )} - </div> - )} - </div> - )} + <ErrorBoundary> + <Suspense fallback={<LoadingSpinner className="flex-1" />}> + <StudySession deckId={deckId} /> + </Suspense> + </ErrorBoundary> </main> </div> ); |
