| 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'; |
|
|
| |
| 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> |
| ); |
| } |
|
|
| |
| 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> |
| ); |
| } |
|
|
| |
| export default function App() { |
| |
| const [file, setFile] = useState(null); |
| const [docReady, setDocReady] = useState(false); |
| const [quiz, setQuiz] = useState(null); |
| const [answers, setAnswers] = useState({}); |
| const [result, setResult] = useState(null); |
|
|
| |
| const [numQ, setNumQ] = useState(5); |
| const [difficulty, setDifficulty] = useState('Medium'); |
| const [qType, setQType] = useState('MCQ'); |
|
|
| |
| const [parsing, setParsing] = useState(false); |
| const [generating, setGenerating] = useState(false); |
| const [submitting, setSubmitting] = useState(false); |
|
|
| |
| const [parseErr, setParseErr] = useState(''); |
| const [genErr, setGenErr] = useState(''); |
| const [submitErr, setSubmitErr] = useState(''); |
| const [warnMsg, setWarnMsg] = useState(''); |
|
|
| |
| 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(''); |
|
|
| |
| 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; |
|
|
| |
| 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> |
|
|
| {} |
| {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} |
| > |
| ↺ Reset Quiz |
| </button> |
| </> |
| )} |
| </div> |
| )} |
| </aside> |
|
|
| {} |
| <main> |
| {} |
| {generating && <LoadingBlock text="Crafting your quiz…" />} |
| {submitting && <LoadingBlock text="Evaluating your answers…" />} |
|
|
| {} |
| {!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> |
| )} |
|
|
| {} |
| {!submitting && result && ( |
| <ResultsView result={result} onReset={handleReset} /> |
| )} |
|
|
| {} |
| {!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> |
| )} |
|
|
| {} |
| {!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 & question count</div> |
| </div> |
| )} |
| </main> |
| </div> |
| </div> |
| ); |
| } |
|
|