All checks were successful
Automated Container Build / build-and-push (push) Successful in 58s
479 lines
19 KiB
TypeScript
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>
|
|
);
|
|
}
|