muthuk1's picture
Convert OpenMAIC from Next.js to React (Vite)
f56a29b verified
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
import { motion, AnimatePresence } from 'motion/react';
import {
PieChart,
CheckCircle2,
XCircle,
RotateCcw,
ChevronRight,
Check,
BookOpenText,
Loader2,
Sparkles,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/hooks/use-i18n';
import { getCurrentModelConfig } from '@/lib/utils/model-config';
import { createLogger } from '@/lib/logger';
const log = createLogger('QuizView');
import type { QuizQuestion } from '@/lib/types/stage';
import { useDraftCache } from '@/lib/hooks/use-draft-cache';
import { SpeechButton } from '@/components/audio/speech-button';
import { gradeChoiceQuestions, isShortAnswer, type QuestionResult } from '@/lib/quiz/grading';
import {
clearSubmitted,
draftKey,
readSubmittedState,
writeSubmittedAnswers,
writeSubmittedResults,
type SubmittedState,
} from '@/lib/quiz/persistence';
// ─── Types ──────────────────────────────────────────────────────────────────
type Phase = 'not_started' | 'answering' | 'grading' | 'reviewing';
interface QuizViewProps {
readonly questions: QuizQuestion[];
readonly sceneId: string;
}
/** Call /api/quiz-grade for a single short-answer question. */
async function gradeShortAnswerQuestion(
q: QuizQuestion,
userAnswer: string,
language: string,
): Promise<QuestionResult> {
const pts = q.points ?? 1;
try {
const modelConfig = getCurrentModelConfig();
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'x-model': modelConfig.modelString,
'x-api-key': modelConfig.apiKey,
};
if (modelConfig.baseUrl) headers['x-base-url'] = modelConfig.baseUrl;
if (modelConfig.providerType) headers['x-provider-type'] = modelConfig.providerType;
const res = await fetch('/api/quiz-grade', {
method: 'POST',
headers,
body: JSON.stringify({
question: q.question,
userAnswer,
points: pts,
commentPrompt: q.commentPrompt,
language,
}),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = (await res.json()) as { score: number; comment: string };
const earned = Math.max(0, Math.min(pts, data.score));
return {
questionId: q.id,
correct: earned >= pts * 0.8,
status: earned >= pts * 0.8 ? 'correct' : 'incorrect',
earned,
aiComment: data.comment,
};
} catch (err) {
log.error('[quiz-view] AI grading failed for', q.id, err);
// Fallback: give half credit
return {
questionId: q.id,
correct: null,
status: 'incorrect',
earned: Math.round(pts * 0.5),
aiComment:
language === 'zh-CN'
? 'θ―„εˆ†ζœεŠ‘ζš‚ζ—ΆδΈε―η”¨οΌŒε·²η»™δΊˆεŸΊη‘€εˆ†γ€‚'
: 'Grading service unavailable. Base score given.',
};
}
}
// ─── Sub-components ─────────────────────────────────────────────────────────
function QuizCover({
questionCount,
totalPoints,
onStart,
}: {
questionCount: number;
totalPoints: number;
onStart: () => void;
}) {
const { t } = useI18n();
return (
<div className="w-full h-full flex flex-col items-center justify-center gap-4 relative overflow-hidden">
{/* Background decoration */}
<div className="absolute top-0 right-0 p-6 opacity-[0.03]">
<PieChart className="w-52 h-52 text-violet-500" />
</div>
<div className="absolute bottom-0 left-0 p-6 opacity-[0.02]">
<BookOpenText className="w-40 h-40 text-violet-500 rotate-12" />
</div>
<motion.div
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ type: 'spring', stiffness: 200, damping: 20 }}
className="w-16 h-16 bg-gradient-to-br from-violet-100 to-purple-50 dark:from-violet-900/50 dark:to-purple-900/30 rounded-2xl flex items-center justify-center shadow-lg shadow-violet-100 dark:shadow-violet-900/30 ring-1 ring-violet-200/50 dark:ring-violet-700/50"
>
<PieChart className="w-8 h-8 text-violet-500" />
</motion.div>
<motion.div
initial={{ y: 10, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ delay: 0.1 }}
className="text-center z-10"
>
<h3 className="text-xl font-bold text-gray-800 dark:text-gray-100">{t('quiz.title')}</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">{t('quiz.subtitle')}</p>
</motion.div>
<motion.div
initial={{ y: 10, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ delay: 0.2 }}
className="flex gap-5 text-sm z-10"
>
<div className="flex items-center gap-2 text-gray-500 dark:text-gray-400">
<div className="w-7 h-7 rounded-lg bg-violet-50 dark:bg-violet-900/30 flex items-center justify-center">
<BookOpenText className="w-3.5 h-3.5 text-violet-500" />
</div>
<span>
{questionCount} {t('quiz.questionsCount')}
</span>
</div>
<div className="flex items-center gap-2 text-gray-500 dark:text-gray-400">
<div className="w-7 h-7 rounded-lg bg-violet-50 dark:bg-violet-900/30 flex items-center justify-center">
<PieChart className="w-3.5 h-3.5 text-violet-500" />
</div>
<span>
{t('quiz.totalPrefix')} {totalPoints} {t('quiz.pointsSuffix')}
</span>
</div>
</motion.div>
<motion.button
initial={{ y: 10, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ delay: 0.3 }}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={onStart}
className="mt-1 px-8 py-2.5 bg-gradient-to-r from-violet-500 to-purple-500 text-white rounded-full font-medium shadow-lg shadow-violet-200/50 dark:shadow-violet-900/50 hover:shadow-violet-300/50 transition-shadow z-10 flex items-center gap-2"
>
{t('quiz.startQuiz')}
<ChevronRight className="w-4 h-4" />
</motion.button>
</div>
);
}
function SingleChoiceQuestion({
question,
index,
value,
onChange,
disabled,
result,
}: {
question: QuizQuestion;
index: number;
value?: string;
onChange: (value: string) => void;
disabled?: boolean;
result?: QuestionResult;
}) {
const isReview = !!result;
return (
<QuestionCard question={question} index={index} result={result}>
<div className="grid gap-2">
{question.options?.map((opt) => {
const selected = value === opt.value;
const isCorrectOpt = isReview && question.answer?.includes(opt.value);
const isWrong = isReview && selected && result?.status === 'incorrect';
return (
<button
key={opt.value}
disabled={disabled}
onClick={() => !disabled && onChange(opt.value)}
className={cn(
'flex items-center gap-3 px-4 py-3 rounded-xl border text-left transition-all text-sm',
// Default state
!isReview &&
!selected &&
'border-gray-200 dark:border-gray-600 hover:border-violet-200 dark:hover:border-violet-700 hover:bg-violet-50/50 dark:hover:bg-violet-900/30',
!isReview &&
selected &&
'border-violet-400 bg-violet-50 dark:bg-violet-900/30 ring-1 ring-violet-200 dark:ring-violet-700',
// Review states
isReview &&
isCorrectOpt &&
'border-emerald-400 bg-emerald-50 dark:bg-emerald-900/30',
isReview &&
isWrong &&
!isCorrectOpt &&
'border-red-300 bg-red-50 dark:bg-red-900/30',
isReview &&
!isCorrectOpt &&
!selected &&
'border-gray-100 dark:border-gray-700 opacity-60',
disabled && !isReview && 'cursor-default',
)}
>
<span
className={cn(
'w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold shrink-0 transition-colors',
!isReview &&
!selected &&
'bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400',
!isReview && selected && 'bg-violet-500 text-white',
isReview && isCorrectOpt && 'bg-emerald-500 text-white',
isReview && isWrong && !isCorrectOpt && 'bg-red-400 text-white',
isReview &&
!isCorrectOpt &&
!selected &&
'bg-gray-100 dark:bg-gray-700 text-gray-400 dark:text-gray-500',
)}
>
{opt.value}
</span>
<span
className={cn(
'flex-1',
isReview && !isCorrectOpt && !selected && 'text-gray-400 dark:text-gray-500',
)}
>
{opt.label}
</span>
{isReview && isCorrectOpt && (
<CheckCircle2 className="w-5 h-5 text-emerald-500 shrink-0" />
)}
{isReview && isWrong && !isCorrectOpt && (
<XCircle className="w-5 h-5 text-red-400 shrink-0" />
)}
</button>
);
})}
</div>
</QuestionCard>
);
}
function MultipleChoiceQuestion({
question,
index,
value,
onChange,
disabled,
result,
}: {
question: QuizQuestion;
index: number;
value?: string[];
onChange: (value: string[]) => void;
disabled?: boolean;
result?: QuestionResult;
}) {
const isReview = !!result;
const selected = value ?? [];
const toggle = (optValue: string) => {
if (disabled) return;
if (selected.includes(optValue)) {
onChange(selected.filter((v) => v !== optValue));
} else {
onChange([...selected, optValue]);
}
};
const { t } = useI18n();
return (
<QuestionCard question={question} index={index} result={result}>
{!isReview && (
<p className="text-xs text-gray-400 dark:text-gray-500 mb-2">
{t('quiz.multipleChoiceHint')}
</p>
)}
<div className="grid gap-2">
{question.options?.map((opt) => {
const isSelected = selected.includes(opt.value);
const isCorrectOpt = isReview && question.answer?.includes(opt.value);
const isWrong = isReview && isSelected && !isCorrectOpt;
return (
<button
key={opt.value}
disabled={disabled}
onClick={() => toggle(opt.value)}
className={cn(
'flex items-center gap-3 px-4 py-3 rounded-xl border text-left transition-all text-sm',
!isReview &&
!isSelected &&
'border-gray-200 dark:border-gray-600 hover:border-violet-200 dark:hover:border-violet-700 hover:bg-violet-50/50 dark:hover:bg-violet-900/30',
!isReview &&
isSelected &&
'border-violet-400 bg-violet-50 dark:bg-violet-900/30 ring-1 ring-violet-200 dark:ring-violet-700',
isReview &&
isCorrectOpt &&
'border-emerald-400 bg-emerald-50 dark:bg-emerald-900/30',
isReview && isWrong && 'border-red-300 bg-red-50 dark:bg-red-900/30',
isReview &&
!isCorrectOpt &&
!isSelected &&
'border-gray-100 dark:border-gray-700 opacity-60',
disabled && !isReview && 'cursor-default',
)}
>
<span
className={cn(
'w-7 h-7 rounded-lg flex items-center justify-center text-xs font-bold shrink-0 transition-colors',
!isReview &&
!isSelected &&
'bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400',
!isReview && isSelected && 'bg-violet-500 text-white',
isReview && isCorrectOpt && 'bg-emerald-500 text-white',
isReview && isWrong && 'bg-red-400 text-white',
isReview &&
!isCorrectOpt &&
!isSelected &&
'bg-gray-100 dark:bg-gray-700 text-gray-400 dark:text-gray-500',
)}
>
{!isReview && isSelected ? <Check className="w-3.5 h-3.5" /> : opt.value}
</span>
<span
className={cn(
'flex-1',
isReview && !isCorrectOpt && !isSelected && 'text-gray-400 dark:text-gray-500',
)}
>
{opt.label}
</span>
{isReview && isCorrectOpt && (
<CheckCircle2 className="w-5 h-5 text-emerald-500 shrink-0" />
)}
{isReview && isWrong && <XCircle className="w-5 h-5 text-red-400 shrink-0" />}
</button>
);
})}
</div>
</QuestionCard>
);
}
function ShortAnswerQuestion({
question,
index,
value,
onChange,
disabled,
result,
}: {
question: QuizQuestion;
index: number;
value?: string;
onChange: (value: string) => void;
disabled?: boolean;
result?: QuestionResult;
}) {
const isReview = !!result;
const { t } = useI18n();
// Ref to track latest value for voice transcription append
const valueRef = useRef(value);
useEffect(() => {
valueRef.current = value;
}, [value]);
return (
<QuestionCard question={question} index={index} result={result}>
{!isReview ? (
<div className="relative">
<textarea
value={value ?? ''}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
placeholder={t('quiz.inputPlaceholder')}
className="w-full min-h-[100px] p-3 pb-10 rounded-xl border border-gray-200 dark:border-gray-600 text-sm resize-none focus:outline-none focus:border-violet-300 dark:focus:border-violet-600 focus:ring-2 focus:ring-violet-100 dark:focus:ring-violet-900/50 transition-all disabled:bg-gray-50 dark:disabled:bg-gray-800 disabled:text-gray-500 dark:bg-gray-800/50 dark:text-gray-200 dark:placeholder:text-gray-500"
/>
<SpeechButton
size="sm"
disabled={disabled}
className="absolute bottom-3 left-3"
onTranscription={(text) => {
const cur = valueRef.current ?? '';
onChange(cur + (cur ? ' ' : '') + text);
}}
/>
<span className="absolute bottom-3 right-3 text-xs text-gray-300 dark:text-gray-600">
{(value ?? '').length} {t('quiz.charCount')}
</span>
</div>
) : (
<div className="space-y-3">
<div className="p-3 rounded-xl bg-gray-50 dark:bg-gray-800/50 border border-gray-100 dark:border-gray-700 text-sm text-gray-700 dark:text-gray-300">
<p className="text-xs text-gray-400 dark:text-gray-500 mb-1">{t('quiz.yourAnswer')}</p>
{value || (
<span className="text-gray-400 dark:text-gray-500 italic">
{t('quiz.notAnswered')}
</span>
)}
</div>
{result.aiComment && (
<div className="flex items-start gap-2 px-3 py-2 rounded-lg bg-violet-50 dark:bg-violet-900/30 border border-violet-100 dark:border-violet-800">
<Sparkles className="w-4 h-4 text-violet-500 shrink-0 mt-0.5" />
<div>
<p className="text-xs font-medium text-violet-600 dark:text-violet-400 mb-0.5">
{t('quiz.aiComment')}
</p>
<p className="text-xs text-violet-600/80 dark:text-violet-400/80">
{result.aiComment}
</p>
</div>
<span className="ml-auto text-xs font-bold text-violet-600 dark:text-violet-400 shrink-0">
{result.earned}/{question.points ?? 1}
{t('quiz.pointsSuffix')}
</span>
</div>
)}
</div>
)}
</QuestionCard>
);
}
function QuestionCard({
question,
index,
result,
children,
}: {
question: QuizQuestion;
index: number;
result?: QuestionResult;
children: React.ReactNode;
}) {
const { t } = useI18n();
const isReview = !!result;
const pts = question.points ?? 1;
return (
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.05 }}
className={cn(
'bg-white dark:bg-gray-800 rounded-2xl border p-5 relative overflow-hidden',
!isReview && 'border-gray-150 dark:border-gray-700 shadow-sm',
isReview &&
result.status === 'correct' &&
'border-emerald-200 dark:border-emerald-800 shadow-sm shadow-emerald-50 dark:shadow-emerald-900/20',
isReview &&
result.status === 'incorrect' &&
'border-red-200 dark:border-red-800 shadow-sm shadow-red-50 dark:shadow-red-900/20',
)}
>
{/* Left accent */}
<div
className={cn(
'absolute left-0 top-0 bottom-0 w-1 rounded-l-2xl',
!isReview && 'bg-violet-400',
isReview && result.status === 'correct' && 'bg-emerald-400',
isReview && result.status === 'incorrect' && 'bg-red-400',
)}
/>
{/* Header */}
<div className="flex items-start justify-between mb-3">
<div className="flex items-start gap-3">
<span
className={cn(
'w-7 h-7 rounded-lg flex items-center justify-center text-xs font-bold shrink-0',
!isReview &&
'bg-violet-100 dark:bg-violet-900/50 text-violet-600 dark:text-violet-400',
isReview &&
result.status === 'correct' &&
'bg-emerald-100 dark:bg-emerald-900/50 text-emerald-600 dark:text-emerald-400',
isReview &&
result.status === 'incorrect' &&
'bg-red-100 dark:bg-red-900/50 text-red-600 dark:text-red-400',
)}
>
{index + 1}
</span>
<div>
<p className="text-sm font-medium text-gray-800 dark:text-gray-100 leading-relaxed">
{question.question}
</p>
<p className="text-xs text-gray-400 mt-0.5">
{question.type === 'single'
? t('quiz.singleChoice')
: question.type === 'multiple'
? t('quiz.multipleChoice')
: t('quiz.shortAnswer')}
{' Β· '}
{pts} {t('quiz.pointsSuffix')}
</p>
</div>
</div>
{isReview && (
<div className="shrink-0 ml-2">
{result.status === 'correct' && <CheckCircle2 className="w-6 h-6 text-emerald-500" />}
{result.status === 'incorrect' && <XCircle className="w-6 h-6 text-red-400" />}
</div>
)}
</div>
{/* Body */}
{children}
{/* Analysis (review only) */}
{isReview && question.analysis && (
<div className="mt-3 p-3 rounded-lg bg-blue-50/70 dark:bg-blue-900/30 border border-blue-100 dark:border-blue-800 text-xs text-blue-700 dark:text-blue-300 leading-relaxed">
<span className="font-medium">{t('quiz.analysis')}</span>
{question.analysis}
</div>
)}
</motion.div>
);
}
function ScoreBanner({
score,
total,
results,
}: {
score: number;
total: number;
results: QuestionResult[];
}) {
const { t } = useI18n();
const pct = total > 0 ? Math.round((score / total) * 100) : 0;
const correctCount = results.filter((r) => r.status === 'correct').length;
const incorrectCount = results.filter((r) => r.status === 'incorrect').length;
const color = pct >= 80 ? 'emerald' : pct >= 60 ? 'amber' : 'red';
const colorMap = {
emerald: {
bg: 'from-emerald-500 to-teal-500',
shadow: 'shadow-emerald-200/50 dark:shadow-emerald-900/50',
ring: 'bg-emerald-400/30',
text: t('quiz.excellent'),
},
amber: {
bg: 'from-amber-500 to-yellow-500',
shadow: 'shadow-amber-200/50 dark:shadow-amber-900/50',
ring: 'bg-amber-400/30',
text: t('quiz.keepGoing'),
},
red: {
bg: 'from-red-500 to-rose-500',
shadow: 'shadow-red-200/50 dark:shadow-red-900/50',
ring: 'bg-red-400/30',
text: t('quiz.needsReview'),
},
};
const c = colorMap[color];
return (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
className={cn('rounded-2xl p-6 bg-gradient-to-r text-white shadow-lg', c.bg, c.shadow)}
>
<div className="flex items-center justify-between">
<div>
<p className="text-white/80 text-sm font-medium">{c.text}</p>
<div className="flex items-baseline gap-1 mt-1">
<span className="text-4xl font-black">{score}</span>
<span className="text-white/60 text-lg">/ {total}</span>
</div>
<div className="flex gap-3 mt-3 text-xs">
<span className="flex items-center gap-1">
<CheckCircle2 className="w-3.5 h-3.5" /> {correctCount} {t('quiz.correct')}
</span>
<span className="flex items-center gap-1">
<XCircle className="w-3.5 h-3.5" /> {incorrectCount} {t('quiz.incorrect')}
</span>
</div>
</div>
{/* Percentage ring */}
<div className="relative w-20 h-20">
<svg className="w-20 h-20 -rotate-90" viewBox="0 0 80 80">
<circle
cx="40"
cy="40"
r="34"
fill="none"
stroke="rgba(255,255,255,0.2)"
strokeWidth="6"
/>
<motion.circle
cx="40"
cy="40"
r="34"
fill="none"
stroke="white"
strokeWidth="6"
strokeLinecap="round"
strokeDasharray={`${2 * Math.PI * 34}`}
initial={{ strokeDashoffset: 2 * Math.PI * 34 }}
animate={{ strokeDashoffset: 2 * Math.PI * 34 * (1 - pct / 100) }}
transition={{ duration: 1, ease: 'easeOut', delay: 0.3 }}
/>
</svg>
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-lg font-black">{pct}%</span>
</div>
</div>
</div>
</motion.div>
);
}
// ─── Main Component ─────────────────────────────────────────────────────────
export function QuizView({ questions, sceneId }: QuizViewProps) {
const { t, locale } = useI18n();
// Rehydrate submitted state from localStorage on first mount. Runs once.
const [initialSubmitted] = useState<SubmittedState>(() => readSubmittedState(sceneId));
const [phase, setPhase] = useState<Phase>(() => {
if (initialSubmitted?.kind === 'reviewing') return 'reviewing';
if (initialSubmitted?.kind === 'answering') return 'answering';
return 'not_started';
});
const [answers, setAnswers] = useState<Record<string, string | string[]>>(
() => initialSubmitted?.answers ?? {},
);
const [results, setResults] = useState<QuestionResult[]>(() =>
initialSubmitted?.kind === 'reviewing' ? initialSubmitted.results : [],
);
// Draft cache for quiz answers, keyed by sceneId to isolate across classrooms
const {
cachedValue: cachedAnswers,
updateCache: updateAnswersCache,
clearCache: clearAnswersCache,
} = useDraftCache<Record<string, string | string[]>>({
key: draftKey(sceneId),
});
// Restore cached draft answers (only when there is no submitted state).
const [prevCachedAnswers, setPrevCachedAnswers] = useState(cachedAnswers);
if (cachedAnswers !== prevCachedAnswers) {
setPrevCachedAnswers(cachedAnswers);
if (
!initialSubmitted &&
cachedAnswers &&
Object.keys(cachedAnswers).length > 0 &&
phase === 'not_started'
) {
setAnswers(cachedAnswers);
setPhase('answering');
}
}
const totalPoints = useMemo(
() => questions.reduce((sum, q) => sum + (q.points ?? 1), 0),
[questions],
);
const allAnswered = useMemo(() => {
return questions.every((q) => {
const a = answers[q.id];
if (!a) return false;
if (Array.isArray(a)) return a.length > 0;
return (a as string).trim().length > 0;
});
}, [questions, answers]);
const handleSetAnswer = useCallback(
(questionId: string, value: string | string[]) => {
setAnswers((prev) => {
const next = { ...prev, [questionId]: value };
updateAnswersCache(next);
return next;
});
},
[updateAnswersCache],
);
const handleSubmit = useCallback(() => {
setPhase('grading');
clearAnswersCache();
writeSubmittedAnswers(sceneId, answers);
}, [clearAnswersCache, answers, sceneId]);
// When entering grading phase, grade choice questions locally + call API for short-answer
useEffect(() => {
if (phase !== 'grading') return;
let cancelled = false;
(async () => {
// 1. Grade choice questions locally (instant)
const choiceResults = gradeChoiceQuestions(questions, answers);
// 2. Grade short-answer questions via AI API (parallel)
const shortAnswerQs = questions.filter(isShortAnswer);
const aiResults = await Promise.all(
shortAnswerQs.map((q) =>
gradeShortAnswerQuestion(q, (answers[q.id] as string) ?? '', locale),
),
);
if (cancelled) return;
// 3. Merge results in original question order
const allResultsMap = new Map<string, QuestionResult>();
for (const r of [...choiceResults, ...aiResults]) {
allResultsMap.set(r.questionId, r);
}
const ordered = questions.map((q) => allResultsMap.get(q.id)!).filter(Boolean);
setResults(ordered);
setPhase('reviewing');
writeSubmittedResults(sceneId, ordered);
})();
return () => {
cancelled = true;
};
}, [phase, questions, answers, locale, sceneId]);
const handleRetry = useCallback(() => {
setPhase('not_started');
setAnswers({});
setResults([]);
clearAnswersCache();
clearSubmitted(sceneId);
}, [clearAnswersCache, sceneId]);
const earnedScore = useMemo(() => results.reduce((sum, r) => sum + r.earned, 0), [results]);
const resultMap = useMemo(() => {
const map: Record<string, QuestionResult> = {};
results.forEach((r) => {
map[r.questionId] = r;
});
return map;
}, [results]);
return (
<div className="w-full h-full bg-gradient-to-b from-gray-50 to-white dark:from-gray-900 dark:to-gray-900 overflow-hidden flex flex-col">
<AnimatePresence mode="wait">
{phase === 'not_started' && (
<motion.div
key="cover"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0, x: -20 }}
className="flex-1"
>
<QuizCover
questionCount={questions.length}
totalPoints={totalPoints}
onStart={() => setPhase('answering')}
/>
</motion.div>
)}
{phase === 'answering' && (
<motion.div
key="answering"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
className="flex-1 flex flex-col min-h-0"
>
{/* Header bar */}
<div className="flex items-center justify-between px-6 py-3 border-b border-gray-100 dark:border-gray-700 bg-white/80 dark:bg-gray-900/80 backdrop-blur shrink-0">
<div className="flex items-center gap-2">
<PieChart className="w-4 h-4 text-violet-500" />
<span className="text-sm font-semibold text-gray-700 dark:text-gray-200">
{t('quiz.answering')}
</span>
<span className="text-xs text-gray-400 ml-1">
{
Object.keys(answers).filter((k) => {
const a = answers[k];
if (Array.isArray(a)) return a.length > 0;
return typeof a === 'string' && a.trim().length > 0;
}).length
}{' '}
/ {questions.length}
</span>
</div>
<button
onClick={handleSubmit}
disabled={!allAnswered}
className={cn(
'px-4 py-1.5 rounded-lg text-xs font-medium transition-all',
allAnswered
? 'bg-gradient-to-r from-violet-500 to-purple-500 text-white shadow-sm hover:shadow-md hover:shadow-violet-200/50 dark:hover:shadow-violet-900/50 active:scale-[0.97]'
: 'bg-gray-100 dark:bg-gray-700 text-gray-400 dark:text-gray-500 cursor-not-allowed',
)}
>
{t('quiz.submitAnswers')}
</button>
</div>
{/* Questions */}
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
{questions.map((q, i) => {
if (q.type === 'single') {
return (
<SingleChoiceQuestion
key={q.id}
question={q}
index={i}
value={answers[q.id] as string | undefined}
onChange={(v) => handleSetAnswer(q.id, v)}
/>
);
}
if (q.type === 'multiple') {
return (
<MultipleChoiceQuestion
key={q.id}
question={q}
index={i}
value={answers[q.id] as string[] | undefined}
onChange={(v) => handleSetAnswer(q.id, v)}
/>
);
}
return (
<ShortAnswerQuestion
key={q.id}
question={q}
index={i}
value={answers[q.id] as string | undefined}
onChange={(v) => handleSetAnswer(q.id, v)}
/>
);
})}
</div>
</motion.div>
)}
{phase === 'grading' && (
<motion.div
key="grading"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="flex-1 flex flex-col items-center justify-center gap-5"
>
<motion.div
animate={{ rotate: 360 }}
transition={{ repeat: Infinity, duration: 1.5, ease: 'linear' }}
>
<Loader2 className="w-10 h-10 text-violet-500" />
</motion.div>
<div className="text-center">
<p className="text-base font-semibold text-gray-700 dark:text-gray-200">
{t('quiz.aiGrading')}
</p>
<p className="text-sm text-gray-400 mt-1">{t('quiz.aiGradingWait')}</p>
</div>
<div className="flex gap-1 mt-2">
{[0, 1, 2].map((i) => (
<motion.div
key={i}
className="w-2 h-2 rounded-full bg-violet-400"
animate={{ opacity: [0.3, 1, 0.3] }}
transition={{
repeat: Infinity,
duration: 1.2,
delay: i * 0.2,
}}
/>
))}
</div>
</motion.div>
)}
{phase === 'reviewing' && (
<motion.div
key="reviewing"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
className="flex-1 flex flex-col min-h-0"
>
{/* Header bar */}
<div className="flex items-center justify-between px-6 py-3 border-b border-gray-100 dark:border-gray-700 bg-white/80 dark:bg-gray-900/80 backdrop-blur shrink-0">
<div className="flex items-center gap-2">
<CheckCircle2 className="w-4 h-4 text-emerald-500" />
<span className="text-sm font-semibold text-gray-700 dark:text-gray-200">
{t('quiz.quizReport')}
</span>
</div>
<button
onClick={handleRetry}
className="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400 hover:text-violet-600 dark:hover:text-violet-400 transition-colors"
>
<RotateCcw className="w-3.5 h-3.5" />
{t('quiz.retry')}
</button>
</div>
{/* Results */}
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
<ScoreBanner score={earnedScore} total={totalPoints} results={results} />
{questions.map((q, i) => {
const r = resultMap[q.id];
if (q.type === 'single') {
return (
<SingleChoiceQuestion
key={q.id}
question={q}
index={i}
value={answers[q.id] as string | undefined}
onChange={() => {}}
disabled
result={r}
/>
);
}
if (q.type === 'multiple') {
return (
<MultipleChoiceQuestion
key={q.id}
question={q}
index={i}
value={answers[q.id] as string[] | undefined}
onChange={() => {}}
disabled
result={r}
/>
);
}
return (
<ShortAnswerQuestion
key={q.id}
question={q}
index={i}
value={answers[q.id] as string | undefined}
onChange={() => {}}
disabled
result={r}
/>
);
})}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}