aboutsummaryrefslogtreecommitdiffhomepage
path: root/src/client/pages/StudyPage.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'src/client/pages/StudyPage.tsx')
-rw-r--r--src/client/pages/StudyPage.tsx482
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>
);