Quizify / frontend /src /App.jsx
hetsheta's picture
Migrate frontend to React + Vite with glassmorphism UI
5d00a33
Raw
History Blame Contribute Delete
11.9 kB
import { useState } from 'react';
import './App.css';
import { parseDocument, generateQuiz, submitQuiz } from './api';
import DropZone from './components/DropZone';
import StepHeader from './components/StepHeader';
import QuestionCard from './components/QuestionCard';
import ResultsView from './components/ResultsView';
import Alert from './components/Alert';
// ── small helpers ──────────────────────────────────────────────
function NumberInput({ label, value, onChange, min = 1, max = 20 }) {
return (
<div className="form-group">
<label className="form-label" htmlFor="num-questions">{label}</label>
<div className="form-number-wrap">
<input
id="num-questions"
type="number"
className="form-input"
value={value}
min={min}
max={max}
onChange={(e) => {
const v = Math.min(max, Math.max(min, Number(e.target.value)));
onChange(v);
}}
/>
<div className="num-btns">
<button
className="num-btn"
onClick={() => onChange(Math.min(max, value + 1))}
type="button"
>▲</button>
<button
className="num-btn"
onClick={() => onChange(Math.max(min, value - 1))}
type="button"
>▼</button>
</div>
</div>
</div>
);
}
function SelectInput({ label, id, value, onChange, options }) {
return (
<div className="form-group">
<label className="form-label" htmlFor={id}>{label}</label>
<select
id={id}
className="form-select"
value={value}
onChange={(e) => onChange(e.target.value)}
>
{options.map((o) => <option key={o} value={o}>{o}</option>)}
</select>
</div>
);
}
function LoadingBlock({ text }) {
return (
<div style={{ padding: '3rem 0', textAlign: 'center' }}>
<div className="loading-spinner" />
<div className="loading-text">{text}</div>
</div>
);
}
// ── progress strip ────────────────────────────────────────────
function QuizProgress({ answered, total }) {
const pct = total ? (answered / total) * 100 : 0;
return (
<div className="quiz-progress-wrap">
<div className="quiz-progress-label">
<span>Progress</span>
<span>{answered}/{total} answered</span>
</div>
<div className="quiz-progress-bar-bg">
<div className="quiz-progress-bar-fill" style={{ width: `${pct}%` }} />
</div>
</div>
);
}
// ── main app ──────────────────────────────────────────────────
export default function App() {
// wizard state
const [file, setFile] = useState(null);
const [docReady, setDocReady] = useState(false);
const [quiz, setQuiz] = useState(null); // { quiz_id, questions }
const [answers, setAnswers] = useState({}); // { [i]: string }
const [result, setResult] = useState(null);
// settings
const [numQ, setNumQ] = useState(5);
const [difficulty, setDifficulty] = useState('Medium');
const [qType, setQType] = useState('MCQ');
// async state
const [parsing, setParsing] = useState(false);
const [generating, setGenerating] = useState(false);
const [submitting, setSubmitting] = useState(false);
// errors
const [parseErr, setParseErr] = useState('');
const [genErr, setGenErr] = useState('');
const [submitErr, setSubmitErr] = useState('');
const [warnMsg, setWarnMsg] = useState('');
// ── handlers ──
const handleParse = async () => {
if (!file) return;
setParseErr('');
setParsing(true);
try {
await parseDocument(file);
setDocReady(true);
} catch {
setParseErr('Failed to connect to backend. Is the server running?');
} finally {
setParsing(false);
}
};
const handleGenerate = async () => {
setGenErr('');
setGenerating(true);
setAnswers({});
setResult(null);
try {
const data = await generateQuiz({
topic: 'full document',
num_questions: numQ,
difficulty,
question_type: qType,
});
setQuiz(data);
} catch {
setGenErr('Quiz generation failed. Please try again.');
} finally {
setGenerating(false);
}
};
const handleAnswer = (idx, val) => {
setAnswers((prev) => ({ ...prev, [idx]: val }));
setWarnMsg('');
};
const handleSubmit = async () => {
if (!quiz) return;
setWarnMsg('');
setSubmitErr('');
// validation
const unanswered = quiz.questions.reduce((acc, _, i) => {
if (!answers[i]?.trim()) acc.push(i + 1);
return acc;
}, []);
if (unanswered.length) {
setWarnMsg(
`Please answer all questions before submitting. (${unanswered.length} remaining: Q${unanswered.join(', Q')})`
);
return;
}
const payload = quiz.questions.map((_, i) => ({
question_index: i,
user_answer: answers[i] || '',
}));
setSubmitting(true);
try {
const res = await submitQuiz(quiz.quiz_id, payload);
setResult(res);
} catch (e) {
if (e.message === 'Quiz expired') {
setSubmitErr('Quiz session expired. Please generate a new quiz.');
} else {
setSubmitErr('Submission failed. Please try again.');
}
} finally {
setSubmitting(false);
}
};
const handleReset = () => {
setQuiz(null);
setAnswers({});
setResult(null);
setGenErr('');
setSubmitErr('');
setWarnMsg('');
};
const handleFullReset = () => {
handleReset();
setDocReady(false);
setFile(null);
setParseErr('');
};
const answeredCount = quiz
? quiz.questions.filter((_, i) => answers[i]?.trim()).length
: 0;
// ── render ──
return (
<div className="app">
{/* HERO */}
<header className="hero" role="banner">
<h1 className="hero-logo">Quizify</h1>
<p className="hero-sub">AI-Powered Knowledge Assessment</p>
<div className="hero-line" />
</header>
<div className="main-grid">
{/* ══ LEFT PANEL ══ */}
<aside>
{/* STEP 1 */}
<div className="glass-panel">
<StepHeader num="01" title="Upload Document" />
<DropZone onFile={setFile} disabled={parsing || docReady} />
{docReady && (
<div className="status-chip chip-ready">
<div className="chip-dot" />
Document Ready
</div>
)}
{parseErr && <Alert type="error">{parseErr}</Alert>}
{!docReady && (
<>
<div className="spacer-sm" />
<button
id="btn-parse"
className="btn"
onClick={handleParse}
disabled={!file || parsing}
>
{parsing
? <><div className="btn-spinner" /> Parsing…</>
: 'Parse Document →'}
</button>
</>
)}
{docReady && (
<>
<div className="spacer-sm" />
<button
id="btn-new-doc"
className="btn btn-ghost"
onClick={handleFullReset}
style={{ fontSize: '.78rem' }}
>
↩ Upload new document
</button>
</>
)}
</div>
{/* STEP 2 */}
{docReady && (
<div className="glass-panel">
<StepHeader num="02" title="Configure Quiz" />
<NumberInput
label="Number of Questions"
value={numQ}
onChange={setNumQ}
min={1}
max={20}
/>
<SelectInput
label="Difficulty Level"
id="difficulty"
value={difficulty}
onChange={setDifficulty}
options={['Easy', 'Medium', 'Hard']}
/>
<SelectInput
label="Question Type"
id="qtype"
value={qType}
onChange={setQType}
options={['MCQ', 'True/False', 'Short Answer']}
/>
{genErr && <Alert type="error">{genErr}</Alert>}
<div className="spacer-sm" />
<button
id="btn-generate"
className="btn"
onClick={handleGenerate}
disabled={generating}
>
{generating
? <><div className="btn-spinner" /> Crafting quiz…</>
: '✦ Generate Quiz'}
</button>
{quiz && !result && (
<>
<div className="spacer-sm" />
<button
id="btn-reset-quiz"
className="btn btn-ghost"
onClick={handleReset}
>
&nbsp; Reset Quiz
</button>
</>
)}
</div>
)}
</aside>
{/* ══ RIGHT PANEL ══ */}
<main>
{/* Loading states */}
{generating && <LoadingBlock text="Crafting your quiz…" />}
{submitting && <LoadingBlock text="Evaluating your answers…" />}
{/* QUIZ QUESTIONS */}
{!generating && !submitting && quiz && !result && (
<div>
<StepHeader num="03" title="Answer the Questions" />
<QuizProgress answered={answeredCount} total={quiz.questions.length} />
{quiz.questions.map((q, i) => (
<QuestionCard
key={i}
index={i}
total={quiz.questions.length}
question={q}
answer={answers[i] || ''}
onAnswer={(val) => handleAnswer(i, val)}
/>
))}
{warnMsg && <Alert type="warning">{warnMsg}</Alert>}
{submitErr && <Alert type="error">{submitErr}</Alert>}
<div className="spacer-sm" />
<button
id="btn-submit"
className="btn"
onClick={handleSubmit}
disabled={submitting}
>
Submit Quiz →
</button>
</div>
)}
{/* RESULTS */}
{!submitting && result && (
<ResultsView result={result} onReset={handleReset} />
)}
{/* EMPTY — no document */}
{!quiz && !result && !generating && !docReady && (
<div className="empty-state">
<div className="empty-icon">📄</div>
<div className="empty-title">Upload a document to begin your session</div>
<div className="empty-sub">Supports PDF · DOCX · PPTX · TXT · MD</div>
</div>
)}
{/* EMPTY — document ready, not generated */}
{!quiz && !result && !generating && docReady && (
<div className="empty-state">
<div className="empty-icon"></div>
<div className="empty-title">Configure your quiz settings and generate</div>
<div className="empty-sub">Choose difficulty, type &amp; question count</div>
</div>
)}
</main>
</div>
</div>
);
}