diff --git a/app/Http/Controllers/Admin/QuizController.php b/app/Http/Controllers/Admin/QuizController.php new file mode 100644 index 0000000..a36cef0 --- /dev/null +++ b/app/Http/Controllers/Admin/QuizController.php @@ -0,0 +1,104 @@ +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.'); + } +} diff --git a/app/Http/Controllers/Admin/QuizQuestionController.php b/app/Http/Controllers/Admin/QuizQuestionController.php new file mode 100644 index 0000000..c8a0b9a --- /dev/null +++ b/app/Http/Controllers/Admin/QuizQuestionController.php @@ -0,0 +1,89 @@ +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.'); + } +} diff --git a/app/Http/Controllers/Child/QuizController.php b/app/Http/Controllers/Child/QuizController.php new file mode 100644 index 0000000..bdcdbce --- /dev/null +++ b/app/Http/Controllers/Child/QuizController.php @@ -0,0 +1,96 @@ +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, + }; + } +} diff --git a/app/Models/Quiz.php b/app/Models/Quiz.php new file mode 100644 index 0000000..661128a --- /dev/null +++ b/app/Models/Quiz.php @@ -0,0 +1,10 @@ + 'boolean']; + public function subject() { return $this->belongsTo(Subject::class); } + public function questions() { return $this->hasMany(QuizQuestion::class)->orderBy('sort_order'); } + public function attempts() { return $this->hasMany(QuizAttempt::class); } +} diff --git a/app/Models/QuizAnswerOption.php b/app/Models/QuizAnswerOption.php new file mode 100644 index 0000000..b99d7cf --- /dev/null +++ b/app/Models/QuizAnswerOption.php @@ -0,0 +1,8 @@ +belongsTo(QuizQuestion::class,'quiz_question_id'); } +} diff --git a/app/Models/QuizAttempt.php b/app/Models/QuizAttempt.php new file mode 100644 index 0000000..bc16baa --- /dev/null +++ b/app/Models/QuizAttempt.php @@ -0,0 +1,20 @@ + 'datetime', 'completed_at' => 'datetime']; + public const POINTS = [1, 1, 2, 2, 3, 3, 4, 6, 8, 10]; + public function user() { return $this->belongsTo(User::class); } + public function quiz() { return $this->belongsTo(Quiz::class); } + public function answers() { return $this->hasMany(QuizAttemptAnswer::class); } + public function stars(): string { + $s = $this->score; + if ($s >= 40) return '🌟🌟🌟🌟🌟'; + if ($s >= 30) return '⭐⭐⭐⭐'; + if ($s >= 20) return '⭐⭐⭐'; + if ($s >= 10) return '⭐⭐'; + if ($s >= 1) return '⭐'; + return '—'; + } +} diff --git a/app/Models/QuizAttemptAnswer.php b/app/Models/QuizAttemptAnswer.php new file mode 100644 index 0000000..44e63a1 --- /dev/null +++ b/app/Models/QuizAttemptAnswer.php @@ -0,0 +1,8 @@ +belongsTo(QuizAttempt::class,'quiz_attempt_id'); } + public function question() { return $this->belongsTo(QuizQuestion::class,'quiz_question_id'); } +} diff --git a/app/Models/QuizQuestion.php b/app/Models/QuizQuestion.php new file mode 100644 index 0000000..9914e48 --- /dev/null +++ b/app/Models/QuizQuestion.php @@ -0,0 +1,17 @@ +belongsTo(Quiz::class); } + public function answerOptions() { return $this->hasMany(QuizAnswerOption::class,'quiz_question_id')->orderBy('sort_order'); } + public function typeLabel(): string { + return match($this->type) { + 'multiple_choice' => 'Multiple Choice', + 'exclusion' => 'Ausschluss', + 'true_false' => 'Wahr/Falsch', + 'free_text' => 'Freitext', + default => $this->type, + }; + } +} diff --git a/database/migrations/2026_05_05_220000_create_quizzes_table.php b/database/migrations/2026_05_05_220000_create_quizzes_table.php new file mode 100644 index 0000000..5ff4b07 --- /dev/null +++ b/database/migrations/2026_05_05_220000_create_quizzes_table.php @@ -0,0 +1,38 @@ +id(); + $table->foreignId('subject_id')->constrained()->cascadeOnDelete(); + $table->string('title'); + $table->text('description')->nullable(); + $table->boolean('active')->default(true); + $table->timestamps(); + }); + Schema::create('quiz_questions', function (Blueprint $table) { + $table->id(); + $table->foreignId('quiz_id')->constrained()->cascadeOnDelete(); + $table->unsignedSmallInteger('sort_order')->default(0); + $table->enum('type', ['multiple_choice','exclusion','true_false','free_text']); + $table->text('question_text'); + $table->unsignedSmallInteger('time_limit')->nullable(); + $table->string('correct_answer')->nullable(); + $table->timestamps(); + }); + Schema::create('quiz_answer_options', function (Blueprint $table) { + $table->id(); + $table->foreignId('quiz_question_id')->constrained()->cascadeOnDelete(); + $table->string('text'); + $table->boolean('is_correct')->default(false); + $table->unsignedSmallInteger('sort_order')->default(0); + }); + } + public function down(): void { + Schema::dropIfExists('quiz_answer_options'); + Schema::dropIfExists('quiz_questions'); + Schema::dropIfExists('quizzes'); + } +}; diff --git a/database/migrations/2026_05_05_220001_create_quiz_attempts_table.php b/database/migrations/2026_05_05_220001_create_quiz_attempts_table.php new file mode 100644 index 0000000..fd12f6a --- /dev/null +++ b/database/migrations/2026_05_05_220001_create_quiz_attempts_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->foreignId('quiz_id')->constrained()->cascadeOnDelete(); + $table->unsignedSmallInteger('score')->default(0); + $table->unsignedSmallInteger('current_question')->default(0); + $table->enum('status', ['in_progress','completed'])->default('in_progress'); + $table->timestamp('started_at')->nullable(); + $table->timestamp('completed_at')->nullable(); + $table->timestamps(); + }); + Schema::create('quiz_attempt_answers', function (Blueprint $table) { + $table->id(); + $table->foreignId('quiz_attempt_id')->constrained()->cascadeOnDelete(); + $table->foreignId('quiz_question_id')->constrained()->cascadeOnDelete(); + $table->string('answer_given')->nullable(); + $table->boolean('is_correct')->default(false); + $table->unsignedSmallInteger('points_earned')->default(0); + $table->timestamps(); + }); + } + public function down(): void { + Schema::dropIfExists('quiz_attempt_answers'); + Schema::dropIfExists('quiz_attempts'); + } +}; diff --git a/resources/views/admin/quiz_questions/create.blade.php b/resources/views/admin/quiz_questions/create.blade.php new file mode 100644 index 0000000..47da9ed --- /dev/null +++ b/resources/views/admin/quiz_questions/create.blade.php @@ -0,0 +1,71 @@ +@extends('layouts.admin') +@section('title','Frage hinzufügen') +@section('content') +
{{ $q->question_text }}
+JSON im gleichen Format wie der Export. Erstellt immer ein neues Quiz.
+ +| Fach | +Titel | +Fragen | +Status | ++ |
|---|---|---|---|---|
| {{ $quiz->subject->icon }} {{ $quiz->subject->name }} | +{{ $quiz->title }} | ++ + {{ $quiz->questions_count }}/10 + + | +{{ $quiz->active ? '✅' : '⏸️' }} | ++ ⬇ Export + Bearbeiten + + | +
| Noch keine Quizzes. Erstes Quiz erstellen → | ||||
Beantworte 10 Fragen – max. 40 Münzen zu gewinnen!
+ +@foreach($subjects as $subjectId => $subject) +@php $group = $quizzes->get($subjectId, collect()) @endphp +@if($group->isNotEmpty()) +{{ $quiz->description }}
@endif +🧩
+Noch keine Quizzes verfügbar. Schau später nochmal vorbei!
+{{ $attempt->quiz->title }}
+| # | +Frage | +Ergebnis | +Münzen | +
|---|---|---|---|
| {{ $i+1 }} | +{{ Str::limit($a->question->question_text, 55) }} | ++ @if($a->is_correct) + ✅ {{ $display }} + @else + ❌ {{ $display }} + @endif + | ++ {{ $a->is_correct ? '+' . $a->points_earned : '0' }} + | +