Study/src/components/flashcards/FlashcardViewer.tsx
Elijah 7d3831149c
All checks were successful
Automated Container Build / build-and-push (push) Successful in 58s
style: Enhance progress bar visibility across flashcards and quizzes
2026-06-28 13:26:52 -07:00

479 lines
19 KiB
TypeScript

"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
interface Card {
id: string;
front: string;
back: string;
}
interface ProgressData {
mode: "SEQUENTIAL" | "SHUFFLED";
currentIndex: number;
orderJson: string;
cardResultsJson: string | null;
}
interface FlashcardViewerProps {
cards: Card[];
deckId: string;
isShared?: boolean;
initialProgress?: ProgressData | null;
}
type CardResult = "correct" | "missed";
export function FlashcardViewer({ cards, deckId, isShared = false, initialProgress }: FlashcardViewerProps) {
const [order, setOrder] = useState<string[]>(
initialProgress ? JSON.parse(initialProgress.orderJson) : cards.map((c) => c.id)
);
const [currentIndex, setCurrentIndex] = useState(
initialProgress ? initialProgress.currentIndex : 0
);
const [isFlipped, setIsFlipped] = useState(false);
const [hasFlippedOnce, setHasFlippedOnce] = useState(false);
const [results, setResults] = useState<Record<string, CardResult>>(
initialProgress && initialProgress.cardResultsJson
? JSON.parse(initialProgress.cardResultsJson)
: {}
);
const [isShuffled, setIsShuffled] = useState<boolean>(
initialProgress ? initialProgress.mode === "SHUFFLED" : false
);
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(
initialProgress
? initialProgress.currentIndex >= JSON.parse(initialProgress.orderJson).length
: false
);
const [swipeClass, setSwipeClass] = useState("");
const cardRef = useRef<HTMLDivElement>(null);
// Touch/drag state
const dragRef = useRef({ startX: 0, currentX: 0, isDragging: false });
const currentCard = order.length > 0 ? cards.find((c) => c.id === order[currentIndex]) : null;
const correctCount = Object.values(results).filter((r) => r === "correct").length;
const missedCount = Object.values(results).filter((r) => r === "missed").length;
const totalGraded = correctCount + missedCount;
// Auto-hide toast
useEffect(() => {
if (toastMessage) {
const timer = setTimeout(() => setToastMessage(null), 3000);
return () => clearTimeout(timer);
}
}, [toastMessage]);
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
const gradeCard = useCallback(
(grade: CardResult) => {
if (!currentCard || !hasFlippedOnce) return;
setToastMessage(null);
const newResults = { ...results, [currentCard.id]: grade };
setResults(newResults);
// Animate
setSwipeClass(grade === "correct" ? "animate-swipe-right" : "animate-swipe-left");
setTimeout(() => {
setSwipeClass("");
setIsFlipped(false);
setHasFlippedOnce(false);
if (currentIndex + 1 >= order.length) {
setCompleted(true);
saveProgress(order, currentIndex, newResults, isShuffled ? "SHUFFLED" : "SEQUENTIAL");
} else {
const nextIndex = currentIndex + 1;
setCurrentIndex(nextIndex);
saveProgress(order, nextIndex, newResults, isShuffled ? "SHUFFLED" : "SEQUENTIAL");
}
}, 350);
},
[currentCard, hasFlippedOnce, results, currentIndex, order, isShuffled]
);
// Keyboard shortcuts
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if (completed) return;
if (e.key === " " || e.key === "Enter" || e.key === "ArrowUp" || e.key === "ArrowDown") {
e.preventDefault();
setToastMessage(null);
setIsFlipped((prev) => {
if (!prev) setHasFlippedOnce(true);
return !prev;
});
} else if (e.key === "ArrowRight" && hasFlippedOnce) {
gradeCard("correct");
} else if (e.key === "ArrowLeft" && hasFlippedOnce) {
gradeCard("missed");
}
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [completed, isFlipped, gradeCard]);
// Touch handlers for swipe
function handleTouchStart(e: React.TouchEvent) {
if (!hasFlippedOnce) return;
dragRef.current = {
startX: e.touches[0].clientX,
currentX: e.touches[0].clientX,
isDragging: true,
};
}
function handleTouchMove(e: React.TouchEvent) {
if (!dragRef.current.isDragging) return;
dragRef.current.currentX = e.touches[0].clientX;
const dx = dragRef.current.currentX - dragRef.current.startX;
if (cardRef.current) {
cardRef.current.style.transform = `translateX(${dx}px) rotate(${dx * 0.05}deg)`;
}
}
function handleTouchEnd() {
if (!dragRef.current.isDragging) return;
dragRef.current.isDragging = false;
const dx = dragRef.current.currentX - dragRef.current.startX;
if (cardRef.current) {
cardRef.current.style.transform = "";
}
if (Math.abs(dx) > 80) {
gradeCard(dx > 0 ? "correct" : "missed");
}
}
// Save progress (debounced / fire-and-forget)
function saveProgress(
orderArr: string[],
index: number,
cardResults: Record<string, CardResult>,
m: "SEQUENTIAL" | "SHUFFLED"
) {
if (isShared) return;
fetch("/api/progress", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
contentType: "DECK",
contentId: deckId,
mode: m,
currentIndex: index,
orderJson: JSON.stringify(orderArr),
cardResultsJson: JSON.stringify(cardResults),
}),
}).catch(() => {}); // fire-and-forget
}
// Restart handlers
function restartFullSet() {
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() {
const missedIds = Object.entries(results)
.filter(([, r]) => r === "missed")
.map(([id]) => id);
if (missedIds.length === 0) return;
const newOrder = isShuffled
? [...missedIds].sort(() => Math.random() - 0.5)
: missedIds;
setOrder(newOrder);
setCurrentIndex(0);
setResults({});
setIsFlipped(false);
setHasFlippedOnce(false);
setCompleted(false);
saveProgress(newOrder, 0, {}, isShuffled ? "SHUFFLED" : "SEQUENTIAL");
}
// Completed summary
if (completed) {
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-success-bg mb-4">
<svg className="w-7 h-7 text-success" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<h3 className="text-lg font-semibold text-text-heading mb-2">Set Complete!</h3>
<div className="flex justify-center gap-6 mb-6">
<div className="text-center">
<div className="text-2xl font-bold text-success">{correctCount}</div>
<div className="text-xs text-text-muted">Correct</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-error">{missedCount}</div>
<div className="text-xs text-text-muted">Missed</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-text-heading">{order.length}</div>
<div className="text-xs text-text-muted">Total</div>
</div>
</div>
{correctCount > 0 && (
<div className="w-full bg-bg-surface-alt rounded-full h-2 mb-6">
<div
className="h-2 rounded-full bg-success transition-all duration-500"
style={{ width: `${(correctCount / order.length) * 100}%` }}
/>
</div>
)}
<div className="flex flex-col gap-3">
<button
onClick={restartFullSet}
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"
>
Restart Full Set
</button>
{missedCount > 0 && (
<button
onClick={redoMissed}
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"
>
Redo Missed Only ({missedCount} {missedCount === 1 ? "card" : "cards"})
</button>
)}
</div>
</div>
</div>
);
}
// Study view
return (
<div className="flex flex-col items-center">
{/* Running tally */}
<div className="w-full max-w-4xl mb-6">
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-3">
<span className="text-success font-medium">{correctCount} </span>
<span className="text-error font-medium">{missedCount} </span>
</div>
<span className="text-text-muted">
{currentIndex + 1} of {order.length}
</span>
</div>
{/* Progress bar */}
<div className="w-full bg-bg-surface rounded-full h-2 mt-2 border border-border-light/50 overflow-hidden">
<div
className="h-full bg-primary transition-all duration-300"
style={{ width: `${((currentIndex + 1) / order.length) * 100}%` }}
/>
</div>
</div>
{/* Card */}
{currentCard && (
<div className="w-full max-w-4xl">
<div
ref={cardRef}
className={`perspective-1000 cursor-pointer select-none relative ${swipeClass}`}
onClick={() => {
setToastMessage(null);
setIsFlipped(!isFlipped);
if (!isFlipped) setHasFlippedOnce(true);
}}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
style={{ transition: swipeClass ? "none" : "transform 0.15s ease-out" }}
>
<div
key={currentCard.id}
className={`relative w-full min-h-[450px] md:min-h-[550px] preserve-3d ${!swipeClass ? "transition-transform duration-300" : ""} ${
isFlipped ? "rotate-x-180" : ""
}`}
>
{/* Front */}
<div className="absolute inset-0 backface-hidden bg-bg-surface rounded-2xl border border-border-light shadow-[var(--shadow-card)] p-8 md:p-12 flex flex-col items-center justify-center">
<span className="absolute top-3 right-4 text-xs font-medium text-text-muted uppercase tracking-wider">
Front
</span>
<div className="markdown-content text-center text-xl md:text-3xl text-text-heading leading-relaxed w-full">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{currentCard.front}
</ReactMarkdown>
</div>
<p className="mt-6 text-xs text-text-muted">
Tap <span className="hidden md:inline">or press Space </span>to flip
</p>
</div>
{/* Back */}
<div className="absolute inset-0 backface-hidden rotate-x-180 bg-bg-surface rounded-2xl border border-border-light shadow-[var(--shadow-card)] p-8 md:p-12 flex flex-col items-center justify-center">
<span className="absolute top-3 right-4 text-xs font-medium text-text-muted uppercase tracking-wider">
Back
</span>
<div className="markdown-content text-center text-lg md:text-xl text-text-body leading-relaxed max-h-[400px] md:max-h-[450px] overflow-y-auto w-full">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{currentCard.back}
</ReactMarkdown>
</div>
</div>
</div>
{/* Grading Overlay */}
{swipeClass && (
<div
className={`absolute inset-0 z-20 flex items-center justify-center rounded-2xl border-2 md:border-4 transition-colors duration-150 ${
swipeClass === "animate-swipe-left"
? "border-error bg-bg-surface/40 backdrop-blur-[2px]"
: "border-success bg-bg-surface/40 backdrop-blur-[2px]"
}`}
>
<span
className={`text-3xl md:text-5xl font-bold px-8 py-4 rounded-3xl -rotate-12 uppercase tracking-wider shadow-lg ${
swipeClass === "animate-swipe-left"
? "text-error bg-error-bg/90"
: "text-success bg-success-bg/90"
}`}
>
{swipeClass === "animate-swipe-left" ? "Still learning" : "Know"}
</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="w-full mt-6">
{/* Primary Action Row (Grading) */}
<div className={`flex items-center justify-center gap-4 min-h-[52px] mb-4 relative z-10 ${!hasFlippedOnce ? 'pointer-events-none' : ''}`}>
{hasFlippedOnce && (
<div className="flex items-center gap-2 md:gap-4 animate-fade-in pointer-events-auto">
<button
onClick={() => gradeCard("missed")}
className="flex items-center gap-2 px-6 py-3 rounded-xl border-2 border-error/30 text-error font-medium hover:bg-error-bg transition-all duration-200 cursor-pointer bg-bg-base/80 backdrop-blur"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
Missed
</button>
<button
onClick={() => gradeCard("correct")}
className="flex items-center gap-2 px-6 py-3 rounded-xl border-2 border-success/30 text-success font-medium hover:bg-success-bg transition-all duration-200 cursor-pointer bg-bg-base/80 backdrop-blur"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
Got It
</button>
</div>
)}
</div>
{/* Secondary Action Row (Navigation/Settings) */}
<div className={`flex items-center justify-between w-full px-2 transition-all duration-300 relative z-20 ${!hasFlippedOnce ? '-mt-[68px]' : ''}`}>
{currentIndex > 0 ? (
<button
onClick={() => {
const prevIndex = currentIndex - 1;
setCurrentIndex(prevIndex);
setIsFlipped(false);
setHasFlippedOnce(false);
const prevId = order[prevIndex];
const newResults = { ...results };
delete newResults[prevId];
setResults(newResults);
saveProgress(order, prevIndex, newResults, isShuffled ? "SHUFFLED" : "SEQUENTIAL");
}}
className="p-2 rounded-full text-text-muted hover:bg-bg-surface-alt hover:text-text-heading transition-all duration-200 cursor-pointer bg-bg-base/50"
title="Previous Card"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
</button>
) : <div className="w-9 h-9" />}
<button
onClick={toggleShuffle}
className={`p-2 rounded-full transition-all duration-200 cursor-pointer bg-bg-base/50 ${
isShuffled
? "text-primary hover:bg-primary/10"
: "text-text-muted hover:bg-bg-surface-alt hover:text-text-heading"
}`}
title={isShuffled ? "Turn off shuffle" : "Shuffle remaining"}
>
<svg className={`w-5 h-5 ${isShuffled ? "stroke-[2.5]" : "stroke-2"}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M16 3h5v5M4 20L21 3M21 16v5h-5M15 15l6 6M4 4l5 5" />
</svg>
</button>
</div>
</div>
<div className="text-center mt-4 text-xs text-text-muted">
{hasFlippedOnce
? "← Missed · Got It →"
: "Space to flip"}
</div>
</div>
)}
</div>
);
}