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) {
const [order, setOrder] = useState<string[]>(
initialProgress ? JSON.parse(initialProgress.orderJson) : []
initialProgress ? JSON.parse(initialProgress.orderJson) : cards.map((c) => c.id)
);
const [currentIndex, setCurrentIndex] = useState(
initialProgress ? initialProgress.currentIndex : 0
@ -40,10 +40,10 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
? JSON.parse(initialProgress.cardResultsJson)
: {}
);
const [mode, setMode] = useState<"SEQUENTIAL" | "SHUFFLED">(
initialProgress ? initialProgress.mode : "SEQUENTIAL"
const [isShuffled, setIsShuffled] = useState<boolean>(
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
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 totalGraded = correctCount + missedCount;
// Start a study session
function startSession(selectedMode: "SEQUENTIAL" | "SHUFFLED") {
setMode(selectedMode);
let newOrder: string[];
if (selectedMode === "SHUFFLED") {
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 {
newOrder = cards.map((c) => c.id);
// Auto-hide toast
useEffect(() => {
if (toastMessage) {
const timer = setTimeout(() => setToastMessage(null), 3000);
return () => clearTimeout(timer);
}
setOrder(newOrder);
setCurrentIndex(0);
setResults({});
setIsFlipped(false);
setHasFlippedOnce(false);
setCompleted(false);
setStarted(true);
}, [toastMessage]);
// Save initial progress
saveProgress(newOrder, 0, {}, selectedMode);
function toggleShuffle() {
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
@ -92,6 +96,7 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
(grade: CardResult) => {
if (!currentCard || !hasFlippedOnce) return;
setToastMessage(null);
const newResults = { ...results, [currentCard.id]: grade };
setResults(newResults);
@ -105,21 +110,21 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
if (currentIndex + 1 >= order.length) {
setCompleted(true);
saveProgress(order, currentIndex, newResults, mode);
saveProgress(order, currentIndex, newResults, isShuffled ? "SHUFFLED" : "SEQUENTIAL");
} else {
const nextIndex = currentIndex + 1;
setCurrentIndex(nextIndex);
saveProgress(order, nextIndex, newResults, mode);
saveProgress(order, nextIndex, newResults, isShuffled ? "SHUFFLED" : "SEQUENTIAL");
}
}, 350);
},
[currentCard, hasFlippedOnce, results, currentIndex, order, mode]
[currentCard, hasFlippedOnce, results, currentIndex, order, isShuffled]
);
// Keyboard shortcuts
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if (!started || completed) return;
if (completed) return;
if (e.key === " " || e.key === "Enter" || e.key === "ArrowUp" || e.key === "ArrowDown") {
e.preventDefault();
@ -136,7 +141,7 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [started, completed, isFlipped, gradeCard]);
}, [completed, isFlipped, gradeCard]);
// Touch handlers for swipe
function handleTouchStart(e: React.TouchEvent) {
@ -196,7 +201,19 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
// Restart handlers
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() {
@ -206,15 +223,8 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
if (missedIds.length === 0) return;
const newOrder = mode === "SHUFFLED"
? (() => {
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;
})()
const newOrder = isShuffled
? [...missedIds].sort(() => Math.random() - 0.5)
: missedIds;
setOrder(newOrder);
@ -223,42 +233,7 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
setIsFlipped(false);
setHasFlippedOnce(false);
setCompleted(false);
saveProgress(newOrder, 0, {}, mode);
}
// 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>
);
saveProgress(newOrder, 0, {}, isShuffled ? "SHUFFLED" : "SEQUENTIAL");
}
// Completed summary
@ -410,6 +385,15 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
</span>
</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 className="relative w-full mt-6">
@ -425,7 +409,7 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
const newResults = { ...results };
delete newResults[prevId];
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"
title="Previous Card"
@ -461,6 +445,18 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
)}
</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">
{hasFlippedOnce
? "← Missed · Got It →"