feat: Redesign flashcard study screen with live shuffle toggle
All checks were successful
Automated Container Build / build-and-push (push) Successful in 58s

This commit is contained in:
Elijah 2026-06-28 08:46:05 -07:00
parent c34da6941e
commit e4985d07e1

View file

@ -28,7 +28,7 @@ type CardResult = "correct" | "missed";
export function FlashcardViewer({ cards, deckId, isShared = false, initialProgress }: FlashcardViewerProps) { export function FlashcardViewer({ cards, deckId, isShared = false, initialProgress }: FlashcardViewerProps) {
const [order, setOrder] = useState<string[]>( const [order, setOrder] = useState<string[]>(
initialProgress ? JSON.parse(initialProgress.orderJson) : [] initialProgress ? JSON.parse(initialProgress.orderJson) : cards.map((c) => c.id)
); );
const [currentIndex, setCurrentIndex] = useState( const [currentIndex, setCurrentIndex] = useState(
initialProgress ? initialProgress.currentIndex : 0 initialProgress ? initialProgress.currentIndex : 0
@ -40,10 +40,10 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
? JSON.parse(initialProgress.cardResultsJson) ? JSON.parse(initialProgress.cardResultsJson)
: {} : {}
); );
const [mode, setMode] = useState<"SEQUENTIAL" | "SHUFFLED">( const [isShuffled, setIsShuffled] = useState<boolean>(
initialProgress ? initialProgress.mode : "SEQUENTIAL" initialProgress ? initialProgress.mode === "SHUFFLED" : false
); );
const [started, setStarted] = useState(!!initialProgress); const [toastMessage, setToastMessage] = useState<string | null>(null);
// If progress is provided and index is already at or past the end, it means completed // If progress is provided and index is already at or past the end, it means completed
const [completed, setCompleted] = useState( const [completed, setCompleted] = useState(
@ -62,29 +62,33 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
const missedCount = Object.values(results).filter((r) => r === "missed").length; const missedCount = Object.values(results).filter((r) => r === "missed").length;
const totalGraded = correctCount + missedCount; const totalGraded = correctCount + missedCount;
// Start a study session // Auto-hide toast
function startSession(selectedMode: "SEQUENTIAL" | "SHUFFLED") { useEffect(() => {
setMode(selectedMode); if (toastMessage) {
let newOrder: string[]; const timer = setTimeout(() => setToastMessage(null), 3000);
if (selectedMode === "SHUFFLED") { return () => clearTimeout(timer);
newOrder = [...cards.map((c) => c.id)];
for (let i = newOrder.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[newOrder[i], newOrder[j]] = [newOrder[j], newOrder[i]];
} }
} else { }, [toastMessage]);
newOrder = cards.map((c) => c.id);
}
setOrder(newOrder);
setCurrentIndex(0);
setResults({});
setIsFlipped(false);
setHasFlippedOnce(false);
setCompleted(false);
setStarted(true);
// Save initial progress function toggleShuffle() {
saveProgress(newOrder, 0, {}, selectedMode); const newShuffled = !isShuffled;
setIsShuffled(newShuffled);
const remainingIds = order.slice(currentIndex);
let newRemaining: string[];
if (newShuffled) {
newRemaining = [...remainingIds].sort(() => Math.random() - 0.5);
setToastMessage("Card shuffle on");
} else {
const originalIndices = new Map(cards.map((c, i) => [c.id, i]));
newRemaining = [...remainingIds].sort((a, b) => (originalIndices.get(a) || 0) - (originalIndices.get(b) || 0));
setToastMessage("Card shuffle off");
}
const newOrder = [...order.slice(0, currentIndex), ...newRemaining];
setOrder(newOrder);
saveProgress(newOrder, currentIndex, results, newShuffled ? "SHUFFLED" : "SEQUENTIAL");
} }
// Grade the current card // Grade the current card
@ -92,6 +96,7 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
(grade: CardResult) => { (grade: CardResult) => {
if (!currentCard || !hasFlippedOnce) return; if (!currentCard || !hasFlippedOnce) return;
setToastMessage(null);
const newResults = { ...results, [currentCard.id]: grade }; const newResults = { ...results, [currentCard.id]: grade };
setResults(newResults); setResults(newResults);
@ -105,21 +110,21 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
if (currentIndex + 1 >= order.length) { if (currentIndex + 1 >= order.length) {
setCompleted(true); setCompleted(true);
saveProgress(order, currentIndex, newResults, mode); saveProgress(order, currentIndex, newResults, isShuffled ? "SHUFFLED" : "SEQUENTIAL");
} else { } else {
const nextIndex = currentIndex + 1; const nextIndex = currentIndex + 1;
setCurrentIndex(nextIndex); setCurrentIndex(nextIndex);
saveProgress(order, nextIndex, newResults, mode); saveProgress(order, nextIndex, newResults, isShuffled ? "SHUFFLED" : "SEQUENTIAL");
} }
}, 350); }, 350);
}, },
[currentCard, hasFlippedOnce, results, currentIndex, order, mode] [currentCard, hasFlippedOnce, results, currentIndex, order, isShuffled]
); );
// Keyboard shortcuts // Keyboard shortcuts
useEffect(() => { useEffect(() => {
function handleKeyDown(e: KeyboardEvent) { function handleKeyDown(e: KeyboardEvent) {
if (!started || completed) return; if (completed) return;
if (e.key === " " || e.key === "Enter" || e.key === "ArrowUp" || e.key === "ArrowDown") { if (e.key === " " || e.key === "Enter" || e.key === "ArrowUp" || e.key === "ArrowDown") {
e.preventDefault(); e.preventDefault();
@ -136,7 +141,7 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
window.addEventListener("keydown", handleKeyDown); window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown);
}, [started, completed, isFlipped, gradeCard]); }, [completed, isFlipped, gradeCard]);
// Touch handlers for swipe // Touch handlers for swipe
function handleTouchStart(e: React.TouchEvent) { function handleTouchStart(e: React.TouchEvent) {
@ -196,7 +201,19 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
// Restart handlers // Restart handlers
function restartFullSet() { function restartFullSet() {
startSession(mode); let newOrder: string[];
if (isShuffled) {
newOrder = [...cards.map((c) => c.id)].sort(() => Math.random() - 0.5);
} else {
newOrder = cards.map((c) => c.id);
}
setOrder(newOrder);
setCurrentIndex(0);
setResults({});
setIsFlipped(false);
setHasFlippedOnce(false);
setCompleted(false);
saveProgress(newOrder, 0, {}, isShuffled ? "SHUFFLED" : "SEQUENTIAL");
} }
function redoMissed() { function redoMissed() {
@ -206,15 +223,8 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
if (missedIds.length === 0) return; if (missedIds.length === 0) return;
const newOrder = mode === "SHUFFLED" const newOrder = isShuffled
? (() => { ? [...missedIds].sort(() => Math.random() - 0.5)
const arr = [...missedIds];
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
})()
: missedIds; : missedIds;
setOrder(newOrder); setOrder(newOrder);
@ -223,42 +233,7 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
setIsFlipped(false); setIsFlipped(false);
setHasFlippedOnce(false); setHasFlippedOnce(false);
setCompleted(false); setCompleted(false);
saveProgress(newOrder, 0, {}, mode); saveProgress(newOrder, 0, {}, isShuffled ? "SHUFFLED" : "SEQUENTIAL");
}
// Mode selection (not yet started)
if (!started) {
return (
<div className="flex flex-col items-center justify-center py-16">
<div className="bg-bg-surface rounded-2xl border border-border-light shadow-[var(--shadow-card)] p-8 max-w-md w-full text-center">
<div className="inline-flex items-center justify-center w-14 h-14 rounded-xl bg-bg-callout mb-4">
<svg className="w-7 h-7 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</div>
<h3 className="text-lg font-semibold text-text-heading mb-1">
{cards.length} cards ready
</h3>
<p className="text-sm text-text-secondary mb-6">
Choose how you&apos;d like to study
</p>
<div className="flex flex-col gap-3">
<button
onClick={() => startSession("SEQUENTIAL")}
className="w-full py-3 px-4 rounded-lg bg-primary text-white font-medium hover:bg-primary-hover transition-all duration-200 cursor-pointer"
>
Sequential Order
</button>
<button
onClick={() => startSession("SHUFFLED")}
className="w-full py-3 px-4 rounded-lg border border-border text-text-heading font-medium hover:bg-bg-surface-alt transition-all duration-200 cursor-pointer"
>
Shuffle Cards
</button>
</div>
</div>
</div>
);
} }
// Completed summary // Completed summary
@ -410,6 +385,15 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
</span> </span>
</div> </div>
)} )}
{/* Toast Notification */}
{toastMessage && (
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 z-30 animate-fade-in pointer-events-none">
<div className="bg-bg-surface-alt/95 backdrop-blur shadow-[var(--shadow-card)] text-text-heading text-sm font-semibold px-6 py-2.5 rounded-full whitespace-nowrap">
{toastMessage}
</div>
</div>
)}
</div> </div>
<div className="relative w-full mt-6"> <div className="relative w-full mt-6">
@ -425,7 +409,7 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
const newResults = { ...results }; const newResults = { ...results };
delete newResults[prevId]; delete newResults[prevId];
setResults(newResults); setResults(newResults);
saveProgress(order, prevIndex, newResults, mode); saveProgress(order, prevIndex, newResults, isShuffled ? "SHUFFLED" : "SEQUENTIAL");
}} }}
className="absolute left-0 top-0 p-2 rounded-full text-text-muted hover:bg-bg-surface-alt hover:text-text-heading transition-all duration-200 cursor-pointer z-10" className="absolute left-0 top-0 p-2 rounded-full text-text-muted hover:bg-bg-surface-alt hover:text-text-heading transition-all duration-200 cursor-pointer z-10"
title="Previous Card" title="Previous Card"
@ -461,6 +445,18 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
)} )}
</div> </div>
<button
onClick={toggleShuffle}
className={`absolute right-0 top-1 p-2 rounded-full hover:bg-bg-surface-alt transition-all duration-200 cursor-pointer z-10 ${
isShuffled ? "text-primary" : "text-text-muted hover:text-text-heading"
}`}
title={isShuffled ? "Turn shuffle off" : "Turn shuffle on"}
>
<svg className={`w-5 h-5 ${isShuffled ? "stroke-[3]" : "stroke-2"}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
</svg>
</button>
<div className="text-center mt-4 text-xs text-text-muted"> <div className="text-center mt-4 text-xs text-text-muted">
{hasFlippedOnce {hasFlippedOnce
? "← Missed · Got It →" ? "← Missed · Got It →"