diff options
| author | nsfisis <nsfisis@gmail.com> | 2025-12-07 18:50:08 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2025-12-07 18:50:08 +0900 |
| commit | f443ac18ccb8ab34fb5bf69b0802eb69cf89cf06 (patch) | |
| tree | 571fa2cdd1959598e623fdd839c07d63b03b1124 /src/client/pages/StudyPage.tsx | |
| parent | b965d9432b4037dd2f65bb4c8690965e090228ca (diff) | |
| download | kioku-f443ac18ccb8ab34fb5bf69b0802eb69cf89cf06.tar.gz kioku-f443ac18ccb8ab34fb5bf69b0802eb69cf89cf06.tar.zst kioku-f443ac18ccb8ab34fb5bf69b0802eb69cf89cf06.zip | |
feat(client): add study session page with card flip and rating UI
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 <noreply@anthropic.com>
Diffstat (limited to 'src/client/pages/StudyPage.tsx')
| -rw-r--r-- | src/client/pages/StudyPage.tsx | 441 |
1 files changed, 441 insertions, 0 deletions
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<Rating, string> = { + 1: "Again", + 2: "Hard", + 3: "Good", + 4: "Easy", +}; + +const RatingColors: Record<Rating, string> = { + 1: "#dc3545", + 2: "#fd7e14", + 3: "#28a745", + 4: "#007bff", +}; + +export function StudyPage() { + const { deckId } = useParams<{ deckId: string }>(); + const [deck, setDeck] = useState<Deck | null>(null); + const [cards, setCards] = useState<Card[]>([]); + 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 [completedCount, setCompletedCount] = useState(0); + const cardStartTimeRef = useRef<number>(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<string, Rating> = { + "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 ( + <div> + <p>Invalid deck ID</p> + <Link href="/">Back to decks</Link> + </div> + ); + } + + const currentCard = cards[currentIndex]; + const isSessionComplete = currentIndex >= cards.length && cards.length > 0; + const hasNoCards = !isLoading && cards.length === 0; + const remainingCards = cards.length - currentIndex; + + return ( + <div style={{ maxWidth: "600px", margin: "0 auto", padding: "1rem" }}> + <header style={{ marginBottom: "1rem" }}> + <Link href={`/decks/${deckId}`} style={{ textDecoration: "none" }}> + ← Back to Deck + </Link> + </header> + + {isLoading && <p>Loading study session...</p>} + + {error && ( + <div role="alert" style={{ color: "red", marginBottom: "1rem" }}> + {error} + <button + type="button" + onClick={fetchData} + style={{ marginLeft: "0.5rem" }} + > + Retry + </button> + </div> + )} + + {!isLoading && !error && deck && ( + <> + <div + style={{ + display: "flex", + justifyContent: "space-between", + alignItems: "center", + marginBottom: "1rem", + }} + > + <h1 style={{ margin: 0 }}>Study: {deck.name}</h1> + {!isSessionComplete && !hasNoCards && ( + <span + data-testid="remaining-count" + style={{ + backgroundColor: "#f0f0f0", + padding: "0.25rem 0.75rem", + borderRadius: "12px", + fontSize: "0.875rem", + }} + > + {remainingCards} remaining + </span> + )} + </div> + + {hasNoCards && ( + <div + data-testid="no-cards" + style={{ + textAlign: "center", + padding: "3rem 1rem", + backgroundColor: "#f8f9fa", + borderRadius: "8px", + }} + > + <h2 style={{ marginTop: 0 }}>No cards to study</h2> + <p style={{ color: "#666" }}> + There are no due cards in this deck right now. + </p> + <Link href={`/decks/${deckId}`}> + <button type="button">Back to Deck</button> + </Link> + </div> + )} + + {isSessionComplete && ( + <div + data-testid="session-complete" + style={{ + textAlign: "center", + padding: "3rem 1rem", + backgroundColor: "#d4edda", + borderRadius: "8px", + }} + > + <h2 style={{ marginTop: 0, color: "#155724" }}> + Session Complete! + </h2> + <p style={{ fontSize: "1.25rem", marginBottom: "1.5rem" }}> + You reviewed{" "} + <strong data-testid="completed-count">{completedCount}</strong>{" "} + card{completedCount !== 1 ? "s" : ""}. + </p> + <div style={{ display: "flex", gap: "1rem", justifyContent: "center" }}> + <Link href={`/decks/${deckId}`}> + <button type="button">Back to Deck</button> + </Link> + <Link href="/"> + <button type="button">All Decks</button> + </Link> + </div> + </div> + )} + + {currentCard && !isSessionComplete && ( + <div data-testid="study-card"> + <div + data-testid="card-container" + onClick={!isFlipped ? handleFlip : undefined} + onKeyDown={(e) => { + 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 ? ( + <> + <p + data-testid="card-front" + style={{ + fontSize: "1.25rem", + textAlign: "center", + margin: 0, + whiteSpace: "pre-wrap", + wordBreak: "break-word", + }} + > + {currentCard.front} + </p> + <p + style={{ + marginTop: "1.5rem", + color: "#666", + fontSize: "0.875rem", + }} + > + Click or press Space to reveal + </p> + </> + ) : ( + <> + <p + data-testid="card-back" + style={{ + fontSize: "1.25rem", + textAlign: "center", + margin: 0, + whiteSpace: "pre-wrap", + wordBreak: "break-word", + }} + > + {currentCard.back} + </p> + </> + )} + </div> + + {isFlipped && ( + <div + data-testid="rating-buttons" + style={{ + display: "flex", + gap: "0.5rem", + justifyContent: "center", + marginTop: "1rem", + }} + > + {([1, 2, 3, 4] as Rating[]).map((rating) => ( + <button + key={rating} + type="button" + data-testid={`rating-${rating}`} + onClick={() => handleRating(rating)} + disabled={isSubmitting} + style={{ + flex: 1, + padding: "0.75rem 1rem", + backgroundColor: RatingColors[rating], + color: "white", + border: "none", + borderRadius: "4px", + cursor: isSubmitting ? "not-allowed" : "pointer", + opacity: isSubmitting ? 0.6 : 1, + fontSize: "0.875rem", + }} + > + <span style={{ display: "block", fontWeight: "bold" }}> + {RatingLabels[rating]} + </span> + <span style={{ display: "block", fontSize: "0.75rem" }}> + {rating} + </span> + </button> + ))} + </div> + )} + </div> + )} + </> + )} + </div> + ); +} |
