Files
Forge-Engine/templates/base.html
T
william f7a50e5afe
Build and deploy / test-python (push) Successful in 9m41s
Build and deploy / test-js (push) Successful in 51s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Ajoute un suivi xAPI optionnel (bolt-on) au paquet SCORM exporté
Greffe l'envoi de statements xAPI vers un LRS configurable par le
créateur du jeu, en parallèle du reporting SCORM existant : réglages
stockés en base (_meta), formulaire dans la modale d'export, injection
dans le paquet exporté. Relié aussi bien à l'action de flow "Modifier
un score/statut" qu'au parcours quête/quiz (qui alimentait déjà le
SCORM classique via un chemin séparé).

L'export ne se lance plus automatiquement à l'ouverture de la modale,
pour laisser le temps d'enregistrer les réglages xAPI avant de générer
le paquet.
2026-09-05 10:00:57 +02:00

274 lines
14 KiB
HTML

<!DOCTYPE html>
<html lang="fr" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Forge Engine{% endblock %}</title>
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='branding/forge-favicon.svg') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
<script>window.CSRF_TOKEN = {{ csrf_token()|tojson }};</script>
<script src="{{ url_for('static', filename='csrf_fetch.js') }}"></script>
{% block extra_head %}{% endblock %}
</head>
<body class="{% block body_class %}{% endblock %}">
<!-- #pageChrome : tout ce qui est en dehors de <main> mais doit quand même
changer d'une page à l'autre (barre du jeu comprise) — un seul bloc
stable pour que pjax.js puisse le remplacer d'un coup à chaque
navigation (voir swapDocument() dans pjax.js). Avant l'ajout de ce
conteneur, pjax.js cherchait un <header class="topbar"> qui n'a jamais
existé (la barre est un <nav>, pas un <header>) : la barre de
navigation du jeu (ajoutée après pjax.js) ne changeait donc jamais
pendant la navigation pjax — absente en arrivant sur un jeu tant qu'on
ne rechargeait pas la page en entier (Ctrl+F5), et inversement
laissée en place en revenant sur l'accueil (liste des jeux) où elle
n'a pourtant rien à faire. -->
<div id="pageChrome">
<nav class="navbar topbar" role="navigation" aria-label="main navigation">
<div class="navbar-brand">
<a href="{{ url_for('index') }}" class="navbar-item brand">
<img src="{{ url_for('static', filename='branding/forge-logo.svg') }}" alt="" width="32" height="32" style="border-radius:8px;">
<strong class="forge-gradient-title">Forge Engine</strong>
</a>
</div>
{% if g.get('current_user') %}
<div class="navbar-end" style="margin-left:auto; display:flex; align-items:center; gap:10px; padding-right:12px;">
<a href="{{ url_for('profile') }}" class="hint" style="text-decoration:underline;">{{ g.current_user.prenom }} {{ g.current_user.nom }}{{ ' (admin)' if g.current_user.role == 'admin' else '' }}</a>
<form method="post" action="{{ url_for('logout') }}"><button type="submit" class="button is-small">Déconnexion</button></form>
</div>
{% endif %}
</nav>
{% if game is defined and game %}
<!-- Barre toujours visible entre les différentes pages d'UN MÊME jeu.
Écrans/Éléments de jeu/Variables/Objets n'ont plus leur propre page —
tout ça vit désormais dans les onglets du tableau de bord (voir
game_dashboard.html) — donc un seul lien y mène. Remplace le fil
d'Ariane (retiré, devenu redondant) : n'apparaît que si la route a mis
un `game` dans le contexte du template (déjà fait par toutes les
pages d'un jeu) — absente sur l'accueil (liste des jeux). "Jouer" est
délibérément le DERNIER lien — une action à part (ouvre l'aperçu
jouable dans un nouvel onglet), pas une page de gestion de plus. -->
<nav class="gameNavBar" aria-label="Navigation du jeu">
<!-- Onboarding guidé (voir routes/onboarding/onboarding_new.py) : même
route pour tous les comptes — routes/games/game_dashboard.py y rend
une version simplifiée (juste les écrans) pour un type "restreint"
(quiz/embranchement/rpg), la version complète pour "custom". -->
<a href="{{ url_for('game_dashboard', slug=game.slug) }}" class="gameNavLink {{ 'active' if request.path == '/game/' ~ game.slug else '' }}" title="Écrans du jeu">Mes écrans</a>
<!-- "Jouer"/"Exporter" groupés à droite (gameNavRight, margin-left:auto
en CSS) et distingués par COULEUR (orange/vert, voir .gameNavLink--
play/--export dans static/style.css) — demande explicite, ce sont
des ACTIONS (lancer une partie, produire un paquet), pas des pages
de gestion comme "Mes écrans" à gauche. -->
<div class="gameNavRight">
<a href="{{ url_for('game_play', slug=game.slug) }}" target="_blank" class="gameNavLink gameNavLink--play" title="Aperçu jouable du jeu (une seule partie partagée, pour la conception)">Jouer</a>
<!-- Phase 1.2 (feuille de route produit) : seul export retenu — paquet
STATIQUE (aucun Python/Flask embarqué, voir
publish/build_scorm_package.py), pensé pour un import dans le LMS
du client (Moodle...). L'export exécutable Windows ("Publier",
publish/build_package.py) et l'export en ligne (route publique
/jouer/<slug>, routes/public_play/) ont été retirés, jugés
redondants avec celui-ci. -->
<button type="button" class="gameNavLink gameNavLink--export" onclick="window.forgeOpenScormModal && window.forgeOpenScormModal()" title="Exporte le jeu en paquet Web/SCORM importable dans un LMS">Exporter (Web/SCORM)</button>
</div>
</nav>
{% endif %}
</div>
<main class="content {% block content_class %}{% endblock %}">
{% set recovery_codes = pop_recovery_codes() %}
{% if recovery_codes %}
<!-- À L'INTÉRIEUR de <main> (et pas à côté de #pageChrome) : pjax.js ne
remplace jamais que #pageChrome et main.content d'une navigation à
l'autre (voir swapDocument() dans pjax.js) — posé ailleurs, ce modal
serait bien présent dans le HTML renvoyé par le serveur juste après
l'inscription, mais jamais copié dans le DOM réellement affiché
puisque la redirection qui suit la confirmation de la 2FA passe par
une navigation pjax (fetch), pas un vrai rechargement de page. -->
<div class="modal is-active" id="recoveryCodesModal">
<div class="modal-background"></div>
<div class="modal-content">
<div class="box">
<h2 class="title is-4">Tes codes de récupération</h2>
<p class="hint mb-4">Note ces 10 codes en sécurité (gestionnaire de mots de passe, papier...) : chacun ne fonctionne qu'une seule fois et permet de te connecter si tu perds l'accès à ton application d'authentification. Ils ne seront plus jamais affichés.</p>
<div class="recoveryCodesGrid">
{% for code in recovery_codes %}
<code class="recoveryCode">{{ code }}</code>
{% endfor %}
</div>
<button type="button" class="button primary is-fullwidth mt-4" onclick="document.getElementById('recoveryCodesModal').remove()">J'ai noté mes codes</button>
</div>
</div>
</div>
{% endif %}
{% if game is defined and game %}
<!-- Même remarque que pour le modal des codes de récupération ci-dessus : à L'INTÉRIEUR
de <main> pour que pjax.js le remplace (et rejoue son script JS, voir
runScriptsIn()) à chaque navigation entre pages d'un même jeu — posé
dans #pageChrome (comme le bouton "🎓 Exporter (Web/SCORM)" qui
l'ouvre), son script ne serait rejoué qu'au tout premier chargement
complet de la page. -->
<style>
@keyframes forgePublishPulse { 0%, 100% { opacity: 1; } 50% { opacity: .35; } }
</style>
<div class="modal" id="scormModal">
<div class="modal-background"></div>
<div class="modal-content">
<div class="box">
<h2 class="title is-4">Exporter « {{ game.name }} » en Web/SCORM</h2>
<p class="hint mb-4">Construit un paquet SCORM 1.2 (imsmanifest.xml + page web autonome, aucun serveur requis) : importe le .zip obtenu directement dans ton LMS (Moodle...) — le score et le statut de la partie remontent automatiquement.</p>
<!-- Suivi xAPI (voir publish/build_scorm_package.py, routes/publish/
xapi_settings.py) : greffé sur le SCORM ci-dessus, jamais à sa
place — un réglage jamais renseigné (endpoint vide) désactive
entièrement xAPI pour ce jeu, le SCORM continue seul. Réglé
séparément du bouton d'export : ces valeurs sont déjà en base
au moment où on clique "Exporter", jamais retapées à chaque export. -->
<details class="propGroup mb-4" id="xapiSettingsGroup">
<summary>Suivi xAPI (optionnel)</summary>
<div class="propGroupBody">
<p class="help mb-2">Envoie en plus les résultats vers un LRS (Learning Record Store) externe, en parallèle du SCORM ci-dessus. Utilise une clé API dédiée à cet export, jamais tes identifiants LRS principaux.</p>
<div class="field">
<label class="label is-small">URL du LRS</label>
<div class="control"><input class="input is-small" type="text" id="xapiEndpoint" placeholder="https://mon-lrs.exemple.com/xAPI"></div>
</div>
<div class="field">
<label class="label is-small">Login</label>
<div class="control"><input class="input is-small" type="text" id="xapiLogin" autocomplete="off"></div>
</div>
<div class="field">
<label class="label is-small">Mot de passe</label>
<div class="control"><input class="input is-small" type="password" id="xapiPassword" autocomplete="new-password"></div>
<p class="help" id="xapiPasswordHint"></p>
</div>
<div class="field is-grouped" style="align-items:center;">
<div class="control"><button type="button" class="button is-small" onclick="window.forgeSaveXapiSettings()">Enregistrer</button></div>
<span class="hint" id="xapiSaveStatus"></span>
</div>
</div>
</details>
<div id="scormProgress" style="display:none;">
<p class="hint" id="scormStepText">Préparation du jeu...</p>
<div class="passwordStrengthBar" style="margin-top:10px;"><div class="passwordStrengthFill strong" id="scormProgressFill" style="width:100%; animation:forgePublishPulse 1.2s ease-in-out infinite;"></div></div>
</div>
<div id="scormError" class="notification is-danger" style="display:none;"></div>
<div id="scormDone" class="notification is-success" style="display:none;">Téléchargement lancé — importe directement le .zip dans ton LMS.</div>
<div class="field mt-4" style="display:flex; justify-content:flex-end; gap:8px;">
<button type="button" class="button is-small" onclick="window.forgeCloseScormModal()">Fermer</button>
<button type="button" class="button primary is-small" id="scormExportButton" onclick="window.forgeStartScormExport()">Exporter (Web/SCORM)</button>
</div>
</div>
</div>
</div>
<script>
(function () {
var modal = document.getElementById('scormModal');
var stepText = document.getElementById('scormStepText');
var progressBox = document.getElementById('scormProgress');
var errorBox = document.getElementById('scormError');
var doneBox = document.getElementById('scormDone');
var steps = ["Préparation du jeu...", "Génération du paquet hors ligne...", "Écriture du manifeste SCORM..."];
var stepTimer = null;
var xapiEndpoint = document.getElementById('xapiEndpoint');
var xapiLogin = document.getElementById('xapiLogin');
var xapiPassword = document.getElementById('xapiPassword');
var xapiPasswordHint = document.getElementById('xapiPasswordHint');
var xapiSaveStatus = document.getElementById('xapiSaveStatus');
function forgeLoadXapiSettings() {
fetch("{{ url_for('xapi_settings_get', slug=game.slug) }}")
.then(function (r) { return r.json(); })
.then(function (json) {
xapiEndpoint.value = json.endpoint || '';
xapiLogin.value = json.login || '';
xapiPassword.value = '';
xapiPasswordHint.textContent = json.password_set
? 'Mot de passe déjà enregistré — laisse ce champ vide pour le garder.' : '';
});
}
// Réglages, jamais retapés à chaque export : sauvegardés indépendamment
// du bouton "Exporter" ci-dessous (voir routes/publish/xapi_settings.py).
window.forgeSaveXapiSettings = function () {
xapiSaveStatus.textContent = 'Enregistrement...';
fetch("{{ url_for('xapi_settings_set', slug=game.slug) }}", {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
endpoint: xapiEndpoint.value.trim(), login: xapiLogin.value.trim(),
password: xapiPassword.value || null,
}),
})
.then(function (r) { return r.json(); })
.then(function (json) {
xapiSaveStatus.textContent = json.ok ? 'Enregistré ✓' : 'Échec de l\'enregistrement.';
forgeLoadXapiSettings();
})
.catch(function () { xapiSaveStatus.textContent = 'Échec de l\'enregistrement.'; });
};
// Ouvrir la modale ne lance PLUS l'export tout de suite : le temps que
// l'utilisateur renseigne (et surtout enregistre, bouton séparé
// ci-dessus) ses réglages xAPI serait sinon une course contre le fetch
// d'export, qui pourrait partir avec des réglages pas encore sauvegardés.
// L'export est déclenché explicitement via forgeStartScormExport().
window.forgeOpenScormModal = function () {
progressBox.style.display = 'none';
errorBox.style.display = 'none';
doneBox.style.display = 'none';
xapiSaveStatus.textContent = '';
forgeLoadXapiSettings();
modal.classList.add('is-active');
};
window.forgeStartScormExport = function () {
var stepIndex = 0;
stepText.textContent = steps[0];
progressBox.style.display = '';
errorBox.style.display = 'none';
doneBox.style.display = 'none';
stepTimer = setInterval(function () {
stepIndex = (stepIndex + 1) % steps.length;
stepText.textContent = steps[stepIndex];
}, 1200);
var filename = "{{ game.slug }}-scorm.zip";
fetch("{{ url_for('export_scorm', slug=game.slug) }}", { method: 'POST' })
.then(function (resp) {
if (!resp.ok) throw new Error('HTTP ' + resp.status);
var disposition = resp.headers.get('Content-Disposition') || '';
var match = /filename="?([^";]+)"?/.exec(disposition);
if (match) filename = match[1];
return resp.blob();
})
.then(function (blob) {
clearInterval(stepTimer);
progressBox.style.display = 'none';
doneBox.style.display = '';
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(function () { URL.revokeObjectURL(url); }, 4000);
})
.catch(function (err) {
clearInterval(stepTimer);
progressBox.style.display = 'none';
errorBox.style.display = '';
errorBox.textContent = "Échec de l'export (" + err.message + "). Réessaie.";
});
};
window.forgeCloseScormModal = function () {
modal.classList.remove('is-active');
if (stepTimer) clearInterval(stepTimer);
};
})();
</script>
{% endif %}
{% block content %}{% endblock %}
</main>
<script src="{{ url_for('static', filename='pjax.js') }}"></script>
</body>
</html>