Add Quiz feature: 10-question quizzes with progressive scoring (max 40 pts)

- Quizzes table with questions, answer options, attempts, answers
- Question types: multiple_choice, exclusion, true_false, free_text
- Progressive scoring: [1,1,2,2,3,3,4,6,8,10] = max 40 per quiz
- Alpine.js countdown timer per question with auto-submit on timeout
- Admin: CRUD for quizzes + per-question editor, JSON export/import
- Child: quiz overview with best scores, question view, result breakdown
- Nav: Quiz link in child header and admin sidebar
This commit is contained in:
root
2026-05-05 21:14:09 +00:00
parent 213d4b4832
commit 6c6dd26823
21 changed files with 984 additions and 1 deletions
@@ -0,0 +1,104 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\{Quiz, QuizQuestion, QuizAnswerOption, Subject};
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class QuizController extends Controller {
public function index() {
$quizzes = Quiz::with('subject')->withCount('questions')->latest()->get();
return view('admin.quizzes.index', compact('quizzes'));
}
public function create() {
$subjects = Subject::all();
return view('admin.quizzes.create', compact('subjects'));
}
public function store(Request $r) {
$r->validate(['title'=>'required|string|max:120','subject_id'=>'required|exists:subjects,id','description'=>'nullable|string|max:500']);
$quiz = Quiz::create($r->only('title','subject_id','description') + ['active'=>true]);
return redirect()->route('admin.quizzes.edit', $quiz)->with('success','Quiz erstellt jetzt Fragen hinzufügen.');
}
public function edit(Quiz $quiz) {
$subjects = Subject::all();
$questions = $quiz->questions()->with('answerOptions')->get();
return view('admin.quizzes.edit', compact('quiz','subjects','questions'));
}
public function update(Request $r, Quiz $quiz) {
$r->validate(['title'=>'required|string|max:120','subject_id'=>'required|exists:subjects,id','description'=>'nullable|string|max:500']);
$quiz->update($r->only('title','subject_id','description') + ['active'=>$r->boolean('active')]);
return back()->with('success','Quiz gespeichert.');
}
public function destroy(Quiz $quiz) {
$quiz->delete();
return redirect()->route('admin.quizzes.index')->with('success','Quiz gelöscht.');
}
public function export(Quiz $quiz) {
$quiz->load('subject','questions.answerOptions');
$data = [
'subject' => $quiz->subject->slug,
'title' => $quiz->title,
'description' => $quiz->description,
'questions' => $quiz->questions->map(fn($q) => [
'type' => $q->type,
'question_text' => $q->question_text,
'time_limit' => $q->time_limit,
'correct_answer' => $q->correct_answer,
'options' => $q->answerOptions->map(fn($o) => [
'text' => $o->text,
'is_correct' => (bool)$o->is_correct,
])->values(),
])->values(),
];
$filename = 'quiz-'.now()->format('Y-m-d').'-'.str($quiz->title)->slug().'.json';
return response()->streamDownload(
fn() => print(json_encode($data, JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE)),
$filename, ['Content-Type'=>'application/json']
);
}
public function import(Request $r) {
$r->validate(['file'=>'required|file|mimes:json|max:4096']);
$raw = json_decode(file_get_contents($r->file('file')->getRealPath()), true);
if (!is_array($raw) || !isset($raw['title'],$raw['subject'],$raw['questions'])) {
return back()->with('error','Ungültiges JSON-Format. Felder: title, subject, questions erwartet.');
}
$subject = Subject::where('slug',$raw['subject'])->first();
if (!$subject) return back()->with('error','Unbekanntes Fach: '.$raw['subject']);
DB::transaction(function() use ($raw,$subject) {
$quiz = Quiz::create([
'subject_id' => $subject->id,
'title' => $raw['title'],
'description' => $raw['description'] ?? null,
'active' => true,
]);
foreach (array_slice($raw['questions'],0,10) as $i => $item) {
$q = QuizQuestion::create([
'quiz_id' => $quiz->id,
'sort_order' => $i,
'type' => $item['type'] ?? 'multiple_choice',
'question_text' => $item['question_text'],
'time_limit' => $item['time_limit'] ?? null,
'correct_answer' => $item['correct_answer'] ?? null,
]);
foreach (($item['options'] ?? []) as $j => $opt) {
QuizAnswerOption::create([
'quiz_question_id' => $q->id,
'text' => $opt['text'],
'is_correct' => $opt['is_correct'] ?? false,
'sort_order' => $j,
]);
}
}
});
return redirect()->route('admin.quizzes.index')->with('success','Quiz "'.$raw['title'].'" importiert.');
}
}
@@ -0,0 +1,89 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\{Quiz, QuizQuestion, QuizAnswerOption};
use Illuminate\Http\Request;
class QuizQuestionController extends Controller {
public function create(Quiz $quiz) {
return view('admin.quiz_questions.create', compact('quiz'));
}
public function store(Request $r, Quiz $quiz) {
$r->validate([
'type' => 'required|in:multiple_choice,exclusion,true_false,free_text',
'question_text' => 'required|string',
'time_limit' => 'nullable|integer|min:5|max:120',
'correct_answer' => 'nullable|string|max:200',
'correct_option' => 'nullable|integer|min:0|max:3',
'options' => 'array',
'options.*.text' => 'nullable|string|max:200',
]);
$q = QuizQuestion::create([
'quiz_id' => $quiz->id,
'sort_order' => ($quiz->questions()->max('sort_order') ?? -1) + 1,
'type' => $r->type,
'question_text' => $r->question_text,
'time_limit' => $r->time_limit ?: null,
'correct_answer' => $r->correct_answer,
]);
if (in_array($r->type,['multiple_choice','exclusion'])) {
$correctIdx = (int)$r->input('correct_option', 0);
foreach (($r->options ?? []) as $i => $opt) {
if (!empty($opt['text'])) {
QuizAnswerOption::create([
'quiz_question_id' => $q->id,
'text' => $opt['text'],
'is_correct' => $i === $correctIdx,
'sort_order' => $i,
]);
}
}
}
return redirect()->route('admin.quizzes.edit', $quiz)->with('success','Frage hinzugefügt.');
}
public function edit(QuizQuestion $question) {
return view('admin.quiz_questions.edit', compact('question'));
}
public function update(Request $r, QuizQuestion $question) {
$r->validate([
'type' => 'required|in:multiple_choice,exclusion,true_false,free_text',
'question_text' => 'required|string',
'time_limit' => 'nullable|integer|min:5|max:120',
'correct_answer' => 'nullable|string|max:200',
'correct_option' => 'nullable|integer|min:0|max:3',
'options' => 'array',
'options.*.text' => 'nullable|string|max:200',
]);
$question->update([
'type' => $r->type,
'question_text' => $r->question_text,
'time_limit' => $r->time_limit ?: null,
'correct_answer' => $r->correct_answer,
]);
if (in_array($r->type,['multiple_choice','exclusion'])) {
$question->answerOptions()->delete();
$correctIdx = (int)$r->input('correct_option', 0);
foreach (($r->options ?? []) as $i => $opt) {
if (!empty($opt['text'])) {
QuizAnswerOption::create([
'quiz_question_id' => $question->id,
'text' => $opt['text'],
'is_correct' => $i === $correctIdx,
'sort_order' => $i,
]);
}
}
}
return redirect()->route('admin.quizzes.edit', $question->quiz)->with('success','Frage gespeichert.');
}
public function destroy(QuizQuestion $question) {
$quiz = $question->quiz;
$question->delete();
return redirect()->route('admin.quizzes.edit', $quiz)->with('success','Frage gelöscht.');
}
}
@@ -0,0 +1,96 @@
<?php
namespace App\Http\Controllers\Child;
use App\Http\Controllers\Controller;
use App\Models\{Quiz, QuizAttempt, QuizAttemptAnswer, QuizQuestion, Subject};
use Illuminate\Http\Request;
class QuizController extends Controller {
public function index() {
$subjects = Subject::all()->keyBy('id');
$quizzes = Quiz::with('subject')->where('active',true)
->withCount('questions')->get()->groupBy('subject_id');
$bestScores = QuizAttempt::where('user_id',auth()->id())
->where('status','completed')
->selectRaw('quiz_id, MAX(score) as best')
->groupBy('quiz_id')->pluck('best','quiz_id');
return view('child.quiz.index', compact('subjects','quizzes','bestScores'));
}
public function start(Quiz $quiz) {
abort_unless($quiz->active, 403);
$attempt = QuizAttempt::firstOrCreate(
['user_id'=>auth()->id(),'quiz_id'=>$quiz->id,'status'=>'in_progress'],
['score'=>0,'current_question'=>0,'started_at'=>now()]
);
return redirect()->route('quiz.question', $attempt);
}
public function question(QuizAttempt $attempt) {
abort_unless($attempt->user_id === auth()->id(), 403);
if ($attempt->status === 'completed') return redirect()->route('quiz.result', $attempt);
$question = $attempt->quiz->questions()->skip($attempt->current_question)->first();
$pointsIfCorrect = QuizAttempt::POINTS[$attempt->current_question];
return view('child.quiz.question', compact('attempt','question','pointsIfCorrect'));
}
public function answer(Request $r, QuizAttempt $attempt) {
abort_unless($attempt->user_id === auth()->id(), 403);
abort_if($attempt->status === 'completed', 400);
$question = $attempt->quiz->questions()->skip($attempt->current_question)->first();
$possible = QuizAttempt::POINTS[$attempt->current_question];
$timedOut = $r->input('timeout') === '1';
$answerGiven = $r->input('answer','');
$isCorrect = false;
$pointsEarned = 0;
if (!$timedOut && $answerGiven !== '') {
$isCorrect = $this->check($question, $answerGiven);
$pointsEarned = $isCorrect ? $possible : 0;
}
QuizAttemptAnswer::create([
'quiz_attempt_id' => $attempt->id,
'quiz_question_id' => $question->id,
'answer_given' => $timedOut ? '__timeout__' : $answerGiven,
'is_correct' => $isCorrect,
'points_earned' => $pointsEarned,
]);
session()->flash('last_answer', [
'correct' => $isCorrect,
'points' => $pointsEarned,
'possible' => $possible,
'timeout' => $timedOut,
]);
$next = $attempt->current_question + 1;
$totalQuestions = $attempt->quiz->questions()->count();
if ($next >= $totalQuestions || $next >= 10) {
$total = QuizAttemptAnswer::where('quiz_attempt_id',$attempt->id)->sum('points_earned');
$attempt->update(['score'=>$total,'current_question'=>$next,'status'=>'completed','completed_at'=>now()]);
auth()->user()->increment('points', $total);
return redirect()->route('quiz.result', $attempt);
}
$attempt->update(['current_question'=>$next]);
return redirect()->route('quiz.question', $attempt);
}
public function result(QuizAttempt $attempt) {
abort_unless($attempt->user_id === auth()->id(), 403);
abort_unless($attempt->status === 'completed', 400);
$answers = $attempt->answers()->with('question.answerOptions')->get();
return view('child.quiz.result', compact('attempt','answers'));
}
private function check(QuizQuestion $q, string $answer): bool {
return match($q->type) {
'multiple_choice','exclusion' => ($opt = $q->answerOptions()->where('is_correct',true)->first())
&& (string)$opt->id === $answer,
'true_false','free_text' => strtolower(trim($answer)) === strtolower(trim((string)$q->correct_answer)),
default => false,
};
}
}