Initial working commit
Some checks failed
Automated Container Build / build-and-push (push) Failing after 7s
Some checks failed
Automated Container Build / build-and-push (push) Failing after 7s
This commit is contained in:
parent
666ceb7325
commit
b7ce314f01
105 changed files with 35510 additions and 11 deletions
449
src/components/flashcards/FlashcardViewer.tsx
Normal file
449
src/components/flashcards/FlashcardViewer.tsx
Normal file
|
|
@ -0,0 +1,449 @@
|
|||
"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) : []
|
||||
);
|
||||
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 [mode, setMode] = useState<"SEQUENTIAL" | "SHUFFLED">(
|
||||
initialProgress ? initialProgress.mode : "SEQUENTIAL"
|
||||
);
|
||||
const [started, setStarted] = useState(!!initialProgress);
|
||||
|
||||
// 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;
|
||||
|
||||
// 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);
|
||||
}
|
||||
setOrder(newOrder);
|
||||
setCurrentIndex(0);
|
||||
setResults({});
|
||||
setIsFlipped(false);
|
||||
setHasFlippedOnce(false);
|
||||
setCompleted(false);
|
||||
setStarted(true);
|
||||
|
||||
// Save initial progress
|
||||
saveProgress(newOrder, 0, {}, selectedMode);
|
||||
}
|
||||
|
||||
// Grade the current card
|
||||
const gradeCard = useCallback(
|
||||
(grade: CardResult) => {
|
||||
if (!currentCard || !hasFlippedOnce) return;
|
||||
|
||||
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, mode);
|
||||
} else {
|
||||
const nextIndex = currentIndex + 1;
|
||||
setCurrentIndex(nextIndex);
|
||||
saveProgress(order, nextIndex, newResults, mode);
|
||||
}
|
||||
}, 350);
|
||||
},
|
||||
[currentCard, hasFlippedOnce, results, currentIndex, order, mode]
|
||||
);
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (!started || completed) return;
|
||||
|
||||
if (e.key === " " || e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
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);
|
||||
}, [started, completed, isFlipped, gradeCard]);
|
||||
|
||||
// Touch handlers for swipe
|
||||
function handleTouchStart(e: React.TouchEvent) {
|
||||
if (!isFlipped) 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() {
|
||||
startSession(mode);
|
||||
}
|
||||
|
||||
function redoMissed() {
|
||||
const missedIds = Object.entries(results)
|
||||
.filter(([, r]) => r === "missed")
|
||||
.map(([id]) => id);
|
||||
|
||||
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;
|
||||
})()
|
||||
: missedIds;
|
||||
|
||||
setOrder(newOrder);
|
||||
setCurrentIndex(0);
|
||||
setResults({});
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
// 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-alt rounded-full h-1.5 mt-2">
|
||||
<div
|
||||
className="h-1.5 rounded-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={() => {
|
||||
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-500" : ""} ${
|
||||
isFlipped ? "rotate-y-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 or press Space to flip
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Back */}
|
||||
<div className="absolute inset-0 backface-hidden rotate-y-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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Grading buttons (visible after first flip) */}
|
||||
{hasFlippedOnce && (
|
||||
<div className="flex items-center justify-center gap-4 mt-6 animate-fade-in">
|
||||
<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"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Keyboard hint */}
|
||||
<div className="text-center mt-4 text-xs text-text-muted">
|
||||
{hasFlippedOnce
|
||||
? "← Missed · Got It →"
|
||||
: "Space to flip"}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue