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
399
src/components/quizzes/QuizViewer.tsx
Normal file
399
src/components/quizzes/QuizViewer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue