Initial working commit
Some checks failed
Automated Container Build / build-and-push (push) Failing after 7s

This commit is contained in:
Elijah 2026-06-27 19:19:38 -07:00
parent 666ceb7325
commit b7ce314f01
105 changed files with 35510 additions and 11 deletions

View 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&apos;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>
);
}