From f443ac18ccb8ab34fb5bf69b0802eb69cf89cf06 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Sun, 7 Dec 2025 18:50:08 +0900 Subject: feat(client): add study session page with card flip and rating UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the complete study flow frontend: - Study session page with card display and flip interaction - Rating buttons (Again, Hard, Good, Easy) with keyboard shortcuts - Progress display showing remaining cards count - Session complete screen with review summary - Study Now button on deck detail page 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/client/pages/StudyPage.tsx | 441 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 441 insertions(+) create mode 100644 src/client/pages/StudyPage.tsx (limited to 'src/client/pages/StudyPage.tsx') diff --git a/src/client/pages/StudyPage.tsx b/src/client/pages/StudyPage.tsx new file mode 100644 index 0000000..05e9943 --- /dev/null +++ b/src/client/pages/StudyPage.tsx @@ -0,0 +1,441 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { Link, useParams } from "wouter"; +import { ApiClientError, apiClient } from "../api"; + +interface Card { + id: string; + deckId: string; + front: string; + back: string; + state: number; + due: string; + stability: number; + difficulty: number; + reps: number; + lapses: number; +} + +interface Deck { + id: string; + name: string; +} + +type Rating = 1 | 2 | 3 | 4; + +const RatingLabels: Record = { + 1: "Again", + 2: "Hard", + 3: "Good", + 4: "Easy", +}; + +const RatingColors: Record = { + 1: "#dc3545", + 2: "#fd7e14", + 3: "#28a745", + 4: "#007bff", +}; + +export function StudyPage() { + const { deckId } = useParams<{ deckId: string }>(); + const [deck, setDeck] = useState(null); + const [cards, setCards] = useState([]); + const [currentIndex, setCurrentIndex] = useState(0); + const [isFlipped, setIsFlipped] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); + const [completedCount, setCompletedCount] = useState(0); + const cardStartTimeRef = useRef(Date.now()); + + const fetchDeck = useCallback(async () => { + if (!deckId) return; + + const authHeader = apiClient.getAuthHeader(); + if (!authHeader) { + throw new ApiClientError("Not authenticated", 401); + } + + const res = await fetch(`/api/decks/${deckId}`, { + headers: authHeader, + }); + + if (!res.ok) { + const errorBody = await res.json().catch(() => ({})); + throw new ApiClientError( + (errorBody as { error?: string }).error || + `Request failed with status ${res.status}`, + res.status, + ); + } + + const data = await res.json(); + setDeck(data.deck); + }, [deckId]); + + const fetchDueCards = useCallback(async () => { + if (!deckId) return; + + const authHeader = apiClient.getAuthHeader(); + if (!authHeader) { + throw new ApiClientError("Not authenticated", 401); + } + + const res = await fetch(`/api/decks/${deckId}/study`, { + headers: authHeader, + }); + + if (!res.ok) { + const errorBody = await res.json().catch(() => ({})); + throw new ApiClientError( + (errorBody as { error?: string }).error || + `Request failed with status ${res.status}`, + res.status, + ); + } + + const data = await res.json(); + setCards(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]); + + useEffect(() => { + cardStartTimeRef.current = Date.now(); + }, [currentIndex]); + + const handleFlip = () => { + setIsFlipped(true); + }; + + const handleRating = async (rating: Rating) => { + if (!deckId || isSubmitting) return; + + const currentCard = cards[currentIndex]; + if (!currentCard) return; + + setIsSubmitting(true); + setError(null); + + const durationMs = Date.now() - cardStartTimeRef.current; + + try { + const authHeader = apiClient.getAuthHeader(); + if (!authHeader) { + throw new ApiClientError("Not authenticated", 401); + } + + const res = await fetch( + `/api/decks/${deckId}/study/${currentCard.id}`, + { + method: "POST", + headers: { + ...authHeader, + "Content-Type": "application/json", + }, + body: JSON.stringify({ rating, durationMs }), + }, + ); + + if (!res.ok) { + const errorBody = await res.json().catch(() => ({})); + throw new ApiClientError( + (errorBody as { error?: string }).error || + `Request failed with status ${res.status}`, + res.status, + ); + } + + setCompletedCount((prev) => prev + 1); + setIsFlipped(false); + setCurrentIndex((prev) => prev + 1); + } catch (err) { + if (err instanceof ApiClientError) { + setError(err.message); + } else { + setError("Failed to submit review. Please try again."); + } + } finally { + setIsSubmitting(false); + } + }; + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (isSubmitting) return; + + if (!isFlipped) { + if (e.key === " " || e.key === "Enter") { + e.preventDefault(); + handleFlip(); + } + } else { + const keyRatingMap: Record = { + "1": 1, + "2": 2, + "3": 3, + "4": 4, + }; + + const rating = keyRatingMap[e.key]; + if (rating) { + e.preventDefault(); + handleRating(rating); + } + } + }, + [isFlipped, isSubmitting], + ); + + useEffect(() => { + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [handleKeyDown]); + + if (!deckId) { + return ( +
+

Invalid deck ID

+ Back to decks +
+ ); + } + + const currentCard = cards[currentIndex]; + const isSessionComplete = currentIndex >= cards.length && cards.length > 0; + const hasNoCards = !isLoading && cards.length === 0; + const remainingCards = cards.length - currentIndex; + + return ( +
+
+ + ← Back to Deck + +
+ + {isLoading &&

Loading study session...

} + + {error && ( +
+ {error} + +
+ )} + + {!isLoading && !error && deck && ( + <> +
+

Study: {deck.name}

+ {!isSessionComplete && !hasNoCards && ( + + {remainingCards} remaining + + )} +
+ + {hasNoCards && ( +
+

No cards to study

+

+ There are no due cards in this deck right now. +

+ + + +
+ )} + + {isSessionComplete && ( +
+

+ Session Complete! +

+

+ You reviewed{" "} + {completedCount}{" "} + card{completedCount !== 1 ? "s" : ""}. +

+
+ + + + + + +
+
+ )} + + {currentCard && !isSessionComplete && ( +
+
{ + if (!isFlipped && (e.key === " " || e.key === "Enter")) { + e.preventDefault(); + handleFlip(); + } + }} + role="button" + tabIndex={0} + aria-label={isFlipped ? "Card showing answer" : "Click to reveal answer"} + style={{ + border: "1px solid #ccc", + borderRadius: "8px", + padding: "2rem", + minHeight: "200px", + display: "flex", + flexDirection: "column", + justifyContent: "center", + alignItems: "center", + cursor: isFlipped ? "default" : "pointer", + backgroundColor: isFlipped ? "#f8f9fa" : "white", + transition: "background-color 0.2s", + }} + > + {!isFlipped ? ( + <> +

+ {currentCard.front} +

+

+ Click or press Space to reveal +

+ + ) : ( + <> +

+ {currentCard.back} +

+ + )} +
+ + {isFlipped && ( +
+ {([1, 2, 3, 4] as Rating[]).map((rating) => ( + + ))} +
+ )} +
+ )} + + )} +
+ ); +} -- cgit v1.2.3-70-g09d2