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) {
|
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 {
|
|
||||||
newOrder = cards.map((c) => c.id);
|
|
||||||
}
|
}
|
||||||
setOrder(newOrder);
|
}, [toastMessage]);
|
||||||
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'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 →"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue