Some checks failed
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-school (push) Successful in 44s
CI / test-go-edu-search (push) Successful in 51s
CI / test-python-klausur (push) Failing after 2m44s
CI / test-python-agent-core (push) Successful in 33s
CI / test-nodejs-website (push) Successful in 34s
New feature: After OCR vocabulary extraction, users can generate interactive
learning modules (flashcards, quiz, type trainer) with one click.
Frontend (studio-v2):
- Fortune Sheet spreadsheet editor tab in vocab-worksheet
- "Lernmodule generieren" button in ExportTab
- /learn page with unit overview and exercise type cards
- /learn/[unitId]/flashcards — Flip-card trainer with Leitner spaced repetition
- /learn/[unitId]/quiz — Multiple choice quiz with explanations
- /learn/[unitId]/type — Type-in trainer with Levenshtein distance feedback
- AudioButton component using Web Speech API for EN+DE TTS
Backend (klausur-service):
- vocab_learn_bridge.py: Converts VocabularyEntry[] to analysis_data format
- POST /sessions/{id}/generate-learning-unit endpoint
Backend (backend-lehrer):
- generate-qa, generate-mc, generate-cloze endpoints on learning units
- get-qa/mc/cloze data retrieval endpoints
- Leitner progress update + next review items endpoints
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
127 lines
3.9 KiB
TypeScript
127 lines
3.9 KiB
TypeScript
'use client'
|
|
|
|
import React, { useState, useCallback } from 'react'
|
|
|
|
interface Option {
|
|
id: string
|
|
text: string
|
|
}
|
|
|
|
interface QuizQuestionProps {
|
|
question: string
|
|
options: Option[]
|
|
correctAnswer: string
|
|
explanation?: string
|
|
questionNumber: number
|
|
totalQuestions: number
|
|
onAnswer: (correct: boolean) => void
|
|
isDark: boolean
|
|
}
|
|
|
|
export function QuizQuestion({
|
|
question,
|
|
options,
|
|
correctAnswer,
|
|
explanation,
|
|
questionNumber,
|
|
totalQuestions,
|
|
onAnswer,
|
|
isDark,
|
|
}: QuizQuestionProps) {
|
|
const [selected, setSelected] = useState<string | null>(null)
|
|
const [revealed, setRevealed] = useState(false)
|
|
|
|
const handleSelect = useCallback((optionId: string) => {
|
|
if (revealed) return
|
|
setSelected(optionId)
|
|
setRevealed(true)
|
|
|
|
const isCorrect = optionId === correctAnswer
|
|
setTimeout(() => {
|
|
onAnswer(isCorrect)
|
|
setSelected(null)
|
|
setRevealed(false)
|
|
}, isCorrect ? 1000 : 2500)
|
|
}, [revealed, correctAnswer, onAnswer])
|
|
|
|
const getOptionStyle = (optionId: string) => {
|
|
if (!revealed) {
|
|
return isDark
|
|
? 'bg-white/10 border-white/20 hover:bg-white/20 hover:border-white/30 text-white'
|
|
: 'bg-white border-slate-200 hover:bg-slate-50 hover:border-slate-300 text-slate-900'
|
|
}
|
|
|
|
if (optionId === correctAnswer) {
|
|
return isDark
|
|
? 'bg-green-500/20 border-green-400 text-green-200'
|
|
: 'bg-green-50 border-green-500 text-green-800'
|
|
}
|
|
|
|
if (optionId === selected && optionId !== correctAnswer) {
|
|
return isDark
|
|
? 'bg-red-500/20 border-red-400 text-red-200'
|
|
: 'bg-red-50 border-red-500 text-red-800'
|
|
}
|
|
|
|
return isDark
|
|
? 'bg-white/5 border-white/10 text-white/40'
|
|
: 'bg-slate-50 border-slate-200 text-slate-400'
|
|
}
|
|
|
|
return (
|
|
<div className="w-full max-w-lg mx-auto space-y-6">
|
|
{/* Progress */}
|
|
<div className="flex items-center justify-between">
|
|
<span className={`text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
|
|
Frage {questionNumber} / {totalQuestions}
|
|
</span>
|
|
<div className="w-32 h-1.5 rounded-full bg-white/10 overflow-hidden">
|
|
<div
|
|
className="h-full rounded-full bg-gradient-to-r from-purple-500 to-pink-500 transition-all"
|
|
style={{ width: `${(questionNumber / totalQuestions) * 100}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Question */}
|
|
<div className={`p-6 rounded-2xl ${isDark ? 'bg-white/10 backdrop-blur-xl border border-white/20' : 'bg-white shadow-lg border border-slate-200'}`}>
|
|
<p className={`text-xl font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
|
{question}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Options */}
|
|
<div className="space-y-3">
|
|
{options.map((opt, idx) => (
|
|
<button
|
|
key={opt.id}
|
|
onClick={() => handleSelect(opt.id)}
|
|
disabled={revealed}
|
|
className={`w-full p-4 rounded-xl border-2 text-left transition-all flex items-center gap-3 ${getOptionStyle(opt.id)}`}
|
|
>
|
|
<span className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold flex-shrink-0 ${
|
|
revealed && opt.id === correctAnswer
|
|
? 'bg-green-500 text-white'
|
|
: revealed && opt.id === selected
|
|
? 'bg-red-500 text-white'
|
|
: isDark ? 'bg-white/10' : 'bg-slate-100'
|
|
}`}>
|
|
{String.fromCharCode(65 + idx)}
|
|
</span>
|
|
<span className="text-base">{opt.text}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Explanation */}
|
|
{revealed && explanation && (
|
|
<div className={`p-4 rounded-xl ${isDark ? 'bg-blue-500/10 border border-blue-500/20' : 'bg-blue-50 border border-blue-200'}`}>
|
|
<p className={`text-sm ${isDark ? 'text-blue-200' : 'text-blue-700'}`}>
|
|
{explanation}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|