Files
Forge-Engine/templates/base.html
T
williamandClaude Sonnet 5 1289079da5 Ajoute "Publier" : exporte un jeu en exécutable Windows autonome
Fonctionnalité mise de côté depuis le tout début de ce chantier
("jouable en toute autonomie"). Nouveau bouton "📦 Publier" dans la barre
de navigation du jeu (après "▶️ Jouer") : construit un zip contenant un
Python portable + Flask embarqués, une copie figée du moteur de rendu
(screens/db/filters + le strict minimum de core/) et des données du jeu
(game.db + uploads/), lancé en double-cliquant sur run.bat — aucune
installation requise, 100% hors-ligne (voir le commit précédent qui a
auto-hébergé polices/animate.css, dernière dépendance CDN de l'app).

Exploration préalable a confirmé que screens/ et db/ sont déjà
totalement découplés de auth/routes/core (seul lien : un try/except
optionnel dans db/connection.py) et que templates/play.html n'appelle
que 4 endpoints "purs" (aucune logique d'auth mélangée dedans) — ce qui
a permis de réutiliser ces routes quasiment telles quelles dans un
mini-serveur Flask séparé plutôt que de les réécrire.

- db/constants.py : PROJECTS_DIR devient surchargeable via
  FORGE_PROJECTS_DIR (même schéma que auth/connection.py) — le serveur
  joueur autonome pointe ainsi vers son propre dossier "projects/"
  embarqué.
- publish/vendor_runtime.py : télécharge (une fois par poste, mis en
  cache sous data/publish_vendor/ — déjà ignoré par git) le ZIP Python
  embeddable officiel (python.org) et vendore Flask via pip --target ;
  fonctions séparées et mockables pour ne jamais déclencher de vrai
  téléchargement dans les tests.
- publish/player_app_template.py : mini-Flask autonome, réutilise
  core/flask_app.py et core/jinja_filters.py tels quels (SLUG figé en
  dur au moment de la publication, csrf_token() factice puisqu'aucune
  session n'existe dans cet export). sys.path doit être complété
  manuellement au démarrage : le python311._pth de la distribution
  embeddable ne référence que le dossier de python.exe lui-même, jamais
  celui du script lancé.
- publish/build_package.py : assemble le zip dans un dossier temporaire
  (jamais les vrais fichiers de l'app), copié/nettoyé après envoi de la
  réponse HTTP (routes/publish/publish_game.py, déjà protégée par la
  garde d'accès existante — aucune vérification supplémentaire).
- templates/base.html : bouton + modale (avancement séquentiel, jamais
  de suivi serveur réel — le build est rapide) qui déclenche le
  téléchargement du zip via un blob, comme la modale des codes de
  récupération déjà en place (posée À L'INTÉRIEUR de <main> pour que
  pjax.js la remplace et rejoue son script à chaque navigation).

Vérifié pour de vrai (pas seulement via les tests) : zip construit,
extrait, lancé avec le Python embeddable réel — /, /game/test/play,
/game/test/runtime-payload et /static/style.css répondent tous 200.

tests/test_publish.py (nouveau, vendor mocké) : structure du zip,
SLUG correctement substitué, route protégée par la même isolation par
projet que le reste de l'app. 203 tests au total.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 09:58:32 +02:00

176 lines
9.1 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">
<a href="{{ url_for('game_dashboard', slug=game.slug) }}" class="gameNavLink {{ 'active' if request.path == '/game/' ~ game.slug else '' }}" title="Écrants, objets, templates, variables">📊 Tableau de bord</a>
<a href="{{ url_for('game_play', slug=game.slug) }}" target="_blank" class="gameNavLink" title="Aperçu jouable du jeu">▶️ Jouer</a>
<button type="button" class="gameNavLink" onclick="window.forgeOpenPublishModal && window.forgeOpenPublishModal()" title="Exporte le jeu en exécutable Windows autonome (zip)">📦 Publier</button>
</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 "📦 Publier" qui l'ouvre), son
script ne serait rejoué qu'au tout premier chargement complet de la
page. -->
<div class="modal" id="publishModal">
<div class="modal-background"></div>
<div class="modal-content">
<div class="box">
<h2 class="title is-4">Publier « {{ game.name }} »</h2>
<p class="hint mb-4">Construit un exécutable Windows autonome (Python + serveur embarqués) : dézippé puis lancé en double-cliquant sur <code>run.bat</code>, le jeu se joue hors connexion, sans rien installer.</p>
<div id="publishProgress">
<p class="hint" id="publishStepText">Préparation du jeu...</p>
<div class="passwordStrengthBar" style="margin-top:10px;"><div class="passwordStrengthFill strong" id="publishProgressFill" style="width:100%; animation:forgePublishPulse 1.2s ease-in-out infinite;"></div></div>
</div>
<div id="publishError" class="notification is-danger" style="display:none;"></div>
<div id="publishDone" class="notification is-success" style="display:none;">✅ Téléchargement lancé — dézippe le fichier puis double-clique sur <code>run.bat</code>.</div>
<div class="field mt-4" style="display:flex; justify-content:flex-end;">
<button type="button" class="button is-small" onclick="window.forgeClosePublishModal()">Fermer</button>
</div>
</div>
</div>
</div>
<style>
@keyframes forgePublishPulse { 0%, 100% { opacity: 1; } 50% { opacity: .35; } }
</style>
<script>
(function () {
var modal = document.getElementById('publishModal');
var stepText = document.getElementById('publishStepText');
var progressBox = document.getElementById('publishProgress');
var errorBox = document.getElementById('publishError');
var doneBox = document.getElementById('publishDone');
var steps = ["Préparation du jeu...", "Copie des données...", "Compression du dossier..."];
var stepTimer = null;
window.forgeOpenPublishModal = function () {
var stepIndex = 0;
stepText.textContent = steps[0];
progressBox.style.display = '';
errorBox.style.display = 'none';
doneBox.style.display = 'none';
modal.classList.add('is-active');
stepTimer = setInterval(function () {
stepIndex = (stepIndex + 1) % steps.length;
stepText.textContent = steps[stepIndex];
}, 1200);
var filename = "{{ game.slug }}.zip";
fetch("{{ url_for('publish_game', 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 la publication (" + err.message + "). Réessaie, ou vérifie ta connexion internet (nécessaire uniquement lors de la toute première publication sur ce poste).";
});
};
window.forgeClosePublishModal = 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>