App Flask complète pour https://dynamic.alpinux.org : - 10 quiz Linux, 5 niveaux (Découverte → Expert), 50+ questions - Public : Découverte, Débutant, Intermédiaire (6 quiz) - Membres AlpID : Avancé, Expert (4 quiz — Git, Admin, Sécurité, Bash) - Navigation question par question avec avance automatique après choix - Score calculé côté serveur, enregistré en SQLite si connecté - Page profil : meilleurs scores par quiz + historique des tentatives Authentification : - OIDC via authlib + AlpID (Keycloak), SSO partagé avec Gitea/Nextcloud - Décorateur @login_required, redirection post-login sur l'URL d'origine - /auth/login, /auth/callback, /auth/logout Structure : - dynamic/app.py, db.py, quiz.py, auth_utils.py - dynamic/routes/ (public.py, auth.py, protected.py) - dynamic/templates/ (base, index, quiz/*, profil/) - dynamic/static/ (style.css thème Alpinux, quiz.js vanilla) - dynamic/data/quizzes.json (source de vérité des questions) - dynamic/.env.example Infrastructure : - scripts/dynamic.alpinux.org.vhost.conf (Apache reverse proxy) - scripts/dynamic.alpinux.org.service (systemd Gunicorn) - docs/technique/deploiement-dynamic.md (procédure complète) - mkdocs.yml : page de déploiement ajoutée à la nav Technique - .gitignore : exclut venv/ et .env Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
49 lines
1.7 KiB
JavaScript
49 lines
1.7 KiB
JavaScript
/* Quiz navigation — vanilla JS, aucune dépendance */
|
|
(function () {
|
|
const quiz = JSON.parse(document.getElementById('quiz-data').textContent);
|
|
const total = quiz.questions.length;
|
|
let current = 0;
|
|
|
|
function showQuestion(index) {
|
|
document.querySelectorAll('.question-block').forEach(function (el) {
|
|
el.hidden = parseInt(el.dataset.index) !== index;
|
|
});
|
|
|
|
document.getElementById('progress-text').textContent =
|
|
(index + 1) + ' / ' + total;
|
|
document.getElementById('progress-bar').style.width =
|
|
Math.round((index / total) * 100) + '%';
|
|
|
|
document.getElementById('btn-prev').hidden = index === 0;
|
|
document.getElementById('btn-next').hidden = index === total - 1;
|
|
document.getElementById('btn-submit').hidden = index !== total - 1;
|
|
}
|
|
|
|
window.navigate = function (delta) {
|
|
var next = current + delta;
|
|
if (next < 0 || next >= total) return;
|
|
current = next;
|
|
showQuestion(current);
|
|
};
|
|
|
|
window.onChoice = function (questionIndex, choiceIndex) {
|
|
/* Mise à jour visuelle */
|
|
document.querySelectorAll('.question-block[data-index="' + questionIndex + '"] .choice-label')
|
|
.forEach(function (lbl, j) {
|
|
lbl.classList.toggle('selected', j === choiceIndex);
|
|
});
|
|
|
|
/* Le radio button natif sert de valeur soumise avec le formulaire.
|
|
On synchronise aussi un hidden input de secours. */
|
|
var hidden = document.getElementById('ans-' + questionIndex);
|
|
if (hidden) hidden.value = choiceIndex;
|
|
|
|
/* Avancer automatiquement sauf sur la dernière question */
|
|
if (questionIndex < total - 1) {
|
|
setTimeout(function () { navigate(1); }, 350);
|
|
}
|
|
};
|
|
|
|
/* Init */
|
|
showQuestion(0);
|
|
})();
|