"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( 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>( initialProgress && initialProgress.cardResultsJson ? JSON.parse(initialProgress.cardResultsJson) : {} ); const [isShuffled, setIsShuffled] = useState( initialProgress ? initialProgress.mode === "SHUFFLED" : false ); const [toastMessage, setToastMessage] = useState(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(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, 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 (

Set Complete!

{correctCount}
Correct
{missedCount}
Missed
{order.length}
Total
{correctCount > 0 && (
)}
{missedCount > 0 && ( )}
); } // Study view return (
{/* Running tally */}
{correctCount} ✓ {missedCount} ✗
{currentIndex + 1} of {order.length}
{/* Progress bar */}
{/* Card */} {currentCard && (
{ setToastMessage(null); setIsFlipped(!isFlipped); if (!isFlipped) setHasFlippedOnce(true); }} onTouchStart={handleTouchStart} onTouchMove={handleTouchMove} onTouchEnd={handleTouchEnd} style={{ transition: swipeClass ? "none" : "transform 0.15s ease-out" }} >
{/* Front */}
Front
{currentCard.front}

Tap or press Space to flip

{/* Back */}
Back
{currentCard.back}
{/* Grading Overlay */} {swipeClass && (
{swipeClass === "animate-swipe-left" ? "Still learning" : "Know"}
)} {/* Toast Notification */} {toastMessage && (
{toastMessage}
)}
{/* Primary Action Row (Grading) */}
{hasFlippedOnce && (
)}
{/* Secondary Action Row (Navigation/Settings) */}
{currentIndex > 0 ? ( ) :
}
{hasFlippedOnce ? "← Missed · Got It →" : "Space to flip"}
)}
); }