feat: Redesign flashcard study screen with live shuffle toggle
All checks were successful
Automated Container Build / build-and-push (push) Successful in 58s
All checks were successful
Automated Container Build / build-and-push (push) Successful in 58s
This commit is contained in:
parent
c34da6941e
commit
e4985d07e1
1 changed files with 73 additions and 77 deletions
|
|
@ -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'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 →"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue