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,399 @@
"use client";
import { useState, useEffect } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { scoreQuestion } from "@/lib/scoring";
import { QuizResults } from "./QuizResults";
interface Option {
id: string;
text: string;
isCorrect: boolean;
}
interface Question {
id: string;
type: string;
prompt: string;
rationale: string;
category: string;
options: Option[];
}
interface QuizViewerProps {
quiz: {
id: string;
name: string;
questions: Question[];
progress: Array<{
mode: string;
currentIndex: number;
orderJson: string;
answersJson: string | null;
}>;
};
retakeIds: string[] | null;
isShared?: boolean;
onFinished: () => void;
}
export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: QuizViewerProps) {
const [order, setOrder] = useState<string[]>([]);
const [currentIndex, setCurrentIndex] = useState(0);
const [answers, setAnswers] = useState<Record<string, string[]>>({});
const [submittedAnswers, setSubmittedAnswers] = useState<string[]>([]);
const [shuffledOptions, setShuffledOptions] = useState<Record<string, Option[]>>({});
const [loading, setLoading] = useState(true);
const [resultsData, setResultsData] = useState<any | null>(null);
// Initialize session
useEffect(() => {
// Generate shuffled options once per session mount
const newShuffledOpts: Record<string, Option[]> = {};
for (const q of quiz.questions) {
newShuffledOpts[q.id] = [...q.options].sort(() => Math.random() - 0.5);
}
setShuffledOptions(newShuffledOpts);
// If retaking specific questions
if (retakeIds && retakeIds.length > 0) {
setOrder(retakeIds);
setCurrentIndex(0);
setAnswers({});
setSubmittedAnswers([]);
setLoading(false);
return;
}
// Otherwise, normal sequential logic checking for progress
const prog = quiz.progress?.find((p) => p.mode === "SEQUENTIAL");
if (prog) {
let savedOrder: string[] = [];
try {
savedOrder = JSON.parse(prog.orderJson);
} catch {
savedOrder = quiz.questions.map((q) => q.id);
}
let savedAnswers: Record<string, string[]> = {};
if (prog.answersJson) {
try {
savedAnswers = JSON.parse(prog.answersJson);
} catch {}
}
setOrder(savedOrder);
setCurrentIndex(Math.min(prog.currentIndex, savedOrder.length - 1));
setAnswers(savedAnswers);
setSubmittedAnswers(Object.keys(savedAnswers));
} else {
const freshOrder = quiz.questions.map((q) => q.id).sort(() => Math.random() - 0.5);
setOrder(freshOrder);
setCurrentIndex(0);
setAnswers({});
setSubmittedAnswers([]);
}
setLoading(false);
}, [quiz, retakeIds]);
// Persist progress as you go
function saveProgress(
idx: number,
ans: Record<string, string[]>
) {
if (retakeIds || isShared) return; // Don't persist retake partial state or shared mode into the main progress row
fetch("/api/progress", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
contentType: "QUIZ",
contentId: quiz.id,
mode: "SEQUENTIAL",
currentIndex: idx,
orderJson: JSON.stringify(order),
answersJson: JSON.stringify(ans),
}),
}).catch(() => {});
}
if (loading) return <div>Loading quiz...</div>;
const currentQId = order[currentIndex];
const currentQ = quiz.questions.find((q) => q.id === currentQId);
const isSubmitted = currentQId ? submittedAnswers.includes(currentQId) : false;
const currentSelections = currentQId ? answers[currentQId] || [] : [];
// Compute running score
let runningScore = 0;
let maxPossibleSoFar = 0;
submittedAnswers.forEach((qId) => {
const q = quiz.questions.find((x) => x.id === qId);
if (!q) return;
maxPossibleSoFar += 1; // 1 point per question
const formattedQ = {
id: q.id,
type: q.type as "MULTIPLE_CHOICE" | "SATA",
options: q.options,
};
runningScore += scoreQuestion(formattedQ, answers[qId] || []);
});
function toggleOption(optId: string) {
if (isSubmitted || !currentQId || !currentQ) return;
if (currentQ.type === "MULTIPLE_CHOICE") {
setAnswers({ ...answers, [currentQId]: [optId] });
} else {
const prev = answers[currentQId] || [];
const updated = prev.includes(optId)
? prev.filter((id) => id !== optId)
: [...prev, optId];
setAnswers({ ...answers, [currentQId]: updated });
}
}
function handleSubmit() {
if (isSubmitted || !currentQId) return;
const newSubmitted = [...submittedAnswers, currentQId];
setSubmittedAnswers(newSubmitted);
saveProgress(currentIndex, answers);
}
function handleNext() {
if (currentIndex + 1 < order.length) {
const nextIdx = currentIndex + 1;
setCurrentIndex(nextIdx);
saveProgress(nextIdx, answers);
}
}
async function handleFinish() {
// If shared, grade it locally and show results, don't ping backend
if (isShared) {
// Simulate an attempt object
let runningScore = 0;
let maxScore = quiz.questions.length; // Actually, depends on retakeIds, but shared doesn't support retakeIds typically
if (retakeIds) maxScore = retakeIds.length;
const scoredItems = (retakeIds || quiz.questions.map(q => q.id)).map(qId => {
const q = quiz.questions.find((x) => x.id === qId);
if (!q) return 0;
const formattedQ = {
id: q.id,
type: q.type as "MULTIPLE_CHOICE" | "SATA",
options: q.options,
};
return scoreQuestion(formattedQ, answers[qId] || []);
});
runningScore = scoredItems.reduce((acc, curr) => acc + curr, 0);
const attempt = {
id: "shared-attempt",
score: runningScore,
maxScore: maxScore,
answersJson: JSON.stringify(answers),
isPartialRetake: false,
completedAt: new Date().toISOString(),
};
setResultsData(attempt);
return;
}
// Send to backend
const isPartialRetake = !!retakeIds;
try {
const res = await fetch(`/api/quizzes/${quiz.id}/attempt`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
answersJson: JSON.stringify(answers),
isPartialRetake,
}),
});
if (res.ok) {
const attempt = await res.json();
// Clear progress
if (!isPartialRetake) {
await fetch("/api/progress", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
contentType: "QUIZ",
contentId: quiz.id,
mode: "SEQUENTIAL",
}),
});
}
setResultsData(attempt);
}
} catch (e) {
console.error(e);
}
}
if (resultsData) {
return (
<QuizResults
quiz={quiz}
attempt={resultsData}
onRetake={(missedIds: string[]) => {
// Restart this component with retakeIds
setOrder(missedIds);
setCurrentIndex(0);
setAnswers({});
setSubmittedAnswers([]);
setResultsData(null);
}}
onClose={onFinished}
/>
);
}
if (!currentQ) {
return <div>Question not found.</div>;
}
return (
<div className="flex flex-col items-center">
{/* Running Score Header */}
<div className="w-full max-w-2xl mb-6 flex items-center justify-between bg-bg-surface border border-border-light rounded-lg px-5 py-3 shadow-[var(--shadow-card)]">
<div>
<span className="text-sm font-medium text-text-secondary">Progress: </span>
<span className="text-sm font-bold text-text-heading">{currentIndex + 1} / {order.length}</span>
</div>
<div>
<span className="text-sm font-medium text-text-secondary">Score so far: </span>
<span className="text-sm font-bold text-primary">
{runningScore.toFixed(2)} / {maxPossibleSoFar}
</span>
</div>
</div>
{/* Question Card */}
<div className="w-full max-w-2xl bg-bg-surface rounded-2xl border border-border-light shadow-[var(--shadow-card)] overflow-hidden mb-8">
{/* Header (Type & Category) */}
<div className="bg-bg-surface-alt border-b border-border-light px-6 py-4 flex items-center justify-between">
<span className="text-xs font-bold uppercase tracking-wider text-primary">
{currentQ.type === "MULTIPLE_CHOICE" ? "Multiple Choice" : "Select All That Apply"}
</span>
<span className="inline-flex px-2.5 py-1 rounded-full bg-badge-bg text-badge-text text-xs font-medium">
{currentQ.category}
</span>
</div>
<div className="p-6 md:p-8">
<div className="markdown-content text-lg text-text-heading mb-8 leading-relaxed font-medium">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{currentQ.prompt}
</ReactMarkdown>
</div>
<div className="space-y-3">
{(shuffledOptions[currentQId] || currentQ.options).map((opt) => {
const isSelected = currentSelections.includes(opt.id);
let stateClass = "border-border hover:border-primary/50 cursor-pointer";
let icon = null;
if (isSubmitted) {
stateClass = "cursor-default opacity-80";
if (opt.isCorrect) {
stateClass = "border-success bg-success-bg/30 text-text-heading";
icon = (
<svg className="w-5 h-5 text-success flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
</svg>
);
} else if (isSelected && !opt.isCorrect) {
// Selected incorrectly
stateClass = "border-error bg-error-bg/30 text-text-heading";
icon = (
<svg className="w-5 h-5 text-error flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M6 18L18 6M6 6l12 12" />
</svg>
);
} else {
// Not selected, not correct
stateClass = "border-border-light bg-bg-surface-alt/50";
}
} else if (isSelected) {
stateClass = "border-primary bg-primary/5 ring-1 ring-primary";
}
return (
<div
key={opt.id}
onClick={() => toggleOption(opt.id)}
className={`flex items-start gap-4 p-4 rounded-xl border-2 transition-all duration-200 ${stateClass}`}
>
<div className="pt-0.5">
{icon ? icon : (
<div className={`w-5 h-5 rounded ${currentQ.type === 'SATA' ? 'border' : 'border rounded-full'} flex items-center justify-center transition-colors ${
isSelected ? 'border-primary bg-primary text-white' : 'border-border-light bg-bg-surface'
}`}>
{isSelected && (
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
)}
</div>
)}
</div>
<div className="flex-1 text-text-body font-medium leading-snug">
{opt.text}
</div>
</div>
);
})}
</div>
{/* Rationale shown after submission */}
{isSubmitted && (
<div className="mt-8 bg-bg-callout border border-primary/20 rounded-xl p-5 animate-slide-up">
<h4 className="text-sm font-bold text-text-heading uppercase tracking-wider mb-2">Rationale</h4>
<div className="markdown-content text-sm text-text-body">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{currentQ.rationale}
</ReactMarkdown>
</div>
</div>
)}
</div>
{/* Action bar */}
<div className="bg-bg-surface-alt px-6 py-5 border-t border-border-light flex justify-end">
{!isSubmitted ? (
<button
onClick={handleSubmit}
disabled={currentSelections.length === 0}
className="px-6 py-2.5 rounded-lg bg-primary text-white font-medium hover:bg-primary-hover disabled:opacity-50 transition-all duration-200 cursor-pointer"
>
Submit Answer
</button>
) : currentIndex + 1 < order.length ? (
<button
onClick={handleNext}
className="px-6 py-2.5 rounded-lg bg-text-heading text-white font-medium hover:bg-text-body transition-all duration-200 cursor-pointer"
>
Next Question
</button>
) : (
<button
onClick={handleFinish}
className="px-6 py-2.5 rounded-lg bg-success text-white font-medium hover:bg-success/90 transition-all duration-200 cursor-pointer"
>
Finish Quiz
</button>
)}
</div>
</div>
</div>
);
}