ajoute Quiz flash (chantier vrai cockpit, étape 2) : QCM et vrai/faux, lancé en direct, indépendant d'une séance de lab

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Adam Belkacemi 2026-10-13 12:14:30 +02:00
parent 45f8a69e26
commit 16d1293911
24 changed files with 1249 additions and 5 deletions

View file

@ -15,6 +15,14 @@
<a href="{{ route('labs.essai') }}" class="inline-flex items-center gap-4 border border-[#e3e5e8] rounded-2xl bg-black px-6 py-4 text-lg font-extrabold text-white transition-transform hover:bg-[#ec3013].5.5 active:shadow-none">Reprendre mon TP <span class="font-mono" aria-hidden="true">→</span></a>
</div>
</section>
{{-- Chantier « vrai cockpit », étape 2 : Quiz flash, réel, pas de démo.
Caché par défaut ; rempli et affiché par le sondage ci-dessous. --}}
<section id="quiz-flash" hidden class="mx-auto max-w-7xl px-5 pt-10 lg:px-10" aria-labelledby="t-quiz-flash">
<div class="rounded-2xl border border-[#e3e5e8] bg-[#eef0fd] p-6">
<h2 id="t-quiz-flash" class="text-2xl font-extrabold">Quiz flash</h2>
<div data-quiz-contenu class="mt-4"></div>
</div>
</section>
@php($seancesOuvertes = $eleve->classe->seances()->whereIn('statut', [\App\Models\Seance::SALLE_ATTENTE, \App\Models\Seance::EN_COURS])->with('lab')->get())
@if ($seancesOuvertes->isNotEmpty())
<section class="mx-auto max-w-7xl px-5 pt-10 lg:px-10" aria-labelledby="t-seance-ouverte">
@ -82,4 +90,86 @@
</section>
</aside>
</div>
<script nonce="{{ Vite::cspNonce() }}">
(function () {
var URL_ETAT = @json(route('quiz.eleve.etat'));
var CSRF = @json(csrf_token());
var section = document.getElementById('quiz-flash');
var contenu = section.querySelector('[data-quiz-contenu]');
var questionAffichee = null, repondu = false;
function echapper(s) {
return s.replace(/[&<>"]/g, function (c) { return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]; });
}
function rendreQuestion(q) {
var champs = q.type === 'vrai_faux'
? '<label class="flex items-center gap-2 font-semibold"><input type="radio" name="reponse" value="vrai"> Vrai</label>' +
'<label class="flex items-center gap-2 font-semibold"><input type="radio" name="reponse" value="faux"> Faux</label>'
: q.choix.map(function (c) {
return '<label class="flex items-center gap-2 font-semibold"><input type="radio" name="reponse" value="' + c.id + '"> ' + echapper(c.texte) + '</label>';
}).join('');
contenu.innerHTML =
'<p class="text-xl font-extrabold">' + echapper(q.enonce) + '</p>' +
'<form data-form-reponse class="mt-4 grid gap-2">' + champs +
'<button type="submit" class="mt-2 inline-flex h-11 w-fit items-center rounded-full bg-black px-5 font-bold text-white">Valider ma réponse</button></form>' +
'<p data-resultat class="mt-3 hidden font-extrabold"></p>';
contenu.querySelector('[data-form-reponse]').addEventListener('submit', function (e) {
e.preventDefault();
var choisi = contenu.querySelector('input[name="reponse"]:checked');
if (!choisi) return;
var corps = q.type === 'vrai_faux'
? { reponse_vrai_faux: choisi.value === 'vrai' }
: { choix_id: parseInt(choisi.value, 10) };
fetch('/sessions-quiz/' + sessionActuelle + '/repondre', {
method: 'POST',
headers: { 'X-CSRF-TOKEN': CSRF, 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify(corps),
}).then(function (r) { return r.json(); }).then(function (d) {
repondu = true;
var form = contenu.querySelector('[data-form-reponse]');
form.hidden = true;
var p = contenu.querySelector('[data-resultat]');
p.hidden = false;
p.textContent = d.correcte ? 'Bonne réponse !' : 'Réponse envoyée.';
p.className = 'mt-3 font-extrabold ' + (d.correcte ? 'text-[#3C8A5A]' : '');
}).catch(function () {});
});
}
var sessionActuelle = null;
function sonder() {
fetch(URL_ETAT, { headers: { Accept: 'application/json' } })
.then(function (r) { return r.json(); })
.then(function (d) {
var s = d.session;
if (!s) { section.hidden = true; questionAffichee = null; sessionActuelle = null; return; }
sessionActuelle = s.id;
section.hidden = false;
if (s.statut === 'en_attente' || !s.question) {
questionAffichee = null;
contenu.innerHTML = '<p class="font-semibold">Un quiz va commencer. Garde cette page ouverte.</p>';
return;
}
if (s.deja_repondu) {
questionAffichee = s.question.id;
contenu.innerHTML = '<p class="font-extrabold">' + echapper(s.question.enonce) + '</p><p class="mt-3 font-extrabold">Réponse envoyée. En attente de la suite…</p>';
return;
}
if (s.question.id !== questionAffichee) {
questionAffichee = s.question.id;
repondu = false;
rendreQuestion(s.question);
}
}).catch(function () {});
}
sonder();
setInterval(sonder, 2000);
})();
</script>
@endsection

File diff suppressed because one or more lines are too long

View file

@ -30,6 +30,7 @@
<li><a href="#rejoindre" class="flex items-center rounded-lg border-2 border-transparent px-3 py-2 hover:border-black">Faire rejoindre</a></li>
<li><a href="#eleves" class="flex items-center rounded-lg border-2 border-transparent px-3 py-2 hover:border-black">Élèves</a></li>
<li><a href="#seance" class="flex items-center rounded-lg border-2 border-transparent px-3 py-2 hover:border-black">{{ $classe->estArchivee() ? 'Classe archivée' : 'Séance' }}</a></li>
<li><a href="{{ route('mes-classes.quiz.index', $classe) }}" class="flex items-center rounded-lg border-2 border-transparent px-3 py-2 hover:border-black">Quiz flash</a></li>
</ul>
@if ($seance)
<div class="mt-6 rounded-lg border border-[#dadce0] bg-white p-3 text-sm font-semibold">

View file

@ -0,0 +1,39 @@
@extends('layouts.vitrine')
@section('titre', 'Quiz · '.$classe->nom)
@section('contenu')
<div class="mx-auto max-w-4xl px-5 py-14 lg:px-10">
<p class="font-mono text-sm font-semibold"><a href="{{ route('mes-classes.show', $classe) }}" class="underline underline-offset-4">{{ $classe->nom }}</a></p>
<h1 class="mt-1 text-4xl font-extrabold tracking-tight">Quiz</h1>
<p class="mt-2 font-semibold">Un quiz se prépare une fois, puis se relance en direct autant de fois que tu veux : les élèves le voient apparaître sur leur tableau de bord, sans code à saisir.</p>
<section class="mt-8 rounded-2xl border border-[#e3e5e8] bg-white p-6" aria-labelledby="t-nouveau">
<h2 id="t-nouveau" class="text-xl font-extrabold">Nouveau quiz</h2>
@error('titre')<p class="mt-2 font-bold text-[#A63D2A]">{{ $message }}</p>@enderror
<form action="{{ route('mes-classes.quiz.store', $classe) }}" method="post" class="mt-4 flex flex-wrap gap-3">
@csrf
<input name="titre" required placeholder="Ex. Adressage IPv4" class="flex-1 rounded-xl border border-[#c4c7c5] bg-white px-3 py-2 font-semibold">
<button type="submit" class="inline-flex h-11 items-center rounded-full bg-[#0b57d0] px-5 font-bold text-white">Créer</button>
</form>
</section>
<section class="mt-8" aria-labelledby="t-liste">
<h2 id="t-liste" class="text-xl font-extrabold">Mes quiz ({{ $quizzes->count() }})</h2>
@if ($quizzes->isEmpty())
<p class="mt-2 font-semibold">Aucun quiz pour l'instant.</p>
@else
<ul class="mt-4 grid gap-3">
@foreach ($quizzes as $quiz)
<li>
<a href="{{ route('mes-classes.quiz.show', [$classe, $quiz]) }}" class="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-[#e3e5e8] bg-white p-5 hover:bg-[#f8f9fa]">
<span class="text-lg font-extrabold">{{ $quiz->titre }}</span>
<span class="font-mono text-sm font-semibold">{{ $quiz->questions_count }} question(s)</span>
</a>
</li>
@endforeach
</ul>
@endif
</section>
</div>
@endsection

View file

@ -0,0 +1,67 @@
@extends('layouts.vitrine')
@section('titre', $session->quiz->titre.' · en direct')
@php($etats = \App\Models\SessionQuiz::class)
@section('contenu')
<div class="mx-auto max-w-3xl px-5 py-14 lg:px-10">
<p class="font-mono text-sm font-semibold">{{ $session->quiz->classe->nom }}</p>
<h1 class="mt-1 text-4xl font-extrabold tracking-tight">{{ $session->quiz->titre }}</h1>
@if ($session->statut === $etats::TERMINEE)
<div class="mt-8 rounded-2xl border border-[#e3e5e8] bg-[#e6f4ea] p-8 text-center">
<p class="text-2xl font-extrabold">Quiz terminé.</p>
<a href="{{ route('mes-classes.quiz.show', [$session->quiz->classe, $session->quiz]) }}" class="mt-4 inline-flex h-11 items-center rounded-full bg-black px-5 font-bold text-white">Retour au quiz</a>
</div>
@elseif (! $session->questionCourante)
<div class="mt-8 rounded-2xl border border-[#e3e5e8] bg-white p-8 text-center">
<p class="text-xl font-semibold">Prêt à commencer ({{ $session->quiz->questions->count() }} question(s)).</p>
<form action="{{ route('sessions-quiz.suivante', $session) }}" method="post" class="mt-4">
@csrf
<button type="submit" class="inline-flex h-12 items-center rounded-full bg-black px-6 text-lg font-bold text-white">Commencer</button>
</form>
</div>
@else
@php($question = $session->questionCourante)
@php($numero = $session->quiz->questions->search(fn ($q) => $q->id === $question->id) + 1)
<div class="mt-8 rounded-2xl border border-[#e3e5e8] bg-white p-8">
<p class="font-mono text-sm font-semibold">Question {{ $numero }} / {{ $session->quiz->questions->count() }}</p>
<p class="mt-2 text-2xl font-extrabold">{{ $question->enonce }}</p>
@if ($question->type === \App\Models\QuestionQuiz::VRAI_FAUX)
<p class="mt-4 font-mono font-semibold">Bonne réponse : {{ $question->reponse_vrai_faux ? 'Vrai' : 'Faux' }}</p>
@else
<ul class="mt-4 grid gap-1 font-semibold">
@foreach ($question->choix as $choix)
<li class="{{ $choix->est_correct ? 'text-[#3C8A5A]' : '' }}">{{ $choix->est_correct ? '✓' : '·' }} {{ $choix->texte }}</li>
@endforeach
</ul>
@endif
<div class="mt-6 flex items-center gap-4 rounded-xl border border-[#dadce0] bg-[#f8f9fa] p-4">
<p class="font-mono text-lg font-bold"><span data-repondu>{{ $decompte['repondu'] }}</span> / {{ $total_eleves }} ont répondu</p>
<p class="font-mono text-lg font-bold text-[#3C8A5A]"><span data-correctes>{{ $decompte['correctes'] }}</span> correctes</p>
</div>
<form action="{{ route('sessions-quiz.suivante', $session) }}" method="post" class="mt-6">
@csrf
<button type="submit" class="inline-flex h-12 items-center rounded-full bg-black px-6 text-lg font-bold text-white">{{ $numero === $session->quiz->questions->count() ? 'Terminer' : 'Question suivante' }}</button>
</form>
</div>
@endif
</div>
<script nonce="{{ Vite::cspNonce() }}">
(function () {
var questionId = @json($session->question_quiz_courante_id);
setInterval(function () {
fetch('{{ route('sessions-quiz.etat', $session) }}', { headers: { 'Accept': 'application/json' } })
.then(function (r) { return r.json(); })
.then(function (e) {
if (e.question_id !== questionId || e.statut === 'terminee') { location.reload(); return; }
var r = document.querySelector('[data-repondu]'); if (r) r.textContent = e.decompte.repondu;
var c = document.querySelector('[data-correctes]'); if (c) c.textContent = e.decompte.correctes;
}).catch(function () {});
}, 2000);
})();
</script>
@endsection

View file

@ -0,0 +1,99 @@
@extends('layouts.vitrine')
@section('titre', $quiz->titre)
@section('contenu')
<div class="mx-auto max-w-4xl px-5 py-14 lg:px-10">
<p class="font-mono text-sm font-semibold"><a href="{{ route('mes-classes.quiz.index', $classe) }}" class="underline underline-offset-4">Quiz · {{ $classe->nom }}</a></p>
<div class="mt-1 flex flex-wrap items-center justify-between gap-4">
<h1 class="text-4xl font-extrabold tracking-tight">{{ $quiz->titre }}</h1>
<form action="{{ route('mes-classes.quiz.lancer', [$classe, $quiz]) }}" method="post">
@csrf
<button type="submit" class="inline-flex h-11 items-center rounded-full bg-black px-5 font-bold text-white" @disabled($quiz->questions->isEmpty())>Lancer ce quiz</button>
</form>
</div>
@error('questions')<p class="mt-2 font-bold text-[#A63D2A]">Ajoute au moins une question avant de lancer.</p>@enderror
<section class="mt-8" aria-labelledby="t-questions">
<h2 id="t-questions" class="text-xl font-extrabold">Questions ({{ $quiz->questions->count() }})</h2>
@if ($quiz->questions->isEmpty())
<p class="mt-2 font-semibold">Aucune question pour l'instant.</p>
@else
<ol class="mt-4 grid gap-3">
@foreach ($quiz->questions as $question)
<li class="rounded-2xl border border-[#e3e5e8] bg-white p-5">
<div class="flex flex-wrap items-start justify-between gap-3">
<p class="text-lg font-extrabold">{{ $loop->iteration }}. {{ $question->enonce }}</p>
<form action="{{ route('mes-classes.quiz.questions.destroy', [$classe, $quiz, $question]) }}" method="post" data-confirmer="Supprimer cette question ?">
@csrf
<button type="submit" class="font-bold text-[#A63D2A] underline underline-offset-4">Supprimer</button>
</form>
</div>
@if ($question->type === \App\Models\QuestionQuiz::VRAI_FAUX)
<p class="mt-2 font-mono text-sm font-semibold">Bonne réponse : {{ $question->reponse_vrai_faux ? 'Vrai' : 'Faux' }}</p>
@else
<ul class="mt-2 grid gap-1 font-semibold">
@foreach ($question->choix as $choix)
<li class="{{ $choix->est_correct ? 'text-[#3C8A5A]' : '' }}">{{ $choix->est_correct ? '✓' : '·' }} {{ $choix->texte }}</li>
@endforeach
</ul>
@endif
</li>
@endforeach
</ol>
@endif
</section>
<section class="mt-8 rounded-2xl border border-[#e3e5e8] bg-white p-6" aria-labelledby="t-ajout">
<h2 id="t-ajout" class="text-xl font-extrabold">Ajouter une question</h2>
<form action="{{ route('mes-classes.quiz.questions.store', [$classe, $quiz]) }}" method="post" class="mt-4 grid gap-4" id="form-question">
@csrf
<label class="grid gap-1 font-bold">Énoncé
<textarea name="enonce" required rows="2" class="rounded-xl border border-[#c4c7c5] bg-white px-3 py-2 font-semibold"></textarea>
</label>
<fieldset class="grid gap-1 font-bold">
<legend>Type de question</legend>
<label class="inline-flex items-center gap-2 font-semibold"><input type="radio" name="type" value="qcm" data-type-radio checked> QCM (une bonne réponse)</label>
<label class="inline-flex items-center gap-2 font-semibold"><input type="radio" name="type" value="vrai_faux" data-type-radio> Vrai / Faux</label>
</fieldset>
<div data-bloc-qcm class="grid gap-2">
<p class="font-bold">Choix (coche la bonne réponse)</p>
@for ($i = 0; $i < 4; $i++)
<label class="flex items-center gap-2 font-semibold">
<input type="radio" name="bonne_reponse" value="{{ $i }}" @required($i === 0) aria-label="Choix {{ $i + 1 }} correct">
<input name="choix[]" placeholder="Choix {{ $i + 1 }}{{ $i < 2 ? '' : ' (facultatif)' }}" @required($i < 2) class="flex-1 rounded-xl border border-[#c4c7c5] bg-white px-3 py-2 font-semibold">
</label>
@endfor
</div>
<div data-bloc-vrai-faux class="grid gap-1 font-bold">
<p>Bonne réponse</p>
<label class="inline-flex items-center gap-2 font-semibold"><input type="radio" name="reponse_vrai_faux" value="1"> Vrai</label>
<label class="inline-flex items-center gap-2 font-semibold"><input type="radio" name="reponse_vrai_faux" value="0"> Faux</label>
</div>
<button type="submit" class="inline-flex h-11 w-fit items-center rounded-full bg-[#0b57d0] px-5 font-bold text-white">Ajouter la question</button>
</form>
</section>
</div>
<script nonce="{{ Vite::cspNonce() }}">
(function () {
var form = document.getElementById('form-question');
var blocQcm = form.querySelector('[data-bloc-qcm]');
var blocVraiFaux = form.querySelector('[data-bloc-vrai-faux]');
var champsQcm = blocQcm.querySelectorAll('input');
function appliquer() {
var qcm = form.querySelector('[data-type-radio]:checked').value === 'qcm';
blocQcm.hidden = !qcm;
blocVraiFaux.hidden = qcm;
champsQcm.forEach(function (c) { c.disabled = !qcm; });
}
form.querySelectorAll('[data-type-radio]').forEach(function (r) { r.addEventListener('change', appliquer); });
appliquer();
})();
document.querySelectorAll('form[data-confirmer]').forEach(function (f) {
f.addEventListener('submit', function (e) { if (!window.confirm(f.dataset.confirmer)) e.preventDefault(); });
});
</script>
@endsection