diff options
Diffstat (limited to 'src/client/pages/StudyPage.tsx')
| -rw-r--r-- | src/client/pages/StudyPage.tsx | 135 |
1 files changed, 114 insertions, 21 deletions
diff --git a/src/client/pages/StudyPage.tsx b/src/client/pages/StudyPage.tsx index 423fdd0..4fb7549 100644 --- a/src/client/pages/StudyPage.tsx +++ b/src/client/pages/StudyPage.tsx @@ -2,6 +2,7 @@ import { faCheck, faChevronLeft, faCircleCheck, + faRotateLeft, } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useAtomValue } from "jotai"; @@ -20,6 +21,7 @@ import { ErrorBoundary } from "../components/ErrorBoundary"; import { renderCard } from "../utils/templateRenderer"; type Rating = 1 | 2 | 3 | 4; +type PendingReview = { cardId: string; rating: Rating; durationMs: number }; const RatingLabels: Record<Rating, string> = { 1: "Again", @@ -47,6 +49,15 @@ function StudySession({ deckId }: { deckId: string }) { const [submitError, setSubmitError] = useState<string | null>(null); const [completedCount, setCompletedCount] = useState(0); const cardStartTimeRef = useRef<number>(Date.now()); + const [pendingReview, setPendingReview] = useState<PendingReview | null>( + null, + ); + const pendingReviewRef = useRef<PendingReview | null>(null); + + // Keep ref in sync with state for cleanup effect + useEffect(() => { + pendingReviewRef.current = pendingReview; + }, [pendingReview]); // biome-ignore lint/correctness/useExhaustiveDependencies: Reset timer when card changes useEffect(() => { @@ -57,6 +68,19 @@ function StudySession({ deckId }: { deckId: string }) { setIsFlipped(true); }, []); + const flushPendingReview = useCallback( + async (review: PendingReview) => { + const res = await apiClient.rpc.api.decks[":deckId"].study[ + ":cardId" + ].$post({ + param: { deckId, cardId: review.cardId }, + json: { rating: review.rating, durationMs: review.durationMs }, + }); + await apiClient.handleResponse(res); + }, + [deckId], + ); + const handleRating = useCallback( async (rating: Rating) => { if (isSubmitting) return; @@ -69,35 +93,67 @@ function StudySession({ deckId }: { deckId: string }) { const durationMs = Date.now() - cardStartTimeRef.current; - try { - const res = await apiClient.rpc.api.decks[":deckId"].study[ - ":cardId" - ].$post({ - param: { deckId, cardId: currentCard.id }, - json: { rating, durationMs }, - }); - await apiClient.handleResponse(res); - - setCompletedCount((prev) => prev + 1); - setIsFlipped(false); - setCurrentIndex((prev) => prev + 1); - } catch (err) { - if (err instanceof ApiClientError) { - setSubmitError(err.message); - } else { - setSubmitError("Failed to submit review. Please try again."); + // Flush previous pending review first + if (pendingReview) { + try { + await flushPendingReview(pendingReview); + } catch (err) { + if (err instanceof ApiClientError) { + setSubmitError(err.message); + } else { + setSubmitError("Failed to submit review. Please try again."); + } } - } finally { - setIsSubmitting(false); } + + // Save current review as pending (don't send yet) + setPendingReview({ cardId: currentCard.id, rating, durationMs }); + setCompletedCount((prev) => prev + 1); + setIsFlipped(false); + setCurrentIndex((prev) => prev + 1); + setIsSubmitting(false); }, - [deckId, isSubmitting, cards, currentIndex], + [isSubmitting, cards, currentIndex, pendingReview, flushPendingReview], ); + const handleUndo = useCallback(() => { + if (!pendingReview) return; + setPendingReview(null); + setCurrentIndex((prev) => prev - 1); + setCompletedCount((prev) => prev - 1); + setIsFlipped(false); + }, [pendingReview]); + + // Flush pending review on unmount (fire-and-forget) + useEffect(() => { + return () => { + const review = pendingReviewRef.current; + if (review) { + apiClient.rpc.api.decks[":deckId"].study[":cardId"] + .$post({ + param: { deckId, cardId: review.cardId }, + json: { rating: review.rating, durationMs: review.durationMs }, + }) + .then((res) => apiClient.handleResponse(res)) + .catch(() => {}); + } + }; + }, [deckId]); + const handleKeyDown = useCallback( (e: KeyboardEvent) => { if (isSubmitting) return; + // Undo: Ctrl+Z / Cmd+Z anytime, or z when card is not flipped + if ( + (e.key === "z" && (e.ctrlKey || e.metaKey)) || + (e.key === "z" && !e.ctrlKey && !e.metaKey && !isFlipped) + ) { + e.preventDefault(); + handleUndo(); + return; + } + if (!isFlipped) { if (e.key === " " || e.key === "Enter") { e.preventDefault(); @@ -119,7 +175,7 @@ function StudySession({ deckId }: { deckId: string }) { } } }, - [isFlipped, isSubmitting, handleFlip, handleRating], + [isFlipped, isSubmitting, handleFlip, handleRating, handleUndo], ); useEffect(() => { @@ -185,6 +241,28 @@ function StudySession({ deckId }: { deckId: string }) { )} </div> + {/* Undo Button */} + {pendingReview && !isFlipped && !isSessionComplete && ( + <div className="flex justify-end mb-4"> + <button + type="button" + data-testid="undo-button" + onClick={handleUndo} + className="inline-flex items-center gap-1.5 text-muted hover:text-slate text-sm transition-colors" + > + <FontAwesomeIcon + icon={faRotateLeft} + className="w-3.5 h-3.5" + aria-hidden="true" + /> + Undo + <kbd className="ml-1 px-1.5 py-0.5 bg-ivory rounded text-xs font-mono"> + Z + </kbd> + </button> + </div> + )} + {/* No Cards State */} {hasNoCards && ( <div @@ -240,6 +318,21 @@ function StudySession({ deckId }: { deckId: string }) { card{completedCount !== 1 ? "s" : ""} </p> <div className="flex flex-col sm:flex-row gap-3 justify-center"> + {pendingReview && ( + <button + type="button" + data-testid="undo-button" + onClick={handleUndo} + 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" + > + <FontAwesomeIcon + icon={faRotateLeft} + className="w-4 h-4" + aria-hidden="true" + /> + Undo + </button> + )} <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" |
